@edgekits/i18n-core 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,57 @@ All notable changes to this package are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the package uses
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## 0.3.0 — 2026-10-28
8
+
9
+ ### Upgrading from 0.2.x
10
+
11
+ - **`.i18n-hashes.json` changes shape.** It now holds one baseline per KV namespace id (`{ "<kvId>": { "<locale>:<ns>": "<hash>" } }`). An old flat file is the baseline of the top-level `TRANSLATIONS` id (what 0.2.x wrote), whichever environment migrates first, and is rewritten in the new shape. Commit it as before.
12
+ - **The edge cache starts cold once.** Cache URLs carry the entry format (`https://<keyPrefix>/i18n:v2:<locale>:<ns>`, encoded); 0.2.x entries are never read again and expire on their own. Expect one KV read per namespace and data center right after the deploy.
13
+ - **`migrate` takes about a minute longer and can exit 1.** It purges, waits `--purge-delay` seconds (default 65) for KV to settle, and purges the same URLs again. It exits 1 when KV was updated but the edge cache was not purged (no `CLOUDFLARE_ZONE_ID` or token, or a failed purge); `.i18n-hashes.json` is then not written and the next run retries. A CI step that passed without purge credentials now fails.
14
+ - **Regenerate and commit the generated files** (`npx edgekits-i18n bundle --fallbacks`, or `seed`). `I18n.Schema` types numbers and booleans as such and a list of objects as one merged object type (a key every element has is required, any other optional, a key of differing types a union); a list that mixes strings and objects stops the run. A `null` value and a namespace named `locale` are rejected. Hashes of namespaces with mixed-case keys change once (code-point key order), so the first `migrate` purges those namespaces too.
15
+ - **`keyPrefix` must be a plain lower-case host** (`example.com`, `my-site`): no scheme, path, port syntax, space or upper case. `defineI18nConfig` throws otherwise.
16
+ - **The CLI is stricter.** An unknown argument (`-e staging`) exits 1 naming it; `--purge-delay` is at most 600; `CLOUDFLARE_ZONE_ID` must be 32 hex characters. With `--env <name>`, `.dev.vars.<name>` replaces `.dev.vars` when it exists, as in wrangler. `migrate` compares only the baseline of the KV namespace it writes with the committed file, so `migrate --env staging && migrate` runs in one job.
17
+ - **Node `>=22.23.4`.**
18
+
19
+ ### Changed
20
+
21
+ - The fetcher caches the raw KV value of each namespace, never the merged namespace, marks each entry `X-EdgeKits-I18n: v2` (a response at the URL without it is a miss), and merges the current compiled fallback on every read; a key KV does not have is cached as a `null` marker with `s-maxage=60`; cache URLs are `https://<keyPrefix>/<encodeURIComponent(i18n:v2:<locale>:<ns>)>`.
22
+ - KV bulk reads take at most 100 keys at a time, in parallel; a failing read falls back for its own namespaces only.
23
+ - `migrate` purges twice, `--purge-delay` seconds apart (default 65), writes `.i18n-hashes.json` only after both, and exits 1 when the purge was skipped or failed.
24
+ - `.i18n-hashes.json` holds one baseline per KV namespace id; the old flat shape is the top-level id's baseline, read and rewritten. `migrate` refuses to run only while that KV namespace's baseline differs from the committed file (`HEAD`).
25
+ - `seed` writes the `TRANSLATIONS` binding's `preview_id` when it has one (what `wrangler dev` reads); `migrate` writes its `id`.
26
+ - `I18n.Schema` types numbers and booleans as such, and a list of objects as one object type merged from its elements.
27
+ - Content hashes sort keys by code point (the same on every machine) instead of `localeCompare`; locales and namespaces are read in code point order.
28
+ - CLI arguments are checked: anything but the command, `--fallbacks`, `--purge-all`, `--help` and the value flags exits 1; `--purge-delay` is at most 600; `CLOUDFLARE_ZONE_ID` must be 32 hex characters.
29
+ - With `--env <name>`, `migrate` reads `.dev.vars.<name>` instead of `.dev.vars` when it exists.
30
+ - `localizedPath` keeps a file path without a trailing slash and keeps a `?query`/`#hash`; `toIetfTag` cases each subtag by length; `plural` interpolates both forms.
31
+ - `engines.node` `>=22.23.4`.
32
+
33
+ ### Added
34
+
35
+ - CLI `--purge-delay <seconds>` and `--env <name>` (the binding and `CLOUDFLARE_ZONE_ID` from `env.<name>`; wrangler gets `--env`); without `--env`, a warning when an environment binds `TRANSLATIONS` to another id.
36
+ - `migrate` deletes the KV keys of translation files removed since the last run (`wrangler kv bulk delete`) and purges their URLs.
37
+ - Warnings for a namespace only non-default locales have, keys the default locale lacks, and JSON files in subfolders of a locale folder.
38
+ - `isFilePath`, from the root and `./format`.
39
+ - `migrate --purge-all`: purge every namespace's URLs whatever the baseline says (after a `git revert` of translations).
40
+ - A warning when two wrangler environments put different `TRANSLATIONS` namespaces on one `CLOUDFLARE_ZONE_ID` (they share cache URLs under one `keyPrefix`).
41
+
42
+ ### Fixed
43
+
44
+ - `.dev.vars` is read with dotenv syntax (`util.parseEnv`: quotes, `export `, inline `#` comments).
45
+ - A translation file with a BOM is read; an empty or invalid one stops the run naming `<localesDir>/<locale>/<file>`.
46
+ - A namespace named `locale` is rejected (its fallback collided with `FALLBACK_LOCALE`); a `null` value is rejected with its key path.
47
+ - `defineI18nConfig` rejects a `keyPrefix` that is not a URL host as written.
48
+ - A translated value under a `constructor` or `prototype` key was dropped by the fallback merge.
49
+ - A namespace a page mutated changed the compiled fallback for every later request in the isolate; each call now returns its own copy (`structuredClone`).
50
+ - `cache.put` got a clone of the entry's Response, and the original was never read.
51
+ - `localizedPath` treats `\` as a segment separator and drops tabs and newlines, as the URL parser does: a decoded `/\evil.com/` gives `/evil.com/`, never a protocol-relative path.
52
+ - `migrate --env staging` on a 0.2.x flat baseline read it as staging's and dropped it, so production never deleted its removed keys.
53
+
54
+ ## 0.2.2 — 2026-10-27
55
+
56
+ No changes; released in lockstep.
57
+
7
58
  ## 0.2.1 — 2026-10-27
8
59
 
9
60
  No changes; released in lockstep with the plugin rename (see `@edgekits/astro-i18n` 0.2.1).
package/README.md CHANGED
@@ -1,15 +1,21 @@
1
1
  # @edgekits/i18n-core
2
2
 
3
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,
4
+ Workers KV, one JSON value per locale and namespace; each namespace's KV value is cached at the
5
+ edge under its own URL (merged with the compiled fallback on every read) and purged through the
6
+ Cloudflare Purge API only when its content changed; a namespace KV does not have is cached as
7
+ missing for 60 seconds; the default locale's JSON files define a typed schema. Each cache entry
8
+ carries `X-EdgeKits-I18n: v2`; a response at that URL without it is a miss, read from KV again
9
+ and replaced. After a KV error the compiled fallback is served and not cached, so the next
10
+ request reads KV again. Every namespace a call returns is its own copy: mutating it never
11
+ changes the next request's text. This package holds the config validation,
8
12
  the fetcher, locale resolution, the lang cookie, the format helpers and the `edgekits-i18n`
9
13
  CLI. A framework adapter builds on it: for Astro, use
10
14
  [`@edgekits/astro-i18n`](https://github.com/EdgeKits/i18n/tree/main/packages/astro#readme),
11
15
  which depends on this package and needs nothing else from you.
12
16
 
17
+ Requires Node `>=22.23.4` (the CLI runs under it; the engine itself runs in workerd).
18
+
13
19
  ## `createI18n`
14
20
 
15
21
  One instance per project: your config, the CLI's generated modules, and the platform bindings.
@@ -44,17 +50,33 @@ export const i18n = createI18n(
44
50
  },
45
51
  )
46
52
 
47
- const { common } = await i18n.fetchTranslations('de', ['common'])
53
+ export default {
54
+ async fetch(request: Request): Promise<Response> {
55
+ // Inside a request, never at module scope: on Workers, `env` and the edge cache belong
56
+ // to the request.
57
+ const { common } = await i18n.fetchTranslations('de', ['common'])
58
+ return Response.json(common)
59
+ },
60
+ }
48
61
  ```
