release: WRNexusJS 0.3.5
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.3.4",
|
||||
"version": "0.3.5",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/&/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 `"` text.
|
||||
for (let pass = 0; pass < 2; pass += 1) {
|
||||
const next = decoded
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/&/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;
|
||||
}
|
||||
|
||||
@@ -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="[{&quot;label&quot;:&quot;First&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", () => {
|
||||
|
||||
Reference in New Issue
Block a user