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 ""
+ │ ││ │ ╰─ text "${a}"
+ │ ││ ╰─ openTagEnd
+ │ │╰─ tagName "div"
+ ╰─ ╰─ openTagStart
+2╭─ \\${b}
+ │ ││ ││ │ │ │ │ ╰─ closeTagEnd(div)
+ │ ││ ││ │ │ │ ╰─ closeTagName "div"
+ │ ││ ││ │ │ ╰─ closeTagStart ""
+ │ ││ ││ │ ╰─ placeholder:escape.value
+ │ ││ ││ ╰─ placeholder:escape "${b}"
+ │ ││ │╰─ text
+ │ ││ ╰─ openTagEnd
+ │ │╰─ tagName "div"
+ ╰─ ╰─ openTagStart
+3╭─ \\\${c}
+ │ ││ │ │ │ │ ╰─ closeTagEnd(div)
+ │ ││ │ │ │ ╰─ closeTagName "div"
+ │ ││ │ │ ╰─ closeTagStart ""
+ │ ││ │ ╰─ text "\\${c}"
+ │ ││ ╰─ openTagEnd
+ │ │╰─ tagName "div"
+ ╰─ ╰─ openTagStart
+4╭─ \\\\${d}
+ │ ││ ││ │ │ │ │ ╰─ closeTagEnd(div)
+ │ ││ ││ │ │ │ ╰─ closeTagName "div"
+ │ ││ ││ │ │ ╰─ closeTagStart ""
+ │ ││ ││ │ ╰─ placeholder:escape.value
+ │ ││ ││ ╰─ placeholder:escape "${d}"
+ │ ││ │╰─ text "\\\\"
+ │ ││ ╰─ openTagEnd
+ │ │╰─ tagName "div"
+ ╰─ ╰─ openTagStart
+5╭─ \\\\\${e}
+ │ ││ │ │ │ │ ╰─ closeTagEnd(div)
+ │ ││ │ │ │ ╰─ closeTagName "div"
+ │ ││ │ │ ╰─ closeTagStart ""
+ │ ││ │ ╰─ text "\\\\${e}"
+ │ ││ ╰─ openTagEnd
+ │ │╰─ tagName "div"
+ ╰─ ╰─ openTagStart
+6╭─ \\\\\\${f}
+ │ ││ ││ │ │ │ │ ╰─ closeTagEnd(div)
+ │ ││ ││ │ │ │ ╰─ closeTagName "div"
+ │ ││ ││ │ │ ╰─ closeTagStart ""
+ │ ││ ││ │ ╰─ placeholder:escape.value
+ │ ││ ││ ╰─ placeholder:escape "${f}"
+ │ ││ │╰─ text "\\\\\\"
+ │ ││ ╰─ openTagEnd
+ │ │╰─ tagName "div"
+ ╰─ ╰─ openTagStart
+7╭─ \\\\\\\$!{g}
+ │ ││ │ │ │ │ ╰─ closeTagEnd(div)
+ │ ││ │ │ │ ╰─ closeTagName "div"
+ │ ││ │ │ ╰─ closeTagStart ""
+ │ ││ │ ╰─ text "\\\\\\$!{g}"
+ │ ││ ╰─ openTagEnd
+ │ │╰─ tagName "div"
+ ╰─ ╰─ openTagStart
+8╰─
\ No newline at end of file
diff --git a/src/__tests__/fixtures/placeholder-escaped-backslash-runs/input.marko b/src/__tests__/fixtures/placeholder-escaped-backslash-runs/input.marko
new file mode 100644
index 00000000..969cf90b
--- /dev/null
+++ b/src/__tests__/fixtures/placeholder-escaped-backslash-runs/input.marko
@@ -0,0 +1,7 @@
+\${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;
+}