@pyreon/zero 0.16.0 → 0.19.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-CQiOi3q5.js} +5 -3
- package/lib/api-routes.js +4 -2
- package/lib/{fs-router-MewHc5SB.js → fs-router-BVY4lTH_.js} +4 -3
- package/lib/image-plugin.js +77 -13
- package/lib/index.js +96 -3
- package/lib/rate-limit.js +5 -0
- package/lib/seo.js +11 -6
- package/lib/server.js +229 -25
- package/lib/testing.js +4 -2
- package/lib/types/config.d.ts +41 -0
- package/lib/types/image-plugin.d.ts +65 -7
- package/lib/types/index.d.ts +120 -2
- package/lib/types/server.d.ts +119 -1
- package/lib/{vite-plugin-xjWZwudX.js → vite-plugin-8TXXFqdP.js} +51 -14
- package/package.json +10 -10
- package/src/api-routes.ts +12 -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 +157 -20
- 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 +41 -0
- package/src/vite-plugin.ts +63 -11
package/lib/types/server.d.ts
CHANGED
|
@@ -146,6 +146,15 @@ interface ISRConfig {
|
|
|
146
146
|
* space (e.g. `/user/:id` where `:id` is free-form).
|
|
147
147
|
*/
|
|
148
148
|
maxEntries?: number;
|
|
149
|
+
/**
|
|
150
|
+
* Max wall-time (ms) for a single background revalidation before it is
|
|
151
|
+
* abandoned. Without a bound, a handler that hangs leaves its key
|
|
152
|
+
* pinned in the in-flight set forever — every later request for that
|
|
153
|
+
* key short-circuits the de-dupe guard and the entry can never
|
|
154
|
+
* recover from stale. Default: `30000` (matches the Suspense
|
|
155
|
+
* streaming timeout).
|
|
156
|
+
*/
|
|
157
|
+
revalidateTimeoutMs?: number;
|
|
149
158
|
/**
|
|
150
159
|
* Cache-key derivation function. The default keys cache entries by
|
|
151
160
|
* `url.pathname` ONLY — query strings, cookies, and headers are
|
|
@@ -331,6 +340,38 @@ interface ZeroConfig {
|
|
|
331
340
|
currentPath: string;
|
|
332
341
|
elapsed: number;
|
|
333
342
|
}) => void | Promise<void>;
|
|
343
|
+
/**
|
|
344
|
+
* Route-level code splitting in SSG mode. Default `true`.
|
|
345
|
+
*
|
|
346
|
+
* When `true` (default), each route file becomes its own dynamic-import
|
|
347
|
+
* chunk via `lazy(() => import("..."))` — only the route the user
|
|
348
|
+
* lands on plus its dependencies ship in the initial bundle, the
|
|
349
|
+
* rest fetch on navigation. Matches the SSR/SPA-mode behaviour zero
|
|
350
|
+
* has always had; brings parity to SSG.
|
|
351
|
+
*
|
|
352
|
+
* When `false`, every route is bundled statically into the main
|
|
353
|
+
* client chunk (the pre-2026-Q3 SSG behaviour). Useful for tiny
|
|
354
|
+
* sites (2-5 pages) where the single-chunk-then-instant-nav trade
|
|
355
|
+
* is preferable — the chunk-fetch cost on navigation is gone, and
|
|
356
|
+
* the marginal bytes are negligible.
|
|
357
|
+
*
|
|
358
|
+
* Crossover point: ~5-8 routes. Below that, single-chunk is fine.
|
|
359
|
+
* Above that, lazy() shrinks the initial bundle by a meaningful
|
|
360
|
+
* amount (a 50-route docs site might drop from 200 KB to 80 KB on
|
|
361
|
+
* first paint).
|
|
362
|
+
*
|
|
363
|
+
* Underlying mechanism is the same 3-tier generator zero already
|
|
364
|
+
* uses for SSR/SPA mode (`fs-router.ts:generateRouteEntry`): lazy
|
|
365
|
+
* component + inlined metadata when possible, lazy + lazy-thunked
|
|
366
|
+
* function exports when not, namespace-import fallback for cases
|
|
367
|
+
* the literal-extractor can't reach.
|
|
368
|
+
*
|
|
369
|
+
* @example
|
|
370
|
+
* ssg: {
|
|
371
|
+
* splitChunks: false, // bundle-everything for a 3-page marketing site
|
|
372
|
+
* }
|
|
373
|
+
*/
|
|
374
|
+
splitChunks?: boolean;
|
|
334
375
|
};
|
|
335
376
|
/** ISR config — only used when mode is "isr". */
|
|
336
377
|
isr?: ISRConfig;
|
|
@@ -1074,6 +1115,83 @@ declare function faviconLinks(locale: string | undefined, config: FaviconPluginC
|
|
|
1074
1115
|
href: string;
|
|
1075
1116
|
}>;
|
|
1076
1117
|
//#endregion
|
|
1118
|
+
//#region src/icon.d.ts
|
|
1119
|
+
/** How a named icon set renders each entry. */
|
|
1120
|
+
type IconMode = 'inline' | 'image';
|
|
1121
|
+
//#endregion
|
|
1122
|
+
//#region src/icons-plugin.d.ts
|
|
1123
|
+
/** One named set in the multi-set form. */
|
|
1124
|
+
interface IconSetConfig {
|
|
1125
|
+
/** Folder of `*.svg` files to scan for this set. */
|
|
1126
|
+
dir: string;
|
|
1127
|
+
/**
|
|
1128
|
+
* `'inline'` (default — system icons, `currentColor`-themeable) or
|
|
1129
|
+
* `'image'` (colorful / brand icons, rendered `<img>`, no mutation).
|
|
1130
|
+
*/
|
|
1131
|
+
mode?: IconMode;
|
|
1132
|
+
}
|
|
1133
|
+
interface IconsPluginConfig {
|
|
1134
|
+
/**
|
|
1135
|
+
* Single-set form: a folder of `*.svg` files → one `<Icon name="…" />`
|
|
1136
|
+
* with a single `IconName` union. Mutually exclusive with `sets`.
|
|
1137
|
+
*/
|
|
1138
|
+
dir?: string;
|
|
1139
|
+
/**
|
|
1140
|
+
* Named multi-set form: `{ ui: { dir }, brand: { dir, mode } }` → one
|
|
1141
|
+
* generated file exporting a strictly-typed component PER set with
|
|
1142
|
+
* NAMESPACED types so they never clash:
|
|
1143
|
+
* `ui` → `<UiIcon name="…" />` + `type UiIconName`
|
|
1144
|
+
* `brand` → `<BrandIcon name="…" />` + `type BrandIconName`
|
|
1145
|
+
* Mutually exclusive with `dir`.
|
|
1146
|
+
*/
|
|
1147
|
+
sets?: Record<string, IconSetConfig>;
|
|
1148
|
+
/**
|
|
1149
|
+
* Where to write the generated `.tsx`. Single-set default: `icons.gen.tsx`
|
|
1150
|
+
* next to `dir` (e.g. `src/icons` → `src/icons.gen.tsx`). Multi-set
|
|
1151
|
+
* default: `src/icons.gen.tsx` under the project root. Recommend
|
|
1152
|
+
* gitignoring it — it's a build artifact.
|
|
1153
|
+
*/
|
|
1154
|
+
out?: string;
|
|
1155
|
+
/** Single-set form only — render mode (`'inline'` default | `'image'`). */
|
|
1156
|
+
mode?: IconMode;
|
|
1157
|
+
}
|
|
1158
|
+
/** Set key → exported component name. `ui` → `UiIcon`, `brand-marks` → `BrandMarksIcon`. */
|
|
1159
|
+
declare function componentNameFromSetKey(key: string): string;
|
|
1160
|
+
/** Filename stem → registry key. `Check-Circle.svg` → `check-circle`. */
|
|
1161
|
+
declare function iconNameFromFile(file: string): string;
|
|
1162
|
+
/** List the `*.svg` filenames in `dir` (sorted, stable). Empty if missing. */
|
|
1163
|
+
declare function scanIconDir(dir: string): string[];
|
|
1164
|
+
/**
|
|
1165
|
+
* Render the generated `.tsx` source for a set of svg filenames. Pure —
|
|
1166
|
+
* unit-tested directly; the plugin only adds fs + watch around it.
|
|
1167
|
+
*/
|
|
1168
|
+
declare function generateIconSetSource(files: string[], opts: {
|
|
1169
|
+
mode: IconMode;
|
|
1170
|
+
importDir: string;
|
|
1171
|
+
}): string;
|
|
1172
|
+
/** One resolved set for the multi-set generator. */
|
|
1173
|
+
interface NamedSetInput {
|
|
1174
|
+
/** Set key (`ui`) — becomes `<UiIcon>` + `type UiIconName`. */
|
|
1175
|
+
key: string;
|
|
1176
|
+
files: string[];
|
|
1177
|
+
mode: IconMode;
|
|
1178
|
+
/** Relative import dir from the generated file to this set's folder. */
|
|
1179
|
+
importDir: string;
|
|
1180
|
+
}
|
|
1181
|
+
/**
|
|
1182
|
+
* Render the generated `.tsx` for the NAMED MULTI-SET form. One file, one
|
|
1183
|
+
* `createNamedIcon` import, one strictly-typed component PER set with
|
|
1184
|
+
* namespaced types (`UiIcon`/`UiIconName`, `BrandIcon`/`BrandIconName`) so
|
|
1185
|
+
* sets never clash. Bindings are per-set-prefixed so two sets sharing a
|
|
1186
|
+
* glyph filename don't collide.
|
|
1187
|
+
*/
|
|
1188
|
+
declare function generateNamedIconSetsSource(sets: NamedSetInput[]): string;
|
|
1189
|
+
/**
|
|
1190
|
+
* Vite plugin: scan `dir` for `*.svg`, write a strictly-typed
|
|
1191
|
+
* `icons.gen.tsx`, regenerate on add / unlink in dev.
|
|
1192
|
+
*/
|
|
1193
|
+
declare function iconsPlugin(cfg: IconsPluginConfig): Plugin;
|
|
1194
|
+
//#endregion
|
|
1077
1195
|
//#region src/seo.d.ts
|
|
1078
1196
|
interface SitemapConfig {
|
|
1079
1197
|
/** Base URL of the site (required). e.g. "https://example.com" */
|
|
@@ -1467,5 +1585,5 @@ declare function inferJsonLd(options: InferJsonLdOptions): Record<string, unknow
|
|
|
1467
1585
|
*/
|
|
1468
1586
|
declare function aiPlugin(config: AiPluginConfig): Plugin;
|
|
1469
1587
|
//#endregion
|
|
1470
|
-
export { type AiPluginConfig, type CreateAppOptions, type CreateServerOptions, type FaviconLocaleConfig, type FaviconPluginConfig, type GenerateRouteModuleOptions, type GetStaticPaths, type InferJsonLdOptions, type OgImageLayer, type OgImagePluginConfig, type OgImageTemplate, type RobotsConfig, type SeoPluginConfig, type SitemapConfig, type VercelRevalidateHandlerOptions, _resetVercelRevalidateHandlerCache, aiPlugin, bunAdapter, cloudflareAdapter, compose, createApp, createISRHandler, createLocaleContext, createServer, zeroPlugin as default, defineConfig, detectLocaleFromHeader, faviconLinks, faviconPlugin, filePathToUrlPath, generateLlmsFullTxt, generateLlmsTxt, generateMiddlewareModule, generateRobots, generateRouteModule, generateSitemap, getContext, getZeroPluginConfig, i18nRouting, inferJsonLd, jsonLd, netlifyAdapter, nodeAdapter, ogImagePath, ogImagePlugin, parseFileRoutes, render404Page, resolveAdapter, resolveConfig, scanRouteFiles, seoMiddleware, seoPlugin, staticAdapter, vercelAdapter, vercelRevalidateHandler };
|
|
1588
|
+
export { type AiPluginConfig, type CreateAppOptions, type CreateServerOptions, type FaviconLocaleConfig, type FaviconPluginConfig, type GenerateRouteModuleOptions, type GetStaticPaths, type IconSetConfig, type IconsPluginConfig, type InferJsonLdOptions, type NamedSetInput, type OgImageLayer, type OgImagePluginConfig, type OgImageTemplate, type RobotsConfig, type SeoPluginConfig, type SitemapConfig, type VercelRevalidateHandlerOptions, _resetVercelRevalidateHandlerCache, aiPlugin, bunAdapter, cloudflareAdapter, componentNameFromSetKey, compose, createApp, createISRHandler, createLocaleContext, createServer, zeroPlugin as default, defineConfig, detectLocaleFromHeader, faviconLinks, faviconPlugin, filePathToUrlPath, generateIconSetSource, generateLlmsFullTxt, generateLlmsTxt, generateMiddlewareModule, generateNamedIconSetsSource, generateRobots, generateRouteModule, generateSitemap, getContext, getZeroPluginConfig, i18nRouting, iconNameFromFile, iconsPlugin, inferJsonLd, jsonLd, netlifyAdapter, nodeAdapter, ogImagePath, ogImagePlugin, parseFileRoutes, render404Page, resolveAdapter, resolveConfig, scanIconDir, scanRouteFiles, seoMiddleware, seoPlugin, staticAdapter, vercelAdapter, vercelRevalidateHandler };
|
|
1471
1589
|
//# sourceMappingURL=server2.d.ts.map
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { t as __exportAll } from "./rolldown-runtime-CjeV3_4I.js";
|
|
2
|
-
import { i as matchApiRoute, n as createApiMiddleware, r as generateApiRouteModule } from "./api-routes-
|
|
3
|
-
import { a as generateRouteModuleFromRoutes, c as scanRouteFilesWithExports, o as parseFileRoutes, r as generateMiddlewareModule, s as scanRouteFiles } from "./fs-router-
|
|
2
|
+
import { i as matchApiRoute, n as createApiMiddleware, r as generateApiRouteModule } from "./api-routes-CQiOi3q5.js";
|
|
3
|
+
import { a as generateRouteModuleFromRoutes, c as scanRouteFilesWithExports, o as parseFileRoutes, r as generateMiddlewareModule, s as scanRouteFiles } from "./fs-router-BVY4lTH_.js";
|
|
4
4
|
import { Fragment, createContext, h } from "@pyreon/core";
|
|
5
5
|
import { HeadProvider } from "@pyreon/head";
|
|
6
6
|
import { RouterProvider, RouterView, createRouter, getRedirectInfo } from "@pyreon/router";
|
|
7
7
|
import { createHandler } from "@pyreon/server";
|
|
8
8
|
import { renderToString } from "@pyreon/runtime-server";
|
|
9
9
|
import { existsSync, readdirSync } from "node:fs";
|
|
10
|
-
import { dirname, join, resolve } from "node:path";
|
|
10
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
11
11
|
import { mkdir, readFile, rename, rm, unlink, writeFile } from "node:fs/promises";
|
|
12
12
|
import { Readable } from "node:stream";
|
|
13
13
|
import { signal } from "@pyreon/reactivity";
|
|
@@ -1450,9 +1450,12 @@ const SSR_ENTRY_FILENAME = "__pyreon-zero-ssg-entry.js";
|
|
|
1450
1450
|
function expandUrlPattern(pattern, params) {
|
|
1451
1451
|
return pattern.split("/").map((seg) => {
|
|
1452
1452
|
if (!seg.startsWith(":")) return seg;
|
|
1453
|
-
const
|
|
1453
|
+
const isCatchAll = seg.endsWith("*");
|
|
1454
|
+
const name = isCatchAll ? seg.slice(1, -1) : seg.slice(1);
|
|
1454
1455
|
const value = params[name];
|
|
1455
1456
|
if (value === void 0 || value === "") throw new Error(`[zero:ssg] getStaticPaths for "${pattern}" returned params without "${name}"`);
|
|
1457
|
+
const segs = isCatchAll ? value.split("/") : [value];
|
|
1458
|
+
if (!isCatchAll && value.includes("/") || segs.some((s) => s === "." || s === "..")) throw new Error(`[zero:ssg] getStaticPaths for "${pattern}" produced an unsafe "${name}" value (${JSON.stringify(value)}): a ${isCatchAll ? "catch-all" : "dynamic"} segment must not contain path-traversal ("." / "..")${isCatchAll ? "" : " or \"/\""}.`);
|
|
1456
1459
|
return value;
|
|
1457
1460
|
}).join("/");
|
|
1458
1461
|
}
|
|
@@ -1493,7 +1496,8 @@ async function autoDetectStaticPaths(routesDir, registry, errors = [], i18n) {
|
|
|
1493
1496
|
});
|
|
1494
1497
|
}
|
|
1495
1498
|
}
|
|
1496
|
-
|
|
1499
|
+
const deduped = [...new Set(out)];
|
|
1500
|
+
return deduped.length > 0 ? deduped : ["/"];
|
|
1497
1501
|
}
|
|
1498
1502
|
async function resolvePaths(config, routesDir, registry, errors = []) {
|
|
1499
1503
|
const explicit = config.ssg?.paths;
|
|
@@ -1615,6 +1619,20 @@ function resolveOutputPath(distDir, path) {
|
|
|
1615
1619
|
return join(distDir, path, "index.html");
|
|
1616
1620
|
}
|
|
1617
1621
|
/**
|
|
1622
|
+
* Path-containment check that is SEPARATOR-TERMINATED. A bare
|
|
1623
|
+
* `resolve(filePath).startsWith(resolve(distDir))` is a string-prefix
|
|
1624
|
+
* test, not a path test: with distDir `/app/dist`, a traversed filePath
|
|
1625
|
+
* resolving to the SIBLING `/app/dist-evil/x` passes
|
|
1626
|
+
* `'/app/dist-evil/x'.startsWith('/app/dist')` → true and the build
|
|
1627
|
+
* writes outside the intended output root. `path` derives from caller
|
|
1628
|
+
* route params (CMS slugs via `getStaticPaths`), so this is reachable.
|
|
1629
|
+
*/
|
|
1630
|
+
function isInsideDist(distDir, filePath) {
|
|
1631
|
+
const root = resolve(distDir);
|
|
1632
|
+
const target = resolve(filePath);
|
|
1633
|
+
return target === root || target.startsWith(root + sep);
|
|
1634
|
+
}
|
|
1635
|
+
/**
|
|
1618
1636
|
* Render Netlify / Cloudflare Pages `_redirects` file content. One line
|
|
1619
1637
|
* per redirect, format: `<from> <to> <status>`. Both platforms parse this
|
|
1620
1638
|
* format identically; Vercel ignores it (use the JSON below). Lines with
|
|
@@ -1925,8 +1943,7 @@ function ssgPlugin(userConfig = {}) {
|
|
|
1925
1943
|
});
|
|
1926
1944
|
if (config.ssg?.redirectsAsHtml === "meta-refresh") {
|
|
1927
1945
|
const filePath = resolveOutputPath(distDir, p);
|
|
1928
|
-
|
|
1929
|
-
if (!resolve(filePath).startsWith(resolvedOut)) {
|
|
1946
|
+
if (!isInsideDist(distDir, filePath)) {
|
|
1930
1947
|
errors.push({
|
|
1931
1948
|
path: p,
|
|
1932
1949
|
error: /* @__PURE__ */ new Error(`Path traversal detected: "${p}"`)
|
|
@@ -1940,8 +1957,7 @@ function ssgPlugin(userConfig = {}) {
|
|
|
1940
1957
|
}
|
|
1941
1958
|
const html = injectIntoTemplate(template, result);
|
|
1942
1959
|
const filePath = resolveOutputPath(distDir, p);
|
|
1943
|
-
|
|
1944
|
-
if (!resolve(filePath).startsWith(resolvedOut)) {
|
|
1960
|
+
if (!isInsideDist(distDir, filePath)) {
|
|
1945
1961
|
errors.push({
|
|
1946
1962
|
path: p,
|
|
1947
1963
|
error: /* @__PURE__ */ new Error(`Path traversal detected: "${p}"`)
|
|
@@ -1963,8 +1979,7 @@ function ssgPlugin(userConfig = {}) {
|
|
|
1963
1979
|
const fallbackHtml = await config.ssg.onPathError(p, error);
|
|
1964
1980
|
if (typeof fallbackHtml === "string") {
|
|
1965
1981
|
const filePath = resolveOutputPath(distDir, p);
|
|
1966
|
-
|
|
1967
|
-
if (!resolve(filePath).startsWith(resolvedOut)) {
|
|
1982
|
+
if (!isInsideDist(distDir, filePath)) {
|
|
1968
1983
|
errors.push({
|
|
1969
1984
|
path: p,
|
|
1970
1985
|
error: /* @__PURE__ */ new Error(`Path traversal detected: "${p}"`)
|
|
@@ -2073,6 +2088,7 @@ function ssgPlugin(userConfig = {}) {
|
|
|
2073
2088
|
//#endregion
|
|
2074
2089
|
//#region src/vite-plugin.ts
|
|
2075
2090
|
var vite_plugin_exports = /* @__PURE__ */ __exportAll({
|
|
2091
|
+
argvHasPortFlag: () => argvHasPortFlag,
|
|
2076
2092
|
getZeroPluginConfig: () => getZeroPluginConfig,
|
|
2077
2093
|
zeroPlugin: () => zeroPlugin
|
|
2078
2094
|
});
|
|
@@ -2129,6 +2145,25 @@ function getZeroPluginConfig(plugin) {
|
|
|
2129
2145
|
return zeroPluginConfigMap.get(plugin);
|
|
2130
2146
|
}
|
|
2131
2147
|
/**
|
|
2148
|
+
* Detects `--port` / `--port=N` / `-p N` / `-p=N` in `process.argv`.
|
|
2149
|
+
* Used by the plugin's `config()` hook to decide whether to apply the
|
|
2150
|
+
* default port — when the CLI was invoked with `--port`, the plugin
|
|
2151
|
+
* must skip its default so the CLI flag wins (see the comment at the
|
|
2152
|
+
* port-handling block in `zeroPlugin()` for the full precedence model).
|
|
2153
|
+
*
|
|
2154
|
+
* Exported for testing only (the plugin uses it internally).
|
|
2155
|
+
*
|
|
2156
|
+
* @internal
|
|
2157
|
+
*/
|
|
2158
|
+
function argvHasPortFlag(argv = process.argv) {
|
|
2159
|
+
for (let i = 0; i < argv.length; i++) {
|
|
2160
|
+
const a = argv[i];
|
|
2161
|
+
if (a === "--port" || a === "-p") return true;
|
|
2162
|
+
if (a !== void 0 && (a.startsWith("--port=") || a.startsWith("-p="))) return true;
|
|
2163
|
+
}
|
|
2164
|
+
return false;
|
|
2165
|
+
}
|
|
2166
|
+
/**
|
|
2132
2167
|
* Zero Vite plugin — adds file-based routing and zero-config conventions
|
|
2133
2168
|
* on top of @pyreon/vite-plugin.
|
|
2134
2169
|
*
|
|
@@ -2160,7 +2195,9 @@ function zeroPlugin(userConfig = {}) {
|
|
|
2160
2195
|
async load(id) {
|
|
2161
2196
|
if (id === RESOLVED_VIRTUAL_ROUTES_ID) try {
|
|
2162
2197
|
const baseRoutes = await scanRouteFilesWithExports(routesDir, config.mode);
|
|
2163
|
-
|
|
2198
|
+
const routes = config.i18n ? expandRoutesForLocales(baseRoutes, config.i18n) : baseRoutes;
|
|
2199
|
+
const ssgSplitDisabled = config.mode === "ssg" && config.ssg?.splitChunks === false;
|
|
2200
|
+
return generateRouteModuleFromRoutes(routes, routesDir, { staticImports: ssgSplitDisabled });
|
|
2164
2201
|
} catch (_err) {
|
|
2165
2202
|
return `export const routes = []`;
|
|
2166
2203
|
}
|
|
@@ -2289,7 +2326,7 @@ function zeroPlugin(userConfig = {}) {
|
|
|
2289
2326
|
...runtimeServerAlias ? { alias: { "@pyreon/runtime-server": runtimeServerAlias } } : {}
|
|
2290
2327
|
} },
|
|
2291
2328
|
optimizeDeps: { exclude: pyreonExclude },
|
|
2292
|
-
...userConfig.port
|
|
2329
|
+
...userConfig.port === void 0 && argvHasPortFlag() ? {} : { server: { port: config.port } },
|
|
2293
2330
|
base: config.base,
|
|
2294
2331
|
define: {
|
|
2295
2332
|
__ZERO_MODE__: JSON.stringify(config.mode),
|
|
@@ -2451,4 +2488,4 @@ function flattenRoutePatterns(routes) {
|
|
|
2451
2488
|
|
|
2452
2489
|
//#endregion
|
|
2453
2490
|
export { render404Page as _, detectLocaleFromHeader as a, vercelAdapter as c, netlifyAdapter as d, cloudflareAdapter as f, createServer as g, resolveConfig as h, createLocaleContext as i, staticAdapter as l, defineConfig as m, vite_plugin_exports as n, i18nRouting as o, bunAdapter as p, zeroPlugin as r, resolveAdapter as s, getZeroPluginConfig as t, nodeAdapter as u, createApp as v };
|
|
2454
|
-
//# sourceMappingURL=vite-plugin-
|
|
2491
|
+
//# sourceMappingURL=vite-plugin-8TXXFqdP.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/zero",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Pyreon Zero — zero-config full-stack framework powered by Pyreon and Vite",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Vit Bokisch",
|
|
@@ -168,15 +168,15 @@
|
|
|
168
168
|
"lint": "oxlint ."
|
|
169
169
|
},
|
|
170
170
|
"dependencies": {
|
|
171
|
-
"@pyreon/core": "^0.
|
|
172
|
-
"@pyreon/head": "^0.
|
|
173
|
-
"@pyreon/meta": "^0.
|
|
174
|
-
"@pyreon/reactivity": "^0.
|
|
175
|
-
"@pyreon/router": "^0.
|
|
176
|
-
"@pyreon/runtime-dom": "^0.
|
|
177
|
-
"@pyreon/runtime-server": "^0.
|
|
178
|
-
"@pyreon/server": "^0.
|
|
179
|
-
"@pyreon/vite-plugin": "^0.
|
|
171
|
+
"@pyreon/core": "^0.19.0",
|
|
172
|
+
"@pyreon/head": "^0.19.0",
|
|
173
|
+
"@pyreon/meta": "^0.19.0",
|
|
174
|
+
"@pyreon/reactivity": "^0.19.0",
|
|
175
|
+
"@pyreon/router": "^0.19.0",
|
|
176
|
+
"@pyreon/runtime-dom": "^0.19.0",
|
|
177
|
+
"@pyreon/runtime-server": "^0.19.0",
|
|
178
|
+
"@pyreon/server": "^0.19.0",
|
|
179
|
+
"@pyreon/vite-plugin": "^0.19.0",
|
|
180
180
|
"vite": "^8.0.0"
|
|
181
181
|
},
|
|
182
182
|
"devDependencies": {
|
package/src/api-routes.ts
CHANGED
|
@@ -52,6 +52,15 @@ export function matchApiRoute(pattern: string, path: string): Record<string, str
|
|
|
52
52
|
const pathParts = path.split('/').filter(Boolean)
|
|
53
53
|
const params: Record<string, string> = {}
|
|
54
54
|
|
|
55
|
+
// A param NAME comes from the route pattern (file-based route like
|
|
56
|
+
// `[__proto__].ts`) — developer-controlled, so this is defense-in-depth
|
|
57
|
+
// rather than an attacker vector, but assigning `params['__proto__'] =
|
|
58
|
+
// …` still mutates the result object's prototype instead of setting a
|
|
59
|
+
// param. Skip the dangerous names (consistent with reconcile / i18n
|
|
60
|
+
// deepMerge guards) so a stray route name can't pollute.
|
|
61
|
+
const isUnsafeParam = (name: string): boolean =>
|
|
62
|
+
name === '__proto__' || name === 'constructor' || name === 'prototype'
|
|
63
|
+
|
|
55
64
|
for (let i = 0; i < patternParts.length; i++) {
|
|
56
65
|
const pp = patternParts[i]
|
|
57
66
|
if (!pp) continue
|
|
@@ -59,7 +68,7 @@ export function matchApiRoute(pattern: string, path: string): Record<string, str
|
|
|
59
68
|
// Catch-all: :param*
|
|
60
69
|
if (pp.endsWith('*')) {
|
|
61
70
|
const paramName = pp.slice(1, -1)
|
|
62
|
-
params[paramName] = pathParts.slice(i).join('/')
|
|
71
|
+
if (!isUnsafeParam(paramName)) params[paramName] = pathParts.slice(i).join('/')
|
|
63
72
|
return params
|
|
64
73
|
}
|
|
65
74
|
|
|
@@ -68,7 +77,8 @@ export function matchApiRoute(pattern: string, path: string): Record<string, str
|
|
|
68
77
|
|
|
69
78
|
// Dynamic segment: :param
|
|
70
79
|
if (pp.startsWith(':')) {
|
|
71
|
-
|
|
80
|
+
const paramName = pp.slice(1)
|
|
81
|
+
if (!isUnsafeParam(paramName)) params[paramName] = pathParts[i]!
|
|
72
82
|
continue
|
|
73
83
|
}
|
|
74
84
|
|
package/src/fs-router.ts
CHANGED
|
@@ -1100,7 +1100,13 @@ export function generateRouteModuleFromRoutes(
|
|
|
1100
1100
|
const opts: string[] = []
|
|
1101
1101
|
if (loadingName) opts.push(`loading: ${loadingName}`)
|
|
1102
1102
|
if (errorName) opts.push(`error: ${errorName}`)
|
|
1103
|
-
|
|
1103
|
+
// `hmrId` lets `@pyreon/router`'s dev HMR coordinator map a
|
|
1104
|
+
// hot-updated module back to its route record(s) for an in-place
|
|
1105
|
+
// component swap (no page reload, signals preserved). Inert in
|
|
1106
|
+
// production — the coordinator is only registered in a dev browser,
|
|
1107
|
+
// so `_hmrId` is dead metadata once built.
|
|
1108
|
+
opts.push(`hmrId: ${JSON.stringify(fullPath)}`)
|
|
1109
|
+
const optsStr = `, { ${opts.join(', ')} }`
|
|
1104
1110
|
imports.push(`const ${name} = lazy(() => import("${fullPath}")${optsStr})`)
|
|
1105
1111
|
return name
|
|
1106
1112
|
}
|
package/src/icon.tsx
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { PyreonHTMLAttributes, SvgAttributes, VNodeChild } from '@pyreon/core'
|
|
2
|
+
import { splitProps } from '@pyreon/core'
|
|
3
|
+
|
|
4
|
+
// ─── Icon ───────────────────────────────────────────────────────────────────
|
|
5
|
+
//
|
|
6
|
+
// Renders a FULL, already-complete SVG that you loaded — it does NOT
|
|
7
|
+
// synthesize its own <svg> wrapper around hand-authored <path> children.
|
|
8
|
+
// You load an svg (it contains the <svg> root itself); Icon renders it and
|
|
9
|
+
// makes it container-sizable + theme-aware.
|
|
10
|
+
//
|
|
11
|
+
// Two ways to hand it the loaded svg (you chose: support both):
|
|
12
|
+
// • `as` — an imported SVG *component* (`import X from './x.svg?component'`).
|
|
13
|
+
// Rendered directly — NO host wrapper. Recommended form: it's a
|
|
14
|
+
// real <svg> element, so container-fill is reliable.
|
|
15
|
+
// • `svg` — the raw `<svg>…</svg>` *markup string*
|
|
16
|
+
// (`import x from './x.svg?raw'`). Inlined via a single `<span>`
|
|
17
|
+
// host (a markup string can't mount without a parent element —
|
|
18
|
+
// this one host is unavoidable for the string form).
|
|
19
|
+
//
|
|
20
|
+
// Either way:
|
|
21
|
+
// • Container-filling defaults (`fill="currentColor"`,
|
|
22
|
+
// `display:block;width:100%;height:100%`) — every consumer prop spreads
|
|
23
|
+
// through and OVERRIDES them (`style`, `class`, `fill`, `aria-*`, …).
|
|
24
|
+
// • No fixed size → it fills its container; the consumer's wrapper
|
|
25
|
+
// (`<span style="width:2rem"><Icon/></span>`, a flex/grid cell,
|
|
26
|
+
// `font-size`) controls the size.
|
|
27
|
+
// • `fill="currentColor"` → CSS `color` themes it (dark mode for free).
|
|
28
|
+
//
|
|
29
|
+
// Two layers (mirrors createLink/Link, createImage/Image):
|
|
30
|
+
// 1. createIcon(source) — factory: one component per loaded glyph
|
|
31
|
+
// 2. Icon — generic shell for a one-off loaded svg
|
|
32
|
+
//
|
|
33
|
+
// There is intentionally no `useIcon` — an icon has no composable behaviour
|
|
34
|
+
// (no async, no state, no router). A hook layer would be surface for its
|
|
35
|
+
// own sake.
|
|
36
|
+
|
|
37
|
+
const FILL_STYLE = 'display:block;width:100%;height:100%'
|
|
38
|
+
|
|
39
|
+
/** An imported SVG component (`import X from './x.svg?component'`). */
|
|
40
|
+
export type SvgComponent = (props: SvgAttributes) => VNodeChild
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Props for {@link Icon}. The standard `<svg>` attribute surface
|
|
44
|
+
* (`fill`, `class`, `style`, `aria-*`, `onClick`, …) — every one passed
|
|
45
|
+
* straight through and overriding the container-fill defaults — plus the
|
|
46
|
+
* two source props.
|
|
47
|
+
*/
|
|
48
|
+
export interface IconProps extends SvgAttributes {
|
|
49
|
+
/**
|
|
50
|
+
* An imported SVG component, e.g. `import X from './icon.svg?component'`.
|
|
51
|
+
* Rendered directly with no host wrapper. Recommended over `svg`.
|
|
52
|
+
*/
|
|
53
|
+
as?: SvgComponent | undefined
|
|
54
|
+
/**
|
|
55
|
+
* A full `<svg>…</svg>` markup string, e.g.
|
|
56
|
+
* `import x from './icon.svg?raw'`. Inlined inside a single `<span>` host.
|
|
57
|
+
*/
|
|
58
|
+
svg?: string | undefined
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Render a loaded SVG — container-filling, theme-aware, props-transparent.
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* import Check from './check.svg?component'
|
|
66
|
+
* <span style="width:2rem"><Icon as={Check} /></span>
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* import check from './check.svg?raw'
|
|
70
|
+
* <span style="width:2rem"><Icon svg={check} /></span>
|
|
71
|
+
*/
|
|
72
|
+
export function Icon(props: IconProps): VNodeChild {
|
|
73
|
+
const [own, rest] = splitProps(props, ['as', 'svg'])
|
|
74
|
+
|
|
75
|
+
// Component form — render the imported SVG directly, no host wrapper.
|
|
76
|
+
// Defaults first so consumer `rest` (spread) overrides them; JSX spread
|
|
77
|
+
// is reactivity-safe (compiler wraps it with `_wrapSpread`).
|
|
78
|
+
if (own.as) {
|
|
79
|
+
const As = own.as
|
|
80
|
+
return <As fill="currentColor" style={FILL_STYLE} {...rest} />
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Raw-markup form — the string already contains its own <svg>, so we
|
|
84
|
+
// inline it via a single <span> host. `dangerouslySetInnerHTML` last so
|
|
85
|
+
// it can't be clobbered by a stray spread key.
|
|
86
|
+
if (own.svg) {
|
|
87
|
+
// svg-only props (`fill`, `viewBox`, …) are inapplicable to the host
|
|
88
|
+
// span AND can't reach the opaque inlined markup — only host-level
|
|
89
|
+
// attrs (`class`, `style`, `aria-*`, events) are meaningfully
|
|
90
|
+
// forwardable here. Narrow the spread to the host's real surface.
|
|
91
|
+
const hostRest = rest as unknown as PyreonHTMLAttributes<HTMLElement>
|
|
92
|
+
return (
|
|
93
|
+
<span
|
|
94
|
+
style={FILL_STYLE}
|
|
95
|
+
{...hostRest}
|
|
96
|
+
dangerouslySetInnerHTML={{ __html: own.svg }}
|
|
97
|
+
/>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Build a reusable icon component from a loaded svg — a markup string OR an
|
|
106
|
+
* imported SVG component. The result is still just `<Icon>`, so it's
|
|
107
|
+
* container-sizable + theme-aware with every prop passed through.
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* import check from './check.svg?raw'
|
|
111
|
+
* export const Check = createIcon(check)
|
|
112
|
+
*
|
|
113
|
+
* import StarSvg from './star.svg?component'
|
|
114
|
+
* export const Star = createIcon(StarSvg)
|
|
115
|
+
*
|
|
116
|
+
* // …sized + themed entirely by the consumer:
|
|
117
|
+
* <span style="width:48px"><Check class="text-green-600" /></span>
|
|
118
|
+
*/
|
|
119
|
+
export function createIcon(
|
|
120
|
+
source: string | SvgComponent,
|
|
121
|
+
): (props: SvgAttributes) => VNodeChild {
|
|
122
|
+
return (props: SvgAttributes) =>
|
|
123
|
+
typeof source === 'string' ? (
|
|
124
|
+
<Icon svg={source} {...props} />
|
|
125
|
+
) : (
|
|
126
|
+
<Icon as={source} {...props} />
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ─── createNamedIcon — typed icon-set runtime ────────────────────────────────
|
|
131
|
+
//
|
|
132
|
+
// The runtime half of `iconsPlugin`. The plugin scans a folder and writes a
|
|
133
|
+
// generated file that calls this with a registry literal; `keyof typeof
|
|
134
|
+
// REGISTRY` makes `name` a strict union (autocompletes, rejects typos) and
|
|
135
|
+
// gives real go-to-definition — zero per-app wiring.
|
|
136
|
+
|
|
137
|
+
/** How a named icon set renders each entry. */
|
|
138
|
+
export type IconMode = 'inline' | 'image'
|
|
139
|
+
|
|
140
|
+
/** Props of a component built by {@link createNamedIcon}. */
|
|
141
|
+
export type NamedIconProps<R extends Record<string, string>> = {
|
|
142
|
+
/** A name from the scanned set — strictly typed to the available files. */
|
|
143
|
+
name: keyof R & string
|
|
144
|
+
/** `<img>` alt text (image mode). Defaults to `""` (decorative). */
|
|
145
|
+
alt?: string
|
|
146
|
+
} & Omit<IconProps, 'as' | 'svg'>
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Build a strictly-typed `<Icon name="…" />` from a name→source registry.
|
|
150
|
+
*
|
|
151
|
+
* - `mode: 'inline'` (default) — `source` is raw `<svg>` markup; rendered via
|
|
152
|
+
* {@link Icon} so it's `currentColor`-themeable (system icons you recolor).
|
|
153
|
+
* - `mode: 'image'` — `source` is an asset URL; rendered as `<img>` with NO
|
|
154
|
+
* svg mutation, original colors preserved (colorful / brand icons).
|
|
155
|
+
*
|
|
156
|
+
* Either way it stays container-filling + props-transparent. Not called by
|
|
157
|
+
* hand normally — `iconsPlugin` emits the generated file that calls it.
|
|
158
|
+
*
|
|
159
|
+
* @example
|
|
160
|
+
* // icons.gen.tsx (auto-generated):
|
|
161
|
+
* export const Icon = createNamedIcon({ 'check-circle': '<svg…' })
|
|
162
|
+
* // app:
|
|
163
|
+
* <span style="width:2rem"><Icon name="check-circle" /></span>
|
|
164
|
+
*/
|
|
165
|
+
export function createNamedIcon<R extends Record<string, string>>(
|
|
166
|
+
registry: R,
|
|
167
|
+
options: { mode?: IconMode } = {},
|
|
168
|
+
): (props: NamedIconProps<R>) => VNodeChild {
|
|
169
|
+
const mode = options.mode ?? 'inline'
|
|
170
|
+
return (props: NamedIconProps<R>) => {
|
|
171
|
+
const [own, rest] = splitProps(props, ['name', 'alt'])
|
|
172
|
+
const source = registry[own.name]
|
|
173
|
+
if (mode === 'image') {
|
|
174
|
+
// svg-only props can't apply to an <img>; only host attrs forward.
|
|
175
|
+
const hostRest = rest as unknown as PyreonHTMLAttributes<HTMLImageElement>
|
|
176
|
+
return (
|
|
177
|
+
<img src={source} alt={own.alt ?? ''} style={FILL_STYLE} {...hostRest} />
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
return <Icon svg={source} {...rest} />
|
|
181
|
+
}
|
|
182
|
+
}
|