diff --git a/web/src/pages/TemplatesPage/renderNoteTypePreview.test.ts b/web/src/pages/TemplatesPage/renderNoteTypePreview.test.ts index 23583d3c8..3b26991e2 100644 --- a/web/src/pages/TemplatesPage/renderNoteTypePreview.test.ts +++ b/web/src/pages/TemplatesPage/renderNoteTypePreview.test.ts @@ -85,6 +85,95 @@ describe('renderCardSide', () => { }); }); +describe('conditional blocks', () => { + const conditional: AnkiNoteType = { + ...basic, + tmpls: [ + { + name: 'Card 1', + ord: 0, + qfmt: '{{#Header}}

{{Header}}

{{/Header}}{{Front}}', + afmt: '{{FrontSide}}{{#Back Extra}}{{/Back Extra}}', + }, + ], + flds: [ + { name: 'Header', ord: 0 }, + { name: 'Front', ord: 1 }, + { name: 'Back Extra', ord: 2 }, + ], + }; + + it('renders {{#Field}}...{{/Field}} content when the field is non-empty', () => { + const result = renderCardSide( + conditional, + { Header: 'Cell anatomy', Front: 'Q', 'Back Extra': '' }, + 'front' + ); + expect(result).toContain('

Cell anatomy

'); + expect(result).not.toContain('{{#Header}}'); + expect(result).not.toContain('{{/Header}}'); + }); + + it('removes {{#Field}}...{{/Field}} content when the field is empty', () => { + const result = renderCardSide( + conditional, + { Header: '', Front: 'Q', 'Back Extra': '' }, + 'front' + ); + expect(result).not.toContain('

'); + expect(result).not.toContain('{{#Header}}'); + expect(result).toBe('Q'); + }); + + it('substitutes field names that contain spaces', () => { + const result = renderCardSide( + conditional, + { Header: '', Front: 'Q', 'Back Extra': 'Mitochondria' }, + 'back' + ); + expect(result).toContain(''); + expect(result).not.toContain('{{Back Extra}}'); + }); + + it('handles {{^Field}}...{{/Field}} inverse — renders when empty, hides when present', () => { + const inverse: AnkiNoteType = { + ...basic, + tmpls: [ + { + name: 'Card 1', + ord: 0, + qfmt: '{{^Hint}}no hint{{/Hint}}{{Front}}', + afmt: '{{Back}}', + }, + ], + flds: [ + { name: 'Front', ord: 0 }, + { name: 'Back', ord: 1 }, + { name: 'Hint', ord: 2 }, + ], + }; + const empty = renderCardSide(inverse, { Front: 'Q', Back: 'A', Hint: '' }, 'front'); + expect(empty).toContain('no hint'); + const filled = renderCardSide( + inverse, + { Front: 'Q', Back: 'A', Hint: 'something' }, + 'front' + ); + expect(filled).not.toContain('no hint'); + }); + + it('strips conditional tokens that reference unknown fields', () => { + const result = renderCardSide( + conditional, + { Front: 'Q' }, + 'front' + ); + expect(result).not.toContain('{{#'); + expect(result).not.toContain('{{/'); + expect(result).toBe('Q'); + }); +}); + describe('buildPreviewDocument', () => { it('wraps the rendered side in a sandbox-friendly HTML document', () => { const doc = buildPreviewDocument(basic, { Front: 'Q', Back: 'A' }, 'front'); diff --git a/web/src/pages/TemplatesPage/renderNoteTypePreview.ts b/web/src/pages/TemplatesPage/renderNoteTypePreview.ts index 77ecfebcb..373d8da91 100644 --- a/web/src/pages/TemplatesPage/renderNoteTypePreview.ts +++ b/web/src/pages/TemplatesPage/renderNoteTypePreview.ts @@ -2,9 +2,10 @@ import { AnkiNoteType } from '../../lib/backend/templates'; type PreviewData = Record; -const CLOZE_FIELD_RE = /\{\{cloze:([\w-]+)\}\}/g; +const CONDITIONAL_RE = /\{\{([#^])([^}]+?)\}\}([\s\S]*?)\{\{\/\2\}\}/g; +const CLOZE_FIELD_RE = /\{\{cloze:([^}]+?)\}\}/g; const CLOZE_TOKEN_RE = /\{\{c\d+::([^}:]+)(?:::[^}]+)?\}\}/g; -const FIELD_RE = /\{\{([\w-]+)\}\}/g; +const FIELD_RE = /\{\{(?![#^/])([^}]+?)\}\}/g; function renderClozeContent(value: string, side: 'front' | 'back'): string { return value.replace(CLOZE_TOKEN_RE, (_match, answer) => { @@ -13,15 +14,39 @@ function renderClozeContent(value: string, side: 'front' | 'back'): string { }); } +function resolveConditionals(format: string, data: PreviewData): string { + let previous = ''; + let next = format; + while (next !== previous) { + previous = next; + next = next.replace( + CONDITIONAL_RE, + (_match, kind: string, fieldName: string, content: string) => { + const value = data[fieldName.trim()] ?? ''; + const isPresent = value.length > 0; + const shouldShow = kind === '#' ? isPresent : !isPresent; + return shouldShow ? content : ''; + } + ); + } + return next; +} + function substituteFields( format: string, data: PreviewData, side: 'front' | 'back' ): string { - const withCloze = format.replace(CLOZE_FIELD_RE, (_match, fieldName) => - renderClozeContent(data[fieldName] ?? '', side) + const withoutConditionals = resolveConditionals(format, data); + const withCloze = withoutConditionals.replace( + CLOZE_FIELD_RE, + (_match, fieldName: string) => + renderClozeContent(data[fieldName.trim()] ?? '', side) + ); + return withCloze.replace( + FIELD_RE, + (_match, fieldName: string) => data[fieldName.trim()] ?? '' ); - return withCloze.replace(FIELD_RE, (_match, fieldName) => data[fieldName] ?? ''); } export function renderCardSide(