release: WRNexusJS 0.2.31

This commit is contained in:
2026-07-14 21:58:19 +05:30
parent 3cfdc747fa
commit 8b4b7ca5ac
58 changed files with 1152 additions and 196 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/compiler",
"version": "0.2.30",
"version": "0.2.31",
"type": "module",
"main": "src/index.ts",
"exports": {
+83 -6
View File
@@ -78,8 +78,22 @@ function renderAttr(attr: Attr): string {
}
function eventAttribute(name: string): string {
if (name.startsWith("browser-")) return `data-on-wrnexus-browser-${name.slice(8)}`;
if (name.startsWith("mobile-")) return `data-on-wrnexus-mobile-${name.slice(7)}`;
if (name.startsWith("window:")) {
return `data-on-window-${name.slice("window:".length)}`;
}
if (name.startsWith("document:")) {
return `data-on-document-${name.slice("document:".length)}`;
}
if (name.startsWith("browser-")) {
return `data-on-wrnexus-browser-${name.slice(8)}`;
}
if (name.startsWith("mobile-")) {
return `data-on-wrnexus-mobile-${name.slice(7)}`;
}
return `data-on-${name}`;
}
@@ -437,7 +451,9 @@ function renderNestedComponentInvocation(
.filter((attr) => attr.name !== "data-component")
.map((attr) => {
if (attr.event) {
return ` ${eventAttribute(attr.name)}="${compileAttrValue(attr.value, ctx)}"`;
return (
escLit(` ${eventAttribute(attr.name)}="`) + escLit(attrEscape(attr.value)) + escLit(`"`)
);
}
if (attr.boolean) {
@@ -790,6 +806,19 @@ interface CompCtx {
loopVars?: Set<string>;
}
interface ComponentBehavior {
functions: string;
lifecycle: {
mount?: string;
update?: string;
unmount?: string;
};
watches: Array<{
state: string;
body: string;
}>;
}
/** Parse a `data-for="item in list"` / `"item, i in list"` directive value. */
export function parseForExpr(value: string): { item: string; index?: string; list: string } | null {
const m = /^\s*([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*)\s*)?\s+in\s+([\s\S]+?)\s*$/.exec(
@@ -861,6 +890,44 @@ function escLit(s: string): string {
return s.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
}
function componentBehavior(ast: PageAst): ComponentBehavior | null {
const functions = ast.functions
.map((body) => body.trim())
.filter(Boolean)
.join("\n\n");
const lifecycle = {
...(ast.lifecycle.mount?.trim() ? { mount: ast.lifecycle.mount.trim() } : {}),
...(ast.lifecycle.update?.trim() ? { update: ast.lifecycle.update.trim() } : {}),
...(ast.lifecycle.unmount?.trim() ? { unmount: ast.lifecycle.unmount.trim() } : {}),
};
const watches = ast.watches.map((watch) => ({
state: watch.state,
body: watch.body.trim(),
}));
if (!functions && Object.keys(lifecycle).length === 0 && watches.length === 0) {
return null;
}
return {
functions,
lifecycle,
watches,
};
}
function behaviorAttribute(behavior: ComponentBehavior | null): string {
if (!behavior) {
return "";
}
return ` data-wrn-behavior="${attrEscape(JSON.stringify(behavior))}"`;
}
const INTERP_RE = /\{([^{}]+)\}/g;
function exprRefsState(expr: string, stateNames: Set<string>): boolean {
@@ -971,7 +1038,7 @@ function renderComponentNode(node: ViewNode, ctx: CompCtx): string {
.filter((a) => a.name !== "class" && !a.name.startsWith("class:"))
.map((a) => {
if (a.event) {
return ` ${eventAttribute(a.name)}="${compileAttrValue(a.value, ctx)}"`;
return ` ${eventAttribute(a.name)}="${escLit(attrEscape(a.value))}"`;
}
if (a.boolean) {
@@ -1066,12 +1133,17 @@ function generateComponent(ast: PageAst): string {
// A component needs a reactive scope only when it has state or event handlers.
// Prop-driven text/attributes are baked server-side, so static components ship
// no JavaScript at all.
const needsScope = ast.states.length > 0 || viewHasEvents(ast.view);
const behavior = componentBehavior(ast);
const needsScope = ast.states.length > 0 || viewHasEvents(ast.view) || behavior !== null;
const scopeKeys = [
...effectiveProps.map((prop) => prop.name),
...ast.states.map((state) => state.name),
];
const behaviorAttr = behaviorAttribute(behavior);
const decls: string[] = [];
for (const prop of effectiveProps) {
decls.push(
@@ -1083,7 +1155,7 @@ function generateComponent(ast: PageAst): string {
}
const returnExpr = needsScope
? "`" + styleTag + '<div data-scope="${__scope}">' + viewCode + "</div>`"
? "`" + styleTag + `<div data-scope="\${__scope}"${behaviorAttr}>` + viewCode + "</div>`"
: "`" + styleTag + viewCode + "`";
const scopeLine =
@@ -1098,6 +1170,11 @@ function generateComponent(ast: PageAst): string {
} else {
out.push(`export const __wrnexusComponent = ${JSON.stringify(ast.name)};`);
}
if (behavior) {
out.push(`export const __wrnexusBehavior = ${JSON.stringify(behavior, null, 2)};`);
}
out.push(`function __coerce(v: any, def: any): any {
if (v === undefined || v === null) return def;
if (typeof def === "number") return Number(v);
+73
View File
@@ -105,6 +105,19 @@ export interface ModeFunctionsBlock {
body: string;
}
export type LifecycleHookName = "mount" | "update" | "unmount";
export interface LifecycleBlock {
mount?: string;
update?: string;
unmount?: string;
}
export interface WatchBlock {
state: string;
body: string;
}
export interface RealtimeHandler {
event: string;
args: string[];
@@ -141,6 +154,8 @@ export interface PageAst {
functions: string[];
dataApis: DataApiBlock[];
modeFunctions: ModeFunctionsBlock[];
lifecycle: LifecycleBlock;
watches: WatchBlock[];
apis: ApiBlock[];
realtimes: RealtimeBlock[];
}
@@ -210,6 +225,8 @@ export function parse(source: string): PageAst {
const functions: string[] = [];
const dataApis: DataApiBlock[] = [];
const modeFunctions: ModeFunctionsBlock[] = [];
const lifecycle: LifecycleBlock = {};
const watches: WatchBlock[] = [];
const apis: ApiBlock[] = [];
const realtimes: RealtimeBlock[] = [];
@@ -339,6 +356,52 @@ export function parse(source: string): PageAst {
styles.push(lx.readBalancedBraces());
break;
}
case "lifecycle": {
lx.next();
expect("lbrace");
while (lx.peek().type !== "rbrace") {
const hook = lx.peek();
if (hook.type === "eof") {
throw new ParseError("Unexpected end of input inside lifecycle block");
}
if (hook.type !== "ident") {
throw new ParseError(`Expected a lifecycle hook at offset ${hook.pos}`);
}
if (hook.value !== "mount" && hook.value !== "update" && hook.value !== "unmount") {
throw new ParseError(`Unknown lifecycle hook '${hook.value}' at offset ${hook.pos}`);
}
const hookName = hook.value as LifecycleHookName;
lx.next();
if (lifecycle[hookName] !== undefined) {
throw new ParseError(`Duplicate lifecycle hook '${hookName}' at offset ${hook.pos}`);
}
lifecycle[hookName] = lx.readBalancedBraces();
}
expect("rbrace");
break;
}
case "watch": {
lx.next();
const stateName = expect("ident").value;
const body = lx.readBalancedBraces();
watches.push({
state: stateName,
body,
});
break;
}
case "functions": {
lx.next();
functions.push(lx.readBalancedBraces());
@@ -350,6 +413,14 @@ export function parse(source: string): PageAst {
}
expect("rbrace");
const declaredStates = new Set(states.map((state) => state.name));
for (const watcher of watches) {
if (!declaredStates.has(watcher.state)) {
throw new ParseError(`Cannot watch undeclared state '${watcher.state}'`);
}
}
return {
type: "page",
kind,
@@ -363,6 +434,8 @@ export function parse(source: string): PageAst {
functions,
dataApis,
modeFunctions,
lifecycle,
watches,
apis,
realtimes,
};
+193
View File
@@ -243,3 +243,196 @@ layout AppLayout {
expect(output).toContain("export function render");
});
test("parses component lifecycle hooks", () => {
const ast = parse(`
component BackToTop {
state visible = false
lifecycle {
mount {
visible = true
}
update {
console.log(visible)
}
unmount {
visible = false
}
}
view {
<button>Top</button>
}
}
`);
expect(ast.lifecycle.mount).toContain("visible = true");
expect(ast.lifecycle.update).toContain("console.log(visible)");
expect(ast.lifecycle.unmount).toContain("visible = false");
});
test("parses state watchers", () => {
const ast = parse(`
component Menu {
state open = false
watch open {
console.log("Open changed", open)
}
view {
<button>{open}</button>
}
}
`);
expect(ast.watches).toEqual([
{
state: "open",
body: expect.stringContaining('console.log("Open changed", open)'),
},
]);
});
test("rejects watching an undeclared state", () => {
expect(() =>
parse(`
component Broken {
watch missing {
console.log(missing)
}
view {
<div>Broken</div>
}
}
`),
).toThrow("Cannot watch undeclared state 'missing'");
});
test("rejects unknown lifecycle hooks", () => {
expect(() =>
parse(`
component Broken {
lifecycle {
created {
console.log("invalid")
}
}
view {
<div>Broken</div>
}
}
`),
).toThrow("Unknown lifecycle hook 'created'");
});
test("component functions compile into browser behavior metadata", () => {
const output = generate(
parse(`
component Counter {
state count = 0
functions {
function increment() {
count += 1
}
}
view {
<button @click="increment()">
{count}
</button>
}
}
`),
);
expect(output).toContain("data-wrn-behavior");
expect(output).toContain("function increment()");
expect(output).toContain("export const __wrnexusBehavior");
});
test("component lifecycle hooks compile into behavior metadata", () => {
const output = generate(
parse(`
component LifecycleTest {
state ready = false
lifecycle {
mount {
ready = true
}
update {
console.log(ready)
}
unmount {
ready = false
}
}
view {
<div>{ready}</div>
}
}
`),
);
expect(output).toContain("&quot;mount&quot;");
expect(output).toContain("&quot;update&quot;");
expect(output).toContain("&quot;unmount&quot;");
});
test("component watchers compile into behavior metadata", () => {
const output = generate(
parse(`
component WatchTest {
state visible = false
watch visible {
console.log("changed", visible)
}
view {
<div>{visible}</div>
}
}
`),
);
expect(output).toContain("&quot;state&quot;:&quot;visible&quot;");
expect(output).toContain("console.log");
});
test("behavior-only components still receive a reactive scope", () => {
const output = generate(
parse(`
component BehaviorOnly {
functions {
function sayHello() {
console.log("hello")
}
}
lifecycle {
mount {
sayHello()
}
}
view {
<div>Hello</div>
}
}
`),
);
expect(output).toContain("data-scope");
expect(output).toContain("data-wrn-behavior");
});