@edgekits/astro-i18n 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,28 @@ 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.7 — 2026-10-17
8
+
9
+ ### Fixed
10
+
11
+ - 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.
12
+ - 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`.
13
+
14
+ ### Components
15
+
16
+ - `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.
17
+ - Each item's label `span` carries `lang` with its locale, so a screen reader says `Deutsch` or `日本語` in that language.
18
+
19
+ ### Config
20
+
21
+ - `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.
22
+
23
+ ### Docs
24
+
25
+ - 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`).
26
+ - "Installing it": a marketplace added from the `/plugin` panel lives in user settings; how a repository declares it under `extraKnownMarketplaces`.
27
+ - The skill's `seo.md` documents `texts.trigger`; the starter `common.json` has `switchLanguage`.
28
+
7
29
  ## 0.1.6 — 2026-10-16
8
30
 
9
31
  No runtime changes: the `astro-i18n` skill only, found on the first one-prompt run for the tutorial.
package/README.md CHANGED
@@ -117,7 +117,7 @@ The integration sets up:
117
117
  - `vite.ssr.noExternal` for the package, whose runtime ships as source;
118
118
  - Astro's `i18n` block, when `astro.config` has none: `locales` from `languages`, your
119
119
  `defaultLocale`, `routing: { prefixDefaultLocale: true, redirectToDefaultLocale: false }`.
120
- Astro needs it to compute `preferredLocale` from `Accept-Language`. If you keep your own
120
+ Astro needs it to set `Astro.currentLocale`, which `resolveLocale` reads. If you keep your own
121
121
  block, its `locales` must include every configured locale and its `defaultLocale` must match,
122
122
  or the build stops with a message saying which;
123
123
  - the `Locale` type: the union of your locales, for the package's exports;
@@ -261,11 +261,11 @@ under Node and `src/content.config.ts` (evaluated by `astro sync`) can use them.
261
261
  ### Geo-IP locale fallback: `countryToLocale`
262
262
 
263
263
  When a request's URL has no locale, the middleware picks one in this order: the language
264
- cookie, then Astro's `preferredLocale` (full tag, then its language part), then, only when Astro
265
- reports no preferred locale, the visitor's country (`request.cf.country`) through your
266
- `countryToLocale` table, and finally `defaultLocale`. Astro computes `preferredLocale` by
267
- matching `Accept-Language` against its `i18n.locales`, which the integration sets from your
268
- `languages` unless your `astro.config` has its own `i18n` block.
264
+ cookie, then the `Accept-Language` header (each tag in `q` order, the full tag, then its
265
+ language part, so `es-MX` reaches `es`; the first tag that names one of your `languages` wins),
266
+ then, only when no tag matched, the visitor's country (`request.cf.country`) through your
267
+ `countryToLocale` table, and finally `defaultLocale`. The middleware reads the header itself;
268
+ Astro's `preferredLocale` is not used.
269
269
 
