@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.
@@ -146,6 +146,15 @@ interface ISRConfig {
146
146
  * space (e.g. `/user/:id` where `:id` is free-form).
147
147
  */
148
148
  maxEntries?: number;
149
+ /**
150
+ * Max wall-time (ms) for a single background revalidation before it is
151
+ * abandoned. Without a bound, a handler that hangs leaves its key
152
+ * pinned in the in-flight set forever — every later request for that
153
+ * key short-circuits the de-dupe guard and the entry can never
154
+ * recover from stale. Default: `30000` (matches the Suspense
155
+ * streaming timeout).
156
+ */
157
+ revalidateTimeoutMs?: number;
149
158
  /**
150
159
  * Cache-key derivation function. The default keys cache entries by
151
160
  * `url.pathname` ONLY — query strings, cookies, and headers are
@@ -331,6 +340,38 @@ interface ZeroConfig {
331
340
  currentPath: string;
332
341
  elapsed: number;
333
342
  }) => void | Promise<void>;
343
+ /**
344
+ * Route-level code splitting in SSG mode. Default `true`.
345
+ *
346
+ * When `true` (default), each route file becomes its own dynamic-import
347
+ * chunk via `lazy(() => import("..."))` — only the route the user
348
+ * lands on plus its dependencies ship in the initial bundle, the
349
+ * rest fetch on navigation. Matches the SSR/SPA-mode behaviour zero
350
+ * has always had; brings parity to SSG.
351
+ *
352
+ * When `false`, every route is bundled statically into the main
353
+ * client chunk (the pre-2026-Q3 SSG behaviour). Useful for tiny
354
+ * sites (2-5 pages) where the single-chunk-then-instant-nav trade
355
+ * is preferable — the chunk-fetch cost on navigation is gone, and
356
+ * the marginal bytes are negligible.
357
+ *
358
+ * Crossover point: ~5-8 routes. Below that, single-chunk is fine.
359
+ * Above that, lazy() shrinks the initial bundle by a meaningful
360
+ * amount (a 50-route docs site might drop from 200 KB to 80 KB on
361
+ * first paint).
362
+ *
363
+ * Underlying mechanism is the same 3-tier generator zero already
364
+ * uses for SSR/SPA mode (`fs-router.ts:generateRouteEntry`): lazy
365
+ * component + inlined metadata when possible, lazy + lazy-thunked
366
+ * function exports when not, namespace-import fallback for cases
367
+ * the literal-extractor can't reach.
368
+ *
369
+ * @example
370
+ * ssg: {
371
+ * splitChunks: false, // bundle-everything for a 3-page marketing site
372
+ * }
373
+ */
374
+ splitChunks?: boolean;
334
375
  };
335
376
  /** ISR config — only used when mode is "isr". */
336
377
  isr?: ISRConfig;
@@ -1074,6 +1115,83 @@ declare function faviconLinks(locale: string | undefined, config: FaviconPluginC
1074
1115
  href: string;
1075
1116
  }>;
1076
1117
  //#endregion
