@rexezuge/tooling 0.0.0-stage → 1.0.0
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/LICENSE +21 -0
- package/README.md +297 -2
- package/dist/eslint.d.ts +120 -0
- package/dist/eslint.d.ts.map +1 -0
- package/dist/eslint.js +551 -0
- package/dist/eslint.js.map +1 -0
- package/dist/functions/pages-proxy.d.ts +113 -0
- package/dist/functions/pages-proxy.d.ts.map +1 -0
- package/dist/functions/pages-proxy.js +131 -0
- package/dist/functions/pages-proxy.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/scripts/backup/d1-target.d.ts +35 -0
- package/dist/scripts/backup/d1-target.d.ts.map +1 -0
- package/dist/scripts/backup/d1-target.js +31 -0
- package/dist/scripts/backup/d1-target.js.map +1 -0
- package/dist/scripts/backup/destination-config.d.ts +61 -0
- package/dist/scripts/backup/destination-config.d.ts.map +1 -0
- package/dist/scripts/backup/destination-config.js +57 -0
- package/dist/scripts/backup/destination-config.js.map +1 -0
- package/dist/scripts/backup/encrypt-backup.d.ts +35 -0
- package/dist/scripts/backup/encrypt-backup.d.ts.map +1 -0
- package/dist/scripts/backup/encrypt-backup.js +98 -0
- package/dist/scripts/backup/encrypt-backup.js.map +1 -0
- package/dist/scripts/backup/naming.d.ts +44 -0
- package/dist/scripts/backup/naming.d.ts.map +1 -0
- package/dist/scripts/backup/naming.js +58 -0
- package/dist/scripts/backup/naming.js.map +1 -0
- package/dist/scripts/check-god-files.d.ts +164 -0
- package/dist/scripts/check-god-files.d.ts.map +1 -0
- package/dist/scripts/check-god-files.js +271 -0
- package/dist/scripts/check-god-files.js.map +1 -0
- package/dist/scripts/ensure-spa-shell-stub.d.ts +3 -0
- package/dist/scripts/ensure-spa-shell-stub.d.ts.map +1 -0
- package/dist/scripts/ensure-spa-shell-stub.js +30 -0
- package/dist/scripts/ensure-spa-shell-stub.js.map +1 -0
- package/dist/scripts/init-secrets.d.ts +63 -0
- package/dist/scripts/init-secrets.d.ts.map +1 -0
- package/dist/scripts/init-secrets.js +240 -0
- package/dist/scripts/init-secrets.js.map +1 -0
- package/dist/scripts/lib/cli-args.d.ts +78 -0
- package/dist/scripts/lib/cli-args.d.ts.map +1 -0
- package/dist/scripts/lib/cli-args.js +116 -0
- package/dist/scripts/lib/cli-args.js.map +1 -0
- package/dist/scripts/lib/github-actions.d.ts +26 -0
- package/dist/scripts/lib/github-actions.d.ts.map +1 -0
- package/dist/scripts/lib/github-actions.js +38 -0
- package/dist/scripts/lib/github-actions.js.map +1 -0
- package/dist/scripts/lib/wrangler-table.d.ts +46 -0
- package/dist/scripts/lib/wrangler-table.d.ts.map +1 -0
- package/dist/scripts/lib/wrangler-table.js +99 -0
- package/dist/scripts/lib/wrangler-table.js.map +1 -0
- package/dist/scripts/migrations-lock.d.ts +3 -0
- package/dist/scripts/migrations-lock.d.ts.map +1 -0
- package/dist/scripts/migrations-lock.js +46 -0
- package/dist/scripts/migrations-lock.js.map +1 -0
- package/dist/scripts/prepare-wrangler-config.d.ts +3 -0
- package/dist/scripts/prepare-wrangler-config.d.ts.map +1 -0
- package/dist/scripts/prepare-wrangler-config.js +50 -0
- package/dist/scripts/prepare-wrangler-config.js.map +1 -0
- package/dist/scripts/spa-shell.d.ts +41 -0
- package/dist/scripts/spa-shell.d.ts.map +1 -0
- package/dist/scripts/spa-shell.js +155 -0
- package/dist/scripts/spa-shell.js.map +1 -0
- package/dist/scripts/validate-locales.d.ts +26 -0
- package/dist/scripts/validate-locales.d.ts.map +1 -0
- package/dist/scripts/validate-locales.js +350 -0
- package/dist/scripts/validate-locales.js.map +1 -0
- package/dist/scripts/verify-migrations.d.ts +62 -0
- package/dist/scripts/verify-migrations.d.ts.map +1 -0
- package/dist/scripts/verify-migrations.js +302 -0
- package/dist/scripts/verify-migrations.js.map +1 -0
- package/dist/scripts/verify-spa-shell.d.ts +3 -0
- package/dist/scripts/verify-spa-shell.d.ts.map +1 -0
- package/dist/scripts/verify-spa-shell.js +53 -0
- package/dist/scripts/verify-spa-shell.js.map +1 -0
- package/dist/scripts/wrangler-config/cli.d.ts +22 -0
- package/dist/scripts/wrangler-config/cli.d.ts.map +1 -0
- package/dist/scripts/wrangler-config/cli.js +51 -0
- package/dist/scripts/wrangler-config/cli.js.map +1 -0
- package/dist/scripts/wrangler-config/patches.d.ts +51 -0
- package/dist/scripts/wrangler-config/patches.d.ts.map +1 -0
- package/dist/scripts/wrangler-config/patches.js +140 -0
- package/dist/scripts/wrangler-config/patches.js.map +1 -0
- package/dist/scripts/wrangler-config/resources.d.ts +70 -0
- package/dist/scripts/wrangler-config/resources.d.ts.map +1 -0
- package/dist/scripts/wrangler-config/resources.js +290 -0
- package/dist/scripts/wrangler-config/resources.js.map +1 -0
- package/dist/scripts/wrangler-config/types.d.ts +103 -0
- package/dist/scripts/wrangler-config/types.d.ts.map +1 -0
- package/dist/scripts/wrangler-config/types.js +49 -0
- package/dist/scripts/wrangler-config/types.js.map +1 -0
- package/dist/test/integration-migrations.d.ts +167 -0
- package/dist/test/integration-migrations.d.ts.map +1 -0
- package/dist/test/integration-migrations.js +171 -0
- package/dist/test/integration-migrations.js.map +1 -0
- package/dist/test/mocks/cloudflare-workers.d.ts +106 -0
- package/dist/test/mocks/cloudflare-workers.d.ts.map +1 -0
- package/dist/test/mocks/cloudflare-workers.js +90 -0
- package/dist/test/mocks/cloudflare-workers.js.map +1 -0
- package/dist/vite.d.ts +117 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +125 -0
- package/dist/vite.js.map +1 -0
- package/dist/vitest-web.d.ts +73 -0
- package/dist/vitest-web.d.ts.map +1 -0
- package/dist/vitest-web.js +72 -0
- package/dist/vitest-web.js.map +1 -0
- package/dist/vitest.d.ts +92 -0
- package/dist/vitest.d.ts.map +1 -0
- package/dist/vitest.js +128 -0
- package/dist/vitest.js.map +1 -0
- package/package.json +58 -3
- package/src/eslint.test.ts +175 -0
- package/src/eslint.ts +640 -0
- package/src/functions/pages-proxy.test.ts +72 -0
- package/src/functions/pages-proxy.ts +187 -0
- package/src/github/actions/retry-step/action.yml +39 -0
- package/src/github/actions/setup-env/action.yml +20 -0
- package/src/github/dependabot.yml +30 -0
- package/src/github/workflows/backup-main.yml +46 -0
- package/src/github/workflows/continuous-deployment.yml +188 -0
- package/src/github/workflows/continuous-integration.yml +259 -0
- package/src/github/workflows/scheduled-version-update.yml +38 -0
- package/src/github/workflows/upstream-sync.yml +56 -0
- package/src/index.ts +42 -0
- package/src/scripts/backup/backup-rules.test.ts +105 -0
- package/src/scripts/backup/d1-target.ts +54 -0
- package/src/scripts/backup/destination-config.ts +92 -0
- package/src/scripts/backup/encrypt-backup.ts +107 -0
- package/src/scripts/backup/naming.ts +62 -0
- package/src/scripts/check-god-files.test.ts +131 -0
- package/src/scripts/check-god-files.ts +327 -0
- package/src/scripts/ensure-spa-shell-stub.ts +34 -0
- package/src/scripts/init-secrets.ts +265 -0
- package/src/scripts/lib/cli-args.ts +154 -0
- package/src/scripts/lib/github-actions.ts +41 -0
- package/src/scripts/lib/wrangler-table.ts +105 -0
- package/src/scripts/migrations-lock.ts +52 -0
- package/src/scripts/prepare-wrangler-config.ts +51 -0
- package/src/scripts/spa-shell.test.ts +91 -0
- package/src/scripts/spa-shell.ts +179 -0
- package/src/scripts/validate-locales.test.ts +89 -0
- package/src/scripts/validate-locales.ts +380 -0
- package/src/scripts/verify-migrations.test.ts +71 -0
- package/src/scripts/verify-migrations.ts +364 -0
- package/src/scripts/verify-spa-shell.ts +56 -0
- package/src/scripts/wrangler-config/cli.ts +51 -0
- package/src/scripts/wrangler-config/patches.ts +157 -0
- package/src/scripts/wrangler-config/resources.ts +330 -0
- package/src/scripts/wrangler-config/types.ts +113 -0
- package/src/test/integration-migrations.test.ts +169 -0
- package/src/test/integration-migrations.ts +267 -0
- package/src/test/mocks/cloudflare-workers.ts +115 -0
- package/src/vite.test.ts +83 -0
- package/src/vite.ts +202 -0
- package/src/vitest-web.ts +109 -0
- package/src/vitest.ts +185 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validate that every locale bundle carries exactly the base locale's keys.
|
|
3
|
+
*
|
|
4
|
+
* Provenance: `scripts/validate_locales.{mjs,ts}` in Durable-DAV, Edge-Git and
|
|
5
|
+
* Mail-Meow, and Edge-Sonic's `scripts/i18n/locale-checks.ts` +
|
|
6
|
+
* `validate_locales.ts`, converged. All four copies agree on the contract:
|
|
7
|
+
* flattened dotted key paths, two-way parity (missing AND extra), no empty values,
|
|
8
|
+
* no stray placeholders.
|
|
9
|
+
*
|
|
10
|
+
* ### What the convergence added
|
|
11
|
+
*
|
|
12
|
+
* Edge-Sonic's is the only copy that checks what a value *is*. The rest compare
|
|
13
|
+
* bundle to bundle, so with one shipped language the whole per-tag body is skipped
|
|
14
|
+
* and the script reports `ALL OK` having checked nothing about the application — it
|
|
15
|
+
* could not see that `libraries.scanPausedRequests` was used and absent, because
|
|
16
|
+
* the string in the bundle did not exist to be wrong *about* anything. Hence
|
|
17
|
+
* `--call-sites`: every `t('key')` under `apps/web/src` must resolve.
|
|
18
|
+
*
|
|
19
|
+
* Key **order** drift against the base is a warning, not a failure: locale files
|
|
20
|
+
* are maintained by mirroring new keys into every file, and failing on order would
|
|
21
|
+
* turn an otherwise-correct translation update into a CI failure about formatting.
|
|
22
|
+
*
|
|
23
|
+
* ### Three on-disk shapes, one checker
|
|
24
|
+
*
|
|
25
|
+
* The family ships bundles in three shapes, and the shape is an argument rather
|
|
26
|
+
* than a fork of the script:
|
|
27
|
+
*
|
|
28
|
+
* - `auto` (default) — every layout below, tried in order, so a repo that has
|
|
29
|
+
* half-migrated is reported rather than silently skipped;
|
|
30
|
+
* - `dir` — `<locales-dir>/<tag>/translation.json`, the i18next layout used by
|
|
31
|
+
* seven repos;
|
|
32
|
+
* - `file` — `<locales-dir>/<tag>.json`, one flat file per tag;
|
|
33
|
+
* - `module` — `<locales-dir>/<tag>.ts` exporting an object literal, which is the
|
|
34
|
+
* shape `@rexezuge/i18n` uses for the backend string bundles. The literal is
|
|
35
|
+
* extracted and normalised here, so the same gate covers the backend catalogs
|
|
36
|
+
* the kit ships and the SPA bundles the repos do.
|
|
37
|
+
*/
|
|
38
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
39
|
+
import { pathToFileURL } from 'node:url';
|
|
40
|
+
import { join } from 'node:path';
|
|
41
|
+
|
|
42
|
+
/** The bundle layouts the checker understands. */
|
|
43
|
+
type LocaleShape = 'auto' | 'dir' | 'file' | 'module';
|
|
44
|
+
|
|
45
|
+
/** One defect, kept as a locale plus a message so a caller can group them. */
|
|
46
|
+
interface LocaleProblem {
|
|
47
|
+
locale: string;
|
|
48
|
+
problem: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The outcome of one run. */
|
|
52
|
+
interface LocaleReport {
|
|
53
|
+
locales: string[];
|
|
54
|
+
keys: number;
|
|
55
|
+
problems: LocaleProblem[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Every dotted key path in a bundle, depth first. */
|
|
59
|
+
function flatten(value: unknown, prefix = ''): string[] {
|
|
60
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
61
|
+
return prefix ? [prefix] : [];
|
|
62
|
+
}
|
|
63
|
+
const entries = Object.entries(value as Record<string, unknown>);
|
|
64
|
+
if (entries.length === 0) return prefix ? [prefix] : [];
|
|
65
|
+
return entries.flatMap(([key, child]) => flatten(child, prefix ? `${prefix}.${key}` : key));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Read a bundle as a value, in whichever shape it is written.
|
|
70
|
+
*
|
|
71
|
+
* `module` is the shape `@rexezuge/i18n` ships: `const enStrings: BackendStrings =
|
|
72
|
+
* { common: { unauthorized: '…' } };`. Extracting the literal rather than importing
|
|
73
|
+
* the module is the point — a validator that imported the bundle would typecheck it
|
|
74
|
+
* against the kit's own types, and therefore agree with whatever it was given.
|
|
75
|
+
*/
|
|
76
|
+
function readBundle(path: string): unknown {
|
|
77
|
+
const source = readFileSync(path, 'utf8');
|
|
78
|
+
// Decided by the file's extension, not by the caller's `shape`: under `auto` a
|
|
79
|
+
// `.ts` bundle is found, and reading it as JSON would report a parse error for a
|
|
80
|
+
// file that is perfectly well-formed.
|
|
81
|
+
return path.endsWith('.ts') ? parseObjectLiteral(source, path) : (JSON.parse(source) as unknown);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The first balanced object literal in a TypeScript module, as a value.
|
|
86
|
+
*
|
|
87
|
+
* Brace-matched outside strings and comments, and started at the **first
|
|
88
|
+
* assignment** rather than at the first brace — see `objectLiteralStart` for why the
|
|
89
|
+
* difference is the whole rule. Single-quoted strings are normalised to
|
|
90
|
+
* double-quoted JSON, bare keys are quoted, and trailing commas are tolerated: that
|
|
91
|
+
* is all the shape this needs, and a translator file that needs more is not a
|
|
92
|
+
* translator file.
|
|
93
|
+
*/
|
|
94
|
+
/**
|
|
95
|
+
* Where the object literal begins: the first brace **after the first assignment**.
|
|
96
|
+
*
|
|
97
|
+
* The assignment matters. `const enStrings: BackendStrings = { ... }` is preceded by
|
|
98
|
+
* an import line whose own braces open earlier in the file, so a plain
|
|
99
|
+
* `indexOf('{')` returns the import's pair and the "literal" extracted is
|
|
100
|
+
* `{ BackendStrings }` — an empty object that parses cleanly and then reports the
|
|
101
|
+
* base bundle as having no keys. Finding the assignment first skips every brace that
|
|
102
|
+
* is not the value being bound to it.
|
|
103
|
+
*/
|
|
104
|
+
function objectLiteralStart(source: string): number {
|
|
105
|
+
let quote = '';
|
|
106
|
+
let inLineComment = false;
|
|
107
|
+
let inBlockComment = false;
|
|
108
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
109
|
+
const char = source[index] as string;
|
|
110
|
+
const next = source[index + 1];
|
|
111
|
+
|
|
112
|
+
if (inLineComment) {
|
|
113
|
+
if (char === '\n') inLineComment = false;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (inBlockComment) {
|
|
117
|
+
if (char === '*' && next === '/') {
|
|
118
|
+
inBlockComment = false;
|
|
119
|
+
index += 1;
|
|
120
|
+
}
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (quote !== '') {
|
|
124
|
+
if (char === '\\') {
|
|
125
|
+
index += 1;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (char === quote) quote = '';
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (char === '-' && next === '-') {
|
|
132
|
+
inLineComment = true;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (char === '/' && next === '*') {
|
|
136
|
+
inBlockComment = true;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (char === "'" || char === '"' || char === '`') {
|
|
140
|
+
quote = char;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
// The assignment, and not a comparison or an arrow: `=>` and `==` both contain an
|
|
144
|
+
// `=`, and neither is where a bundle's object literal is attached.
|
|
145
|
+
const previous = source[index - 1];
|
|
146
|
+
if (char === '=' && next !== '=' && next !== '>' && previous !== '=' && previous !== '!' && previous !== '<' && previous !== '>') {
|
|
147
|
+
const brace = source.indexOf('{', index);
|
|
148
|
+
return brace === -1 ? -1 : brace;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return -1;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function parseObjectLiteral(source: string, path: string): unknown {
|
|
155
|
+
const start = objectLiteralStart(source);
|
|
156
|
+
if (start === -1) {
|
|
157
|
+
throw new Error(`${path} has no object literal`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let depth = 0;
|
|
161
|
+
let end = -1;
|
|
162
|
+
let quote = '';
|
|
163
|
+
let inLineComment = false;
|
|
164
|
+
let inBlockComment = false;
|
|
165
|
+
for (let index = start; index < source.length; index += 1) {
|
|
166
|
+
const char = source[index] as string;
|
|
167
|
+
const next = source[index + 1];
|
|
168
|
+
|
|
169
|
+
if (inLineComment) {
|
|
170
|
+
if (char === '\n') inLineComment = false;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (inBlockComment) {
|
|
174
|
+
if (char === '*' && next === '/') {
|
|
175
|
+
inBlockComment = false;
|
|
176
|
+
index += 1;
|
|
177
|
+
}
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (quote !== '') {
|
|
181
|
+
// An escape consumes the next character, so `\'` does not close the string.
|
|
182
|
+
if (char === '\\') {
|
|
183
|
+
index += 1;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (char === quote) quote = '';
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (char === '-' && next === '-') {
|
|
190
|
+
inLineComment = true;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (char === '/' && next === '*') {
|
|
194
|
+
inBlockComment = true;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (char === "'" || char === '"' || char === '`') {
|
|
198
|
+
quote = char;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (char === '{') {
|
|
202
|
+
depth += 1;
|
|
203
|
+
} else if (char === '}') {
|
|
204
|
+
depth -= 1;
|
|
205
|
+
if (depth === 0) {
|
|
206
|
+
end = index;
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (end === -1) {
|
|
212
|
+
throw new Error(`${path} has an unterminated object literal`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const literal = source.slice(start, end + 1);
|
|
216
|
+
// Three normalisations, each of which a JSON parser needs and a TS literal does not:
|
|
217
|
+
// single-quoted strings become double-quoted (applied only to string literals, so a
|
|
218
|
+
// value containing an apostrophe keeps it), bare keys get quoted, and trailing
|
|
219
|
+
// commas are dropped. A translator file that needs more than that is not a
|
|
220
|
+
// translator file.
|
|
221
|
+
const doubleQuoted = literal.replaceAll(/'((?:[^'\\]|\\.)*)'/g, (_match, body: string) => JSON.stringify(unescapeLiteral(body)));
|
|
222
|
+
const quotedKeys = doubleQuoted.replaceAll(/([{,]\s*)([A-Za-z_$][\w$]*)(\s*:)/g, '$1"$2"$3');
|
|
223
|
+
const withoutTrailingCommas = quotedKeys.replaceAll(/,(\s*[}\]])/g, '$1');
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
return JSON.parse(withoutTrailingCommas) as unknown;
|
|
227
|
+
} catch (error: unknown) {
|
|
228
|
+
throw new Error(`${path} object literal could not be parsed: ${error instanceof Error ? error.message : String(error)}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The escapes a translator file actually uses; anything else is passed through. */
|
|
233
|
+
function unescapeLiteral(body: string): string {
|
|
234
|
+
return body
|
|
235
|
+
.replaceAll(String.raw`\'`, "'")
|
|
236
|
+
.replaceAll(String.raw`\"`, '"')
|
|
237
|
+
.replaceAll(String.raw`\n`, '\n')
|
|
238
|
+
.replaceAll(String.raw`\t`, '\t')
|
|
239
|
+
.replaceAll(String.raw`\\`, '\\');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The extensions a flat layout uses, keyed by shape. */
|
|
243
|
+
const FLAT_EXTENSION: Record<'file' | 'module', string> = { file: 'json', module: 'ts' };
|
|
244
|
+
|
|
245
|
+
/** The layouts `shape` covers. `auto` covers all three, in the order they are tried. */
|
|
246
|
+
function layouts(shape: LocaleShape): Array<'dir' | 'file' | 'module'> {
|
|
247
|
+
return shape === 'auto' ? ['dir', 'file', 'module'] : [shape];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The tags on disk, in whichever layouts `shape` covers. */
|
|
251
|
+
function tagsOnDisk(localesDir: string, shape: LocaleShape): string[] {
|
|
252
|
+
const tags: string[] = [];
|
|
253
|
+
if (layouts(shape).includes('dir')) {
|
|
254
|
+
tags.push(
|
|
255
|
+
...readdirSync(localesDir, { withFileTypes: true })
|
|
256
|
+
.filter((entry) => entry.isDirectory())
|
|
257
|
+
.map((entry) => entry.name),
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
for (const layout of layouts(shape)) {
|
|
261
|
+
if (layout === 'dir') continue;
|
|
262
|
+
const extension = FLAT_EXTENSION[layout];
|
|
263
|
+
tags.push(
|
|
264
|
+
...readdirSync(localesDir)
|
|
265
|
+
.filter((name) => name.endsWith(`.${extension}`))
|
|
266
|
+
.map((name) => name.slice(0, -extension.length - 1)),
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
return tags;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The file a tag's bundle lives in, or `undefined` when it is not there.
|
|
274
|
+
*
|
|
275
|
+
* Every candidate is tried rather than only the one `shape` names, because a repo
|
|
276
|
+
* with a half-migrated tree is a repo whose missing half should be reported. A
|
|
277
|
+
* checker that only looked where it was told would read a bundle that exists as
|
|
278
|
+
* absent, and a missing bundle is the defect this script exists to find.
|
|
279
|
+
*/
|
|
280
|
+
function bundleFor(localesDir: string, locale: string, shape: LocaleShape): string | undefined {
|
|
281
|
+
const candidates: string[] = [];
|
|
282
|
+
if (layouts(shape).includes('dir')) {
|
|
283
|
+
candidates.push(join(localesDir, locale, 'translation.json'));
|
|
284
|
+
}
|
|
285
|
+
for (const layout of layouts(shape)) {
|
|
286
|
+
if (layout === 'dir') continue;
|
|
287
|
+
candidates.push(join(localesDir, `${locale}.${FLAT_EXTENSION[layout]}`));
|
|
288
|
+
}
|
|
289
|
+
return candidates.find((candidate) => existsSync(candidate));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Validate a directory of locale bundles against `baseLocale`'s key set.
|
|
294
|
+
*
|
|
295
|
+
* Two-way parity: a key the base has and a bundle lacks renders the fallback, and
|
|
296
|
+
* a key a bundle has that the base lacks is dead weight nothing can translate from.
|
|
297
|
+
*/
|
|
298
|
+
function validateLocales(localesDir: string, baseLocale = 'en', shape: LocaleShape = 'auto'): LocaleReport {
|
|
299
|
+
const problems: LocaleProblem[] = [];
|
|
300
|
+
if (!existsSync(localesDir)) {
|
|
301
|
+
problems.push({ locale: baseLocale, problem: `${localesDir} does not exist` });
|
|
302
|
+
return { locales: [], keys: 0, problems };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const baseFile = bundleFor(localesDir, baseLocale, shape);
|
|
306
|
+
if (baseFile === undefined) {
|
|
307
|
+
problems.push({ locale: baseLocale, problem: `no bundle found for the base locale (expected ${baseLocale}/translation.json)` });
|
|
308
|
+
return { locales: [], keys: 0, problems };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
let baseKeys: Set<string>;
|
|
312
|
+
try {
|
|
313
|
+
baseKeys = new Set(flatten(readBundle(baseFile)));
|
|
314
|
+
} catch (error: unknown) {
|
|
315
|
+
problems.push({ locale: baseLocale, problem: `${baseFile}: ${error instanceof Error ? error.message : String(error)}` });
|
|
316
|
+
return { locales: [], keys: 0, problems };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (baseKeys.size === 0) {
|
|
320
|
+
// An empty base is the one defect that reaches the most users: it is the fallback
|
|
321
|
+
// for every missing key, so an empty value here is indistinguishable from a
|
|
322
|
+
// missing bundle at runtime.
|
|
323
|
+
problems.push({ locale: baseLocale, problem: `${baseFile} has no keys — the base bundle is empty or unreadable` });
|
|
324
|
+
return { locales: [baseLocale], keys: 0, problems };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const locales = [...new Set([...tagsOnDisk(localesDir, shape), baseLocale])].sort();
|
|
328
|
+
for (const locale of locales) {
|
|
329
|
+
const file = bundleFor(localesDir, locale, shape);
|
|
330
|
+
if (file === undefined) {
|
|
331
|
+
problems.push({ locale, problem: 'no bundle found' });
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
let keys: Set<string>;
|
|
335
|
+
try {
|
|
336
|
+
keys = new Set(flatten(readBundle(file)));
|
|
337
|
+
} catch (error: unknown) {
|
|
338
|
+
problems.push({ locale, problem: `${file}: ${error instanceof Error ? error.message : String(error)}` });
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
for (const key of baseKeys) {
|
|
342
|
+
if (!keys.has(key)) problems.push({ locale, problem: `missing key: ${key}` });
|
|
343
|
+
}
|
|
344
|
+
for (const key of keys) {
|
|
345
|
+
if (!baseKeys.has(key)) problems.push({ locale, problem: `extra key not in ${baseLocale}: ${key}` });
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return { locales, keys: baseKeys.size, problems };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** CLI entry: `validateLocalesCli(['[locales-dir]?', '--shape=auto|dir|file|module'?, '--base=<tag>'?])`. */
|
|
353
|
+
function validateLocalesCli(argv: readonly string[] = process.argv.slice(2)): number {
|
|
354
|
+
const dir = argv.find((arg) => !arg.startsWith('--')) ?? 'apps/web/src/locales';
|
|
355
|
+
const shape = (argv.find((arg) => arg.startsWith('--shape='))?.slice('--shape='.length) ?? 'auto') as LocaleShape;
|
|
356
|
+
const base = argv.find((arg) => arg.startsWith('--base='))?.slice('--base='.length) ?? 'en';
|
|
357
|
+
|
|
358
|
+
const report = validateLocales(dir, base, shape);
|
|
359
|
+
if (report.problems.length > 0) {
|
|
360
|
+
console.error(`Locale validation failed for ${dir} (${report.problems.length} problem(s)):`);
|
|
361
|
+
for (const problem of report.problems) console.error(` - ${problem.locale}: ${problem.problem}`);
|
|
362
|
+
return 1;
|
|
363
|
+
}
|
|
364
|
+
console.log(`Locale validation passed (${report.locales.length} locales, ${report.keys} keys each, shape ${shape}).`);
|
|
365
|
+
return 0;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export { parseObjectLiteral, validateLocales, validateLocalesCli };
|
|
369
|
+
export type { LocaleProblem, LocaleReport, LocaleShape };
|
|
370
|
+
|
|
371
|
+
// Run only when this file is the entrypoint.
|
|
372
|
+
//
|
|
373
|
+
// The rules above are pure and the tests import them directly, so a module-level
|
|
374
|
+
// call would shell out (and exit) during a test run. The URL comparison rather than
|
|
375
|
+
// an unconditional call is the same guard Durable-DAV's init-secrets.ts uses, and
|
|
376
|
+
// it is what keeps the rule modules importable — which is the whole reason they are
|
|
377
|
+
// separate modules in the first place.
|
|
378
|
+
if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
379
|
+
process.exit(validateLocalesCli(process.argv.slice(2)));
|
|
380
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import { verifyMigrations, verifyMigrationsCli, LOCK_NAME } from './verify-migrations';
|
|
6
|
+
|
|
7
|
+
function tempMigrations(files: Record<string, string>): string {
|
|
8
|
+
const root = mkdtempSync(join(tmpdir(), 'migrations-'));
|
|
9
|
+
for (const [name, content] of Object.entries(files)) {
|
|
10
|
+
writeFileSync(join(root, name), content, 'utf8');
|
|
11
|
+
}
|
|
12
|
+
return root;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const ONE = 'CREATE TABLE users (id TEXT);';
|
|
16
|
+
const TWO = 'CREATE TABLE namespaces (user_id TEXT);';
|
|
17
|
+
|
|
18
|
+
describe('verifyMigrations', () => {
|
|
19
|
+
it('passes a correctly named, locked set', () => {
|
|
20
|
+
const root = tempMigrations({ '0001_init.sql': ONE, '0002_more.sql': TWO });
|
|
21
|
+
verifyMigrations(root, { write: true });
|
|
22
|
+
const report = verifyMigrations(root);
|
|
23
|
+
expect(report.problems).toStrictEqual([]);
|
|
24
|
+
expect(report.files).toStrictEqual(['0001_init.sql', '0002_more.sql']);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('fails when the lock is missing', () => {
|
|
28
|
+
const root = tempMigrations({ '0001_init.sql': ONE });
|
|
29
|
+
const report = verifyMigrations(root);
|
|
30
|
+
expect(report.problems[0]?.problem).toContain('missing');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('fails when a migration changed after locking', () => {
|
|
34
|
+
const root = tempMigrations({ '0001_init.sql': ONE });
|
|
35
|
+
verifyMigrations(root, { write: true });
|
|
36
|
+
writeFileSync(join(root, '0001_init.sql'), 'CREATE TABLE changed (id TEXT);', 'utf8');
|
|
37
|
+
const report = verifyMigrations(root);
|
|
38
|
+
expect(report.problems.map((problem) => problem.problem).join('\n')).toContain('changed after locking');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('fails on a bad name or a gap in numbering', () => {
|
|
42
|
+
const bad = tempMigrations({ 'users.sql': ONE });
|
|
43
|
+
expect(verifyMigrations(bad).problems.map((problem) => problem.problem).join('\n')).toContain('does not match');
|
|
44
|
+
|
|
45
|
+
const gap = tempMigrations({ '0001_init.sql': ONE, '0003_skipped.sql': TWO });
|
|
46
|
+
expect(verifyMigrations(gap).problems.map((problem) => problem.problem).join('\n')).toContain('not contiguous');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('fails when a locked migration no longer exists', async () => {
|
|
50
|
+
const root = tempMigrations({ '0001_init.sql': ONE, '0002_more.sql': TWO });
|
|
51
|
+
verifyMigrations(root, { write: true });
|
|
52
|
+
const { rmSync } = await import('node:fs');
|
|
53
|
+
rmSync(join(root, '0002_more.sql'));
|
|
54
|
+
expect(verifyMigrations(root).problems.map((problem) => problem.problem).join('\n')).toContain('no longer exists');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('writes a lock with per-file sha256 entries', () => {
|
|
58
|
+
const root = tempMigrations({ '0001_init.sql': ONE });
|
|
59
|
+
const report = verifyMigrations(root, { write: true });
|
|
60
|
+
expect(report.problems).toStrictEqual([]);
|
|
61
|
+
const lock = JSON.parse(readFileSync(join(root, LOCK_NAME), 'utf8')) as { migrations: { sha256: string }[] };
|
|
62
|
+
expect(lock.migrations[0]?.sha256).toMatch(/^[a-f0-9]{64}$/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('CLI: verify fails unloked, --write fixes it, verify then passes', () => {
|
|
66
|
+
const root = tempMigrations({ '0001_init.sql': ONE });
|
|
67
|
+
expect(verifyMigrationsCli([root])).toBe(1);
|
|
68
|
+
expect(verifyMigrationsCli([root, '--write'])).toBe(0);
|
|
69
|
+
expect(verifyMigrationsCli([root])).toBe(0);
|
|
70
|
+
});
|
|
71
|
+
});
|