aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--src/client/utils/templateRenderer.test.ts341
-rw-r--r--src/client/utils/templateRenderer.ts188
2 files changed, 529 insertions, 0 deletions
diff --git a/src/client/utils/templateRenderer.test.ts b/src/client/utils/templateRenderer.test.ts
new file mode 100644
index 0000000..7f81aa0
--- /dev/null
+++ b/src/client/utils/templateRenderer.test.ts
@@ -0,0 +1,341 @@
+import { describe, expect, it } from "vitest";
+import {
+ extractFieldNames,
+ renderCard,
+ renderTemplate,
+ validateTemplate,
+} from "./templateRenderer";
+
+describe("renderTemplate", () => {
+ describe("basic replacement", () => {
+ it("replaces a single field placeholder", () => {
+ const result = renderTemplate("{{Front}}", { Front: "Hello" });
+ expect(result).toBe("Hello");
+ });
+
+ it("replaces multiple different placeholders", () => {
+ const result = renderTemplate("{{Front}} - {{Back}}", {
+ Front: "Question",
+ Back: "Answer",
+ });
+ expect(result).toBe("Question - Answer");
+ });
+
+ it("replaces duplicate placeholders", () => {
+ const result = renderTemplate("{{Word}} means {{Word}}", {
+ Word: "hello",
+ });
+ expect(result).toBe("hello means hello");
+ });
+ });
+
+ describe("whitespace handling", () => {
+ it("trims whitespace inside placeholders", () => {
+ const result = renderTemplate("{{ Front }}", { Front: "Hello" });
+ expect(result).toBe("Hello");
+ });
+
+ it("handles various whitespace patterns", () => {
+ const result = renderTemplate("{{ Front }} and {{Back }}", {
+ Front: "A",
+ Back: "B",
+ });
+ expect(result).toBe("A and B");
+ });
+
+ it("preserves whitespace in values", () => {
+ const result = renderTemplate("{{Front}}", {
+ Front: " spaced value ",
+ });
+ expect(result).toBe(" spaced value ");
+ });
+ });
+
+ describe("missing fields", () => {
+ it("replaces missing field with empty string", () => {
+ const result = renderTemplate("{{Missing}}", {});
+ expect(result).toBe("");
+ });
+
+ it("replaces only missing fields with empty string", () => {
+ const result = renderTemplate("{{Present}} and {{Missing}}", {
+ Present: "Here",
+ });
+ expect(result).toBe("Here and ");
+ });
+ });
+
+ describe("surrounding text", () => {
+ it("preserves text before placeholder", () => {
+ const result = renderTemplate("Q: {{Front}}", { Front: "Question" });
+ expect(result).toBe("Q: Question");
+ });
+
+ it("preserves text after placeholder", () => {
+ const result = renderTemplate("{{Front}} (answer below)", {
+ Front: "Question",
+ });
+ expect(result).toBe("Question (answer below)");
+ });
+
+ it("handles complex templates with multiple fields and text", () => {
+ const result = renderTemplate(
+ "Word: {{Word}}\nReading: {{Reading}}\nMeaning: {{Meaning}}",
+ {
+ Word: "日本語",
+ Reading: "にほんご",
+ Meaning: "Japanese language",
+ },
+ );
+ expect(result).toBe(
+ "Word: 日本語\nReading: にほんご\nMeaning: Japanese language",
+ );
+ });
+ });
+
+ describe("edge cases", () => {
+ it("returns empty string for empty template", () => {
+ const result = renderTemplate("", { Front: "Hello" });
+ expect(result).toBe("");
+ });
+
+ it("returns template unchanged when no placeholders", () => {
+ const result = renderTemplate("Plain text", { Front: "Hello" });
+ expect(result).toBe("Plain text");
+ });
+
+ it("handles special characters in field values", () => {
+ const result = renderTemplate("{{Front}}", {
+ Front: "Hello <script>alert('xss')</script>",
+ });
+ expect(result).toBe("Hello <script>alert('xss')</script>");
+ });
+
+ it("handles curly braces in values", () => {
+ const result = renderTemplate("{{Front}}", {
+ Front: "Object { key: value }",
+ });
+ expect(result).toBe("Object { key: value }");
+ });
+
+ it("handles adjacent braces with placeholder", () => {
+ // {{{{Front}}}} - the pattern matches {{{{Front}} as a placeholder
+ // with field name "{{Front", leaving trailing }}
+ const result = renderTemplate("{{{{Front}}}}", { Front: "Hello" });
+ // Since "{{Front" is not a defined field, it's replaced with empty string
+ expect(result).toBe("}}");
+ });
+
+ it("handles leading braces as part of field name", () => {
+ // If you actually define the field with braces, it works
+ const result = renderTemplate("{{{{Front}}}}", { "{{Front": "Hello" });
+ expect(result).toBe("Hello}}");
+ });
+
+ it("handles malformed placeholders", () => {
+ // Single braces are not replaced
+ const result = renderTemplate("{Front}", { Front: "Hello" });
+ expect(result).toBe("{Front}");
+ });
+
+ it("handles empty field values", () => {
+ const result = renderTemplate("{{Front}}", { Front: "" });
+ expect(result).toBe("");
+ });
+ });
+
+ describe("case sensitivity", () => {
+ it("is case-sensitive for field names", () => {
+ const result = renderTemplate("{{front}} {{Front}} {{FRONT}}", {
+ front: "a",
+ Front: "b",
+ FRONT: "c",
+ });
+ expect(result).toBe("a b c");
+ });
+
+ it("does not match different case", () => {
+ const result = renderTemplate("{{front}}", { Front: "Hello" });
+ expect(result).toBe("");
+ });
+ });
+});
+
+describe("extractFieldNames", () => {
+ it("extracts single field name", () => {
+ const result = extractFieldNames("{{Front}}");
+ expect(result).toEqual(["Front"]);
+ });
+
+ it("extracts multiple field names", () => {
+ const result = extractFieldNames("{{Front}} and {{Back}}");
+ expect(result).toContain("Front");
+ expect(result).toContain("Back");
+ expect(result).toHaveLength(2);
+ });
+
+ it("removes duplicates", () => {
+ const result = extractFieldNames("{{Word}} and {{Word}} again");
+ expect(result).toEqual(["Word"]);
+ });
+
+ it("trims whitespace from field names", () => {
+ const result = extractFieldNames("{{ Front }} and {{Back }}");
+ expect(result).toContain("Front");
+ expect(result).toContain("Back");
+ });
+
+ it("returns empty array for no placeholders", () => {
+ const result = extractFieldNames("Plain text");
+ expect(result).toEqual([]);
+ });
+
+ it("returns empty array for empty template", () => {
+ const result = extractFieldNames("");
+ expect(result).toEqual([]);
+ });
+});
+
+describe("validateTemplate", () => {
+ it("returns valid when all fields exist", () => {
+ const result = validateTemplate("{{Front}} {{Back}}", {
+ Front: "Q",
+ Back: "A",
+ });
+ expect(result).toEqual({ valid: true, missingFields: [] });
+ });
+
+ it("returns invalid with missing fields", () => {
+ const result = validateTemplate("{{Front}} {{Back}} {{Extra}}", {
+ Front: "Q",
+ });
+ expect(result.valid).toBe(false);
+ expect(result.missingFields).toContain("Back");
+ expect(result.missingFields).toContain("Extra");
+ });
+
+ it("returns valid for template with no placeholders", () => {
+ const result = validateTemplate("Plain text", {});
+ expect(result).toEqual({ valid: true, missingFields: [] });
+ });
+
+ it("handles extra fields in values", () => {
+ const result = validateTemplate("{{Front}}", {
+ Front: "Q",
+ Back: "A",
+ Extra: "E",
+ });
+ expect(result).toEqual({ valid: true, missingFields: [] });
+ });
+});
+
+describe("renderCard", () => {
+ const fieldValues = {
+ Front: "What is 2+2?",
+ Back: "4",
+ };
+
+ describe("normal cards (isReversed = false)", () => {
+ it("renders front with frontTemplate", () => {
+ const result = renderCard({
+ frontTemplate: "{{Front}}",
+ backTemplate: "{{Back}}",
+ fieldValues,
+ isReversed: false,
+ });
+ expect(result.front).toBe("What is 2+2?");
+ });
+
+ it("renders back with backTemplate", () => {
+ const result = renderCard({
+ frontTemplate: "{{Front}}",
+ backTemplate: "{{Back}}",
+ fieldValues,
+ isReversed: false,
+ });
+ expect(result.back).toBe("4");
+ });
+
+ it("handles templates with surrounding text", () => {
+ const result = renderCard({
+ frontTemplate: "Q: {{Front}}",
+ backTemplate: "A: {{Back}}",
+ fieldValues,
+ isReversed: false,
+ });
+ expect(result.front).toBe("Q: What is 2+2?");
+ expect(result.back).toBe("A: 4");
+ });
+ });
+
+ describe("reversed cards (isReversed = true)", () => {
+ it("renders front with backTemplate", () => {
+ const result = renderCard({
+ frontTemplate: "{{Front}}",
+ backTemplate: "{{Back}}",
+ fieldValues,
+ isReversed: true,
+ });
+ expect(result.front).toBe("4");
+ });
+
+ it("renders back with frontTemplate", () => {
+ const result = renderCard({
+ frontTemplate: "{{Front}}",
+ backTemplate: "{{Back}}",
+ fieldValues,
+ isReversed: true,
+ });
+ expect(result.back).toBe("What is 2+2?");
+ });
+
+ it("handles templates with surrounding text", () => {
+ const result = renderCard({
+ frontTemplate: "Q: {{Front}}",
+ backTemplate: "A: {{Back}}",
+ fieldValues,
+ isReversed: true,
+ });
+ expect(result.front).toBe("A: 4");
+ expect(result.back).toBe("Q: What is 2+2?");
+ });
+ });
+
+ describe("complex templates", () => {
+ it("handles multi-field templates", () => {
+ const complexValues = {
+ Word: "日本語",
+ Reading: "にほんご",
+ Meaning: "Japanese language",
+ };
+
+ const result = renderCard({
+ frontTemplate: "{{Word}}\n({{Reading}})",
+ backTemplate: "{{Meaning}}",
+ fieldValues: complexValues,
+ isReversed: false,
+ });
+
+ expect(result.front).toBe("日本語\n(にほんご)");
+ expect(result.back).toBe("Japanese language");
+ });
+
+ it("handles reversed multi-field templates", () => {
+ const complexValues = {
+ Word: "日本語",
+ Reading: "にほんご",
+ Meaning: "Japanese language",
+ };
+
+ const result = renderCard({
+ frontTemplate: "{{Word}}\n({{Reading}})",
+ backTemplate: "{{Meaning}}",
+ fieldValues: complexValues,
+ isReversed: true,
+ });
+
+ expect(result.front).toBe("Japanese language");
+ expect(result.back).toBe("日本語\n(にほんご)");
+ });
+ });
+});
diff --git a/src/client/utils/templateRenderer.ts b/src/client/utils/templateRenderer.ts
new file mode 100644
index 0000000..cae74a6
--- /dev/null
+++ b/src/client/utils/templateRenderer.ts
@@ -0,0 +1,188 @@
+/**
+ * Custom mustache-like template renderer for card display.
+ *
+ * Syntax: `{{FieldName}}` is replaced with the corresponding field value.
+ *
+ * Features:
+ * - Simple `{{FieldName}}` replacement
+ * - Case-sensitive field matching
+ * - Missing fields are replaced with empty string
+ * - Whitespace around field names is trimmed: `{{ Front }}` works like `{{Front}}`
+ *
+ * Examples:
+ * - Simple: `{{Front}}` → "What is the capital of Japan?"
+ * - With text: `Q: {{Front}}` → "Q: What is the capital of Japan?"
+ * - Multiple fields: `{{Word}} - {{Reading}}` → "日本語 - にほんご"
+ */
+
+/**
+ * Regex to match mustache-style placeholders: {{fieldName}}
+ * Captures the field name (with optional whitespace that will be trimmed)
+ */
+const TEMPLATE_PATTERN = /\{\{\s*([^}]+?)\s*\}\}/g;
+
+/**
+ * Field values for template rendering.
+ * Keys are field names, values are the corresponding content.
+ */
+export type FieldValues = Record<string, string>;
+
+/**
+ * Renders a mustache-like template by replacing `{{FieldName}}` placeholders
+ * with corresponding values from the fieldValues object.
+ *
+ * @param template - The template string with `{{FieldName}}` placeholders
+ * @param fieldValues - Object mapping field names to their values
+ * @returns The rendered string with all placeholders replaced
+ *
+ * @example
+ * ```typescript
+ * renderTemplate("{{Front}}", { Front: "Hello" })
+ * // Returns: "Hello"
+ *
+ * renderTemplate("Q: {{Question}}\nHint: {{Hint}}", {
+ * Question: "What is 2+2?",
+ * Hint: "It's even"
+ * })
+ * // Returns: "Q: What is 2+2?\nHint: It's even"
+ * ```
+ */
+export function renderTemplate(
+ template: string,
+ fieldValues: FieldValues,
+): string {
+ return template.replace(TEMPLATE_PATTERN, (_, fieldName: string) => {
+ const trimmedName = fieldName.trim();
+ return fieldValues[trimmedName] ?? "";
+ });
+}
+
+/**
+ * Extracts all field names used in a template.
+ *
+ * @param template - The template string to analyze
+ * @returns Array of unique field names found in the template
+ *
+ * @example
+ * ```typescript
+ * extractFieldNames("{{Front}} and {{Back}}")
+ * // Returns: ["Front", "Back"]
+ *
+ * extractFieldNames("{{Word}} - {{Word}}")
+ * // Returns: ["Word"] (duplicates removed)
+ * ```
+ */
+export function extractFieldNames(template: string): string[] {
+ const names = new Set<string>();
+
+ // Use matchAll to get all matches without assignment in loop
+ const matches = template.matchAll(TEMPLATE_PATTERN);
+ for (const match of matches) {
+ const fieldName = match[1];
+ if (fieldName) {
+ names.add(fieldName.trim());
+ }
+ }
+
+ return Array.from(names);
+}
+
+/**
+ * Validates that all field names used in a template exist in the provided field values.
+ *
+ * @param template - The template string to validate
+ * @param fieldValues - Object mapping field names to their values
+ * @returns Object with `valid` boolean and `missingFields` array if invalid
+ *
+ * @example
+ * ```typescript
+ * validateTemplate("{{Front}}", { Front: "Hello" })
+ * // Returns: { valid: true, missingFields: [] }
+ *
+ * validateTemplate("{{Front}} {{Back}}", { Front: "Hello" })
+ * // Returns: { valid: false, missingFields: ["Back"] }
+ * ```
+ */
+export function validateTemplate(
+ template: string,
+ fieldValues: FieldValues,
+): { valid: boolean; missingFields: string[] } {
+ const usedFields = extractFieldNames(template);
+ const availableFields = new Set(Object.keys(fieldValues));
+ const missingFields = usedFields.filter(
+ (field) => !availableFields.has(field),
+ );
+
+ return {
+ valid: missingFields.length === 0,
+ missingFields,
+ };
+}
+
+/**
+ * Options for rendering a card's display content.
+ */
+export interface RenderCardOptions {
+ /** The front template (e.g., "{{Front}}") */
+ frontTemplate: string;
+ /** The back template (e.g., "{{Back}}") */
+ backTemplate: string;
+ /** Field values from the note */
+ fieldValues: FieldValues;
+ /** Whether this is a reversed card */
+ isReversed: boolean;
+}
+
+/**
+ * Renders a card's front and back content based on templates and note field values.
+ *
+ * For normal cards (isReversed = false):
+ * - Front: Render frontTemplate
+ * - Back: Render backTemplate
+ *
+ * For reversed cards (isReversed = true):
+ * - Front: Render backTemplate
+ * - Back: Render frontTemplate
+ *
+ * @param options - The rendering options
+ * @returns Object with rendered `front` and `back` strings
+ *
+ * @example
+ * ```typescript
+ * // Normal card
+ * renderCard({
+ * frontTemplate: "{{Front}}",
+ * backTemplate: "{{Back}}",
+ * fieldValues: { Front: "Question", Back: "Answer" },
+ * isReversed: false
+ * })
+ * // Returns: { front: "Question", back: "Answer" }
+ *
+ * // Reversed card
+ * renderCard({
+ * frontTemplate: "{{Front}}",
+ * backTemplate: "{{Back}}",
+ * fieldValues: { Front: "Question", Back: "Answer" },
+ * isReversed: true
+ * })
+ * // Returns: { front: "Answer", back: "Question" }
+ * ```
+ */
+export function renderCard(options: RenderCardOptions): {
+ front: string;
+ back: string;
+} {
+ const { frontTemplate, backTemplate, fieldValues, isReversed } = options;
+
+ if (isReversed) {
+ return {
+ front: renderTemplate(backTemplate, fieldValues),
+ back: renderTemplate(frontTemplate, fieldValues),
+ };
+ }
+
+ return {
+ front: renderTemplate(frontTemplate, fieldValues),
+ back: renderTemplate(backTemplate, fieldValues),
+ };
+}