@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.
- package/CHANGELOG.md +43 -0
- package/LICENSE +21 -0
- package/README.md +143 -2
- package/dist/accept-language.d.ts +20 -0
- package/dist/accept-language.js +51 -0
- package/dist/assemble.d.ts +21 -0
- package/dist/assemble.js +27 -0
- package/dist/cli/generate.d.ts +52 -0
- package/dist/cli/generate.js +171 -0
- package/dist/cli/hashes.d.ts +19 -0
- package/dist/cli/hashes.js +42 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +449 -0
- package/dist/cli/key-prefix-check.d.ts +6 -0
- package/dist/cli/key-prefix-check.js +27 -0
- package/dist/cli/load-config.d.ts +7 -0
- package/dist/cli/load-config.js +17 -0
- package/dist/cli/purge.d.ts +29 -0
- package/dist/cli/purge.js +94 -0
- package/dist/cli/wrangler.d.ts +10 -0
- package/dist/cli/wrangler.js +71 -0
- package/dist/cli/zone.d.ts +21 -0
- package/dist/cli/zone.js +42 -0
- package/dist/config.d.ts +20 -0
- package/dist/config.js +21 -0
- package/dist/cookie.d.ts +33 -0
- package/dist/cookie.js +32 -0
- package/dist/create-i18n.d.ts +64 -0
- package/dist/create-i18n.js +112 -0
- package/dist/deep-merge.d.ts +8 -0
- package/dist/deep-merge.js +32 -0
- package/dist/fetcher.d.ts +31 -0
- package/dist/fetcher.js +223 -0
- package/dist/format.d.ts +50 -0
- package/dist/format.js +103 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +13 -0
- package/dist/key-format.d.ts +9 -0
- package/dist/key-format.js +21 -0
- package/dist/locale-fallback.d.ts +32 -0
- package/dist/locale-fallback.js +46 -0
- package/dist/markdown-path.d.ts +18 -0
- package/dist/markdown-path.js +22 -0
- package/dist/parse-boolean.d.ts +9 -0
- package/dist/parse-boolean.js +20 -0
- package/dist/types.d.ts +63 -0
- package/dist/types.js +45 -0
- 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
|
-
#
|
|
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,
|
|
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;
|
package/dist/assemble.js
ADDED
|
@@ -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
|
+
}
|