@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.
Files changed (52) hide show
  1. package/lib/{api-routes-DANluJic.js → api-routes-Ci0kVmM4.js} +2 -2
  2. package/lib/client.js +4 -1
  3. package/lib/env.js +6 -6
  4. package/lib/font.js +3 -3
  5. package/lib/{fs-router-ZebyutPa.js → fs-router-MewHc5SB.js} +25 -30
  6. package/lib/i18n-routing.js +112 -1
  7. package/lib/image.js +140 -58
  8. package/lib/index.js +252 -82
  9. package/lib/og-image.js +5 -5
  10. package/lib/rolldown-runtime-CjeV3_4I.js +18 -0
  11. package/lib/script.js +114 -25
  12. package/lib/seo.js +186 -15
  13. package/lib/server.js +274 -564
  14. package/lib/types/config.d.ts +307 -3
  15. package/lib/types/env.d.ts +2 -2
  16. package/lib/types/i18n-routing.d.ts +193 -2
  17. package/lib/types/image.d.ts +105 -5
  18. package/lib/types/index.d.ts +666 -182
  19. package/lib/types/script.d.ts +78 -6
  20. package/lib/types/seo.d.ts +128 -4
  21. package/lib/types/server.d.ts +607 -72
  22. package/lib/vite-plugin-y0NmCLJA.js +2476 -0
  23. package/package.json +11 -10
  24. package/src/adapters/bun.ts +20 -1
  25. package/src/adapters/cloudflare.ts +78 -1
  26. package/src/adapters/index.ts +25 -3
  27. package/src/adapters/netlify.ts +63 -1
  28. package/src/adapters/node.ts +25 -1
  29. package/src/adapters/static.ts +26 -1
  30. package/src/adapters/validate.ts +8 -1
  31. package/src/adapters/vercel.ts +76 -1
  32. package/src/adapters/warn-missing-env.ts +49 -0
  33. package/src/app.ts +14 -0
  34. package/src/client.ts +18 -0
  35. package/src/entry-server.ts +55 -5
  36. package/src/env.ts +7 -7
  37. package/src/font.ts +3 -3
  38. package/src/fs-router.ts +72 -3
  39. package/src/i18n-routing.ts +246 -12
  40. package/src/image.tsx +242 -91
  41. package/src/index.ts +4 -4
  42. package/src/isr.ts +24 -6
  43. package/src/manifest.ts +675 -0
  44. package/src/og-image.ts +5 -5
  45. package/src/script.tsx +159 -36
  46. package/src/seo.ts +346 -15
  47. package/src/server.ts +10 -2
  48. package/src/ssg-plugin.ts +1211 -54
  49. package/src/types.ts +333 -10
  50. package/src/vercel-revalidate-handler.ts +204 -0
  51. package/src/vite-plugin.ts +171 -41
  52. package/lib/vite-plugin-E4BHYvYW.js +0 -855
@@ -14,6 +14,7 @@ import { resolveConfig } from './config'
14
14
  // Used in the dev-mode SSR catch handler to convert loader-thrown
15
15
  // `redirect()` errors into real HTTP redirects (302/307/308).
16
16
  import { getRedirectInfo } from '@pyreon/router'
17
+ import { matchPattern } from './entry-server'
17
18
 
