From c62ab4147dbbbe40217ef3738e96206785c15fdf Mon Sep 17 00:00:00 2001 From: Dylan Piercey Date: Wed, 23 Sep 2026 09:14:52 -0700 Subject: [PATCH] feat: export escapeText for printing text content The parser reads a backslash run before `${` or `$!{` as escapes and every other backslash as text, and both prettier-plugin-marko and the compiler's source printer need the inverse to print text that reads back the same. `escapeText(text, next)` owns that rule next to the grammar it inverts, taking the content printed after the text since a backslash run ending the text joins any that starts it. Its tests exposed that `checkForPlaceholder` stepped past only two more characters after keeping half of an odd backslash run, the length of `${`, so from five backslashes on the `${` was left unread and parsed as a placeholder. Every odd run is now read as escapes. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/escape-text.md | 5 ++ .changeset/odd-backslash-runs.md | 5 ++ src/__tests__/escape.test.ts | 61 ++++++++++++++++++ ...holder-escaped-backslash-runs.expected.txt | 63 +++++++++++++++++++ .../input.marko | 7 +++ src/index.ts | 1 + src/states/PLACEHOLDER.ts | 3 +- src/util/escape.ts | 26 ++++++++ 8 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 .changeset/escape-text.md create mode 100644 .changeset/odd-backslash-runs.md create mode 100644 src/__tests__/escape.test.ts create mode 100644 src/__tests__/fixtures/placeholder-escaped-backslash-runs/__snapshots__/placeholder-escaped-backslash-runs.expected.txt create mode 100644 src/__tests__/fixtures/placeholder-escaped-backslash-runs/input.marko create mode 100644 src/util/escape.ts diff --git a/.changeset/escape-text.md b/.changeset/escape-text.md new file mode 100644 index 00000000..a7e017ba --- /dev/null +++ b/.changeset/escape-text.md @@ -0,0 +1,5 @@ +--- +"htmljs-parser": minor +--- + +Export `escapeText`, which escapes text content so the parser reads it back as the same text, for tools that print Marko source. Only a backslash run before `${` or `$!{` is changed; pass the content printed after the text as the second argument so that a backslash run ending the text does not escape a placeholder it leads into. diff --git a/.changeset/odd-backslash-runs.md b/.changeset/odd-backslash-runs.md new file mode 100644 index 00000000..eed91ad7 --- /dev/null +++ b/.changeset/odd-backslash-runs.md @@ -0,0 +1,5 @@ +--- +"htmljs-parser": patch +--- + +Read an odd run of five or more backslashes before `${` or `$!{` as escapes, like a run of one or three: `\\\\\${x}` is now the text `\\${x}` rather than two backslashes and a placeholder. diff --git a/src/__tests__/escape.test.ts b/src/__tests__/escape.test.ts new file mode 100644 index 00000000..d36376cd --- /dev/null +++ b/src/__tests__/escape.test.ts @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { createParser, escapeText, TagType } from "../index.ts"; + +const texts = [ + "plain text", + "C:\\Users\\dev", + "a regexp /\\d+/", + "${literal}", + "$!{literal}", + "a \\${escaped} literal", + "\\\\", + "ends with a slash\\", + "ends with slashes\\\\", + "$ and { apart", +]; + +describe("escapeText", () => { + for (const text of texts) { + for (const context of ["html", "concise", "parsed"] as const) { + it(`reads back ${JSON.stringify(text)} as ${context} text`, () => { + assert.deepEqual(readBack(context, escapeText(text)), [text]); + }); + + it(`reads back ${JSON.stringify(text)} before a placeholder in ${context} text`, () => { + assert.deepEqual( + readBack(context, `${escapeText(text, "${x}")}\${x}`), + [text, "${x}"], + ); + }); + + it(`reads back ${JSON.stringify(text)} before escaped text in ${context} text`, () => { + const next = escapeText("${y}"); + assert.deepEqual(readBack(context, escapeText(text, next) + next), [ + text + "${y}", + ]); + }); + } + } +}); + +function readBack(context: "html" | "concise" | "parsed", body: string) { + const parts: string[] = []; + let text = ""; + const parser = createParser({ + onOpenTagName: () => (context === "parsed" ? TagType.text : undefined), + onText(range) { + text += parser.read(range); + }, + onPlaceholder(range) { + if (text) parts.push(text); + text = ""; + parts.push(parser.read(range)); + }, + }); + + parser.parse(context === "concise" ? `div -- ${body}` : `
${body}
`); + if (text) parts.push(text); + return parts; +} diff --git a/src/__tests__/fixtures/placeholder-escaped-backslash-runs/__snapshots__/placeholder-escaped-backslash-runs.expected.txt b/src/__tests__/fixtures/placeholder-escaped-backslash-runs/__snapshots__/placeholder-escaped-backslash-runs.expected.txt new file mode 100644 index 00000000..0617610b --- /dev/null +++ b/src/__tests__/fixtures/placeholder-escaped-backslash-runs/__snapshots__/placeholder-escaped-backslash-runs.expected.txt @@ -0,0 +1,63 @@ +1╭─
\${a}
+ │ ││ │ │ │ │ ╰─ closeTagEnd(div) + │ ││ │ │ │ ╰─ closeTagName "div" + │ ││ │ │ ╰─ closeTagStart "\\${b} + │ ││ ││ │ │ │ │ ╰─ closeTagEnd(div) + │ ││ ││ │ │ │ ╰─ closeTagName "div" + │ ││ ││ │ │ ╰─ closeTagStart "\\\${c} + │ ││ │ │ │ │ ╰─ closeTagEnd(div) + │ ││ │ │ │ ╰─ closeTagName "div" + │ ││ │ │ ╰─ closeTagStart "\\\\${d} + │ ││ ││ │ │ │ │ ╰─ closeTagEnd(div) + │ ││ ││ │ │ │ ╰─ closeTagName "div" + │ ││ ││ │ │ ╰─ closeTagStart "\\\\\${e} + │ ││ │ │ │ │ ╰─ closeTagEnd(div) + │ ││ │ │ │ ╰─ closeTagName "div" + │ ││ │ │ ╰─ closeTagStart "\\\\\\${f} + │ ││ ││ │ │ │ │ ╰─ closeTagEnd(div) + │ ││ ││ │ │ │ ╰─ closeTagName "div" + │ ││ ││ │ │ ╰─ closeTagStart "\\\\\\\$!{g} + │ ││ │ │ │ │ ╰─ closeTagEnd(div) + │ ││ │ │ │ ╰─ closeTagName "div" + │ ││ │ │ ╰─ closeTagStart "\${a} +
\\${b}
+
\\\${c}
+
\\\\${d}
+
\\\\\${e}
+
\\\\\\${f}
+
\\\\\\\$!{g}
diff --git a/src/index.ts b/src/index.ts index b09bb43a..526d527a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ export { type Ranges, TagType, } from "./internal.ts"; +export { escapeText } from "./util/escape.ts"; export { isValidAttrValue, isValidScriptlet, diff --git a/src/states/PLACEHOLDER.ts b/src/states/PLACEHOLDER.ts index 9343c191..750aa2f2 100644 --- a/src/states/PLACEHOLDER.ts +++ b/src/states/PLACEHOLDER.ts @@ -81,7 +81,8 @@ export function checkForPlaceholder(parser: Parser, code: number) { parser.endText(); parser.pos += extra; parser.startText(); - parser.pos += escape ? 2 : 3; // skip the ${ or $!{ + // the kept half of the backslashes, then the ${ or $!{ + parser.pos += ahead - extra + (escape ? 2 : 3); return true; } diff --git a/src/util/escape.ts b/src/util/escape.ts new file mode 100644 index 00000000..1a8134ca --- /dev/null +++ b/src/util/escape.ts @@ -0,0 +1,26 @@ +const backslashRunReg = /(\\*)(\$!?\{|$)/g; +const placeholderStartReg = /^\\*\$!?\{/; + +/** + * Escapes text content so that the parser reads it back as the same text. + * Only a backslash run before `${` or `$!{` is read as escapes, halving it, + * with an odd run keeping the placeholder as text, so only those runs change. + * Pass the content printed right after the text as `next`: a backslash run + * ending the text joins any that starts `next`, so it is doubled when that + * run leads into `${` or `$!{`. Whitespace is left alone, since which of it + * renders depends on the text's siblings and syntax. + */ +export function escapeText(text: string, next = "") { + return text.replace( + backslashRunReg, + placeholderStartReg.test(next) ? escapeRunBeforePlaceholder : escapeRun, + ); +} + +function escapeRun(_: string, run: string, open: string) { + return open ? `${run}${run}\\${open}` : run; +} + +function escapeRunBeforePlaceholder(_: string, run: string, open: string) { + return open ? `${run}${run}\\${open}` : run + run; +}