@edgekits/i18n-core 0.2.2 → 0.3.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/dist/cli/index.js CHANGED
@@ -1,46 +1,85 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFileSync } from 'node:child_process';
3
3
  import fs from 'node:fs';
4
+ import os from 'node:os';
4
5
  import path from 'node:path';
5
6
  import { DEFAULT_GENERATED_DIR, DEFAULT_LOCALES_DIR } from "../types.js";
6
- import { assertValidNames, buildBulkData, buildFallbacksContent, buildRuntimeConstantsContent, buildTypesContent, localesWithTranslations, schemaSourceLocale, } from "./generate.js";
7
- import { diffHashes, purgeUrlsFor } from "./hashes.js";
7
+ import { collectTranslations } from "./collect.js";
8
+ import { parseDevVars } from "./dev-vars.js";
9
+ import { assertNoNullValues, assertValidNames, buildBulkData, buildFallbacksContent, buildRuntimeConstantsContent, buildTypesContent, localesWithTranslations, schemaSourceLocale, translationWarnings, } from "./generate.js";
10
+ import { diffHashes, kvKeysFor, purgeUrlsFor, readBaseline, removedKeys, stableStringify, writeBaseline, } from "./hashes.js";
8
11
  import { CONFIG_FILE, loadI18nConfig } from "./load-config.js";
9
12
  import { purgeTranslationsCache } from "./purge.js";
10
- import { getKvNamespaceId, getWranglerVar, KV_BINDING, resolveWranglerBin } from "./wrangler.js";
13
+ import { KV_BINDING, readWorkerSettings, resolveWranglerBin } from "./wrangler.js";
11
14
  import { checkKeyPrefixZone } from "./zone.js";
15
+ // KV can take up to 60 s to show a write in other locations; the second purge comes after.
16
+ const DEFAULT_PURGE_DELAY_S = 65;
17
+ // Ten minutes: far past KV's settling time, and well inside setTimeout's range.
18
+ const MAX_PURGE_DELAY_S = 600;
12
19
  const HELP = `Usage: edgekits-i18n <command> [--fallbacks] [--config <path>] [--wrangler <path>]
20
+ [--env <name>] [--purge-delay <seconds>] [--purge-all]
13
21
 
14
22
  Reads ${CONFIG_FILE} and <localesDir>/<locale>/<namespace>.json from the config's directory.
15
23
 
16
24
  Commands:
17
25
  bundle Write i18n-data.json, src/i18n.generated.d.ts and <generatedDir>/runtime-constants.ts.
18
26
  No side effects. --fallbacks also writes <generatedDir>/fallbacks.generated.ts.
19
- seed bundle + fallbacks, then push to LOCAL KV (wrangler --local).
20
- .i18n-hashes.json is left untouched: it records production, not local KV.
21
- migrate bundle + fallbacks, then push to production KV, purge the changed edge-cache
22
- entries, and write .i18n-hashes.json. Refuses to run while .i18n-hashes.json
23
- has uncommitted changes.
27
+ seed bundle + fallbacks, then push to LOCAL KV (wrangler --local): the TRANSLATIONS
28
+ preview_id when set (what wrangler dev reads), else its id.
29
+ .i18n-hashes.json is left untouched: it records remote KV, not local.
30
+ migrate bundle + fallbacks, then push to remote KV, delete the keys of removed translation
31
+ files, purge the changed edge-cache entries, wait --purge-delay seconds, purge them
32
+ again, and write .i18n-hashes.json. Refuses to run while the baseline of the KV
33
+ namespace it writes differs from the one committed in .i18n-hashes.json.
24
34
 
25
35
  Options:
26
- --config <path> The ${CONFIG_FILE} to load (default: ./${CONFIG_FILE}). Its directory is the
27
- project root: locales, generated files and .i18n-hashes.json live there.
28
- --wrangler <path> The Worker's wrangler config (default: ./wrangler.jsonc): the TRANSLATIONS id and
29
- CLOUDFLARE_ZONE_ID; .dev.vars and the wrangler binary come from its directory.
36
+ --config <path> The config to load (default: ./${CONFIG_FILE}). Its directory
37
+ is the project root: locales, generated files and .i18n-hashes.json live there.
38
+ --wrangler <path> The Worker's wrangler config (default: ./wrangler.jsonc): the TRANSLATIONS
39
+ binding and CLOUDFLARE_ZONE_ID; .dev.vars and the wrangler binary come from
40
+ its directory.
41
+ --env <name> Read the TRANSLATIONS binding and CLOUDFLARE_ZONE_ID from env.<name> of the
42
+ wrangler config (wrangler does not inherit them), pass --env to wrangler, and
43
+ read .dev.vars.<name> instead of .dev.vars when it exists (as wrangler does).
44
+ --purge-delay <seconds> migrate: the wait between the two purges (default ${DEFAULT_PURGE_DELAY_S}, at most ${MAX_PURGE_DELAY_S}). KV can
45
+ take up to 60 s to show a write in other locations, and a read there in that window
46
+ re-caches the old value. 0 purges once.
47
+ --purge-all migrate: purge every namespace's cache URLs, changed or not. For after a
48
+ git revert of translations, which reverts .i18n-hashes.json with them.
49
+
50
+ Exit status: 0 on success, 1 on an error. migrate also exits 1 when KV was updated but the edge
51
+ cache was not purged (no CLOUDFLARE_ZONE_ID or token, a failed purge); .i18n-hashes.json is then
52
+ not written, so the next migrate retries.
30
53
 
31
54
  Monorepo: keep the config, locales and generated files in one package and pass any one app's
32
55
  Worker config (every app binds the same KV namespace and zone), from the repository root:
33
56
  edgekits-i18n migrate --config packages/i18n/${CONFIG_FILE} --wrangler apps/app/wrangler.jsonc
34
57
  `;
