diff --git a/packages/syntax/src/parser.ts b/packages/syntax/src/parser.ts index e061eb70..658ef296 100644 --- a/packages/syntax/src/parser.ts +++ b/packages/syntax/src/parser.ts @@ -1022,12 +1022,13 @@ export function parseHtmlView(src: string, pos: number): { nodes: ViewNode[]; en const quote = src[i]; if (quote !== '"' && quote !== "'") return fail("Expected a quoted attribute value"); i++; - // Backslash-escapes the delimiter (and anything else) so an attribute - // value -- e.g. an `api="fn({ a: \"b\" })"` call expression -- can carry - // the same quote character it's wrapped in. + // Backslash escapes the delimiter -- so an attribute value such as the + // call expression `api="fn({ a: \"b\" })"` can carry the same quote + // character it's wrapped in -- and escapes itself. Every other backslash + // is a literal one, so a value like "C:\Users\name" survives intact. let value = ""; while (i < src.length && src[i] !== quote) { - if (src[i] === "\\" && i + 1 < src.length) { + if (src[i] === "\\" && (src[i + 1] === quote || src[i + 1] === "\\")) { value += src[i + 1]; i += 2; continue; diff --git a/packages/syntax/test/attribute-escapes.test.ts b/packages/syntax/test/attribute-escapes.test.ts new file mode 100644 index 00000000..2701c354 --- /dev/null +++ b/packages/syntax/test/attribute-escapes.test.ts @@ -0,0 +1,53 @@ +import { expect, test } from "bun:test"; +import { parse } from "../src/index.ts"; + +// One literal backslash character. Composing the .wrn sources below by +// concatenation keeps the escaping unambiguous at a glance. +const BS = String.fromCharCode(92); +const Q = '"'; + +const view = (attr: string) => + `page Esc {\n view {\n
\n
x
\n
\n }\n}\n`; + +const attrValue = (source: string): string => { + const ast = parse(source) as unknown; + const found: string[] = []; + const walk = (node: unknown): void => { + if (!node || typeof node !== "object") return; + if (Array.isArray(node)) { + node.forEach(walk); + return; + } + const record = node as Record; + if (typeof record.value === "string" && typeof record.name === "string") { + found.push(record.value); + } + for (const key of Object.keys(record)) walk(record[key]); + }; + walk(ast); + return found[found.length - 1] ?? ""; +}; + +test("a backslash-escaped delimiter becomes a bare quote", () => { + // api="fn({ a: \"b\" })" -> fn({ a: "b" }) + const attr = `api=${Q}fn({ a: ${BS}${Q}b${BS}${Q} })${Q}`; + expect(attrValue(view(attr))).toBe(`fn({ a: ${Q}b${Q} })`); +}); + +test("a doubled backslash collapses to exactly one", () => { + // data-x="a\\b" -> a\b + const attr = `data-x=${Q}a${BS}${BS}b${Q}`; + expect(attrValue(view(attr))).toBe(`a${BS}b`); +}); + +test("a lone backslash before an ordinary character is preserved", () => { + // data-path="C:\Users\name" must survive the lexer intact. + const attr = `data-path=${Q}C:${BS}Users${BS}name${Q}`; + expect(attrValue(view(attr))).toBe(`C:${BS}Users${BS}name`); +}); + +test("a lone backslash before an escape-looking character is preserved verbatim", () => { + // data-x="a\nb" is two characters a, backslash, n, b -- not a newline. + const attr = `data-x=${Q}a${BS}nb${Q}`; + expect(attrValue(view(attr))).toBe(`a${BS}nb`); +});