@edgekits/astro-i18n 0.2.0 → 0.2.2
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 +15 -0
- package/README.md +14 -9
- package/package.json +2 -2
- package/src/middleware/i18n.ts +50 -16
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,21 @@ 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.2 — 2026-10-27
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- `'prefix-except-default'` keeps the query string through its rewrites and redirects: `/?sent=1` renders the success state, and a form posted to `/?_action=…` runs its action.
|
|
12
|
+
- In `'prefix'` mode the redirects keep the query string too (`/?utm=x` -> `/en/?utm=x`).
|
|
13
|
+
- `'prefix-except-default'` serves the default locale's `[lang]` endpoints at the root whatever their extension (`/rss.xml` from `src/pages/[lang]/rss.xml.ts`; `/en/rss.xml` redirects to it); root routes such as `sitemap.xml` are served as they are.
|
|
14
|
+
- The skill's no-JS form recipe (`redirectAfterNativePost`) answers its 303 at `ctx.originPathname`, the path the form was posted from; with `ctx.url.pathname` a root-mode post landed on `/<defaultLocale>/?sent=1` and one more redirect. A project that copied the recipe makes the same change in `src/ui/forms/native-post.ts`.
|
|
15
|
+
|
|
16
|
+
## 0.2.1 — 2026-10-27
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- The Claude Code plugin is `edgekits-i18n` (was `astro-i18n`): install `edgekits-i18n@edgekits`. An existing `astro-i18n@edgekits` install is not updated: remove it and install the new name. No code change.
|
|
21
|
+
|
|
7
22
|
## 0.2.0 — 2026-10-27
|
|
8
23
|
|
|
9
24
|
### Changed
|
package/README.md
CHANGED
|
@@ -323,13 +323,18 @@ In `'prefix-except-default'` the default locale has no prefix here either: with
|
|
|
323
323
|
- `'prefix-except-default'` (the default when `routing` is left out): the default locale at the
|
|
324
324
|
root (`/`, `/about/`), every other locale under its prefix (`/de/about/`). The middleware
|
|
325
325
|
serves a root URL by rewriting it internally to `/<defaultLocale>/…`, so the URL stays;
|
|
326
|
-
`/en/about/` redirects permanently to `/about/`.
|
|
327
|
-
|
|
328
|
-
|
|
326
|
+
`/en/about/` redirects permanently to `/about/`. An endpoint under `src/pages/[lang]/`
|
|
327
|
+
answers at the root too (`/rss.xml`, and `/en/rss.xml` redirects to it), while a root route
|
|
328
|
+
of its own (`sitemap.xml`, `robots.txt`) is served as is. A root URL consults neither
|
|
329
|
+
Accept-Language nor the country: only a lang cookie for another locale (the visitor chose it
|
|
330
|
+
in the switcher) redirects, temporarily, to that locale.
|
|
329
331
|
- `'prefix'`: every URL starts with its locale (`/en/about/`); `/` and a URL without a locale
|
|
330
332
|
redirect to the visitor's locale (cookie, Accept-Language, country, default). Projects that
|
|
331
333
|
set `routing: 'prefix'` keep exactly this by leaving their config alone.
|
|
332
334
|
|
|
335
|
+
In both modes, rewrites and redirects keep the query string (`/?utm=x` redirects to
|
|
336
|
+
`/en/?utm=x` in `'prefix'`, and renders `/` with `?utm=x` in `'prefix-except-default'`).
|
|
337
|
+
|
|
333
338
|
In `'prefix-except-default'`, build canonical and hreflang URLs from `Astro.originPathname`
|
|
334
339
|
(the URL the visitor requested; `Astro.url` is the rewritten `/<defaultLocale>/…`) through
|
|
335
340
|
`localizedPath` from `/runtime`. Astro's own `astro:i18n` URL helpers prefix the default locale
|
|
@@ -493,7 +498,7 @@ per render.
|
|
|
493
498
|
`astro-i18n` is a free agent skill that sets up `@edgekits/astro-i18n` in your project and keeps
|
|
494
499
|
working with it afterwards. It runs in Claude Code as a plugin, and in Cursor, OpenCode or any other
|
|
495
500
|
coding agent that reads `SKILL.md`. Its source is in
|
|
496
|
-
[`plugins/
|
|
501
|
+
[`plugins/edgekits-i18n`](https://github.com/EdgeKits/i18n/tree/main/plugins/edgekits-i18n).
|
|
497
502
|
|
|
498
503
|
### What it does
|
|
499
504
|
|
|
@@ -524,7 +529,7 @@ In Claude Code, add the EdgeKits marketplace once, then install the plugin:
|
|
|
524
529
|
|
|
525
530
|
```
|
|
526
531
|
/plugin marketplace add EdgeKits/skills
|
|
527
|
-
/plugin install
|
|
532
|
+
/plugin install edgekits-i18n@edgekits
|
|
528
533
|
```
|
|
529
534
|
|
|
530
535
|
That installs it for you, in every project. To set it up for everyone working on one
|
|
@@ -532,11 +537,11 @@ repository, run these in the repository and commit `.claude/settings.json`:
|
|
|
532
537
|
|
|
533
538
|
```sh
|
|
534
539
|
claude plugin marketplace add EdgeKits/skills --scope project
|
|
535
|
-
claude plugin install
|
|
540
|
+
claude plugin install edgekits-i18n@edgekits --scope project
|
|
536
541
|
```
|
|
537
542
|
|
|
538
543
|
The first command declares the EdgeKits marketplace in `.claude/settings.json`; the second
|
|
539
|
-
turns the plugin on there, under `enabledPlugins` (`"
|
|
544
|
+
turns the plugin on there, under `enabledPlugins` (`"edgekits-i18n@edgekits": true`). Claude Code
|
|
540
545
|
honours those entries for a teammate once they trust the folder, but it does not download the
|
|
541
546
|
plugin for them: each teammate runs the same `claude plugin install … --scope project` once.
|
|
542
547
|
|
|
@@ -547,7 +552,7 @@ adds the marketplace: `/plugin marketplace add EdgeKits/skills`, or the CLI line
|
|
|
547
552
|
`--scope project` above. Declaring it in `.claude/settings.json` under `extraKnownMarketplaces`,
|
|
548
553
|
keyed by the marketplace's name as in the block below, spares them that step once they trust the
|
|
549
554
|
folder. In both cases the plugin itself is still installed once per contributor with
|
|
550
|
-
`claude plugin install
|
|
555
|
+
`claude plugin install edgekits-i18n@edgekits --scope project`, as the paragraph above says.
|
|
551
556
|
|
|
552
557
|
```json
|
|
553
558
|
{
|
|
@@ -557,7 +562,7 @@ folder. In both cases the plugin itself is still installed once per contributor
|
|
|
557
562
|
}
|
|
558
563
|
},
|
|
559
564
|
"enabledPlugins": {
|
|
560
|
-
"
|
|
565
|
+
"edgekits-i18n@edgekits": true
|
|
561
566
|
}
|
|
562
567
|
}
|
|
563
568
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgekits/astro-i18n",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
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",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"format:check": "prettier --check ."
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
|
-
"@edgekits/i18n-core": "0.2.
|
|
60
|
+
"@edgekits/i18n-core": "0.2.2"
|
|
61
61
|
},
|
|
62
62
|
"peerDependencies": {
|
|
63
63
|
"@astrojs/cloudflare": "^14.3.0",
|
package/src/middleware/i18n.ts
CHANGED
|
@@ -72,6 +72,8 @@ export type I18nMiddlewareDeps = {
|
|
|
72
72
|
// dev and production alike: see negotiatedRedirect(). A browser keeps a 301 and never asks
|
|
73
73
|
// the server again, so a language chosen later in the switcher would be bypassed on the
|
|
74
74
|
// next visit to `/`, and the URL-wins cookie sync below would then overwrite that choice.
|
|
75
|
+
//
|
|
76
|
+
// Both kinds, in both routing modes, keep the query string (`?utm=…`, `?sent=1`).
|
|
75
77
|
const REDIRECT_STATUS = import.meta.env?.DEV ? 302 : 301
|
|
76
78
|
|
|
77
79
|
// A redirect whose target depends on the visitor: temporary, never stored by a shared
|
|
@@ -89,11 +91,24 @@ function negotiatedRedirect(target: string, vary = 'Cookie, Accept-Language'): R
|
|
|
89
91
|
})
|
|
90
92
|
}
|
|
91
93
|
|
|
94
|
+
const isIgnoredPrefix = (pathname: string) => IGNORED_PREFIXES.some((p) => pathname.startsWith(p))
|
|
95
|
+
|
|
96
|
+
// The project's localized routes live under `src/pages/[lang]/`. `context.routePattern` is
|
|
97
|
+
// the route Astro matched for the original URL, before any rewrite: a locale-less
|
|
98
|
+
// `/rss.xml` matches `/[lang]` (lang = "rss.xml") or nothing (the 404 route), never a
|
|
99
|
+
// root route of its own, which `/sitemap.xml` does.
|
|
100
|
+
const isLangRoute = (routePattern: string | undefined) =>
|
|
101
|
+
routePattern === '/[lang]' || !!routePattern?.startsWith('/[lang]/')
|
|
102
|
+
|
|
103
|
+
// The pattern of the route Astro falls back to when nothing matched (a custom `404.astro`
|
|
104
|
+
// or its built-in one): astro/dist/core/routing/internal/astro-designed-error-pages.js.
|
|
105
|
+
const NOT_FOUND_ROUTE = '/404'
|
|
106
|
+
|
|
92
107
|
// Determines if the current request path matches static assets or ignored prefixes.
|
|
93
108
|
// Returns true to skip i18n processing for this request.
|
|
94
109
|
function shouldBypassI18n(pathname: string): boolean {
|
|
95
110
|
if (PUBLIC_FILE_REGEX.test(pathname)) return true
|
|
96
|
-
if (
|
|
111
|
+
if (isIgnoredPrefix(pathname)) return true
|
|
97
112
|
// Markdown endpoints: /en/blog/slug.md/ (with or without trailing slash)
|
|
98
113
|
if (MARKDOWN_PATH.test(pathname)) return true
|
|
99
114
|
return false
|
|
@@ -155,7 +170,8 @@ export function createI18nMiddleware(deps: I18nMiddlewareDeps): MiddlewareHandle
|
|
|
155
170
|
urlLocale: Locale,
|
|
156
171
|
segments: string[],
|
|
157
172
|
): Promise<Response> {
|
|
158
|
-
const
|
|
173
|
+
const url = new URL(context.request.url)
|
|
174
|
+
const pathname = url.pathname
|
|
159
175
|
// Keep the lang cookie in sync with the URL locale — but ONLY update an
|
|
160
176
|
// EXISTING cookie that differs or does not parse. Rationale (HTML edge cache, P1/P2):
|
|
161
177
|
// - The old unconditional set emitted `Set-Cookie` on EVERY localized
|
|
@@ -183,7 +199,7 @@ export function createI18nMiddleware(deps: I18nMiddlewareDeps): MiddlewareHandle
|
|
|
183
199
|
// Normalize trailing slash and structure
|
|
184
200
|
const normalized = buildLocalizedPath(urlLocale, segments.slice(1))
|
|
185
201
|
if (pathname !== normalized) {
|
|
186
|
-
return context.redirect(normalized, REDIRECT_STATUS)
|
|
202
|
+
return context.redirect(normalized + url.search, REDIRECT_STATUS)
|
|
187
203
|
}
|
|
188
204
|
|
|
189
205
|
return await next()
|
|
@@ -196,12 +212,12 @@ export function createI18nMiddleware(deps: I18nMiddlewareDeps): MiddlewareHandle
|
|
|
196
212
|
segments: string[],
|
|
197
213
|
firstSegment: string | null,
|
|
198
214
|
): Promise<Response> {
|
|
199
|
-
const pathname = new URL(context.request.url)
|
|
215
|
+
const { pathname, search } = new URL(context.request.url)
|
|
200
216
|
const urlLocale = firstSegment ? deps.parseLocale(firstSegment) : null
|
|
201
217
|
|
|
202
218
|
// The default locale's prefix is not canonical: one permanent hop to the path without it.
|
|
203
219
|
if (urlLocale === deps.defaultLocale) {
|
|
204
|
-
return context.redirect(localizedPath(deps.defaultLocale, pathname, pathOptions), REDIRECT_STATUS)
|
|
220
|
+
return context.redirect(localizedPath(deps.defaultLocale, pathname, pathOptions) + search, REDIRECT_STATUS)
|
|
205
221
|
}
|
|
206
222
|
|
|
207
223
|
if (urlLocale) {
|
|
@@ -217,7 +233,7 @@ export function createI18nMiddleware(deps: I18nMiddlewareDeps): MiddlewareHandle
|
|
|
217
233
|
const cookie = deps.readCookieLang(context.cookies)
|
|
218
234
|
if (cookie.present && cookie.locale !== deps.defaultLocale) {
|
|
219
235
|
if (cookie.locale) {
|
|
220
|
-
return negotiatedRedirect(localizedPath(cookie.locale, pathname, pathOptions), 'Cookie')
|
|
236
|
+
return negotiatedRedirect(localizedPath(cookie.locale, pathname, pathOptions) + search, 'Cookie')
|
|
221
237
|
}
|
|
222
238
|
// Present but invalid: corrected to the URL's locale, as serveLocalized does.
|
|
223
239
|
deps.setCookieLang(context.cookies, deps.defaultLocale)
|
|
@@ -225,14 +241,16 @@ export function createI18nMiddleware(deps: I18nMiddlewareDeps): MiddlewareHandle
|
|
|
225
241
|
|
|
226
242
|
const normalized = localizedPath(deps.defaultLocale, pathname, pathOptions)
|
|
227
243
|
if (pathname !== normalized) {
|
|
228
|
-
return context.redirect(normalized, REDIRECT_STATUS)
|
|
244
|
+
return context.redirect(normalized + search, REDIRECT_STATUS)
|
|
229
245
|
}
|
|
230
246
|
|
|
231
247
|
// The project's pages live under `src/pages/[lang]/`: render `/<default>/…` while the
|
|
232
248
|
// URL stays. `next(path)` rewrites without running the middleware again
|
|
233
249
|
// (astro/dist/core/middleware/astro-middleware.js:33-40) and keeps `locals`;
|
|
234
250
|
// `context.rewrite()` would re-run it (astro/dist/core/rewrites/handler.js:67-71).
|
|
235
|
-
|
|
251
|
+
// The rewritten URL is built from the string alone (core/routing/rewrite.js:22), and
|
|
252
|
+
// `context.url`, Astro.url and Actions' `?_action=` read it: the query goes in too.
|
|
253
|
+
return await next(`/${deps.defaultLocale}${pathname}${search}`)
|
|
236
254
|
}
|
|
237
255
|
|
|
238
256
|
// Main i18n middleware handler. Intercepts incoming requests, manages routing,
|
|
@@ -265,14 +283,30 @@ export function createI18nMiddleware(deps: I18nMiddlewareDeps): MiddlewareHandle
|
|
|
265
283
|
// (the core's markdown-path.ts). Without `markdownLocale` they pass through untouched.
|
|
266
284
|
const target = markdownTarget(pathname)
|
|
267
285
|
if (target) {
|
|
268
|
-
return context.redirect(target, REDIRECT_STATUS)
|
|
286
|
+
return context.redirect(target + url.search, REDIRECT_STATUS)
|
|
269
287
|
}
|
|
270
288
|
|
|
271
|
-
// 'prefix-except-default':
|
|
272
|
-
//
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
289
|
+
// 'prefix-except-default': the default locale's `[lang]` routes answer at the root,
|
|
290
|
+
// whatever the extension. A `.md` path without a locale segment always belongs to one;
|
|
291
|
+
// any other path outside the ignored prefixes when Astro matched it to a `[lang]`
|
|
292
|
+
// route or to none, while a root route of its own (`/sitemap.xml`) is served as is.
|
|
293
|
+
// The `/<default>/…` form of a `[lang]` route moves to the root, as a page's does
|
|
294
|
+
// (`.md` excepted: markdownTarget() above decides those).
|
|
295
|
+
let rewrite: string | undefined
|
|
296
|
+
if (deps.routing !== 'prefix') {
|
|
297
|
+
const markdown = MARKDOWN_PATH.test(pathname)
|
|
298
|
+
if (firstSegment === deps.defaultLocale && !markdown && isLangRoute(context.routePattern)) {
|
|
299
|
+
return context.redirect(pathname.slice(deps.defaultLocale.length + 1) + url.search, REDIRECT_STATUS)
|
|
300
|
+
}
|
|
301
|
+
const langRouteOrNone = isLangRoute(context.routePattern) || context.routePattern === NOT_FOUND_ROUTE
|
|
302
|
+
if (
|
|
303
|
+
(!firstSegment || !isLocale(firstSegment)) &&
|
|
304
|
+
(markdown || (langRouteOrNone && !isIgnoredPrefix(pathname)))
|
|
305
|
+
) {
|
|
306
|
+
rewrite = `/${deps.defaultLocale}${pathname}${url.search}`
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const response = rewrite ? await next(rewrite) : await next()
|
|
276
310
|
|
|
277
311
|
const contentType = response.headers.get('content-type') || ''
|
|
278
312
|
const isHtml = contentType.includes('text/html')
|
|
@@ -303,7 +337,7 @@ export function createI18nMiddleware(deps: I18nMiddlewareDeps): MiddlewareHandle
|
|
|
303
337
|
if (!firstSegment) {
|
|
304
338
|
const target = buildLocalizedPath(fallbackLocale, [])
|
|
305
339
|
if (pathname !== target) {
|
|
306
|
-
return negotiatedRedirect(target)
|
|
340
|
+
return negotiatedRedirect(target + url.search)
|
|
307
341
|
}
|
|
308
342
|
return await next()
|
|
309
343
|
}
|
|
@@ -319,7 +353,7 @@ export function createI18nMiddleware(deps: I18nMiddlewareDeps): MiddlewareHandle
|
|
|
319
353
|
const target = buildLocalizedPath(fallbackLocale, segments)
|
|
320
354
|
|
|
321
355
|
if (pathname !== target) {
|
|
322
|
-
return negotiatedRedirect(target)
|
|
356
|
+
return negotiatedRedirect(target + url.search)
|
|
323
357
|
}
|
|
324
358
|
|
|
325
359
|
return await next()
|