270
270
  ```ts
271
271
  // edgekits-i18n.config.ts
@@ -325,6 +325,11 @@ Tailwind. Pass classes per element through the `classes` prop; a slot you leave
325
325
  | `itemLabel` | the `span` holding the flag and the language name |
326
326
  | `activeIcon` | the current-language marker (your `activeIcon` component or the default) |
327
327
 
328
+ `LanguageSwitcher` has one string of its own, the trigger's `aria-label`, `Switch language` by
329
+ default. Translate it through `texts={{ trigger: … }}`, from a namespace you fetch yourself (the
330
+ package fetches nothing for its components). Each language name renders with `lang` set to its
331
+ locale, so a screen reader says `Deutsch` or `日本語` in that language.
332
+
328
333
  `MissingTranslationBanner` has one slot, `root` (the banner `div`).
329
334
 
330
335
  Hooks rendered whether or not you pass `classes`, for CSS or your own scripts:
@@ -394,6 +399,33 @@ name. A locale without a `countryCode` gets the name alone, and so does every lo
394
399
  pass no `flag`. A recipe that copies only the SVGs your locales use from the `flag-icons` npm
395
400
  package is under [Flags in the switcher](#flags-in-the-switcher) in "Using the skill".
396
401
 
402
+ ### Deployment targets
403
+
404
+ **Workers Free or Paid, on your own zone.** Everything on this page applies as written.
405
+
406
+ **Cloudflare for SaaS.** The Worker is the SaaS zone's fallback origin on a `*/*` route. It is
407
+ a regular Worker: `request.cf`, `caches.default` and `env` behave as usual, and the host-only
408
+ language cookie and the relative redirects keep visitors on their custom hostname. Two
409
+ constraints:
410
+
411
+ - `keyPrefix` must be a hostname inside the zone that `CLOUDFLARE_ZONE_ID` names: the zone name
412
+ itself or a subdomain of it. Single-file purge reaches only URLs on hostnames that zone owns.
413
+ With a `keyPrefix` on another domain, `migrate` reports success and nothing is purged.
414
+ - One tenant per build. `keyPrefix`, the locales, `defaultLocale` and `countryToLocale` are
415
+ build-time constants, so one Worker serving many custom hostnames serves the same translation
416
+ set to all of them.
417
+
418
+ **Workers for Platforms, trusted namespace.** `request.cf` and `caches.default` are available.
419
+ The Cache API is shared across the namespace and the cache URL is `https://<keyPrefix>/…`, so
420
+ `keyPrefix` must be unique per tenant, or two tenants read each other's translations from the
421
+ cache.
422
+
423
+ **Workers for Platforms, untrusted namespace.** `request.cf` is absent, so the country step of
424
+ the locale cascade never runs. `caches.default` is disabled, so every render reads its
425
+ namespaces from KV in one bulk read: the output is correct, only the cache layer and its KV
426
+ savings are gone. Set `I18N_CACHE=off` so the behaviour is explicit, and expect one bulk KV read
427
+ per render.
428
+
397
429
  <!-- skill-guide:start -->
398
430
 
399
431
  ## Using the skill
@@ -448,6 +480,28 @@ turns the plugin on there, under `enabledPlugins` (`"astro-i18n@edgekits": true`
448
480
  honours those entries for a teammate once they trust the folder, but it does not download the
449
481
  plugin for them: each teammate runs the same `claude plugin install … --scope project` once.
450
482
 
483
+ A marketplace added from the `/plugin` panel, or with `/plugin marketplace add`, is stored in
484
+ your user settings, even when you then install the plugin at project scope, so the committed
485
+ `.claude/settings.json` holds only `enabledPlugins`. On a fresh clone a teammate therefore first
486
+ adds the marketplace: `/plugin marketplace add EdgeKits/skills`, or the CLI line with
487
+ `--scope project` above. Declaring it in `.claude/settings.json` under `extraKnownMarketplaces`,
488
+ keyed by the marketplace's name as in the block below, spares them that step once they trust the
489
+ folder. In both cases the plugin itself is still installed once per contributor with
490
+ `claude plugin install astro-i18n@edgekits --scope project`, as the paragraph above says.
491
+
492
+ ```json
493
+ {
494
+ "extraKnownMarketplaces": {
495
+ "edgekits": {
496
+ "source": { "source": "github", "repo": "EdgeKits/skills" }
497
+ }
498
+ },
499
+ "enabledPlugins": {
500
+ "astro-i18n@edgekits": true
501
+ }
502
+ }
503
+ ```
504
+
451
505
  Any other agent, with the [skills CLI](https://skills.sh):
452
506
 
453
507
  ```sh
package/dist/cli/index.js CHANGED
@@ -144,7 +144,8 @@ async function run(command, args) {
144
144
  console.log('Generated:\n' + outputs.map((p) => `- ${path.relative(ROOT, p).split(path.sep).join('/')}`).join('\n'));
145
145
  // -------------------------------------------------------------------------
146
146
  // 5. Push to KV + purge changed cache entries via Cloudflare API
147
- // seed → local KV only, no purge (local KV; the edge cache is not involved), hashes untouched
147
+ // seed → local KV only, no purge, hashes untouched (a warm local cache under
148
+ // `astro dev` is the consumer's to drop: `I18N_CACHE=off`, restart)
148
149
  // migrate → remote KV + Cloudflare Purge API, hashes written on success
149
150
  // -------------------------------------------------------------------------
150
151
  if (!shouldDeploy) {
@@ -207,7 +208,8 @@ async function deploy({ config, kvId, wranglerBin, isLocal, changedKeys, current
207
208
  process.exit(1);
208
209
  }
209
210
  if (isLocal) {
210
- // Local KV: no purge (the edge cache is not involved), and the hashes describe production only.
211
+ // Local KV: no purge (a warm local cache under `astro dev` is the consumer's to drop),
212
+ // and the hashes describe production only.
211
213
  console.log('[i18n] .i18n-hashes.json left untouched (local seed).');
212
214
  return;
213
215
  }
@@ -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 computes `preferredLocale` (the middleware's Accept-Language step) only
23
- // with an `i18n` block: set it when the project has none, check it when it has one.
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.6",
3
+ "version": "0.1.7",
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
@@ -197,7 +197,8 @@ async function run(command: Command, args: string[]): Promise<void> {
197
197
 
198
198
  // -------------------------------------------------------------------------
199
199
  // 5. Push to KV + purge changed cache entries via Cloudflare API
200
- // seed → local KV only, no purge (local KV; the edge cache is not involved), hashes untouched
200
+ // seed → local KV only, no purge, hashes untouched (a warm local cache under
201
+ // `astro dev` is the consumer's to drop: `I18N_CACHE=off`, restart)
201
202
  // migrate → remote KV + Cloudflare Purge API, hashes written on success
202
203
  // -------------------------------------------------------------------------
203
204
 
@@ -289,7 +290,8 @@ async function deploy({
289
290
  }
290
291
 
291
292
  if (isLocal) {
292
- // Local KV: no purge (the edge cache is not involved), and the hashes describe production only.
293
+ // Local KV: no purge (a warm local cache under `astro dev` is the consumer's to drop),
294
+ // and the hashes describe production only.
293
295
  console.log('[i18n] .i18n-hashes.json left untouched (local seed).')
294
296
  return
295
297
  }
@@ -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="Switch language"
42
+ aria-label={texts?.trigger ?? 'Switch language'}
40
43
  >
41
44
  {
42
45
  TriggerIcon ? (
@@ -76,7 +79,8 @@ const { uiLocale: currentLang } = Astro.locals
76
79
  role="menuitem"
77
80
  disabled={isActive}
78
81
  >
79
- <span class={classes.itemLabel}>
82
+ {/* The label is the language's own name: `lang` lets a screen reader say it in that language. */}
83
+ <span class={classes.itemLabel} lang={locale}>
80
84
  {Flag && flagCode && <Flag code={flagCode} size={16} />}