49
62
 
63
+ The edge cache URL's host is `keyPrefix` (`https://<keyPrefix>/…`) and the cache is per zone: two
64
+ Workers on one zone that read different KV namespaces (production and staging, say) need
65
+ different `keyPrefix` values, or each serves the other's cached translations.
66
+
50
67
  The instance also carries the locale constants (`DEFAULT_LOCALE`, `SUPPORTED_LOCALES`, `NAMESPACE_KEYS`,
51
68
  `LANGUAGES`), `isLocale`/`parseLocale`, the fallback cascade (`resolveFallbackLocale`), the
52
69
  lang-cookie helpers over any `CookieJar`, and the KV key and cache URL builders the CLI shares.
53
70
  `routing` in the config is validated here but read by the adapter.
54
71
 
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):
72
+ The pure helpers are exported from the root and from `./format` (Node-safe): `fmt`, `plural`
73
+ (both forms interpolate `{count}`), `pluralIcu`, `toIetfTag` (`zh-hant-tw` -> `zh-Hant-TW`),
74
+ `isFilePath`, and `localizedPath`, the path of a page in a locale for either routing mode. A
75
+ leading locale segment is stripped first; the result ends in a slash, except for a file path
76
+ (`isFilePath`: the last segment ends in an extension that starts with a letter, `/rss.xml`, not
77
+ `/blog/v2.0`); a `?query` and a `#hash` in the input are kept after the path. Segments are
78
+ read as a URL parser reads an https path (`\` separates them like `/`, a tab or newline is
79
+ dropped), so a decoded `/\evil.com/` gives `/evil.com/` and no result starts with `//`:
58
80
 
59
81
  ```ts
60
82
  import { localizedPath } from '@edgekits/i18n-core/format'
@@ -64,55 +86,119 @@ const options = { routing: 'prefix-except-default', defaultLocale: 'en', isLocal
64
86
  localizedPath('de', '/about/', options) // '/de/about/'
65
87
  localizedPath('en', '/de/about/', options) // '/about/'
66
88
  localizedPath('en', '/de/about/', { ...options, routing: 'prefix' }) // '/en/about/'
89
+ localizedPath('de', '/rss.xml', options) // '/de/rss.xml'
90
+ localizedPath('de', '/about/?x=1#top', options) // '/de/about/?x=1#top'
67
91
  ```
68
92
 
69
93
  ## CLI
70
94
 
71
95
  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:
96
+ `wrangler.jsonc` from the directory it runs in, unless flags name them:
73
97
 
74
98
  ```sh
75
99
  npx edgekits-i18n seed
76
100
  ```
77
101
 
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. |
102
+ | Flag | Default | What comes from it |
103
+ | ------------------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
104
+ | `--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. |
105
+ | `--wrangler <path>` | `./wrangler.jsonc` | The `TRANSLATIONS` KV binding and the `CLOUDFLARE_ZONE_ID` var. Its directory is the Worker root: `.dev.vars` and the project's wrangler are found there. |
106
+ | `--env <name>` | none (the top level) | The `TRANSLATIONS` binding and `CLOUDFLARE_ZONE_ID` from `env.<name>` of the wrangler config (wrangler does not inherit them into an environment); wrangler itself gets `--env <name>`; `.dev.vars.<name>` is read instead of `.dev.vars` when it exists, as wrangler does. |
107
+ | `--purge-delay <seconds>` | `65` | `migrate`: the wait between its two purges (below), `0` to `600`. `0` purges once. |
108
+ | `--purge-all` | off | `migrate`: purge every namespace's cache URLs, changed or not (after a `git revert` of translations, below). |
109
+ | `--fallbacks` | off | `bundle`: also write `<generatedDir>/fallbacks.generated.ts`. |
82
110
 
83
- Both paths resolve against the current directory, and a flag may come before or after the
111
+ The paths resolve against the current directory, and a flag may come before or after the
84
112
  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` |
113
+ local KV state lands in the `.wrangler/state/` that `wrangler dev` there reads. Without `--env`,
114
+ when an `env.<name>` binds `TRANSLATIONS` to another id, the CLI warns once, naming those
115
+ environments. Any other argument (`-e staging`, a second command) exits `1`, naming it.
116
+
117
+ Two environments on one zone that bind different KV namespaces share cache URLs when they share
118
+ the `keyPrefix`: each `migrate` would purge, and each Worker cache, the other's entries. Give
119
+ each environment its own `keyPrefix` (a config per environment, `--config`); `migrate` warns
120
+ when the wrangler config puts two such environments on one `CLOUDFLARE_ZONE_ID`.
121
+
122
+ | Command | Writes | Then |
123
+ | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
124
+ | `edgekits-i18n bundle` | `i18n-data.json` (the KV payload), `src/i18n.generated.d.ts` (the schema), `<generatedDir>/runtime-constants.ts` | nothing else |
125
+ | `edgekits-i18n bundle --fallbacks` | the above and `<generatedDir>/fallbacks.generated.ts` (the default locale compiled in, served if KV is unreachable) | nothing else |
126
+ | `edgekits-i18n seed` | the same as `bundle --fallbacks` | pushes every value to local KV (`wrangler kv bulk put --local`) |
127
+ | `edgekits-i18n migrate` | the same as `bundle --fallbacks` | pushes to remote KV, deletes the keys of removed files, purges the changed and removed namespaces' edge-cache entries twice, writes `.i18n-hashes.json` |
128
+
129
+ `seed` writes the `TRANSLATIONS` binding's `preview_id` when it has one (the namespace
130
+ `wrangler dev` reads), otherwise its `id`; `migrate` always writes the `id`. `seed` only adds
131
+ and overwrites: after you delete a translation file, wipe `.wrangler/state` (or delete the key
132
+ with `wrangler kv key delete --local`) so local KV stops serving it.
93
133
 
