release: WRNexusJS 0.3.5

This commit is contained in:
2026-07-24 12:46:44 +05:30
parent 44ba847210
commit c81dedff17
2114 changed files with 65790 additions and 150559 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-server",
"version": "0.3.4",
"version": "0.3.5",
"type": "module",
"main": "src/index.ts",
"exports": {
+64 -11
View File
@@ -1195,6 +1195,11 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
};
pageCtx.__wrnexusCallApi = (path, method = "GET") => callApiFromContext(ctx, path, method);
let body = await renderComponents(String(await component(pageCtx)), ctx.t);
const resolvedTheme = deps.theme
? resolveThemeName(ctx.cookies.get(THEME_COOKIE), deps.theme)
: "";
const language = deps.i18n && ctx.lang ? ctx.lang : (meta.lang ?? deps.seo?.lang ?? "en");
let documentTemplate: string | undefined;
// Page layout: a page selects one by exporting `layout = "<name>"`
// (app/layouts/<name>.wrn), else falls back to a `default` layout if one
@@ -1229,6 +1234,46 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
}
}
// A conventional app/layouts/document.wrn is a request-aware, top-level
// document shell. It wraps the selected page layout and may own html, head,
// body, and #app. The SSR renderer still merges framework metadata/assets.
const documentLayout =
layoutName === "document"
? undefined
: router.layouts.find((item) => item.name === "document");
if (documentLayout) {
try {
const documentMod = await loadModule(documentLayout.file);
const documentRender = (
documentMod as { render?: (props: Record<string, unknown>) => string }
).render;
if (typeof documentRender === "function") {
const rendered = String(
documentRender({
cookies: ctx.cookies.getAll(),
theme: resolvedTheme,
language,
url: ctx.url.toString(),
pathname: ctx.url.pathname,
}),
);
documentTemplate = await renderComponents(fillSlots(rendered, body), ctx.t);
body = documentTemplate;
}
} catch (err) {
console.error("[wrnexus] document layout failed to render", err);
deps.devToolbar?.collector.add(
issueFromError(err, {
ruleId: "server/document-layout-render-error",
category: "server",
title: "Document layout failed to render",
pathname: ctx.url.pathname,
source: { file: documentLayout.file },
}),
);
}
}
if (isMobileRequest) body = revealMobileOnlyHtml(body);
// i18n: resolve `{t:key}` / `t:attr` markers against the request language.
@@ -1243,9 +1288,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
// <html> attributes: no-flash theme (from cookie, validated) + active lang.
const attrs: string[] = [];
if (deps.theme)
attrs.push(`data-theme="${resolveThemeName(ctx.cookies.get(THEME_COOKIE), deps.theme)}"`);
const language = deps.i18n && ctx.lang ? ctx.lang : (meta.lang ?? deps.seo?.lang ?? "en");
if (deps.theme) attrs.push(`data-theme="${resolvedTheme}"`);
attrs.push(`lang="${safeLanguageTag(language)}"`);
const htmlAttrs = attrs.length ? ` ${attrs.join(" ")}` : undefined;
@@ -1271,6 +1314,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
.filter(Boolean)
.join("\n") || undefined,
htmlAttrs,
documentTemplate,
});
// Conditional GET: hash the page CONTENT (`body`), not the assembled shell —
// the shell carries a per-request CSP nonce in dev, which would otherwise make
@@ -1392,12 +1436,21 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
* attribute values, so component props arrive as the author wrote them.
*/
function decodeHtmlEntities(value: string): string {
return value
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/g, "&");
let decoded = value;
// Static page attributes can be escaped by their author/generator and then
// escaped again by the page compiler. Decode both layers so structured
// component props arrive as valid JSON instead of `&quot;` text.
for (let pass = 0; pass < 2; pass += 1) {
const next = decoded
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/g, "&");
if (next === decoded) break;
decoded = next;
}
return decoded;
}
/** Content types worth gzipping (text + text-like application types). */
@@ -1554,10 +1607,10 @@ export function fillSlots(html: string, inner: string): string {
*/
export function parseComponentProps(attrStr: string): Record<string, string> {
const props: Record<string, string> = {};
for (const m of attrStr.matchAll(/([A-Za-z_][\w-]*)="([^"]*)"/g)) {
for (const m of attrStr.matchAll(/([A-Za-z_][\w-]*)(?:="([^"]*)")?/g)) {
const key = m[1]!;
if (key === "data-component") continue;
props[key] = decodeHtmlEntities(m[2]!);
props[key] = decodeHtmlEntities(m[2] ?? "");
}
return props;
}
+12 -2
View File
@@ -2,8 +2,18 @@ import { test, expect } from "bun:test";
import { parseComponentProps, resolveTProps } from "../src/runtime.ts";
test("parseComponentProps extracts quoted attrs, skips data-component", () => {
const props = parseComponentProps('data-component="badge" label="New" count="3"');
expect(props).toEqual({ label: "New", count: "3" });
const props = parseComponentProps(
'data-component="badge" label="New" count="3" disabled autofocus',
);
expect(props).toEqual({ label: "New", count: "3", disabled: "", autofocus: "" });
});
test("parseComponentProps decodes compiler-escaped structured props", () => {
const props = parseComponentProps(
'data-component="list" items="[{&amp;quot;label&amp;quot;:&amp;quot;First&amp;quot;}]"',
);
expect(props.items).toBe('[{"label":"First"}]');
expect(JSON.parse(props.items!)).toEqual([{ label: "First" }]);
});
test("resolveTProps translates {t:key} markers via the active language", () => {