next-i18n-lite 1.0.4 → 2.0.1
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/README.md +175 -52
- package/package.json +7 -2
- package/scripts/postinstall.mjs +24 -0
- package/scripts/scaffoldI18n.mjs +169 -0
package/README.md
CHANGED
|
@@ -4,6 +4,7 @@ next-i18n-lite
|
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
+
- ✅ auto-configure the i18n library using scaffold
|
|
7
8
|
- ✅ TypeScript support with full type safety
|
|
8
9
|
- ✅ React Context API integration
|
|
9
10
|
- ✅ Automatic RTL support for Arabic.
|
|
@@ -25,9 +26,18 @@ yarn add next-i18n-lite
|
|
|
25
26
|
pnpm add next-i18n-lite
|
|
26
27
|
```
|
|
27
28
|
|
|
29
|
+
## Scaffold Prompt
|
|
30
|
+
|
|
31
|
+
When you install the library, you will see a prompt like this in your terminal:
|
|
32
|
+
⚡ **Prompt:** Do you want to auto-configure the i18n library using scaffold? (Y/n)
|
|
33
|
+
|
|
34
|
+
✅ Press Enter (default) → scaffold runs
|
|
35
|
+
❌ Type 'n' → skip
|
|
36
|
+
|
|
37
|
+
##### Note: it won't effect on the existed files!
|
|
28
38
|
## Quick Start
|
|
29
39
|
|
|
30
|
-
## 📁
|
|
40
|
+
## 📁 Folder Structure
|
|
31
41
|
|
|
32
42
|
Create the following files **inside the `app/` directory** (recommended):
|
|
33
43
|
|
|
@@ -37,6 +47,10 @@ Create the following files **inside the `app/` directory** (recommended):
|
|
|
37
47
|
- Location: `app/lib/locales/`
|
|
38
48
|
- Files:
|
|
39
49
|
- `en.ts` – English translations
|
|
50
|
+
- `es.ts` – Spanish translations
|
|
51
|
+
- `fr.ts` – French translations
|
|
52
|
+
- `pt.ts` – Portuguese translations
|
|
53
|
+
- `de.ts` – German translations
|
|
40
54
|
- `ar.ts` – Arabic translations
|
|
41
55
|
|
|
42
56
|
2. **I18nBoundary.tsx**
|
|
@@ -49,7 +63,7 @@ Create the following files **inside the `app/` directory** (recommended):
|
|
|
49
63
|
|
|
50
64
|
|
|
51
65
|
|
|
52
|
-
that's all
|
|
66
|
+
##### ✅that's all
|
|
53
67
|
---
|
|
54
68
|
|
|
55
69
|
## 🗂 Directory Tree
|
|
@@ -68,13 +82,18 @@ app/
|
|
|
68
82
|
├─ I18nBoundary.tsx # Client-only i18n wrapper
|
|
69
83
|
└─ locales/
|
|
70
84
|
├─ en.ts # English translations
|
|
85
|
+
├─ es.ts # Spanish translations
|
|
86
|
+
├─ fr.ts # French translations
|
|
87
|
+
├─ pt.ts # Portuguese translations
|
|
88
|
+
├─ de.ts # German translations
|
|
71
89
|
└─ ar.ts # Arabic translations
|
|
72
90
|
```
|
|
73
91
|
### 1. Create translation files
|
|
74
92
|
|
|
75
|
-
```
|
|
93
|
+
```bash
|
|
76
94
|
// lib/locales/en.ts
|
|
77
95
|
export const en = {
|
|
96
|
+
home:'Home',
|
|
78
97
|
common: {
|
|
79
98
|
welcome: 'Welcome',
|
|
80
99
|
loading: 'Loading...',
|
|
@@ -84,7 +103,62 @@ export const en = {
|
|
|
84
103
|
price: 'Price: ${amount}',
|
|
85
104
|
},
|
|
86
105
|
};
|
|
106
|
+
```
|
|
107
|
+
```bash
|
|
108
|
+
// lib/locales/es.ts
|
|
109
|
+
export const es = {
|
|
110
|
+
home:'Home',
|
|
111
|
+
common: {
|
|
112
|
+
welcome: "Bienvenido",
|
|
113
|
+
loading: "Cargando...",
|
|
114
|
+
},
|
|
115
|
+
product: {
|
|
116
|
+
addToCart: "Agregar al carrito",
|
|
117
|
+
price: "Precio: ${amount}",
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
```
|
|
121
|
+
```bash
|
|
122
|
+
// lib/locales/fr.ts
|
|
123
|
+
export const fr = {
|
|
124
|
+
common: {
|
|
125
|
+
welcome: "Bienvenue",
|
|
126
|
+
loading: "Chargement...",
|
|
127
|
+
},
|
|
128
|
+
product: {
|
|
129
|
+
addToCart: "Ajouter au panier",
|
|
130
|
+
price: "Prix : ${amount}",
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
```
|
|
134
|
+
```bash
|
|
135
|
+
// lib/locales/pt.ts
|
|
136
|
+
export const pt = {
|
|
137
|
+
common: {
|
|
138
|
+
welcome: "Bem-vindo",
|
|
139
|
+
loading: "Carregando...",
|
|
140
|
+
},
|
|
141
|
+
product: {
|
|
142
|
+
addToCart: "Adicionar ao carrinho",
|
|
143
|
+
price: "Preço: ${amount}",
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
```
|
|
147
|
+
```bash
|
|
148
|
+
// lib/locales/de.ts
|
|
149
|
+
export const de = {
|
|
150
|
+
common: {
|
|
151
|
+
welcome: "Willkommen",
|
|
152
|
+
loading: "Wird geladen...",
|
|
153
|
+
},
|
|
154
|
+
product: {
|
|
155
|
+
addToCart: "In den Warenkorb",
|
|
156
|
+
price: "Preis: ${amount}",
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
```
|
|
87
160
|
|
|
161
|
+
```bash
|
|
88
162
|
// lib/locales/ar.ts
|
|
89
163
|
export const ar = {
|
|
90
164
|
common: {
|
|
@@ -97,39 +171,49 @@ export const ar = {
|
|
|
97
171
|
},
|
|
98
172
|
};
|
|
99
173
|
```
|
|
100
|
-
|
|
101
|
-
```typescript
|
|
102
|
-
export const en = {
|
|
103
|
-
welcome: 'Welcome',
|
|
104
|
-
loading: 'Loading...',
|
|
105
|
-
}
|
|
106
|
-
```
|
|
174
|
+
|
|
107
175
|
### 2. Setup Provider (Next.js App Router)
|
|
108
176
|
|
|
109
177
|
#### I18nBoundary.tsx
|
|
110
|
-
```
|
|
111
|
-
// lib/I18nBoundary.tsx
|
|
178
|
+
```bash
|
|
179
|
+
// app/lib/I18nBoundary.tsx
|
|
112
180
|
'use client';
|
|
113
|
-
|
|
114
181
|
import { ReactNode, useEffect, useState } from 'react';
|
|
115
182
|
import { I18nProvider } from 'next-i18n-lite/react';
|
|
116
|
-
|
|
117
183
|
import { en } from './locales/en';
|
|
184
|
+
import { es } from './locales/es';
|
|
185
|
+
import { fr } from './locales/fr';
|
|
186
|
+
import { pt } from './locales/pt';
|
|
187
|
+
import { de } from './locales/de';
|
|
118
188
|
import { ar } from './locales/ar';
|
|
119
189
|
|
|
120
|
-
const translations = { en, ar };
|
|
190
|
+
const translations = { en, ar, es, fr, pt, de };
|
|
191
|
+
|
|
192
|
+
// Supporting Arabic
|
|
193
|
+
const RTL_LOCALES = new Set(['ar']);
|
|
194
|
+
|
|
195
|
+
function getDirection(locale: string) {
|
|
196
|
+
return RTL_LOCALES.has(locale) ? 'rtl' : 'ltr';
|
|
197
|
+
}
|
|
121
198
|
|
|
122
199
|
export function I18nBoundary({ children }: { children: ReactNode }) {
|
|
123
|
-
const [locale, setLocale] = useState<
|
|
200
|
+
const [locale, setLocale] = useState<string | null>(null);
|
|
124
201
|
|
|
125
202
|
useEffect(() => {
|
|
126
|
-
const saved = localStorage.getItem('locale');
|
|
127
|
-
|
|
203
|
+
const saved = localStorage.getItem('locale') || 'en';
|
|
204
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
205
|
+
setLocale(saved);
|
|
206
|
+
|
|
207
|
+
// handle document direction here (ONCE)
|
|
208
|
+
document.documentElement.lang = saved;
|
|
209
|
+
document.documentElement.dir = getDirection(saved);
|
|
128
210
|
}, []);
|
|
129
211
|
|
|
212
|
+
// hydration-safe gate
|
|
130
213
|
if (!locale) {
|
|
131
214
|
return null;
|
|
132
215
|
}
|
|
216
|
+
|
|
133
217
|
return (
|
|
134
218
|
<I18nProvider translations={translations} defaultLocale={locale}>
|
|
135
219
|
{children}
|
|
@@ -143,11 +227,11 @@ still may face issue for setLocale
|
|
|
143
227
|
|
|
144
228
|
Fix: not actually fixed yet but if you don't like red flags in code just
|
|
145
229
|
|
|
146
|
-
```
|
|
230
|
+
```bash
|
|
147
231
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
148
232
|
```
|
|
149
233
|
yup! ignore it :D
|
|
150
|
-
```
|
|
234
|
+
```bash
|
|
151
235
|
// app/layout.tsx
|
|
152
236
|
import { I18nBoundary } from "./lib/I18nBoundary";
|
|
153
237
|
|
|
@@ -170,59 +254,56 @@ also i provided 2 options:
|
|
|
170
254
|
- Choice 2:Hover dropdown list
|
|
171
255
|
|
|
172
256
|
you can ignore styling & lucide-react library both only for example ^^
|
|
173
|
-
```
|
|
257
|
+
```bash
|
|
258
|
+
// components/i18n/LocaleSwitcher.tsx
|
|
174
259
|
'use client';
|
|
260
|
+
|
|
175
261
|
import { Globe } from "lucide-react";
|
|
176
262
|
import { useI18n } from "next-i18n-lite/react";
|
|
177
|
-
import { useEffect
|
|
263
|
+
import { useEffect } from "react";
|
|
178
264
|
|
|
179
265
|
const languages = [
|
|
180
266
|
{ code: 'en', name: 'English' },
|
|
267
|
+
{ code: 'es', name: 'Español' },
|
|
268
|
+
{ code: 'fr', name: 'Français' },
|
|
269
|
+
{ code: 'pt', name: 'Português' },
|
|
270
|
+
{ code: 'de', name: 'Deutsch' },
|
|
181
271
|
{ code: 'ar', name: 'العربية' },
|
|
182
272
|
];
|
|
183
273
|
|
|
184
274
|
export function LocaleSwitcher() {
|
|
185
275
|
const { locale, setLocale, isRTL } = useI18n();
|
|
186
|
-
const [mounted, setMounted] = useState(false);
|
|
187
276
|
|
|
188
|
-
|
|
189
|
-
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
190
|
-
setMounted(true);
|
|
191
|
-
}, []);
|
|
192
|
-
|
|
193
|
-
if (!mounted) return null;
|
|
277
|
+
const dropdownPosition = isRTL ? 'left-0' : 'right-0';
|
|
194
278
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
setLocale(persist);
|
|
199
|
-
localStorage.setItem('locale', persist); // persist choice
|
|
279
|
+
const changeLocale = (code: string) => {
|
|
280
|
+
setLocale(code);
|
|
281
|
+
localStorage.setItem('locale', code);
|
|
200
282
|
};
|
|
201
283
|
|
|
202
|
-
//
|
|
203
|
-
|
|
284
|
+
// sync document direction (just to make sure eveything in running well in rtl direction)
|
|
285
|
+
useEffect(() => {
|
|
286
|
+
document.documentElement.lang = locale;
|
|
287
|
+
document.documentElement.dir = locale === 'ar' ? 'rtl' : 'ltr';
|
|
288
|
+
}, [locale]);
|
|
204
289
|
|
|
205
290
|
return (
|
|
206
291
|
<div className="relative group inline-block">
|
|
207
|
-
|
|
208
|
-
<Globe color="white" onClick={toggleLocale} className="top-5 "/>
|
|
292
|
+
<Globe color="white" />
|
|
209
293
|
|
|
210
|
-
{/* Choice 2:Hover dropdown list */}
|
|
211
294
|
<div
|
|
212
|
-
className={`absolute ${dropdownPosition} mt-2 bg-card border border-border rounded-xl shadow-lg
|
|
295
|
+
className={`absolute ${dropdownPosition} mt-2 bg-card border border-border rounded-xl shadow-lg
|
|
296
|
+
opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all z-50`}
|
|
213
297
|
>
|
|
214
298
|
{languages.map((lang) => (
|
|
215
299
|
<button
|
|
216
300
|
key={lang.code}
|
|
217
|
-
onClick={() =>
|
|
218
|
-
|
|
219
|
-
localStorage.setItem('locale', lang.code);
|
|
220
|
-
}}
|
|
221
|
-
className={`w-full flex items-center gap-3 px-4 py-3 transition-colors first:rounded-t-xl last:rounded-b-xl
|
|
301
|
+
onClick={() => changeLocale(lang.code)}
|
|
302
|
+
className={`w-full flex items-center gap-3 px-4 py-3 transition-colors
|
|
222
303
|
${locale === lang.code ? 'bg-primary/10 text-primary' : 'hover:bg-gray-600'}
|
|
223
304
|
`}
|
|
224
305
|
>
|
|
225
|
-
|
|
306
|
+
{lang.name}
|
|
226
307
|
</button>
|
|
227
308
|
))}
|
|
228
309
|
</div>
|
|
@@ -230,7 +311,7 @@ export function LocaleSwitcher() {
|
|
|
230
311
|
);
|
|
231
312
|
}
|
|
232
313
|
```
|
|
233
|
-
### 4. Use in
|
|
314
|
+
### 4. Use in Header Component
|
|
234
315
|
### `useI18n()`
|
|
235
316
|
const { t, locale, setLocale, isRTL } = useI18n();
|
|
236
317
|
|
|
@@ -239,13 +320,21 @@ Returns:
|
|
|
239
320
|
- `locale` - Current locale
|
|
240
321
|
- `setLocale(locale)` - Change locale
|
|
241
322
|
- `isRTL` - Boolean indicating RTL direction
|
|
242
|
-
```
|
|
323
|
+
```bash
|
|
243
324
|
'use client';
|
|
244
325
|
import { useI18n } from "next-i18n-lite/react";
|
|
245
326
|
|
|
246
|
-
export function
|
|
327
|
+
export function Header() {
|
|
247
328
|
const { t } = useI18n();
|
|
248
|
-
|
|
329
|
+
const [mounted, setMounted] = useState(false);
|
|
330
|
+
// To avoid hydration mismatch
|
|
331
|
+
if (typeof window !== "undefined" && !mounted) {
|
|
332
|
+
setMounted(true);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (!mounted) {
|
|
336
|
+
return <div className="w-10 h-10" />;
|
|
337
|
+
}
|
|
249
338
|
return (
|
|
250
339
|
<div>
|
|
251
340
|
<h1>{t('common.welcome')}</h1>
|
|
@@ -254,7 +343,41 @@ export function page() {
|
|
|
254
343
|
);
|
|
255
344
|
}
|
|
256
345
|
```
|
|
257
|
-
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
## Formatters
|
|
349
|
+
|
|
350
|
+
library also provides helper function to
|
|
351
|
+
- **handle pluralization**
|
|
352
|
+
according to the current locale.
|
|
353
|
+
---
|
|
354
|
+
|
|
355
|
+
```bash
|
|
356
|
+
import { pluralize } from "next-i18n-lite"
|
|
357
|
+
export default function Cart() {
|
|
358
|
+
const { t } = useI18n();
|
|
359
|
+
const items = 1 // 2 try it out
|
|
360
|
+
return (
|
|
361
|
+
<p>{pluralize(items, t('unit'), t('units'))}</p>
|
|
362
|
+
)
|
|
363
|
+
};
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
### Upcoming Features
|
|
367
|
+
- **format dates,**
|
|
368
|
+
`formatDate(date: Date, locale: string): string`
|
|
369
|
+
- **format numbers,**
|
|
370
|
+
`formatNumber(num: number, locale: string): string`
|
|
371
|
+
- **format currencies,**
|
|
372
|
+
`formatCurrency(amount: number, locale: string, currency = 'USD'): string`
|
|
373
|
+
|
|
374
|
+
```ts
|
|
375
|
+
import {
|
|
376
|
+
formatDate,
|
|
377
|
+
formatNumber,
|
|
378
|
+
formatCurrency
|
|
379
|
+
} from "next-i18n-lite"
|
|
380
|
+
```
|
|
258
381
|
## API Reference
|
|
259
382
|
|
|
260
383
|
### `useI18n()`
|
|
@@ -276,4 +399,4 @@ Returns:
|
|
|
276
399
|
codex410@gmail.com
|
|
277
400
|
## License
|
|
278
401
|
|
|
279
|
-
MIT ©
|
|
402
|
+
MIT © Islam Abozeed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "next-i18n-lite",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"description": "Lightweight i18n library for Next.js with TypeScript support",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.mjs",
|
|
@@ -19,15 +19,20 @@
|
|
|
19
19
|
},
|
|
20
20
|
"files": [
|
|
21
21
|
"dist",
|
|
22
|
+
"scripts",
|
|
22
23
|
"README.md",
|
|
23
24
|
"LICENSE"
|
|
24
25
|
],
|
|
25
26
|
"scripts": {
|
|
26
27
|
"build": "tsup",
|
|
27
28
|
"dev": "tsup --watch",
|
|
29
|
+
"postinstall": "node ./scripts/postinstall.mjs || true",
|
|
28
30
|
"prepublishOnly": "npm run build",
|
|
29
31
|
"test": "echo \"Error: no test specified\" && exit 1"
|
|
30
32
|
},
|
|
33
|
+
"bin":{
|
|
34
|
+
"setup-i18n": "./scripts/scaffoldI18n.mjs"
|
|
35
|
+
},
|
|
31
36
|
"keywords": [
|
|
32
37
|
"i18n",
|
|
33
38
|
"internationalization",
|
|
@@ -37,7 +42,7 @@
|
|
|
37
42
|
"localization",
|
|
38
43
|
"translation"
|
|
39
44
|
],
|
|
40
|
-
"author": "codexpro410 <
|
|
45
|
+
"author": "codexpro410 <codex410@gmail.com>",
|
|
41
46
|
"license": "MIT",
|
|
42
47
|
"repository": {
|
|
43
48
|
"type": "git",
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import readline from 'readline';
|
|
3
|
+
import { scaffoldI18n } from './scaffoldI18n.mjs';
|
|
4
|
+
|
|
5
|
+
const rl = readline.createInterface({
|
|
6
|
+
input: process.stdin,
|
|
7
|
+
output: process.stdout,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
// Default answer is Yes (y) → user must type 'N' to skip
|
|
11
|
+
rl.question(
|
|
12
|
+
'Do you want to auto-configure the i18n library using scaffold? (Y/n) ',
|
|
13
|
+
(answer) => {
|
|
14
|
+
const normalized = answer.trim().toLowerCase();
|
|
15
|
+
if (normalized === 'n') {
|
|
16
|
+
console.log('Skipped scaffold.');
|
|
17
|
+
} else {
|
|
18
|
+
console.log('Running scaffold...');
|
|
19
|
+
scaffoldI18n();
|
|
20
|
+
console.log('✅ Scaffold completed!');
|
|
21
|
+
}
|
|
22
|
+
rl.close();
|
|
23
|
+
}
|
|
24
|
+
);
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
export function scaffoldI18n() {
|
|
5
|
+
// ----------------------
|
|
6
|
+
// 1️⃣ Locales Scaffold
|
|
7
|
+
// ----------------------
|
|
8
|
+
const locales = {
|
|
9
|
+
en: {
|
|
10
|
+
home: 'Home',
|
|
11
|
+
common: { welcome: 'Welcome', loading: 'Loading...' },
|
|
12
|
+
product: { addToCart: 'Add to Cart', price: 'Price: ${amount}' },
|
|
13
|
+
},
|
|
14
|
+
es: {
|
|
15
|
+
home: 'Inicio',
|
|
16
|
+
common: { welcome: 'Bienvenido', loading: 'Cargando...' },
|
|
17
|
+
product: { addToCart: 'Agregar al carrito', price: 'Precio: ${amount}' },
|
|
18
|
+
},
|
|
19
|
+
fr: {
|
|
20
|
+
home: 'Accueil',
|
|
21
|
+
common: { welcome: 'Bienvenue', loading: 'Chargement...' },
|
|
22
|
+
product: { addToCart: 'Ajouter au panier', price: 'Prix: ${amount}' },
|
|
23
|
+
},
|
|
24
|
+
pt: {
|
|
25
|
+
home: 'Início',
|
|
26
|
+
common: { welcome: 'Bem-vindo', loading: 'Carregando...' },
|
|
27
|
+
product: { addToCart: 'Adicionar ao carrinho', price: 'Preço: ${amount}' },
|
|
28
|
+
},
|
|
29
|
+
de: {
|
|
30
|
+
home: 'Startseite',
|
|
31
|
+
common: { welcome: 'Willkommen', loading: 'Wird geladen...' },
|
|
32
|
+
product: { addToCart: 'In den Warenkorb', price: 'Preis: ${amount}' },
|
|
33
|
+
},
|
|
34
|
+
ar: {
|
|
35
|
+
home: 'الصفحة الرئيسية',
|
|
36
|
+
common: { welcome: 'مرحباً', loading: 'جاري التحميل...' },
|
|
37
|
+
product: { addToCart: 'أضف إلى السلة', price: 'السعر: ${amount}' },
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const localesDir = path.resolve('./app/lib/locales');
|
|
42
|
+
if (!fs.existsSync(localesDir)) fs.mkdirSync(localesDir, { recursive: true });
|
|
43
|
+
|
|
44
|
+
for (const [code, content] of Object.entries(locales)) {
|
|
45
|
+
const filePath = path.join(localesDir, `${code}.ts`);
|
|
46
|
+
if (!fs.existsSync(filePath)) {
|
|
47
|
+
const fileContent = `export const ${code} = ${JSON.stringify(content, null, 2)};\n`;
|
|
48
|
+
fs.writeFileSync(filePath, fileContent, 'utf-8');
|
|
49
|
+
console.log(`Created locale: ${filePath}`);
|
|
50
|
+
} else {
|
|
51
|
+
console.log(`Locale exists: ${filePath}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ----------------------
|
|
56
|
+
// 2️⃣ I18nBoundary Scaffold
|
|
57
|
+
// ----------------------
|
|
58
|
+
const i18nBoundaryPath = path.resolve('./app/lib/I18nBoundary.tsx');
|
|
59
|
+
if (!fs.existsSync(i18nBoundaryPath)) {
|
|
60
|
+
const i18nBoundaryContent = `
|
|
61
|
+
// app/lib/I18nBoundary.tsx
|
|
62
|
+
'use client';
|
|
63
|
+
import { ReactNode, useEffect, useState } from 'react';
|
|
64
|
+
import { I18nProvider } from 'next-i18n-lite/react';
|
|
65
|
+
import { en } from './locales/en';
|
|
66
|
+
import { es } from './locales/es';
|
|
67
|
+
import { fr } from './locales/fr';
|
|
68
|
+
import { pt } from './locales/pt';
|
|
69
|
+
import { de } from './locales/de';
|
|
70
|
+
import { ar } from './locales/ar';
|
|
71
|
+
|
|
72
|
+
const translations = { en, ar, es, fr, pt, de };
|
|
73
|
+
const RTL_LOCALES = new Set(['ar']);
|
|
74
|
+
|
|
75
|
+
function getDirection(locale: string) {
|
|
76
|
+
return RTL_LOCALES.has(locale) ? 'rtl' : 'ltr';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function I18nBoundary({ children }: { children: ReactNode }) {
|
|
80
|
+
const [locale, setLocale] = useState<string | null>(null);
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
const saved = localStorage.getItem('locale') || 'en';
|
|
84
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
85
|
+
setLocale(saved);
|
|
86
|
+
document.documentElement.lang = saved;
|
|
87
|
+
document.documentElement.dir = getDirection(saved);
|
|
88
|
+
}, []);
|
|
89
|
+
|
|
90
|
+
if (!locale) return null;
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<I18nProvider translations={translations} defaultLocale={locale}>
|
|
94
|
+
{children}
|
|
95
|
+
</I18nProvider>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
`.trim();
|
|
99
|
+
fs.writeFileSync(i18nBoundaryPath, i18nBoundaryContent, 'utf-8');
|
|
100
|
+
console.log(`Created: ${i18nBoundaryPath}`);
|
|
101
|
+
} else {
|
|
102
|
+
console.log(`Exists: ${i18nBoundaryPath}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ----------------------
|
|
106
|
+
// 3️⃣ LocaleSwitcher Scaffold
|
|
107
|
+
// ----------------------
|
|
108
|
+
const localeSwitcherPath = path.resolve('./app/components/i18n/LocaleSwitcher.tsx');
|
|
109
|
+
const localeSwitcherDir = path.dirname(localeSwitcherPath);
|
|
110
|
+
if (!fs.existsSync(localeSwitcherDir)) fs.mkdirSync(localeSwitcherDir, { recursive: true });
|
|
111
|
+
|
|
112
|
+
if (!fs.existsSync(localeSwitcherPath)) {
|
|
113
|
+
const localeSwitcherContent = `
|
|
114
|
+
// components/i18n/LocaleSwitcher.tsx
|
|
115
|
+
'use client';
|
|
116
|
+
import { Globe } from "lucide-react";
|
|
117
|
+
import { useI18n } from "next-i18n-lite/react";
|
|
118
|
+
import { useEffect } from "react";
|
|
119
|
+
|
|
120
|
+
const languages = [
|
|
121
|
+
{ code: 'en', name: 'English' },
|
|
122
|
+
{ code: 'es', name: 'Español' },
|
|
123
|
+
{ code: 'fr', name: 'Français' },
|
|
124
|
+
{ code: 'pt', name: 'Português' },
|
|
125
|
+
{ code: 'de', name: 'Deutsch' },
|
|
126
|
+
{ code: 'ar', name: 'العربية' },
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
export function LocaleSwitcher() {
|
|
130
|
+
const { locale, setLocale, isRTL } = useI18n();
|
|
131
|
+
const dropdownPosition = isRTL ? 'left-0' : 'right-0';
|
|
132
|
+
|
|
133
|
+
const changeLocale = (code: string) => {
|
|
134
|
+
setLocale(code);
|
|
135
|
+
localStorage.setItem('locale', code);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// sync document direction
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
document.documentElement.lang = locale;
|
|
141
|
+
document.documentElement.dir = locale === 'ar' ? 'rtl' : 'ltr';
|
|
142
|
+
}, [locale]);
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<div className="relative group inline-block">
|
|
146
|
+
<Globe color="white" />
|
|
147
|
+
<div className={\`absolute \${dropdownPosition} mt-2 bg-card border border-border rounded-xl shadow-lg
|
|
148
|
+
opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all z-50\`}>
|
|
149
|
+
{languages.map((lang) => (
|
|
150
|
+
<button
|
|
151
|
+
key={lang.code}
|
|
152
|
+
onClick={() => changeLocale(lang.code)}
|
|
153
|
+
className={\`w-full flex items-center gap-3 px-4 py-3 transition-colors
|
|
154
|
+
\${locale === lang.code ? 'bg-primary/10 text-primary' : 'hover:bg-gray-600'}\`}
|
|
155
|
+
>
|
|
156
|
+
{lang.name}
|
|
157
|
+
</button>
|
|
158
|
+
))}
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
`.trim();
|
|
164
|
+
fs.writeFileSync(localeSwitcherPath, localeSwitcherContent, 'utf-8');
|
|
165
|
+
console.log(`Created: ${localeSwitcherPath}`);
|
|
166
|
+
} else {
|
|
167
|
+
console.log(`Exists: ${localeSwitcherPath}`);
|
|
168
|
+
}
|
|
169
|
+
}
|