feat(i18n): add Hindi (24 dictionaries, 25 codes, all 100%)
- Full 8,703-key Hindi dictionary in src/i18n/hi (7 namespace batches),
placeholders verified, only 2% identical strings (brand/protocol names).
- Register hi in Language/LANGUAGE_LOADERS, detectLanguage (hi-IN),
LanguageToggle (🇮🇳, हिन्दी) and dateFnsI18n (hi-IN).
- Lazy-load Noto Sans Devanagari for hi via lib/localeFonts.ts (renamed from
cjkFonts.ts) and add it to the CSS font stack; other locales don't download it.
- check-i18n covers hi: 24 dictionaries / 25 codes at 100%; tsc/tests/build green.
This commit is contained in:
parent
7e48884eb9
commit
828684cf15
20 changed files with 9776 additions and 32 deletions
32
AGENTS.md
32
AGENTS.md
|
|
@ -2688,7 +2688,7 @@ similar are internal endpoints/hook names, not credits — leave them.)
|
|||
is now **multi-way** — en/es stay key-for-key identical (unchanged), `th` (and
|
||||
`ja`) may be partial and are reported as **coverage %**; a `th`/`ja` key absent
|
||||
from `en` fails.
|
||||
- **Font:** `@fontsource/kanit` (Kanit), **lazy-loaded per locale** (`lib/cjkFonts.ts`) and added to the
|
||||
- **Font:** `@fontsource/kanit` (Kanit), **lazy-loaded per locale** (`lib/localeFonts.ts`) and added to the
|
||||
CSS + Tailwind font stacks — ⚠️ Outfit/Inter/Bebas/Barlow have **no Thai
|
||||
glyphs**, so without it Thai silently falls back to a system font.
|
||||
- **Detection:** a Thai-device browser (`th*`) starts in `th` (`detectLanguage`).
|
||||
|
|
@ -2697,18 +2697,19 @@ similar are internal endpoints/hook names, not credits — leave them.)
|
|||
stays English (spot, park, bowl, DIY, vert, crew, line, Stash, The Lab, Nostr,
|
||||
Lightning, Zap, Relay, sats) because Thai skaters use the English terms.
|
||||
|
||||
### Catalan, Basque, Galician + pt-PT — 23 dictionaries, 24 codes, all 100% (2026-10-26)
|
||||
- Added **ca (Català), eu (Euskara), gl (Galego)** → **23 dictionaries**;
|
||||
**pt-PT** is a variant that shares the `pt` dictionary (24 selectable codes).
|
||||
- Registered in `Language` + `LANGUAGE_LOADERS`, `detectLanguage` (ca/eu/gl
|
||||
### Catalan, Basque, Galician, Hindi + pt-PT — 24 dictionaries, 25 codes, all 100% (2026-10-26)
|
||||
- Added **ca (Català), eu (Euskara), gl (Galego), hi (हिन्दी)** → **24
|
||||
dictionaries**; **pt-PT** is a variant that shares the `pt` dictionary
|
||||
(25 selectable codes).
|
||||
- Registered in `Language` + `LANGUAGE_LOADERS`, `detectLanguage` (ca/eu/gl/hi
|
||||
checked before `es`; `pt-BR`→`pt`, `pt-PT`→`pt-PT`), the `LanguageToggle`
|
||||
list, and `dateFnsI18n` (date-fns `ca`/`eu`/`gl`/`pt`, codes
|
||||
`ca-ES`/`eu-ES`/`gl-ES`/`pt-BR`/`pt-PT`).
|
||||
list, and `dateFnsI18n` (date-fns `ca`/`eu`/`gl`/`hi`/`pt`, codes
|
||||
`ca-ES`/`eu-ES`/`gl-ES`/`hi-IN`/`pt-BR`/`pt-PT`).
|
||||
- **Flags are real SVGs** in `public/flags/` for `ca`, `eu`, `gl` and `gb-eng`
|
||||
(there are no Unicode region flags for Catalonia/Basque/Galicia, and England
|
||||
is not the UK flag). `LanguageToggle` renders a path as `<img>` and an emoji
|
||||
as text. **English now uses the England flag** (not the UK), and Portuguese
|
||||
has **Brasil 🇧🇷** (`pt`) + **Portugal 🇵🇹** (`pt-PT`).
|
||||
as text. **English now uses the England flag** (not the UK), Portuguese has
|
||||
**Brasil 🇧🇷** (`pt`) + **Portugal 🇵🇹** (`pt-PT`), and Hindi uses 🇮🇳.
|
||||
- **`pt-PT`** shares the `pt` base and applies a European drift
|
||||
(`src/i18n/pt-PT/drift.ts`: Salvar→Guardar, Senha→Palavra-passe,
|
||||
Ônibus→Autocarro, etc.). A full European-Portuguese review is a follow-up.
|
||||
|
|
@ -2718,15 +2719,18 @@ similar are internal endpoints/hook names, not credits — leave them.)
|
|||
covering the surface (no grey bands), while still letting you drag across the
|
||||
antimeridian to Hawaii/Pacific. On a wide desktop the whole globe cannot also
|
||||
fit without distortion; the clamp prioritises coverage.
|
||||
- Full dictionaries in `src/i18n/{ca,eu,gl}/` — 7 namespace batches each
|
||||
- Full dictionaries in `src/i18n/{ca,eu,gl,hi}/` — 7 namespace batches each
|
||||
(8,703 keys), composed in `index.ts`; placeholders verified, only 2–4%
|
||||
identical strings (brand/protocol names). `check-i18n.mjs` includes them in
|
||||
`extraLocales` and reports 100%.
|
||||
- Latin script, so **no new font**; each locale lazy-loads like the rest.
|
||||
- Catalan/Galician/Portuguese are Latin; **Hindi is Devanagari**, so
|
||||
`@fontsource/noto-sans-devanagari` (400/500/700) is **lazy-loaded** for `hi`
|
||||
via `lib/localeFonts.ts` (same pattern as CJK) and the CSS stack includes
|
||||
`'Noto Sans Devanagari'` before `system-ui`.
|
||||
- Translation sources were split by namespace into
|
||||
`/tmp/opencode/i18n-src/batch1..7.json` (regenerate from `en.ts` if
|
||||
re-translating). When adding new UI strings, add them to `en`/`es` **and all
|
||||
23 locales** (batch file or `drift.ts`).
|
||||
24 dictionaries (25 codes)** (batch file or `drift.ts`).
|
||||
|
||||
### i18n drift closed — all 20 locales 100% (2026-10-13)
|
||||
- Every non-en/es locale had fallen to **97%** (245 keys each — `trustUi`,
|
||||
|
|
@ -2753,7 +2757,7 @@ similar are internal endpoints/hook names, not credits — leave them.)
|
|||
bloats the app. Each language is its own on-demand chunk.
|
||||
- **Fonts:** added **Noto Sans KR** (Korean) and **Inter** (Cyrillic/Russian) to
|
||||
the CSS + Tailwind stacks; JP/KR/TH each need their own font (Outfit/Inter
|
||||
lack CJK). **(2026-10-12) CJK fonts are lazy-loaded per locale** (`lib/cjkFonts.ts`,
|
||||
lack CJK). **(2026-10-12) CJK fonts are lazy-loaded per locale** (`lib/localeFonts.ts`,
|
||||
wired in `contexts/LanguageContext.tsx`) — they're ~16 MB of font payload that
|
||||
non-CJK users no longer download at boot.
|
||||
- `check-i18n.mjs` now reports **all 12**; new locales are *partial* (coverage %)
|
||||
|
|
@ -2775,7 +2779,7 @@ similar are internal endpoints/hook names, not credits — leave them.)
|
|||
Both partial-locale rules below still hold for any *future* keys.
|
||||
- **Partial by design** (same as `th`): a missing `ja` key falls back to English.
|
||||
`check-i18n.mjs` reports `ja` coverage and fails if a `ja` key is absent from `en`.
|
||||
- **Font:** `@fontsource/noto-sans-jp` (Noto Sans JP), **lazy-loaded per locale** (`lib/cjkFonts.ts`) and
|
||||
- **Font:** `@fontsource/noto-sans-jp` (Noto Sans JP), **lazy-loaded per locale** (`lib/localeFonts.ts`) and
|
||||
added to the CSS + Tailwind font stacks — ⚠️ Outfit/Inter/Bebas/Barlow/Kanit have
|
||||
**no Japanese glyphs**, so without it Japanese falls back to a system font.
|
||||
- **Detection:** a Japanese-device browser (`ja*`) starts in `ja` (`detectLanguage`).
|
||||
|
|
|
|||
10
package-lock.json
generated
10
package-lock.json
generated
|
|
@ -24,6 +24,7 @@
|
|||
"@fontsource/barlow-condensed": "^5.2.8",
|
||||
"@fontsource/bebas-neue": "^5.2.7",
|
||||
"@fontsource/kanit": "^5.3.0",
|
||||
"@fontsource/noto-sans-devanagari": "^5.3.0",
|
||||
"@fontsource/noto-sans-jp": "^5.3.0",
|
||||
"@fontsource/noto-sans-kr": "^5.3.0",
|
||||
"@getalby/sdk": "^5.1.1",
|
||||
|
|
@ -1531,6 +1532,15 @@
|
|||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/noto-sans-devanagari": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/noto-sans-devanagari/-/noto-sans-devanagari-5.3.0.tgz",
|
||||
"integrity": "sha512-7khYmipS/5KAUUmrO1DKir8yL8XI00d0+ZE9BqbS8XF1jzObv6CpPOk3UeYxF+Pvi8bx9bBvprBCOcbx/eQWTQ==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/noto-sans-jp": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/noto-sans-jp/-/noto-sans-jp-5.3.0.tgz",
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@
|
|||
"@fontsource/barlow-condensed": "^5.2.8",
|
||||
"@fontsource/bebas-neue": "^5.2.7",
|
||||
"@fontsource/kanit": "^5.3.0",
|
||||
"@fontsource/noto-sans-devanagari": "^5.3.0",
|
||||
"@fontsource/noto-sans-jp": "^5.3.0",
|
||||
"@fontsource/noto-sans-kr": "^5.3.0",
|
||||
"@getalby/sdk": "^5.1.1",
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ async function main() {
|
|||
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', 'pt-PT', 'fr', 'de', 'it', 'nl', 'pl', 'ru', 'ko', 'zh-CN', 'zh-TW', 'uk', 'tr', 'vi', 'id', 'sv', 'cs', 'ro', 'el', 'ca', 'eu', 'gl'];
|
||||
const extraLocales = ['pt', 'pt-PT', 'fr', 'de', 'it', 'nl', 'pl', 'ru', 'ko', 'zh-CN', 'zh-TW', 'uk', 'tr', 'vi', 'id', 'sv', 'cs', 'ro', 'el', 'ca', 'eu', 'gl', 'hi'];
|
||||
const extraStats = [];
|
||||
for (const lang of extraLocales) {
|
||||
const dict = flatten(await loadTranslations(join(SRC, `i18n/${lang}/index.ts`)));
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ const languages: LanguageOption[] = [
|
|||
{ code: 'el', name: 'Greek', nativeName: 'Ελληνικά', flag: '🇬🇷' },
|
||||
{ code: 'vi', name: 'Vietnamese', nativeName: 'Tiếng Việt', flag: '🇻🇳' },
|
||||
{ code: 'tr', name: 'Turkish', nativeName: 'Türkçe', flag: '🇹🇷' },
|
||||
{ code: 'hi', name: 'Hindi', nativeName: 'हिन्दी', flag: '🇮🇳' },
|
||||
];
|
||||
|
||||
interface LanguageToggleProps {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
type Translations,
|
||||
} from './language';
|
||||
import { setDateLocale } from '@/lib/dateFnsI18n';
|
||||
import { loadCjkFont } from '@/lib/cjkFonts';
|
||||
import { loadLocaleFont } from '@/lib/localeFonts';
|
||||
|
||||
interface LanguageProviderProps {
|
||||
children: ReactNode;
|
||||
|
|
@ -52,6 +52,7 @@ function detectLanguage(): Language {
|
|||
if (tags.some((t) => t.startsWith('ca'))) return 'ca';
|
||||
if (tags.some((t) => t.startsWith('eu'))) return 'eu';
|
||||
if (tags.some((t) => t.startsWith('gl'))) return 'gl';
|
||||
if (tags.some((t) => t.startsWith('hi'))) return 'hi';
|
||||
if (tags.some((t) => t.startsWith('es'))) return 'es';
|
||||
return 'en';
|
||||
}
|
||||
|
|
@ -85,7 +86,7 @@ export function LanguageProvider({ children }: LanguageProviderProps) {
|
|||
// Lazy-load the CJK display font for the active locale (ja/ko/th) — large font
|
||||
// files that non-CJK users no longer have to download at boot.
|
||||
useEffect(() => {
|
||||
loadCjkFont(language);
|
||||
loadLocaleFont(language);
|
||||
}, [language]);
|
||||
|
||||
const setLanguage = useCallback((lang: Language) => {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import en from '@/i18n/en';
|
|||
* `react-refresh/only-export-components` rule.
|
||||
*/
|
||||
|
||||
export type Language = 'en' | 'es' | 'ca' | 'eu' | 'gl' | 'th' | 'ja' | 'pt' | 'pt-PT' | 'fr' | 'de' | 'it' | 'nl' | 'pl' | 'ru' | 'ko' | 'ko-KP' | 'zh-CN' | 'zh-TW' | 'uk' | 'tr' | 'vi' | 'id' | 'sv' | 'cs' | 'ro' | 'el';
|
||||
export type Language = 'en' | 'es' | 'ca' | 'eu' | 'gl' | 'hi' | 'th' | 'ja' | 'pt' | 'pt-PT' | 'fr' | 'de' | 'it' | 'nl' | 'pl' | 'ru' | 'ko' | 'ko-KP' | 'zh-CN' | 'zh-TW' | 'uk' | 'tr' | 'vi' | 'id' | 'sv' | 'cs' | 'ro' | 'el';
|
||||
|
||||
type TranslationKey = string;
|
||||
type TranslationValue = string | { [key: string]: TranslationValue };
|
||||
|
|
@ -42,6 +42,7 @@ export const LANGUAGE_LOADERS: Record<Language, () => Promise<Translations>> = {
|
|||
ca: () => import('@/i18n/ca').then((m) => m.default),
|
||||
eu: () => import('@/i18n/eu').then((m) => m.default),
|
||||
gl: () => import('@/i18n/gl').then((m) => m.default),
|
||||
hi: () => import('@/i18n/hi').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),
|
||||
|
|
|
|||
1268
src/i18n/hi/batch1.ts
Normal file
1268
src/i18n/hi/batch1.ts
Normal file
File diff suppressed because it is too large
Load diff
1491
src/i18n/hi/batch2.ts
Normal file
1491
src/i18n/hi/batch2.ts
Normal file
File diff suppressed because it is too large
Load diff
1300
src/i18n/hi/batch3.ts
Normal file
1300
src/i18n/hi/batch3.ts
Normal file
File diff suppressed because it is too large
Load diff
1427
src/i18n/hi/batch4.ts
Normal file
1427
src/i18n/hi/batch4.ts
Normal file
File diff suppressed because it is too large
Load diff
1463
src/i18n/hi/batch5.ts
Normal file
1463
src/i18n/hi/batch5.ts
Normal file
File diff suppressed because it is too large
Load diff
1137
src/i18n/hi/batch6.ts
Normal file
1137
src/i18n/hi/batch6.ts
Normal file
File diff suppressed because it is too large
Load diff
1599
src/i18n/hi/batch7.ts
Normal file
1599
src/i18n/hi/batch7.ts
Normal file
File diff suppressed because it is too large
Load diff
4
src/i18n/hi/drift.ts
Normal file
4
src/i18n/hi/drift.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/** Post-translation corrections for hi. */
|
||||
const drift = {};
|
||||
|
||||
export default drift;
|
||||
24
src/i18n/hi/index.ts
Normal file
24
src/i18n/hi/index.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import batch1 from './batch1';
|
||||
import batch2 from './batch2';
|
||||
import batch3 from './batch3';
|
||||
import batch4 from './batch4';
|
||||
import batch5 from './batch5';
|
||||
import batch6 from './batch6';
|
||||
import batch7 from './batch7';
|
||||
import drift from './drift';
|
||||
import { deepMerge } from '../deepMerge';
|
||||
|
||||
const hi = deepMerge(
|
||||
{
|
||||
...batch1,
|
||||
...batch2,
|
||||
...batch3,
|
||||
...batch4,
|
||||
...batch5,
|
||||
...batch6,
|
||||
...batch7,
|
||||
},
|
||||
drift,
|
||||
);
|
||||
|
||||
export default hi;
|
||||
|
|
@ -149,18 +149,18 @@
|
|||
/* ── Display font utilities ───────────────────────────────────────────────── */
|
||||
/* Bebas Neue — used for all major headlines */
|
||||
.font-display {
|
||||
font-family: 'Bebas Neue', 'Kanit', 'Noto Sans JP', 'Noto Sans KR', 'Impact', 'Arial Black', sans-serif;
|
||||
font-family: 'Bebas Neue', 'Kanit', 'Noto Sans JP', 'Noto Sans KR', 'Noto Sans Devanagari', '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', 'Noto Sans KR', 'Arial Narrow', sans-serif;
|
||||
font-family: 'Barlow Condensed', 'Kanit', 'Noto Sans JP', 'Noto Sans KR', 'Noto Sans Devanagari', 'Arial Narrow', sans-serif;
|
||||
}
|
||||
|
||||
.neon-header {
|
||||
animation: neon-glow 2s ease-in-out infinite;
|
||||
font-family: 'Bebas Neue', 'Kanit', 'Noto Sans JP', 'Noto Sans KR', 'Impact', 'Arial Black', sans-serif;
|
||||
font-family: 'Bebas Neue', 'Kanit', 'Noto Sans JP', 'Noto Sans KR', 'Noto Sans Devanagari', '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', 'Noto Sans KR', system-ui, sans-serif;
|
||||
font-family: 'Inter Variable', 'Inter', 'Kanit', 'Noto Sans JP', 'Noto Sans KR', 'Noto Sans Devanagari', system-ui, sans-serif;
|
||||
box-shadow: 0 8px 40px rgba(0,0,0,0.6), 0 2px 8px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
ca as caLocale,
|
||||
eu as euLocale,
|
||||
gl as glLocale,
|
||||
hi as hiLocale,
|
||||
th as thLocale,
|
||||
ja as jaLocale,
|
||||
ptBR as ptLocale,
|
||||
|
|
@ -67,6 +68,9 @@ export function setDateLocale(language: Language): void {
|
|||
} else if (language === 'gl') {
|
||||
activeLocale = glLocale;
|
||||
activeCode = 'gl-ES';
|
||||
} else if (language === 'hi') {
|
||||
activeLocale = hiLocale;
|
||||
activeCode = 'hi-IN';
|
||||
} else if (language === 'th') {
|
||||
activeLocale = thLocale;
|
||||
activeCode = 'th-TH';
|
||||
|
|
|
|||
|
|
@ -3,15 +3,17 @@ import type { Language } from '@/contexts/language';
|
|||
const loaded = new Set<Language>();
|
||||
|
||||
/**
|
||||
* Lazy-load the CJK display font for the active locale.
|
||||
* Lazy-load the script-specific display font for the active locale (CJK +
|
||||
* Devanagari).
|
||||
*
|
||||
* The Thai (Kanit), Japanese (Noto Sans JP) and Korean (Noto Sans KR) font
|
||||
* families are large and only needed for users on those locales — but they were
|
||||
* imported statically in main.tsx, so every visitor downloaded them. They are
|
||||
* now loaded on demand, once per locale, and non-CJK users never fetch them.
|
||||
* Text briefly falls back to a system font until the stylesheet resolves.
|
||||
* The Thai (Kanit), Japanese (Noto Sans JP), Korean (Noto Sans KR) and Hindi
|
||||
* (Noto Sans Devanagari) font families are large and only needed for users on
|
||||
* those locales — but they were imported statically in main.tsx, so every
|
||||
* visitor downloaded them. They are now loaded on demand, once per locale, and
|
||||
* other users never fetch them. Text briefly falls back to a system font until
|
||||
* the stylesheet resolves.
|
||||
*/
|
||||
export function loadCjkFont(language: Language): void {
|
||||
export function loadLocaleFont(language: Language): void {
|
||||
if (loaded.has(language)) return;
|
||||
loaded.add(language);
|
||||
|
||||
|
|
@ -34,5 +36,11 @@ export function loadCjkFont(language: Language): void {
|
|||
import('@fontsource/kanit/600.css'),
|
||||
import('@fontsource/kanit/700.css'),
|
||||
]).catch(() => {});
|
||||
} else if (language === 'hi') {
|
||||
void Promise.all([
|
||||
import('@fontsource/noto-sans-devanagari/400.css'),
|
||||
import('@fontsource/noto-sans-devanagari/500.css'),
|
||||
import('@fontsource/noto-sans-devanagari/700.css'),
|
||||
]).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
|
@ -34,9 +34,10 @@ import '@fontsource/bebas-neue';
|
|||
import '@fontsource/barlow-condensed/400.css';
|
||||
import '@fontsource/barlow-condensed/600.css';
|
||||
import '@fontsource/barlow-condensed/700.css';
|
||||
// CJK fonts (Thai Kanit, Noto Sans JP, Noto Sans KR) are NOT imported here —
|
||||
// they're large and only needed for ja/ko/th users, so they're lazy-loaded per
|
||||
// locale (lib/cjkFonts.ts, wired in contexts/LanguageContext.tsx).
|
||||
// Script fonts (Thai Kanit, Noto Sans JP/KR, Noto Sans Devanagari) are NOT
|
||||
// imported here — they're large and only needed for ja/ko/th/hi users, so
|
||||
// they're lazy-loaded per locale (lib/localeFonts.ts, wired in
|
||||
// contexts/LanguageContext.tsx).
|
||||
// Cyrillic (Russian) — Inter has Cyrillic; Outfit does not
|
||||
import '@fontsource-variable/inter';
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue