i18n: lazy-load locales + register 8 new (pt fr de it nl pl ru ko) with fonts
This commit is contained in:
parent
7ab9a5b399
commit
1460b06277
18 changed files with 201 additions and 24 deletions
10
package-lock.json
generated
10
package-lock.json
generated
|
|
@ -22,6 +22,7 @@
|
|||
"@fontsource/bebas-neue": "^5.2.7",
|
||||
"@fontsource/kanit": "^5.3.0",
|
||||
"@fontsource/noto-sans-jp": "^5.3.0",
|
||||
"@fontsource/noto-sans-kr": "^5.3.0",
|
||||
"@getalby/sdk": "^5.1.1",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
|
|
@ -1521,6 +1522,15 @@
|
|||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/noto-sans-kr": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/noto-sans-kr/-/noto-sans-kr-5.3.0.tgz",
|
||||
"integrity": "sha512-/JnpTjaCOXW7xUoqOyCVYSr05VOkDy5Yla9O+WAEiS7u+yYLsmHoqj6v1W0bf91G8G3XA2+NnRS3CX2Tf/FVZg==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@getalby/lightning-tools": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@getalby/lightning-tools/-/lightning-tools-5.2.0.tgz",
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@
|
|||
"@fontsource/bebas-neue": "^5.2.7",
|
||||
"@fontsource/kanit": "^5.3.0",
|
||||
"@fontsource/noto-sans-jp": "^5.3.0",
|
||||
"@fontsource/noto-sans-kr": "^5.3.0",
|
||||
"@getalby/sdk": "^5.1.1",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
|
|
|
|||
|
|
@ -129,6 +129,17 @@ async function main() {
|
|||
for (const key of ja) if (!en.has(key)) problems.push(`ja has a key not in en: ${key}`);
|
||||
const jaCoverage = en.size ? Math.round(((en.size - missingJa) / en.size) * 100) : 100;
|
||||
|
||||
// 5. Additional partial locales (same rules as th/ja).
|
||||
const extraLocales = ['pt', 'fr', 'de', 'it', 'nl', 'pl', 'ru', 'ko'];
|
||||
const extraStats = [];
|
||||
for (const lang of extraLocales) {
|
||||
const dict = flatten(await loadTranslations(join(SRC, `i18n/${lang}/index.ts`)));
|
||||
for (const key of dict) if (!en.has(key)) problems.push(`${lang} has a key not in en: ${key}`);
|
||||
const missing = [...en].filter((k) => !dict.has(k)).length;
|
||||
const cov = en.size ? Math.round(((en.size - missing) / en.size) * 100) : 100;
|
||||
extraStats.push(`${lang} ${cov}%`);
|
||||
}
|
||||
|
||||
if (problems.length) {
|
||||
console.error(`\n✖ i18n check failed (${problems.length}):`);
|
||||
for (const p of problems.slice(0, 200)) console.error(` • ${p}`);
|
||||
|
|
@ -136,7 +147,7 @@ async function main() {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`✓ i18n check passed — ${en.size} keys in en/es, ${used.size} used · th ${thCoverage}% (${en.size - missingTh}/${en.size}) · ja ${jaCoverage}% (${en.size - missingJa}/${en.size})`);
|
||||
console.log(`✓ i18n check passed — ${en.size} keys in en/es, ${used.size} used · th ${thCoverage}% · ja ${jaCoverage}% · ${extraStats.join(' · ')}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,14 @@ interface LanguageOption {
|
|||
const languages: LanguageOption[] = [
|
||||
{ code: 'en', name: 'English', nativeName: 'English', flag: '🇬🇧' },
|
||||
{ code: 'es', name: 'Spanish', nativeName: 'Español', flag: '🇪🇸' },
|
||||
{ code: 'pt', name: 'Portuguese', nativeName: 'Português', flag: '🇧🇷' },
|
||||
{ code: 'fr', name: 'French', nativeName: 'Français', flag: '🇫🇷' },
|
||||
{ code: 'de', name: 'German', nativeName: 'Deutsch', flag: '🇩🇪' },
|
||||
{ code: 'it', name: 'Italian', nativeName: 'Italiano', flag: '🇮🇹' },
|
||||
{ code: 'nl', name: 'Dutch', nativeName: 'Nederlands', flag: '🇳🇱' },
|
||||
{ code: 'pl', name: 'Polish', nativeName: 'Polski', flag: '🇵🇱' },
|
||||
{ code: 'ru', name: 'Russian', nativeName: 'Русский', flag: '🇷🇺' },
|
||||
{ code: 'ko', name: 'Korean', nativeName: '한국어', flag: '🇰🇷' },
|
||||
{ code: 'th', name: 'Thai', nativeName: 'ไทย', flag: '🇹🇭' },
|
||||
{ code: 'ja', name: 'Japanese', nativeName: '日本語', flag: '🇯🇵' },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ import {
|
|||
LanguageContext,
|
||||
getNestedValue,
|
||||
interpolate,
|
||||
translations,
|
||||
englishTranslations,
|
||||
LANGUAGE_LOADERS,
|
||||
type Language,
|
||||
type Translations,
|
||||
} from './language';
|
||||
import { setDateLocale } from '@/lib/dateFnsI18n';
|
||||
|
||||
|
|
@ -24,6 +26,14 @@ function detectLanguage(): Language {
|
|||
const tags = [navigator.language, ...(navigator.languages ?? [])].map((t) => t?.toLowerCase() ?? '');
|
||||
if (tags.some((t) => t.startsWith('th'))) return 'th';
|
||||
if (tags.some((t) => t.startsWith('ja'))) return 'ja';
|
||||
if (tags.some((t) => t.startsWith('ko'))) return 'ko';
|
||||
if (tags.some((t) => t.startsWith('pt'))) return 'pt';
|
||||
if (tags.some((t) => t.startsWith('fr'))) return 'fr';
|
||||
if (tags.some((t) => t.startsWith('de'))) return 'de';
|
||||
if (tags.some((t) => t.startsWith('it'))) return 'it';
|
||||
if (tags.some((t) => t.startsWith('nl'))) return 'nl';
|
||||
if (tags.some((t) => t.startsWith('pl'))) return 'pl';
|
||||
if (tags.some((t) => t.startsWith('ru'))) return 'ru';
|
||||
if (tags.some((t) => t.startsWith('es'))) return 'es';
|
||||
return 'en';
|
||||
}
|
||||
|
|
@ -35,12 +45,25 @@ function detectLanguage(): Language {
|
|||
export function LanguageProvider({ children }: LanguageProviderProps) {
|
||||
const [storedLanguage, setStoredLanguage] = useLocalStorage<Language>('app-language', detectLanguage());
|
||||
const [language, setLanguageState] = useState<Language>(storedLanguage);
|
||||
// The loaded dictionary for `language`. Starts as English (the static source)
|
||||
// and is replaced once the lazy locale chunk resolves, so the first paint is
|
||||
// never blocked and untranslated keys fall back to English.
|
||||
const [dict, setDict] = useState<Translations>(englishTranslations);
|
||||
|
||||
// Sync with localStorage
|
||||
useEffect(() => {
|
||||
setLanguageState(storedLanguage);
|
||||
}, [storedLanguage]);
|
||||
|
||||
// Lazy-load the locale dictionary whenever the language changes.
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
LANGUAGE_LOADERS[language]()
|
||||
.then((d) => { if (active) setDict(d); })
|
||||
.catch(() => { if (active) setDict(englishTranslations); });
|
||||
return () => { active = false; };
|
||||
}, [language]);
|
||||
|
||||
const setLanguage = useCallback((lang: Language) => {
|
||||
setLanguageState(lang);
|
||||
setStoredLanguage(lang);
|
||||
|
|
@ -54,25 +77,17 @@ export function LanguageProvider({ children }: LanguageProviderProps) {
|
|||
}, [language]);
|
||||
|
||||
const t = useCallback((key: string, params?: Record<string, string | number>): string => {
|
||||
// Try to get translation in current language
|
||||
const translation = getNestedValue(translations[language], key);
|
||||
// Current locale first, then the English source as fallback.
|
||||
const translation = getNestedValue(dict, key) ?? getNestedValue(englishTranslations, key);
|
||||
|
||||
if (translation) {
|
||||
return interpolate(translation, params);
|
||||
}
|
||||
|
||||
// Fallback to English
|
||||
if (language !== 'en') {
|
||||
const fallback = getNestedValue(translations.en, key);
|
||||
if (fallback) {
|
||||
return interpolate(fallback, params);
|
||||
}
|
||||
}
|
||||
|
||||
// Return key if no translation found (helps identify missing translations)
|
||||
console.warn(`Missing translation for key: ${key}`);
|
||||
return key;
|
||||
}, [language]);
|
||||
}, [dict]);
|
||||
|
||||
// Currently no RTL languages supported, but ready for future
|
||||
const isRTL = false;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
import { createContext, useContext } from 'react';
|
||||
import en from '@/i18n/en';
|
||||
import es from '@/i18n/es';
|
||||
import th from '@/i18n/th';
|
||||
import ja from '@/i18n/ja';
|
||||
|
||||
/**
|
||||
* Language context + types + hooks. Split from the `LanguageProvider` component
|
||||
|
|
@ -10,7 +7,7 @@ import ja from '@/i18n/ja';
|
|||
* `react-refresh/only-export-components` rule.
|
||||
*/
|
||||
|
||||
export type Language = 'en' | 'es' | 'th' | 'ja';
|
||||
export type Language = 'en' | 'es' | 'th' | 'ja' | 'pt' | 'fr' | 'de' | 'it' | 'nl' | 'pl' | 'ru' | 'ko';
|
||||
|
||||
type TranslationKey = string;
|
||||
type TranslationValue = string | { [key: string]: TranslationValue };
|
||||
|
|
@ -32,7 +29,27 @@ export interface LanguageContextType {
|
|||
|
||||
export const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
|
||||
|
||||
export const translations: Record<Language, Translations> = { en, es, th, ja };
|
||||
export const englishTranslations = en;
|
||||
|
||||
/**
|
||||
* Locale dictionaries are loaded on demand — bundling every language eagerly
|
||||
* bloats the app (each locale is hundreds of KB). English is the static source +
|
||||
* runtime fallback; everything else is a dynamic import.
|
||||
*/
|
||||
export const LANGUAGE_LOADERS: Record<Language, () => Promise<Translations>> = {
|
||||
en: async () => en,
|
||||
es: () => import('@/i18n/es').then((m) => m.default),
|
||||
th: () => import('@/i18n/th').then((m) => m.default),
|
||||
ja: () => import('@/i18n/ja').then((m) => m.default),
|
||||
pt: () => import('@/i18n/pt').then((m) => m.default),
|
||||
fr: () => import('@/i18n/fr').then((m) => m.default),
|
||||
de: () => import('@/i18n/de').then((m) => m.default),
|
||||
it: () => import('@/i18n/it').then((m) => m.default),
|
||||
nl: () => import('@/i18n/nl').then((m) => m.default),
|
||||
pl: () => import('@/i18n/pl').then((m) => m.default),
|
||||
ru: () => import('@/i18n/ru').then((m) => m.default),
|
||||
ko: () => import('@/i18n/ko').then((m) => m.default),
|
||||
};
|
||||
|
||||
/** Get a nested value from an object using dot notation (`nav.home`). */
|
||||
export function getNestedValue(obj: Translations, path: string): string | undefined {
|
||||
|
|
|
|||
9
src/i18n/de/index.ts
Normal file
9
src/i18n/de/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* de translations — fragments composed here.
|
||||
*
|
||||
* Partial by design: missing keys fall back to English; `check-i18n.mjs`
|
||||
* reports coverage. Add fragments as the namespaces land.
|
||||
*/
|
||||
const de = {};
|
||||
|
||||
export default de;
|
||||
9
src/i18n/fr/index.ts
Normal file
9
src/i18n/fr/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* fr translations — fragments composed here.
|
||||
*
|
||||
* Partial by design: missing keys fall back to English; `check-i18n.mjs`
|
||||
* reports coverage. Add fragments as the namespaces land.
|
||||
*/
|
||||
const fr = {};
|
||||
|
||||
export default fr;
|
||||
9
src/i18n/it/index.ts
Normal file
9
src/i18n/it/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* it translations — fragments composed here.
|
||||
*
|
||||
* Partial by design: missing keys fall back to English; `check-i18n.mjs`
|
||||
* reports coverage. Add fragments as the namespaces land.
|
||||
*/
|
||||
const it = {};
|
||||
|
||||
export default it;
|
||||
9
src/i18n/ko/index.ts
Normal file
9
src/i18n/ko/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* ko translations — fragments composed here.
|
||||
*
|
||||
* Partial by design: missing keys fall back to English; `check-i18n.mjs`
|
||||
* reports coverage. Add fragments as the namespaces land.
|
||||
*/
|
||||
const ko = {};
|
||||
|
||||
export default ko;
|
||||
9
src/i18n/nl/index.ts
Normal file
9
src/i18n/nl/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* nl translations — fragments composed here.
|
||||
*
|
||||
* Partial by design: missing keys fall back to English; `check-i18n.mjs`
|
||||
* reports coverage. Add fragments as the namespaces land.
|
||||
*/
|
||||
const nl = {};
|
||||
|
||||
export default nl;
|
||||
9
src/i18n/pl/index.ts
Normal file
9
src/i18n/pl/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* pl translations — fragments composed here.
|
||||
*
|
||||
* Partial by design: missing keys fall back to English; `check-i18n.mjs`
|
||||
* reports coverage. Add fragments as the namespaces land.
|
||||
*/
|
||||
const pl = {};
|
||||
|
||||
export default pl;
|
||||
9
src/i18n/pt/index.ts
Normal file
9
src/i18n/pt/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* pt translations — fragments composed here.
|
||||
*
|
||||
* Partial by design: missing keys fall back to English; `check-i18n.mjs`
|
||||
* reports coverage. Add fragments as the namespaces land.
|
||||
*/
|
||||
const pt = {};
|
||||
|
||||
export default pt;
|
||||
9
src/i18n/ru/index.ts
Normal file
9
src/i18n/ru/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* ru translations — fragments composed here.
|
||||
*
|
||||
* Partial by design: missing keys fall back to English; `check-i18n.mjs`
|
||||
* reports coverage. Add fragments as the namespaces land.
|
||||
*/
|
||||
const ru = {};
|
||||
|
||||
export default ru;
|
||||
|
|
@ -149,18 +149,18 @@
|
|||
/* ── Display font utilities ───────────────────────────────────────────────── */
|
||||
/* Bebas Neue — used for all major headlines */
|
||||
.font-display {
|
||||
font-family: 'Bebas Neue', 'Kanit', 'Noto Sans JP', 'Impact', 'Arial Black', sans-serif;
|
||||
font-family: 'Bebas Neue', 'Kanit', 'Noto Sans JP', 'Noto Sans KR', 'Impact', 'Arial Black', sans-serif;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* Barlow Condensed — used for subheadings, labels, nav */
|
||||
.font-condensed {
|
||||
font-family: 'Barlow Condensed', 'Kanit', 'Noto Sans JP', 'Arial Narrow', sans-serif;
|
||||
font-family: 'Barlow Condensed', 'Kanit', 'Noto Sans JP', 'Noto Sans KR', 'Arial Narrow', sans-serif;
|
||||
}
|
||||
|
||||
.neon-header {
|
||||
animation: neon-glow 2s ease-in-out infinite;
|
||||
font-family: 'Bebas Neue', 'Kanit', 'Noto Sans JP', 'Impact', 'Arial Black', sans-serif;
|
||||
font-family: 'Bebas Neue', 'Kanit', 'Noto Sans JP', 'Noto Sans KR', 'Impact', 'Arial Black', sans-serif;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
|
|
@ -714,7 +714,7 @@ select {
|
|||
position: relative;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
font-family: 'Inter Variable', 'Inter', 'Kanit', 'Noto Sans JP', system-ui, sans-serif;
|
||||
font-family: 'Inter Variable', 'Inter', 'Kanit', 'Noto Sans JP', 'Noto Sans KR', system-ui, sans-serif;
|
||||
box-shadow: 0 8px 40px rgba(0,0,0,0.6), 0 2px 8px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,20 @@ import {
|
|||
formatDistance as _formatDistance,
|
||||
format as _format,
|
||||
} from 'date-fns';
|
||||
import { enUS, es as esLocale, th as thLocale, ja as jaLocale } from 'date-fns/locale';
|
||||
import {
|
||||
enUS,
|
||||
es as esLocale,
|
||||
th as thLocale,
|
||||
ja as jaLocale,
|
||||
ptBR as ptLocale,
|
||||
fr as frLocale,
|
||||
de as deLocale,
|
||||
it as itLocale,
|
||||
nl as nlLocale,
|
||||
pl as plLocale,
|
||||
ru as ruLocale,
|
||||
ko as koLocale,
|
||||
} from 'date-fns/locale';
|
||||
import type { Locale } from 'date-fns';
|
||||
import type { Language } from '@/contexts/language';
|
||||
|
||||
|
|
@ -37,6 +50,30 @@ export function setDateLocale(language: Language): void {
|
|||
} else if (language === 'ja') {
|
||||
activeLocale = jaLocale;
|
||||
activeCode = 'ja-JP';
|
||||
} else if (language === 'pt') {
|
||||
activeLocale = ptLocale;
|
||||
activeCode = 'pt-BR';
|
||||
} else if (language === 'fr') {
|
||||
activeLocale = frLocale;
|
||||
activeCode = 'fr-FR';
|
||||
} else if (language === 'de') {
|
||||
activeLocale = deLocale;
|
||||
activeCode = 'de-DE';
|
||||
} else if (language === 'it') {
|
||||
activeLocale = itLocale;
|
||||
activeCode = 'it-IT';
|
||||
} else if (language === 'nl') {
|
||||
activeLocale = nlLocale;
|
||||
activeCode = 'nl-NL';
|
||||
} else if (language === 'pl') {
|
||||
activeLocale = plLocale;
|
||||
activeCode = 'pl-PL';
|
||||
} else if (language === 'ru') {
|
||||
activeLocale = ruLocale;
|
||||
activeCode = 'ru-RU';
|
||||
} else if (language === 'ko') {
|
||||
activeLocale = koLocale;
|
||||
activeCode = 'ko-KR';
|
||||
} else {
|
||||
activeLocale = enUS;
|
||||
activeCode = 'en-US';
|
||||
|
|
|
|||
|
|
@ -43,6 +43,12 @@ import '@fontsource/kanit/700.css';
|
|||
import '@fontsource/noto-sans-jp/400.css';
|
||||
import '@fontsource/noto-sans-jp/500.css';
|
||||
import '@fontsource/noto-sans-jp/700.css';
|
||||
// Korean glyphs — Noto Sans KR
|
||||
import '@fontsource/noto-sans-kr/400.css';
|
||||
import '@fontsource/noto-sans-kr/500.css';
|
||||
import '@fontsource/noto-sans-kr/700.css';
|
||||
// Cyrillic (Russian) — Inter has Cyrillic; Outfit does not
|
||||
import '@fontsource-variable/inter';
|
||||
|
||||
// Leaflet CSS is loaded via CDN in index.html
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export default {
|
|||
},
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Outfit Variable', 'Outfit', 'Kanit', 'Noto Sans JP', 'system-ui', 'sans-serif'],
|
||||
sans: ['Outfit Variable', 'Outfit', 'Inter Variable', 'Kanit', 'Noto Sans JP', 'Noto Sans KR', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
|
|
|
|||
Loading…
Reference in a new issue