@edgekits/astro-i18n 0.1.2 → 0.1.3

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,23 @@ 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.3 — 2026-10-13
8
+
9
+ ### Fixed
10
+
11
+ - The `lang` cookie is no longer `httpOnly`. The `LanguageSwitcher` writes it from the browser and the middleware keeps it in sync with the URL; an `httpOnly` copy set by the server could never be overwritten by the switcher, so a stale value disabled it.
12
+ - A `lang` cookie that is present but not one of the configured locales (a value left by another site on the same host) is set to the URL's locale on the next request. A request without the cookie still gets no `Set-Cookie`.
13
+
14
+ ### Added
15
+
16
+ - Lists in translation JSON are typed: an array of strings (or an empty one) is `string[]`, an array of objects is `Array<{…}>` from its first element. An array that mixes kinds of elements stops the CLI with an error naming the key. A locale's list replaces the fallback's whole.
17
+
18
+ ### Changed
19
+
20
+ - The skill's flags recipe serves the SVGs from `public/flags/` through an `<img>` instead of inlining them.
21
+ - The skill treats the island-free form client as the default, not a prohibition: asked for React or a form library, it uses it, with errors as codes and no zod in the browser.
22
+ - "Using the skill": `git init` under Prerequisites; Troubleshooting notes for npm 12's `allowScripts` (`esbuild`, `workerd`) and for a `lang` cookie left by another site on the same host.
23
+
7
24
  ## 0.1.2 — 2026-10-13
8
25
 
9
26
  ### Fixed
package/README.md CHANGED
@@ -164,6 +164,9 @@ locales/
164
164
  The default locale's files define the schema: their namespaces and keys are the types. Another
165
165
  locale may leave keys out; the default locale's text fills the gaps.
166
166
 
167
+ Lists are allowed and typed: `["a", "b"]` is `string[]`, a list of objects is `Array<{…}>` from
168
+ its first element, and a locale that has the list replaces it whole.
169
+
167
170
  ### 6. Generate and seed
168
171
 
