@pyreon/zero 0.15.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.
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 +275 -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 +634 -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 +575 -72
  22. package/lib/vite-plugin-xjWZwudX.js +2454 -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 +301 -10
  50. package/src/vercel-revalidate-handler.ts +204 -0
  51. package/src/vite-plugin.ts +108 -30
  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,28 @@ 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
+
70
93
  /**
71
94
  * Zero Vite plugin — adds file-based routing and zero-config conventions
72
95
  * on top of @pyreon/vite-plugin.
@@ -85,10 +108,9 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin[] {
85
108
  let routesDir: string;
86
109
  let root: string;
87
110
 
88
- const mainPlugin: Plugin & { _zeroConfig: ZeroConfig } = {
111
+ const mainPlugin: Plugin = {
89
112
  name: "pyreon-zero",
90
113
  enforce: "pre",
91
- _zeroConfig: userConfig,
92
114
 
93
115
  configResolved(resolvedConfig) {
94
116
  root = resolvedConfig.root;
@@ -109,7 +131,13 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin[] {
109
131
  // • lazy() for routes that only export `default` (best code splitting)
110
132
  // • Direct mod.loader/.guard/.meta access for routes with metadata
111
133
  // • No spurious IMPORT_IS_UNDEFINED warnings from Rolldown
112
- const routes = await scanRouteFilesWithExports(routesDir, config.mode);
134
+ const baseRoutes = await scanRouteFilesWithExports(routesDir, config.mode);
135
+ // PR H — fan routes into per-locale variants when `i18n` is
136
+ // configured. No-op when unset; identity-returns the input
137
+ // otherwise so existing apps see byte-identical output.
138
+ const routes = config.i18n
139
+ ? expandRoutesForLocales(baseRoutes, config.i18n)
140
+ : baseRoutes;
113
141
  return generateRouteModuleFromRoutes(routes, routesDir, {
114
142
  staticImports: config.mode === 'ssg',
115
143
  });
@@ -203,10 +231,10 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin[] {
203
231
  renderSsr(server, root, req.originalUrl ?? pathname, pathname, webReq).then(
204
232
  (result) => {
205
233
  if (result === null) return next();
206
- res.statusCode = 200;
234
+ res.statusCode = result.status;
207
235
  res.setHeader("Content-Type", "text/html; charset=utf-8");
208
- res.setHeader("Content-Length", Buffer.byteLength(result));
209
- res.end(result);
236
+ res.setHeader("Content-Length", Buffer.byteLength(result.html));
237
+ res.end(result.html);
210
238
  },
211
239
  (err: unknown) => {
212
240
  // Loader-thrown `redirect()` — convert to a real HTTP redirect
@@ -327,15 +355,15 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin[] {
327
355
  // Discover all @pyreon/* packages installed in node_modules.
328
356
  // The "bun" export condition points to TS source — esbuild's
329
357
  // dep optimizer would compile them with the wrong JSX runtime.
330
- const root = viteUserConfig.root ?? process.cwd()
331
- const pyreonExclude = scanPyreonPackages(root)
358
+ const cwd = viteUserConfig.root ?? process.cwd()
359
+ const pyreonExclude = scanPyreonPackages(cwd)
332
360
 
333
361
  // `@pyreon/runtime-server` is only imported by zero's dev SSR
334
362
  // middleware and the production server entry — apps rarely list it
335
363
  // as a direct dep. Resolve it to the copy nested under zero so
336
364
  // `ssrLoadModule("@pyreon/runtime-server")` works uniformly.
337
365
  const runtimeServerAlias = resolveNestedPackage(
338
- root,
366
+ cwd,
339
367
  "@pyreon/runtime-server",
340
368
  )
341
369
 
@@ -371,6 +399,25 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin[] {
371
399
  ...(userConfig.port !== undefined
372
400
  ? { server: { port: config.port } }
373
401
  : {}),
402
+ // Propagate `zero({ base })` to Vite's `base` config — that's
403
+ // what controls asset URL rewriting in the built HTML/JS
404
+ // (`<script src="/blog/assets/…">`). Pre-fix this was a
405
+ // typed-but-unimplemented field: `__ZERO_BASE__` was defined
406
+ // as a Vite global but no consumer existed, AND Vite's own
407
+ // `base` had to be set manually in vite.config.ts. Setting
408
+ // it here makes `zero({ base: '/blog/' })` the canonical
409
+ // single-source-of-truth surface; the value flows through
410
+ // to (a) Vite's HTML/asset URL rewriter, (b) `createRouter`
411
+ // via `__ZERO_BASE__` in `startClient` / `createApp`, (c)
412
+ // the SSG entry's `createApp({ base })` call.
413
+ //
414
+ // Vite's config-merge semantics: plugin-returned config is
415
+ // the BASE; user's `vite.config.ts` top-level overrides.
416
+ // So a user who sets BOTH `zero({ base: '/blog/' })` AND
417
+ // `vite.config.base: '/foo/'` gets the latter — the user's
418
+ // explicit override wins. The default `/` is a no-op
419
+ // (matches Vite's default), so always-setting is safe.
420
+ base: config.base,
374
421
  define: {
375
422
  __ZERO_MODE__: JSON.stringify(config.mode),
376
423
  __ZERO_BASE__: JSON.stringify(config.base),
@@ -379,6 +426,12 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin[] {
379
426
  },
380
427
  };
381
428
 
429
+ // Stash the original user config keyed by plugin identity so the CLI
430
+ // (which loads vite.config.ts and inspects the plugin list) can
431
+ // recover it via `getZeroPluginConfig(plugin)` without us hanging a
432
+ // `_`-prefixed property off the public Plugin object.
433
+ zeroPluginConfigMap.set(mainPlugin, userConfig);
434
+
382
435
  // SSG mode auto-wires the static-site generation hook. Other modes get
383
436
  // just the main plugin. The SSG plugin internally no-ops when
384
437
  // `mode !== 'ssg'`, but skipping it entirely keeps the plugin chain
@@ -488,13 +541,24 @@ async function dispatchApiRoute(
488
541
  }
489
542
 
490
543
  /**
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.
544
+ * Static-page 404 fallback for apps WITHOUT `_404.tsx` in the routes tree.
493
545
  *
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.
546
+ * For `mode: 'ssr'` apps with `_404.tsx`, the SSR middleware's `renderSsr`
547
+ * routes unmatched URLs through the router-driven path (PR L5 + M1.2) that
548
+ * produces a layout-wrapped 404 with HTTP status 404, never reaching here.
549
+ * This function is the LEGACY fallback that fires only when:
550
+ * - The app is in `mode: 'spa'` / `mode: 'ssg'` (no dev SSR middleware), OR
551
+ * - The app has no reachable `notFoundComponent` in its routes tree (so the
552
+ * SSR middleware's `resolveRoute` returns matched: [] and falls through).
553
+ *
554
+ * Returns true if the 404 was handled (response sent), false if the path
555
+ * actually matches a route (caller continues to next middleware).
556
+ *
557
+ * Pre-M1.2 a stale comment claimed `_404.tsx` "cannot be SSR-rendered because
558
+ * the compiler emits _tpl() calls that require document". That was wrong — the
559
+ * SSR runtime renders compiler-emitted components fine via `renderToString`
560
+ * (no document needed). The static fallback exists for backward compat with
561
+ * apps that don't ship `_404.tsx`, not because SSR-rendering it is impossible.
498
562
  */
