@edgekits/astro-i18n 0.2.2 → 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,48 @@ 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
+
7
49
  ## 0.2.2 — 2026-10-27
8
50
 
9
51
  ### Fixed
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
 
@@ -335,9 +373,15 @@ In `'prefix-except-default'` the default locale has no prefix here either: with
335
373
  In both modes, rewrites and redirects keep the query string (`/?utm=x` redirects to
336
374
  `/en/?utm=x` in `'prefix'`, and renders `/` with `?utm=x` in `'prefix-except-default'`).
337
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
+
338
381
  In `'prefix-except-default'`, build canonical and hreflang URLs from `Astro.originPathname`
339
382
  (the URL the visitor requested; `Astro.url` is the rewritten `/<defaultLocale>/…`) through
340
- `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
341
385
  here, so `localizedPath` is the one to use:
342
386
 
343
387
  ```astro
@@ -352,9 +396,9 @@ const options = {
352
396
  const path = Astro.originPathname
353
397
  ---
354
398
 
355
- <link rel="canonical" href={new URL(localizedPath(Astro.locals.uiLocale, path, options), Astro.site)} />
356
- {SUPPORTED_LOCALES.map((locale) => <link rel="alternate" hreflang={toIetfTag(locale)} href={new URL(localizedPath(locale, path, options), Astro.site)} />)}
357
- <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)} />
358
402
  ```
359
403
 
360
404
  Two limits:
@@ -363,8 +407,46 @@ Two limits:
363
407
  page at `/de/` (a content slug `de`) cannot exist next to the German site.
364
408
  - A shared HTML cache in front of the site must key root paths on the lang cookie, or skip
365
409
  caching requests that carry one: the same URL renders for a cookie-less visitor and
366
- redirects one whose cookie names another locale. Under `'prefix'`, `/en/` is in the same
367
- 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.
368
450
 
369
451
  ### Styling the components
370
452
 
@@ -487,9 +569,9 @@ cache.
487
569
 
488
570
  **Workers for Platforms, untrusted namespace.** `request.cf` is absent, so the country step of
489
571
  the locale cascade never runs. `caches.default` is disabled, so every render reads its
490
- namespaces from KV in one bulk read: the output is correct, only the cache layer and its KV
491
- savings are gone. Set `I18N_CACHE=off` so the behaviour is explicit, and expect one bulk KV read
492
- 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).
493
575
 
494
576
  <!-- skill-guide:start -->
495
577
 
@@ -575,11 +657,12 @@ npx skills add EdgeKits/i18n --skill astro-i18n
575
657
 
576
658
  ### Prerequisites
577
659
 
578
- - 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`.
579
661
  For a new site: `npm create astro@latest`, then `npx astro add cloudflare`, then set
580
662
  `output: 'server'` in `astro.config`.
581
- - 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
582
664
  package's CLI runs the project's own wrangler, never a global one.
665
+ - Node `>=22.23.4`.
583
666
  - A git repository (`git init`): the generated files and the purge baseline
584
667
  (`.i18n-hashes.json`) are committed, and `migrate` checks the baseline against git (outside
585
668
  a repository it skips that check, with a warning).
@@ -687,12 +770,12 @@ A locale without a `countryCode` shows its name alone. A `countryCode` whose SVG
687
770
 
688
771
  Before it reports back, the skill runs its adoption checklist: the config matches your answers
689
772
  and every locale code is lowercase; `seed` writes the generated files and leaves
690
- `.i18n-hashes.json` alone; `astro check` passes; under `astro dev`, `/` redirects to a locale,
691
- 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
692
775
  first segment such as `/zz/` is prefixed rather than crashing, `<html lang>` reads `pt-BR` on a
693
776
  `pt-br` page, hreflang tags cover every locale plus `x-default`, and the switcher changes the
694
777
  URL and sets the language cookie. It also checks that no page is left at `src/pages/*.astro`
695
- and that every `countryCode` has its SVG.
778
+ (the error pages `404.astro` and `500.astro` excepted) and that every `countryCode` has its SVG.
696
779
 
697
780
  ### Everyday work
698
781
 