18
19
  /**
19
20
  * Scan node_modules/@pyreon/ to discover all installed Pyreon packages.
@@ -46,7 +47,6 @@ function resolveNestedPackage(root: string, name: string): string | undefined {
46
47
  if (existsSync(nested)) return nested
47
48
  return undefined
48
49
  }
49
- import { matchPattern } from "./entry-server";
50
50
  import { renderErrorOverlay } from "./error-overlay";
51
51
  import {
52
52
  generateMiddlewareModule,
@@ -54,6 +54,7 @@ import {
54
54
  scanRouteFiles,
55
55
  scanRouteFilesWithExports,
56
56
  } from "./fs-router";
57
+ import { expandRoutesForLocales } from "./i18n-routing";
57
58
  import { render404Page } from "./not-found";
58
59
  import { ssgPlugin } from "./ssg-plugin";
59
60
  import type { ZeroConfig } from "./types";
@@ -67,6 +68,49 @@ const RESOLVED_VIRTUAL_MIDDLEWARE_ID = `\0${VIRTUAL_MIDDLEWARE_ID}`;
67
68
  const VIRTUAL_API_ROUTES_ID = "virtual:zero/api-routes";
68
69
  const RESOLVED_VIRTUAL_API_ROUTES_ID = `\0${VIRTUAL_API_ROUTES_ID}`;
69
70
 
71
+ /**
72
+ * Per-plugin-instance storage for the user-supplied ZeroConfig. Lets
73
+ * downstream consumers (e.g. `@pyreon/zero-cli`'s `build` command, which
74
+ * loads the user's `vite.config.ts` and inspects its plugin list)
75
+ * recover the original config without us attaching internal state to
76
+ * the public Plugin object via an underscore-prefixed property.
77
+ *
78
+ * Exported via `getZeroPluginConfig(plugin)` so the WeakMap itself
79
+ * stays an implementation detail — callers can't enumerate or mutate
80
+ * the table, only read by Plugin identity.
81
+ */
82
+ const zeroPluginConfigMap = new WeakMap<Plugin, ZeroConfig>();
83
+
84
+ /**
85
+ * Retrieve the `ZeroConfig` that was passed to `zeroPlugin(userConfig)`
86
+ * when the plugin was created. Returns `undefined` if the argument
87
+ * isn't a recognized pyreon-zero main plugin instance.
88
+ */
89
+ export function getZeroPluginConfig(plugin: Plugin): ZeroConfig | undefined {
90
+ return zeroPluginConfigMap.get(plugin);
91
+ }
92
+
93
+ /**
94
+ * Detects `--port` / `--port=N` / `-p N` / `-p=N` in `process.argv`.
95
+ * Used by the plugin's `config()` hook to decide whether to apply the
96
+ * default port — when the CLI was invoked with `--port`, the plugin
97
+ * must skip its default so the CLI flag wins (see the comment at the
98
+ * port-handling block in `zeroPlugin()` for the full precedence model).
99
+ *
100
+ * Exported for testing only (the plugin uses it internally).
101
+ *
102
+ * @internal
103
+ */
104
+ export function argvHasPortFlag(argv: readonly string[] = process.argv): boolean {
105
+ for (let i = 0; i < argv.length; i++) {
106
+ const a = argv[i];
107
+ if (a === "--port" || a === "-p") return true;
108
+ if (a !== undefined && (a.startsWith("--port=") || a.startsWith("-p=")))
109
+ return true;
110
+ }
111
+ return false;
112
+ }
113
+
70
114
  /**
71
115
  * Zero Vite plugin — adds file-based routing and zero-config conventions
72
116
  * on top of @pyreon/vite-plugin.
@@ -85,10 +129,9 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin[] {
85
129
  let routesDir: string;
86
130
  let root: string;
87
131
 
88
- const mainPlugin: Plugin & { _zeroConfig: ZeroConfig } = {
132
+ const mainPlugin: Plugin = {
89
133
  name: "pyreon-zero",
90
134
  enforce: "pre",
91
- _zeroConfig: userConfig,
92
135
 
93
136
  configResolved(resolvedConfig) {
94
137
  root = resolvedConfig.root;
@@ -109,9 +152,30 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin[] {
109
152
  // • lazy() for routes that only export `default` (best code splitting)
110
153
  // • Direct mod.loader/.guard/.meta access for routes with metadata
111
154
  // • No spurious IMPORT_IS_UNDEFINED warnings from Rolldown
112
- const routes = await scanRouteFilesWithExports(routesDir, config.mode);
155
+ const baseRoutes = await scanRouteFilesWithExports(routesDir, config.mode);
156
+ // PR H — fan routes into per-locale variants when `i18n` is
157
+ // configured. No-op when unset; identity-returns the input
158
+ // otherwise so existing apps see byte-identical output.
159
+ const routes = config.i18n
160
+ ? expandRoutesForLocales(baseRoutes, config.i18n)
161
+ : baseRoutes;
162
+ // SSG mode: lazy() route splitting by default (parity with
163
+ // SSR/SPA). Opt-out via `ssg.splitChunks: false` for tiny
164
+ // sites that prefer single-chunk + instant navigation.
165
+ //
166
+ // Pre-2026-Q3: SSG was hardcoded to `staticImports: true`
167
+ // (bundle everything). Trade-off was instant post-hydration
168
+ // nav, but the initial bundle grew linearly with route
169
+ // count — a 50-route docs site shipped all 50 route
170
+ // components on first paint. Lazy splitting (now the
171
+ // default for SSG) fixes that: only the landing route +
172
+ // deps load up front, the rest fetch on navigation. See
173
+ // `ssg.splitChunks` JSDoc in types.ts for the crossover-
174
+ // point rationale.
175
+ const ssgSplitDisabled =
176
+ config.mode === "ssg" && config.ssg?.splitChunks === false;
113
177
  return generateRouteModuleFromRoutes(routes, routesDir, {
114
- staticImports: config.mode === 'ssg',
178
+ staticImports: ssgSplitDisabled,
115
179
  });
116
180
  } catch (_err) {
117
181
  return `export const routes = []`;
@@ -203,10 +267,10 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin[] {
203
267
  renderSsr(server, root, req.originalUrl ?? pathname, pathname, webReq).then(
204
268
  (result) => {
205
269
  if (result === null) return next();
206
- res.statusCode = 200;
270
+ res.statusCode = result.status;
207
271
  res.setHeader("Content-Type", "text/html; charset=utf-8");
208
- res.setHeader("Content-Length", Buffer.byteLength(result));
209
- res.end(result);
272
+ res.setHeader("Content-Length", Buffer.byteLength(result.html));
273
+ res.end(result.html);
210
274
  },
211
275
  (err: unknown) => {
212
276
  // Loader-thrown `redirect()` — convert to a real HTTP redirect
@@ -327,15 +391,15 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin[] {
327
391
  // Discover all @pyreon/* packages installed in node_modules.
328
392
  // The "bun" export condition points to TS source — esbuild's
329
393
  // dep optimizer would compile them with the wrong JSX runtime.
330
- const root = viteUserConfig.root ?? process.cwd()
331
- const pyreonExclude = scanPyreonPackages(root)
394
+ const cwd = viteUserConfig.root ?? process.cwd()
395
+ const pyreonExclude = scanPyreonPackages(cwd)
332
396
 
333
397
  // `@pyreon/runtime-server` is only imported by zero's dev SSR
334
398
  // middleware and the production server entry — apps rarely list it
335
399
  // as a direct dep. Resolve it to the copy nested under zero so
336
400
  // `ssrLoadModule("@pyreon/runtime-server")` works uniformly.
337
401
  const runtimeServerAlias = resolveNestedPackage(
338
- root,
402
+ cwd,
339
403
  "@pyreon/runtime-server",
340
404
  )
341
405
 
@@ -361,16 +425,51 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin[] {
361
425
  optimizeDeps: {
362
426
  exclude: pyreonExclude,
363
427
  },
364
- // Only set the port when the user explicitly provided one in
365
- // `zero({ port: N })`. Without this guard, the plugin always
366
- // returned `server: { port: 3000 }` which overrode Vite's CLI
367
- // `--port` flag and made multi-example dev impossible — every
368
- // example tried to bind 3000 even when launched with
369
- // `vite --port 5173`. Surfaced when wiring up the playwright
370
- // e2e suite.
371
- ...(userConfig.port !== undefined
372
- ? { server: { port: config.port } }
373
- : {}),
428
+ // Port handling the zero-canonical default is 3000 (matches
429
+ // `zero dev` / `zero preview` / the runtime adapter, and
430
+ // matches Next.js / Remix / Astro convention).
431
+ //
432
+ // Apply the default UNLESS Vite's CLI was invoked with
433
+ // `--port`/`-p` (in which case the CLI flag must win — see
434
+ // memory: vite cli port doesnt override plugin). PR #579
435
+ // proved this empirically: returning `server: { port: 3000 }`
436
+ // unconditionally clobbered `vite --port 517N --strictPort`
437
+ // in the e2e playwright config and every webServer timed
438
+ // out. argv detection here lets the CLI win at the source.
439
+ //
440
+ // Precedence (CLI > user vite.config > zero({port}) > 3000):
441
+ // 1. `vite --port N` → argvHasPortFlag() === true → plugin
442
+ // omits `server.port` entirely → CLI value wins
443
+ // 2. User `vite.config.ts server: { port: N }` → user
444
+ // config beats plugin in Vite's merge order
445
+ // 3. `zero({ port: N })` → resolved into `config.port`
446
+ // 4. Default 3000 — when no other source set a port
447
+ //
448
+ // `process.argv` is populated by the time Vite invokes the
449
+ // plugin's config() hook (Vite calls plugins synchronously
450
+ // during CLI bootstrap before applying inline overrides).
451
+ ...(userConfig.port === undefined && argvHasPortFlag()
452
+ ? {}
453
+ : { server: { port: config.port } }),
454
+ // Propagate `zero({ base })` to Vite's `base` config — that's
455
+ // what controls asset URL rewriting in the built HTML/JS
456
+ // (`<script src="/blog/assets/…">`). Pre-fix this was a
457
+ // typed-but-unimplemented field: `__ZERO_BASE__` was defined
458
+ // as a Vite global but no consumer existed, AND Vite's own
459
+ // `base` had to be set manually in vite.config.ts. Setting
460
+ // it here makes `zero({ base: '/blog/' })` the canonical
461
+ // single-source-of-truth surface; the value flows through
462
+ // to (a) Vite's HTML/asset URL rewriter, (b) `createRouter`
463
+ // via `__ZERO_BASE__` in `startClient` / `createApp`, (c)
464
+ // the SSG entry's `createApp({ base })` call.
465
+ //
466
+ // Vite's config-merge semantics: plugin-returned config is
467
+ // the BASE; user's `vite.config.ts` top-level overrides.
468
+ // So a user who sets BOTH `zero({ base: '/blog/' })` AND
469
+ // `vite.config.base: '/foo/'` gets the latter — the user's
470
+ // explicit override wins. The default `/` is a no-op
471
+ // (matches Vite's default), so always-setting is safe.
472
+ base: config.base,
374
473
  define: {
375
474
  __ZERO_MODE__: JSON.stringify(config.mode),
376
475
  __ZERO_BASE__: JSON.stringify(config.base),
@@ -379,6 +478,12 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin[] {
379
478
  },
380
479
  };
381
480
 
481
+ // Stash the original user config keyed by plugin identity so the CLI
482
+ // (which loads vite.config.ts and inspects the plugin list) can
483
+ // recover it via `getZeroPluginConfig(plugin)` without us hanging a
484
+ // `_`-prefixed property off the public Plugin object.
485
+ zeroPluginConfigMap.set(mainPlugin, userConfig);
486
+
382
487
  // SSG mode auto-wires the static-site generation hook. Other modes get
383
488
  // just the main plugin. The SSG plugin internally no-ops when
384
489
  // `mode !== 'ssg'`, but skipping it entirely keeps the plugin chain
@@ -488,13 +593,24 @@ async function dispatchApiRoute(
488
593
  }
489
594
 
490
595
  /**
491
- * Check if the requested path matches any route. If not, render a 404 page.
492
- * Returns true if the 404 was handled (response sent), false otherwise.
596
+ * Static-page 404 fallback for apps WITHOUT `_404.tsx` in the routes tree.
493
597
  *
494
- * In dev mode, the _404.tsx component cannot be SSR-rendered because
495
- * the compiler emits _tpl() calls that require `document`. Instead,
496
- * we return a static 404 page. The actual component rendering happens
497
- * on the client side when the SPA loads.
598
+ * For `mode: 'ssr'` apps with `_404.tsx`, the SSR middleware's `renderSsr`
599
+ * routes unmatched URLs through the router-driven path (PR L5 + M1.2) that
600
+ * produces a layout-wrapped 404 with HTTP status 404, never reaching here.
601
+ * This function is the LEGACY fallback that fires only when:
602
+ * - The app is in `mode: 'spa'` / `mode: 'ssg'` (no dev SSR middleware), OR
603
+ * - The app has no reachable `notFoundComponent` in its routes tree (so the
604
+ * SSR middleware's `resolveRoute` returns matched: [] and falls through).
605
+ *
606
+ * Returns true if the 404 was handled (response sent), false if the path
607
+ * actually matches a route (caller continues to next middleware).
608
+ *
609
+ * Pre-M1.2 a stale comment claimed `_404.tsx` "cannot be SSR-rendered because
610
+ * the compiler emits _tpl() calls that require document". That was wrong — the
611
+ * SSR runtime renders compiler-emitted components fine via `renderToString`
612
+ * (no document needed). The static fallback exists for backward compat with
613
+ * apps that don't ship `_404.tsx`, not because SSR-rendering it is impossible.
498
614
  */
