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(`${node.tag}>`);
+ return escLit(`<${node.tag}`) + openAttrs + escLit(">") + inner + escLit(`${node.tag}>`);
+}
+/**
+ * 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,