212 lines
5.5 KiB
JavaScript
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,
|
|
};
|