init new frontend

This commit is contained in:
Bradley Nelson
2026-06-17 17:06:30 -06:00
parent b8a0018785
commit daa3010458
114 changed files with 32716 additions and 119 deletions
+71
View File
@@ -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');
});
});
+202
View File
@@ -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
};