499
615
  async function handle404(
500
616
  server: import("vite").ViteDevServer,
@@ -510,10 +626,11 @@ async function handle404(
510
626
  return false; // Route matches — not a 404
511
627
  }
512
628
 
513
- // No route matched — return a 404.
514
- // In dev, we return a static page since the compiler emits _tpl() calls
515
- // that require document (unavailable in SSR). The _404.tsx component
516
- // renders on the client side after hydration.
629
+ // No route matched + no `_404.tsx` reachable emit a minimal static page
630
+ // so the user gets SOMETHING. Apps that want branded 404s should add
631
+ // `_404.tsx` to their routes tree (canonical pattern); the SSR middleware
632
+ // then routes through the router-driven path with layout chrome instead
633
+ // of landing here.
517
634
  const html = await render404Page(undefined);
518
635
 
519
636
  res.statusCode = 404;
@@ -541,18 +658,12 @@ async function renderSsr(
541
658
  originalUrl: string,
542
659
  pathname: string,
543
660
  req?: Request,
544
- ): Promise<string | null> {
545
- // Pattern check FIRST — otherwise SSR would try (and likely crash) on
546
- // asset paths that happened to accept text/html (e.g. curl-style).
661
+ ): Promise<{ html: string; status: number } | null> {
547
662
  const routesMod = await server.ssrLoadModule(VIRTUAL_ROUTES_ID);
548
663
  const routes = routesMod.routes as Array<{
549
664
  path?: string;
550
665
  children?: unknown[];
551
666
  }>;
552
- const patterns = flattenRoutePatterns(routes);
553
- if (!patterns.some((pattern) => matchPattern(pattern, pathname))) {
554
- return null;
555
- }
556
667
 
557
668
  // Read + transform index.html (Vite injects the HMR client / JSX prelude).
558
669
  let template = await readFile(join(root, "index.html"), "utf-8");
@@ -565,7 +676,7 @@ async function renderSsr(
565
676
  // `@pyreon/runtime-server` isn't a direct dep of most apps, so zero's
566
677
  // `config()` hook registers an alias that points it at the copy under
567
678
  // zero's own `node_modules` — same path → same Vite module → same instance.
568
- const [core, headPkg, headSsr, routerPkg, runtimeServer] = await Promise.all(
679
+ const [core, _headPkg, headSsr, routerPkg, runtimeServer] = await Promise.all(
569
680
  [
570
681
  server.ssrLoadModule("@pyreon/core") as Promise<
571
682
  typeof import("@pyreon/core")
@@ -620,8 +731,25 @@ async function renderSsr(
620
731
  // `preload` loads lazy route components AND runs loaders for `pathname` so
621
732
  // the synchronous render pass produces final HTML — no loading fallbacks,
622
733
  // no `useLoaderData() === undefined`.
734
+ //
735
+ // M1.2 — Unmatched URLs no longer bail to a static 404 page. The router's
736
+ // `resolveRoute` (PR L5) walks the route tree and, if a parent layout has
737
+ // `notFoundComponent` AND the URL is under that layout's prefix, builds a
738
+ // synthetic chain `[...ancestorLayouts, syntheticLeaf]` with
739
+ // `isNotFound: true`. The render then produces 404 HTML INSIDE the
740
+ // layout's chrome. If the routes tree has no reachable `notFoundComponent`,
741
+ // `matched` stays empty — fall through to `handle404` for the static
742
+ // fallback (preserves backward compat for apps without `_404.tsx`).
623
743
  await routerInst.preload(pathname, req);
624
744
 
745
+ const resolved = routerInst.currentRoute() as
746
+ | { matched?: unknown[]; isNotFound?: boolean }
747
+ | undefined;
748
+ if (!resolved?.matched || resolved.matched.length === 0) {
749
+ return null;
750
+ }
751
+ const status = resolved.isNotFound === true ? 404 : 200;
752
+
625
753
  return runtimeServer.runWithRequestContext(async () => {
626
754
  const app = core.h(App as Parameters<typeof core.h>[0], null);
627
755
 
@@ -630,14 +758,16 @@ async function renderSsr(
630
758
  routerInst as Parameters<typeof routerPkg.serializeLoaderData>[0],
631
759
  );
632
760
  const hasData = loaderData && Object.keys(loaderData).length > 0;
761
+ // M2.2 — safe serializer (parity with production handler / SSG entry).
633
762
  const loaderScript = hasData
634
- ? `<script>window.__PYREON_LOADER_DATA__=${JSON.stringify(loaderData).replace(/<\//g, "<\\/")}</script>`
763
+ ? `<script>window.__PYREON_LOADER_DATA__=${routerPkg.stringifyLoaderData(loaderData)}</script>`
635
764
  : "";
636
765
 
637
- return template
766
+ const html = template
638
767
  .replace("<!--pyreon-head-->", head)
639
768
  .replace("<!--pyreon-app-->", appHtml)
640
769
  .replace("<!--pyreon-scripts-->", loaderScript);
770
+ return { html, status };
641
771
  });
642
772
  }
643
773