81
85
  {label.name}
82
86
  </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
- // Cache API unavailable (e.g. Astro dev server without Miniflare)
65
- debug('i18n Cache API unavailable, using KV only', {
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
  })
@@ -38,8 +38,8 @@ export function edgekitsI18n(config: I18nConfig, options: EdgekitsI18nOptions =
38
38
  astroConfig.root,
39
39
  )
40
40
 
41
- // Astro computes `preferredLocale` (the middleware's Accept-Language step) only
42
- // with an `i18n` block: set it when the project has none, check it when it has one.
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({
@@ -5,19 +5,19 @@
5
5
  *
6
6
  * Order (unchanged from the middleware):
7
7
  * 1. the lang cookie, if valid;
8
- * 2. the preferred locale (the middleware passes Astro's `preferredLocale`: its match of
9
- * Accept-Language against the project's Astro `i18n.locales`, undefined when nothing
10
- * matches or `i18n` is not configured), full match, then its language part
11
- * ('pt-br' -> 'pt');
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
- preferredLocale: () => string | undefined
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 browserRaw = inputs.preferredLocale()
36
- if (browserRaw) {
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
- // 2) Fallback: language part only, e.g. 'pt-br' -> 'pt'
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))
@@ -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. Astro's `preferredLocale` (Accept-Language matched against the project's Astro
89
- // `i18n.locales`): full tag, then its language part.
90
- // 3. Only when Astro reports no preferred locale: the Cloudflare Geo-IP country, through
91
- // the project's `countryToLocale` table (skipped when the config has none).
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
- preferredLocale: () => context.preferredLocale,
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(', ')}.`,