1118
+ //#region src/icon.d.ts
1119
+ /** How a named icon set renders each entry. */
1120
+ type IconMode = 'inline' | 'image';
1121
+ //#endregion
1122
+ //#region src/icons-plugin.d.ts
1123
+ /** One named set in the multi-set form. */
1124
+ interface IconSetConfig {
1125
+ /** Folder of `*.svg` files to scan for this set. */
1126
+ dir: string;
1127
+ /**
1128
+ * `'inline'` (default — system icons, `currentColor`-themeable) or
1129
+ * `'image'` (colorful / brand icons, rendered `<img>`, no mutation).
1130
+ */
1131
+ mode?: IconMode;
1132
+ }
1133
+ interface IconsPluginConfig {
1134
+ /**
1135
+ * Single-set form: a folder of `*.svg` files → one `<Icon name="…" />`
1136
+ * with a single `IconName` union. Mutually exclusive with `sets`.
1137
+ */
1138
+ dir?: string;
1139
+ /**
1140
+ * Named multi-set form: `{ ui: { dir }, brand: { dir, mode } }` → one
1141
+ * generated file exporting a strictly-typed component PER set with
1142
+ * NAMESPACED types so they never clash:
1143
+ * `ui` → `<UiIcon name="…" />` + `type UiIconName`
1144
+ * `brand` → `<BrandIcon name="…" />` + `type BrandIconName`
1145
+ * Mutually exclusive with `dir`.
1146
+ */
1147
+ sets?: Record<string, IconSetConfig>;
1148
+ /**
1149
+ * Where to write the generated `.tsx`. Single-set default: `icons.gen.tsx`
1150
+ * next to `dir` (e.g. `src/icons` → `src/icons.gen.tsx`). Multi-set
1151
+ * default: `src/icons.gen.tsx` under the project root. Recommend
1152
+ * gitignoring it — it's a build artifact.
1153
+ */
1154
+ out?: string;
1155
+ /** Single-set form only — render mode (`'inline'` default | `'image'`). */
1156
+ mode?: IconMode;
1157
+ }
1158
+ /** Set key → exported component name. `ui` → `UiIcon`, `brand-marks` → `BrandMarksIcon`. */
1159
+ declare function componentNameFromSetKey(key: string): string;
1160
+ /** Filename stem → registry key. `Check-Circle.svg` → `check-circle`. */
1161
+ declare function iconNameFromFile(file: string): string;
1162
+ /** List the `*.svg` filenames in `dir` (sorted, stable). Empty if missing. */
1163
+ declare function scanIconDir(dir: string): string[];
1164
+ /**
1165
+ * Render the generated `.tsx` source for a set of svg filenames. Pure —
1166
+ * unit-tested directly; the plugin only adds fs + watch around it.
1167
+ */
1168
+ declare function generateIconSetSource(files: string[], opts: {
1169
+ mode: IconMode;
1170
+ importDir: string;
1171
+ }): string;
1172
+ /** One resolved set for the multi-set generator. */
1173
+ interface NamedSetInput {
1174
+ /** Set key (`ui`) — becomes `<UiIcon>` + `type UiIconName`. */
1175
+ key: string;
1176
+ files: string[];
1177
+ mode: IconMode;
1178
+ /** Relative import dir from the generated file to this set's folder. */
1179
+ importDir: string;
1180
+ }
1181
+ /**
1182
+ * Render the generated `.tsx` for the NAMED MULTI-SET form. One file, one
1183
+ * `createNamedIcon` import, one strictly-typed component PER set with
1184
+ * namespaced types (`UiIcon`/`UiIconName`, `BrandIcon`/`BrandIconName`) so
1185
+ * sets never clash. Bindings are per-set-prefixed so two sets sharing a
1186
+ * glyph filename don't collide.
1187
+ */
1188
+ declare function generateNamedIconSetsSource(sets: NamedSetInput[]): string;
1189
+ /**
1190
+ * Vite plugin: scan `dir` for `*.svg`, write a strictly-typed
1191
+ * `icons.gen.tsx`, regenerate on add / unlink in dev.
1192
+ */
1193
+ declare function iconsPlugin(cfg: IconsPluginConfig): Plugin;
1194
+ //#endregion
1077
1195
  //#region src/seo.d.ts
