@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.
@@ -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-CQiOi3q5.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;
@@ -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-CQiOi3q5.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-BVY4lTH_.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;
@@ -110,7 +117,7 @@ export default function SvgComponent(props) {
110
117
  const sources = defaultWidths.map((w) => ({
111
118
  src: cdn(rawPath, {
112
119
  width: w,
113
- quality,
120
+ quality: qualityFor(defaultFormats[0]),
114
121
  format: defaultFormats[0]
115
122
  }) ?? rawPath,
116
123
  width: w,
@@ -122,12 +129,12 @@ export default function SvgComponent(props) {
122
129
  srcset,
123
130
  width: metadata.width,
124
131
  height: metadata.height,
125
- placeholder: placeholderStrategy === "none" ? "" : await generateBlurPlaceholder(absPath, placeholderSize),
132
+ placeholder: await generatePlaceholder(absPath, placeholderStrategy, placeholderSize),
126
133
  formats: defaultFormats.map((fmt) => ({
127
134
  type: `image/${fmt}`,
128
135
  srcset: defaultWidths.map((w) => `${cdn(rawPath, {
129
136
  width: w,
130
- quality,
137
+ quality: qualityFor(fmt),
131
138
  format: fmt
132
139
  }) ?? rawPath} ${w}w`).join(", ")
133
140
  })),
@@ -136,13 +143,14 @@ export default function SvgComponent(props) {
136
143
  return `export default ${JSON.stringify(result)}`;
137
144
  }
138
145
  if (!isBuild) {
139
- const result = await loadDevImage(absPath, rawPath, placeholderSize);
146
+ const result = await loadDevImage(absPath, rawPath, placeholderStrategy, placeholderSize);
140
147
  return `export default ${JSON.stringify(result)}`;
141
148
  }
142
149
  const processed = await processImage(absPath, {
143
150
  widths: defaultWidths,
144
151
  formats: defaultFormats,
145
- quality,
152
+ qualityFor,
153
+ placeholderStrategy,
146
154
  placeholderSize,
147
155
  outSubDir,
148
156
  outDir: join(root, outDir)
@@ -153,7 +161,7 @@ export default function SvgComponent(props) {
153
161
  }
154
162
  };
155
163
  }
156
- async function loadDevImage(absPath, rawPath, placeholderSize) {
164
+ async function loadDevImage(absPath, rawPath, strategy, placeholderSize) {
157
165
  const metadata = await getImageMetadata(absPath);
158
166
  const publicPath = rawPath.startsWith("/") ? rawPath : `/@fs/${absPath}`;
159
167
  return {
@@ -161,7 +169,7 @@ async function loadDevImage(absPath, rawPath, placeholderSize) {
161
169
  srcset: "",
162
170
  width: metadata.width,
163
171
  height: metadata.height,
164
- placeholder: await generateBlurPlaceholder(absPath, placeholderSize),
172
+ placeholder: await generatePlaceholder(absPath, strategy, placeholderSize),
165
173
  formats: [],
166
174
  sources: [{
167
175
  src: publicPath,
@@ -208,7 +216,7 @@ async function processImage(absPath, opts) {
208
216
  for (const format of opts.formats) for (const targetWidth of opts.widths) {
209
217
  const width = Math.min(targetWidth, metadata.width);
210
218
  const outPath = join(processedDir, `${name}-${width}.${format}`);
211
- await resizeImage(absPath, outPath, width, format, opts.quality);
219
+ await resizeImage(absPath, outPath, width, format, opts.qualityFor(format));
212
220
  sources.push({
213
221
  src: outPath,
214
222
  width,
@@ -233,7 +241,7 @@ async function processImage(absPath, opts) {
233
241
  }));
234
242
  const fallbackFormat = formats[formats.length - 1];
235
243
  const fallbackSources = formatGroups.get([...formatGroups.keys()].pop());
236
- const placeholder = await generateBlurPlaceholder(absPath, opts.placeholderSize);
244
+ const placeholder = await generatePlaceholder(absPath, opts.placeholderStrategy, opts.placeholderSize);
237
245
  return {
238
246
  src: fallbackSources[fallbackSources.length - 1]?.src ?? absPath,
239
247
  srcset: fallbackFormat?.srcset ?? "",
@@ -351,10 +359,66 @@ async function generateBlurPlaceholder(input, size) {
351
359
  try {
352
360
  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
361
  } catch {
354
- return "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1' height='1'%3E%3C/svg%3E";
362
+ return TRANSPARENT_PLACEHOLDER;
363
+ }
364
+ }
365
+ /** 1×1 transparent SVG — the no-sharp fallback for every strategy. */
366
+ const TRANSPARENT_PLACEHOLDER = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1' height='1'%3E%3C/svg%3E";
367
+ const DEFAULT_QUALITY = 80;
368
+ /**
369
+ * Resolve the public {@link ImageQuality} config into a per-format lookup.
370
+ *
371
+ * - `undefined` → every format gets {@link DEFAULT_QUALITY}.
372
+ * - `number` → that number for every format (backward-compatible).
373
+ * - `Partial<Record<ImageFormat, number>>` → per-format; formats omitted
374
+ * from the map fall back to {@link DEFAULT_QUALITY}.
375
+ *
376
+ * @internal Exported for testing.
377
+ */
378
+ function resolveQuality(q) {
379
+ if (q === void 0) return () => DEFAULT_QUALITY;
380
+ if (typeof q === "number") return () => q;
381
+ return (format) => q[format] ?? DEFAULT_QUALITY;
382
+ }
383
+ /**
384
+ * Dispatch placeholder generation by strategy. Single source of truth used
385
+ * by every code path (CDN / dev / build) — pre-fix each path open-coded
386
+ * `generateBlurPlaceholder`, so `'none'` was honoured only in the CDN path
387
+ * and `'dominant-color'` (typed since the plugin's inception) was never
388
+ * implemented anywhere — the exact typed-but-unimplemented bug class the
389
+ * `audit-types` gate exists to catch.
390
+ *
391
+ * @internal Exported for testing.
392
+ */
393
+ async function generatePlaceholder(input, strategy, size) {
394
+ if (strategy === "none") return "";
395
+ if (strategy === "color") return generateColorPlaceholder(input);
396
+ return generateBlurPlaceholder(input, size);
397
+ }
398
+ /**
399
+ * Generate a dominant-colour placeholder: a ~200-byte flat-fill SVG data URI.
400
+ *
401
+ * Uses sharp's `.stats()` `dominant` swatch — a histogram-binned colour,
402
+ * not a naive average (averaging a photo trends muddy grey). Note the
403
+ * swatch is approximate by design: a pure-red source resolves to ~#f80808,
404
+ * not #ff0000. The SVG is a constant ~200 bytes regardless of source
405
+ * complexity and needs zero image decode, at the cost of showing a solid
406
+ * colour instead of a blurry preview of the content.
407
+ */
408
+ async function generateColorPlaceholder(input) {
409
+ try {
410
+ const { dominant } = await (await import("sharp").then((m) => m.default ?? m))(input).stats();
411
+ const svg = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1' preserveAspectRatio='none'><rect width='1' height='1' fill='${"#" + [
412
+ dominant.r,
413
+ dominant.g,
414
+ dominant.b
415
+ ].map((c) => Math.max(0, Math.min(255, c)).toString(16).padStart(2, "0")).join("")}'/></svg>`;
416
+ return `data:image/svg+xml,${encodeURIComponent(svg)}`;
417
+ } catch {
418
+ return TRANSPARENT_PLACEHOLDER;
355
419
  }
356
420
  }
357
421
 
358
422
  //#endregion
359
- export { cdnProviders, imagePlugin, parseJpegDimensions, parseWebPDimensions };
423
+ export { cdnProviders, generatePlaceholder, imagePlugin, normalizePlaceholder, parseJpegDimensions, parseWebPDimensions, resolveQuality };
360
424
  //# 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")}