94
134
  The generated files are ordinary project files: commit them, and run the CLI again after you
95
- edit a JSON file.
135
+ edit a JSON file. Locales and namespaces are read in code point order, so the files come out
136
+ the same on every machine.
96
137
 
97
138
  A namespace is the file name under `<localesDir>/<locale>/`: segments of lowercase letters,
98
139
  digits and underscores, joined by single dots, so `locales/en/app.billing.json` is the
99
140
  namespace `app.billing`. Its compiled fallback is `FALLBACK_APP__BILLING` (dots become `__`),
100
141
  so `app.billing` and `app__billing` cannot both exist; the KV key and cache URL keep the dot.
142
+ The name `locale` is reserved: the fallbacks file already exports `FALLBACK_LOCALE`.
143
+
144
+ The files are read as JSON (a leading BOM is ignored). A file that is empty or not JSON, or a
145
+ `null` value anywhere (it would replace the default locale's text with `null`), stops the run
146
+ with the file and key path; JSON files in subfolders of a locale folder are ignored with a
147
+ warning. In `I18n.Schema`, leaves are `string`, `number` or `boolean` as in the default locale's
148
+ JSON; a list of objects is one object type merged from its elements: a key every element has is
149
+ required, any other optional (`?:`), and a key whose type differs between elements a union. A
150
+ list that mixes kinds of elements (strings and objects) stops the run, naming the key path and
151
+ the file, as a `null` does. The CLI warns about a namespace
152
+ that only non-default locales have, and about keys a locale has that the default locale lacks
153
+ (neither is typed).
154
+
155
+ `keyPrefix` must be a URL host as written: lowercase, no scheme, path or space: the project's
156
+ own domain (`www.my-site.com`) or a dotless name (`my-site`), never a host you do not own. It is the host of every edge-cache URL, so `defineI18nConfig` rejects anything else.
157
+
158
+ `.i18n-hashes.json` holds, per KV namespace id, one content hash per namespace and locale as of
159
+ the last successful `migrate` against that namespace:
160
+
161
+ ```json
162
+ { "<kvId>": { "en:common": "5c924960820d", "de:common": "…" } }
163
+ ```
101
164
 
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.
165
+ The next `migrate` purges exactly the entries whose hash changed. A file in the pre-0.3 flat
166
+ shape (`{ "en:common": "…" }`) is what 0.2.x wrote for the top-level `TRANSLATIONS` id: it is
167
+ that id's baseline whichever environment migrates first, and it stays that id's when another id
168
+ is written next to it. Rules:
169
+
170
+ - Only `migrate` writes it, and only after both purges succeeded (or nothing changed). `seed`
171
+ never touches it: local KV is not remote KV.
172
+ - Commit it after every `migrate`. It is the record of what each remote namespace holds; with
173
+ `--env`, staging and production keep separate baselines in the one file.
174
+ - `migrate` refuses to run while the baseline of the KV namespace it writes differs from the
175
+ one in the committed file (`git show HEAD:.i18n-hashes.json`), since a wrong baseline would
176
+ skip purges silently. Only that namespace's entry counts, so `migrate --env staging &&
177
+ migrate` in one job works. When the git check cannot run (no `git` on the PATH, or not a git
178
+ work tree), `migrate` warns and proceeds without it.
179
+ - A `git revert` of translations reverts the baseline with them, so the next `migrate` sees no
180
+ change and purges nothing while the edge still holds the reverted text. Run
181
+ `migrate --purge-all`: it purges every namespace's URLs whatever the baseline says.
182
+
183
+ A translation file removed since the last `migrate` (a `de/pricing.json` in the baseline but no
184
+ longer on disk): `migrate` lists its KV key, deletes it (`wrangler kv bulk delete --force`, the
185
+ same id, config and `--env`), and purges its cache URL with the others.
186
+
187
+ `migrate` purges, waits `--purge-delay` seconds (65 by default), and purges the same URLs again:
188
+ KV can take up to 60 s to show a write in other locations, and a request there in that window
189
+ would cache the old value again for a year.
190
+
191
+ `migrate` reads the zone ID from `CLOUDFLARE_ZONE_ID` (a var in `wrangler.jsonc`, or in
192
+ `env.<name>` with `--env`, or the environment) and the token from
193
+ `CLOUDFLARE_CACHEPURGE_API_TOKEN` (the environment or `.dev.vars`, read with dotenv syntax:
194
+ quotes, `export ` and `#` comments; with `--env <name>`, `.dev.vars.<name>` when it exists). The
195
+ zone ID must be 32 hex characters: from the wrangler config it is checked before anything is
196
+ written, from the environment at the purge.
197
+
198
+ Exit status: `0` on success, `1` on any error. `migrate` also exits `1` after the KV push when
199
+ the edge cache was not purged: no zone ID or token (or a malformed zone ID), a failed delete of
200
+ the removed files' keys, or a failed purge (either round). The message says so,
201
+ `.i18n-hashes.json` is left alone, and the next run retries the same deletions and purge.
116
202
 
117
203
  ### Monorepo: one translations folder, several Workers
118
204
 
@@ -125,7 +211,7 @@ apps/admin/ wrangler.jsonc (+ .dev.vars)
125
211
  - `packages/i18n/` holds the config, `locales/` and the generated files; each app imports them
126
212
  from there.
127
213
  - Every app's `wrangler.jsonc` binds the same `TRANSLATIONS` namespace and sets the same
128
- `CLOUDFLARE_ZONE_ID`.
214
+ `CLOUDFLARE_ZONE_ID` (or the same per `env.<name>`, used with `--env <name>`).
129
215
  - Run one `migrate` per change, from the repository root, with any one app's wrangler config:
130
216
 
131
217
  ```sh
@@ -15,6 +15,11 @@
15
15
  * degrades to default-locale text for the keys it predates instead of handing pages
16
16
  * `undefined` (see ADR-0014 and the 2026-07-18 `/en/blog/` incident).
17
17
  *
18
+ * The result is the caller's own: the fallback is a module-level object shared by every
19
+ * request in the isolate, so it is deep-copied first (`structuredClone`; deepMerge copies
20
+ * only the levels the raw value overrides). A page that mutates what it was handed never
21
+ * changes the next request's text.
22
+ *
18
23
  * Deliberately pure — no `cloudflare:workers` imports, so it is testable under
19
24
  * plain Node (`npm test`).
20
25
  */
package/dist/assemble.js CHANGED
@@ -16,6 +16,11 @@ import { deepMerge } from "./deep-merge.js";
16
16
  * degrades to default-locale text for the keys it predates instead of handing pages
17
17
  * `undefined` (see ADR-0014 and the 2026-07-18 `/en/blog/` incident).
18
18
  *
19
+ * The result is the caller's own: the fallback is a module-level object shared by every
20
+ * request in the isolate, so it is deep-copied first (`structuredClone`; deepMerge copies
21
+ * only the levels the raw value overrides). A page that mutates what it was handed never
22
+ * changes the next request's text.
23
+ *
19
24
  * Deliberately pure — no `cloudflare:workers` imports, so it is testable under
20
25
  * plain Node (`npm test`).
21
26
  */
@@ -23,5 +28,5 @@ export function assembleNamespace(rawValue, fallback) {
23
28
  const raw = rawValue ?? {};
24
29
  if (!fallback)
25
30
  return raw;
26
- return deepMerge(fallback, raw);
31
+ return deepMerge(structuredClone(fallback), raw);
27
32
  }
@@ -0,0 +1,13 @@
1
+ import type { CollectedData } from './generate.ts';
2
+ /**
3
+ * Read `<localesDir>/<locale>/<namespace>.json` into the collected tree. A locale is a
4
+ * folder of `localesDir` with at least one JSON file directly in it; JSON files in its
5
+ * subfolders are not read and come back in `ignored`. Locales and namespaces are inserted
6
+ * sorted by code point, so the generated files and the KV bulk data come out the same on
7
+ * every machine. Throws, naming the file through `display`, on a file that is empty or not
8
+ * JSON (a leading BOM is stripped first).
9
+ */
10
+ export declare function collectTranslations(localesDir: string, display: (file: string) => string): {
11
+ collected: CollectedData;
12
+ ignored: string[];
13
+ };
@@ -0,0 +1,54 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ // Code point order: the same on every machine, whatever the file system lists first.
4
+ const byCodePoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
5
+ /**
6
+ * Read `<localesDir>/<locale>/<namespace>.json` into the collected tree. A locale is a
7
+ * folder of `localesDir` with at least one JSON file directly in it; JSON files in its
8
+ * subfolders are not read and come back in `ignored`. Locales and namespaces are inserted
9
+ * sorted by code point, so the generated files and the KV bulk data come out the same on
10
+ * every machine. Throws, naming the file through `display`, on a file that is empty or not
11
+ * JSON (a leading BOM is stripped first).
12
+ */
13
+ export function collectTranslations(localesDir, display) {
14
+ const collected = {};
15
+ const ignored = [];
16
+ const localeDirs = fs
17
+ .readdirSync(localesDir, { withFileTypes: true })
18
+ .filter((d) => d.isDirectory())
19
+ .map((d) => d.name)
20
+ .sort(byCodePoint);
21
+ for (const locale of localeDirs) {
22
+ const localeDir = path.join(localesDir, locale);
23
+ // Namespace names (not file names: `a-b.json` sorts before `a.json`, `a` before `a-b`).
24
+ const entries = fs
25
+ .readdirSync(localeDir, { withFileTypes: true, recursive: true })
26
+ .sort((a, b) => byCodePoint(path.basename(a.name, '.json'), path.basename(b.name, '.json')));
27
+ for (const entry of entries) {
28
+ if (!entry.isFile() || !entry.name.endsWith('.json'))
29
+ continue;
30
+ const file = path.join(entry.parentPath, entry.name);
31
+ if (entry.parentPath !== localeDir) {
32
+ ignored.push(display(file));
33
+ continue;
34
+ }
35
+ const namespace = path.basename(entry.name, '.json');
36
+ if (!collected[locale])
37
+ collected[locale] = {};
38
+ collected[locale][namespace] = readTranslationFile(file, display);
39
+ }
40
+ }
41
+ return { collected, ignored };
42
+ }
43
+ function readTranslationFile(file, display) {
44
+ const raw = fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '');
45
+ if (raw.trim() === '') {
46
+ throw new Error(`${display(file)} is empty: write {} for a namespace with no keys yet.`);
47
+ }
48
+ try {
49
+ return JSON.parse(raw);
50
+ }
51
+ catch (err) {
52
+ throw new Error(`${display(file)} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
53
+ }
54
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * A `.dev.vars` file's variables, in the dotenv syntax wrangler reads it with: quoted
3
+ * values, `#` comments (inline too, outside quotes), `export ` prefixes, CRLF. Node's own
4
+ * `util.parseEnv` does the parsing. The BOM and the `export ` prefixes are removed first:
5
+ * Node's parser keeps a BOM in the first key, and the prefix is handled here rather than
6
+ * left to whichever Node release runs the CLI.
7
+ */
8
+ export declare function parseDevVars(content: string): Record<string, string>;
@@ -0,0 +1,13 @@
1
+ import { parseEnv } from 'node:util';
2
+ /**
3
+ * A `.dev.vars` file's variables, in the dotenv syntax wrangler reads it with: quoted
4
+ * values, `#` comments (inline too, outside quotes), `export ` prefixes, CRLF. Node's own
5
+ * `util.parseEnv` does the parsing. The BOM and the `export ` prefixes are removed first:
6
+ * Node's parser keeps a BOM in the first key, and the prefix is handled here rather than
7
+ * left to whichever Node release runs the CLI.
8
+ */
9
+ export function parseDevVars(content) {
10
+ const source = content.replace(/^\uFEFF/, '').replace(/^[ \t]*export[ \t]+/gm, '');
11
+ const parsed = parseEnv(source);
12
+ return Object.fromEntries(Object.entries(parsed).filter((entry) => typeof entry[1] === 'string'));
13
+ }
@@ -12,11 +12,16 @@ export type KvBulkEntry = {
12
12
  export declare const NAMESPACE_NAME: RegExp;
13
13
  /**
14
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.
15
+ * `languages`, a namespace file name does not match NAMESPACE_NAME or is reserved
16
+ * (`locale`), or two namespaces compile to the same fallback identifier (`app.billing` and
17
+ * `app__billing`). Run on the collected tree before anything is generated or pushed.
18
18
  */
19
19
  export declare function assertValidNames(collected: CollectedData, languages: Readonly<Record<string, unknown>>): void;
20
+ /**
21
+ * Throws, naming the key path and the file, on the first `null` in any locale: merged over
22
+ * the compiled fallback, a null replaces the default locale's text.
23
+ */
24
+ export declare function assertNoNullValues(collected: CollectedData): void;
20
25
  /**
21
26
  * A single-quoted TS string literal, escaped by JSON.stringify. For every name the
22
27
  * validation accepts it is byte-identical to the plain `'name'` the CLI always wrote, so
@@ -34,7 +39,7 @@ export declare function schemaSourceLocale(collected: CollectedData, defaultLoca
34
39
  export declare function buildBulkData(collected: CollectedData, keyPrefix: string): KvBulkEntry[];
35
40
  /**
36
41
  * `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.
42
+ * Throws, naming the key, when an array in it mixes kinds of elements or a value is null.
38
43
  */
39
44
  export declare function buildTypesContent(collected: CollectedData, defaultLocale: string): string;
40
45
  /**
@@ -50,3 +55,10 @@ export declare function buildRuntimeConstantsContent(collected: CollectedData, d
50
55
  * Null when the default locale has no translations (nothing to fall back to).
51
56
  */
52
57
  export declare function buildFallbacksContent(collected: CollectedData, defaultLocale: string): string | null;
58
+ /**
59
+ * Warnings about what I18n.Schema and the fallbacks will not cover, compared with the
60
+ * default locale: a namespace no default-locale file has, and keys a locale's file has
61
+ * that the default locale's lacks (a count per file, the first three paths). Empty when
62
+ * the default locale has no translations (the CLI warns about that on its own).
63
+ */
64
+ export declare function translationWarnings(collected: CollectedData, defaultLocale: string): string[];