@pyreon/zero 0.18.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
@@ -1106,6 +1115,83 @@ declare function faviconLinks(locale: string | undefined, config: FaviconPluginC
1106
1115
  href: string;
1107
1116
  }>;
1108
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
1109
1195
  //#region src/seo.d.ts
1110
1196
  interface SitemapConfig {
1111
1197
  /** Base URL of the site (required). e.g. "https://example.com" */
@@ -1499,5 +1585,5 @@ declare function inferJsonLd(options: InferJsonLdOptions): Record<string, unknow
1499
1585
  */
1500
1586
  declare function aiPlugin(config: AiPluginConfig): Plugin;
1501
1587
  //#endregion
1502
- 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 };
1503
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}"`)
@@ -2473,4 +2488,4 @@ function flattenRoutePatterns(routes) {
2473
2488
 
2474
2489
  //#endregion
2475
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 };
2476
- //# sourceMappingURL=vite-plugin-y0NmCLJA.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.18.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.18.0",
172
- "@pyreon/head": "^0.18.0",
173
- "@pyreon/meta": "^0.18.0",
174
- "@pyreon/reactivity": "^0.18.0",
175
- "@pyreon/router": "^0.18.0",
176
- "@pyreon/runtime-dom": "^0.18.0",
177
- "@pyreon/runtime-server": "^0.18.0",
178
- "@pyreon/server": "^0.18.0",
179
- "@pyreon/vite-plugin": "^0.18.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
+ }