@pyreon/zero 0.12.3 → 0.12.5

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 CHANGED
@@ -71,6 +71,23 @@ export interface FaviconPluginConfig {
71
71
  * ```
72
72
  */
73
73
  locales?: Record<string, FaviconLocaleConfig>
74
+ /**
75
+ * Dev mode favicon — shown only during development to distinguish
76
+ * dev tabs from production. Can be:
77
+ * - A path to a separate icon file
78
+ * - `true` to auto-generate a dev badge (grayscale + "DEV" overlay)
79
+ *
80
+ * @example
81
+ * ```ts
82
+ * faviconPlugin({
83
+ * source: "./icon.svg",
84
+ * devSource: "./icon-dev.svg", // custom dev icon
85
+ * // OR
86
+ * devSource: true, // auto-generate grayscale badge
87
+ * })
88
+ * ```
89
+ */
90
+ devSource?: string | boolean
74
91
  }
75
92
 
76
93
  interface FaviconSize {
@@ -122,36 +139,59 @@ export function faviconPlugin(config: FaviconPluginConfig): Plugin {
122
139
  // Dev server: serve generated favicons on-the-fly
123
140
  configureServer(server) {
124
141
  const sourcePath = join(root, config.source)
142
+ const darkPath = config.darkSource ? join(root, config.darkSource) : null
143
+ const devSourcePath = typeof config.devSource === 'string'
144
+ ? join(root, config.devSource)
145
+ : null
146
+ const autoDevBadge = config.devSource === true
125
147
  const devCache = new Map<string, Uint8Array>()
126
148
 
149
+ /** Resolve source path for a request — handles dark variants and dev badge. */
150
+ function resolveSourceForDev(baseName: string, defaultSource: string): string {
151
+ // Dark variant: favicon-dark-32x32.png → use darkSource
152
+ if (darkPath && baseName.includes('-dark-')) return darkPath
153
+ // Light variant: favicon-light-32x32.png → use source
154
+ if (baseName.includes('-light-')) return defaultSource
155
+ return defaultSource
156
+ }
157
+
127
158
  server.middlewares.use(async (req, res, next) => {
128
159
  const url = req.url ?? ''
129
160
 
130
- // Resolve locale-specific source: /{locale}/favicon.svg → locale source
161
+ // Resolve locale-specific source
131
162
  const localeSource = resolveLocaleSource(url, config, root)
132
-
133
- // Serve source as favicon.svg in dev
134
163
  const svgUrl = localeSource ? localeSource.url : url
135
164
  const svgPath = localeSource ? localeSource.sourcePath : sourcePath
136
165
  const isSvgSource = localeSource ? localeSource.source.endsWith('.svg') : config.source.endsWith('.svg')
137
166
 
167
+ // Serve favicon.svg — in dev, add dev badge overlay if configured
138
168
  if (svgUrl.endsWith('/favicon.svg') && isSvgSource) {
139
169
  try {
140
- const content = await readFile(svgPath, 'utf-8')
170
+ let content = await readFile(svgPath, 'utf-8')
171
+ if (autoDevBadge) content = addDevBadgeToSvg(content)
172
+ else if (devSourcePath && existsSync(devSourcePath)) {
173
+ content = await readFile(devSourcePath, 'utf-8')
174
+ }
141
175
  res.setHeader('Content-Type', 'image/svg+xml')
142
176
  res.end(content)
143
177
  return
144
178
  } catch { /* fall through */ }
145
179
  }
146
180
 
147
- // Serve generated PNGs on-demand (supports /{locale}/favicon-32x32.png)
181
+ // Serve generated PNGs on-demand supports dark variants + dev badge
148
182
  const baseName = svgUrl.split('/').pop() ?? ''
149
- const sizeMatch = SIZES.find((s) => s.name === baseName)
183
+ // Strip light-/dark- prefix for size matching
184
+ const cleanName = baseName.replace(/-?(light|dark)-/, '-')
185
+ const sizeMatch = SIZES.find((s) => s.name === cleanName || baseName === s.name)
150
186
  if (sizeMatch) {
151
- const cacheKey = `${svgPath}:${sizeMatch.size}`
187
+ const resolvedSource = resolveSourceForDev(baseName, svgPath)
188
+ const cacheKey = `${resolvedSource}:${sizeMatch.size}:${autoDevBadge}`
152
189
  let png = devCache.get(cacheKey)
153
190
  if (!png) {
154
- const result = await resizeToPng(svgPath, sizeMatch.size)
191
+ let result = await resizeToPng(resolvedSource, sizeMatch.size)
192
+ if (result && autoDevBadge) {
193
+ result = await addDevBadgeToPng(result, sizeMatch.size)
194
+ }
155
195
  if (result) {
156
196
  png = result
157
197
  devCache.set(cacheKey, result)
@@ -210,12 +250,14 @@ export function faviconPlugin(config: FaviconPluginConfig): Plugin {
210
250
  // Inject favicon <link> tags into HTML
211
251
  transformIndexHtml() {
212
252
  const isSvg = config.source.endsWith('.svg')
253
+ const hasDark = !!config.darkSource
213
254
  const tags: Array<{
214
255
  tag: string
215
256
  attrs: Record<string, string>
216
257
  injectTo: 'head'
217
258
  }> = []
218
259
 
260
+ // SVG favicon (with prefers-color-scheme media query when dark variant exists)
219
261
  if (isSvg) {
220
262
  tags.push({
221
263
  tag: 'link',
@@ -224,23 +266,28 @@ export function faviconPlugin(config: FaviconPluginConfig): Plugin {
224
266
  })
225
267
  }
226
268
 
227
- tags.push(
228
- {
229
- tag: 'link',
230
- attrs: { rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-32x32.png' },
231
- injectTo: 'head',
232
- },
233
- {
234
- tag: 'link',
235
- attrs: { rel: 'icon', type: 'image/png', sizes: '16x16', href: '/favicon-16x16.png' },
236
- injectTo: 'head',
237
- },
238
- {
239
- tag: 'link',
240
- attrs: { rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon.png' },
241
- injectTo: 'head',
242
- },
243
- )
269
+ if (hasDark) {
270
+ // Dual-variant PNG/ICO favicons — light active, dark hidden via media="not all".
271
+ // The themeScript and initTheme() swap these based on the resolved theme.
272
+ const lightAttrs = { 'data-favicon-theme': 'light' }
273
+ const darkAttrs = { 'data-favicon-theme': 'dark', media: 'not all' }
274
+
275
+ tags.push(
276
+ { tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-light-32x32.png', ...lightAttrs }, injectTo: 'head' },
277
+ { tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-dark-32x32.png', ...darkAttrs }, injectTo: 'head' },
278
+ { tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '16x16', href: '/favicon-light-16x16.png', ...lightAttrs }, injectTo: 'head' },
279
+ { tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '16x16', href: '/favicon-dark-16x16.png', ...darkAttrs }, injectTo: 'head' },
280
+ { tag: 'link', attrs: { rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon-light.png', ...lightAttrs }, injectTo: 'head' },
281
+ { tag: 'link', attrs: { rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon-dark.png', ...darkAttrs }, injectTo: 'head' },
282
+ )
283
+ } else {
284
+ // Single-variant (no dark mode)
285
+ tags.push(
286
+ { tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-32x32.png' }, injectTo: 'head' },
287
+ { tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '16x16', href: '/favicon-16x16.png' }, injectTo: 'head' },
288
+ { tag: 'link', attrs: { rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon.png' }, injectTo: 'head' },
289
+ )
290
+ }
244
291
 
245
292
  if (generateManifest) {
246
293
  tags.push({
@@ -371,14 +418,43 @@ async function generateFaviconSet(
371
418
  }
372
419
 
373
420
  // 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
- })
421
+ if (darkSource) {
422
+ // Dual-variant: generate light + dark PNGs with prefixed names
423
+ const darkPath = join(rootDir, darkSource)
424
+ const darkExists = existsSync(darkPath)
425
+
426
+ for (const { size, name } of SIZES) {
427
+ // Light variant
428
+ const lightName = name.replace(/^(favicon-)/, '$1light-').replace(/^(apple-touch-icon)/, '$1-light').replace(/^(icon-)/, '$1light-')
429
+ const lightPng = await resizeToPng(sourcePath, size)
430
+ if (lightPng) {
431
+ this.emitFile({ type: 'asset', fileName: `${prefix}${lightName}`, source: lightPng })
432
+ }
433
+
434
+ // Dark variant
435
+ if (darkExists) {
436
+ const darkName = name.replace(/^(favicon-)/, '$1dark-').replace(/^(apple-touch-icon)/, '$1-dark').replace(/^(icon-)/, '$1dark-')
437
+ const darkPng = await resizeToPng(darkPath, size)
438
+ if (darkPng) {
439
+ this.emitFile({ type: 'asset', fileName: `${prefix}${darkName}`, source: darkPng })
440
+ }
441
+ }
442
+ }
443
+
444
+ // Also generate standard names (used by manifest + external references)
445
+ for (const { size, name } of SIZES) {
446
+ const pngBuffer = await resizeToPng(sourcePath, size)
447
+ if (pngBuffer) {
448
+ this.emitFile({ type: 'asset', fileName: `${prefix}${name}`, source: pngBuffer })
449
+ }
450
+ }
451
+ } else {
452
+ // Single-variant
453
+ for (const { size, name } of SIZES) {
454
+ const pngBuffer = await resizeToPng(sourcePath, size)
455
+ if (pngBuffer) {
456
+ this.emitFile({ type: 'asset', fileName: `${prefix}${name}`, source: pngBuffer })
457
+ }
382
458
  }
383
459
  }
384
460
 
@@ -519,3 +595,57 @@ export function createIcoFromPngs(entries: IcoEntry[]): Uint8Array {
519
595
 
520
596
  return Buffer.concat([header, dirEntries, ...dataBuffers])
521
597
  }
598
+
599
+ // ─── Dev badge helpers ──────────────────────────────────────────────────────
600
+
601
+ /**
602
+ * Add a "DEV" badge overlay to an SVG string.
603
+ * Adds a small colored circle with "DEV" text in the bottom-right corner.
604
+ */
605
+ function addDevBadgeToSvg(svg: string): string {
606
+ const viewBoxMatch = svg.match(/viewBox="([^"]*)"/)
607
+ const viewBox = viewBoxMatch?.[1] ?? '0 0 32 32'
608
+ const [, , w, h] = viewBox.split(' ').map(Number)
609
+ const size = Math.min(w ?? 32, h ?? 32)
610
+ const r = size * 0.28
611
+ const cx = (w ?? 32) - r
612
+ const cy = (h ?? 32) - r
613
+ const fontSize = r * 0.85
614
+
615
+ const badge = `<circle cx="${cx}" cy="${cy}" r="${r}" fill="#ef4444" stroke="white" stroke-width="${size * 0.03}"/>` +
616
+ `<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>`
617
+
618
+ // Insert badge before closing </svg>
619
+ return svg.replace(/<\/svg>\s*$/, `${badge}</svg>`)
620
+ }
621
+
622
+ /**
623
+ * Add a "DEV" badge to a PNG buffer via sharp composite.
624
+ * Composites a red circle with "D" in the bottom-right corner.
625
+ */
626
+ async function addDevBadgeToPng(pngBuffer: Uint8Array, size: number): Promise<Uint8Array> {
627
+ try {
628
+ const sharp = await import('sharp').then((m) => m.default ?? m)
629
+ const r = Math.round(size * 0.28)
630
+ const d = r * 2
631
+ const fontSize = Math.round(r * 0.85)
632
+
633
+ const badgeSvg = `<svg width="${d}" height="${d}" xmlns="http://www.w3.org/2000/svg">
634
+ <circle cx="${r}" cy="${r}" r="${r}" fill="#ef4444"/>
635
+ <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>
636
+ </svg>`
637
+
638
+ const badgePng = await sharp(Buffer.from(badgeSvg)).png().toBuffer()
639
+
640
+ return await (sharp(Buffer.from(pngBuffer)) as any)
641
+ .composite([{
642
+ input: badgePng,
643
+ gravity: 'southeast',
644
+ }])
645
+ .png()
646
+ .toBuffer()
647
+ } catch {
648
+ // sharp not available — return original
649
+ return pngBuffer
650
+ }
651
+ }
package/src/index.ts CHANGED
@@ -1,46 +1,17 @@
1
- // ─── Core ─────────────────────────────────────────────────────────────────────
2
-
3
- export type { CreateAppOptions } from "./app";
4
- export { createApp } from "./app";
5
- export type { CreateServerOptions } from "./entry-server";
6
- export { createServer } from "./entry-server";
7
-
8
- // ─── Vite plugin ─────────────────────────────────────────────────────────────
9
-
10
- export { zeroPlugin as default } from "./vite-plugin";
11
-
12
- // ─── File-system routing ─────────────────────────────────────────────────────
13
-
14
- export type { GenerateRouteModuleOptions } from './fs-router'
15
- export {
16
- filePathToUrlPath,
17
- generateMiddlewareModule,
18
- generateRouteModule,
19
- parseFileRoutes,
20
- scanRouteFiles,
21
- } from './fs-router'
22
-
23
- // ─── Config ──────────────────────────────────────────────────────────────────
24
-
25
- export { defineConfig, resolveConfig } from "./config";
26
-
27
- // ─── ISR ─────────────────────────────────────────────────────────────────────
28
-
29
- export { createISRHandler } from "./isr";
30
-
31
- // ─── Adapters ────────────────────────────────────────────────────────────────
32
-
33
- export {
34
- bunAdapter,
35
- cloudflareAdapter,
36
- netlifyAdapter,
37
- nodeAdapter,
38
- resolveAdapter,
39
- staticAdapter,
40
- vercelAdapter,
41
- } from "./adapters";
42
-
43
- // ─── Components ─────────────────────────────────────────────────────────────
1
+ /**
2
+ * @pyreon/zero — client-safe exports.
3
+ *
4
+ * This entry contains only browser-safe components and hooks.
5
+ * No node:fs, node:path, or other server-only imports.
6
+ *
7
+ * For server/build-time features, use subpath imports:
8
+ * import { faviconPlugin } from "@pyreon/zero/favicon"
9
+ * import { createServer } from "@pyreon/zero/server"
10
+ * import { defineConfig } from "@pyreon/zero/config"
11
+ * import { validateEnv } from "@pyreon/zero/env"
12
+ */
13
+
14
+ // ─── Components (browser-safe) ──────────────────────────────────────────────
44
15
 
45
16
  export type { ImageProps, ImageSource } from "./image";
46
17
  export { Image } from "./image";
@@ -48,41 +19,10 @@ export type { LinkProps, LinkRenderProps, UseLinkReturn } from "./link";
48
19
  export { createLink, Link, prefetchRoute, useLink } from "./link";
49
20
  export type { ScriptProps, ScriptStrategy } from "./script";
50
21
  export { Script } from "./script";
22
+ export type { MetaProps } from "./meta";
23
+ export { buildMetaTags, Meta } from "./meta";
51
24
 
52
- // ─── 404 Not Found ──────────────────────────────────────────────────────────
53
-
54
- export { render404Page } from "./not-found";
55
-
56
- // ─── Middleware ──────────────────────────────────────────────────────────────
57
-
58
- export type { CacheConfig, CacheRule } from "./cache";
59
- export { cacheMiddleware, securityHeaders, varyEncoding } from "./cache";
60
- export { compose, getContext } from "./middleware";
61
-
62
- // ─── Font optimization ─────────────────────────────────────────────────────
63
-
64
- export type {
65
- FallbackMetrics,
66
- FontConfig,
67
- FontDisplay,
68
- GoogleFontInput,
69
- GoogleFontStatic,
70
- GoogleFontVariable,
71
- LocalFont,
72
- } from "./font";
73
- export { fontPlugin, fontVariables } from "./font";
74
-
75
- // ─── Image processing ──────────────────────────────────────────────────────
76
-
77
- export type {
78
- FormatSource,
79
- ImageFormat,
80
- ImagePluginConfig,
81
- ProcessedImage,
82
- } from "./image-plugin";
83
- export { imagePlugin } from "./image-plugin";
84
-
85
- // ─── Theme ──────────────────────────────────────────────────────────────────
25
+ // ─── Theme (browser-safe) ───────────────────────────────────────────────────
86
26
 
87
27
  export type { Theme } from "./theme";
88
28
  export {
@@ -96,120 +36,45 @@ export {
96
36
  toggleTheme,
97
37
  } from "./theme";
98
38
 
99
- // ─── SEO ────────────────────────────────────────────────────────────────────
100
-
101
- export type {
102
- ChangeFreq,
103
- JsonLdType,
104
- RobotsConfig,
105
- RobotsRule,
106
- SeoPluginConfig,
107
- SitemapConfig,
108
- SitemapEntry,
109
- } from "./seo";
110
- export {
111
- generateRobots,
112
- generateSitemap,
113
- jsonLd,
114
- seoMiddleware,
115
- seoPlugin,
116
- } from "./seo";
117
-
118
- // ─── API routes ──────────────────────────────────────────────────────────────
119
-
120
- export type {
121
- ApiContext,
122
- ApiHandler,
123
- ApiRouteEntry,
124
- ApiRouteModule,
125
- HttpMethod,
126
- } from "./api-routes";
127
- export { createApiMiddleware, generateApiRouteModule } from "./api-routes";
128
-
129
- // ─── CORS ────────────────────────────────────────────────────────────────────
130
-
131
- export type { CorsConfig } from "./cors";
132
- export { corsMiddleware } from "./cors";
133
-
134
- // ─── Rate limiting ──────────────────────────────────────────────────────────
135
-
136
- export type { RateLimitConfig } from "./rate-limit";
137
- export { rateLimitMiddleware } from "./rate-limit";
138
-
139
- // ─── Compression ────────────────────────────────────────────────────────────
140
-
141
- export type { CompressionConfig } from "./compression";
142
- export {
143
- compressionMiddleware,
144
- compressResponse,
145
- isCompressible,
146
- } from "./compression";
147
-
148
- // ─── Actions ─────────────────────────────────────────────────────────────────
149
-
150
- export type { Action, ActionContext, ActionHandler } from "./actions";
151
- export { createActionMiddleware, defineAction } from "./actions";
152
-
153
- // ─── Favicon ────────────────────────────────────────────────────────────────
154
-
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";
166
-
167
- // ─── Meta ───────────────────────────────────────────────────────────────────
168
-
169
- export type { MetaProps } from "./meta";
170
- export { buildMetaTags, Meta } from "./meta";
171
-
172
- // ─── I18n routing ───────────────────────────────────────────────────────────
39
+ // ─── I18n hooks (browser-safe) ──────────────────────────────────────────────
173
40
 
174
41
  export type { I18nRoutingConfig, LocaleContext } from "./i18n-routing";
175
42
  export {
176
43
  buildLocalePath,
177
- createLocaleContext,
178
- detectLocaleFromHeader,
179
44
  extractLocaleFromPath,
180
- i18nRouting,
181
45
  setLocale,
182
46
  useLocale,
183
47
  } from "./i18n-routing";
184
48
 
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
-
212
- // ─── Types ───────────────────────────────────────────────────────────────────
49
+ // ─── Server-only stubs ──────────────────────────────────────────────────────
50
+ // Throw clear error messages when developers accidentally import server-only
51
+ // APIs from the main entry. These are tree-shaken if not imported.
52
+
53
+ function serverOnly(name: string, subpath: string): never {
54
+ throw new Error(
55
+ `[Pyreon] "${name}" is server-only and cannot be imported from "@pyreon/zero".\n` +
56
+ `Import from the subpath instead:\n\n` +
57
+ ` import { ${name} } from "@pyreon/zero/${subpath}"\n`,
58
+ )
59
+ }
60
+
61
+ /* eslint-disable @typescript-eslint/no-unused-vars */
62
+ /** @deprecated Import from `@pyreon/zero/favicon` instead */
63
+ export function faviconPlugin(..._: unknown[]): never { return serverOnly('faviconPlugin', 'favicon') }
64
+ /** @deprecated Import from `@pyreon/zero/seo` instead */
65
+ export function seoPlugin(..._: unknown[]): never { return serverOnly('seoPlugin', 'seo') }
66
+ /** @deprecated Import from `@pyreon/zero/server` instead */
67
+ export function createServer(..._: unknown[]): never { return serverOnly('createServer', 'server') }
68
+ /** @deprecated Import from `@pyreon/zero/config` instead */
69
+ export function defineConfig(..._: unknown[]): never { return serverOnly('defineConfig', 'config') }
70
+ /** @deprecated Import from `@pyreon/zero/env` instead */
71
+ export function validateEnv(..._: unknown[]): never { return serverOnly('validateEnv', 'env') }
72
+ /** @deprecated Import from `@pyreon/zero/og-image` instead */
73
+ export function ogImagePlugin(..._: unknown[]): never { return serverOnly('ogImagePlugin', 'og-image') }
74
+ /** @deprecated Import from `@pyreon/zero/ai` instead */
75
+ export function aiPlugin(..._: unknown[]): never { return serverOnly('aiPlugin', 'ai') }
76
+
77
+ // ─── Types (no runtime, safe everywhere) ────────────────────────────────────
213
78
 
214
79
  export type {
215
80
  Adapter,
package/src/meta.tsx CHANGED
@@ -1,11 +1,45 @@
1
1
  import type { VNodeChild } from '@pyreon/core'
2
2
  import type { UseHeadInput } from '@pyreon/head'
3
3
  import { useHead } from '@pyreon/head'
4
- import type { FaviconPluginConfig } from './favicon'
5
- import { faviconLinks } from './favicon'
6
4
  import type { I18nRoutingConfig } from './i18n-routing'
7
5
  import { extractLocaleFromPath } from './i18n-routing'
8
- import { ogImagePath } from './og-image'
6
+
7
+ // ─── Inline helpers (no node:fs dependency) ─────────────────────────────────
8
+ // These are inlined to avoid importing from favicon.ts/og-image.ts which
9
+ // pull in node:fs at the top level — making Meta unsafe for client bundles.
10
+
11
+ /** Favicon plugin config shape (type-only). */
12
+ interface FaviconPluginConfig {
13
+ source: string
14
+ themeColor?: string
15
+ manifest?: boolean
16
+ locales?: Record<string, { source: string; darkSource?: string }>
17
+ [key: string]: unknown
18
+ }
19
+
20
+ function faviconLinks(
21
+ locale: string | undefined,
22
+ config: FaviconPluginConfig,
23
+ ): Array<{ rel: string; type?: string; sizes?: string; href: string }> {
24
+ const hasLocaleOverride = locale && config.locales?.[locale]
25
+ const prefix = hasLocaleOverride ? `/${locale}` : ''
26
+ const isSvg = (hasLocaleOverride ? config.locales![locale]!.source : config.source).endsWith('.svg')
27
+ const links: Array<{ rel: string; type?: string; sizes?: string; href: string }> = []
28
+ if (isSvg) links.push({ rel: 'icon', type: 'image/svg+xml', href: `${prefix}/favicon.svg` })
29
+ links.push(
30
+ { rel: 'icon', type: 'image/png', sizes: '32x32', href: `${prefix}/favicon-32x32.png` },
31
+ { rel: 'icon', type: 'image/png', sizes: '16x16', href: `${prefix}/favicon-16x16.png` },
32
+ { rel: 'apple-touch-icon', sizes: '180x180', href: `${prefix}/apple-touch-icon.png` },
33
+ )
34
+ if (config.manifest !== false) links.push({ rel: 'manifest', href: `${prefix}/site.webmanifest` })
35
+ return links
36
+ }
37
+
38
+ function ogImagePath(templateName: string, locale?: string, outDir = 'og', format: 'png' | 'jpeg' = 'png'): string {
39
+ const ext = format === 'jpeg' ? 'jpg' : 'png'
40
+ const suffix = locale ? `-${locale}` : ''
41
+ return `/${outDir}/${templateName}${suffix}.${ext}`
42
+ }
9
43
 
10
44
  // ─── Meta component ────────────────────────────────────────────────────────
11
45
 
package/src/server.ts ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @pyreon/zero/server — server-only exports.
3
+ *
4
+ * Import from this subpath for SSR, middleware, adapters, and build tools.
5
+ * These modules use node:fs, node:path, etc. and must NOT be imported
6
+ * in client-side code.
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * import { createServer, createApp } from "@pyreon/zero/server"
11
+ * ```
12
+ */
13
+
14
+ // ─── Server entry ───────────────────────────────────────────────────────────
15
+
16
+ export type { CreateAppOptions } from "./app";
17
+ export { createApp } from "./app";
18
+ export type { CreateServerOptions } from "./entry-server";
19
+ export { createServer } from "./entry-server";
20
+
21
+ // ─── Config ─────────────────────────────────────────────────────────────────
22
+
23
+ export { defineConfig, resolveConfig } from "./config";
24
+
25
+ // ─── File-system routing ────────────────────────────────────────────────────
26
+
27
+ export type { GenerateRouteModuleOptions } from './fs-router'
28
+ export {
29
+ filePathToUrlPath,
30
+ generateMiddlewareModule,
31
+ generateRouteModule,
32
+ parseFileRoutes,
33
+ scanRouteFiles,
34
+ } from './fs-router'
35
+
36
+ // ─── ISR ────────────────────────────────────────────────────────────────────
37
+
38
+ export { createISRHandler } from "./isr";
39
+
40
+ // ─── Adapters ───────────────────────────────────────────────────────────────
41
+
42
+ export {
43
+ bunAdapter,
44
+ cloudflareAdapter,
45
+ netlifyAdapter,
46
+ nodeAdapter,
47
+ resolveAdapter,
48
+ staticAdapter,
49
+ vercelAdapter,
50
+ } from "./adapters";
51
+
52
+ // ─── 404 ────────────────────────────────────────────────────────────────────
53
+
54
+ export { render404Page } from "./not-found";
55
+
56
+ // ─── Middleware ──────────────────────────────────────────────────────────────
57
+
58
+ export { compose, getContext } from "./middleware";
59
+
60
+ // ─── Vite plugin ────────────────────────────────────────────────────────────
61
+
62
+ export { zeroPlugin as default } from "./vite-plugin";
63
+
64
+ // ─── I18n server-only ───────────────────────────────────────────────────────
65
+
66
+ export {
67
+ createLocaleContext,
68
+ detectLocaleFromHeader,
69
+ i18nRouting,
70
+ } from "./i18n-routing";