diff options
| -rw-r--r-- | src/client/utils/templateRenderer.test.ts | 341 | ||||
| -rw-r--r-- | src/client/utils/templateRenderer.ts | 188 |
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), + }; +} |