169
172
  ```sh
@@ -458,6 +461,9 @@ npx skills add EdgeKits/astro-i18n --skill astro-i18n
458
461
  `output: 'server'` in `astro.config`.
459
462
  - wrangler `^4.125` installed in the project (`npm i -D wrangler`) and a `wrangler.jsonc`. The
460
463
  package's CLI runs the project's own wrangler, never a global one.
464
+ - A git repository (`git init`): the generated files and the purge baseline
465
+ (`.i18n-hashes.json`) are committed, and `migrate` checks the baseline against git (outside
466
+ a repository it skips that check, with a warning).
461
467
  - A Cloudflare account. Local development needs none until you create the real KV namespace.
462
468
  - For `edgekits-i18n migrate` (pushing copy to production): the site on a proxied Cloudflare
463
469
  zone (orange cloud), that zone's ID, and an API token with the Zone › Cache Purge permission.
@@ -502,8 +508,8 @@ these, it uses the defaults: no geo table, no markdown locale, `auto` middleware
502
508
  The root `index.astro` moves too, since the middleware redirects `/` to `/<locale>/`.
503
509
  Endpoints that are not localized (`robots.txt`, `sitemap.xml`, `/api/*`) and `public/` stay
504
510
  where they are.
505
- - `src/components/Flag.astro` and one SVG per flag in `src/assets/icons/flags/`, if you chose
506
- flags (the recipe is below).
511
+ - `src/components/Flag.astro` and one SVG per flag in `public/flags/`, if you chose flags (the
512
+ recipe is below).
507
513
  - A KV namespace on your Cloudflare account (`wrangler kv namespace create`). The skill asks
508
514
  before it runs that command. Until then, local development works with a placeholder id of 32
509
515
  hexadecimal characters, such as `00000000000000000000000000000000`: the CLI checks the id's
@@ -516,37 +522,36 @@ prop and passes it each locale's `countryCode`. The skill's recipe, which you ca
516
522
  hand:
517
523
 
518
524
  1. `npm i -D flag-icons` (MIT).
519
- 2. Copy the SVG of each configured `countryCode`, and only those, into
520
- `src/assets/icons/flags/`, and commit them:
525
+ 2. Copy the SVG of each configured `countryCode`, and only those, into `public/flags/`, and
526
+ commit them:
521
527
 
522
528
  ```sh
523
- mkdir -p src/assets/icons/flags
524
- for code in us de br; do cp node_modules/flag-icons/flags/4x3/$code.svg src/assets/icons/flags/; done
529
+ mkdir -p public/flags
530
+ for code in us de br; do cp node_modules/flag-icons/flags/4x3/$code.svg public/flags/; done
525
531
  ```
526
532
 
527
- 3. Add `src/components/Flag.astro`:
533
+ 3. Add `src/components/Flag.astro`. It serves the file through an `<img>` instead of inlining
534
+ the SVG: some flag-icons files are large (`es.svg` with its coat of arms is about 80 KB), and
535
+ an `<img>` is one cached request per flag instead of that markup in every page.
528
536
 
529
537
  ```astro
530
538
  ---
531
- import type { SvgComponent } from 'astro/types'
532
-
539
+ // src/components/Flag.astro
533
540
  interface Props {
534
- code: string
541
+ code?: string
535
542
  size?: number
536
543
  }
537
544
  const { code, size = 16 } = Astro.props
538
-
539
- const flags = import.meta.glob<SvgComponent>('../assets/icons/flags/*.svg', { eager: true, import: 'default' })
540
- const Svg = flags[`../assets/icons/flags/${code}.svg`]
541
545
  ---
542
546
 
543
- {Svg && <Svg width={(size * 4) / 3} height={size} aria-hidden="true" />}
547
+ {code && <img src={`/flags/${code}.svg`} width={(size * 4) / 3} height={size} alt="" loading="lazy" decoding="async" />}
544
548
  ```
545
549
 
546
550
  4. Pass it to the switcher: `<LanguageSwitcher flag={Flag} />`.
547
551
 
548
- A locale without a `countryCode`, or without an SVG, shows its name alone. The flag belongs to
549
- the `countryCode`, not the language, so `pt` and `pt-br` can show different flags.
552
+ A locale without a `countryCode` shows its name alone. A `countryCode` whose SVG is missing from
553
+ `public/flags/` shows a broken image, so copy one file per code. The flag belongs to the
554
+ `countryCode`, not the language, so `pt` and `pt-br` can show different flags.
550
555
 
551
556
  #### How it checks the result
552
557
 
@@ -587,9 +592,10 @@ file has uncommitted changes.
587
592
  form". The skill asks for the fields and their rules, where the form submits (an Astro Action
588
593
  by default) and where it renders. What comes back: stable error codes (`INVALID_EMAIL`) with a
589
594
  message for every code in every locale's `messages.json`; a server schema that emits the codes;
590
- an Astro Action; and a `.astro` form with an inline script. The browser side uses no framework
591
- and no zod: it validates with a small hand-written mirror of the server schema, so the form
592
- works with a strict Content Security Policy and without JavaScript.
595
+ an Astro Action; and a `.astro` form with an inline script. By default the browser side uses
596
+ no framework and no zod: it validates with a small hand-written mirror of the server schema, so
597
+ the form works with a strict Content Security Policy and without JavaScript. Ask for React or a
598
+ form library and the skill uses it, with the same error codes and still no zod in the browser.
593
599
 
594
600
  **SEO.** Ask for "hreflang tags", "a sitemap for every locale", "an RSS feed per locale",
595
601
  "`llms.txt`" or "`robots.txt` that blocks staging". The skill writes them as your project's
@@ -626,12 +632,27 @@ endpoints.
626
632
  - **`migrate` updated KV but the site shows old copy.** The purge was skipped: the zone is not
627
633
  proxied, or the zone ID or token is missing. Fix it and run `migrate` again; it retries the
628
634
  same purge.
635
+ - **npm 12: `esbuild` or `workerd` did not install.** npm 12 blocks dependency install
636
+ scripts by default and lists the skipped packages at the end of `npm install`. Both need
637
+ theirs: run `npm install-scripts approve esbuild workerd`, then
638
+ `npm rebuild esbuild workerd`. The approval is the `allowScripts` field in `package.json`;
639
+ `approve` pins the installed versions (`"esbuild@0.28.2": true`), and a name-only entry
640
+ allows later versions too:
641
+
642
+ ```json
643
+ "allowScripts": { "esbuild": true, "workerd": true }
644
+ ```
645
+
646
+ - **The language switcher has no effect (before 0.1.3).** A `lang` cookie from another site on
647
+ the same host (every `localhost` port shares one set of cookies) could not be overwritten by
648
+ the switcher. Since 0.1.3 the middleware replaces a `lang` value that is not one of your
649
+ locales with the URL's locale on the next request, and the switcher can write it again.
629
650
  - **Windows.** Local KV (`seed`) can fail with "Network connection lost" or "internal error"
630
651
  when the project sits in a deep folder: the local Workers runtime hits the path length
631
652
  limit, so keep the project at a short path. Stopping `astro dev` can leave `workerd.exe`
632
653
  running and holding `.wrangler/state`; end it before the next `seed`. In PowerShell, copy
633
654
  the flags with
634
- `foreach ($c in 'us','de','br') { Copy-Item node_modules/flag-icons/flags/4x3/$c.svg src/assets/icons/flags/ }`.
655
+ `foreach ($c in 'us','de','br') { Copy-Item node_modules/flag-icons/flags/4x3/$c.svg public/flags/ }`.
635
656
 
636
657
  ### Privacy
637
658
 
@@ -31,7 +31,10 @@ export declare function localesWithTranslations(collected: CollectedData): strin
31
31
  export declare function schemaSourceLocale(collected: CollectedData, defaultLocale: string): string | undefined;
32
32
  /** `wrangler kv bulk put` input: one entry per locale and namespace. */
