@pyreon/zero 0.19.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.
@@ -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;
@@ -102,5 +118,5 @@ interface IcoEntry {
102
118
  /** @internal Exported for testing */
103
119
  declare function createIcoFromPngs(entries: IcoEntry[]): Uint8Array;
104
120
  //#endregion
105
- export { FaviconLocaleConfig, FaviconPluginConfig, IcoEntry, createIcoFromPngs, faviconLinks, faviconPlugin };
121
+ export { FaviconLocaleConfig, FaviconPluginConfig, IcoEntry, createIcoFromPngs, faviconLinks, faviconPlugin, faviconVersionQuery };
106
122
  //# 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
  *
@@ -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
@@ -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.20.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.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",
171
+ "@pyreon/core": "^0.20.0",
172
+ "@pyreon/head": "^0.20.0",
173
+ "@pyreon/meta": "^0.20.0",
174
+ "@pyreon/reactivity": "^0.20.0",
175
+ "@pyreon/router": "^0.20.0",
176
+ "@pyreon/runtime-dom": "^0.20.0",
177
+ "@pyreon/runtime-server": "^0.20.0",
178
+ "@pyreon/server": "^0.20.0",
179
+ "@pyreon/vite-plugin": "^0.20.0",
180
180
  "vite": "^8.0.0"
181
181
  },
182
182
  "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
