diff --git a/bun.lock b/bun.lock
index dbc7f035..e2dbb42f 100644
--- a/bun.lock
+++ b/bun.lock
@@ -317,7 +317,7 @@
},
"packages/csr": {
"name": "@wrnexus/csr",
- "version": "0.8.19",
+ "version": "0.8.20",
"dependencies": {
"@wrnexus/core": "workspace:*",
},
@@ -332,7 +332,7 @@
},
"packages/dev-server": {
"name": "@wrnexus/dev-server",
- "version": "0.8.29",
+ "version": "0.8.30",
"dependencies": {
"@wrnexus/authz": "workspace:*",
"@wrnexus/cache": "workspace:*",
@@ -617,7 +617,7 @@
},
"packages/ui": {
"name": "@wrnexus/ui",
- "version": "0.8.16",
+ "version": "0.8.17",
"dependencies": {
"@wrnexus/core": "workspace:*",
},
diff --git a/packages/csr/package.json b/packages/csr/package.json
index 58f32450..f1b36e46 100644
--- a/packages/csr/package.json
+++ b/packages/csr/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/csr",
- "version": "0.8.19",
+ "version": "0.8.20",
"type": "module",
"main": "src/index.ts",
"exports": {
diff --git a/packages/csr/src/nav-runtime.ts b/packages/csr/src/nav-runtime.ts
index c5fd87b4..753f0685 100644
--- a/packages/csr/src/nav-runtime.ts
+++ b/packages/csr/src/nav-runtime.ts
@@ -367,6 +367,27 @@ export const NAV_RUNTIME = String.raw`
});
}
+ function syncI18n(nextDocument) {
+ var script = Array.prototype.find.call(
+ nextDocument.querySelectorAll("script:not([src])"),
+ function (node) { return /^window\.__wrnI18n=/.test(String(node.textContent || "").trim()); },
+ );
+ if (!script) return;
+ var match = /^window\.__wrnI18n=([\s\S]*);\s*$/.exec(String(script.textContent || "").trim());
+ if (!match) return;
+ try {
+ var incoming = JSON.parse(match[1]);
+ var current = window.__wrnI18n || {};
+ var translator = current.t;
+ var setter = current.set;
+ window.__wrnI18n = incoming;
+ if (translator) window.__wrnI18n.t = translator;
+ if (setter) window.__wrnI18n.set = setter;
+ } catch (error) {
+ console.error("[wrnexus] failed to synchronize i18n data", error);
+ }
+ }
+
/**
* Run explicit component cleanup before removing the existing page.
*
@@ -516,6 +537,8 @@ export const NAV_RUNTIME = String.raw`
syncPreservationPolicy(doc);
+ syncI18n(doc);
+
syncWrnStyles(doc);
var importedNodes = [];
@@ -568,6 +591,10 @@ export const NAV_RUNTIME = String.raw`
*/
rehydrate(currentApp);
+ if (window.__wrnLang && typeof window.__wrnLang.bind === "function") {
+ window.__wrnLang.bind(currentApp);
+ }
+
if (!isPop) {
history.pushState(
{
diff --git a/packages/csr/test/nav.test.ts b/packages/csr/test/nav.test.ts
index 50857717..7d1bb54e 100644
--- a/packages/csr/test/nav.test.ts
+++ b/packages/csr/test/nav.test.ts
@@ -139,6 +139,28 @@ test("rebinds theme controls after swapping the page", async () => {
expect(win.document.querySelector("[data-wrn-theme-toggle]")).not.toBeNull();
});
+test("synchronizes and rebinds i18n data during client navigation", async () => {
+ install(`
`);
+ let boundRoot: unknown;
+ const translate = () => "translated";
+ const setLanguage = () => true;
+ win.__wrnI18n = { lang: "en", messages: { old: "Old" }, t: translate, set: setLanguage };
+ win.__wrnLang = { bind: (root: unknown) => (boundRoot = root) };
+ nextHtml =
+ `` +
+ `` +
+ ``;
+
+ win.document.getElementById("lnk").click();
+ await flush();
+
+ expect(win.__wrnI18n.lang).toBe("mr");
+ expect(win.__wrnI18n.messages.home.title).toBe("नवीन");
+ expect(win.__wrnI18n.t).toBe(translate);
+ expect(win.__wrnI18n.set).toBe(setLanguage);
+ expect(boundRoot).toBe(win.document.getElementById("app"));
+});
+
test("unmounts and remounts package runtimes during client navigation", async () => {
install(
``,
diff --git a/packages/dev-server/README.md b/packages/dev-server/README.md
index 9290e0ad..d2f25190 100644
--- a/packages/dev-server/README.md
+++ b/packages/dev-server/README.md
@@ -66,6 +66,14 @@ interface RunningServer {
In development, `startServer` also connects `app/db/migrations` (and `app/db//migrations`) and auto-applies migrations, then starts an in-process file watcher. CSS edits hot-swap live. Page, component, layout, API, middleware, realtime, schema, locale, and public-asset edits invalidate only their cached modules, rescan routes where necessary, and morph fresh HTML through the existing HMR WebSocket. The server process and active gateway stay running.
+### Custom not-found handlers
+
+Add `app/pages/404.wrn` to customize unmatched frontend routes. The rendered
+page keeps the requested response's HTTP `404` status. Add `app/api/404.ts`
+with normal HTTP method exports to customize unmatched backend/API responses;
+its response body and headers are preserved and its status is normalized to
+`404`.
+
`getWrnCompileMetrics()` exposes cumulative content-addressed compiler cache
`hits`, `misses`, successful `compilations`, `errors`, `totalDurationMs`, and
`lastDurationMs` for the DevToolbar or custom diagnostics. Tests and embedded
diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json
index 8c6b7d4b..3fead6a8 100644
--- a/packages/dev-server/package.json
+++ b/packages/dev-server/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-server",
- "version": "0.8.29",
+ "version": "0.8.30",
"type": "module",
"main": "src/index.ts",
"exports": {
diff --git a/packages/dev-server/src/runtime.ts b/packages/dev-server/src/runtime.ts
index 5eb37f1d..0f8e56d8 100644
--- a/packages/dev-server/src/runtime.ts
+++ b/packages/dev-server/src/runtime.ts
@@ -672,6 +672,25 @@ export const HMR_CLIENT_JS = `
pendingSync = false;
var doc = new DOMParser().parseFromString(html, "text/html");
+ var i18nScript = Array.prototype.find.call(
+ doc.querySelectorAll("script:not([src])"),
+ function (node) { return /^window\.__wrnI18n=/.test(String(node.textContent || "").trim()); },
+ );
+ if (i18nScript) {
+ var i18nMatch = /^window\.__wrnI18n=([\s\S]*);\s*$/.exec(String(i18nScript.textContent || "").trim());
+ if (i18nMatch) {
+ try {
+ var incomingI18n = JSON.parse(i18nMatch[1]);
+ var existingI18n = window.__wrnI18n || {};
+ incomingI18n.t = existingI18n.t;
+ incomingI18n.set = existingI18n.set;
+ window.__wrnI18n = incomingI18n;
+ } catch (error) {
+ console.error("[wrnexus] failed to synchronize i18n HMR data", error);
+ }
+ }
+ }
+
if (doc.title) {
document.title = doc.title;
}
@@ -704,6 +723,10 @@ export const HMR_CLIENT_JS = `
window.__wrnexusHydrateCsrFetches(document);
}
+ if (window.__wrnLang && typeof window.__wrnLang.bind === "function") {
+ window.__wrnLang.bind(document);
+ }
+
if (
window.wrnTheme &&
typeof window.wrnTheme.bind === "function"
@@ -1449,7 +1472,23 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
async function handleApi(ctx: Context): Promise {
const matched = router.matchApi(ctx.url.pathname);
- if (!matched) return Response.json({ error: "Not Found" }, { status: 404 });
+ if (!matched) {
+ const fallback = router.matchApi("/api/404");
+ if (fallback && ctx.url.pathname !== "/api/404") {
+ const originalPath = ctx.url.pathname;
+ ctx.url.pathname = "/api/404";
+ try {
+ const response = await handleApi(ctx);
+ return new Response(response.body, {
+ status: 404,
+ headers: response.headers,
+ });
+ } finally {
+ ctx.url.pathname = originalPath;
+ }
+ }
+ return Response.json({ error: "Not Found" }, { status: 404 });
+ }
// Expose the canonical matched route to package dispatchers. A package may
// contribute several URL paths from one module, and request URLs can be
@@ -1606,7 +1645,23 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
}),
);
}
- const response = renderNotFound();
+ let response: Response;
+ const fallback = router.matchPage("/404");
+ if (fallback && ctx.url.pathname !== "/404") {
+ const originalPath = ctx.url.pathname;
+ ctx.url.pathname = "/404";
+ try {
+ const rendered = await handlePage(ctx);
+ response = new Response(rendered.body, {
+ status: 404,
+ headers: rendered.headers,
+ });
+ } finally {
+ ctx.url.pathname = originalPath;
+ }
+ } else {
+ response = renderNotFound();
+ }
if (!isMobileRequest) return response;
const headers = new Headers(response.headers);
headers.set("x-wrnexus-original-status", "404");
diff --git a/packages/dev-server/test/not-found-runtime.test.ts b/packages/dev-server/test/not-found-runtime.test.ts
new file mode 100644
index 00000000..74090c6a
--- /dev/null
+++ b/packages/dev-server/test/not-found-runtime.test.ts
@@ -0,0 +1,51 @@
+import { afterEach, expect, test } from "bun:test";
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import { buildRouter } from "@wrnexus/router";
+import { createHandlers, type RuntimeDeps } from "../src/runtime.ts";
+
+const roots: string[] = [];
+afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })));
+
+function customNotFoundRuntime() {
+ const root = mkdtempSync(join(tmpdir(), "wrnexus-not-found-"));
+ roots.push(root);
+ const app = join(root, "app");
+ mkdirSync(join(app, "pages"), { recursive: true });
+ mkdirSync(join(app, "api"), { recursive: true });
+ writeFileSync(join(app, "pages/404.ts"), "export default () => '';");
+ writeFileSync(join(app, "api/404.ts"), "export const GET = () => null;");
+ return createHandlers({
+ mode: "production",
+ hmr: false,
+ router: buildRouter(app),
+ loadModule: async (file) =>
+ file.includes(`${join("api", "404")}.ts`)
+ ? { GET: () => Response.json({ code: "CUSTOM_NOT_FOUND" }, { headers: { "x-custom": "yes" } }) }
+ : { default: () => "That page is gone
" },
+ getMiddleware: async () => [],
+ assets: { serve: async () => null },
+ } satisfies RuntimeDeps);
+}
+
+const server = { upgrade: () => false };
+
+test("renders app/pages/404 with an HTTP 404 status", async () => {
+ const response = await customNotFoundRuntime().fetch(
+ new Request("https://example.test/missing"),
+ server,
+ );
+ expect(response?.status).toBe(404);
+ expect(await response?.text()).toContain("That page is gone");
+});
+
+test("uses app/api/404 for unmatched API routes and preserves headers", async () => {
+ const response = await customNotFoundRuntime().fetch(
+ new Request("https://example.test/api/missing"),
+ server,
+ );
+ expect(response?.status).toBe(404);
+ expect(response?.headers.get("x-custom")).toBe("yes");
+ expect(await response?.json()).toEqual({ code: "CUSTOM_NOT_FOUND" });
+});
diff --git a/packages/ui/components/Card.wrn b/packages/ui/components/Card.wrn
index f1f560bf..38586080 100644
--- a/packages/ui/components/Card.wrn
+++ b/packages/ui/components/Card.wrn
@@ -10,14 +10,18 @@ component Card {
props {
title: string = "Card title"
+ titleKey: string = ""
subtitle: string = ""
+ subtitleKey: string = ""
description: string = ""
+ descriptionKey: string = ""
header: string = ""
footer: string = ""
imageSrc: string = ""
imageAlt: string = ""
imagePosition: string = "top"
actionLabel: string = ""
+ actionLabelKey: string = ""
actionHref: string = ""
headerActions: unknown[] = []
navigation: unknown[] = []
@@ -105,13 +109,13 @@ component Card {
{#if item.title || item.label}
-
{item.title || item.label}
+
{item.title || item.label}
{/if}
{#if item.subtitle}
-
{item.subtitle}
+
{item.subtitle}
{/if}
{#if item.description}
-
{item.description}
+
{item.description}
{/if}
{#if item.actionLabel || item.href}
- {item.actionLabel || "Learn more"}
+ {item.actionLabel || "Learn more"}
{/if}
@@ -168,10 +172,10 @@ component Card {
{header}
{/if}
{#if title}
-
{title}
+
{title}
{/if}
{#if subtitle}
-
{subtitle}
+
{subtitle}
{/if}
@@ -268,7 +272,7 @@ component Card {
{:else}
{#if description}
- {description}
+ {description}
{/if}
{/if}
@@ -285,7 +289,7 @@ component Card {
class="wrn-next__card-action"
@click='output.action({ href: actionHref, label: actionLabel })'
>
- {actionLabel}
+ {actionLabel}
{/if}
diff --git a/packages/ui/components/Carousel.wrn b/packages/ui/components/Carousel.wrn
index fa587ec6..901ae661 100644
--- a/packages/ui/components/Carousel.wrn
+++ b/packages/ui/components/Carousel.wrn
@@ -388,11 +388,11 @@ component Carousel {
{/if}
- {#if item.eyebrow}
{item.eyebrow}{/if}
- {#if item.title || item.label}
{item.title || item.label}
{/if}
- {#if item.description}
{item.description}
{/if}
+ {#if item.eyebrow}
{item.eyebrow}{/if}
+ {#if item.title || item.label}
{item.title || item.label}
{/if}
+ {#if item.description}
{item.description}
{/if}
{#if item.actionLabel}
-
{item.actionLabel}
+
{item.actionLabel}
{/if}
diff --git a/packages/ui/components/Footer.wrn b/packages/ui/components/Footer.wrn
index 08fa4fa1..0925eb4a 100644
--- a/packages/ui/components/Footer.wrn
+++ b/packages/ui/components/Footer.wrn
@@ -13,9 +13,23 @@ component Footer {
columns: number = 3
maxWidth: string = "compact"
copyright: string = ""
+ copyrightKey: string = ""
+ translationPrefix: string = ""
class: string = ""
}
+ functions {
+ shared function translationKey(item, field) {
+ if (!item) return ""
+ const explicit = item[field + "Key"]
+ if (explicit) return explicit
+ if (!translationPrefix) return ""
+ const source = item.value || item.href || item.label || item.title || ""
+ const slug = String(source).replace(/^\/+|\/+$/g, "").replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase()
+ return slug ? translationPrefix + "." + slug + (field === "description" ? ".description" : "") : ""
+ }
+ }
+
view {