499
563
  async function handle404(
500
564
  server: import("vite").ViteDevServer,
@@ -510,10 +574,11 @@ async function handle404(
510
574
  return false; // Route matches — not a 404
511
575
  }
512
576
 
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.
577
+ // No route matched + no `_404.tsx` reachable emit a minimal static page
578
+ // so the user gets SOMETHING. Apps that want branded 404s should add
579
+ // `_404.tsx` to their routes tree (canonical pattern); the SSR middleware
580
+ // then routes through the router-driven path with layout chrome instead
581
+ // of landing here.
517
582
  const html = await render404Page(undefined);
518
583
 
519
584
  res.statusCode = 404;
@@ -541,18 +606,12 @@ async function renderSsr(
541
606
  originalUrl: string,
542
607
  pathname: string,
543
608
  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).
609
+ ): Promise<{ html: string; status: number } | null> {
547
610
  const routesMod = await server.ssrLoadModule(VIRTUAL_ROUTES_ID);
548
611
  const routes = routesMod.routes as Array<{
549
612
  path?: string;
550
613
  children?: unknown[];
551
614
  }>;
552
- const patterns = flattenRoutePatterns(routes);
553
- if (!patterns.some((pattern) => matchPattern(pattern, pathname))) {
554
- return null;
555
- }
556
615
 
557
616
  // Read + transform index.html (Vite injects the HMR client / JSX prelude).
558
617
  let template = await readFile(join(root, "index.html"), "utf-8");
@@ -565,7 +624,7 @@ async function renderSsr(
565
624
  // `@pyreon/runtime-server` isn't a direct dep of most apps, so zero's
566
625
  // `config()` hook registers an alias that points it at the copy under
567
626
  // zero's own `node_modules` — same path → same Vite module → same instance.
568
- const [core, headPkg, headSsr, routerPkg, runtimeServer] = await Promise.all(
627
+ const [core, _headPkg, headSsr, routerPkg, runtimeServer] = await Promise.all(
569
628
  [
570
629
  server.ssrLoadModule("@pyreon/core") as Promise<
571
630
  typeof import("@pyreon/core")
@@ -620,8 +679,25 @@ async function renderSsr(
620
679
  // `preload` loads lazy route components AND runs loaders for `pathname` so
621
680
  // the synchronous render pass produces final HTML — no loading fallbacks,
622
681
  // no `useLoaderData() === undefined`.
682
+ //
683
+ // M1.2 — Unmatched URLs no longer bail to a static 404 page. The router's
684
+ // `resolveRoute` (PR L5) walks the route tree and, if a parent layout has
685
+ // `notFoundComponent` AND the URL is under that layout's prefix, builds a
686
+ // synthetic chain `[...ancestorLayouts, syntheticLeaf]` with
687
+ // `isNotFound: true`. The render then produces 404 HTML INSIDE the
688
+ // layout's chrome. If the routes tree has no reachable `notFoundComponent`,
689
+ // `matched` stays empty — fall through to `handle404` for the static
690
+ // fallback (preserves backward compat for apps without `_404.tsx`).
623
691
  await routerInst.preload(pathname, req);
624
692
 
693
+ const resolved = routerInst.currentRoute() as
694
+ | { matched?: unknown[]; isNotFound?: boolean }
695
+ | undefined;
696
+ if (!resolved?.matched || resolved.matched.length === 0) {
697
+ return null;
698
+ }
699
+ const status = resolved.isNotFound === true ? 404 : 200;
700
+
625
701
  return runtimeServer.runWithRequestContext(async () => {
626
702
  const app = core.h(App as Parameters<typeof core.h>[0], null);
627
703
 
@@ -630,14 +706,16 @@ async function renderSsr(
630
706
  routerInst as Parameters<typeof routerPkg.serializeLoaderData>[0],
631
707
  );
632
708
  const hasData = loaderData && Object.keys(loaderData).length > 0;
709
+ // M2.2 — safe serializer (parity with production handler / SSG entry).
633
710
  const loaderScript = hasData
634
- ? `<script>window.__PYREON_LOADER_DATA__=${JSON.stringify(loaderData).replace(/<\//g, "<\\/")}</script>`
711
+ ? `<script>window.__PYREON_LOADER_DATA__=${routerPkg.stringifyLoaderData(loaderData)}</script>`
635
712
  : "";
636
713
 
637
- return template
714
+ const html = template
638
715
  .replace("<!--pyreon-head-->", head)
639
716
  .replace("<!--pyreon-app-->", appHtml)
640
717
  .replace("<!--pyreon-scripts-->", loaderScript);
718
+ return { html, status };
641
719
  });
642
720
  }
643
721