@edgekits/astro-i18n 0.1.8 → 0.2.1

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +92 -28
  3. package/dist/integration/index.d.ts +25 -4
  4. package/dist/integration/index.js +35 -9
  5. package/dist/integration/vite-plugin.d.ts +1 -1
  6. package/dist/types.d.ts +7 -55
  7. package/dist/types.js +1 -44
  8. package/package.json +7 -17
  9. package/src/components/LanguageSwitcher.astro +89 -93
  10. package/src/config.entry.ts +5 -36
  11. package/src/constants.ts +5 -3
  12. package/src/cookie-storage.ts +19 -25
  13. package/src/fetcher.ts +3 -304
  14. package/src/format.ts +3 -107
  15. package/src/instance.ts +44 -0
  16. package/src/integration/index.ts +38 -9
  17. package/src/integration/vite-plugin.ts +1 -1
  18. package/src/middleware/i18n.ts +232 -114
  19. package/src/resolve-locale.ts +7 -39
  20. package/src/runtime.ts +2 -2
  21. package/src/schema.ts +3 -11
  22. package/src/translations-keys.ts +7 -38
  23. package/src/types.ts +16 -116
  24. package/dist/cli/generate.d.ts +0 -50
  25. package/dist/cli/generate.js +0 -160
  26. package/dist/cli/hashes.d.ts +0 -19
  27. package/dist/cli/hashes.js +0 -42
  28. package/dist/cli/index.d.ts +0 -2
  29. package/dist/cli/index.js +0 -372
  30. package/dist/cli/key-prefix-check.d.ts +0 -6
  31. package/dist/cli/key-prefix-check.js +0 -27
  32. package/dist/cli/load-config.d.ts +0 -7
  33. package/dist/cli/load-config.js +0 -19
  34. package/dist/cli/purge.d.ts +0 -29
  35. package/dist/cli/purge.js +0 -94
  36. package/dist/cli/wrangler.d.ts +0 -10
  37. package/dist/cli/wrangler.js +0 -71
  38. package/dist/cli/zone.d.ts +0 -21
  39. package/dist/cli/zone.js +0 -42
  40. package/dist/key-format.d.ts +0 -8
  41. package/dist/key-format.js +0 -15
  42. package/src/accept-language.ts +0 -56
  43. package/src/assemble.ts +0 -29
  44. package/src/cli/generate.ts +0 -205
  45. package/src/cli/hashes.ts +0 -66
  46. package/src/cli/index.ts +0 -472
  47. package/src/cli/key-prefix-check.ts +0 -31
  48. package/src/cli/load-config.ts +0 -26
  49. package/src/cli/purge.ts +0 -122
  50. package/src/cli/wrangler.ts +0 -96
  51. package/src/cli/zone.ts +0 -64
  52. package/src/deep-merge.ts +0 -38
  53. package/src/key-format.ts +0 -17
  54. package/src/locale-fallback.ts +0 -58
  55. package/src/markdown-path.ts +0 -31
  56. package/src/parse-boolean.ts +0 -20
