@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/LICENSE +21 -0
  3. package/README.md +657 -2
  4. package/dist/cli/generate.d.ts +47 -0
  5. package/dist/cli/generate.js +133 -0
  6. package/dist/cli/hashes.d.ts +19 -0
  7. package/dist/cli/hashes.js +42 -0
  8. package/dist/cli/index.d.ts +2 -0
  9. package/dist/cli/index.js +362 -0
  10. package/dist/cli/load-config.d.ts +7 -0
  11. package/dist/cli/load-config.js +19 -0
  12. package/dist/cli/purge.d.ts +29 -0
  13. package/dist/cli/purge.js +94 -0
  14. package/dist/cli/wrangler.d.ts +10 -0
  15. package/dist/cli/wrangler.js +71 -0
  16. package/dist/index.d.ts +2 -0
  17. package/dist/index.js +4 -0
  18. package/dist/integration/index.d.ts +26 -0
  19. package/dist/integration/index.js +103 -0
  20. package/dist/integration/virtual-types.d.ts +14 -0
  21. package/dist/integration/virtual-types.js +34 -0
  22. package/dist/integration/vite-plugin.d.ts +17 -0
  23. package/dist/integration/vite-plugin.js +56 -0
  24. package/dist/key-format.d.ts +8 -0
  25. package/dist/key-format.js +15 -0
  26. package/dist/types.d.ts +66 -0
  27. package/dist/types.js +38 -0
  28. package/package.json +78 -4
  29. package/src/assemble.ts +29 -0
  30. package/src/cli/generate.ts +175 -0
  31. package/src/cli/hashes.ts +66 -0
  32. package/src/cli/index.ts +461 -0
  33. package/src/cli/load-config.ts +26 -0
  34. package/src/cli/purge.ts +122 -0
  35. package/src/cli/wrangler.ts +96 -0
  36. package/src/components/LanguageSwitcher.astro +211 -0
  37. package/src/components/MissingTranslationBanner.astro +31 -0
  38. package/src/config.entry.ts +38 -0
  39. package/src/config.ts +11 -0
  40. package/src/constants.ts +29 -0
  41. package/src/cookie-storage.ts +32 -0
  42. package/src/deep-merge.ts +38 -0
  43. package/src/fetcher.ts +304 -0
  44. package/src/format.ts +107 -0
  45. package/src/index.ts +11 -0
  46. package/src/integration/index.ts +136 -0
  47. package/src/integration/virtual-types.ts +34 -0
  48. package/src/integration/vite-plugin.ts +67 -0
  49. package/src/key-format.ts +17 -0
  50. package/src/locale-fallback.ts +65 -0
  51. package/src/markdown-path.ts +31 -0
  52. package/src/middleware/i18n.ts +223 -0
  53. package/src/middleware.ts +2 -0
  54. package/src/parse-boolean.ts +20 -0
  55. package/src/resolve-locale.ts +57 -0
  56. package/src/runtime.ts +19 -0
  57. package/src/schema.ts +28 -0
  58. package/src/translations-keys.ts +42 -0
  59. package/src/types.ts +121 -0
  60. package/src/virtual.d.ts +23 -0
package/README.md CHANGED
@@ -1,3 +1,658 @@
1
- # Temporary Holding Version
1
+ # @edgekits/astro-i18n
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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).