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(`
About
`); + 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( `
Old
Next
`, 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 { {item.imageAlt || item.title || ''} {/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 {