feat: make state-based attributes reactive

This commit is contained in:
2026-07-14 00:43:07 +05:30
parent 4ce087b238
commit 420706ca3b
61 changed files with 221 additions and 99 deletions
+1 -1
View File
@@ -149,7 +149,7 @@ A file opens with `page <Name>` or `component <Name>` followed by a `{ ... }` bo
- `layout = "<name>"` — selects `app/layouts/<name>.wrn` (pages only).
- `props { name = <default> ... }` — component props; each default's type drives coercion.
- `state <ident> = <expr>` — reactive state seeded from a raw JS expression.
- `view { <html> }` — plain HTML with `{expr}` interpolation, hyphenated attributes, boolean attributes, `@event="..."` client bindings, and `<!-- comments -->`.
- `view { <html> }` — plain HTML with `{expr}` interpolation in text and attributes, hyphenated attributes, boolean attributes, `@event="..."` client bindings, and `<!-- comments -->`. Attribute expressions that reference `state` keep an SSR value and update reactively in the browser.
- `seo { key = "value" ... }` — metadata merged into the generated `meta`.
- `style { <raw css> }` — inlined page/component stylesheet (repeatable).
- `functions { <raw js> }` — shared server-side helpers (repeatable).
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/compiler",
"version": "0.2.24",
"version": "0.2.25",
"type": "module",
"main": "src/index.ts",
"exports": {
+40 -11
View File
@@ -79,8 +79,35 @@ function eventAttribute(name: string): string {
return `data-on-${name}`;
}
function renderAttrs(attrs: Attr[], csrId?: string): string {
const rendered = attrs.map(renderAttr).join("");
function reactiveAttrValue(raw: string, reactive: PageReactive): string | null {
let found = false;
const value = raw.replace(/\{([^{}]+)\}/g, (whole, inner: string) => {
const expr = inner.trim();
if (!exprRefsState(expr, reactive.stateNames)) return whole;
found = true;
try {
const result = new Function("with(this){return (" + expr + ");}").call(reactive.scope);
return result == null ? "" : String(result);
} catch {
return whole;
}
});
return found ? value : null;
}
function renderAttrs(attrs: Attr[], csrId?: string, reactive: PageReactive | null = null): string {
let bindIndex = 0;
const rendered = attrs
.map((attr) => {
const base = renderAttr(attr);
if (!reactive || attr.event || attr.boolean || !base || !attr.value.includes("{"))
return base;
const initial = reactiveAttrValue(attr.value, reactive);
if (initial === null) return base;
const marker = JSON.stringify([attr.name, attr.value]);
return ` ${attr.name}="${attrEscape(initial)}" data-wrn-bind-${bindIndex++}="${attrEscape(marker)}"`;
})
.join("");
return csrId ? `${rendered} data-wrnexus-csr="${attrEscape(csrId)}"` : rendered;
}
@@ -312,7 +339,7 @@ function renderNode(
// Void elements (<br>, <img>, …) have no closing tag and no children.
if (VOID_ELEMENTS.has(node.tag.toLowerCase())) {
return `<${node.tag}${renderAttrs(node.attrs, csrId)}>`;
return `<${node.tag}${renderAttrs(node.attrs, csrId, reactive)}>`;
}
const inner =
@@ -331,7 +358,7 @@ function renderNode(
)
.join("");
return `<${node.tag}${renderAttrs(node.attrs, csrId)}>${inner}</${node.tag}>`;
return `<${node.tag}${renderAttrs(node.attrs, csrId, reactive)}>${inner}</${node.tag}>`;
}
function ssrMarker(bindings: SsrBinding[], binding: RenderBinding): string {
@@ -745,14 +772,16 @@ function renderComponentNode(node: ViewNode, ctx: CompCtx): string {
);
}
let bindIndex = 0;
const attrs = node.attrs
.map((a) =>
a.event
? ` ${eventAttribute(a.name)}="${compileAttrValue(a.value, ctx)}"`
: a.boolean
? ` ${a.name}`
: ` ${a.name}="${compileAttrValue(a.value, ctx)}"`,
)
.map((a) => {
if (a.event) return ` ${eventAttribute(a.name)}="${compileAttrValue(a.value, ctx)}"`;
if (a.boolean) return ` ${a.name}`;
const rendered = ` ${a.name}="${compileAttrValue(a.value, ctx)}"`;
if (!a.value.includes("{") || !exprRefsState(a.value, ctx.stateNames)) return rendered;
const marker = attrEscape(JSON.stringify([a.name, a.value]));
return `${rendered} data-wrn-bind-${bindIndex++}="${escLit(marker)}"`;
})
.join("");
// A `data-for` element introduces loop variables for its subtree.
+14
View File
@@ -116,6 +116,20 @@ test("page state text bakes its initial value into a reactive data-text span", (
expect(page).toContain("data-scope"); // state still forces a reactive scope
});
test("page state attributes bake their initial value and retain a reactive binding", () => {
const page = compileWireFile(`page Password {
state show = false
view {
<input type="{show ? 'text' : 'password'}" aria-label="{show ? 'Hide' : 'Show'}">
<button @click="show = !show">Toggle</button>
}
}`);
expect(page).toContain('type="password"');
expect(page).toContain('aria-label="Show"');
expect(page.match(/data-wrn-bind-/g)).toHaveLength(2);
expect(page).toContain("show ? 'text' : 'password'");
});
test("data-for: loop-variable mustaches stay literal (not baked server-side)", () => {
const comp = compileWireFile(
`component TodoList {\n state todos = []\n view { <ul><li data-for="t in todos">{t.text}</li></ul> }\n}`,