@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/lib/fs-router-BkbIWqek.js.map +1 -1
- package/lib/{fs-router-n4VA4lxu.js → fs-router-Dil4IKZR.js} +23 -19
- package/lib/fs-router-Dil4IKZR.js.map +1 -0
- package/lib/index.js +872 -17
- package/lib/index.js.map +1 -1
- package/lib/link.js +12 -1
- package/lib/link.js.map +1 -1
- package/package.json +10 -10
- package/src/entry-server.ts +124 -76
- package/src/favicon.ts +380 -0
- package/src/fs-router.ts +54 -13
- package/src/i18n-routing.ts +299 -0
- package/src/index.ts +125 -76
- package/src/link.tsx +12 -0
- package/src/meta.tsx +210 -0
- package/src/middleware.ts +65 -0
- package/src/not-found.ts +44 -0
- package/src/types.ts +2 -0
- package/src/vite-plugin.ts +258 -127
- package/lib/fs-router-n4VA4lxu.js.map +0 -1
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
|
|
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
|
|
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
|
-
|
|
226
|
-
if (
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
}
|
|
260
|
-
|
|
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}"`)
|