@pyreon/zero 0.11.8 → 0.11.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/lib/font.js +20 -7
  2. package/lib/font.js.map +1 -1
  3. package/lib/fs-router-BkbIWqek.js.map +1 -1
  4. package/lib/{fs-router-n4VA4lxu.js → fs-router-Dil4IKZR.js} +23 -19
  5. package/lib/fs-router-Dil4IKZR.js.map +1 -0
  6. package/lib/image-plugin.js.map +1 -1
  7. package/lib/index.js +893 -24
  8. package/lib/index.js.map +1 -1
  9. package/lib/link.js +13 -1
  10. package/lib/link.js.map +1 -1
  11. package/lib/types/actions.d.ts +57 -0
  12. package/lib/types/actions.d.ts.map +1 -0
  13. package/lib/types/adapters/bun.d.ts +6 -0
  14. package/lib/types/adapters/bun.d.ts.map +1 -0
  15. package/lib/types/adapters/index.d.ts +10 -0
  16. package/lib/types/adapters/index.d.ts.map +1 -0
  17. package/lib/types/adapters/node.d.ts +6 -0
  18. package/lib/types/adapters/node.d.ts.map +1 -0
  19. package/lib/types/adapters/static.d.ts +7 -0
  20. package/lib/types/adapters/static.d.ts.map +1 -0
  21. package/lib/types/api-routes.d.ts +66 -0
  22. package/lib/types/api-routes.d.ts.map +1 -0
  23. package/lib/types/app.d.ts +24 -0
  24. package/lib/types/app.d.ts.map +1 -0
  25. package/lib/types/cache.d.ts +54 -0
  26. package/lib/types/cache.d.ts.map +1 -0
  27. package/lib/types/client.d.ts +19 -0
  28. package/lib/types/client.d.ts.map +1 -0
  29. package/lib/types/compression.d.ts +33 -0
  30. package/lib/types/compression.d.ts.map +1 -0
  31. package/lib/types/config.d.ts +18 -0
  32. package/lib/types/config.d.ts.map +1 -0
  33. package/lib/types/cors.d.ts +32 -0
  34. package/lib/types/cors.d.ts.map +1 -0
  35. package/lib/types/entry-server.d.ts +37 -0
  36. package/lib/types/entry-server.d.ts.map +1 -0
  37. package/lib/types/error-overlay.d.ts +6 -0
  38. package/lib/types/error-overlay.d.ts.map +1 -0
  39. package/lib/types/favicon.d.ts +43 -0
  40. package/lib/types/favicon.d.ts.map +1 -0
  41. package/lib/types/font.d.ts +119 -0
  42. package/lib/types/font.d.ts.map +1 -0
  43. package/lib/types/fs-router.d.ts +47 -0
  44. package/lib/types/fs-router.d.ts.map +1 -0
  45. package/lib/types/i18n-routing.d.ts +98 -0
  46. package/lib/types/i18n-routing.d.ts.map +1 -0
  47. package/lib/types/image-plugin.d.ts +79 -0
  48. package/lib/types/image-plugin.d.ts.map +1 -0
  49. package/lib/types/image.d.ts +51 -0
  50. package/lib/types/image.d.ts.map +1 -0
  51. package/lib/types/index.d.ts +46 -0
  52. package/lib/types/index.d.ts.map +1 -0
  53. package/lib/types/isr.d.ts +9 -0
  54. package/lib/types/isr.d.ts.map +1 -0
  55. package/lib/types/link.d.ts +127 -0
  56. package/lib/types/link.d.ts.map +1 -0
  57. package/lib/types/meta.d.ts +91 -0
  58. package/lib/types/meta.d.ts.map +1 -0
  59. package/lib/types/middleware.d.ts +35 -0
  60. package/lib/types/middleware.d.ts.map +1 -0
  61. package/lib/types/not-found.d.ts +7 -0
  62. package/lib/types/not-found.d.ts.map +1 -0
  63. package/lib/types/rate-limit.d.ts +34 -0
  64. package/lib/types/rate-limit.d.ts.map +1 -0
  65. package/lib/types/script.d.ts +35 -0
  66. package/lib/types/script.d.ts.map +1 -0
  67. package/lib/types/seo.d.ts +88 -0
  68. package/lib/types/seo.d.ts.map +1 -0
  69. package/lib/types/testing.d.ts +85 -0
  70. package/lib/types/testing.d.ts.map +1 -0
  71. package/lib/types/theme.d.ts +39 -0
  72. package/lib/types/theme.d.ts.map +1 -0
  73. package/lib/types/types.d.ts +111 -0
  74. package/lib/types/types.d.ts.map +1 -0
  75. package/lib/types/utils/use-intersection-observer.d.ts +10 -0
  76. package/lib/types/utils/use-intersection-observer.d.ts.map +1 -0
  77. package/lib/types/utils/with-headers.d.ts +6 -0
  78. package/lib/types/utils/with-headers.d.ts.map +1 -0
  79. package/lib/types/vite-plugin.d.ts +17 -0
  80. package/lib/types/vite-plugin.d.ts.map +1 -0
  81. package/package.json +10 -10
  82. package/src/entry-server.ts +124 -76
  83. package/src/favicon.ts +380 -0
  84. package/src/font.ts +32 -8
  85. package/src/fs-router.ts +54 -13
  86. package/src/i18n-routing.ts +299 -0
  87. package/src/image-plugin.ts +1 -1
  88. package/src/index.ts +125 -76
  89. package/src/link.tsx +19 -0
  90. package/src/meta.tsx +210 -0
  91. package/src/middleware.ts +65 -0
  92. package/src/not-found.ts +44 -0
  93. package/src/types.ts +2 -0
  94. package/src/vite-plugin.ts +258 -127
  95. package/lib/fs-router-n4VA4lxu.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/zero",