1078
1196
  interface SitemapConfig {
1079
1197
  /** Base URL of the site (required). e.g. "https://example.com" */
@@ -1467,5 +1585,5 @@ declare function inferJsonLd(options: InferJsonLdOptions): Record<string, unknow
1467
1585
  */
1468
1586
  declare function aiPlugin(config: AiPluginConfig): Plugin;
1469
1587
  //#endregion
1470
- export { type AiPluginConfig, type CreateAppOptions, type CreateServerOptions, type FaviconLocaleConfig, type FaviconPluginConfig, type GenerateRouteModuleOptions, type GetStaticPaths, type InferJsonLdOptions, type OgImageLayer, type OgImagePluginConfig, type OgImageTemplate, type RobotsConfig, type SeoPluginConfig, type SitemapConfig, type VercelRevalidateHandlerOptions, _resetVercelRevalidateHandlerCache, aiPlugin, bunAdapter, cloudflareAdapter, compose, createApp, createISRHandler, createLocaleContext, createServer, zeroPlugin as default, defineConfig, detectLocaleFromHeader, faviconLinks, faviconPlugin, filePathToUrlPath, generateLlmsFullTxt, generateLlmsTxt, generateMiddlewareModule, generateRobots, generateRouteModule, generateSitemap, getContext, getZeroPluginConfig, i18nRouting, inferJsonLd, jsonLd, netlifyAdapter, nodeAdapter, ogImagePath, ogImagePlugin, parseFileRoutes, render404Page, resolveAdapter, resolveConfig, scanRouteFiles, seoMiddleware, seoPlugin, staticAdapter, vercelAdapter, vercelRevalidateHandler };
1588
+ export { type AiPluginConfig, type CreateAppOptions, type CreateServerOptions, type FaviconLocaleConfig, type FaviconPluginConfig, type GenerateRouteModuleOptions, type GetStaticPaths, type IconSetConfig, type IconsPluginConfig, type InferJsonLdOptions, type NamedSetInput, type OgImageLayer, type OgImagePluginConfig, type OgImageTemplate, type RobotsConfig, type SeoPluginConfig, type SitemapConfig, type VercelRevalidateHandlerOptions, _resetVercelRevalidateHandlerCache, aiPlugin, bunAdapter, cloudflareAdapter, componentNameFromSetKey, compose, createApp, createISRHandler, createLocaleContext, createServer, zeroPlugin as default, defineConfig, detectLocaleFromHeader, faviconLinks, faviconPlugin, filePathToUrlPath, generateIconSetSource, generateLlmsFullTxt, generateLlmsTxt, generateMiddlewareModule, generateNamedIconSetsSource, generateRobots, generateRouteModule, generateSitemap, getContext, getZeroPluginConfig, i18nRouting, iconNameFromFile, iconsPlugin, inferJsonLd, jsonLd, netlifyAdapter, nodeAdapter, ogImagePath, ogImagePlugin, parseFileRoutes, render404Page, resolveAdapter, resolveConfig, scanIconDir, scanRouteFiles, seoMiddleware, seoPlugin, staticAdapter, vercelAdapter, vercelRevalidateHandler };
1471
1589
  //# sourceMappingURL=server2.d.ts.map
@@ -1,13 +1,13 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-CjeV3_4I.js";
2
- import { i as matchApiRoute, n as createApiMiddleware, r as generateApiRouteModule } from "./api-routes-Ci0kVmM4.js";
3
- import { a as generateRouteModuleFromRoutes, c as scanRouteFilesWithExports, o as parseFileRoutes, r as generateMiddlewareModule, s as scanRouteFiles } from "./fs-router-MewHc5SB.js";
2
+ import { i as matchApiRoute, n as createApiMiddleware, r as generateApiRouteModule } from "./api-routes-CQiOi3q5.js";
3
+ import { a as generateRouteModuleFromRoutes, c as scanRouteFilesWithExports, o as parseFileRoutes, r as generateMiddlewareModule, s as scanRouteFiles } from "./fs-router-BVY4lTH_.js";
4
4
  import { Fragment, createContext, h } from "@pyreon/core";
5
5
  import { HeadProvider } from "@pyreon/head";
6
6
  import { RouterProvider, RouterView, createRouter, getRedirectInfo } from "@pyreon/router";
7
7
  import { createHandler } from "@pyreon/server";
8
8
  import { renderToString } from "@pyreon/runtime-server";
9
9
  import { existsSync, readdirSync } from "node:fs";
10
- import { dirname, join, resolve } from "node:path";
10
+ import { dirname, join, resolve, sep } from "node:path";
11
11
  import { mkdir, readFile, rename, rm, unlink, writeFile } from "node:fs/promises";
12
12
  import { Readable } from "node:stream";
13
13
  import { signal } from "@pyreon/reactivity";
@@ -1450,9 +1450,12 @@ const SSR_ENTRY_FILENAME = "__pyreon-zero-ssg-entry.js";
1450
1450
  function expandUrlPattern(pattern, params) {
1451
1451
  return pattern.split("/").map((seg) => {
1452
1452
  if (!seg.startsWith(":")) return seg;
1453
- const name = seg.endsWith("*") ? seg.slice(1, -1) : seg.slice(1);
1453
+ const isCatchAll = seg.endsWith("*");
1454
+ const name = isCatchAll ? seg.slice(1, -1) : seg.slice(1);
1454
1455
  const value = params[name];
1455
1456
  if (value === void 0 || value === "") throw new Error(`[zero:ssg] getStaticPaths for "${pattern}" returned params without "${name}"`);
1457
+ const segs = isCatchAll ? value.split("/") : [value];
1458
+ if (!isCatchAll && value.includes("/") || segs.some((s) => s === "." || s === "..")) throw new Error(`[zero:ssg] getStaticPaths for "${pattern}" produced an unsafe "${name}" value (${JSON.stringify(value)}): a ${isCatchAll ? "catch-all" : "dynamic"} segment must not contain path-traversal ("." / "..")${isCatchAll ? "" : " or \"/\""}.`);
1456
1459
  return value;
1457
1460
  }).join("/");
1458
1461
  }
@@ -1493,7 +1496,8 @@ async function autoDetectStaticPaths(routesDir, registry, errors = [], i18n) {
1493
1496
  });
