create-forgeon 0.1.11 → 0.1.13
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/package.json +1 -1
- package/src/modules/executor.test.mjs +23 -3
- package/src/modules/i18n.mjs +21 -0
- package/src/presets/i18n.mjs +13 -1
- package/templates/base/apps/api/src/health/health.controller.ts +4 -12
- package/templates/base/resources/i18n/en/common.json +3 -9
- package/templates/base/resources/i18n/en/errors.json +4 -0
- package/templates/base/resources/i18n/en/validation.json +1 -3
- package/templates/base/resources/i18n/uk/common.json +3 -9
- package/templates/base/resources/i18n/uk/errors.json +4 -0
- package/templates/base/resources/i18n/uk/validation.json +1 -3
- package/templates/docs-fragments/README/40_i18n.md +5 -0
- package/templates/module-fragments/i18n/10_overview.md +3 -2
- package/templates/module-fragments/i18n/20_scope.md +3 -1
- package/templates/module-presets/i18n/apps/web/src/App.tsx +11 -33
- package/templates/module-presets/i18n/apps/web/src/i18n.ts +35 -0
- package/templates/module-presets/i18n/apps/web/src/main.tsx +10 -0
- package/templates/module-presets/i18n/packages/i18n-contracts/package.json +2 -1
- package/templates/module-presets/i18n/packages/i18n-contracts/scripts/check-keys.mjs +97 -0
- package/templates/module-presets/i18n/packages/i18n-contracts/src/index.ts +2 -0
- package/templates/module-presets/i18n/packages/i18n-web/src/index.ts +1 -1
package/package.json
CHANGED
|
@@ -104,7 +104,7 @@ describe('addModule', () => {
|
|
|
104
104
|
|
|
105
105
|
const appTsx = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'App.tsx'), 'utf8');
|
|
106
106
|
assert.match(appTsx, /@forgeon\/i18n-web/);
|
|
107
|
-
assert.match(appTsx,
|
|
107
|
+
assert.match(appTsx, /react-i18next/);
|
|
108
108
|
assert.match(appTsx, /checkApiHealth/);
|
|
109
109
|
|
|
110
110
|
const i18nWebPackage = fs.readFileSync(
|
|
@@ -123,14 +123,33 @@ describe('addModule', () => {
|
|
|
123
123
|
path.join(projectRoot, 'packages', 'i18n-web', 'src', 'index.ts'),
|
|
124
124
|
'utf8',
|
|
125
125
|
);
|
|
126
|
-
assert.match(i18nWebSource, /@forgeon\/i18n-contracts
|
|
126
|
+
assert.match(i18nWebSource, /@forgeon\/i18n-contracts/);
|
|
127
127
|
|
|
128
128
|
const enCommon = JSON.parse(
|
|
129
129
|
fs.readFileSync(path.join(projectRoot, 'resources', 'i18n', 'en', 'common.json'), 'utf8'),
|
|
130
130
|
);
|
|
131
|
-
assert.equal(enCommon.
|
|
131
|
+
assert.equal(enCommon.checkApiHealth, 'Check API health');
|
|
132
132
|
assert.equal(enCommon.languages.english, 'English');
|
|
133
133
|
|
|
134
|
+
const enErrors = JSON.parse(
|
|
135
|
+
fs.readFileSync(path.join(projectRoot, 'resources', 'i18n', 'en', 'errors.json'), 'utf8'),
|
|
136
|
+
);
|
|
137
|
+
assert.equal(enErrors.notFound, 'Resource not found');
|
|
138
|
+
|
|
139
|
+
const webPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'package.json'), 'utf8');
|
|
140
|
+
assert.match(webPackage, /"i18next":/);
|
|
141
|
+
assert.match(webPackage, /"react-i18next":/);
|
|
142
|
+
|
|
143
|
+
const mainTsx = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'main.tsx'), 'utf8');
|
|
144
|
+
assert.match(mainTsx, /import '\.\/i18n';/);
|
|
145
|
+
|
|
146
|
+
const i18nTs = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'i18n.ts'), 'utf8');
|
|
147
|
+
assert.match(i18nTs, /initReactI18next/);
|
|
148
|
+
assert.match(i18nTs, /\.\.\/\.\.\/\.\.\/\.\.\/resources\/i18n\/en\/common\.json/);
|
|
149
|
+
|
|
150
|
+
const rootPackage = fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8');
|
|
151
|
+
assert.match(rootPackage, /"i18n:check"/);
|
|
152
|
+
|
|
134
153
|
const caddyDockerfile = fs.readFileSync(
|
|
135
154
|
path.join(projectRoot, 'infra', 'docker', 'caddy.Dockerfile'),
|
|
136
155
|
'utf8',
|
|
@@ -144,6 +163,7 @@ describe('addModule', () => {
|
|
|
144
163
|
caddyDockerfile,
|
|
145
164
|
/COPY packages\/i18n-web\/package\.json packages\/i18n-web\/package\.json/,
|
|
146
165
|
);
|
|
166
|
+
assert.match(caddyDockerfile, /COPY resources resources/);
|
|
147
167
|
} finally {
|
|
148
168
|
fs.rmSync(targetRoot, { recursive: true, force: true });
|
|
149
169
|
}
|
package/src/modules/i18n.mjs
CHANGED
|
@@ -166,6 +166,11 @@ function patchProxyDockerfile(filePath) {
|
|
|
166
166
|
'COPY packages/i18n-contracts packages/i18n-contracts',
|
|
167
167
|
'COPY packages/i18n-web packages/i18n-web',
|
|
168
168
|
);
|
|
169
|
+
content = ensureLineAfter(
|
|
170
|
+
content,
|
|
171
|
+
'COPY packages/i18n-web packages/i18n-web',
|
|
172
|
+
'COPY resources resources',
|
|
173
|
+
);
|
|
169
174
|
|
|
170
175
|
fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
|
|
171
176
|
}
|
|
@@ -232,6 +237,8 @@ function patchWebPackage(targetRoot) {
|
|
|
232
237
|
);
|
|
233
238
|
ensureDependency(packageJson, '@forgeon/i18n-contracts', 'workspace:*');
|
|
234
239
|
ensureDependency(packageJson, '@forgeon/i18n-web', 'workspace:*');
|
|
240
|
+
ensureDependency(packageJson, 'i18next', '^23.16.8');
|
|
241
|
+
ensureDependency(packageJson, 'react-i18next', '^15.1.2');
|
|
235
242
|
writeJson(packagePath, packageJson);
|
|
236
243
|
}
|
|
237
244
|
|
|
@@ -246,6 +253,17 @@ function patchI18nPackage(targetRoot) {
|
|
|
246
253
|
writeJson(packagePath, packageJson);
|
|
247
254
|
}
|
|
248
255
|
|
|
256
|
+
function patchRootPackage(targetRoot) {
|
|
257
|
+
const packagePath = path.join(targetRoot, 'package.json');
|
|
258
|
+
if (!fs.existsSync(packagePath)) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
|
263
|
+
ensureScript(packageJson, 'i18n:check', 'pnpm --filter @forgeon/i18n-contracts check:keys');
|
|
264
|
+
writeJson(packagePath, packageJson);
|
|
265
|
+
}
|
|
266
|
+
|
|
249
267
|
export function applyI18nModule({ packageRoot, targetRoot }) {
|
|
250
268
|
copyFromBase(packageRoot, targetRoot, path.join('packages', 'i18n'));
|
|
251
269
|
copyFromBase(packageRoot, targetRoot, path.join('resources', 'i18n'));
|
|
@@ -253,6 +271,8 @@ export function applyI18nModule({ packageRoot, targetRoot }) {
|
|
|
253
271
|
copyFromPreset(packageRoot, targetRoot, path.join('packages', 'i18n-contracts'));
|
|
254
272
|
copyFromPreset(packageRoot, targetRoot, path.join('packages', 'i18n-web'));
|
|
255
273
|
copyFromPreset(packageRoot, targetRoot, path.join('apps', 'web', 'src', 'App.tsx'));
|
|
274
|
+
copyFromPreset(packageRoot, targetRoot, path.join('apps', 'web', 'src', 'i18n.ts'));
|
|
275
|
+
copyFromPreset(packageRoot, targetRoot, path.join('apps', 'web', 'src', 'main.tsx'));
|
|
256
276
|
|
|
257
277
|
copyFromBase(packageRoot, targetRoot, path.join('apps', 'api', 'src', 'app.module.ts'));
|
|
258
278
|
copyFromBase(packageRoot, targetRoot, path.join('apps', 'api', 'src', 'config', 'app.config.ts'));
|
|
@@ -270,6 +290,7 @@ export function applyI18nModule({ packageRoot, targetRoot }) {
|
|
|
270
290
|
patchI18nPackage(targetRoot);
|
|
271
291
|
patchApiPackage(targetRoot);
|
|
272
292
|
patchWebPackage(targetRoot);
|
|
293
|
+
patchRootPackage(targetRoot);
|
|
273
294
|
patchApiDockerfile(targetRoot);
|
|
274
295
|
patchProxyDockerfiles(targetRoot);
|
|
275
296
|
|
package/src/presets/i18n.mjs
CHANGED
|
@@ -58,7 +58,8 @@ export function applyI18nDisabled(targetRoot) {
|
|
|
58
58
|
)
|
|
59
59
|
.replace(/^COPY packages\/i18n-web\/package\.json packages\/i18n-web\/package\.json\r?\n/gm, '')
|
|
60
60
|
.replace(/^COPY packages\/i18n-contracts packages\/i18n-contracts\r?\n/gm, '')
|
|
61
|
-
.replace(/^COPY packages\/i18n-web packages\/i18n-web\r?\n/gm, '')
|
|
61
|
+
.replace(/^COPY packages\/i18n-web packages\/i18n-web\r?\n/gm, '')
|
|
62
|
+
.replace(/^COPY resources resources\r?\n/gm, '');
|
|
62
63
|
|
|
63
64
|
fs.writeFileSync(dockerfilePath, content, 'utf8');
|
|
64
65
|
}
|
|
@@ -75,10 +76,21 @@ export function applyI18nDisabled(targetRoot) {
|
|
|
75
76
|
if (webPackage.dependencies) {
|
|
76
77
|
delete webPackage.dependencies['@forgeon/i18n-contracts'];
|
|
77
78
|
delete webPackage.dependencies['@forgeon/i18n-web'];
|
|
79
|
+
delete webPackage.dependencies.i18next;
|
|
80
|
+
delete webPackage.dependencies['react-i18next'];
|
|
78
81
|
}
|
|
79
82
|
|
|
80
83
|
writeJson(webPackagePath, webPackage);
|
|
81
84
|
}
|
|
85
|
+
|
|
86
|
+
const rootPackagePath = path.join(targetRoot, 'package.json');
|
|
87
|
+
if (fs.existsSync(rootPackagePath)) {
|
|
88
|
+
const rootPackage = JSON.parse(fs.readFileSync(rootPackagePath, 'utf8'));
|
|
89
|
+
if (rootPackage.scripts) {
|
|
90
|
+
delete rootPackage.scripts['i18n:check'];
|
|
91
|
+
}
|
|
92
|
+
writeJson(rootPackagePath, rootPackage);
|
|
93
|
+
}
|
|
82
94
|
|
|
83
95
|
const appModulePath = path.join(targetRoot, 'apps', 'api', 'src', 'app.module.ts');
|
|
84
96
|
fs.writeFileSync(
|
|
@@ -16,26 +16,18 @@ export class HealthController {
|
|
|
16
16
|
};
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
@Get('meta')
|
|
20
|
-
getMeta(@Query('lang') lang?: string) {
|
|
21
|
-
return {
|
|
22
|
-
checkApiHealth: this.translate('common.checkApiHealth', lang),
|
|
23
|
-
languageLabel: this.translate('common.language', lang),
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
|
|
27
19
|
@Get('echo')
|
|
28
20
|
getEcho(@Query() query: EchoQueryDto) {
|
|
29
21
|
return { value: query.value };
|
|
30
22
|
}
|
|
31
23
|
|
|
32
24
|
private translate(key: string, lang?: string): string {
|
|
33
|
-
if (!this.i18n) {
|
|
25
|
+
if (!this.i18n) {
|
|
34
26
|
if (key === 'common.ok') return 'OK';
|
|
35
27
|
if (key === 'common.checkApiHealth') return 'Check API health';
|
|
36
28
|
if (key === 'common.language') return 'Language';
|
|
37
|
-
if (key === 'languages.english') return 'English';
|
|
38
|
-
if (key === 'languages.ukrainian') return 'Ukrainian';
|
|
29
|
+
if (key === 'common.languages.english') return 'English';
|
|
30
|
+
if (key === 'common.languages.ukrainian') return 'Ukrainian';
|
|
39
31
|
return key;
|
|
40
32
|
}
|
|
41
33
|
|
|
@@ -52,6 +44,6 @@ export class HealthController {
|
|
|
52
44
|
}
|
|
53
45
|
|
|
54
46
|
private localeNameKey(locale: 'en' | 'uk'): string {
|
|
55
|
-
return locale === 'uk' ? 'languages.ukrainian' : 'languages.english';
|
|
47
|
+
return locale === 'uk' ? 'common.languages.ukrainian' : 'common.languages.english';
|
|
56
48
|
}
|
|
57
49
|
}
|
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
},
|
|
6
|
-
"common": {
|
|
7
|
-
"ok": "OK",
|
|
8
|
-
"checkApiHealth": "Check API health",
|
|
9
|
-
"language": "Language"
|
|
10
|
-
},
|
|
2
|
+
"ok": "OK",
|
|
3
|
+
"checkApiHealth": "Check API health",
|
|
4
|
+
"language": "Language",
|
|
11
5
|
"languages": {
|
|
12
6
|
"english": "English",
|
|
13
7
|
"ukrainian": "Ukrainian"
|
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
},
|
|
6
|
-
"common": {
|
|
7
|
-
"ok": "OK",
|
|
8
|
-
"checkApiHealth": "Перевірити API health",
|
|
9
|
-
"language": "Мова"
|
|
10
|
-
},
|
|
2
|
+
"ok": "OK",
|
|
3
|
+
"checkApiHealth": "Перевірити API health",
|
|
4
|
+
"language": "Мова",
|
|
11
5
|
"languages": {
|
|
12
6
|
"english": "Англійська",
|
|
13
7
|
"ukrainian": "Українська"
|
|
@@ -6,11 +6,16 @@ Environment keys:
|
|
|
6
6
|
- `I18N_FALLBACK_LANG=en`
|
|
7
7
|
|
|
8
8
|
Resources location: `resources/i18n`.
|
|
9
|
+
These dictionaries are shared by backend (`nestjs-i18n`) and frontend (`react-i18next`).
|
|
9
10
|
|
|
10
11
|
Packages:
|
|
11
12
|
- `@forgeon/i18n`
|
|
12
13
|
- `@forgeon/i18n-contracts`
|
|
13
14
|
- `@forgeon/i18n-web`
|
|
15
|
+
- `react-i18next`
|
|
16
|
+
|
|
17
|
+
Dictionary key validation:
|
|
18
|
+
- `pnpm i18n:check`
|
|
14
19
|
|
|
15
20
|
You can apply i18n later with:
|
|
16
21
|
`npx create-forgeon@latest add i18n --project .`
|
|
@@ -4,6 +4,7 @@ Adds optional i18n support across backend and frontend.
|
|
|
4
4
|
|
|
5
5
|
Included parts:
|
|
6
6
|
- `@forgeon/i18n` (NestJS i18n integration package)
|
|
7
|
-
- `@forgeon/i18n-contracts` (shared locale/query constants)
|
|
7
|
+
- `@forgeon/i18n-contracts` (shared locale/query constants and key checks)
|
|
8
8
|
- `@forgeon/i18n-web` (React-side locale helpers)
|
|
9
|
-
- `
|
|
9
|
+
- `react-i18next` integration for frontend translations
|
|
10
|
+
- shared dictionaries in `resources/i18n/*` (`en`, `uk`) used by both API and web
|
|
@@ -3,5 +3,7 @@
|
|
|
3
3
|
- API module wiring (`AppModule`, config, filter, health endpoint translation)
|
|
4
4
|
- API package/deps/scripts updates
|
|
5
5
|
- Docker env + compose i18n env keys
|
|
6
|
-
- Frontend
|
|
6
|
+
- Frontend `react-i18next` setup (`apps/web/src/i18n.ts`)
|
|
7
|
+
- Frontend language selector and translated UI in `apps/web/src/App.tsx`
|
|
7
8
|
- Frontend package/deps/scripts updates for i18n helpers
|
|
9
|
+
- Root script `i18n:check` for dictionary key consistency across locales
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import i18n from './i18n';
|
|
2
4
|
import * as i18nWeb from '@forgeon/i18n-web';
|
|
3
5
|
import type { I18nLocale } from '@forgeon/i18n-web';
|
|
4
6
|
import './styles.css';
|
|
@@ -9,47 +11,23 @@ type HealthResponse = {
|
|
|
9
11
|
i18n: string;
|
|
10
12
|
};
|
|
11
13
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
};
|
|
14
|
+
function localeLabelKey(locale: I18nLocale): string {
|
|
15
|
+
return locale === 'uk' ? 'common:languages.ukrainian' : 'common:languages.english';
|
|
16
|
+
}
|
|
16
17
|
|
|
17
18
|
export default function App() {
|
|
19
|
+
const { t } = useTranslation(['common']);
|
|
18
20
|
const { I18N_LOCALES, getInitialLocale, persistLocale, toLangQuery } = i18nWeb;
|
|
19
21
|
const [locale, setLocale] = useState<I18nLocale>(getInitialLocale);
|
|
20
22
|
const [data, setData] = useState<HealthResponse | null>(null);
|
|
21
23
|
const [error, setError] = useState<string | null>(null);
|
|
22
|
-
const [labels, setLabels] = useState<HealthMetaResponse>({
|
|
23
|
-
checkApiHealth: 'Check API health',
|
|
24
|
-
languageLabel: 'Language',
|
|
25
|
-
});
|
|
26
24
|
|
|
27
25
|
const changeLocale = (nextLocale: I18nLocale) => {
|
|
28
26
|
setLocale(nextLocale);
|
|
29
27
|
persistLocale(nextLocale);
|
|
28
|
+
void i18n.changeLanguage(nextLocale);
|
|
30
29
|
};
|
|
31
30
|
|
|
32
|
-
useEffect(() => {
|
|
33
|
-
const loadLabels = async () => {
|
|
34
|
-
try {
|
|
35
|
-
const response = await fetch(`/api/health/meta${toLangQuery(locale)}`, {
|
|
36
|
-
headers: {
|
|
37
|
-
'Accept-Language': locale,
|
|
38
|
-
},
|
|
39
|
-
});
|
|
40
|
-
if (!response.ok) {
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
const payload = (await response.json()) as HealthMetaResponse;
|
|
44
|
-
setLabels(payload);
|
|
45
|
-
} catch {
|
|
46
|
-
// Keep fallback labels if meta request fails.
|
|
47
|
-
}
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
void loadLabels();
|
|
51
|
-
}, [locale, toLangQuery]);
|
|
52
|
-
|
|
53
31
|
const checkApi = async () => {
|
|
54
32
|
setError(null);
|
|
55
33
|
try {
|
|
@@ -72,7 +50,7 @@ export default function App() {
|
|
|
72
50
|
<main className="page">
|
|
73
51
|
<h1>Forgeon Fullstack Scaffold</h1>
|
|
74
52
|
<p>Default frontend preset: React + Vite + TypeScript.</p>
|
|
75
|
-
<label htmlFor="language">{
|
|
53
|
+
<label htmlFor="language">{t('common:language')}:</label>
|
|
76
54
|
<select
|
|
77
55
|
id="language"
|
|
78
56
|
value={locale}
|
|
@@ -80,11 +58,11 @@ export default function App() {
|
|
|
80
58
|
>
|
|
81
59
|
{I18N_LOCALES.map((item) => (
|
|
82
60
|
<option key={item} value={item}>
|
|
83
|
-
{item}
|
|
61
|
+
{t(localeLabelKey(item))}
|
|
84
62
|
</option>
|
|
85
63
|
))}
|
|
86
64
|
</select>
|
|
87
|
-
<button onClick={checkApi}>{
|
|
65
|
+
<button onClick={checkApi}>{t('common:checkApiHealth')}</button>
|
|
88
66
|
{data ? <pre>{JSON.stringify(data, null, 2)}</pre> : null}
|
|
89
67
|
{error ? <p className="error">{error}</p> : null}
|
|
90
68
|
</main>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import i18n from 'i18next';
|
|
2
|
+
import { initReactI18next } from 'react-i18next';
|
|
3
|
+
import { I18N_DEFAULT_LANG } from '@forgeon/i18n-contracts';
|
|
4
|
+
import { getInitialLocale } from '@forgeon/i18n-web';
|
|
5
|
+
import enCommon from '../../../../resources/i18n/en/common.json';
|
|
6
|
+
import enErrors from '../../../../resources/i18n/en/errors.json';
|
|
7
|
+
import enValidation from '../../../../resources/i18n/en/validation.json';
|
|
8
|
+
import ukCommon from '../../../../resources/i18n/uk/common.json';
|
|
9
|
+
import ukErrors from '../../../../resources/i18n/uk/errors.json';
|
|
10
|
+
import ukValidation from '../../../../resources/i18n/uk/validation.json';
|
|
11
|
+
|
|
12
|
+
const resources = {
|
|
13
|
+
en: {
|
|
14
|
+
common: enCommon,
|
|
15
|
+
errors: enErrors,
|
|
16
|
+
validation: enValidation,
|
|
17
|
+
},
|
|
18
|
+
uk: {
|
|
19
|
+
common: ukCommon,
|
|
20
|
+
errors: ukErrors,
|
|
21
|
+
validation: ukValidation,
|
|
22
|
+
},
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
void i18n.use(initReactI18next).init({
|
|
26
|
+
resources,
|
|
27
|
+
lng: getInitialLocale(),
|
|
28
|
+
fallbackLng: I18N_DEFAULT_LANG,
|
|
29
|
+
interpolation: {
|
|
30
|
+
escapeValue: false,
|
|
31
|
+
},
|
|
32
|
+
defaultNS: 'common',
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export default i18n;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
6
|
+
const __dirname = path.dirname(__filename);
|
|
7
|
+
const projectRoot = path.resolve(__dirname, '..', '..', '..');
|
|
8
|
+
const resourcesRoot = path.join(projectRoot, 'resources', 'i18n');
|
|
9
|
+
const baseLocale = 'en';
|
|
10
|
+
|
|
11
|
+
function readJson(filePath) {
|
|
12
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function flattenKeys(value, prefix = '') {
|
|
16
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
17
|
+
return prefix ? [prefix] : [];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const entries = Object.entries(value);
|
|
21
|
+
if (entries.length === 0) {
|
|
22
|
+
return prefix ? [prefix] : [];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const result = [];
|
|
26
|
+
for (const [key, nested] of entries) {
|
|
27
|
+
const nextPrefix = prefix ? `${prefix}.${key}` : key;
|
|
28
|
+
result.push(...flattenKeys(nested, nextPrefix));
|
|
29
|
+
}
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function sorted(array) {
|
|
34
|
+
return [...array].sort((a, b) => a.localeCompare(b));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function main() {
|
|
38
|
+
if (!fs.existsSync(resourcesRoot)) {
|
|
39
|
+
console.error(`Missing resources folder: ${resourcesRoot}`);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const localeDirs = fs
|
|
44
|
+
.readdirSync(resourcesRoot, { withFileTypes: true })
|
|
45
|
+
.filter((entry) => entry.isDirectory())
|
|
46
|
+
.map((entry) => entry.name);
|
|
47
|
+
|
|
48
|
+
if (!localeDirs.includes(baseLocale)) {
|
|
49
|
+
console.error(`Missing base locale "${baseLocale}" in ${resourcesRoot}`);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const baseLocaleDir = path.join(resourcesRoot, baseLocale);
|
|
54
|
+
const baseFiles = fs
|
|
55
|
+
.readdirSync(baseLocaleDir)
|
|
56
|
+
.filter((name) => name.endsWith('.json'));
|
|
57
|
+
|
|
58
|
+
const errors = [];
|
|
59
|
+
|
|
60
|
+
for (const locale of localeDirs) {
|
|
61
|
+
const localeDir = path.join(resourcesRoot, locale);
|
|
62
|
+
for (const fileName of baseFiles) {
|
|
63
|
+
const baseFilePath = path.join(baseLocaleDir, fileName);
|
|
64
|
+
const localeFilePath = path.join(localeDir, fileName);
|
|
65
|
+
|
|
66
|
+
if (!fs.existsSync(localeFilePath)) {
|
|
67
|
+
errors.push(`[${locale}] missing file: ${fileName}`);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const baseKeys = sorted(flattenKeys(readJson(baseFilePath)));
|
|
72
|
+
const localeKeys = sorted(flattenKeys(readJson(localeFilePath)));
|
|
73
|
+
|
|
74
|
+
const missingKeys = baseKeys.filter((key) => !localeKeys.includes(key));
|
|
75
|
+
const extraKeys = localeKeys.filter((key) => !baseKeys.includes(key));
|
|
76
|
+
|
|
77
|
+
for (const key of missingKeys) {
|
|
78
|
+
errors.push(`[${locale}] missing key in ${fileName}: ${key}`);
|
|
79
|
+
}
|
|
80
|
+
for (const key of extraKeys) {
|
|
81
|
+
errors.push(`[${locale}] extra key in ${fileName}: ${key}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (errors.length > 0) {
|
|
87
|
+
console.error('i18n key check failed:');
|
|
88
|
+
for (const error of errors) {
|
|
89
|
+
console.error(`- ${error}`);
|
|
90
|
+
}
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
console.log('i18n key check passed.');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
main();
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export const I18N_LOCALES = ['en', 'uk'] as const;
|
|
2
|
+
export const I18N_NAMESPACES = ['common', 'errors', 'validation'] as const;
|
|
2
3
|
|
|
3
4
|
export type I18nLocale = (typeof I18N_LOCALES)[number];
|
|
5
|
+
export type I18nNamespace = (typeof I18N_NAMESPACES)[number];
|
|
4
6
|
|
|
5
7
|
export const I18N_DEFAULT_LANG: I18nLocale = 'en';
|
|
6
8
|
export const I18N_FALLBACK_LANG: I18nLocale = 'en';
|