i18n(ja): add Japanese as fourth locale (plumbing + font + batch 1 shell)

This commit is contained in:
SkateSpots 2026-10-07 17:21:18 +07:00
parent ae02f51d20
commit ab05973ff0
14 changed files with 224 additions and 12 deletions

View file

@ -2179,8 +2179,9 @@ similar are internal endpoints/hook names, not credits — leave them.)
pages (`common`, `nav`, `chromeUi`, `desktopNav`, `navSheet`, `pwa`, `home`, pages (`common`, `nav`, `chromeUi`, `desktopNav`, `navSheet`, `pwa`, `home`,
`about`, `faq`, `onboarding`); more namespaces land in later batches. `about`, `faq`, `onboarding`); more namespaces land in later batches.
- **Partial by design:** a missing `th` key falls back to English. `check:i18n` - **Partial by design:** a missing `th` key falls back to English. `check:i18n`
is now **3-way** — en/es stay key-for-key identical (unchanged), `th` may be is now **multi-way** — en/es stay key-for-key identical (unchanged), `th` (and
partial and is reported as **coverage %**; a `th` key absent from `en` fails. `ja`) may be partial and are reported as **coverage %**; a `th`/`ja` key absent
from `en` fails.
- **Font:** `@fontsource/kanit` (Kanit) imported in `main.tsx` and added to the - **Font:** `@fontsource/kanit` (Kanit) imported in `main.tsx` and added to the
CSS + Tailwind font stacks — ⚠️ Outfit/Inter/Bebas/Barlow have **no Thai CSS + Tailwind font stacks — ⚠️ Outfit/Inter/Bebas/Barlow have **no Thai
glyphs**, so without it Thai silently falls back to a system font. glyphs**, so without it Thai silently falls back to a system font.
@ -2189,6 +2190,23 @@ similar are internal endpoints/hook names, not credits — leave them.)
- **Do-not-translate is broader than tricks for Thai:** core skate vocabulary - **Do-not-translate is broader than tricks for Thai:** core skate vocabulary
stays English (spot, park, bowl, DIY, vert, crew, line, Stash, The Lab, Nostr, stays English (spot, park, bowl, DIY, vert, crew, line, Stash, The Lab, Nostr,
Lightning, Zap, Relay, sats) because Thai skaters use the English terms. Lightning, Zap, Relay, sats) because Thai skaters use the English terms.
### Japanese (日本語) — fourth locale (2026-10-07)
- Added `ja` alongside en/es/th on the **same framework**. Translations live in
**`src/i18n/ja/`** as fragments composed in `index.ts`. Batch 1 = `common`,
`nav`, `pwa`, `home`; more namespaces land in later batches.
- **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) imported in `main.tsx` 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`).
- **Katakana/English for skate vocabulary** (spot→スポット, park→パーク, crew→クルー;
Stash/The Lab/Nostr/Lightning/Zap/sats stay English). Trick names stay English.
- **Castellano term change (2026-10-07):** crew is **"peña"** in `es.ts`, not
"cuadrilla" — a case-preserving replace of 158 occurrences (`Cuadrilla→Peña`,
`cuadrilla→peña`, `CUADRILLA→PEÑA`). ⚠️ Do **not** use `perl -CSD` on these files
(it mojibakes UTF-8); use a UTF-8-safe edit/Node.
- The app ships **English + Spanish (Castellano, Spain)**. The framework already - The app ships **English + Spanish (Castellano, Spain)**. The framework already
exists: `src/contexts/language.ts` (context/types/hooks) + `LanguageContext.tsx` exists: `src/contexts/language.ts` (context/types/hooks) + `LanguageContext.tsx`
(provider), `src/i18n/en.ts` + `src/i18n/es.ts` (one default-export object (provider), `src/i18n/en.ts` + `src/i18n/es.ts` (one default-export object

10
package-lock.json generated
View file

@ -21,6 +21,7 @@
"@fontsource/barlow-condensed": "^5.2.8", "@fontsource/barlow-condensed": "^5.2.8",
"@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",
"@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",
@ -1511,6 +1512,15 @@
"url": "https://github.com/sponsors/ayuhito" "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",
"integrity": "sha512-OZbBzZ8LrFRs2RFT2Cc0HUV2C2ZeSfyEXhDJWR0EZuZaraJfRubpvrFmUnCljuBDEkw8Vn6CE/+nf9dj9b+0lg==",
"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

@ -41,6 +41,7 @@
"@fontsource/barlow-condensed": "^5.2.8", "@fontsource/barlow-condensed": "^5.2.8",
"@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",
"@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

@ -102,6 +102,7 @@ async function main() {
const en = flatten(await loadTranslations(join(SRC, 'i18n/en.ts'))); const en = flatten(await loadTranslations(join(SRC, 'i18n/en.ts')));
const es = flatten(await loadTranslations(join(SRC, 'i18n/es.ts'))); const es = flatten(await loadTranslations(join(SRC, 'i18n/es.ts')));
const th = flatten(await loadTranslations(join(SRC, 'i18n/th/index.ts'))); const th = flatten(await loadTranslations(join(SRC, 'i18n/th/index.ts')));
const ja = flatten(await loadTranslations(join(SRC, 'i18n/ja/index.ts')));
const problems = []; const problems = [];
@ -123,6 +124,11 @@ async function main() {
for (const key of th) if (!en.has(key)) problems.push(`th has a key not in en: ${key}`); for (const key of th) if (!en.has(key)) problems.push(`th has a key not in en: ${key}`);
const thCoverage = en.size ? Math.round(((en.size - missingTh) / en.size) * 100) : 100; const thCoverage = en.size ? Math.round(((en.size - missingTh) / en.size) * 100) : 100;
// 4. Japanese may also be PARTIAL (same rules as Thai).
const missingJa = [...en].filter((k) => !ja.has(k)).length;
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;
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}`);
@ -130,7 +136,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 coverage ${thCoverage}% (${en.size - missingTh}/${en.size})`); 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})`);
} }
main().catch((err) => { main().catch((err) => {

View file

@ -20,6 +20,7 @@ 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: 'th', name: 'Thai', nativeName: 'ไทย', flag: '🇹🇭' }, { code: 'th', name: 'Thai', nativeName: 'ไทย', flag: '🇹🇭' },
{ code: 'ja', name: 'Japanese', nativeName: '日本語', flag: '🇯🇵' },
]; ];
interface LanguageToggleProps { interface LanguageToggleProps {

View file

@ -1115,6 +1115,8 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
? '\n\nAlways reply in Spanish (Castellano). Keep skateboarding trick names in English (kickflip, ollie, nollie, heelflip, etc.) and never translate them.' ? '\n\nAlways reply in Spanish (Castellano). Keep skateboarding trick names in English (kickflip, ollie, nollie, heelflip, etc.) and never translate them.'
: language === 'th' : language === 'th'
? '\n\nAlways reply in Thai (ไทย) with a friendly, casual skate tone. Keep skateboarding trick names in English (kickflip, ollie, nollie, heelflip, etc.) and core skate vocabulary (spot, park, bowl, DIY, vert, crew, line) in English — never translate those.' ? '\n\nAlways reply in Thai (ไทย) with a friendly, casual skate tone. Keep skateboarding trick names in English (kickflip, ollie, nollie, heelflip, etc.) and core skate vocabulary (spot, park, bowl, DIY, vert, crew, line) in English — never translate those.'
: language === 'ja'
? '\n\nAlways reply in Japanese (日本語) with a friendly, casual skate tone. Keep skateboarding trick names in English (kickflip, ollie, nollie, heelflip, etc.) and core skate vocabulary in English or katakana (spot, park, bowl, DIY, vert, crew, line) — never translate the trick names.'
: ''; : '';
const systemPrompt = (personal.available && isPro const systemPrompt = (personal.available && isPro
? `${SYSTEM_PROMPT}${PERSONAL_TOOLS_PROMPT}` ? `${SYSTEM_PROMPT}${PERSONAL_TOOLS_PROMPT}`

View file

@ -23,6 +23,7 @@ function detectLanguage(): Language {
if (typeof navigator === 'undefined') return 'en'; if (typeof navigator === 'undefined') return 'en';
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('es'))) return 'es'; if (tags.some((t) => t.startsWith('es'))) return 'es';
return 'en'; return 'en';
} }

View file

@ -2,6 +2,7 @@ import { createContext, useContext } from 'react';
import en from '@/i18n/en'; import en from '@/i18n/en';
import es from '@/i18n/es'; import es from '@/i18n/es';
import th from '@/i18n/th'; 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
@ -9,7 +10,7 @@ import th from '@/i18n/th';
* `react-refresh/only-export-components` rule. * `react-refresh/only-export-components` rule.
*/ */
export type Language = 'en' | 'es' | 'th'; export type Language = 'en' | 'es' | 'th' | 'ja';
type TranslationKey = string; type TranslationKey = string;
type TranslationValue = string | { [key: string]: TranslationValue }; type TranslationValue = string | { [key: string]: TranslationValue };
@ -31,7 +32,7 @@ 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 }; export const translations: Record<Language, Translations> = { en, es, th, ja };
/** 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 {

150
src/i18n/ja/batch1.ts Normal file
View file

@ -0,0 +1,150 @@
/**
* Japanese (日本語) — batch 1: app shell + static pages.
*
* Partial by design: any key not yet translated falls back to English, and
* `scripts/check-i18n.mjs` reports coverage. Skate/brand vocabulary stays
* English or katakana (Spot→スポット, Park→パーク, Crew→クルー, Stash, The Lab,
* Nostr, Lightning, Zap, sats), because Japanese skaters use those.
*/
const jaBatch1 = {
common: {
loading: '読み込み中...',
error: 'エラー',
save: '保存',
cancel: 'キャンセル',
delete: '削除',
edit: '編集',
add: '追加',
remove: '削除',
create: '作成',
submit: '送信',
close: '閉じる',
back: '戻る',
next: '次へ',
done: '完了',
search: '検索',
filter: '絞り込み',
sort: '並べ替え',
share: 'シェア',
more: 'もっと見る',
copy: 'コピー',
copied: 'コピーしました!',
viewAll: 'すべて表示',
seeMore: 'もっと見る',
showMore: 'もっと表示',
showLess: '表示を減らす',
noResults: '結果が見つかりません',
tryAgain: 'もう一度試す',
learnMore: '詳しく見る',
getStarted: 'はじめる',
yes: 'はい',
no: 'いいえ',
or: 'または',
and: 'と',
free: '無料',
new: '新着',
popular: '人気',
featured: '注目',
verified: '認証済み',
sats: 'sats',
clear: 'クリア',
clearFilters: 'フィルターをクリア',
clearAll: 'すべてクリア',
hide: '非表示',
undo: '元に戻す',
},
nav: {
home: 'ホーム',
map: 'スポット',
lab: 'The Lab',
events: 'イベント',
crews: 'クルー',
wallet: 'ウォレット',
settings: '設定',
more: 'もっと見る',
rewards: 'リワード',
stats: 'コミュニティ統計',
about: '概要',
food: 'フード',
marketplace: 'マーケットプレイス',
mints: 'ミント',
appShop: 'アプリショップ',
diy: 'DIY',
trades: 'マーケットプレイス',
stash: 'Stash',
collections: 'コレクション',
contacts: '連絡先',
messages: 'メッセージ',
skaters: 'スケーター',
glossary: '用語集',
riderCard: 'ライダーカード',
supporters: 'サポーター ⚡',
newsletter: 'ニュースレター',
viewProfile: 'プロフィールを見る',
leaderboards: 'リーダーボード',
},
pwa: {
installTitle: 'SkateSpotsをインストール',
installSubtitle: 'アプリの全機能を体験',
addToHomeScreen: 'ホーム画面に追加',
installApp: 'アプリをインストール',
maybeLater: 'あとで',
gotIt: '了解!',
benefits: {
fast: '超高速',
offline: 'オフライン対応',
fullAccess: '全機能',
},
iosStep1: '共有ボタンをタップ',
iosStep1Hint: 'Safariの下部にあります',
iosStep2: 'スクロールして「ホーム画面に追加」をタップ',
iosStep2Hint: '共有メニュー内',
iosStep3: '「追加」をタップしてインストール',
iosStep3Hint: 'ホーム画面にアプリが表示されます',
androidStep1: 'メニューボタンをタップ',
androidStep1Hint: 'ブラウザの(三点)メニュー',
androidStep2: '「ホーム画面に追加」を選択',
androidStep2Hint: 'または「アプリをインストール」',
androidStep3: '確認してインストール',
androidStep3Hint: 'ホーム画面にアプリが表示されます',
tagline: '無料 • オフライン対応 • 高速で信頼できる',
},
home: {
tagline: 'コミュニティ主導 • リレー基盤',
title: 'SKATE SPOTS',
subtitle: 'スポットを発見 • 集まる • トリックを学ぶ • 地元を応援',
description: '本当のスケートはオフラインで起きる — このツールはそれを見つける手助けをします。',
betaWarning: 'ベータ - 開発中 • 自己責任で利用してください',
pwaHint: 'プログレッシブウェブアプリ - 端末にインストール',
exploreMap: 'マップを見る',
addSpot: 'スポットを追加',
globalSpots: '世界中のスポット',
openCommunity: 'オープンなコミュニティ',
lightningZaps: 'Lightning Zaps',
featuredSpots: '注目のスポット',
featuredSpotsDesc: '世界中のコミュニティが認証したスケートスポット',
upcomingEvents: '開催予定のイベント',
upcomingEventsDesc: '近日開催のコンテスト、ミートアップ、セッション',
noSpotsYet: 'まだスポットがありません',
noSpotsHint: '最初のスケートスポットを追加しましょう!',
noEventsYet: '予定されたイベントはありません',
noEventsHint: 'また後で確認するか、作成しましょう!',
theLab: 'The Lab',
theLabDesc: 'トリックを学び、コーチを見つけて、スケートを上達させよう',
coaches: 'コーチ',
coachesDesc: '上達を手助けしてくれる経験豊富なスケーターから学ぶ',
filmers: 'フィルマー',
filmersDesc: 'フィルマーとつながって最高のクリップを撮ろう',
findCoach: 'コーチを探す',
becomeCoach: 'コーチになる',
findFilmer: 'フィルマーを探す',
becomeFilmer: 'フィルマーになる',
viewAllSpots: 'すべてのスポットを見る',
viewAllEvents: 'すべてのイベントを見る',
createEvent: 'イベントを作成',
readyToShare: 'シェアする準備はできた?',
readyToShareDesc: 'お気に入りのスポットを追加してスケートコミュニティを育てましょう。あなたがシェアする一つ一つが、仲間が新しい場所を見つける助けになります。',
},
};
export default jaBatch1;

15
src/i18n/ja/index.ts Normal file
View file

@ -0,0 +1,15 @@
import batch1 from './batch1';
/**
* Japanese (日本語) translations — composed from fragments so parallel batches
* never touch one giant file (same pattern as `i18n/th/`).
*
* Partial by design: any key not yet translated falls back to English, and
* `scripts/check-i18n.mjs` reports the coverage. Add new fragments here as the
* remaining namespaces land.
*/
const ja = {
...batch1,
};
export default ja;

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', 'Impact', 'Arial Black', sans-serif; font-family: 'Bebas Neue', 'Kanit', 'Noto Sans JP', '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', 'Arial Narrow', sans-serif; font-family: 'Barlow Condensed', 'Kanit', 'Noto Sans JP', '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', 'Impact', 'Arial Black', sans-serif; font-family: 'Bebas Neue', 'Kanit', 'Noto Sans JP', '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', system-ui, sans-serif; font-family: 'Inter Variable', 'Inter', 'Kanit', 'Noto Sans JP', 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,7 @@ 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 } from 'date-fns/locale'; import { enUS, es as esLocale, th as thLocale, ja as jaLocale } 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';
@ -34,6 +34,9 @@ export function setDateLocale(language: Language): void {
} else if (language === 'th') { } else if (language === 'th') {
activeLocale = thLocale; activeLocale = thLocale;
activeCode = 'th-TH'; activeCode = 'th-TH';
} else if (language === 'ja') {
activeLocale = jaLocale;
activeCode = 'ja-JP';
} else { } else {
activeLocale = enUS; activeLocale = enUS;
activeCode = 'en-US'; activeCode = 'en-US';

View file

@ -39,6 +39,10 @@ import '@fontsource/kanit/400.css';
import '@fontsource/kanit/500.css'; import '@fontsource/kanit/500.css';
import '@fontsource/kanit/600.css'; import '@fontsource/kanit/600.css';
import '@fontsource/kanit/700.css'; import '@fontsource/kanit/700.css';
// Japanese glyphs — Noto Sans JP (Outfit/Inter/Bebas/Barlow/Kanit have none)
import '@fontsource/noto-sans-jp/400.css';
import '@fontsource/noto-sans-jp/500.css';
import '@fontsource/noto-sans-jp/700.css';
// 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', 'system-ui', 'sans-serif'], sans: ['Outfit Variable', 'Outfit', 'Kanit', 'Noto Sans JP', 'system-ui', 'sans-serif'],
}, },
colors: { colors: {
border: 'hsl(var(--border))', border: 'hsl(var(--border))',