@pyreon/zero 0.16.0 → 0.19.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/src/manifest.ts CHANGED
@@ -475,6 +475,105 @@ const ButtonLink = createLink((props) => (
475
475
  seeAlso: ['Link', 'useLink'],
476
476
  },
477
477
 
478
+ {
479
+ name: 'Icon',
480
+ kind: 'component',
481
+ signature: '<Icon as={ImportedSvgComponent} | svg={rawSvgMarkupString} {...hostProps} />',
482
+ summary:
483
+ "Renders a FULL loaded SVG — it does NOT synthesize its own `<svg>` around hand-authored `<path>` children. You load an svg (it already contains the `<svg>` root) and Icon makes it container-sizable + theme-aware. Two source props: `as` — an imported SVG *component* (`import X from './x.svg?component'`), rendered DIRECTLY with no host wrapper (recommended; it's a real `<svg>` so container-fill is reliable); `svg` — the raw `<svg>…</svg>` *markup string* (`import x from './x.svg?raw'`), inlined via a single `<span>` host (a markup string needs a parent to mount — this one host is unavoidable for the string form). Defaults (`fill=\"currentColor\"`, `display:block;width:100%;height:100%`) are overridable — consumer props spread through and win. No fixed size → fills its container; `fill=\"currentColor\"` themes via CSS `color`. Intentionally no `useIcon` hook (an icon has no composable behaviour); two layers: `createIcon` (one component per loaded glyph) + `Icon` (one-off).",
484
+ example: `import { Icon } from '@pyreon/zero'
485
+ import Check from './check.svg?component'
486
+ import checkRaw from './check.svg?raw'
487
+
488
+ // Component form — rendered directly, no wrapper, reliable fill:
489
+ <span style="width:2rem"><Icon as={Check} /></span>
490
+
491
+ // Raw-markup form — inlined inside one <span> host:
492
+ <span style="width:2rem"><Icon svg={checkRaw} /></span>`,
493
+ mistakes: [
494
+ "Expecting `<Icon>` to synthesize an `<svg>` from `<path>` children — it does NOT. Pass a loaded svg via `as` (imported `?component`) or `svg` (imported `?raw` string). Children are not the API",
495
+ "Expecting `<Icon>` to size itself — it has NO intrinsic size; it fills its container. Wrap + size it (`<span style=\"width:1.5rem\">`) or use a sized flex/grid cell",
496
+ "Hardcoding `fill=\"#000\"` — breaks theming. Leave the `currentColor` default; drive colour with CSS `color` so dark mode + hover work for free. Only the `as` form forwards `fill` to the real svg — the `svg`-string form's markup is opaque, so colour it via `currentColor` inside the asset",
497
+ "Expecting svg-only props (`viewBox`, `fill`) to apply in the `svg`-string form — they can't reach the opaque inlined markup; only host attrs (`class`, `style`, `aria-*`, events) forward. Use the `as` form when you need to drive svg attributes",
498
+ "Reaching for a `useIcon` hook — there isn't one, by design. Use `createIcon` or inline `<Icon>`; an icon has no behaviour worth a hook layer",
499
+ "Preferring `svg` (raw string) for the wrapper-free guarantee — it's the opposite: `svg` ALWAYS adds a `<span>` host (unavoidable for string inlining); `as` is the zero-wrapper form",
500
+ ],
501
+ seeAlso: ['createIcon', 'IconProps', 'Image'],
502
+ },
503
+ {
504
+ name: 'createIcon',
505
+ kind: 'function',
506
+ signature: 'function createIcon(source: string | SvgComponent): (props: SvgAttributes) => VNodeChild',
507
+ summary:
508
+ "Builds a reusable icon component from a LOADED svg — a raw `<svg>…</svg>` markup string (`?raw`) OR an imported SVG component (`?component`). The result is still just `<Icon>` (string → `svg` prop, component → `as` prop), so it's container-sizable + theme-aware with every prop passed through. A generated icon set is `createIcon`-per-glyph with zero per-icon boilerplate. Mirrors the `createLink`/`createImage` factory layer, minus a hook (icons have no composable behaviour).",
509
+ example: `import { createIcon } from '@pyreon/zero'
510
+ import StarSvg from './star.svg?component'
511
+ import checkRaw from './check.svg?raw'
512
+
513
+ export const Star = createIcon(StarSvg) // component → rendered directly
514
+ export const Check = createIcon(checkRaw) // raw string → inlined via <span>
515
+
516
+ // Sized + themed entirely by the consumer:
517
+ <span style="width:48px"><Check class="text-green-600" aria-label="done" /></span>`,
518
+ mistakes: [
519
+ "Calling `createIcon` inside a component body — define icon components at module scope (like `createLink`/`createImage`). Re-creating the component every render defeats identity-based reconciliation",
520
+ "Passing hand-built `<path>` JSX as `source` — `source` is a full loaded svg: a `?raw` markup string OR a `?component` import. It does NOT take individual shapes; the loaded asset already contains its own `<svg>` root",
521
+ "Assuming the `?raw` form has no wrapper — the string form ALWAYS adds one `<span>` host (unavoidable for inlining markup). Use the `?component` form for the zero-wrapper, attribute-forwarding path",
522
+ ],
523
+ seeAlso: ['Icon', 'IconProps', 'createNamedIcon', 'iconsPlugin'],
524
+ },
525
+ {
526
+ name: 'iconsPlugin',
527
+ kind: 'function',
528
+ signature: "iconsPlugin({ dir | sets, out?, mode?: 'inline' | 'image' }): Plugin",
529
+ summary:
530
+ "Vite plugin (from `@pyreon/zero/server`): point it at a folder of `*.svg` files and it writes a strictly-typed generated `icons.gen.tsx` exporting `<Icon name=\"…\" />`. Add an svg → the `name` union widens; remove one → an invalid `name` fails typecheck. The generated file calls `createNamedIcon(REGISTRY)`, so `keyof typeof REGISTRY` IS the type surface (autocomplete + real go-to-definition, zero per-app wiring — same one-touch shape as fs-router / islands auto-registry). Regenerates on add/unlink in dev (idempotent write — never rewrites identical content). **Named multi-set form** (`sets: { ui: { dir }, brand: { dir, mode } }`, mutually exclusive with `dir`): one generated file exports a strictly-typed component PER set with NAMESPACED types so they never clash — `ui` → `<UiIcon name=\"…\" />` + `type UiIconName`, `brand` → `<BrandIcon name=\"…\" />` + `type BrandIconName`; per-set binding prefixes mean two sets sharing a glyph filename don't collide. Two render modes per the colorful-vs-system split (settable per-set): `mode: 'inline'` (default — system icons; each svg inlined as raw `?raw` markup, `currentColor`-themeable, recolor via CSS `color`) and `mode: 'image'` (colorful / brand icons; each svg emitted as a static asset, rendered `<img>`, NO mutation, original colors preserved). Default `out` is `icons.gen.tsx` next to `dir` for the single-set form (`src/icons` → `src/icons.gen.tsx`) or `src/icons.gen.tsx` for the multi-set form — recommend gitignoring it (build artifact). It writes a real file (NOT a virtual module) deliberately: the published `@pyreon/zero` package can't `import` a plugin virtual module — Rolldown resolves static imports before plugin `resolveId` (the same constraint that makes islands need `hydrateIslandsAuto(registry)` with an explicit import).",
531
+ example: `// vite.config.ts — single set:
532
+ import { iconsPlugin } from '@pyreon/zero/server'
533
+ iconsPlugin({ dir: './src/icons' })
534
+ // app: import { Icon } from './icons.gen'; <Icon name="check-circle" />
535
+
536
+ // Named multi-set — per-set typed components, no IconName clash:
537
+ iconsPlugin({ sets: {
538
+ ui: { dir: './src/icons/ui' },
539
+ brand: { dir: './src/icons/brand', mode: 'image' },
540
+ }})
541
+ // app: import { UiIcon, BrandIcon } from './icons.gen'
542
+ // <UiIcon name="arrow-left" /> <BrandIcon name="logo-mark" />`,
543
+ mistakes: [
544
+ "Passing BOTH `dir` and `sets` (or neither) — exactly one is required; the plugin throws `[Pyreon] iconsPlugin: provide EXACTLY ONE of dir or sets` at config time",
545
+ "Using `mode: 'inline'` (default) for multicolor / brand SVGs — inline mode is for monochrome system icons you recolor via `currentColor`. A multicolor logo's hardcoded fills survive but you lose nothing by using `mode: 'image'`, which is the correct choice for no-mutation colorful assets",
546
+ "Using `mode: 'image'` for icons you need to recolor — `<img>` can't be themed via CSS `color`; the svg is opaque. Recolorable system icons need `mode: 'inline'`",
547
+ "Editing the generated `icons.gen.tsx` by hand — it's regenerated on every add/unlink. Add/remove `.svg` files in the set folder(s) instead; commit the gitignore entry, not the file",
548
+ "Expecting a virtual `import 'virtual:zero/icons'` — there isn't one (Rolldown import-ordering constraint). The plugin writes a REAL file you import by path; that's what gives go-to-definition + zero wiring",
549
+ "Pointing a set `dir` at a folder that doesn't exist yet — `scanIconDir` returns empty and the generated `*IconName` is `never` (every `name` fails typecheck). Create the folder + drop at least one `.svg` first",
550
+ "Forgetting `vite/client` types — the generated file's `?raw` imports rely on Vite's ambient `*.svg?raw` module declaration; the generated file emits `/// <reference types=\"vite/client\" />` but the consuming tsconfig must still resolve `vite/client`",
551
+ ],
552
+ seeAlso: ['createNamedIcon', 'Icon', 'IconProps'],
553
+ },
554
+ {
555
+ name: 'createNamedIcon',
556
+ kind: 'function',
557
+ signature:
558
+ "function createNamedIcon<R extends Record<string, string>>(registry: R, options?: { mode?: 'inline' | 'image' }): (props: { name: keyof R & string } & …) => VNodeChild",
559
+ summary:
560
+ "Runtime half of `iconsPlugin` — builds a strictly-typed `<Icon name=\"…\" />` from a name→source registry. `keyof R` makes `name` a precise string union (the generated file passes a literal registry so the union infers there → autocomplete + go-to-definition). `mode: 'inline'` (default) treats each `source` as raw `<svg>` markup rendered via `Icon` (`currentColor`-themeable system icons); `mode: 'image'` treats each `source` as an asset URL rendered `<img>` with NO mutation (colorful / brand icons). Either way it stays container-filling + props-transparent. Not normally hand-called — `iconsPlugin` emits the generated file that calls it; call it directly only for a hand-maintained set.",
561
+ example: `// icons.gen.tsx (auto-generated by iconsPlugin):
562
+ import { createNamedIcon } from '@pyreon/zero'
563
+ export const Icon = createNamedIcon({ 'check-circle': '<svg…>…</svg>' })
564
+
565
+ // image mode (hand-maintained colorful set):
566
+ import logo from './logo.svg' // Vite → URL
567
+ export const Brand = createNamedIcon({ logo }, { mode: 'image' })
568
+ <Brand name="logo" alt="Company" />`,
569
+ mistakes: [
570
+ "Passing a `Record<string, string>` typed loosely (e.g. `: Record<string, string>`) — that widens `keyof R` to `string` and you lose the typed `name`. Pass the object literal directly (or `as const`) so the keys infer",
571
+ "Using `mode: 'image'` then expecting `fill` / svg props to apply — the `<img>` is opaque; only host attrs (`class`, `style`, `alt`, events) forward. Use `mode: 'inline'` for svg-attribute control",
572
+ "Omitting `alt` in `mode: 'image'` — it defaults to `\"\"` (decorative). Pass a real `alt` for meaningful icons; screen readers skip empty-alt images",
573
+ "Calling `createNamedIcon` inside a component body — define the set once at module scope (the generated file does). Re-creating it per render defeats identity-based reconciliation",
574
+ ],
575
+ seeAlso: ['iconsPlugin', 'Icon', 'IconProps'],
576
+ },
478
577
  {
479
578
  name: 'Image',
480
579
  kind: 'component',
package/src/rate-limit.ts CHANGED
@@ -62,6 +62,22 @@ export function rateLimitMiddleware(config: RateLimitConfig = {}): Middleware {
62
62
  for (const [key, entry] of store) {
63
63
  if (entry.resetAt <= now) store.delete(key)
64
64
  }
65
+ // HARD cap. The expired-sweep above only removes entries whose
66
+ // window has elapsed. An attacker flooding unique keys WITHIN one
67
+ // window (spoofed `X-Forwarded-For` / proxy header — `defaultKeyFn`
68
+ // trusts request headers) produces only fresh entries, so the sweep
69
+ // frees nothing and `store.set` grows the Map without bound — an
70
+ // unauthenticated memory-exhaustion DoS. `MAX_STORE_SIZE` was a
71
+ // declared constant used ONLY as a sweep trigger, never enforced.
72
+ // Map preserves insertion order, so evicting from the front drops
73
+ // the oldest trackers first (acceptable: an evicted attacker key
74
+ // simply gets a fresh window — no bypass of legitimate limits since
75
+ // a real client re-inserts and is immediately re-tracked).
76
+ while (store.size > MAX_STORE_SIZE) {
77
+ const oldest = store.keys().next().value
78
+ if (oldest === undefined) break
79
+ store.delete(oldest)
80
+ }
65
81
  }
66
82
 
67
83
  return (ctx: MiddlewareContext) => {
package/src/seo.ts CHANGED
@@ -135,10 +135,25 @@ export function generateSitemap(
135
135
  .filter((p): p is string => p !== null)
136
136
  .filter((p) => !exclude.some((e) => p.startsWith(e)))
137
137
 
138
- const allPaths: SitemapEntry[] = [
139
- ...paths.map((p) => ({ path: p, changefreq, priority })),
140
- ...(config.additionalPaths ?? []),
141
- ]
138
+ // Dedup by path (first-wins, order-preserving). The same static route
139
+ // routinely appears in BOTH the file-system route scan AND
140
+ // `additionalPaths` (e.g. SSG-emitted paths merged in via
141
+ // `seoPlugin`), which previously produced a DUPLICATE `<url>` entry —
142
+ // the i18n branch of `clusterPathsByLocale` dedups via `byUnPrefixed`,
143
+ // but the non-i18n branch is a raw 1:1 `entries.map(...)`, so without
144
+ // this the duplicate reached the emitted sitemap. Dedup here covers
145
+ // both branches at the single source. The route-scan entry wins so its
146
+ // configured `changefreq`/`priority` is kept over a bare dup.
147
+ const allPaths: SitemapEntry[] = (() => {
148
+ const byPath = new Map<string, SitemapEntry>()
149
+ for (const e of [
150
+ ...paths.map((p) => ({ path: p, changefreq, priority })),
151
+ ...(config.additionalPaths ?? []),
152
+ ]) {
153
+ if (!byPath.has(e.path)) byPath.set(e.path, e)
154
+ }
155
+ return [...byPath.values()]
156
+ })()
142
157
 
143
158
  // PR K: when i18n is set, cluster URLs by their un-prefixed (default-
144
159
  // locale) form so each `<url>` entry can carry the hreflang siblings
package/src/server.ts CHANGED
@@ -70,6 +70,8 @@ export { compose, getContext } from "./middleware";
70
70
  export { zeroPlugin as default, getZeroPluginConfig } from "./vite-plugin";
71
71
  export type { FaviconPluginConfig, FaviconLocaleConfig } from "./favicon";
72
72
  export { faviconPlugin, faviconLinks } from "./favicon";
73
+ export type { IconsPluginConfig, IconSetConfig, NamedSetInput } from "./icons-plugin";
74
+ export { iconsPlugin, iconNameFromFile, scanIconDir, generateIconSetSource, generateNamedIconSetsSource, componentNameFromSetKey } from "./icons-plugin";
73
75
  export type { SeoPluginConfig, SitemapConfig, RobotsConfig } from "./seo";
74
76
  export { seoPlugin, generateSitemap, generateRobots, jsonLd, seoMiddleware } from "./seo";
75
77
  export type { OgImagePluginConfig, OgImageTemplate, OgImageLayer } from "./og-image";
package/src/sharp.d.ts CHANGED
@@ -9,6 +9,12 @@ declare module 'sharp' {
9
9
  toFile(path: string): Promise<void>
10
10
  toBuffer(): Promise<Buffer>
11
11
  metadata(): Promise<{ width?: number; height?: number; format?: string }>
12
+ /**
13
+ * Image statistics. `dominant` is the histogram-mode RGB swatch —
14
+ * the basis of the `'color'` / `'dominant-color'` placeholder
15
+ * strategy (a flat-fill SVG, not a muddy channel average).
16
+ */
17
+ stats(): Promise<{ dominant: { r: number; g: number; b: number } }>
12
18
  }
13
19
 
14
20
  function sharp(input: string | Buffer): SharpInstance
package/src/ssg-plugin.ts CHANGED
@@ -23,7 +23,7 @@
23
23
 
24
24
  import { existsSync } from 'node:fs'
25
25
  import { mkdir, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises'
26
- import { dirname, join, resolve } from 'node:path'
26
+ import { dirname, join, resolve, sep } from 'node:path'
27
27
  import { pathToFileURL } from 'node:url'
28
28
  import type { Plugin } from 'vite'
29
29
  import { resolveAdapter } from './adapters'
@@ -373,6 +373,25 @@ export function expandUrlPattern(pattern: string, params: Record<string, string>
373
373
  `[zero:ssg] getStaticPaths for "${pattern}" returned params without "${name}"`,
374
374
  )
375
375
  }
376
+ // Path-escape guard. The value is substituted verbatim into the
377
+ // URL that becomes a `dist/<path>/index.html` write target. A
378
+ // single (non-catch-all) `:slug` is ONE segment — a value
379
+ // containing `/` or being `.`/`..` (e.g. an unsanitized CMS slug
380
+ // `../../secret`) would escape the intended structure and write
381
+ // outside it. Catch-all `:slug*` legitimately spans segments
382
+ // (`a/b/c`), so it's exempt from the `/` check but still rejects
383
+ // `.`/`..` traversal segments.
384
+ const segs = isCatchAll ? value.split('/') : [value]
385
+ if (
386
+ (!isCatchAll && value.includes('/')) ||
387
+ segs.some((s) => s === '.' || s === '..')
388
+ ) {
389
+ throw new Error(
390
+ `[zero:ssg] getStaticPaths for "${pattern}" produced an unsafe "${name}" value ` +
391
+ `(${JSON.stringify(value)}): a ${isCatchAll ? 'catch-all' : 'dynamic'} segment ` +
392
+ `must not contain path-traversal ("." / "..")${isCatchAll ? '' : ' or "/"'}.`,
393
+ )
394
+ }
376
395
  return value
377
396
  })
378
397
  .join('/')
@@ -443,10 +462,17 @@ async function autoDetectStaticPaths(
443
462
  }
444
463
  }
445
464
 
465
+ // Dedup (order-preserving). The same concrete path can be produced
466
+ // more than once — a `getStaticPaths` returning a duplicate slug, or
467
+ // i18n route fan-out colliding — which otherwise renders the same
468
+ // `dist/<path>/index.html` twice (wasted work + last-write race) and
469
+ // feeds a duplicate `<url>` into the SSG→sitemap merge.
470
+ const deduped = [...new Set(out)]
471
+
446
472
  // Always include "/" as a fallback if no static routes were found —
447
473
  // a project with only dynamic routes still needs an index.html for the
448
474
  // host to know where to send unmatched URLs.
449
- return out.length > 0 ? out : ['/']
475
+ return deduped.length > 0 ? deduped : ['/']
450
476
  }
