i18n(ja): add Japanese as fourth locale (plumbing + font + batch 1 shell)
This commit is contained in:
parent
ae02f51d20
commit
ab05973ff0
14 changed files with 224 additions and 12 deletions
22
AGENTS.md
22
AGENTS.md
|
|
@ -2179,8 +2179,9 @@ similar are internal endpoints/hook names, not credits — leave them.)
|
|||
pages (`common`, `nav`, `chromeUi`, `desktopNav`, `navSheet`, `pwa`, `home`,
|
||||
`about`, `faq`, `onboarding`); more namespaces land in later batches.
|
||||
- **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
|
||||
partial and is reported as **coverage %**; a `th` key absent from `en` fails.
|
||||
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) imported in `main.tsx` 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.
|
||||
|
|
@ -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
|
||||
stays English (spot, park, bowl, DIY, vert, crew, line, Stash, The Lab, Nostr,
|
||||
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
|
||||
exists: `src/contexts/language.ts` (context/types/hooks) + `LanguageContext.tsx`
|
||||
(provider), `src/i18n/en.ts` + `src/i18n/es.ts` (one default-export object
|
||||
|
|
|
|||
10
package-lock.json
generated
10
package-lock.json
generated
|
|
@ -21,6 +21,7 @@
|
|||
"@fontsource/barlow-condensed": "^5.2.8",
|
||||
"@fontsource/bebas-neue": "^5.2.7",
|
||||
"@fontsource/kanit": "^5.3.0",
|
||||
"@fontsource/noto-sans-jp": "^5.3.0",
|
||||
"@getalby/sdk": "^5.1.1",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
|
|
@ -1511,6 +1512,15 @@
|
|||
"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": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@getalby/lightning-tools/-/lightning-tools-5.2.0.tgz",
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@
|
|||
"@fontsource/barlow-condensed": "^5.2.8",
|
||||
"@fontsource/bebas-neue": "^5.2.7",
|
||||
"@fontsource/kanit": "^5.3.0",
|
||||
"@fontsource/noto-sans-jp": "^5.3.0",
|
||||
"@getalby/sdk": "^5.1.1",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ async function main() {
|
|||
const en = flatten(await loadTranslations(join(SRC, 'i18n/en.ts')));
|
||||
const es = flatten(await loadTranslations(join(SRC, 'i18n/es.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 = [];
|
||||
|
||||
|
|
@ -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}`);
|
||||
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) {
|
||||
console.error(`\n✖ i18n check failed (${problems.length}):`);
|
||||
for (const p of problems.slice(0, 200)) console.error(` • ${p}`);
|
||||
|
|
@ -130,7 +136,7 @@ async function main() {
|
|||
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) => {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ const languages: LanguageOption[] = [
|
|||
{ code: 'en', name: 'English', nativeName: 'English', flag: '🇬🇧' },
|
||||
{ code: 'es', name: 'Spanish', nativeName: 'Español', flag: '🇪🇸' },
|
||||
{ code: 'th', name: 'Thai', nativeName: 'ไทย', flag: '🇹🇭' },
|
||||
{ code: 'ja', name: 'Japanese', nativeName: '日本語', flag: '🇯🇵' },
|
||||
];
|
||||
|
||||
interface LanguageToggleProps {
|
||||
|
|
|
|||
|
|
@ -1115,7 +1115,9 @@ 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.'
|
||||
: 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.'
|
||||
: '';
|
||||
: 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
|
||||
? `${SYSTEM_PROMPT}${PERSONAL_TOOLS_PROMPT}`
|
||||
: SYSTEM_PROMPT) + languageDirective;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ function detectLanguage(): Language {
|
|||
if (typeof navigator === 'undefined') return 'en';
|
||||
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('es'))) return 'es';
|
||||
return 'en';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ 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
|
||||
|
|
@ -9,7 +10,7 @@ import th from '@/i18n/th';
|
|||
* `react-refresh/only-export-components` rule.
|
||||
*/
|
||||
|
||||
export type Language = 'en' | 'es' | 'th';
|
||||
export type Language = 'en' | 'es' | 'th' | 'ja';
|
||||
|
||||
type TranslationKey = string;
|
||||
type TranslationValue = string | { [key: string]: TranslationValue };
|
||||
|
|
@ -31,7 +32,7 @@ export interface LanguageContextType {
|
|||
|
||||
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`). */
|
||||
export function getNestedValue(obj: Translations, path: string): string | undefined {
|
||||
|
|
|
|||
150
src/i18n/ja/batch1.ts
Normal file
150
src/i18n/ja/batch1.ts
Normal 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
15
src/i18n/ja/index.ts
Normal 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;
|
||||
|
|
@ -149,18 +149,18 @@
|
|||
/* ── Display font utilities ───────────────────────────────────────────────── */
|
||||
/* Bebas Neue — used for all major headlines */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Barlow Condensed — used for subheadings, labels, nav */
|
||||
.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 {
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -714,7 +714,7 @@ select {
|
|||
position: relative;
|
||||
overflow: hidden;
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import {
|
|||
formatDistance as _formatDistance,
|
||||
format as _format,
|
||||
} 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 { Language } from '@/contexts/language';
|
||||
|
||||
|
|
@ -34,6 +34,9 @@ export function setDateLocale(language: Language): void {
|
|||
} else if (language === 'th') {
|
||||
activeLocale = thLocale;
|
||||
activeCode = 'th-TH';
|
||||
} else if (language === 'ja') {
|
||||
activeLocale = jaLocale;
|
||||
activeCode = 'ja-JP';
|
||||
} else {
|
||||
activeLocale = enUS;
|
||||
activeCode = 'en-US';
|
||||
|
|
|
|||
|
|
@ -39,6 +39,10 @@ import '@fontsource/kanit/400.css';
|
|||
import '@fontsource/kanit/500.css';
|
||||
import '@fontsource/kanit/600.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
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export default {
|
|||
},
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Outfit Variable', 'Outfit', 'Kanit', 'system-ui', 'sans-serif'],
|
||||
sans: ['Outfit Variable', 'Outfit', 'Kanit', 'Noto Sans JP', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
|
|
|
|||
Loading…
Reference in a new issue