@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.
@@ -1,6 +1,22 @@
1
1
  import { Plugin } from "vite";
2
2
 
3
3
  //#region src/favicon.d.ts
4
+ /**
5
+ * Stable content hash (FNV-1a, 32-bit) of the favicon source file(s),
6
+ * rendered as a `?v=<hex>` cache-bust query for the injected `<head>`
7
+ * links. Browsers cache favicons extremely aggressively (often per-
8
+ * session / effectively forever), so with a stable URL a changed icon
9
+ * is never re-fetched by returning visitors. Same source bytes →
10
+ * identical query (no needless cache churn); changed bytes → new query
11
+ * → browser re-downloads. Falls back to `''` (no query, prior
12
+ * behaviour) if a source can't be read — never break the build over a
13
+ * cache-bust nicety. NOTE: this versions everything referenced via
14
+ * `<link>` (svg/png/apple-touch/manifest). The bare `/favicon.ico`
15
+ * convention request (browsers fetch it with no link tag) and the
16
+ * `site.webmanifest`'s internal icon entries keep stable URLs — those
17
+ * rely on host cache headers / are re-resolved on PWA (re)install.
18
+ */
19
+ declare function faviconVersionQuery(paths: string[]): string;
4
20
  interface FaviconLocaleConfig {
5
21
  /** Locale-specific source icon (SVG or PNG). */
6
22
  source: string;
@@ -19,9 +35,21 @@ interface FaviconPluginConfig {
19
35
  /** Generate web manifest. Default: true */
20
36
  manifest?: boolean;
21
37
  /**
22
- * Dark mode favicon (SVG only).
23
- * When provided, the SVG favicon uses prefers-color-scheme media query
24
- * to switch between light and dark variants.
38
+ * Dark-mode favicon source.
39
+ *
40
+ * When provided, the plugin emits theme-aware `light`/`dark` variants
41
+ * (`favicon-light.svg` / `favicon-dark.svg` for SVG sources, plus the
42
+ * `*-light-*` / `*-dark-*` PNG/apple-touch set) tagged with
43
+ * `data-favicon-theme`. The injected blocking theme-swap script and
44
+ * `initTheme()` toggle their `media` attribute so the displayed
45
+ * favicon follows the app's resolved theme — including a manual
46
+ * in-app theme toggle, not just the OS `prefers-color-scheme`.
47
+ *
48
+ * For SVG sources a `favicon.svg` is also emitted that wraps both
49
+ * variants behind an OS `prefers-color-scheme` query — kept as the
50
+ * no-JS / direct-`/favicon.svg`-reference fallback only (it cannot
51
+ * follow a manual toggle, which is why the `data-favicon-theme`
52
+ * variants above are what the reactive mechanism actually uses).
25
53
  */
26
54
  darkSource?: string;
27
55
  /**
@@ -94,6 +122,8 @@ declare function faviconLinks(locale: string | undefined, config: FaviconPluginC
94
122
  type?: string;
95
123
  sizes?: string;
96
124
  href: string;
125
+ 'data-favicon-theme'?: string;
126
+ media?: string;
97
127
  }>;
98
128
  interface IcoEntry {
99
129
  buffer: Buffer;
@@ -102,5 +132,5 @@ interface IcoEntry {
102
132
  /** @internal Exported for testing */
103
133
  declare function createIcoFromPngs(entries: IcoEntry[]): Uint8Array;
104
134
  //#endregion
105
- export { FaviconLocaleConfig, FaviconPluginConfig, IcoEntry, createIcoFromPngs, faviconLinks, faviconPlugin };
135
+ export { FaviconLocaleConfig, FaviconPluginConfig, IcoEntry, createIcoFromPngs, faviconLinks, faviconPlugin, faviconVersionQuery };
106
136
  //# sourceMappingURL=favicon2.d.ts.map
@@ -1,5 +1,3 @@
1
- import * as _$_pyreon_core0 from "@pyreon/core";
2
- import * as _$_pyreon_reactivity0 from "@pyreon/reactivity";
3
1
  import { Plugin } from "vite";
4
2
  //#region src/types.d.ts
5
3
  type RenderMode = 'ssr' | 'ssg' | 'spa' | 'isr';
@@ -265,9 +263,9 @@ declare function createLocaleContext(locale: string, path: string, config: I18nR
265
263
  */
266
264
  declare function i18nRouting(config: I18nRoutingConfig): Plugin;
267
265
  /** @internal Context for the current locale. */
268
- declare const LocaleCtx: _$_pyreon_core0.Context<string>;
266
+ declare const LocaleCtx: import("@pyreon/core").Context<string>;
269
267
  /** Current locale signal — set by the server middleware or client-side detection. */
270
- declare const localeSignal: _$_pyreon_reactivity0.Signal<string>;
268
+ declare const localeSignal: import("@pyreon/reactivity").Signal<string>;
271
269
  /**
272
270
  * Read the current locale reactively.
273
271
  *
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Ambient type declarations for the custom image-import queries that
3
+ * `@pyreon/zero`'s `imagePlugin` introduces (`?optimize` / `?component`
4
+ * / `?raw`). Shipped + exported so the documented usage type-checks out
5
+ * of the box — no consumer hand-authoring required.
6
+ *
7
+ * Add ONE line to any tsconfig-covered `.d.ts` (e.g. `src/env.d.ts`):
8
+ * /// <reference types="@pyreon/zero/image-types" />
9
+ *
10
+ * Or via tsconfig.json:
11
+ * "types": ["@pyreon/zero/image-types"]
12
+ *
13
+ * This is an ambient-only **script** (no top-level import/export) so
14
+ * every `declare module` below is a global module augmentation. The
15
+ * `ProcessedImage` shape is referenced via the package self-ref
16
+ * `import('@pyreon/zero/image-plugin')` (resolution-stable in the
17
+ * published layout, and re-uses the plugin's own type so it can never
18
+ * drift out of sync).
19
+ */
20
+ //#region src/image-types.d.ts
21
+ declare module '*.jpg?optimize' {
22
+ const image: import('@pyreon/zero/image-plugin').ProcessedImage;
23
+ export default image;
24
+ }
25
+ declare module '*.jpeg?optimize' {
26
+ const image: import('@pyreon/zero/image-plugin').ProcessedImage;
27
+ export default image;
28
+ }
29
+ declare module '*.png?optimize' {
30
+ const image: import('@pyreon/zero/image-plugin').ProcessedImage;
31
+ export default image;
32
+ }
33
+ declare module '*.webp?optimize' {
34
+ const image: import('@pyreon/zero/image-plugin').ProcessedImage;
35
+ export default image;
36
+ }
37
+ declare module '*.avif?optimize' {
38
+ const image: import('@pyreon/zero/image-plugin').ProcessedImage;
39
+ export default image;
40
+ }
41
+ declare module '*.svg?component' {
42
+ const component: import('@pyreon/core').ComponentFn<{
43
+ width?: number;
44
+ height?: number;
45
+ class?: string;
46
+ style?: string;
47
+ [key: string]: unknown;
48
+ }>;
49
+ export default component;
50
+ }
51
+ declare module '*.svg?raw' {
52
+ const svg: string;
53
+ export default svg;
54
+ }
55
+ //# sourceMappingURL=image-types2.d.ts.map
@@ -1,6 +1,4 @@
1
- import * as _$_pyreon_core0 from "@pyreon/core";
2
1
  import { ComponentFn, Ref, SvgAttributes, VNodeChild } from "@pyreon/core";
3
- import * as _$_pyreon_reactivity0 from "@pyreon/reactivity";
4
2
  import { LoaderContext, NavigationGuard } from "@pyreon/router";
5
3
  import { Middleware } from "@pyreon/server";
6
4
 
@@ -272,7 +270,7 @@ interface LinkProps {
272
270
  /** Props passed to a custom component via createLink. */
273
271
  interface LinkRenderProps {
274
272
  href: string;
275
- ref: _$_pyreon_core0.Ref<HTMLAnchorElement>;
273
+ ref: import('@pyreon/core').Ref<HTMLAnchorElement>;
276
274
  onClick: (e: MouseEvent) => void;
277
275
  onMouseEnter: () => void;
278
276
  onTouchStart: () => void;
@@ -289,7 +287,7 @@ interface LinkRenderProps {
289
287
  /** Return type of useLink. */
290
288
  interface UseLinkReturn {
291
289
  /** Ref object — attach to the root element for viewport-based prefetch. */
292
- ref: _$_pyreon_core0.Ref<HTMLAnchorElement>;
290
+ ref: import('@pyreon/core').Ref<HTMLAnchorElement>;
293
291
  /** Click handler — performs client-side navigation. */
294
292
  handleClick: (e: MouseEvent) => void;
295
293
  /** Mouse enter handler — triggers hover prefetch. */
@@ -1191,7 +1189,7 @@ declare function buildMetaTags(props: Omit<MetaProps, 'title' | 'description' |
1191
1189
  //#region src/theme.d.ts
1192
1190
  type Theme = 'light' | 'dark' | 'system';
1193
1191
  /** Reactive theme signal. */
1194
- declare const theme: _$_pyreon_reactivity0.Signal<Theme>;
1192
+ declare const theme: import("@pyreon/reactivity").Signal<Theme>;
1195
1193
  /**
1196
1194
  * Set the default theme for SSR (when `matchMedia` is unavailable).
1197
1195
  * Call once at server startup before rendering.
@@ -1,5 +1,3 @@
1
- import * as _$_pyreon_core0 from "@pyreon/core";
2
-
3
1
  //#region src/link.d.ts
4
2
  interface LinkProps {
5
3
  /** Target URL path. */
@@ -26,7 +24,7 @@ interface LinkProps {
26
24
  /** Props passed to a custom component via createLink. */
27
25
  interface LinkRenderProps {
28
26
  href: string;
29
- ref: _$_pyreon_core0.Ref<HTMLAnchorElement>;
27
+ ref: import('@pyreon/core').Ref<HTMLAnchorElement>;
30
28
  onClick: (e: MouseEvent) => void;
31
29
  onMouseEnter: () => void;
32
30
  onTouchStart: () => void;
@@ -43,7 +41,7 @@ interface LinkRenderProps {
43
41
  /** Return type of useLink. */
44
42
  interface UseLinkReturn {
45
43
  /** Ref object — attach to the root element for viewport-based prefetch. */
46
- ref: _$_pyreon_core0.Ref<HTMLAnchorElement>;
44
+ ref: import('@pyreon/core').Ref<HTMLAnchorElement>;
47
45
  /** Click handler — performs client-side navigation. */
48
46
  handleClick: (e: MouseEvent) => void;
49
47
  /** Mouse enter handler — triggers hover prefetch. */
@@ -1,6 +1,4 @@
1
- import * as _$_pyreon_core0 from "@pyreon/core";
2
1
  import { ComponentFn } from "@pyreon/core";
3
- import * as _$_pyreon_router0 from "@pyreon/router";
4
2
  import { RouteRecord } from "@pyreon/router";
5
3
  import { Middleware, MiddlewareContext } from "@pyreon/server";
6
4
  import { Plugin } from "vite";
@@ -36,8 +34,8 @@ interface CreateAppOptions {
36
34
  * Used internally by entry-server and entry-client.
37
35
  */
38
36
  declare function createApp(options: CreateAppOptions): {
39
- App: () => _$_pyreon_core0.VNode;
40
- router: _$_pyreon_router0.Router<string>;
37
+ App: () => import("@pyreon/core").VNode;
38
+ router: import("@pyreon/router").Router<string>;
41
39
  };
42
40
  //#endregion
43
41
  //#region src/api-routes.d.ts
@@ -1038,9 +1036,21 @@ interface FaviconPluginConfig {
1038
1036
  /** Generate web manifest. Default: true */
1039
1037
  manifest?: boolean;
1040
1038
  /**
1041
- * Dark mode favicon (SVG only).
1042
- * When provided, the SVG favicon uses prefers-color-scheme media query
1043
- * to switch between light and dark variants.
1039
+ * Dark-mode favicon source.
1040
+ *
1041
+ * When provided, the plugin emits theme-aware `light`/`dark` variants
1042
+ * (`favicon-light.svg` / `favicon-dark.svg` for SVG sources, plus the
1043
+ * `*-light-*` / `*-dark-*` PNG/apple-touch set) tagged with
1044
+ * `data-favicon-theme`. The injected blocking theme-swap script and
1045
+ * `initTheme()` toggle their `media` attribute so the displayed
1046
+ * favicon follows the app's resolved theme — including a manual
1047
+ * in-app theme toggle, not just the OS `prefers-color-scheme`.
1048
+ *
1049
+ * For SVG sources a `favicon.svg` is also emitted that wraps both
1050
+ * variants behind an OS `prefers-color-scheme` query — kept as the
1051
+ * no-JS / direct-`/favicon.svg`-reference fallback only (it cannot
1052
+ * follow a manual toggle, which is why the `data-favicon-theme`
1053
+ * variants above are what the reactive mechanism actually uses).
1044
1054
  */
1045
1055
  darkSource?: string;
1046
1056
  /**
@@ -1113,6 +1123,8 @@ declare function faviconLinks(locale: string | undefined, config: FaviconPluginC
1113
1123
  type?: string;
1114
1124
  sizes?: string;
1115
1125
  href: string;
1126
+ 'data-favicon-theme'?: string;
1127
+ media?: string;
1116
1128
  }>;
1117
1129
  //#endregion
1118
1130
  //#region src/icon.d.ts
@@ -1,10 +1,9 @@
1
- import * as _$_pyreon_reactivity0 from "@pyreon/reactivity";
2
1
  import { VNodeChild } from "@pyreon/core";
3
2
 
4
3
  //#region src/theme.d.ts
5
4
  type Theme = 'light' | 'dark' | 'system';
6
5
  /** Reactive theme signal. */
7
- declare const theme: _$_pyreon_reactivity0.Signal<Theme>;
6
+ declare const theme: import("@pyreon/reactivity").Signal<Theme>;
8
7
  /**
9
8
  * Set the default theme for SSR (when `matchMedia` is unavailable).
10
9
  * Call once at server startup before rendering.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/zero",
3
- "version": "0.19.0",
3
+ "version": "0.21.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",
@@ -84,6 +84,11 @@
84
84
  "import": "./lib/image-plugin.js",
85
85
  "types": "./lib/types/image-plugin.d.ts"
86
86
  },
87
+ "./image-types": {
88
+ "bun": "./src/image-types.ts",
89
+ "import": "./lib/image-types.js",
90
+ "types": "./lib/types/image-types.d.ts"
91
+ },
87
92
  "./actions": {
88
93
  "bun": "./src/actions.ts",
89
94
  "import": "./lib/actions.js",
@@ -168,15 +173,15 @@
168
173
  "lint": "oxlint ."
169
174
  },
170
175
  "dependencies": {
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",
176
+ "@pyreon/core": "^0.21.0",
177
+ "@pyreon/head": "^0.21.0",
178
+ "@pyreon/meta": "^0.21.0",
179
+ "@pyreon/reactivity": "^0.21.0",
180
+ "@pyreon/router": "^0.21.0",
181
+ "@pyreon/runtime-dom": "^0.21.0",
182
+ "@pyreon/runtime-server": "^0.21.0",
183
+ "@pyreon/server": "^0.21.0",
184
+ "@pyreon/vite-plugin": "^0.21.0",
180
185
  "vite": "^8.0.0"
181
186
  },
182
187
  "devDependencies": {
package/src/favicon.ts CHANGED
@@ -1,8 +1,43 @@
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
  import type { Plugin } from 'vite'
5
5
 
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
+ export function faviconVersionQuery(paths: string[]): string {
22
+ let h = 0x811c9dc5
23
+ let any = false
24
+ for (const p of paths) {
25
+ let buf: Buffer
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, 0x01000193)
35
+ }
36
+ }
37
+ if (!any) return ''
38
+ return `?v=${(h >>> 0).toString(16).padStart(8, '0')}`
39
+ }
40
+
6
41
  let sharpWarned = false
7
42
  function warnSharpMissing() {
8
43
  if (sharpWarned) return
@@ -46,9 +81,21 @@ export interface FaviconPluginConfig {
46
81
  /** Generate web manifest. Default: true */
47
82
  manifest?: boolean
48
83
  /**
49
- * Dark mode favicon (SVG only).
50
- * When provided, the SVG favicon uses prefers-color-scheme media query
51
- * to switch between light and dark variants.
84
+ * Dark-mode favicon source.
85
+ *
86
+ * When provided, the plugin emits theme-aware `light`/`dark` variants
87
+ * (`favicon-light.svg` / `favicon-dark.svg` for SVG sources, plus the
88
+ * `*-light-*` / `*-dark-*` PNG/apple-touch set) tagged with
89
+ * `data-favicon-theme`. The injected blocking theme-swap script and
90
+ * `initTheme()` toggle their `media` attribute so the displayed
91
+ * favicon follows the app's resolved theme — including a manual
92
+ * in-app theme toggle, not just the OS `prefers-color-scheme`.
93
+ *
94
+ * For SVG sources a `favicon.svg` is also emitted that wraps both
95
+ * variants behind an OS `prefers-color-scheme` query — kept as the
96
+ * no-JS / direct-`/favicon.svg`-reference fallback only (it cannot
97
+ * follow a manual toggle, which is why the `data-favicon-theme`
98
+ * variants above are what the reactive mechanism actually uses).
52
99
  */
53
100
  darkSource?: string
54
101
  /**
@@ -126,6 +173,17 @@ export function faviconPlugin(config: FaviconPluginConfig): Plugin {
126
173
 
127
174
  let root = ''
128
175
  let isBuild = false
176
+ // Lazily computed once per build/dev session (source rarely changes
177
+ // within a run; recomputing per index.html transform is wasteful).
178
+ let versionQuery: string | null = null
179
+ function getVersionQuery(): string {
180
+ if (versionQuery === null) {
181
+ const paths = [join(root, config.source)]
182
+ if (config.darkSource) paths.push(join(root, config.darkSource))
183
+ versionQuery = faviconVersionQuery(paths)
184
+ }
185
+ return versionQuery
186
+ }
129
187
 
130
188
  return {
131
189
  name: 'pyreon-zero-favicon',
@@ -156,7 +214,11 @@ export function faviconPlugin(config: FaviconPluginConfig): Plugin {
156
214
  }
157
215
 
158
216
  server.middlewares.use(async (req, res, next) => {
159
- const url = req.url ?? ''
217
+ // Strip the `?v=<hash>` cache-bust query (and any query) before
218
+ // matching — the injected links carry it; dev serves fresh
219
+ // (`Cache-Control: no-cache`) so the version is irrelevant here,
220
+ // but a query in the path would break every name match below.
221
+ const url = (req.url ?? '').split('?')[0]!
160
222
 
161
223
  // Resolve locale-specific source
162
224
  const localeSource = resolveLocaleSource(url, config, root)
@@ -164,6 +226,34 @@ export function faviconPlugin(config: FaviconPluginConfig): Plugin {
164
226
  const svgPath = localeSource ? localeSource.sourcePath : sourcePath
165
227
  const isSvgSource = localeSource ? localeSource.source.endsWith('.svg') : config.source.endsWith('.svg')
166
228
 
229
+ // Serve the per-theme SVG variants (the app-toggle path):
230
+ // /favicon-light.svg → source, /favicon-dark.svg → darkSource.
231
+ // Dev-badge / devSource override applies to the light variant
232
+ // only (it is the active default the swap toggles to), matching
233
+ // the /favicon.svg handler's intent.
234
+ if (
235
+ isSvgSource &&
236
+ (svgUrl.endsWith('/favicon-light.svg') ||
237
+ svgUrl.endsWith('/favicon-dark.svg'))
238
+ ) {
239
+ const isDarkVariant = svgUrl.endsWith('/favicon-dark.svg')
240
+ const variantPath = isDarkVariant ? (darkPath ?? svgPath) : svgPath
241
+ try {
242
+ let content = await readFile(variantPath, 'utf-8')
243
+ if (!isDarkVariant) {
244
+ if (autoDevBadge) content = addDevBadgeToSvg(content)
245
+ else if (devSourcePath && existsSync(devSourcePath)) {
246
+ content = await readFile(devSourcePath, 'utf-8')
247
+ }
248
+ }
249
+ res.setHeader('Content-Type', 'image/svg+xml')
250
+ res.end(content)
251
+ return
252
+ } catch {
253
+ /* fall through */
254
+ }
255
+ }
256
+
167
257
  // Serve favicon.svg — in dev, add dev badge overlay if configured
168
258
  if (svgUrl.endsWith('/favicon.svg') && isSvgSource) {
169
259
  try {
@@ -257,8 +347,22 @@ export function faviconPlugin(config: FaviconPluginConfig): Plugin {
257
347
  injectTo: 'head'
258
348
  }> = []
259
349
 
260
- // SVG favicon (with prefers-color-scheme media query when dark variant exists)
261
- if (isSvg) {
350
+ // SVG favicon. Browsers prefer an SVG favicon over PNG when both
351
+ // are present, so the SVG link MUST carry the same
352
+ // `data-favicon-theme` contract the PNG dual-variant uses —
353
+ // otherwise the theme-swap script / initTheme() (which only touch
354
+ // `[data-favicon-theme]`) can never change the displayed icon and
355
+ // the whole reactive-favicon feature is silently dead in every
356
+ // SVG-capable browser. When a dark variant exists, emit TWO
357
+ // theme-aware SVG links (mirroring the PNG pattern); the static
358
+ // `/favicon.svg` (an OS `prefers-color-scheme` wrapped dual) stays
359
+ // emitted as the no-JS / direct-reference fallback only.
360
+ if (isSvg && hasDark) {
361
+ tags.push(
362
+ { tag: 'link', attrs: { rel: 'icon', type: 'image/svg+xml', href: '/favicon-light.svg', 'data-favicon-theme': 'light' }, injectTo: 'head' },
363
+ { tag: 'link', attrs: { rel: 'icon', type: 'image/svg+xml', href: '/favicon-dark.svg', 'data-favicon-theme': 'dark', media: 'not all' }, injectTo: 'head' },
364
+ )
365
+ } else if (isSvg) {
262
366
  tags.push({
263
367
  tag: 'link',
264
368
  attrs: { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
@@ -316,12 +420,44 @@ export function faviconPlugin(config: FaviconPluginConfig): Plugin {
316
420
  } as any)
317
421
  }
318
422
 
423
+ // Cache-bust: stamp the source content hash onto every injected
424
+ // favicon/manifest link href so a changed icon is actually
425
+ // re-downloaded by returning visitors (theme-swap toggles `media`,
426
+ // not `href`, so this is orthogonal to the light/dark variants).
427
+ const v = getVersionQuery()
428
+ if (v) {
429
+ for (const t of tags) {
430
+ if (t.tag === 'link' && t.attrs.href) t.attrs.href += v
431
+ }
432
+ }
433
+
319
434
  return tags
320
435
  },
321
436
 
322
437
  async generateBundle() {
323
438
  if (!isBuild) return
324
439
 
440
+ // `faviconPlugin` is in the plugin list and a `source` is configured
441
+ // (it's a required field), so the user clearly WANTS favicons. If
442
+ // `sharp` is missing, the old behaviour was a single swallow-able
443
+ // `console.warn` + emit nothing — i.e. silently ship a production
444
+ // site with zero favicons. That's the footgun. Fail the build loudly
445
+ // with an actionable message instead. Dev keeps the soft warning
446
+ // (see `warnSharpMissing`) so local iteration isn't blocked.
447
+ try {
448
+ await import('sharp')
449
+ } catch {
450
+ this.error(
451
+ '[Pyreon] faviconPlugin: a favicon `source` is configured but ' +
452
+ '`sharp` is not installed — NO favicons would be generated and ' +
453
+ 'the production build would silently ship none.\n' +
454
+ ' Fix: bun add -D sharp (or: npm i -D sharp)\n' +
455
+ ` Source: ${config.source}\n` +
456
+ 'To intentionally build without favicons, remove faviconPlugin() ' +
457
+ 'from your Vite plugins.',
458
+ )
459
+ }
460
+
325
461
  // Generate favicons for the base (default) source
326
462
  await generateFaviconSet.call(this, root, config.source, config.darkSource, '', config, themeColor, backgroundColor, generateManifest)
327
463
 
@@ -420,6 +556,22 @@ async function generateFaviconSet(
420
556
  if (existsSync(darkPath)) {
421
557
  const darkSvg = await readFile(darkPath, 'utf-8')
422
558
  finalSvg = wrapSvgWithDarkMode(svgContent, darkSvg)
559
+ // Per-theme SVG variants for the app-toggle path:
560
+ // transformIndexHtml / faviconLinks emit
561
+ // `/favicon-light.svg` + `/favicon-dark.svg` with
562
+ // `data-favicon-theme` so the theme-swap actually changes the
563
+ // SVG (the wrapped `favicon.svg` is OS-`prefers-color-scheme`
564
+ // only — kept above as the no-JS / direct-ref fallback).
565
+ this.emitFile({
566
+ type: 'asset',
567
+ fileName: `${prefix}favicon-light.svg`,
568
+ source: svgContent,
569
+ })
570
+ this.emitFile({
571
+ type: 'asset',
572
+ fileName: `${prefix}favicon-dark.svg`,
573
+ source: darkSvg,
574
+ })
423
575
  }
424
576
  }
425
577
 
@@ -517,14 +669,39 @@ async function generateFaviconSet(
517
669
  export function faviconLinks(
518
670
  locale: string | undefined,
519
671
  config: FaviconPluginConfig,
520
- ): Array<{ rel: string; type?: string; sizes?: string; href: string }> {
672
+ ): Array<{
673
+ rel: string
674
+ type?: string
675
+ sizes?: string
676
+ href: string
677
+ 'data-favicon-theme'?: string
678
+ media?: string
679
+ }> {
521
680
  const hasLocaleOverride = locale && config.locales?.[locale]
522
681
  const prefix = hasLocaleOverride ? `/${locale}` : ''
523
682
  const isSvg = (hasLocaleOverride ? config.locales![locale]!.source : config.source).endsWith('.svg')
524
-
525
- const links: Array<{ rel: string; type?: string; sizes?: string; href: string }> = []
526
-
527
- if (isSvg) {
683
+ const hasDark = !!config.darkSource
684
+
685
+ const links: Array<{
686
+ rel: string
687
+ type?: string
688
+ sizes?: string
689
+ href: string
690
+ 'data-favicon-theme'?: string
691
+ media?: string
692
+ }> = []
693
+
694
+ // Mirror transformIndexHtml: a single static SVG link would always
695
+ // win over the theme-toggled PNGs (browsers prefer SVG), silently
696
+ // killing reactive switching for SSR'd pages too. Emit the two
697
+ // theme-aware SVG variants so initTheme()'s `[data-favicon-theme]`
698
+ // swap reaches the SVG. `/favicon.svg` stays the no-JS fallback.
699
+ if (isSvg && hasDark) {
700
+ links.push(
701
+ { rel: 'icon', type: 'image/svg+xml', href: `${prefix}/favicon-light.svg`, 'data-favicon-theme': 'light' },
702
+ { rel: 'icon', type: 'image/svg+xml', href: `${prefix}/favicon-dark.svg`, 'data-favicon-theme': 'dark', media: 'not all' },
703
+ )
704
+ } else if (isSvg) {
528
705
  links.push({ rel: 'icon', type: 'image/svg+xml', href: `${prefix}/favicon.svg` })
529
706
  }
530
707