fix(i18n): ship i18n data as a JSON block so CSP cannot block it
window.__wrnI18n was undefined in development: the payload shipped as an executable inline script, and a document's CSP nonce is fixed at load, so any such script arriving from a later response is blocked. Client translations and language switching silently had no data. The payload is now a type="application/json" block, which the browser never executes and script-src therefore never applies to. The i18n runtime, CSR navigation, and HMR all read the block instead of matching window.__wrnI18n= with a regex. Pages now render zero executable inline scripts, so an inline script-src violation is structurally impossible rather than merely unobserved. Zero framework JavaScript on island-free routes is unaffected: the block is inert data, and nothing loads to read it unless the page needs it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 `<script type="application/json" ${I18N_DATA_ATTRIBUTE}>${renderI18nData(i18n, lang)}</script>`;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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]');
|
||||
});
|
||||
Reference in New Issue
Block a user