@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.
- package/lib/font.js +20 -7
- package/lib/font.js.map +1 -1
- 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/image-plugin.js.map +1 -1
- package/lib/index.js +893 -24
- package/lib/index.js.map +1 -1
- package/lib/link.js +13 -1
- package/lib/link.js.map +1 -1
- package/lib/types/actions.d.ts +57 -0
- package/lib/types/actions.d.ts.map +1 -0
- package/lib/types/adapters/bun.d.ts +6 -0
- package/lib/types/adapters/bun.d.ts.map +1 -0
- package/lib/types/adapters/index.d.ts +10 -0
- package/lib/types/adapters/index.d.ts.map +1 -0
- package/lib/types/adapters/node.d.ts +6 -0
- package/lib/types/adapters/node.d.ts.map +1 -0
- package/lib/types/adapters/static.d.ts +7 -0
- package/lib/types/adapters/static.d.ts.map +1 -0
- package/lib/types/api-routes.d.ts +66 -0
- package/lib/types/api-routes.d.ts.map +1 -0
- package/lib/types/app.d.ts +24 -0
- package/lib/types/app.d.ts.map +1 -0
- package/lib/types/cache.d.ts +54 -0
- package/lib/types/cache.d.ts.map +1 -0
- package/lib/types/client.d.ts +19 -0
- package/lib/types/client.d.ts.map +1 -0
- package/lib/types/compression.d.ts +33 -0
- package/lib/types/compression.d.ts.map +1 -0
- package/lib/types/config.d.ts +18 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/cors.d.ts +32 -0
- package/lib/types/cors.d.ts.map +1 -0
- package/lib/types/entry-server.d.ts +37 -0
- package/lib/types/entry-server.d.ts.map +1 -0
- package/lib/types/error-overlay.d.ts +6 -0
- package/lib/types/error-overlay.d.ts.map +1 -0
- package/lib/types/favicon.d.ts +43 -0
- package/lib/types/favicon.d.ts.map +1 -0
- package/lib/types/font.d.ts +119 -0
- package/lib/types/font.d.ts.map +1 -0
- package/lib/types/fs-router.d.ts +47 -0
- package/lib/types/fs-router.d.ts.map +1 -0
- package/lib/types/i18n-routing.d.ts +98 -0
- package/lib/types/i18n-routing.d.ts.map +1 -0
- package/lib/types/image-plugin.d.ts +79 -0
- package/lib/types/image-plugin.d.ts.map +1 -0
- package/lib/types/image.d.ts +51 -0
- package/lib/types/image.d.ts.map +1 -0
- package/lib/types/index.d.ts +46 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/isr.d.ts +9 -0
- package/lib/types/isr.d.ts.map +1 -0
- package/lib/types/link.d.ts +127 -0
- package/lib/types/link.d.ts.map +1 -0
- package/lib/types/meta.d.ts +91 -0
- package/lib/types/meta.d.ts.map +1 -0
- package/lib/types/middleware.d.ts +35 -0
- package/lib/types/middleware.d.ts.map +1 -0
- package/lib/types/not-found.d.ts +7 -0
- package/lib/types/not-found.d.ts.map +1 -0
- package/lib/types/rate-limit.d.ts +34 -0
- package/lib/types/rate-limit.d.ts.map +1 -0
- package/lib/types/script.d.ts +35 -0
- package/lib/types/script.d.ts.map +1 -0
- package/lib/types/seo.d.ts +88 -0
- package/lib/types/seo.d.ts.map +1 -0
- package/lib/types/testing.d.ts +85 -0
- package/lib/types/testing.d.ts.map +1 -0
- package/lib/types/theme.d.ts +39 -0
- package/lib/types/theme.d.ts.map +1 -0
- package/lib/types/types.d.ts +111 -0
- package/lib/types/types.d.ts.map +1 -0
- package/lib/types/utils/use-intersection-observer.d.ts +10 -0
- package/lib/types/utils/use-intersection-observer.d.ts.map +1 -0
- package/lib/types/utils/with-headers.d.ts +6 -0
- package/lib/types/utils/with-headers.d.ts.map +1 -0
- package/lib/types/vite-plugin.d.ts +17 -0
- package/lib/types/vite-plugin.d.ts.map +1 -0
- package/package.json +10 -10
- package/src/entry-server.ts +124 -76
- package/src/favicon.ts +380 -0
- package/src/font.ts +32 -8
- package/src/fs-router.ts +54 -13
- package/src/i18n-routing.ts +299 -0
- package/src/image-plugin.ts +1 -1
- package/src/index.ts +125 -76
- package/src/link.tsx +19 -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/zero",
|
|
3
|
-
"version": "0.11.
|
|
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.
|
|
120
|
-
"@pyreon/head": "^0.11.
|
|
121
|
-
"@pyreon/meta": "^0.11.
|
|
122
|
-
"@pyreon/router": "^0.11.
|
|
123
|
-
"@pyreon/runtime-dom": "^0.11.
|
|
124
|
-
"@pyreon/runtime-server": "^0.11.
|
|
125
|
-
"@pyreon/server": "^0.11.
|
|
126
|
-
"@pyreon/vite-plugin": "^0.11.
|
|
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.
|
|
130
|
+
"@pyreon/reactivity": "^0.11.10"
|
|
131
131
|
}
|
|
132
132
|
}
|
package/src/entry-server.ts
CHANGED
|
@@ -1,61 +1,69 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type {
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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(
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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:
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
}
|