35
58
  const COMMANDS = ['bundle', 'seed', 'migrate'];
36
- // The flags that take a path; each is removed from argv, with its value, before the
59
+ // The flags that take no value. Anything else that is neither the command nor a value flag
60
+ // stops the run: a mistyped flag (`-e staging`) must not run migrate against the default
61
+ // environment.
62
+ const BOOLEAN_FLAGS = ['--fallbacks', '--purge-all', '--help', '-h'];
63
+ // The flags that take a value; each is removed from argv, with its value, before the
37
64
  // command is looked for.
38
- const PATH_FLAGS = ['--config', '--wrangler'];
65
+ const VALUE_FLAGS = {
66
+ '--config': 'a path',
67
+ '--wrangler': 'a path',
68
+ '--env': 'an environment name',
69
+ '--purge-delay': 'a number of seconds',
70
+ };
39
71
  const WRANGLER_FILE = 'wrangler.jsonc';
40
72
  const KV_NAMESPACE_ID = /^[0-9a-f]{32}$/i;
73
+ // A zone id reaches the Purge API's URL path next to the token: only a real id's shape.
74
+ const ZONE_ID = /^[0-9a-f]{32}$/i;
75
+ // An environment name reaches wrangler's argv: only the characters wrangler's own names use.
76
+ const ENV_NAME = /^[A-Za-z0-9_-]+$/;
77
+ // How migrate ends when KV was written but the edge cache was not purged.
78
+ const CACHE_NOT_PURGED = 'KV is updated; the edge cache is not. .i18n-hashes.json was NOT written, so the next ' +
79
+ '`edgekits-i18n migrate` retries the same purge.';
41
80
  await main(process.argv.slice(2));
