create-forgeon 0.1.25 → 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 +1 -1
- package/src/modules/executor.test.mjs +4 -0
- package/src/modules/i18n.mjs +2 -0
- package/src/presets/i18n.mjs +66 -23
- package/templates/base/apps/api/src/health/health.controller.ts +47 -26
- package/templates/base/apps/web/src/App.tsx +76 -35
- package/templates/base/apps/web/src/styles.css +29 -17
- package/templates/base/docs/AI/MODULE_CHECKS.md +25 -0
- package/templates/base/docs/AI/MODULE_SPEC.md +1 -0
- package/templates/base/docs/AI/TASKS.md +8 -7
- package/templates/base/docs/README.md +10 -0
- package/templates/base/resources/i18n/en/common.json +7 -2
- package/templates/base/resources/i18n/en/errors.json +2 -1
- package/templates/base/scripts/i18n-add.mjs +188 -0
- package/templates/docs-fragments/README/40_i18n.md +4 -0
- package/templates/module-fragments/i18n/10_overview.md +2 -1
- package/templates/module-presets/i18n/apps/web/src/App.tsx +64 -27
- package/templates/module-presets/i18n/apps/web/src/i18n.ts +0 -8
- package/templates/module-presets/i18n/packages/i18n-contracts/src/generated-keys.d.ts +5 -1
- package/templates/module-presets/i18n/packages/i18n-contracts/src/generated.ts +1 -1
- package/templates/base/resources/i18n/uk/common.json +0 -9
- package/templates/base/resources/i18n/uk/errors.json +0 -4
- package/templates/base/resources/i18n/uk/validation.json +0 -3
package/package.json
CHANGED
|
@@ -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'),
|
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
|
}
|
|
@@ -142,29 +143,71 @@ export class AppModule {}
|
|
|
142
143
|
'health',
|
|
143
144
|
'health.controller.ts',
|
|
144
145
|
);
|
|
145
|
-
fs.writeFileSync(
|
|
146
|
-
healthControllerPath,
|
|
147
|
-
`import { Controller, Get, Query } from '@nestjs/common';
|
|
148
|
-
import {
|
|
149
|
-
|
|
150
|
-
@Controller('health')
|
|
151
|
-
export class HealthController {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
146
|
+
fs.writeFileSync(
|
|
147
|
+
healthControllerPath,
|
|
148
|
+
`import { BadRequestException, ConflictException, Controller, Get, Post, Query } from '@nestjs/common';
|
|
149
|
+
import { PrismaService } from '@forgeon/db-prisma';
|
|
150
|
+
|
|
151
|
+
@Controller('health')
|
|
152
|
+
export class HealthController {
|
|
153
|
+
constructor(private readonly prisma: PrismaService) {}
|
|
154
|
+
|
|
155
|
+
@Get()
|
|
156
|
+
getHealth(@Query('lang') _lang?: string) {
|
|
157
|
+
return {
|
|
158
|
+
status: 'ok',
|
|
159
|
+
message: 'OK',
|
|
160
|
+
i18n: 'English',
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
@Get('error')
|
|
165
|
+
getErrorProbe() {
|
|
166
|
+
throw new ConflictException({
|
|
167
|
+
message: 'Email already exists',
|
|
168
|
+
details: {
|
|
169
|
+
feature: 'core-errors',
|
|
170
|
+
probeId: 'health.error',
|
|
171
|
+
probe: 'Error envelope probe',
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
@Get('validation')
|
|
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
|
+
|
|
185
|
+
return {
|
|
186
|
+
status: 'ok',
|
|
187
|
+
validated: true,
|
|
188
|
+
value,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
@Post('db')
|
|
193
|
+
async getDbProbe() {
|
|
194
|
+
const token = \`\${Date.now()}-\${Math.floor(Math.random() * 1_000_000)}\`;
|
|
195
|
+
const email = \`health-probe-\${token}@example.local\`;
|
|
196
|
+
const user = await this.prisma.user.create({
|
|
197
|
+
data: { email },
|
|
198
|
+
select: { id: true, email: true, createdAt: true },
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
status: 'ok',
|
|
203
|
+
feature: 'db-prisma',
|
|
204
|
+
user,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
`,
|
|
209
|
+
'utf8',
|
|
210
|
+
);
|
|
168
211
|
|
|
169
212
|
removeIfExists(
|
|
170
213
|
path.join(targetRoot, 'apps', 'api', 'src', 'common', 'filters', 'app-exception.filter.ts'),
|
|
@@ -1,49 +1,70 @@
|
|
|
1
|
-
import { Controller, Get,
|
|
1
|
+
import { BadRequestException, ConflictException, Controller, Get, Post, Query } from '@nestjs/common';
|
|
2
|
+
import { PrismaService } from '@forgeon/db-prisma';
|
|
2
3
|
import { I18nService } from 'nestjs-i18n';
|
|
3
|
-
import { EchoQueryDto } from '../common/dto/echo-query.dto';
|
|
4
4
|
|
|
5
5
|
@Controller('health')
|
|
6
6
|
export class HealthController {
|
|
7
|
-
constructor(
|
|
7
|
+
constructor(
|
|
8
|
+
private readonly prisma: PrismaService,
|
|
9
|
+
private readonly i18n: I18nService,
|
|
10
|
+
) {}
|
|
8
11
|
|
|
9
12
|
@Get()
|
|
10
13
|
getHealth(@Query('lang') lang?: string) {
|
|
11
|
-
const locale = this.resolveLocale(lang);
|
|
12
14
|
return {
|
|
13
15
|
status: 'ok',
|
|
14
16
|
message: this.translate('common.ok', lang),
|
|
15
|
-
i18n: this.translate(
|
|
17
|
+
i18n: this.translate('common.languages.english', lang),
|
|
16
18
|
};
|
|
17
19
|
}
|
|
18
20
|
|
|
19
|
-
@Get('
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
@Get('error')
|
|
22
|
+
getErrorProbe(@Query('lang') lang?: string) {
|
|
23
|
+
throw new ConflictException({
|
|
24
|
+
message: this.translate('errors.emailAlreadyExists', lang),
|
|
25
|
+
details: {
|
|
26
|
+
feature: 'core-errors',
|
|
27
|
+
probeId: 'health.error',
|
|
28
|
+
probe: this.translate('common.probes.error', lang),
|
|
29
|
+
},
|
|
30
|
+
});
|
|
22
31
|
}
|
|
23
32
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
@Get('validation')
|
|
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
|
+
});
|
|
32
41
|
}
|
|
33
42
|
|
|
34
|
-
|
|
35
|
-
|
|
43
|
+
return {
|
|
44
|
+
status: 'ok',
|
|
45
|
+
validated: true,
|
|
46
|
+
value,
|
|
47
|
+
};
|
|
36
48
|
}
|
|
37
49
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
50
|
+
@Post('db')
|
|
51
|
+
async getDbProbe() {
|
|
52
|
+
const token = `${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`;
|
|
53
|
+
const email = `health-probe-${token}@example.local`;
|
|
54
|
+
const user = await this.prisma.user.create({
|
|
55
|
+
data: { email },
|
|
56
|
+
select: { id: true, email: true, createdAt: true },
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
status: 'ok',
|
|
61
|
+
feature: 'db-prisma',
|
|
62
|
+
user,
|
|
63
|
+
};
|
|
44
64
|
}
|
|
45
65
|
|
|
46
|
-
private
|
|
47
|
-
|
|
66
|
+
private translate(key: string, lang?: string): string {
|
|
67
|
+
const value = this.i18n.t(key, { lang, defaultValue: key });
|
|
68
|
+
return typeof value === 'string' ? value : key;
|
|
48
69
|
}
|
|
49
70
|
}
|
|
@@ -1,37 +1,78 @@
|
|
|
1
|
-
import { useState } from 'react';
|
|
2
|
-
import './styles.css';
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import './styles.css';
|
|
3
|
+
|
|
4
|
+
type ProbeResult = {
|
|
5
|
+
statusCode: number;
|
|
6
|
+
body: unknown;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export default function App() {
|
|
10
|
+
const [healthResult, setHealthResult] = useState<ProbeResult | null>(null);
|
|
11
|
+
const [errorProbeResult, setErrorProbeResult] = useState<ProbeResult | null>(null);
|
|
12
|
+
const [validationProbeResult, setValidationProbeResult] = useState<ProbeResult | null>(null);
|
|
13
|
+
const [dbProbeResult, setDbProbeResult] = useState<ProbeResult | null>(null);
|
|
14
|
+
const [networkError, setNetworkError] = useState<string | null>(null);
|
|
15
|
+
|
|
16
|
+
const requestProbe = async (url: string, init?: RequestInit): Promise<ProbeResult> => {
|
|
17
|
+
const response = await fetch(url, init);
|
|
18
|
+
let body: unknown = null;
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
body = await response.json();
|
|
22
|
+
} catch {
|
|
23
|
+
body = { message: 'Non-JSON response' };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
statusCode: response.status,
|
|
28
|
+
body,
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const runProbe = async (
|
|
33
|
+
setter: (value: ProbeResult | null) => void,
|
|
34
|
+
url: string,
|
|
35
|
+
init?: RequestInit,
|
|
36
|
+
) => {
|
|
37
|
+
setNetworkError(null);
|
|
38
|
+
try {
|
|
39
|
+
const result = await requestProbe(url, init);
|
|
40
|
+
setter(result);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
setNetworkError(err instanceof Error ? err.message : 'Unknown error');
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const renderResult = (title: string, result: ProbeResult | null) => (
|
|
47
|
+
<section>
|
|
48
|
+
<h3>{title}</h3>
|
|
49
|
+
{result ? <pre>{JSON.stringify(result, null, 2)}</pre> : null}
|
|
50
|
+
</section>
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<main className="page">
|
|
55
|
+
<h1>Forgeon Fullstack Scaffold</h1>
|
|
56
|
+
<p>Default frontend preset: React + Vite + TypeScript.</p>
|
|
57
|
+
<div className="actions">
|
|
58
|
+
<button onClick={() => runProbe(setHealthResult, '/api/health')}>Check API health</button>
|
|
59
|
+
<button onClick={() => runProbe(setErrorProbeResult, '/api/health/error')}>
|
|
60
|
+
Check error envelope
|
|
61
|
+
</button>
|
|
62
|
+
<button onClick={() => runProbe(setValidationProbeResult, '/api/health/validation')}>
|
|
63
|
+
Check validation (expect 400)
|
|
64
|
+
</button>
|
|
65
|
+
<button onClick={() => runProbe(setDbProbeResult, '/api/health/db', { method: 'POST' })}>
|
|
66
|
+
Check database (create user)
|
|
67
|
+
</button>
|
|
68
|
+
</div>
|
|
69
|
+
{renderResult('Health response', healthResult)}
|
|
70
|
+
{renderResult('Error probe response', errorProbeResult)}
|
|
71
|
+
{renderResult('Validation probe response', validationProbeResult)}
|
|
72
|
+
{renderResult('DB probe response', dbProbeResult)}
|
|
73
|
+
{networkError ? <p className="error">{networkError}</p> : null}
|
|
74
|
+
</main>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
3
77
|
|
|
4
|
-
type HealthResponse = {
|
|
5
|
-
status: string;
|
|
6
|
-
message: string;
|
|
7
|
-
};
|
|
8
|
-
|
|
9
|
-
export default function App() {
|
|
10
|
-
const [data, setData] = useState<HealthResponse | null>(null);
|
|
11
|
-
const [error, setError] = useState<string | null>(null);
|
|
12
|
-
|
|
13
|
-
const checkApi = async () => {
|
|
14
|
-
setError(null);
|
|
15
|
-
try {
|
|
16
|
-
const response = await fetch('/api/health');
|
|
17
|
-
if (!response.ok) {
|
|
18
|
-
throw new Error(`HTTP ${response.status}`);
|
|
19
|
-
}
|
|
20
|
-
const payload = (await response.json()) as HealthResponse;
|
|
21
|
-
setData(payload);
|
|
22
|
-
} catch (err) {
|
|
23
|
-
setError(err instanceof Error ? err.message : 'Unknown error');
|
|
24
|
-
}
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
return (
|
|
28
|
-
<main className="page">
|
|
29
|
-
<h1>Forgeon Fullstack Scaffold</h1>
|
|
30
|
-
<p>Default frontend preset: React + Vite + TypeScript.</p>
|
|
31
|
-
<button onClick={checkApi}>Check API health</button>
|
|
32
|
-
{data ? <pre>{JSON.stringify(data, null, 2)}</pre> : null}
|
|
33
|
-
{error ? <p className="error">{error}</p> : null}
|
|
34
|
-
</main>
|
|
35
|
-
);
|
|
36
|
-
}
|
|
37
78
|
|
|
@@ -8,23 +8,35 @@ body {
|
|
|
8
8
|
color: #0f172a;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
.page {
|
|
12
|
-
max-width: 720px;
|
|
13
|
-
margin: 3rem auto;
|
|
14
|
-
padding: 0 1rem;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
border-radius: 0.5rem;
|
|
11
|
+
.page {
|
|
12
|
+
max-width: 720px;
|
|
13
|
+
margin: 3rem auto;
|
|
14
|
+
padding: 0 1rem;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.actions {
|
|
18
|
+
display: grid;
|
|
19
|
+
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
20
|
+
gap: 0.6rem;
|
|
21
|
+
margin: 1rem 0 1.25rem;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
button {
|
|
25
|
+
padding: 0.6rem 1rem;
|
|
26
|
+
border: 0;
|
|
27
|
+
border-radius: 0.5rem;
|
|
28
|
+
cursor: pointer;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
h3 {
|
|
32
|
+
margin: 1rem 0 0.5rem;
|
|
33
|
+
font-size: 1rem;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
pre {
|
|
37
|
+
background: #e2e8f0;
|
|
38
|
+
padding: 1rem;
|
|
39
|
+
border-radius: 0.5rem;
|
|
28
40
|
overflow: auto;
|
|
29
41
|
}
|
|
30
42
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# MODULE CHECKS
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Define mandatory runtime verification hooks for Forgeon modules.
|
|
6
|
+
|
|
7
|
+
If a module can be validated through a safe API call, it must provide:
|
|
8
|
+
|
|
9
|
+
1. A probe endpoint in API (`/api/health/*`).
|
|
10
|
+
2. A probe trigger on default web page (`apps/web/src/App.tsx`).
|
|
11
|
+
3. A visible result block in UI with HTTP status and JSON body.
|
|
12
|
+
|
|
13
|
+
## Current Baseline Probes
|
|
14
|
+
|
|
15
|
+
- `core-errors`: `GET /api/health/error` (returns error envelope, expected `409`)
|
|
16
|
+
- `core-validation`: `GET /api/health/validation` without `value` (expected `400`)
|
|
17
|
+
- `db-prisma`: `POST /api/health/db` (creates probe user and returns it, expected `201`)
|
|
18
|
+
|
|
19
|
+
## Rules For Future Modules
|
|
20
|
+
|
|
21
|
+
- Probe path should be explicit and feature-scoped (`/api/health/<feature>`).
|
|
22
|
+
- Probe must be deterministic and documented (expected status + payload shape).
|
|
23
|
+
- If probe writes data, it must use clearly marked probe/test records.
|
|
24
|
+
- Probe should not require hidden setup beyond documented env/dependencies.
|
|
25
|
+
- `create-forgeon add <module>` must wire both API probe and web probe UI when feasible.
|
|
@@ -63,3 +63,4 @@ Must contain:
|
|
|
63
63
|
- Contracts package can be imported from both sides without circular dependencies.
|
|
64
64
|
- Contracts package exports are stable from `dist/index` entrypoint.
|
|
65
65
|
- Module has docs under `docs/AI/MODULES/<module-id>.md`.
|
|
66
|
+
- If module behavior can be runtime-checked, it also includes API+Web probe hooks (see `docs/AI/MODULE_CHECKS.md`).
|
|
@@ -48,11 +48,12 @@ Create or update create-forgeon preset flow:
|
|
|
48
48
|
## Add Fullstack Module
|
|
49
49
|
|
|
50
50
|
```text
|
|
51
|
-
Implement `create-forgeon add <module-id>` for a fullstack feature.
|
|
52
|
-
Requirements:
|
|
53
|
-
- split module into contracts/api/web packages
|
|
54
|
-
- contracts is source of truth for routes, DTOs, errors
|
|
55
|
-
- add
|
|
56
|
-
-
|
|
57
|
-
|
|
51
|
+
Implement `create-forgeon add <module-id>` for a fullstack feature.
|
|
52
|
+
Requirements:
|
|
53
|
+
- split module into contracts/api/web packages
|
|
54
|
+
- contracts is source of truth for routes, DTOs, errors
|
|
55
|
+
- if feasible, add module probe hooks in API (`/api/health/*`) and web diagnostics UI
|
|
56
|
+
- add docs note under docs/AI/MODULES/<module-id>.md
|
|
57
|
+
- keep backward compatibility
|
|
58
|
+
```
|
|
58
59
|
|
|
@@ -3,5 +3,15 @@
|
|
|
3
3
|
- `AI/PROJECT.md` - project overview and run modes
|
|
4
4
|
- `AI/ARCHITECTURE.md` - monorepo design and extension model
|
|
5
5
|
- `AI/MODULE_SPEC.md` - fullstack module contract (`contracts/api/web`)
|
|
6
|
+
- `AI/MODULE_CHECKS.md` - required runtime probe hooks for modules
|
|
6
7
|
- `AI/VALIDATION.md` - DTO/env validation standards
|
|
7
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`
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"ok": "OK",
|
|
3
3
|
"checkApiHealth": "Check API health",
|
|
4
|
+
"checkErrorEnvelope": "Check error envelope",
|
|
5
|
+
"checkValidation": "Check validation (expect 400)",
|
|
6
|
+
"checkDatabase": "Check database (create user)",
|
|
4
7
|
"language": "Language",
|
|
8
|
+
"probes": {
|
|
9
|
+
"error": "Error envelope probe"
|
|
10
|
+
},
|
|
5
11
|
"languages": {
|
|
6
|
-
"english": "English"
|
|
7
|
-
"ukrainian": "Ukrainian"
|
|
12
|
+
"english": "English"
|
|
8
13
|
}
|
|
9
14
|
}
|
|
@@ -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
|
|
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.
|
|
@@ -5,22 +5,20 @@ import * as i18nWeb from '@forgeon/i18n-web';
|
|
|
5
5
|
import type { I18nLocale } from '@forgeon/i18n-web';
|
|
6
6
|
import './styles.css';
|
|
7
7
|
|
|
8
|
-
type
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
i18n: string;
|
|
8
|
+
type ProbeResult = {
|
|
9
|
+
statusCode: number;
|
|
10
|
+
body: unknown;
|
|
12
11
|
};
|
|
13
12
|
|
|
14
|
-
function localeLabelKey(locale: I18nLocale): string {
|
|
15
|
-
return locale === 'uk' ? 'common:languages.ukrainian' : 'common:languages.english';
|
|
16
|
-
}
|
|
17
|
-
|
|
18
13
|
export default function App() {
|
|
19
14
|
const { t } = useTranslation(['common']);
|
|
20
15
|
const { I18N_LOCALES, getInitialLocale, persistLocale, toLangQuery } = i18nWeb;
|
|
21
16
|
const [locale, setLocale] = useState<I18nLocale>(getInitialLocale);
|
|
22
|
-
const [
|
|
23
|
-
const [
|
|
17
|
+
const [healthResult, setHealthResult] = useState<ProbeResult | null>(null);
|
|
18
|
+
const [errorProbeResult, setErrorProbeResult] = useState<ProbeResult | null>(null);
|
|
19
|
+
const [validationProbeResult, setValidationProbeResult] = useState<ProbeResult | null>(null);
|
|
20
|
+
const [dbProbeResult, setDbProbeResult] = useState<ProbeResult | null>(null);
|
|
21
|
+
const [networkError, setNetworkError] = useState<string | null>(null);
|
|
24
22
|
|
|
25
23
|
const changeLocale = (nextLocale: I18nLocale) => {
|
|
26
24
|
setLocale(nextLocale);
|
|
@@ -28,24 +26,49 @@ export default function App() {
|
|
|
28
26
|
void i18n.changeLanguage(nextLocale);
|
|
29
27
|
};
|
|
30
28
|
|
|
31
|
-
const
|
|
32
|
-
|
|
29
|
+
const requestProbe = async (path: string, init?: RequestInit): Promise<ProbeResult> => {
|
|
30
|
+
const response = await fetch(`/api${path}${toLangQuery(locale)}`, {
|
|
31
|
+
...init,
|
|
32
|
+
headers: {
|
|
33
|
+
...(init?.headers ?? {}),
|
|
34
|
+
'Accept-Language': locale,
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
let body: unknown = null;
|
|
39
|
+
try {
|
|
40
|
+
body = await response.json();
|
|
41
|
+
} catch {
|
|
42
|
+
body = { message: 'Non-JSON response' };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
statusCode: response.status,
|
|
47
|
+
body,
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const runProbe = async (
|
|
52
|
+
setter: (value: ProbeResult | null) => void,
|
|
53
|
+
path: string,
|
|
54
|
+
init?: RequestInit,
|
|
55
|
+
) => {
|
|
56
|
+
setNetworkError(null);
|
|
33
57
|
try {
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
'Accept-Language': locale,
|
|
37
|
-
},
|
|
38
|
-
});
|
|
39
|
-
if (!response.ok) {
|
|
40
|
-
throw new Error(`HTTP ${response.status}`);
|
|
41
|
-
}
|
|
42
|
-
const payload = (await response.json()) as HealthResponse;
|
|
43
|
-
setData(payload);
|
|
58
|
+
const result = await requestProbe(path, init);
|
|
59
|
+
setter(result);
|
|
44
60
|
} catch (err) {
|
|
45
|
-
|
|
61
|
+
setNetworkError(err instanceof Error ? err.message : 'Unknown error');
|
|
46
62
|
}
|
|
47
63
|
};
|
|
48
64
|
|
|
65
|
+
const renderResult = (title: string, result: ProbeResult | null) => (
|
|
66
|
+
<section>
|
|
67
|
+
<h3>{title}</h3>
|
|
68
|
+
{result ? <pre>{JSON.stringify(result, null, 2)}</pre> : null}
|
|
69
|
+
</section>
|
|
70
|
+
);
|
|
71
|
+
|
|
49
72
|
return (
|
|
50
73
|
<main className="page">
|
|
51
74
|
<h1>Forgeon Fullstack Scaffold</h1>
|
|
@@ -58,13 +81,27 @@ export default function App() {
|
|
|
58
81
|
>
|
|
59
82
|
{I18N_LOCALES.map((item) => (
|
|
60
83
|
<option key={item} value={item}>
|
|
61
|
-
{t(
|
|
84
|
+
{t(`common:languages.${item}`, { defaultValue: item })}
|
|
62
85
|
</option>
|
|
63
86
|
))}
|
|
64
87
|
</select>
|
|
65
|
-
<
|
|
66
|
-
|
|
67
|
-
|
|
88
|
+
<div className="actions">
|
|
89
|
+
<button onClick={() => runProbe(setHealthResult, '/health')}>{t('common:checkApiHealth')}</button>
|
|
90
|
+
<button onClick={() => runProbe(setErrorProbeResult, '/health/error')}>
|
|
91
|
+
{t('common:checkErrorEnvelope')}
|
|
92
|
+
</button>
|
|
93
|
+
<button onClick={() => runProbe(setValidationProbeResult, '/health/validation')}>
|
|
94
|
+
{t('common:checkValidation')}
|
|
95
|
+
</button>
|
|
96
|
+
<button onClick={() => runProbe(setDbProbeResult, '/health/db', { method: 'POST' })}>
|
|
97
|
+
{t('common:checkDatabase')}
|
|
98
|
+
</button>
|
|
99
|
+
</div>
|
|
100
|
+
{renderResult('Health response', healthResult)}
|
|
101
|
+
{renderResult('Error probe response', errorProbeResult)}
|
|
102
|
+
{renderResult('Validation probe response', validationProbeResult)}
|
|
103
|
+
{renderResult('DB probe response', dbProbeResult)}
|
|
104
|
+
{networkError ? <p className="error">{networkError}</p> : null}
|
|
68
105
|
</main>
|
|
69
106
|
);
|
|
70
107
|
}
|
|
@@ -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"
|
|
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];
|