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, };