@edgekits/i18n-core 0.0.0-stage → 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 (48) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/LICENSE +21 -0
  3. package/README.md +143 -2
  4. package/dist/accept-language.d.ts +20 -0
  5. package/dist/accept-language.js +51 -0
  6. package/dist/assemble.d.ts +21 -0
  7. package/dist/assemble.js +27 -0
  8. package/dist/cli/generate.d.ts +52 -0
  9. package/dist/cli/generate.js +171 -0
  10. package/dist/cli/hashes.d.ts +19 -0
  11. package/dist/cli/hashes.js +42 -0
  12. package/dist/cli/index.d.ts +2 -0
  13. package/dist/cli/index.js +449 -0
  14. package/dist/cli/key-prefix-check.d.ts +6 -0
  15. package/dist/cli/key-prefix-check.js +27 -0
  16. package/dist/cli/load-config.d.ts +7 -0
  17. package/dist/cli/load-config.js +17 -0
  18. package/dist/cli/purge.d.ts +29 -0
  19. package/dist/cli/purge.js +94 -0
  20. package/dist/cli/wrangler.d.ts +10 -0
  21. package/dist/cli/wrangler.js +71 -0
  22. package/dist/cli/zone.d.ts +21 -0
  23. package/dist/cli/zone.js +42 -0
  24. package/dist/config.d.ts +20 -0
  25. package/dist/config.js +21 -0
  26. package/dist/cookie.d.ts +33 -0
  27. package/dist/cookie.js +32 -0
  28. package/dist/create-i18n.d.ts +64 -0
  29. package/dist/create-i18n.js +112 -0
  30. package/dist/deep-merge.d.ts +8 -0
  31. package/dist/deep-merge.js +32 -0
  32. package/dist/fetcher.d.ts +31 -0
  33. package/dist/fetcher.js +223 -0
  34. package/dist/format.d.ts +50 -0
  35. package/dist/format.js +103 -0
  36. package/dist/index.d.ts +12 -0
  37. package/dist/index.js +13 -0
  38. package/dist/key-format.d.ts +9 -0
  39. package/dist/key-format.js +21 -0
  40. package/dist/locale-fallback.d.ts +32 -0
  41. package/dist/locale-fallback.js +46 -0
  42. package/dist/markdown-path.d.ts +18 -0
  43. package/dist/markdown-path.js +22 -0
  44. package/dist/parse-boolean.d.ts +9 -0
  45. package/dist/parse-boolean.js +20 -0
  46. package/dist/types.d.ts +63 -0
  47. package/dist/types.js +45 -0
  48. package/package.json +59 -4
