@edgekits/astro-i18n 0.1.7 → 0.2.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +98 -27
  3. package/dist/integration/index.d.ts +25 -4
  4. package/dist/integration/index.js +35 -9
  5. package/dist/integration/vite-plugin.d.ts +1 -1
  6. package/dist/types.d.ts +7 -55
  7. package/dist/types.js +1 -44
  8. package/package.json +7 -17
  9. package/src/components/LanguageSwitcher.astro +89 -91
  10. package/src/config.entry.ts +5 -36
  11. package/src/constants.ts +5 -3
  12. package/src/cookie-storage.ts +19 -25
  13. package/src/fetcher.ts +3 -304
  14. package/src/format.ts +3 -107
  15. package/src/instance.ts +44 -0
  16. package/src/integration/index.ts +38 -9
  17. package/src/integration/vite-plugin.ts +1 -1
  18. package/src/middleware/i18n.ts +232 -114
  19. package/src/resolve-locale.ts +7 -39
  20. package/src/runtime.ts +2 -2
  21. package/src/schema.ts +3 -11
  22. package/src/translations-keys.ts +7 -38
  23. package/src/types.ts +16 -116
  24. package/dist/cli/generate.d.ts +0 -50
  25. package/dist/cli/generate.js +0 -160
  26. package/dist/cli/hashes.d.ts +0 -19
  27. package/dist/cli/hashes.js +0 -42
  28. package/dist/cli/index.d.ts +0 -2
  29. package/dist/cli/index.js +0 -368
  30. package/dist/cli/load-config.d.ts +0 -7
  31. package/dist/cli/load-config.js +0 -19
  32. package/dist/cli/purge.d.ts +0 -29
  33. package/dist/cli/purge.js +0 -94
  34. package/dist/cli/wrangler.d.ts +0 -10
  35. package/dist/cli/wrangler.js +0 -71
  36. package/dist/key-format.d.ts +0 -8
  37. package/dist/key-format.js +0 -15
  38. package/src/accept-language.ts +0 -56
  39. package/src/assemble.ts +0 -29
  40. package/src/cli/generate.ts +0 -205
  41. package/src/cli/hashes.ts +0 -66
  42. package/src/cli/index.ts +0 -467
  43. package/src/cli/load-config.ts +0 -26
  44. package/src/cli/purge.ts +0 -122
  45. package/src/cli/wrangler.ts +0 -96
  46. package/src/deep-merge.ts +0 -38
  47. package/src/key-format.ts +0 -17
  48. package/src/locale-fallback.ts +0 -58
  49. package/src/markdown-path.ts +0 -31
  50. package/src/parse-boolean.ts +0 -20
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.2.0 — 2026-10-27
8
+
9
+ ### Changed
10
+
11
+ - The package is an adapter over [`@edgekits/i18n-core`](https://github.com/EdgeKits/i18n/tree/main/packages/core#readme), the framework-free engine extracted from it. Same entries, same exports, same generated files. The `edgekits-i18n` command now ships with the core and reaches your project's `.bin` through this package's dependency; `jiti` and `jsonc-parser` are no longer direct dependencies of this package. Config validation errors are prefixed `[@edgekits/i18n-core]`.
12
+ - The switcher's client script builds its target URL through the routing mode, so the one script Astro inlines per page changes.
13
+ - A project's own Astro `i18n` block must keep `prefixDefaultLocale: true`, in both routing modes (the build stops otherwise): Astro checks the URL after the middleware, on the rewritten `/<defaultLocale>/…` path, and with `false` it would answer 404 there.
14
+
15
+ ### Added
16
+
17
+ - `routing: 'prefix-except-default'`: the default locale at the root (`/`, `/about/`), the other locales under their prefix (`/de/about/`). It is the default when `routing` is left out, so new projects get it. Configs that say `routing: 'prefix'` are unchanged. See README "Routing modes".
18
+ - `localizedPath` in `/runtime` (and `./format`): the path of a page in a locale for either mode, for links, canonical, hreflang and sitemaps.
19
+ - `<language-switcher>` carries `data-routing` and `data-default-locale`.
20
+
21
+ ### Fixed
22
+
23
+ - After a failed KV read, the fallback text is no longer written to the edge cache for the requested locale. It was cached for a year, so one KV error could leave a locale showing default-locale text until the next `migrate` purged it.
24
+
25
+ ### Docs
26
+
27
+ - The skill asks the routing question, its SEO references build links, canonical, hreflang and the sitemap with `localizedPath`, and the plugin ships a second skill, `tanstack-i18n`.
28
+
29
+ ## 0.1.8 — 2026-10-18
30
+
31
+ ### Added
32
+
33
+ - `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.
34
+
35
+ ### Fixed
36
+
37
+ - `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.
38
+
39
+ ### Docs
40
+
41
+ - 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.
42
+
7
43
  ## 0.1.7 — 2026-10-17
8
44
 
9
45
  ### Fixed
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
 
@@ -68,23 +69,24 @@ export default defineI18nConfig({
68
69
  keyPrefix: 'example.com',
69
70
  // Whether MissingTranslationBanner may render.
70
71
  missingTranslationBanner: true,
71
- // Every URL starts with its locale (/en/, /de/about/). The only routing there is.
72
+ // Every URL starts with its locale (/en/, /de/about/). Leave it out for the default,
73
+ // 'prefix-except-default': the default locale at the root (see Routing modes).
72
74
  routing: 'prefix',
73
75
  })
74
76
  ```
75
77
 
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) |
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) |
88
90
 
89
91
  Locale codes (the keys of `languages`) are lowercase everywhere: the config, the `locales/`
90
92
  folders and the URLs (`pt-br`, not `pt-BR`). `toIetfTag()` from `./format` gives the IETF form
@@ -116,10 +118,10 @@ The integration sets up:
116
118
  - a Vite plugin serving the config and your generated files to the runtime as virtual modules;
117
119
  - `vite.ssr.noExternal` for the package, whose runtime ships as source;
118
120
  - Astro's `i18n` block, when `astro.config` has none: `locales` from `languages`, your
119
- `defaultLocale`, `routing: { prefixDefaultLocale: true, redirectToDefaultLocale: false }`.
120
- Astro needs it to set `Astro.currentLocale`, which `resolveLocale` reads. If you keep your own
121
- block, its `locales` must include every configured locale and its `defaultLocale` must match,
122
- or the build stops with a message saying which;
121
+ `defaultLocale`, `routing: { prefixDefaultLocale: true, redirectToDefaultLocale: false }` in
122
+ both modes. Astro needs it to set `Astro.currentLocale`, which `resolveLocale` reads. If you
123
+ keep your own block, its `locales` must include every configured locale, its `defaultLocale`
124
+ must match, and its `routing` must be that one, or the build stops with a message saying which;
123
125
  - the `Locale` type: the union of your locales, for the package's exports;
124
126
  - type declarations for its virtual modules, so `tsc` and `astro check` accept the package's
125
127
  runtime source in your program;
@@ -135,9 +137,10 @@ The integration sets up:
135
137
  export const onRequest = sequence(i18n, yourOwnMiddleware)
136
138
  ```
137
139
 
138
- The middleware redirects a URL without a locale (`/`, `/about/`) to a localized one and sets
139
- `Astro.locals.uiLocale` (the URL's locale) and `Astro.locals.translationLocale` (the locale to
140
- fetch translations for) on every request.
140
+ In `'prefix'` the middleware redirects a URL without a locale (`/`, `/about/`) to a localized
141
+ one; in `'prefix-except-default'` such a URL is a default-locale page (see
142
+ [Routing modes](#routing-modes)). Either way it sets `Astro.locals.uiLocale` (the URL's locale)
143
+ and `Astro.locals.translationLocale` (the locale to fetch translations for) on every request.
141
144
 
142
145
  ### 5. Write translations
143
146
 
@@ -173,6 +176,9 @@ its first element, and a locale that has the list replaces it whole.
173
176
  npx edgekits-i18n seed
174
177
  ```
175
178
 
179
+ The `edgekits-i18n` command comes from `@edgekits/i18n-core`, which this package depends on, so
180
+ it is in your project's `node_modules/.bin` with no extra install.
181
+
176
182
  | Command | Writes | Then |
177
183
  | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
178
184
  | `edgekits-i18n bundle` | `i18n-data.json` (the KV payload), `src/i18n.generated.d.ts` (the schema), `<generatedDir>/runtime-constants.ts` | nothing else |
@@ -305,6 +311,56 @@ export default defineI18nConfig({
305
311
  Use it when your markdown endpoints exist in one locale only. Without it, the package assumes
306
312
  nothing about them.
307
313
 
314
+ In `'prefix-except-default'` the default locale has no prefix here either: with
315
+ `markdownLocale: 'en'` (the default locale) the canonical form is `/blog/x.md`, and
316
+ `/en/blog/x.md` redirects to it. A `.md` path without a locale segment is served from
317
+ `src/pages/[lang]/` as the default locale's, like any root page.
318
+
319
+ ### Routing modes
320
+
321
+ `routing` decides where the default locale lives. Pages stay in `src/pages/[lang]/…` in both.
322
+
323
+ - `'prefix-except-default'` (the default when `routing` is left out): the default locale at the
324
+ root (`/`, `/about/`), every other locale under its prefix (`/de/about/`). The middleware
325
+ serves a root URL by rewriting it internally to `/<defaultLocale>/…`, so the URL stays;
326
+ `/en/about/` redirects permanently to `/about/`. A root URL consults neither Accept-Language
327
+ nor the country: only a lang cookie for another locale (the visitor chose it in the switcher)
328
+ redirects, temporarily, to that locale.
329
+ - `'prefix'`: every URL starts with its locale (`/en/about/`); `/` and a URL without a locale
330
+ redirect to the visitor's locale (cookie, Accept-Language, country, default). Projects that
331
+ set `routing: 'prefix'` keep exactly this by leaving their config alone.
332
+
333
+ In `'prefix-except-default'`, build canonical and hreflang URLs from `Astro.originPathname`
334
+ (the URL the visitor requested; `Astro.url` is the rewritten `/<defaultLocale>/…`) through
335
+ `localizedPath` from `/runtime`. Astro's own `astro:i18n` URL helpers prefix the default locale
336
+ here, so `localizedPath` is the one to use:
337
+
338
+ ```astro
339
+ ---
340
+ import { DEFAULT_LOCALE, I18N_CONFIG, SUPPORTED_LOCALES, localizedPath, toIetfTag } from '@edgekits/astro-i18n/runtime'
341
+
342
+ const options = {
343
+ routing: I18N_CONFIG.routing,
344
+ defaultLocale: DEFAULT_LOCALE,
345
+ isLocale: (segment: string) => (SUPPORTED_LOCALES as readonly string[]).includes(segment),
346
+ }
347
+ const path = Astro.originPathname
348
+ ---
349
+
350
+ <link rel="canonical" href={new URL(localizedPath(Astro.locals.uiLocale, path, options), Astro.site)} />
351
+ {SUPPORTED_LOCALES.map((locale) => <link rel="alternate" hreflang={toIetfTag(locale)} href={new URL(localizedPath(locale, path, options), Astro.site)} />)}
352
+ <link rel="alternate" hreflang="x-default" href={new URL(localizedPath(DEFAULT_LOCALE, path, options), Astro.site)} />
353
+ ```
354
+
355
+ Two limits:
356
+
357
+ - A root path whose first segment equals a locale code is read as that locale: a default-locale
358
+ page at `/de/` (a content slug `de`) cannot exist next to the German site.
359
+ - A shared HTML cache in front of the site must key root paths on the lang cookie, or skip
360
+ caching requests that carry one: the same URL renders for a cookie-less visitor and
361
+ redirects one whose cookie names another locale. Under `'prefix'`, `/en/` is in the same
362
+ situation: it answers a visitor whose cookie names another locale with a `Set-Cookie`.
363
+
308
364
  ### Styling the components
309
365
 
310
366
  `LanguageSwitcher` and `MissingTranslationBanner` are headless: they carry structure, behaviour
@@ -408,9 +464,13 @@ a regular Worker: `request.cf`, `caches.default` and `env` behave as usual, and
408
464
  language cookie and the relative redirects keep visitors on their custom hostname. Two
409
465
  constraints:
410
466
 
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.
467
+ - `keyPrefix` is the host of every cache URL that `migrate` purges from the zone
468
+ `CLOUDFLARE_ZONE_ID` names. Recommended: a hostname of that zone (the zone name or a
469
+ subdomain). A unique name without a dot works too: single-file purge from your zone reaches
470
+ any URL whose hostname is no proxied Cloudflare zone. Never another domain that is a proxied
471
+ Cloudflare zone: its URLs can be purged only from that zone, by its owner, and the Purge API
472
+ reports success anyway. When the token can read the zone (Zone › Zone › Read), `migrate`
473
+ warns about a dotted `keyPrefix` outside the zone.
414
474
  - One tenant per build. `keyPrefix`, the locales, `defaultLocale` and `countryToLocale` are
415
475
  build-time constants, so one Worker serving many custom hostnames serves the same translation
416
476
  set to all of them.
@@ -433,7 +493,7 @@ per render.
433
493
  `astro-i18n` is a free agent skill that sets up `@edgekits/astro-i18n` in your project and keeps
434
494
  working with it afterwards. It runs in Claude Code as a plugin, and in Cursor, OpenCode or any other
435
495
  coding agent that reads `SKILL.md`. Its source is in
436
- [`plugins/astro-i18n`](https://github.com/EdgeKits/astro-i18n/tree/main/plugins/astro-i18n).
496
+ [`plugins/astro-i18n`](https://github.com/EdgeKits/i18n/tree/main/plugins/astro-i18n).
437
497
 
438
498
  ### What it does
439
499
 
@@ -505,7 +565,7 @@ folder. In both cases the plugin itself is still installed once per contributor
505
565
  Any other agent, with the [skills CLI](https://skills.sh):
506
566
 
507
567
  ```sh
508
- npx skills add EdgeKits/astro-i18n --skill astro-i18n
568
+ npx skills add EdgeKits/i18n --skill astro-i18n
509
569
  ```
510
570
 
511
571
  ### Prerequisites
@@ -521,6 +581,8 @@ npx skills add EdgeKits/astro-i18n --skill astro-i18n
521
581
  - A Cloudflare account. Local development needs none until you create the real KV namespace.
522
582
  - For `edgekits-i18n migrate` (pushing copy to production): the site on a proxied Cloudflare
523
583
  zone (orange cloud), that zone's ID, and an API token with the Zone › Cache Purge permission.
584
+ Zone › Zone › Read on the same token is optional: with it, `migrate` checks `keyPrefix`
585
+ against the zone's name.
524
586
 
525
587
  ### First run on a fresh site
526
588
 
@@ -544,12 +606,18 @@ The skill asks before it writes any code, and waits for the answers:
544
606
  | Should a visitor's country pick the locale when nothing else does? How? | `DE`, `AT`, `CH` → `de`; `BR` → `pt-br` |
545
607
  | Middleware: registered by the integration (`auto`), or in your own sequence? | `auto` |
546
608
  | Flags in the language switcher? | Yes: `en` → `us`, `de` → `de`, `pt-br` → `br` |
609
+ | Routing: the default locale at the root, or every locale prefixed? | At the root |
547
610
 
548
611
  For flags, a regional locale takes its region (`pt-br` → `br`, `en-gb` → `gb`), and for a
549
612
  language-only locale the skill proposes the obvious country (`en` → `us`) for you to confirm. It
550
613
  shows the full list back before it writes the config. If you have no preference on any of
551
614
  these, it uses the defaults: no geo table, no markdown locale, `auto` middleware, flags on.
552
615
 
616
+ For routing, the default locale at the root (`/`, `/about/`; other locales prefixed) is
617
+ `routing: 'prefix-except-default'`, the default; every locale prefixed (`/en/about/`) is
618
+ `routing: 'prefix'`. The skill asks only with two or more locales; with one, the root mode is
619
+ the only sensible one and needs no question.
620
+
553
621
  #### What it creates
554
622
 
555
623
  - `edgekits-i18n.config.ts` with your answers, and the integration in `astro.config`.
@@ -559,11 +627,14 @@ these, it uses the defaults: no geo table, no markdown locale, `auto` middleware
559
627
  `src/i18n.generated.d.ts`, `src/i18n/runtime-constants.ts`, `src/i18n/fallbacks.generated.ts`.
560
628
  Commit them.
561
629
  - The route layout: `src/pages/about.astro` becomes `src/pages/[lang]/about.astro`, and so on.
562
- The root `index.astro` moves too, since the middleware redirects `/` to `/<locale>/`.
630
+ The root `index.astro` moves too: in `'prefix'` the middleware redirects `/` to
631
+ `/<locale>/`, and in `'prefix-except-default'` it serves the default locale's page there.
563
632
  Endpoints that are not localized (`robots.txt`, `sitemap.xml`, `/api/*`) and `public/` stay
564
633
  where they are.
565
634
  - `src/components/Flag.astro` and one SVG per flag in `public/flags/`, if you chose flags (the
566
635
  recipe is below).
636
+ - The language switcher and the hreflang tags work in both routing modes: the hreflang
637
+ component builds its URLs with `localizedPath`, which knows the mode.
567
638
  - A KV namespace on your Cloudflare account (`wrangler kv namespace create`). The skill asks
568
639
  before it runs that command. Until then, local development works with a placeholder id of 32
569
640
  hexadecimal characters, such as `00000000000000000000000000000000`: the CLI checks the id's
@@ -1,5 +1,5 @@
1
1
  import type { AstroConfig, AstroIntegration } from 'astro';
2
- import { type I18nConfig } from '../types.ts';
2
+ import { type I18nConfig, type I18nRouting } from '@edgekits/i18n-core';
3
3
  export type EdgekitsI18nOptions = {
4
4
  middleware?: 'auto' | 'manual';
5
5
  };
@@ -10,6 +10,14 @@ export declare const PACKAGE_NAME = "@edgekits/astro-i18n";
10
10
  * plugin's virtual modules.
11
11
  */
12
12
  export declare function edgekitsI18n(config: I18nConfig, options?: EdgekitsI18nOptions): AstroIntegration;
13
+ /**
14
+ * The validated config with `routing` resolved: absent means 'prefix-except-default', the
15
+ * mode for new projects. Resolved here, before the Vite plugin serialises it, so the
16
+ * virtual config (and `I18N_CONFIG`) always carries the mode the middleware runs.
17
+ */
18
+ export declare function resolveConfig(config: I18nConfig): I18nConfig & {
19
+ routing: I18nRouting;
20
+ };
13
21
  /**
14
22
  * Augments `I18nLocales` with the configured locales, so `Locale` narrows from
15
23
  * `string` to their union in the project, through both entries. The `export {}` keeps
@@ -19,8 +27,21 @@ export declare function edgekitsI18n(config: I18nConfig, options?: EdgekitsI18nO
19
27
  export declare function localesDeclaration(locales: readonly string[]): string;
20
28
  /**
21
29
  * The Astro `i18n` block set when the project has none: locales and default from the
22
- * config; every URL locale-prefixed (the middleware redirects `/` itself, so Astro
23
- * does not). Complete, `fallbackType` included: Astro does not re-validate what
24
- * `updateConfig` merges, so its schema defaults never fill a partial block.
30
+ * config; routed as locale-prefixed in both modes (the middleware redirects `/` itself in
31
+ * 'prefix', so Astro does not). Complete, `fallbackType` included: Astro does not
32
+ * re-validate what `updateConfig` merges, so its schema defaults never fill a partial block.
33
+ *
34
+ * `prefixDefaultLocale: true` in 'prefix-except-default' too: Astro's own i18n check runs
35
+ * after the middleware, on the rewritten URL (astro/dist/core/routing/handler.js:128-130),
36
+ * and with `prefixDefaultLocale: false` the strategy `pathname-prefix-other-locales`
37
+ * answers notFound to any path carrying the default locale (astro/dist/i18n/router.js:
38
+ * 104-121) — the rewritten `/<default>/about/` is exactly that. With `true` the check
39
+ * passes and `Astro.currentLocale` works on root pages.
25
40
  */
26
41
  export declare function astroI18n(config: I18nConfig): NonNullable<AstroConfig['i18n']>;
42
+ /**
43
+ * A project's own Astro `i18n` block must cover every configured locale (as a string
44
+ * entry, a `path` or one of its `codes`), share the default locale, and route as
45
+ * `astroI18n` does (`prefixDefaultLocale: true`, `redirectToDefaultLocale: false`).
46
+ */
47
+ export declare function assertAstroI18n(block: NonNullable<AstroConfig['i18n']>, config: I18nConfig): void;
@@ -1,4 +1,4 @@
1
- import { DEFAULT_GENERATED_DIR, defineI18nConfig } from "../types.js";
1
+ import { DEFAULT_GENERATED_DIR, defineI18nConfig } from '@edgekits/i18n-core';
2
2
  import { i18nVitePlugin } from "./vite-plugin.js";
3
3
  import { virtualModulesDeclaration } from "./virtual-types.js";
4
4
  const MIDDLEWARE_MODES = ['auto', 'manual'];
@@ -9,7 +9,7 @@ export const PACKAGE_NAME = '@edgekits/astro-i18n';
9
9
  * plugin's virtual modules.
10
10
  */
11
11
  export function edgekitsI18n(config, options = {}) {
12
- const i18nConfig = defineI18nConfig(config);
12
+ const i18nConfig = resolveConfig(config);
13
13
  const middleware = options.middleware ?? 'auto';
14
14
  if (!MIDDLEWARE_MODES.includes(middleware)) {
15
15
  throw new Error(`[@edgekits/astro-i18n] \`middleware\` "${String(middleware)}" is not supported; use 'auto' or 'manual'.`);
@@ -45,8 +45,18 @@ export function edgekitsI18n(config, options = {}) {
45
45
  },
46
46
  };
47
47
  }
48
- // Each entry that exports `I18nLocales` from its own declaration: the root entry's comes
49
- // from dist/types.d.ts, the runtime entry's from the shipped source (src/types.ts).
48
+ /**
49
+ * The validated config with `routing` resolved: absent means 'prefix-except-default', the
50
+ * mode for new projects. Resolved here, before the Vite plugin serialises it, so the
51
+ * virtual config (and `I18N_CONFIG`) always carries the mode the middleware runs.
52
+ */
53
+ export function resolveConfig(config) {
54
+ const validated = defineI18nConfig(config);
55
+ return { ...validated, routing: validated.routing ?? 'prefix-except-default' };
56
+ }
57
+ // Each entry that exports `I18nLocales`: both re-export the core's interface (root through
58
+ // dist/types.d.ts, runtime through the shipped src/types.ts), so either augmentation
59
+ // merges into it; both are kept so neither entry depends on how the other resolves.
50
60
  const LOCALE_MODULES = ['@edgekits/astro-i18n', '@edgekits/astro-i18n/runtime'];
51
61
  /**
52
62
  * Augments `I18nLocales` with the configured locales, so `Locale` narrows from
@@ -71,9 +81,16 @@ export function localesDeclaration(locales) {
71
81
  }
72
82
  /**
73
83
  * The Astro `i18n` block set when the project has none: locales and default from the
74
- * config; every URL locale-prefixed (the middleware redirects `/` itself, so Astro
75
- * does not). Complete, `fallbackType` included: Astro does not re-validate what
76
- * `updateConfig` merges, so its schema defaults never fill a partial block.
84
+ * config; routed as locale-prefixed in both modes (the middleware redirects `/` itself in
85
+ * 'prefix', so Astro does not). Complete, `fallbackType` included: Astro does not
86
+ * re-validate what `updateConfig` merges, so its schema defaults never fill a partial block.
87
+ *
88
+ * `prefixDefaultLocale: true` in 'prefix-except-default' too: Astro's own i18n check runs
89
+ * after the middleware, on the rewritten URL (astro/dist/core/routing/handler.js:128-130),
90
+ * and with `prefixDefaultLocale: false` the strategy `pathname-prefix-other-locales`
91
+ * answers notFound to any path carrying the default locale (astro/dist/i18n/router.js:
92
+ * 104-121) — the rewritten `/<default>/about/` is exactly that. With `true` the check
93
+ * passes and `Astro.currentLocale` works on root pages.
77
94
  */
78
95
  export function astroI18n(config) {
79
96
  return {
@@ -84,9 +101,10 @@ export function astroI18n(config) {
84
101
  }
85
102
  /**
86
103
  * A project's own Astro `i18n` block must cover every configured locale (as a string
87
- * entry, a `path` or one of its `codes`) and share the default locale.
104
+ * entry, a `path` or one of its `codes`), share the default locale, and route as
105
+ * `astroI18n` does (`prefixDefaultLocale: true`, `redirectToDefaultLocale: false`).
88
106
  */
89
- function assertAstroI18n(block, config) {
107
+ export function assertAstroI18n(block, config) {
90
108
  const declared = new Set(block.locales.flatMap((entry) => (typeof entry === 'string' ? [entry] : [entry.path, ...entry.codes])));
91
109
  const missing = Object.keys(config.languages).filter((locale) => !declared.has(locale));
92
110
  if (missing.length > 0) {
@@ -97,6 +115,14 @@ function assertAstroI18n(block, config) {
97
115
  throw new Error(`[@edgekits/astro-i18n] Astro \`i18n.defaultLocale\` "${block.defaultLocale}" differs from \`defaultLocale\` "${config.defaultLocale}". ` +
98
116
  'Make them equal, or remove the `i18n` block from astro.config and the integration sets it for you.');
99
117
  }
118
+ // Absent values are Astro's schema defaults (both false): `redirectToDefaultLocale` left
119
+ // out is fine, `prefixDefaultLocale` left out is not.
120
+ const routing = typeof block.routing === 'object' ? block.routing : undefined;
121
+ if (routing?.prefixDefaultLocale !== true || routing.redirectToDefaultLocale === true) {
122
+ throw new Error('[@edgekits/astro-i18n] Astro `i18n.routing` must set `prefixDefaultLocale: true` and `redirectToDefaultLocale: false` ' +
123
+ `(found ${JSON.stringify(block.routing)}): the middleware handles the default locale itself, and Astro's ` +
124
+ 'own check would otherwise answer 404 or redirect the pages it serves. Set them, or remove the `i18n` block from astro.config and the integration sets it for you.');
125
+ }
100
126
  }
101
127
  function trimSlashes(path) {
102
128
  return path.replace(/^\/+|\/+$/g, '');
@@ -1,5 +1,5 @@
1
1
  import type { AstroConfig } from 'astro';
2
- import type { I18nConfig } from '../types.ts';
2
+ import type { I18nConfig } from '@edgekits/i18n-core';
3
3
  type VitePlugin = NonNullable<NonNullable<AstroConfig['vite']>['plugins']>[number];
4
4
  export declare const CONFIG_ID = "virtual:@edgekits/astro-i18n/config";
5
5
  export declare const FALLBACKS_ID = "virtual:@edgekits/astro-i18n/fallbacks";
package/dist/types.d.ts CHANGED
@@ -1,60 +1,13 @@
1
1
  /**
2
- * Public types of the package and the config validator. Pure: no virtual modules,
3
- * no `cloudflare:workers`, so the consumer's `astro.config` and the CLI can import it
4
- * under plain Node.
2
+ * The core's public types and config helpers, re-exported for this package's entries
3
+ * (root, `./config`, `./runtime`), plus what only Astro needs: the `App.Locals` the
4
+ * middleware fills. `I18nLocales` is the core's interface: augmenting it through either
5
+ * entry (the integration's injected `locales.d.ts`) merges into that one declaration.
5
6
  */
6
- export type I18nLanguage = {
7
- countryCode?: string;
8
- name: string;
9
- };
10
- export type I18nConfig = {
11
- languages: Record<string, I18nLanguage>;
12
- defaultLocale: string;
13
- cookie: {
14
- name: string;
15
- maxAge: number;
16
- };
17
- keyPrefix: string;
18
- missingTranslationBanner: boolean;
19
- routing: 'prefix';
20
- generatedDir?: string;
21
- localesDir?: string;
22
- countryToLocale?: Record<string, string>;
23
- markdownLocale?: string;
24
- };
25
- export declare const DEFAULT_GENERATED_DIR = "src/i18n";
26
- export declare const DEFAULT_LOCALES_DIR = "locales";
27
- /** The locale keys of a config's `languages`: a literal union for a literal config. */
28
- export type LocaleOf<C extends I18nConfig> = Extract<keyof C['languages'], string>;
29
- /**
30
- * Validates and returns the config unchanged. Throws at config time on an empty
31
- * `languages`, a `defaultLocale` or `markdownLocale` that is not one of its keys, an
32
- * unknown `routing`, or a `countryToLocale` entry whose key is not an uppercase country
33
- * code or whose value is not one of `languages` (also a type error for a literal config).
34
- */
35
- export declare function defineI18nConfig<const C extends I18nConfig>(config: C & {
36
- countryToLocale?: Record<string, LocaleOf<C>>;
37
- markdownLocale?: LocaleOf<C>;
38
- }): C;
39
- /**
40
- * The project's locales, as keys. Empty here; the integration augments it from the
41
- * config's `languages` (`injectTypes` at `astro:config:done`).
42
- */
43
- export interface I18nLocales {
44
- }
45
- type LocaleKey = Extract<keyof I18nLocales, string>;
46
- export type Locale = [LocaleKey] extends [never] ? string : LocaleKey;
47
- /** The Worker bindings the engine reads from `cloudflare:workers`. */
48
- export interface I18nBindings {
49
- TRANSLATIONS: KVNamespace;
50
- I18N_CACHE?: string;
51
- DEBUG_I18N?: string;
52
- }
7
+ import type { Locale } from '@edgekits/i18n-core';
8
+ export { DEFAULT_GENERATED_DIR, defineI18nConfig, localesOf } from '@edgekits/i18n-core';
9
+ export type { I18nBindings, I18nConfig, I18nLanguage, I18nLocales, Locale, LocaleOf, LocalesOf, } from '@edgekits/i18n-core';
53
10
  declare global {
54
- namespace I18n {
55
- interface Schema {
56
- }
57
- }
58
11
  namespace App {
59
12
  interface Locals {
60
13
  uiLocale: Locale;
@@ -63,4 +16,3 @@ declare global {
63
16
  }
64
17
  }
65
18
  }
66
- export {};
package/dist/types.js CHANGED
@@ -1,44 +1 @@
1
- /**
2
- * Public types of the package and the config validator. Pure: no virtual modules,
3
- * no `cloudflare:workers`, so the consumer's `astro.config` and the CLI can import it
4
- * under plain Node.
5
- */
6
- export const DEFAULT_GENERATED_DIR = 'src/i18n';
7
- export const DEFAULT_LOCALES_DIR = 'locales';
8
- const COUNTRY_CODE = /^[A-Z]{2}$/;
9
- /**
10
- * Validates and returns the config unchanged. Throws at config time on an empty
11
- * `languages`, a `defaultLocale` or `markdownLocale` that is not one of its keys, an
12
- * unknown `routing`, or a `countryToLocale` entry whose key is not an uppercase country
13
- * code or whose value is not one of `languages` (also a type error for a literal config).
14
- */
15
- export function defineI18nConfig(config) {
16
- const locales = Object.keys(config.languages ?? {});
17
- if (locales.length === 0) {
18
- throw new Error('[@edgekits/astro-i18n] `languages` must define at least one locale.');
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
- }
26
- if (!locales.includes(config.defaultLocale)) {
27
- throw new Error(`[@edgekits/astro-i18n] \`defaultLocale\` "${config.defaultLocale}" is not one of \`languages\`: ${locales.join(', ')}.`);
28
- }
29
- if (config.markdownLocale !== undefined && !locales.includes(config.markdownLocale)) {
30
- throw new Error(`[@edgekits/astro-i18n] \`markdownLocale\` "${config.markdownLocale}" is not one of \`languages\`: ${locales.join(', ')}.`);
31
- }
32
- if (config.routing !== 'prefix') {
33
- throw new Error(`[@edgekits/astro-i18n] \`routing\` "${String(config.routing)}" is not supported; the only routing is 'prefix'.`);
34
- }
35
- for (const [country, locale] of Object.entries(config.countryToLocale ?? {})) {
36
- if (!COUNTRY_CODE.test(country)) {
37
- throw new Error(`[@edgekits/astro-i18n] \`countryToLocale\` key "${country}" is not an uppercase ISO 3166-1 alpha-2 country code.`);
38
- }
39
- if (!locales.includes(locale)) {
40
- throw new Error(`[@edgekits/astro-i18n] \`countryToLocale\` maps "${country}" to "${locale}", which is not one of \`languages\`: ${locales.join(', ')}.`);
41
- }
42
- }
43
- return config;
44
- }
1
+ export { DEFAULT_GENERATED_DIR, defineI18nConfig, localesOf } from '@edgekits/i18n-core';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgekits/astro-i18n",
3
- "version": "0.1.7",
3
+ "version": "0.2.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",
@@ -16,11 +16,12 @@
16
16
  "author": "Gary Stupak",
17
17
  "repository": {
18
18
  "type": "git",
19
- "url": "git+https://github.com/EdgeKits/astro-i18n.git"
19
+ "url": "git+https://github.com/EdgeKits/i18n.git",
20
+ "directory": "packages/astro"
20
21
  },
21
- "homepage": "https://github.com/EdgeKits/astro-i18n#readme",
22
+ "homepage": "https://github.com/EdgeKits/i18n#readme",
22
23
  "bugs": {
23
- "url": "https://github.com/EdgeKits/astro-i18n/issues"
24
+ "url": "https://github.com/EdgeKits/i18n/issues"
24
25
  },
25
26
  "engines": {
26
27
  "node": ">=22"
@@ -37,9 +38,6 @@
37
38
  "./components/*": "./src/components/*.astro",
38
39
  "./package.json": "./package.json"
39
40
  },
40
- "bin": {
41
- "edgekits-i18n": "dist/cli/index.js"
42
- },
43
41
  "files": [
44
42
  "dist",
45
43
  "src",
@@ -59,8 +57,7 @@
59
57
  "format:check": "prettier --check ."
60
58
  },
61
59
  "dependencies": {
62
- "jiti": "^2.7.0",
63
- "jsonc-parser": "^3.3.1"
60
+ "@edgekits/i18n-core": "0.2.0"
64
61
  },
65
62
  "peerDependencies": {
66
63
  "@astrojs/cloudflare": "^14.3.0",
@@ -68,13 +65,6 @@
68
65
  "wrangler": "^4.125.0"
69
66
  },
70
67
  "devDependencies": {
71
- "@cloudflare/workers-types": "^5.20261011.1",
72
- "@types/node": "^25.9.1",
73
- "astro": "^7.3.8",
74
- "prettier": "3.9.4",
75
- "prettier-plugin-astro": "0.14.1",
76
- "prettier-plugin-tailwindcss": "0.8.0",
77
- "tsx": "^4.23.12",
78
- "typescript": "~5.9.3"
68
+ "astro": "^7.3.8"
79
69
  }
80
70
  }