@pyreon/zero 0.14.0 → 0.15.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.
Files changed (80) hide show
  1. package/lib/api-routes-DANluJic.js +146 -0
  2. package/lib/client.js +3 -1
  3. package/lib/csp.js +19 -9
  4. package/lib/{fs-router-CQ7Zxeca.js → fs-router-ZebyutPa.js} +43 -6
  5. package/lib/image-plugin.js +4 -0
  6. package/lib/image.js +1 -50
  7. package/lib/index.js +1 -50
  8. package/lib/link.js +1 -49
  9. package/lib/script.js +1 -49
  10. package/lib/server.js +6 -688
  11. package/lib/theme.js +1 -50
  12. package/lib/types/i18n-routing.d.ts +4 -4
  13. package/lib/types/index.d.ts +23 -13
  14. package/lib/types/link.d.ts +3 -3
  15. package/lib/types/server.d.ts +28 -5
  16. package/lib/types/theme.d.ts +2 -2
  17. package/lib/vite-plugin-E4BHYvYW.js +855 -0
  18. package/package.json +15 -13
  19. package/src/app.ts +21 -1
  20. package/src/csp.ts +28 -12
  21. package/src/fs-router.ts +53 -3
  22. package/src/ssg-plugin.ts +366 -0
  23. package/src/types.ts +28 -9
  24. package/src/vite-plugin.ts +220 -40
  25. package/lib/actions.js.map +0 -1
  26. package/lib/ai.js.map +0 -1
  27. package/lib/api-routes.js.map +0 -1
  28. package/lib/cache.js.map +0 -1
  29. package/lib/client.js.map +0 -1
  30. package/lib/compression.js.map +0 -1
  31. package/lib/config.js.map +0 -1
  32. package/lib/cors.js.map +0 -1
  33. package/lib/csp.js.map +0 -1
  34. package/lib/env.js.map +0 -1
  35. package/lib/favicon.js.map +0 -1
  36. package/lib/font.js.map +0 -1
  37. package/lib/fs-router-3xzp-4Wj.js.map +0 -1
  38. package/lib/fs-router-CQ7Zxeca.js.map +0 -1
  39. package/lib/i18n-routing.js.map +0 -1
  40. package/lib/image-plugin.js.map +0 -1
  41. package/lib/image.js.map +0 -1
  42. package/lib/index.js.map +0 -1
  43. package/lib/link.js.map +0 -1
  44. package/lib/logger.js.map +0 -1
  45. package/lib/meta.js.map +0 -1
  46. package/lib/middleware.js.map +0 -1
  47. package/lib/og-image.js.map +0 -1
  48. package/lib/rate-limit.js.map +0 -1
  49. package/lib/script.js.map +0 -1
  50. package/lib/seo.js.map +0 -1
  51. package/lib/server.js.map +0 -1
  52. package/lib/testing.js.map +0 -1
  53. package/lib/theme.js.map +0 -1
  54. package/lib/types/actions.d.ts.map +0 -1
  55. package/lib/types/ai.d.ts.map +0 -1
  56. package/lib/types/api-routes.d.ts.map +0 -1
  57. package/lib/types/cache.d.ts.map +0 -1
  58. package/lib/types/client.d.ts.map +0 -1
  59. package/lib/types/compression.d.ts.map +0 -1
  60. package/lib/types/config.d.ts.map +0 -1
  61. package/lib/types/cors.d.ts.map +0 -1
  62. package/lib/types/csp.d.ts.map +0 -1
  63. package/lib/types/env.d.ts.map +0 -1
  64. package/lib/types/favicon.d.ts.map +0 -1
  65. package/lib/types/font.d.ts.map +0 -1
  66. package/lib/types/i18n-routing.d.ts.map +0 -1
  67. package/lib/types/image-plugin.d.ts.map +0 -1
  68. package/lib/types/image.d.ts.map +0 -1
  69. package/lib/types/index.d.ts.map +0 -1
  70. package/lib/types/link.d.ts.map +0 -1
  71. package/lib/types/logger.d.ts.map +0 -1
  72. package/lib/types/meta.d.ts.map +0 -1
  73. package/lib/types/middleware.d.ts.map +0 -1
  74. package/lib/types/og-image.d.ts.map +0 -1
  75. package/lib/types/rate-limit.d.ts.map +0 -1
  76. package/lib/types/script.d.ts.map +0 -1
  77. package/lib/types/seo.d.ts.map +0 -1
  78. package/lib/types/server.d.ts.map +0 -1
  79. package/lib/types/testing.d.ts.map +0 -1
  80. package/lib/types/theme.d.ts.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/zero",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Pyreon Zero — zero-config full-stack framework powered by Pyreon and Vite",
