diff --git a/docs/public-api-0.8.json b/docs/public-api-0.8.json index e15bf813..aa114d55 100644 --- a/docs/public-api-0.8.json +++ b/docs/public-api-0.8.json @@ -1674,6 +1674,7 @@ "@wrnexus/i18n": { ".": [ "ExtractedTranslationKey", + "I18N_DATA_ATTRIBUTE", "I18N_JS_HREF", "I18N_RUNTIME", "I18nConfig", @@ -1710,6 +1711,7 @@ "plural", "pseudoLocalize", "renderI18nData", + "renderI18nDataTag", "resolveI18n", "resolveLang", "translateHtml", diff --git a/packages/csr/src/nav-runtime.ts b/packages/csr/src/nav-runtime.ts index dbcc912c..6a5a9e93 100644 --- a/packages/csr/src/nav-runtime.ts +++ b/packages/csr/src/nav-runtime.ts @@ -368,15 +368,10 @@ 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()); }, - ); + var script = nextDocument.querySelector('script[type="application/json"][data-wrn-i18n]'); 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 incoming = JSON.parse(String(script.textContent || "{}")); var current = window.__wrnI18n || {}; var translator = current.t; var setter = current.set; diff --git a/packages/csr/test/nav.test.ts b/packages/csr/test/nav.test.ts index b733618a..3cdceae8 100644 --- a/packages/csr/test/nav.test.ts +++ b/packages/csr/test/nav.test.ts @@ -148,7 +148,7 @@ test("synchronizes and rebinds i18n data during client navigation", async () => win.__wrnLang = { bind: (root: unknown) => (boundRoot = root) }; nextHtml = `

नवीन

` + - `` + + `` + ``; win.document.getElementById("lnk").click(); @@ -180,7 +180,7 @@ test("preserves same-language translations when an incoming navigation catalog i }; nextHtml = `

navigation.home

` + - `` + + `` + ``; win.document.getElementById("lnk").click(); diff --git a/packages/dev-server/src/runtime.ts b/packages/dev-server/src/runtime.ts index 740d5823..50f82bab 100644 --- a/packages/dev-server/src/runtime.ts +++ b/packages/dev-server/src/runtime.ts @@ -81,7 +81,7 @@ import { type TenancyConfig, } from "@wrnexus/styles"; import { - renderI18nData, + renderI18nDataTag, makeT, resolveLang, translateHtml, @@ -672,22 +672,16 @@ 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()); }, - ); + var i18nScript = doc.querySelector('script[type="application/json"][data-wrn-i18n]'); if (i18nScript) { - var i18nMatch = /^window[.]__wrnI18n=([^]*);\\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); - } + try { + var incomingI18n = JSON.parse(String(i18nScript.textContent || "{}")); + 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); } } @@ -1978,9 +1972,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers { extraBody: [ renderStoreHydration(storeContainer, (ctx.locals.cspNonce as string) ?? undefined), - deps.i18n - ? `${renderI18nData(deps.i18n, language)}` - : "", + deps.i18n ? renderI18nDataTag(deps.i18n, language) : "", hmr ? hmrClientTag((ctx.locals.cspNonce as string) ?? "") : "", shouldEnableDevToolbar(mode, deps) ? DEV_TOOLBAR_SCRIPT : "", ] diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts index fb6f5cda..72b209d0 100644 --- a/packages/i18n/src/index.ts +++ b/packages/i18n/src/index.ts @@ -425,9 +425,20 @@ function safeJson(value: unknown): string { .replace(/\u2029/g, "\\u2029"); } +/** Attribute marking the JSON block that carries per-request i18n data. */ +export const I18N_DATA_ATTRIBUTE = "data-wrn-i18n"; + +/** + * The i18n payload, emitted as JSON rather than as an assignment. + * + * It ships inside a `type="application/json"` block, which the browser never + * executes, so `script-src` does not apply to it. As an inline executable + * script it was blocked whenever the surrounding document's CSP nonce came + * from a different response, leaving window.__wrnI18n undefined. + */ export function renderI18nData(i18n: ResolvedI18n, lang: string): string { const active = i18n.langs.includes(lang) ? lang : i18n.default; - return `window.__wrnI18n=${safeJson({ + return `${safeJson({ lang: active, langs: i18n.langs, default: i18n.default, @@ -437,7 +448,12 @@ export function renderI18nData(i18n: ResolvedI18n, lang: string): string { directions: i18n.direction, labels: i18n.labels, cookie: i18n.cookie, - })};`; + })}`; +} + +/** The full JSON block, including its script tag. */ +export function renderI18nDataTag(i18n: ResolvedI18n, lang: string): string { + return ``; } export const I18N_RUNTIME = String.raw` @@ -457,7 +473,23 @@ export const I18N_RUNTIME = String.raw` return params && Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : "{" + name + "}"; }); } - function state() { return window.__wrnI18n || {}; } + function readDataBlock() { + var node = document.querySelector('script[type="application/json"][data-wrn-i18n]'); + if (!node) return null; + try { + return JSON.parse(node.textContent || "{}"); + } catch (error) { + console.error("[wrnexus] i18n data block was not valid JSON", error); + return null; + } + } + function state() { + if (!window.__wrnI18n) { + var data = readDataBlock(); + if (data) window.__wrnI18n = data; + } + return window.__wrnI18n || {}; + } function t(key, params) { var current = state(); return interpolate(lookup(current.messages, key) || lookup(current.fallbackMessages, key) || key, params); diff --git a/packages/i18n/test/i18n-data-block.test.ts b/packages/i18n/test/i18n-data-block.test.ts new file mode 100644 index 00000000..9250bab3 --- /dev/null +++ b/packages/i18n/test/i18n-data-block.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from "bun:test"; +import { I18N_RUNTIME, renderI18nData, renderI18nDataTag, resolveI18n } from "../src/index.ts"; + +const i18n = resolveI18n({ en: { hello: "Hello" }, es: { hello: "Hola" } }, { default: "en" }); + +test("the i18n payload is plain JSON, not an assignment", () => { + const data = renderI18nData(i18n, "en"); + expect(() => JSON.parse(data)).not.toThrow(); + expect(data).not.toContain("window.__wrnI18n"); +}); + +test("the data tag is a non-executable JSON block", () => { + // An executable inline script is subject to script-src and gets blocked + // whenever the document's CSP nonce came from a different response, which is + // what left window.__wrnI18n undefined. A JSON block is never executed. + const tag = renderI18nDataTag(i18n, "es"); + expect(tag).toContain('type="application/json"'); + expect(tag).toContain("data-wrn-i18n"); + expect(tag).not.toContain("nonce="); + expect(tag).toContain("Hola"); +}); + +test("the i18n runtime reads the data block instead of relying on an inline assignment", () => { + expect(I18N_RUNTIME).toContain('script[type="application/json"][data-wrn-i18n]'); +}); diff --git a/scripts/validate-0.8.mjs b/scripts/validate-0.8.mjs index 3bcd3ba9..217752d3 100644 --- a/scripts/validate-0.8.mjs +++ b/scripts/validate-0.8.mjs @@ -145,7 +145,7 @@ check( ); check( "i18n runtime data is injected into rendered documents", - has("packages/dev-server/src/runtime.ts", "renderI18nData(deps.i18n, language)") && + has("packages/dev-server/src/runtime.ts", "renderI18nDataTag(deps.i18n, language)") && has("packages/dev-server/src/runtime.ts", "deps.i18n.cookie.name"), ); check(