33
33
  export declare function buildBulkData(collected: CollectedData, keyPrefix: string): KvBulkEntry[];
34
- /** `src/i18n.generated.d.ts`: the global `I18n.Schema`, from the schema source locale. */
34
+ /**
35
+ * `src/i18n.generated.d.ts`: the global `I18n.Schema`, from the schema source locale.
36
+ * Throws, naming the key, when an array in it mixes kinds of elements.
37
+ */
35
38
  export declare function buildTypesContent(collected: CollectedData, defaultLocale: string): string;
36
39
  /**
37
40
  * `runtime-constants.ts`:
@@ -55,29 +55,56 @@ export function buildBulkData(collected, keyPrefix) {
55
55
  }
56
56
  return bulkData;
57
57
  }
58
- function generateSchemaTs(schema, indent = 4) {
58
+ function isTree(value) {
59
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
60
+ }
61
+ // The JSON kind of an array element; an array is typed only when all its elements share one.
62
+ function jsonKind(value) {
63
+ if (value === null)
64
+ return 'null';
65
+ if (Array.isArray(value))
66
+ return 'array';
67
+ return typeof value;
68
+ }
69
+ /**
70
+ * The TS type of one JSON value. Objects become object types; arrays become `string[]`
71
+ * (strings, other scalars, or empty) or `Array<…>` typed from their first element
72
+ * (objects, nested arrays); every other leaf stays `string`. `keyPath` and `file` name the
73
+ * value in the error a mixed array raises.
74
+ */
75
+ function generateSchemaTs(value, indent, keyPath, file) {
76
+ if (Array.isArray(value)) {
77
+ if (value.length === 0)
78
+ return 'string[]';
79
+ const kinds = [...new Set(value.map(jsonKind))];
80
+ if (kinds.length > 1) {
81
+ throw new Error(`Key "${keyPath}" (${file}) is an array that mixes ${kinds.join(', ')} elements: ` +
82
+ 'I18n.Schema cannot type it. Use one kind of element per array.');
83
+ }
84
+ const first = value[0];
85
+ if (!isTree(first) && !Array.isArray(first))
86
+ return 'string[]';
87
+ return `Array<${generateSchemaTs(first, indent, `${keyPath}[0]`, file)}>`;
88
+ }
89
+ if (!isTree(value))
90
+ return 'string';
91
+ const entries = Object.entries(value);
92
+ if (entries.length === 0)
93
+ return '{}';
59
94
  const pad = ' '.repeat(indent);
60
95
  const padInner = ' '.repeat(indent + 2);
61
- if (schema && typeof schema === 'object' && !Array.isArray(schema)) {
62
- const entries = Object.entries(schema);
63
- if (entries.length === 0)
64
- return '{}';
65
- const lines = entries.map(([key, value]) => {
66
- const valueTs = value && typeof value === 'object' && !Array.isArray(value)
67
- ? generateSchemaTs(value, indent + 2)
68
- : 'string';
69
- return `${padInner}${JSON.stringify(key)}: ${valueTs}`;
70
- });
71
- return `{\n${lines.join(';\n')};\n${pad}}`;
72
- }
73
- return 'string';
96
+ const lines = entries.map(([key, child]) => `${padInner}${JSON.stringify(key)}: ${generateSchemaTs(child, indent + 2, `${keyPath}.${key}`, file)}`);
97
+ return `{\n${lines.join(';\n')};\n${pad}}`;
74
98
  }
