@edgekits/astro-i18n 0.0.0-stage → 0.1.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 +33 -0
- package/LICENSE +21 -0
- package/README.md +657 -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 +78 -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 +211 -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 +121 -0
- package/src/virtual.d.ts +23 -0
package/README.md
CHANGED
|
@@ -1,3 +1,658 @@
|
|
|
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]: { name, countryCode? } }`; `countryCode` is optional: the code your flag component receives |
|
|
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
|
+
Locale codes (the keys of `languages`) are lowercase everywhere: the config, the `locales/`
|
|
90
|
+
folders and the URLs (`pt-br`, not `pt-BR`). `toIetfTag()` from `./format` gives the IETF form
|
|
91
|
+
(`pt-BR`) where one is needed, such as `<html lang>` and hreflang.
|
|
92
|
+
|
|
93
|
+
The config is validated when it loads: an empty `languages`, a `defaultLocale` or
|
|
94
|
+
`markdownLocale` outside it, or a bad `countryToLocale` entry throws with a message naming the
|
|
95
|
+
field.
|
|
96
|
+
|
|
97
|
+
### 4. Add the integration
|
|
98
|
+
|
|
99
|
+
```js
|
|
100
|
+
// astro.config.mjs
|
|
101
|
+
import { defineConfig } from 'astro/config'
|
|
102
|
+
import cloudflare from '@astrojs/cloudflare'
|
|
103
|
+
import { edgekitsI18n } from '@edgekits/astro-i18n'
|
|
104
|
+
|
|
105
|
+
import i18nConfig from './edgekits-i18n.config'
|
|
106
|
+
|
|
107
|
+
export default defineConfig({
|
|
108
|
+
output: 'server',
|
|
109
|
+
adapter: cloudflare(),
|
|
110
|
+
integrations: [edgekitsI18n(i18nConfig)],
|
|
111
|
+
})
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The integration sets up:
|
|
115
|
+
|
|
116
|
+
- a Vite plugin serving the config and your generated files to the runtime as virtual modules;
|
|
117
|
+
- `vite.ssr.noExternal` for the package, whose runtime ships as source;
|
|
118
|
+
- Astro's `i18n` block, when `astro.config` has none: `locales` from `languages`, your
|
|
119
|
+
`defaultLocale`, `routing: { prefixDefaultLocale: true, redirectToDefaultLocale: false }`.
|
|
120
|
+
Astro needs it to compute `preferredLocale` from `Accept-Language`. If you keep your own
|
|
121
|
+
block, its `locales` must include every configured locale and its `defaultLocale` must match,
|
|
122
|
+
or the build stops with a message saying which;
|
|
123
|
+
- the `Locale` type: the union of your locales, for the package's exports;
|
|
124
|
+
- type declarations for its virtual modules, so `tsc` and `astro check` accept the package's
|
|
125
|
+
runtime source in your program;
|
|
126
|
+
- the locale-routing middleware, with `edgekitsI18n(i18nConfig)` or
|
|
127
|
+
`edgekitsI18n(i18nConfig, { middleware: 'auto' })`. With `{ middleware: 'manual' }` nothing is
|
|
128
|
+
registered and you place it in your own `sequence()`:
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
// src/middleware.ts
|
|
132
|
+
import { sequence } from 'astro:middleware'
|
|
133
|
+
import { onRequest as i18n } from '@edgekits/astro-i18n/middleware'
|
|
134
|
+
|
|
135
|
+
export const onRequest = sequence(i18n, yourOwnMiddleware)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The middleware redirects a URL without a locale (`/`, `/about/`) to a localized one and sets
|
|
139
|
+
`Astro.locals.uiLocale` (the URL's locale) and `Astro.locals.translationLocale` (the locale to
|
|
140
|
+
fetch translations for) on every request.
|
|
141
|
+
|
|
142
|
+
### 5. Write translations
|
|
143
|
+
|
|
144
|
+
One JSON file per locale and namespace, under `localesDir`:
|
|
145
|
+
|
|
146
|
+
```
|
|
147
|
+
locales/
|
|
148
|
+
en/
|
|
149
|
+
common.json
|
|
150
|
+
de/
|
|
151
|
+
common.json
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
```json
|
|
155
|
+
// locales/en/common.json
|
|
156
|
+
{ "hero": { "title": "Hello, world" } }
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
```json
|
|
160
|
+
// locales/de/common.json
|
|
161
|
+
{ "hero": { "title": "Hallo, Welt" } }
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The default locale's files define the schema: their namespaces and keys are the types. Another
|
|
165
|
+
locale may leave keys out; the default locale's text fills the gaps.
|
|
166
|
+
|
|
167
|
+
### 6. Generate and seed
|
|
168
|
+
|
|
169
|
+
```sh
|
|
170
|
+
npx edgekits-i18n seed
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
| Command | Writes | Then |
|
|
174
|
+
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
|
175
|
+
| `edgekits-i18n bundle` | `i18n-data.json` (the KV payload), `src/i18n.generated.d.ts` (the schema), `<generatedDir>/runtime-constants.ts` | nothing else |
|
|
176
|
+
| `edgekits-i18n bundle --fallbacks` | the above and `<generatedDir>/fallbacks.generated.ts` (the default locale compiled in, served if KV is unreachable) | nothing else |
|
|
177
|
+
| `edgekits-i18n seed` | the same as `bundle --fallbacks` | pushes every value to local KV (`wrangler kv bulk put --local`) |
|
|
178
|
+
| `edgekits-i18n migrate` | the same as `bundle --fallbacks` | pushes to production KV, purges the changed namespaces' edge-cache entries, writes `.i18n-hashes.json` |
|
|
179
|
+
|
|
180
|
+
The generated files are ordinary project files: commit them, and run the CLI again after you
|
|
181
|
+
edit a JSON file.
|
|
182
|
+
|
|
183
|
+
`.i18n-hashes.json` holds one content hash per namespace and locale as of the last successful
|
|
184
|
+
`migrate`; the next `migrate` purges exactly the entries whose hash changed. Rules:
|
|
185
|
+
|
|
186
|
+
- Only `migrate` writes it, and only after the purge succeeded (or nothing changed). `seed`
|
|
187
|
+
never touches it: local KV is not production.
|
|
188
|
+
- Commit it after every `migrate`. It is the record of what production holds.
|
|
189
|
+
- `migrate` refuses to run while the file has uncommitted changes, since a wrong baseline would
|
|
190
|
+
skip purges silently. When that git check cannot run (no `git` on the PATH, or not a git work
|
|
191
|
+
tree), `migrate` warns and proceeds without the purge-baseline check.
|
|
192
|
+
|
|
193
|
+
`migrate` reads the zone ID from `CLOUDFLARE_ZONE_ID` (a var in `wrangler.jsonc` or the
|
|
194
|
+
environment) and the token from `CLOUDFLARE_CACHEPURGE_API_TOKEN` (the environment or
|
|
195
|
+
`.dev.vars`). Without either, it still pushes to KV but skips the purge and leaves
|
|
196
|
+
`.i18n-hashes.json` alone, so the next run retries.
|
|
197
|
+
|
|
198
|
+
### 7. Use translations in a page
|
|
199
|
+
|
|
200
|
+
```astro
|
|
201
|
+
---
|
|
202
|
+
// src/pages/[lang]/index.astro
|
|
203
|
+
import { fetchTranslations } from '@edgekits/astro-i18n/runtime'
|
|
204
|
+
|
|
205
|
+
const { common } = await fetchTranslations(Astro.locals.translationLocale, ['common'])
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
<h1>{common.hero.title}</h1>
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
`fetchTranslations(locale, namespaces)` returns one object per namespace, typed from the
|
|
212
|
+
schema: `common.hero.title` is a `string`, a key that does not exist is a type error. Run
|
|
213
|
+
`npx astro dev` and open `/de/`: the heading reads "Hallo, Welt", served from local KV. Before
|
|
214
|
+
you edit and re-seed, see `I18N_CACHE` below.
|
|
215
|
+
|
|
216
|
+
`npx astro check` passes on this setup as is. `tsc` also checks the package's runtime source
|
|
217
|
+
when a `.ts` file of yours imports it, and that source reads `cloudflare:workers` and a
|
|
218
|
+
`KVNamespace` binding: generate the Workers types once with `npx wrangler types` (it needs the
|
|
219
|
+
`compatibility_date` above) and `tsc` passes too.
|
|
220
|
+
|
|
221
|
+
`@edgekits/astro-i18n/runtime` also exports `fmt` (`{placeholder}` interpolation), `plural` and
|
|
222
|
+
`pluralIcu`, the locale constants (`DEFAULT_LOCALE`, `SUPPORTED_LOCALES`, `LANGUAGES`) and the
|
|
223
|
+
`Locale` type.
|
|
224
|
+
|
|
225
|
+
### 8. Runtime switches
|
|
226
|
+
|
|
227
|
+
Both are optional `vars` in `wrangler.jsonc` (or `.dev.vars` locally), read as `on`/`off`
|
|
228
|
+
(`true`/`false` and `1`/`0` work too):
|
|
229
|
+
|
|
230
|
+
| Var | Default | Effect |
|
|
231
|
+
| ------------ | ------- | --------------------------------------------------------------- |
|
|
232
|
+
| `I18N_CACHE` | `on` | `off` reads KV on every request and skips the edge cache |
|
|
233
|
+
| `DEBUG_I18N` | `off` | `on` logs cache hits, misses and KV reads to the Worker console |
|
|
234
|
+
|
|
235
|
+
While you edit translations locally, put `I18N_CACHE=off` in `.dev.vars`. `astro dev` runs your
|
|
236
|
+
Worker with a local Cache API, which keeps the first value it served for each namespace: after
|
|
237
|
+
a second `seed` the page still shows the old text. With the cache off, a `seed` shows on the
|
|
238
|
+
next request. `.dev.vars` is read when the dev server starts: restart it after you
|
|
239
|
+
add the line.
|
|
240
|
+
|
|
241
|
+
## Reference
|
|
242
|
+
|
|
243
|
+
### Which entry to import
|
|
244
|
+
|
|
245
|
+
| Entry | Where it runs | What it holds |
|
|
246
|
+
| ----------------------------------- | -------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
|
247
|
+
| `@edgekits/astro-i18n` | `astro.config` (Node) | `edgekitsI18n()` (the integration) and `defineI18nConfig` |
|
|
248
|
+
| `@edgekits/astro-i18n/config` | anywhere, Node-safe | `defineI18nConfig`, `localesOf(config)` (`DEFAULT_LOCALE`, `SUPPORTED_LOCALES`, `LANGUAGES`), the types |
|
|
249
|
+
| `@edgekits/astro-i18n/format` | anywhere, Node-safe | `fmt`, `plural`, `pluralIcu`, `toIetfTag`, `replaceLocaleInPath`, `getLangFromUrl` |
|
|
250
|
+
| `@edgekits/astro-i18n/runtime` | workerd only (pages, APIs) | `fetchTranslations`, the locale constants from the integration's config, locale resolution, the format helpers |
|
|
251
|
+
| `@edgekits/astro-i18n/middleware` | workerd only | the i18n middleware (`onRequest`); registered for you unless `middleware: 'manual'` |
|
|
252
|
+
| `@edgekits/astro-i18n/components/*` | `.astro` pages | `LanguageSwitcher`, `MissingTranslationBanner` |
|
|
253
|
+
|
|
254
|
+
Node-safe entries import nothing from `cloudflare:workers` and no virtual module, so unit tests
|
|
255
|
+
under Node and `src/content.config.ts` (evaluated by `astro sync`) can use them. `./runtime` and
|
|
256
|
+
`./middleware` need the integration's Vite setup and the Workers runtime.
|
|
257
|
+
|
|
258
|
+
### Geo-IP locale fallback: `countryToLocale`
|
|
259
|
+
|
|
260
|
+
When a request's URL has no locale, the middleware picks one in this order: the language
|
|
261
|
+
cookie, then Astro's `preferredLocale` (full tag, then its language part), then, only when Astro
|
|
262
|
+
reports no preferred locale, the visitor's country (`request.cf.country`) through your
|
|
263
|
+
`countryToLocale` table, and finally `defaultLocale`. Astro computes `preferredLocale` by
|
|
264
|
+
matching `Accept-Language` against its `i18n.locales`, which the integration sets from your
|
|
265
|
+
`languages` unless your `astro.config` has its own `i18n` block.
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
// edgekits-i18n.config.ts
|
|
269
|
+
export default defineI18nConfig({
|
|
270
|
+
languages: { en: { … }, de: { … }, es: { … }, ja: { … } },
|
|
271
|
+
defaultLocale: 'en',
|
|
272
|
+
// ISO 3166-1 alpha-2 (uppercase) → one of `languages`; checked at config time.
|
|
273
|
+
countryToLocale: { US: 'en', GB: 'en', DE: 'de', AT: 'de', CH: 'de', ES: 'es', MX: 'es', JP: 'ja' },
|
|
274
|
+
// …
|
|
275
|
+
})
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Without `countryToLocale` the geo step is skipped: the package ships no country table of its
|
|
279
|
+
own, so a visitor with no cookie and no preferred locale gets `defaultLocale`.
|
|
280
|
+
|
|
281
|
+
### Markdown endpoints: `markdownLocale`
|
|
282
|
+
|
|
283
|
+
The middleware leaves paths ending in `.md` (or `.md/`) out of locale routing. With
|
|
284
|
+
`markdownLocale` set, it also gives them one canonical form: `/<markdownLocale>/<rest>` with no
|
|
285
|
+
trailing slash, reached in a single redirect. A leading locale segment is replaced; any other
|
|
286
|
+
leading segment is kept.
|
|
287
|
+
|
|
288
|
+
```ts
|
|
289
|
+
// edgekits-i18n.config.ts
|
|
290
|
+
export default defineI18nConfig({
|
|
291
|
+
// …
|
|
292
|
+
markdownLocale: 'en', // one of `languages`; checked at config time
|
|
293
|
+
})
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
| Request | With `markdownLocale: 'en'` | Without `markdownLocale` |
|
|
297
|
+
| ---------------- | --------------------------- | ------------------------ |
|
|
298
|
+
| `/de/blog/x.md/` | redirect to `/en/blog/x.md` | passed through as is |
|
|
299
|
+
| `/blog/x.md` | redirect to `/en/blog/x.md` | passed through as is |
|
|
300
|
+
| `/en/blog/x.md` | served | passed through as is |
|
|
301
|
+
|
|
302
|
+
Use it when your markdown endpoints exist in one locale only. Without it, the package assumes
|
|
303
|
+
nothing about them.
|
|
304
|
+
|
|
305
|
+
### Styling the components
|
|
306
|
+
|
|
307
|
+
`LanguageSwitcher` and `MissingTranslationBanner` are headless: they carry structure, behaviour
|
|
308
|
+
and `data-*` hooks, and every class comes from you. The package ships no CSS and needs no
|
|
309
|
+
Tailwind. Pass classes per element through the `classes` prop; a slot you leave out renders no
|
|
310
|
+
`class` attribute at all.
|
|
311
|
+
|
|
312
|
+
`LanguageSwitcher` slots:
|
|
313
|
+
|
|
314
|
+
| Slot | Element |
|
|
315
|
+
| ------------- | ------------------------------------------------------------------------ |
|
|
316
|
+
| `root` | the wrapper `div` inside `<language-switcher>` |
|
|
317
|
+
| `trigger` | the toggle `button` |
|
|
318
|
+
| `triggerIcon` | the trigger icon (your `triggerIcon` component or the inline default) |
|
|
319
|
+
| `dropdown` | the menu `div` |
|
|
320
|
+
| `item` | each language `button` |
|
|
321
|
+
| `itemActive` | appended to `item` on the current language |
|
|
322
|
+
| `itemLabel` | the `span` holding the flag and the language name |
|
|
323
|
+
| `activeIcon` | the current-language marker (your `activeIcon` component or the default) |
|
|
324
|
+
|
|
325
|
+
`MissingTranslationBanner` has one slot, `root` (the banner `div`).
|
|
326
|
+
|
|
327
|
+
Hooks rendered whether or not you pass `classes`, for CSS or your own scripts:
|
|
328
|
+
|
|
329
|
+
| Hook | Where | Meaning |
|
|
330
|
+
| -------------------------------------------------------------- | ---------------------------------- | -------------------------------- |
|
|
331
|
+
| `data-open="true\|false"` | `<language-switcher>` | the menu is open |
|
|
332
|
+
| `data-current-lang`, `data-cookie-name`, `data-cookie-max-age` | `<language-switcher>` | config the client script reads |
|
|
333
|
+
| `data-ls-trigger` | the toggle button | also carries `aria-expanded` |
|
|
334
|
+
| `data-ls-dropdown` | the menu | `hidden` attribute while closed |
|
|
335
|
+
| `data-ls-item`, `data-lang` | each language button | `data-lang` is the item's locale |
|
|
336
|
+
| `data-active="true"` | the current language's button only | that button is also `disabled` |
|
|
337
|
+
| `data-missing="content\|ui"` | the banner | which of your `texts` is showing |
|
|
338
|
+
|
|
339
|
+
The menu opens and closes through the `hidden` HTML attribute, `data-open` on the element and
|
|
340
|
+
`aria-expanded` on the trigger; the script adds or removes no class. The browser's default
|
|
341
|
+
`[hidden] { display: none }` hides the menu. A display class on the `dropdown` slot (`flex`,
|
|
342
|
+
`grid`, `block`) can override that default, so either leave display alone there or restate it,
|
|
343
|
+
as the CSS example below does.
|
|
344
|
+
|
|
345
|
+
With Tailwind:
|
|
346
|
+
|
|
347
|
+
```astro
|
|
348
|
+
<LanguageSwitcher
|
|
349
|
+
classes={{
|
|
350
|
+
root: 'relative',
|
|
351
|
+
trigger: 'rounded-full p-2 hover:bg-muted',
|
|
352
|
+
triggerIcon: 'size-5',
|
|
353
|
+
dropdown: 'absolute right-8 z-50 -mt-8 w-44 overflow-hidden rounded-lg border bg-card shadow-lg',
|
|
354
|
+
item: 'flex w-44 items-center justify-between px-4 py-2 text-sm hover:bg-muted',
|
|
355
|
+
itemActive: 'bg-background cursor-default',
|
|
356
|
+
itemLabel: 'flex items-center gap-3',
|
|
357
|
+
activeIcon: 'size-6',
|
|
358
|
+
}}
|
|
359
|
+
/>
|
|
360
|
+
<MissingTranslationBanner texts={bannerTexts} classes={{ root: 'mx-6 mt-6 border px-4 py-2 text-center' }} />
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
With plain CSS on the hooks, no `classes` needed:
|
|
364
|
+
|
|
365
|
+
```css
|
|
366
|
+
language-switcher > div {
|
|
367
|
+
position: relative;
|
|
368
|
+
}
|
|
369
|
+
[data-ls-dropdown] {
|
|
370
|
+
position: absolute;
|
|
371
|
+
right: 0;
|
|
372
|
+
display: flex;
|
|
373
|
+
flex-direction: column;
|
|
374
|
+
}
|
|
375
|
+
[data-ls-dropdown][hidden] {
|
|
376
|
+
display: none; /* the UA default, restated because the rule above sets display */
|
|
377
|
+
}
|
|
378
|
+
[data-ls-item][data-active] {
|
|
379
|
+
font-weight: 600;
|
|
380
|
+
}
|
|
381
|
+
[data-missing='content'] {
|
|
382
|
+
border-left: 3px solid orange;
|
|
383
|
+
}
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
#### Flags
|
|
387
|
+
|
|
388
|
+
The package ships no flag images. To show flags in the switcher, pass your own Astro component
|
|
389
|
+
as `flag`: each item renders it as `<Flag code={countryCode} size={16} />` before the language
|
|
390
|
+
name. A locale without a `countryCode` gets the name alone, and so does every locale when you
|
|
391
|
+
pass no `flag`. A recipe that copies only the SVGs your locales use from the `flag-icons` npm
|
|
392
|
+
package is under [Flags in the switcher](#flags-in-the-switcher) in "Using the skill".
|
|
393
|
+
|
|
394
|
+
<!-- skill-guide:start -->
|
|
395
|
+
|
|
396
|
+
## Using the skill
|
|
397
|
+
|
|
398
|
+
`astro-i18n` is a free agent skill that sets up `@edgekits/astro-i18n` in your project and keeps
|
|
399
|
+
working with it afterwards. It runs in Claude Code as a plugin, and in Cursor, OpenCode or any other
|
|
400
|
+
coding agent that reads `SKILL.md`. Its source is in
|
|
401
|
+
[`plugins/astro-i18n`](https://github.com/EdgeKits/astro-i18n/tree/main/plugins/astro-i18n).
|
|
402
|
+
|
|
403
|
+
### What it does
|
|
404
|
+
|
|
405
|
+
The skill adopts `@edgekits/astro-i18n` in an existing Astro project that runs on Cloudflare
|
|
406
|
+
Workers through `@astrojs/cloudflare`. It asks the questions only you can answer, follows the
|
|
407
|
+
package README in order, and checks the result against a checklist. After the setup it handles
|
|
408
|
+
the follow-up work: new keys, locales and namespaces, production copy, localized forms and
|
|
409
|
+
multilingual SEO.
|
|
410
|
+
|
|
411
|
+
It does not create an Astro project, and it stops if the project has no Cloudflare adapter: the
|
|
412
|
+
package depends on Workers KV. It copies no engine code into your project; everything it writes
|
|
413
|
+
uses the package's exports.
|
|
414
|
+
|
|
415
|
+
What changes in your project during the setup:
|
|
416
|
+
|
|
417
|
+
- dependencies: `@edgekits/astro-i18n`, and `flag-icons` as a dev dependency if you want flags;
|
|
418
|
+
- `astro.config`: the `edgekitsI18n()` integration;
|
|
419
|
+
- `wrangler.jsonc`: a `TRANSLATIONS` KV binding;
|
|
420
|
+
- new files: `edgekits-i18n.config.ts`, `locales/<locale>/*.json`, the generated files, and a
|
|
421
|
+
`Flag.astro` with its SVGs;
|
|
422
|
+
- `src/pages/`: your pages move under `src/pages/[lang]/`;
|
|
423
|
+
- your base layout: `<html lang>` from the request's locale, hreflang tags, the language
|
|
424
|
+
switcher.
|
|
425
|
+
|
|
426
|
+
### Installing it
|
|
427
|
+
|
|
428
|
+
In Claude Code, add the EdgeKits marketplace once, then install the plugin:
|
|
429
|
+
|
|
430
|
+
```
|
|
431
|
+
/plugin marketplace add EdgeKits/skills
|
|
432
|
+
/plugin install astro-i18n@edgekits
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
That installs it for you, in every project. To set it up for everyone working on one
|
|
436
|
+
repository, run these in the repository and commit `.claude/settings.json`:
|
|
437
|
+
|
|
438
|
+
```sh
|
|
439
|
+
claude plugin marketplace add EdgeKits/skills --scope project
|
|
440
|
+
claude plugin install astro-i18n@edgekits --scope project
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
The first command declares the EdgeKits marketplace in `.claude/settings.json`; the second
|
|
444
|
+
turns the plugin on there, under `enabledPlugins` (`"astro-i18n@edgekits": true`). Claude Code
|
|
445
|
+
honours those entries for a teammate once they trust the folder, but it does not download the
|
|
446
|
+
plugin for them: each teammate runs the same `claude plugin install … --scope project` once.
|
|
447
|
+
|
|
448
|
+
Any other agent, with the [skills CLI](https://skills.sh):
|
|
449
|
+
|
|
450
|
+
```sh
|
|
451
|
+
npx skills add EdgeKits/astro-i18n --skill astro-i18n
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
### Prerequisites
|
|
455
|
+
|
|
456
|
+
- An Astro `^7.3` project with `output: 'server'` and the `@astrojs/cloudflare` adapter `^14.3`.
|
|
457
|
+
For a new site: `npm create astro@latest`, then `npx astro add cloudflare`, then set
|
|
458
|
+
`output: 'server'` in `astro.config`.
|
|
459
|
+
- wrangler `^4.125` installed in the project (`npm i -D wrangler`) and a `wrangler.jsonc`. The
|
|
460
|
+
package's CLI runs the project's own wrangler, never a global one.
|
|
461
|
+
- A Cloudflare account. Local development needs none until you create the real KV namespace.
|
|
462
|
+
- For `edgekits-i18n migrate` (pushing copy to production): the site on a proxied Cloudflare
|
|
463
|
+
zone (orange cloud), that zone's ID, and an API token with the Zone › Cache Purge permission.
|
|
464
|
+
|
|
465
|
+
### First run on a fresh site
|
|
466
|
+
|
|
467
|
+
#### What to say
|
|
468
|
+
|
|
469
|
+
Open the project in your agent and ask for the result you want, for example:
|
|
470
|
+
|
|
471
|
+
- "Add i18n to this site: English as the default, German and Brazilian Portuguese."
|
|
472
|
+
- "Make the site multilingual with en and es, and send visitors from Spain and Mexico to
|
|
473
|
+
Spanish."
|
|
474
|
+
- "Install @edgekits/astro-i18n and set it up."
|
|
475
|
+
|
|
476
|
+
#### The questions it asks
|
|
477
|
+
|
|
478
|
+
The skill asks before it writes any code, and waits for the answers:
|
|
479
|
+
|
|
480
|
+
| Question | Example answer |
|
|
481
|
+
| ---------------------------------------------------------------------------- | --------------------------------------------- |
|
|
482
|
+
| Which locales, and which is the default? | `en` (default), `de`, `pt-br` |
|
|
483
|
+
| Does the site serve markdown endpoints (`/blog/x.md`)? In which locale? | No (or: yes, `en`) |
|
|
484
|
+
| Should a visitor's country pick the locale when nothing else does? How? | `DE`, `AT`, `CH` → `de`; `BR` → `pt-br` |
|
|
485
|
+
| Middleware: registered by the integration (`auto`), or in your own sequence? | `auto` |
|
|
486
|
+
| Flags in the language switcher? | Yes: `en` → `us`, `de` → `de`, `pt-br` → `br` |
|
|
487
|
+
|
|
488
|
+
For flags, a regional locale takes its region (`pt-br` → `br`, `en-gb` → `gb`), and for a
|
|
489
|
+
language-only locale the skill proposes the obvious country (`en` → `us`) for you to confirm. It
|
|
490
|
+
shows the full list back before it writes the config. If you have no preference on any of
|
|
491
|
+
these, it uses the defaults: no geo table, no markdown locale, `auto` middleware, flags on.
|
|
492
|
+
|
|
493
|
+
#### What it creates
|
|
494
|
+
|
|
495
|
+
- `edgekits-i18n.config.ts` with your answers, and the integration in `astro.config`.
|
|
496
|
+
- `locales/<default>/common.json` and `messages.json` (the banner texts and form error codes),
|
|
497
|
+
and the same files for each other locale.
|
|
498
|
+
- The generated files, written by `npx edgekits-i18n seed`: `i18n-data.json`,
|
|
499
|
+
`src/i18n.generated.d.ts`, `src/i18n/runtime-constants.ts`, `src/i18n/fallbacks.generated.ts`.
|
|
500
|
+
Commit them.
|
|
501
|
+
- The route layout: `src/pages/about.astro` becomes `src/pages/[lang]/about.astro`, and so on.
|
|
502
|
+
The root `index.astro` moves too, since the middleware redirects `/` to `/<locale>/`.
|
|
503
|
+
Endpoints that are not localized (`robots.txt`, `sitemap.xml`, `/api/*`) and `public/` stay
|
|
504
|
+
where they are.
|
|
505
|
+
- `src/components/Flag.astro` and one SVG per flag in `src/assets/icons/flags/`, if you chose
|
|
506
|
+
flags (the recipe is below).
|
|
507
|
+
- A KV namespace on your Cloudflare account (`wrangler kv namespace create`). The skill asks
|
|
508
|
+
before it runs that command. Until then, local development works with a placeholder id of 32
|
|
509
|
+
hexadecimal characters, such as `00000000000000000000000000000000`: the CLI checks the id's
|
|
510
|
+
shape before it runs wrangler.
|
|
511
|
+
|
|
512
|
+
#### Flags in the switcher
|
|
513
|
+
|
|
514
|
+
The package ships no flag images; `LanguageSwitcher` takes your flag component as its `flag`
|
|
515
|
+
prop and passes it each locale's `countryCode`. The skill's recipe, which you can also follow by
|
|
516
|
+
hand:
|
|
517
|
+
|
|
518
|
+
1. `npm i -D flag-icons` (MIT).
|
|
519
|
+
2. Copy the SVG of each configured `countryCode`, and only those, into
|
|
520
|
+
`src/assets/icons/flags/`, and commit them:
|
|
521
|
+
|
|
522
|
+
```sh
|
|
523
|
+
mkdir -p src/assets/icons/flags
|
|
524
|
+
for code in us de br; do cp node_modules/flag-icons/flags/4x3/$code.svg src/assets/icons/flags/; done
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
3. Add `src/components/Flag.astro`:
|
|
528
|
+
|
|
529
|
+
```astro
|
|
530
|
+
---
|
|
531
|
+
import type { SvgComponent } from 'astro/types'
|
|
532
|
+
|
|
533
|
+
interface Props {
|
|
534
|
+
code: string
|
|
535
|
+
size?: number
|
|
536
|
+
}
|
|
537
|
+
const { code, size = 16 } = Astro.props
|
|
538
|
+
|
|
539
|
+
const flags = import.meta.glob<SvgComponent>('../assets/icons/flags/*.svg', { eager: true, import: 'default' })
|
|
540
|
+
const Svg = flags[`../assets/icons/flags/${code}.svg`]
|
|
541
|
+
---
|
|
542
|
+
|
|
543
|
+
{Svg && <Svg width={(size * 4) / 3} height={size} aria-hidden="true" />}
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
4. Pass it to the switcher: `<LanguageSwitcher flag={Flag} />`.
|
|
547
|
+
|
|
548
|
+
A locale without a `countryCode`, or without an SVG, shows its name alone. The flag belongs to
|
|
549
|
+
the `countryCode`, not the language, so `pt` and `pt-br` can show different flags.
|
|
550
|
+
|
|
551
|
+
#### How it checks the result
|
|
552
|
+
|
|
553
|
+
Before it reports back, the skill runs its adoption checklist: the config matches your answers
|
|
554
|
+
and every locale code is lowercase; `seed` writes the generated files and leaves
|
|
555
|
+
`.i18n-hashes.json` alone; `astro check` passes; under `astro dev`, `/` redirects to a locale,
|
|
556
|
+
each locale renders its own copy (or the default locale's, if it has no JSON yet), an unknown
|
|
557
|
+
first segment such as `/zz/` is prefixed rather than crashing, `<html lang>` reads `pt-BR` on a
|
|
558
|
+
`pt-br` page, hreflang tags cover every locale plus `x-default`, and the switcher changes the
|
|
559
|
+
URL and sets the language cookie. It also checks that no page is left at `src/pages/*.astro`
|
|
560
|
+
and that every `countryCode` has its SVG.
|
|
561
|
+
|
|
562
|
+
### Everyday work
|
|
563
|
+
|
|
564
|
+
Ask in plain words: "add a key for the footer note", "add French", "add a `pricing`
|
|
565
|
+
namespace", "change the hero title in production". What the skill does:
|
|
566
|
+
|
|
567
|
+
- **A key or a value.** It adds a new key to the default locale first, since the default
|
|
568
|
+
locale's JSON is the type schema, then to the other locales, and runs `npx edgekits-i18n
|
|
569
|
+
seed`. A key that exists only in another locale is not typed.
|
|
570
|
+
- **A locale.** It adds the locale to `languages` (and its SVG, with flags on), optionally
|
|
571
|
+
writes `locales/<new>/`, runs `seed` and restarts the dev server. A locale without JSON is
|
|
572
|
+
routable already: it shows the default locale's copy and the missing-translation banner.
|
|
573
|
+
- **A namespace.** It creates `locales/<default>/<namespace>.json` and the same file in each
|
|
574
|
+
other locale, runs `seed`, and fetches the namespace only on the pages that render it.
|
|
575
|
+
- **Production copy.** Edit the JSON, then: `seed` and check it locally; run the project's
|
|
576
|
+
checks (`astro check`, tests, build); `npx edgekits-i18n migrate`, which pushes to production
|
|
577
|
+
KV and purges only the changed namespaces; commit the JSON together with
|
|
578
|
+
`.i18n-hashes.json`. No redeploy is needed unless the config or code changed too.
|
|
579
|
+
|
|
580
|
+
`.i18n-hashes.json` records what production KV holds. Only a successful `migrate` writes it,
|
|
581
|
+
it is committed right after, and nothing else edits it. `migrate` refuses to run while that
|
|
582
|
+
file has uncommitted changes.
|
|
583
|
+
|
|
584
|
+
### Forms and SEO
|
|
585
|
+
|
|
586
|
+
**Forms.** Ask for "a localized contact form with name, email and message", or "localize this
|
|
587
|
+
form". The skill asks for the fields and their rules, where the form submits (an Astro Action
|
|
588
|
+
by default) and where it renders. What comes back: stable error codes (`INVALID_EMAIL`) with a
|
|
589
|
+
message for every code in every locale's `messages.json`; a server schema that emits the codes;
|
|
590
|
+
an Astro Action; and a `.astro` form with an inline script. The browser side uses no framework
|
|
591
|
+
and no zod: it validates with a small hand-written mirror of the server schema, so the form
|
|
592
|
+
works with a strict Content Security Policy and without JavaScript.
|
|
593
|
+
|
|
594
|
+
**SEO.** Ask for "hreflang tags", "a sitemap for every locale", "an RSS feed per locale",
|
|
595
|
+
"`llms.txt`" or "`robots.txt` that blocks staging". The skill writes them as your project's
|
|
596
|
+
code, using the package's locale constants and helpers: a `SeoHreflangs` component and
|
|
597
|
+
`<html lang>` in the base layout, `sitemap.xml`, `[lang]/rss.xml`, `llms.txt` and `robots.txt`
|
|
598
|
+
endpoints.
|
|
599
|
+
|
|
600
|
+
### Rules the agent follows
|
|
601
|
+
|
|
602
|
+
- Locale codes are lowercase everywhere: config keys, `locales/` folders, URLs (`pt-br`). Only
|
|
603
|
+
`toIetfTag()` produces `pt-BR`, for `<html lang>` and hreflang.
|
|
604
|
+
- Generated files (`*.generated.*`, `runtime-constants.ts`, `i18n-data.json`) come from the CLI
|
|
605
|
+
and are never edited by hand.
|
|
606
|
+
- `@edgekits/astro-i18n/runtime` and `/middleware` run only in workerd. Anything Node loads
|
|
607
|
+
(`astro.config`, `src/content.config.ts`, unit tests) uses the Node-safe `/config` and
|
|
608
|
+
`/format` entries instead.
|
|
609
|
+
- No engine code is copied into the project. If the package lacks something, the skill says so
|
|
610
|
+
rather than re-implementing it.
|
|
611
|
+
- Secrets (`.dev.vars`, `.env*`, API tokens) never enter git, and anything that touches your
|
|
612
|
+
Cloudflare account is asked first.
|
|
613
|
+
|
|
614
|
+
### Troubleshooting
|
|
615
|
+
|
|
616
|
+
- **Edited translations do not show under `astro dev`.** The local Cache API keeps the first
|
|
617
|
+
value it served. Put `I18N_CACHE=off` in `.dev.vars`, restart the dev server, run `seed`.
|
|
618
|
+
- **A new key is a type error.** It was added only to a non-default locale, or the CLI has not
|
|
619
|
+
run since. Add it to the default locale and run `seed` (or `bundle`); if the editor still
|
|
620
|
+
shows the error, restart its TypeScript server.
|
|
621
|
+
- **`Cannot find module 'cloudflare:workers'` in `tsc`.** Run `npx wrangler types`.
|
|
622
|
+
- **`migrate` refuses to run.** `.i18n-hashes.json` has uncommitted changes. If the last
|
|
623
|
+
`migrate` wrote it, commit it. If anything else changed it, discard the change with
|
|
624
|
+
`git checkout .i18n-hashes.json` and run `migrate` again. Never edit or delete it to get
|
|
625
|
+
past the check.
|
|
626
|
+
- **`migrate` updated KV but the site shows old copy.** The purge was skipped: the zone is not
|
|
627
|
+
proxied, or the zone ID or token is missing. Fix it and run `migrate` again; it retries the
|
|
628
|
+
same purge.
|
|
629
|
+
- **Windows.** Local KV (`seed`) can fail with "Network connection lost" or "internal error"
|
|
630
|
+
when the project sits in a deep folder: the local Workers runtime hits the path length
|
|
631
|
+
limit, so keep the project at a short path. Stopping `astro dev` can leave `workerd.exe`
|
|
632
|
+
running and holding `.wrangler/state`; end it before the next `seed`. In PowerShell, copy
|
|
633
|
+
the flags with
|
|
634
|
+
`foreach ($c in 'us','de','br') { Copy-Item node_modules/flag-icons/flags/4x3/$c.svg src/assets/icons/flags/ }`.
|
|
635
|
+
|
|
636
|
+
### Privacy
|
|
637
|
+
|
|
638
|
+
This plugin collects, stores and sends no data. It contains no code, no hooks and no MCP servers, only
|
|
639
|
+
instructions (`SKILL.md`, its reference pages and two starter translation files). The commands it asks your agent
|
|
640
|
+
to run (`npm install`, `wrangler`, the package's `edgekits-i18n` CLI) talk to npm and to your own Cloudflare account
|
|
641
|
+
under their own terms. EdgeKits runs no service behind this plugin.
|
|
642
|
+
|
|
643
|
+
<!-- skill-guide:end -->
|
|
644
|
+
|
|
645
|
+
## How it works
|
|
646
|
+
|
|
647
|
+
A request reaches the middleware, which settles the URL's locale (redirecting when there is
|
|
648
|
+
none) and sets `uiLocale` and `translationLocale` on `Astro.locals`. A page asks
|
|
649
|
+
`fetchTranslations` for the namespaces it renders. Each namespace is looked up in the Workers
|
|
650
|
+
Cache API under `https://<keyPrefix>/…`; the misses are read from KV in one batch, merged over
|
|
651
|
+
the compiled default-locale fallbacks, returned, and written back to the cache in the
|
|
652
|
+
background. Cached entries live for a year (`s-maxage=31536000, immutable`): `migrate` purges the ones whose content
|
|
653
|
+
changed, and leaves the rest warm. If KV cannot be reached, the compiled fallbacks render the
|
|
654
|
+
default locale's text instead of an error.
|
|
655
|
+
|
|
656
|
+
## License
|
|
657
|
+
|
|
658
|
+
MIT © Gary Stupak. See [LICENSE](LICENSE).
|