@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 +51 -0
- package/README.md +124 -38
- package/dist/assemble.d.ts +5 -0
- package/dist/assemble.js +6 -1
- package/dist/cli/collect.d.ts +13 -0
- package/dist/cli/collect.js +54 -0
- package/dist/cli/dev-vars.d.ts +8 -0
- package/dist/cli/dev-vars.js +13 -0
- package/dist/cli/generate.d.ts +16 -4
- package/dist/cli/generate.js +171 -20
- package/dist/cli/hashes.d.ts +23 -3
- package/dist/cli/hashes.js +56 -2
- package/dist/cli/index.js +344 -187
- package/dist/cli/wrangler.d.ts +16 -2
- package/dist/cli/wrangler.js +63 -10
- package/dist/create-i18n.js +3 -3
- package/dist/deep-merge.d.ts +4 -1
- package/dist/deep-merge.js +7 -6
- package/dist/fetcher.js +80 -66
- package/dist/format.d.ts +15 -5
- package/dist/format.js +41 -13
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/key-format.js +5 -2
- package/dist/types.d.ts +2 -1
- package/dist/types.js +19 -1
- package/package.json +2 -2
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
|
|
5
|
-
its own URL
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
|
57
|
-
|
|
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
|
|
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
|
|
79
|
-
|
|
|
80
|
-
| `--config <path>`
|
|
81
|
-
| `--wrangler <path>`
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
103
|
-
`
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
package/dist/assemble.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/cli/generate.d.ts
CHANGED
|
@@ -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
|
|
16
|
-
* compile to the same fallback identifier (`app.billing` and
|
|
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[];
|