75
- /** `src/i18n.generated.d.ts`: the global `I18n.Schema`, from the schema source locale. */
99
+ /**
100
+ * `src/i18n.generated.d.ts`: the global `I18n.Schema`, from the schema source locale.
101
+ * Throws, naming the key, when an array in it mixes kinds of elements.
102
+ */
76
103
  export function buildTypesContent(collected, defaultLocale) {
77
104
  const source = schemaSourceLocale(collected, defaultLocale);
78
105
  const schemaObject = (source && collected[source]) || {};
79
106
  const namespaceLines = Object.entries(schemaObject).map(([ns, value]) => {
80
- const valueTs = generateSchemaTs(value, 10);
107
+ const valueTs = generateSchemaTs(value, 10, ns, `${source}/${ns}.json`);
81
108
  return ` ${JSON.stringify(ns)}: ${valueTs}`;
82
109
  });
83
110
  const interfaceBody = namespaceLines.length > 0
package/dist/cli/index.js CHANGED
@@ -82,8 +82,12 @@ async function run(command, args) {
82
82
  // 1. Collect translations from <localesDir>/<locale>/<namespace>.json
83
83
  // -------------------------------------------------------------------------
84
84
  const collected = collectTranslations(localesDir);
85
+ // The schema is built here too, so an untypeable value (a mixed array) stops the run
86
+ // before anything is written.
87
+ let typesContent;
85
88
  try {
86
89
  assertValidNames(collected, config.languages);
90
+ typesContent = buildTypesContent(collected, defaultLocale);
87
91
  }
88
92
  catch (err) {
89
93
  console.error(`[i18n] ${err instanceof Error ? err.message : String(err)}`);
@@ -117,7 +121,7 @@ async function run(command, args) {
117
121
  console.warn(`[i18n] defaultLocale="${defaultLocale}" has no translation files. ` +
118
122
  `Using "${source}" as the source for I18n.Schema.`);
119
123
  }
120
- writeFile(OUTPUT_TYPES, buildTypesContent(collected, defaultLocale));
124
+ writeFile(OUTPUT_TYPES, typesContent);
121
125
  writeFile(outputRuntimeConstants, buildRuntimeConstantsContent(collected, defaultLocale));
122
126
  // -------------------------------------------------------------------------
123
127
  // 4. Fallbacks (seed, migrate, or bundle --fallbacks)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgekits/astro-i18n",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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",
@@ -82,35 +82,65 @@ export function buildBulkData(collected: CollectedData, keyPrefix: string): KvBu
82
82
  return bulkData
83
83
  }
84
84
 
85
- function generateSchemaTs(schema: TranslationTree, indent = 4): string {
86
- const pad = ' '.repeat(indent)
87
- const padInner = ' '.repeat(indent + 2)
85
+ function isTree(value: unknown): value is TranslationTree {
86
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
87
+ }
88
88
 
89
- if (schema && typeof schema === 'object' && !Array.isArray(schema)) {
90
- const entries = Object.entries(schema)
91
- if (entries.length === 0) return '{}'
89
+ // The JSON kind of an array element; an array is typed only when all its elements share one.
90
+ function jsonKind(value: unknown): string {
91
+ if (value === null) return 'null'
92
+ if (Array.isArray(value)) return 'array'
93
+ return typeof value
94
+ }
92
95
 
93
- const lines = entries.map(([key, value]) => {
94
- const valueTs =
95
- value && typeof value === 'object' && !Array.isArray(value)
96
- ? generateSchemaTs(value as TranslationTree, indent + 2)
97
- : 'string'
98
- return `${padInner}${JSON.stringify(key)}: ${valueTs}`
99
- })
96
+ /**
97
+ * The TS type of one JSON value. Objects become object types; arrays become `string[]`
98
+ * (strings, other scalars, or empty) or `Array<…>` typed from their first element
99
+ * (objects, nested arrays); every other leaf stays `string`. `keyPath` and `file` name the
100
+ * value in the error a mixed array raises.
101
+ */
102
+ function generateSchemaTs(value: unknown, indent: number, keyPath: string, file: string): string {
103
+ if (Array.isArray(value)) {
104
+ if (value.length === 0) return 'string[]'
100
105
 
101
- return `{\n${lines.join(';\n')};\n${pad}}`
106
+ const kinds = [...new Set(value.map(jsonKind))]
107
+ if (kinds.length > 1) {
108
+ throw new Error(
109
+ `Key "${keyPath}" (${file}) is an array that mixes ${kinds.join(', ')} elements: ` +
110
+ 'I18n.Schema cannot type it. Use one kind of element per array.',
111
+ )
112
+ }
113
+
114
+ const first = value[0]
115
+ if (!isTree(first) && !Array.isArray(first)) return 'string[]'
116
+ return `Array<${generateSchemaTs(first, indent, `${keyPath}[0]`, file)}>`
102
117
  }
103
118
 
104
- return 'string'
119
+ if (!isTree(value)) return 'string'
120
+
121
+ const entries = Object.entries(value)
122
+ if (entries.length === 0) return '{}'
123
+
124
+ const pad = ' '.repeat(indent)
125
+ const padInner = ' '.repeat(indent + 2)
126
+ const lines = entries.map(
127
+ ([key, child]) =>
128
+ `${padInner}${JSON.stringify(key)}: ${generateSchemaTs(child, indent + 2, `${keyPath}.${key}`, file)}`,
129
+ )
130
+
131
+ return `{\n${lines.join(';\n')};\n${pad}}`
105
132
  }
106
133
 
107
- /** `src/i18n.generated.d.ts`: the global `I18n.Schema`, from the schema source locale. */
134
+ /**
135
+ * `src/i18n.generated.d.ts`: the global `I18n.Schema`, from the schema source locale.
136
+ * Throws, naming the key, when an array in it mixes kinds of elements.
137
+ */
108
138
  export function buildTypesContent(collected: CollectedData, defaultLocale: string): string {
109
139
  const source = schemaSourceLocale(collected, defaultLocale)
110
140
  const schemaObject: TranslationTree = (source && collected[source]) || {}
111
141
 
112
142
  const namespaceLines = Object.entries(schemaObject).map(([ns, value]) => {
113
- const valueTs = generateSchemaTs(value as TranslationTree, 10)
143
+ const valueTs = generateSchemaTs(value, 10, ns, `${source}/${ns}.json`)
114
144
  return ` ${JSON.stringify(ns)}: ${valueTs}`
115
145
  })
116
146
 
package/src/cli/index.ts CHANGED
@@ -116,8 +116,12 @@ async function run(command: Command, args: string[]): Promise<void> {
116
116
 
117
117
  const collected = collectTranslations(localesDir)
118
118
 
119
+ // The schema is built here too, so an untypeable value (a mixed array) stops the run
120
+ // before anything is written.
121
+ let typesContent: string
119
122
  try {
120
123
  assertValidNames(collected, config.languages)
124
+ typesContent = buildTypesContent(collected, defaultLocale)
121
125
  } catch (err) {
122
126
  console.error(`[i18n] ${err instanceof Error ? err.message : String(err)}`)
123
127
  process.exit(1)
@@ -161,7 +165,7 @@ async function run(command: Command, args: string[]): Promise<void> {
161
165
  )
162
166
  }
163
167
 
164
- writeFile(OUTPUT_TYPES, buildTypesContent(collected, defaultLocale))
168
+ writeFile(OUTPUT_TYPES, typesContent)
165
169
  writeFile(outputRuntimeConstants, buildRuntimeConstantsContent(collected, defaultLocale))
166
170
 
167
171
  // -------------------------------------------------------------------------
@@ -185,8 +185,8 @@ const { uiLocale: currentLang } = Astro.locals
185
185
  // Persist the EXPLICIT language choice client-side. The i18n middleware
186
186
  // no longer creates the cookie on plain page views (a cookie-less request
187
187
  // must stay free of Set-Cookie so the HTML edge cache can serve it), so
188
- // the switcher is the place where the preference is born. Not httpOnly —
189
- // the server only ever reads it (schema-validated), never trusts it.
188
+ // the switcher is the place where the preference is born. Not httpOnly on
189
+ // either side (cookie-storage.ts): the server validates it, never trusts it.
190
190
  document.cookie = `${cookieName}=${lang}; path=/; max-age=${cookieMaxAge}; SameSite=Lax; Secure`
191
191
 
192
192
  window.location.href = this.replaceLocaleInPath(pathname, currentLang, lang)
@@ -5,28 +5,43 @@ import { LocaleSchema } from './schema'
5
5
  import type { Locale } from './constants'
6
6
  import { LANG_COOKIE_NAME } from './constants'
7
7
 
8
- // Self-contained on purpose: the same options and the same invalid-value warning
9
- // as the project's src/utils/server/cookies.ts, without importing it.
8
+ // The lang cookie is a preference, not a secret, and it has two writers: the
9
+ // LanguageSwitcher's client script (`document.cookie`) and the middleware's URL-wins
10
+ // sync. Hence `httpOnly: false`: an httpOnly copy set here could never be overwritten
11
+ // by the switcher again, and a stale value would disable it for good. The server only
12
+ // reads the value through LocaleSchema, never trusts it. `?.`: plain Node (the tests)
13
+ // has no `import.meta.env`.
10
14
  export function setCookieLang(cookies: AstroCookies, locale: Locale) {
11
15
  cookies.set(LANG_COOKIE_NAME, locale, {
12
16
  path: '/',
13
- httpOnly: true,
14
- secure: import.meta.env.PROD,
17
+ httpOnly: false,
18
+ secure: import.meta.env?.PROD === true,
15
19
  sameSite: 'lax',
16
20
  maxAge: I18N_CONFIG.cookie.maxAge,
17
21
  })
18
22
  }
19
23
 
20
- export function getCookieLang(cookies: AstroCookies): Locale | null {
24
+ export type CookieLang = {
25
+ // A lang cookie came with the request, whatever its value.
26
+ present: boolean
27
+ // Its value when it is a supported locale, otherwise null.
28
+ locale: Locale | null
29
+ }
30
+
31
+ export function readCookieLang(cookies: AstroCookies): CookieLang {
21
32
  const entry = cookies.get(LANG_COOKIE_NAME)
22
- if (!entry) return null
33
+ if (!entry) return { present: false, locale: null }
23
34
 
24
35
  const result = LocaleSchema.safeParse(entry.value)
25
- if (result.success) return result.data
36
+ if (result.success) return { present: true, locale: result.data }
26
37
 
27
- if (!import.meta.env.PROD) {
38
+ if (import.meta.env?.PROD !== true) {
28
39
  console.warn(`Invalid cookie "${LANG_COOKIE_NAME}"`, result.error.issues)
29
40
  }
30
41
 
31
- return null
42
+ return { present: true, locale: null }
43
+ }
44
+
45
+ export function getCookieLang(cookies: AstroCookies): Locale | null {
46
+ return readCookieLang(cookies).locale
32
47
  }
@@ -2,7 +2,7 @@ import type { MiddlewareHandler } from 'astro'
2
2
  import { LocaleSchema } from '../schema'
3
3
  import { I18N_CONFIG } from '../config'
4
4
  import { DEFAULT_LOCALE, type Locale } from '../constants'
5
- import { getCookieLang, setCookieLang } from '../cookie-storage'
5
+ import { getCookieLang, readCookieLang, setCookieLang } from '../cookie-storage'
6
6
  import { resolveFallbackLocale as resolveFallbackFrom } from '../locale-fallback'
7
7
  import { MARKDOWN_PATH, markdownRedirectTarget } from '../markdown-path'
8
8
  import { resolveLocaleForTranslations } from '../resolve-locale'
@@ -183,7 +183,7 @@ export const i18nMiddleware: MiddlewareHandler = async (context, next) => {
183
183
 
184
184
  if (urlLocale) {
185
185
  // Keep the lang cookie in sync with the URL locale — but ONLY update an
186
- // EXISTING cookie that differs. Rationale (HTML edge cache, P1/P2):
186
+ // EXISTING cookie that differs or does not parse. Rationale (HTML edge cache, P1/P2):
187
187
  // - The old unconditional set emitted `Set-Cookie` on EVERY localized
188
188
  // response, which made every response uncacheable (the edge cache
189
189
  // refuses anything carrying Set-Cookie).
@@ -193,8 +193,11 @@ export const i18nMiddleware: MiddlewareHandler = async (context, next) => {
193
193
  // created here — the explicit language choice is persisted client-side
194
194
  // by the LanguageSwitcher, and an existing cookie is re-synced below
195
195
  // when the user lands on a different locale by any other means.
196
- const cookieLocale = getCookieLang(context.cookies)
197
- if (cookieLocale && cookieLocale !== urlLocale) {
196
+ // - A cookie that is present but invalid (a foreign value from another site
197
+ // on the same host, a locale this site does not have) is corrected to the
198
+ // URL locale too, so a stale value never outlives the next request.
199
+ const cookie = readCookieLang(context.cookies)
200
+ if (cookie.present && cookie.locale !== urlLocale) {
198
201
  setCookieLang(context.cookies, urlLocale)
199
202
  }
200
203
  // Set uiLocale to Astro locals