@edgekits/astro-i18n 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,57 @@ All notable changes to this package are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the package uses
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## 0.3.0 — 2026-10-28
8
+
9
+ ### Upgrading from 0.2.x
10
+
11
+ - **`.i18n-hashes.json` changes shape.** It now holds one baseline per KV namespace id (`{ "<kvId>": { "<locale>:<ns>": "<hash>" } }`). An old flat file is the baseline of the top-level `TRANSLATIONS` id (what 0.2.x wrote), whichever environment migrates first, and is rewritten in the new shape. Commit it as before.
12
+ - **The edge cache starts cold once.** Cache URLs carry the entry format (`https://<keyPrefix>/i18n:v2:<locale>:<ns>`, encoded); 0.2.x entries are never read again and expire on their own. Expect one KV read per namespace and data center right after the deploy.
13
+ - **`migrate` takes about a minute longer and can exit 1.** It purges, waits `--purge-delay` seconds (default 65) for KV to settle, and purges the same URLs again. It exits 1 when KV was updated but the edge cache was not purged (no `CLOUDFLARE_ZONE_ID` or token, or a failed purge); `.i18n-hashes.json` is then not written and the next run retries. A CI step that passed without purge credentials now fails.
14
+ - **Regenerate and commit the generated files** (`npx edgekits-i18n bundle --fallbacks`, or `seed`). `I18n.Schema` types numbers and booleans as such and a list of objects as one merged object type (a key every element has is required, any other optional, a key of differing types a union); a list that mixes strings and objects stops the run. A `null` value and a namespace named `locale` are rejected. Hashes of namespaces with mixed-case keys change once (code-point key order), so the first `migrate` purges those namespaces too.
15
+ - **`keyPrefix` must be a plain lower-case host** (`example.com`, `my-site`): no scheme, path, port syntax, space or upper case. `defineI18nConfig` throws otherwise.
16
+ - **The CLI is stricter.** An unknown argument (`-e staging`) exits 1 naming it; `--purge-delay` is at most 600; `CLOUDFLARE_ZONE_ID` must be 32 hex characters. With `--env <name>`, `.dev.vars.<name>` replaces `.dev.vars` when it exists, as in wrangler. `migrate` compares only the baseline of the KV namespace it writes with the committed file, so `migrate --env staging && migrate` runs in one job.
17
+ - **Node `>=22.23.4`.**
18
+ - **Peer floors:** `astro` `^7.4.0`, `@astrojs/cloudflare` `^14.4.0`, `wrangler` `^4.152.0` (`npm i -D wrangler@^4.152.0` if npm reports a peer conflict).
19
+ - **The build fails** on `trailingSlash: 'never'`, on a `base` other than `/`, on a prerendered `404.astro` (either mode), and, with routing `'prefix-except-default'`, on a prerendered `[lang]` route (`export const prerender = true`). Each message says why.
20
+ - **If you copied the skill's recipes:** the no-JS form recipe redirects to `ctx.originPathname` (since 0.2.2); the fallback-post recipe now answers a missing entry with `return Astro.rewrite('/404')`; `src/pages/404.astro` (and `500.astro`) stays at the root, not under `[lang]`, and reads the locale from `Astro.locals`.
21
+ - **Responses change:** root-mode default-locale pages send `Vary: Cookie` (Cloudflare's cache ignores it: bypass or key on the lang cookie); a response that sets the lang cookie is `Cache-Control: private`; redirects of requests other than GET/HEAD are 307/308; a locale segment in another case (`/DE/…`) redirects to `/de/…`; `/404` and `/500` pass through untouched.
22
+
23
+ ### Changed
24
+
25
+ - Built and tested on astro 7.4.2, `@astrojs/cloudflare` 14.4.2 and wrangler 4.154; peer floors raised to `astro` `^7.4.0`, `@astrojs/cloudflare` `^14.4.0`, `wrangler` `^4.152.0`; `engines.node` `>=22.23.4`.
26
+ - The edge cache holds each namespace's raw KV value under versioned URLs (`i18n:v2:`) and merges the compiled fallback on every read, so a default-locale edit reaches every locale on the next deploy; a namespace KV lacks is cached as missing for 60 s (`@edgekits/i18n-core`).
27
+ - Any path whose last segment has an extension that starts with a letter bypasses locale routing (`/site.webmanifest`, `/doc.pdf`); `/blog/v2.0` stays a page.
28
+ - The ignored prefixes match on a segment boundary (`/apis/` is a page) and include `/_server-islands` and `/.well-known`.
29
+ - The soft 404 on bypassed paths replaces only a 404 HTML response; other HTML (a server island) passes.
30
+ - Redirects of requests other than GET/HEAD keep the method: 308 for a normalisation, 307 for a negotiated one.
31
+ - Root-mode default-locale pages carry `Vary: Cookie`; a response that writes the lang cookie is `Cache-Control: private` (merged; `no-store` kept).
32
+ - `localizedPath` keeps a file path without a trailing slash and keeps a `?query`/`#hash`; `toIetfTag` cases each subtag by length (`zh-Hant-TW`); `plural` interpolates both forms.
33
+ - `LanguageSwitcher` keeps the query string when it navigates and writes `Secure` only on https; its file paths follow the core's rule. Markup unchanged.
34
+
35
+ ### Added
36
+
37
+ - The integration refuses `trailingSlash: 'never'`, a `base` other than `/`, a prerendered `404.astro` (served without the middleware; `Astro.rewrite('/404')` into it is a 500), and in `'prefix-except-default'` a prerendered `[lang]` route, each with the reason.
38
+ - A locale segment in another case (`/DE/about/`, `/pt-BR/`) gets one permanent redirect to the lower-case URL.
39
+ - `isFilePath` in `./format`.
40
+
41
+ ### Fixed
42
+
43
+ - The root-mode redirect of a `[lang]` file route's `/<defaultLocale>/…` form could produce a protocol-relative `Location` (`/en//evil.com/x.png` -> `//evil.com/x.png`); every target is now built from path segments.
44
+ - `/404` and `/500` pass through untouched in both modes (they were redirected, or rewritten again when Astro re-ran the middleware for the error page). Astro re-runs it with the original request, so the re-entry is recognised by the error route's pattern with the locals already set: the error page renders once, with the visitor's locale.
45
+ - `localizedPath` treats `\` as a segment separator (`@edgekits/i18n-core`), and the README's canonical/hreflang recipe appends to `Astro.url.origin` instead of resolving against `Astro.site`: a request for `/%5Cevil.com/` no longer yields an off-site canonical.
46
+ - Server islands in `'prefix-except-default'` were redirected to a trailing-slash URL and their HTML replaced by the soft 404.
47
+ - `/.well-known/…` URLs were treated as pages.
48
+
49
+ ## 0.2.2 — 2026-10-27
50
+
51
+ ### Fixed
52
+
53
+ - `'prefix-except-default'` keeps the query string through its rewrites and redirects: `/?sent=1` renders the success state, and a form posted to `/?_action=…` runs its action.
54
+ - In `'prefix'` mode the redirects keep the query string too (`/?utm=x` -> `/en/?utm=x`).
55
+ - `'prefix-except-default'` serves the default locale's `[lang]` endpoints at the root whatever their extension (`/rss.xml` from `src/pages/[lang]/rss.xml.ts`; `/en/rss.xml` redirects to it); root routes such as `sitemap.xml` are served as they are.
56
+ - The skill's no-JS form recipe (`redirectAfterNativePost`) answers its 303 at `ctx.originPathname`, the path the form was posted from; with `ctx.url.pathname` a root-mode post landed on `/<defaultLocale>/?sent=1` and one more redirect. A project that copied the recipe makes the same change in `src/ui/forms/native-post.ts`.
57
+
7
58
  ## 0.2.1 — 2026-10-27
8
59
 
9
60
  ### Changed
package/README.md CHANGED
@@ -15,11 +15,11 @@ The design is explained in the edgekits.dev series:
15
15
 
16
16
  ## Requirements
17
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
18
+ - Astro `^7.4` with `output: 'server'` and the `@astrojs/cloudflare` adapter `^14.4`.
19
+ - wrangler `^4.152`, installed in the project (a peer dependency: the CLI runs the project's own
20
20
  wrangler for its KV writes).
21
21
  - A `wrangler.jsonc` (the CLI reads that file; `wrangler.toml` and `wrangler.json` are not read).
22
- - Node `>=22`.
22
+ - Node `>=22.23.4` (what Astro 7.4 requires).
23
23
  - A Workers KV namespace bound as `TRANSLATIONS`.
24
24
  - For `migrate` only: the site on a proxied Cloudflare zone, its zone ID, and an API token with
25
25
  the Zone › Cache Purge permission. Zone › Zone › Read is optional; with it, `migrate` checks
@@ -75,26 +75,28 @@ export default defineI18nConfig({
75
75
  })
76
76
  ```
77
77
 
78
- | Field | Required | Default | Meaning |
79
- | -------------------------- | -------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
80
- | `languages` | yes | | `{ [locale]: { name, countryCode? } }`; `countryCode` is optional: the code your flag component receives |
81
- | `defaultLocale` | yes | | one of `languages`; its JSON files define the schema |
82
- | `cookie` | yes | | `{ name, maxAge }` (seconds) of the language cookie |
83
- | `keyPrefix` | yes | | a hostname of your zone, or a unique name without a dot: KV keys are `<keyPrefix>:<namespace>:<locale>` and every cache URL is `https://<keyPrefix>/…` (see [Deployment targets](#deployment-targets)) |
84
- | `missingTranslationBanner` | yes | | enables `MissingTranslationBanner` |
85
- | `routing` | no | `'prefix-except-default'` | `'prefix-except-default'`: the default locale at the root (`/about/`), the others prefixed (`/de/about/`); `'prefix'`: every URL starts with its locale (`/en/about/`). See [Routing modes](#routing-modes) |
86
- | `localesDir` | no | `'locales'` | where the CLI reads `<locale>/<namespace>.json` |
87
- | `generatedDir` | no | `'src/i18n'` | where the CLI writes `runtime-constants.ts` and `fallbacks.generated.ts` |
88
- | `countryToLocale` | no | none | geo-IP step of the locale fallback (see Reference) |
89
- | `markdownLocale` | no | none | the locale `.md` endpoints are served in (see Reference) |
78
+ | Field | Required | Default | Meaning |
79
+ | -------------------------- | -------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
80
+ | `languages` | yes | | `{ [locale]: { name, countryCode? } }`; `countryCode` is optional: the code your flag component receives |
81
+ | `defaultLocale` | yes | | one of `languages`; its JSON files define the schema |
82
+ | `cookie` | yes | | `{ name, maxAge }` (seconds) of the language cookie |
83
+ | `keyPrefix` | yes | | a hostname of your zone, or a unique name without a dot, written as a URL host (lowercase, no scheme or path): KV keys are `<keyPrefix>:<namespace>:<locale>` and every cache URL is `https://<keyPrefix>/…` (see [Deployment targets](#deployment-targets)) |
84
+ | `missingTranslationBanner` | yes | | enables `MissingTranslationBanner` |
85
+ | `routing` | no | `'prefix-except-default'` | `'prefix-except-default'`: the default locale at the root (`/about/`), the others prefixed (`/de/about/`); `'prefix'`: every URL starts with its locale (`/en/about/`). See [Routing modes](#routing-modes) |
86
+ | `localesDir` | no | `'locales'` | where the CLI reads `<locale>/<namespace>.json` |
87
+ | `generatedDir` | no | `'src/i18n'` | where the CLI writes `runtime-constants.ts` and `fallbacks.generated.ts` |
88
+ | `countryToLocale` | no | none | geo-IP step of the locale fallback (see Reference) |
89
+ | `markdownLocale` | no | none | the locale `.md` endpoints are served in (see Reference) |
90
90
 
91
91
  Locale codes (the keys of `languages`) are lowercase everywhere: the config, the `locales/`
92
92
  folders and the URLs (`pt-br`, not `pt-BR`). `toIetfTag()` from `./format` gives the IETF form
93
93
  (`pt-BR`) where one is needed, such as `<html lang>` and hreflang.
94
94
 
95
- The config is validated when it loads: an empty `languages`, a `defaultLocale` or
96
- `markdownLocale` outside it, or a bad `countryToLocale` entry throws with a message naming the
97
- field.
95
+ The config is validated when it loads, and each of these throws with a message naming the
96
+ field: an empty `languages`; a locale key that is not lowercase; a `keyPrefix` that is not a
97
+ URL host as written; a `defaultLocale` or `markdownLocale` outside `languages`; a `routing`
98
+ other than the two modes; a `countryToLocale` key that is not an uppercase country code, or a
99
+ value outside `languages`.
98
100
 
99
101
  ### 4. Add the integration
100
102
 
@@ -167,8 +169,12 @@ locales/
167
169
  The default locale's files define the schema: their namespaces and keys are the types. Another
168
170
  locale may leave keys out; the default locale's text fills the gaps.
169
171
 
170
- Lists are allowed and typed: `["a", "b"]` is `string[]`, a list of objects is `Array<{…}>` from
171
- its first element, and a locale that has the list replaces it whole.
172
+ Values are typed as written: strings, numbers and booleans. Lists are allowed and typed:
173
+ `["a", "b"]` is `string[]`; a list of objects is `Array<{…}>`, one object type merged from
174
+ its elements (a key every element has is required, any other optional, a key whose type differs
175
+ between elements a union); a locale that has the list replaces it whole. Two things stop the
176
+ CLI, naming the key path and the file: a `null` (leave the key out to fall back to the default
177
+ locale's text) and a list that mixes kinds of elements (strings and objects).
172
178
 
173
179
  ### 6. Generate and seed
174
180
 
@@ -179,30 +185,49 @@ npx edgekits-i18n seed
179
185
  The `edgekits-i18n` command comes from `@edgekits/i18n-core`, which this package depends on, so
180
186
  it is in your project's `node_modules/.bin` with no extra install.
181
187
 
182
- | Command | Writes | Then |
183
- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
184
- | `edgekits-i18n bundle` | `i18n-data.json` (the KV payload), `src/i18n.generated.d.ts` (the schema), `<generatedDir>/runtime-constants.ts` | nothing else |
185
- | `edgekits-i18n bundle --fallbacks` | the above and `<generatedDir>/fallbacks.generated.ts` (the default locale compiled in, served if KV is unreachable) | nothing else |
186
- | `edgekits-i18n seed` | the same as `bundle --fallbacks` | pushes every value to local KV (`wrangler kv bulk put --local`) |
187
- | `edgekits-i18n migrate` | the same as `bundle --fallbacks` | pushes to production KV, purges the changed namespaces' edge-cache entries, writes `.i18n-hashes.json` |
188
+ | Command | Writes | Then |
189
+ | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
190
+ | `edgekits-i18n bundle` | `i18n-data.json` (the KV payload), `src/i18n.generated.d.ts` (the schema), `<generatedDir>/runtime-constants.ts` | nothing else |
191
+ | `edgekits-i18n bundle --fallbacks` | the above and `<generatedDir>/fallbacks.generated.ts` (the default locale compiled in, served if KV is unreachable) | nothing else |
192
+ | `edgekits-i18n seed` | the same as `bundle --fallbacks` | pushes every value to local KV (`wrangler kv bulk put --local`; the binding's `preview_id` when set) |
193
+ | `edgekits-i18n migrate` | the same as `bundle --fallbacks` | pushes to production KV, deletes the keys of removed files, purges the changed namespaces' edge-cache entries twice, writes `.i18n-hashes.json` |
188
194
 
189
195
  The generated files are ordinary project files: commit them, and run the CLI again after you
190
196
  edit a JSON file.
191
197
 
192
- `.i18n-hashes.json` holds one content hash per namespace and locale as of the last successful
193
- `migrate`; the next `migrate` purges exactly the entries whose hash changed. Rules:
198
+ `.i18n-hashes.json` holds, per KV namespace id, one content hash per namespace and locale as
199
+ of the last successful `migrate`; the next `migrate` purges exactly the entries whose hash
200
+ changed, and deletes the KV keys of files removed since. Rules:
194
201
 
195
- - Only `migrate` writes it, and only after the purge succeeded (or nothing changed). `seed`
202
+ - Only `migrate` writes it, and only after both purges succeeded (or nothing changed). `seed`
196
203
  never touches it: local KV is not production.
197
204
  - Commit it after every `migrate`. It is the record of what production holds.
198
- - `migrate` refuses to run while the file has uncommitted changes, since a wrong baseline would
199
- skip purges silently. When that git check cannot run (no `git` on the PATH, or not a git work
200
- tree), `migrate` warns and proceeds without the purge-baseline check.
205
+ - `migrate` refuses to run while the baseline of the KV namespace it writes differs from the
206
+ one in the committed file (`HEAD`), since a wrong baseline would skip purges silently. Only
207
+ that namespace's entry counts: `migrate --env staging && migrate` in one job works. When the
208
+ git check cannot run (no `git` on the PATH, or not a git work tree), `migrate` warns and
209
+ proceeds without it.
210
+ - A file from 0.2.x (one flat baseline) is the baseline of the top-level `TRANSLATIONS` id,
211
+ whichever environment migrates first; it is rewritten per KV id.
212
+ - After a `git revert` of translations, the baseline is reverted with them and reports no
213
+ change: run `migrate --purge-all`, which purges every namespace's URLs whatever the
214
+ baseline says.
201
215
 
202
216
  `migrate` reads the zone ID from `CLOUDFLARE_ZONE_ID` (a var in `wrangler.jsonc` or the
203
217
  environment) and the token from `CLOUDFLARE_CACHEPURGE_API_TOKEN` (the environment or
204
- `.dev.vars`). Without either, it still pushes to KV but skips the purge and leaves
205
- `.i18n-hashes.json` alone, so the next run retries.
218
+ `.dev.vars`). It purges, waits 65 s for KV to settle (`--purge-delay`), and purges again.
219
+ Without the zone ID or token, when deleting the removed keys or a purge fails, it still pushes
220
+ to KV but exits 1 and leaves `.i18n-hashes.json` alone, so the next run retries. The zone ID
221
+ must be 32 hex characters. `--env <name>` reads the binding and the zone ID from `env.<name>`,
222
+ and `.dev.vars.<name>` instead of `.dev.vars` when it exists (as wrangler does). Two
223
+ environments on one zone with different KV namespaces need a `keyPrefix` each (a config per
224
+ environment): with one, their cache URLs are the same, and `migrate` warns. An unknown
225
+ argument (`-e staging`) exits 1. All flags:
226
+ [the core's CLI reference](https://github.com/EdgeKits/i18n/tree/main/packages/core#cli).
227
+
228
+ After you delete a translation file, `migrate` removes its key from production KV; `seed`
229
+ does not touch local KV's copy. Wipe `.wrangler/state` (or delete the key with
230
+ `wrangler kv key delete --local`) so `astro dev` stops serving it.
206
231
 
207
232
  ### 7. Use translations in a page
208
233
 
@@ -251,14 +276,14 @@ add the line.
251
276
 
252
277
  ### Which entry to import
253
278
 
254
- | Entry | Where it runs | What it holds |
255
- | ----------------------------------- | -------------------------- | -------------------------------------------------------------------------------------------------------------- |
256
- | `@edgekits/astro-i18n` | `astro.config` (Node) | `edgekitsI18n()` (the integration) and `defineI18nConfig` |
257
- | `@edgekits/astro-i18n/config` | anywhere, Node-safe | `defineI18nConfig`, `localesOf(config)` (`DEFAULT_LOCALE`, `SUPPORTED_LOCALES`, `LANGUAGES`), the types |
258
- | `@edgekits/astro-i18n/format` | anywhere, Node-safe | `fmt`, `plural`, `pluralIcu`, `toIetfTag`, `replaceLocaleInPath`, `getLangFromUrl` |
259
- | `@edgekits/astro-i18n/runtime` | workerd only (pages, APIs) | `fetchTranslations`, the locale constants from the integration's config, locale resolution, the format helpers |
260
- | `@edgekits/astro-i18n/middleware` | workerd only | the i18n middleware (`onRequest`); registered for you unless `middleware: 'manual'` |
261
- | `@edgekits/astro-i18n/components/*` | `.astro` pages | `LanguageSwitcher`, `MissingTranslationBanner` |
279
+ | Entry | Where it runs | What it holds |
280
+ | ----------------------------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------- |
281
+ | `@edgekits/astro-i18n` | `astro.config` (Node) | `edgekitsI18n()` (the integration) and `defineI18nConfig` |
282
+ | `@edgekits/astro-i18n/config` | anywhere, Node-safe | `defineI18nConfig`, `localesOf(config)` (`DEFAULT_LOCALE`, `SUPPORTED_LOCALES`, `LANGUAGES`), the types |
283
+ | `@edgekits/astro-i18n/format` | anywhere, Node-safe | `fmt`, `plural`, `pluralIcu`, `toIetfTag`, `localizedPath`, `isFilePath`, `replaceLocaleInPath`, `getLangFromUrl` |
284
+ | `@edgekits/astro-i18n/runtime` | workerd only (pages, APIs) | `fetchTranslations`, the locale constants from the integration's config, locale resolution, the format helpers |
285
+ | `@edgekits/astro-i18n/middleware` | workerd only | the i18n middleware (`onRequest`); registered for you unless `middleware: 'manual'` |
286
+ | `@edgekits/astro-i18n/components/*` | `.astro` pages | `LanguageSwitcher`, `MissingTranslationBanner` |
262
287
 
263
288
  Node-safe entries import nothing from `cloudflare:workers` and no virtual module, so unit tests
264
289
  under Node and `src/content.config.ts` (evaluated by `astro sync`) can use them. `./runtime` and
@@ -266,12 +291,14 @@ under Node and `src/content.config.ts` (evaluated by `astro sync`) can use them.
266
291
 
267
292
  ### Geo-IP locale fallback: `countryToLocale`
268
293
 
269
- When a request's URL has no locale, the middleware picks one in this order: the language
270
- cookie, then the `Accept-Language` header (each tag in `q` order, the full tag, then its
271
- language part, so `es-MX` reaches `es`; the first tag that names one of your `languages` wins),
272
- then, only when no tag matched, the visitor's country (`request.cf.country`) through your
273
- `countryToLocale` table, and finally `defaultLocale`. The middleware reads the header itself;
274
- Astro's `preferredLocale` is not used.
294
+ In `'prefix'`, when a request's URL has no locale, the middleware picks one in this order: the
295
+ language cookie, then the `Accept-Language` header (each tag in `q` order, the full tag, then
296
+ its language part, so `es-MX` reaches `es`; the first tag that names one of your `languages`
297
+ wins), then, only when no tag matched, the visitor's country (`request.cf.country`) through
298
+ your `countryToLocale` table, and finally `defaultLocale`. The middleware reads the header itself;
299
+ Astro's `preferredLocale` is not used. In `'prefix-except-default'` a URL without a locale is a
300
+ default-locale page: no cascade runs, and only a language cookie naming another locale
301
+ redirects (see [Routing modes](#routing-modes)).
275
302
 
276
303
  ```ts
277
304
  // edgekits-i18n.config.ts
@@ -302,12 +329,23 @@ export default defineI18nConfig({
302
329
  })
303
330
  ```
304
331
 
332
+ In `'prefix'`:
333
+
305
334
  | Request | With `markdownLocale: 'en'` | Without `markdownLocale` |
306
335
  | ---------------- | --------------------------- | ------------------------ |
307
336
  | `/de/blog/x.md/` | redirect to `/en/blog/x.md` | passed through as is |
308
337
  | `/blog/x.md` | redirect to `/en/blog/x.md` | passed through as is |
309
338
  | `/en/blog/x.md` | served | passed through as is |
310
339
 
340
+ In `'prefix-except-default'` (default locale `en`), the default locale's form has no prefix, and
341
+ a `.md` path without a locale renders from the default locale's `[lang]` endpoint:
342
+
343
+ | Request | With `markdownLocale: 'en'` | With `markdownLocale: 'de'` | Without `markdownLocale` |
344
+ | ---------------- | --------------------------- | --------------------------- | --------------------------- |
345
+ | `/de/blog/x.md/` | redirect to `/blog/x.md` | redirect to `/de/blog/x.md` | passed through as is |
346
+ | `/blog/x.md` | served (as `/en/blog/x.md`) | redirect to `/de/blog/x.md` | served (as `/en/blog/x.md`) |
347
+ | `/en/blog/x.md` | redirect to `/blog/x.md` | redirect to `/de/blog/x.md` | passed through as is |
348
+
311
349
  Use it when your markdown endpoints exist in one locale only. Without it, the package assumes
312
350
  nothing about them.
313
351
 
@@ -323,16 +361,27 @@ In `'prefix-except-default'` the default locale has no prefix here either: with
323
361
  - `'prefix-except-default'` (the default when `routing` is left out): the default locale at the
324
362
  root (`/`, `/about/`), every other locale under its prefix (`/de/about/`). The middleware
325
363
  serves a root URL by rewriting it internally to `/<defaultLocale>/…`, so the URL stays;
326
- `/en/about/` redirects permanently to `/about/`. A root URL consults neither Accept-Language
327
- nor the country: only a lang cookie for another locale (the visitor chose it in the switcher)
328
- redirects, temporarily, to that locale.
364
+ `/en/about/` redirects permanently to `/about/`. An endpoint under `src/pages/[lang]/`
365
+ answers at the root too (`/rss.xml`, and `/en/rss.xml` redirects to it), while a root route
366
+ of its own (`sitemap.xml`, `robots.txt`) is served as is. A root URL consults neither
367
+ Accept-Language nor the country: only a lang cookie for another locale (the visitor chose it
368
+ in the switcher) redirects, temporarily, to that locale.
329
369
  - `'prefix'`: every URL starts with its locale (`/en/about/`); `/` and a URL without a locale
330
370
  redirect to the visitor's locale (cookie, Accept-Language, country, default). Projects that
331
371
  set `routing: 'prefix'` keep exactly this by leaving their config alone.
332
372
 
373
+ In both modes, rewrites and redirects keep the query string (`/?utm=x` redirects to
374
+ `/en/?utm=x` in `'prefix'`, and renders `/` with `?utm=x` in `'prefix-except-default'`).
375
+
376
+ A middleware after the i18n one sees `context.url.pathname` as the visitor sent it, still
377
+ percent-encoded (`/%61dmin/` for `/admin/`, under the `/<defaultLocale>/…` rewrite in
378
+ `'prefix-except-default'`): match on decoded paths (`decodeURIComponent`), or on
379
+ `context.routePattern` and `context.params`.
380
+
333
381
  In `'prefix-except-default'`, build canonical and hreflang URLs from `Astro.originPathname`
334
382
  (the URL the visitor requested; `Astro.url` is the rewritten `/<defaultLocale>/…`) through
335
- `localizedPath` from `/runtime`. Astro's own `astro:i18n` URL helpers prefix the default locale
383
+ `localizedPath` from `/runtime`, appended to `Astro.url.origin` (string concatenation, not
384
+ `new URL(path, Astro.site)`: the path is the visitor's, and only the origin is yours). Astro's own `astro:i18n` URL helpers prefix the default locale
336
385
  here, so `localizedPath` is the one to use:
337
386
 
338
387
  ```astro
@@ -347,9 +396,9 @@ const options = {
347
396
  const path = Astro.originPathname
348
397
  ---
349
398
 
350
- <link rel="canonical" href={new URL(localizedPath(Astro.locals.uiLocale, path, options), Astro.site)} />
351
- {SUPPORTED_LOCALES.map((locale) => <link rel="alternate" hreflang={toIetfTag(locale)} href={new URL(localizedPath(locale, path, options), Astro.site)} />)}
352
- <link rel="alternate" hreflang="x-default" href={new URL(localizedPath(DEFAULT_LOCALE, path, options), Astro.site)} />
399
+ <link rel="canonical" href={Astro.url.origin + localizedPath(Astro.locals.uiLocale, path, options)} />
400
+ {SUPPORTED_LOCALES.map((locale) => <link rel="alternate" hreflang={toIetfTag(locale)} href={Astro.url.origin + localizedPath(locale, path, options)} />)}
401
+ <link rel="alternate" hreflang="x-default" href={Astro.url.origin + localizedPath(DEFAULT_LOCALE, path, options)} />
353
402
  ```
354
403
 
355
404
  Two limits:
@@ -358,8 +407,46 @@ Two limits:
358
407
  page at `/de/` (a content slug `de`) cannot exist next to the German site.
359
408
  - A shared HTML cache in front of the site must key root paths on the lang cookie, or skip
360
409
  caching requests that carry one: the same URL renders for a cookie-less visitor and
361
- redirects one whose cookie names another locale. Under `'prefix'`, `/en/` is in the same
362
- situation: it answers a visitor whose cookie names another locale with a `Set-Cookie`.
410
+ redirects one whose cookie names another locale. Every default-locale page response carries
411
+ `Vary: Cookie`, but Cloudflare's cache ignores `Vary: Cookie`: a Cache Rule must bypass
412
+ requests with the lang cookie, or include it in the cache key. Under `'prefix'`, `/en/` is in
413
+ the same situation: it answers a visitor whose cookie names another locale with a `Set-Cookie`.
414
+
415
+ #### What the middleware leaves alone, and the rules it keeps (both modes)
416
+
417
+ - **Files and system paths pass through.** A path whose last segment ends in an extension that
418
+ starts with a letter is a file (`/rss.xml`, `/doc.pdf`, `/site.webmanifest`; `/blog/v2.0` is
419
+ a page), and so is everything under `/api`, `/assets`, `/_astro`, `/_image`, `/_actions`,
420
+ `/_server-islands`, `/.well-known`, `/favicon.ico` and the Vite dev server's `/@vite`, `/@id`,
421
+ `/@fs`, `/src` and `/node_modules`, each matched on a segment boundary (`/apis/` is a page). When Astro answers such a request with its 404 page, the middleware
422
+ sends a plain-text 404 instead; any other response passes unchanged.
423
+ - **A page whose last segment contains a dot is addressed with its trailing slash**
424
+ (`/team/john.doe/`): without it, `/team/john.doe` is a file path, gets no redirect and no
425
+ locale. Link to the slash form; `localizedPath` gives it for a path that ends in `/`.
426
+ - **Server islands** (`server:defer`) are fetched from `/_server-islands/…`, a URL without a
427
+ locale: in the island, `Astro.locals.uiLocale` is the default locale. Pass the page's locale
428
+ to the island as a prop.
429
+ - **Error pages stay at the root**: `src/pages/404.astro` (and `500.astro`), not under
430
+ `[lang]`. `/404` and `/500` pass through untouched, and the page reads the locale from
431
+ `Astro.locals` (the visitor's, when Astro renders it for a missing page under `/de/` or for
432
+ a page that answers `new Response(null, { status: 404 })`). In a page, answer a missing
433
+ entry with `return Astro.rewrite('/404')`. Keep `404.astro` on-demand: a prerendered one is
434
+ refused at build time.
435
+ - **Redirects keep the method.** For a request other than GET or HEAD (a form POST), a
436
+ permanent normalisation is a 308 and a negotiated redirect a 307, so the body is resent.
437
+ - **The locale segment is lower case.** `/DE/about/` and `/pt-BR/` redirect permanently, in one
438
+ hop, to the canonical lower-case URL (`/de/about/`, `/pt-br/`; the default locale without its
439
+ prefix in `'prefix-except-default'`).
440
+ - **A response that sets the lang cookie is `Cache-Control: private`** (the URL wins over a
441
+ stale cookie, and the corrected cookie is this visitor's); an existing `no-store` stays.
442
+
443
+ The integration refuses four Astro settings at build time, each with the reason:
444
+ `trailingSlash: 'never'` (Astro strips the slash the middleware adds: every page would
445
+ redirect forever; `'ignore'` and `'always'` work), a `base` other than `/` (the middleware's
446
+ paths assume the site root), a prerendered `404.astro` in either mode (Astro serves it without
447
+ the middleware, and `Astro.rewrite('/404')` into it is a 500), and in `'prefix-except-default'`
448
+ a prerendered `[lang]` route (`export const prerender = true`): root URLs render through a
449
+ rewrite, and Astro refuses a rewrite into a prerendered route.
363
450
 
364
451
  ### Styling the components
365
452
 
@@ -482,9 +569,9 @@ cache.
482
569
 
483
570
  **Workers for Platforms, untrusted namespace.** `request.cf` is absent, so the country step of
484
571
  the locale cascade never runs. `caches.default` is disabled, so every render reads its
485
- namespaces from KV in one bulk read: the output is correct, only the cache layer and its KV
486
- savings are gone. Set `I18N_CACHE=off` so the behaviour is explicit, and expect one bulk KV read
487
- per render.
572
+ namespaces from KV in bulk reads of up to 100 keys: the output is correct, only the cache
573
+ layer and its KV savings are gone. Set `I18N_CACHE=off` so the behaviour is explicit, and
574
+ expect one bulk KV read per render (per 100 namespaces).
488
575
 
489
576
  <!-- skill-guide:start -->
490
577
 
@@ -570,11 +657,12 @@ npx skills add EdgeKits/i18n --skill astro-i18n
570
657
 
571
658
  ### Prerequisites
572
659
 
573
- - An Astro `^7.3` project with `output: 'server'` and the `@astrojs/cloudflare` adapter `^14.3`.
660
+ - An Astro `^7.4` project with `output: 'server'` and the `@astrojs/cloudflare` adapter `^14.4`.
574
661
  For a new site: `npm create astro@latest`, then `npx astro add cloudflare`, then set
575
662
  `output: 'server'` in `astro.config`.
576
- - wrangler `^4.125` installed in the project (`npm i -D wrangler`) and a `wrangler.jsonc`. The
663
+ - wrangler `^4.152` installed in the project (`npm i -D wrangler`) and a `wrangler.jsonc`. The
577
664
  package's CLI runs the project's own wrangler, never a global one.
665
+ - Node `>=22.23.4`.
578
666
  - A git repository (`git init`): the generated files and the purge baseline
579
667
  (`.i18n-hashes.json`) are committed, and `migrate` checks the baseline against git (outside
580
668
  a repository it skips that check, with a warning).
@@ -682,12 +770,12 @@ A locale without a `countryCode` shows its name alone. A `countryCode` whose SVG
682
770
 
683
771
  Before it reports back, the skill runs its adoption checklist: the config matches your answers
684
772
  and every locale code is lowercase; `seed` writes the generated files and leaves
685
- `.i18n-hashes.json` alone; `astro check` passes; under `astro dev`, `/` redirects to a locale,
686
- each locale renders its own copy (or the default locale's, if it has no JSON yet), an unknown
773
+ `.i18n-hashes.json` alone; `astro check` passes; under `astro dev`, `/` redirects to a locale
774
+ (`'prefix'`) or renders the default locale (`'prefix-except-default'`), each locale renders its own copy (or the default locale's, if it has no JSON yet), an unknown
687
775
  first segment such as `/zz/` is prefixed rather than crashing, `<html lang>` reads `pt-BR` on a
688
776
  `pt-br` page, hreflang tags cover every locale plus `x-default`, and the switcher changes the
689
777
  URL and sets the language cookie. It also checks that no page is left at `src/pages/*.astro`
690
- and that every `countryCode` has its SVG.
778
+ (the error pages `404.astro` and `500.astro` excepted) and that every `countryCode` has its SVG.
691
779
 
692
780
  ### Everyday work
693
781
 
@@ -709,7 +797,7 @@ seed`. A key that exists only in another locale is not typed.
709
797
 
710
798
  `.i18n-hashes.json` records what production KV holds. Only a successful `migrate` writes it,
711
799
  it is committed right after, and nothing else edits it. `migrate` refuses to run while that
712
- file has uncommitted changes.
800
+ file's baseline for the KV namespace it writes differs from the committed one.
713
801
 
714
802
  ### Forms and SEO
715
803
 
@@ -753,13 +841,15 @@ canonical at the original instead of using `noindex`.
753
841
  run since. Add it to the default locale and run `seed` (or `bundle`); if the editor still
754
842
  shows the error, restart its TypeScript server.
755
843
  - **`Cannot find module 'cloudflare:workers'` in `tsc`.** Run `npx wrangler types`.
756
- - **`migrate` refuses to run.** `.i18n-hashes.json` has uncommitted changes. If the last
757
- `migrate` wrote it, commit it. If anything else changed it, discard the change with
844
+ - **`migrate` refuses to run.** The baseline of the KV namespace it writes, in
845
+ `.i18n-hashes.json`, differs from the committed one. If the last `migrate` wrote it, commit it. If anything else changed it, discard the change with
758
846
  `git checkout .i18n-hashes.json` and run `migrate` again. Never edit or delete it to get
759
847
  past the check.
760
- - **`migrate` updated KV but the site shows old copy.** The purge was skipped: the zone is not
761
- proxied, or the zone ID or token is missing. Fix it and run `migrate` again; it retries the
762
- same purge.
848
+ - **`migrate` exits 1 after "KV is updated; the edge cache is not".** The purge was skipped or
849
+ failed (the zone ID or token is missing or malformed, or the Purge API refused), or deleting
850
+ the KV keys of removed files failed. Fix it and run `migrate` again; it retries the same
851
+ deletions and purge. If it exited 0 and the site still shows old copy, the zone
852
+ is not proxied.
763
853
  - **npm 12: `esbuild` or `workerd` did not install.** npm 12 blocks dependency install
764
854
  scripts by default and lists the skipped packages at the end of `npm install`. Both need
765
855
  theirs: run `npm install-scripts approve esbuild workerd`, then
@@ -793,13 +883,18 @@ under their own terms. EdgeKits runs no service behind this plugin.
793
883
 
794
884
  ## How it works
795
885
 
796
- A request reaches the middleware, which settles the URL's locale (redirecting when there is
797
- none) and sets `uiLocale` and `translationLocale` on `Astro.locals`. A page asks
886
+ A request reaches the middleware, which settles the URL's locale (in `'prefix'` it redirects a
887
+ URL without one; in `'prefix-except-default'` it renders such a URL as the default locale's
888
+ page through an internal rewrite to `/<defaultLocale>/…`) and sets `uiLocale` and
889
+ `translationLocale` on `Astro.locals`. A page asks
798
890
  `fetchTranslations` for the namespaces it renders. Each namespace is looked up in the Workers
799
- Cache API under `https://<keyPrefix>/…`; the misses are read from KV in one batch, merged over
800
- the compiled default-locale fallbacks, returned, and written back to the cache in the
801
- background. Cached entries live for a year (`s-maxage=31536000, immutable`): `migrate` purges the ones whose content
802
- changed, and leaves the rest warm. If KV cannot be reached, the compiled fallbacks render the
891
+ Cache API under `https://<keyPrefix>/…`; the misses are read from KV in batches of up to 100
892
+ keys, and their KV values are written back to the cache in the background, marked
893
+ `X-EdgeKits-I18n: v2` (a response at that URL without the mark is a miss). Every namespace,
894
+ cached or fresh, is merged over the compiled default-locale fallbacks when it is read, so a new
895
+ default-locale key shows in every locale on the next deploy. A cached value lives for a year
896
+ (`s-maxage=31536000, immutable`): `migrate` purges the ones whose content changed, and leaves
897
+ the rest warm. A namespace KV does not have is cached as missing for 60 seconds. If KV cannot be reached, the compiled fallbacks render the
803
898
  default locale's text instead of an error.
804
899
 
805
900
  ## License
@@ -25,6 +25,36 @@ export declare function resolveConfig(config: I18nConfig): I18nConfig & {
25
25
  * instead of merging.
26
26
  */
27
27
  export declare function localesDeclaration(locales: readonly string[]): string;
28
+ /**
29
+ * The Astro settings the middleware cannot work under, refused at config time:
30
+ * - `trailingSlash: 'never'`: Astro removes a trailing slash before the middleware runs
31
+ * (astro/dist/core/routing/handler.js:105-107, Astro 7.4.2), and the middleware adds it
32
+ * back: every page would redirect forever. `'ignore'` (the default) and `'always'` work.
33
+ * - a `base` other than `/`: the middleware's paths and redirects assume the site's root.
34
+ */
35
+ export declare function assertAstroRouting(astroConfig: Pick<AstroConfig, 'trailingSlash' | 'base'>): void;
36
+ /**
37
+ * In 'prefix-except-default' the default locale's pages render through a rewrite to
38
+ * `/<default>/…`, and Astro refuses a rewrite from an on-demand request into a
39
+ * prerendered route (ForbiddenRewrite, a 500: astro/dist/core/rewrites/handler.js:29-33,
40
+ * Astro 7.4.2). Every `[lang]` route must be on-demand.
41
+ */
42
+ export declare function assertNoPrerenderedLangRoute(routes: ReadonlyArray<{
43
+ pattern: string;
44
+ isPrerendered: boolean;
45
+ }>): void;
46
+ /**
47
+ * A prerendered 404 page, in either mode: Astro serves it as a static file without the
48
+ * middleware (no locale in `locals`), and a page's `Astro.rewrite('/404')` into it from an
49
+ * on-demand request is a ForbiddenRewrite, a 500 (astro/dist/core/rewrites/handler.js:29-33,
50
+ * Astro 7.4.2). Only the project's own route counts: Astro's built-in 404 is on-demand.
51
+ */
52
+ export declare function assertNoPrerendered404(routes: ReadonlyArray<{
53
+ pattern: string;
54
+ isPrerendered: boolean;
55
+ origin?: string;
56
+ entrypoint?: string;
57
+ }>): void;
28
58
  /**
29
59
  * The Astro `i18n` block set when the project has none: locales and default from the
30
60
  * config; routed as locale-prefixed in both modes (the middleware redirects `/` itself in
@@ -32,10 +62,10 @@ export declare function localesDeclaration(locales: readonly string[]): string;
32
62
  * re-validate what `updateConfig` merges, so its schema defaults never fill a partial block.
33
63
  *
34
64
  * `prefixDefaultLocale: true` in 'prefix-except-default' too: Astro's own i18n check runs
35
- * after the middleware, on the rewritten URL (astro/dist/core/routing/handler.js:128-130),
36
- * and with `prefixDefaultLocale: false` the strategy `pathname-prefix-other-locales`
37
- * answers notFound to any path carrying the default locale (astro/dist/i18n/router.js:
38
- * 104-121) — the rewritten `/<default>/about/` is exactly that. With `true` the check
65
+ * after the middleware, on the rewritten URL (astro/dist/core/routing/handler.js:167-169,
66
+ * Astro 7.4.2), and with `prefixDefaultLocale: false` the strategy
67
+ * `pathname-prefix-other-locales` answers notFound to any path carrying the default locale
68
+ * (astro/dist/i18n/router.js:107-124) — the rewritten `/<default>/about/` is exactly that. With `true` the check
39
69
  * passes and `Astro.currentLocale` works on root pages.
40
70
  */
41
71
  export declare function astroI18n(config: I18nConfig): NonNullable<AstroConfig['i18n']>;
@@ -19,6 +19,7 @@ export function edgekitsI18n(config, options = {}) {
19
19
  hooks: {
20
20
  'astro:config:setup': ({ config: astroConfig, updateConfig, addMiddleware }) => {
21
21
  const generatedDir = new URL(`${trimSlashes(i18nConfig.generatedDir ?? DEFAULT_GENERATED_DIR)}/`, astroConfig.root);
22
+ assertAstroRouting(astroConfig);
22
23
  // Astro sets `Astro.currentLocale` (read by `resolveLocale`) only with an `i18n`
23
24
  // block: set it when the project has none, check it when it has one.
24
25
  if (astroConfig.i18n)
@@ -36,6 +37,11 @@ export function edgekitsI18n(config, options = {}) {
36
37
  addMiddleware({ entrypoint: '@edgekits/astro-i18n/middleware', order: 'pre' });
37
38
  }
38
39
  },
40
+ 'astro:routes:resolved': ({ routes }) => {
41
+ assertNoPrerendered404(routes);
42
+ if (i18nConfig.routing === 'prefix-except-default')
43
+ assertNoPrerenderedLangRoute(routes);
44
+ },
39
45
  'astro:config:done': ({ injectTypes }) => {
40
46
  injectTypes({ filename: 'locales.d.ts', content: localesDeclaration(Object.keys(i18nConfig.languages)) });
41
47
  // The shipped runtime imports the virtual modules; a consumer's program needs their
@@ -79,6 +85,53 @@ export function localesDeclaration(locales) {
79
85
  '',
80
86
  ].join('\n');
81
87
  }
88
+ /**
89
+ * The Astro settings the middleware cannot work under, refused at config time:
90
+ * - `trailingSlash: 'never'`: Astro removes a trailing slash before the middleware runs
91
+ * (astro/dist/core/routing/handler.js:105-107, Astro 7.4.2), and the middleware adds it
92
+ * back: every page would redirect forever. `'ignore'` (the default) and `'always'` work.
93
+ * - a `base` other than `/`: the middleware's paths and redirects assume the site's root.
94
+ */
95
+ export function assertAstroRouting(astroConfig) {
96
+ if (astroConfig.trailingSlash === 'never') {
97
+ throw new Error("[@edgekits/astro-i18n] `trailingSlash: 'never'` is not supported: Astro removes the trailing slash " +
98
+ 'before the middleware runs, and the middleware adds it back to every page, so each request would ' +
99
+ "redirect forever. Use 'ignore' (the default) or 'always'.");
100
+ }
101
+ if (astroConfig.base !== undefined && astroConfig.base !== '/') {
102
+ throw new Error(`[@edgekits/astro-i18n] \`base: ${JSON.stringify(astroConfig.base)}\` is not supported: the middleware's ` +
103
+ 'locale paths and redirects are built from the site root. Serve the site at the root of its host ' +
104
+ '(remove `base`, or set it to "/").');
105
+ }
106
+ }
107
+ /**
108
+ * In 'prefix-except-default' the default locale's pages render through a rewrite to
109
+ * `/<default>/…`, and Astro refuses a rewrite from an on-demand request into a
110
+ * prerendered route (ForbiddenRewrite, a 500: astro/dist/core/rewrites/handler.js:29-33,
111
+ * Astro 7.4.2). Every `[lang]` route must be on-demand.
112
+ */
113
+ export function assertNoPrerenderedLangRoute(routes) {
114
+ const prerendered = routes.find((route) => route.isPrerendered && (route.pattern === '/[lang]' || route.pattern.startsWith('/[lang]/')));
115
+ if (prerendered) {
116
+ throw new Error(`[@edgekits/astro-i18n] ${prerendered.pattern} is prerendered: with routing 'prefix-except-default' the ` +
117
+ "default locale's pages render through a rewrite to /<default>/…, and Astro refuses a rewrite into a " +
118
+ "prerendered route (ForbiddenRewrite). Remove `export const prerender = true` from that page, or use routing 'prefix'.");
119
+ }
120
+ }
121
+ /**
122
+ * A prerendered 404 page, in either mode: Astro serves it as a static file without the
123
+ * middleware (no locale in `locals`), and a page's `Astro.rewrite('/404')` into it from an
124
+ * on-demand request is a ForbiddenRewrite, a 500 (astro/dist/core/rewrites/handler.js:29-33,
125
+ * Astro 7.4.2). Only the project's own route counts: Astro's built-in 404 is on-demand.
126
+ */
127
+ export function assertNoPrerendered404(routes) {
128
+ const page = routes.find((route) => route.pattern === '/404' && route.isPrerendered && route.origin === 'project');
129
+ if (page) {
130
+ throw new Error(`[@edgekits/astro-i18n] ${page.entrypoint ?? 'src/pages/404.astro'} is prerendered: Astro serves a prerendered ` +
131
+ "404 page without the middleware, so it never gets the visitor's locale, and a page's `Astro.rewrite('/404')` " +
132
+ 'into it is refused (ForbiddenRewrite, a 500). Remove `export const prerender = true` from the 404 page.');
133
+ }
134
+ }
82
135
  /**
83
136
  * The Astro `i18n` block set when the project has none: locales and default from the
84
137
  * config; routed as locale-prefixed in both modes (the middleware redirects `/` itself in
@@ -86,10 +139,10 @@ export function localesDeclaration(locales) {
86
139
  * re-validate what `updateConfig` merges, so its schema defaults never fill a partial block.
87
140
  *
88
141
  * `prefixDefaultLocale: true` in 'prefix-except-default' too: Astro's own i18n check runs
89
- * after the middleware, on the rewritten URL (astro/dist/core/routing/handler.js:128-130),
90
- * and with `prefixDefaultLocale: false` the strategy `pathname-prefix-other-locales`
91
- * answers notFound to any path carrying the default locale (astro/dist/i18n/router.js:
92
- * 104-121) — the rewritten `/<default>/about/` is exactly that. With `true` the check
142
+ * after the middleware, on the rewritten URL (astro/dist/core/routing/handler.js:167-169,
143
+ * Astro 7.4.2), and with `prefixDefaultLocale: false` the strategy
144
+ * `pathname-prefix-other-locales` answers notFound to any path carrying the default locale
145
+ * (astro/dist/i18n/router.js:107-124) — the rewritten `/<default>/about/` is exactly that. With `true` the check
93
146
  * passes and `Astro.currentLocale` works on root pages.
94
147
  */
95
148
  export function astroI18n(config) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgekits/astro-i18n",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Edge-native i18n for Astro on Cloudflare Workers: translations in Workers KV, a per-namespace edge cache invalidated by the Purge API, and typed keys.",
5
5
  "keywords": [
6
6
  "astro",
@@ -24,7 +24,7 @@
24
24
  "url": "https://github.com/EdgeKits/i18n/issues"
25
25
  },
26
26
  "engines": {
27
- "node": ">=22"
27
+ "node": ">=22.23.4"
28
28
  },
29
29
  "exports": {
30
30
  ".": {
@@ -57,14 +57,16 @@
57
57
  "format:check": "prettier --check ."
58
58
  },
59
59
  "dependencies": {
60
- "@edgekits/i18n-core": "0.2.1"
60
+ "@edgekits/i18n-core": "0.3.0"
61
61
  },
62
62
  "peerDependencies": {
63
- "@astrojs/cloudflare": "^14.3.0",
64
- "astro": "^7.3.0",
65
- "wrangler": "^4.125.0"
63
+ "@astrojs/cloudflare": "^14.4.0",
64
+ "astro": "^7.4.0",
65
+ "wrangler": "^4.152.0"
66
66
  },
67
67
  "devDependencies": {
68
- "astro": "^7.3.8"
68
+ "@astrojs/cloudflare": "^14.4.2",
69
+ "astro": "^7.4.2",
70
+ "wrangler": "^4.152.0"
69
71
  }
70
72
  }
@@ -111,6 +111,25 @@ const hasChoice = SUPPORTED_LOCALES.length > 1
111
111
  // cannot import the project's config.
112
112
  import type { Locale } from '../constants'
113
113
 
114
+ // The core's `localizedPath`, inlined (a client script imports types only): the page's
115
+ // path in `targetLang`, a file path (`/rss.xml`) without a trailing slash. The URL carries
116
+ // the current locale's prefix exactly when it has one (every locale in 'prefix', all but
117
+ // the default in 'prefix-except-default'), so `currentLang` is the only segment to strip.
118
+ // It receives pathnames only. `test/language-switcher-script.test.ts` compares it with the core.
119
+ function localizedPathOf(
120
+ pathname: string,
121
+ targetLang: string,
122
+ currentLang: string | undefined,
123
+ routing: string | undefined,
124
+ defaultLocale: string | undefined,
125
+ ): string {
126
+ const segments = pathname.split('/').filter(Boolean)
127
+ if (segments[0] === currentLang) segments.shift()
128
+ const isFile = /\.[a-z][a-z0-9]{0,10}$/i.test(pathname.slice(pathname.lastIndexOf('/') + 1))
129
+ const rest = segments.length > 0 ? `${segments.join('/')}${isFile ? '' : '/'}` : ''
130
+ return routing !== 'prefix' && targetLang === defaultLocale ? `/${rest}` : `/${targetLang}/${rest}`
131
+ }
132
+
114
133
  class LanguageSwitcher extends HTMLElement {
115
134
  private trigger: HTMLButtonElement | null = null
116
135
  private dropdown: HTMLElement | null = null
@@ -162,18 +181,6 @@ const hasChoice = SUPPORTED_LOCALES.length > 1
162
181
  this.dropdown.hidden ? this.open() : this.close()
163
182
  }
164
183
 
165
- // The core's `localizedPath`, inlined (a client script imports types only): the page's
166
- // path in `targetLang`. The URL carries the current locale's prefix exactly when it has
167
- // one (every locale in 'prefix', all but the default in 'prefix-except-default'), so
168
- // `data-current-lang` is the only segment to strip.
169
- private localizedPath(pathname: string, targetLang: string): string {
170
- const { currentLang, routing, defaultLocale } = this.dataset
171
- const segments = pathname.split('/').filter(Boolean)
172
- if (segments[0] === currentLang) segments.shift()
173
- const rest = segments.length > 0 ? `${segments.join('/')}/` : ''
174
- return routing !== 'prefix' && targetLang === defaultLocale ? `/${rest}` : `/${targetLang}/${rest}`
175
- }
176
-
177
184
  private onSelect = (event: Event) => {
178
185
  const target = event.target as HTMLElement
179
186
  const button = target.closest<HTMLButtonElement>('[data-ls-item]')
@@ -189,9 +196,12 @@ const hasChoice = SUPPORTED_LOCALES.length > 1
189
196
  // must stay free of Set-Cookie so the HTML edge cache can serve it), so
190
197
  // the switcher is the place where the preference is born. Not httpOnly on
191
198
  // either side (cookie-storage.ts): the server validates it, never trusts it.
192
- document.cookie = `${cookieName}=${lang}; path=/; max-age=${cookieMaxAge}; SameSite=Lax; Secure`
199
+ document.cookie = `${cookieName}=${lang}; path=/; max-age=${cookieMaxAge}; SameSite=Lax${location.protocol === 'https:' ? '; Secure' : ''}`
193
200
 
194
- window.location.href = this.localizedPath(window.location.pathname, lang)
201
+ // The query string goes along (`?utm=…`); the hash names a part of this page, not the other.
202
+ const { currentLang, routing, defaultLocale } = this.dataset
203
+ window.location.href =
204
+ localizedPathOf(window.location.pathname, lang, currentLang, routing, defaultLocale) + window.location.search
195
205
  }
196
206
 
197
207
  private onDocumentClick = (event: MouseEvent) => {
@@ -38,6 +38,8 @@ export function edgekitsI18n(config: I18nConfig, options: EdgekitsI18nOptions =
38
38
  astroConfig.root,
39
39
  )
40
40
 
41
+ assertAstroRouting(astroConfig)
42
+
41
43
  // Astro sets `Astro.currentLocale` (read by `resolveLocale`) only with an `i18n`
42
44
  // block: set it when the project has none, check it when it has one.
43
45
  if (astroConfig.i18n) assertAstroI18n(astroConfig.i18n, i18nConfig)
@@ -56,6 +58,10 @@ export function edgekitsI18n(config: I18nConfig, options: EdgekitsI18nOptions =
56
58
  addMiddleware({ entrypoint: '@edgekits/astro-i18n/middleware', order: 'pre' })
57
59
  }
58
60
  },
61
+ 'astro:routes:resolved': ({ routes }) => {
62
+ assertNoPrerendered404(routes)
63
+ if (i18nConfig.routing === 'prefix-except-default') assertNoPrerenderedLangRoute(routes)
64
+ },
59
65
  'astro:config:done': ({ injectTypes }) => {
60
66
  injectTypes({ filename: 'locales.d.ts', content: localesDeclaration(Object.keys(i18nConfig.languages)) })
61
67
  // The shipped runtime imports the virtual modules; a consumer's program needs their
@@ -104,6 +110,68 @@ export function localesDeclaration(locales: readonly string[]): string {
104
110
  ].join('\n')
105
111
  }
106
112
 
113
+ /**
114
+ * The Astro settings the middleware cannot work under, refused at config time:
115
+ * - `trailingSlash: 'never'`: Astro removes a trailing slash before the middleware runs
116
+ * (astro/dist/core/routing/handler.js:105-107, Astro 7.4.2), and the middleware adds it
117
+ * back: every page would redirect forever. `'ignore'` (the default) and `'always'` work.
118
+ * - a `base` other than `/`: the middleware's paths and redirects assume the site's root.
119
+ */
120
+ export function assertAstroRouting(astroConfig: Pick<AstroConfig, 'trailingSlash' | 'base'>): void {
121
+ if (astroConfig.trailingSlash === 'never') {
122
+ throw new Error(
123
+ "[@edgekits/astro-i18n] `trailingSlash: 'never'` is not supported: Astro removes the trailing slash " +
124
+ 'before the middleware runs, and the middleware adds it back to every page, so each request would ' +
125
+ "redirect forever. Use 'ignore' (the default) or 'always'.",
126
+ )
127
+ }
128
+ if (astroConfig.base !== undefined && astroConfig.base !== '/') {
129
+ throw new Error(
130
+ `[@edgekits/astro-i18n] \`base: ${JSON.stringify(astroConfig.base)}\` is not supported: the middleware's ` +
131
+ 'locale paths and redirects are built from the site root. Serve the site at the root of its host ' +
132
+ '(remove `base`, or set it to "/").',
133
+ )
134
+ }
135
+ }
136
+
137
+ /**
138
+ * In 'prefix-except-default' the default locale's pages render through a rewrite to
139
+ * `/<default>/…`, and Astro refuses a rewrite from an on-demand request into a
140
+ * prerendered route (ForbiddenRewrite, a 500: astro/dist/core/rewrites/handler.js:29-33,
141
+ * Astro 7.4.2). Every `[lang]` route must be on-demand.
142
+ */
143
+ export function assertNoPrerenderedLangRoute(routes: ReadonlyArray<{ pattern: string; isPrerendered: boolean }>): void {
144
+ const prerendered = routes.find(
145
+ (route) => route.isPrerendered && (route.pattern === '/[lang]' || route.pattern.startsWith('/[lang]/')),
146
+ )
147
+ if (prerendered) {
148
+ throw new Error(
149
+ `[@edgekits/astro-i18n] ${prerendered.pattern} is prerendered: with routing 'prefix-except-default' the ` +
150
+ "default locale's pages render through a rewrite to /<default>/…, and Astro refuses a rewrite into a " +
151
+ "prerendered route (ForbiddenRewrite). Remove `export const prerender = true` from that page, or use routing 'prefix'.",
152
+ )
153
+ }
154
+ }
155
+
156
+ /**
157
+ * A prerendered 404 page, in either mode: Astro serves it as a static file without the
158
+ * middleware (no locale in `locals`), and a page's `Astro.rewrite('/404')` into it from an
159
+ * on-demand request is a ForbiddenRewrite, a 500 (astro/dist/core/rewrites/handler.js:29-33,
160
+ * Astro 7.4.2). Only the project's own route counts: Astro's built-in 404 is on-demand.
161
+ */
162
+ export function assertNoPrerendered404(
163
+ routes: ReadonlyArray<{ pattern: string; isPrerendered: boolean; origin?: string; entrypoint?: string }>,
164
+ ): void {
165
+ const page = routes.find((route) => route.pattern === '/404' && route.isPrerendered && route.origin === 'project')
166
+ if (page) {
167
+ throw new Error(
168
+ `[@edgekits/astro-i18n] ${page.entrypoint ?? 'src/pages/404.astro'} is prerendered: Astro serves a prerendered ` +
169
+ "404 page without the middleware, so it never gets the visitor's locale, and a page's `Astro.rewrite('/404')` " +
170
+ 'into it is refused (ForbiddenRewrite, a 500). Remove `export const prerender = true` from the 404 page.',
171
+ )
172
+ }
173
+ }
174
+
107
175
  /**
108
176
  * The Astro `i18n` block set when the project has none: locales and default from the
109
177
  * config; routed as locale-prefixed in both modes (the middleware redirects `/` itself in
@@ -111,10 +179,10 @@ export function localesDeclaration(locales: readonly string[]): string {
111
179
  * re-validate what `updateConfig` merges, so its schema defaults never fill a partial block.
112
180
  *
113
181
  * `prefixDefaultLocale: true` in 'prefix-except-default' too: Astro's own i18n check runs
114
- * after the middleware, on the rewritten URL (astro/dist/core/routing/handler.js:128-130),
115
- * and with `prefixDefaultLocale: false` the strategy `pathname-prefix-other-locales`
116
- * answers notFound to any path carrying the default locale (astro/dist/i18n/router.js:
117
- * 104-121) — the rewritten `/<default>/about/` is exactly that. With `true` the check
182
+ * after the middleware, on the rewritten URL (astro/dist/core/routing/handler.js:167-169,
183
+ * Astro 7.4.2), and with `prefixDefaultLocale: false` the strategy
184
+ * `pathname-prefix-other-locales` answers notFound to any path carrying the default locale
185
+ * (astro/dist/i18n/router.js:107-124) — the rewritten `/<default>/about/` is exactly that. With `true` the check
118
186
  * passes and `Astro.currentLocale` works on root pages.
119
187
  */
120
188
  export function astroI18n(config: I18nConfig): NonNullable<AstroConfig['i18n']> {
@@ -1,5 +1,6 @@
1
1
  import type { AstroCookies, MiddlewareHandler } from 'astro'
2
2
  import {
3
+ isFilePath,
3
4
  localizedPath,
4
5
  MARKDOWN_PATH,
5
6
  markdownRedirectTarget,
@@ -12,12 +13,12 @@ import { DEFAULT_LOCALE, type Locale } from '../constants'
12
13
  import { readCookieLang, setCookieLang, type CookieLang } from '../cookie-storage'
13
14
  import { resolveLocaleForTranslations } from '../resolve-locale'
14
15
 
15
- // Regex to identify public static assets (images, fonts, scripts, etc.)
16
- // These files should bypass i18n routing and locale detection.
17
- const PUBLIC_FILE_REGEX = /\.(ico|png|jpg|jpeg|svg|webp|gif|css|js|map|txt|xml|json|woff2?|avif|md)$/i
18
-
19
- // List of URL prefixes that should be ignored by the i18n middleware.
20
- // Includes API endpoints, internal Astro paths, and standard static routes.
16
+ // List of URL prefixes that should be ignored by the i18n middleware, each matched on a
17
+ // segment boundary (`/api` and `/api/…`, never `/apis/`).
18
+ // Includes API endpoints, internal Astro paths, and standard static routes. Server islands
19
+ // are fetched from `<base>/_server-islands/<component>` (astro/dist/runtime/server/render/
20
+ // server-islands.js:153), a URL without a locale: they render with the default locale in
21
+ // `locals` unless the page passes the locale as a prop.
21
22
  // The Vite dev-server prefixes (/@vite, /@id, /@fs, /src, /node_modules) make
22
23
  // dev survive `run_worker_first: ["/*"]` being left on: without them every
23
24
  // dev-module request fell through to the locale logic, got redirected to
@@ -29,7 +30,9 @@ const IGNORED_PREFIXES = [
29
30
  '/_astro',
30
31
  '/_image',
31
32
  '/_actions',
32
- '/favicon',
33
+ '/_server-islands',
34
+ '/.well-known',
35
+ '/favicon.ico',
33
36
  '/@vite',
34
37
  '/@id',
35
38
  '/@fs',
@@ -37,6 +40,19 @@ const IGNORED_PREFIXES = [
37
40
  '/node_modules',
38
41
  ]
39
42
 
43
+ // The error pages, at the root in both modes. Passed through untouched when requested
44
+ // directly or through a page's `Astro.rewrite('/404')`, which re-runs the middleware on
45
+ // `/404` (core/rewrites/handler.js:67-71). Astro's own `isRoute404` accepts `/404/` as well
46
+ // (core/routing/internal/route-errors.js).
47
+ const ERROR_PAGE = /^\/(404|500)\/?$/
48
+
49
+ // When a route answers an empty 404 or 500, Astro renders the error page and runs the
50
+ // middleware again for it with the ORIGINAL request (astro/dist/core/errors/
51
+ // default-handler.js:68-80): the visitor's pathname, the error page's route pattern. The
52
+ // locals the first pass set are still there, which tells it from a first pass on an
53
+ // unmatched path (also routed to `/404`, with no locals yet).
54
+ const ERROR_ROUTES = ['/404', '/500']
55
+
40
56
  // Extracts the context type from Astro's MiddlewareHandler for cleaner function signatures.
41
57
  type I18nMiddlewareContext = Parameters<MiddlewareHandler>[0]
42
58
 
@@ -72,15 +88,30 @@ export type I18nMiddlewareDeps = {
72
88
  // dev and production alike: see negotiatedRedirect(). A browser keeps a 301 and never asks
73
89
  // the server again, so a language chosen later in the switcher would be bypassed on the
74
90
  // next visit to `/`, and the URL-wins cookie sync below would then overwrite that choice.
75
- const REDIRECT_STATUS = import.meta.env?.DEV ? 302 : 301
91
+ //
92
+ // Both kinds, in both routing modes, keep the query string (`?utm=…`, `?sent=1`).
93
+ //
94
+ // A request other than GET or HEAD (a form POST) keeps its method and body across the hop:
95
+ // 308 for a permanent normalisation (307 under `astro dev`), 307 for a negotiated one.
96
+ // A 301 or 302 would let the browser resend it as a GET without the body.
97
+ const IS_DEV = !!import.meta.env?.DEV
98
+ const REDIRECT_STATUS = IS_DEV ? 302 : 301
99
+
100
+ const keepsMethod = (request: Request) => request.method !== 'GET' && request.method !== 'HEAD'
101
+
102
+ // The status of a deterministic normalisation for this request.
103
+ function permanentStatus(request: Request): 301 | 302 | 307 | 308 {
104
+ if (!keepsMethod(request)) return REDIRECT_STATUS
105
+ return IS_DEV ? 307 : 308
106
+ }
76
107
 
77
108
  // A redirect whose target depends on the visitor: temporary, never stored by a shared
78
109
  // cache, and keyed on the request headers that chose it (the country is not a request
79
110
  // header, hence `no-store` rather than relying on Vary alone). `vary` names only the
80
111
  // headers that were consulted: the root of 'prefix-except-default' reads the cookie alone.
81
- function negotiatedRedirect(target: string, vary = 'Cookie, Accept-Language'): Response {
112
+ function negotiatedRedirect(request: Request, target: string, vary = 'Cookie, Accept-Language'): Response {
82
113
  return new Response(null, {
83
- status: 302,
114
+ status: keepsMethod(request) ? 307 : 302,
84
115
  headers: {
85
116
  Location: target,
86
117
  'Cache-Control': 'private, no-store',
@@ -89,12 +120,58 @@ function negotiatedRedirect(target: string, vary = 'Cookie, Accept-Language'): R
89
120
  })
90
121
  }
91
122
 
92
- // Determines if the current request path matches static assets or ignored prefixes.
93
- // Returns true to skip i18n processing for this request.
123
+ // Edits a response's headers in place, or on a copy when they are immutable (a response
124
+ // from `fetch()`).
125
+ function withHeaders(response: Response, edit: (headers: Headers) => void): Response {
126
+ try {
127
+ edit(response.headers)
128
+ return response
129
+ } catch {
130
+ const copy = new Response(response.body, response)
131
+ edit(copy.headers)
132
+ return copy
133
+ }
134
+ }
135
+
136
+ // `Vary: Cookie` added to whatever the response varies on already.
137
+ function varyOnCookie(headers: Headers): void {
138
+ const vary = headers.get('Vary')
139
+ if (!vary) return headers.set('Vary', 'Cookie')
140
+ const fields = vary.split(',').map((field) => field.trim().toLowerCase())
141
+ if (!fields.includes('*') && !fields.includes('cookie')) headers.set('Vary', `${vary}, Cookie`)
142
+ }
143
+
144
+ // A response that sets the lang cookie is for one visitor: `private` replaces `public` and
145
+ // `s-maxage` (a shared cache's directives); every other directive, `no-store` included, stays.
146
+ function makePrivate(headers: Headers): void {
147
+ const kept = (headers.get('Cache-Control') ?? '')
148
+ .split(',')
149
+ .map((directive) => directive.trim())
150
+ .filter((directive) => directive !== '' && !/^(public|private|s-maxage=.*)$/i.test(directive))
151
+ headers.set('Cache-Control', ['private', ...kept].join(', '))
152
+ }
153
+
154
+ const isIgnoredPrefix = (pathname: string) =>
155
+ IGNORED_PREFIXES.some((p) => pathname === p || pathname.startsWith(`${p}/`))
156
+
157
+ // The project's localized routes live under `src/pages/[lang]/`. `context.routePattern` is
158
+ // the route Astro matched for the original URL, before any rewrite: a locale-less
159
+ // `/rss.xml` matches `/[lang]` (lang = "rss.xml") or nothing (the 404 route), never a
160
+ // root route of its own, which `/sitemap.xml` does.
161
+ const isLangRoute = (routePattern: string | undefined) =>
162
+ routePattern === '/[lang]' || !!routePattern?.startsWith('/[lang]/')
163
+
164
+ // The pattern of the route Astro falls back to when nothing matched (a custom `404.astro`
165
+ // or its built-in one): astro/dist/core/routing/internal/astro-designed-error-pages.js.
166
+ const NOT_FOUND_ROUTE = '/404'
167
+
168
+ // Determines if the current request path is a file (any extension that starts with a
169
+ // letter: the core's isFilePath) or an ignored prefix. Returns true to skip i18n
170
+ // processing for this request.
94
171
  function shouldBypassI18n(pathname: string): boolean {
95
- if (PUBLIC_FILE_REGEX.test(pathname)) return true
96
- if (IGNORED_PREFIXES.some((p) => pathname.startsWith(p))) return true
97
- // Markdown endpoints: /en/blog/slug.md/ (with or without trailing slash)
172
+ if (isFilePath(pathname)) return true
173
+ if (isIgnoredPrefix(pathname)) return true
174
+ // Markdown endpoints in the `.md/` form (`/en/blog/slug.md/`); `.md` is a file path
98
175
  if (MARKDOWN_PATH.test(pathname)) return true
99
176
  return false
100
177
  }
@@ -137,14 +214,15 @@ export function createI18nMiddleware(deps: I18nMiddlewareDeps): MiddlewareHandle
137
214
 
138
215
  // The `.md` redirect target, or null. 'prefix': the core's markdown-path.ts as is.
139
216
  // 'prefix-except-default': the same target without the default locale's prefix (through
140
- // localizedPath, minus the trailing slash a `.md` path never has); a path already at
141
- // `/<default>/…` is redirected too, since that is no longer canonical.
217
+ // localizedPath, which keeps a file path without a trailing slash; the `.md/` form loses
218
+ // its slash first); a path already at `/<default>/…` is redirected too, since that is no
219
+ // longer canonical.
142
220
  function markdownTarget(pathname: string): string | null {
143
221
  const target = markdownRedirectTarget(pathname, { isLocale, markdownLocale: deps.markdownLocale })
144
222
  if (deps.routing === 'prefix') return target
145
223
  if (!deps.markdownLocale || !MARKDOWN_PATH.test(pathname)) return null
146
224
 
147
- const localized = localizedPath(deps.markdownLocale, target ?? pathname, pathOptions).slice(0, -1)
225
+ const localized = localizedPath(deps.markdownLocale, (target ?? pathname).replace(/\/+$/, ''), pathOptions)
148
226
  return localized === pathname ? null : localized
149
227
  }
150
228
 
@@ -155,7 +233,8 @@ export function createI18nMiddleware(deps: I18nMiddlewareDeps): MiddlewareHandle
155
233
  urlLocale: Locale,
156
234
  segments: string[],
157
235
  ): Promise<Response> {
158
- const pathname = new URL(context.request.url).pathname
236
+ const url = new URL(context.request.url)
237
+ const pathname = url.pathname
159
238
  // Keep the lang cookie in sync with the URL locale — but ONLY update an
160
239
  // EXISTING cookie that differs or does not parse. Rationale (HTML edge cache, P1/P2):
161
240
  // - The old unconditional set emitted `Set-Cookie` on EVERY localized
@@ -171,7 +250,8 @@ export function createI18nMiddleware(deps: I18nMiddlewareDeps): MiddlewareHandle
171
250
  // on the same host, a locale this site does not have) is corrected to the
172
251
  // URL locale too, so a stale value never outlives the next request.
173
252
  const cookie = deps.readCookieLang(context.cookies)
174
- if (cookie.present && cookie.locale !== urlLocale) {
253
+ const writesCookie = cookie.present && cookie.locale !== urlLocale
254
+ if (writesCookie) {
175
255
  deps.setCookieLang(context.cookies, urlLocale)
176
256
  }
177
257
  // Set uiLocale to Astro locals
@@ -182,11 +262,13 @@ export function createI18nMiddleware(deps: I18nMiddlewareDeps): MiddlewareHandle
182
262
 
183
263
  // Normalize trailing slash and structure
184
264
  const normalized = buildLocalizedPath(urlLocale, segments.slice(1))
185
- if (pathname !== normalized) {
186
- return context.redirect(normalized, REDIRECT_STATUS)
187
- }
265
+ const response =
266
+ pathname !== normalized
267
+ ? context.redirect(normalized + url.search, permanentStatus(context.request))
268
+ : await next()
188
269
 
189
- return await next()
270
+ // The Set-Cookie makes the response this visitor's: no shared cache may keep it.
271
+ return writesCookie ? withHeaders(response, makePrivate) : response
190
272
  }
191
273
 
192
274
  // 'prefix-except-default': the default locale at the root, the others under their prefix.
@@ -196,12 +278,15 @@ export function createI18nMiddleware(deps: I18nMiddlewareDeps): MiddlewareHandle
196
278
  segments: string[],
197
279
  firstSegment: string | null,
198
280
  ): Promise<Response> {
199
- const pathname = new URL(context.request.url).pathname
281
+ const { pathname, search } = new URL(context.request.url)
200
282
  const urlLocale = firstSegment ? deps.parseLocale(firstSegment) : null
201
283
 
202
284
  // The default locale's prefix is not canonical: one permanent hop to the path without it.
203
285
  if (urlLocale === deps.defaultLocale) {
204
- return context.redirect(localizedPath(deps.defaultLocale, pathname, pathOptions), REDIRECT_STATUS)
286
+ return context.redirect(
287
+ localizedPath(deps.defaultLocale, pathname, pathOptions) + search,
288
+ permanentStatus(context.request),
289
+ )
205
290
  }
206
291
 
207
292
  if (urlLocale) {
@@ -215,24 +300,37 @@ export function createI18nMiddleware(deps: I18nMiddlewareDeps): MiddlewareHandle
215
300
  context.locals.translationLocale = deps.resolveLocaleForTranslations(deps.defaultLocale)
216
301
 
217
302
  const cookie = deps.readCookieLang(context.cookies)
303
+ let writesCookie = false
218
304
  if (cookie.present && cookie.locale !== deps.defaultLocale) {
219
305
  if (cookie.locale) {
220
- return negotiatedRedirect(localizedPath(cookie.locale, pathname, pathOptions), 'Cookie')
306
+ return negotiatedRedirect(
307
+ context.request,
308
+ localizedPath(cookie.locale, pathname, pathOptions) + search,
309
+ 'Cookie',
310
+ )
221
311
  }
222
312
  // Present but invalid: corrected to the URL's locale, as serveLocalized does.
223
313
  deps.setCookieLang(context.cookies, deps.defaultLocale)
314
+ writesCookie = true
224
315
  }
225
316
 
226
317
  const normalized = localizedPath(deps.defaultLocale, pathname, pathOptions)
318
+ let response: Response
227
319
  if (pathname !== normalized) {
228
- return context.redirect(normalized, REDIRECT_STATUS)
320
+ response = context.redirect(normalized + search, permanentStatus(context.request))
321
+ } else {
322
+ // The project's pages live under `src/pages/[lang]/`: render `/<default>/…` while the
323
+ // URL stays. `next(path)` rewrites without running the middleware again
324
+ // (astro/dist/core/middleware/astro-middleware.js:33-40, Astro 7.4.2) and keeps
325
+ // `locals`; `context.rewrite()` would re-run it (core/rewrites/handler.js:67-71).
326
+ // The rewritten URL is built from the string alone (core/routing/rewrite.js:22), and
327
+ // `context.url`, Astro.url and Actions' `?_action=` read it: the query goes in too.
328
+ // The page depends on the lang cookie (a valid other locale redirects away), so it
329
+ // varies on it.
330
+ response = withHeaders(await next(`/${deps.defaultLocale}${pathname}${search}`), varyOnCookie)
229
331
  }
230
332
 
231
- // The project's pages live under `src/pages/[lang]/`: render `/<default>/…` while the
232
- // URL stays. `next(path)` rewrites without running the middleware again
233
- // (astro/dist/core/middleware/astro-middleware.js:33-40) and keeps `locals`;
234
- // `context.rewrite()` would re-run it (astro/dist/core/rewrites/handler.js:67-71).
235
- return await next(`/${deps.defaultLocale}${pathname}`)
333
+ return writesCookie ? withHeaders(response, makePrivate) : response
236
334
  }
237
335
 
238
336
  // Main i18n middleware handler. Intercepts incoming requests, manages routing,
@@ -245,6 +343,27 @@ export function createI18nMiddleware(deps: I18nMiddlewareDeps): MiddlewareHandle
245
343
  const segments = pathname.split('/').filter(Boolean) // "/es/about/" -> ["es", "about"]
246
344
  const firstSegment = segments[0] ?? null
247
345
 
346
+ // The error pages: no redirect, no rewrite. On a re-entry for an error page the locals
347
+ // an earlier pass set (the visitor's locale) stay; a direct request gets the default.
348
+ const reentry = ERROR_ROUTES.includes(context.routePattern) && context.locals.uiLocale !== undefined
349
+ if (reentry || ERROR_PAGE.test(pathname)) {
350
+ if (context.locals.uiLocale === undefined) {
351
+ context.locals.uiLocale = deps.defaultLocale
352
+ context.locals.translationLocale = deps.resolveLocaleForTranslations(deps.defaultLocale)
353
+ }
354
+ return await next()
355
+ }
356
+
357
+ // A locale segment in another case (`/DE/…`, `/pt-BR/…`): one permanent hop to the
358
+ // canonical lower-case path (without the default's prefix in 'prefix-except-default').
359
+ if (firstSegment && !isLocale(firstSegment) && isLocale(firstSegment.toLowerCase())) {
360
+ const lowered = `/${[firstSegment.toLowerCase(), ...segments.slice(1)].join('/')}${pathname.endsWith('/') ? '/' : ''}`
361
+ return context.redirect(
362
+ localizedPath(firstSegment.toLowerCase(), lowered, pathOptions) + url.search,
363
+ permanentStatus(context.request),
364
+ )
365
+ }
366
+
248
367
  // 2. SAFETY NET (For static file 404 pages)
249
368
  // Attempt to infer the locale from the URL, or fallback to the default.
250
369
  let safeLocale = deps.defaultLocale
@@ -265,24 +384,45 @@ export function createI18nMiddleware(deps: I18nMiddlewareDeps): MiddlewareHandle
265
384
  // (the core's markdown-path.ts). Without `markdownLocale` they pass through untouched.
266
385
  const target = markdownTarget(pathname)
267
386
  if (target) {
268
- return context.redirect(target, REDIRECT_STATUS)
387
+ return context.redirect(target + url.search, permanentStatus(context.request))
269
388
  }
270
389
 
271
- // 'prefix-except-default': a `.md` path without a locale segment belongs to the
272
- // default locale, whose endpoint lives under `[lang]`.
273
- const rootMarkdown =
274
- deps.routing !== 'prefix' && MARKDOWN_PATH.test(pathname) && (!firstSegment || !isLocale(firstSegment))
275
- const response = rootMarkdown ? await next(`/${deps.defaultLocale}${pathname}`) : await next()
390
+ // 'prefix-except-default': the default locale's `[lang]` routes answer at the root,
391
+ // whatever the extension. A `.md` path without a locale segment always belongs to one;
392
+ // any other path outside the ignored prefixes when Astro matched it to a `[lang]`
393
+ // route or to none, while a root route of its own (`/sitemap.xml`) is served as is.
394
+ // The `/<default>/…` form of a `[lang]` route moves to the root, as a page's does
395
+ // (`.md` excepted: markdownTarget() above decides those).
396
+ let rewrite: string | undefined
397
+ if (deps.routing !== 'prefix') {
398
+ const markdown = MARKDOWN_PATH.test(pathname)
399
+ // Built from the path's segments (localizedPath), never by slicing the pathname:
400
+ // `/en//evil.com/x.png` must not become the protocol-relative `//evil.com/x.png`.
401
+ if (firstSegment === deps.defaultLocale && !markdown && isLangRoute(context.routePattern)) {
402
+ return context.redirect(
403
+ localizedPath(deps.defaultLocale, pathname, pathOptions) + url.search,
404
+ permanentStatus(context.request),
405
+ )
406
+ }
407
+ const langRouteOrNone = isLangRoute(context.routePattern) || context.routePattern === NOT_FOUND_ROUTE
408
+ if (
409
+ (!firstSegment || !isLocale(firstSegment)) &&
410
+ (markdown || (langRouteOrNone && !isIgnoredPrefix(pathname)))
411
+ ) {
412
+ rewrite = `/${deps.defaultLocale}${pathname}${url.search}`
413
+ }
414
+ }
415
+ const response = rewrite ? await next(rewrite) : await next()
276
416
 
277
417
  const contentType = response.headers.get('content-type') || ''
278
418
  const isHtml = contentType.includes('text/html')
279
- const isRedirect = response.status >= 300 && response.status < 400
280
419
 
281
- // If Astro attempts to return HTML for a static/API request (and it's not a redirect),
282
- // it means the file wasn't found and Astro rendered a fallback page (404.astro or catch-all).
283
- // We strictly intercept this and return a lightweight text-based 404 instead.
420
+ // If Astro answers a static/API request with a 404 HTML page, the file wasn't found
421
+ // and Astro rendered its fallback (404.astro). We intercept this and return a
422
+ // lightweight text-based 404 instead: an HTML response with any other status (a
423
+ // server island, an endpoint that renders HTML) is the route's own answer and passes.
284
424
  // Why? To mitigate Directory Bruteforcing / Fuzzing attacks and save resources.
285
- if (isHtml && !isRedirect) {
425
+ if (isHtml && response.status === 404) {
286
426
  // Aggressively save Worker CPU cycles for missing static files
287
427
  return new Response('Not found', {
288
428
  status: 404,
@@ -303,7 +443,7 @@ export function createI18nMiddleware(deps: I18nMiddlewareDeps): MiddlewareHandle
303
443
  if (!firstSegment) {
304
444
  const target = buildLocalizedPath(fallbackLocale, [])
305
445
  if (pathname !== target) {
306
- return negotiatedRedirect(target)
446
+ return negotiatedRedirect(context.request, target + url.search)
307
447
  }
308
448
  return await next()
309
449
  }
@@ -319,7 +459,7 @@ export function createI18nMiddleware(deps: I18nMiddlewareDeps): MiddlewareHandle
319
459
  const target = buildLocalizedPath(fallbackLocale, segments)
320
460
 
321
461
  if (pathname !== target) {
322
- return negotiatedRedirect(target)
462
+ return negotiatedRedirect(context.request, target + url.search)
323
463
  }
324
464
 
325
465
  return await next()
@@ -6,6 +6,6 @@ import { i18n } from './instance'
6
6
  * Purge API.
7
7
  *
8
8
  * Formats: <keyPrefix>:<namespace>:<locale> and
9
- * https://<keyPrefix>/<encodeURIComponent(i18n:<locale>:<namespace>)>
9
+ * https://<keyPrefix>/<encodeURIComponent(i18n:v2:<locale>:<namespace>)>
10
10
  */
11
11
  export const { buildTranslationKvKey, buildTranslationCacheUrl, buildTranslationCacheRequest } = i18n