@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/README.md +15 -2
- package/lib/favicon.js +151 -6
- package/lib/favicon.js.map +1 -1
- package/lib/index.js +40 -3
- package/lib/index.js.map +1 -1
- package/lib/theme.js +5 -2
- package/lib/theme.js.map +1 -1
- package/lib/types/favicon.d.ts +17 -0
- package/lib/types/favicon.d.ts.map +1 -1
- package/lib/types/index.d.ts +18 -2
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/server.d.ts +1 -1
- package/lib/types/theme.d.ts +1 -1
- package/lib/types/theme.d.ts.map +1 -1
- package/package.json +10 -10
- package/src/favicon.ts +163 -33
- package/src/index.ts +28 -0
- package/src/theme.tsx +10 -3
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
|
|
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
|
-
|
|
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
|
|
181
|
+
// Serve generated PNGs on-demand — supports dark variants + dev badge
|
|
148
182
|
const baseName = svgUrl.split('/').pop() ?? ''
|
|
149
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
tag: 'link',
|
|
235
|
-
attrs: { rel: 'icon', type: 'image/png', sizes: '
|
|
236
|
-
injectTo: 'head',
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
tag: 'link',
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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){}})()`
|