package/CHANGELOG.md ADDED
@@ -0,0 +1,43 @@
1
+ # Changelog
2
+
3
+ All notable changes to this package are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the package uses
5
+ [Semantic Versioning](https://semver.org/).
6
+
7
+ ## 0.2.1 — 2026-10-27
8
+
9
+ No changes; released in lockstep with the plugin rename (see `@edgekits/astro-i18n` 0.2.1).
10
+
11
+ ## 0.2.0 — 2026-10-27
12
+
13
+ First release, extracted from `@edgekits/astro-i18n` 0.1.8: the engine with no framework in it.
14
+
15
+ ### Added
16
+
17
+ - `createI18n(config, generated, bindings)`: one instance per project. The platform bindings
18
+ (KV, cache, `waitUntil`, the two flags) are arguments, read when a function runs, so getters
19
+ over request-scoped values work. The instance carries the locale constants, the generated
20
+ `NAMESPACE_KEYS`, the fetcher, the fallback cascade, the lang-cookie helpers over any
21
+ `CookieJar` and the KV key and cache URL builders.
22
+ - No zod: locales are checked by `isLocale`/`parseLocale` against the config's `languages`.
23
+ - `routing` is optional and validated here, but read by the adapter (`'prefix'`,
24
+ `'prefix-except-default'`).
25
+ - The fallback cascade takes a `userPreference` source between the cookie and
26
+ Accept-Language.
27
+ - `localizedPath(locale, path, options)`: the path of a page in a locale for either routing
28
+ mode.
29
+ - `fallbackIdentifier` and dotted namespace names: `app.billing.json` is the namespace
30
+ `app.billing`, compiled as `FALLBACK_APP__BILLING`.
31
+ - CLI `edgekits-i18n`: `--config` and `--wrangler` name the config and the Worker's wrangler
32
+ config (monorepos); the config's directory is the project root, the wrangler config's the
33
+ Worker root.
34
+
35
+ ### Fixed
36
+
37
+ - After a failed KV read the fetcher serves the compiled fallback and no longer writes it to
38
+ the edge cache (it was cached immutable for a year under the requested locale's URL).
39
+
40
+ ### Changed
41
+
42
+ - Config validation errors are prefixed `[@edgekits/i18n-core]` (they said
43
+ `[@edgekits/astro-i18n]`).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gary Stupak
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,144 @@
1
- # Temporary Holding Version
1
+ # @edgekits/i18n-core
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The EdgeKits edge-native i18n engine, free of any framework. Translations live as data in
4
+ Workers KV, one JSON value per locale and namespace; each namespace is cached at the edge under
5
+ its own URL and purged through the Cloudflare Purge API only when its content changed; the
6
+ default locale's JSON files define a typed schema. After a KV error the compiled fallback is
7
+ served and not cached, so the next request reads KV again. This package holds the config validation,
8
+ the fetcher, locale resolution, the lang cookie, the format helpers and the `edgekits-i18n`
9
+ CLI. A framework adapter builds on it: for Astro, use
10
+ [`@edgekits/astro-i18n`](https://github.com/EdgeKits/i18n/tree/main/packages/astro#readme),
11
+ which depends on this package and needs nothing else from you.
12
+
13
+ ## `createI18n`
14
+
15
+ One instance per project: your config, the CLI's generated modules, and the platform bindings.
16
+ Every binding is read when a function runs, never when the instance is created, so getters over
17
+ request-scoped values work.
18
+
19
+ ```ts
20
+ import { env, waitUntil } from 'cloudflare:workers'
21
+ import { createI18n, parseBooleanFlag } from '@edgekits/i18n-core'
22
+
23
+ import config from '../edgekits-i18n.config'
24
+ import * as fallbacks from './i18n/fallbacks.generated'
25
+ import * as runtimeConstants from './i18n/runtime-constants'
26
+
27
+ export const i18n = createI18n(
28
+ config,
29
+ { fallbacks, runtimeConstants },
30
+ {
31
+ get kv() {
32
+ return env.TRANSLATIONS
33
+ },
34
+ get cache() {
35
+ return caches.default ?? null
36
+ },
37
+ waitUntil,
38
+ get cacheEnabled() {
39
+ return parseBooleanFlag(env.I18N_CACHE, true)
40
+ },
41
+ get debug() {
42
+ return parseBooleanFlag(env.DEBUG_I18N, false)
43
+ },
44
+ },
45
+ )
46
+
47
+ const { common } = await i18n.fetchTranslations('de', ['common'])
48
+ ```
49
+
50
+ The instance also carries the locale constants (`DEFAULT_LOCALE`, `SUPPORTED_LOCALES`, `NAMESPACE_KEYS`,
51
+ `LANGUAGES`), `isLocale`/`parseLocale`, the fallback cascade (`resolveFallbackLocale`), the
52
+ lang-cookie helpers over any `CookieJar`, and the KV key and cache URL builders the CLI shares.
53
+ `routing` in the config is validated here but read by the adapter.
54
+
55
+ The pure helpers are exported from the root and from `./format` (Node-safe): `fmt`, `plural`,
56
+ `pluralIcu`, `toIetfTag`, and `localizedPath`, the path of a page in a locale for either
57
+ routing mode (a leading locale segment is stripped first; the result ends in a slash):
58
+
59
+ ```ts
60
+ import { localizedPath } from '@edgekits/i18n-core/format'
61
+
62
+ const options = { routing: 'prefix-except-default', defaultLocale: 'en', isLocale: (s: string) => ['en', 'de'].includes(s) } as const
63
+
64
+ localizedPath('de', '/about/', options) // '/de/about/'
65
+ localizedPath('en', '/de/about/', options) // '/about/'
66
+ localizedPath('en', '/de/about/', { ...options, routing: 'prefix' }) // '/en/about/'
67
+ ```
68
+
69
+ ## CLI
70
+
71
+ The `edgekits-i18n` command is this package's. It reads `edgekits-i18n.config.ts` and
72
+ `wrangler.jsonc` from the directory it runs in, unless two flags name them:
73
+
74
+ ```sh
75
+ npx edgekits-i18n seed
76
+ ```
77
+
78
+ | Flag | Default | What comes from it |
79
+ | ------------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
80
+ | `--config <path>` | `./edgekits-i18n.config.ts` | The config. Its directory is the project root: `localesDir`, `generatedDir`, `i18n-data.json`, `src/i18n.generated.d.ts` and `.i18n-hashes.json` resolve there. |
81
+ | `--wrangler <path>` | `./wrangler.jsonc` | The `TRANSLATIONS` KV id and the `CLOUDFLARE_ZONE_ID` var. Its directory is the Worker root: `.dev.vars` and the project's wrangler are found there. |
82
+
83
+ Both paths resolve against the current directory, and a flag may come before or after the
84
+ command. The CLI always runs wrangler with `--config` set to that file, from its directory, so
85
+ local KV state lands in the `.wrangler/state/` that `wrangler dev` there reads.
86
+
87
+ | Command | Writes | Then |
88
+ | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
89
+ | `edgekits-i18n bundle` | `i18n-data.json` (the KV payload), `src/i18n.generated.d.ts` (the schema), `<generatedDir>/runtime-constants.ts` | nothing else |
90
+ | `edgekits-i18n bundle --fallbacks` | the above and `<generatedDir>/fallbacks.generated.ts` (the default locale compiled in, served if KV is unreachable) | nothing else |
91
+ | `edgekits-i18n seed` | the same as `bundle --fallbacks` | pushes every value to local KV (`wrangler kv bulk put --local`) |
92
+ | `edgekits-i18n migrate` | the same as `bundle --fallbacks` | pushes to production KV, purges the changed namespaces' edge-cache entries, writes `.i18n-hashes.json` |
93
+
94
+ The generated files are ordinary project files: commit them, and run the CLI again after you
95
+ edit a JSON file.
96
+
97
+ A namespace is the file name under `<localesDir>/<locale>/`: segments of lowercase letters,
98
+ digits and underscores, joined by single dots, so `locales/en/app.billing.json` is the
99
+ namespace `app.billing`. Its compiled fallback is `FALLBACK_APP__BILLING` (dots become `__`),
100
+ so `app.billing` and `app__billing` cannot both exist; the KV key and cache URL keep the dot.
101
+
102
+ `.i18n-hashes.json` holds one content hash per namespace and locale as of the last successful
103
+ `migrate`; the next `migrate` purges exactly the entries whose hash changed. Rules:
104
+
105
+ - Only `migrate` writes it, and only after the purge succeeded (or nothing changed). `seed`
106
+ never touches it: local KV is not production.
107
+ - Commit it after every `migrate`. It is the record of what production holds.
108
+ - `migrate` refuses to run while the file has uncommitted changes, since a wrong baseline would
109
+ skip purges silently. When that git check cannot run (no `git` on the PATH, or not a git work
110
+ tree), `migrate` warns and proceeds without the purge-baseline check.
111
+
112
+ `migrate` reads the zone ID from `CLOUDFLARE_ZONE_ID` (a var in `wrangler.jsonc` or the
113
+ environment) and the token from `CLOUDFLARE_CACHEPURGE_API_TOKEN` (the environment or
114
+ `.dev.vars`). Without either, it still pushes to KV but skips the purge and leaves
115
+ `.i18n-hashes.json` alone, so the next run retries.
116
+
117
+ ### Monorepo: one translations folder, several Workers
118
+
119
+ ```text
120
+ packages/i18n/ edgekits-i18n.config.ts, locales/, the generated files, .i18n-hashes.json
121
+ apps/app/ wrangler.jsonc (+ .dev.vars)
122
+ apps/admin/ wrangler.jsonc (+ .dev.vars)
123
+ ```
124
+
125
+ - `packages/i18n/` holds the config, `locales/` and the generated files; each app imports them
126
+ from there.
127
+ - Every app's `wrangler.jsonc` binds the same `TRANSLATIONS` namespace and sets the same
128
+ `CLOUDFLARE_ZONE_ID`.
129
+ - Run one `migrate` per change, from the repository root, with any one app's wrangler config:
130
+
131
+ ```sh
132
+ npx edgekits-i18n migrate --config packages/i18n/edgekits-i18n.config.ts --wrangler apps/app/wrangler.jsonc
133
+ ```
134
+
135
+ It pushes once to the shared namespace and purges once: the cache URLs live on the zone, so
136
+ the purge reaches every host on it.
137
+
138
+ - `.i18n-hashes.json` lives next to the config; commit it there.
139
+ - `seed` takes the same flags. Local KV state belongs to the app whose wrangler config you pass,
140
+ so seed once per app you run `wrangler dev` in.
141
+
142
+ ## License
143
+
144
+ MIT © Gary Stupak. See [LICENSE](LICENSE).
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The Accept-Language step of the middleware's fallback cascade, read from the header
3
+ * itself. Astro's own match is no substitute: it keeps only the tags that equal a
4
+ * configured locale before choosing, so a regional tag (`es-MX`) never reaches a
5
+ * language-part match. Pure: no Astro, no virtual module, no `cloudflare:`.
6
+ */
7
+ export type AcceptLanguageEntry = {
8
+ tag: string;
9
+ q: number;
10
+ };
11
+ /**
12
+ * The header's language ranges, highest `q` first (header order among equals). A missing
13
+ * or unparsable `q` is 1; `q=0` entries and the `*` wildcard are dropped.
14
+ */
15
+ export declare function parseAcceptLanguage(header: string | null): AcceptLanguageEntry[];
16
+ /**
17
+ * The first supported locale the header names: for each tag in `q` order, the full tag
18
+ * (lowercased), then its language subtag (before the first `-` or `_`). Null when none.
19
+ */
20
+ export declare function matchAcceptLanguage<L extends string>(header: string | null, parseLocale: (value: unknown) => L | null): L | null;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The Accept-Language step of the middleware's fallback cascade, read from the header
3
+ * itself. Astro's own match is no substitute: it keeps only the tags that equal a
4
+ * configured locale before choosing, so a regional tag (`es-MX`) never reaches a
5
+ * language-part match. Pure: no Astro, no virtual module, no `cloudflare:`.
6
+ */
7
+ /**
8
+ * The header's language ranges, highest `q` first (header order among equals). A missing
9
+ * or unparsable `q` is 1; `q=0` entries and the `*` wildcard are dropped.
10
+ */
11
+ export function parseAcceptLanguage(header) {
12
+ if (!header)
13
+ return [];
14
+ const entries = [];
15
+ for (const part of header.split(',')) {
16
+ const [rawTag = '', ...params] = part.split(';');
17
+ const tag = rawTag.trim();
18
+ if (!tag || tag === '*')
19
+ continue;
20
+ let q = 1;
21
+ for (const param of params) {
22
+ const [name, value] = param.split('=').map((s) => s.trim());
23
+ if (name?.toLowerCase() !== 'q' || value === undefined)
24
+ continue;
25
+ const parsed = Number.parseFloat(value);
26
+ if (!Number.isNaN(parsed))
27
+ q = parsed;
28
+ }
29
+ if (q <= 0)
30
+ continue;
31
+ entries.push({ tag, q });
32
+ }
33
+ // Array.prototype.sort is stable: equal q keeps header order.
34
+ return entries.sort((a, b) => b.q - a.q);
35
+ }
36
+ /**
37
+ * The first supported locale the header names: for each tag in `q` order, the full tag
38
+ * (lowercased), then its language subtag (before the first `-` or `_`). Null when none.
39
+ */
40
+ export function matchAcceptLanguage(header, parseLocale) {
41
+ for (const { tag } of parseAcceptLanguage(header)) {
42
+ const lower = tag.toLowerCase();
43
+ const full = parseLocale(lower);
44
+ if (full)
45
+ return full;
46
+ const language = parseLocale(lower.split(/[-_]/)[0]);
47
+ if (language)
48
+ return language;
49
+ }
50
+ return null;
51
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Assemble a single translation namespace from a raw value and its compiled
3
+ * fallback dictionary.
4
+ *
5
+ * This is the ONLY place namespace assembly happens: `fetchTranslations` routes
6
+ * its cache-HIT branch, its cache-miss/KV branch and `fetchAllFromKV` through it,
7
+ * so a cached namespace and a freshly read one can never diverge in shape.
8
+ *
9
+ * Semantics (unchanged from the original miss path):
10
+ * - the fallback provides the full known key set,
11
+ * - the raw value (cached entry or KV read) overrides it wherever it has a value,
12
+ * - `null` / `undefined` are tolerated for either argument.
13
+ *
14
+ * Consequence for stale cache entries: a namespace cached before a schema addition
15
+ * degrades to default-locale text for the keys it predates instead of handing pages
16
+ * `undefined` (see ADR-0014 and the 2026-07-18 `/en/blog/` incident).
17
+ *
18
+ * Deliberately pure — no `cloudflare:workers` imports, so it is testable under
19
+ * plain Node (`npm test`).
20
+ */
21
+ export declare function assembleNamespace(rawValue: unknown, fallback: unknown): object;
@@ -0,0 +1,27 @@
1
+ import { deepMerge } from "./deep-merge.js";
2
+ /**
3
+ * Assemble a single translation namespace from a raw value and its compiled
4
+ * fallback dictionary.
5
+ *
6
+ * This is the ONLY place namespace assembly happens: `fetchTranslations` routes
7
+ * its cache-HIT branch, its cache-miss/KV branch and `fetchAllFromKV` through it,
8
+ * so a cached namespace and a freshly read one can never diverge in shape.
9
+ *
10
+ * Semantics (unchanged from the original miss path):
11
+ * - the fallback provides the full known key set,
12
+ * - the raw value (cached entry or KV read) overrides it wherever it has a value,
13
+ * - `null` / `undefined` are tolerated for either argument.
14
+ *
15
+ * Consequence for stale cache entries: a namespace cached before a schema addition
16
+ * degrades to default-locale text for the keys it predates instead of handing pages
17
+ * `undefined` (see ADR-0014 and the 2026-07-18 `/en/blog/` incident).
18
+ *
19
+ * Deliberately pure — no `cloudflare:workers` imports, so it is testable under
20
+ * plain Node (`npm test`).
21
+ */
22
+ export function assembleNamespace(rawValue, fallback) {
23
+ const raw = rawValue ?? {};
24
+ if (!fallback)
25
+ return raw;
26
+ return deepMerge(fallback, raw);
27
+ }
@@ -0,0 +1,52 @@
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`, a namespace file name does not match NAMESPACE_NAME, or two namespaces
16
+ * compile to the same fallback identifier (`app.billing` and `app__billing`). Run on the
17
+ * collected tree before anything is generated or pushed.
18
+ */
19
+ export declare function assertValidNames(collected: CollectedData, languages: Readonly<Record<string, unknown>>): void;
20
+ /**
21
+ * A single-quoted TS string literal, escaped by JSON.stringify. For every name the
22
+ * validation accepts it is byte-identical to the plain `'name'` the CLI always wrote, so
23
+ * existing generated files do not change; anything else comes out escaped, not injected.
24
+ */
25
+ export declare function quoteString(value: string): string;
26
+ /** Locales that have at least one namespace, sorted. */
27
+ export declare function localesWithTranslations(collected: CollectedData): string[];
28
+ /**
29
+ * The locale the schema and the namespace list are derived from: the default locale
30
+ * when it has translations, otherwise the first locale that does.
31
+ */
32
+ export declare function schemaSourceLocale(collected: CollectedData, defaultLocale: string): string | undefined;
33
+ /** `wrangler kv bulk put` input: one entry per locale and namespace. */
34
+ export declare function buildBulkData(collected: CollectedData, keyPrefix: string): KvBulkEntry[];
35
+ /**
36
+ * `src/i18n.generated.d.ts`: the global `I18n.Schema`, from the schema source locale.
37
+ * Throws, naming the key, when an array in it mixes kinds of elements.
38
+ */
39
+ export declare function buildTypesContent(collected: CollectedData, defaultLocale: string): string;
40
+ /**
41
+ * `runtime-constants.ts`:
42
+ * - NAMESPACE_KEYS -> from the schema source locale (default locale preferred)
43
+ * - LOCALES_WITH_TRANSLATIONS -> from all collected locales
44
+ * - FULLY_TRANSLATED_LOCALES -> locales that have ALL namespace keys
45
+ */
46
+ export declare function buildRuntimeConstantsContent(collected: CollectedData, defaultLocale: string): string;
47
+ /**
48
+ * `fallbacks.generated.ts`: one `FALLBACK_<NAMESPACE>` (`fallbackIdentifier`) per
49
+ * default-locale namespace.
50
+ * Null when the default locale has no translations (nothing to fall back to).
51
+ */
52
+ export declare function buildFallbacksContent(collected: CollectedData, defaultLocale: string): string | null;
@@ -0,0 +1,171 @@
1
+ import { fallbackIdentifier, 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 (dots as `__`) the
6
+ // `FALLBACK_<NAMESPACE>` identifier, so its file name is restricted to what is valid in
7
+ // all three: segments of [a-z0-9_], joined by single dots (`app.billing`).
8
+ export const NAMESPACE_NAME = /^[a-z0-9][a-z0-9_]*(\.[a-z0-9][a-z0-9_]*)*$/;
9
+ /**
10
+ * Throws, naming the offender, when a locale folder is not one of the config's
11
+ * `languages`, a namespace file name does not match NAMESPACE_NAME, or two namespaces
12
+ * compile to the same fallback identifier (`app.billing` and `app__billing`). Run on the
13
+ * collected tree before anything is generated or pushed.
14
+ */
15
+ export function assertValidNames(collected, languages) {
16
+ const configured = Object.keys(languages);
17
+ for (const [locale, namespaces] of Object.entries(collected)) {
18
+ if (!configured.includes(locale)) {
19
+ throw new Error(`Locale folder "${locale}" is not one of \`languages\` in edgekits-i18n.config.ts: ${configured.join(', ')}.`);
20
+ }
21
+ const identifiers = new Map();
22
+ for (const ns of Object.keys(namespaces)) {
23
+ if (!NAMESPACE_NAME.test(ns)) {
24
+ throw new Error(`Namespace "${ns}" (${locale}/${ns}.json) must match ${NAMESPACE_NAME}: ` +
25
+ 'segments of lowercase letters, digits and underscores, joined by single dots.');
26
+ }
27
+ const identifier = fallbackIdentifier(ns);
28
+ const other = identifiers.get(identifier);
29
+ if (other !== undefined) {
30
+ throw new Error(`Namespaces "${other}" (${locale}/${other}.json) and "${ns}" (${locale}/${ns}.json) ` +
31
+ `both compile to ${identifier}. Rename one of them.`);
32
+ }
33
+ identifiers.set(identifier, ns);
34
+ }
35
+ }
36
+ }
37
+ /**
38
+ * A single-quoted TS string literal, escaped by JSON.stringify. For every name the
39
+ * validation accepts it is byte-identical to the plain `'name'` the CLI always wrote, so
40
+ * existing generated files do not change; anything else comes out escaped, not injected.
41
+ */
42
+ export function quoteString(value) {
43
+ const escaped = JSON.stringify(value).slice(1, -1).replace(/\\"/g, '"').replace(/'/g, "\\'");
44
+ return `'${escaped}'`;
45
+ }
46
+ /** Locales that have at least one namespace, sorted. */
47
+ export function localesWithTranslations(collected) {
48
+ return Object.keys(collected).sort();
49
+ }
50
+ /**
51
+ * The locale the schema and the namespace list are derived from: the default locale
52
+ * when it has translations, otherwise the first locale that does.
53
+ */
54
+ export function schemaSourceLocale(collected, defaultLocale) {
55
+ if (collected[defaultLocale])
56
+ return defaultLocale;
57
+ return localesWithTranslations(collected)[0];
58
+ }
59
+ /** `wrangler kv bulk put` input: one entry per locale and namespace. */
60
+ export function buildBulkData(collected, keyPrefix) {
61
+ const bulkData = [];
62
+ for (const [locale, namespaces] of Object.entries(collected)) {
63
+ for (const [ns, json] of Object.entries(namespaces)) {
64
+ bulkData.push({ key: formatTranslationKvKey(keyPrefix, locale, ns), value: JSON.stringify(json) });
65
+ }
66
+ }
67
+ return bulkData;
68
+ }
69
+ function isTree(value) {
70
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
71
+ }
72
+ // The JSON kind of an array element; an array is typed only when all its elements share one.
73
+ function jsonKind(value) {
74
+ if (value === null)
75
+ return 'null';
76
+ if (Array.isArray(value))
77
+ return 'array';
78
+ return typeof value;
79
+ }
80
+ /**
81
+ * The TS type of one JSON value. Objects become object types; arrays become `string[]`
82
+ * (strings, other scalars, or empty) or `Array<…>` typed from their first element
83
+ * (objects, nested arrays); every other leaf stays `string`. `keyPath` and `file` name the
84
+ * value in the error a mixed array raises.
85
+ */
86
+ function generateSchemaTs(value, indent, keyPath, file) {
87
+ if (Array.isArray(value)) {
88
+ if (value.length === 0)
89
+ return 'string[]';
90
+ const kinds = [...new Set(value.map(jsonKind))];
91
+ if (kinds.length > 1) {
92
+ throw new Error(`Key "${keyPath}" (${file}) is an array that mixes ${kinds.join(', ')} elements: ` +
93
+ 'I18n.Schema cannot type it. Use one kind of element per array.');
94
+ }
95
+ const first = value[0];
96
+ if (!isTree(first) && !Array.isArray(first))
97
+ return 'string[]';
98
+ return `Array<${generateSchemaTs(first, indent, `${keyPath}[0]`, file)}>`;
99
+ }
100
+ if (!isTree(value))
101
+ return 'string';
102
+ const entries = Object.entries(value);
103
+ if (entries.length === 0)
104
+ return '{}';
105
+ const pad = ' '.repeat(indent);
106
+ const padInner = ' '.repeat(indent + 2);
107
+ const lines = entries.map(([key, child]) => `${padInner}${JSON.stringify(key)}: ${generateSchemaTs(child, indent + 2, `${keyPath}.${key}`, file)}`);
108
+ return `{\n${lines.join(';\n')};\n${pad}}`;
109
+ }
110
+ /**
111
+ * `src/i18n.generated.d.ts`: the global `I18n.Schema`, from the schema source locale.
112
+ * Throws, naming the key, when an array in it mixes kinds of elements.
113
+ */
114
+ export function buildTypesContent(collected, defaultLocale) {
115
+ const source = schemaSourceLocale(collected, defaultLocale);
116
+ const schemaObject = (source && collected[source]) || {};
117
+ const namespaceLines = Object.entries(schemaObject).map(([ns, value]) => {
118
+ const valueTs = generateSchemaTs(value, 10, ns, `${source}/${ns}.json`);
119
+ return ` ${JSON.stringify(ns)}: ${valueTs}`;
120
+ });
121
+ const interfaceBody = namespaceLines.length > 0
122
+ ? ` export interface Schema {\n${namespaceLines.join(';\n')};\n }`
123
+ : ` export interface Schema {}`;
124
+ return `${HEADER}
125
+ declare global {
126
+ namespace I18n {
127
+ ${interfaceBody}
128
+ }
129
+ }
130
+
131
+ export {}
132
+ `;
133
+ }
134
+ /**
135
+ * `runtime-constants.ts`:
136
+ * - NAMESPACE_KEYS -> from the schema source locale (default locale preferred)
137
+ * - LOCALES_WITH_TRANSLATIONS -> from all collected locales
138
+ * - FULLY_TRANSLATED_LOCALES -> locales that have ALL namespace keys
139
+ */
140
+ export function buildRuntimeConstantsContent(collected, defaultLocale) {
141
+ const source = schemaSourceLocale(collected, defaultLocale);
142
+ const namespaceKeys = Object.keys((source && collected[source]) || {}).sort();
143
+ const locales = localesWithTranslations(collected);
144
+ const fullyTranslatedLocales = locales.filter((locale) => {
145
+ const localeNamespaces = Object.keys(collected[locale] || {});
146
+ return namespaceKeys.every((ns) => localeNamespaces.includes(ns));
147
+ });
148
+ return `${HEADER}
149
+ export const NAMESPACE_KEYS = [${namespaceKeys.map(quoteString).join(', ')}] as [string, ...string[]];
150
+
151
+ export const LOCALES_WITH_TRANSLATIONS = [${locales.map(quoteString).join(', ')}] as const;
152
+
153
+ export const FULLY_TRANSLATED_LOCALES = [${fullyTranslatedLocales.map(quoteString).join(', ')}] as const;
154
+ `;
155
+ }
156
+ /**
157
+ * `fallbacks.generated.ts`: one `FALLBACK_<NAMESPACE>` (`fallbackIdentifier`) per
158
+ * default-locale namespace.
159
+ * Null when the default locale has no translations (nothing to fall back to).
160
+ */
161
+ export function buildFallbacksContent(collected, defaultLocale) {
162
+ const fallbackNamespaces = collected[defaultLocale];
163
+ if (!fallbackNamespaces)
164
+ return null;
165
+ const lines = [HEADER];
166
+ lines.push(`export const FALLBACK_LOCALE = ${JSON.stringify(defaultLocale)} as const;\n`);
167
+ for (const [ns, json] of Object.entries(fallbackNamespaces)) {
168
+ lines.push(`export const ${fallbackIdentifier(ns)} = ${JSON.stringify(json, null, 2)} as const;\n`);
169
+ }
170
+ return lines.join('\n');
171
+ }
@@ -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 {};