@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +21 -0
  3. package/README.md +394 -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 +77 -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 +210 -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 +119 -0
  60. 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
- # 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]: { 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[];