3
- "version": "0.11.8",
3
+ "version": "0.11.10",
4
4
  "description": "Pyreon Zero — zero-config full-stack framework powered by Pyreon and Vite",
5
5
  "license": "MIT",
6
6
  "author": "Vit Bokisch",
@@ -116,17 +116,17 @@
116
116
  "lint": "oxlint ."
117
117
  },
118
118
  "dependencies": {
119
- "@pyreon/core": "^0.11.8",
120
- "@pyreon/head": "^0.11.8",
121
- "@pyreon/meta": "^0.11.8",
122
- "@pyreon/router": "^0.11.8",
123
- "@pyreon/runtime-dom": "^0.11.8",
124
- "@pyreon/runtime-server": "^0.11.8",
125
- "@pyreon/server": "^0.11.8",
126
- "@pyreon/vite-plugin": "^0.11.8",
119
+ "@pyreon/core": "^0.11.10",
120
+ "@pyreon/head": "^0.11.10",
121
+ "@pyreon/meta": "^0.11.10",
122
+ "@pyreon/router": "^0.11.10",
123
+ "@pyreon/runtime-dom": "^0.11.10",
124
+ "@pyreon/runtime-server": "^0.11.10",
125
+ "@pyreon/server": "^0.11.10",
126
+ "@pyreon/vite-plugin": "^0.11.10",
127
127
  "vite": "^8.0.0"
128
128
  },
129
129
  "peerDependencies": {
130
- "@pyreon/reactivity": "^0.11.8"
130
+ "@pyreon/reactivity": "^0.11.10"
131
131
  }
132
132
  }
@@ -1,61 +1,69 @@
1
- import type { RouteRecord } from '@pyreon/router'
2
- import type { Middleware, MiddlewareContext } from '@pyreon/server'
3
- import { createHandler } from '@pyreon/server'
4
- import type { ApiRouteEntry } from './api-routes'
5
- import { createApiMiddleware } from './api-routes'
6
- import { createApp } from './app'
7
- import type { RouteMiddlewareEntry, ZeroConfig } from './types'
1
+ import type { ComponentFn } from "@pyreon/core";
2
+ import type { RouteRecord } from "@pyreon/router";
3
+ import type { Middleware, MiddlewareContext } from "@pyreon/server";
4
+ import { createHandler } from "@pyreon/server";
5
+ import type { ApiRouteEntry } from "./api-routes";
6
+ import { createApiMiddleware } from "./api-routes";
7
+ import { createApp } from "./app";
8
+ import { render404Page } from "./not-found";
9
+ import type { RouteMiddlewareEntry, ZeroConfig } from "./types";
8
10
 
9
11
  // ─── Server entry factory ───────────────────────────────────────────────────
10
12
 
11
13
  export interface CreateServerOptions {
12
- /** Route definitions. */
13
- routes: RouteRecord[]
14
- /** Zero config. */
15
- config?: ZeroConfig
16
- /** Additional middleware. */
17
- middleware?: Middleware[]
18
- /** Per-route middleware from virtual:zero/route-middleware. */
19
- routeMiddleware?: RouteMiddlewareEntry[]
20
- /** API route entries from virtual:zero/api-routes. */
21
- apiRoutes?: ApiRouteEntry[]
22
- /** HTML template override. */
23
- template?: string
24
- /** Client entry path. */
25
- clientEntry?: string
14
+ /** Route definitions. */
15
+ routes: RouteRecord[];
16
+ /** Zero config. */
17
+ config?: ZeroConfig;
18
+ /** Additional middleware. */
19
+ middleware?: Middleware[];
20
+ /** Per-route middleware from virtual:zero/route-middleware. */
21
+ routeMiddleware?: RouteMiddlewareEntry[];
22
+ /** API route entries from virtual:zero/api-routes. */
23
+ apiRoutes?: ApiRouteEntry[];
24
+ /** HTML template override. */
25
+ template?: string;
26
+ /** Client entry path. */
27
+ clientEntry?: string;
28
+ /** Component to render when no route matches (from _404.tsx). */
29
+ notFoundComponent?: ComponentFn;
26
30
  }
