183 lines
4.6 KiB
JavaScript
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,
|
|
};
|