Skip to content

Commit bcdbf6d

Browse files
authored
Merge pull request #9 from eremannisto/error-pages
Feature: Improve `404` page handling and `hybrid` mode
2 parents f0657da + 4694ef8 commit bcdbf6d

File tree

18 files changed

+182
-207
lines changed

18 files changed

+182
-207
lines changed

README.md

Lines changed: 57 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ A full-featured i18n integration for Astro. Handles locale detection, URL routin
1111
## Installation
1212

1313
```bash
14-
npm install @mannisto/astro-i18n
1514
pnpm add @mannisto/astro-i18n
15+
npm install @mannisto/astro-i18n
1616
yarn add @mannisto/astro-i18n
1717
```
1818

@@ -42,8 +42,6 @@ See the full [Configuration reference](#configuration) below.
4242

4343
## Modes
4444

45-
The `mode` option controls how pages are rendered and how locale detection works.
46-
4745
### Static
4846

4947
Pages prebuilt at build time, locale detection at the root via a small inline script.
@@ -59,9 +57,9 @@ Pages rendered on demand, middleware handles all locale detection and redirects.
5957
- No flash on first visit
6058
- Supports unprefixed URL rewrites (e.g. `/about``/en/about`)
6159

62-
### Hybrid
60+
### Hybrid (Recommended)
6361

64-
Recommended. Pages prerendered for performance, with a server-rendered catch-all route handling root detection and unprefixed URL redirects.
62+
Pages prerendered for performance, with a server-rendered catch-all route handling root detection and unprefixed URL redirects.
6563

6664
- Requires a Node adapter
6765
- Best of both worlds: static performance with server-side locale handling
@@ -84,14 +82,14 @@ All locale files must define the same set of keys.
8482

8583
### Pages
8684

85+
In `static` and `hybrid` mode, use `getStaticPaths` to prerender pages for each locale:
86+
8787
```astro
8888
---
8989
import { Locale } from "@mannisto/astro-i18n/runtime"
9090
9191
export const getStaticPaths = () => {
92-
return Locale.supported.map((code) => (
93-
{ params: { locale: code } }
94-
))
92+
return Locale.supported.map((code) => ({ params: { locale: code } }))
9593
}
9694
9795
const locale = Locale.from(Astro.url)
@@ -110,11 +108,24 @@ const t = Locale.use(locale)
110108
</html>
111109
```
112110

113-
> **Note:** In `server` mode, omit `getStaticPaths` — pages are rendered on demand.
111+
In `server` mode, omit `getStaticPaths` and opt out of prerendering explicitly:
112+
113+
```astro
114+
---
115+
export const prerender = false
116+
117+
import { Locale } from "@mannisto/astro-i18n/runtime"
118+
119+
const locale = Locale.from(Astro.url)
120+
const t = Locale.use(locale)
121+
---
122+
```
123+
124+
Without `prerender = false`, Astro will treat dynamic routes as static and throw a `GetStaticPathsRequired` error even in server mode.
114125

115126
### Layout
116127

117-
Your layout should derive the locale from the URL and sync it to a cookie on every page load. This ensures the correct locale is remembered across visits.
128+
Your layout should derive the locale from the URL and sync it to a cookie on every page load. This ensures the correct locale is remembered across visits and that 404 pages detect the locale correctly.
118129

119130
```astro
120131
---
@@ -137,6 +148,25 @@ const locale = Locale.from(Astro.url)
137148
</html>
138149
```
139150

151+
### 404 pages
152+
153+
Create a `src/pages/404.astro` at the root. In `hybrid` and `server` mode, unknown paths are redirected to their locale-prefixed equivalent before the 404 renders (e.g. `/banana``/en/banana`), so `Locale.from(Astro.url)` always returns the correct locale. In `static` mode, `Locale.from` falls back to `defaultLocale` for bare unprefixed paths, but locale-prefixed paths like `/fi/banana` still resolve correctly.
154+
155+
```astro
156+
---
157+
import { Locale } from "@mannisto/astro-i18n/runtime"
158+
import Layout from "@layouts/Layout.astro"
159+
160+
const locale = Locale.from(Astro.url)
161+
const t = Locale.use(locale)
162+
---
163+
164+
<Layout title={t("error.title")}>
165+
<h1>{t("error.title")}</h1>
166+
<p>{t("error.description")}</p>
167+
</Layout>
168+
```
169+
140170
### Language switcher
141171

142172
```astro
@@ -164,9 +194,9 @@ const locales = Locale.get()
164194
</script>
165195
```
166196

167-
### Middleware (server mode)
197+
### Middleware
168198

169-
The middleware is auto-registered in `server` mode. It redirects unprefixed URLs (e.g. `/about``/en/about`) and keeps the locale cookie in sync.
199+
The middleware is auto-registered in `server` and `hybrid` mode. It redirects unprefixed URLs (e.g. `/about``/en/about`), keeps the locale cookie in sync, and automatically skips prerendered pages to avoid warnings about unavailable request headers.
170200

171201
You can also compose it manually with other middleware:
172202

@@ -179,39 +209,37 @@ import { onRequest as myMiddleware } from "./my-middleware"
179209
export const onRequest = sequence(i18nMiddleware, myMiddleware)
180210
```
181211

182-
> **Note:** In `hybrid` mode, unprefixed URL redirects are handled by an injected catch-all route, not middleware.
183-
184212
## API
185213

186214
### Locale
187215

188216
```typescript
189-
Locale.supported // ["en", "fi"] — array of all locale codes
190-
Locale.defaultLocale // "en"
191-
Locale.get() // all locale configs
192-
Locale.get("fi") // { code: "fi", name: "Finnish", endonym: "Suomi", ... }
193-
Locale.from(Astro.url) // "fi" — derives current locale from URL
217+
Locale.supported // ["en", "fi"] — array of all locale codes
218+
Locale.defaultLocale // "en"
219+
Locale.get() // all locale configs
220+
Locale.get("fi") // { code: "fi", name: "Finnish", endonym: "Suomi", ... }
221+
Locale.from(Astro.url) // "fi" — derives current locale from URL
194222
```
195223

196224
### Translations
197225

198226
```typescript
199227
const t = Locale.use(locale)
200-
t("nav.home") // "Home"
228+
t("nav.home") // "Home"
201229
```
202230

203231
### URL helpers
204232

205233
```typescript
206-
Locale.url("fi") // "/fi/"
207-
Locale.url("fi", "/about") // "/fi/about"
208-
Locale.url("fi", Astro.url.pathname) // "/fi/current-path"
234+
Locale.url("fi") // "/fi/"
235+
Locale.url("fi", "/about") // "/fi/about"
236+
Locale.url("fi", Astro.url.pathname) // "/fi/current-path"
209237
```
210238

211239
### Switching locale
212240

213241
```typescript
214-
Locale.switch("fi") // sets cookie and redirects to the equivalent page in the new locale
242+
Locale.switch("fi") // sets cookie and navigates to the equivalent page in the new locale
215243
```
216244

217245
## Configuration
@@ -237,11 +265,15 @@ i18n({
237265
// Path to translation JSON files — omit to disable translations
238266
translations: "./src/translations",
239267

240-
// URL paths to bypass the middleware — server mode only
268+
// URL paths to bypass the middleware — server and hybrid mode only
241269
ignore: ["/keystatic", "/api"],
242270
})
243271
```
244272

273+
## Development
274+
275+
See [CONTRIBUTING.md](./CONTRIBUTING.md) for setup, testing, and publishing instructions.
276+
245277
## License
246278

247279
MIT © [Ere Männistö](https://github.com/eremannisto)

dist/index.js

Lines changed: 1 addition & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -75,25 +75,6 @@ var Validate = {
7575
if (fs.existsSync(indexPath)) {
7676
throw new Error(`${NAME} Found conflicting src/pages/index.astro \u2014 remove it.`);
7777
}
78-
},
79-
/**
80-
* Validates that there is no conflicting catch-all route when mode is
81-
* "hybrid". A [...path].astro or [...path].ts would intercept the injected
82-
* redirect route.
83-
*/
84-
catchall(root, mode) {
85-
if (mode !== "hybrid") return;
86-
const candidates = [
87-
new URL("./src/pages/[...path].astro", root),
88-
new URL("./src/pages/[...path].ts", root)
89-
];
90-
for (const candidate of candidates) {
91-
if (fs.existsSync(candidate)) {
92-
throw new Error(
93-
`${NAME} Found conflicting ${candidate.pathname.split("/src/pages/")[1]} \u2014 remove it or switch to "server" mode.`
94-
);
95-
}
96-
}
9778
}
9879
};
9980

@@ -134,7 +115,6 @@ function i18n(config) {
134115
}
135116
Validate.config(config);
136117
Validate.index(astroConfig.root, config.mode);
137-
Validate.catchall(astroConfig.root, config.mode);
138118
resolved = resolveConfig(config);
139119
updateConfig({
140120
vite: {
@@ -183,13 +163,8 @@ export const translations = ${JSON.stringify(translationData)}
183163
entrypoint: "@mannisto/astro-i18n/detect/hybrid",
184164
prerender: false
185165
});
186-
injectRoute({
187-
pattern: "/[...path]",
188-
entrypoint: "@mannisto/astro-i18n/redirect/hybrid",
189-
prerender: false
190-
});
191166
}
192-
if (resolved.mode === "server") {
167+
if (resolved.mode === "server" || resolved.mode === "hybrid") {
193168
addMiddleware({
194169
entrypoint: "@mannisto/astro-i18n/middleware",
195170
order: "pre"

dist/middleware.js

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,28 @@
11
// src/middleware.ts
22
import { defineMiddleware } from "astro/middleware";
33
import { config } from "virtual:astro-i18n/config";
4-
var onRequest = defineMiddleware(({ url, cookies, redirect }, next) => {
5-
const pathname = url.pathname;
6-
const ignore = config.ignore ?? [];
7-
const codes = config.locales.map((l) => l.code);
8-
if (ignore.some((path) => pathname.startsWith(path))) return next();
9-
if (pathname === "/") return next();
10-
const firstSegment = pathname.split("/")[1];
11-
if (codes.includes(firstSegment)) {
12-
const stored2 = cookies.get("locale")?.value;
13-
if (stored2 !== firstSegment) {
14-
cookies.set("locale", firstSegment, { path: "/", sameSite: "lax" });
4+
var onRequest = defineMiddleware(
5+
({ url, cookies, redirect, request, isPrerendered }, next) => {
6+
console.log(url.pathname, isPrerendered);
7+
if (isPrerendered) return next();
8+
const pathname = url.pathname;
9+
const ignore = config.ignore ?? [];
10+
const codes = config.locales.map((l) => l.code);
11+
if (ignore.some((path) => pathname.startsWith(path))) return next();
12+
if (pathname === "/") return next();
13+
const firstSegment = pathname.split("/")[1];
14+
if (codes.includes(firstSegment)) {
15+
const stored2 = cookies.get("locale")?.value;
16+
if (stored2 !== firstSegment) {
17+
cookies.set("locale", firstSegment, { path: "/", sameSite: "lax" });
18+
}
19+
return next();
1520
}
16-
return next();
21+
const stored = cookies.get("locale")?.value;
22+
const targetLocale = stored && codes.includes(stored) ? stored : config.defaultLocale;
23+
return redirect(`/${targetLocale}${pathname}`, 302);
1724
}
18-
const stored = cookies.get("locale")?.value;
19-
const targetLocale = stored && codes.includes(stored) ? stored : config.defaultLocale;
20-
return redirect(`/${targetLocale}${pathname}`, 302);
21-
});
25+
);
2226
var i18nMiddleware = onRequest;
2327
export {
2428
i18nMiddleware,

dist/redirect/hybrid.d.ts

Lines changed: 0 additions & 6 deletions
This file was deleted.

dist/redirect/hybrid.js

Lines changed: 0 additions & 22 deletions
This file was deleted.

package.json

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@mannisto/astro-i18n",
3-
"version": "1.0.0-alpha.7",
3+
"version": "1.0.0-alpha.8",
44
"description": "A flexible alternative to Astro's built-in i18n, with locale routing, detection, and translations for static and SSR sites.",
55
"license": "MIT",
66
"type": "module",
@@ -44,10 +44,6 @@
4444
"./detect/hybrid": {
4545
"types": "./dist/detect/hybrid.d.ts",
4646
"default": "./dist/detect/hybrid.js"
47-
},
48-
"./redirect/hybrid": {
49-
"types": "./dist/redirect/hybrid.d.ts",
50-
"default": "./dist/redirect/hybrid.js"
5147
}
5248
},
5349
"typesVersions": {
@@ -66,9 +62,6 @@
6662
],
6763
"detect/hybrid": [
6864
"./dist/detect/hybrid.d.ts"
69-
],
70-
"redirect/hybrid": [
71-
"./dist/redirect/hybrid.d.ts"
7265
]
7366
}
7467
},

src/index.ts

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@ export default function i18n(config: I18nConfig): AstroIntegration {
4545

4646
Validate.config(config)
4747
Validate.index(astroConfig.root, config.mode)
48-
Validate.catchall(astroConfig.root, config.mode)
4948

5049
resolved = resolveConfig(config)
5150

@@ -95,24 +94,21 @@ export const translations = ${JSON.stringify(translationData)}
9594
})
9695
}
9796

98-
// Hybrid mode — inject a server-side route at / and a catch-all
99-
// redirect for unprefixed paths. Only these routes are server-rendered,
100-
// all locale pages remain static.
97+
// Hybrid mode — inject a server-side route at /
98+
// All locale pages remain prerendered static files.
10199
if (resolved.mode === "hybrid") {
102100
injectRoute({
103101
pattern: "/",
104102
entrypoint: "@mannisto/astro-i18n/detect/hybrid",
105103
prerender: false,
106104
})
107-
injectRoute({
108-
pattern: "/[...path]",
109-
entrypoint: "@mannisto/astro-i18n/redirect/hybrid",
110-
prerender: false,
111-
})
112105
}
113106

114-
// Middleware — only registered for server mode
115-
if (resolved.mode === "server") {
107+
// Middleware — registered for server and hybrid mode.
108+
// Handles unprefixed URL redirects and cookie sync.
109+
// In hybrid mode, static pages are still served directly by the
110+
// adapter — middleware only runs for requests that need redirecting.
111+
if (resolved.mode === "server" || resolved.mode === "hybrid") {
116112
addMiddleware({
117113
entrypoint: "@mannisto/astro-i18n/middleware",
118114
order: "pre",

src/lib/validate.ts

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -95,24 +95,4 @@ export const Validate = {
9595
throw new Error(`${NAME} Found conflicting src/pages/index.astro — remove it.`)
9696
}
9797
},
98-
99-
/**
100-
* Validates that there is no conflicting catch-all route when mode is
101-
* "hybrid". A [...path].astro or [...path].ts would intercept the injected
102-
* redirect route.
103-
*/
104-
catchall(root: URL, mode: string | undefined): void {
105-
if (mode !== "hybrid") return
106-
const candidates = [
107-
new URL("./src/pages/[...path].astro", root),
108-
new URL("./src/pages/[...path].ts", root),
109-
]
110-
for (const candidate of candidates) {
111-
if (fs.existsSync(candidate)) {
112-
throw new Error(
113-
`${NAME} Found conflicting ${candidate.pathname.split("/src/pages/")[1]} — remove it or switch to "server" mode.`
114-
)
115-
}
116-
}
117-
},
11898
}

0 commit comments

Comments
 (0)