@edgekits/astro-i18n 0.1.2 → 0.1.4
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 +31 -0
- package/README.md +44 -20
- package/dist/cli/generate.d.ts +4 -1
- package/dist/cli/generate.js +43 -16
- package/dist/cli/index.js +5 -1
- package/package.json +1 -1
- package/src/cli/generate.ts +47 -17
- package/src/cli/index.ts +5 -1
- package/src/components/LanguageSwitcher.astro +2 -2
- package/src/cookie-storage.ts +24 -9
- package/src/middleware/i18n.ts +7 -4
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,37 @@ 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.4 — 2026-10-14
|
|
8
|
+
|
|
9
|
+
No runtime changes: the `astro-i18n` skill only, found while building astro-edgekits-core.
|
|
10
|
+
|
|
11
|
+
### Skill
|
|
12
|
+
|
|
13
|
+
- Forms: the action example takes `accept: 'form'`, and the enhanced submit sends `FormData`. Without it Astro 7 answers the native no-JS POST with 415 `UNSUPPORTED_MEDIA_TYPE`.
|
|
14
|
+
- Forms: an empty field reaches the action as `null`; the server schema gives that type error the `REQUIRED` code, so the no-JS path reports `REQUIRED` instead of zod's untranslated `invalid_type` text. The client mirror reports `REQUIRED` for an empty email too.
|
|
15
|
+
- Forms: the no-JS error helpers resolve field codes through the `messages` namespace, as the form-level error does, and never print a raw code.
|
|
16
|
+
- Forms: the client email regex is described as a UX approximation; the server's zod is the authority (the old text claimed the two match byte for byte).
|
|
17
|
+
- SEO: list pages show every post in every locale, the URL locale's version where one exists and the default locale's otherwise, with a badge on the card and the banner in `content` mode.
|
|
18
|
+
- SEO: a fallback post stays 200 with its canonical on the default-locale URL, no robots meta and hreflang for the locales the post exists in; the layout takes a `canonicalOverride`, and the sitemap lists only existing post files.
|
|
19
|
+
- The checklists and "Using the skill" cover both.
|
|
20
|
+
|
|
21
|
+
## 0.1.3 — 2026-10-13
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- 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.
|
|
26
|
+
- 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`.
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- 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.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- The skill's flags recipe serves the SVGs from `public/flags/` through an `<img>` instead of inlining them.
|
|
35
|
+
- 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.
|
|
36
|
+
- "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.
|
|
37
|
+
|
|
7
38
|
## 0.1.2 — 2026-10-13
|
|
8
39
|
|
|
9
40
|
### 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 `
|
|
506
|
-
|
|
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
|
-
|
|
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
|
|
524
|
-
for code in us de br; do cp node_modules/flag-icons/flags/4x3/$code.svg
|
|
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
|
-
|
|
532
|
-
|
|
539
|
+
// src/components/Flag.astro
|
|
533
540
|
interface Props {
|
|
534
|
-
code
|
|
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
|
-
{
|
|
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
|
|
549
|
-
|
|
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,15 +592,19 @@ 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.
|
|
591
|
-
and no zod: it validates with a small hand-written mirror of the server schema, so
|
|
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
|
|
596
602
|
code, using the package's locale constants and helpers: a `SeoHreflangs` component and
|
|
597
603
|
`<html lang>` in the base layout, `sitemap.xml`, `[lang]/rss.xml`, `llms.txt` and `robots.txt`
|
|
598
604
|
endpoints.
|
|
605
|
+
For a blog in several languages, ask to "list every post in every locale": an untranslated
|
|
606
|
+
post is listed and shown in the default locale with the banner, and its page points its
|
|
607
|
+
canonical at the original instead of using `noindex`.
|
|
599
608
|
|
|
600
609
|
### Rules the agent follows
|
|
601
610
|
|
|
@@ -626,12 +635,27 @@ endpoints.
|
|
|
626
635
|
- **`migrate` updated KV but the site shows old copy.** The purge was skipped: the zone is not
|
|
627
636
|
proxied, or the zone ID or token is missing. Fix it and run `migrate` again; it retries the
|
|
628
637
|
same purge.
|
|
638
|
+
- **npm 12: `esbuild` or `workerd` did not install.** npm 12 blocks dependency install
|
|
639
|
+
scripts by default and lists the skipped packages at the end of `npm install`. Both need
|
|
640
|
+
theirs: run `npm install-scripts approve esbuild workerd`, then
|
|
641
|
+
`npm rebuild esbuild workerd`. The approval is the `allowScripts` field in `package.json`;
|
|
642
|
+
`approve` pins the installed versions (`"esbuild@0.28.2": true`), and a name-only entry
|
|
643
|
+
allows later versions too:
|
|
644
|
+
|
|
645
|
+
```json
|
|
646
|
+
"allowScripts": { "esbuild": true, "workerd": true }
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
- **The language switcher has no effect (before 0.1.3).** A `lang` cookie from another site on
|
|
650
|
+
the same host (every `localhost` port shares one set of cookies) could not be overwritten by
|
|
651
|
+
the switcher. Since 0.1.3 the middleware replaces a `lang` value that is not one of your
|
|
652
|
+
locales with the URL's locale on the next request, and the switcher can write it again.
|
|
629
653
|
- **Windows.** Local KV (`seed`) can fail with "Network connection lost" or "internal error"
|
|
630
654
|
when the project sits in a deep folder: the local Workers runtime hits the path length
|
|
631
655
|
limit, so keep the project at a short path. Stopping `astro dev` can leave `workerd.exe`
|
|
632
656
|
running and holding `.wrangler/state`; end it before the next `seed`. In PowerShell, copy
|
|
633
657
|
the flags with
|
|
634
|
-
`foreach ($c in 'us','de','br') { Copy-Item node_modules/flag-icons/flags/4x3/$c.svg
|
|
658
|
+
`foreach ($c in 'us','de','br') { Copy-Item node_modules/flag-icons/flags/4x3/$c.svg public/flags/ }`.
|
|
635
659
|
|
|
636
660
|
### Privacy
|
|
637
661
|
|
package/dist/cli/generate.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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`:
|
package/dist/cli/generate.js
CHANGED
|
@@ -55,29 +55,56 @@ export function buildBulkData(collected, keyPrefix) {
|
|
|
55
55
|
}
|
|
56
56
|
return bulkData;
|
|
57
57
|
}
|
|
58
|
-
function
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
/**
|
|
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,
|
|
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.
|
|
3
|
+
"version": "0.1.4",
|
|
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",
|
package/src/cli/generate.ts
CHANGED
|
@@ -82,35 +82,65 @@ export function buildBulkData(collected: CollectedData, keyPrefix: string): KvBu
|
|
|
82
82
|
return bulkData
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
function
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
function isTree(value: unknown): value is TranslationTree {
|
|
86
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
87
|
+
}
|
|
88
88
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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,
|
|
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
|
|
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)
|
package/src/cookie-storage.ts
CHANGED
|
@@ -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
|
-
//
|
|
9
|
-
//
|
|
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:
|
|
14
|
-
secure: import.meta.env
|
|
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
|
|
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 (
|
|
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
|
}
|
package/src/middleware/i18n.ts
CHANGED
|
@@ -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
|
-
|
|
197
|
-
|
|
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
|