@@ -714,7 +797,7 @@ seed`. A key that exists only in another locale is not typed.
714
797
 
715
798
  `.i18n-hashes.json` records what production KV holds. Only a successful `migrate` writes it,
716
799
  it is committed right after, and nothing else edits it. `migrate` refuses to run while that
717
- file has uncommitted changes.
800
+ file's baseline for the KV namespace it writes differs from the committed one.
718
801
 
719
802
  ### Forms and SEO
720
803
 
@@ -758,13 +841,15 @@ canonical at the original instead of using `noindex`.
758
841
  run since. Add it to the default locale and run `seed` (or `bundle`); if the editor still
759
842
  shows the error, restart its TypeScript server.
760
843
  - **`Cannot find module 'cloudflare:workers'` in `tsc`.** Run `npx wrangler types`.
761
- - **`migrate` refuses to run.** `.i18n-hashes.json` has uncommitted changes. If the last
762
- `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
763
846
  `git checkout .i18n-hashes.json` and run `migrate` again. Never edit or delete it to get
764
847
  past the check.
765
- - **`migrate` updated KV but the site shows old copy.** The purge was skipped: the zone is not
766
- proxied, or the zone ID or token is missing. Fix it and run `migrate` again; it retries the
767
- 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.
768
853
  - **npm 12: `esbuild` or `workerd` did not install.** npm 12 blocks dependency install
769
854
  scripts by default and lists the skipped packages at the end of `npm install`. Both need
770
855
  theirs: run `npm install-scripts approve esbuild workerd`, then
@@ -798,13 +883,18 @@ under their own terms. EdgeKits runs no service behind this plugin.
798
883
 
799
884
  ## How it works
800
885
 
801
- A request reaches the middleware, which settles the URL's locale (redirecting when there is
802
- 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
803
890
  `fetchTranslations` for the namespaces it renders. Each namespace is looked up in the Workers
804
- Cache API under `https://<keyPrefix>/…`; the misses are read from KV in one batch, merged over
805
- the compiled default-locale fallbacks, returned, and written back to the cache in the
806
- background. Cached entries live for a year (`s-maxage=31536000, immutable`): `migrate` purges the ones whose content
807
- 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
808
898
  default locale's text instead of an error.
809
899
 
810
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.2",
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.2"
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
 
@@ -74,15 +90,28 @@ export type I18nMiddlewareDeps = {
74
90
  // next visit to `/`, and the URL-wins cookie sync below would then overwrite that choice.
75
91
  //
76
92
  // Both kinds, in both routing modes, keep the query string (`?utm=…`, `?sent=1`).
77
- const REDIRECT_STATUS = import.meta.env?.DEV ? 302 : 301
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
+ }
78
107
 
79
108
  // A redirect whose target depends on the visitor: temporary, never stored by a shared
80
109
  // cache, and keyed on the request headers that chose it (the country is not a request
81
110
  // header, hence `no-store` rather than relying on Vary alone). `vary` names only the
82
111
  // headers that were consulted: the root of 'prefix-except-default' reads the cookie alone.