@@ -126,6 +161,17 @@ export function faviconPlugin(config: FaviconPluginConfig): Plugin {
126
161
 
127
162
  let root = ''
128
163
  let isBuild = false
164
+ // Lazily computed once per build/dev session (source rarely changes
165
+ // within a run; recomputing per index.html transform is wasteful).
166
+ let versionQuery: string | null = null
167
+ function getVersionQuery(): string {
168
+ if (versionQuery === null) {
169
+ const paths = [join(root, config.source)]
170
+ if (config.darkSource) paths.push(join(root, config.darkSource))
171
+ versionQuery = faviconVersionQuery(paths)
172
+ }
173
+ return versionQuery
174
+ }
129
175
 
130
176
  return {
131
177
  name: 'pyreon-zero-favicon',
@@ -156,7 +202,11 @@ export function faviconPlugin(config: FaviconPluginConfig): Plugin {
156
202
  }
157
203
 
158
204
  server.middlewares.use(async (req, res, next) => {
159
- const url = req.url ?? ''
205
+ // Strip the `?v=<hash>` cache-bust query (and any query) before
206
+ // matching — the injected links carry it; dev serves fresh
207
+ // (`Cache-Control: no-cache`) so the version is irrelevant here,
208
+ // but a query in the path would break every name match below.
209
+ const url = (req.url ?? '').split('?')[0]!
160
210
 
161
211
  // Resolve locale-specific source
162
212
  const localeSource = resolveLocaleSource(url, config, root)
@@ -316,12 +366,44 @@ export function faviconPlugin(config: FaviconPluginConfig): Plugin {
316
366
  } as any)
317
367
  }
318
368
 
369
+ // Cache-bust: stamp the source content hash onto every injected
370
+ // favicon/manifest link href so a changed icon is actually
371
+ // re-downloaded by returning visitors (theme-swap toggles `media`,
372
+ // not `href`, so this is orthogonal to the light/dark variants).
373
+ const v = getVersionQuery()
374
+ if (v) {
375
+ for (const t of tags) {
376
+ if (t.tag === 'link' && t.attrs.href) t.attrs.href += v
377
+ }
378
+ }
379
+
319
380
  return tags
320
381
  },
321
382
 
322
383
  async generateBundle() {
323
384
  if (!isBuild) return
324
385
 
386
+ // `faviconPlugin` is in the plugin list and a `source` is configured
387
+ // (it's a required field), so the user clearly WANTS favicons. If
388
+ // `sharp` is missing, the old behaviour was a single swallow-able
389
+ // `console.warn` + emit nothing — i.e. silently ship a production
390
+ // site with zero favicons. That's the footgun. Fail the build loudly
391
+ // with an actionable message instead. Dev keeps the soft warning
392
+ // (see `warnSharpMissing`) so local iteration isn't blocked.
393
+ try {
394
+ await import('sharp')
395
+ } catch {
396
+ this.error(
397
+ '[Pyreon] faviconPlugin: a favicon `source` is configured but ' +
398
+ '`sharp` is not installed — NO favicons would be generated and ' +
399
+ 'the production build would silently ship none.\n' +
400
+ ' Fix: bun add -D sharp (or: npm i -D sharp)\n' +
401
+ ` Source: ${config.source}\n` +
402
+ 'To intentionally build without favicons, remove faviconPlugin() ' +
403
+ 'from your Vite plugins.',
404
+ )
405
+ }
406
+
325
407
  // Generate favicons for the base (default) source
326
408
  await generateFaviconSet.call(this, root, config.source, config.darkSource, '', config, themeColor, backgroundColor, generateManifest)
327
409
 
@@ -229,23 +229,45 @@ export function imagePlugin(config: ImagePluginConfig = {}): Plugin {
229
229
  isBuild = resolvedConfig.command === 'build'
230
230
  },
231
231
 
232
- async resolveId(id) {
233
- // SVG as component: import Logo from './logo.svg?component'
234
- if (svgOpts && id.includes('?component') && id.split('?')[0]!.endsWith('.svg')) {
235
- return `\0virtual:zero-svg:${id}`
236
- }
237
- // Handle ?optimize query on image imports
238
- if (id.includes('?optimize') && include.test(id.split('?')[0]!)) {
239
- return `\0virtual:zero-image:${id}`
240
- }
241
- return null
232
+ async resolveId(id, importer) {
233
+ const isSvgComponent =
234
+ svgOpts && id.includes('?component') && id.split('?')[0]!.endsWith('.svg')
235
+ const isOptimize =
236
+ id.includes('?optimize') && include.test(id.split('?')[0]!)
237
+ if (!isSvgComponent && !isOptimize) return null
238
+
239
+ // Resolve the bare specifier to an ABSOLUTE fs path the way Vite
240
+ // resolves `?url` — importer-relative + alias-aware. The old code
241
+ // embedded the raw unresolved id, so `load()` had to guess: a
242
+ // relative `./img.png?optimize` resolved against cwd (≠ the
243
+ // importer's dir → ENOENT), and an aliased `~/x.png?optimize`
244
+ // arrived already-absolute and got `join(root,'public',…)`-doubled.
245
+ // `this.resolve` (skipSelf so we don't recurse into our own
246
+ // resolveId) handles relative + aliases + extensions. A public-dir
247
+ // web path (`/foo.png?optimize`) doesn't resolve to a module →
248
+ // null → keep the original id so load()'s public/ fallback applies.
249
+ const qIdx = id.indexOf('?')
250
+ const bare = qIdx === -1 ? id : id.slice(0, qIdx)
251
+ const query = qIdx === -1 ? '' : id.slice(qIdx)
252
+ const resolved = await this.resolve(bare, importer, { skipSelf: true })
253
+ const carried = resolved ? `${resolved.id}${query}` : id
254
+
255
+ if (isSvgComponent) return `\0virtual:zero-svg:${carried}`
256
+ return `\0virtual:zero-image:${carried}`
242
257
  },
243
258
 
244
259
  async load(id) {
245
260
  // SVG component loading
246
261
  if (id.startsWith('\0virtual:zero-svg:')) {
247
262
  const rawPath = id.replace('\0virtual:zero-svg:', '').split('?')[0] ?? id
248
- const absPath = rawPath.startsWith('/') ? join(root, rawPath) : rawPath
263
+ // resolveId now carries an absolute fs path for relative/aliased
264
+ // imports → trust it if it exists. Only a public-dir web path
265
+ // (`/logo.svg`, unresolved) falls back to root-join.
266
+ const absPath = existsSync(rawPath)
267
+ ? rawPath
268
+ : rawPath.startsWith('/')
269
+ ? join(root, rawPath)
270
+ : rawPath
249
271
  if (!existsSync(absPath)) return null
250
272
 
251
273
  let svg = await readFile(absPath, 'utf-8')
@@ -287,7 +309,16 @@ export default function SvgComponent(props) {
287
309
  if (!id.startsWith('\0virtual:zero-image:')) return null
288
310
 
289
311
  const rawPath = id.replace('\0virtual:zero-image:', '').split('?')[0] ?? id
290
- const absPath = rawPath.startsWith('/') ? join(root, 'public', rawPath) : rawPath
312
+ // resolveId now carries an absolute fs path for relative/aliased
313
+ // imports (the `./img.png?optimize` and `~/img.png?optimize` cases
314
+ // that used to ENOENT / double-`public`). Trust an existing
315
+ // absolute path; only an unresolved public-dir web path
316
+ // (`/foo.png?optimize`) falls back to `<root>/public/…`.
317
+ const absPath = existsSync(rawPath)
318
+ ? rawPath
319
+ : rawPath.startsWith('/')
320
+ ? join(root, 'public', rawPath)
321
+ : rawPath
291
322
 
292
323
  // CDN mode — rewrite URLs, no local processing
293
324
  if (cdn) {