@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.
Files changed (54) hide show
  1. package/package.json +10 -39
  2. package/src/actions.ts +0 -196
  3. package/src/adapters/bun.ts +0 -114
  4. package/src/adapters/cloudflare.ts +0 -166
  5. package/src/adapters/index.ts +0 -61
  6. package/src/adapters/netlify.ts +0 -154
  7. package/src/adapters/node.ts +0 -163
  8. package/src/adapters/static.ts +0 -42
  9. package/src/adapters/validate.ts +0 -23
  10. package/src/adapters/vercel.ts +0 -182
  11. package/src/adapters/warn-missing-env.ts +0 -49
  12. package/src/ai.ts +0 -623
  13. package/src/api-routes.ts +0 -219
  14. package/src/app.ts +0 -92
  15. package/src/cache.ts +0 -136
  16. package/src/client.ts +0 -143
  17. package/src/compression.ts +0 -116
  18. package/src/config.ts +0 -35
  19. package/src/cors.ts +0 -94
  20. package/src/csp.ts +0 -226
  21. package/src/entry-server.ts +0 -224
  22. package/src/env.ts +0 -344
  23. package/src/error-overlay.ts +0 -118
  24. package/src/favicon.ts +0 -841
  25. package/src/font.ts +0 -511
  26. package/src/fs-router.ts +0 -1519
  27. package/src/i18n-routing.ts +0 -533
  28. package/src/icon.tsx +0 -182
  29. package/src/icons-plugin.ts +0 -296
  30. package/src/image-plugin.ts +0 -751
  31. package/src/image-types.ts +0 -60
  32. package/src/image.tsx +0 -340
  33. package/src/index.ts +0 -92
  34. package/src/isr.ts +0 -394
  35. package/src/link.tsx +0 -304
  36. package/src/logger.ts +0 -144
  37. package/src/manifest.ts +0 -787
  38. package/src/meta.tsx +0 -354
  39. package/src/middleware.ts +0 -65
  40. package/src/not-found.ts +0 -44
  41. package/src/og-image.ts +0 -378
  42. package/src/rate-limit.ts +0 -140
  43. package/src/script.tsx +0 -260
  44. package/src/seo.ts +0 -617
  45. package/src/server.ts +0 -89
  46. package/src/sharp.d.ts +0 -22
  47. package/src/ssg-plugin.ts +0 -1582
  48. package/src/testing.ts +0 -146
  49. package/src/theme.tsx +0 -257
  50. package/src/types.ts +0 -624
  51. package/src/utils/use-intersection-observer.ts +0 -36
  52. package/src/utils/with-headers.ts +0 -13
  53. package/src/vercel-revalidate-handler.ts +0 -204
  54. 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
- }