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:
SkateSpots 2026-10-26 16:35:53 +07:00
parent 7e48884eb9
commit 828684cf15
20 changed files with 9776 additions and 32 deletions

View file

@ -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
View file

@ -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",

View file

@ -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",

View file

@ -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`)));

View file

@ -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 {

View file

@ -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) => {

View file

@ -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

File diff suppressed because it is too large Load diff

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

File diff suppressed because it is too large Load diff

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

File diff suppressed because it is too large Load diff

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

File diff suppressed because it is too large Load diff

4
src/i18n/hi/drift.ts Normal file
View file

@ -0,0 +1,4 @@
/** Post-translation corrections for hi. */
const drift = {};
export default drift;

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

View file

@ -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);
}

View file

@ -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';

View file

@ -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(() => {});
}
}

View file

@ -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';