27
31
 
28
32
  /**
29
33
  * Create a middleware that dispatches per-route middleware based on URL pattern matching.
30
34
  */
31
- function createRouteMiddlewareDispatcher(entries: RouteMiddlewareEntry[]): Middleware {
32
- return async (ctx: MiddlewareContext) => {
33
- for (const entry of entries) {
34
- if (matchPattern(entry.pattern, ctx.path)) {
35
- const mw = Array.isArray(entry.middleware) ? entry.middleware : [entry.middleware]
36
- for (const fn of mw) {
37
- const result = await fn(ctx)
38
- if (result) return result
39
- }
40
- }
41
- }
42
- }
35
+ function createRouteMiddlewareDispatcher(
36
+ entries: RouteMiddlewareEntry[],
37
+ ): Middleware {
38
+ return async (ctx: MiddlewareContext) => {
39
+ for (const entry of entries) {
40
+ if (matchPattern(entry.pattern, ctx.path)) {
41
+ const mw = Array.isArray(entry.middleware)
42
+ ? entry.middleware
43
+ : [entry.middleware];
44
+ for (const fn of mw) {
45
+ const result = await fn(ctx);
46
+ if (result) return result;
47
+ }
48
+ }
49
+ }
50
+ };
43
51
  }
44
52
 
45
53
  /** Simple URL pattern matcher supporting :param and :param* segments. */
46
54
  export function matchPattern(pattern: string, path: string): boolean {
47
- const patternParts = pattern.split('/').filter(Boolean)
48
- const pathParts = path.split('/').filter(Boolean)
49
-
50
- for (let i = 0; i < patternParts.length; i++) {
51
- const pp = patternParts[i]
52
- if (!pp) continue
53
- if (pp.endsWith('*')) return true // catch-all matches everything after
54
- if (pp.startsWith(':')) continue // dynamic segment matches anything
55
- if (pp !== pathParts[i]) return false
56
- }
57
-
58
- return patternParts.length === pathParts.length
55
+ const patternParts = pattern.split("/").filter(Boolean);
56
+ const pathParts = path.split("/").filter(Boolean);
57
+
58
+ for (let i = 0; i < patternParts.length; i++) {
59
+ const pp = patternParts[i];
60
+ if (!pp) continue;
61
+ if (pp.endsWith("*")) return true; // catch-all matches everything after
62
+ if (pp.startsWith(":")) continue; // dynamic segment matches anything
63
+ if (pp !== pathParts[i]) return false;
64
+ }
65
+
66
+ return patternParts.length === pathParts.length;
59
67
  }
60
68
 
61
69
  /**
@@ -69,35 +77,75 @@ export function matchPattern(pattern: string, path: string): boolean {
69
77
  * export default createServer({ routes, routeMiddleware, apiRoutes })
70
78
  */
71
79
  export function createServer(options: CreateServerOptions) {
72
- const config = options.config ?? {}
73
-
74
- const allMiddleware: Middleware[] = []
75
-
76
- // API routes run first — they short-circuit before SSR
77
- if (options.apiRoutes?.length) {
78
- allMiddleware.push(createApiMiddleware(options.apiRoutes))
79
- }
80
-
81
- // Per-route middleware runs next
82
- if (options.routeMiddleware?.length) {
83
- allMiddleware.push(createRouteMiddlewareDispatcher(options.routeMiddleware))
84
- }
85
-
86
- // Then global middleware from config and options
87
- allMiddleware.push(...(config.middleware ?? []))
88
- allMiddleware.push(...(options.middleware ?? []))
89
-
90
- const { App } = createApp({
91
- routes: options.routes,
92
- routerMode: 'history',
93
- })
94
-
95
- return createHandler({
96
- App,
97
- routes: options.routes,
98
- middleware: allMiddleware,
99
- mode: config.ssr?.mode ?? 'string',
100
- ...(options.template ? { template: options.template } : {}),
101
- ...(options.clientEntry ? { clientEntry: options.clientEntry } : {}),
102
- })
80
+ const config = options.config ?? {};
81
+
82
+ const allMiddleware: Middleware[] = [];
83
+
84
+ // API routes run first — they short-circuit before SSR
85
+ if (options.apiRoutes?.length) {
86
+ allMiddleware.push(createApiMiddleware(options.apiRoutes));
87
+ }
88
+
89
+ // Per-route middleware runs next
90
+ if (options.routeMiddleware?.length) {
91
+ allMiddleware.push(
92
+ createRouteMiddlewareDispatcher(options.routeMiddleware),
93
+ );
94
+ }
95
+
96
+ // Then global middleware from config and options
97
+ allMiddleware.push(...(config.middleware ?? []));
98
+ allMiddleware.push(...(options.middleware ?? []));
99
+
100
+ const { App } = createApp({
101
+ routes: options.routes,
102
+ routerMode: "history",
103
+ });
104
+
105
+ const handler = createHandler({
106
+ App,
107
+ routes: options.routes,
108
+ middleware: allMiddleware,
109
+ mode: config.ssr?.mode ?? "string",
110
+ ...(options.template ? { template: options.template } : {}),
111
+ ...(options.clientEntry ? { clientEntry: options.clientEntry } : {}),
112
+ });
113
+
114
+ // Wrap handler with 404 detection when a notFoundComponent is provided
115
+ if (!options.notFoundComponent) return handler;
116
+
117
+ const NotFound = options.notFoundComponent;
118
+ const routePatterns = flattenRoutePatterns(options.routes);
119
+
120
+ return async (req: Request) => {
121
+ const url = new URL(req.url);
122
+ const pathname = url.pathname;
123
+
124
+ // Check if any defined route matches this path
125
+ if (!routePatterns.some((pattern) => matchPattern(pattern, pathname))) {
126
+ const fullHtml = await render404Page(NotFound, options.template);
127
+ return new Response(fullHtml, {
128
+ status: 404,
129
+ headers: { "Content-Type": "text/html; charset=utf-8" },
130
+ });
131
+ }
132
+
133
+ return handler(req);
134
+ };
135
+ }
136
+
137
+ /** Extract all URL patterns from a nested route tree. */
138
+ function flattenRoutePatterns(routes: RouteRecord[], prefix = ""): string[] {
139
+ const patterns: string[] = [];
140
+ for (const route of routes) {
141
+ const fullPath =
142
+ route.path === "/" && prefix ? prefix : `${prefix}${route.path}`;
143
+ patterns.push(fullPath);
144
+ if (route.children) {
145
+ patterns.push(
146
+ ...flattenRoutePatterns(route.children as RouteRecord[], fullPath),
147
+ );
148
+ }
149
+ }
150
+ return patterns;
103
151
  }
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
+ // oxlint-disable-next-line no-console
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
+ // 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
+ }
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/font.ts CHANGED
@@ -146,14 +146,38 @@ export function parseGoogleFamily(input: string): ResolvedFont {
146
146
  }
