@edgekits/i18n-core 0.2.1 → 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/CHANGELOG.md +51 -0
- package/README.md +124 -38
- package/dist/assemble.d.ts +5 -0
- package/dist/assemble.js +6 -1
- package/dist/cli/collect.d.ts +13 -0
- package/dist/cli/collect.js +54 -0
- package/dist/cli/dev-vars.d.ts +8 -0
- package/dist/cli/dev-vars.js +13 -0
- package/dist/cli/generate.d.ts +16 -4
- package/dist/cli/generate.js +171 -20
- package/dist/cli/hashes.d.ts +23 -3
- package/dist/cli/hashes.js +56 -2
- package/dist/cli/index.js +344 -187
- package/dist/cli/wrangler.d.ts +16 -2
- package/dist/cli/wrangler.js +63 -10
- package/dist/create-i18n.js +3 -3
- package/dist/deep-merge.d.ts +4 -1
- package/dist/deep-merge.js +7 -6
- package/dist/fetcher.js +80 -66
- package/dist/format.d.ts +15 -5
- package/dist/format.js +41 -13
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/key-format.js +5 -2
- package/dist/types.d.ts +2 -1
- package/dist/types.js +19 -1
- package/package.json +2 -2
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 {
|
|
7
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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>
|
|
27
|
-
|
|
28
|
-
--wrangler <path>
|
|
29
|
-
|
|
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
|
|
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
|
|
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 } =
|
|
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
|
-
|
|
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
|
|
57
|
-
function
|
|
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 =
|
|
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
|
|
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
|
-
//
|
|
99
|
-
// Used to detect which namespaces
|
|
100
|
-
// TRACKED in git: the committed copy is the
|
|
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
|
-
|
|
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
|
|
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 (
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
|
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({
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
// -
|
|
433
|
+
// - Both purge rounds of every changed and removed URL succeeded.
|
|
297
434
|
//
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
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
|
-
|
|
305
|
-
|
|
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
|
|
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
|
-
'
|
|
317
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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 (
|
|
352
|
-
|
|
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
|
|
359
|
-
*
|
|
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
|
|
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
|
|
366
|
-
|
|
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
|
|
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
|
|
390
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
560
|
+
const git = (args) => execFileSync('git', args, { cwd: paths.root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
415
561
|
try {
|
|
416
|
-
|
|
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
|
|
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 (
|
|
588
|
+
if (same)
|
|
431
589
|
return;
|
|
432
|
-
console.error(`\n[i18n] ❌ ${shownPath}
|
|
433
|
-
|
|
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
|
|
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
|
|
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' +
|