Files
GameList/wwwroot/js/i18n.js

212 lines
5.5 KiB
JavaScript

const storageKey = "app_lang";
const defaultLang = "en";
const translationsAssetUrl = new URL(
"../data/i18n/translations.json",
import.meta.url,
);
const faqBaseUrl = new URL("../data/i18n/faq/", 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;
}
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 loadText(url, label) {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) {
throw new Error(`Failed to load ${label}: ${response.status}`);
}
return response.text();
}
async function loadFaqMarkdownForLanguages(languages) {
if (!Array.isArray(languages) || languages.length === 0) {
throw new Error("No i18n languages available for FAQ loading.");
}
const faqByLanguage = {};
for (const lang of languages) {
const faqUrl = new URL(`${lang}.md`, faqBaseUrl);
try {
faqByLanguage[lang] = await loadText(faqUrl, `faq.${lang}`);
} catch (err) {
if (lang === defaultLang) {
throw err;
}
}
}
const fallback = faqByLanguage[defaultLang];
if (typeof fallback !== "string" || fallback.trim().length === 0) {
throw new Error(`Missing default FAQ language "${defaultLang}".`);
}
languages.forEach((lang) => {
const value = faqByLanguage[lang];
faqByLanguage[lang] =
typeof value === "string" && value.trim().length > 0
? value
: fallback;
});
return faqByLanguage;
}
async function ensureAssetsLoaded() {
if (assetsLoaded) return;
if (assetsLoadingPromise) return assetsLoadingPromise;
assetsLoadingPromise = (async () => {
const translationsRaw = await loadJson(
translationsAssetUrl,
"translations",
);
translations = validateTranslations(translationsRaw);
faqMarkdown = await loadFaqMarkdownForLanguages(
Object.keys(translations),
);
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,
};