@pyreon/zero 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,7 +18,7 @@ bun add @pyreon/zero
18
18
  - **Components** — `<Image>` (lazy load, srcset, blur-up), `<Link>` (prefetch, active state), `<Script>` (loading strategies)
19
19
  - **Theme** — Dark/light/system with `theme` signal, `<ThemeToggle>`, and anti-flash inline script
20
20
  - **Fonts** — Google Fonts self-hosting at build time, local fonts, size-adjusted fallbacks
21
- - **Image optimization** — Build-time processing via `?optimize` imports (WebP/AVIF, blur placeholders)
21
+ - **Image optimization** — Build-time processing via `?optimize` imports (WebP/AVIF, blur placeholders). Type the custom queries with one line — `/// <reference types="@pyreon/zero/image-types" />` — which ships ambient `declare module "*?optimize"` / `"*?component"` / `"*?raw"` reusing the plugin's own `ProcessedImage`.
22
22
  - **SEO** — Sitemap, robots.txt, JSON-LD helpers (Vite plugin + dev middleware)
23
23
  - **Middleware** — `cacheMiddleware()`, `securityHeaders()`, `corsMiddleware()`, `rateLimitMiddleware()`, `compressionMiddleware()`
24
24
  - **Adapters** — Node.js, Bun, static, Vercel, Cloudflare Pages, Netlify Functions
