@edgekits/astro-i18n 0.2.1 → 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 CHANGED
@@ -4,6 +4,15 @@ 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
+
7
16
  ## 0.2.1 — 2026-10-27
8
17
 
9
18
  ### 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/`. 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.
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgekits/astro-i18n",
3
- "version": "0.2.1",
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.1"
60
+ "@edgekits/i18n-core": "0.2.2"
61
61
  },
62
62
  "peerDependencies": {
63
63
  "@astrojs/cloudflare": "^14.3.0",
@@ -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 (IGNORED_PREFIXES.some((p) => pathname.startsWith(p))) return true
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 pathname = new URL(context.request.url).pathname
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).pathname
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
- return await next(`/${deps.defaultLocale}${pathname}`)
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': a `.md` path without a locale segment belongs to the
272
- // default locale, whose endpoint lives under `[lang]`.
273
- const rootMarkdown =
274
- deps.routing !== 'prefix' && MARKDOWN_PATH.test(pathname) && (!firstSegment || !isLocale(firstSegment))
275
- const response = rootMarkdown ? await next(`/${deps.defaultLocale}${pathname}`) : await next()
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()