147
147
  }
148
148
 
149
- // Static weights: wght@400;500;700
150
- const weightMatch = spec.match(/wght@([\d;]+)/)
151
- if (weightMatch && weightMatch[1]) {
152
- return {
153
- family,
154
- italic,
155
- variable: false,
156
- weights: weightMatch[1].split(';').map(Number),
149
+ // Static weights — two formats:
150
+ // Simple: "wght@400;500;700"
151
+ // Tuples: "ital,wght@0,300;0,500;1,300;1,500" (ital_flag,weight pairs)
152
+ const afterAt = spec.split('@')[1]
153
+ if (afterAt) {
154
+ const entries = afterAt.split(';').filter(Boolean)
155
+ const weights = new Set<number>()
156
+
157
+ for (const entry of entries) {
158
+ if (entry.includes(',')) {
159
+ // Tuple format: "0,300" or "1,500" — last value is the weight
160
+ const parts = entry.split(',')
161
+ const weight = Number(parts[parts.length - 1])
162
+ if (weight > 0) weights.add(weight)
163
+ // Detect italic from tuple: "1,xxx" means italic
164
+ if (parts[0] === '1') italic = true
165
+ } else if (entry.includes('..')) {
166
+ // Variable range already handled above — skip
167
+ } else {
168
+ // Simple weight: "400"
169
+ const weight = Number(entry)
170
+ if (weight > 0) weights.add(weight)
171
+ }
172
+ }
173
+
174
+ if (weights.size > 0) {
175
+ return {
176
+ family,
177
+ italic,
178
+ variable: false,
179
+ weights: [...weights].sort((a, b) => a - b),
180
+ }
157
181
  }
158
182
  }
159
183
  }