create-forgeon 0.1.26 → 0.1.27

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.26",
3
+ "version": "0.1.27",
4
4
  "description": "Forgeon project generator CLI",
5
5
  "license": "MIT",
6
6
  "author": "Forgeon",
@@ -208,6 +208,10 @@ describe('addModule', () => {
208
208
  assert.match(rootPackage, /"i18n:sync"/);
209
209
  assert.match(rootPackage, /"i18n:check"/);
210
210
  assert.match(rootPackage, /"i18n:types"/);
211
+ assert.match(rootPackage, /"i18n:add"/);
212
+
213
+ const i18nAddScriptPath = path.join(projectRoot, 'scripts', 'i18n-add.mjs');
214
+ assert.equal(fs.existsSync(i18nAddScriptPath), true);
211
215
 
212
216
  const caddyDockerfile = fs.readFileSync(
213
217
  path.join(projectRoot, 'infra', 'docker', 'caddy.Dockerfile'),
@@ -311,10 +311,12 @@ function patchRootPackage(targetRoot) {
311
311
  'i18n:types',
312
312
  'pnpm --filter @forgeon/i18n-contracts i18n:types',
313
313
  );
314
+ ensureScript(packageJson, 'i18n:add', 'node scripts/i18n-add.mjs');
314
315
  writeJson(packagePath, packageJson);
315
316
  }
316
317
 
317
318
  export function applyI18nModule({ packageRoot, targetRoot }) {
319
+ copyFromBase(packageRoot, targetRoot, path.join('scripts', 'i18n-add.mjs'));
318
320
  copyFromBase(packageRoot, targetRoot, path.join('packages', 'db-prisma'));
319
321
  copyFromBase(packageRoot, targetRoot, path.join('packages', 'i18n'));
320
322
  copyFromBase(packageRoot, targetRoot, path.join('resources', 'i18n'));
@@ -102,6 +102,7 @@ export function applyI18nDisabled(targetRoot) {
102
102
  delete rootPackage.scripts['i18n:sync'];
103
103
  delete rootPackage.scripts['i18n:check'];
104
104
  delete rootPackage.scripts['i18n:types'];
105
+ delete rootPackage.scripts['i18n:add'];
105
106
  }
106
107
  writeJson(rootPackagePath, rootPackage);
107
108
  }
@@ -144,21 +145,19 @@ export class AppModule {}
144
145
  );
145
146
  fs.writeFileSync(
146
147
  healthControllerPath,
147
- `import { ConflictException, Controller, Get, Post, Query } from '@nestjs/common';
148
+ `import { BadRequestException, ConflictException, Controller, Get, Post, Query } from '@nestjs/common';
148
149
  import { PrismaService } from '@forgeon/db-prisma';
149
- import { EchoQueryDto } from '../common/dto/echo-query.dto';
150
150
 
151
151
  @Controller('health')
152
152
  export class HealthController {
153
153
  constructor(private readonly prisma: PrismaService) {}
154
154
 
155
155
  @Get()
156
- getHealth(@Query('lang') lang?: string) {
157
- const locale = this.resolveLocale(lang);
156
+ getHealth(@Query('lang') _lang?: string) {
158
157
  return {
159
158
  status: 'ok',
160
159
  message: 'OK',
161
- i18n: locale === 'uk' ? 'Ukrainian' : 'English',
160
+ i18n: 'English',
162
161
  };
163
162
  }
164
163
 
@@ -168,17 +167,25 @@ export class HealthController {
168
167
  message: 'Email already exists',
169
168
  details: {
170
169
  feature: 'core-errors',
171
- probe: 'health.error',
170
+ probeId: 'health.error',
171
+ probe: 'Error envelope probe',
172
172
  },
173
173
  });
174
174
  }
175
175
 
176
176
  @Get('validation')
177
- getValidationProbe(@Query() query: EchoQueryDto) {
177
+ getValidationProbe(@Query('value') value?: string) {
178
+ if (!value || value.trim().length === 0) {
179
+ throw new BadRequestException({
180
+ message: 'Field is required',
181
+ details: [{ field: 'value', message: 'Field is required' }],
182
+ });
183
+ }
184
+
178
185
  return {
179
186
  status: 'ok',
180
187
  validated: true,
181
- value: query.value,
188
+ value,
182
189
  };
183
190
  }
184
191
 
@@ -197,11 +204,6 @@ export class HealthController {
197
204
  user,
198
205
  };
199
206
  }
200
-
201
- private resolveLocale(lang?: string): 'en' | 'uk' {
202
- const normalized = (lang ?? '').toLowerCase();
203
- return normalized.startsWith('uk') ? 'uk' : 'en';
204
- }
205
207
  }
206
208
  `,
207
209
  'utf8',
@@ -1,42 +1,49 @@
1
- import { ConflictException, Controller, Get, Optional, Post, Query } from '@nestjs/common';
1
+ import { BadRequestException, ConflictException, Controller, Get, Post, Query } from '@nestjs/common';
2
2
  import { PrismaService } from '@forgeon/db-prisma';
3
3
  import { I18nService } from 'nestjs-i18n';
4
- import { EchoQueryDto } from '../common/dto/echo-query.dto';
5
4
 
6
5
  @Controller('health')
7
6
  export class HealthController {
8
7
  constructor(
9
8
  private readonly prisma: PrismaService,
10
- @Optional() private readonly i18n?: I18nService,
9
+ private readonly i18n: I18nService,
11
10
  ) {}
12
11
 
13
12
  @Get()
14
13
  getHealth(@Query('lang') lang?: string) {
15
- const locale = this.resolveLocale(lang);
16
14
  return {
17
15
  status: 'ok',
18
16
  message: this.translate('common.ok', lang),
19
- i18n: this.translate(this.localeNameKey(locale), lang),
17
+ i18n: this.translate('common.languages.english', lang),
20
18
  };
21
19
  }
22
20
 
23
21
  @Get('error')
24
- getErrorProbe() {
22
+ getErrorProbe(@Query('lang') lang?: string) {
25
23
  throw new ConflictException({
26
- message: 'Email already exists',
24
+ message: this.translate('errors.emailAlreadyExists', lang),
27
25
  details: {
28
26
  feature: 'core-errors',
29
- probe: 'health.error',
27
+ probeId: 'health.error',
28
+ probe: this.translate('common.probes.error', lang),
30
29
  },
31
30
  });
32
31
  }
33
32
 
34
33
  @Get('validation')
35
- getValidationProbe(@Query() query: EchoQueryDto) {
34
+ getValidationProbe(@Query('value') value?: string, @Query('lang') lang?: string) {
35
+ if (!value || value.trim().length === 0) {
36
+ const translatedMessage = this.translate('validation.required', lang);
37
+ throw new BadRequestException({
38
+ message: translatedMessage,
39
+ details: [{ field: 'value', message: translatedMessage }],
40
+ });
41
+ }
42
+
36
43
  return {
37
44
  status: 'ok',
38
45
  validated: true,
39
- value: query.value,
46
+ value,
40
47
  };
41
48
  }
42
49
 
@@ -57,26 +64,7 @@ export class HealthController {
57
64
  }
58
65
 
59
66
  private translate(key: string, lang?: string): string {
60
- if (!this.i18n) {
61
- if (key === 'common.ok') return 'OK';
62
- if (key === 'common.languages.english') return 'English';
63
- if (key === 'common.languages.ukrainian') return 'Ukrainian';
64
- return key;
65
- }
66
-
67
67
  const value = this.i18n.t(key, { lang, defaultValue: key });
68
68
  return typeof value === 'string' ? value : key;
69
69
  }
70
-
71
- private resolveLocale(lang?: string): 'en' | 'uk' {
72
- const normalized = (lang ?? '').toLowerCase();
73
- if (normalized.startsWith('uk')) {
74
- return 'uk';
75
- }
76
- return 'en';
77
- }
78
-
79
- private localeNameKey(locale: 'en' | 'uk'): string {
80
- return locale === 'uk' ? 'common.languages.ukrainian' : 'common.languages.english';
81
- }
82
70
  }
@@ -6,3 +6,12 @@
6
6
  - `AI/MODULE_CHECKS.md` - required runtime probe hooks for modules
7
7
  - `AI/VALIDATION.md` - DTO/env validation standards
8
8
  - `AI/TASKS.md` - ready-to-use Codex prompts
9
+
10
+ ## i18n Language Workflow
11
+
12
+ Add a new language from existing namespaces:
13
+ - `pnpm i18n:add uk`
14
+
15
+ Useful follow-up commands:
16
+ - `pnpm i18n:sync`
17
+ - `pnpm i18n:check`
@@ -5,8 +5,10 @@
5
5
  "checkValidation": "Check validation (expect 400)",
6
6
  "checkDatabase": "Check database (create user)",
7
7
  "language": "Language",
8
+ "probes": {
9
+ "error": "Error envelope probe"
10
+ },
8
11
  "languages": {
9
- "english": "English",
10
- "ukrainian": "Ukrainian"
12
+ "english": "English"
11
13
  }
12
14
  }
@@ -1,4 +1,5 @@
1
1
  {
2
2
  "accessDenied": "Access denied",
3
- "notFound": "Resource not found"
3
+ "notFound": "Resource not found",
4
+ "emailAlreadyExists": "Email already exists"
4
5
  }
@@ -0,0 +1,188 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { promises as fs } from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ const LOCALE_REGEX = /^[a-z]{2}(-[A-Z]{2})?$/;
6
+
7
+ function printUsage() {
8
+ console.log('Usage: pnpm i18n:add <locale> [--copy-from=en] [--force] [--empty] [--no-sync]');
9
+ }
10
+
11
+ function parseArgs(argv) {
12
+ const options = {
13
+ locale: '',
14
+ copyFrom: 'en',
15
+ force: false,
16
+ empty: false,
17
+ noSync: false,
18
+ };
19
+
20
+ for (let index = 0; index < argv.length; index += 1) {
21
+ const arg = argv[index];
22
+ if (arg === '--force') {
23
+ options.force = true;
24
+ continue;
25
+ }
26
+ if (arg === '--empty') {
27
+ options.empty = true;
28
+ continue;
29
+ }
30
+ if (arg === '--no-sync') {
31
+ options.noSync = true;
32
+ continue;
33
+ }
34
+ if (arg.startsWith('--copy-from=')) {
35
+ options.copyFrom = arg.slice('--copy-from='.length).trim();
36
+ continue;
37
+ }
38
+ if (arg === '--copy-from') {
39
+ const next = argv[index + 1];
40
+ if (!next || next.startsWith('-')) {
41
+ throw new Error('Missing value for --copy-from.');
42
+ }
43
+ options.copyFrom = next.trim();
44
+ index += 1;
45
+ continue;
46
+ }
47
+ if (arg === '--help' || arg === '-h') {
48
+ options.help = true;
49
+ continue;
50
+ }
51
+ if (arg.startsWith('-')) {
52
+ throw new Error(`Unknown option: ${arg}`);
53
+ }
54
+ if (options.locale) {
55
+ throw new Error(`Unexpected positional argument: ${arg}`);
56
+ }
57
+ options.locale = arg.trim();
58
+ }
59
+
60
+ return options;
61
+ }
62
+
63
+ function validateLocaleOrThrow(value, label) {
64
+ if (!LOCALE_REGEX.test(value)) {
65
+ throw new Error(`${label} must match ${LOCALE_REGEX}. Received: "${value}"`);
66
+ }
67
+ }
68
+
69
+ async function pathExists(targetPath) {
70
+ try {
71
+ await fs.access(targetPath);
72
+ return true;
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ async function listJsonFiles(folderPath) {
79
+ const entries = await fs.readdir(folderPath, { withFileTypes: true });
80
+ return entries
81
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
82
+ .map((entry) => entry.name)
83
+ .sort((left, right) => left.localeCompare(right));
84
+ }
85
+
86
+ function runSyncCommand(cwd) {
87
+ const command = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
88
+ return new Promise((resolve, reject) => {
89
+ const child = spawn(command, ['i18n:sync'], { cwd, stdio: 'inherit' });
90
+ child.on('error', (error) => reject(error));
91
+ child.on('exit', (code) => {
92
+ if (code === 0) {
93
+ resolve();
94
+ return;
95
+ }
96
+ reject(new Error(`"pnpm i18n:sync" failed with exit code ${code ?? 'unknown'}.`));
97
+ });
98
+ });
99
+ }
100
+
101
+ async function main() {
102
+ const options = parseArgs(process.argv.slice(2));
103
+ if (options.help) {
104
+ printUsage();
105
+ return;
106
+ }
107
+
108
+ if (!options.locale) {
109
+ throw new Error('Locale is required.');
110
+ }
111
+ validateLocaleOrThrow(options.locale, 'Locale');
112
+ validateLocaleOrThrow(options.copyFrom, 'copy-from');
113
+
114
+ if (options.locale === options.copyFrom) {
115
+ throw new Error('Locale must be different from --copy-from.');
116
+ }
117
+
118
+ const root = process.cwd();
119
+ const resourcesRoot = path.join(root, 'resources', 'i18n');
120
+ const sourceDir = path.join(resourcesRoot, options.copyFrom);
121
+ const targetDir = path.join(resourcesRoot, options.locale);
122
+
123
+ if (!(await pathExists(sourceDir))) {
124
+ throw new Error(`Source locale folder not found: ${sourceDir}`);
125
+ }
126
+
127
+ const namespaceFiles = await listJsonFiles(sourceDir);
128
+ if (namespaceFiles.length === 0) {
129
+ throw new Error(`Source locale folder has no namespace JSON files: ${sourceDir}`);
130
+ }
131
+
132
+ const targetExisted = await pathExists(targetDir);
133
+ await fs.mkdir(targetDir, { recursive: true });
134
+
135
+ const conflictingFiles = [];
136
+ for (const fileName of namespaceFiles) {
137
+ const destinationPath = path.join(targetDir, fileName);
138
+ if (await pathExists(destinationPath)) {
139
+ conflictingFiles.push(fileName);
140
+ }
141
+ }
142
+
143
+ if (conflictingFiles.length > 0 && !options.force) {
144
+ throw new Error(
145
+ `Target locale already has existing files: ${conflictingFiles.join(', ')}. Use --force to overwrite.`,
146
+ );
147
+ }
148
+
149
+ const createdFiles = [];
150
+ for (const fileName of namespaceFiles) {
151
+ const sourcePath = path.join(sourceDir, fileName);
152
+ const destinationPath = path.join(targetDir, fileName);
153
+ let content = '{}\n';
154
+
155
+ if (!options.empty) {
156
+ content = await fs.readFile(sourcePath, 'utf8');
157
+ if (!content.endsWith('\n')) {
158
+ content = `${content}\n`;
159
+ }
160
+ }
161
+
162
+ await fs.writeFile(destinationPath, content, 'utf8');
163
+ createdFiles.push(fileName);
164
+ }
165
+
166
+ let syncExecuted = false;
167
+ if (!options.noSync) {
168
+ await runSyncCommand(root);
169
+ syncExecuted = true;
170
+ }
171
+
172
+ console.log('i18n locale added successfully.');
173
+ console.log(`- locale: ${options.locale}`);
174
+ console.log(`- copy-from: ${options.copyFrom}`);
175
+ console.log(`- folder: ${path.join('resources', 'i18n', options.locale)} (${targetExisted ? 'existing' : 'created'})`);
176
+ console.log(`- files: ${createdFiles.length} (${options.empty ? 'empty {}' : 'copied'})`);
177
+ for (const fileName of createdFiles) {
178
+ console.log(` - ${fileName}`);
179
+ }
180
+ console.log(`- sync: ${syncExecuted ? 'pnpm i18n:sync executed' : 'skipped (--no-sync)'}`);
181
+ }
182
+
183
+ main().catch((error) => {
184
+ console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
185
+ printUsage();
186
+ process.exitCode = 1;
187
+ });
188
+
@@ -22,5 +22,9 @@ Locale/namespace contracts sync:
22
22
  Translation key types generation (manual):
23
23
  - `pnpm i18n:types`
24
24
 
25
+ Add a new language folder:
26
+ - `pnpm i18n:add uk`
27
+ - optional flags: `--copy-from=en` `--empty` `--force` `--no-sync`
28
+
25
29
  You can apply i18n later with:
26
30
  `npx create-forgeon@latest add i18n --project .`
@@ -7,9 +7,10 @@ Included parts:
7
7
  - `@forgeon/i18n-contracts` (generated locale/namespace contracts + checks/types scripts)
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
10
+ - shared dictionaries in `resources/i18n/*` (`en` by default) used by both API and web
11
11
 
12
12
  Utility commands:
13
13
  - `pnpm i18n:sync` - regenerate `I18N_LOCALES` and `I18N_NAMESPACES` from `resources/i18n`.
14
14
  - `pnpm i18n:check` - verify generated contracts, JSON validity, and missing/extra keys vs fallback locale.
15
15
  - `pnpm i18n:types` - generate translation key type unions for autocomplete.
16
+ - `pnpm i18n:add <locale>` - create `resources/i18n/<locale>` from `--copy-from` namespace files.
@@ -10,10 +10,6 @@ type ProbeResult = {
10
10
  body: unknown;
11
11
  };
12
12
 
13
- function localeLabelKey(locale: I18nLocale): string {
14
- return locale === 'uk' ? 'common:languages.ukrainian' : 'common:languages.english';
15
- }
16
-
17
13
  export default function App() {
18
14
  const { t } = useTranslation(['common']);
19
15
  const { I18N_LOCALES, getInitialLocale, persistLocale, toLangQuery } = i18nWeb;
@@ -85,7 +81,7 @@ export default function App() {
85
81
  >
86
82
  {I18N_LOCALES.map((item) => (
87
83
  <option key={item} value={item}>
88
- {t(localeLabelKey(item))}
84
+ {t(`common:languages.${item}`, { defaultValue: item })}
89
85
  </option>
90
86
  ))}
91
87
  </select>
@@ -4,9 +4,6 @@ import { getInitialLocale, I18N_LOCALES, type I18nLocale } from '@forgeon/i18n-w
4
4
  import enCommon from '../../../resources/i18n/en/common.json';
5
5
  import enErrors from '../../../resources/i18n/en/errors.json';
6
6
  import enValidation from '../../../resources/i18n/en/validation.json';
7
- import ukCommon from '../../../resources/i18n/uk/common.json';
8
- import ukErrors from '../../../resources/i18n/uk/errors.json';
9
- import ukValidation from '../../../resources/i18n/uk/validation.json';
10
7
 
11
8
  const resources = {
12
9
  en: {
@@ -14,11 +11,6 @@ const resources = {
14
11
  errors: enErrors,
15
12
  validation: enValidation,
16
13
  },
17
- uk: {
18
- common: ukCommon,
19
- errors: ukErrors,
20
- validation: ukValidation,
21
- },
22
14
  } as const;
23
15
 
24
16
  const fallbackLocale = (I18N_LOCALES[0] ?? 'en') as I18nLocale;
@@ -2,10 +2,14 @@
2
2
 
3
3
  export type I18nTranslationKey =
4
4
  | "common.checkApiHealth"
5
+ | "common.checkDatabase"
6
+ | "common.checkErrorEnvelope"
7
+ | "common.checkValidation"
5
8
  | "common.language"
6
9
  | "common.languages.english"
7
- | "common.languages.ukrainian"
8
10
  | "common.ok"
11
+ | "common.probes.error"
9
12
  | "errors.accessDenied"
13
+ | "errors.emailAlreadyExists"
10
14
  | "errors.notFound"
11
15
  | "validation.required";
@@ -1,6 +1,6 @@
1
1
  /* AUTO-GENERATED BY `pnpm i18n:sync`. DO NOT EDIT MANUALLY. */
2
2
 
3
- export const I18N_LOCALES = ["en", "uk"] as const;
3
+ export const I18N_LOCALES = ["en"] as const;
4
4
  export const I18N_NAMESPACES = ["common", "errors", "validation"] as const;
5
5
 
6
6
  export type I18nLocale = (typeof I18N_LOCALES)[number];
@@ -1,12 +0,0 @@
1
- {
2
- "ok": "OK",
3
- "checkApiHealth": "Перевірити API health",
4
- "checkErrorEnvelope": "Перевірити error envelope",
5
- "checkValidation": "Перевірити валідацію (очікуємо 400)",
6
- "checkDatabase": "Перевірити базу даних (створити користувача)",
7
- "language": "Мова",
8
- "languages": {
9
- "english": "Англійська",
10
- "ukrainian": "Українська"
11
- }
12
- }
@@ -1,4 +0,0 @@
1
- {
2
- "accessDenied": "Доступ заборонено",
3
- "notFound": "Ресурс не знайдено"
4
- }
@@ -1,3 +0,0 @@
1
- {
2
- "required": "Поле є обов'язковим"
3
- }