release: WRNexusJS 0.3.5
This commit is contained in:
@@ -163,6 +163,7 @@ A file opens with `page <Name>` or `component <Name>` followed by a `{ ... }` bo
|
||||
- `layout = "<name>"` — selects `app/layouts/<name>.wrn` (pages only).
|
||||
- `types { <TypeScript declarations> }` — reusable interfaces and aliases for the current file.
|
||||
- `props { name: Type = <default> ... }` — typed component props. Omit `= <default>` to make a prop required. Legacy inferred props remain supported.
|
||||
- `@event name = function` inside `props` — declares a public component event. Emit it from component behavior with `name(detail)` or `$emit("name", detail)`, and consume it with `<Component @name="handler(event)" />`.
|
||||
- `state <ident>: Type = <expr>` — typed reactive state seeded from a raw JS expression. The annotation is optional for backward compatibility.
|
||||
- `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`.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/compiler",
|
||||
"version": "0.3.4",
|
||||
"version": "0.3.5",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -43,6 +43,38 @@ function isComponentTag(tag: string): boolean {
|
||||
return /^[A-Z][A-Za-z0-9_$]*$/.test(tag);
|
||||
}
|
||||
|
||||
const HTML_BOOLEAN_ATTRIBUTES = new Set([
|
||||
"allowfullscreen",
|
||||
"async",
|
||||
"autofocus",
|
||||
"autoplay",
|
||||
"checked",
|
||||
"controls",
|
||||
"default",
|
||||
"defer",
|
||||
"disabled",
|
||||
"formnovalidate",
|
||||
"hidden",
|
||||
"inert",
|
||||
"ismap",
|
||||
"itemscope",
|
||||
"loop",
|
||||
"multiple",
|
||||
"muted",
|
||||
"nomodule",
|
||||
"novalidate",
|
||||
"open",
|
||||
"playsinline",
|
||||
"readonly",
|
||||
"required",
|
||||
"reversed",
|
||||
"selected",
|
||||
]);
|
||||
|
||||
function isHtmlBooleanAttribute(name: string): boolean {
|
||||
return HTML_BOOLEAN_ATTRIBUTES.has(name.toLowerCase());
|
||||
}
|
||||
|
||||
/** Escape a value placed inside a double-quoted HTML attribute. */
|
||||
function attrEscape(value: string): string {
|
||||
return value
|
||||
@@ -116,13 +148,29 @@ function reactiveAttrValue(raw: string, reactive: PageReactive): string | null {
|
||||
return found ? value : null;
|
||||
}
|
||||
|
||||
function renderAttrs(attrs: Attr[], csrId?: string, reactive: PageReactive | null = null): string {
|
||||
function renderAttrs(
|
||||
attrs: Attr[],
|
||||
csrId?: string,
|
||||
reactive: PageReactive | null = null,
|
||||
dynamicExpressions?: string[],
|
||||
): 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 expression = wholeAttributeExpression(attr.value);
|
||||
if (
|
||||
expression &&
|
||||
exprRefsState(expression, reactive.runtimeStateNames) &&
|
||||
dynamicExpressions
|
||||
) {
|
||||
dynamicExpressions.push(`\${__wrnexusPropAttr(${expression})}`);
|
||||
const sentinel = `\x00WRNEACH${dynamicExpressions.length - 1}\x00`;
|
||||
const marker = JSON.stringify([attr.name, attr.value]);
|
||||
return ` ${attr.name}="${sentinel}" data-wrn-bind-${bindIndex++}="${attrEscape(marker)}"`;
|
||||
}
|
||||
const initial = reactiveAttrValue(attr.value, reactive);
|
||||
if (initial === null) return base;
|
||||
const marker = JSON.stringify([attr.name, attr.value]);
|
||||
@@ -151,6 +199,7 @@ function htmlTextEscape(value: string): string {
|
||||
/** Reactive page context: state names + their initial (SSR) values. */
|
||||
interface PageReactive {
|
||||
stateNames: Set<string>;
|
||||
runtimeStateNames: Set<string>;
|
||||
scope: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -401,7 +450,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, reactive)}>`;
|
||||
return `<${node.tag}${renderAttrs(node.attrs, csrId, reactive, loops)}>`;
|
||||
}
|
||||
|
||||
const inner =
|
||||
@@ -420,7 +469,7 @@ function renderNode(
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `<${node.tag}${renderAttrs(node.attrs, csrId, reactive)}>${inner}</${node.tag}>`;
|
||||
return `<${node.tag}${renderAttrs(node.attrs, csrId, reactive, loops)}>${inner}</${node.tag}>`;
|
||||
}
|
||||
|
||||
function renderPageComponentInvocation(
|
||||
@@ -452,6 +501,11 @@ function renderNestedComponentInvocation(
|
||||
const attrs = node.attrs
|
||||
.filter((attr) => attr.name !== "data-component")
|
||||
.map((attr) => {
|
||||
const spread = /^\{\.\.\.([A-Za-z_$][\w$]*)\}$/.exec(attr.name);
|
||||
if (spread) {
|
||||
return `\${__wireSpreadAttrs(${ctx.resolveExpr(spread[1]!)})}`;
|
||||
}
|
||||
|
||||
if (attr.event) {
|
||||
return (
|
||||
escLit(` ${eventAttribute(attr.name)}="`) + escLit(attrEscape(attr.value)) + escLit(`"`)
|
||||
@@ -490,13 +544,18 @@ function renderNestedComponentInvocation(
|
||||
loops.length > 0
|
||||
? {
|
||||
...ctx,
|
||||
forwardRestAttrs: false,
|
||||
loopVars: new Set([...(ctx.loopVars ?? []), ...loops]),
|
||||
}
|
||||
: ctx;
|
||||
: { ...ctx, forwardRestAttrs: false };
|
||||
|
||||
const inner = node.children.map((child) => renderComponentNode(child, childCtx)).join("");
|
||||
|
||||
return `<div data-component="${attrEscape(node.tag)}"${attrs}>${inner}</div>`;
|
||||
return (
|
||||
`<div data-component="${attrEscape(node.tag)}"` +
|
||||
`${ctx.forwardRestAttrs ? "${__wireSpreadAttrs(__attrs)}" : ""}` +
|
||||
`${attrs}>${inner}</div>`
|
||||
);
|
||||
}
|
||||
|
||||
function ssrMarker(bindings: SsrBinding[], binding: RenderBinding): string {
|
||||
@@ -668,6 +727,7 @@ function hydrationId(ast: PageAst): string {
|
||||
kind: ast.kind,
|
||||
name: ast.name,
|
||||
props: ast.props.map((entry) => entry.name),
|
||||
events: ast.events.map((entry) => entry.name),
|
||||
states: ast.states.map((entry) => entry.name),
|
||||
computed: ast.computed.map((entry) => entry.name),
|
||||
view: ast.view,
|
||||
@@ -744,8 +804,13 @@ export function generate(ast: PageAst): string {
|
||||
...ast.states.map((entry) => entry.name),
|
||||
...ast.computed.map((entry) => entry.name),
|
||||
];
|
||||
const runtimeStateNames = new Set(
|
||||
ast.states.filter((entry) => /\bctx\b/.test(entry.expr)).map((entry) => entry.name),
|
||||
);
|
||||
const reactive: PageReactive | null =
|
||||
reactiveNames.length > 0 ? { stateNames: new Set(reactiveNames), scope: seedScope } : null;
|
||||
reactiveNames.length > 0
|
||||
? { stateNames: new Set(reactiveNames), runtimeStateNames, scope: seedScope }
|
||||
: null;
|
||||
const loops: string[] = [];
|
||||
let html = ast.view
|
||||
.map((node) => renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive))
|
||||
@@ -955,6 +1020,10 @@ interface CompCtx {
|
||||
loopVars?: Set<string>;
|
||||
/** Local identifiers introduced by server-rendered `{#each}` blocks. */
|
||||
serverLocals?: Set<string>;
|
||||
/** Forward undeclared component attributes to this element only. */
|
||||
forwardRestAttrs?: boolean;
|
||||
/** Public component events exposed from the component root. */
|
||||
eventNames?: string[];
|
||||
}
|
||||
|
||||
interface ComponentBehavior {
|
||||
@@ -1138,6 +1207,22 @@ function viewHasServerEach(nodes: ViewNode[]): boolean {
|
||||
});
|
||||
}
|
||||
|
||||
function viewHasRestAttributeSpread(nodes: ViewNode[]): boolean {
|
||||
return nodes.some((node) => {
|
||||
if (node.type === "text") return false;
|
||||
if (node.type === "each") {
|
||||
return viewHasRestAttributeSpread(node.body) || viewHasRestAttributeSpread(node.empty);
|
||||
}
|
||||
if (node.type === "if") {
|
||||
return node.branches.some((branch) => viewHasRestAttributeSpread(branch.body));
|
||||
}
|
||||
return (
|
||||
node.attrs.some((attr) => /^\{\.\.\.([A-Za-z_$][\w$]*)\}$/.test(attr.name)) ||
|
||||
viewHasRestAttributeSpread(node.children)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile a text node. Interpolations that reference state stay as client
|
||||
* mustaches (`{expr}`, hydrated by the reactive runtime); interpolations of
|
||||
@@ -1318,13 +1403,13 @@ function renderComponentNode(node: ViewNode, ctx: CompCtx): string {
|
||||
|
||||
const loopVariables = loopVarsOf(node);
|
||||
|
||||
const elementContext =
|
||||
loopVariables.length > 0
|
||||
? {
|
||||
...ctx,
|
||||
loopVars: new Set([...(ctx.loopVars ?? []), ...loopVariables]),
|
||||
}
|
||||
: ctx;
|
||||
const elementContext = {
|
||||
...ctx,
|
||||
forwardRestAttrs: false,
|
||||
...(loopVariables.length > 0
|
||||
? { loopVars: new Set([...(ctx.loopVars ?? []), ...loopVariables]) }
|
||||
: {}),
|
||||
};
|
||||
|
||||
let bindIndex = 0;
|
||||
const staticClasses: string[] = [];
|
||||
@@ -1349,6 +1434,11 @@ function renderComponentNode(node: ViewNode, ctx: CompCtx): string {
|
||||
const attrs = node.attrs
|
||||
.filter((a) => a.name !== "class" && !a.name.startsWith("class:"))
|
||||
.map((a) => {
|
||||
const spread = /^\{\.\.\.([A-Za-z_$][\w$]*)\}$/.exec(a.name);
|
||||
if (spread) {
|
||||
return `\${__wireSpreadAttrs(${elementContext.resolveExpr(spread[1]!)})}`;
|
||||
}
|
||||
|
||||
if (a.event) {
|
||||
return ` ${eventAttribute(a.name)}="${escLit(attrEscape(a.value))}"`;
|
||||
}
|
||||
@@ -1357,6 +1447,29 @@ function renderComponentNode(node: ViewNode, ctx: CompCtx): string {
|
||||
return ` ${a.name}`;
|
||||
}
|
||||
|
||||
if (isHtmlBooleanAttribute(a.name)) {
|
||||
const expression = wholeAttributeExpression(a.value);
|
||||
if (expression) {
|
||||
const referencesState = exprRefsState(a.value, ctx.stateNames);
|
||||
const referencesLoopVariable = elementContext.loopVars
|
||||
? exprRefsState(a.value, elementContext.loopVars)
|
||||
: false;
|
||||
const referencesServerLocal = ctx.serverLocals
|
||||
? exprRefsState(a.value, ctx.serverLocals)
|
||||
: false;
|
||||
const marker =
|
||||
referencesState || referencesLoopVariable || referencesServerLocal
|
||||
? ` data-wrn-bind-${bindIndex++}="${escLit(
|
||||
attrEscape(JSON.stringify([a.name, a.value])),
|
||||
)}"`
|
||||
: "";
|
||||
return `\${__wireBooleanAttr(${JSON.stringify(a.name)}, ${elementContext.resolveExpr(expression)})}${marker}`;
|
||||
}
|
||||
|
||||
if (a.value === "false") return "";
|
||||
if (a.value === "true" || a.value === "") return ` ${a.name}`;
|
||||
}
|
||||
|
||||
const rendered = ` ${a.name}="${compileAttrValue(a.value, elementContext)}"`;
|
||||
|
||||
const referencesState = exprRefsState(a.value, ctx.stateNames);
|
||||
@@ -1433,6 +1546,10 @@ function renderComponentNode(node: ViewNode, ctx: CompCtx): string {
|
||||
|
||||
const allAttrs =
|
||||
`${loopLocalsAttribute}` +
|
||||
`${ctx.forwardRestAttrs ? "${__wireSpreadAttrs(__attrs)}" : ""}` +
|
||||
`${
|
||||
ctx.eventNames?.length ? ` data-wrn-events="${attrEscape(ctx.eventNames.join(","))}"` : ""
|
||||
}` +
|
||||
`${classAttribute}` +
|
||||
`${classReactiveBinding}` +
|
||||
`${classBindings}` +
|
||||
@@ -1477,6 +1594,9 @@ function generateComponent(ast: PageAst): string {
|
||||
for (const p of effectiveProps) {
|
||||
nameRefs.set(p.name, safeRef(p.name));
|
||||
}
|
||||
if (!nameRefs.has("attrs")) {
|
||||
nameRefs.set("attrs", "__attrs");
|
||||
}
|
||||
for (const s of ast.states) nameRefs.set(s.name, safeRef(s.name));
|
||||
for (const entry of ast.computed) nameRefs.set(entry.name, safeRef(entry.name));
|
||||
const resolveExpr = (expr: string): string => {
|
||||
@@ -1486,14 +1606,33 @@ function generateComponent(ast: PageAst): string {
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const ctx: CompCtx = { stateNames, resolveExpr };
|
||||
const ctx: CompCtx = {
|
||||
stateNames,
|
||||
resolveExpr,
|
||||
eventNames: ast.events.map((event) => event.name),
|
||||
};
|
||||
|
||||
const serverFunctions = ast.functions
|
||||
.map((body) => body.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
|
||||
const viewCode = ast.view.map((node) => renderComponentNode(node, ctx)).join("");
|
||||
const hasExplicitRestSpread = viewHasRestAttributeSpread(ast.view);
|
||||
const rootElementIndex = ast.view.findIndex((node) => node.type === "element");
|
||||
const automaticallyForwardRootAttrs =
|
||||
!hasExplicitRestSpread &&
|
||||
!effectiveProps.some((prop) => prop.name === "attrs") &&
|
||||
rootElementIndex >= 0;
|
||||
const viewCode = ast.view
|
||||
.map((node, index) =>
|
||||
renderComponentNode(
|
||||
node,
|
||||
automaticallyForwardRootAttrs && index === rootElementIndex
|
||||
? { ...ctx, forwardRestAttrs: true }
|
||||
: ctx,
|
||||
),
|
||||
)
|
||||
.join("");
|
||||
const styles = ast.styles.map((body) => body.trim()).filter(Boolean);
|
||||
const styleTag =
|
||||
styles.length > 0
|
||||
@@ -1534,6 +1673,11 @@ function generateComponent(ast: PageAst): string {
|
||||
` const ${nameRefs.get(prop.name)}: ${prop.valueType ?? "any"} = __coerce(__p[${JSON.stringify(prop.name)}], (${resolveExpr(prop.default)}), ${JSON.stringify(runtimeTypeOf(prop.valueType))});`,
|
||||
);
|
||||
}
|
||||
if (!effectiveProps.some((prop) => prop.name === "attrs")) {
|
||||
decls.push(
|
||||
` const __attrs = __restProps(__p, new Set(${JSON.stringify(effectiveProps.map((prop) => prop.name))}));`,
|
||||
);
|
||||
}
|
||||
for (const state of ast.states) {
|
||||
decls.push(
|
||||
` let ${nameRefs.get(state.name)}${state.valueType ? `: ${state.valueType}` : ""} = (${resolveExpr(state.expr)});`,
|
||||
@@ -1584,7 +1728,7 @@ function generateComponent(ast: PageAst): string {
|
||||
|
||||
if (effectiveProps.length > 0) {
|
||||
out.push(
|
||||
`export interface ${ast.name}Props {\n${effectiveProps
|
||||
`export interface ${ast.name}Props {\n [attribute: string]: unknown;\n${effectiveProps
|
||||
.map(
|
||||
(prop) =>
|
||||
` ${JSON.stringify(prop.name)}${prop.required ? "" : "?"}: ${prop.valueType ?? "unknown"};`,
|
||||
@@ -1664,6 +1808,15 @@ function generateComponent(ast: PageAst): string {
|
||||
return declared === "unknown" && def === undefined ? v : String(v);
|
||||
}
|
||||
|
||||
function __restProps(
|
||||
props: Record<string, any>,
|
||||
declared: Set<string>,
|
||||
): Record<string, any> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(props).filter(([name]) => !declared.has(name)),
|
||||
);
|
||||
}
|
||||
|
||||
function __wireHtml(v: any): string {
|
||||
return String(v == null ? "" : v).replace(
|
||||
/[&<>]/g,
|
||||
@@ -1690,6 +1843,48 @@ function __wireAttr(v: any): string {
|
||||
);
|
||||
}
|
||||
|
||||
function __wireBooleanAttr(name: string, value: any): string {
|
||||
return value === true ||
|
||||
value === "true" ||
|
||||
value === "" ||
|
||||
value === 1 ||
|
||||
value === "1" ||
|
||||
value === name
|
||||
? " " + name
|
||||
: "";
|
||||
}
|
||||
|
||||
function __wireSpreadAttrs(value: any): string {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) return "";
|
||||
|
||||
const booleanAttributes = new Set(${JSON.stringify([...HTML_BOOLEAN_ATTRIBUTES])});
|
||||
const attributes: string[] = [];
|
||||
|
||||
for (const [name, raw] of Object.entries(value)) {
|
||||
const lowerName = name.toLowerCase();
|
||||
if (
|
||||
!/^[A-Za-z_:][A-Za-z0-9_.:-]*$/.test(name) ||
|
||||
lowerName.startsWith("on") ||
|
||||
lowerName === "style" ||
|
||||
lowerName === "slot" ||
|
||||
lowerName === "data-component" ||
|
||||
lowerName.startsWith("data-wrn")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (booleanAttributes.has(lowerName)) {
|
||||
attributes.push(__wireBooleanAttr(name, raw));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (raw === false || raw === null || raw === undefined) continue;
|
||||
attributes.push(" " + name + '="' + __wireAttr(raw) + '"');
|
||||
}
|
||||
|
||||
return attributes.join("");
|
||||
}
|
||||
|
||||
function __wireProp(v: any): string {
|
||||
const value =
|
||||
v !== null && typeof v === "object"
|
||||
|
||||
@@ -38,6 +38,7 @@ export type {
|
||||
DataApiBlock,
|
||||
DataMode,
|
||||
EffectBlock,
|
||||
EventDecl,
|
||||
LoadBlock,
|
||||
ModeFunctionsBlock,
|
||||
PageAst,
|
||||
|
||||
@@ -80,6 +80,24 @@ test("parses a component with props and state", () => {
|
||||
expect(ast.states.map((s) => s.name)).toEqual(["count"]);
|
||||
});
|
||||
|
||||
test("component event declarations compile to public root metadata", async () => {
|
||||
const source = `component EventButton {
|
||||
props {
|
||||
label = "Save"
|
||||
@event complete = function
|
||||
}
|
||||
view { <button>{label}</button> }
|
||||
}`;
|
||||
const ast = parse(source);
|
||||
const output = generate(ast);
|
||||
const mod = await compileAndImport(source);
|
||||
const render = mod.render as (props: Record<string, unknown>) => string;
|
||||
|
||||
expect(ast.events).toEqual([{ name: "complete" }]);
|
||||
expect(output).toContain('data-wrn-events="complete"');
|
||||
expect(render({})).toContain('data-wrn-events="complete"');
|
||||
});
|
||||
|
||||
test("typed props, required props, state, custom types, and function parameters compile", () => {
|
||||
const source = `component TypedPicker {
|
||||
types {
|
||||
@@ -169,6 +187,89 @@ test("HTML view: void elements, boolean attrs, comments, lone <", () => {
|
||||
void ast;
|
||||
});
|
||||
|
||||
test("dynamic HTML boolean attributes are omitted when false", async () => {
|
||||
const mod = await compileAndImport(`component BooleanAttributes {
|
||||
props {
|
||||
disabled = false
|
||||
checked = false
|
||||
required = false
|
||||
loading = false
|
||||
}
|
||||
view {
|
||||
<button disabled="{disabled || loading}">Save</button>
|
||||
<input checked="{checked}" required="{required}" />
|
||||
}
|
||||
}`);
|
||||
const render = mod.render as (props?: Record<string, unknown>) => string;
|
||||
|
||||
const enabled = render();
|
||||
expect(enabled).not.toContain(" disabled");
|
||||
expect(enabled).not.toContain(" checked");
|
||||
expect(enabled).not.toContain(" required");
|
||||
|
||||
const disabled = render({ disabled: "true", checked: "true", required: "true" });
|
||||
expect(disabled).toContain("<button disabled>");
|
||||
expect(disabled).toContain("<input checked required>");
|
||||
});
|
||||
|
||||
test("components safely forward undeclared HTML attributes to their root", async () => {
|
||||
const mod = await compileAndImport(`component ForwardingButton {
|
||||
props {
|
||||
label = "Button"
|
||||
variant = "default"
|
||||
disabled = false
|
||||
}
|
||||
view {
|
||||
<button disabled="{disabled}">{label}</button>
|
||||
}
|
||||
}`);
|
||||
const render = mod.render as (props?: Record<string, unknown>) => string;
|
||||
const html = render({
|
||||
label: "Save",
|
||||
variant: "primary",
|
||||
formaction: "/submit-form",
|
||||
formenctype: "application/x-www-form-urlencoded",
|
||||
formmethod: "post",
|
||||
popovertarget: "myPopover",
|
||||
"aria-describedby": "save-help",
|
||||
"data-testid": "save",
|
||||
onclick: "alert(1)",
|
||||
style: "display:none",
|
||||
"data-wrn-bind-0": "unsafe",
|
||||
});
|
||||
|
||||
expect(html).toContain('formaction="/submit-form"');
|
||||
expect(html).toContain('formenctype="application/x-www-form-urlencoded"');
|
||||
expect(html).toContain('formmethod="post"');
|
||||
expect(html).toContain('popovertarget="myPopover"');
|
||||
expect(html).toContain('aria-describedby="save-help"');
|
||||
expect(html).toContain('data-testid="save"');
|
||||
expect(html).not.toContain("variant=");
|
||||
expect(html).not.toContain("onclick=");
|
||||
expect(html).not.toContain("style=");
|
||||
expect(html).not.toContain("data-wrn-bind");
|
||||
});
|
||||
|
||||
test("an explicit attrs spread overrides automatic root forwarding", async () => {
|
||||
const mod = await compileAndImport(`component WrappedControl {
|
||||
props {
|
||||
label = "Control"
|
||||
class = ""
|
||||
}
|
||||
view {
|
||||
<span class="{class}">
|
||||
<button {...attrs}>{label}</button>
|
||||
</span>
|
||||
}
|
||||
}`);
|
||||
const render = mod.render as (props?: Record<string, unknown>) => string;
|
||||
const html = render({ label: "Save", class: "wrapper", formaction: "/save" });
|
||||
|
||||
expect(html).toContain('<span class="wrapper">');
|
||||
expect(html).not.toContain('<span formaction="/save"');
|
||||
expect(html).toContain('<button formaction="/save">Save</button>');
|
||||
});
|
||||
|
||||
test("stateless component bakes props into server HTML (zero JS)", async () => {
|
||||
const mod = await compileAndImport(
|
||||
`component Button {\n props {\n label = "Button"\n variant = "default"\n class = ""\n }\n view { <button class="wire-btn wire-btn--{variant} {class}">{label}</button> }\n}`,
|
||||
@@ -260,6 +361,24 @@ test("page state attributes bake their initial value and retain a reactive bindi
|
||||
expect(page).toContain("show ? 'text' : 'password'");
|
||||
});
|
||||
|
||||
test("request-dependent page state attributes resolve during server rendering", async () => {
|
||||
const mod = await compileAndImport(`page Playground {
|
||||
state label = ctx.url.searchParams.get("label") ?? "Default"
|
||||
state loading = ctx.url.searchParams.get("loading") ?? "false"
|
||||
view {
|
||||
<div data-component="Button" label="{label}" loading="{loading}"></div>
|
||||
}
|
||||
}`);
|
||||
const render = mod.default as (ctx: { url: URL }) => Promise<string>;
|
||||
const html = await render({
|
||||
url: new URL("https://example.test/playground?label=Visible&loading=false"),
|
||||
});
|
||||
|
||||
expect(html).toContain('label="Visible"');
|
||||
expect(html).toContain('loading="false"');
|
||||
expect(html).not.toContain('label=""');
|
||||
});
|
||||
|
||||
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}`,
|
||||
|
||||
Reference in New Issue
Block a user