@pyreon/zero 0.11.8 → 0.11.9

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 ADDED
@@ -0,0 +1,380 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+ import type { Plugin } from 'vite'
5
+
6
+ let sharpWarned = false
7
+ function warnSharpMissing() {
8
+ if (sharpWarned) return
9
+ sharpWarned = true
10
+ // biome-ignore lint/suspicious/noConsole: intentional build-time warning
11
+ console.warn(
12
+ '\n[zero:favicon] sharp not installed — favicons will not be generated. Install for full support: bun add -D sharp\n',
13
+ )
14
+ }
15
+
16
+ // ─── Favicon generation plugin ──────────────────────────────────────────────
17
+ //
18
+ // Generates all favicon formats from a single source file (SVG or PNG):
19
+ // - favicon.ico (16x16 + 32x32 combined)
20
+ // - favicon.svg (copied if source is SVG)
21
+ // - apple-touch-icon.png (180x180)
22
+ // - icon-192.png (for web manifest)
23
+ // - icon-512.png (for web manifest)
24
+ // - site.webmanifest
25
+ //
26
+ // Usage:
27
+ // import { faviconPlugin } from "@pyreon/zero"
28
+ // export default { plugins: [zero(), faviconPlugin({ source: "./icon.svg" })] }
29
+
30
+ export interface FaviconPluginConfig {
31
+ /** Path to the source icon (SVG or PNG, at least 512x512 for PNG). */
32
+ source: string
33
+ /** Theme color for web manifest. Default: "#ffffff" */
34
+ themeColor?: string
35
+ /** Background color for web manifest. Default: "#ffffff" */
36
+ backgroundColor?: string
37
+ /** App name for web manifest. Uses package.json name if not set. */
38
+ name?: string
39
+ /** Generate web manifest. Default: true */
40
+ manifest?: boolean
41
+ /**
42
+ * Dark mode favicon (SVG only).
43
+ * When provided, the SVG favicon uses prefers-color-scheme media query
44
+ * to switch between light and dark variants.
45
+ */
46
+ darkSource?: string
47
+ }
48
+
49
+ interface FaviconSize {
50
+ size: number
51
+ name: string
52
+ }
53
+
54
+ const SIZES: FaviconSize[] = [
55
+ { size: 16, name: 'favicon-16x16.png' },
56
+ { size: 32, name: 'favicon-32x32.png' },
57
+ { size: 180, name: 'apple-touch-icon.png' },
58
+ { size: 192, name: 'icon-192.png' },
59
+ { size: 512, name: 'icon-512.png' },
60
+ ]
61
+
62
+ /**
63
+ * Favicon generation Vite plugin.
64
+ *
65
+ * Generates all required favicon formats at build time from a single source.
66
+ * In dev mode, serves the source directly.
67
+ *
68
+ * @example
69
+ * ```ts
70
+ * // vite.config.ts
71
+ * import { faviconPlugin } from "@pyreon/zero"
72
+ *
73
+ * export default {
74
+ * plugins: [faviconPlugin({ source: "./src/assets/icon.svg" })],
75
+ * }
76
+ * ```
77
+ */
78
+ export function faviconPlugin(config: FaviconPluginConfig): Plugin {
79
+ const themeColor = config.themeColor ?? '#ffffff'
80
+ const backgroundColor = config.backgroundColor ?? '#ffffff'
81
+ const generateManifest = config.manifest !== false
82
+
83
+ let root = ''
84
+ let isBuild = false
85
+
86
+ return {
87
+ name: 'pyreon-zero-favicon',
88
+ enforce: 'pre',
89
+
90
+ configResolved(resolvedConfig) {
91
+ root = resolvedConfig.root
92
+ isBuild = resolvedConfig.command === 'build'
93
+ },
94
+
95
+ // Dev server: serve generated favicons on-the-fly
96
+ configureServer(server) {
97
+ const sourcePath = join(root, config.source)
98
+
99
+ server.middlewares.use(async (req, res, next) => {
100
+ const url = req.url ?? ''
101
+
102
+ // Serve source as favicon.svg in dev
103
+ if (url === '/favicon.svg' && config.source.endsWith('.svg')) {
104
+ try {
105
+ const content = await readFile(sourcePath, 'utf-8')
106
+ res.setHeader('Content-Type', 'image/svg+xml')
107
+ res.end(content)
108
+ return
109
+ } catch { /* fall through */ }
110
+ }
111
+
112
+ // Serve generated PNGs on-demand
113
+ const sizeMatch = SIZES.find((s) => url === `/${s.name}`)
114
+ if (sizeMatch) {
115
+ const png = await resizeToPng(sourcePath, sizeMatch.size)
116
+ if (png) {
117
+ res.setHeader('Content-Type', 'image/png')
118
+ res.setHeader('Cache-Control', 'no-cache')
119
+ res.end(Buffer.from(png))
120
+ return
121
+ }
122
+ }
123
+
124
+ // Serve generated ICO on-demand
125
+ if (url === '/favicon.ico') {
126
+ const ico = await generateIco(sourcePath)
127
+ if (ico) {
128
+ res.setHeader('Content-Type', 'image/x-icon')
129
+ res.setHeader('Cache-Control', 'no-cache')
130
+ res.end(Buffer.from(ico))
131
+ return
132
+ }
133
+ }
134
+
135
+ // Serve manifest
136
+ if (url === '/site.webmanifest' && generateManifest) {
137
+ const manifest = {
138
+ name: config.name ?? 'App',
139
+ short_name: config.name ?? 'App',
140
+ icons: [
141
+ { src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
142
+ { src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
143
+ ],
144
+ theme_color: themeColor,
145
+ background_color: backgroundColor,
146
+ display: 'standalone',
147
+ }
148
+ res.setHeader('Content-Type', 'application/manifest+json')
149
+ res.end(JSON.stringify(manifest, null, 2))
150
+ return
151
+ }
152
+
153
+ next()
154
+ })
155
+ },
156
+
157
+ // Inject favicon <link> tags into HTML
158
+ transformIndexHtml() {
159
+ const isSvg = config.source.endsWith('.svg')
160
+ const tags: Array<{
161
+ tag: string
162
+ attrs: Record<string, string>
163
+ injectTo: 'head'
164
+ }> = []
165
+
166
+ if (isSvg) {
167
+ tags.push({
168
+ tag: 'link',
169
+ attrs: { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
170
+ injectTo: 'head',
171
+ })
172
+ }
173
+
174
+ tags.push(
175
+ {
176
+ tag: 'link',
177
+ attrs: { rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-32x32.png' },
178
+ injectTo: 'head',
179
+ },
180
+ {
181
+ tag: 'link',
182
+ attrs: { rel: 'icon', type: 'image/png', sizes: '16x16', href: '/favicon-16x16.png' },
183
+ injectTo: 'head',
184
+ },
185
+ {
186
+ tag: 'link',
187
+ attrs: { rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon.png' },
188
+ injectTo: 'head',
189
+ },
190
+ )
191
+
192
+ if (generateManifest) {
193
+ tags.push({
194
+ tag: 'link',
195
+ attrs: { rel: 'manifest', href: '/site.webmanifest' },
196
+ injectTo: 'head',
197
+ })
198
+ }
199
+
200
+ tags.push({
201
+ tag: 'meta',
202
+ attrs: { name: 'theme-color', content: themeColor },
203
+ injectTo: 'head',
204
+ })
205
+
206
+ return tags
207
+ },
208
+
209
+ async generateBundle() {
210
+ if (!isBuild) return
211
+
212
+ const sourcePath = join(root, config.source)
213
+ if (!existsSync(sourcePath)) {
214
+ // eslint-disable-next-line no-console
215
+ console.warn(`[zero:favicon] Source not found: ${sourcePath}`)
216
+ return
217
+ }
218
+
219
+ const isSvg = config.source.endsWith('.svg')
220
+
221
+ // Copy SVG as favicon.svg
222
+ if (isSvg) {
223
+ const svgContent = await readFile(sourcePath, 'utf-8')
224
+ let finalSvg = svgContent
225
+
226
+ // If dark mode variant provided, wrap in media query
227
+ if (config.darkSource) {
228
+ const darkPath = join(root, config.darkSource)
229
+ if (existsSync(darkPath)) {
230
+ const darkSvg = await readFile(darkPath, 'utf-8')
231
+ finalSvg = wrapSvgWithDarkMode(svgContent, darkSvg)
232
+ }
233
+ }
234
+
235
+ this.emitFile({
236
+ type: 'asset',
237
+ fileName: 'favicon.svg',
238
+ source: finalSvg,
239
+ })
240
+ }
241
+
242
+ // Generate PNG sizes via sharp
243
+ for (const { size, name } of SIZES) {
244
+ const pngBuffer = await resizeToPng(sourcePath, size)
245
+ if (pngBuffer) {
246
+ this.emitFile({
247
+ type: 'asset',
248
+ fileName: name,
249
+ source: pngBuffer,
250
+ })
251
+ }
252
+ }
253
+
254
+ // Generate favicon.ico (16 + 32)
255
+ const ico = await generateIco(sourcePath)
256
+ if (ico) {
257
+ this.emitFile({
258
+ type: 'asset',
259
+ fileName: 'favicon.ico',
260
+ source: ico,
261
+ })
262
+ }
263
+
264
+ // Generate web manifest
265
+ if (generateManifest) {
266
+ const manifest = {
267
+ name: config.name ?? 'App',
268
+ short_name: config.name ?? 'App',
269
+ icons: [
270
+ { src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
271
+ { src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
272
+ ],
273
+ theme_color: themeColor,
274
+ background_color: backgroundColor,
275
+ display: 'standalone',
276
+ }
277
+
278
+ this.emitFile({
279
+ type: 'asset',
280
+ fileName: 'site.webmanifest',
281
+ source: JSON.stringify(manifest, null, 2),
282
+ })
283
+ }
284
+ },
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Wrap two SVGs into a single SVG that switches based on prefers-color-scheme.
290
+ */
291
+ function wrapSvgWithDarkMode(lightSvg: string, darkSvg: string): string {
292
+ // Extract viewBox from light SVG
293
+ const viewBoxMatch = lightSvg.match(/viewBox="([^"]*)"/)
294
+ const viewBox = viewBoxMatch?.[1] ?? '0 0 32 32'
295
+
296
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}">
297
+ <style>
298
+ :root { color-scheme: light dark; }
299
+ @media (prefers-color-scheme: dark) { .light { display: none; } }
300
+ @media (prefers-color-scheme: light), (prefers-color-scheme: no-preference) { .dark { display: none; } }
301
+ </style>
302
+ <g class="light">${stripSvgWrapper(lightSvg)}</g>
303
+ <g class="dark">${stripSvgWrapper(darkSvg)}</g>
304
+ </svg>`
305
+ }
306
+
307
+ function stripSvgWrapper(svg: string): string {
308
+ return svg
309
+ .replace(/<svg[^>]*>/, '')
310
+ .replace(/<\/svg>\s*$/, '')
311
+ .trim()
312
+ }
313
+
314
+ async function resizeToPng(input: string, size: number): Promise<Uint8Array | null> {
315
+ try {
316
+ const sharp = await import('sharp').then((m) => m.default ?? m)
317
+ return await sharp(input).resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } } as any).png().toBuffer()
318
+ } catch {
319
+ warnSharpMissing()
320
+ return null
321
+ }
322
+ }
323
+
324
+ async function generateIco(input: string): Promise<Uint8Array | null> {
325
+ try {
326
+ const sharp = await import('sharp').then((m) => m.default ?? m)
327
+ const png16 = await sharp(input).resize(16, 16, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } } as any).png().toBuffer()
328
+ const png32 = await sharp(input).resize(32, 32, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } } as any).png().toBuffer()
329
+
330
+ // ICO format: header + directory entries + PNG data
331
+ return createIcoFromPngs([
332
+ { buffer: png16, size: 16 },
333
+ { buffer: png32, size: 32 },
334
+ ])
335
+ } catch {
336
+ warnSharpMissing()
337
+ return null
338
+ }
339
+ }
340
+
341
+ export interface IcoEntry {
342
+ buffer: Buffer
343
+ size: number
344
+ }
345
+
346
+ /** @internal Exported for testing */
347
+ export function createIcoFromPngs(entries: IcoEntry[]): Uint8Array {
348
+ const headerSize = 6
349
+ const dirEntrySize = 16
350
+ const dirSize = dirEntrySize * entries.length
351
+ let dataOffset = headerSize + dirSize
352
+
353
+ // ICO header
354
+ const header = Buffer.alloc(headerSize)
355
+ header.writeUInt16LE(0, 0) // reserved
356
+ header.writeUInt16LE(1, 2) // type: icon
357
+ header.writeUInt16LE(entries.length, 4) // count
358
+
359
+ // Directory entries
360
+ const dirEntries = Buffer.alloc(dirSize)
361
+ const dataBuffers: Buffer[] = []
362
+
363
+ for (let i = 0; i < entries.length; i++) {
364
+ const entry = entries[i]!
365
+ const offset = i * dirEntrySize
366
+ dirEntries.writeUInt8(entry.size === 256 ? 0 : entry.size, offset) // width
367
+ dirEntries.writeUInt8(entry.size === 256 ? 0 : entry.size, offset + 1) // height
368
+ dirEntries.writeUInt8(0, offset + 2) // palette
369
+ dirEntries.writeUInt8(0, offset + 3) // reserved
370
+ dirEntries.writeUInt16LE(1, offset + 4) // color planes
371
+ dirEntries.writeUInt16LE(32, offset + 6) // bits per pixel
372
+ dirEntries.writeUInt32LE(entry.buffer.length, offset + 8) // size
373
+ dirEntries.writeUInt32LE(dataOffset, offset + 12) // offset
374
+
375
+ dataOffset += entry.buffer.length
376
+ dataBuffers.push(entry.buffer)
377
+ }
378
+
379
+ return Buffer.concat([header, dirEntries, ...dataBuffers])
380
+ }
package/src/fs-router.ts CHANGED
@@ -20,9 +20,12 @@ import type { FileRoute, RenderMode } from './types'
20
20
  // Conventions:
21
21
  // [param] → dynamic segment → :param
22
22
  // [...param] → catch-all → :param*
23
- // _layout → layout wrapper (not a route itself)
23
+ // _layout → layout wrapper must use <RouterView /> to render child routes
24
+ // (props.children is NOT passed — the router handles nesting)
24
25
  // _error → error component
25
26
  // _loading → loading component
27
+ // _404 → not-found component (renders on 404)
28
+ // _not-found → alias for _404
26
29
  // (group) → route group (directory ignored in URL)
27
30
 
28
31
  const ROUTE_EXTENSIONS = ['.tsx', '.jsx', '.ts', '.js']
@@ -54,6 +57,7 @@ function parseFilePath(filePath: string, defaultMode: RenderMode): FileRoute {
54
57
  const isLayout = fileName === '_layout'
55
58
  const isError = fileName === '_error'
56
59
  const isLoading = fileName === '_loading'
60
+ const isNotFound = fileName === '_404' || fileName === '_not-found'
57
61
  const isCatchAll = route.includes('[...')
58
62
 
59
63
  // Get directory path (strip groups for consistent grouping)
@@ -73,6 +77,7 @@ function parseFilePath(filePath: string, defaultMode: RenderMode): FileRoute {
73
77
  isLayout,
74
78
  isError,
75
79
  isLoading,
80
+ isNotFound,
76
81
  isCatchAll,
77
82
  renderMode: defaultMode,
78
83
  }
@@ -99,7 +104,7 @@ export function filePathToUrlPath(filePath: string): string {
99
104
  if (seg.startsWith('(') && seg.endsWith(')')) continue
100
105
 
101
106
  // Skip special files
102
- if (seg === '_layout' || seg === '_error' || seg === '_loading') continue
107
+ if (seg === '_layout' || seg === '_error' || seg === '_loading' || seg === '_404' || seg === '_not-found') continue
103
108
 
104
109
  // "index" maps to the parent path
105
110
  if (seg === 'index') continue
@@ -156,6 +161,8 @@ interface RouteNode {
156
161
  error?: FileRoute
157
162
  /** Loading fallback file (if any). */
158
163
  loading?: FileRoute
164
+ /** Not-found (404) file (if any). */
165
+ notFound?: FileRoute
159
166
  /** Child directories. */
160
167
  children: Map<string, RouteNode>
161
168
  }
@@ -186,6 +193,7 @@ function placeRoute(node: RouteNode, route: FileRoute) {
186
193
  if (route.isLayout) node.layout = route
187
194
  else if (route.isError) node.error = route
188
195
  else if (route.isLoading) node.loading = route
196
+ else if (route.isNotFound) node.notFound = route
189
197
  else node.pages.push(route)
190
198
  }
191
199
 
@@ -202,11 +210,26 @@ function buildRouteTree(routes: FileRoute[]): RouteNode {
202
210
  * Wires up layouts as parent routes with children, loaders, guards,
203
211
  * error/loading components, middleware, and meta from route module exports.
204
212
  */
205
- export function generateRouteModule(files: string[], routesDir: string): string {
213
+ export interface GenerateRouteModuleOptions {
214
+ /**
215
+ * When true, skip lazy() for route components and use static imports.
216
+ * Use for SSG/prerender mode where all routes are rendered at build time
217
+ * and code splitting provides no benefit. Avoids Rolldown warnings about
218
+ * static + dynamic imports of the same module.
219
+ */
220
+ staticImports?: boolean
221
+ }
222
+
223
+ export function generateRouteModule(
224
+ files: string[],
225
+ routesDir: string,
226
+ options?: GenerateRouteModuleOptions,
227
+ ): string {
206
228
  const routes = parseFileRoutes(files)
207
229
  const tree = buildRouteTree(routes)
208
230
  const imports: string[] = []
209
231
  let importCounter = 0
232
+ const useStaticImports = options?.staticImports ?? false
210
233
 
211
234
  function nextImport(filePath: string, exportName = 'default'): string {
212
235
  const name = `_${importCounter++}`
@@ -222,11 +245,18 @@ export function generateRouteModule(files: string[], routesDir: string): string
222
245
  function nextLazy(filePath: string, loadingName?: string, errorName?: string): string {
223
246
  const name = `_${importCounter++}`
224
247
  const fullPath = `${routesDir}/${filePath}`
225
- const opts: string[] = []
226
- if (loadingName) opts.push(`loading: ${loadingName}`)
227
- if (errorName) opts.push(`error: ${errorName}`)
228
- const optsStr = opts.length > 0 ? `, { ${opts.join(', ')} }` : ''
229
- imports.push(`const ${name} = lazy(() => import("${fullPath}")${optsStr})`)
248
+
249
+ if (useStaticImports) {
250
+ // SSG mode: static import avoids Rolldown warnings about
251
+ // static + dynamic imports of the same module
252
+ imports.push(`import ${name} from "${fullPath}"`)
253
+ } else {
254
+ const opts: string[] = []
255
+ if (loadingName) opts.push(`loading: ${loadingName}`)
256
+ if (errorName) opts.push(`error: ${errorName}`)
257
+ const optsStr = opts.length > 0 ? `, { ${opts.join(', ')} }` : ''
258
+ imports.push(`const ${name} = lazy(() => import("${fullPath}")${optsStr})`)
259
+ }
230
260
  return name
231
261
  }
232
262
 
@@ -242,6 +272,7 @@ export function generateRouteModule(files: string[], routesDir: string): string
242
272
  indent: string,
243
273
  loadingName: string | undefined,
244
274
  errorName: string | undefined,
275
+ notFoundName: string | undefined,
245
276
  ): string {
246
277
  const mod = nextModuleImport(page.filePath)
247
278
  const comp = nextLazy(page.filePath, loadingName, errorName)
@@ -254,10 +285,15 @@ export function generateRouteModule(files: string[], routesDir: string): string
254
285
  `${indent} meta: { ...${mod}.meta, renderMode: ${mod}.renderMode }`,
255
286
  ]
256
287
 
288
+ // Only emit errorComponent when there's an actual _error file in scope
289
+ // or the route module exports an error component. Avoids referencing
290
+ // undefined .error exports that produce noisy bundler warnings.
257
291
  if (errorName) {
258
292
  props.push(`${indent} errorComponent: ${mod}.error || ${errorName}`)
259
- } else {
260
- props.push(`${indent} errorComponent: ${mod}.error`)
293
+ }
294
+
295
+ if (notFoundName) {
296
+ props.push(`${indent} notFoundComponent: ${notFoundName}`)
261
297
  }
262
298
 
263
299
  return `${indent}{\n${props.join(',\n')}\n${indent}}`
@@ -268,6 +304,7 @@ export function generateRouteModule(files: string[], routesDir: string): string
268
304
  children: string[],
269
305
  indent: string,
270
306
  errorName: string | undefined,
307
+ notFoundName: string | undefined,
271
308
  ): string {
272
309
  const layout = node.layout as FileRoute
273
310
  const layoutMod = nextModuleImport(layout.filePath)
@@ -283,6 +320,9 @@ export function generateRouteModule(files: string[], routesDir: string): string
283
320
  if (errorName) {
284
321
  props.push(`${indent}errorComponent: ${errorName}`)
285
322
  }
323
+ if (notFoundName) {
324
+ props.push(`${indent}notFoundComponent: ${notFoundName}`)
325
+ }
286
326
  if (children.length > 0) {
287
327
  props.push(`${indent}children: [\n${children.join(',\n')}\n${indent}]`)
288
328
  }
@@ -298,6 +338,7 @@ export function generateRouteModule(files: string[], routesDir: string): string
298
338
 
299
339
  const errorName = node.error ? nextImport(node.error.filePath) : undefined
300
340
  const loadingName = node.loading ? nextImport(node.loading.filePath) : undefined
341
+ const notFoundName = node.notFound ? nextImport(node.notFound.filePath) : undefined
301
342
 
302
343
  const childRouteDefs: string[] = []
303
344
  for (const [, childNode] of node.children) {
@@ -305,13 +346,13 @@ export function generateRouteModule(files: string[], routesDir: string): string
305
346
  }
306
347
 
307
348
  const pageRouteDefs = node.pages.map((page) =>
308
- generatePageRoute(page, indent, loadingName, errorName),
349
+ generatePageRoute(page, indent, loadingName, errorName, notFoundName),
309
350
  )
310
351
 
311
352
  const allChildren = [...pageRouteDefs, ...childRouteDefs]
312
353
 
313
354
  if (node.layout) {
314
- return [wrapWithLayout(node, allChildren, indent, errorName)]
355
+ return [wrapWithLayout(node, allChildren, indent, errorName, notFoundName)]
315
356
  }
316
357
  return allChildren
317
358
  }
@@ -350,7 +391,7 @@ export function generateMiddlewareModule(files: string[], routesDir: string): st
350
391
  let counter = 0
351
392
 
352
393
  for (const route of routes) {
353
- if (route.isLayout || route.isError || route.isLoading) continue
394
+ if (route.isLayout || route.isError || route.isLoading || route.isNotFound) continue
354
395
  const name = `_mw${counter++}`
355
396
  const fullPath = `${routesDir}/${route.filePath}`
356
397
  imports.push(`import { middleware as ${name} } from "${fullPath}"`)