5
5
  "license": "MIT",
6
6
  "author": "Vit Bokisch",
@@ -11,6 +11,7 @@
11
11
  },
12
12
  "files": [
13
13
  "lib",
14
+ "!lib/**/*.map",
14
15
  "!lib/analysis",
15
16
  "src",
16
17
  "!src/tests",
@@ -18,6 +19,7 @@
18
19
  "README.md"
19
20
  ],
20
21
  "type": "module",
22
+ "sideEffects": false,
21
23
  "main": "./lib/index.js",
22
24
  "module": "./lib/index.js",
23
25
  "types": "./lib/types/index.d.ts",
@@ -166,26 +168,26 @@
166
168
  "lint": "oxlint ."
167
169
  },
168
170
  "dependencies": {
169
- "@pyreon/core": "^0.14.0",
170
- "@pyreon/head": "^0.14.0",
171
- "@pyreon/meta": "^0.14.0",
172
- "@pyreon/router": "^0.14.0",
173
- "@pyreon/runtime-dom": "^0.14.0",
174
- "@pyreon/runtime-server": "^0.14.0",
175
- "@pyreon/server": "^0.14.0",
176
- "@pyreon/vite-plugin": "^0.14.0",
171
+ "@pyreon/core": "^0.15.0",
172
+ "@pyreon/head": "^0.15.0",
173
+ "@pyreon/meta": "^0.15.0",
174
+ "@pyreon/router": "^0.15.0",
175
+ "@pyreon/runtime-dom": "^0.15.0",
176
+ "@pyreon/runtime-server": "^0.15.0",
177
+ "@pyreon/server": "^0.15.0",
178
+ "@pyreon/vite-plugin": "^0.15.0",
177
179
  "vite": "^8.0.0"
178
180
  },
181
+ "devDependencies": {
182
+ "sharp": "^0.33.0"
183
+ },
179
184
  "peerDependencies": {
180
- "@pyreon/reactivity": "^0.14.0",
185
+ "@pyreon/reactivity": "^0.15.0",
181
186
  "sharp": "^0.33.0"
182
187
  },
183
188
  "peerDependenciesMeta": {
184
189
  "sharp": {
185
190
  "optional": true
186
191
  }
187
- },
188
- "devDependencies": {
189
- "sharp": "^0.33.0"
190
192
  }
191
193
  }
package/src/app.ts CHANGED
@@ -36,7 +36,27 @@ export function createApp(options: CreateAppOptions) {
36
36
  scrollBehavior: 'top',
37
37
  })
38
38
 
39
- const Layout = options.layout ?? DefaultLayout
39
+ // Detect the "double layout" footgun. fs-router emits `_layout.tsx` as a
40
+ // parent route record (the canonical Pyreon way to register a layout via
41
+ // file-system routing). If the user ALSO passes `options.layout` referring
42
+ // to the same component, the layout mounts twice — once via App's wrapper
43
+ // and once via the matched route chain. Result on hydration mismatch:
44
+ // 3× `nav.sidebar` + 3× `main.content`.
45
+ //
46
+ // Defense: when `options.layout` references the same component as ANY
47
+ // top-level route's `component`, drop the explicit option (the route-chain
48
+ // path is canonical) and warn in dev. Anyone who genuinely wants two
49
+ // layout wrappers can compose them inside a single component themselves.
50
+ const hasLayoutInRoutes =
51
+ options.layout !== undefined &&
52
+ options.routes.some((r) => r.component === options.layout)
53
+ if (hasLayoutInRoutes && process.env.NODE_ENV !== 'production') {
54
+ // oxlint-disable-next-line no-console
55
+ console.warn(
56
+ '[Pyreon] `createApp({ layout })` was passed a component that is ALSO a parent route in the matched chain (likely an fs-router `_layout.tsx`). The explicit `layout` option is being ignored to prevent double-mount. Remove the `layout` argument from `createApp`/`startClient` — the fs-router-emitted route handles it.',
57
+ )
58
+ }
59
+ const Layout = hasLayoutInRoutes ? DefaultLayout : (options.layout ?? DefaultLayout)
40
60
 
