@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.
- package/CHANGELOG.md +28 -0
- package/README.md +92 -28
- package/dist/integration/index.d.ts +25 -4
- package/dist/integration/index.js +35 -9
- package/dist/integration/vite-plugin.d.ts +1 -1
- package/dist/types.d.ts +7 -55
- package/dist/types.js +1 -44
- package/package.json +7 -17
- package/src/components/LanguageSwitcher.astro +89 -93
- package/src/config.entry.ts +5 -36
- package/src/constants.ts +5 -3
- package/src/cookie-storage.ts +19 -25
- package/src/fetcher.ts +3 -304
- package/src/format.ts +3 -107
- package/src/instance.ts +44 -0
- package/src/integration/index.ts +38 -9
- package/src/integration/vite-plugin.ts +1 -1
- package/src/middleware/i18n.ts +232 -114
- package/src/resolve-locale.ts +7 -39
- package/src/runtime.ts +2 -2
- package/src/schema.ts +3 -11
- package/src/translations-keys.ts +7 -38
- package/src/types.ts +16 -116
- package/dist/cli/generate.d.ts +0 -50
- package/dist/cli/generate.js +0 -160
- package/dist/cli/hashes.d.ts +0 -19
- package/dist/cli/hashes.js +0 -42
- package/dist/cli/index.d.ts +0 -2
- package/dist/cli/index.js +0 -372
- package/dist/cli/key-prefix-check.d.ts +0 -6
- package/dist/cli/key-prefix-check.js +0 -27
- package/dist/cli/load-config.d.ts +0 -7
- package/dist/cli/load-config.js +0 -19
- package/dist/cli/purge.d.ts +0 -29
- package/dist/cli/purge.js +0 -94
- package/dist/cli/wrangler.d.ts +0 -10
- package/dist/cli/wrangler.js +0 -71
- package/dist/cli/zone.d.ts +0 -21
- package/dist/cli/zone.js +0 -42
- package/dist/key-format.d.ts +0 -8
- package/dist/key-format.js +0 -15
- package/src/accept-language.ts +0 -56
- package/src/assemble.ts +0 -29
- package/src/cli/generate.ts +0 -205
- package/src/cli/hashes.ts +0 -66
- package/src/cli/index.ts +0 -472
- package/src/cli/key-prefix-check.ts +0 -31
- package/src/cli/load-config.ts +0 -26
- package/src/cli/purge.ts +0 -122
- package/src/cli/wrangler.ts +0 -96
- package/src/cli/zone.ts +0 -64
- package/src/deep-merge.ts +0 -38
- package/src/key-format.ts +0 -17
- package/src/locale-fallback.ts +0 -58
- package/src/markdown-path.ts +0 -31
- 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
|
-
}
|
package/src/cli/load-config.ts
DELETED
|
@@ -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
|
-
}
|