diff --git a/src/parser.ts b/src/parser.ts index 36bd554..3c0aeaf 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -134,7 +134,14 @@ export class Parser { throw new Error(`${variable} should be a string, found ${typeof specialVariables[variable]}.`); } - const compiledText = Handlebars.compile(specialVariables[variable]); + // Pass `{ noEscape: true }` so Handlebars does not run every + // `{{ var }}` through `escapeExpression`. Templates render into + // markdown notes, not HTML — escaping `' " & < >` to entities + // mangles `template_title`/`template_tags` for any value that + // contains one of those characters. Triple-stache + // (`{{{ var }}}`) still works the same under `noEscape`, so + // existing templates are unaffected. + const compiledText = Handlebars.compile(specialVariables[variable], { noEscape: true }); res[variable] = compiledText(context); } @@ -283,7 +290,7 @@ export class Parser { }; const templateBody = processedTemplate.body; - const compiledTemplate = Handlebars.compile(templateBody); + const compiledTemplate = Handlebars.compile(templateBody, { noEscape: true }); // note_id is intentionally excluded from the shared context above // because it is only meaningful in the note body (it resolves to a diff --git a/tests/parser.spec.ts b/tests/parser.spec.ts index b7584bf..870bdab 100644 --- a/tests/parser.spec.ts +++ b/tests/parser.spec.ts @@ -1093,4 +1093,118 @@ describe("Template parser", () => { expect(parsedTemplate).toBeNull(); expect(errorMessagesShown).toEqual(1); }); + + // Regression tests for #173: HTML escaping leaks into markdown notes. + // Templates render into markdown, not HTML, so `{{ var }}` must NOT + // pass the value through `escapeExpression` and turn characters like + // `'`, `"`, `&`, `<`, `>` into entities. + test("should not HTML-escape text, number and dropdown variables in the body (#173)", async () => { + const template = { + id: "note-id", + title: "Some Template", + body: dedent` + --- + text_var: text + num_var: number + choice_var: dropdown(alpha, beta) + + --- + + text: {{ text_var }} + number: {{ num_var }} + choice: {{ choice_var }} + ` + }; + testVariableTypes({ + text_var: TextCustomVariable, + num_var: NumberCustomVariable, + choice_var: EnumCustomVariable, + }); + handleVariableDialog("ok", { + text_var: "it's a \"test\" with & ", + num_var: "42", + choice_var: "alpha", + }); + + const parsedTemplate = await parser.parseTemplate(template); + + assert(parsedTemplate); + expect(parsedTemplate.body).toEqual(dedent` + text: it's a "test" with & + number: 42 + choice: alpha + `); + // Make absolutely sure no HTML entities leaked in. + expect(parsedTemplate.body).not.toContain("'"); + expect(parsedTemplate.body).not.toContain("""); + expect(parsedTemplate.body).not.toContain("<"); + expect(parsedTemplate.body).not.toContain(">"); + }); + + test("should not HTML-escape template_title and template_tags (#173)", async () => { + const template = { + id: "note-id", + title: "Fallback Title", + body: dedent` + --- + topic: text + template_title: Today's "notes" — {{ topic }} + template_tags: alpha, {{ topic }} + + --- + + body: {{ topic }} + ` + }; + testVariableTypes({ + topic: TextCustomVariable, + }); + handleVariableDialog("ok", { + topic: "it's ", + }); + + const parsedTemplate = await parser.parseTemplate(template); + + assert(parsedTemplate); + // Title and tag list keep the user's input verbatim. + expect(parsedTemplate.title).toEqual("Today's \"notes\" — it's "); + expect(parsedTemplate.tags).toStrictEqual(["alpha", "it's "]); + // Body is markdown, so `` and the apostrophe must stay as plain characters. + expect(parsedTemplate.body.trim()).toEqual("body: it's "); + // No HTML entities should leak in anywhere. + expect(parsedTemplate.title).not.toMatch(/&(amp|quot|#x27|lt|gt);/); + expect(parsedTemplate.tags.join(",")).not.toMatch(/&(amp|quot|#x27|lt|gt);/); + expect(parsedTemplate.body).not.toMatch(/&(amp|quot|#x27|lt|gt);/); + }); + + test("should still support the {{{ var }}} triple-stache under noEscape (#173)", async () => { + const template = { + id: "note-id", + title: "Some Template", + body: dedent` + --- + text_var: text + + --- + + {{ text_var }} + {{{ text_var }}} + ` + }; + testVariableTypes({ + text_var: TextCustomVariable, + }); + handleVariableDialog("ok", { + text_var: "it's a \"test\"", + }); + + const parsedTemplate = await parser.parseTemplate(template); + + assert(parsedTemplate); + // Double stache and triple stache produce the same value under noEscape. + expect(parsedTemplate.body).toEqual(dedent` + it's a "test" + it's a "test" + `); + }); });