diff --git a/editors/vscode/src/formatter.js b/editors/vscode/src/formatter.js index a8eba438..d5398dab 100644 --- a/editors/vscode/src/formatter.js +++ b/editors/vscode/src/formatter.js @@ -85,6 +85,51 @@ function leadingClosers(line) { return count; } +function formatOpeningTag(source, unit, baseDepth) { + if ( + !source.startsWith("<") || + source.startsWith("$/.exec(source); + + if (!match) { + return source; + } + + const tag = match[1]; + const rawAttributes = match[2].trim(); + const selfClosing = match[3] === "/"; + + if (!rawAttributes) { + return source; + } + + const attributes = []; + const pattern = /(?:[^\s"'=<>`]+)(?:\s*=\s*(?:"[^"]*"|'[^']*'))?/g; + + for (const attribute of rawAttributes.matchAll(pattern)) { + attributes.push(attribute[0]); + } + + if (attributes.length < 2) { + return source; + } + + const base = unit.repeat(baseDepth); + const child = unit.repeat(baseDepth + 1); + + return [ + `${base}<${tag}`, + ...attributes.map((attribute) => `${child}${attribute}`), + `${base}${selfClosing ? "/>" : ">"}`, + ].join("\n"); +} + /** * Format WRN source conservatively: normalize structural indentation and * trailing whitespace without rewriting expressions, HTML, CSS, or JS. @@ -106,7 +151,13 @@ function formatWrn(text, options = {}) { const wasTemplate = state.template; const trimmed = line.trimStart(); const indent = Math.max(0, depth - leadingClosers(trimmed)); - const output = wasTemplate ? line : unit.repeat(indent) + trimmed; + const formattedLine = formatOpeningTag(trimmed, unit, indent); + + const output = wasTemplate + ? line + : formattedLine.startsWith(unit.repeat(indent)) + ? formattedLine + : unit.repeat(indent) + formattedLine; depth = Math.max(0, depth + curlyDelta(trimmed, state) + htmlDelta(trimmed)); return output; }); diff --git a/packages/compiler/src/codegen.ts b/packages/compiler/src/codegen.ts index 8528a138..4b548748 100644 --- a/packages/compiler/src/codegen.ts +++ b/packages/compiler/src/codegen.ts @@ -521,8 +521,8 @@ export function generate(ast: PageAst): string { const needsClientRuntime = ast.states.length > 0 || hasClientBehavior(ast.view); if (needsClientRuntime) { - const scope = ast.states.map((s) => `${s.name}: ${s.expr}`).join(", "); - html = `
${html}
`; + const scopePlaceholder = "__WRNEXUS_DYNAMIC_SCOPE__"; + html = `
${html}
`; } if (styles.length > 0) { @@ -536,6 +536,12 @@ export function generate(ast: PageAst): string { // Escape the static HTML for the template literal, then swap loop sentinels for // their real `${…}` code (which must NOT be escaped). let body = templateEscape(html); + const dynamicStateScope = ast.states + .map( + (state) => + `${JSON.stringify(state.name)}: (() => { try { return (${state.expr}); } catch { return undefined; } })()`, + ) + .join(", "); loops.forEach((code, idx) => { body = body.replace(`\x00WRNEACH${idx}\x00`, () => code); }); @@ -554,16 +560,68 @@ export function generate(ast: PageAst): string { } } - const needsSsrRuntime = ssrBindings.length > 0 || loops.length > 0; + const needsSsrRuntime = + ssrBindings.length > 0 || + loops.length > 0 || + ast.states.some((state) => /\bctx\b/.test(state.expr)); if (needsSsrRuntime) { out.push(ssrRuntimeSource()); out.push(`const __wrnexusSsrBindings = ${JSON.stringify(ssrBindings, null, 2)};`); const decls = loopConsts.length > 0 ? loopConsts.join("\n") + "\n" : ""; out.push( - `export default async function ${ast.name}(ctx: any) {\n${decls} const html = \`${body}\`;\n return await __wrnexusRenderSsrBindings(html, ctx);\n}`, + `export default async function ${ast.name}(ctx: any) { + ${decls} + const __state = { ${dynamicStateScope} }; + + const __scopeValue = Object.entries(__state) + .map(([key, value]) => { + const encoded = + typeof value === "number" || typeof value === "boolean" + ? String(value) + : JSON.stringify(value == null ? "" : String(value)); + + return key + ": " + encoded; + }) + .join(", ") + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); + + const html = \`${body}\`.replace( + "__WRNEXUS_DYNAMIC_SCOPE__", + __scopeValue, + ); + + return await __wrnexusRenderSsrBindings(html, ctx); + }`, ); } else { - out.push(`export default function ${ast.name}() {\n return \`${body}\`;\n}`); + out.push( + `export default function ${ast.name}(ctx: any) { + const __state = { ${dynamicStateScope} }; + + const __scopeValue = Object.entries(__state) + .map(([key, value]) => { + const encoded = + typeof value === "number" || typeof value === "boolean" + ? String(value) + : JSON.stringify(value == null ? "" : String(value)); + + return key + ": " + encoded; + }) + .join(", ") + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); + + return \`${body}\`.replace( + "__WRNEXUS_DYNAMIC_SCOPE__", + __scopeValue, + ); + }`, + ); } // --- API blocks -> method handlers --- @@ -773,25 +831,81 @@ function renderComponentNode(node: ViewNode, ctx: CompCtx): string { } let bindIndex = 0; + const staticClasses: string[] = []; + const conditionalClasses: Array<{ + className: string; + expression: string; + }> = []; + + for (const attr of node.attrs) { + if (!attr.event && attr.name === "class") { + staticClasses.push(attr.value); + } + + if (!attr.event && attr.name.startsWith("class:")) { + conditionalClasses.push({ + className: attr.name.slice("class:".length), + expression: attr.value, + }); + } + } + const attrs = node.attrs + .filter((a) => a.name !== "class" && !a.name.startsWith("class:")) .map((a) => { - if (a.event) return ` ${eventAttribute(a.name)}="${compileAttrValue(a.value, ctx)}"`; - if (a.boolean) return ` ${a.name}`; + 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; + + 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(""); + const initialConditionalClasses = conditionalClasses + .map(({ className, expression }) => { + return `\${(${ctx.resolveExpr(expression)}) ? ${JSON.stringify(` ${className}`)} : ""}`; + }) + .join(""); + + const classAttribute = + staticClasses.length > 0 || conditionalClasses.length > 0 + ? ` class="${compileAttrValue(staticClasses.join(" "), ctx)}${initialConditionalClasses}"` + : ""; + + const classBindings = conditionalClasses + .map(({ className, expression }, index) => { + const marker = attrEscape(JSON.stringify([className, expression])); + + return ` data-wrn-class-${index}="${escLit(marker)}"`; + }) + .join(""); + // A `data-for` element introduces loop variables for its subtree. const loops = loopVarsOf(node); const childCtx = loops.length > 0 ? { ...ctx, loopVars: new Set([...(ctx.loopVars ?? []), ...loops]) } : ctx; - if (VOID_ELEMENTS.has(node.tag.toLowerCase())) return `<${node.tag}${attrs}>`; - const inner = node.children.map((c) => renderComponentNode(c, childCtx)).join(""); - return `<${node.tag}${attrs}>${inner}`; + const allAttrs = `${classAttribute}${classBindings}${attrs}`; + + if (VOID_ELEMENTS.has(node.tag.toLowerCase())) { + return `<${node.tag}${allAttrs}>`; + } + + const inner = node.children.map((child) => renderComponentNode(child, childCtx)).join(""); + + return `<${node.tag}${allAttrs}>${inner}`; } function generateComponent(ast: PageAst): string { diff --git a/packages/compiler/src/parser.ts b/packages/compiler/src/parser.ts index 63f1555a..1b4f297f 100644 --- a/packages/compiler/src/parser.ts +++ b/packages/compiler/src/parser.ts @@ -386,7 +386,14 @@ export function parseHtmlView(src: string, pos: number): { nodes: ViewNode[]; en let i = pos; const isNameStart = (c: string): boolean => /[A-Za-z_]/.test(c); - const isNamePart = (c: string): boolean => /[A-Za-z0-9_:-]/.test(c); + const isNamePart = (c: string): boolean => /[A-Za-z0-9_:.[\]%-]/.test(c); + const isAttributeNamePart = (c: string, next: string | undefined): boolean => { + if (c === "/") { + return next !== ">"; + } + + return isNamePart(c); + }; const isWs = (c: string): boolean => c === " " || c === "\t" || c === "\n" || c === "\r"; const fail = (msg: string): never => { @@ -425,7 +432,9 @@ export function parseHtmlView(src: string, pos: number): { nodes: ViewNode[]; en const readName = (): string => { if (i >= src.length || !isNameStart(src[i]!)) return fail("Expected a tag or attribute name"); const start = i++; - while (i < src.length && isNamePart(src[i]!)) i++; + while (i < src.length && isAttributeNamePart(src[i], src[i + 1])) { + i++; + } return src.slice(start, i); }; diff --git a/packages/csr/src/reactive-runtime.ts b/packages/csr/src/reactive-runtime.ts index 7dba1ff1..21819861 100644 --- a/packages/csr/src/reactive-runtime.ts +++ b/packages/csr/src/reactive-runtime.ts @@ -199,6 +199,45 @@ export const REACTIVE_RUNTIME = String.raw` }); }); + // Conditional class bindings emitted as: + // data-wrn-class-*='["class-name","expression"]' + var classBindNodes = [el].concat( + Array.prototype.slice.call(el.querySelectorAll("*")), + ); + + classBindNodes.forEach(function (node) { + if (!owns(node)) return; + + Array.prototype.slice.call(node.attributes).forEach(function (marker) { + if (marker.name.indexOf("data-wrn-class-") !== 0) return; + + var binding; + + try { + binding = JSON.parse(marker.value); + } catch (e) { + return; + } + + if (!binding || binding.length !== 2) return; + + var className = binding[0]; + var expression = binding[1]; + + reactive(function () { + var enabled = false; + + try { + enabled = !!evalExpr(expression); + } catch (e) { + enabled = false; + } + + node.classList.toggle(className, enabled); + }); + }); + }); + // Reactive ordinary attributes emitted by the compiler. Each marker stores // [attributeName, originalTemplate], preserving an SSR value while allowing // state changes to update type, aria-*, class, href, and other attributes. diff --git a/packages/dev-server/src/index.ts b/packages/dev-server/src/index.ts index 71c99cd2..2c947d52 100644 --- a/packages/dev-server/src/index.ts +++ b/packages/dev-server/src/index.ts @@ -218,9 +218,19 @@ export async function startServer(opts: ServeOptions): Promise { // modules, rescan file routes, and ask browsers to morph in fresh HTML. if (hmr && hub) { const hotUpdate = async (files: string[]): Promise => { - for (const relative of files) invalidateModule(resolve(appDir, relative)); + // Allow VS Code/Bun to finish writing pasted content. + await new Promise((resolvePromise) => setTimeout(resolvePromise, 50)); - Object.assign(router, buildRouter(appDir, { componentDirs: [uiComponentsDir()] })); + for (const relative of files) { + invalidateModule(resolve(appDir, relative)); + } + + Object.assign( + router, + buildRouter(appDir, { + componentDirs: [uiComponentsDir()], + }), + ); middleware.invalidate(); if (files.some((file) => file === "schemas" || file.startsWith("schemas/"))) { diff --git a/packages/dev-server/src/watch.ts b/packages/dev-server/src/watch.ts index 9fdb2734..f040fb08 100644 --- a/packages/dev-server/src/watch.ts +++ b/packages/dev-server/src/watch.ts @@ -71,7 +71,7 @@ export function startWatcher(opts: WatchOptions): FSWatcher | undefined { pendingFiles.add(rel); pending.add(classify(rel)); if (timer) clearTimeout(timer); - timer = setTimeout(flush, 60); // debounce editor write bursts + timer = setTimeout(flush, 200); // debounce editor write bursts }); } catch (err) { console.warn("[wrnexus] file watching unavailable; HMR disabled", err);