Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions web/src/pages/TemplatesPage/renderNoteTypePreview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,95 @@ describe('renderCardSide', () => {
});
});

describe('conditional blocks', () => {
const conditional: AnkiNoteType = {
...basic,
tmpls: [
{
name: 'Card 1',
ord: 0,
qfmt: '{{#Header}}<h1>{{Header}}</h1>{{/Header}}{{Front}}',
afmt: '{{FrontSide}}{{#Back Extra}}<aside>{{Back Extra}}</aside>{{/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('<h1>Cell anatomy</h1>');
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('<h1>');
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('<aside>Mitochondria</aside>');
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}}<em>no hint</em>{{/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('<em>no hint</em>');
const filled = renderCardSide(
inverse,
{ Front: 'Q', Back: 'A', Hint: 'something' },
'front'
);
expect(filled).not.toContain('<em>no hint</em>');
});

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');
Expand Down
35 changes: 30 additions & 5 deletions web/src/pages/TemplatesPage/renderNoteTypePreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import { AnkiNoteType } from '../../lib/backend/templates';

type PreviewData = Record<string, string>;

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) => {
Expand All @@ -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(
Expand Down