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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-forgeon",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Forgeon project generator CLI",
5
5
  "license": "MIT",
6
6
  "author": "Forgeon",
@@ -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, /\/api\/health\/meta/);
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\/src\/index/);
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.common.checkApiHealth, 'Check API health');
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
  }
@@ -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
 
@@ -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
- "errors": {
3
- "accessDenied": "Access denied",
4
- "notFound": "Resource not found"
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"
@@ -0,0 +1,4 @@
1
+ {
2
+ "accessDenied": "Access denied",
3
+ "notFound": "Resource not found"
4
+ }
@@ -1,5 +1,3 @@
1
1
  {
2
- "validation": {
3
- "required": "Field is required"
4
- }
2
+ "required": "Field is required"
5
3
  }
@@ -1,13 +1,7 @@
1
1
  {
2
- "errors": {
3
- "accessDenied": "Доступ заборонено",
4
- "notFound": "Ресурс не знайдено"
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": "Українська"
@@ -0,0 +1,4 @@
1
+ {
2
+ "accessDenied": "Доступ заборонено",
3
+ "notFound": "Ресурс не знайдено"
4
+ }
@@ -1,5 +1,3 @@
1
1
  {
2
- "validation": {
3
- "required": "Поле є обов'язковим"
4
- }
2
+ "required": "Поле є обов'язковим"
5
3
  }
@@ -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
- - `resources/i18n/*` dictionaries (`en`, `uk`)
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 language selector in `apps/web/src/App.tsx`
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 { useEffect, useState } from 'react';
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
- type HealthMetaResponse = {
13
- checkApiHealth: string;
14
- languageLabel: string;
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">{labels.languageLabel}:</label>
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}>{labels.checkApiHealth}</button>
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,10 @@
1
+ import React from 'react';
2
+ import ReactDOM from 'react-dom/client';
3
+ import './i18n';
4
+ import App from './App';
5
+
6
+ ReactDOM.createRoot(document.getElementById('root')!).render(
7
+ <React.StrictMode>
8
+ <App />
9
+ </React.StrictMode>,
10
+ );
@@ -5,7 +5,8 @@
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "scripts": {
8
- "build": "tsc -p tsconfig.json"
8
+ "build": "tsc -p tsconfig.json",
9
+ "check:keys": "node scripts/check-keys.mjs"
9
10
  },
10
11
  "devDependencies": {
11
12
  "@types/node": "^22.10.7",
@@ -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';
@@ -3,7 +3,7 @@ import {
3
3
  I18N_LOCALES,
4
4
  LANG_QUERY_PARAM,
5
5
  type I18nLocale,
6
- } from '@forgeon/i18n-contracts/src/index';
6
+ } from '@forgeon/i18n-contracts';
7
7
 
8
8
  const LOCALE_STORAGE_KEY = 'forgeon.locale';
9
9