@mannisto/astro-i18n 1.0.0-alpha.1
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/LICENSE +21 -0
- package/README.md +264 -0
- package/dist/chunk-SMGDNPQN.js +123 -0
- package/dist/detect/client.d.ts +6 -0
- package/dist/detect/client.js +30 -0
- package/dist/detect/server.d.ts +6 -0
- package/dist/detect/server.js +21 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +185 -0
- package/dist/middleware.d.ts +5 -0
- package/dist/middleware.js +9 -0
- package/dist/runtime.d.ts +67 -0
- package/dist/runtime.js +6 -0
- package/dist/types-DEfk2GVt.d.ts +74 -0
- package/package.json +87 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ere Männistö
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# Astro Internationalization (i18n)
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+

|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
A flexible alternative to Astro's built-in i18n, with locale routing,
|
|
8
|
+
detection, and translations for static and SSR sites.
|
|
9
|
+
|
|
10
|
+
## Features
|
|
11
|
+
|
|
12
|
+
- Three detection modes: `server`, `client`, and `none`
|
|
13
|
+
- Automatic locale prefixing via middleware
|
|
14
|
+
- Optional static translation files with key validation
|
|
15
|
+
- Works with Astro's static and SSR output modes
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
```bash
|
|
19
|
+
pnpm add @mannisto/astro-i18n
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Setup
|
|
23
|
+
|
|
24
|
+
Add the integration to your `astro.config.ts`:
|
|
25
|
+
```typescript
|
|
26
|
+
import { defineConfig } from "astro/config"
|
|
27
|
+
import i18n from "@mannisto/astro-i18n"
|
|
28
|
+
|
|
29
|
+
export default defineConfig({
|
|
30
|
+
integrations: [
|
|
31
|
+
i18n({
|
|
32
|
+
locales: [
|
|
33
|
+
{ code: "en", name: "English", endonym: "English" },
|
|
34
|
+
{ code: "fi", name: "Finnish", endonym: "Suomi", phrase: "Suomeksi" },
|
|
35
|
+
],
|
|
36
|
+
routing: {
|
|
37
|
+
fallback: "en",
|
|
38
|
+
detection: "server",
|
|
39
|
+
autoPrefix: {
|
|
40
|
+
ignore: ["/keystatic"],
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
}),
|
|
44
|
+
],
|
|
45
|
+
})
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Translations
|
|
49
|
+
|
|
50
|
+
Translations are optional. To enable them, add a `translations` path to
|
|
51
|
+
your config pointing to a directory with a JSON file for each locale:
|
|
52
|
+
```
|
|
53
|
+
src/translations/
|
|
54
|
+
en.json
|
|
55
|
+
fi.json
|
|
56
|
+
```
|
|
57
|
+
```typescript
|
|
58
|
+
i18n({
|
|
59
|
+
locales: [...],
|
|
60
|
+
translations: "./src/translations",
|
|
61
|
+
})
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Each file should contain a flat object of key-value pairs:
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"nav.home": "Home",
|
|
68
|
+
"nav.about": "About",
|
|
69
|
+
"footer.copyright": "All rights reserved"
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
All locales must have the same keys as the fallback locale or the
|
|
74
|
+
integration will throw an error at startup. If translations are not
|
|
75
|
+
configured, `Locale.use()` will log a warning when called.
|
|
76
|
+
|
|
77
|
+
## Usage
|
|
78
|
+
|
|
79
|
+
Import `Locale` from the runtime subpath in your pages and components:
|
|
80
|
+
```astro
|
|
81
|
+
---
|
|
82
|
+
import { Locale } from "@mannisto/astro-i18n/runtime"
|
|
83
|
+
|
|
84
|
+
export const getStaticPaths = () =>
|
|
85
|
+
Locale.supported.map((code) => ({ params: { locale: code } }))
|
|
86
|
+
|
|
87
|
+
const { locale } = Astro.params
|
|
88
|
+
const t = Locale.use(locale)
|
|
89
|
+
---
|
|
90
|
+
<html lang={locale}>
|
|
91
|
+
<body>
|
|
92
|
+
<h1>{t("nav.home")}</h1>
|
|
93
|
+
</body>
|
|
94
|
+
</html>
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## API
|
|
98
|
+
|
|
99
|
+
### `Locale.supported`
|
|
100
|
+
|
|
101
|
+
Returns all supported locale codes.
|
|
102
|
+
```typescript
|
|
103
|
+
Locale.supported // ["en", "fi"]
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### `Locale.fallback`
|
|
107
|
+
|
|
108
|
+
Returns the fallback locale code.
|
|
109
|
+
```typescript
|
|
110
|
+
Locale.fallback // "en"
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### `Locale.current(locale)`
|
|
114
|
+
|
|
115
|
+
Returns the current locale from `Astro.params`.
|
|
116
|
+
```typescript
|
|
117
|
+
const locale = Locale.current(Astro.params.locale)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### `Locale.get(code?)`
|
|
121
|
+
|
|
122
|
+
Returns the config for all locales, or a single locale by code.
|
|
123
|
+
```typescript
|
|
124
|
+
Locale.get() // all locales
|
|
125
|
+
Locale.get("fi") // { code: "fi", name: "Finnish", endonym: "Suomi", ... }
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### `Locale.use(locale)`
|
|
129
|
+
|
|
130
|
+
Binds a locale and returns a translation function for that locale.
|
|
131
|
+
Call once at the top of your page, then use the returned function
|
|
132
|
+
to look up individual keys.
|
|
133
|
+
|
|
134
|
+
Requires `translations` to be configured. Logs a warning if called
|
|
135
|
+
without translations configured.
|
|
136
|
+
```typescript
|
|
137
|
+
const t = Locale.use(locale)
|
|
138
|
+
t("nav.home") // "Home"
|
|
139
|
+
t() // { "nav.home": "Home", ... }
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### `Locale.middleware`
|
|
143
|
+
|
|
144
|
+
Middleware that redirects requests without a locale prefix to the correct
|
|
145
|
+
locale based on the user's cookie. Auto-registered when detection is
|
|
146
|
+
`"server"` and `autoPrefix` is enabled.
|
|
147
|
+
|
|
148
|
+
Can also be used manually:
|
|
149
|
+
```typescript
|
|
150
|
+
import { sequence } from "astro/middleware"
|
|
151
|
+
import { Locale } from "@mannisto/astro-i18n/runtime"
|
|
152
|
+
|
|
153
|
+
export const onRequest = sequence(Locale.middleware, myMiddleware)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Detection modes
|
|
157
|
+
|
|
158
|
+
### `server`
|
|
159
|
+
|
|
160
|
+
Reads the `Accept-Language` header on first visit, sets a cookie, and
|
|
161
|
+
redirects to the appropriate locale URL. Requires an adapter for
|
|
162
|
+
production builds.
|
|
163
|
+
```typescript
|
|
164
|
+
import { defineConfig } from "astro/config"
|
|
165
|
+
import node from "@astrojs/node"
|
|
166
|
+
import i18n from "@mannisto/astro-i18n"
|
|
167
|
+
|
|
168
|
+
export default defineConfig({
|
|
169
|
+
adapter: node({ mode: "standalone" }),
|
|
170
|
+
integrations: [
|
|
171
|
+
i18n({
|
|
172
|
+
locales: [
|
|
173
|
+
{ code: "en", name: "English", endonym: "English" },
|
|
174
|
+
{ code: "fi", name: "Finnish", endonym: "Suomi", phrase: "Suomeksi" },
|
|
175
|
+
],
|
|
176
|
+
routing: {
|
|
177
|
+
fallback: "en",
|
|
178
|
+
detection: "server",
|
|
179
|
+
autoPrefix: {
|
|
180
|
+
ignore: ["/keystatic"],
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
}),
|
|
184
|
+
],
|
|
185
|
+
})
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### `client`
|
|
189
|
+
|
|
190
|
+
Serves a static HTML page at `/` with an inline JS redirect script that
|
|
191
|
+
reads `navigator.language` and stores the result in `localStorage`.
|
|
192
|
+
Works without an adapter.
|
|
193
|
+
```typescript
|
|
194
|
+
i18n({
|
|
195
|
+
locales: [
|
|
196
|
+
{ code: "en", name: "English", endonym: "English" },
|
|
197
|
+
{ code: "fi", name: "Finnish", endonym: "Suomi", phrase: "Suomeksi" },
|
|
198
|
+
],
|
|
199
|
+
routing: {
|
|
200
|
+
fallback: "en",
|
|
201
|
+
detection: "client",
|
|
202
|
+
},
|
|
203
|
+
})
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### `none`
|
|
207
|
+
|
|
208
|
+
No detection. Users must navigate to a locale URL directly, e.g. `/en/`.
|
|
209
|
+
```typescript
|
|
210
|
+
i18n({
|
|
211
|
+
locales: [
|
|
212
|
+
{ code: "en", name: "English", endonym: "English" },
|
|
213
|
+
{ code: "fi", name: "Finnish", endonym: "Suomi", phrase: "Suomeksi" },
|
|
214
|
+
],
|
|
215
|
+
routing: {
|
|
216
|
+
fallback: "en",
|
|
217
|
+
detection: "none",
|
|
218
|
+
},
|
|
219
|
+
})
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## Configuration reference
|
|
223
|
+
```typescript
|
|
224
|
+
i18n({
|
|
225
|
+
// required — list of supported locales
|
|
226
|
+
locales: [
|
|
227
|
+
{
|
|
228
|
+
code: "en", // locale code used in URLs
|
|
229
|
+
name: "English", // locale name in English
|
|
230
|
+
endonym: "English", // locale name in its own language
|
|
231
|
+
phrase: "In English", // optional, for locale switchers
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
|
|
235
|
+
routing: {
|
|
236
|
+
// locale to use when no match is found
|
|
237
|
+
// defaults to the first locale in the array
|
|
238
|
+
fallback: "en",
|
|
239
|
+
|
|
240
|
+
// how the user's locale is detected on first visit
|
|
241
|
+
// "server" | "client" | "none" — defaults to "client"
|
|
242
|
+
detection: "server",
|
|
243
|
+
|
|
244
|
+
// middleware that prefixes unknown routes with the user's locale
|
|
245
|
+
// only valid when detection is "server"
|
|
246
|
+
// set to false to disable, or pass an object to configure ignore paths
|
|
247
|
+
autoPrefix: {
|
|
248
|
+
ignore: ["/keystatic", "/api"],
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
// optional — path to translation files, relative to the project root
|
|
253
|
+
// if not set, translations are disabled and Locale.use() will warn when called
|
|
254
|
+
translations: "./src/translations",
|
|
255
|
+
})
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
## Contributing
|
|
259
|
+
|
|
260
|
+
See [CONTRIBUTING.md](./CONTRIBUTING.md).
|
|
261
|
+
|
|
262
|
+
## License
|
|
263
|
+
|
|
264
|
+
MIT © [Ere Männistö](https://github.com/eremannisto)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// src/lib/locale.ts
|
|
2
|
+
import { defineMiddleware } from "astro/middleware";
|
|
3
|
+
import { config, translations } from "virtual:astro-i18n/config";
|
|
4
|
+
var NAME = "@mannisto/astro-i18n";
|
|
5
|
+
var Locale = {
|
|
6
|
+
// ==========================================================================
|
|
7
|
+
// Locale access
|
|
8
|
+
// ==========================================================================
|
|
9
|
+
/**
|
|
10
|
+
* All supported locale codes.
|
|
11
|
+
* @example ["en", "fi"]
|
|
12
|
+
*/
|
|
13
|
+
get supported() {
|
|
14
|
+
return config.locales.map((l) => l.code);
|
|
15
|
+
},
|
|
16
|
+
/**
|
|
17
|
+
* The fallback locale code.
|
|
18
|
+
* @example "en"
|
|
19
|
+
*/
|
|
20
|
+
get fallback() {
|
|
21
|
+
return config.routing.fallback;
|
|
22
|
+
},
|
|
23
|
+
/**
|
|
24
|
+
* Returns the current locale from Astro.params.
|
|
25
|
+
* @example
|
|
26
|
+
* const locale = Locale.current(Astro.params.locale)
|
|
27
|
+
*/
|
|
28
|
+
current(locale) {
|
|
29
|
+
return locale;
|
|
30
|
+
},
|
|
31
|
+
/**
|
|
32
|
+
* Returns the config for all locales, or a single locale by code.
|
|
33
|
+
* Throws if the requested code is not found.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* Locale.get() // all locales
|
|
37
|
+
* Locale.get("fi") // single locale
|
|
38
|
+
*/
|
|
39
|
+
get(code) {
|
|
40
|
+
if (code) {
|
|
41
|
+
const found = config.locales.find((l) => l.code === code);
|
|
42
|
+
if (!found) {
|
|
43
|
+
throw new Error(`${NAME} Locale "${code}" not found.`);
|
|
44
|
+
}
|
|
45
|
+
return found;
|
|
46
|
+
}
|
|
47
|
+
return config.locales;
|
|
48
|
+
},
|
|
49
|
+
// ==========================================================================
|
|
50
|
+
// Translations
|
|
51
|
+
// ==========================================================================
|
|
52
|
+
/**
|
|
53
|
+
* Binds a locale and returns a translation function for that locale.
|
|
54
|
+
*
|
|
55
|
+
* Call once at the top of your page with the current locale, then use
|
|
56
|
+
* the returned function to look up individual keys.
|
|
57
|
+
*
|
|
58
|
+
* Warns if translations are not configured.
|
|
59
|
+
* Throws if the locale or key is not found.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* const t = Locale.use(locale)
|
|
63
|
+
* t("nav.home") // "Home"
|
|
64
|
+
* t() // { "nav.home": "Home", ... }
|
|
65
|
+
*/
|
|
66
|
+
use(locale) {
|
|
67
|
+
if (!config.translations) {
|
|
68
|
+
console.warn(
|
|
69
|
+
`${NAME} Locale.use() was called but translations are not configured. Add a translations path to your i18n config to enable translations.`
|
|
70
|
+
);
|
|
71
|
+
return (key) => key ? "" : {};
|
|
72
|
+
}
|
|
73
|
+
const record = translations[locale];
|
|
74
|
+
return (key) => {
|
|
75
|
+
if (key) {
|
|
76
|
+
if (!record) {
|
|
77
|
+
throw new Error(`${NAME} No translations found for locale "${locale}".`);
|
|
78
|
+
}
|
|
79
|
+
if (!(key in record)) {
|
|
80
|
+
throw new Error(`${NAME} Missing translation key "${key}" in ${locale}.json`);
|
|
81
|
+
}
|
|
82
|
+
return record[key];
|
|
83
|
+
}
|
|
84
|
+
return record ?? {};
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
// ==========================================================================
|
|
88
|
+
// Middleware
|
|
89
|
+
// ==========================================================================
|
|
90
|
+
/**
|
|
91
|
+
* Middleware that redirects requests without a locale prefix to the
|
|
92
|
+
* correct locale based on the user's cookie.
|
|
93
|
+
*
|
|
94
|
+
* Auto-registered when detection is "server" and autoPrefix is enabled.
|
|
95
|
+
* Can also be used manually via Astro's sequence() helper.
|
|
96
|
+
*
|
|
97
|
+
* @example
|
|
98
|
+
* import { sequence } from "astro/middleware"
|
|
99
|
+
* import { Locale } from "@mannisto/astro-i18n/runtime"
|
|
100
|
+
* export const onRequest = sequence(Locale.middleware, myMiddleware)
|
|
101
|
+
*/
|
|
102
|
+
middleware: defineMiddleware(({ url, cookies, redirect }, next) => {
|
|
103
|
+
const pathname = url.pathname;
|
|
104
|
+
const ignoreList = config.routing.autoPrefix !== false ? config.routing.autoPrefix.ignore ?? [] : [];
|
|
105
|
+
if (ignoreList.some((path) => pathname.startsWith(path))) {
|
|
106
|
+
return next();
|
|
107
|
+
}
|
|
108
|
+
if (pathname === "/") {
|
|
109
|
+
return next();
|
|
110
|
+
}
|
|
111
|
+
const firstSegment = pathname.split("/")[1];
|
|
112
|
+
if (config.locales.map((l) => l.code).includes(firstSegment)) {
|
|
113
|
+
return next();
|
|
114
|
+
}
|
|
115
|
+
const stored = cookies.get("locale")?.value;
|
|
116
|
+
const targetLocale = stored && config.locales.map((l) => l.code).includes(stored) ? stored : config.routing.fallback;
|
|
117
|
+
return redirect(`/${targetLocale}${pathname}`, 302);
|
|
118
|
+
})
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export {
|
|
122
|
+
Locale
|
|
123
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// src/detect/client.ts
|
|
2
|
+
import { config } from "virtual:astro-i18n/config";
|
|
3
|
+
var prerender = true;
|
|
4
|
+
var GET = () => {
|
|
5
|
+
const supported = config.locales.map((l) => l.code);
|
|
6
|
+
const fallback = config.routing.fallback;
|
|
7
|
+
const html = `<!DOCTYPE html>
|
|
8
|
+
<html>
|
|
9
|
+
<head>
|
|
10
|
+
<meta charset="UTF-8" />
|
|
11
|
+
<script>
|
|
12
|
+
const supported = ${JSON.stringify(supported)};
|
|
13
|
+
const fallback = "${fallback}";
|
|
14
|
+
const stored = localStorage.getItem("locale");
|
|
15
|
+
const preferred = navigator.language.split("-")[0];
|
|
16
|
+
const locale = stored ?? (supported.includes(preferred) ? preferred : fallback);
|
|
17
|
+
localStorage.setItem("locale", locale);
|
|
18
|
+
window.location.replace("/" + locale + "/");
|
|
19
|
+
</script>
|
|
20
|
+
</head>
|
|
21
|
+
<body></body>
|
|
22
|
+
</html>`;
|
|
23
|
+
return new Response(html, {
|
|
24
|
+
headers: { "content-type": "text/html;charset=utf-8" }
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
export {
|
|
28
|
+
GET,
|
|
29
|
+
prerender
|
|
30
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Locale
|
|
3
|
+
} from "../chunk-SMGDNPQN.js";
|
|
4
|
+
|
|
5
|
+
// src/detect/server.ts
|
|
6
|
+
var prerender = false;
|
|
7
|
+
var GET = ({ request, cookies, redirect }) => {
|
|
8
|
+
const stored = cookies.get("locale")?.value;
|
|
9
|
+
if (stored && Locale.supported.includes(stored)) {
|
|
10
|
+
return redirect(`/${stored}/`, 302);
|
|
11
|
+
}
|
|
12
|
+
const header = request.headers.get("accept-language") ?? "";
|
|
13
|
+
const preferred = header.split(",")[0].split(";")[0].split("-")[0].trim().toLowerCase();
|
|
14
|
+
const locale = Locale.supported.includes(preferred) ? preferred : Locale.fallback;
|
|
15
|
+
cookies.set("locale", locale, { path: "/" });
|
|
16
|
+
return redirect(`/${locale}/`, 302);
|
|
17
|
+
};
|
|
18
|
+
export {
|
|
19
|
+
GET,
|
|
20
|
+
prerender
|
|
21
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { AstroIntegration } from 'astro';
|
|
2
|
+
import { I as I18nConfig } from './types-DEfk2GVt.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The @mannisto/astro-i18n Astro integration.
|
|
6
|
+
*
|
|
7
|
+
* Adds locale routing, detection, and translations to your Astro project
|
|
8
|
+
* without relying on Astro's built-in i18n system.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* import i18n from "@mannisto/astro-i18n"
|
|
12
|
+
*
|
|
13
|
+
* export default defineConfig({
|
|
14
|
+
* integrations: [
|
|
15
|
+
* i18n({
|
|
16
|
+
* locales: [
|
|
17
|
+
* { code: "en", name: "English", endonym: "English" },
|
|
18
|
+
* { code: "fi", name: "Finnish", endonym: "Suomi", phrase: "Suomeksi" },
|
|
19
|
+
* ],
|
|
20
|
+
* routing: {
|
|
21
|
+
* fallback: "en",
|
|
22
|
+
* detection: "server",
|
|
23
|
+
* autoPrefix: { ignore: ["/keystatic"] },
|
|
24
|
+
* },
|
|
25
|
+
* translations: "./src/translations",
|
|
26
|
+
* }),
|
|
27
|
+
* ],
|
|
28
|
+
* })
|
|
29
|
+
*/
|
|
30
|
+
declare function i18n(config: I18nConfig): AstroIntegration;
|
|
31
|
+
|
|
32
|
+
export { i18n as default };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// src/lib/validate.ts
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
var NAME = "@mannisto/astro-i18n";
|
|
4
|
+
var Validate = {
|
|
5
|
+
/**
|
|
6
|
+
* Validates the user-supplied config object.
|
|
7
|
+
* Throws a descriptive error if anything is invalid.
|
|
8
|
+
*/
|
|
9
|
+
config(config) {
|
|
10
|
+
if (!config.locales || config.locales.length === 0) {
|
|
11
|
+
throw new Error(`${NAME} No locales defined.`);
|
|
12
|
+
}
|
|
13
|
+
for (const locale of config.locales) {
|
|
14
|
+
if (!locale.code) {
|
|
15
|
+
throw new Error(`${NAME} A locale is missing a code.`);
|
|
16
|
+
}
|
|
17
|
+
if (!locale.name) {
|
|
18
|
+
throw new Error(`${NAME} Locale "${locale.code}" is missing a name.`);
|
|
19
|
+
}
|
|
20
|
+
if (!locale.endonym) {
|
|
21
|
+
throw new Error(`${NAME} Locale "${locale.code}" is missing an endonym.`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (config.routing?.fallback) {
|
|
25
|
+
const codes = config.locales.map((l) => l.code);
|
|
26
|
+
if (!codes.includes(config.routing.fallback)) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`${NAME} Fallback locale "${config.routing.fallback}" not found in locales.`
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (config.routing?.autoPrefix && config.routing?.detection !== "server") {
|
|
33
|
+
throw new Error(`${NAME} autoPrefix is only valid when detection is "server".`);
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
/**
|
|
37
|
+
* Validates that translation files exist for all locales and that all
|
|
38
|
+
* locales share the same keys as the fallback locale.
|
|
39
|
+
*
|
|
40
|
+
* Returns the loaded translation data for use in the virtual module.
|
|
41
|
+
*/
|
|
42
|
+
translations(config) {
|
|
43
|
+
const data = {};
|
|
44
|
+
for (const locale of config.locales) {
|
|
45
|
+
const filePath = `${config.translations}/${locale.code}.json`;
|
|
46
|
+
if (!fs.existsSync(filePath)) {
|
|
47
|
+
throw new Error(`${NAME} Missing translation file: ${filePath}`);
|
|
48
|
+
}
|
|
49
|
+
data[locale.code] = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
50
|
+
}
|
|
51
|
+
const fallbackKeys = Object.keys(data[config.routing.fallback]);
|
|
52
|
+
for (const locale of config.locales) {
|
|
53
|
+
if (locale.code === config.routing.fallback) continue;
|
|
54
|
+
const localeKeys = Object.keys(data[locale.code]);
|
|
55
|
+
for (const key of fallbackKeys) {
|
|
56
|
+
if (!localeKeys.includes(key)) {
|
|
57
|
+
throw new Error(`${NAME} Missing translation key "${key}" in ${locale.code}.json`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return data;
|
|
62
|
+
},
|
|
63
|
+
/**
|
|
64
|
+
* Validates that there is no conflicting src/pages/index.astro when
|
|
65
|
+
* detection is not "none". If one exists it would intercept the injected
|
|
66
|
+
* detection route at /.
|
|
67
|
+
*/
|
|
68
|
+
index(root, detection) {
|
|
69
|
+
if (detection === "none") return;
|
|
70
|
+
const indexPath = new URL("./src/pages/index.astro", root);
|
|
71
|
+
if (fs.existsSync(indexPath)) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`${NAME} Found conflicting src/pages/index.astro \u2014 remove it or set routing.detection to "none".`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// src/index.ts
|
|
80
|
+
var NAME2 = "@mannisto/astro-i18n";
|
|
81
|
+
var DEFAULT_IGNORE = ["/_astro"];
|
|
82
|
+
function resolveConfig(config) {
|
|
83
|
+
const fallback = config.routing?.fallback ?? config.locales[0].code;
|
|
84
|
+
const detection = config.routing?.detection ?? "client";
|
|
85
|
+
const autoPrefix = config.routing?.autoPrefix === false ? false : {
|
|
86
|
+
ignore: [
|
|
87
|
+
...DEFAULT_IGNORE,
|
|
88
|
+
...typeof config.routing?.autoPrefix === "object" ? config.routing.autoPrefix.ignore ?? [] : []
|
|
89
|
+
]
|
|
90
|
+
};
|
|
91
|
+
return {
|
|
92
|
+
locales: config.locales,
|
|
93
|
+
routing: { fallback, detection, autoPrefix },
|
|
94
|
+
// translations are optional — undefined means disabled
|
|
95
|
+
translations: config.translations
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function i18n(config) {
|
|
99
|
+
let resolved;
|
|
100
|
+
let translationData = {};
|
|
101
|
+
return {
|
|
102
|
+
name: NAME2,
|
|
103
|
+
hooks: {
|
|
104
|
+
// ======================================================================
|
|
105
|
+
// astro:config:setup
|
|
106
|
+
//
|
|
107
|
+
// Validates config, registers the Vite virtual module plugin, injects
|
|
108
|
+
// detection routes, and registers the autoPrefix middleware.
|
|
109
|
+
// ======================================================================
|
|
110
|
+
"astro:config:setup": ({
|
|
111
|
+
config: astroConfig,
|
|
112
|
+
updateConfig,
|
|
113
|
+
injectRoute,
|
|
114
|
+
addMiddleware,
|
|
115
|
+
logger
|
|
116
|
+
}) => {
|
|
117
|
+
if (astroConfig.i18n) {
|
|
118
|
+
logger.warn(
|
|
119
|
+
"Astro's built-in i18n is configured. Remove the i18n key from astro.config to avoid conflicts."
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
Validate.config(config);
|
|
123
|
+
Validate.index(astroConfig.root, config.routing?.detection ?? "client");
|
|
124
|
+
resolved = resolveConfig(config);
|
|
125
|
+
updateConfig({
|
|
126
|
+
vite: {
|
|
127
|
+
plugins: [
|
|
128
|
+
{
|
|
129
|
+
name: "astro-i18n-virtual",
|
|
130
|
+
resolveId(id) {
|
|
131
|
+
if (id === "virtual:astro-i18n/config") {
|
|
132
|
+
return "\0virtual:astro-i18n/config";
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
load(id) {
|
|
136
|
+
if (id === "\0virtual:astro-i18n/config") {
|
|
137
|
+
return `
|
|
138
|
+
export const config = ${JSON.stringify(resolved)}
|
|
139
|
+
export const translations = ${JSON.stringify(translationData)}
|
|
140
|
+
`;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
]
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
if (resolved.routing.detection === "server") {
|
|
148
|
+
injectRoute({
|
|
149
|
+
pattern: "/",
|
|
150
|
+
entrypoint: "@mannisto/astro-i18n/detect/server",
|
|
151
|
+
prerender: false
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
if (resolved.routing.detection === "client") {
|
|
155
|
+
injectRoute({
|
|
156
|
+
pattern: "/",
|
|
157
|
+
entrypoint: "@mannisto/astro-i18n/detect/client",
|
|
158
|
+
prerender: true
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
if (resolved.routing.detection === "server" && resolved.routing.autoPrefix !== false) {
|
|
162
|
+
addMiddleware({
|
|
163
|
+
entrypoint: "@mannisto/astro-i18n/middleware",
|
|
164
|
+
order: "pre"
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
logger.info("i18n configured.");
|
|
168
|
+
},
|
|
169
|
+
// ======================================================================
|
|
170
|
+
// astro:config:done
|
|
171
|
+
//
|
|
172
|
+
// Validates and loads translation files only if translations are
|
|
173
|
+
// configured. Runs after all integrations have finished setup.
|
|
174
|
+
// ======================================================================
|
|
175
|
+
"astro:config:done": () => {
|
|
176
|
+
if (resolved.translations) {
|
|
177
|
+
translationData = Validate.translations(resolved);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
export {
|
|
184
|
+
i18n as default
|
|
185
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import * as astro from 'astro';
|
|
2
|
+
import { L as LocaleCode, a as LocaleConfig } from './types-DEfk2GVt.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Primary public API for locale access, translations, and middleware.
|
|
6
|
+
*
|
|
7
|
+
* Always import from the runtime subpath in pages and components:
|
|
8
|
+
* @example
|
|
9
|
+
* import { Locale } from "@mannisto/astro-i18n/runtime"
|
|
10
|
+
*/
|
|
11
|
+
declare const Locale: {
|
|
12
|
+
/**
|
|
13
|
+
* All supported locale codes.
|
|
14
|
+
* @example ["en", "fi"]
|
|
15
|
+
*/
|
|
16
|
+
readonly supported: LocaleCode[];
|
|
17
|
+
/**
|
|
18
|
+
* The fallback locale code.
|
|
19
|
+
* @example "en"
|
|
20
|
+
*/
|
|
21
|
+
readonly fallback: LocaleCode;
|
|
22
|
+
/**
|
|
23
|
+
* Returns the current locale from Astro.params.
|
|
24
|
+
* @example
|
|
25
|
+
* const locale = Locale.current(Astro.params.locale)
|
|
26
|
+
*/
|
|
27
|
+
current(locale: string): LocaleCode;
|
|
28
|
+
/**
|
|
29
|
+
* Returns the config for all locales, or a single locale by code.
|
|
30
|
+
* Throws if the requested code is not found.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* Locale.get() // all locales
|
|
34
|
+
* Locale.get("fi") // single locale
|
|
35
|
+
*/
|
|
36
|
+
get(code?: LocaleCode): LocaleConfig | LocaleConfig[];
|
|
37
|
+
/**
|
|
38
|
+
* Binds a locale and returns a translation function for that locale.
|
|
39
|
+
*
|
|
40
|
+
* Call once at the top of your page with the current locale, then use
|
|
41
|
+
* the returned function to look up individual keys.
|
|
42
|
+
*
|
|
43
|
+
* Warns if translations are not configured.
|
|
44
|
+
* Throws if the locale or key is not found.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* const t = Locale.use(locale)
|
|
48
|
+
* t("nav.home") // "Home"
|
|
49
|
+
* t() // { "nav.home": "Home", ... }
|
|
50
|
+
*/
|
|
51
|
+
use(locale: LocaleCode): (key?: string) => string | Record<string, string>;
|
|
52
|
+
/**
|
|
53
|
+
* Middleware that redirects requests without a locale prefix to the
|
|
54
|
+
* correct locale based on the user's cookie.
|
|
55
|
+
*
|
|
56
|
+
* Auto-registered when detection is "server" and autoPrefix is enabled.
|
|
57
|
+
* Can also be used manually via Astro's sequence() helper.
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* import { sequence } from "astro/middleware"
|
|
61
|
+
* import { Locale } from "@mannisto/astro-i18n/runtime"
|
|
62
|
+
* export const onRequest = sequence(Locale.middleware, myMiddleware)
|
|
63
|
+
*/
|
|
64
|
+
middleware: astro.MiddlewareHandler;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export { Locale };
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** A locale code, e.g. "en", "fi", "fr" */
|
|
2
|
+
type LocaleCode = string;
|
|
3
|
+
/** Configuration for a single locale */
|
|
4
|
+
type LocaleConfig = {
|
|
5
|
+
/** The locale code, e.g. "en" */
|
|
6
|
+
code: LocaleCode;
|
|
7
|
+
/** The locale name in English, e.g. "English" */
|
|
8
|
+
name: string;
|
|
9
|
+
/** The locale name in its own language, e.g. "Suomi" */
|
|
10
|
+
endonym: string;
|
|
11
|
+
/** Optional short phrase for locale switchers, e.g. "Suomeksi" */
|
|
12
|
+
phrase?: string;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* How the user's preferred locale is detected on their first visit.
|
|
16
|
+
*
|
|
17
|
+
* - "server" — reads the Accept-Language header via a server-side API route
|
|
18
|
+
* - "client" — reads navigator.language via a static JS redirect page
|
|
19
|
+
* - "none" — no detection, user must navigate to a locale URL directly
|
|
20
|
+
*/
|
|
21
|
+
type DetectionMode = "server" | "client" | "none";
|
|
22
|
+
/**
|
|
23
|
+
* Configuration for the autoPrefix middleware.
|
|
24
|
+
* Only valid when detection is "server".
|
|
25
|
+
*/
|
|
26
|
+
type AutoPrefixConfig = {
|
|
27
|
+
/**
|
|
28
|
+
* URL path prefixes that should bypass the autoPrefix middleware.
|
|
29
|
+
* Useful for CMS admin routes, API routes, etc.
|
|
30
|
+
*
|
|
31
|
+
* @example ["/keystatic", "/api"]
|
|
32
|
+
*/
|
|
33
|
+
ignore?: string[];
|
|
34
|
+
};
|
|
35
|
+
/** User-supplied routing configuration */
|
|
36
|
+
type RoutingConfig = {
|
|
37
|
+
/**
|
|
38
|
+
* The locale code to fall back to when no match is found.
|
|
39
|
+
* Defaults to the first locale in the locales array.
|
|
40
|
+
*/
|
|
41
|
+
fallback?: LocaleCode;
|
|
42
|
+
/**
|
|
43
|
+
* How the user's preferred locale is detected on first visit.
|
|
44
|
+
* @default "client"
|
|
45
|
+
*/
|
|
46
|
+
detection?: DetectionMode;
|
|
47
|
+
/**
|
|
48
|
+
* When enabled, the middleware automatically prefixes unknown routes
|
|
49
|
+
* with the user's preferred locale. Only valid when detection is "server".
|
|
50
|
+
*
|
|
51
|
+
* Set to false to disable entirely, or pass an object to configure
|
|
52
|
+
* which paths should be ignored.
|
|
53
|
+
*
|
|
54
|
+
* @default { ignore: ["/_astro"] }
|
|
55
|
+
*/
|
|
56
|
+
autoPrefix?: boolean | AutoPrefixConfig;
|
|
57
|
+
};
|
|
58
|
+
/** The configuration object passed to the i18n() integration */
|
|
59
|
+
type I18nConfig = {
|
|
60
|
+
/** The list of supported locales, in order of preference */
|
|
61
|
+
locales: LocaleConfig[];
|
|
62
|
+
/** Routing and detection options */
|
|
63
|
+
routing?: RoutingConfig;
|
|
64
|
+
/**
|
|
65
|
+
* Path to the translations directory, relative to the project root.
|
|
66
|
+
* Each locale should have a corresponding JSON file, e.g. en.json, fi.json.
|
|
67
|
+
* If not set, translations are disabled and Locale.t() will warn when called.
|
|
68
|
+
*
|
|
69
|
+
* @example "./src/translations"
|
|
70
|
+
*/
|
|
71
|
+
translations?: string;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export type { I18nConfig as I, LocaleCode as L, LocaleConfig as a };
|
package/package.json
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mannisto/astro-i18n",
|
|
3
|
+
"version": "1.0.0-alpha.1",
|
|
4
|
+
"description": "A flexible alternative to Astro's built-in i18n, with locale routing, detection, and translations for static and SSR sites.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"homepage": "https://github.com/eremannisto/astro-i18n#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/eremannisto/astro-i18n"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/eremannisto/astro-i18n/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"astro",
|
|
17
|
+
"astro-integration",
|
|
18
|
+
"i18n",
|
|
19
|
+
"internationalisation",
|
|
20
|
+
"routing",
|
|
21
|
+
"translations"
|
|
22
|
+
],
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"default": "./dist/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./runtime": {
|
|
29
|
+
"types": "./dist/runtime.d.ts",
|
|
30
|
+
"default": "./dist/runtime.js"
|
|
31
|
+
},
|
|
32
|
+
"./middleware": {
|
|
33
|
+
"types": "./dist/middleware.d.ts",
|
|
34
|
+
"default": "./dist/middleware.js"
|
|
35
|
+
},
|
|
36
|
+
"./detect/server": {
|
|
37
|
+
"types": "./dist/detect/server.d.ts",
|
|
38
|
+
"default": "./dist/detect/server.js"
|
|
39
|
+
},
|
|
40
|
+
"./detect/client": {
|
|
41
|
+
"types": "./dist/detect/client.d.ts",
|
|
42
|
+
"default": "./dist/detect/client.js"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"typesVersions": {
|
|
46
|
+
"*": {
|
|
47
|
+
"runtime": [
|
|
48
|
+
"./dist/runtime.d.ts"
|
|
49
|
+
],
|
|
50
|
+
"middleware": [
|
|
51
|
+
"./dist/middleware.d.ts"
|
|
52
|
+
],
|
|
53
|
+
"detect/server": [
|
|
54
|
+
"./dist/detect/server.d.ts"
|
|
55
|
+
],
|
|
56
|
+
"detect/client": [
|
|
57
|
+
"./dist/detect/client.d.ts"
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"files": [
|
|
62
|
+
"dist"
|
|
63
|
+
],
|
|
64
|
+
"peerDependencies": {
|
|
65
|
+
"astro": "^5.0.0"
|
|
66
|
+
},
|
|
67
|
+
"devDependencies": {
|
|
68
|
+
"@biomejs/biome": "^2.0.0",
|
|
69
|
+
"@playwright/test": "^1.50.0",
|
|
70
|
+
"@types/node": "^22.0.0",
|
|
71
|
+
"astro": "^5.17.3",
|
|
72
|
+
"prettier": "^3.8.1",
|
|
73
|
+
"prettier-plugin-astro": "^0.14.1",
|
|
74
|
+
"tsup": "^8.5.1",
|
|
75
|
+
"typescript": "^5.7.0",
|
|
76
|
+
"vitest": "^3.0.0"
|
|
77
|
+
},
|
|
78
|
+
"scripts": {
|
|
79
|
+
"build": "tsup",
|
|
80
|
+
"format": "prettier --write .",
|
|
81
|
+
"lint": "biome lint",
|
|
82
|
+
"check": "biome check",
|
|
83
|
+
"test:unit": "vitest run",
|
|
84
|
+
"test:e2e": "pnpm build > /dev/null 2>&1 && playwright test",
|
|
85
|
+
"test": "pnpm run test:unit && pnpm run test:e2e"
|
|
86
|
+
}
|
|
87
|
+
}
|