diff --git a/editors/vscode/src/compiler.cjs b/editors/vscode/src/compiler.cjs index cb6aed74..38750b88 100644 --- a/editors/vscode/src/compiler.cjs +++ b/editors/vscode/src/compiler.cjs @@ -1,6 +1,6 @@ "use strict"; // Generated by scripts/build-editor-compiler.mjs. Do not edit directly. -// WRN editor compiler source hash: 3dd8f4ba41eb3b4086c63e530a5daa985c1591e76e23bdd0fc47c87eefe5a4a6 +// WRN editor compiler source hash: 0ce7440feda082e0baf1ad3a8f2a5c309f308b755bd074fa8cab6de16efb675e // WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8 // Generated with TypeScript: 6.0.3 const __nodeRequire = require; @@ -1291,15 +1291,15 @@ function bakeLoopAttr(raw, typed = false) { return out + escLit(attrEscape(raw.slice(last))); } /** Render one loop-body node to template-literal source (nested loops inline). */ -function renderLoopBody(node) { +function renderLoopBody(node, locals = []) { if (node.type === "text") { return bakeLoopText(node.value); } if (node.type === "each") { - return compileEachExpr(node); + return compileEachExpr(node, locals); } if (node.type === "if") { - return compileIfExpr(node); + return compileIfExpr(node, locals); } const componentTag = isComponentTag(node.tag); const attrs = node.attrs @@ -1316,7 +1316,13 @@ function renderLoopBody(node) { return escLit(` ${name}="`) + bakeLoopAttr(attr.value, componentTag) + escLit(`"`); }) .join(""); - const inner = node.children.map(renderLoopBody).join(""); + // A handler expression is emitted as text and evaluated at event time, so any + // `{#each}` variable it names has to travel with the element. The runtime + // resolves them with closest("[data-wrn-loop-locals]"). Only elements that + // actually bind an event need it -- marking every node would bloat the HTML. + const localsAttr = locals.length > 0 && node.attrs.some((attr) => attr.event) ? loopLocalsAttr(locals) : ""; + const inner = node.children.map((child) => renderLoopBody(child, locals)).join(""); + const openAttrs = attrs + localsAttr; if (node.tag === "Static") return inner; if (node.tag === "Dynamic") @@ -1360,26 +1366,37 @@ function renderLoopBody(node) { if (island) return escLit(island); return (escLit(`
") + inner + escLit("
")); } if (syntax_1.VOID_ELEMENTS.has(node.tag.toLowerCase())) { - return escLit(`<${node.tag}`) + attrs + escLit(">"); + return escLit(`<${node.tag}`) + openAttrs + escLit(">"); } - return escLit(`<${node.tag}`) + attrs + escLit(">") + inner + escLit(``); + return escLit(`<${node.tag}`) + openAttrs + escLit(">") + inner + escLit(``); +} +/** + * Build the ` data-wrn-loop-locals="..."` attribute for a page-rendered loop + * body. Deliberately not passed through escLit: the `${...}` must stay live so + * the values are encoded at render time. + */ +function loopLocalsAttr(locals) { + const entries = locals.map((name) => `${JSON.stringify(name)}: ${name}`).join(", "); + return ` data-wrn-loop-locals="\${__wrnexusEncodeLoopLocals({ ${entries} })}"`; } /** * Compile a `{#each list as item}` block to a `${…}` template-literal interpolation * that iterates the (server-evaluated) list and joins the per-item body. `list` is a * JS expression evaluated where `ssr` data bindings are in scope as raw named values. */ -function compileEachExpr(node) { +function compileEachExpr(node, outerLocals = []) { const item = node.item; const index = node.index ?? "__wi"; - const body = node.body.map(renderLoopBody).join(""); - const empty = node.empty.map(renderLoopBody).join(""); + // A nested loop can reference the outer loop's variables too. + const locals = [...outerLocals, item, index]; + const body = node.body.map((child) => renderLoopBody(child, locals)).join(""); + const empty = node.empty.map((child) => renderLoopBody(child, outerLocals)).join(""); return ("${(() => { const __wl = Array.isArray(" + node.list + ") ? (" + @@ -1399,11 +1416,11 @@ function compileEachExpr(node) { * that renders the first truthy branch's body (or the `{:else}` body, or "" when neither). * Conditions are JS expressions evaluated in the surrounding server scope. */ -function compileIfExpr(node) { +function compileIfExpr(node, locals = []) { let expr = "``"; // no matching branch → empty string for (let k = node.branches.length - 1; k >= 0; k--) { const b = node.branches[k]; - const bodySrc = "`" + b.body.map(renderLoopBody).join("") + "`"; + const bodySrc = "`" + b.body.map((child) => renderLoopBody(child, locals)).join("") + "`"; expr = b.cond === null ? bodySrc : "(" + b.cond + ") ? " + bodySrc + " : " + expr; } return "${" + expr + "}"; @@ -2282,6 +2299,18 @@ function generateInner(ast) { if (needsRuntimeHelpers) { out.push(`import { buildApiRequest as __wrnexusBuildApiRequest } from "@wrnexus/core";`); out.push(ssrRuntimeSource()); + // Loop bodies that bind an event carry their {#each} locals in an encoded + // attribute. Emitted only when the view actually produced one, so a page + // without handlers in a loop keeps the smaller prelude -- but it MUST be + // emitted whenever the marker is, or the render throws on an undefined + // function instead of the handler throwing on an undefined variable. + if (body.includes("__wrnexusEncodeLoopLocals(")) { + out.push(`function __wrnexusEncodeLoopLocals(value: Record): string { + const json = JSON.stringify(value); + const buffer = (globalThis as { Buffer?: { from(i: string, e: string): { toString(e: string): string } } }).Buffer; + return buffer ? buffer.from(json, "utf8").toString("base64") : btoa(unescape(encodeURIComponent(json))); +}`); + } out.push(`const __wrnexusSsrBindings: __WrnexusSsrBinding[] = ${JSON.stringify(ssrBindings, null, 2)};`); } if (hasServerApis) { @@ -7477,6 +7506,88 @@ exports.isIdentPart = isIdentPart; * reimplementing it — a second hand-rolled scanner is how apostrophes in * prose used to swallow braces. */ +/** + * Identifiers that can precede a `/` without ending an expression, so the `/` + * opens a regex rather than dividing. + */ +const REGEX_PRECEDING_KEYWORDS = new Set([ + "return", + "typeof", + "instanceof", + "in", + "of", + "new", + "delete", + "void", + "do", + "else", + "yield", + "await", + "case", +]); +/** + * Decide whether the `/` at `i` opens a regex literal or is a division sign. + * + * Scans backwards for the last significant character. `a / b` divides; `(/a/)`, + * `= /a/` and `return /a/` do not. Erring towards division is the safe + * direction -- mistaking division for a regex would swallow everything to the + * next `/` and lose any braces in between. + */ +function opensRegex(src, i) { + let j = i - 1; + while (j >= 0 && (src[j] === " " || src[j] === "\t" || src[j] === "\r" || src[j] === "\n")) + j--; + if (j < 0) + return true; + const prev = src[j]; + if (/[A-Za-z0-9_$]/.test(prev)) { + // An identifier ends an expression, so `/` divides -- unless it is a + // keyword that cannot end one, like `return`. + let k = j; + while (k >= 0 && /[A-Za-z0-9_$]/.test(src[k])) + k--; + return REGEX_PRECEDING_KEYWORDS.has(src.slice(k + 1, j + 1)); + } + // `)` and `]` close an expression, `.` continues one, and a quote ends a + // literal; anything else leaves us in a position where a regex may start. + return (prev !== ")" && prev !== "]" && prev !== "." && prev !== '"' && prev !== "'" && prev !== "`"); +} +/** + * Scan a regex literal starting at `i`, returning the index just past its + * closing `/` and flags, or null when this is not in fact a regex. + * + * A regex literal cannot span a newline, so an unterminated one is treated as + * "not a regex" rather than swallowing the rest of the file. That is what keeps + * a bare URL in view text (`https://example.com/a//b`) intact. + */ +function skipRegex(src, i) { + let j = i + 1; + let inClass = false; + while (j < src.length) { + const c = src[j]; + if (c === "\n") + return null; + if (c === "\\") { + j += 2; + continue; + } + if (inClass) { + if (c === "]") + inClass = false; + } + else if (c === "[") { + inClass = true; + } + else if (c === "/") { + j++; + while (j < src.length && /[a-z]/.test(src[j])) + j++; + return j; + } + j++; + } + return null; +} function skipLiteralOrComment(src, i, atLineStart) { const c = src[i]; if (c === "/" && src[i + 1] === "*") { @@ -7500,6 +7611,13 @@ function skipLiteralOrComment(src, i, atLineStart) { } return src.length; } + // A regex literal is neither a string nor a brace pair, but it can contain + // both. Without this, a quote inside one opened a phantom string that + // swallowed every brace to the next quote, and a lone `{`/`}` miscounted + // block depth. + if (c === "/" && opensRegex(src, i)) { + return skipRegex(src, i); + } return null; } class Lexer { @@ -7508,8 +7626,13 @@ class Lexer { constructor(src) { this.src = src; } - /** Skip whitespace and `// line comments`. */ - skipTrivia() { + /** + * Skip whitespace and `// line comments`, but NOT block comments. + * + * Kept separate from `skipTrivia` so `startsWithBlockComment` can still see a + * block comment that `skipTrivia` would otherwise consume. + */ + skipWhitespaceAndLineComments() { const { src } = this; while (this.pos < src.length) { const c = src[this.pos]; @@ -7525,9 +7648,34 @@ class Lexer { break; } } - /** True when the next non-trivia characters open a block comment. */ + /** + * Skip whitespace, line comments and block comments. + * + * Block comments used to be skipped only inside a braced body, so one written + * between two members failed with a bare "Unexpected character '/'" -- the + * same comment parsed or did not depending on where it sat. + */ + skipTrivia() { + const { src } = this; + while (this.pos < src.length) { + this.skipWhitespaceAndLineComments(); + if (src[this.pos] === "/" && src[this.pos + 1] === "*") { + const close = src.indexOf("*/", this.pos + 2); + this.pos = close === -1 ? src.length : close + 2; + continue; + } + break; + } + } + /** + * True when the next non-trivia characters open a block comment. + * + * Deliberately skips only whitespace and line comments: `props {}` refuses + * block comments with an explained error, and that check must run before + * `skipTrivia` would swallow the comment and drop a declaration silently. + */ startsWithBlockComment() { - this.skipTrivia(); + this.skipWhitespaceAndLineComments(); return this.src[this.pos] === "/" && this.src[this.pos + 1] === "*"; } /** Read and consume the next structural token. */ diff --git a/editors/vscode/src/extension.bundle.cjs b/editors/vscode/src/extension.bundle.cjs index e2c62167..b63a9d6f 100644 --- a/editors/vscode/src/extension.bundle.cjs +++ b/editors/vscode/src/extension.bundle.cjs @@ -1,4 +1,4 @@ -// WRN editor extension source hash: 59ff54353ed4f50aa53f3731e232726d55549936b2edf8958beb407cc4fdf9d2 +// WRN editor extension source hash: e2d214e98eb953756974d4b731530ed432c2f9dca6f0aaee975453b2eec84d75 // WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728 "use strict"; var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports); diff --git a/editors/vscode/src/language-server.cjs b/editors/vscode/src/language-server.cjs index 13a21615..59a3ec02 100644 --- a/editors/vscode/src/language-server.cjs +++ b/editors/vscode/src/language-server.cjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// WRN editor language server source hash: e55270eb33d7722a82d13d3a79097fea77eca69612aeba104e9cbdd8fc67e8c6 +// WRN editor language server source hash: cea070c2eebb5339c7b48f4200d34e51f2adb16eba4d6d3f92e5040081c5b079 // WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72 // @bun @bun-cjs (function(exports, require, module, __filename, __dirname) {var __create = Object.create; @@ -169671,6 +169671,64 @@ var isWs = (c) => c === " " || c === "\t" || c === ` ` || c === "\r"; var isIdentStart = (c) => /[A-Za-z_]/.test(c); var isIdentPart = (c) => /[A-Za-z0-9_]/.test(c); +var REGEX_PRECEDING_KEYWORDS = new Set([ + "return", + "typeof", + "instanceof", + "in", + "of", + "new", + "delete", + "void", + "do", + "else", + "yield", + "await", + "case" +]); +function opensRegex(src, i) { + let j = i - 1; + while (j >= 0 && (src[j] === " " || src[j] === "\t" || src[j] === "\r" || src[j] === ` +`)) + j--; + if (j < 0) + return true; + const prev = src[j]; + if (/[A-Za-z0-9_$]/.test(prev)) { + let k = j; + while (k >= 0 && /[A-Za-z0-9_$]/.test(src[k])) + k--; + return REGEX_PRECEDING_KEYWORDS.has(src.slice(k + 1, j + 1)); + } + return prev !== ")" && prev !== "]" && prev !== "." && prev !== '"' && prev !== "'" && prev !== "`"; +} +function skipRegex(src, i) { + let j = i + 1; + let inClass = false; + while (j < src.length) { + const c = src[j]; + if (c === ` +`) + return null; + if (c === "\\") { + j += 2; + continue; + } + if (inClass) { + if (c === "]") + inClass = false; + } else if (c === "[") { + inClass = true; + } else if (c === "/") { + j++; + while (j < src.length && /[a-z]/.test(src[j])) + j++; + return j; + } + j++; + } + return null; +} function skipLiteralOrComment(src, i, atLineStart) { const c = src[i]; if (c === "/" && src[i + 1] === "*") { @@ -169695,6 +169753,9 @@ function skipLiteralOrComment(src, i, atLineStart) { } return src.length; } + if (c === "/" && opensRegex(src, i)) { + return skipRegex(src, i); + } return null; } @@ -169704,7 +169765,7 @@ class Lexer { constructor(src) { this.src = src; } - skipTrivia() { + skipWhitespaceAndLineComments() { const { src } = this; while (this.pos < src.length) { const c = src[this.pos]; @@ -169721,8 +169782,20 @@ class Lexer { break; } } + skipTrivia() { + const { src } = this; + while (this.pos < src.length) { + this.skipWhitespaceAndLineComments(); + if (src[this.pos] === "/" && src[this.pos + 1] === "*") { + const close = src.indexOf("*/", this.pos + 2); + this.pos = close === -1 ? src.length : close + 2; + continue; + } + break; + } + } startsWithBlockComment() { - this.skipTrivia(); + this.skipWhitespaceAndLineComments(); return this.src[this.pos] === "/" && this.src[this.pos + 1] === "*"; } next() { diff --git a/packages/csr/src/reactive-runtime.ts b/packages/csr/src/reactive-runtime.ts index a5edde61..d5cc87e0 100644 --- a/packages/csr/src/reactive-runtime.ts +++ b/packages/csr/src/reactive-runtime.ts @@ -999,20 +999,11 @@ export const REACTIVE_RUNTIME = String.raw` var json = JSON.stringify(locals); if (json === undefined) return; - var bytes = new TextEncoder().encode(json); - var binary = ""; - - for (var index = 0; index < bytes.length; index++) { - binary += String.fromCharCode(bytes[index]); - } - - node.setAttribute("data-wrn-loop-locals", window.btoa(binary)); + node.setAttribute( + "data-wrn-loop-locals", + window.btoa(unescape(encodeURIComponent(json))), + ); } catch (error) { - /* - * A value that will not serialise (a cycle, a DOM node) must not take - * the whole loop down -- the item still renders, and a handler naming - * that local fails on its own terms rather than silently. - */ console.error("[wrnexus] failed to encode loop locals", error); } } diff --git a/packages/syntax/test/syntax.test.ts b/packages/syntax/test/syntax.test.ts index efb7c469..5d9ce948 100644 --- a/packages/syntax/test/syntax.test.ts +++ b/packages/syntax/test/syntax.test.ts @@ -257,7 +257,7 @@ ${body} expect(parse(mk(` const x = /it's/.test("its");`)).name).toBe("P"); expect(parse(mk(` const x = /"/.test("q");`)).name).toBe("P"); // A brace inside a regex used to be counted as block depth. - expect(parse(mk(` const x = /\{/.test("{");`)).name).toBe("P"); + expect(parse(mk(` const x = /\\{/.test("{");`)).name).toBe("P"); expect(parse(mk(` const x = /}/.test("}");`)).name).toBe("P"); // A brace quantifier is balanced, but must not be counted either. expect(parse(mk(` const x = /^a{2,3}$/.test("aa");`)).name).toBe("P"); diff --git a/scripts/security-performance-audit.mjs b/scripts/security-performance-audit.mjs index c5ca4514..f6eb23f5 100644 --- a/scripts/security-performance-audit.mjs +++ b/scripts/security-performance-audit.mjs @@ -183,7 +183,15 @@ const runtimeBudgets = { */ // Raised to 51_400: the callApi transport (query building, CSRF header, // JSON body, success/failure contract) for compiled api blocks bought ~1,025 bytes. - "reactive-runtime.ts": 51_400, + // + // Raised to 51_600 on 2026-08-22: writing `data-wrn-loop-locals` onto + // client-rendered for-loop items bought 263 bytes. Without it a component's + // output binding inside a loop resolved no locals and silently dropped every + // call, while a plain DOM handler in the same position worked -- so the cost + // buys a correctness fix, not a feature. The encoder was trimmed to the + // btoa/encodeURIComponent idiom first, which recovered 65 of those bytes; + // what remains is the smallest form that still handles non-ASCII. + "reactive-runtime.ts": 51_600, "component-controllers.ts": 24_100, "nav-runtime.ts": 12_000, "realtime-runtime.ts": 8_000,