From 1460b06277def466aa9c1fd56dbfc4c2b9d527d5 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Wed, 7 Oct 2026 21:23:39 +0700 Subject: [PATCH] i18n: lazy-load locales + register 8 new (pt fr de it nl pl ru ko) with fonts --- package-lock.json | 10 ++++++++ package.json | 1 + scripts/check-i18n.mjs | 13 ++++++++++- src/components/LanguageToggle.tsx | 8 +++++++ src/contexts/LanguageContext.tsx | 39 +++++++++++++++++++++---------- src/contexts/language.ts | 27 +++++++++++++++++---- src/i18n/de/index.ts | 9 +++++++ src/i18n/fr/index.ts | 9 +++++++ src/i18n/it/index.ts | 9 +++++++ src/i18n/ko/index.ts | 9 +++++++ src/i18n/nl/index.ts | 9 +++++++ src/i18n/pl/index.ts | 9 +++++++ src/i18n/pt/index.ts | 9 +++++++ src/i18n/ru/index.ts | 9 +++++++ src/index.css | 8 +++---- src/lib/dateFnsI18n.ts | 39 ++++++++++++++++++++++++++++++- src/main.tsx | 6 +++++ tailwind.config.ts | 2 +- 18 files changed, 201 insertions(+), 24 deletions(-) create mode 100644 src/i18n/de/index.ts create mode 100644 src/i18n/fr/index.ts create mode 100644 src/i18n/it/index.ts create mode 100644 src/i18n/ko/index.ts create mode 100644 src/i18n/nl/index.ts create mode 100644 src/i18n/pl/index.ts create mode 100644 src/i18n/pt/index.ts create mode 100644 src/i18n/ru/index.ts diff --git a/package-lock.json b/package-lock.json index f9cc924..66558b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index d6ae91e..ce4a952 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/check-i18n.mjs b/scripts/check-i18n.mjs index cbd1839..1054d3b 100644 --- a/scripts/check-i18n.mjs +++ b/scripts/check-i18n.mjs @@ -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) => { diff --git a/src/components/LanguageToggle.tsx b/src/components/LanguageToggle.tsx index ebf6ed3..a7c089d 100644 --- a/src/components/LanguageToggle.tsx +++ b/src/components/LanguageToggle.tsx @@ -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: '🇯🇵' }, ]; diff --git a/src/contexts/LanguageContext.tsx b/src/contexts/LanguageContext.tsx index ea62d2e..fdff6ce 100644 --- a/src/contexts/LanguageContext.tsx +++ b/src/contexts/LanguageContext.tsx @@ -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('app-language', detectLanguage()); const [language, setLanguageState] = useState(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(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 => { - // 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; diff --git a/src/contexts/language.ts b/src/contexts/language.ts index 79ec37f..fb908a2 100644 --- a/src/contexts/language.ts +++ b/src/contexts/language.ts @@ -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(undefined); -export const translations: Record = { 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 Promise> = { + 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 { diff --git a/src/i18n/de/index.ts b/src/i18n/de/index.ts new file mode 100644 index 0000000..d61e132 --- /dev/null +++ b/src/i18n/de/index.ts @@ -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; diff --git a/src/i18n/fr/index.ts b/src/i18n/fr/index.ts new file mode 100644 index 0000000..7a9e2ee --- /dev/null +++ b/src/i18n/fr/index.ts @@ -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; diff --git a/src/i18n/it/index.ts b/src/i18n/it/index.ts new file mode 100644 index 0000000..b96762c --- /dev/null +++ b/src/i18n/it/index.ts @@ -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; diff --git a/src/i18n/ko/index.ts b/src/i18n/ko/index.ts new file mode 100644 index 0000000..fcbdfcc --- /dev/null +++ b/src/i18n/ko/index.ts @@ -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; diff --git a/src/i18n/nl/index.ts b/src/i18n/nl/index.ts new file mode 100644 index 0000000..53c41da --- /dev/null +++ b/src/i18n/nl/index.ts @@ -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; diff --git a/src/i18n/pl/index.ts b/src/i18n/pl/index.ts new file mode 100644 index 0000000..3a96d75 --- /dev/null +++ b/src/i18n/pl/index.ts @@ -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; diff --git a/src/i18n/pt/index.ts b/src/i18n/pt/index.ts new file mode 100644 index 0000000..7ca12a0 --- /dev/null +++ b/src/i18n/pt/index.ts @@ -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; diff --git a/src/i18n/ru/index.ts b/src/i18n/ru/index.ts new file mode 100644 index 0000000..0f97fe5 --- /dev/null +++ b/src/i18n/ru/index.ts @@ -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; diff --git a/src/index.css b/src/index.css index e50364a..60e420e 100644 --- a/src/index.css +++ b/src/index.css @@ -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); } diff --git a/src/lib/dateFnsI18n.ts b/src/lib/dateFnsI18n.ts index 9962e5e..ccc3b6d 100644 --- a/src/lib/dateFnsI18n.ts +++ b/src/lib/dateFnsI18n.ts @@ -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'; diff --git a/src/main.tsx b/src/main.tsx index f74b121..acdf922 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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 diff --git a/tailwind.config.ts b/tailwind.config.ts index 51405f5..74fecf7 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -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))',