@pyreon/zero 0.18.0 → 0.20.0
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/{api-routes-Ci0kVmM4.js → api-routes-CMsLztoj.js} +5 -3
- package/lib/api-routes.js +4 -2
- package/lib/favicon.js +57 -3
- package/lib/{fs-router-MewHc5SB.js → fs-router-Bacdhsq-.js} +4 -3
- package/lib/image-plugin.js +90 -19
- package/lib/index.js +96 -3
- package/lib/rate-limit.js +5 -0
- package/lib/seo.js +11 -6
- package/lib/server.js +2888 -147
- package/lib/testing.js +4 -2
- package/lib/types/config.d.ts +9 -0
- package/lib/types/favicon.d.ts +17 -1
- package/lib/types/i18n-routing.d.ts +2 -4
- package/lib/types/image-plugin.d.ts +65 -7
- package/lib/types/index.d.ts +91 -7
- package/lib/types/link.d.ts +2 -4
- package/lib/types/server.d.ts +89 -5
- package/lib/types/theme.d.ts +1 -2
- package/package.json +10 -10
- package/src/api-routes.ts +12 -2
- package/src/favicon.ts +84 -2
- package/src/fs-router.ts +7 -1
- package/src/icon.tsx +182 -0
- package/src/icons-plugin.ts +296 -0
- package/src/image-plugin.ts +200 -32
- package/src/index.ts +2 -0
- package/src/isr.ts +54 -10
- package/src/manifest.ts +99 -0
- package/src/rate-limit.ts +16 -0
- package/src/seo.ts +19 -4
- package/src/server.ts +2 -0
- package/src/sharp.d.ts +6 -0
- package/src/ssg-plugin.ts +47 -8
- package/src/types.ts +9 -0
- package/lib/vite-plugin-y0NmCLJA.js +0 -2476
|
@@ -16,17 +16,19 @@ function matchApiRoute(pattern, path) {
|
|
|
16
16
|
const patternParts = pattern.split("/").filter(Boolean);
|
|
17
17
|
const pathParts = path.split("/").filter(Boolean);
|
|
18
18
|
const params = {};
|
|
19
|
+
const isUnsafeParam = (name) => name === "__proto__" || name === "constructor" || name === "prototype";
|
|
19
20
|
for (let i = 0; i < patternParts.length; i++) {
|
|
20
21
|
const pp = patternParts[i];
|
|
21
22
|
if (!pp) continue;
|
|
22
23
|
if (pp.endsWith("*")) {
|
|
23
24
|
const paramName = pp.slice(1, -1);
|
|
24
|
-
params[paramName] = pathParts.slice(i).join("/");
|
|
25
|
+
if (!isUnsafeParam(paramName)) params[paramName] = pathParts.slice(i).join("/");
|
|
25
26
|
return params;
|
|
26
27
|
}
|
|
27
28
|
if (i >= pathParts.length) return null;
|
|
28
29
|
if (pp.startsWith(":")) {
|
|
29
|
-
|
|
30
|
+
const paramName = pp.slice(1);
|
|
31
|
+
if (!isUnsafeParam(paramName)) params[paramName] = pathParts[i];
|
|
30
32
|
continue;
|
|
31
33
|
}
|
|
32
34
|
if (pp !== pathParts[i]) return null;
|
|
@@ -143,4 +145,4 @@ function generateApiRouteModule(files, routesDir) {
|
|
|
143
145
|
|
|
144
146
|
//#endregion
|
|
145
147
|
export { matchApiRoute as i, createApiMiddleware as n, generateApiRouteModule as r, api_routes_exports as t };
|
|
146
|
-
//# sourceMappingURL=api-routes-
|
|
148
|
+
//# sourceMappingURL=api-routes-CMsLztoj.js.map
|
package/lib/api-routes.js
CHANGED
|
@@ -7,17 +7,19 @@ function matchApiRoute(pattern, path) {
|
|
|
7
7
|
const patternParts = pattern.split("/").filter(Boolean);
|
|
8
8
|
const pathParts = path.split("/").filter(Boolean);
|
|
9
9
|
const params = {};
|
|
10
|
+
const isUnsafeParam = (name) => name === "__proto__" || name === "constructor" || name === "prototype";
|
|
10
11
|
for (let i = 0; i < patternParts.length; i++) {
|
|
11
12
|
const pp = patternParts[i];
|
|
12
13
|
if (!pp) continue;
|
|
13
14
|
if (pp.endsWith("*")) {
|
|
14
15
|
const paramName = pp.slice(1, -1);
|
|
15
|
-
params[paramName] = pathParts.slice(i).join("/");
|
|
16
|
+
if (!isUnsafeParam(paramName)) params[paramName] = pathParts.slice(i).join("/");
|
|
16
17
|
return params;
|
|
17
18
|
}
|
|
18
19
|
if (i >= pathParts.length) return null;
|
|
19
20
|
if (pp.startsWith(":")) {
|
|
20
|
-
|
|
21
|
+
const paramName = pp.slice(1);
|
|
22
|
+
if (!isUnsafeParam(paramName)) params[paramName] = pathParts[i];
|
|
21
23
|
continue;
|
|
22
24
|
}
|
|
23
25
|
if (pp !== pathParts[i]) return null;
|
package/lib/favicon.js
CHANGED
|
@@ -1,8 +1,42 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
|
|
5
5
|
//#region src/favicon.ts
|
|
6
|
+
/**
|
|
7
|
+
* Stable content hash (FNV-1a, 32-bit) of the favicon source file(s),
|
|
8
|
+
* rendered as a `?v=<hex>` cache-bust query for the injected `<head>`
|
|
9
|
+
* links. Browsers cache favicons extremely aggressively (often per-
|
|
10
|
+
* session / effectively forever), so with a stable URL a changed icon
|
|
11
|
+
* is never re-fetched by returning visitors. Same source bytes →
|
|
12
|
+
* identical query (no needless cache churn); changed bytes → new query
|
|
13
|
+
* → browser re-downloads. Falls back to `''` (no query, prior
|
|
14
|
+
* behaviour) if a source can't be read — never break the build over a
|
|
15
|
+
* cache-bust nicety. NOTE: this versions everything referenced via
|
|
16
|
+
* `<link>` (svg/png/apple-touch/manifest). The bare `/favicon.ico`
|
|
17
|
+
* convention request (browsers fetch it with no link tag) and the
|
|
18
|
+
* `site.webmanifest`'s internal icon entries keep stable URLs — those
|
|
19
|
+
* rely on host cache headers / are re-resolved on PWA (re)install.
|
|
20
|
+
*/
|
|
21
|
+
function faviconVersionQuery(paths) {
|
|
22
|
+
let h = 2166136261;
|
|
23
|
+
let any = false;
|
|
24
|
+
for (const p of paths) {
|
|
25
|
+
let buf;
|
|
26
|
+
try {
|
|
27
|
+
buf = readFileSync(p);
|
|
28
|
+
} catch {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
any = true;
|
|
32
|
+
for (let i = 0; i < buf.length; i++) {
|
|
33
|
+
h ^= buf[i];
|
|
34
|
+
h = Math.imul(h, 16777619);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (!any) return "";
|
|
38
|
+
return `?v=${(h >>> 0).toString(16).padStart(8, "0")}`;
|
|
39
|
+
}
|
|
6
40
|
let sharpWarned = false;
|
|
7
41
|
function warnSharpMissing() {
|
|
8
42
|
if (sharpWarned) return;
|
|
@@ -53,6 +87,15 @@ function faviconPlugin(config) {
|
|
|
53
87
|
const generateManifest = config.manifest !== false;
|
|
54
88
|
let root = "";
|
|
55
89
|
let isBuild = false;
|
|
90
|
+
let versionQuery = null;
|
|
91
|
+
function getVersionQuery() {
|
|
92
|
+
if (versionQuery === null) {
|
|
93
|
+
const paths = [join(root, config.source)];
|
|
94
|
+
if (config.darkSource) paths.push(join(root, config.darkSource));
|
|
95
|
+
versionQuery = faviconVersionQuery(paths);
|
|
96
|
+
}
|
|
97
|
+
return versionQuery;
|
|
98
|
+
}
|
|
56
99
|
return {
|
|
57
100
|
name: "pyreon-zero-favicon",
|
|
58
101
|
enforce: "pre",
|
|
@@ -73,7 +116,7 @@ function faviconPlugin(config) {
|
|
|
73
116
|
return defaultSource;
|
|
74
117
|
}
|
|
75
118
|
server.middlewares.use(async (req, res, next) => {
|
|
76
|
-
const url = req.url ?? "";
|
|
119
|
+
const url = (req.url ?? "").split("?")[0];
|
|
77
120
|
const localeSource = resolveLocaleSource(url, config, root);
|
|
78
121
|
const svgUrl = localeSource ? localeSource.url : url;
|
|
79
122
|
const svgPath = localeSource ? localeSource.sourcePath : sourcePath;
|
|
@@ -277,10 +320,21 @@ function faviconPlugin(config) {
|
|
|
277
320
|
injectTo: "head",
|
|
278
321
|
children: `(function(){try{var t=localStorage.getItem("zero-theme");var r=t==="light"?"light":t==="dark"?"dark":window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light";document.querySelectorAll("[data-favicon-theme]").forEach(function(l){l.media=l.dataset.faviconTheme===r?"":"not all"})}catch(e){}})()`
|
|
279
322
|
});
|
|
323
|
+
const v = getVersionQuery();
|
|
324
|
+
if (v) {
|
|
325
|
+
for (const t of tags) if (t.tag === "link" && t.attrs.href) t.attrs.href += v;
|
|
326
|
+
}
|
|
280
327
|
return tags;
|
|
281
328
|
},
|
|
282
329
|
async generateBundle() {
|
|
283
330
|
if (!isBuild) return;
|
|
331
|
+
try {
|
|
332
|
+
await import("sharp");
|
|
333
|
+
} catch {
|
|
334
|
+
this.error(`[Pyreon] faviconPlugin: a favicon \`source\` is configured but \`sharp\` is not installed — NO favicons would be generated and the production build would silently ship none.
|
|
335
|
+
Fix: bun add -D sharp (or: npm i -D sharp)
|
|
336
|
+
Source: ${config.source}\nTo intentionally build without favicons, remove faviconPlugin() from your Vite plugins.`);
|
|
337
|
+
}
|
|
284
338
|
await generateFaviconSet.call(this, root, config.source, config.darkSource, "", config, themeColor, backgroundColor, generateManifest);
|
|
285
339
|
if (config.locales) for (const [locale, localeConfig] of Object.entries(config.locales)) await generateFaviconSet.call(this, root, localeConfig.source, localeConfig.darkSource, `${locale}/`, config, themeColor, backgroundColor, generateManifest);
|
|
286
340
|
}
|
|
@@ -571,5 +625,5 @@ async function addDevBadgeToPng(pngBuffer, size) {
|
|
|
571
625
|
}
|
|
572
626
|
|
|
573
627
|
//#endregion
|
|
574
|
-
export { createIcoFromPngs, faviconLinks, faviconPlugin };
|
|
628
|
+
export { createIcoFromPngs, faviconLinks, faviconPlugin, faviconVersionQuery };
|
|
575
629
|
//# sourceMappingURL=favicon.js.map
|
|
@@ -750,7 +750,8 @@ function generateRouteModuleFromRoutes(routes, routesDir, options) {
|
|
|
750
750
|
const opts = [];
|
|
751
751
|
if (loadingName) opts.push(`loading: ${loadingName}`);
|
|
752
752
|
if (errorName) opts.push(`error: ${errorName}`);
|
|
753
|
-
|
|
753
|
+
opts.push(`hmrId: ${JSON.stringify(fullPath)}`);
|
|
754
|
+
const optsStr = `, { ${opts.join(", ")} }`;
|
|
754
755
|
imports.push(`const ${name} = lazy(() => import("${fullPath}")${optsStr})`);
|
|
755
756
|
return name;
|
|
756
757
|
}
|
|
@@ -968,7 +969,7 @@ async function scanRouteFiles(routesDir) {
|
|
|
968
969
|
*/
|
|
969
970
|
async function scanRouteFilesWithExports(routesDir, defaultMode = "ssr") {
|
|
970
971
|
const { readFile } = await import("node:fs/promises");
|
|
971
|
-
const { isApiRoute } = await import("./api-routes-
|
|
972
|
+
const { isApiRoute } = await import("./api-routes-CMsLztoj.js").then((n) => n.t);
|
|
972
973
|
const files = (await scanRouteFiles(routesDir)).filter((f) => !isApiRoute(f));
|
|
973
974
|
const exportsMap = /* @__PURE__ */ new Map();
|
|
974
975
|
await Promise.all(files.map(async (filePath) => {
|
|
@@ -984,4 +985,4 @@ async function scanRouteFilesWithExports(routesDir, defaultMode = "ssr") {
|
|
|
984
985
|
|
|
985
986
|
//#endregion
|
|
986
987
|
export { generateRouteModuleFromRoutes as a, scanRouteFilesWithExports as c, generateRouteModule as i, fs_router_exports as n, parseFileRoutes as o, generateMiddlewareModule as r, scanRouteFiles as s, filePathToUrlPath as t };
|
|
987
|
-
//# sourceMappingURL=fs-router-
|
|
988
|
+
//# sourceMappingURL=fs-router-Bacdhsq-.js.map
|
package/lib/image-plugin.js
CHANGED
|
@@ -20,6 +20,13 @@ const cdnProviders = {
|
|
|
20
20
|
/** Bunny CDN: `https://{pullZone}.b-cdn.net/...?width=...&quality=...` */
|
|
21
21
|
bunny: (pullZone) => (src, { width, quality }) => `https://${pullZone}.b-cdn.net/${src}?width=${width}&quality=${quality}`
|
|
22
22
|
};
|
|
23
|
+
/**
|
|
24
|
+
* Normalize the public {@link PlaceholderStrategy} to an internal kind.
|
|
25
|
+
* @internal Exported for testing.
|
|
26
|
+
*/
|
|
27
|
+
function normalizePlaceholder(s) {
|
|
28
|
+
return s === "dominant-color" ? "color" : s;
|
|
29
|
+
}
|
|
23
30
|
const IMAGE_EXT_RE = /\.(jpe?g|png|webp|avif)$/i;
|
|
24
31
|
/**
|
|
25
32
|
* Zero image processing Vite plugin.
|
|
@@ -52,9 +59,9 @@ function imagePlugin(config = {}) {
|
|
|
52
59
|
1920
|
|
53
60
|
];
|
|
54
61
|
const defaultFormats = config.formats ?? ["webp"];
|
|
55
|
-
const
|
|
62
|
+
const qualityFor = resolveQuality(config.quality);
|
|
56
63
|
const placeholderSize = config.placeholderSize ?? 16;
|
|
57
|
-
const placeholderStrategy = config.placeholder ?? "blur";
|
|
64
|
+
const placeholderStrategy = normalizePlaceholder(config.placeholder ?? "blur");
|
|
58
65
|
const outSubDir = config.outDir ?? "assets/img";
|
|
59
66
|
const include = config.include ?? IMAGE_EXT_RE;
|
|
60
67
|
const cdn = config.cdn;
|
|
@@ -70,15 +77,22 @@ function imagePlugin(config = {}) {
|
|
|
70
77
|
outDir = resolvedConfig.build.outDir;
|
|
71
78
|
isBuild = resolvedConfig.command === "build";
|
|
72
79
|
},
|
|
73
|
-
async resolveId(id) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
return null;
|
|
80
|
+
async resolveId(id, importer) {
|
|
81
|
+
const isSvgComponent = svgOpts && id.includes("?component") && id.split("?")[0].endsWith(".svg");
|
|
82
|
+
const isOptimize = id.includes("?optimize") && include.test(id.split("?")[0]);
|
|
83
|
+
if (!isSvgComponent && !isOptimize) return null;
|
|
84
|
+
const qIdx = id.indexOf("?");
|
|
85
|
+
const bare = qIdx === -1 ? id : id.slice(0, qIdx);
|
|
86
|
+
const query = qIdx === -1 ? "" : id.slice(qIdx);
|
|
87
|
+
const resolved = await this.resolve(bare, importer, { skipSelf: true });
|
|
88
|
+
const carried = resolved ? `${resolved.id}${query}` : id;
|
|
89
|
+
if (isSvgComponent) return `\0virtual:zero-svg:${carried}`;
|
|
90
|
+
return `\0virtual:zero-image:${carried}`;
|
|
77
91
|
},
|
|
78
92
|
async load(id) {
|
|
79
93
|
if (id.startsWith("\0virtual:zero-svg:")) {
|
|
80
94
|
const rawPath = id.replace("\0virtual:zero-svg:", "").split("?")[0] ?? id;
|
|
81
|
-
const absPath = rawPath.startsWith("/") ? join(root, rawPath) : rawPath;
|
|
95
|
+
const absPath = existsSync(rawPath) ? rawPath : rawPath.startsWith("/") ? join(root, rawPath) : rawPath;
|
|
82
96
|
if (!existsSync(absPath)) return null;
|
|
83
97
|
let svg = await readFile(absPath, "utf-8");
|
|
84
98
|
if (svgOpts && svgOpts.currentColor !== false) svg = svg.replace(/fill="(?!none)[^"]*"/g, "fill=\"currentColor\"").replace(/stroke="(?!none)[^"]*"/g, "stroke=\"currentColor\"");
|
|
@@ -104,13 +118,13 @@ export default function SvgComponent(props) {
|
|
|
104
118
|
}
|
|
105
119
|
if (!id.startsWith("\0virtual:zero-image:")) return null;
|
|
106
120
|
const rawPath = id.replace("\0virtual:zero-image:", "").split("?")[0] ?? id;
|
|
107
|
-
const absPath = rawPath.startsWith("/") ? join(root, "public", rawPath) : rawPath;
|
|
121
|
+
const absPath = existsSync(rawPath) ? rawPath : rawPath.startsWith("/") ? join(root, "public", rawPath) : rawPath;
|
|
108
122
|
if (cdn) {
|
|
109
123
|
const metadata = await getImageMetadata(absPath);
|
|
110
124
|
const sources = defaultWidths.map((w) => ({
|
|
111
125
|
src: cdn(rawPath, {
|
|
112
126
|
width: w,
|
|
113
|
-
quality,
|
|
127
|
+
quality: qualityFor(defaultFormats[0]),
|
|
114
128
|
format: defaultFormats[0]
|
|
115
129
|
}) ?? rawPath,
|
|
116
130
|
width: w,
|
|
@@ -122,12 +136,12 @@ export default function SvgComponent(props) {
|
|
|
122
136
|
srcset,
|
|
123
137
|
width: metadata.width,
|
|
124
138
|
height: metadata.height,
|
|
125
|
-
placeholder:
|
|
139
|
+
placeholder: await generatePlaceholder(absPath, placeholderStrategy, placeholderSize),
|
|
126
140
|
formats: defaultFormats.map((fmt) => ({
|
|
127
141
|
type: `image/${fmt}`,
|
|
128
142
|
srcset: defaultWidths.map((w) => `${cdn(rawPath, {
|
|
129
143
|
width: w,
|
|
130
|
-
quality,
|
|
144
|
+
quality: qualityFor(fmt),
|
|
131
145
|
format: fmt
|
|
132
146
|
}) ?? rawPath} ${w}w`).join(", ")
|
|
133
147
|
})),
|
|
@@ -136,13 +150,14 @@ export default function SvgComponent(props) {
|
|
|
136
150
|
return `export default ${JSON.stringify(result)}`;
|
|
137
151
|
}
|
|
138
152
|
if (!isBuild) {
|
|
139
|
-
const result = await loadDevImage(absPath, rawPath, placeholderSize);
|
|
153
|
+
const result = await loadDevImage(absPath, rawPath, placeholderStrategy, placeholderSize);
|
|
140
154
|
return `export default ${JSON.stringify(result)}`;
|
|
141
155
|
}
|
|
142
156
|
const processed = await processImage(absPath, {
|
|
143
157
|
widths: defaultWidths,
|
|
144
158
|
formats: defaultFormats,
|
|
145
|
-
|
|
159
|
+
qualityFor,
|
|
160
|
+
placeholderStrategy,
|
|
146
161
|
placeholderSize,
|
|
147
162
|
outSubDir,
|
|
148
163
|
outDir: join(root, outDir)
|
|
@@ -153,7 +168,7 @@ export default function SvgComponent(props) {
|
|
|
153
168
|
}
|
|
154
169
|
};
|
|
155
170
|
}
|
|
156
|
-
async function loadDevImage(absPath, rawPath, placeholderSize) {
|
|
171
|
+
async function loadDevImage(absPath, rawPath, strategy, placeholderSize) {
|
|
157
172
|
const metadata = await getImageMetadata(absPath);
|
|
158
173
|
const publicPath = rawPath.startsWith("/") ? rawPath : `/@fs/${absPath}`;
|
|
159
174
|
return {
|
|
@@ -161,7 +176,7 @@ async function loadDevImage(absPath, rawPath, placeholderSize) {
|
|
|
161
176
|
srcset: "",
|
|
162
177
|
width: metadata.width,
|
|
163
178
|
height: metadata.height,
|
|
164
|
-
placeholder: await
|
|
179
|
+
placeholder: await generatePlaceholder(absPath, strategy, placeholderSize),
|
|
165
180
|
formats: [],
|
|
166
181
|
sources: [{
|
|
167
182
|
src: publicPath,
|
|
@@ -208,7 +223,7 @@ async function processImage(absPath, opts) {
|
|
|
208
223
|
for (const format of opts.formats) for (const targetWidth of opts.widths) {
|
|
209
224
|
const width = Math.min(targetWidth, metadata.width);
|
|
210
225
|
const outPath = join(processedDir, `${name}-${width}.${format}`);
|
|
211
|
-
await resizeImage(absPath, outPath, width, format, opts.
|
|
226
|
+
await resizeImage(absPath, outPath, width, format, opts.qualityFor(format));
|
|
212
227
|
sources.push({
|
|
213
228
|
src: outPath,
|
|
214
229
|
width,
|
|
@@ -233,7 +248,7 @@ async function processImage(absPath, opts) {
|
|
|
233
248
|
}));
|
|
234
249
|
const fallbackFormat = formats[formats.length - 1];
|
|
235
250
|
const fallbackSources = formatGroups.get([...formatGroups.keys()].pop());
|
|
236
|
-
const placeholder = await
|
|
251
|
+
const placeholder = await generatePlaceholder(absPath, opts.placeholderStrategy, opts.placeholderSize);
|
|
237
252
|
return {
|
|
238
253
|
src: fallbackSources[fallbackSources.length - 1]?.src ?? absPath,
|
|
239
254
|
srcset: fallbackFormat?.srcset ?? "",
|
|
@@ -351,10 +366,66 @@ async function generateBlurPlaceholder(input, size) {
|
|
|
351
366
|
try {
|
|
352
367
|
return `data:image/webp;base64,${(await (await import("sharp").then((m) => m.default ?? m))(input).resize(size, size, { fit: "inside" }).blur(2).webp({ quality: 20 }).toBuffer()).toString("base64")}`;
|
|
353
368
|
} catch {
|
|
354
|
-
return
|
|
369
|
+
return TRANSPARENT_PLACEHOLDER;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
/** 1×1 transparent SVG — the no-sharp fallback for every strategy. */
|
|
373
|
+
const TRANSPARENT_PLACEHOLDER = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1' height='1'%3E%3C/svg%3E";
|
|
374
|
+
const DEFAULT_QUALITY = 80;
|
|
375
|
+
/**
|
|
376
|
+
* Resolve the public {@link ImageQuality} config into a per-format lookup.
|
|
377
|
+
*
|
|
378
|
+
* - `undefined` → every format gets {@link DEFAULT_QUALITY}.
|
|
379
|
+
* - `number` → that number for every format (backward-compatible).
|
|
380
|
+
* - `Partial<Record<ImageFormat, number>>` → per-format; formats omitted
|
|
381
|
+
* from the map fall back to {@link DEFAULT_QUALITY}.
|
|
382
|
+
*
|
|
383
|
+
* @internal Exported for testing.
|
|
384
|
+
*/
|
|
385
|
+
function resolveQuality(q) {
|
|
386
|
+
if (q === void 0) return () => DEFAULT_QUALITY;
|
|
387
|
+
if (typeof q === "number") return () => q;
|
|
388
|
+
return (format) => q[format] ?? DEFAULT_QUALITY;
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Dispatch placeholder generation by strategy. Single source of truth used
|
|
392
|
+
* by every code path (CDN / dev / build) — pre-fix each path open-coded
|
|
393
|
+
* `generateBlurPlaceholder`, so `'none'` was honoured only in the CDN path
|
|
394
|
+
* and `'dominant-color'` (typed since the plugin's inception) was never
|
|
395
|
+
* implemented anywhere — the exact typed-but-unimplemented bug class the
|
|
396
|
+
* `audit-types` gate exists to catch.
|
|
397
|
+
*
|
|
398
|
+
* @internal Exported for testing.
|
|
399
|
+
*/
|
|
400
|
+
async function generatePlaceholder(input, strategy, size) {
|
|
401
|
+
if (strategy === "none") return "";
|
|
402
|
+
if (strategy === "color") return generateColorPlaceholder(input);
|
|
403
|
+
return generateBlurPlaceholder(input, size);
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Generate a dominant-colour placeholder: a ~200-byte flat-fill SVG data URI.
|
|
407
|
+
*
|
|
408
|
+
* Uses sharp's `.stats()` `dominant` swatch — a histogram-binned colour,
|
|
409
|
+
* not a naive average (averaging a photo trends muddy grey). Note the
|
|
410
|
+
* swatch is approximate by design: a pure-red source resolves to ~#f80808,
|
|
411
|
+
* not #ff0000. The SVG is a constant ~200 bytes regardless of source
|
|
412
|
+
* complexity and needs zero image decode, at the cost of showing a solid
|
|
413
|
+
* colour instead of a blurry preview of the content.
|
|
414
|
+
*/
|
|
415
|
+
async function generateColorPlaceholder(input) {
|
|
416
|
+
try {
|
|
417
|
+
const { dominant } = await (await import("sharp").then((m) => m.default ?? m))(input).stats();
|
|
418
|
+
const svg = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1' preserveAspectRatio='none'><rect width='1' height='1' fill='${"#" + [
|
|
419
|
+
dominant.r,
|
|
420
|
+
dominant.g,
|
|
421
|
+
dominant.b
|
|
422
|
+
].map((c) => Math.max(0, Math.min(255, c)).toString(16).padStart(2, "0")).join("")}'/></svg>`;
|
|
423
|
+
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
|
424
|
+
} catch {
|
|
425
|
+
return TRANSPARENT_PLACEHOLDER;
|
|
355
426
|
}
|
|
356
427
|
}
|
|
357
428
|
|
|
358
429
|
//#endregion
|
|
359
|
-
export { cdnProviders, imagePlugin, parseJpegDimensions, parseWebPDimensions };
|
|
430
|
+
export { cdnProviders, generatePlaceholder, imagePlugin, normalizePlaceholder, parseJpegDimensions, parseWebPDimensions, resolveQuality };
|
|
360
431
|
//# sourceMappingURL=image-plugin.js.map
|
package/lib/index.js
CHANGED
|
@@ -1,9 +1,102 @@
|
|
|
1
|
-
import { createContext, createRef, onMount, onUnmount } from "@pyreon/core";
|
|
2
|
-
import { effect, signal } from "@pyreon/reactivity";
|
|
1
|
+
import { createContext, createRef, onMount, onUnmount, splitProps } from "@pyreon/core";
|
|
3
2
|
import { jsx, jsxs } from "@pyreon/core/jsx-runtime";
|
|
3
|
+
import { effect, signal } from "@pyreon/reactivity";
|
|
4
4
|
import { useRouter } from "@pyreon/router";
|
|
5
5
|
import { useHead } from "@pyreon/head";
|
|
6
6
|
|
|
7
|
+
//#region src/icon.tsx
|
|
8
|
+
const FILL_STYLE = "display:block;width:100%;height:100%";
|
|
9
|
+
/**
|
|
10
|
+
* Render a loaded SVG — container-filling, theme-aware, props-transparent.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* import Check from './check.svg?component'
|
|
14
|
+
* <span style="width:2rem"><Icon as={Check} /></span>
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* import check from './check.svg?raw'
|
|
18
|
+
* <span style="width:2rem"><Icon svg={check} /></span>
|
|
19
|
+
*/
|
|
20
|
+
function Icon(props) {
|
|
21
|
+
const [own, rest] = splitProps(props, ["as", "svg"]);
|
|
22
|
+
if (own.as) {
|
|
23
|
+
const As = own.as;
|
|
24
|
+
return /* @__PURE__ */ jsx(As, {
|
|
25
|
+
fill: "currentColor",
|
|
26
|
+
style: FILL_STYLE,
|
|
27
|
+
...rest
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
if (own.svg) return /* @__PURE__ */ jsx("span", {
|
|
31
|
+
style: FILL_STYLE,
|
|
32
|
+
...rest,
|
|
33
|
+
dangerouslySetInnerHTML: { __html: own.svg }
|
|
34
|
+
});
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Build a reusable icon component from a loaded svg — a markup string OR an
|
|
39
|
+
* imported SVG component. The result is still just `<Icon>`, so it's
|
|
40
|
+
* container-sizable + theme-aware with every prop passed through.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* import check from './check.svg?raw'
|
|
44
|
+
* export const Check = createIcon(check)
|
|
45
|
+
*
|
|
46
|
+
* import StarSvg from './star.svg?component'
|
|
47
|
+
* export const Star = createIcon(StarSvg)
|
|
48
|
+
*
|
|
49
|
+
* // …sized + themed entirely by the consumer:
|
|
50
|
+
* <span style="width:48px"><Check class="text-green-600" /></span>
|
|
51
|
+
*/
|
|
52
|
+
function createIcon(source) {
|
|
53
|
+
return (props) => typeof source === "string" ? /* @__PURE__ */ jsx(Icon, {
|
|
54
|
+
svg: source,
|
|
55
|
+
...props
|
|
56
|
+
}) : /* @__PURE__ */ jsx(Icon, {
|
|
57
|
+
as: source,
|
|
58
|
+
...props
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Build a strictly-typed `<Icon name="…" />` from a name→source registry.
|
|
63
|
+
*
|
|
64
|
+
* - `mode: 'inline'` (default) — `source` is raw `<svg>` markup; rendered via
|
|
65
|
+
* {@link Icon} so it's `currentColor`-themeable (system icons you recolor).
|
|
66
|
+
* - `mode: 'image'` — `source` is an asset URL; rendered as `<img>` with NO
|
|
67
|
+
* svg mutation, original colors preserved (colorful / brand icons).
|
|
68
|
+
*
|
|
69
|
+
* Either way it stays container-filling + props-transparent. Not called by
|
|
70
|
+
* hand normally — `iconsPlugin` emits the generated file that calls it.
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* // icons.gen.tsx (auto-generated):
|
|
74
|
+
* export const Icon = createNamedIcon({ 'check-circle': '<svg…' })
|
|
75
|
+
* // app:
|
|
76
|
+
* <span style="width:2rem"><Icon name="check-circle" /></span>
|
|
77
|
+
*/
|
|
78
|
+
function createNamedIcon(registry, options = {}) {
|
|
79
|
+
const mode = options.mode ?? "inline";
|
|
80
|
+
return (props) => {
|
|
81
|
+
const [own, rest] = splitProps(props, ["name", "alt"]);
|
|
82
|
+
const source = registry[own.name];
|
|
83
|
+
if (mode === "image") {
|
|
84
|
+
const hostRest = rest;
|
|
85
|
+
return /* @__PURE__ */ jsx("img", {
|
|
86
|
+
src: source,
|
|
87
|
+
alt: own.alt ?? "",
|
|
88
|
+
style: FILL_STYLE,
|
|
89
|
+
...hostRest
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return /* @__PURE__ */ jsx(Icon, {
|
|
93
|
+
svg: source,
|
|
94
|
+
...rest
|
|
95
|
+
});
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
//#endregion
|
|
7
100
|
//#region src/utils/use-intersection-observer.ts
|
|
8
101
|
/**
|
|
9
102
|
* Observes an element and calls `onIntersect` once it enters the viewport.
|
|
@@ -1118,5 +1211,5 @@ function aiPlugin(..._) {
|
|
|
1118
1211
|
}
|
|
1119
1212
|
|
|
1120
1213
|
//#endregion
|
|
1121
|
-
export { Image, Link, Meta, Script, ThemeToggle, aiPlugin, buildLocalePath, buildMetaTags, createImage, createLink, createScript, createServer, defineConfig, extractLocaleFromPath, faviconPlugin, initTheme, ogImagePlugin, prefetchRoute, resolvedTheme, seoPlugin, setLocale, setSSRThemeDefault, setTheme, theme, themeScript, toggleTheme, useImage, useLink, useLocale, useScript, validateEnv };
|
|
1214
|
+
export { Icon, Image, Link, Meta, Script, ThemeToggle, aiPlugin, buildLocalePath, buildMetaTags, createIcon, createImage, createLink, createNamedIcon, createScript, createServer, defineConfig, extractLocaleFromPath, faviconPlugin, initTheme, ogImagePlugin, prefetchRoute, resolvedTheme, seoPlugin, setLocale, setSSRThemeDefault, setTheme, theme, themeScript, toggleTheme, useImage, useLink, useLocale, useScript, validateEnv };
|
|
1122
1215
|
//# sourceMappingURL=index.js.map
|
package/lib/rate-limit.js
CHANGED
|
@@ -26,6 +26,11 @@ function rateLimitMiddleware(config = {}) {
|
|
|
26
26
|
if (store.size < MAX_STORE_SIZE / 2 && now - lastCleanup < windowMs) return;
|
|
27
27
|
lastCleanup = now;
|
|
28
28
|
for (const [key, entry] of store) if (entry.resetAt <= now) store.delete(key);
|
|
29
|
+
while (store.size > MAX_STORE_SIZE) {
|
|
30
|
+
const oldest = store.keys().next().value;
|
|
31
|
+
if (oldest === void 0) break;
|
|
32
|
+
store.delete(oldest);
|
|
33
|
+
}
|
|
29
34
|
}
|
|
30
35
|
return (ctx) => {
|
|
31
36
|
if (include && !include.some((p) => matchSimpleGlob(p, ctx.path))) return;
|
package/lib/seo.js
CHANGED
|
@@ -14,7 +14,7 @@ import { join, resolve } from "node:path";
|
|
|
14
14
|
*/
|
|
15
15
|
function generateSitemap(routeFiles, config, i18n) {
|
|
16
16
|
const { origin, exclude = [], changefreq = "weekly", priority = .7 } = config;
|
|
17
|
-
const
|
|
17
|
+
const paths = routeFiles.filter((f) => {
|
|
18
18
|
const name = f.split("/").pop()?.replace(/\.\w+$/, "");
|
|
19
19
|
return name !== "_layout" && name !== "_error" && name !== "_loading";
|
|
20
20
|
}).map((f) => {
|
|
@@ -23,11 +23,16 @@ function generateSitemap(routeFiles, config, i18n) {
|
|
|
23
23
|
path = path.replace(/\([\w-]+\)\//g, "");
|
|
24
24
|
if (!path.startsWith("/")) path = `/${path}`;
|
|
25
25
|
return path;
|
|
26
|
-
}).filter((p) => p !== null).filter((p) => !exclude.some((e) => p.startsWith(e)))
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
}).filter((p) => p !== null).filter((p) => !exclude.some((e) => p.startsWith(e)));
|
|
27
|
+
const clusters = clusterPathsByLocale((() => {
|
|
28
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
29
|
+
for (const e of [...paths.map((p) => ({
|
|
30
|
+
path: p,
|
|
31
|
+
changefreq,
|
|
32
|
+
priority
|
|
33
|
+
})), ...config.additionalPaths ?? []]) if (!byPath.has(e.path)) byPath.set(e.path, e);
|
|
34
|
+
return [...byPath.values()];
|
|
35
|
+
})(), i18n);
|
|
31
36
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
32
37
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"${i18n != null && i18n.locales.length > 0 ? " xmlns:xhtml=\"http://www.w3.org/1999/xhtml\"" : ""}>
|
|
33
38
|
${clusters.map((cluster) => renderClusterEntry(cluster, origin, changefreq, priority, i18n)).join("\n")}
|