@edgekits/astro-i18n 0.1.6 → 0.1.8
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 +36 -0
- package/README.md +80 -19
- package/dist/cli/index.js +8 -2
- package/dist/cli/key-prefix-check.d.ts +6 -0
- package/dist/cli/key-prefix-check.js +27 -0
- package/dist/cli/zone.d.ts +21 -0
- package/dist/cli/zone.js +42 -0
- package/dist/integration/index.js +2 -2
- package/dist/types.js +6 -0
- package/package.json +1 -1
- package/src/accept-language.ts +56 -0
- package/src/cli/index.ts +9 -2
- package/src/cli/key-prefix-check.ts +31 -0
- package/src/cli/zone.ts +64 -0
- package/src/components/LanguageSwitcher.astro +9 -3
- package/src/fetcher.ts +3 -2
- package/src/integration/index.ts +2 -2
- package/src/locale-fallback.ts +10 -17
- package/src/middleware/i18n.ts +5 -5
- package/src/types.ts +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,42 @@ 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.1.8 — 2026-10-18
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `migrate` checks `keyPrefix` against the purge zone before purging. It reads the zone's name (`GET /zones/{id}`, same token) and warns when `keyPrefix` is a hostname with a dot that is neither the zone name nor a subdomain of it (case-insensitive, on a label boundary: `edgekits.dev.evil.com` warns against `edgekits.dev`). The Purge API answers success even for a URL on another proxied zone, so the name is the only thing left to check. The check needs Zone › Zone › Read on the token; without it (or on any lookup failure) `migrate` prints one info line saying the check was skipped. Either way the purge and the `.i18n-hashes.json` write behave as before.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- `LanguageSwitcher`: under `compressHTML` each item rendered two spaces before its label, because a JSX comment among the button's children split the whitespace run. The comment now sits in the `map` callback. 0.1.7's note that the markup "stays the same apart from the item labels" missed this one extra space per item.
|
|
16
|
+
|
|
17
|
+
### Docs
|
|
18
|
+
|
|
19
|
+
- The `keyPrefix` rule corrected (README "Deployment targets", config table, requirements and the skill guide's prerequisites). A unique name without a dot is valid: single-file purge from your zone reaches any URL whose hostname is no proxied Cloudflare zone. The danger is a hostname that is another proxied Cloudflare zone: its URLs can be purged only from that zone, and the API cannot tell you. Zone › Zone › Read on the token is optional and enables the `migrate` check.
|
|
20
|
+
|
|
21
|
+
## 0.1.7 — 2026-10-17
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- Accept-Language: a regional tag now reaches its language. The middleware reads the header itself (new `src/accept-language.ts`): each tag in `q` order, the full tag, then its language part, first match wins. `es-MX` now lands on `/es/`, and `es-MX, en;q=0.8` on `es` rather than `en`. The previous language-part fallback never ran for regional tags: it read Astro's `preferredLocale`, which keeps only tags equal to a configured locale before choosing, so `es-MX` arrived as undefined and fell through to geo or the default.
|
|
26
|
+
- The cascade order is unchanged: cookie, Accept-Language, then the country only when no tag matched, then `defaultLocale`. Astro's `preferredLocale` is no longer read; the integration still sets Astro's `i18n` block when the project has none, for `Astro.currentLocale`.
|
|
27
|
+
|
|
28
|
+
### Components
|
|
29
|
+
|
|
30
|
+
- `LanguageSwitcher` takes `texts={{ trigger }}` for the toggle's `aria-label`. The default, `Switch language`, is unchanged, so existing markup stays the same apart from the item labels below.
|
|
31
|
+
- Each item's label `span` carries `lang` with its locale, so a screen reader says `Deutsch` or `日本語` in that language.
|
|
32
|
+
|
|
33
|
+
### Config
|
|
34
|
+
|
|
35
|
+
- `defineI18nConfig` rejects a `languages` key that is not all-lowercase (`pt-BR`), naming the key and the lowercase form. Lowercase was documented but not enforced; `toIetfTag` relies on it.
|
|
36
|
+
|
|
37
|
+
### Docs
|
|
38
|
+
|
|
39
|
+
- README "Deployment targets": Workers on your own zone, Cloudflare for SaaS (`keyPrefix` inside the purge zone, one tenant per build), Workers for Platforms trusted (`keyPrefix` unique per tenant) and untrusted (no geo step, no `caches.default`: set `I18N_CACHE=off`).
|
|
40
|
+
- "Installing it": a marketplace added from the `/plugin` panel lives in user settings; how a repository declares it under `extraKnownMarketplaces`.
|
|
41
|
+
- The skill's `seo.md` documents `texts.trigger`; the starter `common.json` has `switchLanguage`.
|
|
42
|
+
|
|
7
43
|
## 0.1.6 — 2026-10-16
|
|
8
44
|
|
|
9
45
|
No runtime changes: the `astro-i18n` skill only, found on the first one-prompt run for the tutorial.
|
package/README.md
CHANGED
|
@@ -22,7 +22,8 @@ The design is explained in the edgekits.dev series:
|
|
|
22
22
|
- Node `>=22`.
|
|
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
|
-
the Zone › Cache Purge permission.
|
|
25
|
+
the Zone › Cache Purge permission. Zone › Zone › Read is optional; with it, `migrate` checks
|
|
26
|
+
`keyPrefix` against the zone's name.
|
|
26
27
|
|
|
27
28
|
## Quick start
|
|
28
29
|
|
|
@@ -73,18 +74,18 @@ export default defineI18nConfig({
|
|
|
73
74
|
})
|
|
74
75
|
```
|
|
75
76
|
|
|
76
|
-
| Field | Required | Default | Meaning
|
|
77
|
-
| -------------------------- | -------- | ------------ |
|
|
78
|
-
| `languages` | yes | | `{ [locale]: { name, countryCode? } }`; `countryCode` is optional: the code your flag component receives
|
|
79
|
-
| `defaultLocale` | yes | | one of `languages`; its JSON files define the schema
|
|
80
|
-
| `cookie` | yes | | `{ name, maxAge }` (seconds) of the language cookie
|
|
81
|
-
| `keyPrefix` | yes | | KV keys are `<keyPrefix>:<namespace>:<locale>`
|
|
82
|
-
| `missingTranslationBanner` | yes | | enables `MissingTranslationBanner`
|
|
83
|
-
| `routing` | yes | | `'prefix'`
|
|
84
|
-
| `localesDir` | no | `'locales'` | where the CLI reads `<locale>/<namespace>.json`
|
|
85
|
-
| `generatedDir` | no | `'src/i18n'` | where the CLI writes `runtime-constants.ts` and `fallbacks.generated.ts`
|
|
86
|
-
| `countryToLocale` | no | none | geo-IP step of the locale fallback (see Reference)
|
|
87
|
-
| `markdownLocale` | no | none | the locale `.md` endpoints are served in (see Reference)
|
|
77
|
+
| Field | Required | Default | Meaning |
|
|
78
|
+
| -------------------------- | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
79
|
+
| `languages` | yes | | `{ [locale]: { name, countryCode? } }`; `countryCode` is optional: the code your flag component receives |
|
|
80
|
+
| `defaultLocale` | yes | | one of `languages`; its JSON files define the schema |
|
|
81
|
+
| `cookie` | yes | | `{ name, maxAge }` (seconds) of the language cookie |
|
|
82
|
+
| `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)) |
|
|
83
|
+
| `missingTranslationBanner` | yes | | enables `MissingTranslationBanner` |
|
|
84
|
+
| `routing` | yes | | `'prefix'` |
|
|
85
|
+
| `localesDir` | no | `'locales'` | where the CLI reads `<locale>/<namespace>.json` |
|
|
86
|
+
| `generatedDir` | no | `'src/i18n'` | where the CLI writes `runtime-constants.ts` and `fallbacks.generated.ts` |
|
|
87
|
+
| `countryToLocale` | no | none | geo-IP step of the locale fallback (see Reference) |
|
|
88
|
+
| `markdownLocale` | no | none | the locale `.md` endpoints are served in (see Reference) |
|
|
88
89
|
|
|
89
90
|
Locale codes (the keys of `languages`) are lowercase everywhere: the config, the `locales/`
|
|
90
91
|
folders and the URLs (`pt-br`, not `pt-BR`). `toIetfTag()` from `./format` gives the IETF form
|
|
@@ -117,7 +118,7 @@ The integration sets up:
|
|
|
117
118
|
- `vite.ssr.noExternal` for the package, whose runtime ships as source;
|
|
118
119
|
- Astro's `i18n` block, when `astro.config` has none: `locales` from `languages`, your
|
|
119
120
|
`defaultLocale`, `routing: { prefixDefaultLocale: true, redirectToDefaultLocale: false }`.
|
|
120
|
-
Astro needs it to
|
|
121
|
+
Astro needs it to set `Astro.currentLocale`, which `resolveLocale` reads. If you keep your own
|
|
121
122
|
block, its `locales` must include every configured locale and its `defaultLocale` must match,
|
|
122
123
|
or the build stops with a message saying which;
|
|
123
124
|
- the `Locale` type: the union of your locales, for the package's exports;
|
|
@@ -261,11 +262,11 @@ under Node and `src/content.config.ts` (evaluated by `astro sync`) can use them.
|
|
|
261
262
|
### Geo-IP locale fallback: `countryToLocale`
|
|
262
263
|
|
|
263
264
|
When a request's URL has no locale, the middleware picks one in this order: the language
|
|
264
|
-
cookie, then
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
265
|
+
cookie, then the `Accept-Language` header (each tag in `q` order, the full tag, then its
|
|
266
|
+
language part, so `es-MX` reaches `es`; the first tag that names one of your `languages` wins),
|
|
267
|
+
then, only when no tag matched, the visitor's country (`request.cf.country`) through your
|
|
268
|
+
`countryToLocale` table, and finally `defaultLocale`. The middleware reads the header itself;
|
|
269
|
+
Astro's `preferredLocale` is not used.
|
|
269
270
|
|
|
270
271
|
```ts
|
|
271
272
|
// edgekits-i18n.config.ts
|
|
@@ -325,6 +326,11 @@ Tailwind. Pass classes per element through the `classes` prop; a slot you leave
|
|
|
325
326
|
| `itemLabel` | the `span` holding the flag and the language name |
|
|
326
327
|
| `activeIcon` | the current-language marker (your `activeIcon` component or the default) |
|
|
327
328
|
|
|
329
|
+
`LanguageSwitcher` has one string of its own, the trigger's `aria-label`, `Switch language` by
|
|
330
|
+
default. Translate it through `texts={{ trigger: … }}`, from a namespace you fetch yourself (the
|
|
331
|
+
package fetches nothing for its components). Each language name renders with `lang` set to its
|
|
332
|
+
locale, so a screen reader says `Deutsch` or `日本語` in that language.
|
|
333
|
+
|
|
328
334
|
`MissingTranslationBanner` has one slot, `root` (the banner `div`).
|
|
329
335
|
|
|
330
336
|
Hooks rendered whether or not you pass `classes`, for CSS or your own scripts:
|
|
@@ -394,6 +400,37 @@ name. A locale without a `countryCode` gets the name alone, and so does every lo
|
|
|
394
400
|
pass no `flag`. A recipe that copies only the SVGs your locales use from the `flag-icons` npm
|
|
395
401
|
package is under [Flags in the switcher](#flags-in-the-switcher) in "Using the skill".
|
|
396
402
|
|
|
403
|
+
### Deployment targets
|
|
404
|
+
|
|
405
|
+
**Workers Free or Paid, on your own zone.** Everything on this page applies as written.
|
|
406
|
+
|
|
407
|
+
**Cloudflare for SaaS.** The Worker is the SaaS zone's fallback origin on a `*/*` route. It is
|
|
408
|
+
a regular Worker: `request.cf`, `caches.default` and `env` behave as usual, and the host-only
|
|
409
|
+
language cookie and the relative redirects keep visitors on their custom hostname. Two
|
|
410
|
+
constraints:
|
|
411
|
+
|
|
412
|
+
- `keyPrefix` is the host of every cache URL that `migrate` purges from the zone
|
|
413
|
+
`CLOUDFLARE_ZONE_ID` names. Recommended: a hostname of that zone (the zone name or a
|
|
414
|
+
subdomain). A unique name without a dot works too: single-file purge from your zone reaches
|
|
415
|
+
any URL whose hostname is no proxied Cloudflare zone. Never another domain that is a proxied
|
|
416
|
+
Cloudflare zone: its URLs can be purged only from that zone, by its owner, and the Purge API
|
|
417
|
+
reports success anyway. When the token can read the zone (Zone › Zone › Read), `migrate`
|
|
418
|
+
warns about a dotted `keyPrefix` outside the zone.
|
|
419
|
+
- One tenant per build. `keyPrefix`, the locales, `defaultLocale` and `countryToLocale` are
|
|
420
|
+
build-time constants, so one Worker serving many custom hostnames serves the same translation
|
|
421
|
+
set to all of them.
|
|
422
|
+
|
|
423
|
+
**Workers for Platforms, trusted namespace.** `request.cf` and `caches.default` are available.
|
|
424
|
+
The Cache API is shared across the namespace and the cache URL is `https://<keyPrefix>/…`, so
|
|
425
|
+
`keyPrefix` must be unique per tenant, or two tenants read each other's translations from the
|
|
426
|
+
cache.
|
|
427
|
+
|
|
428
|
+
**Workers for Platforms, untrusted namespace.** `request.cf` is absent, so the country step of
|
|
429
|
+
the locale cascade never runs. `caches.default` is disabled, so every render reads its
|
|
430
|
+
namespaces from KV in one bulk read: the output is correct, only the cache layer and its KV
|
|
431
|
+
savings are gone. Set `I18N_CACHE=off` so the behaviour is explicit, and expect one bulk KV read
|
|
432
|
+
per render.
|
|
433
|
+
|
|
397
434
|
<!-- skill-guide:start -->
|
|
398
435
|
|
|
399
436
|
## Using the skill
|
|
@@ -448,6 +485,28 @@ turns the plugin on there, under `enabledPlugins` (`"astro-i18n@edgekits": true`
|
|
|
448
485
|
honours those entries for a teammate once they trust the folder, but it does not download the
|
|
449
486
|
plugin for them: each teammate runs the same `claude plugin install … --scope project` once.
|
|
450
487
|
|
|
488
|
+
A marketplace added from the `/plugin` panel, or with `/plugin marketplace add`, is stored in
|
|
489
|
+
your user settings, even when you then install the plugin at project scope, so the committed
|
|
490
|
+
`.claude/settings.json` holds only `enabledPlugins`. On a fresh clone a teammate therefore first
|
|
491
|
+
adds the marketplace: `/plugin marketplace add EdgeKits/skills`, or the CLI line with
|
|
492
|
+
`--scope project` above. Declaring it in `.claude/settings.json` under `extraKnownMarketplaces`,
|
|
493
|
+
keyed by the marketplace's name as in the block below, spares them that step once they trust the
|
|
494
|
+
folder. In both cases the plugin itself is still installed once per contributor with
|
|
495
|
+
`claude plugin install astro-i18n@edgekits --scope project`, as the paragraph above says.
|
|
496
|
+
|
|
497
|
+
```json
|
|
498
|
+
{
|
|
499
|
+
"extraKnownMarketplaces": {
|
|
500
|
+
"edgekits": {
|
|
501
|
+
"source": { "source": "github", "repo": "EdgeKits/skills" }
|
|
502
|
+
}
|
|
503
|
+
},
|
|
504
|
+
"enabledPlugins": {
|
|
505
|
+
"astro-i18n@edgekits": true
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
```
|
|
509
|
+
|
|
451
510
|
Any other agent, with the [skills CLI](https://skills.sh):
|
|
452
511
|
|
|
453
512
|
```sh
|
|
@@ -467,6 +526,8 @@ npx skills add EdgeKits/astro-i18n --skill astro-i18n
|
|
|
467
526
|
- A Cloudflare account. Local development needs none until you create the real KV namespace.
|
|
468
527
|
- For `edgekits-i18n migrate` (pushing copy to production): the site on a proxied Cloudflare
|
|
469
528
|
zone (orange cloud), that zone's ID, and an API token with the Zone › Cache Purge permission.
|
|
529
|
+
Zone › Zone › Read on the same token is optional: with it, `migrate` checks `keyPrefix`
|
|
530
|
+
against the zone's name.
|
|
470
531
|
|
|
471
532
|
### First run on a fresh site
|
|
472
533
|
|
package/dist/cli/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { diffHashes, purgeUrlsFor } from "./hashes.js";
|
|
|
8
8
|
import { CONFIG_FILE, loadI18nConfig } from "./load-config.js";
|
|
9
9
|
import { purgeTranslationsCache } from "./purge.js";
|
|
10
10
|
import { getKvNamespaceId, getWranglerVar, KV_BINDING, resolveWranglerBin } from "./wrangler.js";
|
|
11
|
+
import { checkKeyPrefixZone } from "./zone.js";
|
|
11
12
|
const HELP = `Usage: edgekits-i18n <command> [--fallbacks]
|
|
12
13
|
|
|
13
14
|
Reads ${CONFIG_FILE} and <localesDir>/<locale>/<namespace>.json from the current directory.
|
|
@@ -144,7 +145,8 @@ async function run(command, args) {
|
|
|
144
145
|
console.log('Generated:\n' + outputs.map((p) => `- ${path.relative(ROOT, p).split(path.sep).join('/')}`).join('\n'));
|
|
145
146
|
// -------------------------------------------------------------------------
|
|
146
147
|
// 5. Push to KV + purge changed cache entries via Cloudflare API
|
|
147
|
-
// seed → local KV only, no purge (
|
|
148
|
+
// seed → local KV only, no purge, hashes untouched (a warm local cache under
|
|
149
|
+
// `astro dev` is the consumer's to drop: `I18N_CACHE=off`, restart)
|
|
148
150
|
// migrate → remote KV + Cloudflare Purge API, hashes written on success
|
|
149
151
|
// -------------------------------------------------------------------------
|
|
150
152
|
if (!shouldDeploy) {
|
|
@@ -207,7 +209,8 @@ async function deploy({ config, kvId, wranglerBin, isLocal, changedKeys, current
|
|
|
207
209
|
process.exit(1);
|
|
208
210
|
}
|
|
209
211
|
if (isLocal) {
|
|
210
|
-
// Local KV: no purge (
|
|
212
|
+
// Local KV: no purge (a warm local cache under `astro dev` is the consumer's to drop),
|
|
213
|
+
// and the hashes describe production only.
|
|
211
214
|
console.log('[i18n] .i18n-hashes.json left untouched (local seed).');
|
|
212
215
|
return;
|
|
213
216
|
}
|
|
@@ -254,6 +257,9 @@ async function deploy({ config, kvId, wranglerBin, isLocal, changedKeys, current
|
|
|
254
257
|
'Skipping cache purge — hash file NOT updated. Re-run `edgekits-i18n migrate` after setting the secret.');
|
|
255
258
|
}
|
|
256
259
|
else {
|
|
260
|
+
// Advisory: the Purge API reports success even for URLs it cannot reach, so the
|
|
261
|
+
// name is all there is to check. Never decides shouldWriteHashes.
|
|
262
|
+
await checkKeyPrefixZone(config.keyPrefix, zoneId, apiToken);
|
|
257
263
|
console.log(`[i18n] Purging ${purgeUrls.length} cache entries via Cloudflare API...`);
|
|
258
264
|
const purgeSuccess = await purgeTranslationsCache(zoneId, apiToken, purgeUrls);
|
|
259
265
|
if (purgeSuccess) {
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The warning `migrate` prints when `keyPrefix` is a dotted hostname outside `zoneName`,
|
|
3
|
+
* or null when it is dotless, the zone name itself, or a subdomain of it (label
|
|
4
|
+
* boundary, case-insensitive, both sides trimmed). Pure: no network, no `process`.
|
|
5
|
+
*/
|
|
6
|
+
export declare function keyPrefixZoneWarning(keyPrefix: string, zoneName: string): string | null;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// keyPrefix against the purge zone
|
|
3
|
+
//
|
|
4
|
+
// Every cache URL is https://<keyPrefix>/..., and `migrate` purges those URLs from the
|
|
5
|
+
// zone CLOUDFLARE_ZONE_ID names. A URL a Worker cached is purged from the Worker's own
|
|
6
|
+
// zone when its hostname is no Cloudflare zone or a non-proxied one. A hostname that is
|
|
7
|
+
// an active proxied Cloudflare zone can be purged only from that zone, by its owner, and
|
|
8
|
+
// the Purge API answers `success: true` either way. The response cannot show the miss,
|
|
9
|
+
// so the only check left is the name. A proxied zone always has a dot: a dotless
|
|
10
|
+
// keyPrefix never collides with one.
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
/**
|
|
13
|
+
* The warning `migrate` prints when `keyPrefix` is a dotted hostname outside `zoneName`,
|
|
14
|
+
* or null when it is dotless, the zone name itself, or a subdomain of it (label
|
|
15
|
+
* boundary, case-insensitive, both sides trimmed). Pure: no network, no `process`.
|
|
16
|
+
*/
|
|
17
|
+
export function keyPrefixZoneWarning(keyPrefix, zoneName) {
|
|
18
|
+
const host = keyPrefix.trim().toLowerCase();
|
|
19
|
+
const zone = zoneName.trim().toLowerCase();
|
|
20
|
+
if (!host.includes('.') || host === zone || host.endsWith(`.${zone}`))
|
|
21
|
+
return null;
|
|
22
|
+
return (`[i18n] ⚠️ keyPrefix "${keyPrefix.trim()}" is a hostname outside the zone the purge is issued against (${zoneName.trim()}).\n` +
|
|
23
|
+
' The Purge API answers success either way, so this run cannot tell whether the edge cache was purged.\n' +
|
|
24
|
+
` It is harmless only if "${keyPrefix.trim()}" is no proxied Cloudflare zone: a URL on a proxied zone\n` +
|
|
25
|
+
' can be purged only from that zone, by its owner. Fix: a keyPrefix inside the zone (its name or a\n' +
|
|
26
|
+
' subdomain), or a unique name without a dot.');
|
|
27
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type ZoneDeps = {
|
|
2
|
+
fetch?: (input: string, init: RequestInit) => Promise<Response>;
|
|
3
|
+
};
|
|
4
|
+
/**
|
|
5
|
+
* Read the zone's name: `GET /zones/{zoneId}`, `result.name`. Needs Zone › Zone › Read
|
|
6
|
+
* on the token. Never throws: any failure (non-2xx, network error, unexpected body)
|
|
7
|
+
* comes back as `{ error }`.
|
|
8
|
+
*
|
|
9
|
+
* `deps` replaces `fetch` in tests.
|
|
10
|
+
*/
|
|
11
|
+
export declare function readZoneName(zoneId: string, apiToken: string, deps?: ZoneDeps): Promise<{
|
|
12
|
+
name: string;
|
|
13
|
+
} | {
|
|
14
|
+
error: string;
|
|
15
|
+
}>;
|
|
16
|
+
/**
|
|
17
|
+
* Print the keyPrefix warning when `keyPrefix` lies outside the zone, or one info line
|
|
18
|
+
* when the zone name cannot be read. Advisory only: it returns nothing, and the purge
|
|
19
|
+
* runs the same either way.
|
|
20
|
+
*/
|
|
21
|
+
export declare function checkKeyPrefixZone(keyPrefix: string, zoneId: string, apiToken: string, deps?: ZoneDeps): Promise<void>;
|
package/dist/cli/zone.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { keyPrefixZoneWarning } from "./key-prefix-check.js";
|
|
2
|
+
/**
|
|
3
|
+
* Read the zone's name: `GET /zones/{zoneId}`, `result.name`. Needs Zone › Zone › Read
|
|
4
|
+
* on the token. Never throws: any failure (non-2xx, network error, unexpected body)
|
|
5
|
+
* comes back as `{ error }`.
|
|
6
|
+
*
|
|
7
|
+
* `deps` replaces `fetch` in tests.
|
|
8
|
+
*/
|
|
9
|
+
export async function readZoneName(zoneId, apiToken, deps = {}) {
|
|
10
|
+
const { fetch: fetchFn = fetch } = deps;
|
|
11
|
+
try {
|
|
12
|
+
const response = await fetchFn(`https://api.cloudflare.com/client/v4/zones/${zoneId}`, {
|
|
13
|
+
method: 'GET',
|
|
14
|
+
headers: { Authorization: `Bearer ${apiToken}` },
|
|
15
|
+
});
|
|
16
|
+
if (!response.ok)
|
|
17
|
+
return { error: `HTTP ${response.status}` };
|
|
18
|
+
const body = (await response.json());
|
|
19
|
+
const name = body?.result?.name;
|
|
20
|
+
if (typeof name !== 'string' || name.trim() === '')
|
|
21
|
+
return { error: 'no result.name in the response' };
|
|
22
|
+
return { name };
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Print the keyPrefix warning when `keyPrefix` lies outside the zone, or one info line
|
|
30
|
+
* when the zone name cannot be read. Advisory only: it returns nothing, and the purge
|
|
31
|
+
* runs the same either way.
|
|
32
|
+
*/
|
|
33
|
+
export async function checkKeyPrefixZone(keyPrefix, zoneId, apiToken, deps = {}) {
|
|
34
|
+
const zone = await readZoneName(zoneId, apiToken, deps);
|
|
35
|
+
if ('error' in zone) {
|
|
36
|
+
console.log(`[i18n] ℹ️ Could not read the zone name (${zone.error}), so the keyPrefix check was skipped (needs Zone › Zone › Read on the token).`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const warning = keyPrefixZoneWarning(keyPrefix, zone.name);
|
|
40
|
+
if (warning)
|
|
41
|
+
console.warn(warning);
|
|
42
|
+
}
|
|
@@ -19,8 +19,8 @@ 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
|
-
// Astro
|
|
23
|
-
//
|
|
22
|
+
// Astro sets `Astro.currentLocale` (read by `resolveLocale`) only with an `i18n`
|
|
23
|
+
// block: set it when the project has none, check it when it has one.
|
|
24
24
|
if (astroConfig.i18n)
|
|
25
25
|
assertAstroI18n(astroConfig.i18n, i18nConfig);
|
|
26
26
|
updateConfig({
|
package/dist/types.js
CHANGED
|
@@ -17,6 +17,12 @@ export function defineI18nConfig(config) {
|
|
|
17
17
|
if (locales.length === 0) {
|
|
18
18
|
throw new Error('[@edgekits/astro-i18n] `languages` must define at least one locale.');
|
|
19
19
|
}
|
|
20
|
+
// Lowercase everywhere (config, `locales/` folders, URLs); `toIetfTag` relies on it.
|
|
21
|
+
for (const locale of locales) {
|
|
22
|
+
if (locale !== locale.toLowerCase()) {
|
|
23
|
+
throw new Error(`[@edgekits/astro-i18n] \`languages\` key "${locale}" is not lowercase (use "${locale.toLowerCase()}").`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
20
26
|
if (!locales.includes(config.defaultLocale)) {
|
|
21
27
|
throw new Error(`[@edgekits/astro-i18n] \`defaultLocale\` "${config.defaultLocale}" is not one of \`languages\`: ${locales.join(', ')}.`);
|
|
22
28
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgekits/astro-i18n",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
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",
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Accept-Language step of the middleware's fallback cascade, read from the header
|
|
3
|
+
* itself. Astro's own match is no substitute: it keeps only the tags that equal a
|
|
4
|
+
* configured locale before choosing, so a regional tag (`es-MX`) never reaches a
|
|
5
|
+
* language-part match. Pure: no Astro, no virtual module, no `cloudflare:`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type AcceptLanguageEntry = { tag: string; q: number }
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The header's language ranges, highest `q` first (header order among equals). A missing
|
|
12
|
+
* or unparsable `q` is 1; `q=0` entries and the `*` wildcard are dropped.
|
|
13
|
+
*/
|
|
14
|
+
export function parseAcceptLanguage(header: string | null): AcceptLanguageEntry[] {
|
|
15
|
+
if (!header) return []
|
|
16
|
+
|
|
17
|
+
const entries: AcceptLanguageEntry[] = []
|
|
18
|
+
for (const part of header.split(',')) {
|
|
19
|
+
const [rawTag = '', ...params] = part.split(';')
|
|
20
|
+
const tag = rawTag.trim()
|
|
21
|
+
if (!tag || tag === '*') continue
|
|
22
|
+
|
|
23
|
+
let q = 1
|
|
24
|
+
for (const param of params) {
|
|
25
|
+
const [name, value] = param.split('=').map((s) => s.trim())
|
|
26
|
+
if (name?.toLowerCase() !== 'q' || value === undefined) continue
|
|
27
|
+
const parsed = Number.parseFloat(value)
|
|
28
|
+
if (!Number.isNaN(parsed)) q = parsed
|
|
29
|
+
}
|
|
30
|
+
if (q <= 0) continue
|
|
31
|
+
|
|
32
|
+
entries.push({ tag, q })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Array.prototype.sort is stable: equal q keeps header order.
|
|
36
|
+
return entries.sort((a, b) => b.q - a.q)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The first supported locale the header names: for each tag in `q` order, the full tag
|
|
41
|
+
* (lowercased), then its language subtag (before the first `-` or `_`). Null when none.
|
|
42
|
+
*/
|
|
43
|
+
export function matchAcceptLanguage<L extends string>(
|
|
44
|
+
header: string | null,
|
|
45
|
+
parseLocale: (value: unknown) => L | null,
|
|
46
|
+
): L | null {
|
|
47
|
+
for (const { tag } of parseAcceptLanguage(header)) {
|
|
48
|
+
const lower = tag.toLowerCase()
|
|
49
|
+
const full = parseLocale(lower)
|
|
50
|
+
if (full) return full
|
|
51
|
+
|
|
52
|
+
const language = parseLocale(lower.split(/[-_]/)[0])
|
|
53
|
+
if (language) return language
|
|
54
|
+
}
|
|
55
|
+
return null
|
|
56
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { diffHashes, purgeUrlsFor, type HashMap } from './hashes.ts'
|
|
|
19
19
|
import { CONFIG_FILE, loadI18nConfig } from './load-config.ts'
|
|
20
20
|
import { purgeTranslationsCache } from './purge.ts'
|
|
21
21
|
import { getKvNamespaceId, getWranglerVar, KV_BINDING, resolveWranglerBin } from './wrangler.ts'
|
|
22
|
+
import { checkKeyPrefixZone } from './zone.ts'
|
|
22
23
|
|
|
23
24
|
const HELP = `Usage: edgekits-i18n <command> [--fallbacks]
|
|
24
25
|
|
|
@@ -197,7 +198,8 @@ async function run(command: Command, args: string[]): Promise<void> {
|
|
|
197
198
|
|
|
198
199
|
// -------------------------------------------------------------------------
|
|
199
200
|
// 5. Push to KV + purge changed cache entries via Cloudflare API
|
|
200
|
-
// seed → local KV only, no purge (
|
|
201
|
+
// seed → local KV only, no purge, hashes untouched (a warm local cache under
|
|
202
|
+
// `astro dev` is the consumer's to drop: `I18N_CACHE=off`, restart)
|
|
201
203
|
// migrate → remote KV + Cloudflare Purge API, hashes written on success
|
|
202
204
|
// -------------------------------------------------------------------------
|
|
203
205
|
|
|
@@ -289,7 +291,8 @@ async function deploy({
|
|
|
289
291
|
}
|
|
290
292
|
|
|
291
293
|
if (isLocal) {
|
|
292
|
-
// Local KV: no purge (
|
|
294
|
+
// Local KV: no purge (a warm local cache under `astro dev` is the consumer's to drop),
|
|
295
|
+
// and the hashes describe production only.
|
|
293
296
|
console.log('[i18n] .i18n-hashes.json left untouched (local seed).')
|
|
294
297
|
return
|
|
295
298
|
}
|
|
@@ -344,6 +347,10 @@ async function deploy({
|
|
|
344
347
|
'Skipping cache purge — hash file NOT updated. Re-run `edgekits-i18n migrate` after setting the secret.',
|
|
345
348
|
)
|
|
346
349
|
} else {
|
|
350
|
+
// Advisory: the Purge API reports success even for URLs it cannot reach, so the
|
|
351
|
+
// name is all there is to check. Never decides shouldWriteHashes.
|
|
352
|
+
await checkKeyPrefixZone(config.keyPrefix, zoneId, apiToken)
|
|
353
|
+
|
|
347
354
|
console.log(`[i18n] Purging ${purgeUrls.length} cache entries via Cloudflare API...`)
|
|
348
355
|
const purgeSuccess = await purgeTranslationsCache(zoneId, apiToken, purgeUrls)
|
|
349
356
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// keyPrefix against the purge zone
|
|
3
|
+
//
|
|
4
|
+
// Every cache URL is https://<keyPrefix>/..., and `migrate` purges those URLs from the
|
|
5
|
+
// zone CLOUDFLARE_ZONE_ID names. A URL a Worker cached is purged from the Worker's own
|
|
6
|
+
// zone when its hostname is no Cloudflare zone or a non-proxied one. A hostname that is
|
|
7
|
+
// an active proxied Cloudflare zone can be purged only from that zone, by its owner, and
|
|
8
|
+
// the Purge API answers `success: true` either way. The response cannot show the miss,
|
|
9
|
+
// so the only check left is the name. A proxied zone always has a dot: a dotless
|
|
10
|
+
// keyPrefix never collides with one.
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The warning `migrate` prints when `keyPrefix` is a dotted hostname outside `zoneName`,
|
|
15
|
+
* or null when it is dotless, the zone name itself, or a subdomain of it (label
|
|
16
|
+
* boundary, case-insensitive, both sides trimmed). Pure: no network, no `process`.
|
|
17
|
+
*/
|
|
18
|
+
export function keyPrefixZoneWarning(keyPrefix: string, zoneName: string): string | null {
|
|
19
|
+
const host = keyPrefix.trim().toLowerCase()
|
|
20
|
+
const zone = zoneName.trim().toLowerCase()
|
|
21
|
+
|
|
22
|
+
if (!host.includes('.') || host === zone || host.endsWith(`.${zone}`)) return null
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
`[i18n] ⚠️ keyPrefix "${keyPrefix.trim()}" is a hostname outside the zone the purge is issued against (${zoneName.trim()}).\n` +
|
|
26
|
+
' The Purge API answers success either way, so this run cannot tell whether the edge cache was purged.\n' +
|
|
27
|
+
` It is harmless only if "${keyPrefix.trim()}" is no proxied Cloudflare zone: a URL on a proxied zone\n` +
|
|
28
|
+
' can be purged only from that zone, by its owner. Fix: a keyPrefix inside the zone (its name or a\n' +
|
|
29
|
+
' subdomain), or a unique name without a dot.'
|
|
30
|
+
)
|
|
31
|
+
}
|
package/src/cli/zone.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { keyPrefixZoneWarning } from './key-prefix-check.ts'
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Zone name lookup for the keyPrefix check (migrate only)
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
export type ZoneDeps = {
|
|
8
|
+
fetch?: (input: string, init: RequestInit) => Promise<Response>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Read the zone's name: `GET /zones/{zoneId}`, `result.name`. Needs Zone › Zone › Read
|
|
13
|
+
* on the token. Never throws: any failure (non-2xx, network error, unexpected body)
|
|
14
|
+
* comes back as `{ error }`.
|
|
15
|
+
*
|
|
16
|
+
* `deps` replaces `fetch` in tests.
|
|
17
|
+
*/
|
|
18
|
+
export async function readZoneName(
|
|
19
|
+
zoneId: string,
|
|
20
|
+
apiToken: string,
|
|
21
|
+
deps: ZoneDeps = {},
|
|
22
|
+
): Promise<{ name: string } | { error: string }> {
|
|
23
|
+
const { fetch: fetchFn = fetch } = deps
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const response = await fetchFn(`https://api.cloudflare.com/client/v4/zones/${zoneId}`, {
|
|
27
|
+
method: 'GET',
|
|
28
|
+
headers: { Authorization: `Bearer ${apiToken}` },
|
|
29
|
+
})
|
|
30
|
+
if (!response.ok) return { error: `HTTP ${response.status}` }
|
|
31
|
+
|
|
32
|
+
const body = (await response.json()) as { result?: { name?: unknown } } | null
|
|
33
|
+
const name = body?.result?.name
|
|
34
|
+
if (typeof name !== 'string' || name.trim() === '') return { error: 'no result.name in the response' }
|
|
35
|
+
|
|
36
|
+
return { name }
|
|
37
|
+
} catch (err) {
|
|
38
|
+
return { error: err instanceof Error ? err.message : String(err) }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Print the keyPrefix warning when `keyPrefix` lies outside the zone, or one info line
|
|
44
|
+
* when the zone name cannot be read. Advisory only: it returns nothing, and the purge
|
|
45
|
+
* runs the same either way.
|
|
46
|
+
*/
|
|
47
|
+
export async function checkKeyPrefixZone(
|
|
48
|
+
keyPrefix: string,
|
|
49
|
+
zoneId: string,
|
|
50
|
+
apiToken: string,
|
|
51
|
+
deps: ZoneDeps = {},
|
|
52
|
+
): Promise<void> {
|
|
53
|
+
const zone = await readZoneName(zoneId, apiToken, deps)
|
|
54
|
+
|
|
55
|
+
if ('error' in zone) {
|
|
56
|
+
console.log(
|
|
57
|
+
`[i18n] ℹ️ Could not read the zone name (${zone.error}), so the keyPrefix check was skipped (needs Zone › Zone › Read on the token).`,
|
|
58
|
+
)
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const warning = keyPrefixZoneWarning(keyPrefix, zone.name)
|
|
63
|
+
if (warning) console.warn(warning)
|
|
64
|
+
}
|
|
@@ -17,9 +17,12 @@ interface Props {
|
|
|
17
17
|
classes?: Partial<
|
|
18
18
|
Record<'root' | 'trigger' | 'triggerIcon' | 'dropdown' | 'item' | 'itemActive' | 'itemLabel' | 'activeIcon', string>
|
|
19
19
|
>
|
|
20
|
+
// The component's own words, like MissingTranslationBanner's `texts`: the engine fetches
|
|
21
|
+
// nothing and assumes no namespace. `trigger` is the toggle's aria-label.
|
|
22
|
+
texts?: { trigger?: string }
|
|
20
23
|
}
|
|
21
24
|
|
|
22
|
-
const { flag: Flag, triggerIcon: TriggerIcon, activeIcon: ActiveIcon, classes = {} } = Astro.props
|
|
25
|
+
const { flag: Flag, triggerIcon: TriggerIcon, activeIcon: ActiveIcon, classes = {}, texts } = Astro.props
|
|
23
26
|
const { uiLocale: currentLang } = Astro.locals
|
|
24
27
|
---
|
|
25
28
|
|
|
@@ -36,7 +39,7 @@ const { uiLocale: currentLang } = Astro.locals
|
|
|
36
39
|
class={classes.trigger}
|
|
37
40
|
aria-haspopup="true"
|
|
38
41
|
aria-expanded="false"
|
|
39
|
-
aria-label=
|
|
42
|
+
aria-label={texts?.trigger ?? 'Switch language'}
|
|
40
43
|
>
|
|
41
44
|
{
|
|
42
45
|
TriggerIcon ? (
|
|
@@ -66,6 +69,9 @@ const { uiLocale: currentLang } = Astro.locals
|
|
|
66
69
|
const label = LANGUAGES[locale]
|
|
67
70
|
const flagCode = label.countryCode
|
|
68
71
|
|
|
72
|
+
// The label is the language's own name: `lang` lets a screen reader say it in that
|
|
73
|
+
// language. (A comment here, not among the button's children: there it splits the
|
|
74
|
+
// whitespace run, and compressHTML keeps two spaces before the label.)
|
|
69
75
|
return (
|
|
70
76
|
<button
|
|
71
77
|
type="button"
|
|
@@ -76,7 +82,7 @@ const { uiLocale: currentLang } = Astro.locals
|
|
|
76
82
|
role="menuitem"
|
|
77
83
|
disabled={isActive}
|
|
78
84
|
>
|
|
79
|
-
<span class={classes.itemLabel}>
|
|
85
|
+
<span class={classes.itemLabel} lang={locale}>
|
|
80
86
|
{Flag && flagCode && <Flag code={flagCode} size={16} />}
|
|
81
87
|
{label.name}
|
|
82
88
|
</span>
|
package/src/fetcher.ts
CHANGED
|
@@ -61,8 +61,9 @@ export async function fetchTranslations<N extends Namespace>(
|
|
|
61
61
|
const { cache } = getEdgeCache()
|
|
62
62
|
|
|
63
63
|
if (!cache) {
|
|
64
|
-
//
|
|
65
|
-
|
|
64
|
+
// `caches.default` absent (e.g. an untrusted Workers for Platforms namespace): read KV
|
|
65
|
+
// directly. The package does not use `caches.open()`.
|
|
66
|
+
debug('i18n caches.default absent, reading KV directly', {
|
|
66
67
|
locale,
|
|
67
68
|
namespaces,
|
|
68
69
|
})
|
package/src/integration/index.ts
CHANGED
|
@@ -38,8 +38,8 @@ export function edgekitsI18n(config: I18nConfig, options: EdgekitsI18nOptions =
|
|
|
38
38
|
astroConfig.root,
|
|
39
39
|
)
|
|
40
40
|
|
|
41
|
-
// Astro
|
|
42
|
-
//
|
|
41
|
+
// Astro sets `Astro.currentLocale` (read by `resolveLocale`) only with an `i18n`
|
|
42
|
+
// block: set it when the project has none, check it when it has one.
|
|
43
43
|
if (astroConfig.i18n) assertAstroI18n(astroConfig.i18n, i18nConfig)
|
|
44
44
|
|
|
45
45
|
updateConfig({
|
package/src/locale-fallback.ts
CHANGED
|
@@ -5,19 +5,19 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Order (unchanged from the middleware):
|
|
7
7
|
* 1. the lang cookie, if valid;
|
|
8
|
-
* 2. the
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* 3. ONLY when there is no preferred locale at all: the Cloudflare geo-IP country,
|
|
8
|
+
* 2. the Accept-Language header (the middleware passes the request header): each tag in
|
|
9
|
+
* `q` order, full tag, then its language part ('es-mx' -> 'es'); the first tag that
|
|
10
|
+
* names a supported locale wins (accept-language.ts);
|
|
11
|
+
* 3. ONLY when no tag matched (or there is no header): the Cloudflare geo-IP country,
|
|
13
12
|
* through the project's `countryToLocale` table (skipped without one);
|
|
14
13
|
* 4. the default locale.
|
|
15
|
-
* A preferred locale that does not parse goes straight to 4: geo is not consulted.
|
|
16
14
|
*/
|
|
17
15
|
|
|
16
|
+
import { matchAcceptLanguage } from './accept-language'
|
|
17
|
+
|
|
18
18
|
export type FallbackInputs<L extends string> = {
|
|
19
19
|
cookieLocale: () => L | null
|
|
20
|
-
|
|
20
|
+
acceptLanguage: () => string | null
|
|
21
21
|
country: () => unknown
|
|
22
22
|
}
|
|
23
23
|
|
|
@@ -32,17 +32,10 @@ export function resolveFallbackLocale<L extends string>(inputs: FallbackInputs<L
|
|
|
32
32
|
const cookieLocale = inputs.cookieLocale()
|
|
33
33
|
if (cookieLocale) return cookieLocale
|
|
34
34
|
|
|
35
|
-
const
|
|
36
|
-
if (
|
|
37
|
-
// Currently we support 2-chars locales only.
|
|
38
|
-
// 1) Try full match (for cases where you actually have 'pt-br' in SUPPORTED_LOCALES)
|
|
39
|
-
const full = options.parseLocale(browserRaw)
|
|
40
|
-
if (full) return full
|
|
35
|
+
const browser = matchAcceptLanguage(inputs.acceptLanguage(), options.parseLocale)
|
|
36
|
+
if (browser) return browser
|
|
41
37
|
|
|
42
|
-
|
|
43
|
-
const short = options.parseLocale(browserRaw.split('-')[0])
|
|
44
|
-
if (short) return short
|
|
45
|
-
} else if (options.countryToLocale) {
|
|
38
|
+
if (options.countryToLocale) {
|
|
46
39
|
// 3) Cloudflare Geo-IP Strategy
|
|
47
40
|
// cf is undefined in local dev unless simulated
|
|
48
41
|
const geo = options.parseLocale(mapCountryToLocale(inputs.country(), options.countryToLocale))
|
package/src/middleware/i18n.ts
CHANGED
|
@@ -85,16 +85,16 @@ function buildLocalizedPath(locale: Locale, rest: string[]): string {
|
|
|
85
85
|
// Resolves the most appropriate locale for the user based on a fallback cascade
|
|
86
86
|
// (locale-fallback.ts, where the order is documented and tested):
|
|
87
87
|
// 1. User's saved cookie preference.
|
|
88
|
-
// 2.
|
|
89
|
-
//
|
|
90
|
-
// 3. Only when
|
|
91
|
-
//
|
|
88
|
+
// 2. The Accept-Language header, read here rather than through Astro (which drops
|
|
89
|
+
// regional tags before matching): each tag by q, full tag, then its language part.
|
|
90
|
+
// 3. Only when no tag matched: the Cloudflare Geo-IP country, through the project's
|
|
91
|
+
// `countryToLocale` table (skipped when the config has none).
|
|
92
92
|
// 4. Finally, falls back to the application's DEFAULT_LOCALE.
|
|
93
93
|
function resolveFallbackLocale(context: I18nMiddlewareContext): Locale {
|
|
94
94
|
return resolveFallbackFrom<Locale>(
|
|
95
95
|
{
|
|
96
96
|
cookieLocale: () => getCookieLang(context.cookies),
|
|
97
|
-
|
|
97
|
+
acceptLanguage: () => context.request.headers.get('accept-language'),
|
|
98
98
|
// cf is undefined in local dev unless simulated
|
|
99
99
|
country: () => context.request?.cf?.country,
|
|
100
100
|
},
|
package/src/types.ts
CHANGED
|
@@ -55,6 +55,14 @@ export function defineI18nConfig<const C extends I18nConfig>(
|
|
|
55
55
|
if (locales.length === 0) {
|
|
56
56
|
throw new Error('[@edgekits/astro-i18n] `languages` must define at least one locale.')
|
|
57
57
|
}
|
|
58
|
+
// Lowercase everywhere (config, `locales/` folders, URLs); `toIetfTag` relies on it.
|
|
59
|
+
for (const locale of locales) {
|
|
60
|
+
if (locale !== locale.toLowerCase()) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`[@edgekits/astro-i18n] \`languages\` key "${locale}" is not lowercase (use "${locale.toLowerCase()}").`,
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
58
66
|
if (!locales.includes(config.defaultLocale)) {
|
|
59
67
|
throw new Error(
|
|
60
68
|
`[@edgekits/astro-i18n] \`defaultLocale\` "${config.defaultLocale}" is not one of \`languages\`: ${locales.join(', ')}.`,
|