@@ -145,4 +145,4 @@ function generateApiRouteModule(files, routesDir) {
145
145
 
146
146
  //#endregion
147
147
  export { matchApiRoute as i, createApiMiddleware as n, generateApiRouteModule as r, api_routes_exports as t };
148
- //# sourceMappingURL=api-routes-CQiOi3q5.js.map
148
+ //# sourceMappingURL=api-routes-CMsLztoj.js.map
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,11 +116,25 @@ 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;
80
123
  const isSvgSource = localeSource ? localeSource.source.endsWith(".svg") : config.source.endsWith(".svg");
124
+ if (isSvgSource && (svgUrl.endsWith("/favicon-light.svg") || svgUrl.endsWith("/favicon-dark.svg"))) {
125
+ const isDarkVariant = svgUrl.endsWith("/favicon-dark.svg");
126
+ const variantPath = isDarkVariant ? darkPath ?? svgPath : svgPath;
127
+ try {
128
+ let content = await readFile(variantPath, "utf-8");
129
+ if (!isDarkVariant) {
130
+ if (autoDevBadge) content = addDevBadgeToSvg(content);
131
+ else if (devSourcePath && existsSync(devSourcePath)) content = await readFile(devSourcePath, "utf-8");
132
+ }
133
+ res.setHeader("Content-Type", "image/svg+xml");
134
+ res.end(content);
135
+ return;
136
+ } catch {}
137
+ }
81
138
  if (svgUrl.endsWith("/favicon.svg") && isSvgSource) try {
82
139
  let content = await readFile(svgPath, "utf-8");
83
140
  if (autoDevBadge) content = addDevBadgeToSvg(content);
@@ -154,7 +211,27 @@ function faviconPlugin(config) {
154
211
  const isSvg = config.source.endsWith(".svg");
155
212
  const hasDark = !!config.darkSource;
156
213
  const tags = [];
157
- if (isSvg) tags.push({
214
+ if (isSvg && hasDark) tags.push({
215
+ tag: "link",
216
+ attrs: {
217
+ rel: "icon",
218
+ type: "image/svg+xml",
219
+ href: "/favicon-light.svg",
220
+ "data-favicon-theme": "light"
221
+ },
222
+ injectTo: "head"
223
+ }, {
224
+ tag: "link",
225
+ attrs: {
226
+ rel: "icon",
227
+ type: "image/svg+xml",
228
+ href: "/favicon-dark.svg",
229
+ "data-favicon-theme": "dark",
230
+ media: "not all"
231
+ },
232
+ injectTo: "head"
233
+ });
234
+ else if (isSvg) tags.push({
158
235
  tag: "link",
159
236
  attrs: {
160
237
  rel: "icon",
@@ -277,10 +354,21 @@ function faviconPlugin(config) {
277
354
  injectTo: "head",
278
355
  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
356
  });
357
+ const v = getVersionQuery();
358
+ if (v) {
359
+ for (const t of tags) if (t.tag === "link" && t.attrs.href) t.attrs.href += v;
360
+ }
280
361
  return tags;
281
362
  },
282
363
  async generateBundle() {
283
364
  if (!isBuild) return;
365
+ try {
366
+ await import("sharp");
367
+ } catch {
368
+ 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.
369
+ Fix: bun add -D sharp (or: npm i -D sharp)
370
+ Source: ${config.source}\nTo intentionally build without favicons, remove faviconPlugin() from your Vite plugins.`);
371
+ }
284
372
  await generateFaviconSet.call(this, root, config.source, config.darkSource, "", config, themeColor, backgroundColor, generateManifest);
285
373
  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
374
  }
@@ -335,7 +423,20 @@ async function generateFaviconSet(rootDir, source, darkSource, prefix, config, t
335
423
  let finalSvg = svgContent;
336
424
  if (darkSource) {
337
425
  const darkPath = join(rootDir, darkSource);
338
- if (existsSync(darkPath)) finalSvg = wrapSvgWithDarkMode(svgContent, await readFile(darkPath, "utf-8"));
426
+ if (existsSync(darkPath)) {
427
+ const darkSvg = await readFile(darkPath, "utf-8");
428
+ finalSvg = wrapSvgWithDarkMode(svgContent, darkSvg);
429
+ this.emitFile({
430
+ type: "asset",
431
+ fileName: `${prefix}favicon-light.svg`,
432
+ source: svgContent
433
+ });
434
+ this.emitFile({
435
+ type: "asset",
436
+ fileName: `${prefix}favicon-dark.svg`,
437
+ source: darkSvg
438
+ });
439
+ }
339
440
  }
340
441
  this.emitFile({
341
442
  type: "asset",
@@ -425,8 +526,21 @@ function faviconLinks(locale, config) {
425
526
  const hasLocaleOverride = locale && config.locales?.[locale];
426
527
  const prefix = hasLocaleOverride ? `/${locale}` : "";
427
528
  const isSvg = (hasLocaleOverride ? config.locales[locale].source : config.source).endsWith(".svg");
529
+ const hasDark = !!config.darkSource;
428
530
  const links = [];
429
- if (isSvg) links.push({
531
+ if (isSvg && hasDark) links.push({
532
+ rel: "icon",
533
+ type: "image/svg+xml",
534
+ href: `${prefix}/favicon-light.svg`,
535
+ "data-favicon-theme": "light"
536
+ }, {
537
+ rel: "icon",
538
+ type: "image/svg+xml",
539
+ href: `${prefix}/favicon-dark.svg`,
540
+ "data-favicon-theme": "dark",
541
+ media: "not all"
542
+ });
543
+ else if (isSvg) links.push({
430
544
  rel: "icon",
431
545
  type: "image/svg+xml",
432
546
  href: `${prefix}/favicon.svg`
@@ -571,5 +685,5 @@ async function addDevBadgeToPng(pngBuffer, size) {
571
685
  }
572
686
 
573
687
  //#endregion
574
- export { createIcoFromPngs, faviconLinks, faviconPlugin };
688
+ export { createIcoFromPngs, faviconLinks, faviconPlugin, faviconVersionQuery };
575
689
  //# sourceMappingURL=favicon.js.map
@@ -969,7 +969,7 @@ async function scanRouteFiles(routesDir) {
969
969
  */
970
970
  async function scanRouteFilesWithExports(routesDir, defaultMode = "ssr") {
971
971
  const { readFile } = await import("node:fs/promises");
972
- const { isApiRoute } = await import("./api-routes-CQiOi3q5.js").then((n) => n.t);
972
+ const { isApiRoute } = await import("./api-routes-CMsLztoj.js").then((n) => n.t);
973
973
  const files = (await scanRouteFiles(routesDir)).filter((f) => !isApiRoute(f));
974
974
  const exportsMap = /* @__PURE__ */ new Map();
975
975
  await Promise.all(files.map(async (filePath) => {
@@ -985,4 +985,4 @@ async function scanRouteFilesWithExports(routesDir, defaultMode = "ssr") {
985
985
 
986
986
  //#endregion
987
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 };
988
- //# sourceMappingURL=fs-router-BVY4lTH_.js.map
988
+ //# sourceMappingURL=fs-router-Bacdhsq-.js.map
@@ -77,15 +77,22 @@ function imagePlugin(config = {}) {
77
77
  outDir = resolvedConfig.build.outDir;
78
78
  isBuild = resolvedConfig.command === "build";
79
79
  },
80
- async resolveId(id) {
81
- if (svgOpts && id.includes("?component") && id.split("?")[0].endsWith(".svg")) return `\0virtual:zero-svg:${id}`;
82
- if (id.includes("?optimize") && include.test(id.split("?")[0])) return `\0virtual:zero-image:${id}`;
83
- 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}`;
84
91
  },
85
92
  async load(id) {
86
93
  if (id.startsWith("\0virtual:zero-svg:")) {
87
94
  const rawPath = id.replace("\0virtual:zero-svg:", "").split("?")[0] ?? id;
88
- const absPath = rawPath.startsWith("/") ? join(root, rawPath) : rawPath;
95
+ const absPath = existsSync(rawPath) ? rawPath : rawPath.startsWith("/") ? join(root, rawPath) : rawPath;
89
96
  if (!existsSync(absPath)) return null;
90
97
  let svg = await readFile(absPath, "utf-8");
91
98
  if (svgOpts && svgOpts.currentColor !== false) svg = svg.replace(/fill="(?!none)[^"]*"/g, "fill=\"currentColor\"").replace(/stroke="(?!none)[^"]*"/g, "stroke=\"currentColor\"");
@@ -111,7 +118,7 @@ export default function SvgComponent(props) {
111
118
  }
112
119
  if (!id.startsWith("\0virtual:zero-image:")) return null;
113
120
  const rawPath = id.replace("\0virtual:zero-image:", "").split("?")[0] ?? id;
114
- const absPath = rawPath.startsWith("/") ? join(root, "public", rawPath) : rawPath;
121
+ const absPath = existsSync(rawPath) ? rawPath : rawPath.startsWith("/") ? join(root, "public", rawPath) : rawPath;
115
122
  if (cdn) {
116
123
  const metadata = await getImageMetadata(absPath);
117
124
  const sources = defaultWidths.map((w) => ({
@@ -163,7 +170,7 @@ export default function SvgComponent(props) {
163
170
  }
164
171
  async function loadDevImage(absPath, rawPath, strategy, placeholderSize) {
165
172
  const metadata = await getImageMetadata(absPath);
166
- const publicPath = rawPath.startsWith("/") ? rawPath : `/@fs/${absPath}`;
173
+ const publicPath = rawPath.startsWith("/") && !existsSync(rawPath) ? rawPath : `/@fs/${absPath}`;
167
174
  return {
168
175
  src: publicPath,
169
176
  srcset: "",
File without changes