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 =
`` +
- `` +
+ `` +
``;
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
- ? ``
- : "",
+ 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(