451
477
 
452
478
  async function resolvePaths(
@@ -598,6 +624,21 @@ function resolveOutputPath(distDir: string, path: string): string {
598
624
  return join(distDir, path, 'index.html')
599
625
  }
600
626
 
627
+ /**
628
+ * Path-containment check that is SEPARATOR-TERMINATED. A bare
629
+ * `resolve(filePath).startsWith(resolve(distDir))` is a string-prefix
630
+ * test, not a path test: with distDir `/app/dist`, a traversed filePath
631
+ * resolving to the SIBLING `/app/dist-evil/x` passes
632
+ * `'/app/dist-evil/x'.startsWith('/app/dist')` → true and the build
633
+ * writes outside the intended output root. `path` derives from caller
634
+ * route params (CMS slugs via `getStaticPaths`), so this is reachable.
635
+ */
636
+ function isInsideDist(distDir: string, filePath: string): boolean {
637
+ const root = resolve(distDir)
638
+ const target = resolve(filePath)
639
+ return target === root || target.startsWith(root + sep)
640
+ }
641
+
601
642
  // ─── Redirect emission (PR B) ──────────────────────────────────────────────
602
643
  //
603
644
  // The shape returned by the SSG entry's renderPath when a loader throws
@@ -1128,8 +1169,7 @@ export function ssgPlugin(userConfig: ZeroConfig = {}): Plugin {
1128
1169
 
1129
1170
  if (config.ssg?.redirectsAsHtml === 'meta-refresh') {
1130
1171
  const filePath = resolveOutputPath(distDir, p)
1131
- const resolvedOut = resolve(distDir)
1132
- if (!resolve(filePath).startsWith(resolvedOut)) {
1172
+ if (!isInsideDist(distDir, filePath)) {
1133
1173
  errors.push({ path: p, error: new Error(`Path traversal detected: "${p}"`) })
1134
1174
  return
1135
1175
  }
@@ -1144,8 +1184,7 @@ export function ssgPlugin(userConfig: ZeroConfig = {}): Plugin {
1144
1184
  const filePath = resolveOutputPath(distDir, p)
1145
1185
 
1146
1186
  // Path-traversal guard — same as @pyreon/server's prerender.
1147
- const resolvedOut = resolve(distDir)
1148
- if (!resolve(filePath).startsWith(resolvedOut)) {
1187
+ if (!isInsideDist(distDir, filePath)) {
1149
1188
  errors.push({ path: p, error: new Error(`Path traversal detected: "${p}"`) })
1150
1189
  return
1151
1190
  }
@@ -1174,8 +1213,7 @@ export function ssgPlugin(userConfig: ZeroConfig = {}): Plugin {
1174
1213
  const fallbackHtml = await config.ssg.onPathError(p, error)
1175
1214
  if (typeof fallbackHtml === 'string') {
1176
1215
  const filePath = resolveOutputPath(distDir, p)
1177
- const resolvedOut = resolve(distDir)
1178
- if (!resolve(filePath).startsWith(resolvedOut)) {
1216
+ if (!isInsideDist(distDir, filePath)) {
1179
1217
  errors.push({ path: p, error: new Error(`Path traversal detected: "${p}"`) })
1180
1218
  return
1181
1219
  }
@@ -1505,6 +1543,7 @@ export const _internal = {
1505
1543
  resolvePaths,
1506
1544
  autoDetectStaticPaths,
1507
1545
  resolveOutputPath,
1546
+ isInsideDist,
1508
1547
  expandUrlPattern,
1509
1548
  injectIntoTemplate,
1510
1549
  renderNetlifyRedirects,
package/src/types.ts CHANGED
@@ -58,6 +58,15 @@ export interface ISRConfig {
58
58
  * space (e.g. `/user/:id` where `:id` is free-form).
59
59
  */
60
60
  maxEntries?: number
61
+ /**
62
+ * Max wall-time (ms) for a single background revalidation before it is
63
+ * abandoned. Without a bound, a handler that hangs leaves its key
64
+ * pinned in the in-flight set forever — every later request for that
65
+ * key short-circuits the de-dupe guard and the entry can never
66
+ * recover from stale. Default: `30000` (matches the Suspense
67
+ * streaming timeout).
68
+ */
69
+ revalidateTimeoutMs?: number
61
70
  /**
62
71
  * Cache-key derivation function. The default keys cache entries by
63
72
  * `url.pathname` ONLY — query strings, cookies, and headers are
@@ -254,6 +263,38 @@ export interface ZeroConfig {
254
263
  currentPath: string
255
264
  elapsed: number
256
265
  }) => void | Promise<void>
266
+ /**
267
+ * Route-level code splitting in SSG mode. Default `true`.
268
+ *
269
+ * When `true` (default), each route file becomes its own dynamic-import
270
+ * chunk via `lazy(() => import("..."))` — only the route the user
271
+ * lands on plus its dependencies ship in the initial bundle, the
272
+ * rest fetch on navigation. Matches the SSR/SPA-mode behaviour zero
273
+ * has always had; brings parity to SSG.
274
+ *
275
+ * When `false`, every route is bundled statically into the main
276
+ * client chunk (the pre-2026-Q3 SSG behaviour). Useful for tiny
277
+ * sites (2-5 pages) where the single-chunk-then-instant-nav trade
278
+ * is preferable — the chunk-fetch cost on navigation is gone, and
279
+ * the marginal bytes are negligible.
280
+ *
281
+ * Crossover point: ~5-8 routes. Below that, single-chunk is fine.
282
+ * Above that, lazy() shrinks the initial bundle by a meaningful
283
+ * amount (a 50-route docs site might drop from 200 KB to 80 KB on
284
+ * first paint).
285
+ *
286
+ * Underlying mechanism is the same 3-tier generator zero already
287
+ * uses for SSR/SPA mode (`fs-router.ts:generateRouteEntry`): lazy
288
+ * component + inlined metadata when possible, lazy + lazy-thunked
289
+ * function exports when not, namespace-import fallback for cases
290
+ * the literal-extractor can't reach.
291
+ *
292
+ * @example
293
+ * ssg: {
294
+ * splitChunks: false, // bundle-everything for a 3-page marketing site
295
+ * }
296
+ */
297
+ splitChunks?: boolean
257
298
  }
258
299
 
259
300
  /** ISR config — only used when mode is "isr". */
@@ -90,6 +90,27 @@ export function getZeroPluginConfig(plugin: Plugin): ZeroConfig | undefined {
90
90
  return zeroPluginConfigMap.get(plugin);
91
91
  }
92
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
+
93
114
  /**
94
115
  * Zero Vite plugin — adds file-based routing and zero-config conventions
95
116
  * on top of @pyreon/vite-plugin.
@@ -138,8 +159,23 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin[] {
138
159
  const routes = config.i18n
139
160
  ? expandRoutesForLocales(baseRoutes, config.i18n)
140
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;
141
177
  return generateRouteModuleFromRoutes(routes, routesDir, {
142
- staticImports: config.mode === 'ssg',
178
+ staticImports: ssgSplitDisabled,
143
179
  });
144
180
  } catch (_err) {
145
181
  return `export const routes = []`;
@@ -389,16 +425,32 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin[] {
389
425
  optimizeDeps: {
390
426
  exclude: pyreonExclude,
391
427
  },
392
- // Only set the port when the user explicitly provided one in
393
- // `zero({ port: N })`. Without this guard, the plugin always
394
- // returned `server: { port: 3000 }` which overrode Vite's CLI
395
- // `--port` flag and made multi-example dev impossible — every
396
- // example tried to bind 3000 even when launched with
397
- // `vite --port 5173`. Surfaced when wiring up the playwright
398
- // e2e suite.
399
- ...(userConfig.port !== undefined
400
- ? { server: { port: config.port } }
401
- : {}),
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 } }),
402
454
  // Propagate `zero({ base })` to Vite's `base` config — that's
403
455
  // what controls asset URL rewriting in the built HTML/JS
404
456
  // (`<script src="/blog/assets/…">`). Pre-fix this was a