Files
jontsai_command-center/public/js/i18n.js

387 lines
12 KiB
JavaScript

(function () {
"use strict";
const DEFAULT_LOCALE = "en";
const SUPPORTED_LOCALES = ["en", "zh-CN"];
const STORAGE_KEY = "occ.locale";
const loadedMessages = new Map();
const SKIP_TAGS = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEXTAREA", "CODE", "PRE"]);
let currentLocale = DEFAULT_LOCALE;
let activeMessages = {};
let observer = null;
let isApplyingTranslations = false;
function normalizeLocale(input) {
if (!input || typeof input !== "string") return DEFAULT_LOCALE;
const lc = input.toLowerCase();
if (lc === "zh" || lc.startsWith("zh-")) return "zh-CN";
return "en";
}
function getInitialLocale() {
const fromQuery = new URLSearchParams(window.location.search).get("lang");
if (fromQuery) return normalizeLocale(fromQuery);
const fromStorage = localStorage.getItem(STORAGE_KEY);
if (fromStorage) return normalizeLocale(fromStorage);
return normalizeLocale(navigator.language || DEFAULT_LOCALE);
}
async function loadLocaleMessages(locale) {
const normalized = normalizeLocale(locale);
if (loadedMessages.has(normalized)) return loadedMessages.get(normalized);
const response = await fetch(`/locales/${normalized}.json`, { cache: "no-cache" });
if (!response.ok) throw new Error(`Failed to load locale: ${normalized}`);
const data = await response.json();
loadedMessages.set(normalized, data || {});
return data || {};
}
function getByPath(obj, path) {
return String(path)
.split(".")
.reduce(
(acc, key) =>
acc && Object.prototype.hasOwnProperty.call(acc, key) ? acc[key] : undefined,
obj,
);
}
function interpolate(template, params = {}) {
if (typeof template !== "string") return template;
return template.replace(/\{(\w+)\}/g, (_, key) => {
return Object.prototype.hasOwnProperty.call(params, key) ? String(params[key]) : `{${key}}`;
});
}
function t(key, params = {}, fallback = undefined) {
const value = getByPath(activeMessages, key);
if (value === undefined || value === null) {
if (fallback !== undefined) return interpolate(fallback, params);
return String(key);
}
return interpolate(value, params);
}
function buildReverseMap(source = {}) {
const reversed = {};
for (const [from, to] of Object.entries(source)) {
if (typeof from !== "string" || typeof to !== "string") continue;
if (!Object.prototype.hasOwnProperty.call(reversed, to)) {
reversed[to] = from;
}
}
return reversed;
}
function getExactPhraseMap() {
const exact = getByPath(activeMessages, "phrases.exact") || {};
if (currentLocale !== DEFAULT_LOCALE) return exact;
const zh = loadedMessages.get("zh-CN") || {};
const zhExact = getByPath(zh, "phrases.exact") || {};
return buildReverseMap(zhExact);
}
function getPatternRules() {
const localeRules = getByPath(activeMessages, "phrases.patterns");
if (Array.isArray(localeRules)) return localeRules;
if (currentLocale !== DEFAULT_LOCALE) return [];
const zh = loadedMessages.get("zh-CN") || {};
const zhRules = getByPath(zh, "phrases.reversePatterns");
return Array.isArray(zhRules) ? zhRules : [];
}
function translateTextValue(input) {
if (typeof input !== "string") return input;
if (!input.trim()) return input;
const leading = input.match(/^\s*/)?.[0] || "";
const trailing = input.match(/\s*$/)?.[0] || "";
let core = input.trim();
const exactMap = getExactPhraseMap();
if (Object.prototype.hasOwnProperty.call(exactMap, core)) {
return `${leading}${exactMap[core]}${trailing}`;
}
for (const rule of getPatternRules()) {
if (!rule || typeof rule.pattern !== "string" || typeof rule.replace !== "string") continue;
try {
const regex = new RegExp(rule.pattern);
if (regex.test(core)) {
core = core.replace(regex, rule.replace);
return `${leading}${core}${trailing}`;
}
} catch {
continue;
}
}
return input;
}
function setAttrIfChanged(el, attr, value) {
if (!el || typeof value !== "string") return;
if (el.getAttribute(attr) !== value) {
el.setAttribute(attr, value);
}
}
function translateLooseAttributes(el) {
if (el.hasAttribute("data-i18n-title")) {
setAttrIfChanged(el, "title", t(el.getAttribute("data-i18n-title")));
} else if (el.hasAttribute("title")) {
setAttrIfChanged(el, "title", translateTextValue(el.getAttribute("title")));
}
if (el.hasAttribute("data-i18n-placeholder")) {
setAttrIfChanged(el, "placeholder", t(el.getAttribute("data-i18n-placeholder")));
} else if (el.hasAttribute("placeholder")) {
setAttrIfChanged(el, "placeholder", translateTextValue(el.getAttribute("placeholder")));
}
if (el.hasAttribute("data-i18n-aria-label")) {
setAttrIfChanged(el, "aria-label", t(el.getAttribute("data-i18n-aria-label")));
} else if (el.hasAttribute("aria-label")) {
setAttrIfChanged(el, "aria-label", translateTextValue(el.getAttribute("aria-label")));
}
if (el.hasAttribute("data-tooltip")) {
setAttrIfChanged(el, "data-tooltip", translateTextValue(el.getAttribute("data-tooltip")));
}
}
function translateTextNodes(root) {
if (!root) return;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const nodes = [];
let node;
while ((node = walker.nextNode())) {
nodes.push(node);
}
for (const textNode of nodes) {
const parent = textNode.parentElement;
if (!parent) continue;
if (SKIP_TAGS.has(parent.tagName)) continue;
if (parent.hasAttribute("data-i18n")) continue;
const translated = translateTextValue(textNode.nodeValue || "");
if (translated !== textNode.nodeValue) {
textNode.nodeValue = translated;
}
}
}
function translateElement(el) {
const textKey = el.getAttribute("data-i18n");
if (textKey) {
const translatedText = t(textKey);
if (el.textContent !== translatedText) {
el.textContent = translatedText;
}
}
const titleKey = el.getAttribute("data-i18n-title");
if (titleKey) {
setAttrIfChanged(el, "title", t(titleKey));
}
const placeholderKey = el.getAttribute("data-i18n-placeholder");
if (placeholderKey) {
setAttrIfChanged(el, "placeholder", t(placeholderKey));
}
const ariaLabelKey = el.getAttribute("data-i18n-aria-label");
if (ariaLabelKey) {
setAttrIfChanged(el, "aria-label", t(ariaLabelKey));
}
translateLooseAttributes(el);
}
function translateSubtree(root = document) {
if (!root) return;
isApplyingTranslations = true;
if (root.nodeType === Node.ELEMENT_NODE) {
translateElement(root);
}
root
.querySelectorAll(
"[data-i18n], [data-i18n-title], [data-i18n-placeholder], [data-i18n-aria-label]",
)
.forEach(translateElement);
const elementRoot =
root.nodeType === Node.DOCUMENT_NODE ? root.body || root.documentElement : root;
if (elementRoot) {
translateTextNodes(elementRoot);
if (elementRoot.querySelectorAll) {
elementRoot
.querySelectorAll("[title], [placeholder], [aria-label], [data-tooltip]")
.forEach(translateLooseAttributes);
}
}
isApplyingTranslations = false;
}
function updateDocumentLang() {
document.documentElement.lang = currentLocale;
}
function renderLanguageSwitcher() {
const header = document.querySelector("header");
if (!header) return;
let container = document.getElementById("lang-switcher");
let select = document.getElementById("lang-select");
if (!container) {
container = document.createElement("div");
container.id = "lang-switcher";
container.style.display = "inline-flex";
container.style.alignItems = "center";
container.style.gap = "6px";
container.style.marginLeft = "12px";
container.style.flexShrink = "0";
const label = document.createElement("span");
label.style.fontSize = "0.75rem";
label.style.opacity = "0.8";
label.textContent = "🌐";
container.appendChild(label);
select = document.createElement("select");
select.id = "lang-select";
select.style.fontSize = "0.8rem";
select.style.padding = "3px 8px";
select.style.background = "var(--card-bg, #161b22)";
select.style.color = "var(--text, #c9d1d9)";
select.style.border = "1px solid var(--border, #30363d)";
select.style.borderRadius = "4px";
select.style.cursor = "pointer";
select.innerHTML = `
<option value="en">English</option>
<option value="zh-CN">简体中文</option>
`;
container.appendChild(select);
const targetHost = header.querySelector(".header-left") || header;
targetHost.appendChild(container);
}
select = document.getElementById("lang-select");
if (select && !select.dataset.i18nBound) {
select.addEventListener("change", (e) => {
setLocale(e.target.value, { persist: true });
});
select.dataset.i18nBound = "1";
}
if (select) {
if (!select.options.length) {
select.innerHTML = `
<option value="en">English</option>
<option value="zh-CN">简体中文</option>
`;
}
select.value = currentLocale;
}
}
function installObserver() {
if (observer) observer.disconnect();
if (!document.body) return;
observer = new MutationObserver((mutations) => {
if (isApplyingTranslations) return;
isApplyingTranslations = true;
try {
for (const mutation of mutations) {
if (mutation.type === "childList") {
mutation.addedNodes.forEach((addedNode) => {
if (addedNode.nodeType === Node.TEXT_NODE) {
const parent = addedNode.parentElement;
if (parent && !SKIP_TAGS.has(parent.tagName)) {
const translated = translateTextValue(addedNode.nodeValue || "");
if (translated !== addedNode.nodeValue) addedNode.nodeValue = translated;
}
return;
}
if (addedNode.nodeType === Node.ELEMENT_NODE) {
translateSubtree(addedNode);
}
});
}
if (mutation.type === "characterData" && mutation.target?.nodeType === Node.TEXT_NODE) {
const textNode = mutation.target;
const parent = textNode.parentElement;
if (parent && !SKIP_TAGS.has(parent.tagName)) {
const translated = translateTextValue(textNode.nodeValue || "");
if (translated !== textNode.nodeValue) textNode.nodeValue = translated;
}
}
}
} finally {
isApplyingTranslations = false;
}
});
observer.observe(document.body, {
subtree: true,
childList: true,
characterData: true,
attributes: false,
});
}
async function setLocale(locale, { persist = true } = {}) {
const normalized = normalizeLocale(locale);
const targetLocale = SUPPORTED_LOCALES.includes(normalized) ? normalized : DEFAULT_LOCALE;
let localeMessages;
try {
localeMessages = await loadLocaleMessages(targetLocale);
} catch (error) {
console.error("[i18n] Failed to load locale, fallback to English:", error);
localeMessages = await loadLocaleMessages(DEFAULT_LOCALE);
}
currentLocale = targetLocale;
activeMessages = localeMessages;
if (persist) {
localStorage.setItem(STORAGE_KEY, currentLocale);
}
updateDocumentLang();
translateSubtree(document);
renderLanguageSwitcher();
installObserver();
window.dispatchEvent(new CustomEvent("i18n:updated", { detail: { locale: currentLocale } }));
}
async function init() {
await loadLocaleMessages(DEFAULT_LOCALE);
await loadLocaleMessages("zh-CN").catch(() => null);
const initialLocale = getInitialLocale();
await setLocale(initialLocale, { persist: false });
}
window.I18N = {
init,
t,
setLocale,
getLocale: () => currentLocale,
translateSubtree,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init, { once: true });
} else {
init();
}
})();