fix(i18n): ship valid bundled scripts for locale controls
This commit is contained in:
1 parent
e98cd2b7ce
commit
0d673a3b5a
7 files changed
+61
-17
No files matched your search
+14
-3
@@ -8,9 +8,20 @@ const meta = new URL("../dist/.openai/", import.meta.url);
|
||||
await rm(dist, { recursive: true, force: true });
|
||||
await Promise.all([mkdir(server, { recursive: true }), mkdir(meta, { recursive: true })]);
|
||||
const [rawIndexHtml, addonsJs, bootGuardJs, i18nJs, tasksJs, calendarJs, i18nRenderersJs, manifest, serviceWorker, icon, localeEn, localeZh] = await Promise.all([text("index.html"), text("addons.js"), text("boot-guard.js"), text("i18n.js"), text("tasks.js"), text("calendar.js"), text("i18n-renderers.js"), text("manifest.webmanifest"), text("service-worker.js"), text("life-os-icon.svg"), readFile(new URL("../public/locales/en.json", import.meta.url), "utf8"), readFile(new URL("../public/locales/zh-CN.json", import.meta.url), "utf8")]);
|
||||
const release = "1.5.7";
|
||||
const indexHtml = rawIndexHtml.replace('src="addons.js"', `src="addons.js?v=${release}`).replace('src="tasks.js"', `src="tasks.js?v=${release}`).replace('src="calendar.js"', `src="calendar.js?v=${release}`);
|
||||
await writeFile(new URL("assets.js", server), `export const indexHtml=${JSON.stringify(indexHtml)};\nexport const addonsJs=${JSON.stringify(addonsJs)};\nexport const bootGuardJs=${JSON.stringify(bootGuardJs)};\nexport const i18nJs=${JSON.stringify(i18nJs)};\nexport const localeEn=JSON.parse(${JSON.stringify(localeEn)});\nexport const localeZh=JSON.parse(${JSON.stringify(localeZh)});\nexport const localeEnJs=${JSON.stringify(`window.LifeOSLocales=window.LifeOSLocales||{};window.LifeOSLocales.en=${localeEn};`)};\nexport const localeZhJs=${JSON.stringify(`window.LifeOSLocales=window.LifeOSLocales||{};window.LifeOSLocales['zh-CN']=${localeZh};`)};\nexport const tasksJs=${JSON.stringify(tasksJs)};\nexport const calendarJs=${JSON.stringify(calendarJs)};\nexport const i18nRenderersJs=${JSON.stringify(i18nRenderersJs)};\nexport const manifest=${JSON.stringify(manifest)};\nexport const serviceWorker=${JSON.stringify(serviceWorker)};\nexport const icon=${JSON.stringify(icon)};\n`);
|
||||
const release = "1.5.8";
|
||||
const localeEnJs = `window.LifeOSLocales=window.LifeOSLocales||{};window.LifeOSLocales.en=${localeEn};`;
|
||||
const localeZhJs = `window.LifeOSLocales=window.LifeOSLocales||{};window.LifeOSLocales['zh-CN']=${localeZh};`;
|
||||
let indexHtml = rawIndexHtml.replace('src="addons.js"', `src="addons.js?v=${release}"`).replace('src="tasks.js"', `src="tasks.js?v=${release}"`).replace('src="calendar.js"', `src="calendar.js?v=${release}"`);
|
||||
for (const [src, code] of [
|
||||
['boot-guard.js', bootGuardJs], ['locales/en.js', localeEnJs], ['locales/zh-CN.js', localeZhJs],
|
||||
['i18n.js', i18nJs], ['tasks.js', tasksJs], ['calendar.js', calendarJs],
|
||||
['addons.js', addonsJs], ['i18n-renderers.js', i18nRenderersJs]
|
||||
]) {
|
||||
const tag = `<script src="${src}${src === 'boot-guard.js' ? '' : `?v=${release}`}"></script>`;
|
||||
if (!indexHtml.includes(tag)) throw new Error(`Missing Life OS script: ${src}`);
|
||||
indexHtml = indexHtml.replace(tag, `<script>${code.replace(/<\/script/gi, '<\\/script')}</script>`);
|
||||
}
|
||||
await writeFile(new URL("assets.js", server), `export const indexHtml=${JSON.stringify(indexHtml)};\nexport const addonsJs=${JSON.stringify(addonsJs)};\nexport const bootGuardJs=${JSON.stringify(bootGuardJs)};\nexport const i18nJs=${JSON.stringify(i18nJs)};\nexport const localeEn=JSON.parse(${JSON.stringify(localeEn)});\nexport const localeZh=JSON.parse(${JSON.stringify(localeZh)});\nexport const localeEnJs=${JSON.stringify(localeEnJs)};\nexport const localeZhJs=${JSON.stringify(localeZhJs)};\nexport const tasksJs=${JSON.stringify(tasksJs)};\nexport const calendarJs=${JSON.stringify(calendarJs)};\nexport const i18nRenderersJs=${JSON.stringify(i18nRenderersJs)};\nexport const manifest=${JSON.stringify(manifest)};\nexport const serviceWorker=${JSON.stringify(serviceWorker)};\nexport const icon=${JSON.stringify(icon)};\n`);
|
||||
await copyFile(new URL("../worker/index.js", import.meta.url), new URL("index.js", server));
|
||||
await copyFile(new URL("../.openai/hosting.json", import.meta.url), new URL("hosting.json", meta));
|
||||
console.log("Built Life OS Worker artifact");
|
||||
+39
-5
@@ -4,9 +4,10 @@ import vm from "node:vm";
|
||||
const worker = await import(new URL("../dist/server/index.js", import.meta.url));
|
||||
assert.equal(typeof worker.default?.fetch, "function");
|
||||
const assets = await import(new URL("../dist/server/assets.js", import.meta.url));
|
||||
const source = await readFile(new URL("../public/index.html", import.meta.url), "utf8");
|
||||
assert.match(assets.indexHtml, /!\[1,2\]\.includes\(d\.schemaVersion\)/);
|
||||
assert.doesNotMatch(assets.indexHtml, /localizeDynamicText|createTreeWalker|NodeFilter\.SHOW_TEXT|const ZH=/);
|
||||
assert.match(assets.indexHtml, /locales\/en\.js/);
|
||||
assert.match(assets.indexHtml, /window\.LifeOSLocales\.en=/);
|
||||
assert.match(assets.addonsJs, /window\.exportData = exportData/);
|
||||
assert.match(assets.addonsJs, /schemaVersion: 2/);
|
||||
assert.match(assets.addonsJs, /settings: settings\(\), system: sys\(\)/);
|
||||
@@ -19,8 +20,8 @@ const flatten = (value, prefix = "") => Object.entries(value).flatMap(([key, chi
|
||||
const enKeys = flatten(assets.localeEn), zhKeys = flatten(assets.localeZh);
|
||||
assert.deepEqual(enKeys, zhKeys, "English and Chinese locale keys must match");
|
||||
const resolveKey = (locale, key) => key.split(".").reduce((value, part) => value?.[part], locale);
|
||||
for (const source of [assets.i18nRenderersJs, assets.addonsJs, assets.indexHtml]) {
|
||||
for (const match of source.matchAll(/(?:\bt|LifeI18n\.t)\(['"`]([A-Za-z][\w.]+)['"`]/g)) {
|
||||
for (const script of [assets.i18nRenderersJs, assets.addonsJs, source]) {
|
||||
for (const match of script.matchAll(/(?:\bt|LifeI18n\.t)\(['"`]([A-Za-z][\w.]+)['"`]/g)) {
|
||||
assert.notEqual(resolveKey(assets.localeEn, match[1]), undefined, `Missing locale key: ${match[1]}`);
|
||||
assert.notEqual(resolveKey(assets.localeZh, match[1]), undefined, `Missing Chinese locale key: ${match[1]}`);
|
||||
}
|
||||
@@ -33,7 +34,6 @@ for (const locale of [assets.localeEn, assets.localeZh]) {
|
||||
for (const script of [assets.tasksJs, assets.calendarJs]) {
|
||||
assert.doesNotMatch(script, /createTreeWalker|NodeFilter\.SHOW_TEXT/);
|
||||
}
|
||||
const source = await readFile(new URL("../public/index.html", import.meta.url), "utf8");
|
||||
for (const [, inlineScript] of source.matchAll(/<script>([\s\S]*?)<\/script>/g)) new vm.Script(inlineScript);
|
||||
assert.doesNotMatch(source, /localizeDynamicText|createTreeWalker|NodeFilter\.SHOW_TEXT|const ZH=/);
|
||||
assert.match(source, /id==='system'\)window\.systemApp\(c\)/);
|
||||
@@ -44,7 +44,11 @@ assert.match(assets.indexHtml, /setTimeout\(\(\)=>window\.applyLanguage\?\.\(\),
|
||||
assert.doesNotMatch(assets.indexHtml, /setTimeout\(applyLanguage\s*,/);
|
||||
assert.doesNotMatch(assets.addonsJs, /(?<![\w.])applyLanguage\s*\(/, "add-on actions must not call a missing global locale function");
|
||||
assert.match(assets.indexHtml, /window\.renderIcons=renderIcons;window\.renderAll=renderAll/, "shared renderers are explicitly exposed to locale scripts");
|
||||
assert.ok(assets.indexHtml.indexOf('src="i18n-renderers.js?v=1.5.7"') < assets.indexHtml.lastIndexOf("window.init()"), "locale renderers load before desktop initialization");
|
||||
const shippedScripts = [...assets.indexHtml.matchAll(/<script>([\s\S]*?)<\/script>/g)].map(match => match[1]);
|
||||
assert.equal(shippedScripts.length, 10, "the complete desktop script chain is present in the served HTML");
|
||||
assert.doesNotMatch(assets.indexHtml, /<script\s+src=/, "the desktop cannot silently fall back when an app script fails to load");
|
||||
assert.ok(assets.indexHtml.indexOf('window.systemApp =') < assets.indexHtml.lastIndexOf("window.init()"), "locale renderers run before desktop initialization");
|
||||
for (const script of shippedScripts) new vm.Script(script);
|
||||
for (const script of [assets.i18nJs, assets.tasksJs, assets.calendarJs, assets.i18nRenderersJs, assets.addonsJs]) new vm.Script(script);
|
||||
assert.doesNotMatch(source, /Version 0\.1|Schema v1/);
|
||||
const makeStorage = initial => {
|
||||
@@ -109,6 +113,36 @@ assert.equal(migratedContext.LifeI18n.language(), "zh", "legacy manual preferenc
|
||||
assert.match(assets.i18nRenderersJs, /preference === 'zh'/);
|
||||
assert.match(assets.i18nRenderersJs, /onclick=\"window\.setLanguage\('zh'\)\"/);
|
||||
assert.doesNotMatch(assets.addonsJs, /lifeos_language_preference_v1/, "the device locale preference stays outside cloud-synced keys");
|
||||
const uiNodes = new Map();
|
||||
const uiNode = () => ({ innerHTML: "", textContent: "", style: {}, dataset: {}, classList: { add() {}, remove() {}, toggle() {}, contains: () => false }, remove() {}, append() {}, querySelector: () => null, querySelectorAll: () => [] });
|
||||
for (const id of ["icons", "tasks", "launcher", "calendar", "desktop", "boot", "modal", "windows", "clock", "date", "desktop-brand", "life-launcher", "content-system"]) uiNodes.set(`#${id}`, uiNode());
|
||||
const uiDocument = {
|
||||
documentElement: { dataset: {}, lang: "en" }, title: "", head: { insertAdjacentHTML() {}, append() {} }, body: { append() {} },
|
||||
querySelector: selector => uiNodes.get(selector) || null, getElementById: id => uiNodes.get(`#${id}`) || null,
|
||||
createElement: uiNode, addEventListener() {}, querySelectorAll: () => []
|
||||
};
|
||||
const uiContext = vm.createContext({
|
||||
document: uiDocument, navigator: { languages: ["en-US"], language: "en-US" }, localStorage: makeStorage(),
|
||||
Blob, crypto: globalThis.crypto, innerWidth: 1000, innerHeight: 800,
|
||||
matchMedia: () => ({ matches: false }), setTimeout: () => 0, setInterval: () => 0,
|
||||
fetch: async () => ({ ok: false, json: async () => ({}) }), console,
|
||||
...Object.fromEntries(["icons", "tasks", "launcher", "calendar", "desktop", "boot", "modal"].map(id => [id, uiNodes.get(`#${id}`)]))
|
||||
});
|
||||
uiContext.window = uiContext;
|
||||
for (const index of [0, 2, 3, 4, 8]) vm.runInContext(shippedScripts[index], uiContext);
|
||||
vm.runInContext("windows.system={classList:{contains(){return false}}}", uiContext);
|
||||
uiContext.setLanguage("zh");
|
||||
assert.equal(uiContext.LifeI18n.language(), "zh");
|
||||
assert.match(uiNodes.get("#content-system").innerHTML, /系统设置/);
|
||||
assert.match(uiNodes.get("#icons").innerHTML, /今日/);
|
||||
assert.equal(uiDocument.documentElement.lang, "zh-CN");
|
||||
uiContext.setLanguage("en");
|
||||
assert.match(uiNodes.get("#content-system").innerHTML, /SYSTEM SETTINGS/);
|
||||
assert.match(uiNodes.get("#icons").innerHTML, /TODAY/);
|
||||
uiContext.navigator.languages = ["zh-CN"];
|
||||
uiContext.setLanguage("auto");
|
||||
assert.match(uiNodes.get("#content-system").innerHTML, /系统设置/);
|
||||
assert.equal(uiContext.LifeI18n.preference(), "auto");
|
||||
const zhLogin = await worker.default.fetch(new Request("https://life-os.test/", { headers: { "accept-language": "zh-CN,zh;q=0.9" } }), {});
|
||||
assert.match(await zhLogin.text(), /登录 ChatGPT/);
|
||||
const enLogin = await worker.default.fetch(new Request("https://life-os.test/", { headers: { "accept-language": "en-US,en;q=0.9" } }), {});
|
||||
|
||||
Reference in new issue
Block a user