@pyreon/zero 0.18.0 → 0.20.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.
@@ -16,17 +16,19 @@ function matchApiRoute(pattern, path) {
16
16
  const patternParts = pattern.split("/").filter(Boolean);
17
17
  const pathParts = path.split("/").filter(Boolean);
18
18
  const params = {};
19
+ const isUnsafeParam = (name) => name === "__proto__" || name === "constructor" || name === "prototype";
19
20
  for (let i = 0; i < patternParts.length; i++) {
20
21
  const pp = patternParts[i];
21
22
  if (!pp) continue;
22
23
  if (pp.endsWith("*")) {
23
24
  const paramName = pp.slice(1, -1);
24
- params[paramName] = pathParts.slice(i).join("/");
25
+ if (!isUnsafeParam(paramName)) params[paramName] = pathParts.slice(i).join("/");
25
26
  return params;
26
27
  }
27
28
  if (i >= pathParts.length) return null;
28
29
  if (pp.startsWith(":")) {
29
- params[pp.slice(1)] = pathParts[i];
30
+ const paramName = pp.slice(1);
31
+ if (!isUnsafeParam(paramName)) params[paramName] = pathParts[i];
30
32
  continue;
31
33
  }
32
34
  if (pp !== pathParts[i]) return null;
@@ -143,4 +145,4 @@ function generateApiRouteModule(files, routesDir) {
143
145
 
144
146
  //#endregion
145
147
  export { matchApiRoute as i, createApiMiddleware as n, generateApiRouteModule as r, api_routes_exports as t };
146
- //# sourceMappingURL=api-routes-Ci0kVmM4.js.map
148
+ //# sourceMappingURL=api-routes-CMsLztoj.js.map
package/lib/api-routes.js CHANGED
@@ -7,17 +7,19 @@ function matchApiRoute(pattern, path) {
7
7
  const patternParts = pattern.split("/").filter(Boolean);
8
8
  const pathParts = path.split("/").filter(Boolean);
9
9
  const params = {};
10
+ const isUnsafeParam = (name) => name === "__proto__" || name === "constructor" || name === "prototype";
10
11
  for (let i = 0; i < patternParts.length; i++) {
11
12
  const pp = patternParts[i];
12
13
  if (!pp) continue;
13
14
  if (pp.endsWith("*")) {
14
15
  const paramName = pp.slice(1, -1);
15
- params[paramName] = pathParts.slice(i).join("/");
16
+ if (!isUnsafeParam(paramName)) params[paramName] = pathParts.slice(i).join("/");
16
17
  return params;
17
18
  }
18
19
  if (i >= pathParts.length) return null;
19
20
  if (pp.startsWith(":")) {
20
- params[pp.slice(1)] = pathParts[i];
21
+ const paramName = pp.slice(1);
22
+ if (!isUnsafeParam(paramName)) params[paramName] = pathParts[i];
21
23
  continue;
22
24
  }
23
25
  if (pp !== pathParts[i]) return null;
package/lib/favicon.js CHANGED
@@ -1,8 +1,42 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
 
5
5
  //#region src/favicon.ts
6
+ /**
7
+ * Stable content hash (FNV-1a, 32-bit) of the favicon source file(s),
8
+ * rendered as a `?v=<hex>` cache-bust query for the injected `<head>`
9
+ * links. Browsers cache favicons extremely aggressively (often per-
10
+ * session / effectively forever), so with a stable URL a changed icon
11
+ * is never re-fetched by returning visitors. Same source bytes →
12
+ * identical query (no needless cache churn); changed bytes → new query
13
+ * → browser re-downloads. Falls back to `''` (no query, prior
14
+ * behaviour) if a source can't be read — never break the build over a
15
+ * cache-bust nicety. NOTE: this versions everything referenced via
16
+ * `<link>` (svg/png/apple-touch/manifest). The bare `/favicon.ico`
17
+ * convention request (browsers fetch it with no link tag) and the
18
+ * `site.webmanifest`'s internal icon entries keep stable URLs — those
19
+ * rely on host cache headers / are re-resolved on PWA (re)install.
20
+ */
21
+ function faviconVersionQuery(paths) {
22
+ let h = 2166136261;
23
+ let any = false;
24
+ for (const p of paths) {
25
+ let buf;
26
+ try {
27
+ buf = readFileSync(p);
28
+ } catch {
29
+ continue;
30
+ }
31
+ any = true;
32
+ for (let i = 0; i < buf.length; i++) {
33
+ h ^= buf[i];
34
+ h = Math.imul(h, 16777619);
35
+ }
36
+ }
37
+ if (!any) return "";
38
+ return `?v=${(h >>> 0).toString(16).padStart(8, "0")}`;
39
+ }
6
40
  let sharpWarned = false;
7
41
  function warnSharpMissing() {
8
42
  if (sharpWarned) return;
@@ -53,6 +87,15 @@ function faviconPlugin(config) {
53
87
  const generateManifest = config.manifest !== false;
54
88
  let root = "";
55
89
  let isBuild = false;
90
+ let versionQuery = null;
91
+ function getVersionQuery() {
92
+ if (versionQuery === null) {
93
+ const paths = [join(root, config.source)];
94
+ if (config.darkSource) paths.push(join(root, config.darkSource));
95
+ versionQuery = faviconVersionQuery(paths);
96
+ }
97
+ return versionQuery;
98
+ }
56
99
  return {
57
100
  name: "pyreon-zero-favicon",
58
101
  enforce: "pre",
@@ -73,7 +116,7 @@ function faviconPlugin(config) {
73
116
  return defaultSource;
74
117
  }
75
118
  server.middlewares.use(async (req, res, next) => {
76
- const url = req.url ?? "";
119
+ const url = (req.url ?? "").split("?")[0];
77
120
  const localeSource = resolveLocaleSource(url, config, root);
78
121
  const svgUrl = localeSource ? localeSource.url : url;
79
122
  const svgPath = localeSource ? localeSource.sourcePath : sourcePath;
@@ -277,10 +320,21 @@ function faviconPlugin(config) {
277
320
  injectTo: "head",
278
321
  children: `(function(){try{var t=localStorage.getItem("zero-theme");var r=t==="light"?"light":t==="dark"?"dark":window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light";document.querySelectorAll("[data-favicon-theme]").forEach(function(l){l.media=l.dataset.faviconTheme===r?"":"not all"})}catch(e){}})()`
279
322
  });
323
+ const v = getVersionQuery();
324
+ if (v) {
325
+ for (const t of tags) if (t.tag === "link" && t.attrs.href) t.attrs.href += v;
326
+ }
280
327
  return tags;
281
328
  },
282
329
  async generateBundle() {
283
330
  if (!isBuild) return;
331
+ try {
332
+ await import("sharp");
333
+ } catch {
334
+ this.error(`[Pyreon] faviconPlugin: a favicon \`source\` is configured but \`sharp\` is not installed — NO favicons would be generated and the production build would silently ship none.
335
+ Fix: bun add -D sharp (or: npm i -D sharp)
336
+ Source: ${config.source}\nTo intentionally build without favicons, remove faviconPlugin() from your Vite plugins.`);
337
+ }
284
338
  await generateFaviconSet.call(this, root, config.source, config.darkSource, "", config, themeColor, backgroundColor, generateManifest);
285
339
  if (config.locales) for (const [locale, localeConfig] of Object.entries(config.locales)) await generateFaviconSet.call(this, root, localeConfig.source, localeConfig.darkSource, `${locale}/`, config, themeColor, backgroundColor, generateManifest);
286
340
  }
@@ -571,5 +625,5 @@ async function addDevBadgeToPng(pngBuffer, size) {
571
625
  }
572
626
 
573
627
  //#endregion
574
- export { createIcoFromPngs, faviconLinks, faviconPlugin };
628
+ export { createIcoFromPngs, faviconLinks, faviconPlugin, faviconVersionQuery };
575
629
  //# sourceMappingURL=favicon.js.map
@@ -750,7 +750,8 @@ function generateRouteModuleFromRoutes(routes, routesDir, options) {
750
750
  const opts = [];
751
751
  if (loadingName) opts.push(`loading: ${loadingName}`);
752
752
  if (errorName) opts.push(`error: ${errorName}`);
753
- const optsStr = opts.length > 0 ? `, { ${opts.join(", ")} }` : "";
753
+ opts.push(`hmrId: ${JSON.stringify(fullPath)}`);
754
+ const optsStr = `, { ${opts.join(", ")} }`;
754
755
  imports.push(`const ${name} = lazy(() => import("${fullPath}")${optsStr})`);
755
756
  return name;
756
757
  }
@@ -968,7 +969,7 @@ async function scanRouteFiles(routesDir) {
968
969
  */
969
970
  async function scanRouteFilesWithExports(routesDir, defaultMode = "ssr") {
970
971
  const { readFile } = await import("node:fs/promises");
971
- const { isApiRoute } = await import("./api-routes-Ci0kVmM4.js").then((n) => n.t);
972
+ const { isApiRoute } = await import("./api-routes-CMsLztoj.js").then((n) => n.t);
972
973
  const files = (await scanRouteFiles(routesDir)).filter((f) => !isApiRoute(f));
973
974
  const exportsMap = /* @__PURE__ */ new Map();
974
975
  await Promise.all(files.map(async (filePath) => {
@@ -984,4 +985,4 @@ async function scanRouteFilesWithExports(routesDir, defaultMode = "ssr") {
984
985
 
985
986
  //#endregion
986
987
  export { generateRouteModuleFromRoutes as a, scanRouteFilesWithExports as c, generateRouteModule as i, fs_router_exports as n, parseFileRoutes as o, generateMiddlewareModule as r, scanRouteFiles as s, filePathToUrlPath as t };
987
- //# sourceMappingURL=fs-router-MewHc5SB.js.map
988
+ //# sourceMappingURL=fs-router-Bacdhsq-.js.map
@@ -20,6 +20,13 @@ const cdnProviders = {
20
20
  /** Bunny CDN: `https://{pullZone}.b-cdn.net/...?width=...&quality=...` */
21
21
  bunny: (pullZone) => (src, { width, quality }) => `https://${pullZone}.b-cdn.net/${src}?width=${width}&quality=${quality}`
22
22
  };
23
+ /**
24
+ * Normalize the public {@link PlaceholderStrategy} to an internal kind.
25
+ * @internal Exported for testing.
26
+ */
27
+ function normalizePlaceholder(s) {
28
+ return s === "dominant-color" ? "color" : s;
29
+ }
23
30
  const IMAGE_EXT_RE = /\.(jpe?g|png|webp|avif)$/i;
24
31
  /**
25
32
  * Zero image processing Vite plugin.
@@ -52,9 +59,9 @@ function imagePlugin(config = {}) {
52
59
  1920
53
60
  ];
54
61
  const defaultFormats = config.formats ?? ["webp"];
55
- const quality = config.quality ?? 80;
62
+ const qualityFor = resolveQuality(config.quality);
56
63
  const placeholderSize = config.placeholderSize ?? 16;
57
- const placeholderStrategy = config.placeholder ?? "blur";
64
+ const placeholderStrategy = normalizePlaceholder(config.placeholder ?? "blur");
58
65
  const outSubDir = config.outDir ?? "assets/img";
59
66
  const include = config.include ?? IMAGE_EXT_RE;
60
67
  const cdn = config.cdn;
@@ -70,15 +77,22 @@ function imagePlugin(config = {}) {
70
77
  outDir = resolvedConfig.build.outDir;
71
78
  isBuild = resolvedConfig.command === "build";
72
79
  },
73
- async resolveId(id) {
74
- if (svgOpts && id.includes("?component") && id.split("?")[0].endsWith(".svg")) return `\0virtual:zero-svg:${id}`;
75
- if (id.includes("?optimize") && include.test(id.split("?")[0])) return `\0virtual:zero-image:${id}`;
76
- return null;
80
+ async resolveId(id, importer) {
81
+ const isSvgComponent = svgOpts && id.includes("?component") && id.split("?")[0].endsWith(".svg");
82
+ const isOptimize = id.includes("?optimize") && include.test(id.split("?")[0]);
83
+ if (!isSvgComponent && !isOptimize) return null;
84
+ const qIdx = id.indexOf("?");
85
+ const bare = qIdx === -1 ? id : id.slice(0, qIdx);
86
+ const query = qIdx === -1 ? "" : id.slice(qIdx);
87
+ const resolved = await this.resolve(bare, importer, { skipSelf: true });
88
+ const carried = resolved ? `${resolved.id}${query}` : id;
89
+ if (isSvgComponent) return `\0virtual:zero-svg:${carried}`;
90
+ return `\0virtual:zero-image:${carried}`;
77
91
  },
78
92
  async load(id) {
79
93
  if (id.startsWith("\0virtual:zero-svg:")) {
80
94
  const rawPath = id.replace("\0virtual:zero-svg:", "").split("?")[0] ?? id;
81
- const absPath = rawPath.startsWith("/") ? join(root, rawPath) : rawPath;
95
+ const absPath = existsSync(rawPath) ? rawPath : rawPath.startsWith("/") ? join(root, rawPath) : rawPath;
82
96
  if (!existsSync(absPath)) return null;
83
97
  let svg = await readFile(absPath, "utf-8");
84
98
  if (svgOpts && svgOpts.currentColor !== false) svg = svg.replace(/fill="(?!none)[^"]*"/g, "fill=\"currentColor\"").replace(/stroke="(?!none)[^"]*"/g, "stroke=\"currentColor\"");
@@ -104,13 +118,13 @@ export default function SvgComponent(props) {
104
118
  }
105
119
  if (!id.startsWith("\0virtual:zero-image:")) return null;
106
120
  const rawPath = id.replace("\0virtual:zero-image:", "").split("?")[0] ?? id;
107
- const absPath = rawPath.startsWith("/") ? join(root, "public", rawPath) : rawPath;
121
+ const absPath = existsSync(rawPath) ? rawPath : rawPath.startsWith("/") ? join(root, "public", rawPath) : rawPath;
108
122
  if (cdn) {
109
123
  const metadata = await getImageMetadata(absPath);
110
124
  const sources = defaultWidths.map((w) => ({
111
125
  src: cdn(rawPath, {
112
126
  width: w,
113
- quality,
127
+ quality: qualityFor(defaultFormats[0]),
114
128
  format: defaultFormats[0]
115
129
  }) ?? rawPath,
116
130
  width: w,
@@ -122,12 +136,12 @@ export default function SvgComponent(props) {
122
136
  srcset,
123
137
  width: metadata.width,
124
138
  height: metadata.height,
125
- placeholder: placeholderStrategy === "none" ? "" : await generateBlurPlaceholder(absPath, placeholderSize),
139
+ placeholder: await generatePlaceholder(absPath, placeholderStrategy, placeholderSize),
126
140
  formats: defaultFormats.map((fmt) => ({
127
141
  type: `image/${fmt}`,
128
142
  srcset: defaultWidths.map((w) => `${cdn(rawPath, {
129
143
  width: w,
130
- quality,
144
+ quality: qualityFor(fmt),
131
145
  format: fmt
132
146
  }) ?? rawPath} ${w}w`).join(", ")
133
147
  })),
@@ -136,13 +150,14 @@ export default function SvgComponent(props) {
136
150
  return `export default ${JSON.stringify(result)}`;
137
151
  }
138
152
  if (!isBuild) {
139
- const result = await loadDevImage(absPath, rawPath, placeholderSize);
153
+ const result = await loadDevImage(absPath, rawPath, placeholderStrategy, placeholderSize);
140
154
  return `export default ${JSON.stringify(result)}`;
141
155
  }
142
156
  const processed = await processImage(absPath, {
143
157
  widths: defaultWidths,
144
158
  formats: defaultFormats,
145
- quality,
159
+ qualityFor,
160
+ placeholderStrategy,
146
161
  placeholderSize,
147
162
  outSubDir,
148
163
  outDir: join(root, outDir)
@@ -153,7 +168,7 @@ export default function SvgComponent(props) {
153
168
  }
154
169
  };
155
170
  }
156
- async function loadDevImage(absPath, rawPath, placeholderSize) {
171
+ async function loadDevImage(absPath, rawPath, strategy, placeholderSize) {
157
172
  const metadata = await getImageMetadata(absPath);
158
173
  const publicPath = rawPath.startsWith("/") ? rawPath : `/@fs/${absPath}`;
159
174
  return {
@@ -161,7 +176,7 @@ async function loadDevImage(absPath, rawPath, placeholderSize) {
161
176
  srcset: "",
162
177
  width: metadata.width,
163
178
  height: metadata.height,
164
- placeholder: await generateBlurPlaceholder(absPath, placeholderSize),
179
+ placeholder: await generatePlaceholder(absPath, strategy, placeholderSize),
165
180
  formats: [],
166
181
  sources: [{
167
182
  src: publicPath,
@@ -208,7 +223,7 @@ async function processImage(absPath, opts) {
208
223
  for (const format of opts.formats) for (const targetWidth of opts.widths) {
209
224
  const width = Math.min(targetWidth, metadata.width);
210
225
  const outPath = join(processedDir, `${name}-${width}.${format}`);
211
- await resizeImage(absPath, outPath, width, format, opts.quality);
226
+ await resizeImage(absPath, outPath, width, format, opts.qualityFor(format));
212
227
  sources.push({
213
228
  src: outPath,
214
229
  width,
@@ -233,7 +248,7 @@ async function processImage(absPath, opts) {
233
248
  }));
234
249
  const fallbackFormat = formats[formats.length - 1];
235
250
  const fallbackSources = formatGroups.get([...formatGroups.keys()].pop());
236
- const placeholder = await generateBlurPlaceholder(absPath, opts.placeholderSize);
251
+ const placeholder = await generatePlaceholder(absPath, opts.placeholderStrategy, opts.placeholderSize);
237
252
  return {
238
253
  src: fallbackSources[fallbackSources.length - 1]?.src ?? absPath,
239
254
  srcset: fallbackFormat?.srcset ?? "",
@@ -351,10 +366,66 @@ async function generateBlurPlaceholder(input, size) {
351
366
  try {
352
367
  return `data:image/webp;base64,${(await (await import("sharp").then((m) => m.default ?? m))(input).resize(size, size, { fit: "inside" }).blur(2).webp({ quality: 20 }).toBuffer()).toString("base64")}`;
353
368
  } catch {
354
- return "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1' height='1'%3E%3C/svg%3E";
369
+ return TRANSPARENT_PLACEHOLDER;
370
+ }
371
+ }
372
+ /** 1×1 transparent SVG — the no-sharp fallback for every strategy. */
373
+ const TRANSPARENT_PLACEHOLDER = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1' height='1'%3E%3C/svg%3E";
374
+ const DEFAULT_QUALITY = 80;
375
+ /**
376
+ * Resolve the public {@link ImageQuality} config into a per-format lookup.
377
+ *
378
+ * - `undefined` → every format gets {@link DEFAULT_QUALITY}.
379
+ * - `number` → that number for every format (backward-compatible).
380
+ * - `Partial<Record<ImageFormat, number>>` → per-format; formats omitted
381
+ * from the map fall back to {@link DEFAULT_QUALITY}.
382
+ *
383
+ * @internal Exported for testing.
384
+ */
385
+ function resolveQuality(q) {
386
+ if (q === void 0) return () => DEFAULT_QUALITY;
387
+ if (typeof q === "number") return () => q;
388
+ return (format) => q[format] ?? DEFAULT_QUALITY;
389
+ }
390
+ /**
391
+ * Dispatch placeholder generation by strategy. Single source of truth used
392
+ * by every code path (CDN / dev / build) — pre-fix each path open-coded
393
+ * `generateBlurPlaceholder`, so `'none'` was honoured only in the CDN path
394
+ * and `'dominant-color'` (typed since the plugin's inception) was never
395
+ * implemented anywhere — the exact typed-but-unimplemented bug class the
396
+ * `audit-types` gate exists to catch.
397
+ *
398
+ * @internal Exported for testing.
399
+ */
400
+ async function generatePlaceholder(input, strategy, size) {
401
+ if (strategy === "none") return "";
402
+ if (strategy === "color") return generateColorPlaceholder(input);
403
+ return generateBlurPlaceholder(input, size);
404
+ }
405
+ /**
406
+ * Generate a dominant-colour placeholder: a ~200-byte flat-fill SVG data URI.
407
+ *
408
+ * Uses sharp's `.stats()` `dominant` swatch — a histogram-binned colour,
409
+ * not a naive average (averaging a photo trends muddy grey). Note the
410
+ * swatch is approximate by design: a pure-red source resolves to ~#f80808,
411
+ * not #ff0000. The SVG is a constant ~200 bytes regardless of source
412
+ * complexity and needs zero image decode, at the cost of showing a solid
413
+ * colour instead of a blurry preview of the content.
414
+ */
415
+ async function generateColorPlaceholder(input) {
416
+ try {
417
+ const { dominant } = await (await import("sharp").then((m) => m.default ?? m))(input).stats();
418
+ const svg = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1' preserveAspectRatio='none'><rect width='1' height='1' fill='${"#" + [
419
+ dominant.r,
420
+ dominant.g,
421
+ dominant.b
422
+ ].map((c) => Math.max(0, Math.min(255, c)).toString(16).padStart(2, "0")).join("")}'/></svg>`;
423
+ return `data:image/svg+xml,${encodeURIComponent(svg)}`;
424
+ } catch {
425
+ return TRANSPARENT_PLACEHOLDER;
355
426
  }
356
427
  }
357
428
 
358
429
  //#endregion
359
- export { cdnProviders, imagePlugin, parseJpegDimensions, parseWebPDimensions };
430
+ export { cdnProviders, generatePlaceholder, imagePlugin, normalizePlaceholder, parseJpegDimensions, parseWebPDimensions, resolveQuality };
360
431
  //# sourceMappingURL=image-plugin.js.map
package/lib/index.js CHANGED
@@ -1,9 +1,102 @@
1
- import { createContext, createRef, onMount, onUnmount } from "@pyreon/core";
2
- import { effect, signal } from "@pyreon/reactivity";
1
+ import { createContext, createRef, onMount, onUnmount, splitProps } from "@pyreon/core";
3
2
  import { jsx, jsxs } from "@pyreon/core/jsx-runtime";
3
+ import { effect, signal } from "@pyreon/reactivity";
4
4
  import { useRouter } from "@pyreon/router";
5
5
  import { useHead } from "@pyreon/head";
6
6
 
7
+ //#region src/icon.tsx
8
+ const FILL_STYLE = "display:block;width:100%;height:100%";
9
+ /**
10
+ * Render a loaded SVG — container-filling, theme-aware, props-transparent.
11
+ *
12
+ * @example
13
+ * import Check from './check.svg?component'
14
+ * <span style="width:2rem"><Icon as={Check} /></span>
15
+ *
16
+ * @example
17
+ * import check from './check.svg?raw'
18
+ * <span style="width:2rem"><Icon svg={check} /></span>
19
+ */
20
+ function Icon(props) {
21
+ const [own, rest] = splitProps(props, ["as", "svg"]);
22
+ if (own.as) {
23
+ const As = own.as;
24
+ return /* @__PURE__ */ jsx(As, {
25
+ fill: "currentColor",
26
+ style: FILL_STYLE,
27
+ ...rest
28
+ });
29
+ }
30
+ if (own.svg) return /* @__PURE__ */ jsx("span", {
31
+ style: FILL_STYLE,
32
+ ...rest,
33
+ dangerouslySetInnerHTML: { __html: own.svg }
34
+ });
35
+ return null;
36
+ }
37
+ /**
38
+ * Build a reusable icon component from a loaded svg — a markup string OR an
39
+ * imported SVG component. The result is still just `<Icon>`, so it's
40
+ * container-sizable + theme-aware with every prop passed through.
41
+ *
42
+ * @example
43
+ * import check from './check.svg?raw'
44
+ * export const Check = createIcon(check)
45
+ *
46
+ * import StarSvg from './star.svg?component'
47
+ * export const Star = createIcon(StarSvg)
48
+ *
49
+ * // …sized + themed entirely by the consumer:
50
+ * <span style="width:48px"><Check class="text-green-600" /></span>
51
+ */
52
+ function createIcon(source) {
53
+ return (props) => typeof source === "string" ? /* @__PURE__ */ jsx(Icon, {
54
+ svg: source,
55
+ ...props
56
+ }) : /* @__PURE__ */ jsx(Icon, {
57
+ as: source,
58
+ ...props
59
+ });
60
+ }
61
+ /**
62
+ * Build a strictly-typed `<Icon name="…" />` from a name→source registry.
63
+ *
64
+ * - `mode: 'inline'` (default) — `source` is raw `<svg>` markup; rendered via
65
+ * {@link Icon} so it's `currentColor`-themeable (system icons you recolor).
66
+ * - `mode: 'image'` — `source` is an asset URL; rendered as `<img>` with NO
67
+ * svg mutation, original colors preserved (colorful / brand icons).
68
+ *
69
+ * Either way it stays container-filling + props-transparent. Not called by
70
+ * hand normally — `iconsPlugin` emits the generated file that calls it.
71
+ *
72
+ * @example
73
+ * // icons.gen.tsx (auto-generated):
74
+ * export const Icon = createNamedIcon({ 'check-circle': '<svg…' })
75
+ * // app:
76
+ * <span style="width:2rem"><Icon name="check-circle" /></span>
77
+ */
78
+ function createNamedIcon(registry, options = {}) {
79
+ const mode = options.mode ?? "inline";
80
+ return (props) => {
81
+ const [own, rest] = splitProps(props, ["name", "alt"]);
82
+ const source = registry[own.name];
83
+ if (mode === "image") {
84
+ const hostRest = rest;
85
+ return /* @__PURE__ */ jsx("img", {
86
+ src: source,
87
+ alt: own.alt ?? "",
88
+ style: FILL_STYLE,
89
+ ...hostRest
90
+ });
91
+ }
92
+ return /* @__PURE__ */ jsx(Icon, {
93
+ svg: source,
94
+ ...rest
95
+ });
96
+ };
97
+ }
98
+
99
+ //#endregion
7
100
  //#region src/utils/use-intersection-observer.ts
8
101
  /**
9
102
  * Observes an element and calls `onIntersect` once it enters the viewport.
@@ -1118,5 +1211,5 @@ function aiPlugin(..._) {
1118
1211
  }
1119
1212
 
1120
1213
  //#endregion
1121
- export { Image, Link, Meta, Script, ThemeToggle, aiPlugin, buildLocalePath, buildMetaTags, createImage, createLink, createScript, createServer, defineConfig, extractLocaleFromPath, faviconPlugin, initTheme, ogImagePlugin, prefetchRoute, resolvedTheme, seoPlugin, setLocale, setSSRThemeDefault, setTheme, theme, themeScript, toggleTheme, useImage, useLink, useLocale, useScript, validateEnv };
1214
+ export { Icon, Image, Link, Meta, Script, ThemeToggle, aiPlugin, buildLocalePath, buildMetaTags, createIcon, createImage, createLink, createNamedIcon, createScript, createServer, defineConfig, extractLocaleFromPath, faviconPlugin, initTheme, ogImagePlugin, prefetchRoute, resolvedTheme, seoPlugin, setLocale, setSSRThemeDefault, setTheme, theme, themeScript, toggleTheme, useImage, useLink, useLocale, useScript, validateEnv };
1122
1215
  //# sourceMappingURL=index.js.map
package/lib/rate-limit.js CHANGED
@@ -26,6 +26,11 @@ function rateLimitMiddleware(config = {}) {
26
26
  if (store.size < MAX_STORE_SIZE / 2 && now - lastCleanup < windowMs) return;
27
27
  lastCleanup = now;
28
28
  for (const [key, entry] of store) if (entry.resetAt <= now) store.delete(key);
29
+ while (store.size > MAX_STORE_SIZE) {
30
+ const oldest = store.keys().next().value;
31
+ if (oldest === void 0) break;
32
+ store.delete(oldest);
33
+ }
29
34
  }
30
35
  return (ctx) => {
31
36
  if (include && !include.some((p) => matchSimpleGlob(p, ctx.path))) return;
package/lib/seo.js CHANGED
@@ -14,7 +14,7 @@ import { join, resolve } from "node:path";
14
14
  */
15
15
  function generateSitemap(routeFiles, config, i18n) {
16
16
  const { origin, exclude = [], changefreq = "weekly", priority = .7 } = config;
17
- const clusters = clusterPathsByLocale([...routeFiles.filter((f) => {
17
+ const paths = routeFiles.filter((f) => {
18
18
  const name = f.split("/").pop()?.replace(/\.\w+$/, "");
19
19
  return name !== "_layout" && name !== "_error" && name !== "_loading";
20
20
  }).map((f) => {
@@ -23,11 +23,16 @@ function generateSitemap(routeFiles, config, i18n) {
23
23
  path = path.replace(/\([\w-]+\)\//g, "");
24
24
  if (!path.startsWith("/")) path = `/${path}`;
25
25
  return path;
26
- }).filter((p) => p !== null).filter((p) => !exclude.some((e) => p.startsWith(e))).map((p) => ({
27
- path: p,
28
- changefreq,
29
- priority
30
- })), ...config.additionalPaths ?? []], i18n);
26
+ }).filter((p) => p !== null).filter((p) => !exclude.some((e) => p.startsWith(e)));
27
+ const clusters = clusterPathsByLocale((() => {
28
+ const byPath = /* @__PURE__ */ new Map();
29
+ for (const e of [...paths.map((p) => ({
30
+ path: p,
31
+ changefreq,
32
+ priority
33
+ })), ...config.additionalPaths ?? []]) if (!byPath.has(e.path)) byPath.set(e.path, e);
34
+ return [...byPath.values()];
35
+ })(), i18n);
31
36
  return `<?xml version="1.0" encoding="UTF-8"?>
32
37
  <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"${i18n != null && i18n.locales.length > 0 ? " xmlns:xhtml=\"http://www.w3.org/1999/xhtml\"" : ""}>
33
38
  ${clusters.map((cluster) => renderClusterEntry(cluster, origin, changefreq, priority, i18n)).join("\n")}