fix: conditional classes, dynamic params, HMR and formatter
This commit is contained in:
@@ -85,6 +85,51 @@ function leadingClosers(line) {
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatOpeningTag(source, unit, baseDepth) {
|
||||||
|
if (
|
||||||
|
!source.startsWith("<") ||
|
||||||
|
source.startsWith("</") ||
|
||||||
|
source.startsWith("<!--") ||
|
||||||
|
source.length <= 100
|
||||||
|
) {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = /^<([A-Za-z][\w:-]*)([\s\S]*?)(\/?)>$/.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
|
* Format WRN source conservatively: normalize structural indentation and
|
||||||
* trailing whitespace without rewriting expressions, HTML, CSS, or JS.
|
* trailing whitespace without rewriting expressions, HTML, CSS, or JS.
|
||||||
@@ -106,7 +151,13 @@ function formatWrn(text, options = {}) {
|
|||||||
const wasTemplate = state.template;
|
const wasTemplate = state.template;
|
||||||
const trimmed = line.trimStart();
|
const trimmed = line.trimStart();
|
||||||
const indent = Math.max(0, depth - leadingClosers(trimmed));
|
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));
|
depth = Math.max(0, depth + curlyDelta(trimmed, state) + htmlDelta(trimmed));
|
||||||
return output;
|
return output;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -521,8 +521,8 @@ export function generate(ast: PageAst): string {
|
|||||||
const needsClientRuntime = ast.states.length > 0 || hasClientBehavior(ast.view);
|
const needsClientRuntime = ast.states.length > 0 || hasClientBehavior(ast.view);
|
||||||
|
|
||||||
if (needsClientRuntime) {
|
if (needsClientRuntime) {
|
||||||
const scope = ast.states.map((s) => `${s.name}: ${s.expr}`).join(", ");
|
const scopePlaceholder = "__WRNEXUS_DYNAMIC_SCOPE__";
|
||||||
html = `<div data-scope="${attrEscape(scope)}">${html}</div>`;
|
html = `<div data-scope="${scopePlaceholder}">${html}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (styles.length > 0) {
|
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
|
// Escape the static HTML for the template literal, then swap loop sentinels for
|
||||||
// their real `${…}` code (which must NOT be escaped).
|
// their real `${…}` code (which must NOT be escaped).
|
||||||
let body = templateEscape(html);
|
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) => {
|
loops.forEach((code, idx) => {
|
||||||
body = body.replace(`\x00WRNEACH${idx}\x00`, () => code);
|
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) {
|
if (needsSsrRuntime) {
|
||||||
out.push(ssrRuntimeSource());
|
out.push(ssrRuntimeSource());
|
||||||
out.push(`const __wrnexusSsrBindings = ${JSON.stringify(ssrBindings, null, 2)};`);
|
out.push(`const __wrnexusSsrBindings = ${JSON.stringify(ssrBindings, null, 2)};`);
|
||||||
const decls = loopConsts.length > 0 ? loopConsts.join("\n") + "\n" : "";
|
const decls = loopConsts.length > 0 ? loopConsts.join("\n") + "\n" : "";
|
||||||
out.push(
|
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, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
|
||||||
|
const html = \`${body}\`.replace(
|
||||||
|
"__WRNEXUS_DYNAMIC_SCOPE__",
|
||||||
|
__scopeValue,
|
||||||
|
);
|
||||||
|
|
||||||
|
return await __wrnexusRenderSsrBindings(html, ctx);
|
||||||
|
}`,
|
||||||
);
|
);
|
||||||
} else {
|
} 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, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
|
||||||
|
return \`${body}\`.replace(
|
||||||
|
"__WRNEXUS_DYNAMIC_SCOPE__",
|
||||||
|
__scopeValue,
|
||||||
|
);
|
||||||
|
}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- API blocks -> method handlers ---
|
// --- API blocks -> method handlers ---
|
||||||
@@ -773,25 +831,81 @@ function renderComponentNode(node: ViewNode, ctx: CompCtx): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let bindIndex = 0;
|
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
|
const attrs = node.attrs
|
||||||
|
.filter((a) => a.name !== "class" && !a.name.startsWith("class:"))
|
||||||
.map((a) => {
|
.map((a) => {
|
||||||
if (a.event) return ` ${eventAttribute(a.name)}="${compileAttrValue(a.value, ctx)}"`;
|
if (a.event) {
|
||||||
if (a.boolean) return ` ${a.name}`;
|
return ` ${eventAttribute(a.name)}="${compileAttrValue(a.value, ctx)}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a.boolean) {
|
||||||
|
return ` ${a.name}`;
|
||||||
|
}
|
||||||
|
|
||||||
const rendered = ` ${a.name}="${compileAttrValue(a.value, ctx)}"`;
|
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]));
|
const marker = attrEscape(JSON.stringify([a.name, a.value]));
|
||||||
|
|
||||||
return `${rendered} data-wrn-bind-${bindIndex++}="${escLit(marker)}"`;
|
return `${rendered} data-wrn-bind-${bindIndex++}="${escLit(marker)}"`;
|
||||||
})
|
})
|
||||||
.join("");
|
.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.
|
// A `data-for` element introduces loop variables for its subtree.
|
||||||
const loops = loopVarsOf(node);
|
const loops = loopVarsOf(node);
|
||||||
const childCtx =
|
const childCtx =
|
||||||
loops.length > 0 ? { ...ctx, loopVars: new Set([...(ctx.loopVars ?? []), ...loops]) } : ctx;
|
loops.length > 0 ? { ...ctx, loopVars: new Set([...(ctx.loopVars ?? []), ...loops]) } : ctx;
|
||||||
|
|
||||||
if (VOID_ELEMENTS.has(node.tag.toLowerCase())) return `<${node.tag}${attrs}>`;
|
const allAttrs = `${classAttribute}${classBindings}${attrs}`;
|
||||||
const inner = node.children.map((c) => renderComponentNode(c, childCtx)).join("");
|
|
||||||
return `<${node.tag}${attrs}>${inner}</${node.tag}>`;
|
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}</${node.tag}>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateComponent(ast: PageAst): string {
|
function generateComponent(ast: PageAst): string {
|
||||||
|
|||||||
@@ -386,7 +386,14 @@ export function parseHtmlView(src: string, pos: number): { nodes: ViewNode[]; en
|
|||||||
let i = pos;
|
let i = pos;
|
||||||
|
|
||||||
const isNameStart = (c: string): boolean => /[A-Za-z_]/.test(c);
|
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 isWs = (c: string): boolean => c === " " || c === "\t" || c === "\n" || c === "\r";
|
||||||
|
|
||||||
const fail = (msg: string): never => {
|
const fail = (msg: string): never => {
|
||||||
@@ -425,7 +432,9 @@ export function parseHtmlView(src: string, pos: number): { nodes: ViewNode[]; en
|
|||||||
const readName = (): string => {
|
const readName = (): string => {
|
||||||
if (i >= src.length || !isNameStart(src[i]!)) return fail("Expected a tag or attribute name");
|
if (i >= src.length || !isNameStart(src[i]!)) return fail("Expected a tag or attribute name");
|
||||||
const start = i++;
|
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);
|
return src.slice(start, i);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// Reactive ordinary attributes emitted by the compiler. Each marker stores
|
||||||
// [attributeName, originalTemplate], preserving an SSR value while allowing
|
// [attributeName, originalTemplate], preserving an SSR value while allowing
|
||||||
// state changes to update type, aria-*, class, href, and other attributes.
|
// state changes to update type, aria-*, class, href, and other attributes.
|
||||||
|
|||||||
@@ -218,9 +218,19 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
|||||||
// modules, rescan file routes, and ask browsers to morph in fresh HTML.
|
// modules, rescan file routes, and ask browsers to morph in fresh HTML.
|
||||||
if (hmr && hub) {
|
if (hmr && hub) {
|
||||||
const hotUpdate = async (files: string[]): Promise<void> => {
|
const hotUpdate = async (files: string[]): Promise<void> => {
|
||||||
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();
|
middleware.invalidate();
|
||||||
|
|
||||||
if (files.some((file) => file === "schemas" || file.startsWith("schemas/"))) {
|
if (files.some((file) => file === "schemas" || file.startsWith("schemas/"))) {
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function startWatcher(opts: WatchOptions): FSWatcher | undefined {
|
|||||||
pendingFiles.add(rel);
|
pendingFiles.add(rel);
|
||||||
pending.add(classify(rel));
|
pending.add(classify(rel));
|
||||||
if (timer) clearTimeout(timer);
|
if (timer) clearTimeout(timer);
|
||||||
timer = setTimeout(flush, 60); // debounce editor write bursts
|
timer = setTimeout(flush, 200); // debounce editor write bursts
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[wrnexus] file watching unavailable; HMR disabled", err);
|
console.warn("[wrnexus] file watching unavailable; HMR disabled", err);
|
||||||
|
|||||||
Reference in New Issue
Block a user