feat(i18n): expand Life OS to ten locales
This commit is contained in:
1 parent
a88a7f550f
commit
87276a051e
25 files changed
+6080
-87
No files matched your search
+7
-3
@@ -8,12 +8,16 @@ 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, notificationsJs, i18nRenderersJs, desktopPersonalizationJs, desktopWidgetsJs, systemNavigationJs, browserAppJs, dataActionsJs, terminalAppJs, dbAdminJs, 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("notifications.js"), text("i18n-renderers.js"), text("desktop-personalization.js"), text("desktop-widgets.js"), text("system-navigation.js"), text("browser-app.js"), text("data-actions.js"), text("terminal-app.js"), text("db-admin.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.16.0";
|
||||
const release = "1.17.0";
|
||||
const localeEnJs = `window.LifeOSLocales=window.LifeOSLocales||{};window.LifeOSLocales.en=${localeEn};`;
|
||||
const localeZhJs = `window.LifeOSLocales=window.LifeOSLocales||{};window.LifeOSLocales['zh-CN']=${localeZh};`;
|
||||
const extraLocaleCodes = ['zh-HK', 'zh-MO', 'zh-TW', 'ar', 'fr', 'ru', 'es', 'ja'];
|
||||
const extraLocaleValues = await Promise.all(extraLocaleCodes.map(code => text(`locales/${code}.json`)));
|
||||
const localeScripts = Object.fromEntries(extraLocaleCodes.map((code, index) => [code, `window.LifeOSLocales['${code}']=${extraLocaleValues[index]};`]));
|
||||
const localeData = Object.fromEntries(extraLocaleCodes.map((code, index) => [code, JSON.parse(extraLocaleValues[index])]));
|
||||
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],
|
||||
['boot-guard.js', bootGuardJs], ['locales/en.js', localeEnJs], ['locales/zh-CN.js', localeZhJs], ...extraLocaleCodes.map(code => [`locales/${code}.js`, localeScripts[code]]),
|
||||
['i18n.js', i18nJs], ['tasks.js', tasksJs], ['calendar.js', calendarJs],
|
||||
['addons.js', addonsJs], ['notifications.js', notificationsJs], ['i18n-renderers.js', i18nRenderersJs], ['desktop-personalization.js', desktopPersonalizationJs], ['desktop-widgets.js', desktopWidgetsJs], ['system-navigation.js', systemNavigationJs], ['browser-app.js', browserAppJs], ['data-actions.js', dataActionsJs], ['terminal-app.js', terminalAppJs], ['db-admin.js', dbAdminJs]
|
||||
]) {
|
||||
@@ -21,7 +25,7 @@ for (const [src, code] of [
|
||||
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 notificationsJs=${JSON.stringify(notificationsJs)};\nexport const i18nRenderersJs=${JSON.stringify(i18nRenderersJs)};\nexport const desktopPersonalizationJs=${JSON.stringify(desktopPersonalizationJs)};\nexport const desktopWidgetsJs=${JSON.stringify(desktopWidgetsJs)};\nexport const systemNavigationJs=${JSON.stringify(systemNavigationJs)};\nexport const browserAppJs=${JSON.stringify(browserAppJs)};\nexport const dataActionsJs=${JSON.stringify(dataActionsJs)};\nexport const terminalAppJs=${JSON.stringify(terminalAppJs)};\nexport const dbAdminJs=${JSON.stringify(dbAdminJs)};\nexport const manifest=${JSON.stringify(manifest)};\nexport const serviceWorker=${JSON.stringify(serviceWorker)};\nexport const icon=${JSON.stringify(icon)};\n`);
|
||||
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 localeScripts=${JSON.stringify(localeScripts)};\nexport const localeData=${JSON.stringify(localeData)};\nexport const tasksJs=${JSON.stringify(tasksJs)};\nexport const calendarJs=${JSON.stringify(calendarJs)};\nexport const notificationsJs=${JSON.stringify(notificationsJs)};\nexport const i18nRenderersJs=${JSON.stringify(i18nRenderersJs)};\nexport const desktopPersonalizationJs=${JSON.stringify(desktopPersonalizationJs)};\nexport const desktopWidgetsJs=${JSON.stringify(desktopWidgetsJs)};\nexport const systemNavigationJs=${JSON.stringify(systemNavigationJs)};\nexport const browserAppJs=${JSON.stringify(browserAppJs)};\nexport const dataActionsJs=${JSON.stringify(dataActionsJs)};\nexport const terminalAppJs=${JSON.stringify(terminalAppJs)};\nexport const dbAdminJs=${JSON.stringify(dbAdminJs)};\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("../worker/cloud-state.js", import.meta.url), new URL("cloud-state.js", server));
|
||||
await copyFile(new URL("../worker/db-admin.js", import.meta.url), new URL("db-admin.js", server));
|
||||
|
||||
+44
-19
@@ -24,6 +24,19 @@ assert.equal(assets.manifest.includes('"description"'), false);
|
||||
const flatten = (value, prefix = "") => Object.entries(value).flatMap(([key, child]) => child && typeof child === "object" && !Array.isArray(child) ? flatten(child, prefix ? `${prefix}.${key}` : key) : [prefix ? `${prefix}.${key}` : key]).sort();
|
||||
const enKeys = flatten(assets.localeEn), zhKeys = flatten(assets.localeZh);
|
||||
assert.deepEqual(enKeys, zhKeys, "English and Chinese locale keys must match");
|
||||
const allLocales = { en: assets.localeEn, 'zh-CN': assets.localeZh, ...assets.localeData };
|
||||
assert.equal(Object.keys(allLocales).length, 10);
|
||||
const leafPaths = (value, prefix = '') => value && typeof value === 'object' ? Object.entries(value).flatMap(([key, child]) => leafPaths(child, prefix ? `${prefix}.${key}` : key)) : [[prefix, value]];
|
||||
const referenceLeaves = new Map(leafPaths(assets.localeEn));
|
||||
for (const [code, locale] of Object.entries(allLocales)) {
|
||||
assert.deepEqual(flatten(locale), enKeys, `${code} must include every locale key`);
|
||||
const actualLeaves = new Map(leafPaths(locale));
|
||||
assert.deepEqual([...actualLeaves.keys()].sort(), [...referenceLeaves.keys()].sort(), `${code} must include all films and lines`);
|
||||
for (const [key, value] of actualLeaves) {
|
||||
assert.equal(typeof value, 'string', `${code}.${key} must be text`);
|
||||
assert.deepEqual([...value.matchAll(/\{\w+\}/g)].map(match => match[0]).sort(), [...referenceLeaves.get(key).matchAll(/\{\w+\}/g)].map(match => match[0]).sort(), `${code}.${key} must preserve placeholders`);
|
||||
}
|
||||
}
|
||||
const resolveKey = (locale, key) => key.split(".").reduce((value, part) => value?.[part], locale);
|
||||
for (const script of [assets.i18nRenderersJs, assets.desktopPersonalizationJs, assets.desktopWidgetsJs, assets.addonsJs, source]) {
|
||||
for (const match of script.matchAll(/(?:\bt|LifeI18n\.t)\(['"`]([A-Za-z][\w.]+)['"`]/g)) {
|
||||
@@ -31,7 +44,7 @@ for (const script of [assets.i18nRenderersJs, assets.desktopPersonalizationJs, a
|
||||
assert.notEqual(resolveKey(assets.localeZh, match[1]), undefined, `Missing Chinese locale key: ${match[1]}`);
|
||||
}
|
||||
}
|
||||
for (const locale of [assets.localeEn, assets.localeZh]) {
|
||||
for (const locale of Object.values(allLocales)) {
|
||||
assert.ok(locale.login.title && locale.login.description && locale.login.continue && locale.login.footnote);
|
||||
assert.equal(locale.prompt.films.length, 30);
|
||||
assert.equal(locale.prompt.lines.length, 30);
|
||||
@@ -52,7 +65,7 @@ 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");
|
||||
const shippedScripts = [...assets.indexHtml.matchAll(/<script>([\s\S]*?)<\/script>/g)].map(match => match[1]);
|
||||
assert.equal(shippedScripts.length, 18, "the complete desktop script chain is present in the served HTML");
|
||||
assert.equal(shippedScripts.length, 26, "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);
|
||||
@@ -109,7 +122,7 @@ const terminalStorage = new Map(), terminalRenders = [];
|
||||
const terminalContext = vm.createContext({
|
||||
document: { createElement: () => ({ textContent: '' }), head: { append() {} }, querySelector: () => null },
|
||||
console: { info() {}, warn() {} },
|
||||
LifeI18n: { t: key => key, dictionaries: { en: assets.localeEn, zh: assets.localeZh } }, lang: () => 'en',
|
||||
LifeI18n: { t: key => key, locale: () => 'en-US', dictionaries: { en: assets.localeEn, zh: assets.localeZh } }, lang: () => 'en',
|
||||
APPS: { calendar: ['icon', 'CALENDAR'] }, openApp: id => terminalRenders.push(id),
|
||||
crypto: globalThis.crypto, day: () => '2026-09-28',
|
||||
mems: () => terminalStorage.get('lifeos_memories') || [],
|
||||
@@ -205,7 +218,7 @@ assert.ok(command('date').message.length > 8);
|
||||
assert.ok(command('time').message.includes(':'));
|
||||
assert.ok(command('datetime').message.length > 12);
|
||||
assert.equal(command('uptime').message, 'terminal.uptime');
|
||||
assert.equal(command('version').message, 'Life OS v1.16.0');
|
||||
assert.equal(command('version').message, 'Life OS v1.17.0');
|
||||
assert.equal(command('status').message, 'terminal.status');
|
||||
assert.match(command('apps').message, /calendar/);
|
||||
assert.equal(command('echo "hello Life OS"').message, 'hello Life OS');
|
||||
@@ -232,30 +245,30 @@ const localStorage = makeStorage();
|
||||
const localeContext = vm.createContext({
|
||||
navigator: { languages: ["zh-CN"], language: "zh-CN" },
|
||||
localStorage,
|
||||
LifeOSLocales: { en: assets.localeEn, "zh-CN": assets.localeZh },
|
||||
LifeOSLocales: allLocales,
|
||||
settings: () => legacySettings,
|
||||
renderIcons() { calls.icons++; }, renderAll() { calls.render++; }, applyLanguage() { calls.apply++; }, toast() { calls.toast++; },
|
||||
});
|
||||
localeContext.window = localeContext;
|
||||
vm.runInContext(assets.i18nJs, localeContext);
|
||||
assert.equal(localeContext.LifeI18n.preference(), "auto");
|
||||
assert.equal(localeContext.LifeI18n.language(), "zh", "auto mode follows Chinese browser language");
|
||||
assert.equal(localeContext.LifeI18n.language(), "zh-CN", "auto mode follows Chinese browser language");
|
||||
localeContext.navigator.languages = ["en-US"];
|
||||
assert.equal(localeContext.LifeI18n.language(), "en", "auto mode follows English browser language");
|
||||
assert.equal(localeContext.LifeI18n.t("app.today"), "TODAY");
|
||||
localeContext.setLanguage("zh");
|
||||
assert.equal(localeContext.LifeI18n.language(), "zh", "manual Chinese choice overrides browser language");
|
||||
assert.equal(localeContext.LifeI18n.language(), "zh-CN", "manual Chinese choice overrides browser language");
|
||||
assert.equal(localeContext.LifeI18n.t("app.today"), "今日");
|
||||
assert.equal(localStorage.getItem("lifeos_language_preference_v1"), "zh");
|
||||
assert.equal(localStorage.getItem("lifeos_language_preference_v1"), "zh-CN");
|
||||
assert.deepEqual(calls, { icons: 1, render: 1, apply: 1, toast: 1 }, "language selection redraws the desktop and shows feedback");
|
||||
legacySettings = { language: "en", languageMode: "manual" };
|
||||
assert.equal(localeContext.LifeI18n.language(), "zh", "cloud settings cannot overwrite the local manual choice");
|
||||
assert.equal(localeContext.LifeI18n.language(), "zh-CN", "cloud settings cannot overwrite the local manual choice");
|
||||
localeContext.setLanguage("en");
|
||||
assert.equal(localeContext.LifeI18n.language(), "en", "manual English choice overrides browser language");
|
||||
assert.equal(localeContext.LifeI18n.t("app.today"), "TODAY");
|
||||
const reloadedManualContext = vm.createContext({
|
||||
navigator: { languages: ["en-US"] }, localStorage: makeStorage(),
|
||||
LifeOSLocales: { en: assets.localeEn, "zh-CN": assets.localeZh },
|
||||
LifeOSLocales: allLocales,
|
||||
});
|
||||
reloadedManualContext.window = reloadedManualContext;
|
||||
vm.runInContext(assets.i18nJs, reloadedManualContext);
|
||||
@@ -263,11 +276,11 @@ assert.equal(reloadedManualContext.LifeI18n.language(), "en", "a reloaded page s
|
||||
localeContext.navigator.languages = ["zh-CN"];
|
||||
localeContext.setLanguage("auto");
|
||||
assert.equal(localeContext.LifeI18n.preference(), "auto");
|
||||
assert.equal(localeContext.LifeI18n.language(), "zh", "auto mode ignores stale cloud preference and follows this device");
|
||||
assert.equal(localeContext.LifeI18n.language(), "zh-CN", "auto mode ignores stale cloud preference and follows this device");
|
||||
localeContext.setLanguage("en");
|
||||
const persistedManualContext = vm.createContext({
|
||||
navigator: { languages: ["zh-CN"] }, localStorage,
|
||||
LifeOSLocales: { en: assets.localeEn, "zh-CN": assets.localeZh },
|
||||
LifeOSLocales: allLocales,
|
||||
});
|
||||
persistedManualContext.window = persistedManualContext;
|
||||
vm.runInContext(assets.i18nJs, persistedManualContext);
|
||||
@@ -275,14 +288,20 @@ assert.equal(persistedManualContext.LifeI18n.language(), "en", "a manually selec
|
||||
const migratedStorage = makeStorage({ lifeos_settings: JSON.stringify({ language: "zh" }) });
|
||||
const migratedContext = vm.createContext({
|
||||
navigator: { languages: ["en-US"] }, localStorage: migratedStorage,
|
||||
LifeOSLocales: { en: assets.localeEn, "zh-CN": assets.localeZh },
|
||||
LifeOSLocales: allLocales,
|
||||
settings: () => ({ language: "zh", languageMode: "manual" }),
|
||||
});
|
||||
migratedContext.window = migratedContext;
|
||||
vm.runInContext(assets.i18nJs, migratedContext);
|
||||
assert.equal(migratedContext.LifeI18n.language(), "zh", "legacy manual preference migrates to the local preference key");
|
||||
assert.match(assets.i18nRenderersJs, /preference === 'zh'/);
|
||||
assert.match(assets.i18nRenderersJs, /onclick=\"window\.setLanguage\('zh'\)\"/);
|
||||
assert.equal(migratedContext.LifeI18n.language(), "zh-CN", "legacy manual preference migrates to the local preference key");
|
||||
assert.match(assets.i18nRenderersJs, /LifeI18n\.supported\.map/);
|
||||
assert.ok(assets.i18nRenderersJs.includes("onclick=\"window.setLanguage('${code}')\""));
|
||||
for (const [browserCode, expected] of [['fr-CA','fr'], ['es-MX','es'], ['ar-EG','ar'], ['ru-RU','ru'], ['ja-JP','ja'], ['zh-HK','zh-HK'], ['zh-MO','zh-MO'], ['zh-TW','zh-TW']]) {
|
||||
localeContext.navigator.languages = [browserCode];
|
||||
localeContext.setLanguage('auto');
|
||||
assert.equal(localeContext.LifeI18n.language(), expected, `browser language ${browserCode} is detected`);
|
||||
assert.notEqual(localeContext.LifeI18n.t('app.today'), 'app.today');
|
||||
}
|
||||
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: () => [], addEventListener() {} });
|
||||
@@ -303,15 +322,21 @@ const uiContext = vm.createContext({
|
||||
...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, 5, 10, 11]) vm.runInContext(shippedScripts[index], uiContext);
|
||||
for (const index of [0, ...Array.from({ length: 10 }, (_, i) => i + 2), 12, 13, 18, 19]) vm.runInContext(shippedScripts[index], uiContext);
|
||||
vm.runInContext("windows.system={classList:{contains(){return false}}}", uiContext);
|
||||
uiContext.setLanguage("zh");
|
||||
assert.equal(uiContext.LifeI18n.language(), "zh");
|
||||
assert.equal(uiContext.LifeI18n.language(), "zh-CN");
|
||||
assert.match(uiNodes.get("#content-system").innerHTML, /系统设置/);
|
||||
assert.match(uiNodes.get("#content-system").innerHTML, /桌面个性化/);
|
||||
assert.match(uiNodes.get("#icons").innerHTML, /今日/);
|
||||
assert.equal(uiDocument.documentElement.lang, "zh-CN");
|
||||
uiContext.setLanguage("en");
|
||||
uiContext.setLanguage("ar");
|
||||
assert.equal(uiDocument.documentElement.dir, "rtl");
|
||||
assert.equal(uiDocument.documentElement.lang, "ar");
|
||||
assert.match(uiNodes.get("#content-system").innerHTML, /العربية/);
|
||||
uiContext.setLanguage("en");
|
||||
assert.equal(uiDocument.documentElement.dir, "ltr");
|
||||
assert.match(uiNodes.get("#content-system").innerHTML, /SYSTEM SETTINGS/);
|
||||
assert.match(uiNodes.get("#content-system").innerHTML, /DESKTOP PERSONALIZATION/);
|
||||
assert.match(uiNodes.get("#icons").innerHTML, /TODAY/);
|
||||
@@ -450,4 +475,4 @@ assert.match(await enLogin.text(), /Sign in with ChatGPT/);
|
||||
const localeResponse = await worker.default.fetch(new Request("https://life-os.test/locales/zh-CN.js"), {});
|
||||
assert.equal(localeResponse.status, 200);
|
||||
assert.match(await localeResponse.text(), /window\.LifeOSLocales\['zh-CN'\]/);
|
||||
console.log(`Life OS artifact valid · ${enKeys.length} bilingual locale keys`);
|
||||
console.log(`Life OS artifact valid · ${enKeys.length} keys across ${Object.keys(allLocales).length} locales`);
|
||||
Reference in new issue
Block a user