@pyreon/zero 0.15.0 → 0.18.0
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/lib/{api-routes-DANluJic.js → api-routes-Ci0kVmM4.js} +2 -2
- package/lib/client.js +4 -1
- package/lib/env.js +6 -6
- package/lib/font.js +3 -3
- package/lib/{fs-router-ZebyutPa.js → fs-router-MewHc5SB.js} +25 -30
- package/lib/i18n-routing.js +112 -1
- package/lib/image.js +140 -58
- package/lib/index.js +252 -82
- package/lib/og-image.js +5 -5
- package/lib/rolldown-runtime-CjeV3_4I.js +18 -0
- package/lib/script.js +114 -25
- package/lib/seo.js +186 -15
- package/lib/server.js +274 -564
- package/lib/types/config.d.ts +307 -3
- package/lib/types/env.d.ts +2 -2
- package/lib/types/i18n-routing.d.ts +193 -2
- package/lib/types/image.d.ts +105 -5
- package/lib/types/index.d.ts +666 -182
- package/lib/types/script.d.ts +78 -6
- package/lib/types/seo.d.ts +128 -4
- package/lib/types/server.d.ts +607 -72
- package/lib/vite-plugin-y0NmCLJA.js +2476 -0
- package/package.json +11 -10
- package/src/adapters/bun.ts +20 -1
- package/src/adapters/cloudflare.ts +78 -1
- package/src/adapters/index.ts +25 -3
- package/src/adapters/netlify.ts +63 -1
- package/src/adapters/node.ts +25 -1
- package/src/adapters/static.ts +26 -1
- package/src/adapters/validate.ts +8 -1
- package/src/adapters/vercel.ts +76 -1
- package/src/adapters/warn-missing-env.ts +49 -0
- package/src/app.ts +14 -0
- package/src/client.ts +18 -0
- package/src/entry-server.ts +55 -5
- package/src/env.ts +7 -7
- package/src/font.ts +3 -3
- package/src/fs-router.ts +72 -3
- package/src/i18n-routing.ts +246 -12
- package/src/image.tsx +242 -91
- package/src/index.ts +4 -4
- package/src/isr.ts +24 -6
- package/src/manifest.ts +675 -0
- package/src/og-image.ts +5 -5
- package/src/script.tsx +159 -36
- package/src/seo.ts +346 -15
- package/src/server.ts +10 -2
- package/src/ssg-plugin.ts +1211 -54
- package/src/types.ts +333 -10
- package/src/vercel-revalidate-handler.ts +204 -0
- package/src/vite-plugin.ts +171 -41
- package/lib/vite-plugin-E4BHYvYW.js +0 -855
package/src/entry-server.ts
CHANGED
|
@@ -123,6 +123,11 @@ export function createServer(options: CreateServerOptions) {
|
|
|
123
123
|
const { App } = createApp({
|
|
124
124
|
routes: options.routes,
|
|
125
125
|
routerMode: "history",
|
|
126
|
+
// Forward zero's `base` to createRouter so RouterLinks render
|
|
127
|
+
// correctly prefixed hrefs during SSR — must match the value
|
|
128
|
+
// the client-side `startClient` reads from `__ZERO_BASE__` so
|
|
129
|
+
// hydration doesn't mismatch.
|
|
130
|
+
...(config.base && config.base !== "/" ? { base: config.base } : {}),
|
|
126
131
|
});
|
|
127
132
|
|
|
128
133
|
const handler = createHandler({
|
|
@@ -134,18 +139,40 @@ export function createServer(options: CreateServerOptions) {
|
|
|
134
139
|
...(options.clientEntry ? { clientEntry: options.clientEntry } : {}),
|
|
135
140
|
});
|
|
136
141
|
|
|
137
|
-
//
|
|
142
|
+
// M1.2 — Runtime SSR 404 routes through the router (PR L5).
|
|
143
|
+
// When a URL doesn't match any leaf, @pyreon/router's resolveRoute
|
|
144
|
+
// walks up to the closest parent `notFoundComponent` and builds a
|
|
145
|
+
// synthetic chain `[...ancestorLayouts, syntheticLeaf]`. The handler
|
|
146
|
+
// renders that chain, producing 404 HTML INSIDE the layout's chrome,
|
|
147
|
+
// and reads `resolved.isNotFound` to set HTTP status 404. This
|
|
148
|
+
// replaces the pre-M1 URL-pattern wrapper that bypassed the router
|
|
149
|
+
// for unmatched URLs and rendered the not-found component standalone
|
|
150
|
+
// (no layout wrapping).
|
|
151
|
+
//
|
|
152
|
+
// `options.notFoundComponent` is a legacy fallback for apps that
|
|
153
|
+
// don't carry `_404.tsx` in their routes tree. When set AND the
|
|
154
|
+
// routes tree has no reachable `notFoundComponent`, we render the
|
|
155
|
+
// standalone shape as a final fallback. The canonical pattern is
|
|
156
|
+
// `_404.tsx` inside a `_layout.tsx` directory — that goes through
|
|
157
|
+
// PR L5's router-driven path and gets layout chrome for free.
|
|
138
158
|
if (!options.notFoundComponent) return handler;
|
|
139
159
|
|
|
140
160
|
const NotFound = options.notFoundComponent;
|
|
141
|
-
const
|
|
161
|
+
const hasRouteTreeNotFound = routeTreeHasNotFound(options.routes);
|
|
142
162
|
|
|
143
163
|
return async (req: Request) => {
|
|
164
|
+
// Route-tree notFoundComponent present → handler handles 404 via
|
|
165
|
+
// resolveRoute's `isNotFound` fallback (PR L5). Skip the legacy
|
|
166
|
+
// wrapper entirely — handler.ts sets status 404 + renders layout
|
|
167
|
+
// chrome correctly.
|
|
168
|
+
if (hasRouteTreeNotFound) return handler(req);
|
|
169
|
+
|
|
170
|
+
// Legacy fallback: routes tree has no notFoundComponent but the
|
|
171
|
+
// caller passed `options.notFoundComponent`. Run the URL-pattern
|
|
172
|
+
// check + standalone render for backward compat.
|
|
144
173
|
const url = new URL(req.url);
|
|
145
174
|
const pathname = url.pathname;
|
|
146
|
-
|
|
147
|
-
// Check if any defined route matches this path
|
|
148
|
-
if (!routePatterns.some((pattern) => matchPattern(pattern, pathname))) {
|
|
175
|
+
if (!routePatternsCache(options.routes).some((p) => matchPattern(p, pathname))) {
|
|
149
176
|
const fullHtml = await render404Page(NotFound, options.template);
|
|
150
177
|
return new Response(fullHtml, {
|
|
151
178
|
status: 404,
|
|
@@ -157,6 +184,29 @@ export function createServer(options: CreateServerOptions) {
|
|
|
157
184
|
};
|
|
158
185
|
}
|
|
159
186
|
|
|
187
|
+
/** Walk the route tree looking for any record with a `notFoundComponent`. */
|
|
188
|
+
function routeTreeHasNotFound(routes: RouteRecord[]): boolean {
|
|
189
|
+
for (const r of routes) {
|
|
190
|
+
if (typeof (r as { notFoundComponent?: unknown }).notFoundComponent === "function") {
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
if (r.children && routeTreeHasNotFound(r.children as RouteRecord[])) {
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Lazy cache of flattened patterns — only computed if legacy fallback fires. */
|
|
201
|
+
const _routePatternsCache = new WeakMap<RouteRecord[], string[]>();
|
|
202
|
+
function routePatternsCache(routes: RouteRecord[]): string[] {
|
|
203
|
+
const cached = _routePatternsCache.get(routes);
|
|
204
|
+
if (cached) return cached;
|
|
205
|
+
const out = flattenRoutePatterns(routes);
|
|
206
|
+
_routePatternsCache.set(routes, out);
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
|
|
160
210
|
/** Extract all URL patterns from a nested route tree. */
|
|
161
211
|
function flattenRoutePatterns(routes: RouteRecord[], prefix = ""): string[] {
|
|
162
212
|
const patterns: string[] = [];
|
package/src/env.ts
CHANGED
|
@@ -244,14 +244,14 @@ type InferEnvSchema<T> = {
|
|
|
244
244
|
* ```
|
|
245
245
|
*/
|
|
246
246
|
export function validateEnv<T extends Record<string, SchemaEntry>>(
|
|
247
|
-
|
|
247
|
+
envSchema: T,
|
|
248
248
|
source?: Record<string, string | undefined>,
|
|
249
249
|
): InferEnvSchema<T> {
|
|
250
250
|
const env = source ?? (typeof process !== 'undefined' ? process.env : {})
|
|
251
251
|
const result: Record<string, unknown> = {}
|
|
252
252
|
const errors: string[] = []
|
|
253
253
|
|
|
254
|
-
for (const [key, entry] of Object.entries(
|
|
254
|
+
for (const [key, entry] of Object.entries(envSchema)) {
|
|
255
255
|
const validator = toValidator(entry)
|
|
256
256
|
try {
|
|
257
257
|
result[key] = validator.parse(env[key], key)
|
|
@@ -284,12 +284,12 @@ export function validateEnv<T extends Record<string, SchemaEntry>>(
|
|
|
284
284
|
* ```
|
|
285
285
|
*/
|
|
286
286
|
export function publicEnv(): Record<string, string>
|
|
287
|
-
export function publicEnv<T extends Record<string, SchemaEntry>>(
|
|
288
|
-
export function publicEnv(
|
|
287
|
+
export function publicEnv<T extends Record<string, SchemaEntry>>(envSchema: T): InferEnvSchema<T>
|
|
288
|
+
export function publicEnv(envSchema?: Record<string, SchemaEntry>): Record<string, unknown> {
|
|
289
289
|
const prefix = 'ZERO_PUBLIC_'
|
|
290
290
|
const env = typeof process !== 'undefined' ? process.env : {}
|
|
291
291
|
|
|
292
|
-
if (!
|
|
292
|
+
if (!envSchema) {
|
|
293
293
|
const result: Record<string, string> = {}
|
|
294
294
|
for (const [key, value] of Object.entries(env)) {
|
|
295
295
|
if (key.startsWith(prefix) && value !== undefined) {
|
|
@@ -300,10 +300,10 @@ export function publicEnv(schema?: Record<string, SchemaEntry>): Record<string,
|
|
|
300
300
|
}
|
|
301
301
|
|
|
302
302
|
const prefixedSource: Record<string, string | undefined> = {}
|
|
303
|
-
for (const key of Object.keys(
|
|
303
|
+
for (const key of Object.keys(envSchema)) {
|
|
304
304
|
prefixedSource[key] = env[`${prefix}${key}`]
|
|
305
305
|
}
|
|
306
|
-
return validateEnv(
|
|
306
|
+
return validateEnv(envSchema, prefixedSource)
|
|
307
307
|
}
|
|
308
308
|
|
|
309
309
|
// ─── Custom validator escape hatch ──────────────────────────────────────────
|
package/src/font.ts
CHANGED
|
@@ -159,11 +159,11 @@ export function parseGoogleFamily(input: string): ResolvedFont {
|
|
|
159
159
|
for (const entry of entries) {
|
|
160
160
|
if (entry.includes(',')) {
|
|
161
161
|
// Tuple format: "0,300" or "1,500" — last value is the weight
|
|
162
|
-
const
|
|
163
|
-
const weight = Number(
|
|
162
|
+
const tuple = entry.split(',')
|
|
163
|
+
const weight = Number(tuple[tuple.length - 1])
|
|
164
164
|
if (weight > 0) weights.add(weight)
|
|
165
165
|
// Detect italic from tuple: "1,xxx" means italic
|
|
166
|
-
if (
|
|
166
|
+
if (tuple[0] === '1') italic = true
|
|
167
167
|
} else if (entry.includes('..')) {
|
|
168
168
|
// Variable range already handled above — skip
|
|
169
169
|
} else {
|
package/src/fs-router.ts
CHANGED
|
@@ -2,6 +2,34 @@ import { readFileSync } from 'node:fs'
|
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import type { FileRoute, RenderMode, RouteFileExports } from './types'
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Return type of a route file's `getStaticPaths()` export. Each entry
|
|
7
|
+
* supplies one set of concrete values for the route's dynamic segments;
|
|
8
|
+
* the SSG plugin expands the route's URL pattern with these params and
|
|
9
|
+
* renders one HTML file per entry.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```tsx
|
|
13
|
+
* // src/routes/posts/[id].tsx
|
|
14
|
+
* import type { GetStaticPaths } from '@pyreon/zero/server'
|
|
15
|
+
*
|
|
16
|
+
* export const getStaticPaths: GetStaticPaths<{ id: string }> = async () => {
|
|
17
|
+
* const posts = await fetch('https://api.example.com/posts').then(r => r.json())
|
|
18
|
+
* return posts.map((p) => ({ params: { id: p.slug } }))
|
|
19
|
+
* }
|
|
20
|
+
*
|
|
21
|
+
* export default function Post() { ... }
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* For catch-all routes (`/blog/[...slug].tsx`), pass the full path through
|
|
25
|
+
* the catch-all param: `{ params: { slug: 'a/b' } }` → `/blog/a/b`.
|
|
26
|
+
*/
|
|
27
|
+
export type GetStaticPaths<
|
|
28
|
+
TParams extends Record<string, string> = Record<string, string>,
|
|
29
|
+
> = () =>
|
|
30
|
+
| Array<{ params: TParams }>
|
|
31
|
+
| Promise<Array<{ params: TParams }>>
|
|
32
|
+
|
|
5
33
|
// ─── File-system route conventions ──────────────────────────────────────────
|
|
6
34
|
//
|
|
7
35
|
// src/routes/
|
|
@@ -42,6 +70,8 @@ const ROUTE_EXPORT_NAMES = [
|
|
|
42
70
|
'middleware',
|
|
43
71
|
'loaderKey',
|
|
44
72
|
'gcTime',
|
|
73
|
+
'getStaticPaths',
|
|
74
|
+
'revalidate',
|
|
45
75
|
] as const
|
|
46
76
|
|
|
47
77
|
type RouteExportName = (typeof ROUTE_EXPORT_NAMES)[number]
|
|
@@ -102,12 +132,27 @@ export function detectRouteExports(source: string): RouteFileExports {
|
|
|
102
132
|
const rawRenderMode = found.has('renderMode')
|
|
103
133
|
? extractLiteralExport(source, 'renderMode')
|
|
104
134
|
: undefined
|
|
135
|
+
// PR I — capture `revalidate` as a literal so the build-time ISR
|
|
136
|
+
// manifest (`dist/_pyreon-revalidate.json`) can be emitted from the
|
|
137
|
+
// SSG plugin without loading the route module. The route generator
|
|
138
|
+
// does NOT inline `revalidate` into the route record — it's a build-
|
|
139
|
+
// time-only concern that adapters consume via the manifest.
|
|
140
|
+
const rawRevalidate = found.has('revalidate')
|
|
141
|
+
? extractLiteralExport(source, 'revalidate')
|
|
142
|
+
: undefined
|
|
105
143
|
const cleanMeta = rawMeta !== undefined ? stripTypeAssertions(rawMeta) : undefined
|
|
106
144
|
const cleanRenderMode =
|
|
107
145
|
rawRenderMode !== undefined ? stripTypeAssertions(rawRenderMode) : undefined
|
|
146
|
+
const cleanRevalidate =
|
|
147
|
+
rawRevalidate !== undefined ? stripTypeAssertions(rawRevalidate) : undefined
|
|
108
148
|
const metaLiteral = cleanMeta !== undefined && isPureLiteral(cleanMeta) ? cleanMeta : undefined
|
|
109
149
|
const renderModeLiteral =
|
|
110
150
|
cleanRenderMode !== undefined && isPureLiteral(cleanRenderMode) ? cleanRenderMode : undefined
|
|
151
|
+
// `revalidate` literals are number (`60`) or boolean (`false`) — never
|
|
152
|
+
// an object/array — so `isPureLiteral` is overkill. Keep the same
|
|
153
|
+
// safety check for defense-in-depth.
|
|
154
|
+
const revalidateLiteral =
|
|
155
|
+
cleanRevalidate !== undefined && isPureLiteral(cleanRevalidate) ? cleanRevalidate : undefined
|
|
111
156
|
|
|
112
157
|
return {
|
|
113
158
|
hasLoader: found.has('loader'),
|
|
@@ -118,8 +163,11 @@ export function detectRouteExports(source: string): RouteFileExports {
|
|
|
118
163
|
hasMiddleware: found.has('middleware'),
|
|
119
164
|
hasLoaderKey: found.has('loaderKey'),
|
|
120
165
|
hasGcTime: found.has('gcTime'),
|
|
166
|
+
hasGetStaticPaths: found.has('getStaticPaths'),
|
|
167
|
+
hasRevalidate: found.has('revalidate'),
|
|
121
168
|
...(metaLiteral !== undefined ? { metaLiteral } : {}),
|
|
122
169
|
...(renderModeLiteral !== undefined ? { renderModeLiteral } : {}),
|
|
170
|
+
...(revalidateLiteral !== undefined ? { revalidateLiteral } : {}),
|
|
123
171
|
}
|
|
124
172
|
}
|
|
125
173
|
|
|
@@ -751,6 +799,8 @@ const EMPTY_EXPORTS: RouteFileExports = {
|
|
|
751
799
|
hasMiddleware: false,
|
|
752
800
|
hasLoaderKey: false,
|
|
753
801
|
hasGcTime: false,
|
|
802
|
+
hasGetStaticPaths: false,
|
|
803
|
+
hasRevalidate: false,
|
|
754
804
|
}
|
|
755
805
|
|
|
756
806
|
/**
|
|
@@ -767,7 +817,8 @@ export function hasAnyMetaExport(exports: RouteFileExports): boolean {
|
|
|
767
817
|
exports.hasError ||
|
|
768
818
|
exports.hasMiddleware ||
|
|
769
819
|
exports.hasLoaderKey ||
|
|
770
|
-
exports.hasGcTime
|
|
820
|
+
exports.hasGcTime ||
|
|
821
|
+
exports.hasGetStaticPaths
|
|
771
822
|
)
|
|
772
823
|
}
|
|
773
824
|
|
|
@@ -1095,6 +1146,8 @@ export function generateRouteModuleFromRoutes(
|
|
|
1095
1146
|
if (exp.hasGuard) props.push(`${indent} beforeEnter: ${mod}.guard`)
|
|
1096
1147
|
if (exp.hasLoaderKey) props.push(`${indent} loaderKey: ${mod}.loaderKey`)
|
|
1097
1148
|
if (exp.hasGcTime) props.push(`${indent} gcTime: ${mod}.gcTime`)
|
|
1149
|
+
if (exp.hasGetStaticPaths)
|
|
1150
|
+
props.push(`${indent} getStaticPaths: ${mod}.getStaticPaths`)
|
|
1098
1151
|
if (exp.hasMeta || exp.hasRenderMode) {
|
|
1099
1152
|
const metaParts: string[] = []
|
|
1100
1153
|
if (exp.hasMeta) metaParts.push(`...${mod}.meta`)
|
|
@@ -1132,7 +1185,13 @@ export function generateRouteModuleFromRoutes(
|
|
|
1132
1185
|
const inlineableMeta =
|
|
1133
1186
|
(!exp.hasMeta || exp.metaLiteral !== undefined) &&
|
|
1134
1187
|
(!exp.hasRenderMode || exp.renderModeLiteral !== undefined)
|
|
1135
|
-
|
|
1188
|
+
// getStaticPaths is a build-time export consumed by the SSG plugin's
|
|
1189
|
+
// path-resolution phase. Like loader/guard/error, it can't be inlined
|
|
1190
|
+
// as a literal — we need the actual function reference. Force the
|
|
1191
|
+
// generator into the mixed branch (case 2) when present so a namespace
|
|
1192
|
+
// import is emitted and `mod.getStaticPaths` lands on the route record.
|
|
1193
|
+
const needsFunctionExports =
|
|
1194
|
+
exp.hasLoader || exp.hasGuard || exp.hasError || exp.hasGetStaticPaths
|
|
1136
1195
|
|
|
1137
1196
|
if (hasMeta && inlineableMeta && !needsFunctionExports) {
|
|
1138
1197
|
// Optimal path — component lazy, metadata inlined.
|
|
@@ -1174,6 +1233,15 @@ export function generateRouteModuleFromRoutes(
|
|
|
1174
1233
|
const mod = nextModuleImport(page.filePath)
|
|
1175
1234
|
props.push(`${indent} gcTime: ${mod}.gcTime`)
|
|
1176
1235
|
}
|
|
1236
|
+
if (exp.hasGetStaticPaths) {
|
|
1237
|
+
// getStaticPaths runs at SSG build time (not request time), so
|
|
1238
|
+
// routing it through a dynamic import is fine — but going through
|
|
1239
|
+
// a namespace import keeps it consistent with loaderKey/gcTime
|
|
1240
|
+
// and avoids per-call import overhead during the SSG enumeration
|
|
1241
|
+
// phase.
|
|
1242
|
+
const mod = nextModuleImport(page.filePath)
|
|
1243
|
+
props.push(`${indent} getStaticPaths: ${mod}.getStaticPaths`)
|
|
1244
|
+
}
|
|
1177
1245
|
emitInlineMeta(exp, props, indent)
|
|
1178
1246
|
if (errorName) {
|
|
1179
1247
|
// For error components we can't easily await — pass the lazy
|
|
@@ -1195,6 +1263,8 @@ export function generateRouteModuleFromRoutes(
|
|
|
1195
1263
|
if (exp.hasGuard) props.push(`${indent} beforeEnter: ${mod}.guard`)
|
|
1196
1264
|
if (exp.hasLoaderKey) props.push(`${indent} loaderKey: ${mod}.loaderKey`)
|
|
1197
1265
|
if (exp.hasGcTime) props.push(`${indent} gcTime: ${mod}.gcTime`)
|
|
1266
|
+
if (exp.hasGetStaticPaths)
|
|
1267
|
+
props.push(`${indent} getStaticPaths: ${mod}.getStaticPaths`)
|
|
1198
1268
|
if (exp.hasMeta || exp.hasRenderMode) {
|
|
1199
1269
|
const metaParts: string[] = []
|
|
1200
1270
|
if (exp.hasMeta) metaParts.push(`...${mod}.meta`)
|
|
@@ -1341,7 +1411,6 @@ export function generateRouteModuleFromRoutes(
|
|
|
1341
1411
|
* skipping no-middleware files keeps both paths working.
|
|
1342
1412
|
*/
|
|
1343
1413
|
export function generateMiddlewareModule(files: string[], routesDir: string): string {
|
|
1344
|
-
const { readFileSync } = require('node:fs') as typeof import('node:fs')
|
|
1345
1414
|
const routes = parseFileRoutes(files)
|
|
1346
1415
|
const imports: string[] = []
|
|
1347
1416
|
const entries: string[] = []
|
package/src/i18n-routing.ts
CHANGED
|
@@ -1,19 +1,39 @@
|
|
|
1
1
|
import { createContext } from '@pyreon/core'
|
|
2
2
|
import { signal } from '@pyreon/reactivity'
|
|
3
3
|
import type { Plugin } from 'vite'
|
|
4
|
+
import type { FileRoute } from './types'
|
|
4
5
|
|
|
5
6
|
// ─── Localized routing ─────────────────────────────────────────────────────
|
|
6
7
|
//
|
|
7
|
-
// Adds locale-prefixed routes to Zero's file-system router
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// -
|
|
11
|
-
// -
|
|
12
|
-
//
|
|
8
|
+
// Adds locale-prefixed routes to Zero's file-system router (PR H of the SSG
|
|
9
|
+
// roadmap). Two complementary halves:
|
|
10
|
+
//
|
|
11
|
+
// 1. **Build-time route duplication** — `expandRoutesForLocales(routes, config)`
|
|
12
|
+
// fans every `FileRoute` into per-locale variants according to the
|
|
13
|
+
// configured `strategy`. Called from `vite-plugin.ts`'s virtual-routes
|
|
14
|
+
// load AND `ssg-plugin.ts`'s pre-render path expansion. Wired via the
|
|
15
|
+
// `i18n?: I18nRoutingConfig` field on `ZeroConfig`.
|
|
16
|
+
//
|
|
17
|
+
// 2. **Request-time locale detection** — the `i18nRouting()` Vite plugin
|
|
18
|
+
// below attaches a middleware that reads `Accept-Language` / cookies,
|
|
19
|
+
// sets the `localeSignal` for `useLocale()`, and redirects root
|
|
20
|
+
// requests to the detected locale. Independent from (1) — `i18nRouting()`
|
|
21
|
+
// only handles middleware; route duplication happens via
|
|
22
|
+
// `expandRoutesForLocales` regardless of whether this plugin is mounted.
|
|
23
|
+
//
|
|
24
|
+
// Examples (with `locales: ["en","de","cs"]`, `defaultLocale: "en"`):
|
|
25
|
+
// - `prefix-except-default` (default): `/about` (en, unprefixed) +
|
|
26
|
+
// `/de/about`, `/cs/about`. Best for SEO-on-default-locale apps.
|
|
27
|
+
// - `prefix`: `/en/about`, `/de/about`, `/cs/about`. Every URL
|
|
28
|
+
// self-identifies its locale.
|
|
13
29
|
//
|
|
14
30
|
// Usage:
|
|
15
|
-
//
|
|
16
|
-
//
|
|
31
|
+
// // zero.config.ts
|
|
32
|
+
// import { defineConfig, i18nRouting } from "@pyreon/zero"
|
|
33
|
+
// export default defineConfig({
|
|
34
|
+
// i18n: { locales: ["en","de","cs"], defaultLocale: "en" },
|
|
35
|
+
// plugins: [i18nRouting({ locales: ["en","de","cs"], defaultLocale: "en" })],
|
|
36
|
+
// })
|
|
17
37
|
|
|
18
38
|
export interface I18nRoutingConfig {
|
|
19
39
|
/** Supported locales. e.g. ["en", "de", "cs"] */
|
|
@@ -108,6 +128,218 @@ export function buildLocalePath(
|
|
|
108
128
|
return `/${locale}${clean}`
|
|
109
129
|
}
|
|
110
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Fan a `FileRoute[]` into per-locale duplicates so the file-system router
|
|
133
|
+
* knows about every localized URL pattern at build time. PR H — was the
|
|
134
|
+
* missing half of the i18n story before this PR (the `i18nRouting()` Vite
|
|
135
|
+
* plugin only handled request-time locale detection; routes themselves
|
|
136
|
+
* were never duplicated, so static-host SSG outputs and SSR matching had
|
|
137
|
+
* no `/de/about` / `/cs/about` records to render against).
|
|
138
|
+
*
|
|
139
|
+
* Strategy semantics:
|
|
140
|
+
*
|
|
141
|
+
* - **`prefix-except-default`** (default): the default locale's routes
|
|
142
|
+
* keep their original `urlPath` unchanged (`/about` stays `/about`); all
|
|
143
|
+
* non-default locales get a prefix (`/de/about`, `/cs/about`). Best for
|
|
144
|
+
* SEO-on-default-locale apps — search engines see canonical URLs at
|
|
145
|
+
* `/about` while non-default speakers get explicit prefixes.
|
|
146
|
+
*
|
|
147
|
+
* - **`prefix`**: every locale gets its own prefix, including the default
|
|
148
|
+
* (`/en/about`, `/de/about`, `/cs/about`). Root `/` becomes `/en` /
|
|
149
|
+
* `/de` / `/cs`. Better when no locale is "primary" — every URL
|
|
150
|
+
* self-identifies its locale.
|
|
151
|
+
*
|
|
152
|
+
* Layouts, error boundaries, loading components, and 404 pages duplicate
|
|
153
|
+
* along with their pages — same source file (same `filePath`), new
|
|
154
|
+
* locale-prefixed `urlPath` / `dirPath` / `depth`. The route tree built
|
|
155
|
+
* from the expanded array therefore has one fully-formed subtree per
|
|
156
|
+
* locale, so layout matching, dynamic params (`[id]` → `:id`), and
|
|
157
|
+
* catch-all routes (`[...slug]` → `:slug*`) all compose naturally with
|
|
158
|
+
* the locale prefix — no special cases.
|
|
159
|
+
*
|
|
160
|
+
* `getStaticPaths` composition (for SSG): each duplicate route inherits
|
|
161
|
+
* the same `exports.getStaticPaths`. The SSG plugin's `expandUrlPattern`
|
|
162
|
+
* step then expands `/blog/[slug]` × `[en, de]` × `getStaticPaths()
|
|
163
|
+
* → ['a', 'b']` into `/blog/a`, `/blog/b`, `/de/blog/a`, `/de/blog/b`
|
|
164
|
+
* (or all six prefixed forms under `'prefix'` strategy). Cardinality
|
|
165
|
+
* compounds, which is by design — `ssg.concurrency` (PR D) limits
|
|
166
|
+
* in-flight renders independent of route count.
|
|
167
|
+
*
|
|
168
|
+
* No-op when `config.locales` is empty or contains only the default
|
|
169
|
+
* locale (prefix-except-default strategy with no other locales) — returns
|
|
170
|
+
* the input array unchanged. Always return a fresh array on duplication
|
|
171
|
+
* so callers don't accidentally mutate cached input.
|
|
172
|
+
*
|
|
173
|
+
* Reference: the helper is called from `vite-plugin.ts`'s virtual route
|
|
174
|
+
* module load AND `ssg-plugin.ts`'s pre-render path expansion. Tested in
|
|
175
|
+
* isolation — duplication is a pure transform on FileRoute[] with no
|
|
176
|
+
* filesystem or network side effects.
|
|
177
|
+
*/
|
|
178
|
+
export function expandRoutesForLocales(
|
|
179
|
+
routes: FileRoute[],
|
|
180
|
+
config: I18nRoutingConfig,
|
|
181
|
+
): FileRoute[] {
|
|
182
|
+
const strategy = config.strategy ?? 'prefix-except-default'
|
|
183
|
+
const { locales, defaultLocale } = config
|
|
184
|
+
|
|
185
|
+
// Cheap no-op guards. Empty `locales` would otherwise produce an empty
|
|
186
|
+
// route array, killing the app silently.
|
|
187
|
+
if (locales.length === 0) return routes
|
|
188
|
+
|
|
189
|
+
// PR L2 — Validate every locale string before they reach the filesystem.
|
|
190
|
+
// The locales drive both URL pattern emission (`/${locale}/...`) AND
|
|
191
|
+
// filesystem writes (`mkdir(dist/${locale})` in ssg-plugin.ts's per-
|
|
192
|
+
// locale 404 emit). User-supplied input with `/`, `..`, `\`, NUL, or
|
|
193
|
+
// leading dots could write outside dist OR produce broken URLs.
|
|
194
|
+
// Validate at the single entry point so every downstream consumer
|
|
195
|
+
// (vite-plugin's virtual-routes load AND ssg-plugin's path expansion)
|
|
196
|
+
// benefits from one check.
|
|
197
|
+
//
|
|
198
|
+
// Reject:
|
|
199
|
+
// - empty string (kills the app silently with no usable URLs)
|
|
200
|
+
// - leading/trailing whitespace (URL-malformed)
|
|
201
|
+
// - `/` or `\` (path traversal AND structurally invalid as a URL
|
|
202
|
+
// segment — `/de/sub/about` would split into nested directories)
|
|
203
|
+
// - `..` or `.` whole-string (path traversal)
|
|
204
|
+
// - NUL char (system-call boundary breaks)
|
|
205
|
+
// - leading `.` (hidden directory; macOS/Linux dotfile pattern that
|
|
206
|
+
// would create `dist/.locale/` invisible to most ls outputs)
|
|
207
|
+
//
|
|
208
|
+
// Runs AFTER the empty-locales no-op guard so apps temporarily
|
|
209
|
+
// toggling to `i18n: { locales: [], ... }` (mid-migration shape)
|
|
210
|
+
// don't trip on an unused defaultLocale.
|
|
211
|
+
for (const locale of locales) validateLocale(locale)
|
|
212
|
+
validateLocale(defaultLocale)
|
|
213
|
+
if (
|
|
214
|
+
strategy === 'prefix-except-default'
|
|
215
|
+
&& locales.length === 1
|
|
216
|
+
&& locales[0] === defaultLocale
|
|
217
|
+
) {
|
|
218
|
+
return routes
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const expanded: FileRoute[] = []
|
|
222
|
+
for (const route of routes) {
|
|
223
|
+
for (const locale of locales) {
|
|
224
|
+
// For prefix-except-default, the default locale uses the ORIGINAL
|
|
225
|
+
// urlPath / dirPath / depth — no prefix applied.
|
|
226
|
+
if (strategy === 'prefix-except-default' && locale === defaultLocale) {
|
|
227
|
+
expanded.push(route)
|
|
228
|
+
continue
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// PR H follow-up: skip duplication of ROOT-level layouts under
|
|
232
|
+
// `prefix-except-default`. The unprefixed default-locale root
|
|
233
|
+
// `_layout.tsx` (urlPath `/`) is the parent of the matched chain
|
|
234
|
+
// for EVERY path, including locale-prefixed ones — the route
|
|
235
|
+
// tree's hierarchical matching wraps `/de/about` under `/_layout`
|
|
236
|
+
// automatically. Producing a duplicate `/de/_layout` would cause
|
|
237
|
+
// the matcher to nest BOTH layouts (`/_layout` → `/de/_layout` →
|
|
238
|
+
// page), mounting the layout component twice and rendering two
|
|
239
|
+
// navbars / two PyreonUI providers.
|
|
240
|
+
//
|
|
241
|
+
// Non-root layouts (e.g. `/dashboard/_layout` at urlPath
|
|
242
|
+
// `/dashboard`) MUST still be duplicated — `/de/dashboard/users`
|
|
243
|
+
// is NOT a child of the unprefixed `/dashboard/_layout` (the
|
|
244
|
+
// path patterns don't match), so the de-prefixed dashboard needs
|
|
245
|
+
// its own `_layout`.
|
|
246
|
+
//
|
|
247
|
+
// Under `prefix` strategy this skip does NOT apply: there is no
|
|
248
|
+
// unprefixed default to inherit from, so every locale needs its
|
|
249
|
+
// own root layout (`/en/_layout`, `/de/_layout`, …).
|
|
250
|
+
if (
|
|
251
|
+
strategy === 'prefix-except-default'
|
|
252
|
+
&& route.isLayout
|
|
253
|
+
&& route.urlPath === '/'
|
|
254
|
+
) {
|
|
255
|
+
continue
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const newUrlPath = prefixUrlPath(route.urlPath, locale)
|
|
259
|
+
// dirPath needs the locale segment too so the route-tree builder
|
|
260
|
+
// groups localized siblings correctly. Original empty `dirPath`
|
|
261
|
+
// (root-level routes) becomes the bare locale.
|
|
262
|
+
const newDirPath = route.dirPath === '' ? locale : `${locale}/${route.dirPath}`
|
|
263
|
+
// Recompute depth from the new urlPath. Layouts at the root (depth
|
|
264
|
+
// 0) become depth 1 under their locale prefix; nested routes shift
|
|
265
|
+
// up by 1.
|
|
266
|
+
const newDepth = newUrlPath === '/' ? 0 : newUrlPath.split('/').filter(Boolean).length
|
|
267
|
+
|
|
268
|
+
expanded.push({
|
|
269
|
+
...route,
|
|
270
|
+
urlPath: newUrlPath,
|
|
271
|
+
dirPath: newDirPath,
|
|
272
|
+
depth: newDepth,
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return expanded
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Prepend `/locale` to a URL pattern. Handles three shapes:
|
|
281
|
+
* `/` → `/de`
|
|
282
|
+
* `/about` → `/de/about`
|
|
283
|
+
* `/users/:id` / `/blog/:slug*` → `/de/users/:id` / `/de/blog/:slug*`
|
|
284
|
+
*
|
|
285
|
+
* Internal helper to `expandRoutesForLocales`; not exported because the
|
|
286
|
+
* public surface for path-building is `buildLocalePath` (which strips
|
|
287
|
+
* existing locale prefixes — different semantics).
|
|
288
|
+
*/
|
|
289
|
+
function prefixUrlPath(urlPath: string, locale: string): string {
|
|
290
|
+
if (urlPath === '/') return `/${locale}`
|
|
291
|
+
return `/${locale}${urlPath}`
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Validate a locale string (PR L2).
|
|
296
|
+
*
|
|
297
|
+
* The locale drives both URL pattern emission AND filesystem writes
|
|
298
|
+
* (see `expandRoutesForLocales` for full rationale). Reject input that
|
|
299
|
+
* would either:
|
|
300
|
+
* - break path-traversal boundaries (`..`, `/`, `\`)
|
|
301
|
+
* - produce invalid URL segments (whitespace, NUL)
|
|
302
|
+
* - create hidden-file artifacts (`.` leading)
|
|
303
|
+
* - silently kill the app (empty string)
|
|
304
|
+
*
|
|
305
|
+
* Throws with an actionable `[Pyreon]` error message. Called per-locale
|
|
306
|
+
* by `expandRoutesForLocales` after the empty-locales no-op guard.
|
|
307
|
+
*
|
|
308
|
+
* @internal — exported for unit testing.
|
|
309
|
+
*/
|
|
310
|
+
export function validateLocale(locale: string): void {
|
|
311
|
+
if (typeof locale !== 'string' || locale === '') {
|
|
312
|
+
throw new Error(
|
|
313
|
+
`[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. Locales must be non-empty strings (e.g. "en", "de", "en-US").`,
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
if (locale.trim() !== locale) {
|
|
317
|
+
throw new Error(
|
|
318
|
+
`[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. Leading or trailing whitespace not allowed.`,
|
|
319
|
+
)
|
|
320
|
+
}
|
|
321
|
+
if (locale.includes('/') || locale.includes('\\')) {
|
|
322
|
+
throw new Error(
|
|
323
|
+
`[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. Path separators ("/", "\\\\") not allowed — they would break URL emission and could write outside the dist directory.`,
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
if (locale === '..' || locale === '.') {
|
|
327
|
+
throw new Error(
|
|
328
|
+
`[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. Path-traversal segments not allowed.`,
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
if (locale.startsWith('.')) {
|
|
332
|
+
throw new Error(
|
|
333
|
+
`[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. Leading dot not allowed — it would create a hidden-file directory (\`dist/.${locale.slice(1)}/\`) invisible to most file listings.`,
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
if (locale.includes('\0')) {
|
|
337
|
+
throw new Error(
|
|
338
|
+
`[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. NUL characters not allowed.`,
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
111
343
|
/**
|
|
112
344
|
* Create a LocaleContext for use in components and loaders.
|
|
113
345
|
*/
|
|
@@ -176,10 +408,12 @@ export function i18nRouting(config: I18nRoutingConfig): Plugin {
|
|
|
176
408
|
return {
|
|
177
409
|
name: 'pyreon-zero-i18n-routing',
|
|
178
410
|
|
|
179
|
-
// Route duplication is NOT handled here.
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
411
|
+
// Route duplication is NOT handled here. It happens in
|
|
412
|
+
// `vite-plugin.ts` and `ssg-plugin.ts` via `expandRoutesForLocales`,
|
|
413
|
+
// gated by the `i18n` field on `ZeroConfig`. This plugin only
|
|
414
|
+
// provides: (1) the dev server middleware for locale detection
|
|
415
|
+
// (Accept-Language, cookies, root redirect) and (2) the runtime
|
|
416
|
+
// hooks (useLocale, setLocale) for client-side use.
|
|
183
417
|
configResolved() {},
|
|
184
418
|
|
|
185
419
|
configureServer(server) {
|