@pyreon/zero 0.12.4 → 0.12.5

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/src/favicon.ts CHANGED
@@ -71,6 +71,23 @@ export interface FaviconPluginConfig {
71
71
  * ```
72
72
  */
73
73
  locales?: Record<string, FaviconLocaleConfig>
74
+ /**
75
+ * Dev mode favicon — shown only during development to distinguish
76
+ * dev tabs from production. Can be:
77
+ * - A path to a separate icon file
78
+ * - `true` to auto-generate a dev badge (grayscale + "DEV" overlay)
79
+ *
80
+ * @example
81
+ * ```ts
82
+ * faviconPlugin({
83
+ * source: "./icon.svg",
84
+ * devSource: "./icon-dev.svg", // custom dev icon
85
+ * // OR
86
+ * devSource: true, // auto-generate grayscale badge
87
+ * })
88
+ * ```
89
+ */
90
+ devSource?: string | boolean
74
91
  }
75
92
 
76
93
  interface FaviconSize {
@@ -122,36 +139,59 @@ export function faviconPlugin(config: FaviconPluginConfig): Plugin {
122
139
  // Dev server: serve generated favicons on-the-fly
123
140
  configureServer(server) {
124
141
  const sourcePath = join(root, config.source)
142
+ const darkPath = config.darkSource ? join(root, config.darkSource) : null
143
+ const devSourcePath = typeof config.devSource === 'string'
144
+ ? join(root, config.devSource)
145
+ : null
146
+ const autoDevBadge = config.devSource === true
125
147
  const devCache = new Map<string, Uint8Array>()
126
148
 
149
+ /** Resolve source path for a request — handles dark variants and dev badge. */
150
+ function resolveSourceForDev(baseName: string, defaultSource: string): string {
151
+ // Dark variant: favicon-dark-32x32.png → use darkSource
152
+ if (darkPath && baseName.includes('-dark-')) return darkPath
153
+ // Light variant: favicon-light-32x32.png → use source
154
+ if (baseName.includes('-light-')) return defaultSource
155
+ return defaultSource
156
+ }
157
+
127
158
  server.middlewares.use(async (req, res, next) => {
128
159
  const url = req.url ?? ''
129
160
 
130
- // Resolve locale-specific source: /{locale}/favicon.svg → locale source
161
+ // Resolve locale-specific source
131
162
  const localeSource = resolveLocaleSource(url, config, root)
132
-
133
- // Serve source as favicon.svg in dev
134
163
  const svgUrl = localeSource ? localeSource.url : url
135
164
  const svgPath = localeSource ? localeSource.sourcePath : sourcePath
136
165
  const isSvgSource = localeSource ? localeSource.source.endsWith('.svg') : config.source.endsWith('.svg')
137
166
 
167
+ // Serve favicon.svg — in dev, add dev badge overlay if configured
138
168
  if (svgUrl.endsWith('/favicon.svg') && isSvgSource) {
139
169
  try {
140
- const content = await readFile(svgPath, 'utf-8')
170
+ let content = await readFile(svgPath, 'utf-8')
171
+ if (autoDevBadge) content = addDevBadgeToSvg(content)
172
+ else if (devSourcePath && existsSync(devSourcePath)) {
173
+ content = await readFile(devSourcePath, 'utf-8')
174
+ }
141
175
  res.setHeader('Content-Type', 'image/svg+xml')
142
176
  res.end(content)
143
177
  return
144
178
  } catch { /* fall through */ }
145
179
  }
146
180
 
147
- // Serve generated PNGs on-demand (supports /{locale}/favicon-32x32.png)
181
+ // Serve generated PNGs on-demand supports dark variants + dev badge
148
182
  const baseName = svgUrl.split('/').pop() ?? ''
149
- const sizeMatch = SIZES.find((s) => s.name === baseName)
183
+ // Strip light-/dark- prefix for size matching
184
+ const cleanName = baseName.replace(/-?(light|dark)-/, '-')
185
+ const sizeMatch = SIZES.find((s) => s.name === cleanName || baseName === s.name)
150
186
  if (sizeMatch) {
151
- const cacheKey = `${svgPath}:${sizeMatch.size}`
187
+ const resolvedSource = resolveSourceForDev(baseName, svgPath)
188
+ const cacheKey = `${resolvedSource}:${sizeMatch.size}:${autoDevBadge}`
152
189
  let png = devCache.get(cacheKey)
153
190
  if (!png) {
154
- const result = await resizeToPng(svgPath, sizeMatch.size)
191
+ let result = await resizeToPng(resolvedSource, sizeMatch.size)
192
+ if (result && autoDevBadge) {
193
+ result = await addDevBadgeToPng(result, sizeMatch.size)
194
+ }
155
195
  if (result) {
156
196
  png = result
157
197
  devCache.set(cacheKey, result)
@@ -210,12 +250,14 @@ export function faviconPlugin(config: FaviconPluginConfig): Plugin {
210
250
  // Inject favicon <link> tags into HTML
211
251
  transformIndexHtml() {
212
252
  const isSvg = config.source.endsWith('.svg')
253
+ const hasDark = !!config.darkSource
213
254
  const tags: Array<{
214
255
  tag: string
215
256
  attrs: Record<string, string>
216
257
  injectTo: 'head'
217
258
  }> = []
218
259
 
260
+ // SVG favicon (with prefers-color-scheme media query when dark variant exists)
219
261
  if (isSvg) {
220
262
  tags.push({
221
263
  tag: 'link',
@@ -224,23 +266,28 @@ export function faviconPlugin(config: FaviconPluginConfig): Plugin {
224
266
  })
225
267
  }
226
268
 
227
- tags.push(
228
- {
229
- tag: 'link',
230
- attrs: { rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-32x32.png' },
231
- injectTo: 'head',
232
- },
233
- {
234
- tag: 'link',
235
- attrs: { rel: 'icon', type: 'image/png', sizes: '16x16', href: '/favicon-16x16.png' },
236
- injectTo: 'head',
237
- },
238
- {
239
- tag: 'link',
240
- attrs: { rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon.png' },
241
- injectTo: 'head',
242
- },
243
- )
269
+ if (hasDark) {
270
+ // Dual-variant PNG/ICO favicons — light active, dark hidden via media="not all".
271
+ // The themeScript and initTheme() swap these based on the resolved theme.
272
+ const lightAttrs = { 'data-favicon-theme': 'light' }
273
+ const darkAttrs = { 'data-favicon-theme': 'dark', media: 'not all' }
274
+
275
+ tags.push(
276
+ { tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-light-32x32.png', ...lightAttrs }, injectTo: 'head' },
277
+ { tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-dark-32x32.png', ...darkAttrs }, injectTo: 'head' },
278
+ { tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '16x16', href: '/favicon-light-16x16.png', ...lightAttrs }, injectTo: 'head' },
279
+ { tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '16x16', href: '/favicon-dark-16x16.png', ...darkAttrs }, injectTo: 'head' },
280
+ { tag: 'link', attrs: { rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon-light.png', ...lightAttrs }, injectTo: 'head' },
281
+ { tag: 'link', attrs: { rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon-dark.png', ...darkAttrs }, injectTo: 'head' },
282
+ )
283
+ } else {
284
+ // Single-variant (no dark mode)
285
+ tags.push(
286
+ { tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-32x32.png' }, injectTo: 'head' },
287
+ { tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '16x16', href: '/favicon-16x16.png' }, injectTo: 'head' },
288
+ { tag: 'link', attrs: { rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon.png' }, injectTo: 'head' },
289
+ )
290
+ }
244
291
 
245
292
  if (generateManifest) {
246
293
  tags.push({
@@ -371,14 +418,43 @@ async function generateFaviconSet(
371
418
  }
372
419
 
373
420
  // Generate PNG sizes via sharp
374
- for (const { size, name } of SIZES) {
375
- const pngBuffer = await resizeToPng(sourcePath, size)
376
- if (pngBuffer) {
377
- this.emitFile({
378
- type: 'asset',
379
- fileName: `${prefix}${name}`,
380
- source: pngBuffer,
381
- })
421
+ if (darkSource) {
422
+ // Dual-variant: generate light + dark PNGs with prefixed names
423
+ const darkPath = join(rootDir, darkSource)
424
+ const darkExists = existsSync(darkPath)
425
+
426
+ for (const { size, name } of SIZES) {
427
+ // Light variant
428
+ const lightName = name.replace(/^(favicon-)/, '$1light-').replace(/^(apple-touch-icon)/, '$1-light').replace(/^(icon-)/, '$1light-')
429
+ const lightPng = await resizeToPng(sourcePath, size)
430
+ if (lightPng) {
431
+ this.emitFile({ type: 'asset', fileName: `${prefix}${lightName}`, source: lightPng })
432
+ }
433
+
434
+ // Dark variant
435
+ if (darkExists) {
436
+ const darkName = name.replace(/^(favicon-)/, '$1dark-').replace(/^(apple-touch-icon)/, '$1-dark').replace(/^(icon-)/, '$1dark-')
437
+ const darkPng = await resizeToPng(darkPath, size)
438
+ if (darkPng) {
439
+ this.emitFile({ type: 'asset', fileName: `${prefix}${darkName}`, source: darkPng })
440
+ }
441
+ }
442
+ }
443
+
444
+ // Also generate standard names (used by manifest + external references)
445
+ for (const { size, name } of SIZES) {
446
+ const pngBuffer = await resizeToPng(sourcePath, size)
447
+ if (pngBuffer) {
448
+ this.emitFile({ type: 'asset', fileName: `${prefix}${name}`, source: pngBuffer })
449
+ }
450
+ }
451
+ } else {
452
+ // Single-variant
453
+ for (const { size, name } of SIZES) {
454
+ const pngBuffer = await resizeToPng(sourcePath, size)
455
+ if (pngBuffer) {
456
+ this.emitFile({ type: 'asset', fileName: `${prefix}${name}`, source: pngBuffer })
457
+ }
382
458
  }
383
459
  }
384
460
 
@@ -519,3 +595,57 @@ export function createIcoFromPngs(entries: IcoEntry[]): Uint8Array {
519
595
 
520
596
  return Buffer.concat([header, dirEntries, ...dataBuffers])
521
597
  }
598
+
599
+ // ─── Dev badge helpers ──────────────────────────────────────────────────────
600
+
601
+ /**
602
+ * Add a "DEV" badge overlay to an SVG string.
603
+ * Adds a small colored circle with "DEV" text in the bottom-right corner.
604
+ */
605
+ function addDevBadgeToSvg(svg: string): string {
606
+ const viewBoxMatch = svg.match(/viewBox="([^"]*)"/)
607
+ const viewBox = viewBoxMatch?.[1] ?? '0 0 32 32'
608
+ const [, , w, h] = viewBox.split(' ').map(Number)
609
+ const size = Math.min(w ?? 32, h ?? 32)
610
+ const r = size * 0.28
611
+ const cx = (w ?? 32) - r
612
+ const cy = (h ?? 32) - r
613
+ const fontSize = r * 0.85
614
+
615
+ const badge = `<circle cx="${cx}" cy="${cy}" r="${r}" fill="#ef4444" stroke="white" stroke-width="${size * 0.03}"/>` +
616
+ `<text x="${cx}" y="${cy}" font-size="${fontSize}" font-weight="bold" fill="white" text-anchor="middle" dominant-baseline="central" font-family="sans-serif">D</text>`
617
+
618
+ // Insert badge before closing </svg>
619
+ return svg.replace(/<\/svg>\s*$/, `${badge}</svg>`)
620
+ }
621
+
622
+ /**
623
+ * Add a "DEV" badge to a PNG buffer via sharp composite.
624
+ * Composites a red circle with "D" in the bottom-right corner.
625
+ */
626
+ async function addDevBadgeToPng(pngBuffer: Uint8Array, size: number): Promise<Uint8Array> {
627
+ try {
628
+ const sharp = await import('sharp').then((m) => m.default ?? m)
629
+ const r = Math.round(size * 0.28)
630
+ const d = r * 2
631
+ const fontSize = Math.round(r * 0.85)
632
+
633
+ const badgeSvg = `<svg width="${d}" height="${d}" xmlns="http://www.w3.org/2000/svg">
634
+ <circle cx="${r}" cy="${r}" r="${r}" fill="#ef4444"/>
635
+ <text x="${r}" y="${r}" font-size="${fontSize}" font-weight="bold" fill="white" text-anchor="middle" dominant-baseline="central" font-family="sans-serif">D</text>
636
+ </svg>`
637
+
638
+ const badgePng = await sharp(Buffer.from(badgeSvg)).png().toBuffer()
639
+
640
+ return await (sharp(Buffer.from(pngBuffer)) as any)
641
+ .composite([{
642
+ input: badgePng,
643
+ gravity: 'southeast',
644
+ }])
645
+ .png()
646
+ .toBuffer()
647
+ } catch {
648
+ // sharp not available — return original
649
+ return pngBuffer
650
+ }
651
+ }
package/src/index.ts CHANGED
@@ -46,6 +46,34 @@ export {
46
46
  useLocale,
47
47
  } from "./i18n-routing";
48
48
 
49
+ // ─── Server-only stubs ──────────────────────────────────────────────────────
50
+ // Throw clear error messages when developers accidentally import server-only
51
+ // APIs from the main entry. These are tree-shaken if not imported.
52
+
53
+ function serverOnly(name: string, subpath: string): never {
54
+ throw new Error(
55
+ `[Pyreon] "${name}" is server-only and cannot be imported from "@pyreon/zero".\n` +
56
+ `Import from the subpath instead:\n\n` +
57
+ ` import { ${name} } from "@pyreon/zero/${subpath}"\n`,
58
+ )
59
+ }
60
+
61
+ /* eslint-disable @typescript-eslint/no-unused-vars */
62
+ /** @deprecated Import from `@pyreon/zero/favicon` instead */
63
+ export function faviconPlugin(..._: unknown[]): never { return serverOnly('faviconPlugin', 'favicon') }
64
+ /** @deprecated Import from `@pyreon/zero/seo` instead */
65
+ export function seoPlugin(..._: unknown[]): never { return serverOnly('seoPlugin', 'seo') }
66
+ /** @deprecated Import from `@pyreon/zero/server` instead */
67
+ export function createServer(..._: unknown[]): never { return serverOnly('createServer', 'server') }
68
+ /** @deprecated Import from `@pyreon/zero/config` instead */
69
+ export function defineConfig(..._: unknown[]): never { return serverOnly('defineConfig', 'config') }
70
+ /** @deprecated Import from `@pyreon/zero/env` instead */
71
+ export function validateEnv(..._: unknown[]): never { return serverOnly('validateEnv', 'env') }
72
+ /** @deprecated Import from `@pyreon/zero/og-image` instead */
73
+ export function ogImagePlugin(..._: unknown[]): never { return serverOnly('ogImagePlugin', 'og-image') }
74
+ /** @deprecated Import from `@pyreon/zero/ai` instead */
75
+ export function aiPlugin(..._: unknown[]): never { return serverOnly('aiPlugin', 'ai') }
76
+
49
77
  // ─── Types (no runtime, safe everywhere) ────────────────────────────────────
50
78
 
51
79
  export type {
package/src/theme.tsx CHANGED
@@ -86,9 +86,16 @@ export function initTheme() {
86
86
  mq.addEventListener('change', onChange)
87
87
  onUnmount(() => mq.removeEventListener('change', onChange))
88
88
 
89
- // Re-apply when theme signal changes
89
+ // Re-apply when theme signal changes — updates data-theme + favicons
90
90
  const dispose = effect(() => {
91
- document.documentElement.dataset.theme = resolvedTheme()
91
+ const mode = resolvedTheme()
92
+ document.documentElement.dataset.theme = mode
93
+
94
+ // Swap favicon variants (if dual-variant favicons are present)
95
+ const faviconLinks = document.querySelectorAll<HTMLLinkElement>('[data-favicon-theme]')
96
+ for (const link of faviconLinks) {
97
+ link.media = link.dataset.faviconTheme === mode ? '' : 'not all'
98
+ }
92
99
  })
93
100
  if (dispose) onUnmount(() => dispose.dispose())
94
101
 
@@ -169,4 +176,4 @@ export function ThemeToggle(props: { class?: string; style?: string }): VNodeChi
169
176
  * ...
170
177
  * </head>
171
178
  */
172
- export const themeScript = `(function(){try{var t=localStorage.getItem("${STORAGE_KEY}");var r=t==="light"?"light":t==="dark"?"dark":window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light";document.documentElement.dataset.theme=r}catch(e){}})()`
179
+ export const themeScript = `(function(){try{var t=localStorage.getItem("${STORAGE_KEY}");var r=t==="light"?"light":t==="dark"?"dark":window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light";document.documentElement.dataset.theme=r;document.querySelectorAll("[data-favicon-theme]").forEach(function(l){l.media=l.dataset.faviconTheme===r?"":"not all"})}catch(e){}})()`