@pyreon/zero 0.14.0 → 0.16.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-Ci0kVmM4.js +146 -0
- package/lib/client.js +7 -2
- package/lib/csp.js +19 -9
- package/lib/env.js +6 -6
- package/lib/font.js +3 -3
- package/lib/{fs-router-CQ7Zxeca.js → fs-router-MewHc5SB.js} +56 -24
- package/lib/i18n-routing.js +112 -1
- package/lib/image-plugin.js +4 -0
- package/lib/image.js +141 -108
- package/lib/index.js +253 -132
- package/lib/link.js +1 -49
- package/lib/og-image.js +5 -5
- package/lib/rolldown-runtime-CjeV3_4I.js +18 -0
- package/lib/script.js +115 -74
- package/lib/seo.js +186 -15
- package/lib/server.js +275 -1247
- package/lib/theme.js +1 -50
- package/lib/types/config.d.ts +275 -3
- package/lib/types/env.d.ts +2 -2
- package/lib/types/i18n-routing.d.ts +197 -6
- package/lib/types/image.d.ts +105 -5
- package/lib/types/index.d.ts +640 -178
- package/lib/types/link.d.ts +3 -3
- package/lib/types/script.d.ts +78 -6
- package/lib/types/seo.d.ts +128 -4
- package/lib/types/server.d.ts +603 -77
- package/lib/types/theme.d.ts +2 -2
- package/lib/vite-plugin-xjWZwudX.js +2454 -0
- package/package.json +16 -13
- 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 +35 -1
- package/src/client.ts +18 -0
- package/src/csp.ts +28 -12
- package/src/entry-server.ts +55 -5
- package/src/env.ts +7 -7
- package/src/font.ts +3 -3
- package/src/fs-router.ts +123 -4
- 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 +1523 -0
- package/src/types.ts +329 -19
- package/src/vercel-revalidate-handler.ts +204 -0
- package/src/vite-plugin.ts +326 -68
- package/lib/actions.js.map +0 -1
- package/lib/ai.js.map +0 -1
- package/lib/api-routes.js.map +0 -1
- package/lib/cache.js.map +0 -1
- package/lib/client.js.map +0 -1
- package/lib/compression.js.map +0 -1
- package/lib/config.js.map +0 -1
- package/lib/cors.js.map +0 -1
- package/lib/csp.js.map +0 -1
- package/lib/env.js.map +0 -1
- package/lib/favicon.js.map +0 -1
- package/lib/font.js.map +0 -1
- package/lib/fs-router-3xzp-4Wj.js.map +0 -1
- package/lib/fs-router-CQ7Zxeca.js.map +0 -1
- package/lib/i18n-routing.js.map +0 -1
- package/lib/image-plugin.js.map +0 -1
- package/lib/image.js.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/link.js.map +0 -1
- package/lib/logger.js.map +0 -1
- package/lib/meta.js.map +0 -1
- package/lib/middleware.js.map +0 -1
- package/lib/og-image.js.map +0 -1
- package/lib/rate-limit.js.map +0 -1
- package/lib/script.js.map +0 -1
- package/lib/seo.js.map +0 -1
- package/lib/server.js.map +0 -1
- package/lib/testing.js.map +0 -1
- package/lib/theme.js.map +0 -1
- package/lib/types/actions.d.ts.map +0 -1
- package/lib/types/ai.d.ts.map +0 -1
- package/lib/types/api-routes.d.ts.map +0 -1
- package/lib/types/cache.d.ts.map +0 -1
- package/lib/types/client.d.ts.map +0 -1
- package/lib/types/compression.d.ts.map +0 -1
- package/lib/types/config.d.ts.map +0 -1
- package/lib/types/cors.d.ts.map +0 -1
- package/lib/types/csp.d.ts.map +0 -1
- package/lib/types/env.d.ts.map +0 -1
- package/lib/types/favicon.d.ts.map +0 -1
- package/lib/types/font.d.ts.map +0 -1
- package/lib/types/i18n-routing.d.ts.map +0 -1
- package/lib/types/image-plugin.d.ts.map +0 -1
- package/lib/types/image.d.ts.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
- package/lib/types/link.d.ts.map +0 -1
- package/lib/types/logger.d.ts.map +0 -1
- package/lib/types/meta.d.ts.map +0 -1
- package/lib/types/middleware.d.ts.map +0 -1
- package/lib/types/og-image.d.ts.map +0 -1
- package/lib/types/rate-limit.d.ts.map +0 -1
- package/lib/types/script.d.ts.map +0 -1
- package/lib/types/seo.d.ts.map +0 -1
- package/lib/types/server.d.ts.map +0 -1
- package/lib/types/testing.d.ts.map +0 -1
- package/lib/types/theme.d.ts.map +0 -1
package/src/client.ts
CHANGED
|
@@ -5,6 +5,13 @@ import { hydrateLoaderData } from '@pyreon/router'
|
|
|
5
5
|
import { hydrateRoot, mount } from '@pyreon/runtime-dom'
|
|
6
6
|
import { createApp } from './app'
|
|
7
7
|
|
|
8
|
+
// Vite-injected build-time constant. Defined in `vite-plugin.ts`'s
|
|
9
|
+
// `config()` hook from `zero({ base })`. Falls back to `'/'` for
|
|
10
|
+
// non-Vite builds (test environments, etc.) so the read is always
|
|
11
|
+
// safe. The fallback is documented intent — there's no Pyreon
|
|
12
|
+
// deployment outside Vite that consumes this.
|
|
13
|
+
declare const __ZERO_BASE__: string
|
|
14
|
+
|
|
8
15
|
// ─── Client entry factory ───────────────────────────────────────────────────
|
|
9
16
|
|
|
10
17
|
export interface StartClientOptions {
|
|
@@ -59,10 +66,21 @@ export function startClient(options: StartClientOptions) {
|
|
|
59
66
|
const container = document.getElementById('app')
|
|
60
67
|
if (!container) throw new Error('[Pyreon] Missing #app container element')
|
|
61
68
|
|
|
69
|
+
// Read the Vite-injected base so `createRouter({ base })` matches the
|
|
70
|
+
// value Vite used to rewrite asset URLs. `typeof` guard covers the
|
|
71
|
+
// edge case where the constant isn't defined (non-Vite test contexts);
|
|
72
|
+
// missing the constant in a real Vite build is impossible because the
|
|
73
|
+
// plugin's `config()` hook always declares it via `define`.
|
|
74
|
+
const base =
|
|
75
|
+
typeof __ZERO_BASE__ !== 'undefined' && __ZERO_BASE__ !== '/'
|
|
76
|
+
? __ZERO_BASE__
|
|
77
|
+
: undefined
|
|
78
|
+
|
|
62
79
|
const { App, router } = createApp({
|
|
63
80
|
routes: options.routes,
|
|
64
81
|
routerMode: 'history',
|
|
65
82
|
...(options.layout ? { layout: options.layout } : {}),
|
|
83
|
+
...(base ? { base } : {}),
|
|
66
84
|
})
|
|
67
85
|
|
|
68
86
|
// ── Loader data hydration (SSR path) ───────────────────────────────────────
|
package/src/csp.ts
CHANGED
|
@@ -140,21 +140,37 @@ export function buildCspHeader(directives: CspDirectives, nonce?: string): strin
|
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
/**
|
|
143
|
-
* Generate a random nonce string (base64, 16 bytes).
|
|
143
|
+
* Generate a cryptographically-random nonce string (base64, 16 bytes).
|
|
144
|
+
*
|
|
145
|
+
* Throws when `crypto.getRandomValues` is unavailable. CSP nonces protect
|
|
146
|
+
* against XSS by gating inline script execution; a predictable nonce
|
|
147
|
+
* (`Math.random` ~31 bits of entropy) bypasses CSP entirely. Silent
|
|
148
|
+
* degradation here was a security anti-pattern — we surface the
|
|
149
|
+
* misconfiguration loudly instead.
|
|
150
|
+
*
|
|
151
|
+
* Realistic deployments always have `crypto.getRandomValues`: Node 18+,
|
|
152
|
+
* Bun, Deno, browsers, edge workers (Cloudflare/Vercel/Netlify), and
|
|
153
|
+
* vitest/happy-dom all expose it via `globalThis.crypto`. If you hit
|
|
154
|
+
* this throw, your environment is unusual — fix the env, don't downgrade
|
|
155
|
+
* the security primitive.
|
|
144
156
|
*/
|
|
145
157
|
function generateNonce(): string {
|
|
146
|
-
if (typeof crypto
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
? btoa(binary)
|
|
154
|
-
: Buffer.from(bytes).toString('base64')
|
|
158
|
+
if (typeof crypto === 'undefined' || !crypto.getRandomValues) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
'[Pyreon] CSP nonce generation requires `crypto.getRandomValues` (Web Crypto API). ' +
|
|
161
|
+
'No secure RNG is available in this environment. CSP nonces must be cryptographically ' +
|
|
162
|
+
'random — falling back to `Math.random` would silently weaken XSS protection. ' +
|
|
163
|
+
'Ensure Node 18+, Bun, Deno, an edge runtime, or a browser environment.',
|
|
164
|
+
)
|
|
155
165
|
}
|
|
156
|
-
|
|
157
|
-
|
|
166
|
+
const bytes = new Uint8Array(16)
|
|
167
|
+
crypto.getRandomValues(bytes)
|
|
168
|
+
// Convert to base64 using btoa
|
|
169
|
+
let binary = ''
|
|
170
|
+
for (const byte of bytes) binary += String.fromCharCode(byte)
|
|
171
|
+
return typeof btoa === 'function'
|
|
172
|
+
? btoa(binary)
|
|
173
|
+
: Buffer.from(bytes).toString('base64')
|
|
158
174
|
}
|
|
159
175
|
|
|
160
176
|
/**
|
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/
|
|
@@ -40,6 +68,10 @@ const ROUTE_EXPORT_NAMES = [
|
|
|
40
68
|
'renderMode',
|
|
41
69
|
'error',
|
|
42
70
|
'middleware',
|
|
71
|
+
'loaderKey',
|
|
72
|
+
'gcTime',
|
|
73
|
+
'getStaticPaths',
|
|
74
|
+
'revalidate',
|
|
43
75
|
] as const
|
|
44
76
|
|
|
45
77
|
type RouteExportName = (typeof ROUTE_EXPORT_NAMES)[number]
|
|
@@ -100,12 +132,27 @@ export function detectRouteExports(source: string): RouteFileExports {
|
|
|
100
132
|
const rawRenderMode = found.has('renderMode')
|
|
101
133
|
? extractLiteralExport(source, 'renderMode')
|
|
102
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
|
|
103
143
|
const cleanMeta = rawMeta !== undefined ? stripTypeAssertions(rawMeta) : undefined
|
|
104
144
|
const cleanRenderMode =
|
|
105
145
|
rawRenderMode !== undefined ? stripTypeAssertions(rawRenderMode) : undefined
|
|
146
|
+
const cleanRevalidate =
|
|
147
|
+
rawRevalidate !== undefined ? stripTypeAssertions(rawRevalidate) : undefined
|
|
106
148
|
const metaLiteral = cleanMeta !== undefined && isPureLiteral(cleanMeta) ? cleanMeta : undefined
|
|
107
149
|
const renderModeLiteral =
|
|
108
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
|
|
109
156
|
|
|
110
157
|
return {
|
|
111
158
|
hasLoader: found.has('loader'),
|
|
@@ -114,8 +161,13 @@ export function detectRouteExports(source: string): RouteFileExports {
|
|
|
114
161
|
hasRenderMode: found.has('renderMode'),
|
|
115
162
|
hasError: found.has('error'),
|
|
116
163
|
hasMiddleware: found.has('middleware'),
|
|
164
|
+
hasLoaderKey: found.has('loaderKey'),
|
|
165
|
+
hasGcTime: found.has('gcTime'),
|
|
166
|
+
hasGetStaticPaths: found.has('getStaticPaths'),
|
|
167
|
+
hasRevalidate: found.has('revalidate'),
|
|
117
168
|
...(metaLiteral !== undefined ? { metaLiteral } : {}),
|
|
118
169
|
...(renderModeLiteral !== undefined ? { renderModeLiteral } : {}),
|
|
170
|
+
...(revalidateLiteral !== undefined ? { revalidateLiteral } : {}),
|
|
119
171
|
}
|
|
120
172
|
}
|
|
121
173
|
|
|
@@ -745,6 +797,10 @@ const EMPTY_EXPORTS: RouteFileExports = {
|
|
|
745
797
|
hasRenderMode: false,
|
|
746
798
|
hasError: false,
|
|
747
799
|
hasMiddleware: false,
|
|
800
|
+
hasLoaderKey: false,
|
|
801
|
+
hasGcTime: false,
|
|
802
|
+
hasGetStaticPaths: false,
|
|
803
|
+
hasRevalidate: false,
|
|
748
804
|
}
|
|
749
805
|
|
|
750
806
|
/**
|
|
@@ -759,7 +815,10 @@ export function hasAnyMetaExport(exports: RouteFileExports): boolean {
|
|
|
759
815
|
exports.hasMeta ||
|
|
760
816
|
exports.hasRenderMode ||
|
|
761
817
|
exports.hasError ||
|
|
762
|
-
exports.hasMiddleware
|
|
818
|
+
exports.hasMiddleware ||
|
|
819
|
+
exports.hasLoaderKey ||
|
|
820
|
+
exports.hasGcTime ||
|
|
821
|
+
exports.hasGetStaticPaths
|
|
763
822
|
)
|
|
764
823
|
}
|
|
765
824
|
|
|
@@ -1085,6 +1144,10 @@ export function generateRouteModuleFromRoutes(
|
|
|
1085
1144
|
props.push(`${indent} component: ${mod}.default`)
|
|
1086
1145
|
if (exp.hasLoader) props.push(`${indent} loader: ${mod}.loader`)
|
|
1087
1146
|
if (exp.hasGuard) props.push(`${indent} beforeEnter: ${mod}.guard`)
|
|
1147
|
+
if (exp.hasLoaderKey) props.push(`${indent} loaderKey: ${mod}.loaderKey`)
|
|
1148
|
+
if (exp.hasGcTime) props.push(`${indent} gcTime: ${mod}.gcTime`)
|
|
1149
|
+
if (exp.hasGetStaticPaths)
|
|
1150
|
+
props.push(`${indent} getStaticPaths: ${mod}.getStaticPaths`)
|
|
1088
1151
|
if (exp.hasMeta || exp.hasRenderMode) {
|
|
1089
1152
|
const metaParts: string[] = []
|
|
1090
1153
|
if (exp.hasMeta) metaParts.push(`...${mod}.meta`)
|
|
@@ -1122,7 +1185,13 @@ export function generateRouteModuleFromRoutes(
|
|
|
1122
1185
|
const inlineableMeta =
|
|
1123
1186
|
(!exp.hasMeta || exp.metaLiteral !== undefined) &&
|
|
1124
1187
|
(!exp.hasRenderMode || exp.renderModeLiteral !== undefined)
|
|
1125
|
-
|
|
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
|
|
1126
1195
|
|
|
1127
1196
|
if (hasMeta && inlineableMeta && !needsFunctionExports) {
|
|
1128
1197
|
// Optimal path — component lazy, metadata inlined.
|
|
@@ -1152,6 +1221,27 @@ export function generateRouteModuleFromRoutes(
|
|
|
1152
1221
|
`${indent} beforeEnter: (to, from) => import("${fullPath}").then((m) => m.guard(to, from))`,
|
|
1153
1222
|
)
|
|
1154
1223
|
}
|
|
1224
|
+
if (exp.hasLoaderKey) {
|
|
1225
|
+
// loaderKey runs SYNCHRONOUSLY during the cache-key check; can't be
|
|
1226
|
+
// routed through a dynamic import. Inline a `mod.loaderKey` lookup
|
|
1227
|
+
// via the same namespace-import pattern as the metadata path. Rolldown
|
|
1228
|
+
// will share the chunk with the lazy() component thunk.
|
|
1229
|
+
const mod = nextModuleImport(page.filePath)
|
|
1230
|
+
props.push(`${indent} loaderKey: ${mod}.loaderKey`)
|
|
1231
|
+
}
|
|
1232
|
+
if (exp.hasGcTime) {
|
|
1233
|
+
const mod = nextModuleImport(page.filePath)
|
|
1234
|
+
props.push(`${indent} gcTime: ${mod}.gcTime`)
|
|
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
|
+
}
|
|
1155
1245
|
emitInlineMeta(exp, props, indent)
|
|
1156
1246
|
if (errorName) {
|
|
1157
1247
|
// For error components we can't easily await — pass the lazy
|
|
@@ -1171,6 +1261,10 @@ export function generateRouteModuleFromRoutes(
|
|
|
1171
1261
|
props.push(`${indent} component: ${mod}.default`)
|
|
1172
1262
|
if (exp.hasLoader) props.push(`${indent} loader: ${mod}.loader`)
|
|
1173
1263
|
if (exp.hasGuard) props.push(`${indent} beforeEnter: ${mod}.guard`)
|
|
1264
|
+
if (exp.hasLoaderKey) props.push(`${indent} loaderKey: ${mod}.loaderKey`)
|
|
1265
|
+
if (exp.hasGcTime) props.push(`${indent} gcTime: ${mod}.gcTime`)
|
|
1266
|
+
if (exp.hasGetStaticPaths)
|
|
1267
|
+
props.push(`${indent} getStaticPaths: ${mod}.getStaticPaths`)
|
|
1174
1268
|
if (exp.hasMeta || exp.hasRenderMode) {
|
|
1175
1269
|
const metaParts: string[] = []
|
|
1176
1270
|
if (exp.hasMeta) metaParts.push(`...${mod}.meta`)
|
|
@@ -1231,6 +1325,8 @@ export function generateRouteModuleFromRoutes(
|
|
|
1231
1325
|
if (layoutMod !== undefined) {
|
|
1232
1326
|
if (exp.hasLoader) props.push(`${indent}loader: ${layoutMod}.loader`)
|
|
1233
1327
|
if (exp.hasGuard) props.push(`${indent}beforeEnter: ${layoutMod}.guard`)
|
|
1328
|
+
if (exp.hasLoaderKey) props.push(`${indent}loaderKey: ${layoutMod}.loaderKey`)
|
|
1329
|
+
if (exp.hasGcTime) props.push(`${indent}gcTime: ${layoutMod}.gcTime`)
|
|
1234
1330
|
if (exp.hasMeta || exp.hasRenderMode) {
|
|
1235
1331
|
const metaParts: string[] = []
|
|
1236
1332
|
if (exp.hasMeta) metaParts.push(`...${layoutMod}.meta`)
|
|
@@ -1307,6 +1403,12 @@ export function generateRouteModuleFromRoutes(
|
|
|
1307
1403
|
/**
|
|
1308
1404
|
* Generate a virtual module that maps URL patterns to their middleware exports.
|
|
1309
1405
|
* Used by the server entry to dispatch per-route middleware.
|
|
1406
|
+
*
|
|
1407
|
+
* Detects whether each route file actually exports `middleware` (via
|
|
1408
|
+
* `detectRouteExports` source scanning) and only emits an import for files
|
|
1409
|
+
* that do. The `lazy()` import path tolerates missing exports, but the SSG
|
|
1410
|
+
* static-import path fails Rolldown's missing-export check at build time —
|
|
1411
|
+
* skipping no-middleware files keeps both paths working.
|
|
1310
1412
|
*/
|
|
1311
1413
|
export function generateMiddlewareModule(files: string[], routesDir: string): string {
|
|
1312
1414
|
const routes = parseFileRoutes(files)
|
|
@@ -1316,6 +1418,14 @@ export function generateMiddlewareModule(files: string[], routesDir: string): st
|
|
|
1316
1418
|
|
|
1317
1419
|
for (const route of routes) {
|
|
1318
1420
|
if (route.isLayout || route.isError || route.isLoading || route.isNotFound) continue
|
|
1421
|
+
let hasMw = false
|
|
1422
|
+
try {
|
|
1423
|
+
const source = readFileSync(`${routesDir}/${route.filePath}`, 'utf-8')
|
|
1424
|
+
hasMw = detectRouteExports(source).hasMiddleware
|
|
1425
|
+
} catch {
|
|
1426
|
+
// File can't be read — skip; the SSR runtime falls back gracefully.
|
|
1427
|
+
}
|
|
1428
|
+
if (!hasMw) continue
|
|
1319
1429
|
const name = `_mw${counter++}`
|
|
1320
1430
|
const fullPath = `${routesDir}/${route.filePath}`
|
|
1321
1431
|
imports.push(`import { middleware as ${name} } from "${fullPath}"`)
|
|
@@ -1372,8 +1482,17 @@ export async function scanRouteFilesWithExports(
|
|
|
1372
1482
|
defaultMode: RenderMode = 'ssr',
|
|
1373
1483
|
): Promise<FileRoute[]> {
|
|
1374
1484
|
const { readFile } = await import('node:fs/promises')
|
|
1375
|
-
|
|
1376
|
-
|
|
1485
|
+
const { isApiRoute } = await import('./api-routes')
|
|
1486
|
+
|
|
1487
|
+
// Api routes (`api/**/*.ts`) live in the same routes tree but are served by
|
|
1488
|
+
// a separate virtual module (`virtual:zero/api-routes`). Page-route
|
|
1489
|
+
// generation MUST skip them — they export named HTTP method handlers
|
|
1490
|
+
// (`GET`/`POST`/...), not a default page component, so the SSG `staticImports`
|
|
1491
|
+
// mode would emit `import _N from "api/posts.ts"` and fail Rolldown's
|
|
1492
|
+
// missing-export check at build time. The bug only surfaced under SSG
|
|
1493
|
+
// because the regular lazy()-mode `import()` doesn't fail on missing
|
|
1494
|
+
// default exports.
|
|
1495
|
+
const files = (await scanRouteFiles(routesDir)).filter((f) => !isApiRoute(f))
|
|
1377
1496
|
const exportsMap = new Map<string, RouteFileExports>()
|
|
1378
1497
|
|
|
1379
1498
|
await Promise.all(
|