i18n: lazy-load locales + register 8 new (pt fr de it nl pl ru ko) with fonts

This commit is contained in:
SkateSpots 2026-10-07 21:23:39 +07:00
parent 7ab9a5b399
commit 1460b06277
18 changed files with 201 additions and 24 deletions

10
package-lock.json generated
View file

@ -22,6 +22,7 @@
"@fontsource/bebas-neue": "^5.2.7", "@fontsource/bebas-neue": "^5.2.7",
"@fontsource/kanit": "^5.3.0", "@fontsource/kanit": "^5.3.0",
"@fontsource/noto-sans-jp": "^5.3.0", "@fontsource/noto-sans-jp": "^5.3.0",
"@fontsource/noto-sans-kr": "^5.3.0",
"@getalby/sdk": "^5.1.1", "@getalby/sdk": "^5.1.1",
"@hookform/resolvers": "^3.9.0", "@hookform/resolvers": "^3.9.0",
"@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/sdk": "^1.29.0",
@ -1521,6 +1522,15 @@
"url": "https://github.com/sponsors/ayuhito" "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": { "node_modules/@getalby/lightning-tools": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/@getalby/lightning-tools/-/lightning-tools-5.2.0.tgz", "resolved": "https://registry.npmjs.org/@getalby/lightning-tools/-/lightning-tools-5.2.0.tgz",

View file

@ -42,6 +42,7 @@
"@fontsource/bebas-neue": "^5.2.7", "@fontsource/bebas-neue": "^5.2.7",
"@fontsource/kanit": "^5.3.0", "@fontsource/kanit": "^5.3.0",
"@fontsource/noto-sans-jp": "^5.3.0", "@fontsource/noto-sans-jp": "^5.3.0",
"@fontsource/noto-sans-kr": "^5.3.0",
"@getalby/sdk": "^5.1.1", "@getalby/sdk": "^5.1.1",
"@hookform/resolvers": "^3.9.0", "@hookform/resolvers": "^3.9.0",
"@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/sdk": "^1.29.0",

View file

@ -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}`); 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; 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) { if (problems.length) {
console.error(`\n✖ i18n check failed (${problems.length}):`); console.error(`\n✖ i18n check failed (${problems.length}):`);
for (const p of problems.slice(0, 200)) console.error(` • ${p}`); for (const p of problems.slice(0, 200)) console.error(` • ${p}`);
@ -136,7 +147,7 @@ async function main() {
process.exit(1); 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) => { main().catch((err) => {

View file

@ -19,6 +19,14 @@ interface LanguageOption {
const languages: LanguageOption[] = [ const languages: LanguageOption[] = [
{ code: 'en', name: 'English', nativeName: 'English', flag: '🇬🇧' }, { code: 'en', name: 'English', nativeName: 'English', flag: '🇬🇧' },
{ code: 'es', name: 'Spanish', nativeName: 'Español', 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: 'th', name: 'Thai', nativeName: 'ไทย', flag: '🇹🇭' },
{ code: 'ja', name: 'Japanese', nativeName: '日本語', flag: '🇯🇵' }, { code: 'ja', name: 'Japanese', nativeName: '日本語', flag: '🇯🇵' },
]; ];

View file

@ -4,8 +4,10 @@ import {
LanguageContext, LanguageContext,
getNestedValue, getNestedValue,
interpolate, interpolate,
translations, englishTranslations,
LANGUAGE_LOADERS,
type Language, type Language,
type Translations,
} from './language'; } from './language';
import { setDateLocale } from '@/lib/dateFnsI18n'; import { setDateLocale } from '@/lib/dateFnsI18n';
@ -24,6 +26,14 @@ function detectLanguage(): Language {
const tags = [navigator.language, ...(navigator.languages ?? [])].map((t) => t?.toLowerCase() ?? ''); 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('th'))) return 'th';
if (tags.some((t) => t.startsWith('ja'))) return 'ja'; 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'; if (tags.some((t) => t.startsWith('es'))) return 'es';
return 'en'; return 'en';
} }
@ -35,12 +45,25 @@ function detectLanguage(): Language {
export function LanguageProvider({ children }: LanguageProviderProps) { export function LanguageProvider({ children }: LanguageProviderProps) {
const [storedLanguage, setStoredLanguage] = useLocalStorage<Language>('app-language', detectLanguage()); const [storedLanguage, setStoredLanguage] = useLocalStorage<Language>('app-language', detectLanguage());
const [language, setLanguageState] = useState<Language>(storedLanguage); 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 // Sync with localStorage
useEffect(() => { useEffect(() => {
setLanguageState(storedLanguage); setLanguageState(storedLanguage);
}, [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) => { const setLanguage = useCallback((lang: Language) => {
setLanguageState(lang); setLanguageState(lang);
setStoredLanguage(lang); setStoredLanguage(lang);
@ -54,25 +77,17 @@ export function LanguageProvider({ children }: LanguageProviderProps) {
}, [language]); }, [language]);
const t = useCallback((key: string, params?: Record<string, string | number>): string => { const t = useCallback((key: string, params?: Record<string, string | number>): string => {
// Try to get translation in current language // Current locale first, then the English source as fallback.
const translation = getNestedValue(translations[language], key); const translation = getNestedValue(dict, key) ?? getNestedValue(englishTranslations, key);
if (translation) { if (translation) {
return interpolate(translation, params); 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) // Return key if no translation found (helps identify missing translations)
console.warn(`Missing translation for key: ${key}`); console.warn(`Missing translation for key: ${key}`);
return key; return key;
}, [language]); }, [dict]);
// Currently no RTL languages supported, but ready for future // Currently no RTL languages supported, but ready for future
const isRTL = false; const isRTL = false;

View file

@ -1,8 +1,5 @@
import { createContext, useContext } from 'react'; import { createContext, useContext } from 'react';
import en from '@/i18n/en'; 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 * Language context + types + hooks. Split from the `LanguageProvider` component
@ -10,7 +7,7 @@ import ja from '@/i18n/ja';
* `react-refresh/only-export-components` rule. * `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 TranslationKey = string;
type TranslationValue = string | { [key: string]: TranslationValue }; type TranslationValue = string | { [key: string]: TranslationValue };
@ -32,7 +29,27 @@ export interface LanguageContextType {
export const LanguageContext = createContext<LanguageContextType | undefined>(undefined); 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`). */ /** Get a nested value from an object using dot notation (`nav.home`). */
export function getNestedValue(obj: Translations, path: string): string | undefined { export function getNestedValue(obj: Translations, path: string): string | undefined {

9
src/i18n/de/index.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;

View file

@ -149,18 +149,18 @@
/* ── Display font utilities ───────────────────────────────────────────────── */ /* ── Display font utilities ───────────────────────────────────────────────── */
/* Bebas Neue — used for all major headlines */ /* Bebas Neue — used for all major headlines */
.font-display { .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; letter-spacing: 0.04em;
} }
/* Barlow Condensed — used for subheadings, labels, nav */ /* Barlow Condensed — used for subheadings, labels, nav */
.font-condensed { .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 { .neon-header {
animation: neon-glow 2s ease-in-out infinite; 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; letter-spacing: 0.06em;
} }
@ -714,7 +714,7 @@ select {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
flex-shrink: 0; 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); box-shadow: 0 8px 40px rgba(0,0,0,0.6), 0 2px 8px rgba(0,0,0,0.4);
} }

View file

@ -19,7 +19,20 @@ import {
formatDistance as _formatDistance, formatDistance as _formatDistance,
format as _format, format as _format,
} from 'date-fns'; } 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 { Locale } from 'date-fns';
import type { Language } from '@/contexts/language'; import type { Language } from '@/contexts/language';
@ -37,6 +50,30 @@ export function setDateLocale(language: Language): void {
} else if (language === 'ja') { } else if (language === 'ja') {
activeLocale = jaLocale; activeLocale = jaLocale;
activeCode = 'ja-JP'; 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 { } else {
activeLocale = enUS; activeLocale = enUS;
activeCode = 'en-US'; activeCode = 'en-US';

View file

@ -43,6 +43,12 @@ import '@fontsource/kanit/700.css';
import '@fontsource/noto-sans-jp/400.css'; import '@fontsource/noto-sans-jp/400.css';
import '@fontsource/noto-sans-jp/500.css'; import '@fontsource/noto-sans-jp/500.css';
import '@fontsource/noto-sans-jp/700.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 // Leaflet CSS is loaded via CDN in index.html

View file

@ -28,7 +28,7 @@ export default {
}, },
extend: { extend: {
fontFamily: { 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: { colors: {
border: 'hsl(var(--border))', border: 'hsl(var(--border))',