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(