@edgekits/astro-i18n 0.0.0-stage → 0.1.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 +21 -0
- package/LICENSE +21 -0
- package/README.md +394 -2
- package/dist/cli/generate.d.ts +47 -0
- package/dist/cli/generate.js +133 -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 +362 -0
- package/dist/cli/load-config.d.ts +7 -0
- package/dist/cli/load-config.js +19 -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/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/integration/index.d.ts +26 -0
- package/dist/integration/index.js +103 -0
- package/dist/integration/virtual-types.d.ts +14 -0
- package/dist/integration/virtual-types.js +34 -0
- package/dist/integration/vite-plugin.d.ts +17 -0
- package/dist/integration/vite-plugin.js +56 -0
- package/dist/key-format.d.ts +8 -0
- package/dist/key-format.js +15 -0
- package/dist/types.d.ts +66 -0
- package/dist/types.js +38 -0
- package/package.json +77 -4
- package/src/assemble.ts +29 -0
- package/src/cli/generate.ts +175 -0
- package/src/cli/hashes.ts +66 -0
- package/src/cli/index.ts +461 -0
- package/src/cli/load-config.ts +26 -0
- package/src/cli/purge.ts +122 -0
- package/src/cli/wrangler.ts +96 -0
- package/src/components/LanguageSwitcher.astro +210 -0
- package/src/components/MissingTranslationBanner.astro +31 -0
- package/src/config.entry.ts +38 -0
- package/src/config.ts +11 -0
- package/src/constants.ts +29 -0
- package/src/cookie-storage.ts +32 -0
- package/src/deep-merge.ts +38 -0
- package/src/fetcher.ts +304 -0
- package/src/format.ts +107 -0
- package/src/index.ts +11 -0
- package/src/integration/index.ts +136 -0
- package/src/integration/virtual-types.ts +34 -0
- package/src/integration/vite-plugin.ts +67 -0
- package/src/key-format.ts +17 -0
- package/src/locale-fallback.ts +65 -0
- package/src/markdown-path.ts +31 -0
- package/src/middleware/i18n.ts +223 -0
- package/src/middleware.ts +2 -0
- package/src/parse-boolean.ts +20 -0
- package/src/resolve-locale.ts +57 -0
- package/src/runtime.ts +19 -0
- package/src/schema.ts +28 -0
- package/src/translations-keys.ts +42 -0
- package/src/types.ts +119 -0
- package/src/virtual.d.ts +23 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
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.1.0 — unreleased
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- The engine extracted from edgekits.dev into its own package, with its git history.
|
|
12
|
+
- An Astro integration that serves the config and the generated files as virtual modules, sets `ssr.noExternal`, registers the middleware, and sets Astro's `i18n` block when the project has none.
|
|
13
|
+
- The `Locale` type narrowed to your locales through the augmentable `I18nLocales` interface.
|
|
14
|
+
- The `edgekits-i18n` CLI: `bundle`, `seed` and `migrate`, with per-namespace cache purges and the `.i18n-hashes.json` baseline.
|
|
15
|
+
- Node-safe entries `./config` and `./format` for code that runs outside workerd.
|
|
16
|
+
- Headless `LanguageSwitcher` and `MissingTranslationBanner`: a `classes` prop map and stable `data-*` hooks, no CSS shipped.
|
|
17
|
+
- `countryToLocale`, the geo-IP step of the locale fallback, as an optional config field.
|
|
18
|
+
- `markdownLocale`: the locale `.md` endpoints are redirected to, as an optional config field; without it they pass through untouched.
|
|
19
|
+
- Type declarations for the virtual modules, injected into the consumer's program, so `tsc` and `astro check` accept the runtime source.
|
|
20
|
+
- The `astro-i18n` agent skill (a Claude Code plugin, source in `plugins/astro-i18n/`): adopts the package, localizes forms and multilingual SEO; its version tracks the package's.
|
|
21
|
+
- Security audit fixes (2026-10-12): locale-negotiated redirects are temporary (302) and uncacheable (`Cache-Control: private, no-store`, `Vary: Cookie, Accept-Language`); the CLI validates locale folders and namespace names and escapes generated string literals; the purge client's HTTP 429 retry is bounded and honours `Retry-After`; hardening of `deepMerge` and KV-failure logging; the CLI runs the project's own wrangler (now a declared peer dependency, `^4.125.0`) through Node and `git` directly, with no shell on any platform.
|
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,395 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @edgekits/astro-i18n
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Edge-native i18n for Astro on Cloudflare Workers. Translations live as data in Workers KV, one
|
|
4
|
+
JSON value per locale and namespace, so the server renders every page in its language and
|
|
5
|
+
nothing ships to the client; changing a string is a KV write, not a redeploy. Each namespace is
|
|
6
|
+
cached at the edge under its own URL, and `edgekits-i18n migrate` purges only the namespaces
|
|
7
|
+
whose content changed through the Cloudflare Purge API. The default locale's JSON files define a
|
|
8
|
+
typed schema: the CLI generates the `I18n.Schema` declaration, so `t.common.hero.title` is
|
|
9
|
+
checked by TypeScript and a missing key is a compile error.
|
|
10
|
+
|
|
11
|
+
The design is explained in the edgekits.dev series:
|
|
12
|
+
[Stop Shipping Translations to the Client, Part 1](https://edgekits.dev/en/blog/edge-native-i18n-astro-cloudflare-part-1/),
|
|
13
|
+
[Part 2](https://edgekits.dev/en/blog/edge-native-i18n-astro-cloudflare-part-2/) and
|
|
14
|
+
[Stop Redeploying to Update Translations (Part 3)](https://edgekits.dev/en/blog/granular-edge-cache-purge-astro-cloudflare-part-3/).
|
|
15
|
+
|
|
16
|
+
## Requirements
|
|
17
|
+
|
|
18
|
+
- Astro `^7.3` with `output: 'server'` and the `@astrojs/cloudflare` adapter `^14.3`.
|
|
19
|
+
- wrangler `^4.125`, installed in the project (a peer dependency: the CLI runs the project's own
|
|
20
|
+
wrangler for its KV writes).
|
|
21
|
+
- A `wrangler.jsonc` (the CLI reads that file; `wrangler.toml` and `wrangler.json` are not read).
|
|
22
|
+
- Node `>=22`.
|
|
23
|
+
- A Workers KV namespace bound as `TRANSLATIONS`.
|
|
24
|
+
- For `migrate` only: the site on a proxied Cloudflare zone, its zone ID, and an API token with
|
|
25
|
+
the Zone › Cache Purge permission.
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
### 1. Install
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
npm i @edgekits/astro-i18n
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### 2. Bind a KV namespace
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
npx wrangler kv namespace create TRANSLATIONS
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Add the namespace to `wrangler.jsonc` under the binding name `TRANSLATIONS`:
|
|
42
|
+
|
|
43
|
+
```jsonc
|
|
44
|
+
{
|
|
45
|
+
"compatibility_date": "2026-09-01",
|
|
46
|
+
"kv_namespaces": [{ "binding": "TRANSLATIONS", "id": "<the id wrangler printed>" }],
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### 3. Write `edgekits-i18n.config.ts`
|
|
51
|
+
|
|
52
|
+
At the project root. Both the integration and the CLI read it.
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
// edgekits-i18n.config.ts
|
|
56
|
+
import { defineI18nConfig } from '@edgekits/astro-i18n/config'
|
|
57
|
+
|
|
58
|
+
export default defineI18nConfig({
|
|
59
|
+
// The keys are the locales; the only list of languages there is.
|
|
60
|
+
languages: {
|
|
61
|
+
en: { countryCode: 'us', name: 'English' },
|
|
62
|
+
de: { countryCode: 'de', name: 'Deutsch' },
|
|
63
|
+
},
|
|
64
|
+
defaultLocale: 'en',
|
|
65
|
+
// The language cookie: set by the LanguageSwitcher, kept in sync by the middleware.
|
|
66
|
+
cookie: { name: 'lang', maxAge: 60 * 60 * 24 * 365 },
|
|
67
|
+
// Prefix of every KV key and the host of every edge-cache URL: use your site's domain.
|
|
68
|
+
keyPrefix: 'example.com',
|
|
69
|
+
// Whether MissingTranslationBanner may render.
|
|
70
|
+
missingTranslationBanner: true,
|
|
71
|
+
// Every URL starts with its locale (/en/, /de/about/). The only routing there is.
|
|
72
|
+
routing: 'prefix',
|
|
73
|
+
})
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
| Field | Required | Default | Meaning |
|
|
77
|
+
| -------------------------- | -------- | ------------ | ------------------------------------------------------------------------ |
|
|
78
|
+
| `languages` | yes | | `{ [locale]: { countryCode, name } }`; `countryCode` is for your flag UI |
|
|
79
|
+
| `defaultLocale` | yes | | one of `languages`; its JSON files define the schema |
|
|
80
|
+
| `cookie` | yes | | `{ name, maxAge }` (seconds) of the language cookie |
|
|
81
|
+
| `keyPrefix` | yes | | KV keys are `<keyPrefix>:<namespace>:<locale>` |
|
|
82
|
+
| `missingTranslationBanner` | yes | | enables `MissingTranslationBanner` |
|
|
83
|
+
| `routing` | yes | | `'prefix'` |
|
|
84
|
+
| `localesDir` | no | `'locales'` | where the CLI reads `<locale>/<namespace>.json` |
|
|
85
|
+
| `generatedDir` | no | `'src/i18n'` | where the CLI writes `runtime-constants.ts` and `fallbacks.generated.ts` |
|
|
86
|
+
| `countryToLocale` | no | none | geo-IP step of the locale fallback (see Reference) |
|
|
87
|
+
| `markdownLocale` | no | none | the locale `.md` endpoints are served in (see Reference) |
|
|
88
|
+
|
|
89
|
+
The config is validated when it loads: an empty `languages`, a `defaultLocale` or
|
|
90
|
+
`markdownLocale` outside it, or a bad `countryToLocale` entry throws with a message naming the
|
|
91
|
+
field.
|
|
92
|
+
|
|
93
|
+
### 4. Add the integration
|
|
94
|
+
|
|
95
|
+
```js
|
|
96
|
+
// astro.config.mjs
|
|
97
|
+
import { defineConfig } from 'astro/config'
|
|
98
|
+
import cloudflare from '@astrojs/cloudflare'
|
|
99
|
+
import { edgekitsI18n } from '@edgekits/astro-i18n'
|
|
100
|
+
|
|
101
|
+
import i18nConfig from './edgekits-i18n.config'
|
|
102
|
+
|
|
103
|
+
export default defineConfig({
|
|
104
|
+
output: 'server',
|
|
105
|
+
adapter: cloudflare(),
|
|
106
|
+
integrations: [edgekitsI18n(i18nConfig)],
|
|
107
|
+
})
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The integration sets up:
|
|
111
|
+
|
|
112
|
+
- a Vite plugin serving the config and your generated files to the runtime as virtual modules;
|
|
113
|
+
- `vite.ssr.noExternal` for the package, whose runtime ships as source;
|
|
114
|
+
- Astro's `i18n` block, when `astro.config` has none: `locales` from `languages`, your
|
|
115
|
+
`defaultLocale`, `routing: { prefixDefaultLocale: true, redirectToDefaultLocale: false }`.
|
|
116
|
+
Astro needs it to compute `preferredLocale` from `Accept-Language`. If you keep your own
|
|
117
|
+
block, its `locales` must include every configured locale and its `defaultLocale` must match,
|
|
118
|
+
or the build stops with a message saying which;
|
|
119
|
+
- the `Locale` type: the union of your locales, for the package's exports;
|
|
120
|
+
- type declarations for its virtual modules, so `tsc` and `astro check` accept the package's
|
|
121
|
+
runtime source in your program;
|
|
122
|
+
- the locale-routing middleware, with `edgekitsI18n(i18nConfig)` or
|
|
123
|
+
`edgekitsI18n(i18nConfig, { middleware: 'auto' })`. With `{ middleware: 'manual' }` nothing is
|
|
124
|
+
registered and you place it in your own `sequence()`:
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
// src/middleware.ts
|
|
128
|
+
import { sequence } from 'astro:middleware'
|
|
129
|
+
import { onRequest as i18n } from '@edgekits/astro-i18n/middleware'
|
|
130
|
+
|
|
131
|
+
export const onRequest = sequence(i18n, yourOwnMiddleware)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The middleware redirects a URL without a locale (`/`, `/about/`) to a localized one and sets
|
|
135
|
+
`Astro.locals.uiLocale` (the URL's locale) and `Astro.locals.translationLocale` (the locale to
|
|
136
|
+
fetch translations for) on every request.
|
|
137
|
+
|
|
138
|
+
### 5. Write translations
|
|
139
|
+
|
|
140
|
+
One JSON file per locale and namespace, under `localesDir`:
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
locales/
|
|
144
|
+
en/
|
|
145
|
+
common.json
|
|
146
|
+
de/
|
|
147
|
+
common.json
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
```json
|
|
151
|
+
// locales/en/common.json
|
|
152
|
+
{ "hero": { "title": "Hello, world" } }
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
```json
|
|
156
|
+
// locales/de/common.json
|
|
157
|
+
{ "hero": { "title": "Hallo, Welt" } }
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
The default locale's files define the schema: their namespaces and keys are the types. Another
|
|
161
|
+
locale may leave keys out; the default locale's text fills the gaps.
|
|
162
|
+
|
|
163
|
+
### 6. Generate and seed
|
|
164
|
+
|
|
165
|
+
```sh
|
|
166
|
+
npx edgekits-i18n seed
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
| Command | Writes | Then |
|
|
170
|
+
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
|
171
|
+
| `edgekits-i18n bundle` | `i18n-data.json` (the KV payload), `src/i18n.generated.d.ts` (the schema), `<generatedDir>/runtime-constants.ts` | nothing else |
|
|
172
|
+
| `edgekits-i18n bundle --fallbacks` | the above and `<generatedDir>/fallbacks.generated.ts` (the default locale compiled in, served if KV is unreachable) | nothing else |
|
|
173
|
+
| `edgekits-i18n seed` | the same as `bundle --fallbacks` | pushes every value to local KV (`wrangler kv bulk put --local`) |
|
|
174
|
+
| `edgekits-i18n migrate` | the same as `bundle --fallbacks` | pushes to production KV, purges the changed namespaces' edge-cache entries, writes `.i18n-hashes.json` |
|
|
175
|
+
|
|
176
|
+
The generated files are ordinary project files: commit them, and run the CLI again after you
|
|
177
|
+
edit a JSON file.
|
|
178
|
+
|
|
179
|
+
`.i18n-hashes.json` holds one content hash per namespace and locale as of the last successful
|
|
180
|
+
`migrate`; the next `migrate` purges exactly the entries whose hash changed. Rules:
|
|
181
|
+
|
|
182
|
+
- Only `migrate` writes it, and only after the purge succeeded (or nothing changed). `seed`
|
|
183
|
+
never touches it: local KV is not production.
|
|
184
|
+
- Commit it after every `migrate`. It is the record of what production holds.
|
|
185
|
+
- `migrate` refuses to run while the file has uncommitted changes, since a wrong baseline would
|
|
186
|
+
skip purges silently. When that git check cannot run (no `git` on the PATH, or not a git work
|
|
187
|
+
tree), `migrate` warns and proceeds without the purge-baseline check.
|
|
188
|
+
|
|
189
|
+
`migrate` reads the zone ID from `CLOUDFLARE_ZONE_ID` (a var in `wrangler.jsonc` or the
|
|
190
|
+
environment) and the token from `CLOUDFLARE_CACHEPURGE_API_TOKEN` (the environment or
|
|
191
|
+
`.dev.vars`). Without either, it still pushes to KV but skips the purge and leaves
|
|
192
|
+
`.i18n-hashes.json` alone, so the next run retries.
|
|
193
|
+
|
|
194
|
+
### 7. Use translations in a page
|
|
195
|
+
|
|
196
|
+
```astro
|
|
197
|
+
---
|
|
198
|
+
// src/pages/[lang]/index.astro
|
|
199
|
+
import { fetchTranslations } from '@edgekits/astro-i18n/runtime'
|
|
200
|
+
|
|
201
|
+
const { common } = await fetchTranslations(Astro.locals.translationLocale, ['common'])
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
<h1>{common.hero.title}</h1>
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
`fetchTranslations(locale, namespaces)` returns one object per namespace, typed from the
|
|
208
|
+
schema: `common.hero.title` is a `string`, a key that does not exist is a type error. Run
|
|
209
|
+
`npx astro dev` and open `/de/`: the heading reads "Hallo, Welt", served from local KV. Before
|
|
210
|
+
you edit and re-seed, see `I18N_CACHE` below.
|
|
211
|
+
|
|
212
|
+
`npx astro check` passes on this setup as is. `tsc` also checks the package's runtime source
|
|
213
|
+
when a `.ts` file of yours imports it, and that source reads `cloudflare:workers` and a
|
|
214
|
+
`KVNamespace` binding: generate the Workers types once with `npx wrangler types` (it needs the
|
|
215
|
+
`compatibility_date` above) and `tsc` passes too.
|
|
216
|
+
|
|
217
|
+
`@edgekits/astro-i18n/runtime` also exports `fmt` (`{placeholder}` interpolation), `plural` and
|
|
218
|
+
`pluralIcu`, the locale constants (`DEFAULT_LOCALE`, `SUPPORTED_LOCALES`, `LANGUAGES`) and the
|
|
219
|
+
`Locale` type.
|
|
220
|
+
|
|
221
|
+
### 8. Runtime switches
|
|
222
|
+
|
|
223
|
+
Both are optional `vars` in `wrangler.jsonc` (or `.dev.vars` locally), read as `on`/`off`
|
|
224
|
+
(`true`/`false` and `1`/`0` work too):
|
|
225
|
+
|
|
226
|
+
| Var | Default | Effect |
|
|
227
|
+
| ------------ | ------- | --------------------------------------------------------------- |
|
|
228
|
+
| `I18N_CACHE` | `on` | `off` reads KV on every request and skips the edge cache |
|
|
229
|
+
| `DEBUG_I18N` | `off` | `on` logs cache hits, misses and KV reads to the Worker console |
|
|
230
|
+
|
|
231
|
+
While you edit translations locally, put `I18N_CACHE=off` in `.dev.vars`. `astro dev` runs your
|
|
232
|
+
Worker with a local Cache API, which keeps the first value it served for each namespace: after
|
|
233
|
+
a second `seed` the page still shows the old text. With the cache off, a `seed` shows on the
|
|
234
|
+
next request. `.dev.vars` is read when the dev server starts: restart it after you
|
|
235
|
+
add the line.
|
|
236
|
+
|
|
237
|
+
## Reference
|
|
238
|
+
|
|
239
|
+
### Which entry to import
|
|
240
|
+
|
|
241
|
+
| Entry | Where it runs | What it holds |
|
|
242
|
+
| ----------------------------------- | -------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
|
243
|
+
| `@edgekits/astro-i18n` | `astro.config` (Node) | `edgekitsI18n()` (the integration) and `defineI18nConfig` |
|
|
244
|
+
| `@edgekits/astro-i18n/config` | anywhere, Node-safe | `defineI18nConfig`, `localesOf(config)` (`DEFAULT_LOCALE`, `SUPPORTED_LOCALES`, `LANGUAGES`), the types |
|
|
245
|
+
| `@edgekits/astro-i18n/format` | anywhere, Node-safe | `fmt`, `plural`, `pluralIcu`, `toIetfTag`, `replaceLocaleInPath`, `getLangFromUrl` |
|
|
246
|
+
| `@edgekits/astro-i18n/runtime` | workerd only (pages, APIs) | `fetchTranslations`, the locale constants from the integration's config, locale resolution, the format helpers |
|
|
247
|
+
| `@edgekits/astro-i18n/middleware` | workerd only | the i18n middleware (`onRequest`); registered for you unless `middleware: 'manual'` |
|
|
248
|
+
| `@edgekits/astro-i18n/components/*` | `.astro` pages | `LanguageSwitcher`, `MissingTranslationBanner` |
|
|
249
|
+
|
|
250
|
+
Node-safe entries import nothing from `cloudflare:workers` and no virtual module, so unit tests
|
|
251
|
+
under Node and `src/content.config.ts` (evaluated by `astro sync`) can use them. `./runtime` and
|
|
252
|
+
`./middleware` need the integration's Vite setup and the Workers runtime.
|
|
253
|
+
|
|
254
|
+
### Geo-IP locale fallback: `countryToLocale`
|
|
255
|
+
|
|
256
|
+
When a request's URL has no locale, the middleware picks one in this order: the language
|
|
257
|
+
cookie, then Astro's `preferredLocale` (full tag, then its language part), then, only when Astro
|
|
258
|
+
reports no preferred locale, the visitor's country (`request.cf.country`) through your
|
|
259
|
+
`countryToLocale` table, and finally `defaultLocale`. Astro computes `preferredLocale` by
|
|
260
|
+
matching `Accept-Language` against its `i18n.locales`, which the integration sets from your
|
|
261
|
+
`languages` unless your `astro.config` has its own `i18n` block.
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
// edgekits-i18n.config.ts
|
|
265
|
+
export default defineI18nConfig({
|
|
266
|
+
languages: { en: { … }, de: { … }, es: { … }, ja: { … } },
|
|
267
|
+
defaultLocale: 'en',
|
|
268
|
+
// ISO 3166-1 alpha-2 (uppercase) → one of `languages`; checked at config time.
|
|
269
|
+
countryToLocale: { US: 'en', GB: 'en', DE: 'de', AT: 'de', CH: 'de', ES: 'es', MX: 'es', JP: 'ja' },
|
|
270
|
+
// …
|
|
271
|
+
})
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Without `countryToLocale` the geo step is skipped: the package ships no country table of its
|
|
275
|
+
own, so a visitor with no cookie and no preferred locale gets `defaultLocale`.
|
|
276
|
+
|
|
277
|
+
### Markdown endpoints: `markdownLocale`
|
|
278
|
+
|
|
279
|
+
The middleware leaves paths ending in `.md` (or `.md/`) out of locale routing. With
|
|
280
|
+
`markdownLocale` set, it also gives them one canonical form: `/<markdownLocale>/<rest>` with no
|
|
281
|
+
trailing slash, reached in a single redirect. A leading locale segment is replaced; any other
|
|
282
|
+
leading segment is kept.
|
|
283
|
+
|
|
284
|
+
```ts
|
|
285
|
+
// edgekits-i18n.config.ts
|
|
286
|
+
export default defineI18nConfig({
|
|
287
|
+
// …
|
|
288
|
+
markdownLocale: 'en', // one of `languages`; checked at config time
|
|
289
|
+
})
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
| Request | With `markdownLocale: 'en'` | Without `markdownLocale` |
|
|
293
|
+
| ---------------- | --------------------------- | ------------------------ |
|
|
294
|
+
| `/de/blog/x.md/` | redirect to `/en/blog/x.md` | passed through as is |
|
|
295
|
+
| `/blog/x.md` | redirect to `/en/blog/x.md` | passed through as is |
|
|
296
|
+
| `/en/blog/x.md` | served | passed through as is |
|
|
297
|
+
|
|
298
|
+
Use it when your markdown endpoints exist in one locale only. Without it, the package assumes
|
|
299
|
+
nothing about them.
|
|
300
|
+
|
|
301
|
+
### Styling the components
|
|
302
|
+
|
|
303
|
+
`LanguageSwitcher` and `MissingTranslationBanner` are headless: they carry structure, behaviour
|
|
304
|
+
and `data-*` hooks, and every class comes from you. The package ships no CSS and needs no
|
|
305
|
+
Tailwind. Pass classes per element through the `classes` prop; a slot you leave out renders no
|
|
306
|
+
`class` attribute at all.
|
|
307
|
+
|
|
308
|
+
`LanguageSwitcher` slots:
|
|
309
|
+
|
|
310
|
+
| Slot | Element |
|
|
311
|
+
| ------------- | ------------------------------------------------------------------------ |
|
|
312
|
+
| `root` | the wrapper `div` inside `<language-switcher>` |
|
|
313
|
+
| `trigger` | the toggle `button` |
|
|
314
|
+
| `triggerIcon` | the trigger icon (your `triggerIcon` component or the inline default) |
|
|
315
|
+
| `dropdown` | the menu `div` |
|
|
316
|
+
| `item` | each language `button` |
|
|
317
|
+
| `itemActive` | appended to `item` on the current language |
|
|
318
|
+
| `itemLabel` | the `span` holding the flag and the language name |
|
|
319
|
+
| `activeIcon` | the current-language marker (your `activeIcon` component or the default) |
|
|
320
|
+
|
|
321
|
+
`MissingTranslationBanner` has one slot, `root` (the banner `div`).
|
|
322
|
+
|
|
323
|
+
Hooks rendered whether or not you pass `classes`, for CSS or your own scripts:
|
|
324
|
+
|
|
325
|
+
| Hook | Where | Meaning |
|
|
326
|
+
| -------------------------------------------------------------- | ---------------------------------- | -------------------------------- |
|
|
327
|
+
| `data-open="true\|false"` | `<language-switcher>` | the menu is open |
|
|
328
|
+
| `data-current-lang`, `data-cookie-name`, `data-cookie-max-age` | `<language-switcher>` | config the client script reads |
|
|
329
|
+
| `data-ls-trigger` | the toggle button | also carries `aria-expanded` |
|
|
330
|
+
| `data-ls-dropdown` | the menu | `hidden` attribute while closed |
|
|
331
|
+
| `data-ls-item`, `data-lang` | each language button | `data-lang` is the item's locale |
|
|
332
|
+
| `data-active="true"` | the current language's button only | that button is also `disabled` |
|
|
333
|
+
| `data-missing="content\|ui"` | the banner | which of your `texts` is showing |
|
|
334
|
+
|
|
335
|
+
The menu opens and closes through the `hidden` HTML attribute, `data-open` on the element and
|
|
336
|
+
`aria-expanded` on the trigger; the script adds or removes no class. The browser's default
|
|
337
|
+
`[hidden] { display: none }` hides the menu. A display class on the `dropdown` slot (`flex`,
|
|
338
|
+
`grid`, `block`) can override that default, so either leave display alone there or restate it,
|
|
339
|
+
as the CSS example below does.
|
|
340
|
+
|
|
341
|
+
With Tailwind:
|
|
342
|
+
|
|
343
|
+
```astro
|
|
344
|
+
<LanguageSwitcher
|
|
345
|
+
classes={{
|
|
346
|
+
root: 'relative',
|
|
347
|
+
trigger: 'rounded-full p-2 hover:bg-muted',
|
|
348
|
+
triggerIcon: 'size-5',
|
|
349
|
+
dropdown: 'absolute right-8 z-50 -mt-8 w-44 overflow-hidden rounded-lg border bg-card shadow-lg',
|
|
350
|
+
item: 'flex w-44 items-center justify-between px-4 py-2 text-sm hover:bg-muted',
|
|
351
|
+
itemActive: 'bg-background cursor-default',
|
|
352
|
+
itemLabel: 'flex items-center gap-3',
|
|
353
|
+
activeIcon: 'size-6',
|
|
354
|
+
}}
|
|
355
|
+
/>
|
|
356
|
+
<MissingTranslationBanner texts={bannerTexts} classes={{ root: 'mx-6 mt-6 border px-4 py-2 text-center' }} />
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
With plain CSS on the hooks, no `classes` needed:
|
|
360
|
+
|
|
361
|
+
```css
|
|
362
|
+
language-switcher > div {
|
|
363
|
+
position: relative;
|
|
364
|
+
}
|
|
365
|
+
[data-ls-dropdown] {
|
|
366
|
+
position: absolute;
|
|
367
|
+
right: 0;
|
|
368
|
+
display: flex;
|
|
369
|
+
flex-direction: column;
|
|
370
|
+
}
|
|
371
|
+
[data-ls-dropdown][hidden] {
|
|
372
|
+
display: none; /* the UA default, restated because the rule above sets display */
|
|
373
|
+
}
|
|
374
|
+
[data-ls-item][data-active] {
|
|
375
|
+
font-weight: 600;
|
|
376
|
+
}
|
|
377
|
+
[data-missing='content'] {
|
|
378
|
+
border-left: 3px solid orange;
|
|
379
|
+
}
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
## How it works
|
|
383
|
+
|
|
384
|
+
A request reaches the middleware, which settles the URL's locale (redirecting when there is
|
|
385
|
+
none) and sets `uiLocale` and `translationLocale` on `Astro.locals`. A page asks
|
|
386
|
+
`fetchTranslations` for the namespaces it renders. Each namespace is looked up in the Workers
|
|
387
|
+
Cache API under `https://<keyPrefix>/…`; the misses are read from KV in one batch, merged over
|
|
388
|
+
the compiled default-locale fallbacks, returned, and written back to the cache in the
|
|
389
|
+
background. Cached entries live for a year (`s-maxage=31536000, immutable`): `migrate` purges the ones whose content
|
|
390
|
+
changed, and leaves the rest warm. If KV cannot be reached, the compiled fallbacks render the
|
|
391
|
+
default locale's text instead of an error.
|
|
392
|
+
|
|
393
|
+
## License
|
|
394
|
+
|
|
395
|
+
MIT © Gary Stupak. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CLI's code generation, as pure functions of the collected locale tree: no
|
|
3
|
+
* filesystem, so the emitted text is testable. Formats are those of the site's
|
|
4
|
+
* `scripts/bundle-translations.ts`, header aside.
|
|
5
|
+
*/
|
|
6
|
+
export type TranslationTree = Record<string, unknown>;
|
|
7
|
+
export type CollectedData = Record<string, Record<string, TranslationTree>>;
|
|
8
|
+
export type KvBulkEntry = {
|
|
9
|
+
key: string;
|
|
10
|
+
value: string;
|
|
11
|
+
};
|
|
12
|
+
export declare const NAMESPACE_NAME: RegExp;
|
|
13
|
+
/**
|
|
14
|
+
* Throws, naming the offender, when a locale folder is not one of the config's
|
|
15
|
+
* `languages` or a namespace file name does not match NAMESPACE_NAME. Run on the
|
|
16
|
+
* collected tree before anything is generated or pushed.
|
|
17
|
+
*/
|
|
18
|
+
export declare function assertValidNames(collected: CollectedData, languages: Readonly<Record<string, unknown>>): void;
|
|
19
|
+
/**
|
|
20
|
+
* A single-quoted TS string literal, escaped by JSON.stringify. For every name the
|
|
21
|
+
* validation accepts it is byte-identical to the plain `'name'` the CLI always wrote, so
|
|
22
|
+
* existing generated files do not change; anything else comes out escaped, not injected.
|
|
23
|
+
*/
|
|
24
|
+
export declare function quoteString(value: string): string;
|
|
25
|
+
/** Locales that have at least one namespace, sorted. */
|
|
26
|
+
export declare function localesWithTranslations(collected: CollectedData): string[];
|
|
27
|
+
/**
|
|
28
|
+
* The locale the schema and the namespace list are derived from: the default locale
|
|
29
|
+
* when it has translations, otherwise the first locale that does.
|
|
30
|
+
*/
|
|
31
|
+
export declare function schemaSourceLocale(collected: CollectedData, defaultLocale: string): string | undefined;
|
|
32
|
+
/** `wrangler kv bulk put` input: one entry per locale and namespace. */
|
|
33
|
+
export declare function buildBulkData(collected: CollectedData, keyPrefix: string): KvBulkEntry[];
|
|
34
|
+
/** `src/i18n.generated.d.ts`: the global `I18n.Schema`, from the schema source locale. */
|
|
35
|
+
export declare function buildTypesContent(collected: CollectedData, defaultLocale: string): string;
|
|
36
|
+
/**
|
|
37
|
+
* `runtime-constants.ts`:
|
|
38
|
+
* - NAMESPACE_KEYS -> from the schema source locale (default locale preferred)
|
|
39
|
+
* - LOCALES_WITH_TRANSLATIONS -> from all collected locales
|
|
40
|
+
* - FULLY_TRANSLATED_LOCALES -> locales that have ALL namespace keys
|
|
41
|
+
*/
|
|
42
|
+
export declare function buildRuntimeConstantsContent(collected: CollectedData, defaultLocale: string): string;
|
|
43
|
+
/**
|
|
44
|
+
* `fallbacks.generated.ts`: one `FALLBACK_<NAMESPACE>` per default-locale namespace.
|
|
45
|
+
* Null when the default locale has no translations (nothing to fall back to).
|
|
46
|
+
*/
|
|
47
|
+
export declare function buildFallbacksContent(collected: CollectedData, defaultLocale: string): string | null;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { formatTranslationKvKey } from "../key-format.js";
|
|
2
|
+
const HEADER = `// Auto-generated by @edgekits/astro-i18n (edgekits-i18n CLI)
|
|
3
|
+
// Do not edit manually.
|
|
4
|
+
`;
|
|
5
|
+
// A namespace becomes part of a KV key, a cache URL and the `FALLBACK_<NAMESPACE>`
|
|
6
|
+
// identifier, so its file name is restricted to what is valid in all three.
|
|
7
|
+
export const NAMESPACE_NAME = /^[a-z0-9][a-z0-9_]*$/;
|
|
8
|
+
/**
|
|
9
|
+
* Throws, naming the offender, when a locale folder is not one of the config's
|
|
10
|
+
* `languages` or a namespace file name does not match NAMESPACE_NAME. Run on the
|
|
11
|
+
* collected tree before anything is generated or pushed.
|
|
12
|
+
*/
|
|
13
|
+
export function assertValidNames(collected, languages) {
|
|
14
|
+
const configured = Object.keys(languages);
|
|
15
|
+
for (const [locale, namespaces] of Object.entries(collected)) {
|
|
16
|
+
if (!configured.includes(locale)) {
|
|
17
|
+
throw new Error(`Locale folder "${locale}" is not one of \`languages\` in edgekits-i18n.config.ts: ${configured.join(', ')}.`);
|
|
18
|
+
}
|
|
19
|
+
for (const ns of Object.keys(namespaces)) {
|
|
20
|
+
if (!NAMESPACE_NAME.test(ns)) {
|
|
21
|
+
throw new Error(`Namespace "${ns}" (${locale}/${ns}.json) must match ${NAMESPACE_NAME}: lowercase letters, digits and underscores.`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* A single-quoted TS string literal, escaped by JSON.stringify. For every name the
|
|
28
|
+
* validation accepts it is byte-identical to the plain `'name'` the CLI always wrote, so
|
|
29
|
+
* existing generated files do not change; anything else comes out escaped, not injected.
|
|
30
|
+
*/
|
|
31
|
+
export function quoteString(value) {
|
|
32
|
+
const escaped = JSON.stringify(value).slice(1, -1).replace(/\\"/g, '"').replace(/'/g, "\\'");
|
|
33
|
+
return `'${escaped}'`;
|
|
34
|
+
}
|
|
35
|
+
/** Locales that have at least one namespace, sorted. */
|
|
36
|
+
export function localesWithTranslations(collected) {
|
|
37
|
+
return Object.keys(collected).sort();
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The locale the schema and the namespace list are derived from: the default locale
|
|
41
|
+
* when it has translations, otherwise the first locale that does.
|
|
42
|
+
*/
|
|
43
|
+
export function schemaSourceLocale(collected, defaultLocale) {
|
|
44
|
+
if (collected[defaultLocale])
|
|
45
|
+
return defaultLocale;
|
|
46
|
+
return localesWithTranslations(collected)[0];
|
|
47
|
+
}
|
|
48
|
+
/** `wrangler kv bulk put` input: one entry per locale and namespace. */
|
|
49
|
+
export function buildBulkData(collected, keyPrefix) {
|
|
50
|
+
const bulkData = [];
|
|
51
|
+
for (const [locale, namespaces] of Object.entries(collected)) {
|
|
52
|
+
for (const [ns, json] of Object.entries(namespaces)) {
|
|
53
|
+
bulkData.push({ key: formatTranslationKvKey(keyPrefix, locale, ns), value: JSON.stringify(json) });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return bulkData;
|
|
57
|
+
}
|
|
58
|
+
function generateSchemaTs(schema, indent = 4) {
|
|
59
|
+
const pad = ' '.repeat(indent);
|
|
60
|
+
const padInner = ' '.repeat(indent + 2);
|
|
61
|
+
if (schema && typeof schema === 'object' && !Array.isArray(schema)) {
|
|
62
|
+
const entries = Object.entries(schema);
|
|
63
|
+
if (entries.length === 0)
|
|
64
|
+
return '{}';
|
|
65
|
+
const lines = entries.map(([key, value]) => {
|
|
66
|
+
const valueTs = value && typeof value === 'object' && !Array.isArray(value)
|
|
67
|
+
? generateSchemaTs(value, indent + 2)
|
|
68
|
+
: 'string';
|
|
69
|
+
return `${padInner}${JSON.stringify(key)}: ${valueTs}`;
|
|
70
|
+
});
|
|
71
|
+
return `{\n${lines.join(';\n')};\n${pad}}`;
|
|
72
|
+
}
|
|
73
|
+
return 'string';
|
|
74
|
+
}
|
|
75
|
+
/** `src/i18n.generated.d.ts`: the global `I18n.Schema`, from the schema source locale. */
|
|
76
|
+
export function buildTypesContent(collected, defaultLocale) {
|
|
77
|
+
const source = schemaSourceLocale(collected, defaultLocale);
|
|
78
|
+
const schemaObject = (source && collected[source]) || {};
|
|
79
|
+
const namespaceLines = Object.entries(schemaObject).map(([ns, value]) => {
|
|
80
|
+
const valueTs = generateSchemaTs(value, 10);
|
|
81
|
+
return ` ${JSON.stringify(ns)}: ${valueTs}`;
|
|
82
|
+
});
|
|
83
|
+
const interfaceBody = namespaceLines.length > 0
|
|
84
|
+
? ` export interface Schema {\n${namespaceLines.join(';\n')};\n }`
|
|
85
|
+
: ` export interface Schema {}`;
|
|
86
|
+
return `${HEADER}
|
|
87
|
+
declare global {
|
|
88
|
+
namespace I18n {
|
|
89
|
+
${interfaceBody}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export {}
|
|
94
|
+
`;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* `runtime-constants.ts`:
|
|
98
|
+
* - NAMESPACE_KEYS -> from the schema source locale (default locale preferred)
|
|
99
|
+
* - LOCALES_WITH_TRANSLATIONS -> from all collected locales
|
|
100
|
+
* - FULLY_TRANSLATED_LOCALES -> locales that have ALL namespace keys
|
|
101
|
+
*/
|
|
102
|
+
export function buildRuntimeConstantsContent(collected, defaultLocale) {
|
|
103
|
+
const source = schemaSourceLocale(collected, defaultLocale);
|
|
104
|
+
const namespaceKeys = Object.keys((source && collected[source]) || {}).sort();
|
|
105
|
+
const locales = localesWithTranslations(collected);
|
|
106
|
+
const fullyTranslatedLocales = locales.filter((locale) => {
|
|
107
|
+
const localeNamespaces = Object.keys(collected[locale] || {});
|
|
108
|
+
return namespaceKeys.every((ns) => localeNamespaces.includes(ns));
|
|
109
|
+
});
|
|
110
|
+
return `${HEADER}
|
|
111
|
+
export const NAMESPACE_KEYS = [${namespaceKeys.map(quoteString).join(', ')}] as [string, ...string[]];
|
|
112
|
+
|
|
113
|
+
export const LOCALES_WITH_TRANSLATIONS = [${locales.map(quoteString).join(', ')}] as const;
|
|
114
|
+
|
|
115
|
+
export const FULLY_TRANSLATED_LOCALES = [${fullyTranslatedLocales.map(quoteString).join(', ')}] as const;
|
|
116
|
+
`;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* `fallbacks.generated.ts`: one `FALLBACK_<NAMESPACE>` per default-locale namespace.
|
|
120
|
+
* Null when the default locale has no translations (nothing to fall back to).
|
|
121
|
+
*/
|
|
122
|
+
export function buildFallbacksContent(collected, defaultLocale) {
|
|
123
|
+
const fallbackNamespaces = collected[defaultLocale];
|
|
124
|
+
if (!fallbackNamespaces)
|
|
125
|
+
return null;
|
|
126
|
+
const lines = [HEADER];
|
|
127
|
+
lines.push(`export const FALLBACK_LOCALE = ${JSON.stringify(defaultLocale)} as const;\n`);
|
|
128
|
+
for (const [ns, json] of Object.entries(fallbackNamespaces)) {
|
|
129
|
+
const constName = `FALLBACK_${ns.toUpperCase()}`;
|
|
130
|
+
lines.push(`export const ${constName} = ${JSON.stringify(json, null, 2)} as const;\n`);
|
|
131
|
+
}
|
|
132
|
+
return lines.join('\n');
|
|
133
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CollectedData } from './generate.ts';
|
|
2
|
+
/**
|
|
3
|
+
* Per-namespace content hashes and the change diff that drives the cache purge.
|
|
4
|
+
* Pure apart from hashing. Same algorithm as the site's `scripts/bundle-translations.ts`,
|
|
5
|
+
* so an existing `.i18n-hashes.json` stays a valid baseline.
|
|
6
|
+
*/
|
|
7
|
+
export type HashMap = Record<string, string>;
|
|
8
|
+
export declare function stableStringify(value: unknown): string;
|
|
9
|
+
export declare function computeHash(value: unknown): string;
|
|
10
|
+
/**
|
|
11
|
+
* Hash every collected namespace and list the "<locale>:<namespace>" pairs whose hash
|
|
12
|
+
* differs from the previous run (all of them when there is no previous run).
|
|
13
|
+
*/
|
|
14
|
+
export declare function diffHashes(collected: CollectedData, previousHashes: HashMap): {
|
|
15
|
+
currentHashes: HashMap;
|
|
16
|
+
changedKeys: string[];
|
|
17
|
+
};
|
|
18
|
+
/** Purge API URLs for the changed "<locale>:<namespace>" pairs. */
|
|
19
|
+
export declare function purgeUrlsFor(changedKeys: readonly string[], keyPrefix: string): string[];
|