specweave 1.0.239 → 1.0.241
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +31 -30
- package/README.md +1 -1
- package/bin/specweave.js +16 -0
- package/dist/plugins/specweave-ado/lib/ado-permission-gate.d.ts.map +1 -1
- package/dist/plugins/specweave-ado/lib/ado-permission-gate.js +17 -2
- package/dist/plugins/specweave-ado/lib/ado-permission-gate.js.map +1 -1
- package/dist/plugins/specweave-github/lib/github-feature-sync.d.ts +7 -0
- package/dist/plugins/specweave-github/lib/github-feature-sync.d.ts.map +1 -1
- package/dist/plugins/specweave-github/lib/github-feature-sync.js +53 -0
- package/dist/plugins/specweave-github/lib/github-feature-sync.js.map +1 -1
- package/dist/plugins/specweave-jira/lib/jira-permission-gate.d.ts.map +1 -1
- package/dist/plugins/specweave-jira/lib/jira-permission-gate.js +17 -2
- package/dist/plugins/specweave-jira/lib/jira-permission-gate.js.map +1 -1
- package/dist/plugins/specweave-testing/lib/playwright-cli-detector.d.ts +1 -0
- package/dist/plugins/specweave-testing/lib/playwright-cli-detector.d.ts.map +1 -1
- package/dist/plugins/specweave-testing/lib/playwright-cli-detector.js +7 -3
- package/dist/plugins/specweave-testing/lib/playwright-cli-detector.js.map +1 -1
- package/dist/plugins/specweave-testing/lib/playwright-cli-runner.d.ts.map +1 -1
- package/dist/plugins/specweave-testing/lib/playwright-cli-runner.js +27 -19
- package/dist/plugins/specweave-testing/lib/playwright-cli-runner.js.map +1 -1
- package/dist/plugins/specweave-testing/lib/playwright-routing.d.ts +8 -0
- package/dist/plugins/specweave-testing/lib/playwright-routing.d.ts.map +1 -1
- package/dist/plugins/specweave-testing/lib/playwright-routing.js +10 -7
- package/dist/plugins/specweave-testing/lib/playwright-routing.js.map +1 -1
- package/dist/src/adapters/agents-md-generator.js +1 -1
- package/dist/src/adapters/agents-md-generator.js.map +1 -1
- package/dist/src/adapters/claude/README.md +1 -1
- package/dist/src/adapters/claude-md-generator.js +1 -1
- package/dist/src/adapters/claude-md-generator.js.map +1 -1
- package/dist/src/cli/commands/init.d.ts.map +1 -1
- package/dist/src/cli/commands/init.js +10 -1
- package/dist/src/cli/commands/init.js.map +1 -1
- package/dist/src/cli/commands/refresh-marketplace.d.ts.map +1 -1
- package/dist/src/cli/commands/refresh-marketplace.js +7 -67
- package/dist/src/cli/commands/refresh-marketplace.js.map +1 -1
- package/dist/src/cli/commands/team.d.ts +20 -0
- package/dist/src/cli/commands/team.d.ts.map +1 -0
- package/dist/src/cli/commands/team.js +101 -0
- package/dist/src/cli/commands/team.js.map +1 -0
- package/dist/src/cli/helpers/init/claude-settings-env.d.ts +16 -0
- package/dist/src/cli/helpers/init/claude-settings-env.d.ts.map +1 -0
- package/dist/src/cli/helpers/init/claude-settings-env.js +44 -0
- package/dist/src/cli/helpers/init/claude-settings-env.js.map +1 -0
- package/dist/src/cli/helpers/init/plugin-installer.d.ts.map +1 -1
- package/dist/src/cli/helpers/init/plugin-installer.js +9 -13
- package/dist/src/cli/helpers/init/plugin-installer.js.map +1 -1
- package/dist/src/cli/helpers/issue-tracker/index.d.ts.map +1 -1
- package/dist/src/cli/helpers/issue-tracker/index.js +12 -6
- package/dist/src/cli/helpers/issue-tracker/index.js.map +1 -1
- package/dist/src/cli/helpers/issue-tracker/types.d.ts +2 -0
- package/dist/src/cli/helpers/issue-tracker/types.d.ts.map +1 -1
- package/dist/src/cli/helpers/issue-tracker/types.js.map +1 -1
- package/dist/src/core/increment/discipline-checker.js +1 -1
- package/dist/src/core/increment/discipline-checker.js.map +1 -1
- package/dist/src/core/increment/status-commands.d.ts.map +1 -1
- package/dist/src/core/increment/status-commands.js +7 -0
- package/dist/src/core/increment/status-commands.js.map +1 -1
- package/dist/src/core/lazy-loading/llm-plugin-detector.d.ts +2 -2
- package/dist/src/core/lazy-loading/llm-plugin-detector.d.ts.map +1 -1
- package/dist/src/core/lazy-loading/llm-plugin-detector.js +63 -25
- package/dist/src/core/lazy-loading/llm-plugin-detector.js.map +1 -1
- package/dist/src/core/reflection/reflect-handler.js +2 -2
- package/dist/src/core/reflection/reflect-handler.js.map +1 -1
- package/dist/src/core/session/handoff-context.js +2 -2
- package/dist/src/core/session/handoff-context.js.map +1 -1
- package/dist/src/sync/ado-reconciler.d.ts.map +1 -1
- package/dist/src/sync/ado-reconciler.js +21 -2
- package/dist/src/sync/ado-reconciler.js.map +1 -1
- package/dist/src/sync/engine.d.ts.map +1 -1
- package/dist/src/sync/engine.js +2 -0
- package/dist/src/sync/engine.js.map +1 -1
- package/dist/src/sync/github-reconciler.d.ts.map +1 -1
- package/dist/src/sync/github-reconciler.js +52 -26
- package/dist/src/sync/github-reconciler.js.map +1 -1
- package/dist/src/sync/jira-reconciler.d.ts.map +1 -1
- package/dist/src/sync/jira-reconciler.js +16 -3
- package/dist/src/sync/jira-reconciler.js.map +1 -1
- package/dist/src/sync/providers/ado.d.ts.map +1 -1
- package/dist/src/sync/providers/ado.js +4 -2
- package/dist/src/sync/providers/ado.js.map +1 -1
- package/dist/src/sync/providers/github.d.ts.map +1 -1
- package/dist/src/sync/providers/github.js +11 -0
- package/dist/src/sync/providers/github.js.map +1 -1
- package/dist/src/sync/providers/jira.d.ts.map +1 -1
- package/dist/src/sync/providers/jira.js +14 -2
- package/dist/src/sync/providers/jira.js.map +1 -1
- package/dist/src/sync/sync-coordinator.d.ts.map +1 -1
- package/dist/src/sync/sync-coordinator.js +31 -6
- package/dist/src/sync/sync-coordinator.js.map +1 -1
- package/dist/src/utils/auto-install.js +4 -4
- package/dist/src/utils/auto-install.js.map +1 -1
- package/package.json +2 -2
- package/plugins/FINAL-AUDIT-RECOMMENDATIONS.md +3 -3
- package/plugins/SKILLS-VS-AGENTS.md +1 -1
- package/plugins/specweave/PLUGIN.md +0 -2
- package/plugins/specweave/commands/export-skills.md +1 -1
- package/plugins/specweave/commands/role-orchestrator.md +1 -1
- package/plugins/specweave/hooks/log-decision.sh +6 -0
- package/plugins/specweave/hooks/stop-auto-v5.sh +17 -1
- package/plugins/specweave/hooks/stop-reflect.sh +16 -2
- package/plugins/specweave/hooks/stop-sync.sh +17 -9
- package/plugins/specweave/hooks/user-prompt-submit.sh +119 -35
- package/plugins/specweave/lib/vendor/sync/github-reconciler.js +52 -26
- package/plugins/specweave/lib/vendor/sync/github-reconciler.js.map +1 -1
- package/plugins/specweave/scripts/read-grill-context.sh +149 -0
- package/plugins/specweave/skills/code-review/SKILL.md +608 -0
- package/plugins/specweave/skills/done/SKILL.md +1 -1
- package/plugins/specweave/skills/grill/SKILL.md +91 -0
- package/plugins/specweave/skills/performance/SKILL.md +6 -0
- package/plugins/specweave/skills/security/SKILL.md +7 -0
- package/plugins/specweave/skills/security-patterns/SKILL.md +6 -0
- package/plugins/specweave/skills/tdd-orchestrator/SKILL.md +1 -1
- package/plugins/specweave/skills/team-build/SKILL.md +1 -1
- package/plugins/specweave/skills/team-orchestrate/SKILL.md +1 -1
- package/plugins/specweave/skills/tech-lead/SKILL.md +7 -0
- package/plugins/specweave-ado/lib/ado-permission-gate.js +18 -2
- package/plugins/specweave-ado/lib/ado-permission-gate.ts +19 -2
- package/plugins/specweave-frontend/skills/frontend/SKILL.md +138 -2
- package/plugins/specweave-frontend/skills/i18n-expert/SKILL.md +989 -0
- package/plugins/specweave-github/hooks/github-auto-create-handler.sh +23 -1
- package/plugins/specweave-github/lib/github-feature-sync.js +41 -0
- package/plugins/specweave-github/lib/github-feature-sync.ts +62 -0
- package/plugins/specweave-infrastructure/PLUGIN.md +2 -1
- package/plugins/specweave-infrastructure/skills/gcp-deep-dive/SKILL.md +1172 -0
- package/plugins/specweave-infrastructure/skills/observability/SKILL.md +6 -0
- package/plugins/specweave-infrastructure/skills/opentelemetry/SKILL.md +6 -0
- package/plugins/specweave-jira/lib/jira-permission-gate.js +18 -2
- package/plugins/specweave-jira/lib/jira-permission-gate.ts +19 -2
- package/plugins/specweave-mobile/PLUGIN.md +1 -2
- package/plugins/specweave-mobile/README.md +13 -12
- package/plugins/specweave-mobile/skills/capacitor-ionic/SKILL.md +4 -18
- package/plugins/specweave-mobile/skills/deep-linking-push/SKILL.md +4 -22
- package/plugins/specweave-mobile/skills/expo/SKILL.md +4 -24
- package/plugins/specweave-mobile/skills/mobile-testing/SKILL.md +4 -22
- package/plugins/specweave-mobile/skills/react-native-expert/SKILL.md +404 -47
- package/plugins/specweave-testing/PLUGIN.md +3 -11
- package/plugins/specweave-testing/lib/playwright-cli-detector.js +8 -3
- package/plugins/specweave-testing/lib/playwright-cli-detector.ts +8 -3
- package/plugins/specweave-testing/lib/playwright-cli-runner.js +25 -20
- package/plugins/specweave-testing/lib/playwright-cli-runner.ts +24 -19
- package/plugins/specweave-testing/lib/playwright-routing.js +1 -6
- package/plugins/specweave-testing/lib/playwright-routing.ts +11 -8
- package/plugins/specweave-testing/skills/accessibility-testing/SKILL.md +998 -0
- package/plugins/specweave-testing/skills/e2e-testing/SKILL.md +29 -28
- package/plugins/specweave-testing/skills/mutation-testing/SKILL.md +769 -0
- package/plugins/specweave-testing/skills/performance-testing/SKILL.md +961 -0
- package/plugins/specweave-testing/skills/qa-engineer/SKILL.md +2 -0
- package/plugins/specweave/.specweave/logs/decisions.jsonl +0 -12
- package/plugins/specweave/.specweave/logs/reflect/reflect.log +0 -8
- package/plugins/specweave/.specweave/logs/stop-auto.log +0 -6
- package/plugins/specweave/.specweave/logs/stop-sync.log +0 -10
- package/plugins/specweave/.specweave/state/dashboard.json +0 -43
- package/plugins/specweave/skills/infrastructure/SKILL.md +0 -86
- package/plugins/specweave/skills/qa-lead/SKILL.md +0 -77
- package/plugins/specweave-mobile/skills/mobile-architect/SKILL.md +0 -30
- package/plugins/specweave-testing/commands/e2e-setup.md +0 -1103
- package/plugins/specweave-testing/commands/test-coverage.md +0 -983
- package/plugins/specweave-testing/commands/test-generate.md +0 -1160
- package/plugins/specweave-testing/commands/test-init.md +0 -413
- package/plugins/specweave-testing/commands/ui-automate.md +0 -182
- package/plugins/specweave-testing/commands/ui-inspect.md +0 -82
|
@@ -0,0 +1,989 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Expert in frontend internationalization (i18n) and localization (l10n) covering i18next/react-i18next setup, Next.js i18n routing, RTL support, date/number/currency formatting with Intl APIs, translation management workflows, and performance optimization. Use when implementing multilingual apps, adding locale support, handling RTL layouts, or managing translation pipelines.
|
|
3
|
+
allowed-tools: Read, Write, Edit, Bash
|
|
4
|
+
model: opus
|
|
5
|
+
context: fork
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# i18n Expert
|
|
9
|
+
|
|
10
|
+
You are an expert in frontend internationalization (i18n) and localization (l10n). You help teams build multilingual applications that handle translations, locale-aware formatting, RTL layouts, and translation management workflows.
|
|
11
|
+
|
|
12
|
+
**Triggers**: i18n, internationalization, translation, localization, l10n, RTL, multilingual, locale, react-i18next, hreflang, Intl, pluralization, Crowdin, Lokalise
|
|
13
|
+
|
|
14
|
+
## Core Expertise
|
|
15
|
+
|
|
16
|
+
### 1. i18next / react-i18next Setup
|
|
17
|
+
|
|
18
|
+
**Installation**:
|
|
19
|
+
```bash
|
|
20
|
+
npm install i18next react-i18next i18next-browser-languagedetector i18next-http-backend
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**Configuration** (`src/i18n/index.ts`):
|
|
24
|
+
```typescript
|
|
25
|
+
import i18n from 'i18next';
|
|
26
|
+
import { initReactI18next } from 'react-i18next';
|
|
27
|
+
import LanguageDetector from 'i18next-browser-languagedetector';
|
|
28
|
+
import HttpBackend from 'i18next-http-backend';
|
|
29
|
+
|
|
30
|
+
i18n
|
|
31
|
+
.use(HttpBackend)
|
|
32
|
+
.use(LanguageDetector)
|
|
33
|
+
.use(initReactI18next)
|
|
34
|
+
.init({
|
|
35
|
+
fallbackLng: 'en',
|
|
36
|
+
supportedLngs: ['en', 'de', 'fr', 'ar', 'ja', 'zh'],
|
|
37
|
+
defaultNS: 'common',
|
|
38
|
+
ns: ['common', 'auth', 'dashboard', 'errors'],
|
|
39
|
+
|
|
40
|
+
interpolation: {
|
|
41
|
+
escapeValue: false, // React already escapes
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
detection: {
|
|
45
|
+
order: ['querystring', 'cookie', 'localStorage', 'navigator', 'htmlTag'],
|
|
46
|
+
lookupQuerystring: 'lng',
|
|
47
|
+
lookupCookie: 'i18next',
|
|
48
|
+
lookupLocalStorage: 'i18nextLng',
|
|
49
|
+
caches: ['localStorage', 'cookie'],
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
backend: {
|
|
53
|
+
loadPath: '/locales/{{lng}}/{{ns}}.json',
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
react: {
|
|
57
|
+
useSuspense: true,
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
export default i18n;
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
**Namespace Organization**:
|
|
65
|
+
```
|
|
66
|
+
public/locales/
|
|
67
|
+
├── en/
|
|
68
|
+
│ ├── common.json # Shared: buttons, labels, navigation
|
|
69
|
+
│ ├── auth.json # Login, signup, password reset
|
|
70
|
+
│ ├── dashboard.json # Dashboard-specific strings
|
|
71
|
+
│ ├── errors.json # Error messages
|
|
72
|
+
│ └── validation.json # Form validation messages
|
|
73
|
+
├── de/
|
|
74
|
+
│ ├── common.json
|
|
75
|
+
│ └── ...
|
|
76
|
+
└── ar/
|
|
77
|
+
├── common.json
|
|
78
|
+
└── ...
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**Namespace JSON structure** (`en/common.json`):
|
|
82
|
+
```json
|
|
83
|
+
{
|
|
84
|
+
"nav": {
|
|
85
|
+
"home": "Home",
|
|
86
|
+
"about": "About",
|
|
87
|
+
"settings": "Settings"
|
|
88
|
+
},
|
|
89
|
+
"actions": {
|
|
90
|
+
"save": "Save",
|
|
91
|
+
"cancel": "Cancel",
|
|
92
|
+
"delete": "Delete",
|
|
93
|
+
"confirm": "Are you sure?"
|
|
94
|
+
},
|
|
95
|
+
"greeting": "Hello, {{name}}!"
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
**Component Usage**:
|
|
100
|
+
```tsx
|
|
101
|
+
import { useTranslation } from 'react-i18next';
|
|
102
|
+
|
|
103
|
+
function Dashboard() {
|
|
104
|
+
const { t } = useTranslation('dashboard');
|
|
105
|
+
const { t: tCommon } = useTranslation('common');
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<div>
|
|
109
|
+
<h1>{t('title')}</h1>
|
|
110
|
+
<p>{tCommon('greeting', { name: 'Alice' })}</p>
|
|
111
|
+
<button>{tCommon('actions.save')}</button>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**Pluralization** (`en/common.json`):
|
|
118
|
+
```json
|
|
119
|
+
{
|
|
120
|
+
"items_count": "{{count}} item",
|
|
121
|
+
"items_count_other": "{{count}} items",
|
|
122
|
+
"items_count_zero": "No items"
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
```tsx
|
|
127
|
+
// Automatically selects the correct plural form
|
|
128
|
+
t('items_count', { count: 0 }); // "No items"
|
|
129
|
+
t('items_count', { count: 1 }); // "1 item"
|
|
130
|
+
t('items_count', { count: 5 }); // "5 items"
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**Context-based translations** (e.g., gendered text):
|
|
134
|
+
```json
|
|
135
|
+
{
|
|
136
|
+
"friend": "A friend",
|
|
137
|
+
"friend_male": "A boyfriend",
|
|
138
|
+
"friend_female": "A girlfriend"
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
```tsx
|
|
142
|
+
t('friend', { context: 'male' }); // "A boyfriend"
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
**Language Switcher Component**:
|
|
146
|
+
```tsx
|
|
147
|
+
import { useTranslation } from 'react-i18next';
|
|
148
|
+
|
|
149
|
+
const languages = [
|
|
150
|
+
{ code: 'en', label: 'English', dir: 'ltr' },
|
|
151
|
+
{ code: 'de', label: 'Deutsch', dir: 'ltr' },
|
|
152
|
+
{ code: 'ar', label: 'العربية', dir: 'rtl' },
|
|
153
|
+
{ code: 'ja', label: '日本語', dir: 'ltr' },
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
function LanguageSwitcher() {
|
|
157
|
+
const { i18n } = useTranslation();
|
|
158
|
+
|
|
159
|
+
const changeLanguage = (code: string) => {
|
|
160
|
+
i18n.changeLanguage(code);
|
|
161
|
+
const lang = languages.find((l) => l.code === code);
|
|
162
|
+
document.documentElement.dir = lang?.dir ?? 'ltr';
|
|
163
|
+
document.documentElement.lang = code;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<select
|
|
168
|
+
value={i18n.language}
|
|
169
|
+
onChange={(e) => changeLanguage(e.target.value)}
|
|
170
|
+
aria-label="Select language"
|
|
171
|
+
>
|
|
172
|
+
{languages.map((lang) => (
|
|
173
|
+
<option key={lang.code} value={lang.code}>
|
|
174
|
+
{lang.label}
|
|
175
|
+
</option>
|
|
176
|
+
))}
|
|
177
|
+
</select>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### 2. Next.js i18n (App Router)
|
|
183
|
+
|
|
184
|
+
**Directory-based i18n routing**:
|
|
185
|
+
```
|
|
186
|
+
app/
|
|
187
|
+
├── [locale]/
|
|
188
|
+
│ ├── layout.tsx
|
|
189
|
+
│ ├── page.tsx
|
|
190
|
+
│ ├── about/
|
|
191
|
+
│ │ └── page.tsx
|
|
192
|
+
│ └── dashboard/
|
|
193
|
+
│ └── page.tsx
|
|
194
|
+
├── middleware.ts
|
|
195
|
+
└── i18n/
|
|
196
|
+
├── config.ts
|
|
197
|
+
├── request.ts
|
|
198
|
+
└── dictionaries.ts
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
**i18n Config** (`i18n/config.ts`):
|
|
202
|
+
```typescript
|
|
203
|
+
export const i18nConfig = {
|
|
204
|
+
defaultLocale: 'en',
|
|
205
|
+
locales: ['en', 'de', 'fr', 'ar', 'ja'],
|
|
206
|
+
} as const;
|
|
207
|
+
|
|
208
|
+
export type Locale = (typeof i18nConfig.locales)[number];
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
**Middleware for Language Detection** (`middleware.ts`):
|
|
212
|
+
```typescript
|
|
213
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
214
|
+
import { i18nConfig } from './i18n/config';
|
|
215
|
+
|
|
216
|
+
function getLocale(request: NextRequest): string {
|
|
217
|
+
// 1. Check cookie
|
|
218
|
+
const cookieLocale = request.cookies.get('NEXT_LOCALE')?.value;
|
|
219
|
+
if (cookieLocale && i18nConfig.locales.includes(cookieLocale as any)) {
|
|
220
|
+
return cookieLocale;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// 2. Check Accept-Language header
|
|
224
|
+
const acceptLanguage = request.headers.get('accept-language');
|
|
225
|
+
if (acceptLanguage) {
|
|
226
|
+
const preferred = acceptLanguage
|
|
227
|
+
.split(',')
|
|
228
|
+
.map((lang) => lang.split(';')[0].trim().substring(0, 2))
|
|
229
|
+
.find((lang) => i18nConfig.locales.includes(lang as any));
|
|
230
|
+
if (preferred) return preferred;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return i18nConfig.defaultLocale;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function middleware(request: NextRequest) {
|
|
237
|
+
const { pathname } = request.nextUrl;
|
|
238
|
+
|
|
239
|
+
// Skip static assets, API routes, and _next
|
|
240
|
+
if (
|
|
241
|
+
pathname.startsWith('/_next') ||
|
|
242
|
+
pathname.startsWith('/api') ||
|
|
243
|
+
pathname.includes('.')
|
|
244
|
+
) {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Check if pathname has a locale prefix
|
|
249
|
+
const pathnameHasLocale = i18nConfig.locales.some(
|
|
250
|
+
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
if (pathnameHasLocale) return;
|
|
254
|
+
|
|
255
|
+
// Redirect to locale-prefixed URL
|
|
256
|
+
const locale = getLocale(request);
|
|
257
|
+
const newUrl = new URL(`/${locale}${pathname}`, request.url);
|
|
258
|
+
return NextResponse.redirect(newUrl);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export const config = {
|
|
262
|
+
matcher: ['/((?!_next|api|favicon.ico|robots.txt|sitemap.xml).*)'],
|
|
263
|
+
};
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
**Dictionary Loading** (`i18n/dictionaries.ts`):
|
|
267
|
+
```typescript
|
|
268
|
+
import type { Locale } from './config';
|
|
269
|
+
|
|
270
|
+
const dictionaries = {
|
|
271
|
+
en: () => import('../dictionaries/en.json').then((m) => m.default),
|
|
272
|
+
de: () => import('../dictionaries/de.json').then((m) => m.default),
|
|
273
|
+
fr: () => import('../dictionaries/fr.json').then((m) => m.default),
|
|
274
|
+
ar: () => import('../dictionaries/ar.json').then((m) => m.default),
|
|
275
|
+
ja: () => import('../dictionaries/ja.json').then((m) => m.default),
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
export function getDictionary(locale: Locale) {
|
|
279
|
+
return dictionaries[locale]();
|
|
280
|
+
}
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
**Locale Layout** (`app/[locale]/layout.tsx`):
|
|
284
|
+
```tsx
|
|
285
|
+
import { i18nConfig, type Locale } from '@/i18n/config';
|
|
286
|
+
import { notFound } from 'next/navigation';
|
|
287
|
+
|
|
288
|
+
export function generateStaticParams() {
|
|
289
|
+
return i18nConfig.locales.map((locale) => ({ locale }));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export async function generateMetadata({
|
|
293
|
+
params,
|
|
294
|
+
}: {
|
|
295
|
+
params: Promise<{ locale: Locale }>;
|
|
296
|
+
}) {
|
|
297
|
+
const { locale } = await params;
|
|
298
|
+
return {
|
|
299
|
+
alternates: {
|
|
300
|
+
canonical: `https://example.com/${locale}`,
|
|
301
|
+
languages: Object.fromEntries(
|
|
302
|
+
i18nConfig.locales.map((l) => [l, `https://example.com/${l}`])
|
|
303
|
+
),
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export default async function LocaleLayout({
|
|
309
|
+
children,
|
|
310
|
+
params,
|
|
311
|
+
}: {
|
|
312
|
+
children: React.ReactNode;
|
|
313
|
+
params: Promise<{ locale: Locale }>;
|
|
314
|
+
}) {
|
|
315
|
+
const { locale } = await params;
|
|
316
|
+
|
|
317
|
+
if (!i18nConfig.locales.includes(locale)) {
|
|
318
|
+
notFound();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const dir = locale === 'ar' ? 'rtl' : 'ltr';
|
|
322
|
+
|
|
323
|
+
return (
|
|
324
|
+
<html lang={locale} dir={dir}>
|
|
325
|
+
<body>{children}</body>
|
|
326
|
+
</html>
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
**SEO: hreflang Tags** (in root layout `<head>`):
|
|
332
|
+
```tsx
|
|
333
|
+
import { i18nConfig } from '@/i18n/config';
|
|
334
|
+
|
|
335
|
+
function HreflangTags({ currentPath }: { currentPath: string }) {
|
|
336
|
+
const baseUrl = 'https://example.com';
|
|
337
|
+
|
|
338
|
+
return (
|
|
339
|
+
<>
|
|
340
|
+
{i18nConfig.locales.map((locale) => (
|
|
341
|
+
<link
|
|
342
|
+
key={locale}
|
|
343
|
+
rel="alternate"
|
|
344
|
+
hrefLang={locale}
|
|
345
|
+
href={`${baseUrl}/${locale}${currentPath}`}
|
|
346
|
+
/>
|
|
347
|
+
))}
|
|
348
|
+
<link
|
|
349
|
+
rel="alternate"
|
|
350
|
+
hrefLang="x-default"
|
|
351
|
+
href={`${baseUrl}/${i18nConfig.defaultLocale}${currentPath}`}
|
|
352
|
+
/>
|
|
353
|
+
</>
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
**Locale Page with Dictionary** (`app/[locale]/page.tsx`):
|
|
359
|
+
```tsx
|
|
360
|
+
import { getDictionary } from '@/i18n/dictionaries';
|
|
361
|
+
import type { Locale } from '@/i18n/config';
|
|
362
|
+
|
|
363
|
+
export default async function HomePage({
|
|
364
|
+
params,
|
|
365
|
+
}: {
|
|
366
|
+
params: Promise<{ locale: Locale }>;
|
|
367
|
+
}) {
|
|
368
|
+
const { locale } = await params;
|
|
369
|
+
const dict = await getDictionary(locale);
|
|
370
|
+
|
|
371
|
+
return (
|
|
372
|
+
<main>
|
|
373
|
+
<h1>{dict.home.title}</h1>
|
|
374
|
+
<p>{dict.home.description}</p>
|
|
375
|
+
</main>
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
### 3. RTL Support
|
|
381
|
+
|
|
382
|
+
**CSS Logical Properties** (replace physical properties with logical equivalents):
|
|
383
|
+
|
|
384
|
+
| Physical (avoid) | Logical (prefer) |
|
|
385
|
+
|---|---|
|
|
386
|
+
| `margin-left` | `margin-inline-start` |
|
|
387
|
+
| `margin-right` | `margin-inline-end` |
|
|
388
|
+
| `padding-left` | `padding-inline-start` |
|
|
389
|
+
| `padding-right` | `padding-inline-end` |
|
|
390
|
+
| `text-align: left` | `text-align: start` |
|
|
391
|
+
| `text-align: right` | `text-align: end` |
|
|
392
|
+
| `float: left` | `float: inline-start` |
|
|
393
|
+
| `border-left` | `border-inline-start` |
|
|
394
|
+
| `left: 0` | `inset-inline-start: 0` |
|
|
395
|
+
| `right: 0` | `inset-inline-end: 0` |
|
|
396
|
+
| `width` | `inline-size` |
|
|
397
|
+
| `height` | `block-size` |
|
|
398
|
+
|
|
399
|
+
**RTL-safe CSS example**:
|
|
400
|
+
```css
|
|
401
|
+
.sidebar {
|
|
402
|
+
/* Physical (breaks RTL): */
|
|
403
|
+
/* padding-left: 1rem; margin-right: 2rem; */
|
|
404
|
+
|
|
405
|
+
/* Logical (works in LTR and RTL): */
|
|
406
|
+
padding-inline-start: 1rem;
|
|
407
|
+
margin-inline-end: 2rem;
|
|
408
|
+
border-inline-start: 3px solid var(--accent);
|
|
409
|
+
inset-inline-start: 0;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
.card {
|
|
413
|
+
text-align: start;
|
|
414
|
+
display: flex;
|
|
415
|
+
flex-direction: row; /* Flex auto-reverses in RTL */
|
|
416
|
+
gap: 1rem;
|
|
417
|
+
}
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
**Tailwind CSS RTL Plugin**:
|
|
421
|
+
```bash
|
|
422
|
+
npm install tailwindcss-rtl
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
```typescript
|
|
426
|
+
// tailwind.config.ts
|
|
427
|
+
import type { Config } from 'tailwindcss';
|
|
428
|
+
import rtlPlugin from 'tailwindcss-rtl';
|
|
429
|
+
|
|
430
|
+
export default {
|
|
431
|
+
plugins: [rtlPlugin],
|
|
432
|
+
} satisfies Config;
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
Usage with `rtl:` and `ltr:` variants:
|
|
436
|
+
```tsx
|
|
437
|
+
<div className="ps-4 pe-2 text-start">
|
|
438
|
+
{/* ps = padding-inline-start, pe = padding-inline-end */}
|
|
439
|
+
<span className="ms-2 me-4">
|
|
440
|
+
{/* ms = margin-inline-start, me = margin-inline-end */}
|
|
441
|
+
Bidirectional text
|
|
442
|
+
</span>
|
|
443
|
+
</div>
|
|
444
|
+
|
|
445
|
+
{/* Conditional styles for specific directions */}
|
|
446
|
+
<div className="ltr:pl-4 rtl:pr-4 ltr:text-left rtl:text-right">
|
|
447
|
+
Direction-specific override
|
|
448
|
+
</div>
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
**Tailwind v3.3+ built-in logical properties** (no plugin needed):
|
|
452
|
+
```tsx
|
|
453
|
+
<div className="ps-4 pe-2 ms-2 me-4 text-start">
|
|
454
|
+
{/* These use CSS logical properties natively */}
|
|
455
|
+
</div>
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
**RTL Context Provider**:
|
|
459
|
+
```tsx
|
|
460
|
+
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
|
|
461
|
+
|
|
462
|
+
type Direction = 'ltr' | 'rtl';
|
|
463
|
+
|
|
464
|
+
const RTL_LOCALES = new Set(['ar', 'he', 'fa', 'ur']);
|
|
465
|
+
|
|
466
|
+
const DirectionContext = createContext<Direction>('ltr');
|
|
467
|
+
|
|
468
|
+
export function DirectionProvider({
|
|
469
|
+
locale,
|
|
470
|
+
children,
|
|
471
|
+
}: {
|
|
472
|
+
locale: string;
|
|
473
|
+
children: ReactNode;
|
|
474
|
+
}) {
|
|
475
|
+
const dir: Direction = RTL_LOCALES.has(locale) ? 'rtl' : 'ltr';
|
|
476
|
+
|
|
477
|
+
useEffect(() => {
|
|
478
|
+
document.documentElement.dir = dir;
|
|
479
|
+
document.documentElement.lang = locale;
|
|
480
|
+
}, [dir, locale]);
|
|
481
|
+
|
|
482
|
+
return (
|
|
483
|
+
<DirectionContext.Provider value={dir}>
|
|
484
|
+
{children}
|
|
485
|
+
</DirectionContext.Provider>
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export function useDirection() {
|
|
490
|
+
return useContext(DirectionContext);
|
|
491
|
+
}
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
**Bidirectional text handling**:
|
|
495
|
+
```tsx
|
|
496
|
+
{/* Isolate embedded text that may have different directionality */}
|
|
497
|
+
<p>
|
|
498
|
+
User <bdi>{userName}</bdi> posted a comment.
|
|
499
|
+
</p>
|
|
500
|
+
|
|
501
|
+
{/* Force direction for specific content */}
|
|
502
|
+
<span dir="ltr">+1 (555) 123-4567</span>
|
|
503
|
+
|
|
504
|
+
{/* Unicode control characters for mixed content */}
|
|
505
|
+
<span>{'\u200F'}{arabicText}{'\u200F'}</span>
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
**Icon mirroring for RTL**:
|
|
509
|
+
```css
|
|
510
|
+
/* Mirror directional icons (arrows, chevrons) in RTL */
|
|
511
|
+
[dir='rtl'] .icon-directional {
|
|
512
|
+
transform: scaleX(-1);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/* Do NOT mirror non-directional icons (checkmarks, close, etc.) */
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
### 4. Date / Number / Currency Formatting
|
|
519
|
+
|
|
520
|
+
**Intl.DateTimeFormat**:
|
|
521
|
+
```typescript
|
|
522
|
+
function formatDate(
|
|
523
|
+
date: Date,
|
|
524
|
+
locale: string,
|
|
525
|
+
options?: Intl.DateTimeFormatOptions
|
|
526
|
+
): string {
|
|
527
|
+
const defaults: Intl.DateTimeFormatOptions = {
|
|
528
|
+
year: 'numeric',
|
|
529
|
+
month: 'long',
|
|
530
|
+
day: 'numeric',
|
|
531
|
+
};
|
|
532
|
+
return new Intl.DateTimeFormat(locale, { ...defaults, ...options }).format(date);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Examples:
|
|
536
|
+
formatDate(new Date(), 'en-US'); // "February 11, 2026"
|
|
537
|
+
formatDate(new Date(), 'de-DE'); // "11. Februar 2026"
|
|
538
|
+
formatDate(new Date(), 'ja-JP'); // "2026年2月11日"
|
|
539
|
+
formatDate(new Date(), 'ar-SA'); // "١١ فبراير ٢٠٢٦"
|
|
540
|
+
|
|
541
|
+
// Short format
|
|
542
|
+
formatDate(new Date(), 'en-US', {
|
|
543
|
+
year: 'numeric', month: 'short', day: 'numeric',
|
|
544
|
+
}); // "Feb 11, 2026"
|
|
545
|
+
|
|
546
|
+
// Date and time
|
|
547
|
+
formatDate(new Date(), 'en-US', {
|
|
548
|
+
dateStyle: 'full',
|
|
549
|
+
timeStyle: 'short',
|
|
550
|
+
}); // "Wednesday, February 11, 2026 at 3:45 PM"
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
**Intl.NumberFormat for Currencies**:
|
|
554
|
+
```typescript
|
|
555
|
+
function formatCurrency(
|
|
556
|
+
amount: number,
|
|
557
|
+
currency: string,
|
|
558
|
+
locale: string
|
|
559
|
+
): string {
|
|
560
|
+
return new Intl.NumberFormat(locale, {
|
|
561
|
+
style: 'currency',
|
|
562
|
+
currency,
|
|
563
|
+
minimumFractionDigits: 2,
|
|
564
|
+
}).format(amount);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Examples:
|
|
568
|
+
formatCurrency(1234.5, 'USD', 'en-US'); // "$1,234.50"
|
|
569
|
+
formatCurrency(1234.5, 'EUR', 'de-DE'); // "1.234,50 €"
|
|
570
|
+
formatCurrency(1234.5, 'JPY', 'ja-JP'); // "¥1,235"
|
|
571
|
+
formatCurrency(1234.5, 'SAR', 'ar-SA'); // "١٬٢٣٤٫٥٠ ر.س."
|
|
572
|
+
|
|
573
|
+
// Compact notation
|
|
574
|
+
new Intl.NumberFormat('en', {
|
|
575
|
+
notation: 'compact',
|
|
576
|
+
compactDisplay: 'short',
|
|
577
|
+
}).format(1500000); // "1.5M"
|
|
578
|
+
|
|
579
|
+
// Percentage
|
|
580
|
+
new Intl.NumberFormat('en', {
|
|
581
|
+
style: 'percent',
|
|
582
|
+
minimumFractionDigits: 1,
|
|
583
|
+
}).format(0.856); // "85.6%"
|
|
584
|
+
|
|
585
|
+
// Unit formatting
|
|
586
|
+
new Intl.NumberFormat('en', {
|
|
587
|
+
style: 'unit',
|
|
588
|
+
unit: 'kilometer-per-hour',
|
|
589
|
+
}).format(120); // "120 km/h"
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
**Intl.RelativeTimeFormat**:
|
|
593
|
+
```typescript
|
|
594
|
+
function formatRelativeTime(date: Date, locale: string): string {
|
|
595
|
+
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
|
|
596
|
+
const now = Date.now();
|
|
597
|
+
const diffMs = date.getTime() - now;
|
|
598
|
+
const diffSec = Math.round(diffMs / 1000);
|
|
599
|
+
const diffMin = Math.round(diffSec / 60);
|
|
600
|
+
const diffHr = Math.round(diffMin / 60);
|
|
601
|
+
const diffDay = Math.round(diffHr / 24);
|
|
602
|
+
|
|
603
|
+
if (Math.abs(diffSec) < 60) return rtf.format(diffSec, 'second');
|
|
604
|
+
if (Math.abs(diffMin) < 60) return rtf.format(diffMin, 'minute');
|
|
605
|
+
if (Math.abs(diffHr) < 24) return rtf.format(diffHr, 'hour');
|
|
606
|
+
if (Math.abs(diffDay) < 30) return rtf.format(diffDay, 'day');
|
|
607
|
+
|
|
608
|
+
const diffMonth = Math.round(diffDay / 30);
|
|
609
|
+
if (Math.abs(diffMonth) < 12) return rtf.format(diffMonth, 'month');
|
|
610
|
+
|
|
611
|
+
return rtf.format(Math.round(diffDay / 365), 'year');
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Examples:
|
|
615
|
+
// formatRelativeTime(yesterday, 'en') -> "yesterday"
|
|
616
|
+
// formatRelativeTime(twoHoursAgo, 'de') -> "vor 2 Stunden"
|
|
617
|
+
// formatRelativeTime(nextWeek, 'ja') -> "7日後"
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
**Timezone Handling**:
|
|
621
|
+
```typescript
|
|
622
|
+
// Display in user's timezone
|
|
623
|
+
function formatWithTimezone(
|
|
624
|
+
date: Date,
|
|
625
|
+
locale: string,
|
|
626
|
+
timeZone: string
|
|
627
|
+
): string {
|
|
628
|
+
return new Intl.DateTimeFormat(locale, {
|
|
629
|
+
dateStyle: 'medium',
|
|
630
|
+
timeStyle: 'long',
|
|
631
|
+
timeZone,
|
|
632
|
+
}).format(date);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
formatWithTimezone(new Date(), 'en-US', 'America/New_York');
|
|
636
|
+
// "Feb 11, 2026, 3:45:00 PM EST"
|
|
637
|
+
|
|
638
|
+
formatWithTimezone(new Date(), 'en-US', 'Asia/Tokyo');
|
|
639
|
+
// "Feb 12, 2026, 5:45:00 AM JST"
|
|
640
|
+
|
|
641
|
+
// Get user's timezone
|
|
642
|
+
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
**Reusable Formatting Hook**:
|
|
646
|
+
```tsx
|
|
647
|
+
import { useMemo } from 'react';
|
|
648
|
+
import { useTranslation } from 'react-i18next';
|
|
649
|
+
|
|
650
|
+
export function useFormatters() {
|
|
651
|
+
const { i18n } = useTranslation();
|
|
652
|
+
const locale = i18n.language;
|
|
653
|
+
|
|
654
|
+
return useMemo(() => ({
|
|
655
|
+
date: (date: Date, options?: Intl.DateTimeFormatOptions) =>
|
|
656
|
+
new Intl.DateTimeFormat(locale, options).format(date),
|
|
657
|
+
|
|
658
|
+
number: (value: number, options?: Intl.NumberFormatOptions) =>
|
|
659
|
+
new Intl.NumberFormat(locale, options).format(value),
|
|
660
|
+
|
|
661
|
+
currency: (amount: number, currency: string) =>
|
|
662
|
+
new Intl.NumberFormat(locale, {
|
|
663
|
+
style: 'currency',
|
|
664
|
+
currency,
|
|
665
|
+
}).format(amount),
|
|
666
|
+
|
|
667
|
+
relativeTime: (date: Date) =>
|
|
668
|
+
formatRelativeTime(date, locale),
|
|
669
|
+
|
|
670
|
+
list: (items: string[], type: Intl.ListFormatType = 'conjunction') =>
|
|
671
|
+
new Intl.ListFormat(locale, { type }).format(items),
|
|
672
|
+
}), [locale]);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Usage:
|
|
676
|
+
function PriceDisplay({ amount, currency }: { amount: number; currency: string }) {
|
|
677
|
+
const fmt = useFormatters();
|
|
678
|
+
return <span>{fmt.currency(amount, currency)}</span>;
|
|
679
|
+
}
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
### 5. Translation Management Workflow
|
|
683
|
+
|
|
684
|
+
**Crowdin Integration**:
|
|
685
|
+
|
|
686
|
+
`crowdin.yml`:
|
|
687
|
+
```yaml
|
|
688
|
+
project_id_env: CROWDIN_PROJECT_ID
|
|
689
|
+
api_token_env: CROWDIN_API_TOKEN
|
|
690
|
+
|
|
691
|
+
files:
|
|
692
|
+
- source: /public/locales/en/**/*.json
|
|
693
|
+
translation: /public/locales/%two_letters_code%/**/%original_file_name%
|
|
694
|
+
type: json
|
|
695
|
+
|
|
696
|
+
preserve_hierarchy: true
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
CI/CD pipeline (GitHub Actions):
|
|
700
|
+
```yaml
|
|
701
|
+
name: Translation Sync
|
|
702
|
+
|
|
703
|
+
on:
|
|
704
|
+
push:
|
|
705
|
+
branches: [main]
|
|
706
|
+
paths:
|
|
707
|
+
- 'public/locales/en/**'
|
|
708
|
+
schedule:
|
|
709
|
+
- cron: '0 6 * * 1' # Weekly Monday 6 AM
|
|
710
|
+
|
|
711
|
+
jobs:
|
|
712
|
+
upload-sources:
|
|
713
|
+
runs-on: ubuntu-latest
|
|
714
|
+
if: github.event_name == 'push'
|
|
715
|
+
steps:
|
|
716
|
+
- uses: actions/checkout@v4
|
|
717
|
+
- uses: crowdin/github-action@v2
|
|
718
|
+
with:
|
|
719
|
+
upload_sources: true
|
|
720
|
+
upload_translations: false
|
|
721
|
+
env:
|
|
722
|
+
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
|
723
|
+
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_API_TOKEN }}
|
|
724
|
+
|
|
725
|
+
download-translations:
|
|
726
|
+
runs-on: ubuntu-latest
|
|
727
|
+
if: github.event_name == 'schedule'
|
|
728
|
+
steps:
|
|
729
|
+
- uses: actions/checkout@v4
|
|
730
|
+
- uses: crowdin/github-action@v2
|
|
731
|
+
with:
|
|
732
|
+
upload_sources: false
|
|
733
|
+
download_translations: true
|
|
734
|
+
create_pull_request: true
|
|
735
|
+
pull_request_title: 'chore: update translations from Crowdin'
|
|
736
|
+
pull_request_base_branch_name: main
|
|
737
|
+
env:
|
|
738
|
+
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
|
739
|
+
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_API_TOKEN }}
|
|
740
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
**Lokalise Integration** (alternative):
|
|
744
|
+
```yaml
|
|
745
|
+
# .github/workflows/lokalise-sync.yml
|
|
746
|
+
name: Lokalise Sync
|
|
747
|
+
|
|
748
|
+
on:
|
|
749
|
+
push:
|
|
750
|
+
branches: [main]
|
|
751
|
+
paths: ['public/locales/en/**']
|
|
752
|
+
|
|
753
|
+
jobs:
|
|
754
|
+
sync:
|
|
755
|
+
runs-on: ubuntu-latest
|
|
756
|
+
steps:
|
|
757
|
+
- uses: actions/checkout@v4
|
|
758
|
+
- name: Install Lokalise CLI
|
|
759
|
+
run: |
|
|
760
|
+
curl -sfL https://raw.githubusercontent.com/lokalise/lokalise-cli-2-go/master/install.sh | sh
|
|
761
|
+
- name: Upload source strings
|
|
762
|
+
run: |
|
|
763
|
+
./bin/lokalise2 file upload \
|
|
764
|
+
--token ${{ secrets.LOKALISE_API_TOKEN }} \
|
|
765
|
+
--project-id ${{ secrets.LOKALISE_PROJECT_ID }} \
|
|
766
|
+
--file "public/locales/en/common.json" \
|
|
767
|
+
--lang-iso en
|
|
768
|
+
- name: Download translations
|
|
769
|
+
run: |
|
|
770
|
+
./bin/lokalise2 file download \
|
|
771
|
+
--token ${{ secrets.LOKALISE_API_TOKEN }} \
|
|
772
|
+
--project-id ${{ secrets.LOKALISE_PROJECT_ID }} \
|
|
773
|
+
--format json \
|
|
774
|
+
--original-filenames=true \
|
|
775
|
+
--directory-prefix "public/locales/%LANG_ISO%"
|
|
776
|
+
```
|
|
777
|
+
|
|
778
|
+
**Missing Translation Detection**:
|
|
779
|
+
|
|
780
|
+
```typescript
|
|
781
|
+
// scripts/check-translations.ts
|
|
782
|
+
import fs from 'node:fs';
|
|
783
|
+
import path from 'node:path';
|
|
784
|
+
|
|
785
|
+
const LOCALES_DIR = 'public/locales';
|
|
786
|
+
const SOURCE_LOCALE = 'en';
|
|
787
|
+
|
|
788
|
+
function getKeys(obj: Record<string, unknown>, prefix = ''): string[] {
|
|
789
|
+
return Object.entries(obj).flatMap(([key, value]) => {
|
|
790
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
791
|
+
if (typeof value === 'object' && value !== null) {
|
|
792
|
+
return getKeys(value as Record<string, unknown>, fullKey);
|
|
793
|
+
}
|
|
794
|
+
return [fullKey];
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function checkTranslations() {
|
|
799
|
+
const sourceDir = path.join(LOCALES_DIR, SOURCE_LOCALE);
|
|
800
|
+
const locales = fs
|
|
801
|
+
.readdirSync(LOCALES_DIR)
|
|
802
|
+
.filter((d) => d !== SOURCE_LOCALE && fs.statSync(path.join(LOCALES_DIR, d)).isDirectory());
|
|
803
|
+
|
|
804
|
+
let hasErrors = false;
|
|
805
|
+
|
|
806
|
+
for (const file of fs.readdirSync(sourceDir)) {
|
|
807
|
+
const sourceContent = JSON.parse(
|
|
808
|
+
fs.readFileSync(path.join(sourceDir, file), 'utf-8')
|
|
809
|
+
);
|
|
810
|
+
const sourceKeys = getKeys(sourceContent);
|
|
811
|
+
|
|
812
|
+
for (const locale of locales) {
|
|
813
|
+
const targetPath = path.join(LOCALES_DIR, locale, file);
|
|
814
|
+
|
|
815
|
+
if (!fs.existsSync(targetPath)) {
|
|
816
|
+
console.error(`MISSING FILE: ${locale}/${file}`);
|
|
817
|
+
hasErrors = true;
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const targetContent = JSON.parse(fs.readFileSync(targetPath, 'utf-8'));
|
|
822
|
+
const targetKeys = getKeys(targetContent);
|
|
823
|
+
|
|
824
|
+
const missing = sourceKeys.filter((k) => !targetKeys.includes(k));
|
|
825
|
+
const extra = targetKeys.filter((k) => !sourceKeys.includes(k));
|
|
826
|
+
|
|
827
|
+
if (missing.length > 0) {
|
|
828
|
+
console.error(`${locale}/${file} - MISSING ${missing.length} keys:`);
|
|
829
|
+
missing.forEach((k) => console.error(` - ${k}`));
|
|
830
|
+
hasErrors = true;
|
|
831
|
+
}
|
|
832
|
+
if (extra.length > 0) {
|
|
833
|
+
console.warn(`${locale}/${file} - EXTRA ${extra.length} keys:`);
|
|
834
|
+
extra.forEach((k) => console.warn(` + ${k}`));
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
process.exit(hasErrors ? 1 : 0);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
checkTranslations();
|
|
843
|
+
```
|
|
844
|
+
|
|
845
|
+
Add to CI:
|
|
846
|
+
```json
|
|
847
|
+
{
|
|
848
|
+
"scripts": {
|
|
849
|
+
"i18n:check": "tsx scripts/check-translations.ts"
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
```
|
|
853
|
+
|
|
854
|
+
**Fallback Chain Strategy** (i18next):
|
|
855
|
+
```typescript
|
|
856
|
+
i18n.init({
|
|
857
|
+
fallbackLng: {
|
|
858
|
+
'de-AT': ['de', 'en'], // Austrian German -> German -> English
|
|
859
|
+
'pt-BR': ['pt', 'en'], // Brazilian Portuguese -> Portuguese -> English
|
|
860
|
+
'zh-TW': ['zh-Hant', 'en'], // Traditional Chinese -> English
|
|
861
|
+
default: ['en'],
|
|
862
|
+
},
|
|
863
|
+
|
|
864
|
+
// Show key name for missing translations in dev
|
|
865
|
+
saveMissing: process.env.NODE_ENV === 'development',
|
|
866
|
+
missingKeyHandler: (lngs, ns, key) => {
|
|
867
|
+
console.warn(`Missing translation: [${lngs}] ${ns}:${key}`);
|
|
868
|
+
},
|
|
869
|
+
});
|
|
870
|
+
```
|
|
871
|
+
|
|
872
|
+
### 6. Performance
|
|
873
|
+
|
|
874
|
+
**Code Splitting Translations by Route**:
|
|
875
|
+
```typescript
|
|
876
|
+
// i18next lazy-loading with namespaces per route
|
|
877
|
+
i18n.init({
|
|
878
|
+
partialBundledLanguages: true,
|
|
879
|
+
ns: [], // Start empty, load on demand
|
|
880
|
+
backend: {
|
|
881
|
+
loadPath: '/locales/{{lng}}/{{ns}}.json',
|
|
882
|
+
},
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
// In route component, load namespace on mount
|
|
886
|
+
function DashboardPage() {
|
|
887
|
+
const { t, ready } = useTranslation('dashboard', { useSuspense: true });
|
|
888
|
+
|
|
889
|
+
if (!ready) return <Skeleton />;
|
|
890
|
+
return <div>{t('welcome')}</div>;
|
|
891
|
+
}
|
|
892
|
+
```
|
|
893
|
+
|
|
894
|
+
**Dynamic Import of Locale Data** (for date-fns or similar):
|
|
895
|
+
```typescript
|
|
896
|
+
const localeImports: Record<string, () => Promise<Locale>> = {
|
|
897
|
+
en: () => import('date-fns/locale/enUS').then((m) => m.enUS),
|
|
898
|
+
de: () => import('date-fns/locale/de').then((m) => m.de),
|
|
899
|
+
fr: () => import('date-fns/locale/fr').then((m) => m.fr),
|
|
900
|
+
ja: () => import('date-fns/locale/ja').then((m) => m.ja),
|
|
901
|
+
ar: () => import('date-fns/locale/arSA').then((m) => m.arSA),
|
|
902
|
+
};
|
|
903
|
+
|
|
904
|
+
async function getDateLocale(lang: string): Promise<Locale> {
|
|
905
|
+
const loader = localeImports[lang] ?? localeImports.en;
|
|
906
|
+
return loader();
|
|
907
|
+
}
|
|
908
|
+
```
|
|
909
|
+
|
|
910
|
+
**Bundle Size Optimization**:
|
|
911
|
+
|
|
912
|
+
1. Use namespaces to split translation files (keep each under 10 KB gzipped).
|
|
913
|
+
2. Lazy-load non-critical namespaces after initial render.
|
|
914
|
+
3. Use `i18next-http-backend` instead of bundling all locales.
|
|
915
|
+
4. Tree-shake unused Intl polyfills.
|
|
916
|
+
5. Pre-compress translation JSON with gzip/brotli on CDN.
|
|
917
|
+
|
|
918
|
+
```typescript
|
|
919
|
+
// Webpack/Next.js: exclude unused moment/date-fns locales
|
|
920
|
+
// next.config.ts
|
|
921
|
+
import type { NextConfig } from 'next';
|
|
922
|
+
|
|
923
|
+
const config: NextConfig = {
|
|
924
|
+
webpack(config) {
|
|
925
|
+
// Only include needed locales for moment.js (if used)
|
|
926
|
+
config.plugins.push(
|
|
927
|
+
new (require('webpack')).ContextReplacementPlugin(
|
|
928
|
+
/moment[/\\]locale$/,
|
|
929
|
+
/en|de|fr|ar|ja/
|
|
930
|
+
)
|
|
931
|
+
);
|
|
932
|
+
return config;
|
|
933
|
+
},
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
export default config;
|
|
937
|
+
```
|
|
938
|
+
|
|
939
|
+
**Translation Preloading**:
|
|
940
|
+
```typescript
|
|
941
|
+
// Preload critical namespaces at app startup
|
|
942
|
+
await i18n.loadNamespaces(['common', 'auth']);
|
|
943
|
+
|
|
944
|
+
// Preload next page translations on hover/focus
|
|
945
|
+
function NavLink({ href, ns, children }: {
|
|
946
|
+
href: string;
|
|
947
|
+
ns: string;
|
|
948
|
+
children: React.ReactNode;
|
|
949
|
+
}) {
|
|
950
|
+
const { i18n } = useTranslation();
|
|
951
|
+
|
|
952
|
+
const preloadTranslations = () => {
|
|
953
|
+
i18n.loadNamespaces(ns);
|
|
954
|
+
};
|
|
955
|
+
|
|
956
|
+
return (
|
|
957
|
+
<Link
|
|
958
|
+
href={href}
|
|
959
|
+
onMouseEnter={preloadTranslations}
|
|
960
|
+
onFocus={preloadTranslations}
|
|
961
|
+
>
|
|
962
|
+
{children}
|
|
963
|
+
</Link>
|
|
964
|
+
);
|
|
965
|
+
}
|
|
966
|
+
```
|
|
967
|
+
|
|
968
|
+
## Decision Guide
|
|
969
|
+
|
|
970
|
+
| Scenario | Recommendation |
|
|
971
|
+
|---|---|
|
|
972
|
+
| SPA with React | i18next + react-i18next + HTTP backend |
|
|
973
|
+
| Next.js App Router | Built-in `[locale]` routing + server dictionaries |
|
|
974
|
+
| Need RTL | CSS logical properties + Tailwind `ps`/`pe` utilities |
|
|
975
|
+
| Date/number formatting | Native `Intl` APIs (zero bundle cost) |
|
|
976
|
+
| Translation management | Crowdin (open-source friendly) or Lokalise (developer-focused) |
|
|
977
|
+
| Large app (50+ routes) | Namespace-per-route + lazy loading |
|
|
978
|
+
| SEO-critical pages | `generateStaticParams` + hreflang + language alternates |
|
|
979
|
+
|
|
980
|
+
## Common Pitfalls
|
|
981
|
+
|
|
982
|
+
1. **Hardcoded strings**: Always externalize user-facing text, including error messages, aria labels, and alt text.
|
|
983
|
+
2. **String concatenation for sentences**: Use interpolation (`Hello, {{name}}`) instead of `"Hello, " + name` -- word order varies by language.
|
|
984
|
+
3. **Assuming text length**: German text is ~30% longer than English. Arabic may be shorter. Design flexible layouts.
|
|
985
|
+
4. **Fixed-width containers**: Use `min-width`/`max-width` with logical properties instead of fixed `width`.
|
|
986
|
+
5. **Icon direction**: Mirror arrows and chevrons for RTL, but not universal icons (close, check, search).
|
|
987
|
+
6. **Date format assumptions**: Never hardcode `MM/DD/YYYY`. Use `Intl.DateTimeFormat` with the user's locale.
|
|
988
|
+
7. **Number separators**: `1,000.50` (en) vs `1.000,50` (de) vs `1 000,50` (fr). Always use `Intl.NumberFormat`.
|
|
989
|
+
8. **Pluralization shortcuts**: Many languages have more than two plural forms (Arabic has 6). Use i18next plural rules, not ternary operators.
|