Files
GameList/wwwroot/js/i18n.js

183 lines
4.6 KiB
JavaScript

const storageKey = "app_lang";
const defaultLang = "en";
const translationsAssetUrl = new URL(
"../data/i18n/translations.json",
import.meta.url,
);
const faqAssetUrl = new URL("../data/i18n/faq.json", import.meta.url);
let currentLang = defaultLang;
const listeners = [];
let assetsLoadingPromise = null;
let assetsLoaded = false;
export let translations = {};
export let faqMarkdown = {};
function isRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function validateTranslations(raw) {
if (!isRecord(raw)) {
throw new Error("Invalid i18n translations payload.");
}
const languages = Object.keys(raw);
if (languages.length === 0 || !isRecord(raw[defaultLang])) {
throw new Error(
`Missing default translation language "${defaultLang}".`,
);
}
const defaultDict = raw[defaultLang];
const defaultKeys = Object.keys(defaultDict);
languages.forEach((lang) => {
const dict = raw[lang];
if (!isRecord(dict)) {
throw new Error(
`Invalid translation dictionary for language "${lang}".`,
);
}
Object.entries(dict).forEach(([key, value]) => {
if (typeof value !== "string") {
throw new Error(
`Invalid translation value for "${lang}.${key}".`,
);
}
});
const missing = defaultKeys.filter(
(key) => typeof dict[key] !== "string",
);
if (missing.length > 0) {
throw new Error(
`Missing translation keys for "${lang}": ${missing.join(", ")}`,
);
}
});
return raw;
}
function validateFaq(raw) {
if (!isRecord(raw) || typeof raw[defaultLang] !== "string") {
throw new Error(`Missing default FAQ language "${defaultLang}".`);
}
Object.entries(raw).forEach(([lang, value]) => {
if (typeof value !== "string") {
throw new Error(`Invalid FAQ value for "${lang}".`);
}
});
return raw;
}
async function loadJson(url, label) {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) {
throw new Error(`Failed to load ${label}: ${response.status}`);
}
return response.json();
}
async function ensureAssetsLoaded() {
if (assetsLoaded) return;
if (assetsLoadingPromise) return assetsLoadingPromise;
assetsLoadingPromise = (async () => {
const [translationsRaw, faqRaw] = await Promise.all([
loadJson(translationsAssetUrl, "translations"),
loadJson(faqAssetUrl, "faq"),
]);
translations = validateTranslations(translationsRaw);
faqMarkdown = validateFaq(faqRaw);
assetsLoaded = true;
})().finally(() => {
assetsLoadingPromise = null;
});
return assetsLoadingPromise;
}
function interpolate(template, params = {}) {
return template.replace(
/\{(\w+)\}/g,
(_, key) => params[key] ?? `{${key}}`,
);
}
function t(key, params) {
const fallback = translations[defaultLang]?.[key] ?? key;
const phrase = translations[currentLang]?.[key] ?? fallback;
return interpolate(phrase, params);
}
function detectLanguage() {
const stored = localStorage.getItem(storageKey);
if (stored && translations[stored]) return stored;
const nav = navigator.language?.slice(0, 2);
if (nav && translations[nav]) return nav;
return defaultLang;
}
function applyTranslations(root = document) {
root.querySelectorAll("[data-i18n]").forEach((el) => {
const key = el.dataset.i18n;
const attrs = (el.dataset.i18nAttr || "")
.split(",")
.map((a) => a.trim())
.filter(Boolean);
const text = t(key);
if (attrs.length === 0) {
el.textContent = text;
} else {
attrs.forEach((attr) => el.setAttribute(attr, text));
}
});
}
function notify() {
listeners.forEach((fn) => fn(currentLang));
}
function setLanguage(lang) {
if (!translations[lang]) lang = defaultLang;
currentLang = lang;
localStorage.setItem(storageKey, lang);
document.documentElement.lang = lang;
applyTranslations();
notify();
}
function getLanguage() {
return currentLang;
}
async function initI18n() {
await ensureAssetsLoaded();
currentLang = detectLanguage();
document.documentElement.lang = currentLang;
applyTranslations();
notify();
return currentLang;
}
function onLanguageChange(fn) {
listeners.push(fn);
}
export {
t,
setLanguage,
getLanguage,
initI18n,
applyTranslations,
onLanguageChange,
};