create-forgeon 0.1.20 → 0.1.22

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/README.md CHANGED
@@ -1,8 +1,14 @@
1
- # create-forgeon
2
-
3
- CLI package for generating Forgeon fullstack monorepo projects.
4
-
5
- ## Usage
1
+ # create-forgeon
2
+
3
+ CLI package for generating Forgeon fullstack monorepo projects.
4
+
5
+ > [!WARNING]
6
+ > **Pre-release package. Do not use in production before `1.0.0`.**
7
+ > The project is under active development: each patch can add changes and may introduce breaking regressions.
8
+ >
9
+ > ![warning](https://img.shields.io/badge/STATUS-PRE--RELEASE%20DO%20NOT%20USE-red)
10
+
11
+ ## Usage
6
12
 
7
13
  ```bash
8
14
  npx create-forgeon@latest my-app --i18n true --proxy caddy
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-forgeon",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "description": "Forgeon project generator CLI",
5
5
  "license": "MIT",
6
6
  "author": "Forgeon",
@@ -161,6 +161,15 @@ describe('addModule', () => {
161
161
  'utf8',
162
162
  );
163
163
  assert.match(i18nWebSource, /@forgeon\/i18n-contracts/);
164
+ assert.doesNotMatch(i18nWebSource, /I18N_DEFAULT_LANG/);
165
+
166
+ const i18nContractsIndex = fs.readFileSync(
167
+ path.join(projectRoot, 'packages', 'i18n-contracts', 'src', 'index.ts'),
168
+ 'utf8',
169
+ );
170
+ assert.match(i18nContractsIndex, /from '\.\/generated'/);
171
+ assert.doesNotMatch(i18nContractsIndex, /I18N_DEFAULT_LANG/);
172
+ assert.doesNotMatch(i18nContractsIndex, /I18N_FALLBACK_LANG/);
164
173
 
165
174
  const enCommon = JSON.parse(
166
175
  fs.readFileSync(path.join(projectRoot, 'resources', 'i18n', 'en', 'common.json'), 'utf8'),
@@ -183,9 +192,12 @@ describe('addModule', () => {
183
192
  const i18nTs = fs.readFileSync(path.join(projectRoot, 'apps', 'web', 'src', 'i18n.ts'), 'utf8');
184
193
  assert.match(i18nTs, /initReactI18next/);
185
194
  assert.match(i18nTs, /\.\.\/\.\.\/\.\.\/resources\/i18n\/en\/common\.json/);
195
+ assert.doesNotMatch(i18nTs, /I18N_DEFAULT_LANG/);
186
196
 
187
197
  const rootPackage = fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8');
198
+ assert.match(rootPackage, /"i18n:sync"/);
188
199
  assert.match(rootPackage, /"i18n:check"/);
200
+ assert.match(rootPackage, /"i18n:types"/);
189
201
 
190
202
  const caddyDockerfile = fs.readFileSync(
191
203
  path.join(projectRoot, 'infra', 'docker', 'caddy.Dockerfile'),
@@ -279,7 +279,21 @@ function patchRootPackage(targetRoot) {
279
279
  }
280
280
 
281
281
  const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
282
- ensureScript(packageJson, 'i18n:check', 'pnpm --filter @forgeon/i18n-contracts check:keys');
282
+ ensureScript(
283
+ packageJson,
284
+ 'i18n:sync',
285
+ 'pnpm --filter @forgeon/i18n-contracts i18n:sync',
286
+ );
287
+ ensureScript(
288
+ packageJson,
289
+ 'i18n:check',
290
+ 'pnpm --filter @forgeon/i18n-contracts i18n:check',
291
+ );
292
+ ensureScript(
293
+ packageJson,
294
+ 'i18n:types',
295
+ 'pnpm --filter @forgeon/i18n-contracts i18n:types',
296
+ );
283
297
  writeJson(packagePath, packageJson);
284
298
  }
285
299
 
@@ -87,7 +87,20 @@ export function applyI18nDisabled(targetRoot) {
87
87
  if (fs.existsSync(rootPackagePath)) {
88
88
  const rootPackage = JSON.parse(fs.readFileSync(rootPackagePath, 'utf8'));
89
89
  if (rootPackage.scripts) {
90
+ if (typeof rootPackage.scripts.postinstall === 'string') {
91
+ const nextPostinstall = rootPackage.scripts.postinstall
92
+ .replace(/\s*&&\s*pnpm i18n:sync/g, '')
93
+ .replace(/pnpm i18n:sync\s*&&\s*/g, '')
94
+ .trim();
95
+ if (nextPostinstall.length === 0) {
96
+ delete rootPackage.scripts.postinstall;
97
+ } else {
98
+ rootPackage.scripts.postinstall = nextPostinstall;
99
+ }
100
+ }
101
+ delete rootPackage.scripts['i18n:sync'];
90
102
  delete rootPackage.scripts['i18n:check'];
103
+ delete rootPackage.scripts['i18n:types'];
91
104
  }
92
105
  writeJson(rootPackagePath, rootPackage);
93
106
  }
@@ -2,5 +2,7 @@
2
2
 
3
3
  - i18n is enabled when the module is installed via scaffold/add flow.
4
4
  - Default/fallback are controlled by `I18N_DEFAULT_LANG` and `I18N_FALLBACK_LANG`.
5
- - Locale contracts live in `@forgeon/i18n-contracts`.
5
+ - Locale contracts (`I18N_LOCALES`, `I18N_NAMESPACES`) are generated from `resources/i18n/*` via `pnpm i18n:sync`.
6
+ - Contract validation is available via `pnpm i18n:check`.
7
+ - Translation key type generation is available via `pnpm i18n:types`.
6
8
  - Frontend helpers live in `@forgeon/i18n-web`.
@@ -16,5 +16,11 @@ Packages:
16
16
  Dictionary key validation:
17
17
  - `pnpm i18n:check`
18
18
 
19
+ Locale/namespace contracts sync:
20
+ - `pnpm i18n:sync`
21
+
22
+ Translation key types generation (manual):
23
+ - `pnpm i18n:types`
24
+
19
25
  You can apply i18n later with:
20
26
  `npx create-forgeon@latest add i18n --project .`
@@ -4,7 +4,12 @@ Adds optional i18n support across backend and frontend.
4
4
 
5
5
  Included parts:
6
6
  - `@forgeon/i18n` (NestJS i18n integration package)
7
- - `@forgeon/i18n-contracts` (shared locale/query constants and key checks)
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
10
  - shared dictionaries in `resources/i18n/*` (`en`, `uk`) used by both API and web
11
+
12
+ Utility commands:
13
+ - `pnpm i18n:sync` - regenerate `I18N_LOCALES` and `I18N_NAMESPACES` from `resources/i18n`.
14
+ - `pnpm i18n:check` - verify generated contracts, JSON validity, and missing/extra keys vs fallback locale.
15
+ - `pnpm i18n:types` - generate translation key type unions for autocomplete.
@@ -6,4 +6,7 @@
6
6
  - Frontend `react-i18next` setup (`apps/web/src/i18n.ts`)
7
7
  - Frontend language selector and translated UI in `apps/web/src/App.tsx`
8
8
  - Frontend package/deps/scripts updates for i18n helpers
9
- - Root script `i18n:check` for dictionary key consistency across locales
9
+ - Root scripts:
10
+ - `i18n:sync` for locale/namespace contracts sync from `resources/i18n`
11
+ - `i18n:check` for contract/json/key consistency checks
12
+ - `i18n:types` for translation key type generation
@@ -1,7 +1,6 @@
1
1
  import i18n from 'i18next';
2
2
  import { initReactI18next } from 'react-i18next';
3
- import { I18N_DEFAULT_LANG } from '@forgeon/i18n-contracts';
4
- import { getInitialLocale } from '@forgeon/i18n-web';
3
+ import { getInitialLocale, I18N_LOCALES, type I18nLocale } from '@forgeon/i18n-web';
5
4
  import enCommon from '../../../resources/i18n/en/common.json';
6
5
  import enErrors from '../../../resources/i18n/en/errors.json';
7
6
  import enValidation from '../../../resources/i18n/en/validation.json';
@@ -22,10 +21,12 @@ const resources = {
22
21
  },
23
22
  } as const;
24
23
 
24
+ const fallbackLocale = (I18N_LOCALES[0] ?? 'en') as I18nLocale;
25
+
25
26
  void i18n.use(initReactI18next).init({
26
27
  resources,
27
28
  lng: getInitialLocale(),
28
- fallbackLng: I18N_DEFAULT_LANG,
29
+ fallbackLng: fallbackLocale,
29
30
  interpolation: {
30
31
  escapeValue: false,
31
32
  },
@@ -7,7 +7,9 @@
7
7
  "types": "dist/index.d.ts",
8
8
  "scripts": {
9
9
  "build": "tsc -p tsconfig.json",
10
- "check:keys": "node scripts/check-keys.mjs"
10
+ "i18n:sync": "node scripts/i18n-sync.mjs",
11
+ "i18n:check": "node scripts/i18n-check.mjs",
12
+ "i18n:types": "node scripts/i18n-types.mjs"
11
13
  },
12
14
  "devDependencies": {
13
15
  "@types/node": "^22.10.7",
@@ -0,0 +1,112 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import {
4
+ fail,
5
+ flattenKeys,
6
+ getStructure,
7
+ readGeneratedContracts,
8
+ readJsonFile,
9
+ resourcesRoot,
10
+ sorted,
11
+ success,
12
+ warn,
13
+ } from './i18n-shared.mjs';
14
+
15
+ function diff(expected, actual) {
16
+ return {
17
+ missing: expected.filter((item) => !actual.includes(item)),
18
+ extra: actual.filter((item) => !expected.includes(item)),
19
+ };
20
+ }
21
+
22
+ function main() {
23
+ const issues = [];
24
+ const { locales, namespaces, fallbackLocale } = getStructure();
25
+ const generated = readGeneratedContracts();
26
+
27
+ const localeDiff = diff(locales, generated.locales);
28
+ for (const item of localeDiff.missing) {
29
+ issues.push(`[contracts] missing locale: ${item}`);
30
+ }
31
+ for (const item of localeDiff.extra) {
32
+ issues.push(`[contracts] extra locale: ${item}`);
33
+ }
34
+
35
+ const namespaceDiff = diff(namespaces, generated.namespaces);
36
+ for (const item of namespaceDiff.missing) {
37
+ issues.push(`[contracts] missing namespace: ${item}`);
38
+ }
39
+ for (const item of namespaceDiff.extra) {
40
+ issues.push(`[contracts] extra namespace: ${item}`);
41
+ }
42
+
43
+ const fallbackKeyMap = {};
44
+ for (const namespace of namespaces) {
45
+ const fallbackPath = path.join(resourcesRoot, fallbackLocale, `${namespace}.json`);
46
+ try {
47
+ const fallbackJson = readJsonFile(fallbackPath);
48
+ fallbackKeyMap[namespace] = sorted(flattenKeys(fallbackJson));
49
+ } catch (error) {
50
+ issues.push(
51
+ `[${fallbackLocale}] invalid JSON in ${namespace}.json: ${error instanceof Error ? error.message : String(error)}`,
52
+ );
53
+ }
54
+ }
55
+
56
+ for (const locale of locales) {
57
+ const localeDir = path.join(resourcesRoot, locale);
58
+ const localeNamespaces = fs
59
+ .readdirSync(localeDir, { withFileTypes: true })
60
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
61
+ .map((entry) => entry.name.slice(0, -5));
62
+
63
+ const localeNamespaceDiff = diff(namespaces, sorted(localeNamespaces));
64
+ for (const missingNamespace of localeNamespaceDiff.missing) {
65
+ issues.push(`[${locale}] missing namespace file: ${missingNamespace}.json`);
66
+ }
67
+ for (const extraNamespace of localeNamespaceDiff.extra) {
68
+ issues.push(`[${locale}] extra namespace file: ${extraNamespace}.json`);
69
+ }
70
+
71
+ for (const namespace of namespaces) {
72
+ const localePath = path.join(localeDir, `${namespace}.json`);
73
+ if (!fs.existsSync(localePath)) {
74
+ continue;
75
+ }
76
+
77
+ let localeJson;
78
+ try {
79
+ localeJson = readJsonFile(localePath);
80
+ } catch (error) {
81
+ issues.push(
82
+ `[${locale}] invalid JSON in ${namespace}.json: ${error instanceof Error ? error.message : String(error)}`,
83
+ );
84
+ continue;
85
+ }
86
+
87
+ const localeKeys = sorted(flattenKeys(localeJson));
88
+ const expectedKeys = fallbackKeyMap[namespace] ?? [];
89
+ const keyDiff = diff(expectedKeys, localeKeys);
90
+
91
+ for (const key of keyDiff.missing) {
92
+ issues.push(`[${locale}] missing key in ${namespace}.json: ${key}`);
93
+ }
94
+ for (const key of keyDiff.extra) {
95
+ issues.push(`[${locale}] extra key in ${namespace}.json: ${key}`);
96
+ }
97
+ }
98
+ }
99
+
100
+ if (issues.length > 0) {
101
+ fail('i18n check failed.');
102
+ for (const issue of issues) {
103
+ fail(`- ${issue}`);
104
+ }
105
+ warn('Run `pnpm i18n:sync` if locales/namespaces changed.');
106
+ process.exit(1);
107
+ }
108
+
109
+ success('i18n check passed.');
110
+ }
111
+
112
+ main();
@@ -0,0 +1,162 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const __filename = fileURLToPath(import.meta.url);
6
+ const __dirname = path.dirname(__filename);
7
+
8
+ export const packageRoot = path.resolve(__dirname, '..');
9
+ export const projectRoot = path.resolve(packageRoot, '..', '..');
10
+ export const resourcesRoot = path.join(projectRoot, 'resources', 'i18n');
11
+ export const generatedPath = path.join(packageRoot, 'src', 'generated.ts');
12
+ export const generatedTypesPath = path.join(packageRoot, 'src', 'generated-keys.d.ts');
13
+
14
+ const ANSI = {
15
+ red: '\x1b[31m',
16
+ yellow: '\x1b[33m',
17
+ green: '\x1b[32m',
18
+ cyan: '\x1b[36m',
19
+ reset: '\x1b[0m',
20
+ };
21
+
22
+ function paint(color, text) {
23
+ return `${ANSI[color]}${text}${ANSI.reset}`;
24
+ }
25
+
26
+ export function info(text) {
27
+ console.log(paint('cyan', text));
28
+ }
29
+
30
+ export function success(text) {
31
+ console.log(paint('green', text));
32
+ }
33
+
34
+ export function warn(text) {
35
+ console.log(paint('yellow', text));
36
+ }
37
+
38
+ export function fail(text) {
39
+ console.error(paint('red', text));
40
+ }
41
+
42
+ export function sorted(values) {
43
+ return [...values].sort((a, b) => a.localeCompare(b));
44
+ }
45
+
46
+ export function flattenKeys(value, prefix = '') {
47
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
48
+ return prefix ? [prefix] : [];
49
+ }
50
+
51
+ const entries = Object.entries(value);
52
+ if (entries.length === 0) {
53
+ return prefix ? [prefix] : [];
54
+ }
55
+
56
+ const keys = [];
57
+ for (const [key, nested] of entries) {
58
+ const nextPrefix = prefix ? `${prefix}.${key}` : key;
59
+ keys.push(...flattenKeys(nested, nextPrefix));
60
+ }
61
+ return keys;
62
+ }
63
+
64
+ export function readJsonFile(filePath) {
65
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
66
+ }
67
+
68
+ export function ensureResourcesRoot() {
69
+ if (!fs.existsSync(resourcesRoot)) {
70
+ throw new Error(`Missing resources folder: ${resourcesRoot}`);
71
+ }
72
+ }
73
+
74
+ export function listLocales() {
75
+ ensureResourcesRoot();
76
+ const locales = fs
77
+ .readdirSync(resourcesRoot, { withFileTypes: true })
78
+ .filter((entry) => entry.isDirectory())
79
+ .map((entry) => entry.name);
80
+ return sorted(locales);
81
+ }
82
+
83
+ export function resolveFallbackLocale(locales) {
84
+ const requested = process.env.I18N_FALLBACK_LANG?.trim();
85
+ if (requested && locales.includes(requested)) {
86
+ return requested;
87
+ }
88
+ if (locales.includes('en')) {
89
+ return 'en';
90
+ }
91
+ return locales[0];
92
+ }
93
+
94
+ export function listNamespaceFiles(locale) {
95
+ const localeDir = path.join(resourcesRoot, locale);
96
+ if (!fs.existsSync(localeDir)) {
97
+ return [];
98
+ }
99
+ const namespaces = fs
100
+ .readdirSync(localeDir, { withFileTypes: true })
101
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
102
+ .map((entry) => entry.name.slice(0, -5));
103
+ return sorted(namespaces);
104
+ }
105
+
106
+ export function getStructure() {
107
+ const locales = listLocales();
108
+ if (locales.length === 0) {
109
+ throw new Error(`No locales found in ${resourcesRoot}`);
110
+ }
111
+
112
+ const fallbackLocale = resolveFallbackLocale(locales);
113
+ const namespaces = listNamespaceFiles(fallbackLocale);
114
+ if (namespaces.length === 0) {
115
+ throw new Error(`No namespaces found for fallback locale "${fallbackLocale}"`);
116
+ }
117
+
118
+ return { locales, namespaces, fallbackLocale };
119
+ }
120
+
121
+ export function renderGeneratedContracts({ locales, namespaces }) {
122
+ return `/* AUTO-GENERATED BY \`pnpm i18n:sync\`. DO NOT EDIT MANUALLY. */
123
+
124
+ export const I18N_LOCALES = ${JSON.stringify(locales)} as const;
125
+ export const I18N_NAMESPACES = ${JSON.stringify(namespaces)} as const;
126
+
127
+ export type I18nLocale = (typeof I18N_LOCALES)[number];
128
+ export type I18nNamespace = (typeof I18N_NAMESPACES)[number];
129
+ `;
130
+ }
131
+
132
+ export function parseGeneratedArray(source, constName) {
133
+ const regex = new RegExp(`export const ${constName} = (\\[[^\\n]+\\]) as const;`);
134
+ const match = source.match(regex);
135
+ if (!match) {
136
+ throw new Error(`Could not read ${constName} from generated.ts`);
137
+ }
138
+ return JSON.parse(match[1]);
139
+ }
140
+
141
+ export function readGeneratedContracts() {
142
+ if (!fs.existsSync(generatedPath)) {
143
+ throw new Error(`Missing generated contracts file: ${generatedPath}`);
144
+ }
145
+ const source = fs.readFileSync(generatedPath, 'utf8');
146
+ return {
147
+ locales: parseGeneratedArray(source, 'I18N_LOCALES'),
148
+ namespaces: parseGeneratedArray(source, 'I18N_NAMESPACES'),
149
+ };
150
+ }
151
+
152
+ export function writeTextFile(filePath, content) {
153
+ fs.writeFileSync(filePath, `${content.trimEnd()}\n`, 'utf8');
154
+ }
155
+
156
+ export function readLocaleNamespaceJson(locale, namespace) {
157
+ const filePath = path.join(resourcesRoot, locale, `${namespace}.json`);
158
+ if (!fs.existsSync(filePath)) {
159
+ throw new Error(`Missing file: ${path.relative(projectRoot, filePath)}`);
160
+ }
161
+ return readJsonFile(filePath);
162
+ }
@@ -0,0 +1,19 @@
1
+ import {
2
+ generatedPath,
3
+ getStructure,
4
+ info,
5
+ renderGeneratedContracts,
6
+ success,
7
+ writeTextFile,
8
+ } from './i18n-shared.mjs';
9
+
10
+ function main() {
11
+ const { locales, namespaces, fallbackLocale } = getStructure();
12
+ writeTextFile(generatedPath, renderGeneratedContracts({ locales, namespaces }));
13
+ success('i18n contracts synced.');
14
+ info(`- fallback locale: ${fallbackLocale}`);
15
+ info(`- locales: ${locales.join(', ')}`);
16
+ info(`- namespaces: ${namespaces.join(', ')}`);
17
+ }
18
+
19
+ main();
@@ -0,0 +1,42 @@
1
+ import {
2
+ flattenKeys,
3
+ generatedTypesPath,
4
+ getStructure,
5
+ readLocaleNamespaceJson,
6
+ sorted,
7
+ success,
8
+ writeTextFile,
9
+ } from './i18n-shared.mjs';
10
+
11
+ function escapeQuote(value) {
12
+ return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
13
+ }
14
+
15
+ function main() {
16
+ const { fallbackLocale, namespaces } = getStructure();
17
+ const keys = [];
18
+
19
+ for (const namespace of namespaces) {
20
+ const payload = readLocaleNamespaceJson(fallbackLocale, namespace);
21
+ const flattened = sorted(flattenKeys(payload));
22
+ for (const key of flattened) {
23
+ keys.push(`${namespace}.${key}`);
24
+ }
25
+ }
26
+
27
+ const union =
28
+ keys.length > 0
29
+ ? keys.map((key) => ` | "${escapeQuote(key)}"`).join('\n')
30
+ : ' | never';
31
+
32
+ const content = `/* AUTO-GENERATED BY \`pnpm i18n:types\`. DO NOT EDIT MANUALLY. */
33
+
34
+ export type I18nTranslationKey =
35
+ ${union};
36
+ `;
37
+
38
+ writeTextFile(generatedTypesPath, content);
39
+ success(`i18n types generated (${keys.length} keys).`);
40
+ }
41
+
42
+ main();
@@ -0,0 +1,11 @@
1
+ /* AUTO-GENERATED BY `pnpm i18n:types`. DO NOT EDIT MANUALLY. */
2
+
3
+ export type I18nTranslationKey =
4
+ | "common.checkApiHealth"
5
+ | "common.language"
6
+ | "common.languages.english"
7
+ | "common.languages.ukrainian"
8
+ | "common.ok"
9
+ | "errors.accessDenied"
10
+ | "errors.notFound"
11
+ | "validation.required";
@@ -0,0 +1,7 @@
1
+ /* AUTO-GENERATED BY `pnpm i18n:sync`. DO NOT EDIT MANUALLY. */
2
+
3
+ export const I18N_LOCALES = ["en", "uk"] as const;
4
+ export const I18N_NAMESPACES = ["common", "errors", "validation"] as const;
5
+
6
+ export type I18nLocale = (typeof I18N_LOCALES)[number];
7
+ export type I18nNamespace = (typeof I18N_NAMESPACES)[number];
@@ -1,9 +1,8 @@
1
- export const I18N_LOCALES = ['en', 'uk'] as const;
2
- export const I18N_NAMESPACES = ['common', 'errors', 'validation'] as const;
3
-
4
- export type I18nLocale = (typeof I18N_LOCALES)[number];
5
- export type I18nNamespace = (typeof I18N_NAMESPACES)[number];
6
-
7
- export const I18N_DEFAULT_LANG: I18nLocale = 'en';
8
- export const I18N_FALLBACK_LANG: I18nLocale = 'en';
1
+ export {
2
+ I18N_LOCALES,
3
+ I18N_NAMESPACES,
4
+ type I18nLocale,
5
+ type I18nNamespace,
6
+ } from './generated';
7
+ export type { I18nTranslationKey } from './generated-keys';
9
8
  export const LANG_QUERY_PARAM = 'lang';
@@ -1,11 +1,11 @@
1
1
  import {
2
- I18N_DEFAULT_LANG,
3
2
  I18N_LOCALES,
4
3
  LANG_QUERY_PARAM,
5
4
  type I18nLocale,
6
5
  } from '@forgeon/i18n-contracts';
7
6
 
8
7
  const LOCALE_STORAGE_KEY = 'forgeon.locale';
8
+ const DEFAULT_LOCALE = (I18N_LOCALES[0] ?? 'en') as I18nLocale;
9
9
 
10
10
  type StorageLike = {
11
11
  getItem: (key: string) => string | null;
@@ -35,7 +35,7 @@ export function getInitialLocale(): I18nLocale {
35
35
  if (stored && isSupportedLocale(stored)) {
36
36
  return stored;
37
37
  }
38
- return I18N_DEFAULT_LANG;
38
+ return DEFAULT_LOCALE;
39
39
  }
40
40
 
41
41
  export function persistLocale(locale: I18nLocale): void {
@@ -1,97 +0,0 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { fileURLToPath } from 'node:url';
4
-
5
- const __filename = fileURLToPath(import.meta.url);
6
- const __dirname = path.dirname(__filename);
7
- const projectRoot = path.resolve(__dirname, '..', '..', '..');
8
- const resourcesRoot = path.join(projectRoot, 'resources', 'i18n');
9
- const baseLocale = 'en';
10
-
11
- function readJson(filePath) {
12
- return JSON.parse(fs.readFileSync(filePath, 'utf8'));
13
- }
14
-
15
- function flattenKeys(value, prefix = '') {
16
- if (typeof value !== 'object' || value === null || Array.isArray(value)) {
17
- return prefix ? [prefix] : [];
18
- }
19
-
20
- const entries = Object.entries(value);
21
- if (entries.length === 0) {
22
- return prefix ? [prefix] : [];
23
- }
24
-
25
- const result = [];
26
- for (const [key, nested] of entries) {
27
- const nextPrefix = prefix ? `${prefix}.${key}` : key;
28
- result.push(...flattenKeys(nested, nextPrefix));
29
- }
30
- return result;
31
- }
32
-
33
- function sorted(array) {
34
- return [...array].sort((a, b) => a.localeCompare(b));
35
- }
36
-
37
- function main() {
38
- if (!fs.existsSync(resourcesRoot)) {
39
- console.error(`Missing resources folder: ${resourcesRoot}`);
40
- process.exit(1);
41
- }
42
-
43
- const localeDirs = fs
44
- .readdirSync(resourcesRoot, { withFileTypes: true })
45
- .filter((entry) => entry.isDirectory())
46
- .map((entry) => entry.name);
47
-
48
- if (!localeDirs.includes(baseLocale)) {
49
- console.error(`Missing base locale "${baseLocale}" in ${resourcesRoot}`);
50
- process.exit(1);
51
- }
52
-
53
- const baseLocaleDir = path.join(resourcesRoot, baseLocale);
54
- const baseFiles = fs
55
- .readdirSync(baseLocaleDir)
56
- .filter((name) => name.endsWith('.json'));
57
-
58
- const errors = [];
59
-
60
- for (const locale of localeDirs) {
61
- const localeDir = path.join(resourcesRoot, locale);
62
- for (const fileName of baseFiles) {
63
- const baseFilePath = path.join(baseLocaleDir, fileName);
64
- const localeFilePath = path.join(localeDir, fileName);
65
-
66
- if (!fs.existsSync(localeFilePath)) {
67
- errors.push(`[${locale}] missing file: ${fileName}`);
68
- continue;
69
- }
70
-
71
- const baseKeys = sorted(flattenKeys(readJson(baseFilePath)));
72
- const localeKeys = sorted(flattenKeys(readJson(localeFilePath)));
73
-
74
- const missingKeys = baseKeys.filter((key) => !localeKeys.includes(key));
75
- const extraKeys = localeKeys.filter((key) => !baseKeys.includes(key));
76
-
77
- for (const key of missingKeys) {
78
- errors.push(`[${locale}] missing key in ${fileName}: ${key}`);
79
- }
80
- for (const key of extraKeys) {
81
- errors.push(`[${locale}] extra key in ${fileName}: ${key}`);
82
- }
83
- }
84
- }
85
-
86
- if (errors.length > 0) {
87
- console.error('i18n key check failed:');
88
- for (const error of errors) {
89
- console.error(`- ${error}`);
90
- }
91
- process.exit(1);
92
- }
93
-
94
- console.log('i18n key check passed.');
95
- }
96
-
97
- main();