diff --git a/docs/public-api-0.8.json b/docs/public-api-0.8.json index e0691583..9a5c135c 100644 --- a/docs/public-api-0.8.json +++ b/docs/public-api-0.8.json @@ -2604,6 +2604,7 @@ "StylesMode", "THEME_COOKIE", "THEME_CSS_HREF", + "THEME_CSS_PREFIX", "THEME_JS_HREF", "THEME_PALETTES", "THEME_PALETTE_NAMES", @@ -2614,6 +2615,7 @@ "ThemeSemanticColor", "ThemeToken", "ThemeTokens", + "activeThemeCssHref", "auditCssPerformance", "auditWireTokens", "bundleCss", @@ -2629,6 +2631,7 @@ "loadEnv", "loadRawConfig", "normalizeStyleSources", + "renderActiveThemeCss", "renderFontHead", "renderProductionFontHead", "renderStyles", diff --git a/editors/vscode/src/compiler.cjs b/editors/vscode/src/compiler.cjs index a102f229..e4bf866f 100644 --- a/editors/vscode/src/compiler.cjs +++ b/editors/vscode/src/compiler.cjs @@ -1,6 +1,6 @@ "use strict"; // Generated by scripts/build-editor-compiler.mjs. Do not edit directly. -// WRN editor compiler source hash: 4f9249b868459eb792fcd6dc7814e440bd2c0ad6d08f1dec947939dc48db92b3 +// WRN editor compiler source hash: 196a514b18fb6e31fdd8f4ca667a76b0b62cab547af33725ba78564d70bbc01b // WRN editor compiler generator hash: c71e7fe4258c97b73b384ff14b321f0cf0b30cc2ed0322f5f84b04e757159b18 // Generated with TypeScript: 5.9.3 const __nodeRequire = require; @@ -1454,7 +1454,12 @@ function __wrnexusEscapeHtml(value: unknown): string { return String(value).replace(/[&<>"']/g, (ch) => __wrnexusHtmlEscapes[ch] ?? ch); } -function __wrnexusEvalData(data: unknown, body: string, helpers = "", ctx: any): unknown { +type __WrnexusContext = import("@wrnexus/core").Context & { + __wrnexusCallApi?: (path: string, method: string) => Promise; + localStorage?: unknown; +}; + +function __wrnexusEvalData(data: unknown, body: string, helpers = "", ctx: __WrnexusContext): unknown { const adapters = { cookies: ctx.cookies, session: ctx.session, @@ -1485,7 +1490,7 @@ function __wrnexusPropAttr( ); } -async function __wrnexusCallApi(path: string, method: string, ctx: any): Promise { +async function __wrnexusCallApi(path: string, method: string, ctx: __WrnexusContext): Promise { if (typeof ctx.__wrnexusCallApi === "function") { return await ctx.__wrnexusCallApi(path, method); } @@ -1500,7 +1505,7 @@ async function __wrnexusCallApi(path: string, method: string, ctx: any): Promise return type.includes("application/json") ? await res.json() : await res.text(); } -async function __wrnexusRenderSsrBindings(html: string, ctx: any): Promise { +async function __wrnexusRenderSsrBindings(html: string, ctx: __WrnexusContext): Promise { for (const binding of __wrnexusSsrBindings) { const data = await __wrnexusCallApi(binding.path, binding.method, ctx); const value = __wrnexusEvalData(data, binding.body, binding.helpers, ctx); @@ -1933,14 +1938,14 @@ function generate(ast) { 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) { + out.push(`export default async function ${ast.name}(ctx: __WrnexusContext) { ${storeDeclarations} ${serverLoadAliases} ${decls} const __state: ${stateType} = ${dynamicStateInitializer}; ${ssrStateAliases} ${ssrComputedAliases} - const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, (__state as any)[key]])); + const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, Reflect.get(__state, key)])); const __scopeValue = Object.entries(__hydrationState) .map(([key, value]) => { let encoded: string; @@ -1966,13 +1971,13 @@ function generate(ast) { }`); } else { - out.push(`export default ${storeBindings.length > 0 ? "async " : ""}function ${ast.name}(ctx: any) { + out.push(`export default ${storeBindings.length > 0 ? "async " : ""}function ${ast.name}(ctx: import("@wrnexus/core").Context) { ${storeDeclarations} ${serverLoadAliases} const __state: ${stateType} = ${dynamicStateInitializer}; ${ssrStateAliases} ${ssrComputedAliases} - const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, (__state as any)[key]])); + const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, Reflect.get(__state, key)])); const __scopeValue = Object.entries(__hydrationState) .map(([key, value]) => { let encoded: string; @@ -1996,14 +2001,14 @@ function generate(ast) { }`); } if (staticShellBody !== undefined) { - out.push(`export async function __wrnexusBuildStaticShell(ctx: any = {}) { + out.push(`export async function __wrnexusBuildStaticShell(ctx: import("@wrnexus/core").Context = {} as import("@wrnexus/core").Context) { ${storeDeclarations} ${serverLoadAliases} ${loopConsts.length > 0 ? loopConsts.join("\n") : ""} const __state: ${stateType} = ${dynamicStateInitializer}; ${ssrStateAliases} ${ssrComputedAliases} - const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, (__state as any)[key]])); + const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, Reflect.get(__state, key)])); const __scopeValue = Object.entries(__hydrationState) .map(([key, value]) => { let encoded: string; @@ -2053,7 +2058,7 @@ function generate(ast) { }) .join("\n"); const visible = exposed.filter((entry) => entry.name); - return `export async function ${exportName}(ctx: any) { + return `export async function ${exportName}(ctx: import("@wrnexus/core").Context) { ${exposed .filter((entry) => !entry.name) .map((entry) => entry.body) @@ -2076,7 +2081,7 @@ ${visible.length out.push(`export async function ${action.name}(${action.args.join(", ")}) {${action.body}}`); continue; } - out.push(`export async function ${action.name}(input: any, ctx: any) { + out.push(`export async function ${action.name}(input: InferSchema, ctx: import("@wrnexus/core").Context) { const invalidate = (...tags: string[]) => { const bucket = (ctx.locals.__wrnexusInvalidatedTags ??= []); bucket.push(...tags.flat()); @@ -2098,7 +2103,7 @@ ${ast.actions ast.apis.forEach((api, index) => { const name = `__wrnexusApi_${api.method}_${index}`; out.push(`// ${api.method} ${apiRoutePath(api.path)} -const ${name} = async (ctx: any) => {${api.body}};`); +const ${name} = async (ctx: import("@wrnexus/core").Context) => {${api.body}};`); }); const entries = ast.apis.map((api, index) => ` ${JSON.stringify(`${api.method} ${apiRoutePath(api.path)}`)}: __wrnexusApi_${api.method}_${index},`); out.push(`export const __wrnexusApi = {\n${entries.join("\n")}\n};`); @@ -2114,7 +2119,7 @@ const ${name} = async (ctx: any) => {${api.body}};`); if (ast.realtimes.length > 0) { const handlers = ast.realtimes.flatMap((rt) => rt.handlers.map((h) => { const params = ["ws", ...h.args].join(", "); - return ` ${h.event}(${params}: any) {${h.body}},`; + return ` ${h.event}(${params}: Event) {${h.body}},`; })); out.push(`export const websocket = {\n${handlers.join("\n")}\n};`); } @@ -2697,7 +2702,7 @@ function generateComponent(ast) { if (prop.required) { decls.push(` if (__p[${JSON.stringify(prop.name)}] === undefined) throw new TypeError(${JSON.stringify(`${ast.name} requires prop '${prop.name}' (${prop.valueType ?? "unknown"})`)});`); } - decls.push(` const ${nameRefs.get(prop.name)}: ${prop.valueType ?? "any"} = __coerce(__p[${JSON.stringify(prop.name)}], (${resolveExpr(prop.default)}), ${JSON.stringify((0, types_ts_1.runtimeTypeOf)(prop.valueType))}, ${JSON.stringify(prop.name)});`); + decls.push(` const ${nameRefs.get(prop.name)}: ${prop.valueType ?? "unknown"} = __coerce(__p[${JSON.stringify(prop.name)}], (${resolveExpr(prop.default)}), ${JSON.stringify((0, types_ts_1.runtimeTypeOf)(prop.valueType))}, ${JSON.stringify(prop.name)}) as ${prop.valueType ?? "unknown"};`); } if (!effectiveProps.some((prop) => prop.name === "attrs")) { decls.push(` const __attrs = __restProps(__p, new Set(${JSON.stringify(effectiveProps.map((prop) => prop.name))}));`); @@ -2762,7 +2767,7 @@ function generateComponent(ast) { .map((output) => ` ${JSON.stringify(output.name)}(${output.payload ? `${output.payload.name}${output.payload.optional ? "?" : ""}: ${output.payload.valueType}` : ""}): void;`) .join("\n")}\n}`); } - out.push(`function __coerce(v: any, def: any, declared: string = "unknown", propName: string = "prop"): any { + out.push(`function __coerce(v: unknown, def: unknown, declared: string = "unknown", propName: string = "prop"): unknown { if (v === undefined || v === null) { return def; } @@ -2822,7 +2827,7 @@ function generateComponent(ast) { throw new TypeError("Expected an object prop '" + propName + "'"); } - if (declared === "bigint") return BigInt(v); + if (declared === "bigint") return BigInt(v as string | number | bigint | boolean); if (declared === "function" && typeof v !== "function") { throw new TypeError("Expected a function prop"); } @@ -2830,15 +2835,15 @@ function generateComponent(ast) { } function __restProps( - props: Record, + props: Record, declared: Set, -): Record { +): Record { return Object.fromEntries( Object.entries(props).filter(([name]) => !declared.has(name)), ); } -function __wireHtml(v: any): string { +function __wireHtml(v: unknown): string { return String(v == null ? "" : v).replace( /[&<>]/g, (c) => @@ -2850,7 +2855,7 @@ function __wireHtml(v: any): string { ); } -function __wireAttr(v: any): string { +function __wireAttr(v: unknown): string { return String(v == null ? "" : v).replace( /[&<>"]/g, (c) => @@ -2864,7 +2869,7 @@ function __wireAttr(v: any): string { ); } -function __wireBooleanAttr(name: string, value: any): string { +function __wireBooleanAttr(name: string, value: unknown): string { return value === true || value === "true" || value === "" || @@ -2875,7 +2880,7 @@ function __wireBooleanAttr(name: string, value: any): string { : ""; } -function __wireSpreadAttrs(value: any): string { +function __wireSpreadAttrs(value: unknown): string { if (value === null || typeof value !== "object" || Array.isArray(value)) return ""; const booleanAttributes = new Set(${JSON.stringify([...HTML_BOOLEAN_ATTRIBUTES])}); @@ -2911,7 +2916,7 @@ function __wireSpreadAttrs(value: any): string { return attributes.join(""); } -function __wireProp(v: any): string { +function __wireProp(v: unknown): string { const value = v !== null && typeof v === "object" ? JSON.stringify(v) @@ -2920,16 +2925,16 @@ function __wireProp(v: any): string { return __wireAttr(value); } -function __wireRaw(v: any): string { +function __wireRaw(v: unknown): string { return String(v == null ? "" : v); }`); if (hasServerEach) { - out.push(`function __wrnexusEncodeLoopLocals(value: Record): string { + out.push(`function __wrnexusEncodeLoopLocals(value: Record): string { return __WrnexusBuffer.from(JSON.stringify(value), "utf8").toString("base64"); }`); } if (needsScope) { - out.push(`function __wrnexusSerializeScopeValue(value: any): string { + out.push(`function __wrnexusSerializeScopeValue(value: unknown): string { if (value === undefined) { return "undefined"; } @@ -2963,7 +2968,7 @@ function __wireRaw(v: any): string { } } - function __wrnexusScopeDecl(obj: Record): string { + function __wrnexusScopeDecl(obj: Record): string { return Object.keys(obj) .map( (key) => @@ -2981,7 +2986,7 @@ function __wireRaw(v: any): string { }`); } const serverFunctionSource = serverFunctions ? `${serverFunctions}\n` : ""; - out.push(`export function render(props: ${effectiveProps.length > 0 ? `${ast.name}Props` : "Record"} = {} as ${effectiveProps.length > 0 ? `${ast.name}Props` : "Record"}): string {\n` + + out.push(`export function render(props: ${effectiveProps.length > 0 ? `${ast.name}Props` : "Record"} = {} as ${effectiveProps.length > 0 ? `${ast.name}Props` : "Record"}): string {\n` + ` const __p = props || {};\n` + (decls.length > 0 ? decls.join("\n") + "\n" : "") + serverFunctionSource + diff --git a/editors/vscode/src/extension.bundle.cjs b/editors/vscode/src/extension.bundle.cjs index 1c31bc6a..d8b5416a 100644 --- a/editors/vscode/src/extension.bundle.cjs +++ b/editors/vscode/src/extension.bundle.cjs @@ -1,4 +1,4 @@ -// WRN editor extension source hash: c09cc127dcc7fbae562648b09435fb96134c18e63ba21d7a4b476b916f4a8256 +// WRN editor extension source hash: 47188196a996fce0b8f1e2a4302e563767451d9b56df885abc60e9c925653b21 // WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728 "use strict"; var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports); @@ -23863,7 +23863,12 @@ function __wrnexusEscapeHtml(value: unknown): string { return String(value).replace(/[&<>"']/g, (ch) => __wrnexusHtmlEscapes[ch] ?? ch); } -function __wrnexusEvalData(data: unknown, body: string, helpers = "", ctx: any): unknown { +type __WrnexusContext = import("@wrnexus/core").Context & { + __wrnexusCallApi?: (path: string, method: string) => Promise; + localStorage?: unknown; +}; + +function __wrnexusEvalData(data: unknown, body: string, helpers = "", ctx: __WrnexusContext): unknown { const adapters = { cookies: ctx.cookies, session: ctx.session, @@ -23894,7 +23899,7 @@ function __wrnexusPropAttr( ); } -async function __wrnexusCallApi(path: string, method: string, ctx: any): Promise { +async function __wrnexusCallApi(path: string, method: string, ctx: __WrnexusContext): Promise { if (typeof ctx.__wrnexusCallApi === "function") { return await ctx.__wrnexusCallApi(path, method); } @@ -23909,7 +23914,7 @@ async function __wrnexusCallApi(path: string, method: string, ctx: any): Promise return type.includes("application/json") ? await res.json() : await res.text(); } -async function __wrnexusRenderSsrBindings(html: string, ctx: any): Promise { +async function __wrnexusRenderSsrBindings(html: string, ctx: __WrnexusContext): Promise { for (const binding of __wrnexusSsrBindings) { const data = await __wrnexusCallApi(binding.path, binding.method, ctx); const value = __wrnexusEvalData(data, binding.body, binding.helpers, ctx); @@ -24300,14 +24305,14 @@ ${helpers}`); const decls = loopConsts.length > 0 ? loopConsts.join(` `) + ` ` : ""; - out.push(`export default async function ${ast.name}(ctx: any) { + out.push(`export default async function ${ast.name}(ctx: __WrnexusContext) { ${storeDeclarations} ${serverLoadAliases} ${decls} const __state: ${stateType} = ${dynamicStateInitializer}; ${ssrStateAliases} ${ssrComputedAliases} - const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, (__state as any)[key]])); + const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, Reflect.get(__state, key)])); const __scopeValue = Object.entries(__hydrationState) .map(([key, value]) => { let encoded: string; @@ -24332,13 +24337,13 @@ ${helpers}`); return await __wrnexusRenderSsrBindings(html, ctx); }`); } else { - out.push(`export default ${storeBindings.length > 0 ? "async " : ""}function ${ast.name}(ctx: any) { + out.push(`export default ${storeBindings.length > 0 ? "async " : ""}function ${ast.name}(ctx: import("@wrnexus/core").Context) { ${storeDeclarations} ${serverLoadAliases} const __state: ${stateType} = ${dynamicStateInitializer}; ${ssrStateAliases} ${ssrComputedAliases} - const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, (__state as any)[key]])); + const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, Reflect.get(__state, key)])); const __scopeValue = Object.entries(__hydrationState) .map(([key, value]) => { let encoded: string; @@ -24362,7 +24367,7 @@ ${helpers}`); }`); } if (staticShellBody !== undefined) { - out.push(`export async function __wrnexusBuildStaticShell(ctx: any = {}) { + out.push(`export async function __wrnexusBuildStaticShell(ctx: import("@wrnexus/core").Context = {} as import("@wrnexus/core").Context) { ${storeDeclarations} ${serverLoadAliases} ${loopConsts.length > 0 ? loopConsts.join(` @@ -24370,7 +24375,7 @@ ${helpers}`); const __state: ${stateType} = ${dynamicStateInitializer}; ${ssrStateAliases} ${ssrComputedAliases} - const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, (__state as any)[key]])); + const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, Reflect.get(__state, key)])); const __scopeValue = Object.entries(__hydrationState) .map(([key, value]) => { let encoded: string; @@ -24417,7 +24422,7 @@ ${helpers}`); }).join(` `); const visible = exposed.filter((entry) => entry.name); - return `export async function ${exportName}(ctx: any) { + return `export async function ${exportName}(ctx: import("@wrnexus/core").Context) { ${exposed.filter((entry) => !entry.name).map((entry) => entry.body).join(` `)} ${declarations} @@ -24436,7 +24441,7 @@ ${visible.length ? ` const __values = await Promise.all([${visible.map((entry) out.push(`export async function ${action.name}(${action.args.join(", ")}) {${action.body}}`); continue; } - out.push(`export async function ${action.name}(input: any, ctx: any) { + out.push(`export async function ${action.name}(input: InferSchema, ctx: import("@wrnexus/core").Context) { const invalidate = (...tags: string[]) => { const bucket = (ctx.locals.__wrnexusInvalidatedTags ??= []); bucket.push(...tags.flat()); @@ -24454,7 +24459,7 @@ ${ast.actions.map((action) => ` ${action.name}: createActionClient<${action.sch ast.apis.forEach((api, index) => { const name = `__wrnexusApi_${api.method}_${index}`; out.push(`// ${api.method} ${apiRoutePath(api.path)} -const ${name} = async (ctx: any) => {${api.body}};`); +const ${name} = async (ctx: import("@wrnexus/core").Context) => {${api.body}};`); }); const entries = ast.apis.map((api, index) => ` ${JSON.stringify(`${api.method} ${apiRoutePath(api.path)}`)}: __wrnexusApi_${api.method}_${index},`); out.push(`export const __wrnexusApi = { @@ -24472,7 +24477,7 @@ ${entries.join(` if (ast.realtimes.length > 0) { const handlers = ast.realtimes.flatMap((rt) => rt.handlers.map((h) => { const params = ["ws", ...h.args].join(", "); - return ` ${h.event}(${params}: any) {${h.body}},`; + return ` ${h.event}(${params}: Event) {${h.body}},`; })); out.push(`export const websocket = { ${handlers.join(` @@ -24929,7 +24934,7 @@ ${handlers.join(` if (prop.required) { decls.push(` if (__p[${JSON.stringify(prop.name)}] === undefined) throw new TypeError(${JSON.stringify(`${ast.name} requires prop '${prop.name}' (${prop.valueType ?? "unknown"})`)});`); } - decls.push(` const ${nameRefs.get(prop.name)}: ${prop.valueType ?? "any"} = __coerce(__p[${JSON.stringify(prop.name)}], (${resolveExpr(prop.default)}), ${JSON.stringify((0, types_ts_1.runtimeTypeOf)(prop.valueType))}, ${JSON.stringify(prop.name)});`); + decls.push(` const ${nameRefs.get(prop.name)}: ${prop.valueType ?? "unknown"} = __coerce(__p[${JSON.stringify(prop.name)}], (${resolveExpr(prop.default)}), ${JSON.stringify((0, types_ts_1.runtimeTypeOf)(prop.valueType))}, ${JSON.stringify(prop.name)}) as ${prop.valueType ?? "unknown"};`); } if (!effectiveProps.some((prop) => prop.name === "attrs")) { decls.push(` const __attrs = __restProps(__p, new Set(${JSON.stringify(effectiveProps.map((prop) => prop.name))}));`); @@ -24989,7 +24994,7 @@ ${ast.outputs.map((output) => ` ${JSON.stringify(output.name)}(${output.payload `)} }`); } - out.push(`function __coerce(v: any, def: any, declared: string = "unknown", propName: string = "prop"): any { + out.push(`function __coerce(v: unknown, def: unknown, declared: string = "unknown", propName: string = "prop"): unknown { if (v === undefined || v === null) { return def; } @@ -25049,7 +25054,7 @@ ${ast.outputs.map((output) => ` ${JSON.stringify(output.name)}(${output.payload throw new TypeError("Expected an object prop '" + propName + "'"); } - if (declared === "bigint") return BigInt(v); + if (declared === "bigint") return BigInt(v as string | number | bigint | boolean); if (declared === "function" && typeof v !== "function") { throw new TypeError("Expected a function prop"); } @@ -25057,15 +25062,15 @@ ${ast.outputs.map((output) => ` ${JSON.stringify(output.name)}(${output.payload } function __restProps( - props: Record, + props: Record, declared: Set, -): Record { +): Record { return Object.fromEntries( Object.entries(props).filter(([name]) => !declared.has(name)), ); } -function __wireHtml(v: any): string { +function __wireHtml(v: unknown): string { return String(v == null ? "" : v).replace( /[&<>]/g, (c) => @@ -25077,7 +25082,7 @@ function __wireHtml(v: any): string { ); } -function __wireAttr(v: any): string { +function __wireAttr(v: unknown): string { return String(v == null ? "" : v).replace( /[&<>"]/g, (c) => @@ -25091,7 +25096,7 @@ function __wireAttr(v: any): string { ); } -function __wireBooleanAttr(name: string, value: any): string { +function __wireBooleanAttr(name: string, value: unknown): string { return value === true || value === "true" || value === "" || @@ -25102,7 +25107,7 @@ function __wireBooleanAttr(name: string, value: any): string { : ""; } -function __wireSpreadAttrs(value: any): string { +function __wireSpreadAttrs(value: unknown): string { if (value === null || typeof value !== "object" || Array.isArray(value)) return ""; const booleanAttributes = new Set(${JSON.stringify([...HTML_BOOLEAN_ATTRIBUTES])}); @@ -25138,7 +25143,7 @@ function __wireSpreadAttrs(value: any): string { return attributes.join(""); } -function __wireProp(v: any): string { +function __wireProp(v: unknown): string { const value = v !== null && typeof v === "object" ? JSON.stringify(v) @@ -25147,16 +25152,16 @@ function __wireProp(v: any): string { return __wireAttr(value); } -function __wireRaw(v: any): string { +function __wireRaw(v: unknown): string { return String(v == null ? "" : v); }`); if (hasServerEach) { - out.push(`function __wrnexusEncodeLoopLocals(value: Record): string { + out.push(`function __wrnexusEncodeLoopLocals(value: Record): string { return __WrnexusBuffer.from(JSON.stringify(value), "utf8").toString("base64"); }`); } if (needsScope) { - out.push(`function __wrnexusSerializeScopeValue(value: any): string { + out.push(`function __wrnexusSerializeScopeValue(value: unknown): string { if (value === undefined) { return "undefined"; } @@ -25190,7 +25195,7 @@ function __wireRaw(v: any): string { } } - function __wrnexusScopeDecl(obj: Record): string { + function __wrnexusScopeDecl(obj: Record): string { return Object.keys(obj) .map( (key) => @@ -25209,7 +25214,7 @@ function __wireRaw(v: any): string { } const serverFunctionSource = serverFunctions ? `${serverFunctions} ` : ""; - out.push(`export function render(props: ${effectiveProps.length > 0 ? `${ast.name}Props` : "Record"} = {} as ${effectiveProps.length > 0 ? `${ast.name}Props` : "Record"}): string { + out.push(`export function render(props: ${effectiveProps.length > 0 ? `${ast.name}Props` : "Record"} = {} as ${effectiveProps.length > 0 ? `${ast.name}Props` : "Record"}): string { ` + ` const __p = props || {}; ` + (decls.length > 0 ? decls.join(` `) + ` diff --git a/examples/basic-app/app/example.test.ts b/examples/basic-app/app/example.test.ts index 1d1102eb..12c9b186 100644 --- a/examples/basic-app/app/example.test.ts +++ b/examples/basic-app/app/example.test.ts @@ -70,6 +70,19 @@ describe("full app", () => { expect(html).not.toContain(''); }); + test("home page ships only its active theme stylesheet", async () => { + const response = await app.fetch("/"); + const html = await response.text(); + const href = html.match(/data-wrnexus-theme href="([^"]+)"/)?.[1]; + expect(href).toContain("/__wrnexus/theme/dark/"); + expect(html).not.toContain('href="/__wrnexus/theme.css'); + const cssResponse = await app.fetch(String(href)); + expect(cssResponse.status).toBe(200); + const css = await cssResponse.text(); + expect(css.length).toBeLessThan(10_000); + expect(css).not.toContain("[data-theme="); + }); + test("GET /api/hello returns a translated greeting", async () => { const res = await app.fetch("/api/hello"); expect(res.status).toBe(200); diff --git a/package.json b/package.json index 4970616a..815a8622 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,6 @@ "check": "bun run typecheck && bun run lint && bun run check:component-imports && bun run test && bun run format:check", "auth:dev": "bun run --cwd examples/auth-showcase dev", "auth:check": "bun run --cwd packages/auth check", - "validate:0.6": "node scripts/validate-0.6.mjs", "validate:0.7": "node scripts/validate-0.7.mjs", "security:framework": "node scripts/security-performance-audit.mjs", "security:asvs": "node scripts/check-security-asvs.mjs", diff --git a/packages/captcha/test/validation-plugin.test.ts b/packages/captcha/test/validation-plugin.test.ts new file mode 100644 index 00000000..f9d8ec03 --- /dev/null +++ b/packages/captcha/test/validation-plugin.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test"; +import { createContext } from "@wrnexus/core"; +import { v } from "@wrnexus/validation"; +import { + captchaPlugin, + defineCaptchaProvider, + parseWithCaptcha, + type CaptchaProvider, +} from "../src/index.ts"; +import type { PluginContext, TransformContext } from "@wrnexus/plugin"; + +describe("CAPTCHA validation and development audit", () => { + test("combines schema and action-bound provider verification failures", async () => { + const requests: unknown[] = []; + const provider: CaptchaProvider = defineCaptchaProvider({ + name: "fixture", + client: { responseField: "captcha-response" }, + async verify(input) { + requests.push(input); + return { success: false, provider: "fixture", action: input.action, message: "Try again" }; + }, + }); + const ctx = createContext( + new Request("https://app.test/signup"), + new URL("https://app.test/signup"), + ); + const result = await parseWithCaptcha( + v.object({ email: v.string().email() }), + { email: "invalid", "captcha-response": "token" }, + ctx, + { action: "signup", provider, bindIp: true }, + ); + expect(result.ok).toBe(false); + expect(result.errors.email).toBeDefined(); + expect(result.errors["captcha-response"]).toBe("Try again"); + expect(requests[0]).toEqual( + expect.objectContaining({ action: "signup", hostname: "app.test", providerToken: "token" }), + ); + }); + + test("rejects incomplete custom providers", () => { + expect(() => + defineCaptchaProvider({ name: "", client: { responseField: "x" } } as never), + ).toThrow("stable name"); + expect(() => + defineCaptchaProvider({ name: "x", client: { responseField: "" } } as never), + ).toThrow("responseField"); + expect(() => + defineCaptchaProvider({ name: "x", client: { responseField: "token" } } as never), + ).toThrow("verify()"); + }); + + test("flags exposed secrets, missing provider keys, and inaccessible hard challenges", async () => { + const metadata = new Map(); + const plugin = captchaPlugin(); + const context = { + mode: "development", + file: "signup.wrn", + metadata, + } as TransformContext; + await plugin.transformCode?.( + ``, + context, + ); + const panels = await plugin.devToolbarPanels?.({ metadata } as PluginContext); + const ids = panels?.[0]?.issues?.map((issue) => String((issue as { id: string }).id)) ?? []; + expect(ids.some((id) => id.startsWith("client-secret:"))).toBe(true); + expect(ids.some((id) => id.startsWith("missing-site-key:"))).toBe(true); + expect(ids.some((id) => id.startsWith("hard-without-audio:"))).toBe(true); + }); +}); diff --git a/packages/cli/src/build.ts b/packages/cli/src/build.ts index 533f86e0..8866abf2 100644 --- a/packages/cli/src/build.ts +++ b/packages/cli/src/build.ts @@ -44,6 +44,7 @@ import { bundleCss, renderStyles, resolveThemeConfig, + renderActiveThemeCss, renderThemeCss, renderThemeRuntime, } from "@wrnexus/styles"; @@ -497,6 +498,19 @@ export async function runBuild(appRoot: string): Promise { const themeJs = renderThemeRuntime(theme); writeFileSync(join(distDir, "theme.css"), themeCss, "utf8"); writeFileSync(join(distDir, "theme.js"), themeJs, "utf8"); + const themeAssetsDir = join(distDir, "theme"); + for (const themeName of theme.names) { + const themeDir = join(themeAssetsDir, encodeURIComponent(themeName)); + mkdirSync(themeDir, { recursive: true }); + writeFileSync(join(themeDir, "_.css"), renderActiveThemeCss(theme, themeName), "utf8"); + for (const accentName of theme.accentNames) { + writeFileSync( + join(themeDir, `${encodeURIComponent(accentName)}.css`), + renderActiveThemeCss(theme, themeName, accentName), + "utf8", + ); + } + } assetHash.update(themeCss); assetHash.update(themeJs); console.log(`✓ Theme: ${theme.names.length} themes (default: ${theme.default})`); @@ -709,6 +723,7 @@ await createProductionServer( controllersPath: join(import.meta.dir, "controllers.js"), clientModulesDir: join(import.meta.dir, "client"), themePath: join(import.meta.dir, "theme.css"), + themeAssetsDir: join(import.meta.dir, "theme"), themeJsPath: join(import.meta.dir, "theme.js"), theme: ${JSON.stringify(theme)}, uiCssPath: join(import.meta.dir, "ui.css"), diff --git a/packages/compiler/src/codegen.ts b/packages/compiler/src/codegen.ts index fc58c974..03940dda 100644 --- a/packages/compiler/src/codegen.ts +++ b/packages/compiler/src/codegen.ts @@ -882,7 +882,12 @@ function __wrnexusEscapeHtml(value: unknown): string { return String(value).replace(/[&<>"']/g, (ch) => __wrnexusHtmlEscapes[ch] ?? ch); } -function __wrnexusEvalData(data: unknown, body: string, helpers = "", ctx: any): unknown { +type __WrnexusContext = import("@wrnexus/core").Context & { + __wrnexusCallApi?: (path: string, method: string) => Promise; + localStorage?: unknown; +}; + +function __wrnexusEvalData(data: unknown, body: string, helpers = "", ctx: __WrnexusContext): unknown { const adapters = { cookies: ctx.cookies, session: ctx.session, @@ -913,7 +918,7 @@ function __wrnexusPropAttr( ); } -async function __wrnexusCallApi(path: string, method: string, ctx: any): Promise { +async function __wrnexusCallApi(path: string, method: string, ctx: __WrnexusContext): Promise { if (typeof ctx.__wrnexusCallApi === "function") { return await ctx.__wrnexusCallApi(path, method); } @@ -928,7 +933,7 @@ async function __wrnexusCallApi(path: string, method: string, ctx: any): Promise return type.includes("application/json") ? await res.json() : await res.text(); } -async function __wrnexusRenderSsrBindings(html: string, ctx: any): Promise { +async function __wrnexusRenderSsrBindings(html: string, ctx: __WrnexusContext): Promise { for (const binding of __wrnexusSsrBindings) { const data = await __wrnexusCallApi(binding.path, binding.method, ctx); const value = __wrnexusEvalData(data, binding.body, binding.helpers, ctx); @@ -1438,14 +1443,14 @@ export function generate(ast: PageAst): string { 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) { + `export default async function ${ast.name}(ctx: __WrnexusContext) { ${storeDeclarations} ${serverLoadAliases} ${decls} const __state: ${stateType} = ${dynamicStateInitializer}; ${ssrStateAliases} ${ssrComputedAliases} - const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, (__state as any)[key]])); + const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, Reflect.get(__state, key)])); const __scopeValue = Object.entries(__hydrationState) .map(([key, value]) => { let encoded: string; @@ -1472,13 +1477,13 @@ export function generate(ast: PageAst): string { ); } else { out.push( - `export default ${storeBindings.length > 0 ? "async " : ""}function ${ast.name}(ctx: any) { + `export default ${storeBindings.length > 0 ? "async " : ""}function ${ast.name}(ctx: import("@wrnexus/core").Context) { ${storeDeclarations} ${serverLoadAliases} const __state: ${stateType} = ${dynamicStateInitializer}; ${ssrStateAliases} ${ssrComputedAliases} - const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, (__state as any)[key]])); + const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, Reflect.get(__state, key)])); const __scopeValue = Object.entries(__hydrationState) .map(([key, value]) => { let encoded: string; @@ -1505,14 +1510,14 @@ export function generate(ast: PageAst): string { if (staticShellBody !== undefined) { out.push( - `export async function __wrnexusBuildStaticShell(ctx: any = {}) { + `export async function __wrnexusBuildStaticShell(ctx: import("@wrnexus/core").Context = {} as import("@wrnexus/core").Context) { ${storeDeclarations} ${serverLoadAliases} ${loopConsts.length > 0 ? loopConsts.join("\n") : ""} const __state: ${stateType} = ${dynamicStateInitializer}; ${ssrStateAliases} ${ssrComputedAliases} - const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, (__state as any)[key]])); + const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, Reflect.get(__state, key)])); const __scopeValue = Object.entries(__hydrationState) .map(([key, value]) => { let encoded: string; @@ -1572,7 +1577,7 @@ export function generate(ast: PageAst): string { }) .join("\n"); const visible = exposed.filter((entry) => entry.name); - return `export async function ${exportName}(ctx: any) { + return `export async function ${exportName}(ctx: import("@wrnexus/core").Context) { ${exposed .filter((entry) => !entry.name) .map((entry) => entry.body) @@ -1599,7 +1604,7 @@ ${ ); continue; } - out.push(`export async function ${action.name}(input: any, ctx: any) { + out.push(`export async function ${action.name}(input: InferSchema, ctx: import("@wrnexus/core").Context) { const invalidate = (...tags: string[]) => { const bucket = (ctx.locals.__wrnexusInvalidatedTags ??= []); bucket.push(...tags.flat()); @@ -1630,7 +1635,7 @@ ${ast.actions ast.apis.forEach((api, index) => { const name = `__wrnexusApi_${api.method}_${index}`; out.push(`// ${api.method} ${apiRoutePath(api.path)} -const ${name} = async (ctx: any) => {${api.body}};`); +const ${name} = async (ctx: import("@wrnexus/core").Context) => {${api.body}};`); }); const entries = ast.apis.map( @@ -1652,7 +1657,7 @@ const ${name} = async (ctx: any) => {${api.body}};`); const handlers = ast.realtimes.flatMap((rt) => rt.handlers.map((h) => { const params = ["ws", ...h.args].join(", "); - return ` ${h.event}(${params}: any) {${h.body}},`; + return ` ${h.event}(${params}: Event) {${h.body}},`; }), ); out.push(`export const websocket = {\n${handlers.join("\n")}\n};`); @@ -2413,7 +2418,7 @@ function generateComponent(ast: PageAst): string { ); } decls.push( - ` const ${nameRefs.get(prop.name)}: ${prop.valueType ?? "any"} = __coerce(__p[${JSON.stringify(prop.name)}], (${resolveExpr(prop.default)}), ${JSON.stringify(runtimeTypeOf(prop.valueType))}, ${JSON.stringify(prop.name)});`, + ` const ${nameRefs.get(prop.name)}: ${prop.valueType ?? "unknown"} = __coerce(__p[${JSON.stringify(prop.name)}], (${resolveExpr(prop.default)}), ${JSON.stringify(runtimeTypeOf(prop.valueType))}, ${JSON.stringify(prop.name)}) as ${prop.valueType ?? "unknown"};`, ); } if (!effectiveProps.some((prop) => prop.name === "attrs")) { @@ -2503,7 +2508,7 @@ function generateComponent(ast: PageAst): string { ); } - out.push(`function __coerce(v: any, def: any, declared: string = "unknown", propName: string = "prop"): any { + out.push(`function __coerce(v: unknown, def: unknown, declared: string = "unknown", propName: string = "prop"): unknown { if (v === undefined || v === null) { return def; } @@ -2563,7 +2568,7 @@ function generateComponent(ast: PageAst): string { throw new TypeError("Expected an object prop '" + propName + "'"); } - if (declared === "bigint") return BigInt(v); + if (declared === "bigint") return BigInt(v as string | number | bigint | boolean); if (declared === "function" && typeof v !== "function") { throw new TypeError("Expected a function prop"); } @@ -2571,15 +2576,15 @@ function generateComponent(ast: PageAst): string { } function __restProps( - props: Record, + props: Record, declared: Set, -): Record { +): Record { return Object.fromEntries( Object.entries(props).filter(([name]) => !declared.has(name)), ); } -function __wireHtml(v: any): string { +function __wireHtml(v: unknown): string { return String(v == null ? "" : v).replace( /[&<>]/g, (c) => @@ -2591,7 +2596,7 @@ function __wireHtml(v: any): string { ); } -function __wireAttr(v: any): string { +function __wireAttr(v: unknown): string { return String(v == null ? "" : v).replace( /[&<>"]/g, (c) => @@ -2605,7 +2610,7 @@ function __wireAttr(v: any): string { ); } -function __wireBooleanAttr(name: string, value: any): string { +function __wireBooleanAttr(name: string, value: unknown): string { return value === true || value === "true" || value === "" || @@ -2616,7 +2621,7 @@ function __wireBooleanAttr(name: string, value: any): string { : ""; } -function __wireSpreadAttrs(value: any): string { +function __wireSpreadAttrs(value: unknown): string { if (value === null || typeof value !== "object" || Array.isArray(value)) return ""; const booleanAttributes = new Set(${JSON.stringify([...HTML_BOOLEAN_ATTRIBUTES])}); @@ -2652,7 +2657,7 @@ function __wireSpreadAttrs(value: any): string { return attributes.join(""); } -function __wireProp(v: any): string { +function __wireProp(v: unknown): string { const value = v !== null && typeof v === "object" ? JSON.stringify(v) @@ -2661,18 +2666,18 @@ function __wireProp(v: any): string { return __wireAttr(value); } -function __wireRaw(v: any): string { +function __wireRaw(v: unknown): string { return String(v == null ? "" : v); }`); if (hasServerEach) { - out.push(`function __wrnexusEncodeLoopLocals(value: Record): string { + out.push(`function __wrnexusEncodeLoopLocals(value: Record): string { return __WrnexusBuffer.from(JSON.stringify(value), "utf8").toString("base64"); }`); } if (needsScope) { - out.push(`function __wrnexusSerializeScopeValue(value: any): string { + out.push(`function __wrnexusSerializeScopeValue(value: unknown): string { if (value === undefined) { return "undefined"; } @@ -2706,7 +2711,7 @@ function __wireRaw(v: any): string { } } - function __wrnexusScopeDecl(obj: Record): string { + function __wrnexusScopeDecl(obj: Record): string { return Object.keys(obj) .map( (key) => @@ -2727,7 +2732,7 @@ function __wireRaw(v: any): string { const serverFunctionSource = serverFunctions ? `${serverFunctions}\n` : ""; out.push( - `export function render(props: ${effectiveProps.length > 0 ? `${ast.name}Props` : "Record"} = {} as ${effectiveProps.length > 0 ? `${ast.name}Props` : "Record"}): string {\n` + + `export function render(props: ${effectiveProps.length > 0 ? `${ast.name}Props` : "Record"} = {} as ${effectiveProps.length > 0 ? `${ast.name}Props` : "Record"}): string {\n` + ` const __p = props || {};\n` + (decls.length > 0 ? decls.join("\n") + "\n" : "") + serverFunctionSource + @@ -2742,7 +2747,7 @@ function __wireRaw(v: any): string { return out.join("\n\n") + "\n"; } -function __wireRaw(v: any): string { +function __wireRaw(v: unknown): string { return String(v == null ? "" : v); } @@ -2751,19 +2756,19 @@ function wholeAttributeExpression(value: string): string | null { return match?.[1]?.trim() || null; } -function __wireHtml(v: any): string { +function __wireHtml(v: unknown): string { return String(v == null ? "" : v).replace(/[&<>]/g, (c) => c === "&" ? "&" : c === "<" ? "<" : ">", ); } -function __wireAttr(v: any): string { +function __wireAttr(v: unknown): string { return String(v == null ? "" : v).replace(/[&<>"]/g, (c) => c === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : """, ); } -function __wireProp(v: any): string { +function __wireProp(v: unknown): string { const value = v !== null && typeof v === "object" ? JSON.stringify(v) : String(v == null ? "" : v); diff --git a/packages/compiler/test/__snapshots__/resilience.test.ts.snap b/packages/compiler/test/__snapshots__/resilience.test.ts.snap index 1920b3c7..b93d06c8 100644 --- a/packages/compiler/test/__snapshots__/resilience.test.ts.snap +++ b/packages/compiler/test/__snapshots__/resilience.test.ts.snap @@ -45,7 +45,7 @@ export interface CounterOutputs { "change"(value: number): void; } -function __coerce(v: any, def: any, declared: string = "unknown", propName: string = "prop"): any { +function __coerce(v: unknown, def: unknown, declared: string = "unknown", propName: string = "prop"): unknown { if (v === undefined || v === null) { return def; } @@ -105,7 +105,7 @@ function __coerce(v: any, def: any, declared: string = "unknown", propName: stri throw new TypeError("Expected an object prop '" + propName + "'"); } - if (declared === "bigint") return BigInt(v); + if (declared === "bigint") return BigInt(v as string | number | bigint | boolean); if (declared === "function" && typeof v !== "function") { throw new TypeError("Expected a function prop"); } @@ -113,15 +113,15 @@ function __coerce(v: any, def: any, declared: string = "unknown", propName: stri } function __restProps( - props: Record, + props: Record, declared: Set, -): Record { +): Record { return Object.fromEntries( Object.entries(props).filter(([name]) => !declared.has(name)), ); } -function __wireHtml(v: any): string { +function __wireHtml(v: unknown): string { return String(v == null ? "" : v).replace( /[&<>]/g, (c) => @@ -133,7 +133,7 @@ function __wireHtml(v: any): string { ); } -function __wireAttr(v: any): string { +function __wireAttr(v: unknown): string { return String(v == null ? "" : v).replace( /[&<>"]/g, (c) => @@ -147,7 +147,7 @@ function __wireAttr(v: any): string { ); } -function __wireBooleanAttr(name: string, value: any): string { +function __wireBooleanAttr(name: string, value: unknown): string { return value === true || value === "true" || value === "" || @@ -158,7 +158,7 @@ function __wireBooleanAttr(name: string, value: any): string { : ""; } -function __wireSpreadAttrs(value: any): string { +function __wireSpreadAttrs(value: unknown): string { if (value === null || typeof value !== "object" || Array.isArray(value)) return ""; const booleanAttributes = 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"]); @@ -194,7 +194,7 @@ function __wireSpreadAttrs(value: any): string { return attributes.join(""); } -function __wireProp(v: any): string { +function __wireProp(v: unknown): string { const value = v !== null && typeof v === "object" ? JSON.stringify(v) @@ -203,11 +203,11 @@ function __wireProp(v: any): string { return __wireAttr(value); } -function __wireRaw(v: any): string { +function __wireRaw(v: unknown): string { return String(v == null ? "" : v); } -function __wrnexusSerializeScopeValue(value: any): string { +function __wrnexusSerializeScopeValue(value: unknown): string { if (value === undefined) { return "undefined"; } @@ -241,7 +241,7 @@ function __wrnexusSerializeScopeValue(value: any): string { } } - function __wrnexusScopeDecl(obj: Record): string { + function __wrnexusScopeDecl(obj: Record): string { return Object.keys(obj) .map( (key) => @@ -260,7 +260,7 @@ function __wrnexusSerializeScopeValue(value: any): string { export function render(props: CounterProps = {} as CounterProps): string { const __p = props || {}; - const label: string = __coerce(__p["label"], ("Count"), "string", "label"); + const label: string = __coerce(__p["label"], ("Count"), "string", "label") as string; const __attrs = __restProps(__p, new Set(["label"])); let count = (0); const __scopeState = { "label": label, "count": count }; diff --git a/packages/db/src/session-store.ts b/packages/db/src/session-store.ts index 8506e4b3..f1af222f 100644 --- a/packages/db/src/session-store.ts +++ b/packages/db/src/session-store.ts @@ -31,7 +31,8 @@ export function sqliteSessionStore(path = "sessions.db"): SessionBackend { if (!row) return undefined; try { return { data: JSON.parse(row.data) as Record, expiresAt: row.expiresAt }; - } catch { + } catch (error) { + console.warn(`[wrnexus:db] discarded corrupt session '${id}'`, error); return undefined; } }, diff --git a/packages/dev-server/src/assets.ts b/packages/dev-server/src/assets.ts index 6967e5ba..c65af0dc 100644 --- a/packages/dev-server/src/assets.ts +++ b/packages/dev-server/src/assets.ts @@ -19,6 +19,7 @@ import { } from "@wrnexus/csr"; import { renderStyles, + renderActiveThemeCss, renderThemeCss, renderThemeRuntime, type ResolvedTheme, @@ -122,6 +123,18 @@ export function createDevAssetServer( ? cssResponse(renderThemeCss(theme)) : new Response("Not Found", { status: 404 }); } + const activeThemeMatch = /^\/__wrnexus\/theme\/([^/]+)\/([^/]+)\.css$/.exec(pathname); + if (activeThemeMatch && theme) { + try { + const themeName = decodeURIComponent(activeThemeMatch[1]!); + const accentPart = decodeURIComponent(activeThemeMatch[2]!); + return cssResponse( + renderActiveThemeCss(theme, themeName, accentPart === "_" ? undefined : accentPart), + ); + } catch { + return new Response("Not Found", { status: 404 }); + } + } if (pathname === "/__wrnexus/theme.js") { return theme ? jsResponse(renderThemeRuntime(theme)) diff --git a/packages/dev-server/src/prod.ts b/packages/dev-server/src/prod.ts index 237598a5..8e6d93cd 100644 --- a/packages/dev-server/src/prod.ts +++ b/packages/dev-server/src/prod.ts @@ -99,6 +99,8 @@ export interface ProdOptions { clientModulesDir?: string; /** Absolute path to the pre-built theme stylesheet (`theme.css`). */ themePath?: string; + /** Pre-built active theme/accent stylesheets, loaded on demand. */ + themeAssetsDir?: string; /** Absolute path to the pre-built theme runtime (`theme.js`). */ themeJsPath?: string; /** Resolved theme config: enables `` + `theme.css` link. */ @@ -366,6 +368,28 @@ function createProdAssetServer(opts: ProdOptions): AssetServer { return new Response(opts.schemasJs ?? "window.__wireSchemas={};", { headers: JS_HEADERS }); } if (pathname === "/__wrnexus/theme.css") return serveFile(opts.themePath, CSS_HEADERS); + const activeThemeMatch = /^\/__wrnexus\/theme\/([^/]+)\/([^/]+)\.css$/.exec(pathname); + if (activeThemeMatch && opts.theme && opts.themeAssetsDir) { + try { + const themeName = decodeURIComponent(activeThemeMatch[1]!); + const accentPart = decodeURIComponent(activeThemeMatch[2]!); + if (!opts.theme.names.includes(themeName)) + return new Response("Not Found", { status: 404 }); + if (accentPart !== "_" && !opts.theme.accentNames.some((name) => name === accentPart)) { + return new Response("Not Found", { status: 404 }); + } + return serveFile( + join( + opts.themeAssetsDir, + encodeURIComponent(themeName), + `${accentPart === "_" ? "_" : encodeURIComponent(accentPart)}.css`, + ), + CSS_HEADERS, + ); + } catch { + return new Response("Not Found", { status: 404 }); + } + } if (pathname === "/__wrnexus/theme.js") return serveFile(opts.themeJsPath, JS_HEADERS); if (pathname === "/__wrnexus/ui.css") return serveFile(opts.uiCssPath, CSS_HEADERS); if (pathname === "/__wrnexus/framework.css") diff --git a/packages/dev-server/src/runtime.ts b/packages/dev-server/src/runtime.ts index bbae98a3..e7de2a9f 100644 --- a/packages/dev-server/src/runtime.ts +++ b/packages/dev-server/src/runtime.ts @@ -66,12 +66,12 @@ import type { StoreDefinition } from "@wrnexus/store"; import type { ClientRuntimeDefinition } from "@wrnexus/plugin"; import { CacheCoordinator } from "@wrnexus/cache"; import { generateServiceWorker } from "@wrnexus/pwa"; -import { runtimeScriptsForMarkup } from "./plugin-assets.ts"; +import { collectScripts, usesMobileRuntime } from "./script-selection.ts"; +export { collectScripts, usesMobileRuntime } from "./script-selection.ts"; import { ACCENT_COOKIE, THEME_COOKIE, - THEME_CSS_HREF, - THEME_JS_HREF, + activeThemeCssHref, resolveAccentName, resolveThemeName, type MobileConfig, @@ -81,7 +81,6 @@ import { type TenancyConfig, } from "@wrnexus/styles"; import { - I18N_JS_HREF, renderI18nData, makeT, resolveLang, @@ -945,9 +944,6 @@ export function createHandlers(deps: RuntimeDeps): Handlers { headParts.push( ``, ); - } else if (deps.theme && !deps.stylesIncludeFramework) { - const themeHref = versionAssetUrl(THEME_CSS_HREF, deps.assetVersion); - headParts.push(``); } if (deps.hasUi && !deps.hasFrameworkStyles && !deps.stylesIncludeFramework) { headParts.push( @@ -1839,6 +1835,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers { // attributes: no-flash theme (from cookie, validated) + active lang. const attrs: string[] = []; + let activeThemeHead = ""; if (deps.theme) { const themeName = resolveThemeName(ctx.cookies.get(THEME_COOKIE), deps.theme); @@ -1850,6 +1847,13 @@ export function createHandlers(deps: RuntimeDeps): Handlers { if (accentName) { attrs.push(`data-accent="${accentName}"`); } + if (!deps.hasFrameworkStyles && !deps.stylesIncludeFramework) { + const themeHref = versionAssetUrl( + activeThemeCssHref(themeName, accentName), + deps.assetVersion, + ); + activeThemeHead = ``; + } } attrs.push(`lang="${safeLanguageTag(language)}"`); if (deps.i18n) attrs.push(`dir="${deps.i18n.direction[language] ?? "ltr"}"`); @@ -1873,6 +1877,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers { pwaEnabled ? `` : "", pwaEnabled ? `` : "", pwaEnabled ? `` : "", + activeThemeHead, extraHead, ] .filter(Boolean) @@ -2443,72 +2448,6 @@ export function normalizeComponentName(name: string): string { return name.toLowerCase().replace(/[-_]/g, ""); } -/** - * Decide which framework scripts a rendered page needs. Components are already - * server-rendered into the HTML; the only script is the reactive runtime, and - * only when the page actually contains a scope or a browser-side API fetch. - */ -export function collectScripts( - body: string, - clientRuntimes: readonly ClientRuntimeDefinition[] = [], - navigation: { mode?: "auto" | "client" | "document" } = {}, -): RenderScript[] { - const scripts: RenderScript[] = []; - if ( - /\bdata-scope=/.test(body) || - /\bdata-wrnexus-csr=/.test(body) || - /\bdata-wrn-client-template=/.test(body) || - /\bdata-wrn-async=/.test(body) - ) { - scripts.push("/__wrnexus/reactive.js"); - } - if (/\bdata-wrn-action=/.test(body)) scripts.push("/__wrnexus/actions.js"); - // The theme runtime is only needed when the page can switch themes. - if ( - /\bdata-wire-theme-(toggle|set)\b/.test(body) || - /\bdata-wire-accent-(set|clear)\b/.test(body) - ) { - scripts.push(THEME_JS_HREF); - } - // Validation: schema descriptors + the generic validator, only for pages with a form. - if (/\bdata-schema="[A-Za-z0-9_-]+"/.test(body)) { - scripts.push("/__wrnexus/schemas.js", "/__wrnexus/validate.js"); - } - // Language switcher runtime, only when the page has one. - if (/\bdata-wire-lang-set\b/.test(body) || /\bselect[^>]*\bdata-wire-lang\b/.test(body)) { - scripts.push(I18N_JS_HREF); - } - // Realtime client, only when the page declares a room. - if (/\bdata-room=/.test(body)) { - scripts.push("/__wrnexus/realtime.js"); - } - // File-upload runtime (drag-drop + progress), only when a page has an uploader. - if (/\bdata-uploader\b/.test(body)) { - scripts.push("/__wrnexus/uploader.js"); - } - // Package runtimes are declarative. Components mark the rendered HTML with - // `data-wrnexus-runtime="id"`; the corresponding package chunk is loaded - // once, without requiring application-authored script tags or public copies. - scripts.push(...runtimeScriptsForMarkup(body, clientRuntimes)); - - // `auto` is the performance-first default: a fully static page ships no - // framework JavaScript and its links use native document navigation. Routes - // that already need browser behavior also receive progressive navigation. - // `client` preserves the explicit always-on behavior; `document` disables it. - const mode = navigation.mode ?? "auto"; - if (mode === "client" || (mode === "auto" && scripts.length > 0)) { - scripts.unshift("/__wrnexus/nav.js"); - } - return scripts; -} - -/** Whether rendered markup needs the Capacitor/native browser bridge. */ -export function usesMobileRuntime(body: string): boolean { - return /\b(?:data-mobile-[\w-]+|data-native-(?:browser|mobile|only|requires|unsupported|options)|data-on-wrnexus-(?:browser|mobile)-[\w-]+)\b/.test( - body, - ); -} - function safeLanguageTag(value: string): string { return /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(value) ? value : "en"; } diff --git a/packages/dev-server/src/script-selection.ts b/packages/dev-server/src/script-selection.ts new file mode 100644 index 00000000..a256fa74 --- /dev/null +++ b/packages/dev-server/src/script-selection.ts @@ -0,0 +1,51 @@ +import { I18N_JS_HREF } from "@wrnexus/i18n"; +import type { ClientRuntimeDefinition } from "@wrnexus/plugin"; +import { THEME_JS_HREF } from "@wrnexus/styles"; +import type { RenderScript } from "@wrnexus/ssr"; +import { runtimeScriptsForMarkup } from "./plugin-assets.ts"; + +/** Select browser runtimes from capabilities present in rendered markup. */ +export function collectScripts( + body: string, + clientRuntimes: readonly ClientRuntimeDefinition[] = [], + navigation: { mode?: "auto" | "client" | "document" } = {}, +): RenderScript[] { + const scripts: RenderScript[] = []; + if ( + /\bdata-scope=/.test(body) || + /\bdata-wrnexus-csr=/.test(body) || + /\bdata-wrn-client-template=/.test(body) || + /\bdata-wrn-async=/.test(body) + ) { + scripts.push("/__wrnexus/reactive.js"); + } + if (/\bdata-wrn-action=/.test(body)) scripts.push("/__wrnexus/actions.js"); + if ( + /\bdata-wire-theme-(toggle|set)\b/.test(body) || + /\bdata-wire-accent-(set|clear)\b/.test(body) + ) { + scripts.push(THEME_JS_HREF); + } + if (/\bdata-schema="[A-Za-z0-9_-]+"/.test(body)) { + scripts.push("/__wrnexus/schemas.js", "/__wrnexus/validate.js"); + } + if (/\bdata-wire-lang-set\b/.test(body) || /\bselect[^>]*\bdata-wire-lang\b/.test(body)) { + scripts.push(I18N_JS_HREF); + } + if (/\bdata-room=/.test(body)) scripts.push("/__wrnexus/realtime.js"); + if (/\bdata-uploader\b/.test(body)) scripts.push("/__wrnexus/uploader.js"); + scripts.push(...runtimeScriptsForMarkup(body, clientRuntimes)); + + const mode = navigation.mode ?? "auto"; + if (mode === "client" || (mode === "auto" && scripts.length > 0)) { + scripts.unshift("/__wrnexus/nav.js"); + } + return scripts; +} + +/** Whether rendered markup needs the Capacitor/native browser bridge. */ +export function usesMobileRuntime(body: string): boolean { + return /\b(?:data-mobile-[\w-]+|data-native-(?:browser|mobile|only|requires|unsupported|options)|data-on-wrnexus-(?:browser|mobile)-[\w-]+)\b/.test( + body, + ); +} diff --git a/packages/observability/src/client.ts b/packages/observability/src/client.ts index 2fadb620..8ce27490 100644 --- a/packages/observability/src/client.ts +++ b/packages/observability/src/client.ts @@ -22,7 +22,7 @@ export function webVitalsClient(options: WebVitalsClientOptions = {}): string { function send(name, value) { var body = JSON.stringify({ name: name, value: value, rating: rating(name, value), route: location.pathname, navigationType: performance.getEntriesByType("navigation")[0]?.type }); if (navigator.sendBeacon) navigator.sendBeacon(endpoint, new Blob([body], { type: "application/json" })); - else fetch(endpoint, { method: "POST", headers: { "content-type": "application/json" }, body: body, keepalive: true }).catch(function(){}); + else fetch(endpoint, { method: "POST", headers: { "content-type": "application/json" }, body: body, keepalive: true }).catch(function(error){ console.warn("[wrnexus:observability] web-vitals delivery failed", error); }); } function flush() { if (flushed) return; @@ -31,9 +31,13 @@ export function webVitalsClient(options: WebVitalsClientOptions = {}): string { if (inp) send("INP", inp); if (cls) send("CLS", cls); } - try { new PerformanceObserver(function(list){ var entries=list.getEntries(); var last=entries[entries.length-1]; if(last) lcp = Math.max(lcp, last.startTime); }).observe({type:"largest-contentful-paint",buffered:true}); } catch(_) {} - try { new PerformanceObserver(function(list){ list.getEntries().forEach(function(e){ if(!e.hadRecentInput) cls += e.value; }); }).observe({type:"layout-shift",buffered:true}); } catch(_) {} - try { new PerformanceObserver(function(list){ list.getEntries().forEach(function(e){ inp=Math.max(inp,e.duration||0); }); }).observe({type:"event",durationThreshold:40,buffered:true}); } catch(_) {} + function observe(type, callback, options) { + try { new PerformanceObserver(callback).observe(options); } + catch(error) { console.warn("[wrnexus:observability] PerformanceObserver unavailable for " + type, error); } + } + observe("largest-contentful-paint", function(list){ var entries=list.getEntries(); var last=entries[entries.length-1]; if(last) lcp = Math.max(lcp, last.startTime); }, {type:"largest-contentful-paint",buffered:true}); + observe("layout-shift", function(list){ list.getEntries().forEach(function(e){ if(!e.hadRecentInput) cls += e.value; }); }, {type:"layout-shift",buffered:true}); + observe("event", function(list){ list.getEntries().forEach(function(e){ inp=Math.max(inp,e.duration||0); }); }, {type:"event",durationThreshold:40,buffered:true}); addEventListener("visibilitychange", function(){ if(document.visibilityState === "hidden") flush(); }); addEventListener("pagehide", flush, { once: true }); })();`; diff --git a/packages/observability/src/diagnostics.ts b/packages/observability/src/diagnostics.ts new file mode 100644 index 00000000..96c59923 --- /dev/null +++ b/packages/observability/src/diagnostics.ts @@ -0,0 +1,14 @@ +export type ObservabilityDiagnostic = (message: string, error: unknown) => void; + +/** Report exporter failures without feeding them back through the exporter. */ +export function reportObservabilityFailure( + message: string, + error: unknown, + diagnostic?: ObservabilityDiagnostic, +): void { + if (diagnostic) { + diagnostic(message, error); + return; + } + console.warn(`[wrnexus:observability] ${message}`, error); +} diff --git a/packages/observability/src/integrations.ts b/packages/observability/src/integrations.ts index dbd93716..d2b0cb54 100644 --- a/packages/observability/src/integrations.ts +++ b/packages/observability/src/integrations.ts @@ -2,6 +2,7 @@ import type { MetricPoint } from "./metrics.ts"; import type { LogRecord } from "./logging.ts"; import type { MetricExporter } from "./server.ts"; import type { SpanExporter, SpanRecord, TraceContext } from "./trace.ts"; +import { reportObservabilityFailure, type ObservabilityDiagnostic } from "./diagnostics.ts"; export type FrameworkSpanKind = "database" | "cache" | "queue" | "realtime" | "server-action" | "application"; @@ -26,6 +27,7 @@ export function createOperationTracer( context?: () => Partial; now?: () => number; onExportError?: (error: unknown) => void; + diagnostic?: ObservabilityDiagnostic; } = {}, ): OperationTracer { const now = options.now ?? Date.now; @@ -66,7 +68,9 @@ export function createOperationTracer( try { await options.exporter?.export([record]); } catch (error) { - options.onExportError?.(error); + if (options.onExportError) options.onExportError(error); + else + reportObservabilityFailure("operation span export failed", error, options.diagnostic); } } }, diff --git a/packages/observability/src/trace.ts b/packages/observability/src/trace.ts index 4a59574b..80cb3fcf 100644 --- a/packages/observability/src/trace.ts +++ b/packages/observability/src/trace.ts @@ -1,4 +1,5 @@ import { createTracer, type Context, type Middleware } from "@wrnexus/core"; +import { reportObservabilityFailure, type ObservabilityDiagnostic } from "./diagnostics.ts"; export interface TraceContext { version: "00"; @@ -63,6 +64,7 @@ export interface TraceMiddlewareOptions { routeName?: (ctx: Context) => string; serverTiming?: boolean; onExportError?: (error: unknown, span: SpanRecord) => void | Promise; + diagnostic?: ObservabilityDiagnostic; } export function traceMiddleware(options: TraceMiddlewareOptions = {}): Middleware { @@ -131,7 +133,8 @@ export function traceMiddleware(options: TraceMiddlewareOptions = {}): Middlewar await options.onSpan?.(span); await options.exporter?.export([span]); } catch (error) { - await options.onExportError?.(error, span); + if (options.onExportError) await options.onExportError(error, span); + else reportObservabilityFailure("trace export failed", error, options.diagnostic); } } } diff --git a/packages/observability/test/integrations.test.ts b/packages/observability/test/integrations.test.ts index af416cf3..14361f74 100644 --- a/packages/observability/test/integrations.test.ts +++ b/packages/observability/test/integrations.test.ts @@ -93,4 +93,14 @@ describe("observability integrations", () => { expect(await profile("render", () => "ok")).toBe("ok"); expect(profiles[0]).toMatchObject({ name: "render", durationMs: 4 }); }); + + test("operation exporter failures reach diagnostics without recursive telemetry", async () => { + const diagnostics: string[] = []; + const tracer = createOperationTracer({ + exporter: { export: () => Promise.reject(new Error("offline")) }, + diagnostic: (message) => diagnostics.push(message), + }); + await expect(tracer.span("database", "query", async () => 1)).resolves.toBe(1); + expect(diagnostics).toEqual(["operation span export failed"]); + }); }); diff --git a/packages/observability/test/observability.test.ts b/packages/observability/test/observability.test.ts index 391c94f5..e0a14d57 100644 --- a/packages/observability/test/observability.test.ts +++ b/packages/observability/test/observability.test.ts @@ -8,6 +8,8 @@ describe("@wrnexus/observability", () => { expect(client).toContain('addEventListener("pagehide", flush'); expect(client).not.toContain('send("INP",max)'); expect(client).not.toContain('send("LCP", last.startTime)'); + expect(client).toContain("web-vitals delivery failed"); + expect(client).toContain("PerformanceObserver unavailable for"); }); test("records deterministic counters and histograms", () => { diff --git a/packages/observability/test/operations.test.ts b/packages/observability/test/operations.test.ts index c03213ef..50be0117 100644 --- a/packages/observability/test/operations.test.ts +++ b/packages/observability/test/operations.test.ts @@ -81,6 +81,22 @@ describe("production observability operations", () => { expect(failures).toHaveLength(1); }); + test("reports exporter failures through the non-recursive diagnostic hook", async () => { + const diagnostics: Array<{ message: string; error: unknown }> = []; + const ctx = createContext( + new Request("https://example.test/"), + new URL("https://example.test/"), + ); + const middleware = traceMiddleware({ + random: (target) => target.fill(8), + exporter: { export: () => Promise.reject(new Error("offline")) }, + diagnostic: (message, error) => diagnostics.push({ message, error }), + }); + expect((await middleware(ctx, () => new Response("ok"))).status).toBe(200); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]?.message).toBe("trace export failed"); + }); + test("exports OTLP JSON traces and metrics", async () => { const requests: unknown[] = []; const send = (async (_url: URL | RequestInfo, init?: RequestInit) => { diff --git a/packages/store/src/client.ts b/packages/store/src/client.ts index 954d5f0d..e7b887c2 100644 --- a/packages/store/src/client.ts +++ b/packages/store/src/client.ts @@ -1,6 +1,9 @@ import { createStoreContainer } from "./index.ts"; let globalContainer: ReturnType | undefined; +const storeGlobal = globalThis as typeof globalThis & { + __wrnexusStoreContainer?: ReturnType; +}; export function browserStoreContainer(hydration: Record = {}) { if (!globalContainer) { @@ -17,7 +20,7 @@ export function browserStoreContainer(hydration: Record = {}) { } }, }); - (globalThis as any).__wrnexusStoreContainer = globalContainer; + storeGlobal.__wrnexusStoreContainer = globalContainer; } return globalContainer; } @@ -25,5 +28,5 @@ export function browserStoreContainer(hydration: Record = {}) { export async function resetBrowserStores() { await globalContainer?.dispose(); globalContainer = undefined; - delete (globalThis as any).__wrnexusStoreContainer; + delete storeGlobal.__wrnexusStoreContainer; } diff --git a/packages/store/src/index.ts b/packages/store/src/index.ts index 1edee164..8a6f2bec 100644 --- a/packages/store/src/index.ts +++ b/packages/store/src/index.ts @@ -27,7 +27,7 @@ function readonlySnapshot(state: S): Readonly { return Object.freeze(clone(state)); } -function storageFor(kind: StorePersistenceConfig["storage"]): Storage | null { +function storageFor(kind: StorePersistenceConfig["storage"]): Storage | null { if (typeof window === "undefined") return null; if (kind === "local") return window.localStorage; if (kind === "session") return window.sessionStorage; @@ -91,7 +91,10 @@ export class StoreContainer { readonly runtime: "server" | "client"; readonly request?: unknown; readonly routeId?: string; - private readonly instances = new Map>(); + private readonly instances = new Map< + string, + StoreInstance, Record, Record> + >(); private readonly hydration: Record; private readonly onMutation?: (mutation: StoreMutation) => void; private readonly lastMutations = new Map(); @@ -159,7 +162,7 @@ export class StoreContainer { Object.fromEntries( Reflect.ownKeys(instance.computed).map((key) => [ String(key), - (instance.computed as any)[key], + Reflect.get(instance.computed, key), ]), ), ), @@ -314,7 +317,7 @@ export function createStoreInstance< const publicState = new Proxy({} as State, { get(_target, property) { - return (mutableState as any)[property]; + return Reflect.get(mutableState, property); }, set(_target, property) { throw new TypeError( @@ -334,7 +337,7 @@ export function createStoreInstance< return { enumerable: true, configurable: true, - value: (mutableState as any)[property], + value: Reflect.get(mutableState, property), writable: false, }; }, @@ -360,7 +363,7 @@ export function createStoreInstance< mutate("$reset", () => { const next = createInitialState(); for (const key of Object.keys(mutableState)) { - if (!(key in next)) delete (mutableState as any)[key]; + if (!(key in next)) Reflect.deleteProperty(mutableState, key); } Object.assign(mutableState, next); }); @@ -382,16 +385,20 @@ export function createStoreInstance< definitions.find((entry) => entry.runtime === "shared") ?? definitions.find((entry) => entry.runtime === "legacy"); if (!selected) continue; - (actions as any)[name] = async (...args: unknown[]) => { + Reflect.set(actions, name, async (...args: unknown[]) => { currentAction = name; internalMutation = true; try { - return await (selected.handler as any)(context, ...args); + const handler = selected.handler as unknown as ( + context: StoreActionContext, + ...args: unknown[] + ) => unknown; + return await handler(context, ...args); } finally { currentAction = "direct"; internalMutation = false; } - }; + }); } const computed = new Proxy({} as C, { @@ -461,7 +468,7 @@ export function createStoreInstance< const result: Partial = {}; const serverKeys = new Set(Object.keys(definition.createServerState?.() ?? {})); for (const [key, value] of Object.entries(mutableState)) { - if (!serverKeys.has(key)) (result as any)[key] = clone(value); + if (!serverKeys.has(key)) Reflect.set(result, key, clone(value)); } return result; }, @@ -479,9 +486,9 @@ export function createStoreInstance< return new Proxy(core as StoreInstance, { get(target, property, receiver) { if (Reflect.has(target, property)) return Reflect.get(target, property, receiver); - if (property in actions) return (actions as any)[property]; - if (property in (definition.computed ?? {})) return (computed as any)[property]; - if (property in mutableState) return (publicState as any)[property]; + if (property in actions) return Reflect.get(actions, property); + if (property in (definition.computed ?? {})) return Reflect.get(computed, property); + if (property in mutableState) return Reflect.get(publicState, property); return undefined; }, set(_target, property) { diff --git a/packages/store/src/types.ts b/packages/store/src/types.ts index cb9f3d1b..6116e964 100644 --- a/packages/store/src/types.ts +++ b/packages/store/src/types.ts @@ -1,7 +1,8 @@ export type StoreKind = "global" | "page"; export type StoreRuntime = "shared" | "client" | "server" | "legacy"; export type PersistenceStorage = "memory" | "session" | "local"; -export type StoreFunction = (...args: any[]) => any; +/** Broad callable constraint that preserves each action's concrete parameters and return type. */ +export type StoreFunction = (...args: any[]) => unknown; export type StoreCombinedState< S extends object, diff --git a/packages/styles/src/index.ts b/packages/styles/src/index.ts index d771a07b..cba1a0f4 100644 --- a/packages/styles/src/index.ts +++ b/packages/styles/src/index.ts @@ -61,10 +61,13 @@ export { THEME_PALETTE_NAMES, THEME_COOKIE, THEME_CSS_HREF, + THEME_CSS_PREFIX, THEME_JS_HREF, + activeThemeCssHref, resolveThemeConfig, resolveThemeName, renderThemeCss, + renderActiveThemeCss, renderThemeRuntime, defineThemeTokens, themeVar, diff --git a/packages/styles/src/theme.ts b/packages/styles/src/theme.ts index 7c6e1be4..48a35b31 100644 --- a/packages/styles/src/theme.ts +++ b/packages/styles/src/theme.ts @@ -121,6 +121,7 @@ export interface ResolvedTheme { export const THEME_COOKIE = "wire-theme"; export const ACCENT_COOKIE = "wire-accent"; export const THEME_CSS_HREF = "/__wrnexus/theme.css"; +export const THEME_CSS_PREFIX = "/__wrnexus/theme/"; export const THEME_JS_HREF = "/__wrnexus/theme.js"; /** @@ -519,6 +520,44 @@ function selectorValue(value: string): string { return JSON.stringify(value); } +function globalThemeCss(): string { + return ( + '\nhtml,body{font-family:var(--wire-font-sans,"Plus Jakarta Sans",ui-sans-serif,system-ui,sans-serif);}\n' + + ":root{--wire-radius-sm:0.55rem;--wire-radius-md:0.9rem;--wire-radius-lg:1.35rem;" + + "--wire-shadow-1:0 1px 2px color-mix(in srgb, black 22%, transparent)," + + "0 10px 30px color-mix(in srgb, black 14%, transparent);}\n" + + "wrn-slot{display:contents;}\n" + + "[data-for]{display:none !important;}\n" + ); +} + +/** URL for the small stylesheet containing only one active theme/accent pair. */ +export function activeThemeCssHref(themeName: string, accentName?: string): string { + return `${THEME_CSS_PREFIX}${encodeURIComponent(themeName)}/${accentName ? encodeURIComponent(accentName) : "_"}.css`; +} + +/** Render only the tokens needed for the current SSR-selected theme and accent. */ +export function renderActiveThemeCss( + theme: ResolvedTheme, + themeName: string, + accentName?: string, +): string { + const selectedTheme = theme.themes[themeName]; + if (!selectedTheme) throw new Error(`Unknown theme '${themeName}'.`); + const blocks = [`:root{${tokensToDeclarations(selectedTheme)}}`]; + if (accentName) { + if (!theme.accentNames.includes(accentName as ThemePaletteName)) { + throw new Error(`Unknown theme accent '${accentName}'.`); + } + blocks.push( + `:root{${tokensToDeclarations( + paletteTokens(THEME_PALETTES[accentName as ThemePaletteName], themeScheme(selectedTheme)), + )}}`, + ); + } + return blocks.join("\n") + globalThemeCss(); +} + /** * Generate the theme stylesheet. * @@ -559,36 +598,7 @@ export function renderThemeCss(theme: ResolvedTheme): string { } } - return ( - blocks.join("\n") + - '\nhtml,body{font-family:var(--wire-font-sans,"Plus Jakarta Sans",ui-sans-serif,system-ui,sans-serif);}\n' + - // wraps content spliced into a component's so the client - // runtime can tell which scope authored it. It is a pure ownership marker - // and must never introduce a box: an unknown element would otherwise - // default to display:inline and break the component's own flex/grid layout. - /* - * Shape and elevation tokens the Wire UI stylesheet depends on. - * - * ui.css references --wire-radius-sm (51 times) and --wire-radius-md (21) - * but nothing ever defined them, so every component using them fell back - * to square corners in any app that did not declare them itself. - * --wire-shadow-1 had the same problem: the showcase declared it - * privately, so the library looked right there and flat everywhere else. - * Defining them with the rest of the theme keeps a component looking the - * same in every app; an app can still override them. - */ - ":root{--wire-radius-sm:0.55rem;--wire-radius-md:0.9rem;--wire-radius-lg:1.35rem;" + - "--wire-shadow-1:0 1px 2px color-mix(in srgb, black 22%, transparent)," + - "0 10px 30px color-mix(in srgb, black 14%, transparent);}\n" + - "wrn-slot{display:contents;}\n" + - // A [data-for] element is the loop TEMPLATE, not a rendered row: the server - // ships it with mustaches unresolved ({item.title}) and hydration replaces - // it with a comment marker. Painted as-is it flashes one blank, literal - // row on every page load before the runtime boots. Hiding it costs - // nothing after hydration -- the runtime strips data-for from the clones - // it renders, so this only ever matches the template itself. - "[data-for]{display:none !important;}\n" - ); + return blocks.join("\n") + globalThemeCss(); } /** @@ -603,6 +613,7 @@ export function renderThemeRuntime(theme: ResolvedTheme): string { const names = JSON.stringify(theme.names); const accentNames = JSON.stringify(theme.accentNames); const defaultAccent = JSON.stringify(theme.defaultAccent ?? null); + const cssPrefix = JSON.stringify(THEME_CSS_PREFIX); return `(function(){ var THEME_COOKIE=${JSON.stringify(THEME_COOKIE)}; @@ -611,6 +622,7 @@ export function renderThemeRuntime(theme: ResolvedTheme): string { var ACCENTS=${accentNames}; var DEFAULT_THEME=${JSON.stringify(theme.default)}; var DEFAULT_ACCENT=${defaultAccent}; + var THEME_CSS_PREFIX=${cssPrefix}; var MAX_AGE=31536000; var el=document.documentElement; @@ -637,6 +649,13 @@ export function renderThemeRuntime(theme: ResolvedTheme): string { document.cookie=encodeURIComponent(name)+"=;path=/;max-age=0;samesite=lax"+secure; } + function syncStylesheet(themeName,accentName){ + var link=document.querySelector("link[data-wrnexus-theme]"); + if(!link)return; + var accent=accentName?encodeURIComponent(accentName):"_"; + link.href=THEME_CSS_PREFIX+encodeURIComponent(themeName)+"/"+accent+".css"; + } + function getTheme(){ var current=el.getAttribute("data-theme")||readCookie(THEME_COOKIE)||DEFAULT_THEME; return THEMES.indexOf(current)>=0?current:DEFAULT_THEME; @@ -665,6 +684,7 @@ export function renderThemeRuntime(theme: ResolvedTheme): string { if(THEMES.indexOf(name)<0)return; el.setAttribute("data-theme",name); writeCookie(THEME_COOKIE,name); + syncStylesheet(name,getAccent()); syncThemeButtons(document); window.dispatchEvent(new CustomEvent("wrnexus:theme-changed",{detail:{theme:name}})); } @@ -678,6 +698,7 @@ export function renderThemeRuntime(theme: ResolvedTheme): string { if(ACCENTS.indexOf(name)<0)return; el.setAttribute("data-accent",name); writeCookie(ACCENT_COOKIE,name); + syncStylesheet(getTheme(),name); syncAccentButtons(document); window.dispatchEvent(new CustomEvent("wrnexus:accent-changed",{detail:{accent:name}})); } @@ -685,6 +706,7 @@ export function renderThemeRuntime(theme: ResolvedTheme): string { function clearAccent(){ el.removeAttribute("data-accent"); deleteCookie(ACCENT_COOKIE); + syncStylesheet(getTheme(),null); syncAccentButtons(document); window.dispatchEvent(new CustomEvent("wrnexus:accent-changed",{detail:{accent:null}})); } diff --git a/packages/styles/test/theme.test.ts b/packages/styles/test/theme.test.ts index ed0b9fb3..b80ded63 100644 --- a/packages/styles/test/theme.test.ts +++ b/packages/styles/test/theme.test.ts @@ -5,6 +5,8 @@ import { resolveThemeConfig, resolveThemeName, renderThemeCss, + renderActiveThemeCss, + activeThemeCssHref, renderThemeRuntime, } from "../src/index.ts"; @@ -85,6 +87,17 @@ test("renderThemeCss emits :root + per-theme blocks and --wire-* vars", () => { expect(css).toContain("font-family:var(--wire-font-sans"); }); +test("active theme CSS contains only the selected theme and accent", () => { + const theme = resolveThemeConfig({ default: "dark", palette: "rose" }); + const full = renderThemeCss(theme); + const active = renderActiveThemeCss(theme, "dark", "rose"); + expect(active).toContain(":root{"); + expect(active).not.toContain('[data-theme="light"]'); + expect(active.length).toBeLessThan(full.length / 4); + expect(activeThemeCssHref("dark", "rose")).toBe("/__wrnexus/theme/dark/rose.css"); + expect(() => renderActiveThemeCss(theme, "missing")).toThrow("Unknown theme"); +}); + test("renderThemeRuntime bakes the theme names for cycling", () => { const t = resolveThemeConfig(); const js = renderThemeRuntime(t); diff --git a/packages/syntax/test/versioning-security.test.ts b/packages/syntax/test/versioning-security.test.ts new file mode 100644 index 00000000..a20f5f26 --- /dev/null +++ b/packages/syntax/test/versioning-security.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test"; +import { + createSourceRange, + diagnose, + diagnosticSummary, + sliceSource, + supportsSyntaxFeature, +} from "../src/index.ts"; + +describe("syntax contract and security diagnostics", () => { + test("validates source ranges and summarizes diagnostic codes", () => { + const range = createSourceRange(2, 6); + expect(sliceSource("0123456789", range)).toBe("2345"); + expect(() => createSourceRange(-1, 2)).toThrow(RangeError); + expect(() => createSourceRange(4, 3)).toThrow(RangeError); + expect(supportsSyntaxFeature("runtime-markers")).toBe(true); + expect(supportsSyntaxFeature("dynamic-eval")).toBe(false); + + const summary = diagnosticSummary([ + { code: "A", severity: "error", message: "a" }, + { code: "A", severity: "warning", message: "b" }, + { code: "B", severity: "info", message: "c" }, + ]); + expect(summary).toEqual({ errors: 1, warnings: 1, info: 1, codes: { A: 2, B: 1 } }); + }); + + test("rejects browser secret reads, executable sinks, and sensitive persistence", () => { + const diagnostics = diagnose(`page store Unsafe { + client state { apiToken: string = process.env.API_TOKEN } + persist { storage = "local" include = ["apiToken"] version = 1 } + functions { + client function render(raw: string): void { document.write(raw); setTimeout("run()", 1) } + } +}`); + const codes = diagnostics.map((diagnostic) => diagnostic.code); + expect(codes).toContain("WRN-SEC-SERVER-SECRET-SOURCE"); + expect(codes).toContain("WRN-SEC-DOM-SINK"); + expect(codes).toContain("WRN-SEC-STRING-TIMER"); + expect(codes).toContain("WRN-PERSIST-SENSITIVE"); + }); +}); diff --git a/packages/uploader/src/runtime.ts b/packages/uploader/src/runtime.ts index 3753f165..0977c213 100644 --- a/packages/uploader/src/runtime.ts +++ b/packages/uploader/src/runtime.ts @@ -170,7 +170,8 @@ export const UPLOAD_RUNTIME = ` }); xhr.addEventListener("load", function () { var data = null; - try { data = JSON.parse(xhr.responseText); } catch (e2) {} + try { data = JSON.parse(xhr.responseText); } + catch (error) { console.warn("[wrnexus:uploader] upload response was not valid JSON", error); } if (xhr.status >= 200 && xhr.status < 300 && data && data.ok) { done(ui, (data.files && data.files[0]) || null, file); } else { diff --git a/packages/uploader/test/uploader.test.ts b/packages/uploader/test/uploader.test.ts index 2abd0420..6a45a06f 100644 --- a/packages/uploader/test/uploader.test.ts +++ b/packages/uploader/test/uploader.test.ts @@ -11,6 +11,12 @@ import { upload, verifySignedFileToken, } from "../src/index.ts"; +import { UPLOAD_RUNTIME } from "../src/runtime.ts"; + +test("browser upload response parse failures emit a diagnostic", () => { + expect(UPLOAD_RUNTIME).toContain("upload response was not valid JSON"); + expect(UPLOAD_RUNTIME).not.toContain("catch (e2) {}"); +}); function configure() { configureStorage( diff --git a/scripts/validate-0.6.mjs b/scripts/validate-0.6.mjs deleted file mode 100644 index 26170a9f..00000000 --- a/scripts/validate-0.6.mjs +++ /dev/null @@ -1,495 +0,0 @@ -#!/usr/bin/env node -import console from "node:console"; -import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; -import { dirname, join, relative } from "node:path"; -import process from "node:process"; -import { spawnSync } from "node:child_process"; -import { fileURLToPath } from "node:url"; -import { createRequire } from "node:module"; - -const root = join(dirname(fileURLToPath(import.meta.url)), ".."); -const require = createRequire(import.meta.url); -const failures = []; -const warnings = []; -const passes = []; -const fail = (message) => failures.push(message); -const pass = (message) => passes.push(message); -const readJson = (path) => JSON.parse(readFileSync(path, "utf8")); - -function walk(dir, predicate = () => true) { - if (!existsSync(dir)) return []; - const out = []; - for (const name of readdirSync(dir)) { - if (["node_modules", ".git", "dist", ".wrnexus"].includes(name)) continue; - const path = join(dir, name); - const stat = statSync(path); - if (stat.isDirectory()) out.push(...walk(path, predicate)); - else if (predicate(path)) out.push(path); - } - return out; -} - -const packageDirs = readdirSync(join(root, "packages")) - .filter((name) => existsSync(join(root, "packages", name, "package.json"))) - .sort(); -const frameworkVersion = readJson(join(root, "package.json")).version; -if (packageDirs.length < 33) - fail(`Expected at least 33 framework packages, found ${packageDirs.length}`); -else pass(`${packageDirs.length} framework packages are present`); - -for (const name of packageDirs) { - const manifest = readJson(join(root, "packages", name, "package.json")); - if (manifest.version !== frameworkVersion) { - fail(`packages/${name} is ${manifest.version ?? "unversioned"}; expected ${frameworkVersion}`); - } -} -if (!failures.some((item) => item.startsWith("packages/"))) { - pass(`All framework packages are version ${frameworkVersion}`); -} - -for (const [label, path] of [ - ["root", "package.json"], - ["VS Code extension", "editors/vscode/package.json"], - ["managed CAPTCHA service", "services/managed-captcha/package.json"], -]) { - const version = readJson(join(root, path)).version; - if (version !== frameworkVersion) - fail(`${label} version is ${version}; expected ${frameworkVersion}`); - else pass(`${label} version is ${frameworkVersion}`); -} - -for (const path of [ - "packages/store/src/index.ts", - "packages/typecheck/src/index.ts", - "packages/syntax/src/v060.ts", - "packages/compiler/src/client-codegen.ts", - "packages/compiler/src/server-codegen.ts", - "packages/compiler/src/type-codegen.ts", - "packages/compiler/src/store-codegen.ts", - "packages/csr/src/outputs.ts", - "packages/csr/src/server-client.ts", - "packages/ssr/src/store-context.ts", - "packages/ssr/src/rpc.ts", -]) { - if (!existsSync(join(root, path))) fail(`Missing required v0.6 file: ${path}`); -} - -const syntaxManifest = readJson(join(root, "packages/syntax/package.json")); -const syntaxIndexSource = readFileSync(join(root, "packages/syntax/src/index.ts"), "utf8"); -const compilerCodegenSource = readFileSync(join(root, "packages/compiler/src/codegen.ts"), "utf8"); -const publicV060ImportPattern = /(?:\bfrom\s*|\brequire\s*\()\s*["']@wrnexus\/syntax\/v060["']/; -const publicV060Imports = walk(root, (path) => /\.(?:ts|tsx|js|mjs|cjs)$/.test(path)).filter( - (path) => publicV060ImportPattern.test(readFileSync(path, "utf8")), -); -if (syntaxManifest.exports?.["./v060"]) - fail("@wrnexus/syntax still exposes the version-specific ./v060 subpath"); -if (!syntaxIndexSource.includes("stripRuntimeFunctionModifiers")) - fail("@wrnexus/syntax root does not re-export v0.6 helpers"); -if (!compilerCodegenSource.includes('from "@wrnexus/syntax"')) - fail("compiler does not import syntax helpers from @wrnexus/syntax"); -for (const path of publicV060Imports) - fail(`${relative(root, path)} imports the forbidden @wrnexus/syntax/v060 subpath`); -if ( - !failures.some( - (item) => - item.includes("syntax/v060") || - item.includes("root does not re-export") || - item.includes("compiler does not import syntax helpers"), - ) -) { - pass("v0.6 syntax helpers are exposed through @wrnexus/syntax only"); -} - -const parserSource = readFileSync(join(root, "packages/syntax/src/v060.ts"), "utf8"); -for (const marker of [ - "parseOutputs", - "parseStructuredImports", - "parseStateDeclarations", - "parseRuntimeFunctions", - "parseStoreLifecycle", -]) { - if (!parserSource.includes(marker)) fail(`v0.6 parser helper missing: ${marker}`); -} -if ( - ![ - "parseOutputs", - "parseStructuredImports", - "parseStateDeclarations", - "parseRuntimeFunctions", - "parseStoreLifecycle", - ].some((marker) => !parserSource.includes(marker)) -) { - pass("v0.6 parser helpers are present"); -} - -const uiFiles = walk(join(root, "packages/ui/components"), (path) => path.endsWith(".wrn")); -const forbidden = [ - ["$emit", /\$emit\s*\(/], - ["legacy @event", /^\s*@event\b/m], - ["$event", /\$event\b/], - ["event.detail", /\bevent\.detail\b/], - ["unclassified function", /^\s*(?:async\s+)?function\s+[A-Za-z_$]/m], -]; -for (const file of uiFiles) { - const source = readFileSync(file, "utf8"); - for (const [label, pattern] of forbidden) { - if (pattern.test(source)) fail(`${relative(root, file)} contains ${label}`); - } -} -if (!failures.some((item) => item.startsWith("packages/ui/components/"))) { - pass(`${uiFiles.length} UI components use classified functions and typed output declarations`); -} - -function outputBlocks(source) { - const blocks = []; - for (const match of source.matchAll(/\boutputs\s*\{/g)) { - const brace = source.indexOf("{", match.index); - let depth = 0; - let quote = ""; - for (let index = brace; index < source.length; index++) { - const char = source[index]; - if (quote) { - if (char === "\\") index++; - else if (char === quote) quote = ""; - continue; - } - if (char === '"' || char === "'" || char === "`") quote = char; - else if (char === "{") depth++; - else if (char === "}" && --depth === 0) { - blocks.push(source.slice(brace + 1, index)); - break; - } - } - } - return blocks; -} -for (const file of uiFiles) { - const source = readFileSync(file, "utf8"); - if (outputBlocks(source).some((block) => /\bunknown\b/.test(block))) { - fail(`${relative(root, file)} contains an untyped output payload contract`); - } -} -if (!failures.some((item) => item.includes("untyped output payload contract"))) { - pass("All UI output payload contracts are concrete and contain no unknown types"); -} - -for (const [path, markers] of [ - ["packages/compiler/src/client-codegen.ts", ["RESERVED_BINDINGS", "!parameterNames.has"]], - [ - "packages/compiler/src/store-codegen.ts", - [ - "const server = context.server", - "persist.migrate", - "persist.validate", - "__wrnexusApplyStoreHotUpdate", - ], - ], - ["packages/compiler/src/server-codegen.ts", ["remotelyReferencedServerFunctions"]], - [ - "packages/dev-server/src/pipeline.ts", - ["setCompileImportOptions", 'mode: "compatible"', "WRN-IMPORT-IMPLICIT"], - ], - ["packages/dev-server/src/runtime.ts", ["store-update", "__wrnexusApplyStoreHotUpdate"]], -]) { - const source = readFileSync(join(root, path), "utf8"); - for (const marker of markers) - if (!source.includes(marker)) fail(`${path} is missing focused fix marker: ${marker}`); -} -if (!failures.some((item) => item.includes("focused fix marker"))) { - pass("Focused fixes 1-7 are wired into compiler, store, import, RPC, and HMR sources"); -} - -const storeTypesSource = readFileSync(join(root, "packages/store/src/types.ts"), "utf8"); -const storeRuntimeSource = readFileSync(join(root, "packages/store/src/index.ts"), "utf8"); -const storeCodegenSource = readFileSync( - join(root, "packages/compiler/src/store-codegen.ts"), - "utf8", -); -for (const [label, condition] of [ - [ - "separate client/server state generics", - /StoreCombinedState<[\s\S]*?CS extends object[\s\S]*?SS extends object/.test( - storeTypesSource, - ) && - /createClientState\?: \(\) => CS/.test(storeTypesSource) && - /createServerState\?: \(\) => SS/.test(storeTypesSource), - ], - [ - "callable action default", - /StoreFunction = \(\.\.\.args: any\[\]\) => any/.test(storeTypesSource) && - /Record/.test(storeTypesSource), - ], - [ - "non-colliding initialization promise", - /readonly whenReady: Promise/.test(storeTypesSource) && - !/readonly ready: Promise/.test(storeTypesSource) && - /await instance\.whenReady/.test(storeRuntimeSource), - ], - [ - "browser store whenReady code generation", - /whenReady: Promise\.resolve\(\)/.test(storeCodegenSource) && - /core\.whenReady = init\(\)/.test(storeCodegenSource), - ], -]) { - if (!condition) fail(`Store type regression: missing ${label}`); -} -if (!failures.some((item) => item.startsWith("Store type regression:"))) { - pass( - "Store client/server state, callable actions, and ready-state collision regressions are guarded", - ); -} - -const reactiveRuntimeSource = readFileSync( - join(root, "packages/csr/src/reactive-runtime.ts"), - "utf8", -); -const clientCodegenSource = readFileSync( - join(root, "packages/compiler/src/client-codegen.ts"), - "utf8", -); -const syntaxParserSource = readFileSync(join(root, "packages/syntax/src/parser.ts"), "utf8"); -const compilerMainSource = readFileSync(join(root, "packages/compiler/src/codegen.ts"), "utf8"); -for (const [label, condition] of [ - [ - "synchronous hydration without a browser module", - reactiveRuntimeSource.includes('if (!moduleUrl || moduleUrl === "__WRNEXUS_CLIENT_MODULE__")'), - ], - [ - "runtime binding collision guard", - clientCodegenSource.includes("RUNTIME_BINDINGS") && - clientCodegenSource.includes("!RUNTIME_BINDINGS.has(prop.name)"), - ], - [ - "unknown lifecycle hook validation", - syntaxParserSource.includes("Unknown lifecycle hook") && - syntaxParserSource.includes("Unknown store lifecycle hook"), - ], - [ - "synchronous pages without imported stores", - compilerMainSource.includes('storeBindings.length > 0 ? "async " : ""'), - ], - [ - "mutable store dispose lifecycle", - storeRuntimeSource.includes('runLifecycle("$dispose", definition.lifecycle?.dispose)'), - ], -]) { - if (!condition) fail(`R7 regression: missing ${label}`); -} -const rangeSliderReference = readJson( - join(root, "packages/ui/component-reference.json"), -).components.find((component) => component.name === "RangeSlider"); -if ( - !rangeSliderReference || - !["input", "change", "focus", "blur"].every((name) => rangeSliderReference.events.includes(name)) -) { - fail("R7 regression: RangeSlider output reference is incomplete"); -} -if (!failures.some((item) => item.startsWith("R7 regression:"))) { - pass( - "R7 parser, hydration, codegen, store lifecycle, and component-reference regressions are guarded", - ); -} - -const uiReference = readJson(join(root, "packages/ui/component-reference.json")); -const uiCatalog = readJson(join(root, "packages/ui/component-catalog.json")); -const declaredUiNames = uiFiles - .map((file) => { - const declaration = /^\s*component\s+([A-Za-z][A-Za-z0-9_]*)\b/m.exec( - readFileSync(file, "utf8"), - ); - return declaration?.[1] ?? null; - }) - .filter(Boolean) - .sort(); -const referenceNames = uiReference.components.map((component) => component.name).sort(); -const catalogNames = uiCatalog.components.map((component) => component.name).sort(); -if (JSON.stringify(referenceNames) !== JSON.stringify(declaredUiNames)) { - fail("R8 regression: component reference does not match bundled UI declarations"); -} -if (JSON.stringify(catalogNames) !== JSON.stringify(declaredUiNames)) { - fail("R8 regression: component catalog does not match bundled UI declarations"); -} - -try { - const { compileWireFile } = require(join(root, "editors/vscode/src/compiler.cjs")); - for (const file of uiFiles) { - const output = compileWireFile(readFileSync(file, "utf8"), file); - if (!output.includes("${__wireSpreadAttrs(__attrs)}")) { - fail(`R8 regression: ${relative(root, file)} does not forward native attributes`); - } - } -} catch (error) { - fail(`R8 regression: bundled UI compilation failed: ${error.message}`); -} -if (!failures.some((item) => item.startsWith("R8 regression:"))) { - pass( - "R8 UI compilation, readonly-prop diagnostics, native attributes, catalog, and reference are guarded", - ); -} - -const toggleCountSource = readFileSync( - join(root, "packages/ui/components/ToggleCount.wrn"), - "utf8", -); -for (const [label, condition] of [ - [ - "peer client-function bindings", - clientCodegenSource.includes("context.functions") && - clientCodegenSource.includes("context.functions[name](...args)") && - clientCodegenSource.includes("(...__wrnexusPeerArgs) => context.functions"), - ], - [ - "drift-free ToggleCount animation scheduling", - toggleCountSource.includes("duration * frame / totalFrames") && - toggleCountSource.includes("scheduleValueFrame(") && - toggleCountSource.includes("data-animation-token") && - !toggleCountSource.includes("setTimeout(\n animateValueFrame"), - ], -]) { - if (!condition) fail(`R9 regression: missing ${label}`); -} -if (!failures.some((item) => item.startsWith("R9 regression:"))) { - pass("R9 peer-function binding and drift-free ToggleCount animation are guarded"); -} - -try { - const { parse, generateTargets } = require(join(root, "editors/vscode/src/compiler.cjs")); - const targets = generateTargets( - parse(`component PeerCalls { - state { value: number = 0 } - functions { - client function increment(): void { value += 1 } - client function run(): void { increment() } - } - view { } - }`), - ); - const executable = new Function( - `${targets.browser.replace(/^export\s+/gm, "")}\nreturn { bindClientScope };`, - )(); - const state = { value: 0 }; - const functions = executable.bindClientScope({ - state, - props: {}, - output: {}, - server: {}, - refs: {}, - }); - functions.run(); - if (state.value !== 1) { - fail(`R10 regression: peer client function state was overwritten (${state.value})`); - } else { - pass("R10 peer client functions preserve shared scoped state"); - } -} catch (error) { - fail(`R10 regression: peer client function execution failed: ${error.message}`); -} - -const compilerTargetTestSource = readFileSync( - join(root, "packages/compiler/test/v060-targets.test.ts"), - "utf8", -); -if (!compilerTargetTestSource.includes('targets.browser.replace(/^export\\s+/gm, "")')) { - fail( - "R11 regression: peer-function executable test does not strip export keywords with a whitespace regex", - ); -} else { - pass("R11 peer-function executable regression test uses the correct export-stripping regex"); -} - -const releaseSource = readFileSync(join(root, "scripts/release.ts"), "utf8"); -const referenceGeneratorSource = readFileSync( - join(root, "scripts/generate-ui-component-reference.mjs"), - "utf8", -); -for (const [label, condition] of [ - [ - "canonical generated-reference Git comparison", - releaseSource.includes('["diff", "--quiet", "--", ...generatedFiles]') && - releaseSource.includes('["diff", "--name-status", "--", ...generatedFiles]'), - ], - [ - "component catalog included in the release reference gate", - releaseSource.includes('"packages/ui/component-catalog.json"'), - ], - [ - "platform-independent component reference ordering", - referenceGeneratorSource.includes("function compareText(left, right)") && - referenceGeneratorSource.includes("compareText(left.name, right.name)") && - !referenceGeneratorSource.includes("localeCompare"), - ], - [ - "line-ending-stable generated reference writes", - referenceGeneratorSource.includes("function normalizeNewlines(source)") && - referenceGeneratorSource.includes( - "normalizeNewlines(current) === normalizeNewlines(source)", - ) && - referenceGeneratorSource.includes("return false"), - ], -]) { - if (!condition) fail(`R13 regression: missing ${label}`); -} -if (!failures.some((item) => item.startsWith("R13 regression:"))) { - const referenceGenerationTest = spawnSync( - process.execPath, - [join(root, "scripts/test-ui-reference-generation.mjs")], - { encoding: "utf8" }, - ); - if (referenceGenerationTest.status !== 0) { - fail( - `R13 regression: generated-reference line-ending test failed: ${ - referenceGenerationTest.stderr.trim() || referenceGenerationTest.stdout.trim() - }`, - ); - } else { - pass( - "R13 release reference gate is canonical, complete, platform independent, and line-ending stable", - ); - } -} - -const showcaseFiles = walk(join(root, "examples/component-showcase/app"), (path) => - path.endsWith(".wrn"), -); -for (const file of showcaseFiles) { - const source = readFileSync(file, "utf8"); - if (/\b(?:open|defaultOpen)\s*=\s*(?:"true"|'\{true\}'|"\{true\}")/.test(source)) { - fail(`${relative(root, file)} forces an overlay open`); - } -} -if (!failures.some((item) => item.includes("forces an overlay open"))) { - pass( - `${showcaseFiles.length} generated showcase pages contain no forced-open overlay regression`, - ); -} - -for (const file of walk(root, (path) => path.endsWith(".json"))) { - try { - readJson(file); - } catch (error) { - fail(`Invalid JSON ${relative(root, file)}: ${error.message}`); - } -} -if (!failures.some((item) => item.startsWith("Invalid JSON"))) pass("All JSON files parse"); - -for (const file of walk(root, (path) => /\.(?:mjs|cjs|js)$/.test(path))) { - const check = spawnSync(process.execPath, ["--check", file], { encoding: "utf8" }); - if (check.status !== 0) - fail(`JavaScript syntax failed: ${relative(root, file)}\n${check.stderr.trim()}`); -} -if (!failures.some((item) => item.startsWith("JavaScript syntax failed"))) - pass("JavaScript/MJS syntax checks pass"); - -const bun = spawnSync(process.platform === "win32" ? "bun.exe" : "bun", ["--version"], { - encoding: "utf8", -}); -if (bun.status !== 0) - warnings.push("Bun is unavailable; run `bun install` and `bun run check` on the target machine."); -else pass(`Bun ${bun.stdout.trim()} is available`); - -console.log("WRNexusJS v0.6 validation"); -for (const message of passes) console.log(`PASS ${message}`); -for (const message of warnings) console.warn(`WARN ${message}`); -for (const message of failures) console.error(`FAIL ${message}`); -console.log(`\n${passes.length} passed, ${warnings.length} warnings, ${failures.length} failed`); -process.exitCode = failures.length ? 1 : 0;