feat: First iteration, multi themes, I18N
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
// Internationalisation.
|
||||
//
|
||||
// Two kinds of locale files are merged into one flat dictionary:
|
||||
// locales/<lang>.json core UI texts (shared by all themes)
|
||||
// themes/<id>/locales/<lang>.json theme texts, all under the "theme." root
|
||||
// Missing keys fall back to English, then to the key itself.
|
||||
//
|
||||
// Strings support {param} interpolation and {@some.key} to embed another entry
|
||||
// (e.g. "{@theme.res.material}"), so core texts can use theme vocabulary.
|
||||
|
||||
export const FALLBACK_LOCALE = 'en';
|
||||
const MAX_EMBED_DEPTH = 5;
|
||||
|
||||
let dictionary = {};
|
||||
let currentLocale = FALLBACK_LOCALE;
|
||||
|
||||
export const getLocale = () => currentLocale;
|
||||
|
||||
function flatten(object, prefix = '', out = {}) {
|
||||
for (const [key, value] of Object.entries(object ?? {})) {
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
if (value && typeof value === 'object') flatten(value, path, out);
|
||||
else out[path] = String(value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
return response.ok ? await response.json() : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadDictionary(locale, themeId) {
|
||||
const order = locale === FALLBACK_LOCALE ? [FALLBACK_LOCALE] : [FALLBACK_LOCALE, locale];
|
||||
const files = order.flatMap((l) => [`locales/${l}.json`, `themes/${themeId}/locales/${l}.json`]);
|
||||
const parts = await Promise.all(files.map(fetchJson));
|
||||
dictionary = Object.assign({}, ...parts.map((p) => flatten(p)));
|
||||
currentLocale = locale;
|
||||
document.documentElement.lang = locale;
|
||||
}
|
||||
|
||||
export function has(key) {
|
||||
return key in dictionary;
|
||||
}
|
||||
|
||||
export function t(key, params = {}, depth = 0) {
|
||||
const text = dictionary[key];
|
||||
if (text === undefined) return key;
|
||||
return text.replace(/\{(@?)([\w.]+)\}/g, (match, embed, name) => {
|
||||
if (embed) return depth < MAX_EMBED_DEPTH ? t(name, params, depth + 1) : '';
|
||||
return name in params ? String(params[name]) : match;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate static markup:
|
||||
* data-i18n="key" -> textContent
|
||||
* data-i18n-attr="title:key;aria-label:key" -> attributes
|
||||
*/
|
||||
export function applyTranslations(root = document) {
|
||||
for (const el of root.querySelectorAll('[data-i18n]')) el.textContent = t(el.dataset.i18n);
|
||||
for (const el of root.querySelectorAll('[data-i18n-attr]')) {
|
||||
for (const pair of el.dataset.i18nAttr.split(';')) {
|
||||
const [attr, key] = pair.split(':').map((s) => s.trim());
|
||||
if (attr && key) el.setAttribute(attr, t(key));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Display name of a theme in `locale` (falls back to English, then the id). */
|
||||
export async function fetchThemeName(themeId, locale) {
|
||||
for (const l of [locale, FALLBACK_LOCALE]) {
|
||||
const file = await fetchJson(`themes/${themeId}/locales/${l}.json`);
|
||||
if (file?.theme?.name) return file.theme.name;
|
||||
}
|
||||
return themeId;
|
||||
}
|
||||
Reference in New Issue
Block a user