package/src/cli/index.ts DELETED
@@ -1,472 +0,0 @@
1
- #!/usr/bin/env node
2
- import { execFileSync } from 'node:child_process'
3
- import fs from 'node:fs'
4
- import path from 'node:path'
5
-
6
- import { DEFAULT_GENERATED_DIR, DEFAULT_LOCALES_DIR, type I18nConfig } from '../types.ts'
7
- import {
8
- assertValidNames,
9
- buildBulkData,
10
- buildFallbacksContent,
11
- buildRuntimeConstantsContent,
12
- buildTypesContent,
13
- localesWithTranslations,
14
- schemaSourceLocale,
15
- type CollectedData,
16
- type TranslationTree,
17
- } from './generate.ts'
18
- import { diffHashes, purgeUrlsFor, type HashMap } from './hashes.ts'
19
- import { CONFIG_FILE, loadI18nConfig } from './load-config.ts'
20
- import { purgeTranslationsCache } from './purge.ts'
21
- import { getKvNamespaceId, getWranglerVar, KV_BINDING, resolveWranglerBin } from './wrangler.ts'
22
- import { checkKeyPrefixZone } from './zone.ts'
23
-
24
- const HELP = `Usage: edgekits-i18n <command> [--fallbacks]
25
-
26
- Reads ${CONFIG_FILE} and <localesDir>/<locale>/<namespace>.json from the current directory.
27
-
28
- Commands:
29
- bundle Write i18n-data.json, src/i18n.generated.d.ts and <generatedDir>/runtime-constants.ts.
30
- No side effects. --fallbacks also writes <generatedDir>/fallbacks.generated.ts.
31
- seed bundle + fallbacks, then push to LOCAL KV (wrangler --local).
32
- .i18n-hashes.json is left untouched: it records production, not local KV.
33
- migrate bundle + fallbacks, then push to production KV, purge the changed edge-cache
34
- entries, and write .i18n-hashes.json. Refuses to run while .i18n-hashes.json
35
- has uncommitted changes.
36
- `
37
-
38
- const COMMANDS = ['bundle', 'seed', 'migrate'] as const
39
- type Command = (typeof COMMANDS)[number]
40
-
41
- const ROOT = process.cwd()
42
-
43
- // Fixed outputs at the project root.
44
- const OUTPUT_DATA = path.join(ROOT, 'i18n-data.json')
45
- const OUTPUT_TYPES = path.join(ROOT, 'src', 'i18n.generated.d.ts')
46
-
47
- // Stores per-namespace content hashes from the last production migrate.
48
- // Used to detect which namespaces actually changed and need cache purging.
49
- // TRACKED in git: the committed copy is the production purge baseline.
50
- // Absence means "first run", all namespaces treated as changed.
51
- const HASHES_FILE = path.join(ROOT, '.i18n-hashes.json')
52
-
53
- const KV_NAMESPACE_ID = /^[0-9a-f]{32}$/i
54
-
55
- await main(process.argv.slice(2))
56
-
57
- async function main(args: string[]): Promise<void> {
58
- const command = args.find((arg) => !arg.startsWith('-'))
59
-
60
- if (!command || args.includes('--help') || args.includes('-h')) {
61
- console.log(HELP)
62
- return
63
- }
64
-
65
- if (!(COMMANDS as readonly string[]).includes(command)) {
66
- console.error(`[i18n] Unknown command "${command}".\n`)
67
- console.error(HELP)
68
- process.exit(1)
69
- }
70
-
71
- await run(command as Command, args)
72
- }
73
-
74
- async function run(command: Command, args: string[]): Promise<void> {
75
- const shouldDeploy = command !== 'bundle'
76
- const isLocal = command === 'seed'
77
- const shouldGenerateFallbacks =
78
- shouldDeploy || args.includes('--fallbacks') || process.env.I18N_GENERATE_FALLBACKS === 'true'
79
-
80
- if (command === 'migrate') {
81
- assertCleanPurgeBaseline()
82
- }
83
-
84
- const config = await loadI18nConfig(ROOT)
85
- const defaultLocale = config.defaultLocale
86
- const localesDir = path.join(ROOT, config.localesDir ?? DEFAULT_LOCALES_DIR)
87
- const generatedDir = path.join(ROOT, config.generatedDir ?? DEFAULT_GENERATED_DIR)
88
- const outputRuntimeConstants = path.join(generatedDir, 'runtime-constants.ts')
89
- const outputFallbacks = path.join(generatedDir, 'fallbacks.generated.ts')
90
-
91
- // Read before writing anything, so a missing binding fails the run up front.
92
- const kvId = shouldDeploy ? getKvNamespaceId(ROOT, KV_BINDING) : undefined
93
-
94
- // The id becomes a command-line argument (deploy()); only a real namespace id's shape
95
- // may get there.
96
- if (kvId !== undefined && !KV_NAMESPACE_ID.test(kvId)) {
97
- console.error(
98
- `[i18n] The ${KV_BINDING} id in wrangler.jsonc, "${kvId}", is not a KV namespace id (32 hex characters).`,
99
- )
100
- process.exit(1)
101
- }
102
-
103
- // The project's wrangler, found before anything is written.
104
- let wranglerBin: string | undefined
105
- if (shouldDeploy) {
106
- try {
107
- wranglerBin = resolveWranglerBin(ROOT)
108
- } catch (err) {
109
- console.error(`[i18n] ${err instanceof Error ? err.message : String(err)}`)
110
- process.exit(1)
111
- }
112
- }
113
-
114
- // -------------------------------------------------------------------------
115
- // 1. Collect translations from <localesDir>/<locale>/<namespace>.json
116
- // -------------------------------------------------------------------------
117
-
118
- const collected = collectTranslations(localesDir)
119
-
120
- // The schema is built here too, so an untypeable value (a mixed array) stops the run
121
- // before anything is written.
122
- let typesContent: string
123
- try {
124
- assertValidNames(collected, config.languages)
125
- typesContent = buildTypesContent(collected, defaultLocale)
126
- } catch (err) {
127
- console.error(`[i18n] ${err instanceof Error ? err.message : String(err)}`)
128
- process.exit(1)
129
- }
130
-
131
- const locales = localesWithTranslations(collected)
132
-
133
- if (locales.length === 0) {
134
- console.warn(`[i18n] No locales with JSON files found in ${localesDir}. Generating empty artifacts.`)
135
- }
136
-
137
- // -------------------------------------------------------------------------
138
- // 2. Compute per-namespace content hashes and detect changes
139
- // -------------------------------------------------------------------------
140
-
141
- // Load previous hashes (absent on first run — all namespaces treated as new)
142
- const previousHashes: HashMap = fs.existsSync(HASHES_FILE)
143
- ? (JSON.parse(fs.readFileSync(HASHES_FILE, 'utf8')) as HashMap)
144
- : {}
145
-
146
- const { currentHashes, changedKeys } = diffHashes(collected, previousHashes)
147
-
148
- if (changedKeys.length > 0) {
149
- console.log(`[i18n] Changed namespaces (${changedKeys.length}):`)
150
- changedKeys.forEach((k) => console.log(` - ${k}`))
151
- } else {
152
- console.log('[i18n] No translation changes detected.')
153
- }
154
-
155
- // -------------------------------------------------------------------------
156
- // 3. Write KV bulk data, types and runtime constants
157
- // -------------------------------------------------------------------------
158
-
159
- fs.writeFileSync(OUTPUT_DATA, JSON.stringify(buildBulkData(collected, config.keyPrefix), null, 2) + '\n', 'utf8')
160
-
161
- const source = schemaSourceLocale(collected, defaultLocale)
162
- if (source && source !== defaultLocale) {
163
- console.warn(
164
- `[i18n] defaultLocale="${defaultLocale}" has no translation files. ` +
165
- `Using "${source}" as the source for I18n.Schema.`,
166
- )
167
- }
168
-
169
- writeFile(OUTPUT_TYPES, typesContent)
170
- writeFile(outputRuntimeConstants, buildRuntimeConstantsContent(collected, defaultLocale))
171
-
172
- // -------------------------------------------------------------------------
173
- // 4. Fallbacks (seed, migrate, or bundle --fallbacks)
174
- // -------------------------------------------------------------------------
175
-
176
- let wroteFallbacks = false
177
-
178
- if (shouldGenerateFallbacks) {
179
- const fallbacksContent = buildFallbacksContent(collected, defaultLocale)
180
-
181
- if (fallbacksContent === null) {
182
- console.warn(
183
- `[i18n] No translations found for defaultLocale="${defaultLocale}". ` + 'Skipping fallback generation.',
184
- )
185
- } else {
186
- writeFile(outputFallbacks, fallbacksContent)
187
- wroteFallbacks = true
188
- }
189
- } else {
190
- console.log('[i18n] Skipping fallback generation. Enable with I18N_GENERATE_FALLBACKS=true or --fallbacks.')
191
- }
192
-
193
- const outputs = [OUTPUT_DATA, OUTPUT_TYPES, outputRuntimeConstants, wroteFallbacks ? outputFallbacks : null].filter(
194
- Boolean,
195
- ) as string[]
196
-
197
- console.log('Generated:\n' + outputs.map((p) => `- ${path.relative(ROOT, p).split(path.sep).join('/')}`).join('\n'))
198
-
199
- // -------------------------------------------------------------------------
200
- // 5. Push to KV + purge changed cache entries via Cloudflare API
201
- // seed → local KV only, no purge, hashes untouched (a warm local cache under
202
- // `astro dev` is the consumer's to drop: `I18N_CACHE=off`, restart)
203
- // migrate → remote KV + Cloudflare Purge API, hashes written on success
204
- // -------------------------------------------------------------------------
205
-
206
- if (!shouldDeploy) {
207
- console.log('[i18n] Skipping KV deploy (bundle). Use `edgekits-i18n seed` or `edgekits-i18n migrate`.')
208
- return
209
- }
210
-
211
- await deploy({ config, kvId: kvId!, wranglerBin: wranglerBin!, isLocal, changedKeys, currentHashes })
212
- }
213
-
214
- function collectTranslations(localesDir: string): CollectedData {
215
- if (!fs.existsSync(localesDir)) {
216
- console.error(`Locales directory not found: ${localesDir}`)
217
- process.exit(1)
218
- }
219
-
220
- const localeDirs = fs
221
- .readdirSync(localesDir, { withFileTypes: true })
222
- .filter((d) => d.isDirectory())
223
- .map((d) => d.name)
224
- .filter((locale) => {
225
- const fullPath = path.join(localesDir, locale)
226
- const files = fs.readdirSync(fullPath, { withFileTypes: true })
227
- return files.some((f) => f.isFile() && f.name.endsWith('.json'))
228
- })
229
-
230
- const collected: CollectedData = {}
231
-
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
-
238
- for (const file of files) {
239
- const namespace = path.basename(file.name, '.json')
240
- const raw = fs.readFileSync(path.join(localeDir, file.name), 'utf8')
241
- const json = JSON.parse(raw) as TranslationTree
242
-
243
- if (!collected[locale]) collected[locale] = {}
244
- collected[locale][namespace] = json
245
- }
246
- }
247
-
248
- return collected
249
- }
250
-
251
- function writeFile(file: string, content: string): void {
252
- fs.mkdirSync(path.dirname(file), { recursive: true })
253
- fs.writeFileSync(file, content, 'utf8')
254
- }
255
-
256
- async function deploy({
257
- config,
258
- kvId,
259
- wranglerBin,
260
- isLocal,
261
- changedKeys,
262
- currentHashes,
263
- }: {
264
- config: I18nConfig
265
- kvId: string
266
- wranglerBin: string
267
- isLocal: boolean
268
- changedKeys: string[]
269
- currentHashes: HashMap
270
- }): Promise<void> {
271
- const kvFlag = isLocal ? '--local' : '--remote'
272
- const target = isLocal ? 'local' : 'remote'
273
-
274
- // Push all translations to KV
275
- console.log(`[i18n] Pushing translations to ${target} KV...`)
276
- try {
277
- // wrangler's JavaScript entry, run by this Node binary: no shell on any platform
278
- // (no `.cmd` shim on Windows), and the arguments are constants plus the shape-checked id.
279
- execFileSync(
280
- process.execPath,
281
- [wranglerBin, 'kv', 'bulk', 'put', 'i18n-data.json', kvFlag, `--namespace-id=${kvId}`],
282
- {
283
- cwd: ROOT,
284
- stdio: 'inherit',
285
- },
286
- )
287
- console.log(`[i18n] ✅ KV updated (${target}).`)
288
- } catch (err) {
289
- console.error(`[i18n] ❌ Failed to push to ${target} KV.`, err)
290
- process.exit(1)
291
- }
292
-
293
- if (isLocal) {
294
- // Local KV: no purge (a warm local cache under `astro dev` is the consumer's to drop),
295
- // and the hashes describe production only.
296
- console.log('[i18n] .i18n-hashes.json left untouched (local seed).')
297
- return
298
- }
299
-
300
- // ---------------------------------------------------------------------------
301
- // Graceful recovery for the hash file:
302
- //
303
- // We update .i18n-hashes.json ONLY when one of these holds:
304
- // - No changes: nothing needed purging to begin with.
305
- // - A successful purge of all changed URLs.
306
- //
307
- // If a purge was attempted but failed (network error, rate limit
308
- // saturation, API error, missing credentials), we DO NOT write the file.
309
- // This way, the next `edgekits-i18n migrate` will see the same diff as this
310
- // one and re-attempt the invalidation automatically. No manual intervention,
311
- // no silent cache staleness.
312
- // ---------------------------------------------------------------------------
313
-
314
- let shouldWriteHashes = false
315
-
316
- if (changedKeys.length === 0) {
317
- // Nothing changed — nothing to purge, safe to update hashes.
318
- //
319
- // Said out loud rather than passed over: a silent no-op on a deploy path is
320
- // what made the 2026-09-12 staleness take an afternoon to find. Legitimate
321
- // outcome, so not an error — but the operator has to be able to see it, and
322
- // to see WHICH half of the job was skipped.
323
- console.log(
324
- '[i18n] Nothing changed against the recorded baseline, so NO cache entry was purged.\n' +
325
- ' The bulk put above is unconditional, so production KV did receive every value;\n' +
326
- ' it is the edge cache that was left alone, and it holds translations for a year.\n' +
327
- ' If you expected a change here, .i18n-hashes.json is describing a state that\n' +
328
- ' production is not actually in.',
329
- )
330
- shouldWriteHashes = true
331
- } else {
332
- // Changes — the purge must succeed before we update hashes.
333
- loadDevVars()
334
-
335
- const purgeUrls = purgeUrlsFor(changedKeys, config.keyPrefix)
336
- const zoneId = getWranglerVar(ROOT, 'CLOUDFLARE_ZONE_ID') || process.env.CLOUDFLARE_ZONE_ID
337
- const apiToken = process.env.CLOUDFLARE_CACHEPURGE_API_TOKEN
338
-
339
- if (!zoneId) {
340
- console.warn(
341
- '[i18n] ⚠️ CLOUDFLARE_ZONE_ID not found in wrangler.jsonc vars or environment. ' +
342
- 'Skipping cache purge — hash file NOT updated. Re-run `edgekits-i18n migrate` after setting the variable.',
343
- )
344
- } else if (!apiToken) {
345
- console.warn(
346
- '[i18n] ⚠️ CLOUDFLARE_CACHEPURGE_API_TOKEN not set in environment. ' +
347
- 'Skipping cache purge — hash file NOT updated. Re-run `edgekits-i18n migrate` after setting the secret.',
348
- )
349
- } else {
350
- // Advisory: the Purge API reports success even for URLs it cannot reach, so the
351
- // name is all there is to check. Never decides shouldWriteHashes.
352
- await checkKeyPrefixZone(config.keyPrefix, zoneId, apiToken)
353
-
354
- console.log(`[i18n] Purging ${purgeUrls.length} cache entries via Cloudflare API...`)
355
- const purgeSuccess = await purgeTranslationsCache(zoneId, apiToken, purgeUrls)
356
-
357
- if (purgeSuccess) {
358
- console.log('[i18n] ✅ Cache purge completed.')
359
- shouldWriteHashes = true
360
- } else {
361
- console.error(
362
- '[i18n] ❌ Cache purge failed — hash file NOT updated. ' +
363
- 'Re-run `edgekits-i18n migrate` to retry the same invalidation.',
364
- )
365
- // Non-fatal for KV: translations are already pushed. Cache will
366
- // converge on the next successful migrate.
367
- }
368
- }
369
- }
370
-
371
- if (shouldWriteHashes) {
372
- fs.writeFileSync(HASHES_FILE, JSON.stringify(currentHashes, null, 2) + '\n', 'utf8')
373
- console.log('[i18n] ✅ .i18n-hashes.json updated.')
374
- }
375
- }
376
-
377
- /**
378
- * Load .dev.vars into process.env. wrangler dev reads .dev.vars for the Worker
379
- * runtime, but Node does not — so it is parsed here. Never overwrites a variable
380
- * already set in the real environment.
381
- */
382
- function loadDevVars(): void {
383
- const devVarsPath = path.join(ROOT, '.dev.vars')
384
- if (!fs.existsSync(devVarsPath)) return
385
-
386
- const lines = fs.readFileSync(devVarsPath, 'utf8').split('\n')
387
- for (const line of lines) {
388
- const trimmed = line.trim()
389
- // Skip empty lines and comments
390
- if (!trimmed || trimmed.startsWith('#')) continue
391
- const eqIndex = trimmed.indexOf('=')
392
- if (eqIndex === -1) continue
393
- const key = trimmed.slice(0, eqIndex).trim()
394
- const value = trimmed.slice(eqIndex + 1).trim()
395
- if (key && !(key in process.env)) {
396
- process.env[key] = value
397
- }
398
- }
399
- }
400
-
401
- // ---------------------------------------------------------------------------
402
- // Precondition (migrate only): the purge baseline must be clean
403
- //
404
- // `.i18n-hashes.json` records what PRODUCTION KV already holds, and the diff
405
- // against it is the ONLY thing that decides which cache entries get purged.
406
- //
407
- // Migrate against a modified copy and the diff comes out wrong. Note what that
408
- // does and does not break: the bulk put is unconditional, so every value still
409
- // reaches production KV. What is skipped is the PURGE — and translations are
410
- // served `s-maxage=31536000, immutable` (see fetcher.ts), so the stale edge copy
411
- // never ages out. A purge is the only thing that ends it. The run then writes a
412
- // baseline claiming production is current, and exits 0.
413
- //
414
- // The live page that results is neither the old one nor the new one: keys that
415
- // exist only in the shipped fallbacks render beside keys still served stale
416
- // from the edge. That reached production on 2026-09-12, and nothing failed
417
- // loudly — it was caught by somebody reading the live page. Hence a
418
- // precondition that refuses to run, rather than a warning nobody sees.
419
- //
420
- // `seed` never writes this file, so it is exempt and never dirties it.
421
- // ---------------------------------------------------------------------------
422
-
423
- /**
424
- * Abort if the purge baseline carries uncommitted changes.
425
- *
426
- * Outside a git work tree, or with no git on PATH, this warns and proceeds: a
427
- * guard on the deploy path must not be the reason the CLI cannot run in CI
428
- * or from a tarball.
429
- */
430
- function assertCleanPurgeBaseline(): void {
431
- const relativePath = path.relative(ROOT, HASHES_FILE).split(path.sep).join('/')
432
- let status: string
433
-
434
- try {
435
- // Porcelain v1 is a stable format, and `--` scopes it to the one path, so a
436
- // dirty working tree elsewhere (the usual state) is not this guard's business.
437
- status = execFileSync('git', ['status', '--porcelain', '--', relativePath], {
438
- cwd: ROOT,
439
- encoding: 'utf8',
440
- stdio: ['ignore', 'pipe', 'pipe'],
441
- })
442
- } catch (err) {
443
- console.warn(
444
- `[i18n] ⚠️ Could not check ${relativePath} against git (${err instanceof Error ? err.message.split('\n')[0] : String(err)}).\n` +
445
- ' Proceeding WITHOUT the purge-baseline check. If that file carries hashes\n' +
446
- ' that do not describe production, this run will skip the cache purge and say nothing.',
447
- )
448
- return
449
- }
450
-
451
- if (status.trim() === '') return
452
-
453
- console.error(
454
- `\n[i18n] ❌ ${relativePath} has uncommitted changes. Refusing to migrate.\n` +
455
- `\n git status --porcelain: ${status.trim()}\n` +
456
- '\n That file is the purge baseline: the record of what production KV already holds.\n' +
457
- ' Migrating against a modified copy makes the change diff come out wrong. Every value\n' +
458
- ' is still pushed to KV, but the cache purge is driven by that diff, so stale edge\n' +
459
- ' entries survive — and translations are cached immutable for a year, so they do not\n' +
460
- ' age out on their own. The run would exit 0 as though it had worked.\n' +
461
- '\n Two things dirty this file, and they need opposite fixes:\n' +
462
- '\n 1. Something other than migrate wrote it (a hand edit, or a pre-package local\n' +
463
- ' seed script). Those hashes lie about production. Discard them, then migrate:\n' +
464
- ' git checkout .i18n-hashes.json && npx edgekits-i18n migrate\n' +
465
- '\n 2. A previous `edgekits-i18n migrate` wrote it. Those hashes describe production\n' +
466
- ' and are correct. Commit them; migrating again is unnecessary:\n' +
467
- ' git add .i18n-hashes.json && git commit\n' +
468
- '\n The CLI cannot tell the two apart — the file looks identical either way. If you\n' +
469
- ' are unsure, the last i18n command you ran is the answer.\n',
470
- )
471
- process.exit(1)
472
- }
@@ -1,31 +0,0 @@
1
- // ---------------------------------------------------------------------------
2
- // keyPrefix against the purge zone
3
- //
4
- // Every cache URL is https://<keyPrefix>/..., and `migrate` purges those URLs from the
5
- // zone CLOUDFLARE_ZONE_ID names. A URL a Worker cached is purged from the Worker's own
6
- // zone when its hostname is no Cloudflare zone or a non-proxied one. A hostname that is
7
- // an active proxied Cloudflare zone can be purged only from that zone, by its owner, and
8
- // the Purge API answers `success: true` either way. The response cannot show the miss,
9
- // so the only check left is the name. A proxied zone always has a dot: a dotless
10
- // keyPrefix never collides with one.
11
- // ---------------------------------------------------------------------------
12
-
13
- /**
14
- * The warning `migrate` prints when `keyPrefix` is a dotted hostname outside `zoneName`,
15
- * or null when it is dotless, the zone name itself, or a subdomain of it (label
16
- * boundary, case-insensitive, both sides trimmed). Pure: no network, no `process`.
17
- */
18
- export function keyPrefixZoneWarning(keyPrefix: string, zoneName: string): string | null {
19
- const host = keyPrefix.trim().toLowerCase()
20
- const zone = zoneName.trim().toLowerCase()
21
-
22
- if (!host.includes('.') || host === zone || host.endsWith(`.${zone}`)) return null
23
-
24
- return (
25
- `[i18n] ⚠️ keyPrefix "${keyPrefix.trim()}" is a hostname outside the zone the purge is issued against (${zoneName.trim()}).\n` +
26
- ' The Purge API answers success either way, so this run cannot tell whether the edge cache was purged.\n' +
27
- ` It is harmless only if "${keyPrefix.trim()}" is no proxied Cloudflare zone: a URL on a proxied zone\n` +
28
- ' can be purged only from that zone, by its owner. Fix: a keyPrefix inside the zone (its name or a\n' +
29
- ' subdomain), or a unique name without a dot.'
30
- )
31
- }
@@ -1,26 +0,0 @@
1
- import fs from 'node:fs'
2
- import path from 'node:path'
3
-
4
- import { createJiti } from 'jiti'
5
-
6
- import { defineI18nConfig, type I18nConfig } from '../types.ts'
7
-
8
- export const CONFIG_FILE = 'edgekits-i18n.config.ts'
9
-
10
- /**
11
- * Load the project's `edgekits-i18n.config.ts` from `root` — the same file its
12
- * `astro.config` passes to the integration — and validate it.
13
- */
14
- export async function loadI18nConfig(root: string): Promise<I18nConfig> {
15
- const configPath = path.join(root, CONFIG_FILE)
16
-
17
- if (!fs.existsSync(configPath)) {
18
- console.error(`[i18n] ${CONFIG_FILE} not found in ${root}`)
19
- process.exit(1)
20
- }
21
-
22
- const jiti = createJiti(import.meta.url)
23
- const config = await jiti.import<I18nConfig>(configPath, { default: true })
24
-
25
- return defineI18nConfig(config)
26
- }
package/src/cli/purge.ts DELETED
@@ -1,122 +0,0 @@
1
- // ---------------------------------------------------------------------------
2
- // Cloudflare Cache Purge API
3
- // ---------------------------------------------------------------------------
4
-
5
- /**
6
- * Purge a list of URLs from Cloudflare's edge cache via the Purge API.
7
- *
8
- * Handles Cloudflare's rate limits automatically:
9
- * - Max 100 URLs per request (max operations per request)
10
- * - Max 800 URLs per second on Free plan (8 chunks of 100)
11
- * - HTTP 429: up to MAX_RETRIES retries per chunk, waiting `Retry-After` seconds when
12
- * the response sends it, otherwise 2 s, 4 s, 8 s… (every wait capped at
13
- * MAX_RETRY_WAIT_MS); then the chunk counts as failed.
14
- *
15
- * Reference: https://developers.cloudflare.com/cache/how-to/purge-cache/
16
- *
17
- * `deps` replaces `fetch` and the wait in tests.
18
- *
19
- * @returns true if all chunks succeeded, false if any chunk failed
20
- */
21
- export async function purgeTranslationsCache(
22
- zoneId: string,
23
- apiToken: string,
24
- urls: string[],
25
- deps: PurgeDeps = {},
26
- ): Promise<boolean> {
27
- if (!urls.length) return true
28
-
29
- const { fetch: fetchFn = fetch, sleep: wait = sleep } = deps
30
-
31
- // Cloudflare limit: max 100 URLs per single purge request
32
- const CHUNK_SIZE = 100
33
-
34
- // Cloudflare Free plan limit: 800 URLs/sec = 8 chunks of 100 per second
35
- // Pro and above: 1500 URLs/sec — CHUNK_SIZE * MAX_CHUNKS_PER_SEC covers Free plan
36
- const MAX_CHUNKS_PER_SEC = 8
37
-
38
- console.log(`[i18n] Purging cache for ${urls.length} URL(s)...`)
39
-
40
- // 429 retries of the current chunk; reset when a chunk goes through.
41
- let retries = 0
42
-
43
- for (let i = 0; i < urls.length; i += CHUNK_SIZE) {
44
- const chunk = urls.slice(i, i + CHUNK_SIZE)
45
- const currentChunkIndex = Math.floor(i / CHUNK_SIZE) + 1
46
-
47
- try {
48
- const response = await fetchFn(`https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`, {
49
- method: 'POST',
50
- headers: {
51
- Authorization: `Bearer ${apiToken}`,
52
- 'Content-Type': 'application/json',
53
- },
54
- body: JSON.stringify({ files: chunk }),
55
- })
56
-
57
- // Rate limit hit — wait and retry the same chunk, a bounded number of times
58
- if (response.status === 429) {
59
- if (retries >= MAX_RETRIES) {
60
- console.error(
61
- `[i18n] [Chunk ${currentChunkIndex}] Still rate-limited after ${MAX_RETRIES} retries; ` +
62
- `giving up on ${chunk.length} URL(s):\n` +
63
- chunk.map((url) => ` - ${url}`).join('\n'),
64
- )
65
- return false
66
- }
67
- retries++
68
- const waitMs = retryWaitMs(response.headers.get('retry-after'), retries)
69
- console.warn(
70
- `[i18n] [Chunk ${currentChunkIndex}] Rate limit hit, retry ${retries}/${MAX_RETRIES} in ${waitMs}ms...`,
71
- )
72
- await wait(waitMs)
73
- i -= CHUNK_SIZE // retry same chunk
74
- continue
75
- }
76
-
77
- if (!response.ok) {
78
- const errorData = await response.text()
79
- console.error(`[i18n] [Chunk ${currentChunkIndex}] Cloudflare API error:`, errorData)
80
- return false
81
- }
82
-
83
- retries = 0
84
- console.log(`[i18n] [Chunk ${currentChunkIndex}] Purged ${chunk.length} URL(s).`)
85
-
86
- // After sending MAX_CHUNKS_PER_SEC chunks (= 800 URLs), pause for 1 second
87
- // to stay within the Free plan rate limit before the next batch
88
- if (currentChunkIndex % MAX_CHUNKS_PER_SEC === 0 && i + CHUNK_SIZE < urls.length) {
89
- console.log(`[i18n] Reached 800 URL/sec limit. Waiting 1000ms before next batch...`)
90
- await wait(1000)
91
- }
92
- } catch (error) {
93
- console.error(`[i18n] [Chunk ${currentChunkIndex}] Network error calling Cloudflare API:`, error)
94
- return false
95
- }
96
- }
97
-
98
- return true
99
- }
100
-
101
- export type PurgeDeps = {
102
- fetch?: (input: string, init: RequestInit) => Promise<Response>
103
- sleep?: (ms: number) => Promise<void>
104
- }
105
-
106
- export const MAX_RETRIES = 5
107
- export const MAX_RETRY_WAIT_MS = 30_000
108
-
109
- /**
110
- * The wait before 429 retry number `attempt` (1-based): `Retry-After` in seconds when
111
- * the header holds a non-negative number, otherwise 2 s doubled per attempt. Either way
112
- * capped at MAX_RETRY_WAIT_MS, so a large header cannot stall the run.
113
- */
114
- export function retryWaitMs(retryAfter: string | null, attempt: number): number {
115
- const seconds = retryAfter === null || retryAfter.trim() === '' ? NaN : Number(retryAfter)
116
- const ms = Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : 2000 * 2 ** (attempt - 1)
117
- return Math.min(ms, MAX_RETRY_WAIT_MS)
118
- }
119
-
120
- function sleep(ms: number): Promise<void> {
121
- return new Promise((resolve) => setTimeout(resolve, ms))
122
- }