@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 +42 -0
- package/README.md +164 -74
- package/dist/integration/index.d.ts +34 -4
- package/dist/integration/index.js +57 -4
- package/package.json +9 -7
- package/src/components/LanguageSwitcher.astro +24 -14
- package/src/integration/index.ts +72 -4
- package/src/middleware/i18n.ts +148 -42
- package/src/translations-keys.ts +1 -1
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.
|
|
19
|
-
- wrangler `^4.
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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
|
|
193
|
-
`migrate`; the next `migrate` purges exactly the entries whose hash
|
|
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
|
|
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
|
|
199
|
-
|
|
200
|
-
|
|
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`).
|
|
205
|
-
|
|
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
|
-
|
|
270
|
-
cookie, then the `Accept-Language` header (each tag in `q` order, the full tag, then
|
|
271
|
-
language part, so `es-MX` reaches `es`; the first tag that names one of your `languages`
|
|
272
|
-
then, only when no tag matched, the visitor's country (`request.cf.country`) through
|
|
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
|
|
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={
|
|
356
|
-
{SUPPORTED_LOCALES.map((locale) => <link rel="alternate" hreflang={toIetfTag(locale)} href={
|
|
357
|
-
<link rel="alternate" hreflang="x-default" href={
|
|
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.
|
|
367
|
-
|
|
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
|
|
491
|
-
savings are gone. Set `I18N_CACHE=off` so the behaviour is explicit, and
|
|
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.
|
|
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.
|
|
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
|
|
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.**
|
|
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`
|
|
766
|
-
|
|
767
|
-
|
|
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 (
|
|
802
|
-
|
|
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
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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:
|
|
36
|
-
* and with `prefixDefaultLocale: false` the strategy
|
|
37
|
-
* answers notFound to any path carrying the default locale
|
|
38
|
-
*
|
|
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:
|
|
90
|
-
* and with `prefixDefaultLocale: false` the strategy
|
|
91
|
-
* answers notFound to any path carrying the default locale
|
|
92
|
-
*
|
|
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.
|
|
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.
|
|
60
|
+
"@edgekits/i18n-core": "0.3.0"
|
|
61
61
|
},
|
|
62
62
|
"peerDependencies": {
|
|
63
|
-
"@astrojs/cloudflare": "^14.
|
|
64
|
-
"astro": "^7.
|
|
65
|
-
"wrangler": "^4.
|
|
63
|
+
"@astrojs/cloudflare": "^14.4.0",
|
|
64
|
+
"astro": "^7.4.0",
|
|
65
|
+
"wrangler": "^4.152.0"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
|
-
"
|
|
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
|
-
|
|
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) => {
|
package/src/integration/index.ts
CHANGED
|
@@ -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:
|
|
115
|
-
* and with `prefixDefaultLocale: false` the strategy
|
|
116
|
-
* answers notFound to any path carrying the default locale
|
|
117
|
-
*
|
|
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']> {
|
package/src/middleware/i18n.ts
CHANGED
|
@@ -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
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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
|
-
'/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
108
|
-
// Returns true to skip i18n
|
|
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 (
|
|
172
|
+
if (isFilePath(pathname)) return true
|
|
111
173
|
if (isIgnoredPrefix(pathname)) return true
|
|
112
|
-
// Markdown endpoints
|
|
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,
|
|
156
|
-
// `/<default>/…` is redirected too, since that is no
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
265
|
+
const response =
|
|
266
|
+
pathname !== normalized
|
|
267
|
+
? context.redirect(normalized + url.search, permanentStatus(context.request))
|
|
268
|
+
: await next()
|
|
204
269
|
|
|
205
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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
|
|
316
|
-
//
|
|
317
|
-
//
|
|
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 &&
|
|
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()
|
package/src/translations-keys.ts
CHANGED
|
@@ -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
|