41
61
  function App() {
42
62
  return h(
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 !== 'undefined' && crypto.getRandomValues) {
147
- const bytes = new Uint8Array(16)
148
- crypto.getRandomValues(bytes)
149
- // Convert to base64 using btoa
150
- let binary = ''
151
- for (const byte of bytes) binary += String.fromCharCode(byte)
152
- return typeof btoa === 'function'
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
- // Fallback for environments without crypto
157
- return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2)
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/fs-router.ts CHANGED
@@ -40,6 +40,8 @@ const ROUTE_EXPORT_NAMES = [
40
40
  'renderMode',
41
41
  'error',
42
42
  'middleware',
43
+ 'loaderKey',
44
+ 'gcTime',
43
45
  ] as const
44
46
 
45
47
  type RouteExportName = (typeof ROUTE_EXPORT_NAMES)[number]
@@ -114,6 +116,8 @@ export function detectRouteExports(source: string): RouteFileExports {
114
116
  hasRenderMode: found.has('renderMode'),
115
117
  hasError: found.has('error'),
116
118
  hasMiddleware: found.has('middleware'),
119
+ hasLoaderKey: found.has('loaderKey'),
120
+ hasGcTime: found.has('gcTime'),
117
121
  ...(metaLiteral !== undefined ? { metaLiteral } : {}),
118
122
  ...(renderModeLiteral !== undefined ? { renderModeLiteral } : {}),
119
123
  }
@@ -745,6 +749,8 @@ const EMPTY_EXPORTS: RouteFileExports = {
745
749
  hasRenderMode: false,
746
750
  hasError: false,
747
751
  hasMiddleware: false,
752
+ hasLoaderKey: false,
753
+ hasGcTime: false,
748
754
  }
749
755
 
750
756
  /**
@@ -759,7 +765,9 @@ export function hasAnyMetaExport(exports: RouteFileExports): boolean {
759
765
  exports.hasMeta ||
760
766
  exports.hasRenderMode ||
761
767
  exports.hasError ||
762
- exports.hasMiddleware
768
+ exports.hasMiddleware ||
769
+ exports.hasLoaderKey ||
770
+ exports.hasGcTime
763
771
  )
764
772
  }
765
773
 
@@ -1085,6 +1093,8 @@ export function generateRouteModuleFromRoutes(
1085
1093
  props.push(`${indent} component: ${mod}.default`)
1086
1094
  if (exp.hasLoader) props.push(`${indent} loader: ${mod}.loader`)
1087
1095
  if (exp.hasGuard) props.push(`${indent} beforeEnter: ${mod}.guard`)
1096
+ if (exp.hasLoaderKey) props.push(`${indent} loaderKey: ${mod}.loaderKey`)
1097
+ if (exp.hasGcTime) props.push(`${indent} gcTime: ${mod}.gcTime`)
1088
1098
  if (exp.hasMeta || exp.hasRenderMode) {
1089
1099
  const metaParts: string[] = []
1090
1100
  if (exp.hasMeta) metaParts.push(`...${mod}.meta`)
@@ -1152,6 +1162,18 @@ export function generateRouteModuleFromRoutes(
1152
1162
  `${indent} beforeEnter: (to, from) => import("${fullPath}").then((m) => m.guard(to, from))`,
1153
1163
  )
1154
1164
  }
1165
+ if (exp.hasLoaderKey) {
1166
+ // loaderKey runs SYNCHRONOUSLY during the cache-key check; can't be
1167
+ // routed through a dynamic import. Inline a `mod.loaderKey` lookup
1168
+ // via the same namespace-import pattern as the metadata path. Rolldown
1169
+ // will share the chunk with the lazy() component thunk.
1170
+ const mod = nextModuleImport(page.filePath)
1171
+ props.push(`${indent} loaderKey: ${mod}.loaderKey`)
1172
+ }
1173
+ if (exp.hasGcTime) {
1174
+ const mod = nextModuleImport(page.filePath)
1175
+ props.push(`${indent} gcTime: ${mod}.gcTime`)
1176
+ }
1155
1177
  emitInlineMeta(exp, props, indent)
1156
1178
  if (errorName) {
1157
1179
  // For error components we can't easily await — pass the lazy
@@ -1171,6 +1193,8 @@ export function generateRouteModuleFromRoutes(
1171
1193
  props.push(`${indent} component: ${mod}.default`)
1172
1194
  if (exp.hasLoader) props.push(`${indent} loader: ${mod}.loader`)
1173
1195
  if (exp.hasGuard) props.push(`${indent} beforeEnter: ${mod}.guard`)
1196
+ if (exp.hasLoaderKey) props.push(`${indent} loaderKey: ${mod}.loaderKey`)
1197
+ if (exp.hasGcTime) props.push(`${indent} gcTime: ${mod}.gcTime`)
1174
1198
  if (exp.hasMeta || exp.hasRenderMode) {
1175
1199
  const metaParts: string[] = []
1176
1200
  if (exp.hasMeta) metaParts.push(`...${mod}.meta`)
@@ -1231,6 +1255,8 @@ export function generateRouteModuleFromRoutes(
1231
1255
  if (layoutMod !== undefined) {
1232
1256
  if (exp.hasLoader) props.push(`${indent}loader: ${layoutMod}.loader`)
1233
1257
  if (exp.hasGuard) props.push(`${indent}beforeEnter: ${layoutMod}.guard`)
1258
+ if (exp.hasLoaderKey) props.push(`${indent}loaderKey: ${layoutMod}.loaderKey`)
1259
+ if (exp.hasGcTime) props.push(`${indent}gcTime: ${layoutMod}.gcTime`)
1234
1260
  if (exp.hasMeta || exp.hasRenderMode) {
1235
1261
  const metaParts: string[] = []
1236
1262
  if (exp.hasMeta) metaParts.push(`...${layoutMod}.meta`)
@@ -1307,8 +1333,15 @@ export function generateRouteModuleFromRoutes(
1307
1333
  /**
1308
1334
  * Generate a virtual module that maps URL patterns to their middleware exports.
1309
1335
  * Used by the server entry to dispatch per-route middleware.
1336
+ *
1337
+ * Detects whether each route file actually exports `middleware` (via
1338
+ * `detectRouteExports` source scanning) and only emits an import for files
1339
+ * that do. The `lazy()` import path tolerates missing exports, but the SSG
1340
+ * static-import path fails Rolldown's missing-export check at build time —
1341
+ * skipping no-middleware files keeps both paths working.
1310
1342
  */
1311
1343
  export function generateMiddlewareModule(files: string[], routesDir: string): string {
1344
+ const { readFileSync } = require('node:fs') as typeof import('node:fs')
1312
1345
  const routes = parseFileRoutes(files)
1313
1346
  const imports: string[] = []
1314
1347
  const entries: string[] = []
@@ -1316,6 +1349,14 @@ export function generateMiddlewareModule(files: string[], routesDir: string): st
1316
1349
 
1317
1350
  for (const route of routes) {
1318
1351
  if (route.isLayout || route.isError || route.isLoading || route.isNotFound) continue
1352
+ let hasMw = false
1353
+ try {
1354
+ const source = readFileSync(`${routesDir}/${route.filePath}`, 'utf-8')
1355
+ hasMw = detectRouteExports(source).hasMiddleware
1356
+ } catch {
1357
+ // File can't be read — skip; the SSR runtime falls back gracefully.
1358
+ }
1359
+ if (!hasMw) continue
1319
1360
  const name = `_mw${counter++}`
1320
1361
  const fullPath = `${routesDir}/${route.filePath}`
1321
1362
  imports.push(`import { middleware as ${name} } from "${fullPath}"`)
@@ -1372,8 +1413,17 @@ export async function scanRouteFilesWithExports(
1372
1413
  defaultMode: RenderMode = 'ssr',
1373
1414
  ): Promise<FileRoute[]> {
1374
1415
  const { readFile } = await import('node:fs/promises')
1375
-
1376
- const files = await scanRouteFiles(routesDir)
1416
+ const { isApiRoute } = await import('./api-routes')
1417
+
1418
+ // Api routes (`api/**/*.ts`) live in the same routes tree but are served by
1419
+ // a separate virtual module (`virtual:zero/api-routes`). Page-route
1420
+ // generation MUST skip them — they export named HTTP method handlers
1421
+ // (`GET`/`POST`/...), not a default page component, so the SSG `staticImports`
1422
+ // mode would emit `import _N from "api/posts.ts"` and fail Rolldown's
1423
+ // missing-export check at build time. The bug only surfaced under SSG
1424
+ // because the regular lazy()-mode `import()` doesn't fail on missing
1425
+ // default exports.
1426
+ const files = (await scanRouteFiles(routesDir)).filter((f) => !isApiRoute(f))
1377
1427
  const exportsMap = new Map<string, RouteFileExports>()
1378
1428
 
1379
1429
  await Promise.all(
@@ -0,0 +1,366 @@
1
+ /**
2
+ * SSG (Static Site Generation) build hook for `@pyreon/zero`.
3
+ *
4
+ * Activates when `mode: "ssg"` is set in zero's config. After Vite's client
5
+ * build finishes, this plugin:
6
+ *
7
+ * 1. Triggers a programmatic SSR build via Vite's `build()` API, producing
8
+ * a server bundle in `dist/.zero-ssg-server/` from a synthetic entry
9
+ * that imports `virtual:zero/routes` and `createServer`.
10
+ * 2. Loads the built handler with dynamic `import()`.
11
+ * 3. Resolves the path list from `config.ssg.paths` (string[], async fn,
12
+ * or auto-detected from the static-only routes in the route tree).
13
+ * 4. Calls `prerender()` from `@pyreon/server` to render each path.
14
+ * 5. Cleans up the temporary SSR build directory.
15
+ *
16
+ * Before this PR, `mode: "ssg"` and `ssg.paths` were typed in
17
+ * `types.ts` but had no runtime implementation — the plugin file had zero
18
+ * Rollup build hooks. Apps configured for SSG silently shipped a bare SPA
19
+ * shell with no per-route HTML files, which broke direct-URL deploys to
20
+ * static hosts (no `dist/<path>/index.html`, every URL falls back to the
21
+ * SPA index).
22
+ */
23
+
24
+ import { existsSync } from 'node:fs'
25
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
26
+ import { dirname, join, resolve } from 'node:path'
27
+ import { pathToFileURL } from 'node:url'
28
+ import type { Plugin } from 'vite'
29
+ import { resolveConfig } from './config'
30
+ import { parseFileRoutes, scanRouteFiles } from './fs-router'
31
+ import type { ZeroConfig } from './types'
32
+
33
+ // Marker env var used to skip the SSG hook on the recursive SSR sub-build —
34
+ // the SSR pass loads the same vite config + same plugin chain, so without
35
+ // this guard the SSG hook would re-trigger an infinite build loop.
36
+ const SSG_BUILD_FLAG = 'PYREON_ZERO_SSG_INNER_BUILD'
37
+
38
+ // Synthetic SSR entry source. Imports the user's route tree via the virtual
39
+ // module that zero's main plugin already registers, then exports a default
40
+ // `(path: string) => Promise<string>` renderer that returns the full HTML
41
+ // for a single path.
42
+ //
43
+ // The entry is materialized to disk (not registered as a virtual module)
44
+ // because Rolldown's `rollupOptions.input` phase doesn't reliably resolve
45
+ // `\0`-prefixed virtual ids when used as build entries — a virtual id
46
+ // returned from `resolveId` works fine for downstream imports but fails
47
+ // the entry-resolution stage with `Cannot resolve entry module`.
48
+ //
49
+ // We do NOT use zero's `createServer` because it wraps the user's App with
50
+ // a router whose URL is baked in at App-creation time. SSG needs a fresh
51
+ // router per path, so we mirror the dev SSR pipeline (`renderSsr` in
52
+ // vite-plugin.ts): per request → new createApp({ url: path }) → preload
53
+ // loaders → renderWithHead → serialize loader data → done.
54
+ const SSR_ENTRY_SOURCE = `
55
+ import { routes } from "virtual:zero/routes"
56
+ import { h } from "@pyreon/core"
57
+ import { renderWithHead } from "@pyreon/head/ssr"
58
+ import { serializeLoaderData } from "@pyreon/router"
59
+ import { runWithRequestContext } from "@pyreon/runtime-server"
60
+ import { createApp } from "@pyreon/zero/server"
61
+
62
+ export default async function renderPath(path) {
63
+ const { App, router } = createApp({
64
+ routes,
65
+ routerMode: "history",
66
+ url: path,
67
+ })
68
+
69
+ await router.preload(path)
70
+
71
+ return runWithRequestContext(async () => {
72
+ const app = h(App, null)
73
+ const { html: appHtml, head } = await renderWithHead(app)
74
+ const loaderData = serializeLoaderData(router)
75
+ const hasData = loaderData && Object.keys(loaderData).length > 0
76
+ const loaderScript = hasData
77
+ ? \`<script>window.__PYREON_LOADER_DATA__=\${JSON.stringify(loaderData).replace(/<\\//g, "<\\\\/")}</script>\`
78
+ : ""
79
+ return { appHtml, head, loaderScript }
80
+ })
81
+ }
82
+ `.trimStart()
83
+
84
+ const SSR_ENTRY_FILENAME = '__pyreon-zero-ssg-entry.js'
85
+
86
+ /**
87
+ * Auto-detect static paths from the route tree. A "static" path is one with
88
+ * NO dynamic segments (`[id]`, `[...rest]`). Dynamic routes are skipped
89
+ * because we can't enumerate their values at build time without a
90
+ * `getStaticPaths`-style API.
91
+ */
92
+ async function autoDetectStaticPaths(routesDir: string): Promise<string[]> {
93
+ // Routes dir missing → fall back to "/" anyway. A project that doesn't
94
+ // expose routes via fs-routing (custom routes module, single-page app
95
+ // shell, etc.) still needs at least an index.html so static hosts have
96
+ // a default response. The user can always set explicit `ssg.paths` to
97
+ // override this floor.
98
+ if (!existsSync(routesDir)) return ['/']
99
+ const files = await scanRouteFiles(routesDir)
100
+ const fileRoutes = parseFileRoutes(files)
101
+
102
+ // FileRoute is a FLAT list (no nested children) keyed by `urlPath`.
103
+ // Dynamic segments compile to `:param` (e.g. `[id]` → `:id`) and
104
+ // catch-alls to `*`. Skip any urlPath containing those — they need a
105
+ // `getStaticPaths`-style API to enumerate concrete values, which Pyreon
106
+ // doesn't ship yet.
107
+ const out: string[] = []
108
+ for (const r of fileRoutes) {
109
+ if (r.isLayout || r.isError || r.isLoading || r.isNotFound) continue
110
+ const path = r.urlPath
111
+ if (!path) continue
112
+ if (/[:*]/.test(path)) continue
113
+ out.push(path)
114
+ }
115
+
116
+ // Always include "/" as a fallback if no static routes were found —
117
+ // a project with only dynamic routes still needs an index.html for the
118
+ // host to know where to send unmatched URLs.
119
+ return out.length > 0 ? out : ['/']
120
+ }
121
+
122
+ async function resolvePaths(
123
+ config: ZeroConfig,
124
+ routesDir: string,
125
+ ): Promise<string[]> {
126
+ const explicit = config.ssg?.paths
127
+ if (typeof explicit === 'function') {
128
+ const result = await explicit()
129
+ return Array.isArray(result) ? result : []
130
+ }
131
+ if (Array.isArray(explicit)) return explicit
132
+ return autoDetectStaticPaths(routesDir)
133
+ }
134
+
135
+ function resolveOutputPath(distDir: string, path: string): string {
136
+ if (path === '/') return join(distDir, 'index.html')
137
+ if (path.endsWith('.html')) return join(distDir, path)
138
+ return join(distDir, path, 'index.html')
139
+ }
140
+
141
+ /**
142
+ * Plugin that performs SSG when `mode: "ssg"` is configured. Wires into
143
+ * Vite's `closeBundle` hook so it runs once after the main client build
144
+ * completes. The recursive SSR sub-build is gated by an env flag.
145
+ */
146
+ export function ssgPlugin(userConfig: ZeroConfig = {}): Plugin {
147
+ const config = resolveConfig(userConfig)
148
+ let root = ''
149
+ let distDir = ''
150
+ // Track whether this plugin instance is running inside the inner SSR
151
+ // sub-build (where it must be a no-op) vs. the outer client build.
152
+ const isInnerBuild = process.env[SSG_BUILD_FLAG] === '1'
153
+
154
+ return {
155
+ name: 'pyreon-zero-ssg',
156
+ apply: 'build',
157
+ enforce: 'post',
158
+
159
+ configResolved(resolved) {
160
+ root = resolved.root
161
+ distDir = resolve(root, resolved.build.outDir)
162
+ },
163
+
164
+ async closeBundle() {
165
+ if (config.mode !== 'ssg') return
166
+ if (isInnerBuild) return
167
+
168
+ const ssrOutDir = join(distDir, '.zero-ssg-server')
169
+ const indexHtmlPath = join(distDir, 'index.html')
170
+
171
+ if (!existsSync(indexHtmlPath)) {
172
+ // Client build hasn't produced index.html — nothing we can wrap.
173
+ // Most likely: user is running `vite build --ssr` directly, in
174
+ // which case this plugin shouldn't be active anyway.
175
+ // oxlint-disable-next-line no-console
176
+ console.warn(
177
+ `[zero:ssg] Skipping SSG — ${indexHtmlPath} not found. Did the client build complete?`,
178
+ )
179
+ return
180
+ }
181
+
182
+ // Materialize the SSR entry to disk inside the routes directory so
183
+ // its imports resolve relative to the user's source tree. Doing this
184
+ // INSIDE node_modules-equivalent paths breaks Vite's plugin-resolution
185
+ // semantics; placing it next to the user's routes lets zero's main
186
+ // plugin pick it up identically to user code. Cleaned up after the
187
+ // build.
188
+ const entryPath = join(root, SSR_ENTRY_FILENAME)
189
+ await writeFile(entryPath, SSR_ENTRY_SOURCE, 'utf-8')
190
+
191
+ // Vite's programmatic build API. Loaded lazily so the plugin doesn't
192
+ // pull `vite` into the runtime dep graph at module-evaluation time.
193
+ const { build } = await import('vite')
194
+
195
+ // Inner SSR sub-build. Re-assembles zero's plugin chain plus
196
+ // `@pyreon/vite-plugin` (JSX compiler) — every Pyreon app already
197
+ // has both because zero is built on top of pyreon. Loading both
198
+ // lazily keeps the SSG plugin off the module-eval critical path.
199
+ // Env-flag gate prevents the inner ssgPlugin instance from
200
+ // re-triggering itself.
201
+ process.env[SSG_BUILD_FLAG] = '1'
202
+ try {
203
+ const [{ zeroPlugin }, pyreonModule] = await Promise.all([
204
+ import('./vite-plugin'),
205
+ import('@pyreon/vite-plugin'),
206
+ ])
207
+ const pyreon = (pyreonModule as { default: () => unknown }).default
208
+
209
+ await build({
210
+ root,
211
+ mode: 'production',
212
+ logLevel: 'error',
213
+ configFile: false,
214
+ publicDir: false,
215
+ plugins: [pyreon(), zeroPlugin(userConfig)] as Plugin[],
216
+ resolve: { conditions: ['bun'] },
217
+ build: {
218
+ ssr: entryPath,
219
+ outDir: ssrOutDir,
220
+ emptyOutDir: true,
221
+ target: 'esnext',
222
+ rollupOptions: {
223
+ input: entryPath,
224
+ output: {
225
+ format: 'es',
226
+ entryFileNames: 'entry-server.mjs',
227
+ },
228
+ external: [/^node:/],
229
+ },
230
+ },
231
+ })
232
+ } finally {
233
+ delete process.env[SSG_BUILD_FLAG]
234
+ // Remove the synthetic entry file so it never lands in user's
235
+ // working tree.
236
+ try {
237
+ await rm(entryPath, { force: true })
238
+ } catch {
239
+ // best-effort cleanup
240
+ }
241
+ }
242
+
243
+ // Load the built renderer. Use a file:// URL to avoid Node import
244
+ // cache collisions across multiple builds within the same process.
245
+ const handlerPath = join(ssrOutDir, 'entry-server.mjs')
246
+ if (!existsSync(handlerPath)) {
247
+ // oxlint-disable-next-line no-console
248
+ console.warn(`[zero:ssg] SSR build did not produce ${handlerPath} — skipping prerender`)
249
+ return
250
+ }
251
+ // The path is computed at runtime from a freshly-built SSR artifact
252
+ // — Vite's `dynamic-import-vars` plugin can't statically analyze the
253
+ // import. Without the `@vite-ignore` hint, Vite emits a console
254
+ // warning on every consumer's dev server boot ("The above dynamic
255
+ // import cannot be analyzed by Vite"), which looks alarming but is
256
+ // expected here. Suppress per Vite's own recommendation.
257
+ const handlerMod = (await import(/* @vite-ignore */ pathToFileURL(handlerPath).href)) as {
258
+ default: (path: string) => Promise<{ appHtml: string; head: string; loaderScript: string }>
259
+ }
260
+ const renderPath = handlerMod.default
261
+
262
+ // Read the user's built index.html template. Vite has just produced it
263
+ // with hashed asset URLs (`/assets/index-XYZ.js`), preload links, etc.
264
+ // We inject the rendered head/body/loader-data into placeholder
265
+ // comments — same convention as zero's dev SSR. If the template lacks
266
+ // the placeholders, we fall back to inserting before `</head>` and
267
+ // `</body>` respectively so a bare `index.html` still works.
268
+ const template = await readFile(indexHtmlPath, 'utf-8')
269
+
270
+ // Resolve paths and render.
271
+ const routesDir = join(root, 'src', 'routes')
272
+ const paths = await resolvePaths(config, routesDir)
273
+
274
+ if (paths.length === 0) {
275
+ // oxlint-disable-next-line no-console
276
+ console.warn('[zero:ssg] No static paths to prerender — set ssg.paths in zero config')
277
+ await rm(ssrOutDir, { recursive: true, force: true })
278
+ return
279
+ }
280
+
281
+ let pages = 0
282
+ const errors: { path: string; error: unknown }[] = []
283
+ const start = Date.now()
284
+
285
+ for (const p of paths) {
286
+ try {
287
+ const result = await Promise.race([
288
+ renderPath(p),
289
+ new Promise<never>((_, reject) =>
290
+ setTimeout(() => reject(new Error(`Prerender timeout for "${p}" (30s)`)), 30_000),
291
+ ),
292
+ ])
293
+
294
+ // Inject into the index.html template. Prefer Pyreon's standard
295
+ // placeholders; fall back to <head>/<body>/<#app> insertion
296
+ // points so apps with a minimal `<div id="app"></div>` template
297
+ // still render content.
298
+ let html = template
299
+ if (html.includes('<!--pyreon-head-->')) {
300
+ html = html.replace('<!--pyreon-head-->', result.head)
301
+ } else if (result.head) {
302
+ html = html.replace('</head>', `${result.head}</head>`)
303
+ }
304
+ if (html.includes('<!--pyreon-app-->')) {
305
+ html = html.replace('<!--pyreon-app-->', result.appHtml)
306
+ } else if (result.appHtml) {
307
+ // Drop the rendered HTML inside #app; if not found, append to body.
308
+ const appDivMatch = html.match(/<div\s+id=["']app["']\s*>([\s\S]*?)<\/div>/)
309
+ if (appDivMatch) {
310
+ html = html.replace(appDivMatch[0], `<div id="app">${result.appHtml}</div>`)
311
+ } else {
312
+ html = html.replace('</body>', `<div id="app">${result.appHtml}</div></body>`)
313
+ }
314
+ }
315
+ if (html.includes('<!--pyreon-scripts-->')) {
316
+ html = html.replace('<!--pyreon-scripts-->', result.loaderScript)
317
+ } else if (result.loaderScript) {
318
+ html = html.replace('</body>', `${result.loaderScript}</body>`)
319
+ }
320
+
321
+ const filePath = resolveOutputPath(distDir, p)
322
+
323
+ // Path-traversal guard — same as @pyreon/server's prerender.
324
+ const resolvedOut = resolve(distDir)
325
+ if (!resolve(filePath).startsWith(resolvedOut)) {
326
+ errors.push({ path: p, error: new Error(`Path traversal detected: "${p}"`) })
327
+ continue
328
+ }
329
+
330
+ await mkdir(dirname(filePath), { recursive: true })
331
+ await writeFile(filePath, html, 'utf-8')
332
+ pages++
333
+ } catch (error) {
334
+ errors.push({ path: p, error })
335
+ }
336
+ }
337
+
338
+ // Cleanup the SSR build artifacts — they're an implementation detail
339
+ // and shouldn't ship to the static host.
340
+ await rm(ssrOutDir, { recursive: true, force: true })
341
+
342
+ const elapsed = Date.now() - start
343
+ // oxlint-disable-next-line no-console
344
+ console.log(
345
+ `[zero:ssg] Prerendered ${pages} page(s) in ${elapsed}ms` +
346
+ (errors.length > 0 ? ` (${errors.length} error(s))` : ''),
347
+ )
348
+ for (const { path: errPath, error } of errors) {
349
+ // oxlint-disable-next-line no-console
350
+ console.error(`[zero:ssg] Failed to prerender "${errPath}":`, error)
351
+ }
352
+ },
353
+ } satisfies Plugin
354
+ }
355
+
356
+ // ─── Test exports ─────────────────────────────────────────────────────────────
357
+ //
358
+ // Internal helpers exposed for unit tests. Not part of the public API.
359
+
360
+ export const _internal = {
361
+ resolvePaths,
362
+ autoDetectStaticPaths,
363
+ resolveOutputPath,
364
+ SSR_ENTRY_SOURCE,
365
+ SSR_ENTRY_FILENAME,
366
+ }