@pyreon/zero 0.12.1 → 0.12.3
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/actions.js +97 -0
- package/lib/actions.js.map +1 -0
- package/lib/ai.js +503 -0
- package/lib/ai.js.map +1 -0
- package/lib/api-routes.js +137 -0
- package/lib/api-routes.js.map +1 -0
- package/lib/compression.js +80 -0
- package/lib/compression.js.map +1 -0
- package/lib/cors.js +57 -0
- package/lib/cors.js.map +1 -0
- package/lib/csp.js +119 -0
- package/lib/csp.js.map +1 -0
- package/lib/env.js +217 -0
- package/lib/env.js.map +1 -0
- package/lib/favicon.js +424 -0
- package/lib/favicon.js.map +1 -0
- package/lib/i18n-routing.js +167 -0
- package/lib/i18n-routing.js.map +1 -0
- package/lib/index.js +1631 -179
- package/lib/index.js.map +1 -1
- package/lib/link.js +5 -0
- package/lib/link.js.map +1 -1
- package/lib/logger.js +78 -0
- package/lib/logger.js.map +1 -0
- package/lib/meta.js +336 -0
- package/lib/meta.js.map +1 -0
- package/lib/middleware.js +53 -0
- package/lib/middleware.js.map +1 -0
- package/lib/og-image.js +233 -0
- package/lib/og-image.js.map +1 -0
- package/lib/rate-limit.js +76 -0
- package/lib/rate-limit.js.map +1 -0
- package/lib/testing.js +179 -0
- package/lib/testing.js.map +1 -0
- package/lib/theme.js +11 -2
- package/lib/theme.js.map +1 -1
- package/lib/types/actions.d.ts +27 -24
- package/lib/types/actions.d.ts.map +1 -1
- package/lib/types/ai.d.ts +163 -0
- package/lib/types/ai.d.ts.map +1 -0
- package/lib/types/api-routes.d.ts +37 -33
- package/lib/types/api-routes.d.ts.map +1 -1
- package/lib/types/cache.d.ts +26 -22
- package/lib/types/cache.d.ts.map +1 -1
- package/lib/types/client.d.ts +13 -9
- package/lib/types/client.d.ts.map +1 -1
- package/lib/types/compression.d.ts +14 -10
- package/lib/types/compression.d.ts.map +1 -1
- package/lib/types/config.d.ts +39 -4
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/cors.d.ts +20 -16
- package/lib/types/cors.d.ts.map +1 -1
- package/lib/types/csp.d.ts +88 -0
- package/lib/types/csp.d.ts.map +1 -0
- package/lib/types/env.d.ts +118 -0
- package/lib/types/env.d.ts.map +1 -0
- package/lib/types/favicon.d.ts +70 -24
- package/lib/types/favicon.d.ts.map +1 -1
- package/lib/types/font.d.ts +68 -65
- package/lib/types/font.d.ts.map +1 -1
- package/lib/types/i18n-routing.d.ts +43 -37
- package/lib/types/i18n-routing.d.ts.map +1 -1
- package/lib/types/image-plugin.d.ts +49 -45
- package/lib/types/image-plugin.d.ts.map +1 -1
- package/lib/types/image.d.ts +47 -36
- package/lib/types/image.d.ts.map +1 -1
- package/lib/types/index.d.ts +1961 -46
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/link.d.ts +61 -56
- package/lib/types/link.d.ts.map +1 -1
- package/lib/types/logger.d.ts +57 -0
- package/lib/types/logger.d.ts.map +1 -0
- package/lib/types/meta.d.ts +180 -69
- package/lib/types/meta.d.ts.map +1 -1
- package/lib/types/middleware.d.ts +8 -4
- package/lib/types/middleware.d.ts.map +1 -1
- package/lib/types/og-image.d.ts +111 -0
- package/lib/types/og-image.d.ts.map +1 -0
- package/lib/types/rate-limit.d.ts +20 -16
- package/lib/types/rate-limit.d.ts.map +1 -1
- package/lib/types/script.d.ts +23 -19
- package/lib/types/script.d.ts.map +1 -1
- package/lib/types/seo.d.ts +47 -43
- package/lib/types/seo.d.ts.map +1 -1
- package/lib/types/testing.d.ts +64 -27
- package/lib/types/testing.d.ts.map +1 -1
- package/lib/types/theme.d.ts +22 -12
- package/lib/types/theme.d.ts.map +1 -1
- package/package.json +37 -12
- package/src/actions.ts +1 -3
- package/src/adapters/bun.ts +2 -0
- package/src/adapters/cloudflare.ts +84 -0
- package/src/adapters/index.ts +13 -1
- package/src/adapters/netlify.ts +86 -0
- package/src/adapters/node.ts +2 -0
- package/src/adapters/validate.ts +16 -0
- package/src/adapters/vercel.ts +86 -0
- package/src/ai.ts +623 -0
- package/src/compression.ts +19 -3
- package/src/csp.ts +207 -0
- package/src/entry-server.ts +28 -5
- package/src/env.ts +344 -0
- package/src/favicon.ts +221 -80
- package/src/index.ts +42 -2
- package/src/link.tsx +6 -0
- package/src/logger.ts +144 -0
- package/src/meta.tsx +124 -14
- package/src/og-image.ts +378 -0
- package/src/rate-limit.ts +11 -9
- package/src/theme.tsx +12 -1
- package/src/types.ts +1 -1
- package/src/vite-plugin.ts +5 -1
- package/lib/types/adapters/bun.d.ts +0 -6
- package/lib/types/adapters/bun.d.ts.map +0 -1
- package/lib/types/adapters/index.d.ts +0 -10
- package/lib/types/adapters/index.d.ts.map +0 -1
- package/lib/types/adapters/node.d.ts +0 -6
- package/lib/types/adapters/node.d.ts.map +0 -1
- package/lib/types/adapters/static.d.ts +0 -7
- package/lib/types/adapters/static.d.ts.map +0 -1
- package/lib/types/app.d.ts +0 -24
- package/lib/types/app.d.ts.map +0 -1
- package/lib/types/entry-server.d.ts +0 -37
- package/lib/types/entry-server.d.ts.map +0 -1
- package/lib/types/error-overlay.d.ts +0 -6
- package/lib/types/error-overlay.d.ts.map +0 -1
- package/lib/types/fs-router.d.ts +0 -47
- package/lib/types/fs-router.d.ts.map +0 -1
- package/lib/types/isr.d.ts +0 -9
- package/lib/types/isr.d.ts.map +0 -1
- package/lib/types/not-found.d.ts +0 -7
- package/lib/types/not-found.d.ts.map +0 -1
- package/lib/types/types.d.ts +0 -111
- package/lib/types/types.d.ts.map +0 -1
- package/lib/types/utils/use-intersection-observer.d.ts +0 -10
- package/lib/types/utils/use-intersection-observer.d.ts.map +0 -1
- package/lib/types/utils/with-headers.d.ts +0 -6
- package/lib/types/utils/with-headers.d.ts.map +0 -1
- package/lib/types/vite-plugin.d.ts +0 -17
- package/lib/types/vite-plugin.d.ts.map +0 -1
package/src/favicon.ts
CHANGED
|
@@ -27,6 +27,13 @@ function warnSharpMissing() {
|
|
|
27
27
|
// import { faviconPlugin } from "@pyreon/zero"
|
|
28
28
|
// export default { plugins: [zero(), faviconPlugin({ source: "./icon.svg" })] }
|
|
29
29
|
|
|
30
|
+
export interface FaviconLocaleConfig {
|
|
31
|
+
/** Locale-specific source icon (SVG or PNG). */
|
|
32
|
+
source: string
|
|
33
|
+
/** Optional dark mode variant for this locale. */
|
|
34
|
+
darkSource?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
30
37
|
export interface FaviconPluginConfig {
|
|
31
38
|
/** Path to the source icon (SVG or PNG, at least 512x512 for PNG). */
|
|
32
39
|
source: string
|
|
@@ -44,6 +51,26 @@ export interface FaviconPluginConfig {
|
|
|
44
51
|
* to switch between light and dark variants.
|
|
45
52
|
*/
|
|
46
53
|
darkSource?: string
|
|
54
|
+
/**
|
|
55
|
+
* Locale-specific icon overrides. Each key is a locale code,
|
|
56
|
+
* value is a source icon (and optional dark variant).
|
|
57
|
+
* Locales not in this map use the base `source`.
|
|
58
|
+
*
|
|
59
|
+
* Generated files are placed under `/{locale}/` prefix:
|
|
60
|
+
* /de/favicon.svg, /de/favicon-32x32.png, etc.
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* ```ts
|
|
64
|
+
* faviconPlugin({
|
|
65
|
+
* source: "./icon.svg",
|
|
66
|
+
* locales: {
|
|
67
|
+
* de: { source: "./icon-de.svg" },
|
|
68
|
+
* cs: { source: "./icon-cs.svg" },
|
|
69
|
+
* },
|
|
70
|
+
* })
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
locales?: Record<string, FaviconLocaleConfig>
|
|
47
74
|
}
|
|
48
75
|
|
|
49
76
|
interface FaviconSize {
|
|
@@ -95,24 +122,41 @@ export function faviconPlugin(config: FaviconPluginConfig): Plugin {
|
|
|
95
122
|
// Dev server: serve generated favicons on-the-fly
|
|
96
123
|
configureServer(server) {
|
|
97
124
|
const sourcePath = join(root, config.source)
|
|
125
|
+
const devCache = new Map<string, Uint8Array>()
|
|
98
126
|
|
|
99
127
|
server.middlewares.use(async (req, res, next) => {
|
|
100
128
|
const url = req.url ?? ''
|
|
101
129
|
|
|
130
|
+
// Resolve locale-specific source: /{locale}/favicon.svg → locale source
|
|
131
|
+
const localeSource = resolveLocaleSource(url, config, root)
|
|
132
|
+
|
|
102
133
|
// Serve source as favicon.svg in dev
|
|
103
|
-
|
|
134
|
+
const svgUrl = localeSource ? localeSource.url : url
|
|
135
|
+
const svgPath = localeSource ? localeSource.sourcePath : sourcePath
|
|
136
|
+
const isSvgSource = localeSource ? localeSource.source.endsWith('.svg') : config.source.endsWith('.svg')
|
|
137
|
+
|
|
138
|
+
if (svgUrl.endsWith('/favicon.svg') && isSvgSource) {
|
|
104
139
|
try {
|
|
105
|
-
const content = await readFile(
|
|
140
|
+
const content = await readFile(svgPath, 'utf-8')
|
|
106
141
|
res.setHeader('Content-Type', 'image/svg+xml')
|
|
107
142
|
res.end(content)
|
|
108
143
|
return
|
|
109
144
|
} catch { /* fall through */ }
|
|
110
145
|
}
|
|
111
146
|
|
|
112
|
-
// Serve generated PNGs on-demand
|
|
113
|
-
const
|
|
147
|
+
// Serve generated PNGs on-demand (supports /{locale}/favicon-32x32.png)
|
|
148
|
+
const baseName = svgUrl.split('/').pop() ?? ''
|
|
149
|
+
const sizeMatch = SIZES.find((s) => s.name === baseName)
|
|
114
150
|
if (sizeMatch) {
|
|
115
|
-
const
|
|
151
|
+
const cacheKey = `${svgPath}:${sizeMatch.size}`
|
|
152
|
+
let png = devCache.get(cacheKey)
|
|
153
|
+
if (!png) {
|
|
154
|
+
const result = await resizeToPng(svgPath, sizeMatch.size)
|
|
155
|
+
if (result) {
|
|
156
|
+
png = result
|
|
157
|
+
devCache.set(cacheKey, result)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
116
160
|
if (png) {
|
|
117
161
|
res.setHeader('Content-Type', 'image/png')
|
|
118
162
|
res.setHeader('Cache-Control', 'no-cache')
|
|
@@ -122,8 +166,16 @@ export function faviconPlugin(config: FaviconPluginConfig): Plugin {
|
|
|
122
166
|
}
|
|
123
167
|
|
|
124
168
|
// Serve generated ICO on-demand
|
|
125
|
-
if (
|
|
126
|
-
const
|
|
169
|
+
if (baseName === 'favicon.ico') {
|
|
170
|
+
const cacheKey = `ico:${svgPath}`
|
|
171
|
+
let ico: Uint8Array | undefined = devCache.get(cacheKey)
|
|
172
|
+
if (!ico) {
|
|
173
|
+
const result = await generateIco(svgPath)
|
|
174
|
+
if (result) {
|
|
175
|
+
ico = result
|
|
176
|
+
devCache.set(cacheKey, result)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
127
179
|
if (ico) {
|
|
128
180
|
res.setHeader('Content-Type', 'image/x-icon')
|
|
129
181
|
res.setHeader('Cache-Control', 'no-cache')
|
|
@@ -132,14 +184,15 @@ export function faviconPlugin(config: FaviconPluginConfig): Plugin {
|
|
|
132
184
|
}
|
|
133
185
|
}
|
|
134
186
|
|
|
135
|
-
// Serve manifest
|
|
136
|
-
if (
|
|
187
|
+
// Serve manifest (supports /{locale}/site.webmanifest)
|
|
188
|
+
if (baseName === 'site.webmanifest' && generateManifest) {
|
|
189
|
+
const prefix = localeSource ? `/${localeSource.locale}` : ''
|
|
137
190
|
const manifest = {
|
|
138
191
|
name: config.name ?? 'App',
|
|
139
192
|
short_name: config.name ?? 'App',
|
|
140
193
|
icons: [
|
|
141
|
-
{ src:
|
|
142
|
-
{ src:
|
|
194
|
+
{ src: `${prefix}/icon-192.png`, sizes: '192x192', type: 'image/png' },
|
|
195
|
+
{ src: `${prefix}/icon-512.png`, sizes: '512x512', type: 'image/png' },
|
|
143
196
|
],
|
|
144
197
|
theme_color: themeColor,
|
|
145
198
|
background_color: backgroundColor,
|
|
@@ -209,78 +262,15 @@ export function faviconPlugin(config: FaviconPluginConfig): Plugin {
|
|
|
209
262
|
async generateBundle() {
|
|
210
263
|
if (!isBuild) return
|
|
211
264
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
// oxlint-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
|
-
}
|
|
265
|
+
// Generate favicons for the base (default) source
|
|
266
|
+
await generateFaviconSet.call(this, root, config.source, config.darkSource, '', config, themeColor, backgroundColor, generateManifest)
|
|
241
267
|
|
|
242
|
-
// Generate
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
this.emitFile({
|
|
247
|
-
type: 'asset',
|
|
248
|
-
fileName: name,
|
|
249
|
-
source: pngBuffer,
|
|
250
|
-
})
|
|
268
|
+
// Generate locale-specific favicon sets
|
|
269
|
+
if (config.locales) {
|
|
270
|
+
for (const [locale, localeConfig] of Object.entries(config.locales)) {
|
|
271
|
+
await generateFaviconSet.call(this, root, localeConfig.source, localeConfig.darkSource, `${locale}/`, config, themeColor, backgroundColor, generateManifest)
|
|
251
272
|
}
|
|
252
273
|
}
|
|
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
274
|
},
|
|
285
275
|
}
|
|
286
276
|
}
|
|
@@ -311,6 +301,157 @@ function stripSvgWrapper(svg: string): string {
|
|
|
311
301
|
.trim()
|
|
312
302
|
}
|
|
313
303
|
|
|
304
|
+
/**
|
|
305
|
+
* Resolve the source path for a locale-prefixed favicon URL.
|
|
306
|
+
* Returns null if the URL is not locale-prefixed or locale has no override.
|
|
307
|
+
*/
|
|
308
|
+
function resolveLocaleSource(
|
|
309
|
+
url: string,
|
|
310
|
+
config: FaviconPluginConfig,
|
|
311
|
+
rootDir: string,
|
|
312
|
+
): { locale: string; url: string; source: string; sourcePath: string } | null {
|
|
313
|
+
if (!config.locales) return null
|
|
314
|
+
|
|
315
|
+
for (const [locale, localeConfig] of Object.entries(config.locales)) {
|
|
316
|
+
const prefix = `/${locale}/`
|
|
317
|
+
if (url.startsWith(prefix)) {
|
|
318
|
+
return {
|
|
319
|
+
locale,
|
|
320
|
+
url,
|
|
321
|
+
source: localeConfig.source,
|
|
322
|
+
sourcePath: join(rootDir, localeConfig.source),
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return null
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Generate a complete favicon set (SVG, PNGs, ICO, manifest) with a file prefix.
|
|
331
|
+
* Called once for base (prefix = '') and once per locale (prefix = '{locale}/').
|
|
332
|
+
*/
|
|
333
|
+
async function generateFaviconSet(
|
|
334
|
+
this: any,
|
|
335
|
+
rootDir: string,
|
|
336
|
+
source: string,
|
|
337
|
+
darkSource: string | undefined,
|
|
338
|
+
prefix: string,
|
|
339
|
+
config: FaviconPluginConfig,
|
|
340
|
+
themeColor: string,
|
|
341
|
+
backgroundColor: string,
|
|
342
|
+
generateManifest: boolean,
|
|
343
|
+
): Promise<void> {
|
|
344
|
+
const sourcePath = join(rootDir, source)
|
|
345
|
+
if (!existsSync(sourcePath)) {
|
|
346
|
+
// oxlint-disable-next-line no-console
|
|
347
|
+
console.warn(`[zero:favicon] Source not found: ${sourcePath}`)
|
|
348
|
+
return
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const isSvg = source.endsWith('.svg')
|
|
352
|
+
|
|
353
|
+
// Copy SVG as favicon.svg
|
|
354
|
+
if (isSvg) {
|
|
355
|
+
const svgContent = await readFile(sourcePath, 'utf-8')
|
|
356
|
+
let finalSvg = svgContent
|
|
357
|
+
|
|
358
|
+
if (darkSource) {
|
|
359
|
+
const darkPath = join(rootDir, darkSource)
|
|
360
|
+
if (existsSync(darkPath)) {
|
|
361
|
+
const darkSvg = await readFile(darkPath, 'utf-8')
|
|
362
|
+
finalSvg = wrapSvgWithDarkMode(svgContent, darkSvg)
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
this.emitFile({
|
|
367
|
+
type: 'asset',
|
|
368
|
+
fileName: `${prefix}favicon.svg`,
|
|
369
|
+
source: finalSvg,
|
|
370
|
+
})
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Generate PNG sizes via sharp
|
|
374
|
+
for (const { size, name } of SIZES) {
|
|
375
|
+
const pngBuffer = await resizeToPng(sourcePath, size)
|
|
376
|
+
if (pngBuffer) {
|
|
377
|
+
this.emitFile({
|
|
378
|
+
type: 'asset',
|
|
379
|
+
fileName: `${prefix}${name}`,
|
|
380
|
+
source: pngBuffer,
|
|
381
|
+
})
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Generate favicon.ico (16 + 32)
|
|
386
|
+
const ico = await generateIco(sourcePath)
|
|
387
|
+
if (ico) {
|
|
388
|
+
this.emitFile({
|
|
389
|
+
type: 'asset',
|
|
390
|
+
fileName: `${prefix}favicon.ico`,
|
|
391
|
+
source: ico,
|
|
392
|
+
})
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Generate web manifest
|
|
396
|
+
if (generateManifest) {
|
|
397
|
+
const manifestPrefix = prefix ? `/${prefix.slice(0, -1)}` : ''
|
|
398
|
+
const manifest = {
|
|
399
|
+
name: config.name ?? 'App',
|
|
400
|
+
short_name: config.name ?? 'App',
|
|
401
|
+
icons: [
|
|
402
|
+
{ src: `${manifestPrefix}/icon-192.png`, sizes: '192x192', type: 'image/png' },
|
|
403
|
+
{ src: `${manifestPrefix}/icon-512.png`, sizes: '512x512', type: 'image/png' },
|
|
404
|
+
],
|
|
405
|
+
theme_color: themeColor,
|
|
406
|
+
background_color: backgroundColor,
|
|
407
|
+
display: 'standalone',
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
this.emitFile({
|
|
411
|
+
type: 'asset',
|
|
412
|
+
fileName: `${prefix}site.webmanifest`,
|
|
413
|
+
source: JSON.stringify(manifest, null, 2),
|
|
414
|
+
})
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Get favicon link tags for a specific locale.
|
|
420
|
+
* Returns link objects suitable for `useHead()` or direct HTML injection.
|
|
421
|
+
*
|
|
422
|
+
* @example
|
|
423
|
+
* ```ts
|
|
424
|
+
* const links = faviconLinks("de", { source: "./icon.svg", locales: { de: { source: "./icon-de.svg" } } })
|
|
425
|
+
* // → [{ rel: "icon", type: "image/svg+xml", href: "/de/favicon.svg" }, ...]
|
|
426
|
+
* ```
|
|
427
|
+
*/
|
|
428
|
+
export function faviconLinks(
|
|
429
|
+
locale: string | undefined,
|
|
430
|
+
config: FaviconPluginConfig,
|
|
431
|
+
): Array<{ rel: string; type?: string; sizes?: string; href: string }> {
|
|
432
|
+
const hasLocaleOverride = locale && config.locales?.[locale]
|
|
433
|
+
const prefix = hasLocaleOverride ? `/${locale}` : ''
|
|
434
|
+
const isSvg = (hasLocaleOverride ? config.locales![locale]!.source : config.source).endsWith('.svg')
|
|
435
|
+
|
|
436
|
+
const links: Array<{ rel: string; type?: string; sizes?: string; href: string }> = []
|
|
437
|
+
|
|
438
|
+
if (isSvg) {
|
|
439
|
+
links.push({ rel: 'icon', type: 'image/svg+xml', href: `${prefix}/favicon.svg` })
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
links.push(
|
|
443
|
+
{ rel: 'icon', type: 'image/png', sizes: '32x32', href: `${prefix}/favicon-32x32.png` },
|
|
444
|
+
{ rel: 'icon', type: 'image/png', sizes: '16x16', href: `${prefix}/favicon-16x16.png` },
|
|
445
|
+
{ rel: 'apple-touch-icon', sizes: '180x180', href: `${prefix}/apple-touch-icon.png` },
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
if (config.manifest !== false) {
|
|
449
|
+
links.push({ rel: 'manifest', href: `${prefix}/site.webmanifest` })
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return links
|
|
453
|
+
}
|
|
454
|
+
|
|
314
455
|
async function resizeToPng(input: string, size: number): Promise<Uint8Array | null> {
|
|
315
456
|
try {
|
|
316
457
|
const sharp = await import('sharp').then((m) => m.default ?? m)
|
package/src/index.ts
CHANGED
|
@@ -32,9 +32,12 @@ export { createISRHandler } from "./isr";
|
|
|
32
32
|
|
|
33
33
|
export {
|
|
34
34
|
bunAdapter,
|
|
35
|
+
cloudflareAdapter,
|
|
36
|
+
netlifyAdapter,
|
|
35
37
|
nodeAdapter,
|
|
36
38
|
resolveAdapter,
|
|
37
39
|
staticAdapter,
|
|
40
|
+
vercelAdapter,
|
|
38
41
|
} from "./adapters";
|
|
39
42
|
|
|
40
43
|
// ─── Components ─────────────────────────────────────────────────────────────
|
|
@@ -85,6 +88,7 @@ export type { Theme } from "./theme";
|
|
|
85
88
|
export {
|
|
86
89
|
initTheme,
|
|
87
90
|
resolvedTheme,
|
|
91
|
+
setSSRThemeDefault,
|
|
88
92
|
setTheme,
|
|
89
93
|
ThemeToggle,
|
|
90
94
|
theme,
|
|
@@ -148,8 +152,17 @@ export { createActionMiddleware, defineAction } from "./actions";
|
|
|
148
152
|
|
|
149
153
|
// ─── Favicon ────────────────────────────────────────────────────────────────
|
|
150
154
|
|
|
151
|
-
export type { FaviconPluginConfig } from "./favicon";
|
|
152
|
-
export { faviconPlugin } from "./favicon";
|
|
155
|
+
export type { FaviconLocaleConfig, FaviconPluginConfig } from "./favicon";
|
|
156
|
+
export { faviconLinks, faviconPlugin } from "./favicon";
|
|
157
|
+
|
|
158
|
+
// ─── OG Image ───────────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
export type {
|
|
161
|
+
OgImageLayer,
|
|
162
|
+
OgImagePluginConfig,
|
|
163
|
+
OgImageTemplate,
|
|
164
|
+
} from "./og-image";
|
|
165
|
+
export { ogImagePath, ogImagePlugin } from "./og-image";
|
|
153
166
|
|
|
154
167
|
// ─── Meta ───────────────────────────────────────────────────────────────────
|
|
155
168
|
|
|
@@ -169,6 +182,33 @@ export {
|
|
|
169
182
|
useLocale,
|
|
170
183
|
} from "./i18n-routing";
|
|
171
184
|
|
|
185
|
+
// ─── CSP ────────────────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
export type { CspConfig, CspDirectives } from "./csp";
|
|
188
|
+
export { buildCspHeader, cspMiddleware, useNonce } from "./csp";
|
|
189
|
+
|
|
190
|
+
// ─── Environment validation ─────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
export type { LogEntry, LoggerConfig } from "./logger";
|
|
193
|
+
export { loggerMiddleware } from "./logger";
|
|
194
|
+
|
|
195
|
+
// ─── Request logging ────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
export type { EnvValidator } from "./env";
|
|
198
|
+
export { bool, num, oneOf, publicEnv, schema, str, url, validateEnv } from "./env";
|
|
199
|
+
|
|
200
|
+
// ─── AI integration ─────────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
export type { AiPluginConfig, InferJsonLdOptions } from "./ai";
|
|
203
|
+
export {
|
|
204
|
+
aiPlugin,
|
|
205
|
+
generateAiPluginManifest,
|
|
206
|
+
generateLlmsFullTxt,
|
|
207
|
+
generateLlmsTxt,
|
|
208
|
+
generateOpenApiSpec,
|
|
209
|
+
inferJsonLd,
|
|
210
|
+
} from "./ai";
|
|
211
|
+
|
|
172
212
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
173
213
|
|
|
174
214
|
export type {
|
package/src/link.tsx
CHANGED
|
@@ -70,10 +70,16 @@ export interface UseLinkReturn {
|
|
|
70
70
|
classes: () => string
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
const MAX_PREFETCH_CACHE = 200
|
|
73
74
|
const prefetched = new Set<string>()
|
|
74
75
|
|
|
75
76
|
function doPrefetch(href: string) {
|
|
76
77
|
if (prefetched.has(href)) return
|
|
78
|
+
// Evict oldest entries when cache is full
|
|
79
|
+
if (prefetched.size >= MAX_PREFETCH_CACHE) {
|
|
80
|
+
const first = prefetched.values().next().value
|
|
81
|
+
if (first) prefetched.delete(first)
|
|
82
|
+
}
|
|
77
83
|
prefetched.add(href)
|
|
78
84
|
|
|
79
85
|
const docLink = document.createElement('link')
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request logging middleware.
|
|
3
|
+
*
|
|
4
|
+
* Logs HTTP requests with method, path, status, and duration.
|
|
5
|
+
* Supports custom formatters and log levels.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { loggerMiddleware } from "@pyreon/zero"
|
|
10
|
+
*
|
|
11
|
+
* export default defineConfig({
|
|
12
|
+
* middleware: [loggerMiddleware()],
|
|
13
|
+
* })
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
import type { Middleware, MiddlewareContext } from '@pyreon/server'
|
|
17
|
+
|
|
18
|
+
export interface LoggerConfig {
|
|
19
|
+
/**
|
|
20
|
+
* Log level — controls which requests are logged.
|
|
21
|
+
* - "all": log every request
|
|
22
|
+
* - "none": disable logging
|
|
23
|
+
* Default: "all"
|
|
24
|
+
*/
|
|
25
|
+
level?: 'all' | 'none'
|
|
26
|
+
/**
|
|
27
|
+
* Custom log formatter. Receives request details and returns
|
|
28
|
+
* the string to log (or null to skip).
|
|
29
|
+
*/
|
|
30
|
+
format?: (entry: LogEntry) => string | null
|
|
31
|
+
/**
|
|
32
|
+
* Skip logging for these path prefixes.
|
|
33
|
+
* Default: ["/__", "/@", "/node_modules"]
|
|
34
|
+
*/
|
|
35
|
+
skip?: string[]
|
|
36
|
+
/**
|
|
37
|
+
* Enable colorized output (ANSI codes).
|
|
38
|
+
* Default: true in development, false in production.
|
|
39
|
+
*/
|
|
40
|
+
colors?: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface LogEntry {
|
|
44
|
+
method: string
|
|
45
|
+
path: string
|
|
46
|
+
duration: number
|
|
47
|
+
timestamp: Date
|
|
48
|
+
userAgent?: string | undefined
|
|
49
|
+
ip?: string | undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const COLORS = {
|
|
53
|
+
reset: '\x1b[0m',
|
|
54
|
+
dim: '\x1b[2m',
|
|
55
|
+
green: '\x1b[32m',
|
|
56
|
+
yellow: '\x1b[33m',
|
|
57
|
+
red: '\x1b[31m',
|
|
58
|
+
cyan: '\x1b[36m',
|
|
59
|
+
magenta: '\x1b[35m',
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function methodColor(method: string, colors: boolean): string {
|
|
63
|
+
if (!colors) return method.padEnd(7)
|
|
64
|
+
const padded = method.padEnd(7)
|
|
65
|
+
switch (method) {
|
|
66
|
+
case 'GET': return `${COLORS.green}${padded}${COLORS.reset}`
|
|
67
|
+
case 'POST': return `${COLORS.cyan}${padded}${COLORS.reset}`
|
|
68
|
+
case 'PUT': return `${COLORS.yellow}${padded}${COLORS.reset}`
|
|
69
|
+
case 'PATCH': return `${COLORS.yellow}${padded}${COLORS.reset}`
|
|
70
|
+
case 'DELETE': return `${COLORS.red}${padded}${COLORS.reset}`
|
|
71
|
+
default: return `${COLORS.magenta}${padded}${COLORS.reset}`
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function defaultFormat(entry: LogEntry, colors: boolean): string {
|
|
76
|
+
const dur = entry.duration < 1
|
|
77
|
+
? '<1ms'
|
|
78
|
+
: entry.duration < 1000
|
|
79
|
+
? `${Math.round(entry.duration)}ms`
|
|
80
|
+
: `${(entry.duration / 1000).toFixed(2)}s`
|
|
81
|
+
|
|
82
|
+
const dim = colors ? COLORS.dim : ''
|
|
83
|
+
const reset = colors ? COLORS.reset : ''
|
|
84
|
+
|
|
85
|
+
return ` ${methodColor(entry.method, colors)} ${entry.path} ${dim}${dur}${reset}`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Request logging middleware.
|
|
90
|
+
*
|
|
91
|
+
* Logs incoming requests with method, path, and duration.
|
|
92
|
+
* Runs in middleware phase — logs timing from middleware start to
|
|
93
|
+
* microtask completion (approximate request duration).
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* ```ts
|
|
97
|
+
* // Basic usage
|
|
98
|
+
* loggerMiddleware()
|
|
99
|
+
*
|
|
100
|
+
* // Custom format
|
|
101
|
+
* loggerMiddleware({
|
|
102
|
+
* format: (e) => `${e.method} ${e.path} (${e.duration}ms)`,
|
|
103
|
+
* })
|
|
104
|
+
* ```
|
|
105
|
+
*/
|
|
106
|
+
export function loggerMiddleware(config?: LoggerConfig): Middleware {
|
|
107
|
+
const level = config?.level ?? 'all'
|
|
108
|
+
if (level === 'none') return () => {}
|
|
109
|
+
|
|
110
|
+
const skip = config?.skip ?? ['/__', '/@', '/node_modules']
|
|
111
|
+
const isDev = typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'
|
|
112
|
+
const colors = config?.colors ?? isDev
|
|
113
|
+
|
|
114
|
+
return (ctx: MiddlewareContext) => {
|
|
115
|
+
// Skip internal paths
|
|
116
|
+
if (skip.some((p) => ctx.path.startsWith(p))) return
|
|
117
|
+
|
|
118
|
+
const start = performance.now()
|
|
119
|
+
|
|
120
|
+
const entry: LogEntry = {
|
|
121
|
+
method: ctx.req.method ?? 'GET',
|
|
122
|
+
path: ctx.path,
|
|
123
|
+
duration: 0,
|
|
124
|
+
timestamp: new Date(),
|
|
125
|
+
userAgent: ctx.req.headers.get('user-agent') ?? undefined,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Use queueMicrotask to log after the middleware chain completes
|
|
129
|
+
queueMicrotask(() => {
|
|
130
|
+
entry.duration = performance.now() - start
|
|
131
|
+
|
|
132
|
+
if (config?.format) {
|
|
133
|
+
const line = config.format(entry)
|
|
134
|
+
if (line) {
|
|
135
|
+
// oxlint-disable-next-line no-console
|
|
136
|
+
console.log(line)
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
// oxlint-disable-next-line no-console
|
|
140
|
+
console.log(defaultFormat(entry, colors))
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
}
|