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
5 changes: 5 additions & 0 deletions .changeset/escape-text.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/odd-backslash-runs.md
Original file line number Diff line number Diff line change
@@ -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.
61 changes: 61 additions & 0 deletions src/__tests__/escape.test.ts
Original file line number Diff line number Diff line change
@@ -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}` : `<div>${body}</div>`);
if (text) parts.push(text);
return parts;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
1╭─ <div>\${a}</div>
│ ││ │ │ │ │ ╰─ closeTagEnd(div)
│ ││ │ │ │ ╰─ closeTagName "div"
│ ││ │ │ ╰─ closeTagStart "</"
│ ││ │ ╰─ text "${a}"
│ ││ ╰─ openTagEnd
│ │╰─ tagName "div"
╰─ ╰─ openTagStart
2╭─ <div>\\${b}</div>
│ ││ ││ │ │ │ │ ╰─ closeTagEnd(div)
│ ││ ││ │ │ │ ╰─ closeTagName "div"
│ ││ ││ │ │ ╰─ closeTagStart "</"
│ ││ ││ │ ╰─ placeholder:escape.value
│ ││ ││ ╰─ placeholder:escape "${b}"
│ ││ │╰─ text
│ ││ ╰─ openTagEnd
│ │╰─ tagName "div"
╰─ ╰─ openTagStart
3╭─ <div>\\\${c}</div>
│ ││ │ │ │ │ ╰─ closeTagEnd(div)
│ ││ │ │ │ ╰─ closeTagName "div"
│ ││ │ │ ╰─ closeTagStart "</"
│ ││ │ ╰─ text "\\${c}"
│ ││ ╰─ openTagEnd
│ │╰─ tagName "div"
╰─ ╰─ openTagStart
4╭─ <div>\\\\${d}</div>
│ ││ ││ │ │ │ │ ╰─ closeTagEnd(div)
│ ││ ││ │ │ │ ╰─ closeTagName "div"
│ ││ ││ │ │ ╰─ closeTagStart "</"
│ ││ ││ │ ╰─ placeholder:escape.value
│ ││ ││ ╰─ placeholder:escape "${d}"
│ ││ │╰─ text "\\\\"
│ ││ ╰─ openTagEnd
│ │╰─ tagName "div"
╰─ ╰─ openTagStart
5╭─ <div>\\\\\${e}</div>
│ ││ │ │ │ │ ╰─ closeTagEnd(div)
│ ││ │ │ │ ╰─ closeTagName "div"
│ ││ │ │ ╰─ closeTagStart "</"
│ ││ │ ╰─ text "\\\\${e}"
│ ││ ╰─ openTagEnd
│ │╰─ tagName "div"
╰─ ╰─ openTagStart
6╭─ <div>\\\\\\${f}</div>
│ ││ ││ │ │ │ │ ╰─ closeTagEnd(div)
│ ││ ││ │ │ │ ╰─ closeTagName "div"
│ ││ ││ │ │ ╰─ closeTagStart "</"
│ ││ ││ │ ╰─ placeholder:escape.value
│ ││ ││ ╰─ placeholder:escape "${f}"
│ ││ │╰─ text "\\\\\\"
│ ││ ╰─ openTagEnd
│ │╰─ tagName "div"
╰─ ╰─ openTagStart
7╭─ <div>\\\\\\\$!{g}</div>
│ ││ │ │ │ │ ╰─ closeTagEnd(div)
│ ││ │ │ │ ╰─ closeTagName "div"
│ ││ │ │ ╰─ closeTagStart "</"
│ ││ │ ╰─ text "\\\\\\$!{g}"
│ ││ ╰─ openTagEnd
│ │╰─ tagName "div"
╰─ ╰─ openTagStart
8╰─
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<div>\${a}</div>
<div>\\${b}</div>
<div>\\\${c}</div>
<div>\\\\${d}</div>
<div>\\\\\${e}</div>
<div>\\\\\\${f}</div>
<div>\\\\\\\$!{g}</div>
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export {
type Ranges,
TagType,
} from "./internal.ts";
export { escapeText } from "./util/escape.ts";
export {
isValidAttrValue,
isValidScriptlet,
Expand Down
3 changes: 2 additions & 1 deletion src/states/PLACEHOLDER.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
26 changes: 26 additions & 0 deletions src/util/escape.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading