next-i18n-lite 1.0.3 → 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 +211 -63
- 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,31 +26,74 @@ 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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
40
|
+
## 📁 Folder Structure
|
|
41
|
+
|
|
42
|
+
Create the following files **inside the `app/` directory** (recommended):
|
|
43
|
+
|
|
44
|
+
### Required Files
|
|
45
|
+
|
|
46
|
+
1. **Language files**
|
|
47
|
+
- Location: `app/lib/locales/`
|
|
48
|
+
- Files:
|
|
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
|
|
54
|
+
- `ar.ts` – Arabic translations
|
|
55
|
+
|
|
56
|
+
2. **I18nBoundary.tsx**
|
|
57
|
+
- Location: `app/lib/`
|
|
58
|
+
- Purpose: Client-only i18n wrapper
|
|
59
|
+
|
|
60
|
+
3. **LocaleSwitcher.tsx**
|
|
61
|
+
- Location: `app/components/i18n/`
|
|
62
|
+
- Purpose: Language switch button
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
##### ✅that's all
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## 🗂 Directory Tree
|
|
70
|
+
|
|
71
|
+
```txt
|
|
36
72
|
app/
|
|
37
|
-
├─ layout.tsx
|
|
38
|
-
├─ globals.css
|
|
39
|
-
├─ Header.tsx
|
|
73
|
+
├─ layout.tsx # Root layout
|
|
74
|
+
├─ globals.css # Global styles
|
|
75
|
+
├─ Header.tsx # Header (nav + LocaleSwitcher)
|
|
76
|
+
│
|
|
40
77
|
├─ components/
|
|
41
78
|
│ └─ i18n/
|
|
42
|
-
│ └─ LocaleSwitcher.tsx # Language
|
|
79
|
+
│ └─ LocaleSwitcher.tsx # Language switcher button
|
|
80
|
+
│
|
|
43
81
|
└─ lib/
|
|
44
82
|
├─ I18nBoundary.tsx # Client-only i18n wrapper
|
|
45
83
|
└─ locales/
|
|
46
|
-
├─ en.ts
|
|
47
|
-
|
|
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
|
|
89
|
+
└─ ar.ts # Arabic translations
|
|
90
|
+
```
|
|
48
91
|
### 1. Create translation files
|
|
49
92
|
|
|
50
|
-
```
|
|
93
|
+
```bash
|
|
51
94
|
// lib/locales/en.ts
|
|
52
95
|
export const en = {
|
|
96
|
+
home:'Home',
|
|
53
97
|
common: {
|
|
54
98
|
welcome: 'Welcome',
|
|
55
99
|
loading: 'Loading...',
|
|
@@ -59,7 +103,62 @@ export const en = {
|
|
|
59
103
|
price: 'Price: ${amount}',
|
|
60
104
|
},
|
|
61
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
|
+
```
|
|
62
160
|
|
|
161
|
+
```bash
|
|
63
162
|
// lib/locales/ar.ts
|
|
64
163
|
export const ar = {
|
|
65
164
|
common: {
|
|
@@ -72,39 +171,49 @@ export const ar = {
|
|
|
72
171
|
},
|
|
73
172
|
};
|
|
74
173
|
```
|
|
75
|
-
|
|
76
|
-
```typescript
|
|
77
|
-
export const en = {
|
|
78
|
-
welcome: 'Welcome',
|
|
79
|
-
loading: 'Loading...',
|
|
80
|
-
}
|
|
81
|
-
```
|
|
174
|
+
|
|
82
175
|
### 2. Setup Provider (Next.js App Router)
|
|
83
176
|
|
|
84
177
|
#### I18nBoundary.tsx
|
|
85
|
-
```
|
|
86
|
-
// lib/I18nBoundary.tsx
|
|
178
|
+
```bash
|
|
179
|
+
// app/lib/I18nBoundary.tsx
|
|
87
180
|
'use client';
|
|
88
|
-
|
|
89
181
|
import { ReactNode, useEffect, useState } from 'react';
|
|
90
182
|
import { I18nProvider } from 'next-i18n-lite/react';
|
|
91
|
-
|
|
92
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';
|
|
93
188
|
import { ar } from './locales/ar';
|
|
94
189
|
|
|
95
|
-
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
|
+
}
|
|
96
198
|
|
|
97
199
|
export function I18nBoundary({ children }: { children: ReactNode }) {
|
|
98
|
-
const [locale, setLocale] = useState<
|
|
200
|
+
const [locale, setLocale] = useState<string | null>(null);
|
|
99
201
|
|
|
100
202
|
useEffect(() => {
|
|
101
|
-
const saved = localStorage.getItem('locale');
|
|
102
|
-
|
|
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);
|
|
103
210
|
}, []);
|
|
104
211
|
|
|
212
|
+
// hydration-safe gate
|
|
105
213
|
if (!locale) {
|
|
106
214
|
return null;
|
|
107
215
|
}
|
|
216
|
+
|
|
108
217
|
return (
|
|
109
218
|
<I18nProvider translations={translations} defaultLocale={locale}>
|
|
110
219
|
{children}
|
|
@@ -118,11 +227,11 @@ still may face issue for setLocale
|
|
|
118
227
|
|
|
119
228
|
Fix: not actually fixed yet but if you don't like red flags in code just
|
|
120
229
|
|
|
121
|
-
```
|
|
230
|
+
```bash
|
|
122
231
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
123
232
|
```
|
|
124
233
|
yup! ignore it :D
|
|
125
|
-
```
|
|
234
|
+
```bash
|
|
126
235
|
// app/layout.tsx
|
|
127
236
|
import { I18nBoundary } from "./lib/I18nBoundary";
|
|
128
237
|
|
|
@@ -139,65 +248,62 @@ export default function RootLayout({ children }) {
|
|
|
139
248
|
}
|
|
140
249
|
```
|
|
141
250
|
### 3. Create LocaleSwitcher.tsx
|
|
142
|
-
/components/
|
|
251
|
+
/components/i18n/LocaleSwitcher.tsx (recommended)
|
|
143
252
|
also i provided 2 options:
|
|
144
253
|
- Choice 1: Single-click toggle button
|
|
145
254
|
- Choice 2:Hover dropdown list
|
|
146
255
|
|
|
147
256
|
you can ignore styling & lucide-react library both only for example ^^
|
|
148
|
-
```
|
|
257
|
+
```bash
|
|
258
|
+
// components/i18n/LocaleSwitcher.tsx
|
|
149
259
|
'use client';
|
|
260
|
+
|
|
150
261
|
import { Globe } from "lucide-react";
|
|
151
262
|
import { useI18n } from "next-i18n-lite/react";
|
|
152
|
-
import { useEffect
|
|
263
|
+
import { useEffect } from "react";
|
|
153
264
|
|
|
154
265
|
const languages = [
|
|
155
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' },
|
|
156
271
|
{ code: 'ar', name: 'العربية' },
|
|
157
272
|
];
|
|
158
273
|
|
|
159
274
|
export function LocaleSwitcher() {
|
|
160
275
|
const { locale, setLocale, isRTL } = useI18n();
|
|
161
|
-
const [mounted, setMounted] = useState(false);
|
|
162
|
-
|
|
163
|
-
useEffect(() => {
|
|
164
|
-
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
165
|
-
setMounted(true);
|
|
166
|
-
}, []);
|
|
167
276
|
|
|
168
|
-
|
|
277
|
+
const dropdownPosition = isRTL ? 'left-0' : 'right-0';
|
|
169
278
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
setLocale(persist);
|
|
174
|
-
localStorage.setItem('locale', persist); // persist choice
|
|
279
|
+
const changeLocale = (code: string) => {
|
|
280
|
+
setLocale(code);
|
|
281
|
+
localStorage.setItem('locale', code);
|
|
175
282
|
};
|
|
176
283
|
|
|
177
|
-
//
|
|
178
|
-
|
|
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]);
|
|
179
289
|
|
|
180
290
|
return (
|
|
181
291
|
<div className="relative group inline-block">
|
|
182
|
-
|
|
183
|
-
<Globe color="white" onClick={toggleLocale} className="top-5 "/>
|
|
292
|
+
<Globe color="white" />
|
|
184
293
|
|
|
185
|
-
{/* Choice 2:Hover dropdown list */}
|
|
186
294
|
<div
|
|
187
|
-
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`}
|
|
188
297
|
>
|
|
189
298
|
{languages.map((lang) => (
|
|
190
299
|
<button
|
|
191
300
|
key={lang.code}
|
|
192
|
-
onClick={() =>
|
|
193
|
-
|
|
194
|
-
localStorage.setItem('locale', lang.code);
|
|
195
|
-
}}
|
|
196
|
-
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
|
|
197
303
|
${locale === lang.code ? 'bg-primary/10 text-primary' : 'hover:bg-gray-600'}
|
|
198
304
|
`}
|
|
199
305
|
>
|
|
200
|
-
|
|
306
|
+
{lang.name}
|
|
201
307
|
</button>
|
|
202
308
|
))}
|
|
203
309
|
</div>
|
|
@@ -205,7 +311,7 @@ export function LocaleSwitcher() {
|
|
|
205
311
|
);
|
|
206
312
|
}
|
|
207
313
|
```
|
|
208
|
-
### 4. Use in
|
|
314
|
+
### 4. Use in Header Component
|
|
209
315
|
### `useI18n()`
|
|
210
316
|
const { t, locale, setLocale, isRTL } = useI18n();
|
|
211
317
|
|
|
@@ -214,13 +320,21 @@ Returns:
|
|
|
214
320
|
- `locale` - Current locale
|
|
215
321
|
- `setLocale(locale)` - Change locale
|
|
216
322
|
- `isRTL` - Boolean indicating RTL direction
|
|
217
|
-
```
|
|
323
|
+
```bash
|
|
218
324
|
'use client';
|
|
219
325
|
import { useI18n } from "next-i18n-lite/react";
|
|
220
326
|
|
|
221
|
-
export function
|
|
327
|
+
export function Header() {
|
|
222
328
|
const { t } = useI18n();
|
|
223
|
-
|
|
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
|
+
}
|
|
224
338
|
return (
|
|
225
339
|
<div>
|
|
226
340
|
<h1>{t('common.welcome')}</h1>
|
|
@@ -229,7 +343,41 @@ export function page() {
|
|
|
229
343
|
);
|
|
230
344
|
}
|
|
231
345
|
```
|
|
232
|
-
|
|
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
|
+
```
|
|
233
381
|
## API Reference
|
|
234
382
|
|
|
235
383
|
### `useI18n()`
|
|
@@ -251,4 +399,4 @@ Returns:
|
|
|
251
399
|
codex410@gmail.com
|
|
252
400
|
## License
|
|
253
401
|
|
|
254
|
-
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
|
+
}
|