42
81
  async function main(argv) {
43
- const { args, flags } = extractPathFlags(argv);
82
+ const { args, flags } = extractValueFlags(argv);
44
83
  const command = args.find((arg) => !arg.startsWith('-'));
45
84
  if (!command || args.includes('--help') || args.includes('-h')) {
46
85
  console.log(HELP);
@@ -51,22 +90,33 @@ async function main(argv) {
51
90
  console.error(HELP);
52
91
  process.exit(1);
53
92
  }
54
- await run(command, args, resolvePaths(flags));
93
+ const unknown = args.find((arg, i) => arg.startsWith('-') ? !BOOLEAN_FLAGS.includes(arg) : i !== args.indexOf(command));
94
+ if (unknown !== undefined) {
95
+ console.error(`[i18n] Unknown argument "${unknown}". Run edgekits-i18n --help for the options.`);
96
+ process.exit(1);
97
+ }
98
+ const purgeAll = args.includes('--purge-all');
99
+ if (purgeAll && command !== 'migrate') {
100
+ console.error('[i18n] --purge-all applies to migrate only.');
101
+ process.exit(1);
102
+ }
103
+ await run(command, args, resolvePaths(flags), { ...resolveOptions(flags), purgeAll });
55
104
  }
56
- /** Takes `--config <path>` / `--wrangler <path>` (or `--flag=<path>`) out of argv. */
57
- function extractPathFlags(argv) {
105
+ /** Takes the value flags (`--config <path>`, or `--config=<path>`, …) out of argv. */
106
+ function extractValueFlags(argv) {
58
107
  const args = [];
59
108
  const flags = {};
109
+ const names = Object.keys(VALUE_FLAGS);
60
110
  for (let i = 0; i < argv.length; i++) {
61
111
  const arg = argv[i];
62
- const flag = PATH_FLAGS.find((f) => arg === f || arg.startsWith(`${f}=`));
112
+ const flag = names.find((f) => arg === f || arg.startsWith(`${f}=`));
63
113
  if (!flag) {
64
114
  args.push(arg);
65
115
  continue;
66
116
  }
67
117
  const value = arg === flag ? argv[++i] : arg.slice(flag.length + 1);
68
118
  if (!value || value.startsWith('-')) {
69
- console.error(`[i18n] ${flag} needs a path.`);
119
+ console.error(`[i18n] ${flag} needs ${VALUE_FLAGS[flag]}.`);
70
120
  process.exit(1);
71
121
  }
72
122
  flags[flag] = value;
@@ -95,24 +145,42 @@ function resolvePaths(flags) {
95
145
  // Fixed outputs at the project root.
96
146
  data: path.join(root, 'i18n-data.json'),
97
147
  types: path.join(root, 'src', 'i18n.generated.d.ts'),
98
- // Stores per-namespace content hashes from the last production migrate.
99
- // Used to detect which namespaces actually changed and need cache purging.
100
- // TRACKED in git: the committed copy is the production purge baseline.
101
- // Absence means "first run", all namespaces treated as changed.
148
+ // Per-namespace content hashes from the last migrate, one baseline per KV namespace id.
149
+ // Used to detect which namespaces changed (or were removed) and need cache purging.
150
+ // TRACKED in git: the committed copy is the purge baseline.
151
+ // Absence (or no entry for the id) means "first run", all namespaces treated as changed.
102
152
  hashes: path.join(root, '.i18n-hashes.json'),
103
153
  };
104
154
  }
155
+ function resolveOptions(flags) {
156
+ const env = flags['--env'];
157
+ if (env !== undefined && !ENV_NAME.test(env)) {
158
+ console.error(`[i18n] --env "${env}" is not an environment name (letters, digits, "-" and "_").`);
159
+ process.exit(1);
160
+ }
161
+ const delay = flags['--purge-delay'];
162
+ const purgeDelay = delay === undefined ? DEFAULT_PURGE_DELAY_S : Number(delay);
163
+ if (!Number.isFinite(purgeDelay) || purgeDelay < 0 || purgeDelay > MAX_PURGE_DELAY_S) {
164
+ console.error(`[i18n] --purge-delay "${delay}" is not a number of seconds from 0 to ${MAX_PURGE_DELAY_S}.`);
165
+ process.exit(1);
166
+ }
167
+ return { env, purgeDelay };
168
+ }
105
169
  // A path as the user sees it: relative to where the CLI was run, forward slashes.
106
170
  function shown(file) {
107
171
  return path.relative(process.cwd(), file).split(path.sep).join('/');
108
172
  }
109
- async function run(command, args, paths) {
173
+ function fail(message) {
174
+ console.error(`[i18n] ${message}`);
175
+ process.exit(1);
176
+ }
177
+ function errorMessage(err) {
178
+ return err instanceof Error ? err.message : String(err);
179
+ }
180
+ async function run(command, args, paths, options) {
110
181
  const shouldDeploy = command !== 'bundle';
111
182
  const isLocal = command === 'seed';
112
183
  const shouldGenerateFallbacks = shouldDeploy || args.includes('--fallbacks') || process.env.I18N_GENERATE_FALLBACKS === 'true';
113
- if (command === 'migrate') {
114
- assertCleanPurgeBaseline(paths);
115
- }
116
184
  const config = await loadI18nConfig(paths.config);
117
185
  const defaultLocale = config.defaultLocale;
118
186
  const localesDir = path.join(paths.root, config.localesDir ?? DEFAULT_LOCALES_DIR);
@@ -120,12 +188,34 @@ async function run(command, args, paths) {
120
188
  const outputRuntimeConstants = path.join(generatedDir, 'runtime-constants.ts');
121
189
  const outputFallbacks = path.join(generatedDir, 'fallbacks.generated.ts');
122
190
  // Read before writing anything, so a missing binding fails the run up front.
123
- const kvId = shouldDeploy ? getKvNamespaceId(paths.wranglerConfig, KV_BINDING) : undefined;
191
+ const worker = shouldDeploy ? readWorkerSettings(paths.wranglerConfig, KV_BINDING, options.env) : undefined;
192
+ // seed writes the namespace `wrangler dev` reads (preview_id when set), migrate the id.
193
+ const writeKvId = worker && (isLocal ? (worker.previewId ?? worker.kvId) : worker.kvId);
124
194
  // The id becomes a command-line argument (deploy()); only a real namespace id's shape
125
195
  // may get there.
126
- if (kvId !== undefined && !KV_NAMESPACE_ID.test(kvId)) {
127
- console.error(`[i18n] The ${KV_BINDING} id in ${shown(paths.wranglerConfig)}, "${kvId}", is not a KV namespace id (32 hex characters).`);
128
- process.exit(1);
196
+ if (writeKvId !== undefined && !KV_NAMESPACE_ID.test(writeKvId)) {
197
+ const field = isLocal && worker?.previewId !== undefined ? 'preview_id' : 'id';
198
+ fail(`The ${KV_BINDING} ${field} in ${shown(paths.wranglerConfig)}, "${writeKvId}", is not a KV namespace id (32 hex characters).`);
199
+ }
200
+ if (worker && worker.otherEnvs.length > 0) {
201
+ console.warn(`[i18n] ⚠️ ${shown(paths.wranglerConfig)} binds ${KV_BINDING} to a different id in ` +
202
+ `${worker.otherEnvs.map((name) => `env.${name}`).join(', ')}; this run uses the top-level binding. ` +
203
+ 'Pass --env <name> to target one of those environments.');
204
+ }
205
+ // Two KV namespaces behind one zone and one keyPrefix: their entries share cache URLs, so
206
+ // each migrate purges, and each Worker caches, the other's.
207
+ if (worker && worker.sameZoneScopes.length > 0) {
208
+ console.warn(`[i18n] ⚠️ ${worker.sameZoneScopes.join(', ')} of ${shown(paths.wranglerConfig)} uses the same ` +
209
+ `CLOUDFLARE_ZONE_ID with another ${KV_BINDING} namespace: with one keyPrefix both cache translations ` +
210
+ 'under the same URLs. Use a per-environment keyPrefix (a config per environment).');
211
+ }
212
+ // A zone id from the wrangler config is checked before anything is written; one from the
213
+ // environment or .dev.vars, at the purge.
214
+ if (worker?.zoneId !== undefined && !isLocal && !ZONE_ID.test(worker.zoneId)) {
215
+ fail(`CLOUDFLARE_ZONE_ID "${worker.zoneId}" in ${shown(paths.wranglerConfig)} is not a zone id (32 hex characters).`);
216
+ }
217
+ if (command === 'migrate') {
218
+ assertCleanPurgeBaseline(paths, worker.kvId, worker.topLevelKvId);
129
219
  }
130
220
  // The Worker's wrangler, found before anything is written.
131
221
  let wranglerBin;
@@ -134,24 +224,41 @@ async function run(command, args, paths) {
134
224
  wranglerBin = resolveWranglerBin(paths.wranglerRoot);
135
225
  }
136
226
  catch (err) {
137
- console.error(`[i18n] ${err instanceof Error ? err.message : String(err)}`);
138
- process.exit(1);
227
+ fail(errorMessage(err));
139
228
  }
140
229
  }
141
230
  // -------------------------------------------------------------------------
142
231
  // 1. Collect translations from <localesDir>/<locale>/<namespace>.json
143
232
  // -------------------------------------------------------------------------
144
- const collected = collectTranslations(localesDir);
145
- // The schema is built here too, so an untypeable value (a mixed array) stops the run
146
- // before anything is written.
233
+ if (!fs.existsSync(localesDir)) {
234
+ fail(`Locales directory not found: ${localesDir}`);
235
+ }
236
+ let collected;
237
+ let ignored;
238
+ try {
239
+ ;
240
+ ({ collected, ignored } = collectTranslations(localesDir, shown));
241
+ }
242
+ catch (err) {
243
+ fail(errorMessage(err));
244
+ }
245
+ for (const file of ignored) {
246
+ console.warn(`[i18n] ⚠️ ${file} is in a subfolder of its locale folder and is ignored: a namespace is ` +
247
+ '<localesDir>/<locale>/<namespace>.json.');
248
+ }
249
+ // The schema is built here too, so an untypeable value (a mixed array, a null) stops the
250
+ // run before anything is written.
147
251
  let typesContent;
148
252
  try {
149
253
  assertValidNames(collected, config.languages);
254
+ assertNoNullValues(collected);
150
255
  typesContent = buildTypesContent(collected, defaultLocale);
151
256
  }
152
257
  catch (err) {
153
- console.error(`[i18n] ${err instanceof Error ? err.message : String(err)}`);
154
- process.exit(1);
258
+ fail(errorMessage(err));
259
+ }
260
+ for (const warning of translationWarnings(collected, defaultLocale)) {
261
+ console.warn(`[i18n] ⚠️ ${warning}`);
155
262
  }
156
263
  const locales = localesWithTranslations(collected);
157
264
  if (locales.length === 0) {
@@ -160,18 +267,37 @@ async function run(command, args, paths) {
160
267
  // -------------------------------------------------------------------------
161
268
  // 2. Compute per-namespace content hashes and detect changes
162
269
  // -------------------------------------------------------------------------
163
- // Load previous hashes (absent on first run — all namespaces treated as new)
164
- const previousHashes = fs.existsSync(paths.hashes)
165
- ? JSON.parse(fs.readFileSync(paths.hashes, 'utf8'))
166
- : {};
167
- const { currentHashes, changedKeys } = diffHashes(collected, previousHashes);
168
- if (changedKeys.length > 0) {
169
- console.log(`[i18n] Changed namespaces (${changedKeys.length}):`);
170
- changedKeys.forEach((k) => console.log(` - ${k}`));
270
+ // The parsed baseline file (absent on first run — all namespaces treated as new)
271
+ let baselineFile;
272
+ if (fs.existsSync(paths.hashes)) {
273
+ try {
274
+ baselineFile = JSON.parse(fs.readFileSync(paths.hashes, 'utf8'));
275
+ }
276
+ catch (err) {
277
+ fail(`${shown(paths.hashes)} is not valid JSON: ${errorMessage(err)}`);
278
+ }
171
279
  }
172
- else {
280
+ // The baseline of the KV namespace migrate writes; bundle reads no wrangler config and
281
+ // gets one only when the file holds a single baseline.
282
+ const previousHashes = readBaseline(baselineFile, worker?.kvId, worker?.topLevelKvId);
283
+ const { currentHashes, changedKeys } = diffHashes(collected, previousHashes ?? {});
284
+ const removed = previousHashes ? removedKeys(previousHashes, currentHashes) : [];
285
+ if (previousHashes === undefined) {
286
+ console.log(`[i18n] ${shown(paths.hashes)} holds baselines for several KV namespaces: seed and migrate list the changes.`);
287
+ }
288
+ else if (changedKeys.length === 0 && removed.length === 0) {
173
289
  console.log('[i18n] No translation changes detected.');
174
290
  }
291
+ else {
292
+ if (changedKeys.length > 0) {
293
+ console.log(`[i18n] Changed namespaces (${changedKeys.length}):`);
294
+ changedKeys.forEach((k) => console.log(` - ${k}`));
295
+ }
296
+ if (removed.length > 0) {
297
+ console.log(`[i18n] Removed namespaces (${removed.length}):`);
298
+ removed.forEach((k) => console.log(` - ${k}`));
299
+ }
300
+ }
175
301
  // -------------------------------------------------------------------------
176
302
  // 3. Write KV bulk data, types and runtime constants
177
303
  // -------------------------------------------------------------------------
@@ -206,76 +332,53 @@ async function run(command, args, paths) {
206
332
  // 5. Push to KV + purge changed cache entries via Cloudflare API
207
333
  // seed → local KV only, no purge, hashes untouched (a warm local cache under
208
334
  // `astro dev` is the consumer's to drop: `I18N_CACHE=off`, restart)
209
- // migrate → remote KV + Cloudflare Purge API, hashes written on success
335
+ // migrate → remote KV, removed keys deleted, two purge rounds, hashes written on success
210
336
  // -------------------------------------------------------------------------
211
337
  if (!shouldDeploy) {
212
338
  console.log('[i18n] Skipping KV deploy (bundle). Use `edgekits-i18n seed` or `edgekits-i18n migrate`.');
213
339
  return;
214
340
  }
215
- await deploy({ config, paths, kvId: kvId, wranglerBin: wranglerBin, isLocal, changedKeys, currentHashes });
216
- }
217
- function collectTranslations(localesDir) {
218
- if (!fs.existsSync(localesDir)) {
219
- console.error(`Locales directory not found: ${localesDir}`);
220
- process.exit(1);
221
- }
222
- const localeDirs = fs
223
- .readdirSync(localesDir, { withFileTypes: true })
224
- .filter((d) => d.isDirectory())
225
- .map((d) => d.name)
226
- .filter((locale) => {
227
- const fullPath = path.join(localesDir, locale);
228
- const files = fs.readdirSync(fullPath, { withFileTypes: true });
229
- return files.some((f) => f.isFile() && f.name.endsWith('.json'));
341
+ await deploy({
342
+ config,
343
+ paths,
344
+ options,
345
+ worker: worker,
346
+ writeKvId: writeKvId,
347
+ wranglerBin: wranglerBin,
348
+ isLocal,
349
+ changedKeys,
350
+ removed,
351
+ currentHashes,
352
+ baselineFile,
230
353
  });
231
- const collected = {};
232
- for (const locale of localeDirs) {
233
- const localeDir = path.join(localesDir, locale);
234
- const files = fs
235
- .readdirSync(localeDir, { withFileTypes: true })
236
- .filter((f) => f.isFile() && f.name.endsWith('.json'));
237
- for (const file of files) {
238
- const namespace = path.basename(file.name, '.json');
239
- const raw = fs.readFileSync(path.join(localeDir, file.name), 'utf8');
240
- const json = JSON.parse(raw);
241
- if (!collected[locale])
242
- collected[locale] = {};
243
- collected[locale][namespace] = json;
244
- }
245
- }
246
- return collected;
247
354
  }
248
355
  function writeFile(file, content) {
249
356
  fs.mkdirSync(path.dirname(file), { recursive: true });
250
357
  fs.writeFileSync(file, content, 'utf8');
251
358
  }
252
- async function deploy({ config, paths, kvId, wranglerBin, isLocal, changedKeys, currentHashes, }) {
359
+ /**
360
+ * Run the Worker's wrangler with `args`, then `--config` and `--env` when given. wrangler's
361
+ * JavaScript entry, run by this Node binary: no shell on any platform (no `.cmd` shim on
362
+ * Windows), and the arguments are constants, shape-checked ids and names, and absolute paths.
363
+ * Always an explicit --config, from the Worker root: wrangler resolves `.wrangler/state`
364
+ * (where a local seed lands, and where `wrangler dev` there reads) from the directory of the
365
+ * config it loaded, and without --config a stray wrangler.json there would win over the
366
+ * wrangler.jsonc this CLI read.
367
+ */
368
+ function runWrangler(wranglerBin, paths, options, args) {
369
+ const envArgs = options.env !== undefined ? ['--env', options.env] : [];
370
+ execFileSync(process.execPath, [wranglerBin, ...args, '--config', paths.wranglerConfig, ...envArgs], {
371
+ cwd: paths.wranglerRoot,
372
+ stdio: 'inherit',
373
+ });
374
+ }
375
+ async function deploy({ config, paths, options, worker, writeKvId, wranglerBin, isLocal, changedKeys, removed, currentHashes, baselineFile, }) {
253
376
  const kvFlag = isLocal ? '--local' : '--remote';
254
377
  const target = isLocal ? 'local' : 'remote';
255
378
  // Push all translations to KV
256
379
  console.log(`[i18n] Pushing translations to ${target} KV...`);
257
380
  try {
258
- // wrangler's JavaScript entry, run by this Node binary: no shell on any platform
259
- // (no `.cmd` shim on Windows), and the arguments are constants, the shape-checked id
260
- // and absolute paths.
261
- // Always an explicit --config, from the Worker root: wrangler resolves `.wrangler/state`
262
- // (where a local seed lands, and where `wrangler dev` there reads) from the directory
263
- // of the config it loaded, and without --config a stray wrangler.json there would win
264
- // over the wrangler.jsonc this CLI read.
265
- execFileSync(process.execPath, [
266
- wranglerBin,
267
- 'kv',
268
- 'bulk',
269
- 'put',
270
- paths.data,
271
- kvFlag,
272
- `--namespace-id=${kvId}`,
273
- '--config',
274
- paths.wranglerConfig,
275
- ], {
276
- cwd: paths.wranglerRoot,
277
- stdio: 'inherit',
278
- });
381
+ runWrangler(wranglerBin, paths, options, ['kv', 'bulk', 'put', paths.data, kvFlag, `--namespace-id=${writeKvId}`]);
279
382
  console.log(`[i18n] ✅ KV updated (${target}).`);
280
383
  }
281
384
  catch (err) {
@@ -284,25 +387,62 @@ async function deploy({ config, paths, kvId, wranglerBin, isLocal, changedKeys,
284
387
  }
285
388
  if (isLocal) {
286
389
  // Local KV: no purge (a warm local cache under `astro dev` is the consumer's to drop),
287
- // and the hashes describe production only.
390
+ // and the hashes describe remote KV only.
288
391
  console.log('[i18n] .i18n-hashes.json left untouched (local seed).');
289
392
  return;
290
393
  }
291
394
  // ---------------------------------------------------------------------------
395
+ // The removed translation files: their keys leave KV, their URLs join the purge.
396
+ // ---------------------------------------------------------------------------
397
+ if (removed.length > 0) {
398
+ const keys = kvKeysFor(removed, config.keyPrefix);
399
+ console.log(`[i18n] Deleting ${keys.length} KV key(s) of removed translation files:`);
400
+ keys.forEach((key) => console.log(` - ${key}`));
401
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'edgekits-i18n-'));
402
+ let deleteError;
403
+ try {
404
+ const keysFile = path.join(dir, 'deleted-keys.json');
405
+ fs.writeFileSync(keysFile, JSON.stringify(keys), 'utf8');
406
+ runWrangler(wranglerBin, paths, options, [
407
+ 'kv',
408
+ 'bulk',
409
+ 'delete',
410
+ keysFile,
411
+ '--remote',
412
+ `--namespace-id=${writeKvId}`,
413
+ '--force',
414
+ ]);
415
+ }
416
+ catch (err) {
417
+ deleteError = err ?? new Error('wrangler kv bulk delete failed');
418
+ }
419
+ finally {
420
+ fs.rmSync(dir, { recursive: true, force: true });
421
+ }
422
+ if (deleteError !== undefined) {
423
+ console.error('[i18n] ❌ Failed to delete the removed keys from remote KV.', deleteError);
424
+ fail(CACHE_NOT_PURGED);
425
+ }
426
+ console.log(`[i18n] ✅ ${keys.length} key(s) deleted.`);
427
+ }
428
+ // ---------------------------------------------------------------------------
292
429
  // Graceful recovery for the hash file:
293
430
  //
294
431
  // We update .i18n-hashes.json ONLY when one of these holds:
295
432
  // - No changes: nothing needed purging to begin with.
296
- // - A successful purge of all changed URLs.
433
+ // - Both purge rounds of every changed and removed URL succeeded.
297
434
  //
298
- // If a purge was attempted but failed (network error, rate limit
299
- // saturation, API error, missing credentials), we DO NOT write the file.
300
- // This way, the next `edgekits-i18n migrate` will see the same diff as this
301
- // one and re-attempt the invalidation automatically. No manual intervention,
302
- // no silent cache staleness.
435
+ // Otherwise (missing zone id or token, network error, rate limit saturation,
436
+ // API error) we DO NOT write the file and exit 1: the next `edgekits-i18n
437
+ // migrate` sees the same diff and re-attempts the invalidation.
303
438
  // ---------------------------------------------------------------------------
304
- let shouldWriteHashes = false;
305
- if (changedKeys.length === 0) {
439
+ // `--purge-all`: every current namespace, changed or not (a reverted baseline reports no
440
+ // change for a rollback), plus the removed ones.
441
+ const purgeKeys = options.purgeAll ? [...Object.keys(currentHashes), ...removed] : [...changedKeys, ...removed];
442
+ if (options.purgeAll) {
443
+ console.log(`[i18n] --purge-all: purging all ${purgeKeys.length} namespace URL(s), changed or not.`);
444
+ }
445
+ if (purgeKeys.length === 0) {
306
446
  // Nothing changed — nothing to purge, safe to update hashes.
307
447
  //
308
448
  // Said out loud rather than passed over: a silent no-op on a deploy path is
@@ -310,70 +450,71 @@ async function deploy({ config, paths, kvId, wranglerBin, isLocal, changedKeys,
310
450
  // outcome, so not an error — but the operator has to be able to see it, and
311
451
  // to see WHICH half of the job was skipped.
312
452
  console.log('[i18n] Nothing changed against the recorded baseline, so NO cache entry was purged.\n' +
313
- ' The bulk put above is unconditional, so production KV did receive every value;\n' +
453
+ ' The bulk put above is unconditional, so remote KV did receive every value;\n' +
314
454
  ' it is the edge cache that was left alone, and it holds translations for a year.\n' +
315
455
  ' If you expected a change here, .i18n-hashes.json is describing a state that\n' +
316
- ' production is not actually in.');
317
- shouldWriteHashes = true;
456
+ ' this KV namespace is not actually in (after a git revert of translations, say):\n' +
457
+ ' run migrate --purge-all.');
458
+ writeHashes(paths, baselineFile, worker, currentHashes);
459
+ return;
318
460
  }
319
- else {
320
- // Changes — the purge must succeed before we update hashes.
321
- loadDevVars(paths.wranglerRoot);
322
- const purgeUrls = purgeUrlsFor(changedKeys, config.keyPrefix);
323
- const zoneId = getWranglerVar(paths.wranglerConfig, 'CLOUDFLARE_ZONE_ID') || process.env.CLOUDFLARE_ZONE_ID;
324
- const apiToken = process.env.CLOUDFLARE_CACHEPURGE_API_TOKEN;
325
- if (!zoneId) {
326
- console.warn(`[i18n] ⚠️ CLOUDFLARE_ZONE_ID not found in ${shown(paths.wranglerConfig)} vars or environment. ` +
327
- 'Skipping cache purge — hash file NOT updated. Re-run `edgekits-i18n migrate` after setting the variable.');
328
- }
329
- else if (!apiToken) {
330
- console.warn('[i18n] ⚠️ CLOUDFLARE_CACHEPURGE_API_TOKEN not set in environment. ' +
331
- 'Skipping cache purge — hash file NOT updated. Re-run `edgekits-i18n migrate` after setting the secret.');
332
- }
333
- else {
334
- // Advisory: the Purge API reports success even for URLs it cannot reach, so the
335
- // name is all there is to check. Never decides shouldWriteHashes.
336
- await checkKeyPrefixZone(config.keyPrefix, zoneId, apiToken);
337
- console.log(`[i18n] Purging ${purgeUrls.length} cache entries via Cloudflare API...`);
338
- const purgeSuccess = await purgeTranslationsCache(zoneId, apiToken, purgeUrls);
339
- if (purgeSuccess) {
340
- console.log('[i18n] ✅ Cache purge completed.');
341
- shouldWriteHashes = true;
342
- }
343
- else {
344
- console.error('[i18n] ❌ Cache purge failed — hash file NOT updated. ' +
345
- 'Re-run `edgekits-i18n migrate` to retry the same invalidation.');
346
- // Non-fatal for KV: translations are already pushed. Cache will
347
- // converge on the next successful migrate.
348
- }
349
- }
461
+ loadDevVars(paths.wranglerRoot, options.env);
462
+ const zoneId = worker.zoneId || process.env.CLOUDFLARE_ZONE_ID;
463
+ const apiToken = process.env.CLOUDFLARE_CACHEPURGE_API_TOKEN;
464
+ const scope = options.env !== undefined
465
+ ? `env.${options.env}.vars of ${shown(paths.wranglerConfig)}`
466
+ : `${shown(paths.wranglerConfig)} vars`;
467
+ if (!zoneId) {
468
+ fail(`❌ CLOUDFLARE_ZONE_ID not found in ${scope} or the environment, so the cache purge was skipped. ${CACHE_NOT_PURGED}`);
350
469
  }
351
- if (shouldWriteHashes) {
352
- fs.writeFileSync(paths.hashes, JSON.stringify(currentHashes, null, 2) + '\n', 'utf8');
353
- console.log(`[i18n] ✅ ${shown(paths.hashes)} updated.`);
470
+ if (!ZONE_ID.test(zoneId)) {
471
+ fail(`❌ CLOUDFLARE_ZONE_ID "${zoneId}" is not a zone id (32 hex characters), so the cache purge was skipped. ${CACHE_NOT_PURGED}`);
354
472
  }
473
+ if (!apiToken) {
474
+ fail(`❌ CLOUDFLARE_CACHEPURGE_API_TOKEN not set in the environment or .dev.vars, so the cache purge was skipped. ${CACHE_NOT_PURGED}`);
475
+ }
476
+ // Advisory: the Purge API reports success even for URLs it cannot reach, so the
477
+ // name is all there is to check. Never decides whether the hashes are written.
478
+ await checkKeyPrefixZone(config.keyPrefix, zoneId, apiToken);
479
+ const purgeUrls = purgeUrlsFor(purgeKeys, config.keyPrefix);
480
+ console.log(`[i18n] Purging ${purgeUrls.length} cache entries via Cloudflare API...`);
481
+ if (!(await purgeTranslationsCache(zoneId, apiToken, purgeUrls))) {
482
+ fail(`❌ Cache purge failed. ${CACHE_NOT_PURGED}`);
483
+ }
484
+ // A read of a purged URL in a location where KV still shows the old value caches that
485
+ // old value again, for a year. The same URLs are purged again once KV has settled.
486
+ if (options.purgeDelay > 0) {
487
+ console.log(`[i18n] Waiting ${options.purgeDelay} s for KV to settle, then purging the same ${purgeUrls.length} URL(s) again: ` +
488
+ 'KV can take up to 60 s to show a write in other locations, and a read there meanwhile re-caches the old value.');
489
+ await new Promise((resolve) => setTimeout(resolve, options.purgeDelay * 1000));
490
+ if (!(await purgeTranslationsCache(zoneId, apiToken, purgeUrls))) {
491
+ fail(`❌ The second cache purge failed. ${CACHE_NOT_PURGED}`);
492
+ }
493
+ }
494
+ console.log('[i18n] ✅ Cache purge completed.');
495
+ writeHashes(paths, baselineFile, worker, currentHashes);
496
+ }
497
+ // The baseline of the Worker's KV id replaced in the file, the other namespaces' kept
498
+ // (ADR-0034); an old flat file stays the top-level id's.
499
+ function writeHashes(paths, baselineFile, worker, hashes) {
500
+ const file = writeBaseline(baselineFile, worker.kvId, hashes, worker.topLevelKvId);
501
+ fs.writeFileSync(paths.hashes, JSON.stringify(file, null, 2) + '\n', 'utf8');
502
+ console.log(`[i18n] ✅ ${shown(paths.hashes)} updated.`);
355
503
  }
356
504
  /**
357
505
  * Load the Worker root's .dev.vars into process.env. wrangler dev reads .dev.vars for
358
- * the Worker runtime, but Node does not — so it is parsed here. Never overwrites a
359
- * variable already set in the real environment.
506
+ * the Worker runtime, but Node does not — so it is parsed here, with the same dotenv
507
+ * syntax (dev-vars.ts). With `--env <name>`, `.dev.vars.<name>` replaces `.dev.vars` when it
508
+ * exists, as in wrangler (the two are not merged). Never overwrites a variable already set
509
+ * in the real environment.
360
510
  */
361
- function loadDevVars(wranglerRoot) {
362
- const devVarsPath = path.join(wranglerRoot, '.dev.vars');
511
+ function loadDevVars(wranglerRoot, env) {
512
+ const envPath = env !== undefined ? path.join(wranglerRoot, `.dev.vars.${env}`) : undefined;
513
+ const devVarsPath = envPath !== undefined && fs.existsSync(envPath) ? envPath : path.join(wranglerRoot, '.dev.vars');
363
514
  if (!fs.existsSync(devVarsPath))
364
515
  return;
365
- const lines = fs.readFileSync(devVarsPath, 'utf8').split('\n');
366
- for (const line of lines) {
367
- const trimmed = line.trim();
368
- // Skip empty lines and comments
369
- if (!trimmed || trimmed.startsWith('#'))
370
- continue;
371
- const eqIndex = trimmed.indexOf('=');
372
- if (eqIndex === -1)
373
- continue;
374
- const key = trimmed.slice(0, eqIndex).trim();
375
- const value = trimmed.slice(eqIndex + 1).trim();
376
- if (key && !(key in process.env)) {
516
+ for (const [key, value] of Object.entries(parseDevVars(fs.readFileSync(devVarsPath, 'utf8')))) {
517
+ if (!(key in process.env)) {
377
518
  process.env[key] = value;
378
519
  }
379
520
  }
@@ -381,15 +522,15 @@ function loadDevVars(wranglerRoot) {
381
522
  // ---------------------------------------------------------------------------
382
523
  // Precondition (migrate only): the purge baseline must be clean
383
524
  //
384
- // `.i18n-hashes.json` records what PRODUCTION KV already holds, and the diff
525
+ // `.i18n-hashes.json` records what remote KV already holds, and the diff
385
526
  // against it is the ONLY thing that decides which cache entries get purged.
386
527
  //
387
528
  // Migrate against a modified copy and the diff comes out wrong. Note what that
388
529
  // does and does not break: the bulk put is unconditional, so every value still
389
- // reaches production KV. What is skipped is the PURGE — and translations are
390
- // served `s-maxage=31536000, immutable` (see fetcher.ts), so the stale edge copy
530
+ // reaches KV. What is skipped is the PURGE — and translations are served
531
+ // `s-maxage=31536000, immutable` (see fetcher.ts), so the stale edge copy
391
532
  // never ages out. A purge is the only thing that ends it. The run then writes a
392
- // baseline claiming production is current, and exits 0.
533
+ // baseline claiming KV is current, and exits 0.
393
534
  //
394
535
  // The live page that results is neither the old one nor the new one: keys that
395
536
  // exist only in the shipped fallbacks render beside keys still served stale
@@ -398,49 +539,65 @@ function loadDevVars(wranglerRoot) {
398
539
  // precondition that refuses to run, rather than a warning nobody sees.
399
540
  //
400
541
  // `seed` never writes this file, so it is exempt and never dirties it.
542
+ //
543
+ // Only the baseline of the KV namespace this run writes is compared with the committed
544
+ // file (`git show HEAD:<file>`): a migrate of another namespace earlier in the same job
545
+ // (`migrate --env staging && migrate`) changes only its own entry.
401
546
  // ---------------------------------------------------------------------------
402
547
  /**
403
- * Abort if the purge baseline carries uncommitted changes.
548
+ * Abort if the baseline of `kvId` in the purge baseline file differs from the committed
549
+ * one. `flatOwner`: the top-level KV id, whose baseline an old flat file is.
404
550
  *
405
551
  * Outside a git work tree, or with no git on PATH, this warns and proceeds: a
406
552
  * guard on the deploy path must not be the reason the CLI cannot run in CI
407
553
  * or from a tarball.
408
554
  */
409
- function assertCleanPurgeBaseline(paths) {
410
- // git runs from the project root on the bare file name; messages show the path from
411
- // where the CLI was run.
555
+ function assertCleanPurgeBaseline(paths, kvId, flatOwner) {
556
+ // git runs from the project root on the file's path from there; messages show the path
557
+ // from where the CLI was run.
412
558
  const relativePath = path.relative(paths.root, paths.hashes).split(path.sep).join('/');
413
559
  const shownPath = shown(paths.hashes);
414
- let status;
560
+ const git = (args) => execFileSync('git', args, { cwd: paths.root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
415
561
  try {
416
- // Porcelain v1 is a stable format, and `--` scopes it to the one path, so a
417
- // dirty working tree elsewhere (the usual state) is not this guard's business.
418
- status = execFileSync('git', ['status', '--porcelain', '--', relativePath], {
419
- cwd: paths.root,
420
- encoding: 'utf8',
421
- stdio: ['ignore', 'pipe', 'pipe'],
422
- });
562
+ git(['rev-parse', '--is-inside-work-tree']);
423
563
  }
424
564
  catch (err) {
425
565
  console.warn(`[i18n] ⚠️ Could not check ${shownPath} against git (${err instanceof Error ? err.message.split('\n')[0] : String(err)}).\n` +
426
566
  ' Proceeding WITHOUT the purge-baseline check. If that file carries hashes\n' +
427
- ' that do not describe production, this run will skip the cache purge and say nothing.');
567
+ ' that do not describe KV, this run will skip the cache purge and say nothing.');
568
+ return;
569
+ }
570
+ // The committed file; none when HEAD does not have it (a first migrate, or no commit yet).
571
+ let committed;
572
+ try {
573
+ committed = git(['show', `HEAD:./${relativePath}`]);
574
+ }
575
+ catch {
576
+ committed = undefined;
577
+ }
578
+ const working = fs.existsSync(paths.hashes) ? fs.readFileSync(paths.hashes, 'utf8') : undefined;
579
+ const entryOf = (text) => text === undefined ? {} : readBaseline(JSON.parse(text), kvId, flatOwner);
580
+ let same;
581
+ try {
582
+ same = stableStringify(entryOf(committed)) === stableStringify(entryOf(working));
583
+ }
584
+ catch {
585
+ // Not JSON: the run itself reports the working file; a broken committed one is history.
428
586
  return;
429
587
  }
430
- if (status.trim() === '')
588
+ if (same)
431
589
  return;
432
- console.error(`\n[i18n] ❌ ${shownPath} has uncommitted changes. Refusing to migrate.\n` +
433
- `\n git status --porcelain: ${status.trim()}\n` +
434
- '\n That file is the purge baseline: the record of what production KV already holds.\n' +
590
+ console.error(`\n[i18n] ❌ ${shownPath}: the baseline of KV namespace ${kvId} differs from the committed one (HEAD). Refusing to migrate.\n` +
591
+ '\n That file is the purge baseline: the record of what remote KV already holds.\n' +
435
592
  ' Migrating against a modified copy makes the change diff come out wrong. Every value\n' +
436
593
  ' is still pushed to KV, but the cache purge is driven by that diff, so stale edge\n' +
437
594
  ' entries survive — and translations are cached immutable for a year, so they do not\n' +
438
595
  ' age out on their own. The run would exit 0 as though it had worked.\n' +
439
596
  '\n Two things dirty this file, and they need opposite fixes:\n' +
440
597
  '\n 1. Something other than migrate wrote it (a hand edit, or a pre-package local\n' +
441
- ' seed script). Those hashes lie about production. Discard them, then migrate:\n' +
598
+ ' seed script). Those hashes lie about KV. Discard them, then migrate:\n' +
442
599
  ` git checkout ${shownPath} && npx edgekits-i18n migrate\n` +
443
- '\n 2. A previous `edgekits-i18n migrate` wrote it. Those hashes describe production\n' +
600
+ '\n 2. A previous `edgekits-i18n migrate` wrote it. Those hashes describe KV\n' +
444
601
  ' and are correct. Commit them; migrating again is unnecessary:\n' +
445
602
  ` git add ${shownPath} && git commit\n` +
446
603
  '\n The CLI cannot tell the two apart — the file looks identical either way. If you\n' +