@pyreon/zero 0.24.4 → 0.24.6
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/package.json +10 -39
- package/src/actions.ts +0 -196
- package/src/adapters/bun.ts +0 -114
- package/src/adapters/cloudflare.ts +0 -166
- package/src/adapters/index.ts +0 -61
- package/src/adapters/netlify.ts +0 -154
- package/src/adapters/node.ts +0 -163
- package/src/adapters/static.ts +0 -42
- package/src/adapters/validate.ts +0 -23
- package/src/adapters/vercel.ts +0 -182
- package/src/adapters/warn-missing-env.ts +0 -49
- package/src/ai.ts +0 -623
- package/src/api-routes.ts +0 -219
- package/src/app.ts +0 -92
- package/src/cache.ts +0 -136
- package/src/client.ts +0 -143
- package/src/compression.ts +0 -116
- package/src/config.ts +0 -35
- package/src/cors.ts +0 -94
- package/src/csp.ts +0 -226
- package/src/entry-server.ts +0 -224
- package/src/env.ts +0 -344
- package/src/error-overlay.ts +0 -118
- package/src/favicon.ts +0 -841
- package/src/font.ts +0 -511
- package/src/fs-router.ts +0 -1519
- package/src/i18n-routing.ts +0 -533
- package/src/icon.tsx +0 -182
- package/src/icons-plugin.ts +0 -296
- package/src/image-plugin.ts +0 -751
- package/src/image-types.ts +0 -60
- package/src/image.tsx +0 -340
- package/src/index.ts +0 -92
- package/src/isr.ts +0 -394
- package/src/link.tsx +0 -304
- package/src/logger.ts +0 -144
- package/src/manifest.ts +0 -787
- package/src/meta.tsx +0 -354
- package/src/middleware.ts +0 -65
- package/src/not-found.ts +0 -44
- package/src/og-image.ts +0 -378
- package/src/rate-limit.ts +0 -140
- package/src/script.tsx +0 -260
- package/src/seo.ts +0 -617
- package/src/server.ts +0 -89
- package/src/sharp.d.ts +0 -22
- package/src/ssg-plugin.ts +0 -1582
- package/src/testing.ts +0 -146
- package/src/theme.tsx +0 -257
- package/src/types.ts +0 -624
- package/src/utils/use-intersection-observer.ts +0 -36
- package/src/utils/with-headers.ts +0 -13
- package/src/vercel-revalidate-handler.ts +0 -204
- package/src/vite-plugin.ts +0 -848
package/src/favicon.ts
DELETED
|
@@ -1,841 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
-
import { readFile } from 'node:fs/promises'
|
|
3
|
-
import { join } from 'node:path'
|
|
4
|
-
import type { Plugin } from 'vite'
|
|
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
|
-
|
|
41
|
-
let sharpWarned = false
|
|
42
|
-
function warnSharpMissing() {
|
|
43
|
-
if (sharpWarned) return
|
|
44
|
-
sharpWarned = true
|
|
45
|
-
// oxlint-disable-next-line no-console
|
|
46
|
-
console.warn(
|
|
47
|
-
'\n[Pyreon] sharp not installed — favicons will not be generated. Install for full support: bun add -D sharp\n',
|
|
48
|
-
)
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// ─── Favicon generation plugin ──────────────────────────────────────────────
|
|
52
|
-
//
|
|
53
|
-
// Generates all favicon formats from a single source file (SVG or PNG):
|
|
54
|
-
// - favicon.ico (16x16 + 32x32 combined)
|
|
55
|
-
// - favicon.svg (copied if source is SVG)
|
|
56
|
-
// - apple-touch-icon.png (180x180)
|
|
57
|
-
// - icon-192.png (for web manifest)
|
|
58
|
-
// - icon-512.png (for web manifest)
|
|
59
|
-
// - site.webmanifest
|
|
60
|
-
//
|
|
61
|
-
// Usage:
|
|
62
|
-
// import { faviconPlugin } from "@pyreon/zero"
|
|
63
|
-
// export default { plugins: [Pyreon] }
|
|
64
|
-
|
|
65
|
-
export interface FaviconLocaleConfig {
|
|
66
|
-
/** Locale-specific source icon (SVG or PNG). */
|
|
67
|
-
source: string
|
|
68
|
-
/** Optional dark mode variant for this locale. */
|
|
69
|
-
darkSource?: string
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export interface FaviconPluginConfig {
|
|
73
|
-
/** Path to the source icon (SVG or PNG, at least 512x512 for PNG). */
|
|
74
|
-
source: string
|
|
75
|
-
/** Theme color for web manifest. Default: "#ffffff" */
|
|
76
|
-
themeColor?: string
|
|
77
|
-
/** Background color for web manifest. Default: "#ffffff" */
|
|
78
|
-
backgroundColor?: string
|
|
79
|
-
/** App name for web manifest. Uses package.json name if not set. */
|
|
80
|
-
name?: string
|
|
81
|
-
/** Generate web manifest. Default: true */
|
|
82
|
-
manifest?: boolean
|
|
83
|
-
/**
|
|
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).
|
|
99
|
-
*/
|
|
100
|
-
darkSource?: string
|
|
101
|
-
/**
|
|
102
|
-
* Locale-specific icon overrides. Each key is a locale code,
|
|
103
|
-
* value is a source icon (and optional dark variant).
|
|
104
|
-
* Locales not in this map use the base `source`.
|
|
105
|
-
*
|
|
106
|
-
* Generated files are placed under `/{locale}/` prefix:
|
|
107
|
-
* /de/favicon.svg, /de/favicon-32x32.png, etc.
|
|
108
|
-
*
|
|
109
|
-
* @example
|
|
110
|
-
* ```ts
|
|
111
|
-
* faviconPlugin({
|
|
112
|
-
* source: "./icon.svg",
|
|
113
|
-
* locales: {
|
|
114
|
-
* de: { source: "./icon-de.svg" },
|
|
115
|
-
* cs: { source: "./icon-cs.svg" },
|
|
116
|
-
* },
|
|
117
|
-
* })
|
|
118
|
-
* ```
|
|
119
|
-
*/
|
|
120
|
-
locales?: Record<string, FaviconLocaleConfig>
|
|
121
|
-
/**
|
|
122
|
-
* Dev mode favicon — shown only during development to distinguish
|
|
123
|
-
* dev tabs from production. Can be:
|
|
124
|
-
* - A path to a separate icon file
|
|
125
|
-
* - `true` to auto-generate a dev badge (grayscale + "DEV" overlay)
|
|
126
|
-
*
|
|
127
|
-
* @example
|
|
128
|
-
* ```ts
|
|
129
|
-
* faviconPlugin({
|
|
130
|
-
* source: "./icon.svg",
|
|
131
|
-
* devSource: "./icon-dev.svg", // custom dev icon
|
|
132
|
-
* // OR
|
|
133
|
-
* devSource: true, // auto-generate grayscale badge
|
|
134
|
-
* })
|
|
135
|
-
* ```
|
|
136
|
-
*/
|
|
137
|
-
devSource?: string | boolean
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
interface FaviconSize {
|
|
141
|
-
size: number
|
|
142
|
-
name: string
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
const SIZES: FaviconSize[] = [
|
|
146
|
-
{ size: 16, name: 'favicon-16x16.png' },
|
|
147
|
-
{ size: 32, name: 'favicon-32x32.png' },
|
|
148
|
-
{ size: 180, name: 'apple-touch-icon.png' },
|
|
149
|
-
{ size: 192, name: 'icon-192.png' },
|
|
150
|
-
{ size: 512, name: 'icon-512.png' },
|
|
151
|
-
]
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Favicon generation Vite plugin.
|
|
155
|
-
*
|
|
156
|
-
* Generates all required favicon formats at build time from a single source.
|
|
157
|
-
* In dev mode, serves the source directly.
|
|
158
|
-
*
|
|
159
|
-
* @example
|
|
160
|
-
* ```ts
|
|
161
|
-
* // vite.config.ts
|
|
162
|
-
* import { faviconPlugin } from "@pyreon/zero"
|
|
163
|
-
*
|
|
164
|
-
* export default {
|
|
165
|
-
* plugins: [faviconPlugin({ source: "./src/assets/icon.svg" })],
|
|
166
|
-
* }
|
|
167
|
-
* ```
|
|
168
|
-
*/
|
|
169
|
-
export function faviconPlugin(config: FaviconPluginConfig): Plugin {
|
|
170
|
-
const themeColor = config.themeColor ?? '#ffffff'
|
|
171
|
-
const backgroundColor = config.backgroundColor ?? '#ffffff'
|
|
172
|
-
const generateManifest = config.manifest !== false
|
|
173
|
-
|
|
174
|
-
let root = ''
|
|
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
|
-
}
|
|
187
|
-
|
|
188
|
-
return {
|
|
189
|
-
name: 'pyreon-zero-favicon',
|
|
190
|
-
enforce: 'pre',
|
|
191
|
-
|
|
192
|
-
configResolved(resolvedConfig) {
|
|
193
|
-
root = resolvedConfig.root
|
|
194
|
-
isBuild = resolvedConfig.command === 'build'
|
|
195
|
-
},
|
|
196
|
-
|
|
197
|
-
// Dev server: serve generated favicons on-the-fly
|
|
198
|
-
configureServer(server) {
|
|
199
|
-
const sourcePath = join(root, config.source)
|
|
200
|
-
const darkPath = config.darkSource ? join(root, config.darkSource) : null
|
|
201
|
-
const devSourcePath = typeof config.devSource === 'string'
|
|
202
|
-
? join(root, config.devSource)
|
|
203
|
-
: null
|
|
204
|
-
const autoDevBadge = config.devSource === true
|
|
205
|
-
const devCache = new Map<string, Uint8Array>()
|
|
206
|
-
|
|
207
|
-
/** Resolve source path for a request — handles dark variants and dev badge. */
|
|
208
|
-
function resolveSourceForDev(baseName: string, defaultSource: string): string {
|
|
209
|
-
// Dark variant: favicon-dark-32x32.png → use darkSource
|
|
210
|
-
if (darkPath && baseName.includes('-dark-')) return darkPath
|
|
211
|
-
// Light variant: favicon-light-32x32.png → use source
|
|
212
|
-
if (baseName.includes('-light-')) return defaultSource
|
|
213
|
-
return defaultSource
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
server.middlewares.use(async (req, res, next) => {
|
|
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]!
|
|
222
|
-
|
|
223
|
-
// Resolve locale-specific source
|
|
224
|
-
const localeSource = resolveLocaleSource(url, config, root)
|
|
225
|
-
const svgUrl = localeSource ? localeSource.url : url
|
|
226
|
-
const svgPath = localeSource ? localeSource.sourcePath : sourcePath
|
|
227
|
-
const isSvgSource = localeSource ? localeSource.source.endsWith('.svg') : config.source.endsWith('.svg')
|
|
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
|
-
|
|
257
|
-
// Serve favicon.svg — in dev, add dev badge overlay if configured
|
|
258
|
-
if (svgUrl.endsWith('/favicon.svg') && isSvgSource) {
|
|
259
|
-
try {
|
|
260
|
-
let content = await readFile(svgPath, 'utf-8')
|
|
261
|
-
if (autoDevBadge) content = addDevBadgeToSvg(content)
|
|
262
|
-
else if (devSourcePath && existsSync(devSourcePath)) {
|
|
263
|
-
content = await readFile(devSourcePath, 'utf-8')
|
|
264
|
-
}
|
|
265
|
-
res.setHeader('Content-Type', 'image/svg+xml')
|
|
266
|
-
res.end(content)
|
|
267
|
-
return
|
|
268
|
-
} catch { /* fall through */ }
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// Serve generated PNGs on-demand — supports dark variants + dev badge
|
|
272
|
-
const baseName = svgUrl.split('/').pop() ?? ''
|
|
273
|
-
// Strip light-/dark- prefix for size matching
|
|
274
|
-
const cleanName = baseName.replace(/-?(light|dark)-/, '-')
|
|
275
|
-
const sizeMatch = SIZES.find((s) => s.name === cleanName || baseName === s.name)
|
|
276
|
-
if (sizeMatch) {
|
|
277
|
-
const resolvedSource = resolveSourceForDev(baseName, svgPath)
|
|
278
|
-
const cacheKey = `${resolvedSource}:${sizeMatch.size}:${autoDevBadge}`
|
|
279
|
-
let png = devCache.get(cacheKey)
|
|
280
|
-
if (!png) {
|
|
281
|
-
let result = await resizeToPng(resolvedSource, sizeMatch.size)
|
|
282
|
-
if (result && autoDevBadge) {
|
|
283
|
-
result = await addDevBadgeToPng(result, sizeMatch.size)
|
|
284
|
-
}
|
|
285
|
-
if (result) {
|
|
286
|
-
png = result
|
|
287
|
-
devCache.set(cacheKey, result)
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
if (png) {
|
|
291
|
-
res.setHeader('Content-Type', 'image/png')
|
|
292
|
-
res.setHeader('Cache-Control', 'no-cache')
|
|
293
|
-
res.end(Buffer.from(png))
|
|
294
|
-
return
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// Serve generated ICO on-demand
|
|
299
|
-
if (baseName === 'favicon.ico') {
|
|
300
|
-
const cacheKey = `ico:${svgPath}`
|
|
301
|
-
let ico: Uint8Array | undefined = devCache.get(cacheKey)
|
|
302
|
-
if (!ico) {
|
|
303
|
-
const result = await generateIco(svgPath)
|
|
304
|
-
if (result) {
|
|
305
|
-
ico = result
|
|
306
|
-
devCache.set(cacheKey, result)
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
if (ico) {
|
|
310
|
-
res.setHeader('Content-Type', 'image/x-icon')
|
|
311
|
-
res.setHeader('Cache-Control', 'no-cache')
|
|
312
|
-
res.end(Buffer.from(ico))
|
|
313
|
-
return
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// Serve manifest (supports /{locale}/site.webmanifest)
|
|
318
|
-
if (baseName === 'site.webmanifest' && generateManifest) {
|
|
319
|
-
const prefix = localeSource ? `/${localeSource.locale}` : ''
|
|
320
|
-
const manifest = {
|
|
321
|
-
name: config.name ?? 'App',
|
|
322
|
-
short_name: config.name ?? 'App',
|
|
323
|
-
icons: [
|
|
324
|
-
{ src: `${prefix}/icon-192.png`, sizes: '192x192', type: 'image/png' },
|
|
325
|
-
{ src: `${prefix}/icon-512.png`, sizes: '512x512', type: 'image/png' },
|
|
326
|
-
],
|
|
327
|
-
theme_color: themeColor,
|
|
328
|
-
background_color: backgroundColor,
|
|
329
|
-
display: 'standalone',
|
|
330
|
-
}
|
|
331
|
-
res.setHeader('Content-Type', 'application/manifest+json')
|
|
332
|
-
res.end(JSON.stringify(manifest, null, 2))
|
|
333
|
-
return
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
next()
|
|
337
|
-
})
|
|
338
|
-
},
|
|
339
|
-
|
|
340
|
-
// Inject favicon <link> tags into HTML
|
|
341
|
-
transformIndexHtml() {
|
|
342
|
-
const isSvg = config.source.endsWith('.svg')
|
|
343
|
-
const hasDark = !!config.darkSource
|
|
344
|
-
const tags: Array<{
|
|
345
|
-
tag: string
|
|
346
|
-
attrs: Record<string, string>
|
|
347
|
-
injectTo: 'head'
|
|
348
|
-
}> = []
|
|
349
|
-
|
|
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) {
|
|
366
|
-
tags.push({
|
|
367
|
-
tag: 'link',
|
|
368
|
-
attrs: { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
|
|
369
|
-
injectTo: 'head',
|
|
370
|
-
})
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
if (hasDark) {
|
|
374
|
-
// Dual-variant PNG/ICO favicons — light active, dark hidden via media="not all".
|
|
375
|
-
// The themeScript and initTheme() swap these based on the resolved theme.
|
|
376
|
-
const lightAttrs = { 'data-favicon-theme': 'light' }
|
|
377
|
-
const darkAttrs = { 'data-favicon-theme': 'dark', media: 'not all' }
|
|
378
|
-
|
|
379
|
-
tags.push(
|
|
380
|
-
{ tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-light-32x32.png', ...lightAttrs }, injectTo: 'head' },
|
|
381
|
-
{ tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-dark-32x32.png', ...darkAttrs }, injectTo: 'head' },
|
|
382
|
-
{ tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '16x16', href: '/favicon-light-16x16.png', ...lightAttrs }, injectTo: 'head' },
|
|
383
|
-
{ tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '16x16', href: '/favicon-dark-16x16.png', ...darkAttrs }, injectTo: 'head' },
|
|
384
|
-
{ tag: 'link', attrs: { rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon-light.png', ...lightAttrs }, injectTo: 'head' },
|
|
385
|
-
{ tag: 'link', attrs: { rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon-dark.png', ...darkAttrs }, injectTo: 'head' },
|
|
386
|
-
)
|
|
387
|
-
} else {
|
|
388
|
-
// Single-variant (no dark mode)
|
|
389
|
-
tags.push(
|
|
390
|
-
{ tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-32x32.png' }, injectTo: 'head' },
|
|
391
|
-
{ tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '16x16', href: '/favicon-16x16.png' }, injectTo: 'head' },
|
|
392
|
-
{ tag: 'link', attrs: { rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon.png' }, injectTo: 'head' },
|
|
393
|
-
)
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
if (generateManifest) {
|
|
397
|
-
tags.push({
|
|
398
|
-
tag: 'link',
|
|
399
|
-
attrs: { rel: 'manifest', href: '/site.webmanifest' },
|
|
400
|
-
injectTo: 'head',
|
|
401
|
-
})
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
tags.push({
|
|
405
|
-
tag: 'meta',
|
|
406
|
-
attrs: { name: 'theme-color', content: themeColor },
|
|
407
|
-
injectTo: 'head',
|
|
408
|
-
})
|
|
409
|
-
|
|
410
|
-
// Auto-inject favicon swap script when dark variant exists.
|
|
411
|
-
// This runs in the blocking <head> before any render — no flash.
|
|
412
|
-
// Reads theme from localStorage or OS preference, then swaps
|
|
413
|
-
// data-favicon-theme media attributes.
|
|
414
|
-
if (hasDark) {
|
|
415
|
-
tags.push({
|
|
416
|
-
tag: 'script',
|
|
417
|
-
attrs: {},
|
|
418
|
-
injectTo: 'head',
|
|
419
|
-
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){}})()`,
|
|
420
|
-
} as any)
|
|
421
|
-
}
|
|
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
|
-
|
|
434
|
-
return tags
|
|
435
|
-
},
|
|
436
|
-
|
|
437
|
-
async generateBundle() {
|
|
438
|
-
if (!isBuild) return
|
|
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
|
-
|
|
461
|
-
// Generate favicons for the base (default) source
|
|
462
|
-
await generateFaviconSet.call(this, root, config.source, config.darkSource, '', config, themeColor, backgroundColor, generateManifest)
|
|
463
|
-
|
|
464
|
-
// Generate locale-specific favicon sets
|
|
465
|
-
if (config.locales) {
|
|
466
|
-
for (const [locale, localeConfig] of Object.entries(config.locales)) {
|
|
467
|
-
await generateFaviconSet.call(this, root, localeConfig.source, localeConfig.darkSource, `${locale}/`, config, themeColor, backgroundColor, generateManifest)
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
},
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
/**
|
|
475
|
-
* Wrap two SVGs into a single SVG that switches based on prefers-color-scheme.
|
|
476
|
-
*/
|
|
477
|
-
function wrapSvgWithDarkMode(lightSvg: string, darkSvg: string): string {
|
|
478
|
-
// Extract viewBox from light SVG
|
|
479
|
-
const viewBoxMatch = lightSvg.match(/viewBox="([^"]*)"/)
|
|
480
|
-
const viewBox = viewBoxMatch?.[1] ?? '0 0 32 32'
|
|
481
|
-
|
|
482
|
-
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}">
|
|
483
|
-
<style>
|
|
484
|
-
:root { color-scheme: light dark; }
|
|
485
|
-
@media (prefers-color-scheme: dark) { .light { display: none; } }
|
|
486
|
-
@media (prefers-color-scheme: light), (prefers-color-scheme: no-preference) { .dark { display: none; } }
|
|
487
|
-
</style>
|
|
488
|
-
<g class="light">${stripSvgWrapper(lightSvg)}</g>
|
|
489
|
-
<g class="dark">${stripSvgWrapper(darkSvg)}</g>
|
|
490
|
-
</svg>`
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
function stripSvgWrapper(svg: string): string {
|
|
494
|
-
return svg
|
|
495
|
-
.replace(/<svg[^>]*>/, '')
|
|
496
|
-
.replace(/<\/svg>\s*$/, '')
|
|
497
|
-
.trim()
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
/**
|
|
501
|
-
* Resolve the source path for a locale-prefixed favicon URL.
|
|
502
|
-
* Returns null if the URL is not locale-prefixed or locale has no override.
|
|
503
|
-
*/
|
|
504
|
-
function resolveLocaleSource(
|
|
505
|
-
url: string,
|
|
506
|
-
config: FaviconPluginConfig,
|
|
507
|
-
rootDir: string,
|
|
508
|
-
): { locale: string; url: string; source: string; sourcePath: string } | null {
|
|
509
|
-
if (!config.locales) return null
|
|
510
|
-
|
|
511
|
-
for (const [locale, localeConfig] of Object.entries(config.locales)) {
|
|
512
|
-
const prefix = `/${locale}/`
|
|
513
|
-
if (url.startsWith(prefix)) {
|
|
514
|
-
return {
|
|
515
|
-
locale,
|
|
516
|
-
url,
|
|
517
|
-
source: localeConfig.source,
|
|
518
|
-
sourcePath: join(rootDir, localeConfig.source),
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
return null
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
/**
|
|
526
|
-
* Generate a complete favicon set (SVG, PNGs, ICO, manifest) with a file prefix.
|
|
527
|
-
* Called once for base (prefix = '') and once per locale (prefix = '{locale}/').
|
|
528
|
-
*/
|
|
529
|
-
async function generateFaviconSet(
|
|
530
|
-
this: any,
|
|
531
|
-
rootDir: string,
|
|
532
|
-
source: string,
|
|
533
|
-
darkSource: string | undefined,
|
|
534
|
-
prefix: string,
|
|
535
|
-
config: FaviconPluginConfig,
|
|
536
|
-
themeColor: string,
|
|
537
|
-
backgroundColor: string,
|
|
538
|
-
generateManifest: boolean,
|
|
539
|
-
): Promise<void> {
|
|
540
|
-
const sourcePath = join(rootDir, source)
|
|
541
|
-
if (!existsSync(sourcePath)) {
|
|
542
|
-
// oxlint-disable-next-line no-console
|
|
543
|
-
console.warn(`[Pyreon] Source not found: ${sourcePath}`)
|
|
544
|
-
return
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
const isSvg = source.endsWith('.svg')
|
|
548
|
-
|
|
549
|
-
// Copy SVG as favicon.svg
|
|
550
|
-
if (isSvg) {
|
|
551
|
-
const svgContent = await readFile(sourcePath, 'utf-8')
|
|
552
|
-
let finalSvg = svgContent
|
|
553
|
-
|
|
554
|
-
if (darkSource) {
|
|
555
|
-
const darkPath = join(rootDir, darkSource)
|
|
556
|
-
if (existsSync(darkPath)) {
|
|
557
|
-
const darkSvg = await readFile(darkPath, 'utf-8')
|
|
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
|
-
})
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
this.emitFile({
|
|
579
|
-
type: 'asset',
|
|
580
|
-
fileName: `${prefix}favicon.svg`,
|
|
581
|
-
source: finalSvg,
|
|
582
|
-
})
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
// Generate PNG sizes via sharp
|
|
586
|
-
if (darkSource) {
|
|
587
|
-
// Dual-variant: generate light + dark PNGs with prefixed names
|
|
588
|
-
const darkPath = join(rootDir, darkSource)
|
|
589
|
-
const darkExists = existsSync(darkPath)
|
|
590
|
-
|
|
591
|
-
for (const { size, name } of SIZES) {
|
|
592
|
-
// Light variant
|
|
593
|
-
const lightName = name.replace(/^(favicon-)/, '$1light-').replace(/^(apple-touch-icon)/, '$1-light').replace(/^(icon-)/, '$1light-')
|
|
594
|
-
const lightPng = await resizeToPng(sourcePath, size)
|
|
595
|
-
if (lightPng) {
|
|
596
|
-
this.emitFile({ type: 'asset', fileName: `${prefix}${lightName}`, source: lightPng })
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
// Dark variant
|
|
600
|
-
if (darkExists) {
|
|
601
|
-
const darkName = name.replace(/^(favicon-)/, '$1dark-').replace(/^(apple-touch-icon)/, '$1-dark').replace(/^(icon-)/, '$1dark-')
|
|
602
|
-
const darkPng = await resizeToPng(darkPath, size)
|
|
603
|
-
if (darkPng) {
|
|
604
|
-
this.emitFile({ type: 'asset', fileName: `${prefix}${darkName}`, source: darkPng })
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
// Also generate standard names (used by manifest + external references)
|
|
610
|
-
for (const { size, name } of SIZES) {
|
|
611
|
-
const pngBuffer = await resizeToPng(sourcePath, size)
|
|
612
|
-
if (pngBuffer) {
|
|
613
|
-
this.emitFile({ type: 'asset', fileName: `${prefix}${name}`, source: pngBuffer })
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
} else {
|
|
617
|
-
// Single-variant
|
|
618
|
-
for (const { size, name } of SIZES) {
|
|
619
|
-
const pngBuffer = await resizeToPng(sourcePath, size)
|
|
620
|
-
if (pngBuffer) {
|
|
621
|
-
this.emitFile({ type: 'asset', fileName: `${prefix}${name}`, source: pngBuffer })
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
// Generate favicon.ico (16 + 32)
|
|
627
|
-
const ico = await generateIco(sourcePath)
|
|
628
|
-
if (ico) {
|
|
629
|
-
this.emitFile({
|
|
630
|
-
type: 'asset',
|
|
631
|
-
fileName: `${prefix}favicon.ico`,
|
|
632
|
-
source: ico,
|
|
633
|
-
})
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
// Generate web manifest
|
|
637
|
-
if (generateManifest) {
|
|
638
|
-
const manifestPrefix = prefix ? `/${prefix.slice(0, -1)}` : ''
|
|
639
|
-
const manifest = {
|
|
640
|
-
name: config.name ?? 'App',
|
|
641
|
-
short_name: config.name ?? 'App',
|
|
642
|
-
icons: [
|
|
643
|
-
{ src: `${manifestPrefix}/icon-192.png`, sizes: '192x192', type: 'image/png' },
|
|
644
|
-
{ src: `${manifestPrefix}/icon-512.png`, sizes: '512x512', type: 'image/png' },
|
|
645
|
-
],
|
|
646
|
-
theme_color: themeColor,
|
|
647
|
-
background_color: backgroundColor,
|
|
648
|
-
display: 'standalone',
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
this.emitFile({
|
|
652
|
-
type: 'asset',
|
|
653
|
-
fileName: `${prefix}site.webmanifest`,
|
|
654
|
-
source: JSON.stringify(manifest, null, 2),
|
|
655
|
-
})
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
/**
|
|
660
|
-
* Get favicon link tags for a specific locale.
|
|
661
|
-
* Returns link objects suitable for `useHead()` or direct HTML injection.
|
|
662
|
-
*
|
|
663
|
-
* @example
|
|
664
|
-
* ```ts
|
|
665
|
-
* const links = faviconLinks("de", { source: "./icon.svg", locales: { de: { source: "./icon-de.svg" } } })
|
|
666
|
-
* // → [{ rel: "icon", type: "image/svg+xml", href: "/de/favicon.svg" }, ...]
|
|
667
|
-
* ```
|
|
668
|
-
*/
|
|
669
|
-
export function faviconLinks(
|
|
670
|
-
locale: string | undefined,
|
|
671
|
-
config: FaviconPluginConfig,
|
|
672
|
-
): Array<{
|
|
673
|
-
rel: string
|
|
674
|
-
type?: string
|
|
675
|
-
sizes?: string
|
|
676
|
-
href: string
|
|
677
|
-
'data-favicon-theme'?: string
|
|
678
|
-
media?: string
|
|
679
|
-
}> {
|
|
680
|
-
const hasLocaleOverride = locale && config.locales?.[locale]
|
|
681
|
-
const prefix = hasLocaleOverride ? `/${locale}` : ''
|
|
682
|
-
const isSvg = (hasLocaleOverride ? config.locales![locale]!.source : config.source).endsWith('.svg')
|
|
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) {
|
|
705
|
-
links.push({ rel: 'icon', type: 'image/svg+xml', href: `${prefix}/favicon.svg` })
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
links.push(
|
|
709
|
-
{ rel: 'icon', type: 'image/png', sizes: '32x32', href: `${prefix}/favicon-32x32.png` },
|
|
710
|
-
{ rel: 'icon', type: 'image/png', sizes: '16x16', href: `${prefix}/favicon-16x16.png` },
|
|
711
|
-
{ rel: 'apple-touch-icon', sizes: '180x180', href: `${prefix}/apple-touch-icon.png` },
|
|
712
|
-
)
|
|
713
|
-
|
|
714
|
-
if (config.manifest !== false) {
|
|
715
|
-
links.push({ rel: 'manifest', href: `${prefix}/site.webmanifest` })
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
return links
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
async function resizeToPng(input: string, size: number): Promise<Uint8Array | null> {
|
|
722
|
-
try {
|
|
723
|
-
const sharp = await import('sharp').then((m) => m.default ?? m)
|
|
724
|
-
return await sharp(input).resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } } as any).png().toBuffer()
|
|
725
|
-
} catch {
|
|
726
|
-
warnSharpMissing()
|
|
727
|
-
return null
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
async function generateIco(input: string): Promise<Uint8Array | null> {
|
|
732
|
-
try {
|
|
733
|
-
const sharp = await import('sharp').then((m) => m.default ?? m)
|
|
734
|
-
const png16 = await sharp(input).resize(16, 16, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } } as any).png().toBuffer()
|
|
735
|
-
const png32 = await sharp(input).resize(32, 32, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } } as any).png().toBuffer()
|
|
736
|
-
|
|
737
|
-
// ICO format: header + directory entries + PNG data
|
|
738
|
-
return createIcoFromPngs([
|
|
739
|
-
{ buffer: png16, size: 16 },
|
|
740
|
-
{ buffer: png32, size: 32 },
|
|
741
|
-
])
|
|
742
|
-
} catch {
|
|
743
|
-
warnSharpMissing()
|
|
744
|
-
return null
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
export interface IcoEntry {
|
|
749
|
-
buffer: Buffer
|
|
750
|
-
size: number
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
/** @internal Exported for testing */
|
|
754
|
-
export function createIcoFromPngs(entries: IcoEntry[]): Uint8Array {
|
|
755
|
-
const headerSize = 6
|
|
756
|
-
const dirEntrySize = 16
|
|
757
|
-
const dirSize = dirEntrySize * entries.length
|
|
758
|
-
let dataOffset = headerSize + dirSize
|
|
759
|
-
|
|
760
|
-
// ICO header
|
|
761
|
-
const header = Buffer.alloc(headerSize)
|
|
762
|
-
header.writeUInt16LE(0, 0) // reserved
|
|
763
|
-
header.writeUInt16LE(1, 2) // type: icon
|
|
764
|
-
header.writeUInt16LE(entries.length, 4) // count
|
|
765
|
-
|
|
766
|
-
// Directory entries
|
|
767
|
-
const dirEntries = Buffer.alloc(dirSize)
|
|
768
|
-
const dataBuffers: Buffer[] = []
|
|
769
|
-
|
|
770
|
-
for (let i = 0; i < entries.length; i++) {
|
|
771
|
-
const entry = entries[i]!
|
|
772
|
-
const offset = i * dirEntrySize
|
|
773
|
-
dirEntries.writeUInt8(entry.size === 256 ? 0 : entry.size, offset) // width
|
|
774
|
-
dirEntries.writeUInt8(entry.size === 256 ? 0 : entry.size, offset + 1) // height
|
|
775
|
-
dirEntries.writeUInt8(0, offset + 2) // palette
|
|
776
|
-
dirEntries.writeUInt8(0, offset + 3) // reserved
|
|
777
|
-
dirEntries.writeUInt16LE(1, offset + 4) // color planes
|
|
778
|
-
dirEntries.writeUInt16LE(32, offset + 6) // bits per pixel
|
|
779
|
-
dirEntries.writeUInt32LE(entry.buffer.length, offset + 8) // size
|
|
780
|
-
dirEntries.writeUInt32LE(dataOffset, offset + 12) // offset
|
|
781
|
-
|
|
782
|
-
dataOffset += entry.buffer.length
|
|
783
|
-
dataBuffers.push(entry.buffer)
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
return Buffer.concat([header, dirEntries, ...dataBuffers])
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
// ─── Dev badge helpers ──────────────────────────────────────────────────────
|
|
790
|
-
|
|
791
|
-
/**
|
|
792
|
-
* Add a "DEV" badge overlay to an SVG string.
|
|
793
|
-
* Adds a small colored circle with "DEV" text in the bottom-right corner.
|
|
794
|
-
*/
|
|
795
|
-
function addDevBadgeToSvg(svg: string): string {
|
|
796
|
-
const viewBoxMatch = svg.match(/viewBox="([^"]*)"/)
|
|
797
|
-
const viewBox = viewBoxMatch?.[1] ?? '0 0 32 32'
|
|
798
|
-
const [, , w, h] = viewBox.split(' ').map(Number)
|
|
799
|
-
const size = Math.min(w ?? 32, h ?? 32)
|
|
800
|
-
const r = size * 0.28
|
|
801
|
-
const cx = (w ?? 32) - r
|
|
802
|
-
const cy = (h ?? 32) - r
|
|
803
|
-
const fontSize = r * 0.85
|
|
804
|
-
|
|
805
|
-
const badge = `<circle cx="${cx}" cy="${cy}" r="${r}" fill="#ef4444" stroke="white" stroke-width="${size * 0.03}"/>` +
|
|
806
|
-
`<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>`
|
|
807
|
-
|
|
808
|
-
// Insert badge before closing </svg>
|
|
809
|
-
return svg.replace(/<\/svg>\s*$/, `${badge}</svg>`)
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
/**
|
|
813
|
-
* Add a "DEV" badge to a PNG buffer via sharp composite.
|
|
814
|
-
* Composites a red circle with "D" in the bottom-right corner.
|
|
815
|
-
*/
|
|
816
|
-
async function addDevBadgeToPng(pngBuffer: Uint8Array, size: number): Promise<Uint8Array> {
|
|
817
|
-
try {
|
|
818
|
-
const sharp = await import('sharp').then((m) => m.default ?? m)
|
|
819
|
-
const r = Math.round(size * 0.28)
|
|
820
|
-
const d = r * 2
|
|
821
|
-
const fontSize = Math.round(r * 0.85)
|
|
822
|
-
|
|
823
|
-
const badgeSvg = `<svg width="${d}" height="${d}" xmlns="http://www.w3.org/2000/svg">
|
|
824
|
-
<circle cx="${r}" cy="${r}" r="${r}" fill="#ef4444"/>
|
|
825
|
-
<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>
|
|
826
|
-
</svg>`
|
|
827
|
-
|
|
828
|
-
const badgePng = await sharp(Buffer.from(badgeSvg)).png().toBuffer()
|
|
829
|
-
|
|
830
|
-
return await (sharp(Buffer.from(pngBuffer)) as any)
|
|
831
|
-
.composite([{
|
|
832
|
-
input: badgePng,
|
|
833
|
-
gravity: 'southeast',
|
|
834
|
-
}])
|
|
835
|
-
.png()
|
|
836
|
-
.toBuffer()
|
|
837
|
-
} catch {
|
|
838
|
-
// sharp not available — return original
|
|
839
|
-
return pngBuffer
|
|
840
|
-
}
|
|
841
|
-
}
|