1494
1497
  }
1495
1498
  }
1496
- return out.length > 0 ? out : ["/"];
1499
+ const deduped = [...new Set(out)];
1500
+ return deduped.length > 0 ? deduped : ["/"];
1497
1501
  }
1498
1502
  async function resolvePaths(config, routesDir, registry, errors = []) {
1499
1503
  const explicit = config.ssg?.paths;
@@ -1615,6 +1619,20 @@ function resolveOutputPath(distDir, path) {
1615
1619
  return join(distDir, path, "index.html");
1616
1620
  }
1617
1621
  /**
1622
+ * Path-containment check that is SEPARATOR-TERMINATED. A bare
1623
+ * `resolve(filePath).startsWith(resolve(distDir))` is a string-prefix
1624
+ * test, not a path test: with distDir `/app/dist`, a traversed filePath
1625
+ * resolving to the SIBLING `/app/dist-evil/x` passes
1626
+ * `'/app/dist-evil/x'.startsWith('/app/dist')` → true and the build
1627
+ * writes outside the intended output root. `path` derives from caller
1628
+ * route params (CMS slugs via `getStaticPaths`), so this is reachable.
1629
+ */
1630
+ function isInsideDist(distDir, filePath) {
1631
+ const root = resolve(distDir);
1632
+ const target = resolve(filePath);
1633
+ return target === root || target.startsWith(root + sep);
1634
+ }
1635
+ /**
1618
1636
  * Render Netlify / Cloudflare Pages `_redirects` file content. One line
1619
1637
  * per redirect, format: `<from> <to> <status>`. Both platforms parse this
1620
1638
  * format identically; Vercel ignores it (use the JSON below). Lines with
@@ -1925,8 +1943,7 @@ function ssgPlugin(userConfig = {}) {
1925
1943
  });
1926
1944
  if (config.ssg?.redirectsAsHtml === "meta-refresh") {
1927
1945
  const filePath = resolveOutputPath(distDir, p);
1928
- const resolvedOut = resolve(distDir);
1929
- if (!resolve(filePath).startsWith(resolvedOut)) {
1946
+ if (!isInsideDist(distDir, filePath)) {
1930
1947
  errors.push({
1931
1948
  path: p,
1932
1949
  error: /* @__PURE__ */ new Error(`Path traversal detected: "${p}"`)
@@ -1940,8 +1957,7 @@ function ssgPlugin(userConfig = {}) {
1940
1957
  }
1941
1958
  const html = injectIntoTemplate(template, result);
1942
1959
  const filePath = resolveOutputPath(distDir, p);
1943
- const resolvedOut = resolve(distDir);
1944
- if (!resolve(filePath).startsWith(resolvedOut)) {
1960
+ if (!isInsideDist(distDir, filePath)) {
1945
1961
  errors.push({
1946
1962
  path: p,
1947
1963
  error: /* @__PURE__ */ new Error(`Path traversal detected: "${p}"`)
@@ -1963,8 +1979,7 @@ function ssgPlugin(userConfig = {}) {
1963
1979
  const fallbackHtml = await config.ssg.onPathError(p, error);
1964
1980
  if (typeof fallbackHtml === "string") {
1965
1981
  const filePath = resolveOutputPath(distDir, p);
1966
- const resolvedOut = resolve(distDir);
1967
- if (!resolve(filePath).startsWith(resolvedOut)) {
1982
+ if (!isInsideDist(distDir, filePath)) {
1968
1983
  errors.push({
1969
1984
  path: p,
1970
1985
  error: /* @__PURE__ */ new Error(`Path traversal detected: "${p}"`)
@@ -2073,6 +2088,7 @@ function ssgPlugin(userConfig = {}) {
2073
2088
  //#endregion
2074
2089
  //#region src/vite-plugin.ts
2075
2090
  var vite_plugin_exports = /* @__PURE__ */ __exportAll({
2091
+ argvHasPortFlag: () => argvHasPortFlag,
2076
2092
  getZeroPluginConfig: () => getZeroPluginConfig,
2077
2093
  zeroPlugin: () => zeroPlugin
2078
2094
  });
@@ -2129,6 +2145,25 @@ function getZeroPluginConfig(plugin) {
2129
2145
  return zeroPluginConfigMap.get(plugin);
2130
2146
  }
2131
2147
  /**
2148
+ * Detects `--port` / `--port=N` / `-p N` / `-p=N` in `process.argv`.
2149
+ * Used by the plugin's `config()` hook to decide whether to apply the
2150
+ * default port — when the CLI was invoked with `--port`, the plugin
2151
+ * must skip its default so the CLI flag wins (see the comment at the
2152
+ * port-handling block in `zeroPlugin()` for the full precedence model).
2153
+ *
2154
+ * Exported for testing only (the plugin uses it internally).
2155
+ *
2156
+ * @internal
2157
+ */
2158
+ function argvHasPortFlag(argv = process.argv) {
2159
+ for (let i = 0; i < argv.length; i++) {
2160
+ const a = argv[i];
2161
+ if (a === "--port" || a === "-p") return true;
2162
+ if (a !== void 0 && (a.startsWith("--port=") || a.startsWith("-p="))) return true;
2163
+ }
2164
+ return false;
2165
+ }
2166
+ /**
2132
2167
  * Zero Vite plugin — adds file-based routing and zero-config conventions
2133
2168
  * on top of @pyreon/vite-plugin.
2134
2169
  *
@@ -2160,7 +2195,9 @@ function zeroPlugin(userConfig = {}) {
2160
2195
  async load(id) {
2161
2196
  if (id === RESOLVED_VIRTUAL_ROUTES_ID) try {
2162
2197
  const baseRoutes = await scanRouteFilesWithExports(routesDir, config.mode);
2163
- return generateRouteModuleFromRoutes(config.i18n ? expandRoutesForLocales(baseRoutes, config.i18n) : baseRoutes, routesDir, { staticImports: config.mode === "ssg" });
2198
+ const routes = config.i18n ? expandRoutesForLocales(baseRoutes, config.i18n) : baseRoutes;
2199
+ const ssgSplitDisabled = config.mode === "ssg" && config.ssg?.splitChunks === false;
2200
+ return generateRouteModuleFromRoutes(routes, routesDir, { staticImports: ssgSplitDisabled });
2164
2201
  } catch (_err) {
2165
2202
  return `export const routes = []`;
2166
2203
  }
@@ -2289,7 +2326,7 @@ function zeroPlugin(userConfig = {}) {
2289
2326
  ...runtimeServerAlias ? { alias: { "@pyreon/runtime-server": runtimeServerAlias } } : {}
2290
2327
  } },
2291
2328
  optimizeDeps: { exclude: pyreonExclude },
2292
- ...userConfig.port !== void 0 ? { server: { port: config.port } } : {},
2329
+ ...userConfig.port === void 0 && argvHasPortFlag() ? {} : { server: { port: config.port } },
2293
2330
  base: config.base,
2294
2331
  define: {
2295
2332
  __ZERO_MODE__: JSON.stringify(config.mode),
@@ -2451,4 +2488,4 @@ function flattenRoutePatterns(routes) {
2451
2488
 
2452
2489
  //#endregion
2453
2490
  export { render404Page as _, detectLocaleFromHeader as a, vercelAdapter as c, netlifyAdapter as d, cloudflareAdapter as f, createServer as g, resolveConfig as h, createLocaleContext as i, staticAdapter as l, defineConfig as m, vite_plugin_exports as n, i18nRouting as o, bunAdapter as p, zeroPlugin as r, resolveAdapter as s, getZeroPluginConfig as t, nodeAdapter as u, createApp as v };
2454
- //# sourceMappingURL=vite-plugin-xjWZwudX.js.map
2491
+ //# sourceMappingURL=vite-plugin-8TXXFqdP.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/zero",
3
- "version": "0.16.0",
3
+ "version": "0.19.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",
@@ -168,15 +168,15 @@
168
168
  "lint": "oxlint ."
169
169
  },
170
170
  "dependencies": {
171
- "@pyreon/core": "^0.16.0",
172
- "@pyreon/head": "^0.16.0",
173
- "@pyreon/meta": "^0.16.0",
174
- "@pyreon/reactivity": "^0.16.0",
175
- "@pyreon/router": "^0.16.0",
176
- "@pyreon/runtime-dom": "^0.16.0",
177
- "@pyreon/runtime-server": "^0.16.0",
178
- "@pyreon/server": "^0.16.0",
179
- "@pyreon/vite-plugin": "^0.16.0",
171
+ "@pyreon/core": "^0.19.0",
172
+ "@pyreon/head": "^0.19.0",
173
+ "@pyreon/meta": "^0.19.0",
174
+ "@pyreon/reactivity": "^0.19.0",
175
+ "@pyreon/router": "^0.19.0",
176
+ "@pyreon/runtime-dom": "^0.19.0",
177
+ "@pyreon/runtime-server": "^0.19.0",
178
+ "@pyreon/server": "^0.19.0",
179
+ "@pyreon/vite-plugin": "^0.19.0",
180
180
  "vite": "^8.0.0"
181
181
  },
182
182
  "devDependencies": {
package/src/api-routes.ts CHANGED
@@ -52,6 +52,15 @@ export function matchApiRoute(pattern: string, path: string): Record<string, str
52
52
  const pathParts = path.split('/').filter(Boolean)
53
53
  const params: Record<string, string> = {}
54
54
 
55
+ // A param NAME comes from the route pattern (file-based route like
56
+ // `[__proto__].ts`) — developer-controlled, so this is defense-in-depth
57
+ // rather than an attacker vector, but assigning `params['__proto__'] =
58
+ // …` still mutates the result object's prototype instead of setting a
59
+ // param. Skip the dangerous names (consistent with reconcile / i18n
60
+ // deepMerge guards) so a stray route name can't pollute.
61
+ const isUnsafeParam = (name: string): boolean =>
62
+ name === '__proto__' || name === 'constructor' || name === 'prototype'
63
+
55
64
  for (let i = 0; i < patternParts.length; i++) {
56
65
  const pp = patternParts[i]
57
66
  if (!pp) continue
@@ -59,7 +68,7 @@ export function matchApiRoute(pattern: string, path: string): Record<string, str
59
68
  // Catch-all: :param*
60
69
  if (pp.endsWith('*')) {
61
70
  const paramName = pp.slice(1, -1)
62
- params[paramName] = pathParts.slice(i).join('/')
71
+ if (!isUnsafeParam(paramName)) params[paramName] = pathParts.slice(i).join('/')
63
72
  return params
64
73
  }
65
74
 
@@ -68,7 +77,8 @@ export function matchApiRoute(pattern: string, path: string): Record<string, str
68
77
 
69
78
  // Dynamic segment: :param
70
79
  if (pp.startsWith(':')) {
71
- params[pp.slice(1)] = pathParts[i]!
80
+ const paramName = pp.slice(1)
81
+ if (!isUnsafeParam(paramName)) params[paramName] = pathParts[i]!
72
82
  continue
73
83
  }
74
84
 
package/src/fs-router.ts CHANGED
@@ -1100,7 +1100,13 @@ export function generateRouteModuleFromRoutes(
1100
1100
  const opts: string[] = []
1101
1101
  if (loadingName) opts.push(`loading: ${loadingName}`)
1102
1102
  if (errorName) opts.push(`error: ${errorName}`)
1103
- const optsStr = opts.length > 0 ? `, { ${opts.join(', ')} }` : ''
1103
+ // `hmrId` lets `@pyreon/router`'s dev HMR coordinator map a
1104
+ // hot-updated module back to its route record(s) for an in-place
1105
+ // component swap (no page reload, signals preserved). Inert in
1106
+ // production — the coordinator is only registered in a dev browser,
1107
+ // so `_hmrId` is dead metadata once built.
1108
+ opts.push(`hmrId: ${JSON.stringify(fullPath)}`)
1109
+ const optsStr = `, { ${opts.join(', ')} }`
1104
1110
  imports.push(`const ${name} = lazy(() => import("${fullPath}")${optsStr})`)
1105
1111
  return name
1106
1112
  }
package/src/icon.tsx ADDED
@@ -0,0 +1,182 @@
1
+ import type { PyreonHTMLAttributes, SvgAttributes, VNodeChild } from '@pyreon/core'
2
+ import { splitProps } from '@pyreon/core'
3
+
4
+ // ─── Icon ───────────────────────────────────────────────────────────────────
5
+ //
6
+ // Renders a FULL, already-complete SVG that you loaded — it does NOT
7
+ // synthesize its own <svg> wrapper around hand-authored <path> children.
8
+ // You load an svg (it contains the <svg> root itself); Icon renders it and
9
+ // makes it container-sizable + theme-aware.
10
+ //
11
+ // Two ways to hand it the loaded svg (you chose: support both):
12
+ // • `as` — an imported SVG *component* (`import X from './x.svg?component'`).
13
+ // Rendered directly — NO host wrapper. Recommended form: it's a
14
+ // real <svg> element, so container-fill is reliable.
15
+ // • `svg` — the raw `<svg>…</svg>` *markup string*
16
+ // (`import x from './x.svg?raw'`). Inlined via a single `<span>`
17
+ // host (a markup string can't mount without a parent element —
18
+ // this one host is unavoidable for the string form).
19
+ //
20
+ // Either way:
21
+ // • Container-filling defaults (`fill="currentColor"`,
22
+ // `display:block;width:100%;height:100%`) — every consumer prop spreads
23
+ // through and OVERRIDES them (`style`, `class`, `fill`, `aria-*`, …).
24
+ // • No fixed size → it fills its container; the consumer's wrapper
25
+ // (`<span style="width:2rem"><Icon/></span>`, a flex/grid cell,
26
+ // `font-size`) controls the size.
27
+ // • `fill="currentColor"` → CSS `color` themes it (dark mode for free).
28
+ //
29
+ // Two layers (mirrors createLink/Link, createImage/Image):
30
+ // 1. createIcon(source) — factory: one component per loaded glyph
31
+ // 2. Icon — generic shell for a one-off loaded svg
32
+ //
33
+ // There is intentionally no `useIcon` — an icon has no composable behaviour
34
+ // (no async, no state, no router). A hook layer would be surface for its
35
+ // own sake.
36
+
37
+ const FILL_STYLE = 'display:block;width:100%;height:100%'
38
+
39
+ /** An imported SVG component (`import X from './x.svg?component'`). */
40
+ export type SvgComponent = (props: SvgAttributes) => VNodeChild
41
+
42
+ /**
43
+ * Props for {@link Icon}. The standard `<svg>` attribute surface
44
+ * (`fill`, `class`, `style`, `aria-*`, `onClick`, …) — every one passed
45
+ * straight through and overriding the container-fill defaults — plus the
46
+ * two source props.
47
+ */
48
+ export interface IconProps extends SvgAttributes {
49
+ /**
50
+ * An imported SVG component, e.g. `import X from './icon.svg?component'`.
51
+ * Rendered directly with no host wrapper. Recommended over `svg`.
52
+ */
53
+ as?: SvgComponent | undefined
54
+ /**
55
+ * A full `<svg>…</svg>` markup string, e.g.
56
+ * `import x from './icon.svg?raw'`. Inlined inside a single `<span>` host.
57
+ */
58
+ svg?: string | undefined
59
+ }
60
+
61
+ /**
62
+ * Render a loaded SVG — container-filling, theme-aware, props-transparent.
63
+ *
64
+ * @example
65
+ * import Check from './check.svg?component'
66
+ * <span style="width:2rem"><Icon as={Check} /></span>
67
+ *
68
+ * @example
69
+ * import check from './check.svg?raw'
70
+ * <span style="width:2rem"><Icon svg={check} /></span>
71
+ */
72
+ export function Icon(props: IconProps): VNodeChild {
73
+ const [own, rest] = splitProps(props, ['as', 'svg'])
74
+
75
+ // Component form — render the imported SVG directly, no host wrapper.
76
+ // Defaults first so consumer `rest` (spread) overrides them; JSX spread
77
+ // is reactivity-safe (compiler wraps it with `_wrapSpread`).
78
+ if (own.as) {
79
+ const As = own.as
80
+ return <As fill="currentColor" style={FILL_STYLE} {...rest} />
81
+ }
82
+
83
+ // Raw-markup form — the string already contains its own <svg>, so we
84
+ // inline it via a single <span> host. `dangerouslySetInnerHTML` last so
85
+ // it can't be clobbered by a stray spread key.
86
+ if (own.svg) {
87
+ // svg-only props (`fill`, `viewBox`, …) are inapplicable to the host
88
+ // span AND can't reach the opaque inlined markup — only host-level
89
+ // attrs (`class`, `style`, `aria-*`, events) are meaningfully
90
+ // forwardable here. Narrow the spread to the host's real surface.
91
+ const hostRest = rest as unknown as PyreonHTMLAttributes<HTMLElement>
92
+ return (
93
+ <span
94
+ style={FILL_STYLE}
95
+ {...hostRest}
96
+ dangerouslySetInnerHTML={{ __html: own.svg }}
97
+ />
98
+ )
99
+ }
100
+
101
+ return null
102
+ }
103
+
104
+ /**
105
+ * Build a reusable icon component from a loaded svg — a markup string OR an
106
+ * imported SVG component. The result is still just `<Icon>`, so it's
107
+ * container-sizable + theme-aware with every prop passed through.
108
+ *
109
+ * @example
110
+ * import check from './check.svg?raw'
111
+ * export const Check = createIcon(check)
112
+ *
113
+ * import StarSvg from './star.svg?component'
114
+ * export const Star = createIcon(StarSvg)
115
+ *
116
+ * // …sized + themed entirely by the consumer:
117
+ * <span style="width:48px"><Check class="text-green-600" /></span>
118
+ */
119
+ export function createIcon(
120
+ source: string | SvgComponent,
121
+ ): (props: SvgAttributes) => VNodeChild {
122
+ return (props: SvgAttributes) =>
123
+ typeof source === 'string' ? (
124
+ <Icon svg={source} {...props} />
125
+ ) : (
126
+ <Icon as={source} {...props} />
127
+ )
128
+ }
129
+
130
+ // ─── createNamedIcon — typed icon-set runtime ────────────────────────────────
131
+ //
132
+ // The runtime half of `iconsPlugin`. The plugin scans a folder and writes a
133
+ // generated file that calls this with a registry literal; `keyof typeof
134
+ // REGISTRY` makes `name` a strict union (autocompletes, rejects typos) and
135
+ // gives real go-to-definition — zero per-app wiring.
136
+
137
+ /** How a named icon set renders each entry. */
138
+ export type IconMode = 'inline' | 'image'
139
+
140
+ /** Props of a component built by {@link createNamedIcon}. */
141
+ export type NamedIconProps<R extends Record<string, string>> = {
142
+ /** A name from the scanned set — strictly typed to the available files. */
143
+ name: keyof R & string
144
+ /** `<img>` alt text (image mode). Defaults to `""` (decorative). */
145
+ alt?: string
146
+ } & Omit<IconProps, 'as' | 'svg'>
147
+
148
+ /**
149
+ * Build a strictly-typed `<Icon name="…" />` from a name→source registry.
150
+ *
151
+ * - `mode: 'inline'` (default) — `source` is raw `<svg>` markup; rendered via
152
+ * {@link Icon} so it's `currentColor`-themeable (system icons you recolor).
153
+ * - `mode: 'image'` — `source` is an asset URL; rendered as `<img>` with NO
154
+ * svg mutation, original colors preserved (colorful / brand icons).
155
+ *
156
+ * Either way it stays container-filling + props-transparent. Not called by
157
+ * hand normally — `iconsPlugin` emits the generated file that calls it.
158
+ *
159
+ * @example
160
+ * // icons.gen.tsx (auto-generated):
161
+ * export const Icon = createNamedIcon({ 'check-circle': '<svg…' })
162
+ * // app:
163
+ * <span style="width:2rem"><Icon name="check-circle" /></span>
164
+ */
165
+ export function createNamedIcon<R extends Record<string, string>>(
166
+ registry: R,
167
+ options: { mode?: IconMode } = {},
168
+ ): (props: NamedIconProps<R>) => VNodeChild {
169
+ const mode = options.mode ?? 'inline'
170
+ return (props: NamedIconProps<R>) => {
171
+ const [own, rest] = splitProps(props, ['name', 'alt'])
172
+ const source = registry[own.name]
173
+ if (mode === 'image') {
174
+ // svg-only props can't apply to an <img>; only host attrs forward.
175
+ const hostRest = rest as unknown as PyreonHTMLAttributes<HTMLImageElement>
176
+ return (
177
+ <img src={source} alt={own.alt ?? ''} style={FILL_STYLE} {...hostRest} />
178
+ )
179
+ }
180
+ return <Icon svg={source} {...rest} />
181
+ }
182
+ }