83
- function negotiatedRedirect(target: string, vary = 'Cookie, Accept-Language'): Response {
112
+ function negotiatedRedirect(request: Request, target: string, vary = 'Cookie, Accept-Language'): Response {
84
113
  return new Response(null, {
85
- status: 302,
114
+ status: keepsMethod(request) ? 307 : 302,
86
115
  headers: {
87
116
  Location: target,
88
117
  'Cache-Control': 'private, no-store',
@@ -91,7 +120,39 @@ function negotiatedRedirect(target: string, vary = 'Cookie, Accept-Language'): R
91
120
  })
92
121
  }
93
122
 
94
- const isIgnoredPrefix = (pathname: string) => IGNORED_PREFIXES.some((p) => pathname.startsWith(p))
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}/`))
95
156
 
96
157
  // The project's localized routes live under `src/pages/[lang]/`. `context.routePattern` is
97
158
  // the route Astro matched for the original URL, before any rewrite: a locale-less
@@ -104,12 +165,13 @@ const isLangRoute = (routePattern: string | undefined) =>
104
165
  // or its built-in one): astro/dist/core/routing/internal/astro-designed-error-pages.js.
105
166
  const NOT_FOUND_ROUTE = '/404'
106
167
 
107
- // Determines if the current request path matches static assets or ignored prefixes.
108
- // Returns true to skip i18n processing for this request.
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.
109
171
  function shouldBypassI18n(pathname: string): boolean {
110
- if (PUBLIC_FILE_REGEX.test(pathname)) return true
172
+ if (isFilePath(pathname)) return true
111
173
  if (isIgnoredPrefix(pathname)) return true
112
- // Markdown endpoints: /en/blog/slug.md/ (with or without trailing slash)
174
+ // Markdown endpoints in the `.md/` form (`/en/blog/slug.md/`); `.md` is a file path
113
175
  if (MARKDOWN_PATH.test(pathname)) return true
114
176
  return false
115
177
  }
@@ -152,14 +214,15 @@ export function createI18nMiddleware(deps: I18nMiddlewareDeps): MiddlewareHandle
152
214
 
153
215
  // The `.md` redirect target, or null. 'prefix': the core's markdown-path.ts as is.
154
216
  // 'prefix-except-default': the same target without the default locale's prefix (through
155
- // localizedPath, minus the trailing slash a `.md` path never has); a path already at
156
- // `/<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.
157
220
  function markdownTarget(pathname: string): string | null {
158
221
  const target = markdownRedirectTarget(pathname, { isLocale, markdownLocale: deps.markdownLocale })
159
222
  if (deps.routing === 'prefix') return target
160
223
  if (!deps.markdownLocale || !MARKDOWN_PATH.test(pathname)) return null
161
224
 
162
- const localized = localizedPath(deps.markdownLocale, target ?? pathname, pathOptions).slice(0, -1)
225
+ const localized = localizedPath(deps.markdownLocale, (target ?? pathname).replace(/\/+$/, ''), pathOptions)
163
226
  return localized === pathname ? null : localized
164
227
  }
165
228
 
@@ -187,7 +250,8 @@ export function createI18nMiddleware(deps: I18nMiddlewareDeps): MiddlewareHandle
187
250
  // on the same host, a locale this site does not have) is corrected to the
188
251
  // URL locale too, so a stale value never outlives the next request.
189
252
  const cookie = deps.readCookieLang(context.cookies)
190
- if (cookie.present && cookie.locale !== urlLocale) {
253
+ const writesCookie = cookie.present && cookie.locale !== urlLocale
254
+ if (writesCookie) {
191
255
  deps.setCookieLang(context.cookies, urlLocale)
192
256
  }
193
257
  // Set uiLocale to Astro locals
@@ -198,11 +262,13 @@ export function createI18nMiddleware(deps: I18nMiddlewareDeps): MiddlewareHandle
198
262
 
199
263
  // Normalize trailing slash and structure
200
264
  const normalized = buildLocalizedPath(urlLocale, segments.slice(1))
201
- if (pathname !== normalized) {
202
- return context.redirect(normalized + url.search, REDIRECT_STATUS)
203
- }
265
+ const response =
266
+ pathname !== normalized
267
+ ? context.redirect(normalized + url.search, permanentStatus(context.request))
268
+ : await next()
204
269
 
205
- 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
206
272
  }
207
273
 
208
274
  // 'prefix-except-default': the default locale at the root, the others under their prefix.
@@ -217,7 +283,10 @@ export function createI18nMiddleware(deps: I18nMiddlewareDeps): MiddlewareHandle
217
283
 
218
284
  // The default locale's prefix is not canonical: one permanent hop to the path without it.
219
285
  if (urlLocale === deps.defaultLocale) {
220
- return context.redirect(localizedPath(deps.defaultLocale, pathname, pathOptions) + search, REDIRECT_STATUS)
286
+ return context.redirect(
287
+ localizedPath(deps.defaultLocale, pathname, pathOptions) + search,
288
+ permanentStatus(context.request),
289
+ )
221
290
  }
222
291
 
223
292
  if (urlLocale) {
@@ -231,26 +300,37 @@ export function createI18nMiddleware(deps: I18nMiddlewareDeps): MiddlewareHandle
231
300
  context.locals.translationLocale = deps.resolveLocaleForTranslations(deps.defaultLocale)
232
301
 
233
302
  const cookie = deps.readCookieLang(context.cookies)
303
+ let writesCookie = false
234
304
  if (cookie.present && cookie.locale !== deps.defaultLocale) {
235
305
  if (cookie.locale) {
236
- return negotiatedRedirect(localizedPath(cookie.locale, pathname, pathOptions) + search, 'Cookie')
306
+ return negotiatedRedirect(
307
+ context.request,
308
+ localizedPath(cookie.locale, pathname, pathOptions) + search,
309
+ 'Cookie',
310
+ )
237
311
  }
238
312
  // Present but invalid: corrected to the URL's locale, as serveLocalized does.
239
313
  deps.setCookieLang(context.cookies, deps.defaultLocale)
314
+ writesCookie = true
240
315
  }
241
316
 
242
317
  const normalized = localizedPath(deps.defaultLocale, pathname, pathOptions)
318
+ let response: Response
243
319
  if (pathname !== normalized) {
244
- return context.redirect(normalized + search, 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)
245
331
  }
246
332
 
247
- // The project's pages live under `src/pages/[lang]/`: render `/<default>/…` while the
248
- // URL stays. `next(path)` rewrites without running the middleware again
249
- // (astro/dist/core/middleware/astro-middleware.js:33-40) and keeps `locals`;
250
- // `context.rewrite()` would re-run it (astro/dist/core/rewrites/handler.js:67-71).
251
- // The rewritten URL is built from the string alone (core/routing/rewrite.js:22), and
252
- // `context.url`, Astro.url and Actions' `?_action=` read it: the query goes in too.
253
- return await next(`/${deps.defaultLocale}${pathname}${search}`)
333
+ return writesCookie ? withHeaders(response, makePrivate) : response
254
334
  }
255
335
 
256
336
  // Main i18n middleware handler. Intercepts incoming requests, manages routing,
@@ -263,6 +343,27 @@ export function createI18nMiddleware(deps: I18nMiddlewareDeps): MiddlewareHandle
263
343
  const segments = pathname.split('/').filter(Boolean) // "/es/about/" -> ["es", "about"]
264
344
  const firstSegment = segments[0] ?? null
265
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
+
266
367
  // 2. SAFETY NET (For static file 404 pages)
267
368
  // Attempt to infer the locale from the URL, or fallback to the default.
268
369
  let safeLocale = deps.defaultLocale
@@ -283,7 +384,7 @@ export function createI18nMiddleware(deps: I18nMiddlewareDeps): MiddlewareHandle
283
384
  // (the core's markdown-path.ts). Without `markdownLocale` they pass through untouched.
284
385
  const target = markdownTarget(pathname)
285
386
  if (target) {
286
- return context.redirect(target + url.search, REDIRECT_STATUS)
387
+ return context.redirect(target + url.search, permanentStatus(context.request))
287
388
  }
288
389
 
289
390
  // 'prefix-except-default': the default locale's `[lang]` routes answer at the root,
@@ -295,8 +396,13 @@ export function createI18nMiddleware(deps: I18nMiddlewareDeps): MiddlewareHandle
295
396
  let rewrite: string | undefined
296
397
  if (deps.routing !== 'prefix') {
297
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`.
298
401
  if (firstSegment === deps.defaultLocale && !markdown && isLangRoute(context.routePattern)) {
299
- return context.redirect(pathname.slice(deps.defaultLocale.length + 1) + url.search, REDIRECT_STATUS)
402
+ return context.redirect(
403
+ localizedPath(deps.defaultLocale, pathname, pathOptions) + url.search,
404
+ permanentStatus(context.request),
405
+ )
300
406
  }
301
407
  const langRouteOrNone = isLangRoute(context.routePattern) || context.routePattern === NOT_FOUND_ROUTE
302
408
  if (
@@ -310,13 +416,13 @@ export function createI18nMiddleware(deps: I18nMiddlewareDeps): MiddlewareHandle
310
416
 
311
417
  const contentType = response.headers.get('content-type') || ''
312
418
  const isHtml = contentType.includes('text/html')
313
- const isRedirect = response.status >= 300 && response.status < 400
314
419
 
315
- // If Astro attempts to return HTML for a static/API request (and it's not a redirect),
316
- // it means the file wasn't found and Astro rendered a fallback page (404.astro or catch-all).
317
- // 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.
318
424
  // Why? To mitigate Directory Bruteforcing / Fuzzing attacks and save resources.
319
- if (isHtml && !isRedirect) {
425
+ if (isHtml && response.status === 404) {
320
426
  // Aggressively save Worker CPU cycles for missing static files
321
427
  return new Response('Not found', {
322
428
  status: 404,
@@ -337,7 +443,7 @@ export function createI18nMiddleware(deps: I18nMiddlewareDeps): MiddlewareHandle
337
443
  if (!firstSegment) {
338
444
  const target = buildLocalizedPath(fallbackLocale, [])
339
445
  if (pathname !== target) {
340
- return negotiatedRedirect(target + url.search)
446
+ return negotiatedRedirect(context.request, target + url.search)
341
447
  }
342
448
  return await next()
343
449
  }
@@ -353,7 +459,7 @@ export function createI18nMiddleware(deps: I18nMiddlewareDeps): MiddlewareHandle
353
459
  const target = buildLocalizedPath(fallbackLocale, segments)
354
460
 
355
461
  if (pathname !== target) {
356
- return negotiatedRedirect(target + url.search)
462
+ return negotiatedRedirect(context.request, target + url.search)
357
463
  }
358
464
 
359
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