init new frontend
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getNestedValue, interpolate, resolveBrowserLocale } from './index.svelte';
|
||||
|
||||
describe('resolveBrowserLocale', () => {
|
||||
it('matches an exact full tag', () => {
|
||||
expect(resolveBrowserLocale(['zh-TW'])).toBe('zh-TW');
|
||||
expect(resolveBrowserLocale(['fr-FR', 'fr'])).toBe('fr');
|
||||
});
|
||||
|
||||
it('maps Traditional Chinese variants to zh-TW', () => {
|
||||
expect(resolveBrowserLocale(['zh-Hant'])).toBe('zh-TW');
|
||||
expect(resolveBrowserLocale(['zh-HK'])).toBe('zh-TW');
|
||||
expect(resolveBrowserLocale(['zh-MO'])).toBe('zh-TW');
|
||||
});
|
||||
|
||||
it('maps Simplified/other Chinese to zh', () => {
|
||||
expect(resolveBrowserLocale(['zh-CN'])).toBe('zh');
|
||||
expect(resolveBrowserLocale(['zh'])).toBe('zh');
|
||||
});
|
||||
|
||||
it('falls back to the primary subtag', () => {
|
||||
expect(resolveBrowserLocale(['de-AT'])).toBe('de');
|
||||
});
|
||||
|
||||
it('defaults to en when nothing matches', () => {
|
||||
expect(resolveBrowserLocale(['xx-YY'])).toBe('en');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNestedValue', () => {
|
||||
const dict = {
|
||||
'flat.key': 'flat value',
|
||||
nav: { files: 'Files', shared: 'Shared' },
|
||||
button: { save_changes: 'Save changes' }
|
||||
};
|
||||
|
||||
it('resolves a direct key that contains dots', () => {
|
||||
expect(getNestedValue(dict, 'flat.key')).toBe('flat value');
|
||||
});
|
||||
|
||||
it('resolves dotted nested paths', () => {
|
||||
expect(getNestedValue(dict, 'nav.files')).toBe('Files');
|
||||
});
|
||||
|
||||
it('returns null for missing keys', () => {
|
||||
expect(getNestedValue(dict, 'nav.missing')).toBeNull();
|
||||
expect(getNestedValue(undefined, 'nav.files')).toBeNull();
|
||||
});
|
||||
|
||||
it('applies the prefix_suffix underscore fallback', () => {
|
||||
expect(getNestedValue(dict, 'button_save_changes')).toBe('Save changes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('interpolate', () => {
|
||||
it('replaces {{param}} placeholders', () => {
|
||||
expect(interpolate('Hello {{name}}', { name: 'Ada' })).toBe('Hello Ada');
|
||||
});
|
||||
|
||||
it('trims whitespace inside placeholders', () => {
|
||||
expect(interpolate('Send to {{ email }}', { email: 'a@b.c' })).toBe('Send to a@b.c');
|
||||
});
|
||||
|
||||
it('leaves unknown placeholders intact', () => {
|
||||
expect(interpolate('Hi {{name}}', {})).toBe('Hi {{name}}');
|
||||
});
|
||||
|
||||
it('coerces non-string params', () => {
|
||||
expect(interpolate('{{count}} items', { count: 5 })).toBe('5 items');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Reactive i18n — ported from static/js/core/i18n.js.
|
||||
*
|
||||
* Kept as a bespoke module (rather than svelte-i18n) so the 16 existing locale
|
||||
* JSON files work byte-for-byte: they use `{{param}}` interpolation, dot-notation
|
||||
* nested keys, and a prefix_suffix underscore-fallback heuristic that ICU-based
|
||||
* libraries don't model. `t()` reads module-level runes, so any component that
|
||||
* calls it re-renders when the locale changes.
|
||||
*
|
||||
* Storage key `oxicloud-locale` and the server round-trip via
|
||||
* PATCH /api/auth/me/profile are preserved for cross-device/email parity.
|
||||
*/
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
import { getCsrfHeaders } from '$lib/api/csrf';
|
||||
|
||||
// Keep in sync with the locale files in static/locales (and, post-cutover,
|
||||
// frontend/static/locales). Mirrors AVAILABLE_LOCALES in the legacy selector.
|
||||
export const SUPPORTED_LOCALES = [
|
||||
'en',
|
||||
'es',
|
||||
'zh',
|
||||
'zh-TW',
|
||||
'fa',
|
||||
'fr',
|
||||
'de',
|
||||
'pt',
|
||||
'nl',
|
||||
'it',
|
||||
'hi',
|
||||
'ar',
|
||||
'ru',
|
||||
'ja',
|
||||
'ko',
|
||||
'pl'
|
||||
] as const;
|
||||
|
||||
export type Locale = (typeof SUPPORTED_LOCALES)[number];
|
||||
|
||||
const STORAGE_KEY = 'oxicloud-locale';
|
||||
|
||||
type Dict = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Resolve the best supported locale from a browser language list.
|
||||
* Priority: exact full-tag > Chinese script/region heuristics > primary subtag.
|
||||
*/
|
||||
export function resolveBrowserLocale(
|
||||
langs: readonly string[] = typeof navigator !== 'undefined'
|
||||
? (navigator.languages ?? [navigator.language || 'en'])
|
||||
: ['en']
|
||||
): Locale {
|
||||
const lowerSupported = SUPPORTED_LOCALES.map((l) => l.toLowerCase());
|
||||
|
||||
for (const bl of langs) {
|
||||
const idx = lowerSupported.indexOf(bl.toLowerCase());
|
||||
if (idx !== -1) return SUPPORTED_LOCALES[idx];
|
||||
}
|
||||
for (const bl of langs) {
|
||||
const tag = bl.toLowerCase();
|
||||
if (!tag.startsWith('zh')) continue;
|
||||
const isTraditional = tag.includes('hant') || /\b(tw|hk|mo)\b/.test(tag);
|
||||
const target: Locale = isTraditional ? 'zh-TW' : 'zh';
|
||||
if (SUPPORTED_LOCALES.includes(target)) return target;
|
||||
}
|
||||
for (const bl of langs) {
|
||||
const primary = bl.substring(0, 2).toLowerCase();
|
||||
const match = SUPPORTED_LOCALES.find((l) => l === primary);
|
||||
if (match) return match;
|
||||
}
|
||||
return 'en';
|
||||
}
|
||||
|
||||
/** Resolve a dot-notation key with a prefix_suffix underscore fallback. */
|
||||
export function getNestedValue(obj: Dict | undefined, path: string): string | null {
|
||||
if (obj && typeof obj === 'object' && path in obj) {
|
||||
const value = obj[path];
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
const keys = path.split('.');
|
||||
let current: unknown = obj;
|
||||
for (const key of keys) {
|
||||
if (current && typeof current === 'object' && key in (current as Dict)) {
|
||||
current = (current as Dict)[key];
|
||||
} else {
|
||||
if (path.includes('_') && !path.includes('.')) {
|
||||
const [prefix, ...parts] = path.split('_');
|
||||
const suffix = parts.join('_');
|
||||
const branch = obj?.[prefix];
|
||||
if (branch && typeof branch === 'object' && suffix in (branch as Dict)) {
|
||||
const v = (branch as Dict)[suffix];
|
||||
return typeof v === 'string' ? v : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return typeof current === 'string' ? current : null;
|
||||
}
|
||||
|
||||
/** Replace `{{param}}` placeholders; leaves unknown placeholders intact. */
|
||||
export function interpolate(text: string, params: Record<string, unknown>): string {
|
||||
return text.replace(/{{\s*([^}]+)\s*}}/g, (_, key: string) => {
|
||||
const k = key.trim();
|
||||
return params[k] !== undefined ? String(params[k]) : `{{${key}}}`;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Reactive state ─────────────────────────────────────────────────────────
|
||||
|
||||
const dicts = $state<Record<string, Dict>>({});
|
||||
const store = $state<{ locale: string; loaded: boolean }>({
|
||||
locale: resolveBrowserLocale(),
|
||||
loaded: false
|
||||
});
|
||||
|
||||
async function loadDict(locale: string): Promise<Dict> {
|
||||
if (dicts[locale]) return dicts[locale];
|
||||
try {
|
||||
const res = await fetch(`/locales/${locale}.json`);
|
||||
if (!res.ok) throw new Error(`locale ${locale} ${res.status}`);
|
||||
dicts[locale] = (await res.json()) as Dict;
|
||||
} catch (err) {
|
||||
console.error('i18n: failed to load locale', locale, err);
|
||||
dicts[locale] = {};
|
||||
}
|
||||
return dicts[locale];
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a key.
|
||||
* - `t(key)` / `t(key, params)` — interpolation params object.
|
||||
* - `t(key, fallback)` — string fallback used when the key is missing.
|
||||
* - `t(key, params, fallback)` — both; the fallback is also interpolated.
|
||||
*/
|
||||
export function t(
|
||||
key: string,
|
||||
paramsOrFallback: string | Record<string, unknown> = {},
|
||||
fallbackArg?: string
|
||||
): string {
|
||||
const isStringForm = typeof paramsOrFallback === 'string';
|
||||
const params = isStringForm ? {} : paramsOrFallback;
|
||||
const fallback = isStringForm ? paramsOrFallback : (fallbackArg ?? null);
|
||||
|
||||
const localeData = dicts[store.locale];
|
||||
if (!localeData) {
|
||||
return fallback ? interpolate(fallback, params) : (key.split('.').pop() ?? key);
|
||||
}
|
||||
|
||||
let value = getNestedValue(localeData, key);
|
||||
if (!value && store.locale !== 'en' && dicts.en) {
|
||||
value = getNestedValue(dicts.en, key);
|
||||
}
|
||||
if (!value) return fallback ? interpolate(fallback, params) : key;
|
||||
return interpolate(value, params);
|
||||
}
|
||||
|
||||
export async function initI18n(): Promise<void> {
|
||||
const saved = typeof localStorage !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null;
|
||||
if (saved && (SUPPORTED_LOCALES as readonly string[]).includes(saved)) {
|
||||
store.locale = saved;
|
||||
}
|
||||
await loadDict(store.locale);
|
||||
if (store.locale !== 'en') await loadDict('en');
|
||||
store.loaded = true;
|
||||
}
|
||||
|
||||
export async function setLocale(locale: Locale): Promise<boolean> {
|
||||
if (!(SUPPORTED_LOCALES as readonly string[]).includes(locale)) {
|
||||
console.error(`Locale not supported: ${locale}`);
|
||||
return false;
|
||||
}
|
||||
await loadDict(locale);
|
||||
store.locale = locale;
|
||||
if (typeof localStorage !== 'undefined') localStorage.setItem(STORAGE_KEY, locale);
|
||||
persistLocaleToServer(locale);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Fire-and-forget server persistence; anonymous callers 401 and that's fine. */
|
||||
function persistLocaleToServer(locale: string): void {
|
||||
apiFetch('/api/auth/me/profile', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ preferred_locale: locale })
|
||||
}).catch((err: unknown) => {
|
||||
console.debug('locale: server persistence skipped', err);
|
||||
});
|
||||
}
|
||||
|
||||
export const i18n = {
|
||||
t,
|
||||
setLocale,
|
||||
get locale() {
|
||||
return store.locale;
|
||||
},
|
||||
get loaded() {
|
||||
return store.loaded;
|
||||
},
|
||||
supported: SUPPORTED_LOCALES
|
||||
};
|
||||
Reference in New Issue
Block a user