@edgekits/astro-i18n 0.0.0-stage → 0.1.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 (60) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/LICENSE +21 -0
  3. package/README.md +657 -2
  4. package/dist/cli/generate.d.ts +47 -0
  5. package/dist/cli/generate.js +133 -0
  6. package/dist/cli/hashes.d.ts +19 -0
  7. package/dist/cli/hashes.js +42 -0
  8. package/dist/cli/index.d.ts +2 -0
  9. package/dist/cli/index.js +362 -0
  10. package/dist/cli/load-config.d.ts +7 -0
  11. package/dist/cli/load-config.js +19 -0
  12. package/dist/cli/purge.d.ts +29 -0
  13. package/dist/cli/purge.js +94 -0
  14. package/dist/cli/wrangler.d.ts +10 -0
  15. package/dist/cli/wrangler.js +71 -0
  16. package/dist/index.d.ts +2 -0
  17. package/dist/index.js +4 -0
  18. package/dist/integration/index.d.ts +26 -0
  19. package/dist/integration/index.js +103 -0
  20. package/dist/integration/virtual-types.d.ts +14 -0
  21. package/dist/integration/virtual-types.js +34 -0
  22. package/dist/integration/vite-plugin.d.ts +17 -0
  23. package/dist/integration/vite-plugin.js +56 -0
  24. package/dist/key-format.d.ts +8 -0
  25. package/dist/key-format.js +15 -0
  26. package/dist/types.d.ts +66 -0
  27. package/dist/types.js +38 -0
  28. package/package.json +78 -4
  29. package/src/assemble.ts +29 -0
  30. package/src/cli/generate.ts +175 -0
  31. package/src/cli/hashes.ts +66 -0
  32. package/src/cli/index.ts +461 -0
  33. package/src/cli/load-config.ts +26 -0
  34. package/src/cli/purge.ts +122 -0
  35. package/src/cli/wrangler.ts +96 -0
  36. package/src/components/LanguageSwitcher.astro +211 -0
  37. package/src/components/MissingTranslationBanner.astro +31 -0
  38. package/src/config.entry.ts +38 -0
  39. package/src/config.ts +11 -0
  40. package/src/constants.ts +29 -0
  41. package/src/cookie-storage.ts +32 -0
  42. package/src/deep-merge.ts +38 -0
  43. package/src/fetcher.ts +304 -0
  44. package/src/format.ts +107 -0
  45. package/src/index.ts +11 -0
  46. package/src/integration/index.ts +136 -0
  47. package/src/integration/virtual-types.ts +34 -0
  48. package/src/integration/vite-plugin.ts +67 -0
  49. package/src/key-format.ts +17 -0
  50. package/src/locale-fallback.ts +65 -0
  51. package/src/markdown-path.ts +31 -0
  52. package/src/middleware/i18n.ts +223 -0
  53. package/src/middleware.ts +2 -0
  54. package/src/parse-boolean.ts +20 -0
  55. package/src/resolve-locale.ts +57 -0
  56. package/src/runtime.ts +19 -0
  57. package/src/schema.ts +28 -0
  58. package/src/translations-keys.ts +42 -0
  59. package/src/types.ts +121 -0
  60. package/src/virtual.d.ts +23 -0
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The CLI's code generation, as pure functions of the collected locale tree: no
3
+ * filesystem, so the emitted text is testable. Formats are those of the site's
4
+ * `scripts/bundle-translations.ts`, header aside.
5
+ */
6
+ export type TranslationTree = Record<string, unknown>;
7
+ export type CollectedData = Record<string, Record<string, TranslationTree>>;
8
+ export type KvBulkEntry = {
9
+ key: string;
10
+ value: string;
11
+ };
12
+ export declare const NAMESPACE_NAME: RegExp;
13
+ /**
14
+ * Throws, naming the offender, when a locale folder is not one of the config's
15
+ * `languages` or a namespace file name does not match NAMESPACE_NAME. Run on the
16
+ * collected tree before anything is generated or pushed.
17
+ */
18
+ export declare function assertValidNames(collected: CollectedData, languages: Readonly<Record<string, unknown>>): void;
19
+ /**
20
+ * A single-quoted TS string literal, escaped by JSON.stringify. For every name the
21
+ * validation accepts it is byte-identical to the plain `'name'` the CLI always wrote, so
22
+ * existing generated files do not change; anything else comes out escaped, not injected.
23
+ */
24
+ export declare function quoteString(value: string): string;
25
+ /** Locales that have at least one namespace, sorted. */
26
+ export declare function localesWithTranslations(collected: CollectedData): string[];
27
+ /**
28
+ * The locale the schema and the namespace list are derived from: the default locale
29
+ * when it has translations, otherwise the first locale that does.
30
+ */
31
+ export declare function schemaSourceLocale(collected: CollectedData, defaultLocale: string): string | undefined;
32
+ /** `wrangler kv bulk put` input: one entry per locale and namespace. */
33
+ export declare function buildBulkData(collected: CollectedData, keyPrefix: string): KvBulkEntry[];
34
+ /** `src/i18n.generated.d.ts`: the global `I18n.Schema`, from the schema source locale. */
35
+ export declare function buildTypesContent(collected: CollectedData, defaultLocale: string): string;
36
+ /**
37
+ * `runtime-constants.ts`:
38
+ * - NAMESPACE_KEYS -> from the schema source locale (default locale preferred)
39
+ * - LOCALES_WITH_TRANSLATIONS -> from all collected locales
40
+ * - FULLY_TRANSLATED_LOCALES -> locales that have ALL namespace keys
41
+ */
42
+ export declare function buildRuntimeConstantsContent(collected: CollectedData, defaultLocale: string): string;
43
+ /**
44
+ * `fallbacks.generated.ts`: one `FALLBACK_<NAMESPACE>` per default-locale namespace.
45
+ * Null when the default locale has no translations (nothing to fall back to).
46
+ */
47
+ export declare function buildFallbacksContent(collected: CollectedData, defaultLocale: string): string | null;
@@ -0,0 +1,133 @@
1
+ import { formatTranslationKvKey } from "../key-format.js";
2
+ const HEADER = `// Auto-generated by @edgekits/astro-i18n (edgekits-i18n CLI)
3
+ // Do not edit manually.
4
+ `;
5
+ // A namespace becomes part of a KV key, a cache URL and the `FALLBACK_<NAMESPACE>`
6
+ // identifier, so its file name is restricted to what is valid in all three.
7
+ export const NAMESPACE_NAME = /^[a-z0-9][a-z0-9_]*$/;
8
+ /**
9
+ * Throws, naming the offender, when a locale folder is not one of the config's
10
+ * `languages` or a namespace file name does not match NAMESPACE_NAME. Run on the
11
+ * collected tree before anything is generated or pushed.
12
+ */
13
+ export function assertValidNames(collected, languages) {
14
+ const configured = Object.keys(languages);
15
+ for (const [locale, namespaces] of Object.entries(collected)) {
16
+ if (!configured.includes(locale)) {
17
+ throw new Error(`Locale folder "${locale}" is not one of \`languages\` in edgekits-i18n.config.ts: ${configured.join(', ')}.`);
18
+ }
19
+ for (const ns of Object.keys(namespaces)) {
20
+ if (!NAMESPACE_NAME.test(ns)) {
21
+ throw new Error(`Namespace "${ns}" (${locale}/${ns}.json) must match ${NAMESPACE_NAME}: lowercase letters, digits and underscores.`);
22
+ }
23
+ }
24
+ }
25
+ }
26
+ /**
27
+ * A single-quoted TS string literal, escaped by JSON.stringify. For every name the
28
+ * validation accepts it is byte-identical to the plain `'name'` the CLI always wrote, so
29
+ * existing generated files do not change; anything else comes out escaped, not injected.
30
+ */
31
+ export function quoteString(value) {
32
+ const escaped = JSON.stringify(value).slice(1, -1).replace(/\\"/g, '"').replace(/'/g, "\\'");
33
+ return `'${escaped}'`;
34
+ }
35
+ /** Locales that have at least one namespace, sorted. */
36
+ export function localesWithTranslations(collected) {
37
+ return Object.keys(collected).sort();
38
+ }
39
+ /**
40
+ * The locale the schema and the namespace list are derived from: the default locale
41
+ * when it has translations, otherwise the first locale that does.
42
+ */
43
+ export function schemaSourceLocale(collected, defaultLocale) {
44
+ if (collected[defaultLocale])
45
+ return defaultLocale;
46
+ return localesWithTranslations(collected)[0];
47
+ }
48
+ /** `wrangler kv bulk put` input: one entry per locale and namespace. */
49
+ export function buildBulkData(collected, keyPrefix) {
50
+ const bulkData = [];
51
+ for (const [locale, namespaces] of Object.entries(collected)) {
52
+ for (const [ns, json] of Object.entries(namespaces)) {
53
+ bulkData.push({ key: formatTranslationKvKey(keyPrefix, locale, ns), value: JSON.stringify(json) });
54
+ }
55
+ }
56
+ return bulkData;
57
+ }
58
+ function generateSchemaTs(schema, indent = 4) {
59
+ const pad = ' '.repeat(indent);
60
+ const padInner = ' '.repeat(indent + 2);
61
+ if (schema && typeof schema === 'object' && !Array.isArray(schema)) {
62
+ const entries = Object.entries(schema);
63
+ if (entries.length === 0)
64
+ return '{}';
65
+ const lines = entries.map(([key, value]) => {
66
+ const valueTs = value && typeof value === 'object' && !Array.isArray(value)
67
+ ? generateSchemaTs(value, indent + 2)
68
+ : 'string';
69
+ return `${padInner}${JSON.stringify(key)}: ${valueTs}`;
70
+ });
71
+ return `{\n${lines.join(';\n')};\n${pad}}`;
72
+ }
73
+ return 'string';
74
+ }
75
+ /** `src/i18n.generated.d.ts`: the global `I18n.Schema`, from the schema source locale. */
76
+ export function buildTypesContent(collected, defaultLocale) {
77
+ const source = schemaSourceLocale(collected, defaultLocale);
78
+ const schemaObject = (source && collected[source]) || {};
79
+ const namespaceLines = Object.entries(schemaObject).map(([ns, value]) => {
80
+ const valueTs = generateSchemaTs(value, 10);
81
+ return ` ${JSON.stringify(ns)}: ${valueTs}`;
82
+ });
83
+ const interfaceBody = namespaceLines.length > 0
84
+ ? ` export interface Schema {\n${namespaceLines.join(';\n')};\n }`
85
+ : ` export interface Schema {}`;
86
+ return `${HEADER}
87
+ declare global {
88
+ namespace I18n {
89
+ ${interfaceBody}
90
+ }
91
+ }
92
+
93
+ export {}
94
+ `;
95
+ }
96
+ /**
97
+ * `runtime-constants.ts`:
98
+ * - NAMESPACE_KEYS -> from the schema source locale (default locale preferred)
99
+ * - LOCALES_WITH_TRANSLATIONS -> from all collected locales
100
+ * - FULLY_TRANSLATED_LOCALES -> locales that have ALL namespace keys
101
+ */
102
+ export function buildRuntimeConstantsContent(collected, defaultLocale) {
103
+ const source = schemaSourceLocale(collected, defaultLocale);
104
+ const namespaceKeys = Object.keys((source && collected[source]) || {}).sort();
105
+ const locales = localesWithTranslations(collected);
106
+ const fullyTranslatedLocales = locales.filter((locale) => {
107
+ const localeNamespaces = Object.keys(collected[locale] || {});
108
+ return namespaceKeys.every((ns) => localeNamespaces.includes(ns));
109
+ });
110
+ return `${HEADER}
111
+ export const NAMESPACE_KEYS = [${namespaceKeys.map(quoteString).join(', ')}] as [string, ...string[]];
112
+
113
+ export const LOCALES_WITH_TRANSLATIONS = [${locales.map(quoteString).join(', ')}] as const;
114
+
115
+ export const FULLY_TRANSLATED_LOCALES = [${fullyTranslatedLocales.map(quoteString).join(', ')}] as const;
116
+ `;
117
+ }
118
+ /**
119
+ * `fallbacks.generated.ts`: one `FALLBACK_<NAMESPACE>` per default-locale namespace.
120
+ * Null when the default locale has no translations (nothing to fall back to).
121
+ */
122
+ export function buildFallbacksContent(collected, defaultLocale) {
123
+ const fallbackNamespaces = collected[defaultLocale];
124
+ if (!fallbackNamespaces)
125
+ return null;
126
+ const lines = [HEADER];
127
+ lines.push(`export const FALLBACK_LOCALE = ${JSON.stringify(defaultLocale)} as const;\n`);
128
+ for (const [ns, json] of Object.entries(fallbackNamespaces)) {
129
+ const constName = `FALLBACK_${ns.toUpperCase()}`;
130
+ lines.push(`export const ${constName} = ${JSON.stringify(json, null, 2)} as const;\n`);
131
+ }
132
+ return lines.join('\n');
133
+ }
@@ -0,0 +1,19 @@
1
+ import type { CollectedData } from './generate.ts';
2
+ /**
3
+ * Per-namespace content hashes and the change diff that drives the cache purge.
4
+ * Pure apart from hashing. Same algorithm as the site's `scripts/bundle-translations.ts`,
5
+ * so an existing `.i18n-hashes.json` stays a valid baseline.
6
+ */
7
+ export type HashMap = Record<string, string>;
8
+ export declare function stableStringify(value: unknown): string;
9
+ export declare function computeHash(value: unknown): string;
10
+ /**
11
+ * Hash every collected namespace and list the "<locale>:<namespace>" pairs whose hash
12
+ * differs from the previous run (all of them when there is no previous run).
13
+ */
14
+ export declare function diffHashes(collected: CollectedData, previousHashes: HashMap): {
15
+ currentHashes: HashMap;
16
+ changedKeys: string[];
17
+ };
18
+ /** Purge API URLs for the changed "<locale>:<namespace>" pairs. */
19
+ export declare function purgeUrlsFor(changedKeys: readonly string[], keyPrefix: string): string[];
@@ -0,0 +1,42 @@
1
+ import crypto from 'node:crypto';
2
+ import { formatTranslationCacheUrl } from "../key-format.js";
3
+ export function stableStringify(value) {
4
+ if (value === null || typeof value !== 'object') {
5
+ return JSON.stringify(value);
6
+ }
7
+ if (Array.isArray(value)) {
8
+ return '[' + value.map(stableStringify).join(',') + ']';
9
+ }
10
+ const entries = Object.entries(value).sort(([a], [b]) => a.localeCompare(b));
11
+ return '{' + entries.map(([k, v]) => JSON.stringify(k) + ':' + stableStringify(v)).join(',') + '}';
12
+ }
13
+ export function computeHash(value) {
14
+ const json = stableStringify(value);
15
+ return crypto.createHash('sha256').update(json).digest('hex').slice(0, 12);
16
+ }
17
+ /**
18
+ * Hash every collected namespace and list the "<locale>:<namespace>" pairs whose hash
19
+ * differs from the previous run (all of them when there is no previous run).
20
+ */
21
+ export function diffHashes(collected, previousHashes) {
22
+ const currentHashes = {};
23
+ const changedKeys = [];
24
+ for (const [locale, namespaces] of Object.entries(collected)) {
25
+ for (const [ns, json] of Object.entries(namespaces)) {
26
+ const hashKey = `${locale}:${ns}`;
27
+ const hash = computeHash(json);
28
+ currentHashes[hashKey] = hash;
29
+ if (previousHashes[hashKey] !== hash) {
30
+ changedKeys.push(hashKey);
31
+ }
32
+ }
33
+ }
34
+ return { currentHashes, changedKeys };
35
+ }
36
+ /** Purge API URLs for the changed "<locale>:<namespace>" pairs. */
37
+ export function purgeUrlsFor(changedKeys, keyPrefix) {
38
+ return changedKeys.map((hashKey) => {
39
+ const [locale, ns] = hashKey.split(':');
40
+ return formatTranslationCacheUrl(keyPrefix, locale, ns);
41
+ });
42
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,362 @@
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
+ import { DEFAULT_GENERATED_DIR, DEFAULT_LOCALES_DIR } from "../types.js";
6
+ import { assertValidNames, buildBulkData, buildFallbacksContent, buildRuntimeConstantsContent, buildTypesContent, localesWithTranslations, schemaSourceLocale, } from "./generate.js";
7
+ import { diffHashes, purgeUrlsFor } from "./hashes.js";
8
+ import { CONFIG_FILE, loadI18nConfig } from "./load-config.js";
9
+ import { purgeTranslationsCache } from "./purge.js";
10
+ import { getKvNamespaceId, getWranglerVar, KV_BINDING, resolveWranglerBin } from "./wrangler.js";
11
+ const HELP = `Usage: edgekits-i18n <command> [--fallbacks]
12
+
13
+ Reads ${CONFIG_FILE} and <localesDir>/<locale>/<namespace>.json from the current directory.
14
+
15
+ Commands:
16
+ bundle Write i18n-data.json, src/i18n.generated.d.ts and <generatedDir>/runtime-constants.ts.
17
+ No side effects. --fallbacks also writes <generatedDir>/fallbacks.generated.ts.
18
+ seed bundle + fallbacks, then push to LOCAL KV (wrangler --local).
19
+ .i18n-hashes.json is left untouched: it records production, not local KV.
20
+ migrate bundle + fallbacks, then push to production KV, purge the changed edge-cache
21
+ entries, and write .i18n-hashes.json. Refuses to run while .i18n-hashes.json
22
+ has uncommitted changes.
23
+ `;
24
+ const COMMANDS = ['bundle', 'seed', 'migrate'];
25
+ const ROOT = process.cwd();
26
+ // Fixed outputs at the project root.
27
+ const OUTPUT_DATA = path.join(ROOT, 'i18n-data.json');
28
+ const OUTPUT_TYPES = path.join(ROOT, 'src', 'i18n.generated.d.ts');
29
+ // Stores per-namespace content hashes from the last production migrate.
30
+ // Used to detect which namespaces actually changed and need cache purging.
31
+ // TRACKED in git: the committed copy is the production purge baseline.
32
+ // Absence means "first run", all namespaces treated as changed.
33
+ const HASHES_FILE = path.join(ROOT, '.i18n-hashes.json');
34
+ const KV_NAMESPACE_ID = /^[0-9a-f]{32}$/i;
35
+ await main(process.argv.slice(2));
36
+ async function main(args) {
37
+ const command = args.find((arg) => !arg.startsWith('-'));
38
+ if (!command || args.includes('--help') || args.includes('-h')) {
39
+ console.log(HELP);
40
+ return;
41
+ }
42
+ if (!COMMANDS.includes(command)) {
43
+ console.error(`[i18n] Unknown command "${command}".\n`);
44
+ console.error(HELP);
45
+ process.exit(1);
46
+ }
47
+ await run(command, args);
48
+ }
49
+ async function run(command, args) {
50
+ const shouldDeploy = command !== 'bundle';
51
+ const isLocal = command === 'seed';
52
+ const shouldGenerateFallbacks = shouldDeploy || args.includes('--fallbacks') || process.env.I18N_GENERATE_FALLBACKS === 'true';
53
+ if (command === 'migrate') {
54
+ assertCleanPurgeBaseline();
55
+ }
56
+ const config = await loadI18nConfig(ROOT);
57
+ const defaultLocale = config.defaultLocale;
58
+ const localesDir = path.join(ROOT, config.localesDir ?? DEFAULT_LOCALES_DIR);
59
+ const generatedDir = path.join(ROOT, config.generatedDir ?? DEFAULT_GENERATED_DIR);
60
+ const outputRuntimeConstants = path.join(generatedDir, 'runtime-constants.ts');
61
+ const outputFallbacks = path.join(generatedDir, 'fallbacks.generated.ts');
62
+ // Read before writing anything, so a missing binding fails the run up front.
63
+ const kvId = shouldDeploy ? getKvNamespaceId(ROOT, KV_BINDING) : undefined;
64
+ // The id becomes a command-line argument (deploy()); only a real namespace id's shape
65
+ // may get there.
66
+ if (kvId !== undefined && !KV_NAMESPACE_ID.test(kvId)) {
67
+ console.error(`[i18n] The ${KV_BINDING} id in wrangler.jsonc, "${kvId}", is not a KV namespace id (32 hex characters).`);
68
+ process.exit(1);
69
+ }
70
+ // The project's wrangler, found before anything is written.
71
+ let wranglerBin;
72
+ if (shouldDeploy) {
73
+ try {
74
+ wranglerBin = resolveWranglerBin(ROOT);
75
+ }
76
+ catch (err) {
77
+ console.error(`[i18n] ${err instanceof Error ? err.message : String(err)}`);
78
+ process.exit(1);
79
+ }
80
+ }
81
+ // -------------------------------------------------------------------------
82
+ // 1. Collect translations from <localesDir>/<locale>/<namespace>.json
83
+ // -------------------------------------------------------------------------
84
+ const collected = collectTranslations(localesDir);
85
+ try {
86
+ assertValidNames(collected, config.languages);
87
+ }
88
+ catch (err) {
89
+ console.error(`[i18n] ${err instanceof Error ? err.message : String(err)}`);
90
+ process.exit(1);
91
+ }
92
+ const locales = localesWithTranslations(collected);
93
+ if (locales.length === 0) {
94
+ console.warn(`[i18n] No locales with JSON files found in ${localesDir}. Generating empty artifacts.`);
95
+ }
96
+ // -------------------------------------------------------------------------
97
+ // 2. Compute per-namespace content hashes and detect changes
98
+ // -------------------------------------------------------------------------
99
+ // Load previous hashes (absent on first run — all namespaces treated as new)
100
+ const previousHashes = fs.existsSync(HASHES_FILE)
101
+ ? JSON.parse(fs.readFileSync(HASHES_FILE, 'utf8'))
102
+ : {};
103
+ const { currentHashes, changedKeys } = diffHashes(collected, previousHashes);
104
+ if (changedKeys.length > 0) {
105
+ console.log(`[i18n] Changed namespaces (${changedKeys.length}):`);
106
+ changedKeys.forEach((k) => console.log(` - ${k}`));
107
+ }
108
+ else {
109
+ console.log('[i18n] No translation changes detected.');
110
+ }
111
+ // -------------------------------------------------------------------------
112
+ // 3. Write KV bulk data, types and runtime constants
113
+ // -------------------------------------------------------------------------
114
+ fs.writeFileSync(OUTPUT_DATA, JSON.stringify(buildBulkData(collected, config.keyPrefix), null, 2) + '\n', 'utf8');
115
+ const source = schemaSourceLocale(collected, defaultLocale);
116
+ if (source && source !== defaultLocale) {
117
+ console.warn(`[i18n] defaultLocale="${defaultLocale}" has no translation files. ` +
118
+ `Using "${source}" as the source for I18n.Schema.`);
119
+ }
120
+ writeFile(OUTPUT_TYPES, buildTypesContent(collected, defaultLocale));
121
+ writeFile(outputRuntimeConstants, buildRuntimeConstantsContent(collected, defaultLocale));
122
+ // -------------------------------------------------------------------------
123
+ // 4. Fallbacks (seed, migrate, or bundle --fallbacks)
124
+ // -------------------------------------------------------------------------
125
+ let wroteFallbacks = false;
126
+ if (shouldGenerateFallbacks) {
127
+ const fallbacksContent = buildFallbacksContent(collected, defaultLocale);
128
+ if (fallbacksContent === null) {
129
+ console.warn(`[i18n] No translations found for defaultLocale="${defaultLocale}". ` + 'Skipping fallback generation.');
130
+ }
131
+ else {
132
+ writeFile(outputFallbacks, fallbacksContent);
133
+ wroteFallbacks = true;
134
+ }
135
+ }
136
+ else {
137
+ console.log('[i18n] Skipping fallback generation. Enable with I18N_GENERATE_FALLBACKS=true or --fallbacks.');
138
+ }
139
+ const outputs = [OUTPUT_DATA, OUTPUT_TYPES, outputRuntimeConstants, wroteFallbacks ? outputFallbacks : null].filter(Boolean);
140
+ console.log('Generated:\n' + outputs.map((p) => `- ${path.relative(ROOT, p).split(path.sep).join('/')}`).join('\n'));
141
+ // -------------------------------------------------------------------------
142
+ // 5. Push to KV + purge changed cache entries via Cloudflare API
143
+ // seed → local KV only, no purge (local KV; the edge cache is not involved), hashes untouched
144
+ // migrate → remote KV + Cloudflare Purge API, hashes written on success
145
+ // -------------------------------------------------------------------------
146
+ if (!shouldDeploy) {
147
+ console.log('[i18n] Skipping KV deploy (bundle). Use `edgekits-i18n seed` or `edgekits-i18n migrate`.');
148
+ return;
149
+ }
150
+ await deploy({ config, kvId: kvId, wranglerBin: wranglerBin, isLocal, changedKeys, currentHashes });
151
+ }
152
+ function collectTranslations(localesDir) {
153
+ if (!fs.existsSync(localesDir)) {
154
+ console.error(`Locales directory not found: ${localesDir}`);
155
+ process.exit(1);
156
+ }
157
+ const localeDirs = fs
158
+ .readdirSync(localesDir, { withFileTypes: true })
159
+ .filter((d) => d.isDirectory())
160
+ .map((d) => d.name)
161
+ .filter((locale) => {
162
+ const fullPath = path.join(localesDir, locale);
163
+ const files = fs.readdirSync(fullPath, { withFileTypes: true });
164
+ return files.some((f) => f.isFile() && f.name.endsWith('.json'));
165
+ });
166
+ const collected = {};
167
+ for (const locale of localeDirs) {
168
+ const localeDir = path.join(localesDir, locale);
169
+ const files = fs
170
+ .readdirSync(localeDir, { withFileTypes: true })
171
+ .filter((f) => f.isFile() && f.name.endsWith('.json'));
172
+ for (const file of files) {
173
+ const namespace = path.basename(file.name, '.json');
174
+ const raw = fs.readFileSync(path.join(localeDir, file.name), 'utf8');
175
+ const json = JSON.parse(raw);
176
+ if (!collected[locale])
177
+ collected[locale] = {};
178
+ collected[locale][namespace] = json;
179
+ }
180
+ }
181
+ return collected;
182
+ }
183
+ function writeFile(file, content) {
184
+ fs.mkdirSync(path.dirname(file), { recursive: true });
185
+ fs.writeFileSync(file, content, 'utf8');
186
+ }
187
+ async function deploy({ config, kvId, wranglerBin, isLocal, changedKeys, currentHashes, }) {
188
+ const kvFlag = isLocal ? '--local' : '--remote';
189
+ const target = isLocal ? 'local' : 'remote';
190
+ // Push all translations to KV
191
+ console.log(`[i18n] Pushing translations to ${target} KV...`);
192
+ try {
193
+ // wrangler's JavaScript entry, run by this Node binary: no shell on any platform
194
+ // (no `.cmd` shim on Windows), and the arguments are constants plus the shape-checked id.
195
+ execFileSync(process.execPath, [wranglerBin, 'kv', 'bulk', 'put', 'i18n-data.json', kvFlag, `--namespace-id=${kvId}`], {
196
+ cwd: ROOT,
197
+ stdio: 'inherit',
198
+ });
199
+ console.log(`[i18n] ✅ KV updated (${target}).`);
200
+ }
201
+ catch (err) {
202
+ console.error(`[i18n] ❌ Failed to push to ${target} KV.`, err);
203
+ process.exit(1);
204
+ }
205
+ if (isLocal) {
206
+ // Local KV: no purge (the edge cache is not involved), and the hashes describe production only.
207
+ console.log('[i18n] .i18n-hashes.json left untouched (local seed).');
208
+ return;
209
+ }
210
+ // ---------------------------------------------------------------------------
211
+ // Graceful recovery for the hash file:
212
+ //
213
+ // We update .i18n-hashes.json ONLY when one of these holds:
214
+ // - No changes: nothing needed purging to begin with.
215
+ // - A successful purge of all changed URLs.
216
+ //
217
+ // If a purge was attempted but failed (network error, rate limit
218
+ // saturation, API error, missing credentials), we DO NOT write the file.
219
+ // This way, the next `edgekits-i18n migrate` will see the same diff as this
220
+ // one and re-attempt the invalidation automatically. No manual intervention,
221
+ // no silent cache staleness.
222
+ // ---------------------------------------------------------------------------
223
+ let shouldWriteHashes = false;
224
+ if (changedKeys.length === 0) {
225
+ // Nothing changed — nothing to purge, safe to update hashes.
226
+ //
227
+ // Said out loud rather than passed over: a silent no-op on a deploy path is
228
+ // what made the 2026-09-12 staleness take an afternoon to find. Legitimate
229
+ // outcome, so not an error — but the operator has to be able to see it, and
230
+ // to see WHICH half of the job was skipped.
231
+ console.log('[i18n] Nothing changed against the recorded baseline, so NO cache entry was purged.\n' +
232
+ ' The bulk put above is unconditional, so production KV did receive every value;\n' +
233
+ ' it is the edge cache that was left alone, and it holds translations for a year.\n' +
234
+ ' If you expected a change here, .i18n-hashes.json is describing a state that\n' +
235
+ ' production is not actually in.');
236
+ shouldWriteHashes = true;
237
+ }
238
+ else {
239
+ // Changes — the purge must succeed before we update hashes.
240
+ loadDevVars();
241
+ const purgeUrls = purgeUrlsFor(changedKeys, config.keyPrefix);
242
+ const zoneId = getWranglerVar(ROOT, 'CLOUDFLARE_ZONE_ID') || process.env.CLOUDFLARE_ZONE_ID;
243
+ const apiToken = process.env.CLOUDFLARE_CACHEPURGE_API_TOKEN;
244
+ if (!zoneId) {
245
+ console.warn('[i18n] ⚠️ CLOUDFLARE_ZONE_ID not found in wrangler.jsonc vars or environment. ' +
246
+ 'Skipping cache purge — hash file NOT updated. Re-run `edgekits-i18n migrate` after setting the variable.');
247
+ }
248
+ else if (!apiToken) {
249
+ console.warn('[i18n] ⚠️ CLOUDFLARE_CACHEPURGE_API_TOKEN not set in environment. ' +
250
+ 'Skipping cache purge — hash file NOT updated. Re-run `edgekits-i18n migrate` after setting the secret.');
251
+ }
252
+ else {
253
+ console.log(`[i18n] Purging ${purgeUrls.length} cache entries via Cloudflare API...`);
254
+ const purgeSuccess = await purgeTranslationsCache(zoneId, apiToken, purgeUrls);
255
+ if (purgeSuccess) {
256
+ console.log('[i18n] ✅ Cache purge completed.');
257
+ shouldWriteHashes = true;
258
+ }
259
+ else {
260
+ console.error('[i18n] ❌ Cache purge failed — hash file NOT updated. ' +
261
+ 'Re-run `edgekits-i18n migrate` to retry the same invalidation.');
262
+ // Non-fatal for KV: translations are already pushed. Cache will
263
+ // converge on the next successful migrate.
264
+ }
265
+ }
266
+ }
267
+ if (shouldWriteHashes) {
268
+ fs.writeFileSync(HASHES_FILE, JSON.stringify(currentHashes, null, 2) + '\n', 'utf8');
269
+ console.log('[i18n] ✅ .i18n-hashes.json updated.');
270
+ }
271
+ }
272
+ /**
273
+ * Load .dev.vars into process.env. wrangler dev reads .dev.vars for the Worker
274
+ * runtime, but Node does not — so it is parsed here. Never overwrites a variable
275
+ * already set in the real environment.
276
+ */
277
+ function loadDevVars() {
278
+ const devVarsPath = path.join(ROOT, '.dev.vars');
279
+ if (!fs.existsSync(devVarsPath))
280
+ return;
281
+ const lines = fs.readFileSync(devVarsPath, 'utf8').split('\n');
282
+ for (const line of lines) {
283
+ const trimmed = line.trim();
284
+ // Skip empty lines and comments
285
+ if (!trimmed || trimmed.startsWith('#'))
286
+ continue;
287
+ const eqIndex = trimmed.indexOf('=');
288
+ if (eqIndex === -1)
289
+ continue;
290
+ const key = trimmed.slice(0, eqIndex).trim();
291
+ const value = trimmed.slice(eqIndex + 1).trim();
292
+ if (key && !(key in process.env)) {
293
+ process.env[key] = value;
294
+ }
295
+ }
296
+ }
297
+ // ---------------------------------------------------------------------------
298
+ // Precondition (migrate only): the purge baseline must be clean
299
+ //
300
+ // `.i18n-hashes.json` records what PRODUCTION KV already holds, and the diff
301
+ // against it is the ONLY thing that decides which cache entries get purged.
302
+ //
303
+ // Migrate against a modified copy and the diff comes out wrong. Note what that
304
+ // does and does not break: the bulk put is unconditional, so every value still
305
+ // reaches production KV. What is skipped is the PURGE — and translations are
306
+ // served `s-maxage=31536000, immutable` (see fetcher.ts), so the stale edge copy
307
+ // never ages out. A purge is the only thing that ends it. The run then writes a
308
+ // baseline claiming production is current, and exits 0.
309
+ //
310
+ // The live page that results is neither the old one nor the new one: keys that
311
+ // exist only in the shipped fallbacks render beside keys still served stale
312
+ // from the edge. That reached production on 2026-09-12, and nothing failed
313
+ // loudly — it was caught by somebody reading the live page. Hence a
314
+ // precondition that refuses to run, rather than a warning nobody sees.
315
+ //
316
+ // `seed` never writes this file, so it is exempt and never dirties it.
317
+ // ---------------------------------------------------------------------------
318
+ /**
319
+ * Abort if the purge baseline carries uncommitted changes.
320
+ *
321
+ * Outside a git work tree, or with no git on PATH, this warns and proceeds: a
322
+ * guard on the deploy path must not be the reason the CLI cannot run in CI
323
+ * or from a tarball.
324
+ */
325
+ function assertCleanPurgeBaseline() {
326
+ const relativePath = path.relative(ROOT, HASHES_FILE).split(path.sep).join('/');
327
+ let status;
328
+ try {
329
+ // Porcelain v1 is a stable format, and `--` scopes it to the one path, so a
330
+ // dirty working tree elsewhere (the usual state) is not this guard's business.
331
+ status = execFileSync('git', ['status', '--porcelain', '--', relativePath], {
332
+ cwd: ROOT,
333
+ encoding: 'utf8',
334
+ stdio: ['ignore', 'pipe', 'pipe'],
335
+ });
336
+ }
337
+ catch (err) {
338
+ console.warn(`[i18n] ⚠️ Could not check ${relativePath} against git (${err instanceof Error ? err.message.split('\n')[0] : String(err)}).\n` +
339
+ ' Proceeding WITHOUT the purge-baseline check. If that file carries hashes\n' +
340
+ ' that do not describe production, this run will skip the cache purge and say nothing.');
341
+ return;
342
+ }
343
+ if (status.trim() === '')
344
+ return;
345
+ console.error(`\n[i18n] ❌ ${relativePath} has uncommitted changes. Refusing to migrate.\n` +
346
+ `\n git status --porcelain: ${status.trim()}\n` +
347
+ '\n That file is the purge baseline: the record of what production KV already holds.\n' +
348
+ ' Migrating against a modified copy makes the change diff come out wrong. Every value\n' +
349
+ ' is still pushed to KV, but the cache purge is driven by that diff, so stale edge\n' +
350
+ ' entries survive — and translations are cached immutable for a year, so they do not\n' +
351
+ ' age out on their own. The run would exit 0 as though it had worked.\n' +
352
+ '\n Two things dirty this file, and they need opposite fixes:\n' +
353
+ '\n 1. Something other than migrate wrote it (a hand edit, or a pre-package local\n' +
354
+ ' seed script). Those hashes lie about production. Discard them, then migrate:\n' +
355
+ ' git checkout .i18n-hashes.json && npx edgekits-i18n migrate\n' +
356
+ '\n 2. A previous `edgekits-i18n migrate` wrote it. Those hashes describe production\n' +
357
+ ' and are correct. Commit them; migrating again is unnecessary:\n' +
358
+ ' git add .i18n-hashes.json && git commit\n' +
359
+ '\n The CLI cannot tell the two apart — the file looks identical either way. If you\n' +
360
+ ' are unsure, the last i18n command you ran is the answer.\n');
361
+ process.exit(1);
362
+ }
@@ -0,0 +1,7 @@
1
+ import { type I18nConfig } from '../types.ts';
2
+ export declare const CONFIG_FILE = "edgekits-i18n.config.ts";
3
+ /**
4
+ * Load the project's `edgekits-i18n.config.ts` from `root` — the same file its
5
+ * `astro.config` passes to the integration — and validate it.
6
+ */
7
+ export declare function loadI18nConfig(root: string): Promise<I18nConfig>;
@@ -0,0 +1,19 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createJiti } from 'jiti';
4
+ import { defineI18nConfig } from "../types.js";
5
+ export const CONFIG_FILE = 'edgekits-i18n.config.ts';
6
+ /**
7
+ * Load the project's `edgekits-i18n.config.ts` from `root` — the same file its
8
+ * `astro.config` passes to the integration — and validate it.
9
+ */
10
+ export async function loadI18nConfig(root) {
11
+ const configPath = path.join(root, CONFIG_FILE);
12
+ if (!fs.existsSync(configPath)) {
13
+ console.error(`[i18n] ${CONFIG_FILE} not found in ${root}`);
14
+ process.exit(1);
15
+ }
16
+ const jiti = createJiti(import.meta.url);
17
+ const config = await jiti.import(configPath, { default: true });
18
+ return defineI18nConfig(config);
19
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Purge a list of URLs from Cloudflare's edge cache via the Purge API.
3
+ *
4
+ * Handles Cloudflare's rate limits automatically:
5
+ * - Max 100 URLs per request (max operations per request)
6
+ * - Max 800 URLs per second on Free plan (8 chunks of 100)
7
+ * - HTTP 429: up to MAX_RETRIES retries per chunk, waiting `Retry-After` seconds when
8
+ * the response sends it, otherwise 2 s, 4 s, 8 s… (every wait capped at
9
+ * MAX_RETRY_WAIT_MS); then the chunk counts as failed.
10
+ *
11
+ * Reference: https://developers.cloudflare.com/cache/how-to/purge-cache/
12
+ *
13
+ * `deps` replaces `fetch` and the wait in tests.
14
+ *
15
+ * @returns true if all chunks succeeded, false if any chunk failed
16
+ */
17
+ export declare function purgeTranslationsCache(zoneId: string, apiToken: string, urls: string[], deps?: PurgeDeps): Promise<boolean>;
18
+ export type PurgeDeps = {
19
+ fetch?: (input: string, init: RequestInit) => Promise<Response>;
20
+ sleep?: (ms: number) => Promise<void>;
21
+ };
22
+ export declare const MAX_RETRIES = 5;
23
+ export declare const MAX_RETRY_WAIT_MS = 30000;
24
+ /**
25
+ * The wait before 429 retry number `attempt` (1-based): `Retry-After` in seconds when
26
+ * the header holds a non-negative number, otherwise 2 s doubled per attempt. Either way
27
+ * capped at MAX_RETRY_WAIT_MS, so a large header cannot stall the run.
28
+ */
29
+ export declare function retryWaitMs(retryAfter: string | null, attempt: number): number;