@pyreon/zero 0.18.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 +9 -0
- package/lib/types/image-plugin.d.ts +65 -7
- package/lib/types/index.d.ts +88 -2
- package/lib/types/server.d.ts +87 -1
- package/lib/{vite-plugin-y0NmCLJA.js → vite-plugin-8TXXFqdP.js} +27 -12
- 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 +9 -0
package/src/manifest.ts
CHANGED
|
@@ -475,6 +475,105 @@ const ButtonLink = createLink((props) => (
|
|
|
475
475
|
seeAlso: ['Link', 'useLink'],
|
|
476
476
|
},
|
|
477
477
|
|
|
478
|
+
{
|
|
479
|
+
name: 'Icon',
|
|
480
|
+
kind: 'component',
|
|
481
|
+
signature: '<Icon as={ImportedSvgComponent} | svg={rawSvgMarkupString} {...hostProps} />',
|
|
482
|
+
summary:
|
|
483
|
+
"Renders a FULL loaded SVG — it does NOT synthesize its own `<svg>` around hand-authored `<path>` children. You load an svg (it already contains the `<svg>` root) and Icon makes it container-sizable + theme-aware. Two source props: `as` — an imported SVG *component* (`import X from './x.svg?component'`), rendered DIRECTLY with no host wrapper (recommended; it's a real `<svg>` so container-fill is reliable); `svg` — the raw `<svg>…</svg>` *markup string* (`import x from './x.svg?raw'`), inlined via a single `<span>` host (a markup string needs a parent to mount — this one host is unavoidable for the string form). Defaults (`fill=\"currentColor\"`, `display:block;width:100%;height:100%`) are overridable — consumer props spread through and win. No fixed size → fills its container; `fill=\"currentColor\"` themes via CSS `color`. Intentionally no `useIcon` hook (an icon has no composable behaviour); two layers: `createIcon` (one component per loaded glyph) + `Icon` (one-off).",
|
|
484
|
+
example: `import { Icon } from '@pyreon/zero'
|
|
485
|
+
import Check from './check.svg?component'
|
|
486
|
+
import checkRaw from './check.svg?raw'
|
|
487
|
+
|
|
488
|
+
// Component form — rendered directly, no wrapper, reliable fill:
|
|
489
|
+
<span style="width:2rem"><Icon as={Check} /></span>
|
|
490
|
+
|
|
491
|
+
// Raw-markup form — inlined inside one <span> host:
|
|
492
|
+
<span style="width:2rem"><Icon svg={checkRaw} /></span>`,
|
|
493
|
+
mistakes: [
|
|
494
|
+
"Expecting `<Icon>` to synthesize an `<svg>` from `<path>` children — it does NOT. Pass a loaded svg via `as` (imported `?component`) or `svg` (imported `?raw` string). Children are not the API",
|
|
495
|
+
"Expecting `<Icon>` to size itself — it has NO intrinsic size; it fills its container. Wrap + size it (`<span style=\"width:1.5rem\">`) or use a sized flex/grid cell",
|
|
496
|
+
"Hardcoding `fill=\"#000\"` — breaks theming. Leave the `currentColor` default; drive colour with CSS `color` so dark mode + hover work for free. Only the `as` form forwards `fill` to the real svg — the `svg`-string form's markup is opaque, so colour it via `currentColor` inside the asset",
|
|
497
|
+
"Expecting svg-only props (`viewBox`, `fill`) to apply in the `svg`-string form — they can't reach the opaque inlined markup; only host attrs (`class`, `style`, `aria-*`, events) forward. Use the `as` form when you need to drive svg attributes",
|
|
498
|
+
"Reaching for a `useIcon` hook — there isn't one, by design. Use `createIcon` or inline `<Icon>`; an icon has no behaviour worth a hook layer",
|
|
499
|
+
"Preferring `svg` (raw string) for the wrapper-free guarantee — it's the opposite: `svg` ALWAYS adds a `<span>` host (unavoidable for string inlining); `as` is the zero-wrapper form",
|
|
500
|
+
],
|
|
501
|
+
seeAlso: ['createIcon', 'IconProps', 'Image'],
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
name: 'createIcon',
|
|
505
|
+
kind: 'function',
|
|
506
|
+
signature: 'function createIcon(source: string | SvgComponent): (props: SvgAttributes) => VNodeChild',
|
|
507
|
+
summary:
|
|
508
|
+
"Builds a reusable icon component from a LOADED svg — a raw `<svg>…</svg>` markup string (`?raw`) OR an imported SVG component (`?component`). The result is still just `<Icon>` (string → `svg` prop, component → `as` prop), so it's container-sizable + theme-aware with every prop passed through. A generated icon set is `createIcon`-per-glyph with zero per-icon boilerplate. Mirrors the `createLink`/`createImage` factory layer, minus a hook (icons have no composable behaviour).",
|
|
509
|
+
example: `import { createIcon } from '@pyreon/zero'
|
|
510
|
+
import StarSvg from './star.svg?component'
|
|
511
|
+
import checkRaw from './check.svg?raw'
|
|
512
|
+
|
|
513
|
+
export const Star = createIcon(StarSvg) // component → rendered directly
|
|
514
|
+
export const Check = createIcon(checkRaw) // raw string → inlined via <span>
|
|
515
|
+
|
|
516
|
+
// Sized + themed entirely by the consumer:
|
|
517
|
+
<span style="width:48px"><Check class="text-green-600" aria-label="done" /></span>`,
|
|
518
|
+
mistakes: [
|
|
519
|
+
"Calling `createIcon` inside a component body — define icon components at module scope (like `createLink`/`createImage`). Re-creating the component every render defeats identity-based reconciliation",
|
|
520
|
+
"Passing hand-built `<path>` JSX as `source` — `source` is a full loaded svg: a `?raw` markup string OR a `?component` import. It does NOT take individual shapes; the loaded asset already contains its own `<svg>` root",
|
|
521
|
+
"Assuming the `?raw` form has no wrapper — the string form ALWAYS adds one `<span>` host (unavoidable for inlining markup). Use the `?component` form for the zero-wrapper, attribute-forwarding path",
|
|
522
|
+
],
|
|
523
|
+
seeAlso: ['Icon', 'IconProps', 'createNamedIcon', 'iconsPlugin'],
|
|
524
|
+
},
|
|
525
|
+
{
|
|
526
|
+
name: 'iconsPlugin',
|
|
527
|
+
kind: 'function',
|
|
528
|
+
signature: "iconsPlugin({ dir | sets, out?, mode?: 'inline' | 'image' }): Plugin",
|
|
529
|
+
summary:
|
|
530
|
+
"Vite plugin (from `@pyreon/zero/server`): point it at a folder of `*.svg` files and it writes a strictly-typed generated `icons.gen.tsx` exporting `<Icon name=\"…\" />`. Add an svg → the `name` union widens; remove one → an invalid `name` fails typecheck. The generated file calls `createNamedIcon(REGISTRY)`, so `keyof typeof REGISTRY` IS the type surface (autocomplete + real go-to-definition, zero per-app wiring — same one-touch shape as fs-router / islands auto-registry). Regenerates on add/unlink in dev (idempotent write — never rewrites identical content). **Named multi-set form** (`sets: { ui: { dir }, brand: { dir, mode } }`, mutually exclusive with `dir`): one generated file exports a strictly-typed component PER set with NAMESPACED types so they never clash — `ui` → `<UiIcon name=\"…\" />` + `type UiIconName`, `brand` → `<BrandIcon name=\"…\" />` + `type BrandIconName`; per-set binding prefixes mean two sets sharing a glyph filename don't collide. Two render modes per the colorful-vs-system split (settable per-set): `mode: 'inline'` (default — system icons; each svg inlined as raw `?raw` markup, `currentColor`-themeable, recolor via CSS `color`) and `mode: 'image'` (colorful / brand icons; each svg emitted as a static asset, rendered `<img>`, NO mutation, original colors preserved). Default `out` is `icons.gen.tsx` next to `dir` for the single-set form (`src/icons` → `src/icons.gen.tsx`) or `src/icons.gen.tsx` for the multi-set form — recommend gitignoring it (build artifact). It writes a real file (NOT a virtual module) deliberately: the published `@pyreon/zero` package can't `import` a plugin virtual module — Rolldown resolves static imports before plugin `resolveId` (the same constraint that makes islands need `hydrateIslandsAuto(registry)` with an explicit import).",
|
|
531
|
+
example: `// vite.config.ts — single set:
|
|
532
|
+
import { iconsPlugin } from '@pyreon/zero/server'
|
|
533
|
+
iconsPlugin({ dir: './src/icons' })
|
|
534
|
+
// app: import { Icon } from './icons.gen'; <Icon name="check-circle" />
|
|
535
|
+
|
|
536
|
+
// Named multi-set — per-set typed components, no IconName clash:
|
|
537
|
+
iconsPlugin({ sets: {
|
|
538
|
+
ui: { dir: './src/icons/ui' },
|
|
539
|
+
brand: { dir: './src/icons/brand', mode: 'image' },
|
|
540
|
+
}})
|
|
541
|
+
// app: import { UiIcon, BrandIcon } from './icons.gen'
|
|
542
|
+
// <UiIcon name="arrow-left" /> <BrandIcon name="logo-mark" />`,
|
|
543
|
+
mistakes: [
|
|
544
|
+
"Passing BOTH `dir` and `sets` (or neither) — exactly one is required; the plugin throws `[Pyreon] iconsPlugin: provide EXACTLY ONE of dir or sets` at config time",
|
|
545
|
+
"Using `mode: 'inline'` (default) for multicolor / brand SVGs — inline mode is for monochrome system icons you recolor via `currentColor`. A multicolor logo's hardcoded fills survive but you lose nothing by using `mode: 'image'`, which is the correct choice for no-mutation colorful assets",
|
|
546
|
+
"Using `mode: 'image'` for icons you need to recolor — `<img>` can't be themed via CSS `color`; the svg is opaque. Recolorable system icons need `mode: 'inline'`",
|
|
547
|
+
"Editing the generated `icons.gen.tsx` by hand — it's regenerated on every add/unlink. Add/remove `.svg` files in the set folder(s) instead; commit the gitignore entry, not the file",
|
|
548
|
+
"Expecting a virtual `import 'virtual:zero/icons'` — there isn't one (Rolldown import-ordering constraint). The plugin writes a REAL file you import by path; that's what gives go-to-definition + zero wiring",
|
|
549
|
+
"Pointing a set `dir` at a folder that doesn't exist yet — `scanIconDir` returns empty and the generated `*IconName` is `never` (every `name` fails typecheck). Create the folder + drop at least one `.svg` first",
|
|
550
|
+
"Forgetting `vite/client` types — the generated file's `?raw` imports rely on Vite's ambient `*.svg?raw` module declaration; the generated file emits `/// <reference types=\"vite/client\" />` but the consuming tsconfig must still resolve `vite/client`",
|
|
551
|
+
],
|
|
552
|
+
seeAlso: ['createNamedIcon', 'Icon', 'IconProps'],
|
|
553
|
+
},
|
|
554
|
+
{
|
|
555
|
+
name: 'createNamedIcon',
|
|
556
|
+
kind: 'function',
|
|
557
|
+
signature:
|
|
558
|
+
"function createNamedIcon<R extends Record<string, string>>(registry: R, options?: { mode?: 'inline' | 'image' }): (props: { name: keyof R & string } & …) => VNodeChild",
|
|
559
|
+
summary:
|
|
560
|
+
"Runtime half of `iconsPlugin` — builds a strictly-typed `<Icon name=\"…\" />` from a name→source registry. `keyof R` makes `name` a precise string union (the generated file passes a literal registry so the union infers there → autocomplete + go-to-definition). `mode: 'inline'` (default) treats each `source` as raw `<svg>` markup rendered via `Icon` (`currentColor`-themeable system icons); `mode: 'image'` treats each `source` as an asset URL rendered `<img>` with NO mutation (colorful / brand icons). Either way it stays container-filling + props-transparent. Not normally hand-called — `iconsPlugin` emits the generated file that calls it; call it directly only for a hand-maintained set.",
|
|
561
|
+
example: `// icons.gen.tsx (auto-generated by iconsPlugin):
|
|
562
|
+
import { createNamedIcon } from '@pyreon/zero'
|
|
563
|
+
export const Icon = createNamedIcon({ 'check-circle': '<svg…>…</svg>' })
|
|
564
|
+
|
|
565
|
+
// image mode (hand-maintained colorful set):
|
|
566
|
+
import logo from './logo.svg' // Vite → URL
|
|
567
|
+
export const Brand = createNamedIcon({ logo }, { mode: 'image' })
|
|
568
|
+
<Brand name="logo" alt="Company" />`,
|
|
569
|
+
mistakes: [
|
|
570
|
+
"Passing a `Record<string, string>` typed loosely (e.g. `: Record<string, string>`) — that widens `keyof R` to `string` and you lose the typed `name`. Pass the object literal directly (or `as const`) so the keys infer",
|
|
571
|
+
"Using `mode: 'image'` then expecting `fill` / svg props to apply — the `<img>` is opaque; only host attrs (`class`, `style`, `alt`, events) forward. Use `mode: 'inline'` for svg-attribute control",
|
|
572
|
+
"Omitting `alt` in `mode: 'image'` — it defaults to `\"\"` (decorative). Pass a real `alt` for meaningful icons; screen readers skip empty-alt images",
|
|
573
|
+
"Calling `createNamedIcon` inside a component body — define the set once at module scope (the generated file does). Re-creating it per render defeats identity-based reconciliation",
|
|
574
|
+
],
|
|
575
|
+
seeAlso: ['iconsPlugin', 'Icon', 'IconProps'],
|
|
576
|
+
},
|
|
478
577
|
{
|
|
479
578
|
name: 'Image',
|
|
480
579
|
kind: 'component',
|
package/src/rate-limit.ts
CHANGED
|
@@ -62,6 +62,22 @@ export function rateLimitMiddleware(config: RateLimitConfig = {}): Middleware {
|
|
|
62
62
|
for (const [key, entry] of store) {
|
|
63
63
|
if (entry.resetAt <= now) store.delete(key)
|
|
64
64
|
}
|
|
65
|
+
// HARD cap. The expired-sweep above only removes entries whose
|
|
66
|
+
// window has elapsed. An attacker flooding unique keys WITHIN one
|
|
67
|
+
// window (spoofed `X-Forwarded-For` / proxy header — `defaultKeyFn`
|
|
68
|
+
// trusts request headers) produces only fresh entries, so the sweep
|
|
69
|
+
// frees nothing and `store.set` grows the Map without bound — an
|
|
70
|
+
// unauthenticated memory-exhaustion DoS. `MAX_STORE_SIZE` was a
|
|
71
|
+
// declared constant used ONLY as a sweep trigger, never enforced.
|
|
72
|
+
// Map preserves insertion order, so evicting from the front drops
|
|
73
|
+
// the oldest trackers first (acceptable: an evicted attacker key
|
|
74
|
+
// simply gets a fresh window — no bypass of legitimate limits since
|
|
75
|
+
// a real client re-inserts and is immediately re-tracked).
|
|
76
|
+
while (store.size > MAX_STORE_SIZE) {
|
|
77
|
+
const oldest = store.keys().next().value
|
|
78
|
+
if (oldest === undefined) break
|
|
79
|
+
store.delete(oldest)
|
|
80
|
+
}
|
|
65
81
|
}
|
|
66
82
|
|
|
67
83
|
return (ctx: MiddlewareContext) => {
|
package/src/seo.ts
CHANGED
|
@@ -135,10 +135,25 @@ export function generateSitemap(
|
|
|
135
135
|
.filter((p): p is string => p !== null)
|
|
136
136
|
.filter((p) => !exclude.some((e) => p.startsWith(e)))
|
|
137
137
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
138
|
+
// Dedup by path (first-wins, order-preserving). The same static route
|
|
139
|
+
// routinely appears in BOTH the file-system route scan AND
|
|
140
|
+
// `additionalPaths` (e.g. SSG-emitted paths merged in via
|
|
141
|
+
// `seoPlugin`), which previously produced a DUPLICATE `<url>` entry —
|
|
142
|
+
// the i18n branch of `clusterPathsByLocale` dedups via `byUnPrefixed`,
|
|
143
|
+
// but the non-i18n branch is a raw 1:1 `entries.map(...)`, so without
|
|
144
|
+
// this the duplicate reached the emitted sitemap. Dedup here covers
|
|
145
|
+
// both branches at the single source. The route-scan entry wins so its
|
|
146
|
+
// configured `changefreq`/`priority` is kept over a bare dup.
|
|
147
|
+
const allPaths: SitemapEntry[] = (() => {
|
|
148
|
+
const byPath = new Map<string, SitemapEntry>()
|
|
149
|
+
for (const e of [
|
|
150
|
+
...paths.map((p) => ({ path: p, changefreq, priority })),
|
|
151
|
+
...(config.additionalPaths ?? []),
|
|
152
|
+
]) {
|
|
153
|
+
if (!byPath.has(e.path)) byPath.set(e.path, e)
|
|
154
|
+
}
|
|
155
|
+
return [...byPath.values()]
|
|
156
|
+
})()
|
|
142
157
|
|
|
143
158
|
// PR K: when i18n is set, cluster URLs by their un-prefixed (default-
|
|
144
159
|
// locale) form so each `<url>` entry can carry the hreflang siblings
|
package/src/server.ts
CHANGED
|
@@ -70,6 +70,8 @@ export { compose, getContext } from "./middleware";
|
|
|
70
70
|
export { zeroPlugin as default, getZeroPluginConfig } from "./vite-plugin";
|
|
71
71
|
export type { FaviconPluginConfig, FaviconLocaleConfig } from "./favicon";
|
|
72
72
|
export { faviconPlugin, faviconLinks } from "./favicon";
|
|
73
|
+
export type { IconsPluginConfig, IconSetConfig, NamedSetInput } from "./icons-plugin";
|
|
74
|
+
export { iconsPlugin, iconNameFromFile, scanIconDir, generateIconSetSource, generateNamedIconSetsSource, componentNameFromSetKey } from "./icons-plugin";
|
|
73
75
|
export type { SeoPluginConfig, SitemapConfig, RobotsConfig } from "./seo";
|
|
74
76
|
export { seoPlugin, generateSitemap, generateRobots, jsonLd, seoMiddleware } from "./seo";
|
|
75
77
|
export type { OgImagePluginConfig, OgImageTemplate, OgImageLayer } from "./og-image";
|
package/src/sharp.d.ts
CHANGED
|
@@ -9,6 +9,12 @@ declare module 'sharp' {
|
|
|
9
9
|
toFile(path: string): Promise<void>
|
|
10
10
|
toBuffer(): Promise<Buffer>
|
|
11
11
|
metadata(): Promise<{ width?: number; height?: number; format?: string }>
|
|
12
|
+
/**
|
|
13
|
+
* Image statistics. `dominant` is the histogram-mode RGB swatch —
|
|
14
|
+
* the basis of the `'color'` / `'dominant-color'` placeholder
|
|
15
|
+
* strategy (a flat-fill SVG, not a muddy channel average).
|
|
16
|
+
*/
|
|
17
|
+
stats(): Promise<{ dominant: { r: number; g: number; b: number } }>
|
|
12
18
|
}
|
|
13
19
|
|
|
14
20
|
function sharp(input: string | Buffer): SharpInstance
|
package/src/ssg-plugin.ts
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
import { existsSync } from 'node:fs'
|
|
25
25
|
import { mkdir, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises'
|
|
26
|
-
import { dirname, join, resolve } from 'node:path'
|
|
26
|
+
import { dirname, join, resolve, sep } from 'node:path'
|
|
27
27
|
import { pathToFileURL } from 'node:url'
|
|
28
28
|
import type { Plugin } from 'vite'
|
|
29
29
|
import { resolveAdapter } from './adapters'
|
|
@@ -373,6 +373,25 @@ export function expandUrlPattern(pattern: string, params: Record<string, string>
|
|
|
373
373
|
`[zero:ssg] getStaticPaths for "${pattern}" returned params without "${name}"`,
|
|
374
374
|
)
|
|
375
375
|
}
|
|
376
|
+
// Path-escape guard. The value is substituted verbatim into the
|
|
377
|
+
// URL that becomes a `dist/<path>/index.html` write target. A
|
|
378
|
+
// single (non-catch-all) `:slug` is ONE segment — a value
|
|
379
|
+
// containing `/` or being `.`/`..` (e.g. an unsanitized CMS slug
|
|
380
|
+
// `../../secret`) would escape the intended structure and write
|
|
381
|
+
// outside it. Catch-all `:slug*` legitimately spans segments
|
|
382
|
+
// (`a/b/c`), so it's exempt from the `/` check but still rejects
|
|
383
|
+
// `.`/`..` traversal segments.
|
|
384
|
+
const segs = isCatchAll ? value.split('/') : [value]
|
|
385
|
+
if (
|
|
386
|
+
(!isCatchAll && value.includes('/')) ||
|
|
387
|
+
segs.some((s) => s === '.' || s === '..')
|
|
388
|
+
) {
|
|
389
|
+
throw new Error(
|
|
390
|
+
`[zero:ssg] getStaticPaths for "${pattern}" produced an unsafe "${name}" value ` +
|
|
391
|
+
`(${JSON.stringify(value)}): a ${isCatchAll ? 'catch-all' : 'dynamic'} segment ` +
|
|
392
|
+
`must not contain path-traversal ("." / "..")${isCatchAll ? '' : ' or "/"'}.`,
|
|
393
|
+
)
|
|
394
|
+
}
|
|
376
395
|
return value
|
|
377
396
|
})
|
|
378
397
|
.join('/')
|
|
@@ -443,10 +462,17 @@ async function autoDetectStaticPaths(
|
|
|
443
462
|
}
|
|
444
463
|
}
|
|
445
464
|
|
|
465
|
+
// Dedup (order-preserving). The same concrete path can be produced
|
|
466
|
+
// more than once — a `getStaticPaths` returning a duplicate slug, or
|
|
467
|
+
// i18n route fan-out colliding — which otherwise renders the same
|
|
468
|
+
// `dist/<path>/index.html` twice (wasted work + last-write race) and
|
|
469
|
+
// feeds a duplicate `<url>` into the SSG→sitemap merge.
|
|
470
|
+
const deduped = [...new Set(out)]
|
|
471
|
+
|
|
446
472
|
// Always include "/" as a fallback if no static routes were found —
|
|
447
473
|
// a project with only dynamic routes still needs an index.html for the
|
|
448
474
|
// host to know where to send unmatched URLs.
|
|
449
|
-
return
|
|
475
|
+
return deduped.length > 0 ? deduped : ['/']
|
|
450
476
|
}
|
|
451
477
|
|
|
452
478
|
async function resolvePaths(
|
|
@@ -598,6 +624,21 @@ function resolveOutputPath(distDir: string, path: string): string {
|
|
|
598
624
|
return join(distDir, path, 'index.html')
|
|
599
625
|
}
|
|
600
626
|
|
|
627
|
+
/**
|
|
628
|
+
* Path-containment check that is SEPARATOR-TERMINATED. A bare
|
|
629
|
+
* `resolve(filePath).startsWith(resolve(distDir))` is a string-prefix
|
|
630
|
+
* test, not a path test: with distDir `/app/dist`, a traversed filePath
|
|
631
|
+
* resolving to the SIBLING `/app/dist-evil/x` passes
|
|
632
|
+
* `'/app/dist-evil/x'.startsWith('/app/dist')` → true and the build
|
|
633
|
+
* writes outside the intended output root. `path` derives from caller
|
|
634
|
+
* route params (CMS slugs via `getStaticPaths`), so this is reachable.
|
|
635
|
+
*/
|
|
636
|
+
function isInsideDist(distDir: string, filePath: string): boolean {
|
|
637
|
+
const root = resolve(distDir)
|
|
638
|
+
const target = resolve(filePath)
|
|
639
|
+
return target === root || target.startsWith(root + sep)
|
|
640
|
+
}
|
|
641
|
+
|
|
601
642
|
// ─── Redirect emission (PR B) ──────────────────────────────────────────────
|
|
602
643
|
//
|
|
603
644
|
// The shape returned by the SSG entry's renderPath when a loader throws
|
|
@@ -1128,8 +1169,7 @@ export function ssgPlugin(userConfig: ZeroConfig = {}): Plugin {
|
|
|
1128
1169
|
|
|
1129
1170
|
if (config.ssg?.redirectsAsHtml === 'meta-refresh') {
|
|
1130
1171
|
const filePath = resolveOutputPath(distDir, p)
|
|
1131
|
-
|
|
1132
|
-
if (!resolve(filePath).startsWith(resolvedOut)) {
|
|
1172
|
+
if (!isInsideDist(distDir, filePath)) {
|
|
1133
1173
|
errors.push({ path: p, error: new Error(`Path traversal detected: "${p}"`) })
|
|
1134
1174
|
return
|
|
1135
1175
|
}
|
|
@@ -1144,8 +1184,7 @@ export function ssgPlugin(userConfig: ZeroConfig = {}): Plugin {
|
|
|
1144
1184
|
const filePath = resolveOutputPath(distDir, p)
|
|
1145
1185
|
|
|
1146
1186
|
// Path-traversal guard — same as @pyreon/server's prerender.
|
|
1147
|
-
|
|
1148
|
-
if (!resolve(filePath).startsWith(resolvedOut)) {
|
|
1187
|
+
if (!isInsideDist(distDir, filePath)) {
|
|
1149
1188
|
errors.push({ path: p, error: new Error(`Path traversal detected: "${p}"`) })
|
|
1150
1189
|
return
|
|
1151
1190
|
}
|
|
@@ -1174,8 +1213,7 @@ export function ssgPlugin(userConfig: ZeroConfig = {}): Plugin {
|
|
|
1174
1213
|
const fallbackHtml = await config.ssg.onPathError(p, error)
|
|
1175
1214
|
if (typeof fallbackHtml === 'string') {
|
|
1176
1215
|
const filePath = resolveOutputPath(distDir, p)
|
|
1177
|
-
|
|
1178
|
-
if (!resolve(filePath).startsWith(resolvedOut)) {
|
|
1216
|
+
if (!isInsideDist(distDir, filePath)) {
|
|
1179
1217
|
errors.push({ path: p, error: new Error(`Path traversal detected: "${p}"`) })
|
|
1180
1218
|
return
|
|
1181
1219
|
}
|
|
@@ -1505,6 +1543,7 @@ export const _internal = {
|
|
|
1505
1543
|
resolvePaths,
|
|
1506
1544
|
autoDetectStaticPaths,
|
|
1507
1545
|
resolveOutputPath,
|
|
1546
|
+
isInsideDist,
|
|
1508
1547
|
expandUrlPattern,
|
|
1509
1548
|
injectIntoTemplate,
|
|
1510
1549
|
renderNetlifyRedirects,
|
package/src/types.ts
CHANGED
|
@@ -58,6 +58,15 @@ export interface ISRConfig {
|
|
|
58
58
|
* space (e.g. `/user/:id` where `:id` is free-form).
|
|
59
59
|
*/
|
|
60
60
|
maxEntries?: number
|
|
61
|
+
/**
|
|
62
|
+
* Max wall-time (ms) for a single background revalidation before it is
|
|
63
|
+
* abandoned. Without a bound, a handler that hangs leaves its key
|
|
64
|
+
* pinned in the in-flight set forever — every later request for that
|
|
65
|
+
* key short-circuits the de-dupe guard and the entry can never
|
|
66
|
+
* recover from stale. Default: `30000` (matches the Suspense
|
|
67
|
+
* streaming timeout).
|
|
68
|
+
*/
|
|
69
|
+
revalidateTimeoutMs?: number
|
|
61
70
|
/**
|
|
62
71
|
* Cache-key derivation function. The default keys cache entries by
|
|
63
72
|
* `url.pathname` ONLY — query strings, cookies, and headers are
|