create-forgeon 0.1.26 → 0.1.29
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 +10 -4
- package/src/modules/i18n.mjs +2 -0
- package/src/presets/i18n.mjs +15 -13
- package/templates/base/apps/api/src/common/dto/echo-query.dto.ts +4 -4
- package/templates/base/apps/api/src/health/health.controller.ts +18 -30
- package/templates/base/docs/AI/MODULE_SPEC.md +2 -0
- package/templates/base/docs/AI/ROADMAP.md +171 -0
- package/templates/base/docs/AI/TASKS.md +1 -0
- package/templates/base/docs/README.md +10 -0
- package/templates/base/packages/core/src/errors/core-exception.filter.ts +18 -6
- package/templates/base/resources/i18n/en/common.json +39 -9
- package/templates/base/resources/i18n/en/errors.json +31 -2
- package/templates/base/resources/i18n/en/meta.json +8 -0
- package/templates/base/resources/i18n/en/notifications.json +21 -0
- package/templates/base/resources/i18n/en/ui.json +31 -0
- package/templates/base/resources/i18n/en/validation.json +30 -1
- package/templates/base/scripts/i18n-add.mjs +191 -0
- package/templates/docs-fragments/README/40_i18n.md +5 -0
- package/templates/module-fragments/i18n/10_overview.md +3 -1
- package/templates/module-presets/i18n/apps/web/src/App.tsx +7 -11
- package/templates/module-presets/i18n/apps/web/src/i18n.ts +6 -8
- package/templates/module-presets/i18n/packages/i18n-contracts/src/generated-keys.d.ts +97 -8
- package/templates/module-presets/i18n/packages/i18n-contracts/src/generated.ts +9 -2
- package/templates/base/resources/i18n/uk/common.json +0 -12
- package/templates/base/resources/i18n/uk/errors.json +0 -4
- package/templates/base/resources/i18n/uk/validation.json +0 -3
package/package.json
CHANGED
|
@@ -140,7 +140,7 @@ describe('addModule', () => {
|
|
|
140
140
|
const appTsx = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'App.tsx'), 'utf8');
|
|
141
141
|
assert.match(appTsx, /@forgeon\/i18n-web/);
|
|
142
142
|
assert.match(appTsx, /react-i18next/);
|
|
143
|
-
assert.match(appTsx, /
|
|
143
|
+
assert.match(appTsx, /ui:labels\.language/);
|
|
144
144
|
|
|
145
145
|
const i18nWebPackage = fs.readFileSync(
|
|
146
146
|
path.join(projectRoot, 'packages', 'i18n-web', 'package.json'),
|
|
@@ -184,13 +184,14 @@ describe('addModule', () => {
|
|
|
184
184
|
const enCommon = JSON.parse(
|
|
185
185
|
fs.readFileSync(path.join(projectRoot, 'resources', 'i18n', 'en', 'common.json'), 'utf8'),
|
|
186
186
|
);
|
|
187
|
-
assert.equal(enCommon.
|
|
188
|
-
assert.equal(enCommon.
|
|
187
|
+
assert.equal(enCommon.actions.ok, 'OK');
|
|
188
|
+
assert.equal(enCommon.nav.next, 'Next');
|
|
189
189
|
|
|
190
190
|
const enErrors = JSON.parse(
|
|
191
191
|
fs.readFileSync(path.join(projectRoot, 'resources', 'i18n', 'en', 'errors.json'), 'utf8'),
|
|
192
192
|
);
|
|
193
|
-
assert.equal(enErrors.
|
|
193
|
+
assert.equal(enErrors.http.NOT_FOUND, 'Resource not found');
|
|
194
|
+
assert.equal(enErrors.validation.VALIDATION_ERROR, 'Validation error');
|
|
194
195
|
|
|
195
196
|
const webPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'package.json'), 'utf8');
|
|
196
197
|
assert.match(webPackage, /"i18next":/);
|
|
@@ -202,12 +203,17 @@ describe('addModule', () => {
|
|
|
202
203
|
const i18nTs = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'i18n.ts'), 'utf8');
|
|
203
204
|
assert.match(i18nTs, /initReactI18next/);
|
|
204
205
|
assert.match(i18nTs, /\.\.\/\.\.\/\.\.\/resources\/i18n\/en\/common\.json/);
|
|
206
|
+
assert.match(i18nTs, /\.\.\/\.\.\/\.\.\/resources\/i18n\/en\/ui\.json/);
|
|
205
207
|
assert.doesNotMatch(i18nTs, /I18N_DEFAULT_LANG/);
|
|
206
208
|
|
|
207
209
|
const rootPackage = fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8');
|
|
208
210
|
assert.match(rootPackage, /"i18n:sync"/);
|
|
209
211
|
assert.match(rootPackage, /"i18n:check"/);
|
|
210
212
|
assert.match(rootPackage, /"i18n:types"/);
|
|
213
|
+
assert.match(rootPackage, /"i18n:add"/);
|
|
214
|
+
|
|
215
|
+
const i18nAddScriptPath = path.join(projectRoot, 'scripts', 'i18n-add.mjs');
|
|
216
|
+
assert.equal(fs.existsSync(i18nAddScriptPath), true);
|
|
211
217
|
|
|
212
218
|
const caddyDockerfile = fs.readFileSync(
|
|
213
219
|
path.join(projectRoot, 'infra', 'docker', 'caddy.Dockerfile'),
|
package/src/modules/i18n.mjs
CHANGED
|
@@ -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'));
|
package/src/presets/i18n.mjs
CHANGED
|
@@ -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')
|
|
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:
|
|
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
|
-
|
|
170
|
+
probeId: 'health.error',
|
|
171
|
+
probe: 'Error envelope probe',
|
|
172
172
|
},
|
|
173
173
|
});
|
|
174
174
|
}
|
|
175
175
|
|
|
176
176
|
@Get('validation')
|
|
177
|
-
getValidationProbe(@Query()
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
message: this.translate('common.ok', lang),
|
|
19
|
-
i18n:
|
|
16
|
+
message: this.translate('common.actions.ok', lang),
|
|
17
|
+
i18n: 'en',
|
|
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: '
|
|
24
|
+
message: this.translate('errors.http.CONFLICT', lang),
|
|
27
25
|
details: {
|
|
28
26
|
feature: 'core-errors',
|
|
29
|
-
|
|
27
|
+
probeId: 'health.error',
|
|
28
|
+
probe: 'Error envelope probe',
|
|
30
29
|
},
|
|
31
30
|
});
|
|
32
31
|
}
|
|
33
32
|
|
|
34
33
|
@Get('validation')
|
|
35
|
-
getValidationProbe(@Query()
|
|
34
|
+
getValidationProbe(@Query('value') value?: string, @Query('lang') lang?: string) {
|
|
35
|
+
if (!value || value.trim().length === 0) {
|
|
36
|
+
const translatedMessage = this.translate('validation.generic.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
|
|
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
|
}
|
|
@@ -64,3 +64,5 @@ Must contain:
|
|
|
64
64
|
- Contracts package exports are stable from `dist/index` entrypoint.
|
|
65
65
|
- Module has docs under `docs/AI/MODULES/<module-id>.md`.
|
|
66
66
|
- If module behavior can be runtime-checked, it also includes API+Web probe hooks (see `docs/AI/MODULE_CHECKS.md`).
|
|
67
|
+
- If i18n is enabled, module-specific namespaces must be created and wired for both API and web.
|
|
68
|
+
- If module is added before i18n, namespace templates must still be prepared and applied when i18n is installed later.
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# ROADMAP
|
|
2
|
+
|
|
3
|
+
This is a living plan. Scope and priorities may change.
|
|
4
|
+
|
|
5
|
+
## Current Foundation (Implemented)
|
|
6
|
+
|
|
7
|
+
- [x] Canonical scaffold: NestJS API + React web + Prisma/Postgres + Docker
|
|
8
|
+
- [x] Proxy preset selection: `caddy | nginx | none`
|
|
9
|
+
- [x] `@forgeon/core`:
|
|
10
|
+
- [x] `core-config` (typed env config + validation)
|
|
11
|
+
- [x] `core-errors` (global envelope + exception filter)
|
|
12
|
+
- [x] `core-validation` (global validation pipe)
|
|
13
|
+
- [x] `@forgeon/db-prisma` as default-applied DB module
|
|
14
|
+
- [x] i18n add-module baseline:
|
|
15
|
+
- [x] `@forgeon/i18n`, `@forgeon/i18n-contracts`, `@forgeon/i18n-web`
|
|
16
|
+
- [x] shared dictionaries in `resources/i18n/*`
|
|
17
|
+
- [x] tooling: `i18n:sync`, `i18n:check`, `i18n:types`, `i18n:add`
|
|
18
|
+
- [x] module diagnostics probes pattern (`/api/health/*` + web test buttons)
|
|
19
|
+
|
|
20
|
+
## Standards (Accepted)
|
|
21
|
+
|
|
22
|
+
- [x] `*-contracts` and `*-web` packages are ESM-first
|
|
23
|
+
- [x] API runtime modules use Node-oriented TS config
|
|
24
|
+
- [x] no cross-package imports via `/src/*`; only package entrypoints
|
|
25
|
+
|
|
26
|
+
## Updated Priority Backlog
|
|
27
|
+
|
|
28
|
+
### P0 (Immediate Must-Have)
|
|
29
|
+
|
|
30
|
+
- [ ] `logger`
|
|
31
|
+
- [ ] canonical logger module
|
|
32
|
+
- [ ] requestId / correlationId propagation
|
|
33
|
+
- [ ] structured log conventions
|
|
34
|
+
|
|
35
|
+
- [ ] `openapi / swagger`
|
|
36
|
+
- [ ] env toggle: `SWAGGER_ENABLED`
|
|
37
|
+
- [ ] standard setup
|
|
38
|
+
- [ ] bearer integration hook for jwt-auth
|
|
39
|
+
- [ ] `/docs` route
|
|
40
|
+
|
|
41
|
+
- [ ] `jwt-auth`
|
|
42
|
+
- [ ] module split: contracts/api/web
|
|
43
|
+
- [ ] access + refresh baseline
|
|
44
|
+
- [ ] guards/strategy integration
|
|
45
|
+
|
|
46
|
+
- [ ] `rbac / permissions`
|
|
47
|
+
- [ ] decorators: `@Roles()`, `@Permissions()`
|
|
48
|
+
- [ ] guard + policy helper
|
|
49
|
+
- [ ] contracts: `Role`, `Permission`
|
|
50
|
+
- [ ] integration with jwt-auth claims
|
|
51
|
+
|
|
52
|
+
- [ ] `redis/queue foundation`
|
|
53
|
+
- [ ] base Redis config/service
|
|
54
|
+
- [ ] queue baseline (BullMQ or equivalent)
|
|
55
|
+
- [ ] retry and dead-letter conventions
|
|
56
|
+
|
|
57
|
+
- [ ] `rate-limit`
|
|
58
|
+
- [ ] Nest Throttler add-module
|
|
59
|
+
- [ ] policies: route / user / ip
|
|
60
|
+
- [ ] error code: `TOO_MANY_REQUESTS`
|
|
61
|
+
- [ ] reverse-proxy-aware mode (`trust proxy`)
|
|
62
|
+
|
|
63
|
+
- [ ] `files` (upload + storage)
|
|
64
|
+
- [ ] upload endpoints + DTO + guards
|
|
65
|
+
- [ ] storage presets: local + S3-compatible (MinIO/R2)
|
|
66
|
+
- [ ] MIME/size validation
|
|
67
|
+
- [ ] optional image processing subpackage (`sharp`)
|
|
68
|
+
- [ ] error codes: `UPLOAD_INVALID_TYPE`, `UPLOAD_TOO_LARGE`, `UPLOAD_QUOTA`
|
|
69
|
+
|
|
70
|
+
### P1 (Strongly Recommended)
|
|
71
|
+
|
|
72
|
+
- [ ] `testing baseline`
|
|
73
|
+
- [ ] unit + e2e presets
|
|
74
|
+
- [ ] test helpers for add-modules
|
|
75
|
+
- [ ] smoke test template for generated project
|
|
76
|
+
|
|
77
|
+
- [ ] `CI quality gates`
|
|
78
|
+
- [ ] `typecheck`, `lint`, `test`, docker build smoke
|
|
79
|
+
- [ ] release gate checklist
|
|
80
|
+
|
|
81
|
+
- [ ] `cache` (Redis)
|
|
82
|
+
- [ ] CacheModule preset
|
|
83
|
+
- [ ] key naming conventions
|
|
84
|
+
- [ ] shared wrapper/service
|
|
85
|
+
|
|
86
|
+
- [ ] `scheduler`
|
|
87
|
+
- [ ] `@nestjs/schedule` integration
|
|
88
|
+
- [ ] task template
|
|
89
|
+
- [ ] optional distributed lock (Redis)
|
|
90
|
+
|
|
91
|
+
- [ ] `mail`
|
|
92
|
+
- [ ] at least one provider preset (SMTP/Resend/SendGrid)
|
|
93
|
+
- [ ] templates: verify email, reset password
|
|
94
|
+
- [ ] optional outbox with queue
|
|
95
|
+
|
|
96
|
+
- [ ] workspace `eslint/prettier` config package
|
|
97
|
+
|
|
98
|
+
### P2 (Later)
|
|
99
|
+
|
|
100
|
+
- [ ] frontend `http-client` module
|
|
101
|
+
- [ ] frontend UI kit package
|
|
102
|
+
- [ ] migrate reusable parts from `eso-dt` (when available)
|
|
103
|
+
- [ ] extend missing primitives
|
|
104
|
+
- [ ] `realtime` (ws)
|
|
105
|
+
- [ ] gateway baseline
|
|
106
|
+
- [ ] jwt auth for ws
|
|
107
|
+
- [ ] rooms + basic events
|
|
108
|
+
- [ ] `webhooks` module (subject to scope validation)
|
|
109
|
+
- [ ] signed inbound verify (HMAC)
|
|
110
|
+
- [ ] signed outbound sender
|
|
111
|
+
- [ ] replay protection (timestamp/nonce)
|
|
112
|
+
|
|
113
|
+
## Execution Plan (3 Sprints)
|
|
114
|
+
|
|
115
|
+
### Sprint 1: Platform Baseline and Security Start
|
|
116
|
+
|
|
117
|
+
Scope:
|
|
118
|
+
- `logger`
|
|
119
|
+
- `openapi/swagger`
|
|
120
|
+
- `jwt-auth`
|
|
121
|
+
- `testing baseline`
|
|
122
|
+
- `CI quality gates`
|
|
123
|
+
|
|
124
|
+
Definition of Done:
|
|
125
|
+
- add-modules install cleanly via `create-forgeon add <module>`
|
|
126
|
+
- local dev (`pnpm dev`) and docker build both pass on fresh generated project
|
|
127
|
+
- each module has probe endpoint and web probe UI hook when applicable
|
|
128
|
+
- docs updated in both root and template docs
|
|
129
|
+
|
|
130
|
+
### Sprint 2: Authorization and Traffic Control
|
|
131
|
+
|
|
132
|
+
Scope:
|
|
133
|
+
- `rbac/permissions`
|
|
134
|
+
- `redis/queue foundation`
|
|
135
|
+
- `rate-limit`
|
|
136
|
+
- `files`
|
|
137
|
+
- `cache`
|
|
138
|
+
|
|
139
|
+
Definition of Done:
|
|
140
|
+
- claims/roles/permissions flow validated end-to-end (api + web contracts)
|
|
141
|
+
- rate-limit and files include standardized error codes and envelope mapping
|
|
142
|
+
- Redis-backed modules run in docker profile with documented env keys
|
|
143
|
+
- at least one e2e happy-path per module
|
|
144
|
+
|
|
145
|
+
### Sprint 3: Async Integrations and Frontend Foundation
|
|
146
|
+
|
|
147
|
+
Scope:
|
|
148
|
+
- `scheduler`
|
|
149
|
+
- `mail`
|
|
150
|
+
- workspace `eslint/prettier` config package
|
|
151
|
+
- frontend `http-client`
|
|
152
|
+
|
|
153
|
+
Definition of Done:
|
|
154
|
+
- queue/scheduler/mail basic scenarios work in local + docker
|
|
155
|
+
- frontend http-client consumes api contracts with typed errors
|
|
156
|
+
- lint/typecheck/test/build pass through CI gate preset
|
|
157
|
+
- docs include migration notes and extension points
|
|
158
|
+
|
|
159
|
+
## Explicit Dependencies and Order Constraints
|
|
160
|
+
|
|
161
|
+
- `rbac` depends on `jwt-auth`
|
|
162
|
+
- `rate-limit` should follow Redis/queue foundation for scalable mode
|
|
163
|
+
- `mail` should reuse queue foundation where possible
|
|
164
|
+
- `openapi` is most useful before/with `jwt-auth` and `http-client`
|
|
165
|
+
- `realtime` and `webhooks` stay post-MVP unless a concrete use-case appears
|
|
166
|
+
|
|
167
|
+
## i18n Policy For Add-Modules
|
|
168
|
+
|
|
169
|
+
- [ ] each add-module that introduces user-facing text defines its own namespace templates
|
|
170
|
+
- [ ] if i18n is already enabled, namespace files are added during module installation
|
|
171
|
+
- [ ] if module is installed first and i18n later, namespaces are merged during i18n installation
|
|
@@ -53,6 +53,7 @@ Requirements:
|
|
|
53
53
|
- split module into contracts/api/web packages
|
|
54
54
|
- contracts is source of truth for routes, DTOs, errors
|
|
55
55
|
- if feasible, add module probe hooks in API (`/api/health/*`) and web diagnostics UI
|
|
56
|
+
- if i18n is enabled, add module namespace files and wire them for both API and web
|
|
56
57
|
- add docs note under docs/AI/MODULES/<module-id>.md
|
|
57
58
|
- keep backward compatibility
|
|
58
59
|
```
|
|
@@ -2,7 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
- `AI/PROJECT.md` - project overview and run modes
|
|
4
4
|
- `AI/ARCHITECTURE.md` - monorepo design and extension model
|
|
5
|
+
- `AI/ROADMAP.md` - implementation roadmap and feature priorities
|
|
5
6
|
- `AI/MODULE_SPEC.md` - fullstack module contract (`contracts/api/web`)
|
|
6
7
|
- `AI/MODULE_CHECKS.md` - required runtime probe hooks for modules
|
|
7
8
|
- `AI/VALIDATION.md` - DTO/env validation standards
|
|
8
9
|
- `AI/TASKS.md` - ready-to-use Codex prompts
|
|
10
|
+
|
|
11
|
+
## i18n Language Workflow
|
|
12
|
+
|
|
13
|
+
Add a new language from existing namespaces:
|
|
14
|
+
- `pnpm i18n:add uk`
|
|
15
|
+
|
|
16
|
+
Useful follow-up commands:
|
|
17
|
+
- `pnpm i18n:sync`
|
|
18
|
+
- `pnpm i18n:check`
|
|
@@ -51,17 +51,29 @@ export class CoreExceptionFilter implements ExceptionFilter {
|
|
|
51
51
|
private resolveCode(status: number): string {
|
|
52
52
|
switch (status) {
|
|
53
53
|
case HttpStatus.BAD_REQUEST:
|
|
54
|
-
return '
|
|
54
|
+
return 'BAD_REQUEST';
|
|
55
55
|
case HttpStatus.UNAUTHORIZED:
|
|
56
|
-
return '
|
|
56
|
+
return 'UNAUTHORIZED';
|
|
57
57
|
case HttpStatus.FORBIDDEN:
|
|
58
|
-
return '
|
|
58
|
+
return 'FORBIDDEN';
|
|
59
59
|
case HttpStatus.NOT_FOUND:
|
|
60
|
-
return '
|
|
60
|
+
return 'NOT_FOUND';
|
|
61
61
|
case HttpStatus.CONFLICT:
|
|
62
|
-
return '
|
|
62
|
+
return 'CONFLICT';
|
|
63
|
+
case HttpStatus.TOO_MANY_REQUESTS:
|
|
64
|
+
return 'TOO_MANY_REQUESTS';
|
|
65
|
+
case HttpStatus.METHOD_NOT_ALLOWED:
|
|
66
|
+
return 'METHOD_NOT_ALLOWED';
|
|
67
|
+
case HttpStatus.UNPROCESSABLE_ENTITY:
|
|
68
|
+
return 'UNPROCESSABLE_ENTITY';
|
|
69
|
+
case HttpStatus.SERVICE_UNAVAILABLE:
|
|
70
|
+
return 'SERVICE_UNAVAILABLE';
|
|
71
|
+
case HttpStatus.BAD_GATEWAY:
|
|
72
|
+
return 'BAD_GATEWAY';
|
|
73
|
+
case HttpStatus.GATEWAY_TIMEOUT:
|
|
74
|
+
return 'GATEWAY_TIMEOUT';
|
|
63
75
|
default:
|
|
64
|
-
return '
|
|
76
|
+
return 'INTERNAL_ERROR';
|
|
65
77
|
}
|
|
66
78
|
}
|
|
67
79
|
|
|
@@ -1,12 +1,42 @@
|
|
|
1
1
|
{
|
|
2
|
-
"
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
"
|
|
10
|
-
"
|
|
2
|
+
"actions": {
|
|
3
|
+
"ok": "OK",
|
|
4
|
+
"cancel": "Cancel",
|
|
5
|
+
"save": "Save",
|
|
6
|
+
"delete": "Delete",
|
|
7
|
+
"edit": "Edit",
|
|
8
|
+
"create": "Create",
|
|
9
|
+
"update": "Update",
|
|
10
|
+
"confirm": "Confirm",
|
|
11
|
+
"retry": "Retry"
|
|
12
|
+
},
|
|
13
|
+
"nav": {
|
|
14
|
+
"back": "Back",
|
|
15
|
+
"next": "Next",
|
|
16
|
+
"close": "Close"
|
|
17
|
+
},
|
|
18
|
+
"state": {
|
|
19
|
+
"loading": "Loading...",
|
|
20
|
+
"empty": "No data",
|
|
21
|
+
"selected": "Selected"
|
|
22
|
+
},
|
|
23
|
+
"status": {
|
|
24
|
+
"active": "Active",
|
|
25
|
+
"inactive": "Inactive",
|
|
26
|
+
"pending": "Pending",
|
|
27
|
+
"disabled": "Disabled",
|
|
28
|
+
"archived": "Archived"
|
|
29
|
+
},
|
|
30
|
+
"time": {
|
|
31
|
+
"today": "Today",
|
|
32
|
+
"yesterday": "Yesterday",
|
|
33
|
+
"tomorrow": "Tomorrow",
|
|
34
|
+
"now": "Now"
|
|
35
|
+
},
|
|
36
|
+
"misc": {
|
|
37
|
+
"yes": "Yes",
|
|
38
|
+
"no": "No",
|
|
39
|
+
"on": "On",
|
|
40
|
+
"off": "Off"
|
|
11
41
|
}
|
|
12
42
|
}
|
|
@@ -1,4 +1,33 @@
|
|
|
1
1
|
{
|
|
2
|
-
"
|
|
3
|
-
|
|
2
|
+
"http": {
|
|
3
|
+
"BAD_REQUEST": "Bad request",
|
|
4
|
+
"NOT_FOUND": "Resource not found",
|
|
5
|
+
"UNAUTHORIZED": "Unauthorized",
|
|
6
|
+
"FORBIDDEN": "Access denied",
|
|
7
|
+
"CONFLICT": "Conflict",
|
|
8
|
+
"TOO_MANY_REQUESTS": "Too many requests",
|
|
9
|
+
"METHOD_NOT_ALLOWED": "Method not allowed",
|
|
10
|
+
"UNPROCESSABLE_ENTITY": "Unprocessable entity",
|
|
11
|
+
"INTERNAL_ERROR": "Internal server error",
|
|
12
|
+
"SERVICE_UNAVAILABLE": "Service unavailable",
|
|
13
|
+
"BAD_GATEWAY": "Bad gateway",
|
|
14
|
+
"GATEWAY_TIMEOUT": "Gateway timeout"
|
|
15
|
+
},
|
|
16
|
+
"network": {
|
|
17
|
+
"NETWORK_ERROR": "Network error",
|
|
18
|
+
"TIMEOUT": "Request timeout",
|
|
19
|
+
"OFFLINE": "You appear to be offline"
|
|
20
|
+
},
|
|
21
|
+
"auth": {
|
|
22
|
+
"AUTH_INVALID_CREDENTIALS": "Invalid credentials",
|
|
23
|
+
"AUTH_TOKEN_EXPIRED": "Session expired"
|
|
24
|
+
},
|
|
25
|
+
"validation": {
|
|
26
|
+
"VALIDATION_ERROR": "Validation error"
|
|
27
|
+
},
|
|
28
|
+
"files": {
|
|
29
|
+
"UPLOAD_FILE_TOO_LARGE": "File is too large",
|
|
30
|
+
"UPLOAD_INVALID_TYPE": "Invalid file type",
|
|
31
|
+
"UPLOAD_QUOTA_EXCEEDED": "Upload quota exceeded"
|
|
32
|
+
}
|
|
4
33
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"success": {
|
|
3
|
+
"saved": "Saved successfully",
|
|
4
|
+
"updated": "Updated successfully",
|
|
5
|
+
"deleted": "Deleted successfully",
|
|
6
|
+
"copied": "Copied successfully"
|
|
7
|
+
},
|
|
8
|
+
"error": {
|
|
9
|
+
"saveFailed": "Failed to save",
|
|
10
|
+
"updateFailed": "Failed to update",
|
|
11
|
+
"deleteFailed": "Failed to delete"
|
|
12
|
+
},
|
|
13
|
+
"info": {
|
|
14
|
+
"changesDiscarded": "Changes were discarded",
|
|
15
|
+
"sessionExpired": "Your session has expired"
|
|
16
|
+
},
|
|
17
|
+
"progress": {
|
|
18
|
+
"uploading": "Uploading...",
|
|
19
|
+
"processing": "Processing..."
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"labels": {
|
|
3
|
+
"name": "Name",
|
|
4
|
+
"email": "Email",
|
|
5
|
+
"password": "Password",
|
|
6
|
+
"search": "Search",
|
|
7
|
+
"language": "Language"
|
|
8
|
+
},
|
|
9
|
+
"placeholders": {
|
|
10
|
+
"email": "Enter email",
|
|
11
|
+
"search": "Search...",
|
|
12
|
+
"password": "Enter password"
|
|
13
|
+
},
|
|
14
|
+
"titles": {
|
|
15
|
+
"home": "Home",
|
|
16
|
+
"dashboard": "Dashboard",
|
|
17
|
+
"settings": "Settings"
|
|
18
|
+
},
|
|
19
|
+
"messages": {
|
|
20
|
+
"emptyState": "Nothing to display yet",
|
|
21
|
+
"noResults": "No results found"
|
|
22
|
+
},
|
|
23
|
+
"table": {
|
|
24
|
+
"rowsPerPage": "Rows per page",
|
|
25
|
+
"noData": "No data"
|
|
26
|
+
},
|
|
27
|
+
"modal": {
|
|
28
|
+
"confirmTitle": "Please confirm",
|
|
29
|
+
"confirmText": "Are you sure you want to continue?"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -1,3 +1,32 @@
|
|
|
1
1
|
{
|
|
2
|
-
"
|
|
2
|
+
"generic": {
|
|
3
|
+
"required": "Field is required",
|
|
4
|
+
"invalid": "Invalid value",
|
|
5
|
+
"outOfRange": "Value is out of range"
|
|
6
|
+
},
|
|
7
|
+
"string": {
|
|
8
|
+
"minLength": "Value is too short",
|
|
9
|
+
"maxLength": "Value is too long",
|
|
10
|
+
"pattern": "Value does not match required format"
|
|
11
|
+
},
|
|
12
|
+
"number": {
|
|
13
|
+
"min": "Value is too small",
|
|
14
|
+
"max": "Value is too large",
|
|
15
|
+
"int": "Value must be an integer"
|
|
16
|
+
},
|
|
17
|
+
"format": {
|
|
18
|
+
"email": "Invalid email format",
|
|
19
|
+
"phone": "Invalid phone format",
|
|
20
|
+
"url": "Invalid URL format",
|
|
21
|
+
"uuid": "Invalid UUID format"
|
|
22
|
+
},
|
|
23
|
+
"date": {
|
|
24
|
+
"minDate": "Date is too early",
|
|
25
|
+
"maxDate": "Date is too late",
|
|
26
|
+
"invalidDate": "Invalid date"
|
|
27
|
+
},
|
|
28
|
+
"file": {
|
|
29
|
+
"fileTooLarge": "File is too large",
|
|
30
|
+
"invalidType": "Invalid file type"
|
|
31
|
+
}
|
|
3
32
|
}
|
|
@@ -0,0 +1,191 @@
|
|
|
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 = 'pnpm i18n:sync';
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
const child = spawn(command, {
|
|
90
|
+
cwd,
|
|
91
|
+
stdio: 'inherit',
|
|
92
|
+
shell: true,
|
|
93
|
+
});
|
|
94
|
+
child.on('error', (error) => reject(error));
|
|
95
|
+
child.on('exit', (code) => {
|
|
96
|
+
if (code === 0) {
|
|
97
|
+
resolve();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
reject(new Error(`"pnpm i18n:sync" failed with exit code ${code ?? 'unknown'}.`));
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function main() {
|
|
106
|
+
const options = parseArgs(process.argv.slice(2));
|
|
107
|
+
if (options.help) {
|
|
108
|
+
printUsage();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!options.locale) {
|
|
113
|
+
throw new Error('Locale is required.');
|
|
114
|
+
}
|
|
115
|
+
validateLocaleOrThrow(options.locale, 'Locale');
|
|
116
|
+
validateLocaleOrThrow(options.copyFrom, 'copy-from');
|
|
117
|
+
|
|
118
|
+
if (options.locale === options.copyFrom) {
|
|
119
|
+
throw new Error('Locale must be different from --copy-from.');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const root = process.cwd();
|
|
123
|
+
const resourcesRoot = path.join(root, 'resources', 'i18n');
|
|
124
|
+
const sourceDir = path.join(resourcesRoot, options.copyFrom);
|
|
125
|
+
const targetDir = path.join(resourcesRoot, options.locale);
|
|
126
|
+
|
|
127
|
+
if (!(await pathExists(sourceDir))) {
|
|
128
|
+
throw new Error(`Source locale folder not found: ${sourceDir}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const namespaceFiles = await listJsonFiles(sourceDir);
|
|
132
|
+
if (namespaceFiles.length === 0) {
|
|
133
|
+
throw new Error(`Source locale folder has no namespace JSON files: ${sourceDir}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const targetExisted = await pathExists(targetDir);
|
|
137
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
138
|
+
|
|
139
|
+
const conflictingFiles = [];
|
|
140
|
+
for (const fileName of namespaceFiles) {
|
|
141
|
+
const destinationPath = path.join(targetDir, fileName);
|
|
142
|
+
if (await pathExists(destinationPath)) {
|
|
143
|
+
conflictingFiles.push(fileName);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (conflictingFiles.length > 0 && !options.force) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`Target locale already has existing files: ${conflictingFiles.join(', ')}. Use --force to overwrite.`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const createdFiles = [];
|
|
154
|
+
for (const fileName of namespaceFiles) {
|
|
155
|
+
const sourcePath = path.join(sourceDir, fileName);
|
|
156
|
+
const destinationPath = path.join(targetDir, fileName);
|
|
157
|
+
let content = '{}\n';
|
|
158
|
+
|
|
159
|
+
if (!options.empty) {
|
|
160
|
+
content = await fs.readFile(sourcePath, 'utf8');
|
|
161
|
+
if (!content.endsWith('\n')) {
|
|
162
|
+
content = `${content}\n`;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
await fs.writeFile(destinationPath, content, 'utf8');
|
|
167
|
+
createdFiles.push(fileName);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let syncExecuted = false;
|
|
171
|
+
if (!options.noSync) {
|
|
172
|
+
await runSyncCommand(root);
|
|
173
|
+
syncExecuted = true;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
console.log('i18n locale added successfully.');
|
|
177
|
+
console.log(`- locale: ${options.locale}`);
|
|
178
|
+
console.log(`- copy-from: ${options.copyFrom}`);
|
|
179
|
+
console.log(`- folder: ${path.join('resources', 'i18n', options.locale)} (${targetExisted ? 'existing' : 'created'})`);
|
|
180
|
+
console.log(`- files: ${createdFiles.length} (${options.empty ? 'empty {}' : 'copied'})`);
|
|
181
|
+
for (const fileName of createdFiles) {
|
|
182
|
+
console.log(` - ${fileName}`);
|
|
183
|
+
}
|
|
184
|
+
console.log(`- sync: ${syncExecuted ? 'pnpm i18n:sync executed' : 'skipped (--no-sync)'}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
main().catch((error) => {
|
|
188
|
+
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
189
|
+
printUsage();
|
|
190
|
+
process.exitCode = 1;
|
|
191
|
+
});
|
|
@@ -6,6 +6,7 @@ Environment keys:
|
|
|
6
6
|
|
|
7
7
|
Resources location: `resources/i18n`.
|
|
8
8
|
These dictionaries are shared by backend (`nestjs-i18n`) and frontend (`react-i18next`).
|
|
9
|
+
Default namespaces: `common`, `errors`, `validation`, `ui`, `notifications`, `meta`.
|
|
9
10
|
|
|
10
11
|
Packages:
|
|
11
12
|
- `@forgeon/i18n`
|
|
@@ -22,5 +23,9 @@ Locale/namespace contracts sync:
|
|
|
22
23
|
Translation key types generation (manual):
|
|
23
24
|
- `pnpm i18n:types`
|
|
24
25
|
|
|
26
|
+
Add a new language folder:
|
|
27
|
+
- `pnpm i18n:add uk`
|
|
28
|
+
- optional flags: `--copy-from=en` `--empty` `--force` `--no-sync`
|
|
29
|
+
|
|
25
30
|
You can apply i18n later with:
|
|
26
31
|
`npx create-forgeon@latest add i18n --project .`
|
|
@@ -7,9 +7,11 @@ 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
|
|
10
|
+
- shared dictionaries in `resources/i18n/*` (`en` by default) used by both API and web
|
|
11
|
+
- default namespaces: `common`, `errors`, `validation`, `ui`, `notifications`, `meta`
|
|
11
12
|
|
|
12
13
|
Utility commands:
|
|
13
14
|
- `pnpm i18n:sync` - regenerate `I18N_LOCALES` and `I18N_NAMESPACES` from `resources/i18n`.
|
|
14
15
|
- `pnpm i18n:check` - verify generated contracts, JSON validity, and missing/extra keys vs fallback locale.
|
|
15
16
|
- `pnpm i18n:types` - generate translation key type unions for autocomplete.
|
|
17
|
+
- `pnpm i18n:add <locale>` - create `resources/i18n/<locale>` from `--copy-from` namespace files.
|
|
@@ -10,12 +10,8 @@ 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
|
-
const { t } = useTranslation(['
|
|
14
|
+
const { t } = useTranslation(['ui']);
|
|
19
15
|
const { I18N_LOCALES, getInitialLocale, persistLocale, toLangQuery } = i18nWeb;
|
|
20
16
|
const [locale, setLocale] = useState<I18nLocale>(getInitialLocale);
|
|
21
17
|
const [healthResult, setHealthResult] = useState<ProbeResult | null>(null);
|
|
@@ -77,7 +73,7 @@ export default function App() {
|
|
|
77
73
|
<main className="page">
|
|
78
74
|
<h1>Forgeon Fullstack Scaffold</h1>
|
|
79
75
|
<p>Default frontend preset: React + Vite + TypeScript.</p>
|
|
80
|
-
<label htmlFor="language">{t('
|
|
76
|
+
<label htmlFor="language">{t('ui:labels.language')}:</label>
|
|
81
77
|
<select
|
|
82
78
|
id="language"
|
|
83
79
|
value={locale}
|
|
@@ -85,20 +81,20 @@ export default function App() {
|
|
|
85
81
|
>
|
|
86
82
|
{I18N_LOCALES.map((item) => (
|
|
87
83
|
<option key={item} value={item}>
|
|
88
|
-
{
|
|
84
|
+
{item}
|
|
89
85
|
</option>
|
|
90
86
|
))}
|
|
91
87
|
</select>
|
|
92
88
|
<div className="actions">
|
|
93
|
-
<button onClick={() => runProbe(setHealthResult, '/health')}>
|
|
89
|
+
<button onClick={() => runProbe(setHealthResult, '/health')}>Check API health</button>
|
|
94
90
|
<button onClick={() => runProbe(setErrorProbeResult, '/health/error')}>
|
|
95
|
-
|
|
91
|
+
Check error envelope
|
|
96
92
|
</button>
|
|
97
93
|
<button onClick={() => runProbe(setValidationProbeResult, '/health/validation')}>
|
|
98
|
-
|
|
94
|
+
Check validation (expect 400)
|
|
99
95
|
</button>
|
|
100
96
|
<button onClick={() => runProbe(setDbProbeResult, '/health/db', { method: 'POST' })}>
|
|
101
|
-
|
|
97
|
+
Check database (create user)
|
|
102
98
|
</button>
|
|
103
99
|
</div>
|
|
104
100
|
{renderResult('Health response', healthResult)}
|
|
@@ -3,22 +3,20 @@ import { initReactI18next } from 'react-i18next';
|
|
|
3
3
|
import { getInitialLocale, I18N_LOCALES, type I18nLocale } from '@forgeon/i18n-web';
|
|
4
4
|
import enCommon from '../../../resources/i18n/en/common.json';
|
|
5
5
|
import enErrors from '../../../resources/i18n/en/errors.json';
|
|
6
|
+
import enMeta from '../../../resources/i18n/en/meta.json';
|
|
7
|
+
import enNotifications from '../../../resources/i18n/en/notifications.json';
|
|
8
|
+
import enUi from '../../../resources/i18n/en/ui.json';
|
|
6
9
|
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
10
|
|
|
11
11
|
const resources = {
|
|
12
12
|
en: {
|
|
13
13
|
common: enCommon,
|
|
14
14
|
errors: enErrors,
|
|
15
|
+
meta: enMeta,
|
|
16
|
+
notifications: enNotifications,
|
|
17
|
+
ui: enUi,
|
|
15
18
|
validation: enValidation,
|
|
16
19
|
},
|
|
17
|
-
uk: {
|
|
18
|
-
common: ukCommon,
|
|
19
|
-
errors: ukErrors,
|
|
20
|
-
validation: ukValidation,
|
|
21
|
-
},
|
|
22
20
|
} as const;
|
|
23
21
|
|
|
24
22
|
const fallbackLocale = (I18N_LOCALES[0] ?? 'en') as I18nLocale;
|
|
@@ -1,11 +1,100 @@
|
|
|
1
1
|
/* AUTO-GENERATED BY `pnpm i18n:types`. DO NOT EDIT MANUALLY. */
|
|
2
2
|
|
|
3
3
|
export type I18nTranslationKey =
|
|
4
|
-
| "common.
|
|
5
|
-
| "common.
|
|
6
|
-
| "common.
|
|
7
|
-
| "common.
|
|
8
|
-
| "common.
|
|
9
|
-
| "
|
|
10
|
-
| "
|
|
11
|
-
| "
|
|
4
|
+
| "common.actions.ok"
|
|
5
|
+
| "common.actions.cancel"
|
|
6
|
+
| "common.actions.save"
|
|
7
|
+
| "common.actions.delete"
|
|
8
|
+
| "common.actions.edit"
|
|
9
|
+
| "common.actions.create"
|
|
10
|
+
| "common.actions.update"
|
|
11
|
+
| "common.actions.confirm"
|
|
12
|
+
| "common.actions.retry"
|
|
13
|
+
| "common.nav.back"
|
|
14
|
+
| "common.nav.next"
|
|
15
|
+
| "common.nav.close"
|
|
16
|
+
| "common.state.loading"
|
|
17
|
+
| "common.state.empty"
|
|
18
|
+
| "common.state.selected"
|
|
19
|
+
| "common.status.active"
|
|
20
|
+
| "common.status.inactive"
|
|
21
|
+
| "common.status.pending"
|
|
22
|
+
| "common.status.disabled"
|
|
23
|
+
| "common.status.archived"
|
|
24
|
+
| "common.time.today"
|
|
25
|
+
| "common.time.yesterday"
|
|
26
|
+
| "common.time.tomorrow"
|
|
27
|
+
| "common.time.now"
|
|
28
|
+
| "common.misc.yes"
|
|
29
|
+
| "common.misc.no"
|
|
30
|
+
| "common.misc.on"
|
|
31
|
+
| "common.misc.off"
|
|
32
|
+
| "errors.http.NOT_FOUND"
|
|
33
|
+
| "errors.http.BAD_REQUEST"
|
|
34
|
+
| "errors.http.UNAUTHORIZED"
|
|
35
|
+
| "errors.http.FORBIDDEN"
|
|
36
|
+
| "errors.http.CONFLICT"
|
|
37
|
+
| "errors.http.TOO_MANY_REQUESTS"
|
|
38
|
+
| "errors.http.METHOD_NOT_ALLOWED"
|
|
39
|
+
| "errors.http.UNPROCESSABLE_ENTITY"
|
|
40
|
+
| "errors.http.INTERNAL_ERROR"
|
|
41
|
+
| "errors.http.SERVICE_UNAVAILABLE"
|
|
42
|
+
| "errors.http.BAD_GATEWAY"
|
|
43
|
+
| "errors.http.GATEWAY_TIMEOUT"
|
|
44
|
+
| "errors.network.NETWORK_ERROR"
|
|
45
|
+
| "errors.network.TIMEOUT"
|
|
46
|
+
| "errors.network.OFFLINE"
|
|
47
|
+
| "errors.auth.AUTH_INVALID_CREDENTIALS"
|
|
48
|
+
| "errors.auth.AUTH_TOKEN_EXPIRED"
|
|
49
|
+
| "errors.validation.VALIDATION_ERROR"
|
|
50
|
+
| "errors.files.UPLOAD_FILE_TOO_LARGE"
|
|
51
|
+
| "errors.files.UPLOAD_INVALID_TYPE"
|
|
52
|
+
| "errors.files.UPLOAD_QUOTA_EXCEEDED"
|
|
53
|
+
| "meta.pages.home.title"
|
|
54
|
+
| "meta.pages.home.description"
|
|
55
|
+
| "notifications.success.saved"
|
|
56
|
+
| "notifications.success.updated"
|
|
57
|
+
| "notifications.success.deleted"
|
|
58
|
+
| "notifications.success.copied"
|
|
59
|
+
| "notifications.error.saveFailed"
|
|
60
|
+
| "notifications.error.updateFailed"
|
|
61
|
+
| "notifications.error.deleteFailed"
|
|
62
|
+
| "notifications.info.changesDiscarded"
|
|
63
|
+
| "notifications.info.sessionExpired"
|
|
64
|
+
| "notifications.progress.uploading"
|
|
65
|
+
| "notifications.progress.processing"
|
|
66
|
+
| "ui.labels.name"
|
|
67
|
+
| "ui.labels.email"
|
|
68
|
+
| "ui.labels.password"
|
|
69
|
+
| "ui.labels.search"
|
|
70
|
+
| "ui.labels.language"
|
|
71
|
+
| "ui.placeholders.email"
|
|
72
|
+
| "ui.placeholders.search"
|
|
73
|
+
| "ui.placeholders.password"
|
|
74
|
+
| "ui.titles.home"
|
|
75
|
+
| "ui.titles.dashboard"
|
|
76
|
+
| "ui.titles.settings"
|
|
77
|
+
| "ui.messages.emptyState"
|
|
78
|
+
| "ui.messages.noResults"
|
|
79
|
+
| "ui.table.rowsPerPage"
|
|
80
|
+
| "ui.table.noData"
|
|
81
|
+
| "ui.modal.confirmTitle"
|
|
82
|
+
| "ui.modal.confirmText"
|
|
83
|
+
| "validation.generic.required"
|
|
84
|
+
| "validation.generic.invalid"
|
|
85
|
+
| "validation.generic.outOfRange"
|
|
86
|
+
| "validation.string.minLength"
|
|
87
|
+
| "validation.string.maxLength"
|
|
88
|
+
| "validation.string.pattern"
|
|
89
|
+
| "validation.number.min"
|
|
90
|
+
| "validation.number.max"
|
|
91
|
+
| "validation.number.int"
|
|
92
|
+
| "validation.format.email"
|
|
93
|
+
| "validation.format.phone"
|
|
94
|
+
| "validation.format.url"
|
|
95
|
+
| "validation.format.uuid"
|
|
96
|
+
| "validation.date.minDate"
|
|
97
|
+
| "validation.date.maxDate"
|
|
98
|
+
| "validation.date.invalidDate"
|
|
99
|
+
| "validation.file.fileTooLarge"
|
|
100
|
+
| "validation.file.invalidType";
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
/* AUTO-GENERATED BY `pnpm i18n:sync`. DO NOT EDIT MANUALLY. */
|
|
2
2
|
|
|
3
|
-
export const I18N_LOCALES = ["en"
|
|
4
|
-
export const I18N_NAMESPACES = [
|
|
3
|
+
export const I18N_LOCALES = ["en"] as const;
|
|
4
|
+
export const I18N_NAMESPACES = [
|
|
5
|
+
"common",
|
|
6
|
+
"errors",
|
|
7
|
+
"meta",
|
|
8
|
+
"notifications",
|
|
9
|
+
"ui",
|
|
10
|
+
"validation"
|
|
11
|
+
] as const;
|
|
5
12
|
|
|
6
13
|
export type I18nLocale = (typeof I18N_LOCALES)[number];
|
|
7
14
|
export type I18nNamespace = (typeof I18N_NAMESPACES)[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
|
-
}
|