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`);
+});