prerender-crawler 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ryan Carniato
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,122 @@
1
+ # prerender-crawler
2
+
3
+ Framework-agnostic build-time prerendering. Point it at anything fetch-shaped — `Request` in, `Response` out — and it crawls the site into static files: seed pages, link discovery, header hints, redirects, retries, throttling, and an integration seam for capturing build-time data alongside the pages. No browser, no subprocess, no framework knowledge.
4
+
5
+ Ships as an engine (`prerender-crawler`) and a Vite plugin built on it (`prerender-crawler/vite`).
6
+
7
+ ## Vite plugin
8
+
9
+ ```ts
10
+ // vite.config.ts
11
+ import { defineConfig } from "vite";
12
+ import { prerender } from "prerender-crawler/vite";
13
+
14
+ export default defineConfig({
15
+ plugins: [
16
+ framework(), // anything producing a client build and an SSR build
17
+ prerender({ mode: "static" })
18
+ ]
19
+ });
20
+ ```
21
+
22
+ The plugin is build-only. It assumes three things about the app:
23
+
24
+ 1. `vite build` produces a client output directory (the `client` environment's `outDir`, default `dist/client`).
25
+ 2. Some environment's output includes a module exporting a request handler — `handleRequest`, `fetch`, or `default.fetch`. Default: `server.js` in the `ssr` environment's `outDir`; override with `serverEntry`.
26
+ 3. Optionally, a [`filesystem-routing`](https://www.npmjs.com/package/filesystem-routing) route directory names the static pages.
27
+
28
+ After the other environments build, it imports the server handler and crawls it in-process. Pages and integration-emitted files land in the client output.
29
+
30
+ ### Options
31
+
32
+ Everything from [`PrerenderOptions`](#engine-options) plus:
33
+
34
+ | Option | Default | |
35
+ | ------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
36
+ | `serverEntry` | `<ssr outDir>/server.js` | Built module exporting the handler. |
37
+ | `fileRoutes` | `true` | Seed the crawl with the static pages of the project's `filesystem-routing` directory. `true` applies when the package and `src/routes` exist and is skipped silently otherwise; pass `{ dir, extensions }` to mirror a customized `fileRoutes()` (then a missing package is an error); `false` disables. Dynamic routes are still found by following links. |
38
+
39
+ ### `import.meta.env.PRERENDER_MODE`
40
+
41
+ The plugin defines this constant in every build environment with the run's `mode`. Runtime code can ask "am I a prerendered build, and which kind" — absent (dev, or a build without the plugin) means live. It's how integrations ship posture-aware client behavior without the app wiring anything.
42
+
43
+ ## Modes
44
+
45
+ The one distinction every downstream policy keys on:
46
+
47
+ - **`"static"`** — the written files _are_ the deployment. Every rendered page is written; anything the crawl didn't produce doesn't exist at runtime, so integrations treat gaps as errors.
48
+ - **`"hybrid"`** — a live server is deployed alongside. The crawl is a build-time pass (data baking, selected pages). Rendered pages are _not_ written by default, because on most hosts a static HTML file shadows live SSR of the same route; gaps fall back to the server.
49
+
50
+ ## Engine
51
+
52
+ The plugin is a thin driver. The engine works with any transport:
53
+
54
+ ```ts
55
+ import { runPrerender } from "prerender-crawler";
56
+
57
+ const result = await runPrerender({
58
+ transport: { fetch: request => app.handle(request) },
59
+ outDir: "dist",
60
+ pages: ["/", "/about", { path: "/404", filename: "404.html" }],
61
+ mode: "static"
62
+ });
63
+
64
+ result.pages; // RenderedPage[] — path, referrers, filename, emitted, html
65
+ result.files; // EmittedFile[] — what integrations emitted
66
+ result.skipped; // SkippedPage[] — failures left out (failOnError: false)
67
+ ```
68
+
69
+ ### Engine options
70
+
71
+ | Option | Default | |
72
+ | ------------------------ | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
73
+ | `mode` | `"static"` | See [Modes](#modes). Decides the `emitPages` default. |
74
+ | `pages` | `["/"]` | Seeds: strings, `{ path, filename?, emit? }` entries, or a (async) function returning them. Duplicates collapse to one render. |
75
+ | `crawlLinks` | `true` | Follow same-origin links in rendered HTML. The only way dynamic routes are discovered without explicit seeding. |
76
+ | `hintHeader` | `"x-prerender"` | Response header naming additional paths (comma-separated) — the route the data lives on announces the routes built from it. |
77
+ | `filter` | | `(path) => boolean`; drops a discovered path before it's fetched. |
78
+ | `concurrency` | `8` | Pages in flight at once. |
79
+ | `interval` | `0` | Minimum ms between the starts of consecutive requests across all workers — a throttle for renders hitting rate-limited APIs. |
80
+ | `retries` / `retryDelay` | `2` / `500` | Re-fetch attempts for a failed page, and the wait between them. |
81
+ | `failOnError` | `true` | Whether a page that still fails after retries fails the run. Otherwise it's reported in `skipped`, with the pages that linked to it. |
82
+ | `maxRedirects` | `5` | Internal redirect hops followed for one page. |
83
+ | `emitPages` | `true` static / `false` hybrid | Whether rendered pages are written: a boolean, or a per-path predicate. Per-entry `emit` overrides. Unemitted pages still render fully — links are still followed, data still captured. |
84
+ | `autoSubfolderIndex` | `true` | `/about` → `about/index.html` (true) or `about.html` (false). |
85
+ | `origin` | `"http://localhost"` | Origin requests are minted under. |
86
+ | `onRendered` | | Observes every rendered page — the seam for sitemaps and post-processing. |
87
+ | `integrations` | `[]` | See below. |
88
+
89
+ ### Integrations
90
+
91
+ An integration is a hook bundle that participates in the run without owning it:
92
+
93
+ ```ts
94
+ interface PrerenderIntegration {
95
+ name: string;
96
+ setup?(context: PrerenderContext): void | Promise<void>; // before the first render
97
+ teardown?(context: PrerenderContext): void | Promise<void>; // after the last render, before writes
98
+ client?: string; // module a bundler plugin imports into the client build (reserved)
99
+ }
100
+
101
+ interface PrerenderContext {
102
+ mode: PrerenderMode;
103
+ origin: string;
104
+ outDir: string;
105
+ emitFile(file: { filename: string; contents: string | Uint8Array }): void;
106
+ }
107
+ ```
108
+
109
+ `emitFile` is the channel for artifacts produced during the crawl — captured server-function results, extracted payloads, sitemaps. Throwing from `teardown` fails the run: the place to verify the crawl produced everything the runtime half will need. [`@solidjs/prerender`](../solid) is the reference integration.
110
+
111
+ ### Utilities
112
+
113
+ - `fileRoutePages({ root, dir, extensions })` / `staticRoutePaths(entries)` — the static page paths of a `filesystem-routing` manifest, as a `pages` source.
114
+ - `extractLinks(html)`, `normalizeLink(href, from)`, `normalizePath(path)`, `outputFilename(path, autoSubfolderIndex)` — the crawl's own primitives.
115
+
116
+ ## Requirements
117
+
118
+ Node 20+. Vite 7 or 8 for the plugin (optional peer). `filesystem-routing` ≥ 0.2 for route seeding (optional peer).
119
+
120
+ ## License
121
+
122
+ MIT
@@ -0,0 +1,12 @@
1
+ import type { PrerenderOptions, PrerenderResult, Transport } from "./types.ts";
2
+ export interface RunOptions extends PrerenderOptions {
3
+ transport: Transport;
4
+ outDir: string;
5
+ }
6
+ /**
7
+ * The run: seed, fetch, follow redirects, write, discover (links + header
8
+ * hints), repeat until the queue drains. Every mechanism here is
9
+ * transport-agnostic — in-process dispatch and loopback HTTP drive the
10
+ * identical loop.
11
+ */
12
+ export declare function runPrerender(options: RunOptions): Promise<PrerenderResult>;
package/dist/crawl.js ADDED
@@ -0,0 +1,222 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { extractLinks, normalizeLink, normalizePath } from "./links.js";
4
+ import { outputFilename } from "./output.js";
5
+ const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
6
+ async function resolveSeeds(pages) {
7
+ const source = typeof pages === "function" ? await pages() : (pages ?? ["/"]);
8
+ // Seed sources overlap routinely (an explicit list plus a route-manifest
9
+ // scan both naming `/`): one render per path, the first spelling wins.
10
+ const byPath = new Map();
11
+ for (const entry of source) {
12
+ const page = typeof entry === "string"
13
+ ? { path: normalizePath(entry) }
14
+ : { ...entry, path: normalizePath(entry.path) };
15
+ if (!byPath.has(page.path))
16
+ byPath.set(page.path, page);
17
+ }
18
+ return [...byPath.values()];
19
+ }
20
+ /**
21
+ * The run: seed, fetch, follow redirects, write, discover (links + header
22
+ * hints), repeat until the queue drains. Every mechanism here is
23
+ * transport-agnostic — in-process dispatch and loopback HTTP drive the
24
+ * identical loop.
25
+ */
26
+ export async function runPrerender(options) {
27
+ const { transport, outDir, mode = "static", crawlLinks = true, hintHeader = "x-prerender", filter, concurrency = 8, interval = 0, retries = 2, retryDelay = 500, failOnError = true, maxRedirects = 5,
28
+ // hybrid's crawl bakes data; writing its HTML would shadow live SSR
29
+ emitPages = mode === "static", autoSubfolderIndex = true, origin = "http://localhost", onRendered, integrations = [] } = options;
30
+ const originUrl = new URL(origin);
31
+ const rendered = [];
32
+ const skipped = [];
33
+ const emitted = [];
34
+ const context = {
35
+ mode,
36
+ origin,
37
+ outDir,
38
+ emitFile: file => void emitted.push(file)
39
+ };
40
+ const seeds = await resolveSeeds(options.pages);
41
+ const seen = new Set(seeds.map(page => page.path));
42
+ const queue = [...seeds];
43
+ // Provenance: which pages named each discovered path. Recorded for every
44
+ // mention (not just the first), so a failure can point at every page
45
+ // carrying the broken link.
46
+ const referrers = new Map();
47
+ const referrersOf = (path) => [...(referrers.get(path) ?? [])];
48
+ // Discovered paths (crawled links, header hints) pass the filter;
49
+ // explicitly seeded pages are the caller's statement of intent and skip it.
50
+ const discovered = (path, from) => {
51
+ let sources = referrers.get(path);
52
+ if (!sources)
53
+ referrers.set(path, (sources = new Set()));
54
+ sources.add(from);
55
+ if (seen.has(path) || (filter && !filter(path)))
56
+ return;
57
+ seen.add(path);
58
+ queue.push({ path });
59
+ };
60
+ // The throttle: every request start claims the next slot on a shared
61
+ // timeline, so starts are at least `interval` apart no matter how many
62
+ // workers are running.
63
+ let nextSlot = 0;
64
+ async function pace() {
65
+ if (interval <= 0)
66
+ return;
67
+ const now = Date.now();
68
+ const slot = Math.max(now, nextSlot);
69
+ nextSlot = slot + interval;
70
+ if (slot > now)
71
+ await wait(slot - now);
72
+ }
73
+ async function fetchFollowingRedirects(path) {
74
+ let url = new URL(path, originUrl);
75
+ for (let hop = 0;; hop++) {
76
+ await pace();
77
+ const response = await transport.fetch(new Request(url, { headers: { accept: "text/html,*/*", [hintHeader]: "1" } }));
78
+ const location = response.headers.get("location");
79
+ if (response.status < 300 || response.status >= 400 || !location)
80
+ return response;
81
+ if (hop >= maxRedirects) {
82
+ throw new Error(`Redirect chain from ${path} exceeded ${maxRedirects} hops`);
83
+ }
84
+ const target = new URL(location, url);
85
+ if (target.origin !== originUrl.origin) {
86
+ // an external redirect terminates the chain; the page becomes a stub
87
+ return response;
88
+ }
89
+ url = target;
90
+ }
91
+ }
92
+ async function renderPage(entry) {
93
+ let response;
94
+ let error;
95
+ for (let attempt = 0; attempt <= retries; attempt++) {
96
+ if (attempt > 0)
97
+ await wait(retryDelay);
98
+ try {
99
+ response = await fetchFollowingRedirects(entry.path);
100
+ error = undefined;
101
+ if (response.status < 500)
102
+ break; // retry only what might heal
103
+ }
104
+ catch (thrown) {
105
+ error = thrown;
106
+ }
107
+ }
108
+ if (!response || error !== undefined || response.status >= 400) {
109
+ // The message carries provenance: a 404 is usually a broken link, and
110
+ // the fix lives on the pages that carry it, not at the missing route.
111
+ const from = referrersOf(entry.path);
112
+ const linked = from.length ? ` (linked from ${from.join(", ")})` : "";
113
+ const failure = error !== undefined
114
+ ? new Error(`Prerendering ${entry.path} failed${linked}: ${describe(error)}`, {
115
+ cause: error
116
+ })
117
+ : new Error(`Prerendering ${entry.path} answered ${response.status}${linked}`);
118
+ if (failOnError)
119
+ throw failure;
120
+ // referrers are completed once the crawl settles: pages still in
121
+ // flight may yet link here, and the report should name all of them
122
+ skipped.push({ path: entry.path, error: failure, referrers: [] });
123
+ return;
124
+ }
125
+ const filename = entry.filename ?? outputFilename(entry.path, autoSubfolderIndex);
126
+ let html;
127
+ const contentType = response.headers.get("content-type") ?? "";
128
+ const isHTML = contentType.includes("text/html");
129
+ const location = response.headers.get("location");
130
+ if (location && response.status >= 300 && response.status < 400) {
131
+ // external redirect (internal ones were followed): a meta-refresh stub
132
+ // keeps the path working on hosts without redirect support
133
+ html = redirectStub(location);
134
+ }
135
+ else {
136
+ html = await response.text();
137
+ }
138
+ // Emission is policy, rendering is not: an unemitted page has still
139
+ // fully executed (integration capture happened server-side) and its
140
+ // links still feed the crawl — it just leaves no HTML file behind to
141
+ // shadow a live server's SSR of the route.
142
+ const emitted = entry.emit ?? (typeof emitPages === "function" ? emitPages(entry.path) : emitPages);
143
+ if (emitted)
144
+ await writeOutput(outDir, filename, html);
145
+ if (isHTML) {
146
+ const pageUrl = new URL(entry.path, originUrl);
147
+ if (crawlLinks) {
148
+ for (const path of extractLinks(html, pageUrl))
149
+ discovered(path, entry.path);
150
+ }
151
+ }
152
+ const hints = response.headers.get(hintHeader);
153
+ if (hints) {
154
+ for (const hint of hints.split(",")) {
155
+ const path = normalizeLink(hint.trim(), originUrl, originUrl.origin);
156
+ if (path !== undefined)
157
+ discovered(path, entry.path);
158
+ }
159
+ }
160
+ const page = {
161
+ path: entry.path,
162
+ referrers: referrersOf(entry.path),
163
+ filename,
164
+ emitted,
165
+ response,
166
+ html
167
+ };
168
+ rendered.push(page);
169
+ if (onRendered)
170
+ await onRendered(page);
171
+ }
172
+ try {
173
+ for (const integration of integrations)
174
+ await integration.setup?.(context);
175
+ // A worker pool over a queue that grows while it drains: discoveries
176
+ // enqueue, idle workers pick them up, the run resolves when the last
177
+ // worker goes idle against an empty queue.
178
+ await new Promise((resolve, reject) => {
179
+ let active = 0;
180
+ let failed = false;
181
+ const pump = () => {
182
+ if (failed)
183
+ return;
184
+ while (active < concurrency && queue.length) {
185
+ const entry = queue.shift();
186
+ active++;
187
+ renderPage(entry).then(() => {
188
+ active--;
189
+ pump();
190
+ }, failure => {
191
+ failed = true;
192
+ reject(failure);
193
+ });
194
+ }
195
+ if (active === 0 && queue.length === 0)
196
+ resolve();
197
+ };
198
+ pump();
199
+ });
200
+ for (const miss of skipped)
201
+ miss.referrers = referrersOf(miss.path);
202
+ for (const integration of integrations)
203
+ await integration.teardown?.(context);
204
+ for (const file of emitted) {
205
+ await writeOutput(outDir, file.filename, file.contents);
206
+ }
207
+ }
208
+ finally {
209
+ await transport.close?.();
210
+ }
211
+ return { pages: rendered, files: emitted, skipped };
212
+ }
213
+ const describe = (error) => (error instanceof Error ? error.message : String(error));
214
+ function redirectStub(location) {
215
+ const target = String(location).replace(/"/g, "&quot;");
216
+ return `<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0; url=${target}"><link rel="canonical" href="${target}"></head></html>`;
217
+ }
218
+ async function writeOutput(outDir, filename, contents) {
219
+ const target = join(outDir, filename);
220
+ await mkdir(dirname(target), { recursive: true });
221
+ await writeFile(target, contents);
222
+ }
@@ -0,0 +1,35 @@
1
+ export interface FileRoutePagesOptions {
2
+ /** Project root the route dir resolves against. @default process.cwd() */
3
+ root?: string;
4
+ /** Route directory, mirroring `fileRoutes({ dir })`. @default "src/routes" */
5
+ dir?: string;
6
+ /** Route file extensions, mirroring `fileRoutes({ extensions })`. @default ["js", "jsx", "ts", "tsx"] */
7
+ extensions?: string[];
8
+ }
9
+ /** The subset of a `filesystem-routing` manifest entry this module reads. */
10
+ export interface RouteEntryLike {
11
+ path: string;
12
+ page?: boolean;
13
+ }
14
+ /**
15
+ * The statically addressable page paths of a route manifest: pages whose
16
+ * path has no parameter or catch-all segment, with `(group)` segments
17
+ * stripped the way emission adapters strip them. Pure — the seam the
18
+ * plugin and tests share.
19
+ */
20
+ export declare function staticRoutePaths(entries: readonly RouteEntryLike[]): string[];
21
+ /**
22
+ * A `pages` source scanning a `filesystem-routing` route directory for its
23
+ * static pages. The package is resolved from the project root (it is the
24
+ * APP's dependency), loaded lazily so projects without it pay nothing.
25
+ *
26
+ * ```ts
27
+ * prerender({ pages: fileRoutePages({ dir: "src/pages" }) })
28
+ * ```
29
+ *
30
+ * The `prerender()` plugin applies this automatically (`fileRoutes: true`,
31
+ * the default) when the package and the route directory exist.
32
+ */
33
+ export declare function fileRoutePages(options?: FileRoutePagesOptions): () => Promise<string[]>;
34
+ /** Whether `filesystem-routing` resolves from the project (for the plugin's auto mode). */
35
+ export declare function hasFileSystemRouting(root: string): boolean;
@@ -0,0 +1,84 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ // Route-manifest seeding: the static pages a file-system router declares
10
+ // are known before anything renders, so they seed the crawl directly —
11
+ // a page nothing links to still gets built, and the crawl starts wide
12
+ // instead of unwinding from `/`. Dynamic routes (`/posts/:slug`,
13
+ // `/*404`) are left to link discovery: only a render knows their values.
14
+ import { createRequire } from "node:module";
15
+ import path from "node:path";
16
+ import { pathToFileURL } from "node:url";
17
+ /**
18
+ * The statically addressable page paths of a route manifest: pages whose
19
+ * path has no parameter or catch-all segment, with `(group)` segments
20
+ * stripped the way emission adapters strip them. Pure — the seam the
21
+ * plugin and tests share.
22
+ */
23
+ export function staticRoutePaths(entries) {
24
+ const paths = new Set();
25
+ for (const entry of entries) {
26
+ if (!entry.page)
27
+ continue;
28
+ const segments = entry.path.split("/").filter(segment => segment !== "");
29
+ if (segments.some(segment => segment.startsWith(":") || segment.startsWith("*")))
30
+ continue;
31
+ const concrete = segments.filter(segment => !/^\(.*\)$/.test(segment));
32
+ paths.add("/" + concrete.join("/"));
33
+ }
34
+ return [...paths];
35
+ }
36
+ /**
37
+ * A `pages` source scanning a `filesystem-routing` route directory for its
38
+ * static pages. The package is resolved from the project root (it is the
39
+ * APP's dependency), loaded lazily so projects without it pay nothing.
40
+ *
41
+ * ```ts
42
+ * prerender({ pages: fileRoutePages({ dir: "src/pages" }) })
43
+ * ```
44
+ *
45
+ * The `prerender()` plugin applies this automatically (`fileRoutes: true`,
46
+ * the default) when the package and the route directory exist.
47
+ */
48
+ export function fileRoutePages(options = {}) {
49
+ const root = options.root ?? process.cwd();
50
+ const dir = path.resolve(root, options.dir ?? "src/routes");
51
+ const extensions = options.extensions ?? ["js", "jsx", "ts", "tsx"];
52
+ return async () => {
53
+ const routing = await loadFileSystemRouting(root);
54
+ const router = new routing.PageFileSystemRouter({ dir, extensions });
55
+ return staticRoutePaths(await router.getRoutes());
56
+ };
57
+ }
58
+ /** Whether `filesystem-routing` resolves from the project (for the plugin's auto mode). */
59
+ export function hasFileSystemRouting(root) {
60
+ try {
61
+ resolveFileSystemRouting(root);
62
+ return true;
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ }
68
+ function resolveFileSystemRouting(root) {
69
+ // from the project first (the app's copy), then from here (a test or a
70
+ // setup that installed it alongside the plugin)
71
+ for (const from of [path.join(root, "package.json"), import.meta.url]) {
72
+ try {
73
+ return createRequire(from).resolve("filesystem-routing");
74
+ }
75
+ catch {
76
+ // try the next base
77
+ }
78
+ }
79
+ throw new Error(`fileRoutePages needs the "filesystem-routing" package, which could not be resolved from ${root}.`);
80
+ }
81
+ async function loadFileSystemRouting(root) {
82
+ const resolved = resolveFileSystemRouting(root);
83
+ return (await import(__rewriteRelativeImportExtension(pathToFileURL(resolved).href)));
84
+ }
@@ -0,0 +1,5 @@
1
+ export { runPrerender } from "./crawl.ts";
2
+ export type { RunOptions } from "./crawl.ts";
3
+ export { extractLinks, normalizeLink, normalizePath } from "./links.ts";
4
+ export { outputFilename } from "./output.ts";
5
+ export type { EmittedFile, PageEntry, PagesSource, PrerenderContext, PrerenderIntegration, PrerenderMode, PrerenderOptions, PrerenderResult, RenderedPage, SkippedPage, Transport } from "./types.ts";
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { runPrerender } from "./crawl.js";
2
+ export { extractLinks, normalizeLink, normalizePath } from "./links.js";
3
+ export { outputFilename } from "./output.js";
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Link discovery: which hrefs in a rendered page name more pages of this
3
+ * site. The rules here are correctness fixes other prerenderers earned one
4
+ * bug report at a time — resolve relative hrefs against the PAGE's URL
5
+ * (not the origin), honor <base href>, strip queries and fragments before
6
+ * dedupe, and never leave the origin.
7
+ */
8
+ /**
9
+ * Extracts the crawlable same-origin route paths from a page's HTML.
10
+ * Returned paths are normalized (`/about`, no query, no fragment, no
11
+ * trailing slash except the root) and deduped.
12
+ */
13
+ export declare function extractLinks(html: string, pageUrl: URL): string[];
14
+ /**
15
+ * Resolves one href to a normalized same-origin path, or undefined when it
16
+ * is not a page of this site (foreign origin, unparseable).
17
+ */
18
+ export declare function normalizeLink(href: string, base: URL, origin: string): string | undefined;
19
+ /**
20
+ * One spelling per page: query and fragment never reach here (URL parsing
21
+ * split them off), the trailing slash is dropped (except the root), and
22
+ * percent-encoding is left exactly as the URL parser produced it.
23
+ */
24
+ export declare function normalizePath(pathname: string): string;
package/dist/links.js ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Link discovery: which hrefs in a rendered page name more pages of this
3
+ * site. The rules here are correctness fixes other prerenderers earned one
4
+ * bug report at a time — resolve relative hrefs against the PAGE's URL
5
+ * (not the origin), honor <base href>, strip queries and fragments before
6
+ * dedupe, and never leave the origin.
7
+ */
8
+ const LINK_PATTERN = /<a\s[^>]*?href\s*=\s*(?:"([^"]*)"|'([^']*)')/gis;
9
+ const BASE_PATTERN = /<base\s[^>]*?href\s*=\s*(?:"([^"]*)"|'([^']*)')/is;
10
+ /** Schemes that are never pages. */
11
+ const NON_PAGE_SCHEME = /^(?:mailto|tel|javascript|data|blob|about):/i;
12
+ /**
13
+ * Extracts the crawlable same-origin route paths from a page's HTML.
14
+ * Returned paths are normalized (`/about`, no query, no fragment, no
15
+ * trailing slash except the root) and deduped.
16
+ */
17
+ export function extractLinks(html, pageUrl) {
18
+ // <base href> shifts what relative hrefs resolve against, exactly as the
19
+ // browser would resolve them.
20
+ const baseMatch = BASE_PATTERN.exec(html);
21
+ let base = pageUrl;
22
+ if (baseMatch) {
23
+ const href = baseMatch[1] ?? baseMatch[2] ?? "";
24
+ try {
25
+ base = new URL(href, pageUrl);
26
+ }
27
+ catch {
28
+ // an unparseable <base> is ignored, like a browser ignores it
29
+ }
30
+ }
31
+ const found = new Set();
32
+ for (const match of html.matchAll(LINK_PATTERN)) {
33
+ const href = (match[1] ?? match[2] ?? "").trim();
34
+ if (!href || NON_PAGE_SCHEME.test(href))
35
+ continue;
36
+ const path = normalizeLink(href, base, pageUrl.origin);
37
+ if (path !== undefined)
38
+ found.add(path);
39
+ }
40
+ return [...found];
41
+ }
42
+ /**
43
+ * Resolves one href to a normalized same-origin path, or undefined when it
44
+ * is not a page of this site (foreign origin, unparseable).
45
+ */
46
+ export function normalizeLink(href, base, origin) {
47
+ let url;
48
+ try {
49
+ url = new URL(href, base);
50
+ }
51
+ catch {
52
+ return undefined;
53
+ }
54
+ if (url.origin !== origin)
55
+ return undefined;
56
+ return normalizePath(url.pathname);
57
+ }
58
+ /**
59
+ * One spelling per page: query and fragment never reach here (URL parsing
60
+ * split them off), the trailing slash is dropped (except the root), and
61
+ * percent-encoding is left exactly as the URL parser produced it.
62
+ */
63
+ export function normalizePath(pathname) {
64
+ if (pathname === "" || pathname === "/")
65
+ return "/";
66
+ return pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
67
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Where a route's HTML lands on disk. Static hosts resolve `/about` by
3
+ * probing `about/index.html` (and some `about.html`) — `autoSubfolderIndex`
4
+ * picks which convention the output follows. A path that already names a
5
+ * file (`/sitemap.xml`) is written verbatim.
6
+ */
7
+ export declare function outputFilename(path: string, autoSubfolderIndex: boolean): string;
package/dist/output.js ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Where a route's HTML lands on disk. Static hosts resolve `/about` by
3
+ * probing `about/index.html` (and some `about.html`) — `autoSubfolderIndex`
4
+ * picks which convention the output follows. A path that already names a
5
+ * file (`/sitemap.xml`) is written verbatim.
6
+ */
7
+ export function outputFilename(path, autoSubfolderIndex) {
8
+ if (path === "/")
9
+ return "index.html";
10
+ const trimmed = path.replace(/^\/+/, "");
11
+ // a final segment with an extension is a file, not a route
12
+ const lastSegment = trimmed.slice(trimmed.lastIndexOf("/") + 1);
13
+ if (lastSegment.includes("."))
14
+ return trimmed;
15
+ return autoSubfolderIndex ? `${trimmed}/index.html` : `${trimmed}.html`;
16
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * The engine's whole contract with the app is one function: something
3
+ * fetch-shaped it can throw `Request`s at. In-process dispatch against a
4
+ * built server entry and loopback HTTP against a preview server are both
5
+ * just transports; the crawler never knows which one it is driving.
6
+ */
7
+ export interface Transport {
8
+ fetch(request: Request): Promise<Response>;
9
+ /** Released when the run finishes, success or failure. */
10
+ close?(): void | Promise<void>;
11
+ }
12
+ /** A page to prerender: a route path, optionally with an explicit output filename. */
13
+ export interface PageEntry {
14
+ path: string;
15
+ /**
16
+ * Output file relative to the output directory. Defaults to the path's
17
+ * natural mapping (`/` -> `index.html`, `/about` -> `about/index.html`
18
+ * under `autoSubfolderIndex`, `/sitemap.xml` -> `sitemap.xml`). The
19
+ * escape hatch for host conventions: `{ path: "/404", filename:
20
+ * "404.html" }`.
21
+ */
22
+ filename?: string;
23
+ /**
24
+ * Whether this page's HTML is written to disk, overriding the run-level
25
+ * `emitPages` policy for this entry. Rendering is unaffected either way:
26
+ * an unemitted page still executes fully (its links are still crawled,
27
+ * its captured data still baked) — emission only decides whether the
28
+ * HTML becomes a static file, which on most hosts SHADOWS live SSR for
29
+ * that route.
30
+ */
31
+ emit?: boolean;
32
+ }
33
+ /**
34
+ * Seed pages: a list, or a function producing one — the seam route
35
+ * sources (filesystem routing manifests, CMS queries) feed. Anything
36
+ * shaped `{ path }` is accepted alongside plain strings.
37
+ */
38
+ export type PagesSource = Array<string | PageEntry> | (() => Array<string | PageEntry> | Promise<Array<string | PageEntry>>);
39
+ /** A page the engine rendered (and, when `emitted`, wrote). */
40
+ export interface RenderedPage {
41
+ /** The normalized route path (`/about`), origin and query stripped. */
42
+ path: string;
43
+ /**
44
+ * The pages whose links (or hint headers) led here, as known when this
45
+ * page rendered; empty for seeds. Provenance for diagnostics — "why was
46
+ * this route crawled" — and for sitemap tooling.
47
+ */
48
+ referrers: string[];
49
+ /** The page's output file (written only when `emitted`), relative to the output directory. */
50
+ filename: string;
51
+ /** Whether the HTML was written to disk (see `emitPages` / `PageEntry.emit`). */
52
+ emitted: boolean;
53
+ /** The response the transport answered with (body consumed). */
54
+ response: Response;
55
+ /** The rendered HTML. */
56
+ html: string;
57
+ }
58
+ /** An extra artifact an integration ships alongside the rendered pages. */
59
+ export interface EmittedFile {
60
+ /** Output path relative to the output directory. */
61
+ filename: string;
62
+ contents: string | Uint8Array;
63
+ }
64
+ /**
65
+ * What the output is for. The distinction every policy downstream keys on:
66
+ *
67
+ * - `"static"`: the written files ARE the deployment (SSG). Every rendered
68
+ * page is written; anything the crawl did not produce does not exist at
69
+ * runtime, so integrations treat gaps as errors.
70
+ * - `"hybrid"`: a live server is deployed alongside. The crawl is a
71
+ * build-time pass (data baking, selected pages); rendered pages are not
72
+ * written by default because a static HTML file shadows live SSR of the
73
+ * same route on most hosts, and gaps fall back to the server.
74
+ */
75
+ export type PrerenderMode = "static" | "hybrid";
76
+ /**
77
+ * The context integrations set up against. `emitFile` is the channel for
78
+ * artifacts produced during the crawl (payload extraction, captured
79
+ * server-function results): queued during the run, written with the pages.
80
+ */
81
+ export interface PrerenderContext {
82
+ mode: PrerenderMode;
83
+ origin: string;
84
+ outDir: string;
85
+ emitFile(file: EmittedFile): void;
86
+ }
87
+ /** A hook bundle participating in the run without owning any of it. */
88
+ export interface PrerenderIntegration {
89
+ name: string;
90
+ /** Before the first page renders. */
91
+ setup?(context: PrerenderContext): void | Promise<void>;
92
+ /**
93
+ * After the last page rendered, before any file is written. Throwing
94
+ * here fails the run — the place for an integration to verify the crawl
95
+ * produced everything its runtime half will need.
96
+ */
97
+ teardown?(context: PrerenderContext): void | Promise<void>;
98
+ /**
99
+ * A module specifier the bundler integration imports for side effects
100
+ * into the CLIENT build when this integration is active — how an
101
+ * integration ships runtime behavior (a transport interceptor, a
102
+ * posture switch) without the app wiring it by hand. Not consumed by the
103
+ * engine itself; reserved for bundler plugins built on it.
104
+ */
105
+ client?: string;
106
+ }
107
+ export interface PrerenderOptions {
108
+ /** See `PrerenderMode`. Decides the `emitPages` default. @default "static" */
109
+ mode?: PrerenderMode;
110
+ /** Seed pages. @default ["/"] */
111
+ pages?: PagesSource;
112
+ /**
113
+ * Extract same-origin links from rendered HTML and prerender them too.
114
+ * The only way dynamic routes are discovered without explicit seeding.
115
+ * @default true
116
+ */
117
+ crawlLinks?: boolean;
118
+ /**
119
+ * Response header a rendered page names additional paths on
120
+ * (comma-separated) — the route the data lives on announces the routes
121
+ * built from it. @default "x-prerender"
122
+ */
123
+ hintHeader?: string;
124
+ /** Drops a discovered path before it is fetched. */
125
+ filter?(path: string): boolean;
126
+ /** Pages in flight at once. @default 8 */
127
+ concurrency?: number;
128
+ /**
129
+ * Minimum milliseconds between the starts of consecutive requests, across
130
+ * all workers — a throttle for renders that call rate-limited external
131
+ * APIs. `concurrency` bounds how many are in flight; `interval` bounds how
132
+ * fast new ones begin. @default 0
133
+ */
134
+ interval?: number;
135
+ /** Re-fetch attempts for a failed page. @default 2 */
136
+ retries?: number;
137
+ /** Milliseconds between attempts. @default 500 */
138
+ retryDelay?: number;
139
+ /**
140
+ * Whether a page that still fails after retries fails the run. Pages
141
+ * skipped by `failOnError: false` are reported in the result.
142
+ * @default true
143
+ */
144
+ failOnError?: boolean;
145
+ /** Internal redirect hops followed for one page. @default 5 */
146
+ maxRedirects?: number;
147
+ /**
148
+ * Whether rendered pages are written to disk: a blanket policy, or a
149
+ * per-path predicate; per-entry `emit` flags override it either way. A
150
+ * page excluded from emission still renders fully — link discovery and
151
+ * integration capture (build-time data baking) happen regardless — it
152
+ * just produces no HTML file. Turn this off (or scope it) when a live
153
+ * server keeps serving the crawled routes: a written HTML file is served
154
+ * ahead of SSR by most hosts, freezing the route.
155
+ * @default true in `static` mode, false in `hybrid`
156
+ */
157
+ emitPages?: boolean | ((path: string) => boolean);
158
+ /** `/about` -> `about/index.html` (true) or `about.html` (false). @default true */
159
+ autoSubfolderIndex?: boolean;
160
+ /** Origin requests are minted under. @default "http://localhost" */
161
+ origin?: string;
162
+ /** Observes every written page — the seam for sitemaps and post-processing. */
163
+ onRendered?(page: RenderedPage): void | Promise<void>;
164
+ integrations?: PrerenderIntegration[];
165
+ }
166
+ /** A page that failed after retries and was left out of the output. */
167
+ export interface SkippedPage {
168
+ path: string;
169
+ error: unknown;
170
+ /** The pages that linked here — where to look for the broken link. */
171
+ referrers: string[];
172
+ }
173
+ /** What a finished run reports. */
174
+ export interface PrerenderResult {
175
+ pages: RenderedPage[];
176
+ /** Extra files integrations emitted. */
177
+ files: EmittedFile[];
178
+ /** Paths that failed and were skipped (only with `failOnError: false`). */
179
+ skipped: SkippedPage[];
180
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/vite.d.ts ADDED
@@ -0,0 +1,42 @@
1
+ import type { Plugin } from "vite";
2
+ import type { FileRoutePagesOptions } from "./file-routes.ts";
3
+ import type { PrerenderOptions } from "./types.ts";
4
+ export { fileRoutePages, staticRoutePaths } from "./file-routes.ts";
5
+ export type { FileRoutePagesOptions, RouteEntryLike } from "./file-routes.ts";
6
+ export type * from "./types.ts";
7
+ /** The `import.meta.env` key the plugin defines with the build's `PrerenderMode`. */
8
+ export declare const PRERENDER_MODE_ENV = "PRERENDER_MODE";
9
+ export interface PrerenderPluginOptions extends PrerenderOptions {
10
+ /**
11
+ * Path (relative to the project root) of the built module exporting the
12
+ * request handler — `handleRequest`, `fetch`, or `default.fetch`.
13
+ * Defaults to `server.js` inside the `ssr` environment's output directory.
14
+ */
15
+ serverEntry?: string;
16
+ /**
17
+ * Seed the crawl with the static pages of the project's
18
+ * `filesystem-routing` route directory, merged with `pages`. Dynamic
19
+ * routes (`/posts/:slug`) are still discovered by links — only a render
20
+ * knows their values.
21
+ *
22
+ * `true` (default) applies when the package and `src/routes` exist and
23
+ * is silently skipped otherwise; pass options to mirror a customized
24
+ * `fileRoutes({ dir, extensions })` (then a missing package is an error);
25
+ * `false` disables it.
26
+ */
27
+ fileRoutes?: boolean | FileRoutePagesOptions;
28
+ }
29
+ /**
30
+ * Prerenders the app at build time: crawls the built server handler from
31
+ * `pages` (default `["/"]`, plus the file-routed static pages, plus every
32
+ * same-origin link discovered along the way) and writes each page's HTML —
33
+ * and whatever the integrations emit — into the client output.
34
+ *
35
+ * ```ts
36
+ * import { prerender } from "prerender-crawler/vite";
37
+ * export default defineConfig({
38
+ * plugins: [framework(), prerender({ mode: "static", integrations: [...] })]
39
+ * });
40
+ * ```
41
+ */
42
+ export declare function prerender(options?: PrerenderPluginOptions): Plugin;
package/dist/vite.js ADDED
@@ -0,0 +1,140 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ // The Vite plugin: `prerender()` from `prerender-crawler/vite`.
10
+ //
11
+ // Framework-agnostic by construction. It knows three things about the app:
12
+ // that `vite build` produces a client output directory, that some
13
+ // environment's output includes a module exporting a fetch-shaped handler
14
+ // (`handleRequest` or `fetch`: Request in, Response out), and — optionally
15
+ // — that a `filesystem-routing` directory names the static pages. Anything
16
+ // framework-specific rides along as an integration (see
17
+ // `PrerenderIntegration`), the same seam the engine exposes to non-Vite
18
+ // drivers.
19
+ //
20
+ // Responsibilities, all build-only:
21
+ //
22
+ // 1. Orchestration: a `buildApp` hook (declaring one claims the app build;
23
+ // Vite's build-everything fallback stands down) that builds the
24
+ // remaining environments, imports the built server handler, and drives
25
+ // the crawl against it in-process — no HTTP server, no subprocess.
26
+ // Pages and integration-emitted files land in the client output.
27
+ // 2. Posture: `import.meta.env.PRERENDER_MODE` is defined in every build
28
+ // environment so runtime code can ask "am I a prerendered build, and of
29
+ // which kind" — the one bit of client-side knowledge integrations need.
30
+ // Absent (dev, or a build without this plugin) means "live".
31
+ // 3. Seeding: the static pages of a `filesystem-routing` route directory
32
+ // seed the crawl automatically, so a page nothing links to still builds.
33
+ import { existsSync } from "node:fs";
34
+ import path from "node:path";
35
+ import { pathToFileURL } from "node:url";
36
+ import { runPrerender } from "./crawl.js";
37
+ import { fileRoutePages, hasFileSystemRouting } from "./file-routes.js";
38
+ export { fileRoutePages, staticRoutePaths } from "./file-routes.js";
39
+ /** The `import.meta.env` key the plugin defines with the build's `PrerenderMode`. */
40
+ export const PRERENDER_MODE_ENV = "PRERENDER_MODE";
41
+ /**
42
+ * Prerenders the app at build time: crawls the built server handler from
43
+ * `pages` (default `["/"]`, plus the file-routed static pages, plus every
44
+ * same-origin link discovered along the way) and writes each page's HTML —
45
+ * and whatever the integrations emit — into the client output.
46
+ *
47
+ * ```ts
48
+ * import { prerender } from "prerender-crawler/vite";
49
+ * export default defineConfig({
50
+ * plugins: [framework(), prerender({ mode: "static", integrations: [...] })]
51
+ * });
52
+ * ```
53
+ */
54
+ export function prerender(options = {}) {
55
+ const mode = options.mode ?? "static";
56
+ return {
57
+ name: "prerender",
58
+ apply: "build",
59
+ config() {
60
+ // Vite merges `import.meta.env.*` defines into the whole-object form
61
+ // too, so `import.meta.env.PRERENDER_MODE` and `import.meta.env`
62
+ // destructuring both see it. Every environment gets it: the server
63
+ // build may legitimately ask as well.
64
+ return { define: { [`import.meta.env.${PRERENDER_MODE_ENV}`]: JSON.stringify(mode) } };
65
+ },
66
+ buildApp: {
67
+ order: "post",
68
+ async handler(builder) {
69
+ // Build whatever is not yet built, in definition order. Frameworks
70
+ // that build the client first at normal order have already done so
71
+ // here; declaring this hook suppressed the fallbacks that would
72
+ // otherwise have built the rest.
73
+ for (const environment of Object.values(builder.environments)) {
74
+ if (!environment.isBuilt)
75
+ await builder.build(environment);
76
+ }
77
+ const root = builder.config.root;
78
+ const logger = builder.config.logger;
79
+ const clientOut = path.resolve(root, builder.environments.client?.config.build.outDir ?? "dist/client");
80
+ const ssrOut = path.resolve(root, builder.environments.ssr?.config.build.outDir ?? "dist/server");
81
+ const entry = options.serverEntry
82
+ ? path.resolve(root, options.serverEntry)
83
+ : path.join(ssrOut, "server.js");
84
+ const handleRequest = await loadHandler(entry);
85
+ const routeSeeds = await fileRouteSeeds(root, options.fileRoutes);
86
+ const { serverEntry: _entry, fileRoutes: _fileRoutes, pages, ...crawl } = options;
87
+ const result = await runPrerender({
88
+ ...crawl,
89
+ mode,
90
+ pages: async () => [
91
+ ...(typeof pages === "function" ? await pages() : (pages ?? ["/"])),
92
+ ...routeSeeds
93
+ ],
94
+ transport: { fetch: request => handleRequest(request) },
95
+ outDir: clientOut
96
+ });
97
+ const written = result.pages.filter(page => page.emitted).length;
98
+ const seeded = routeSeeds.length ? `, ${routeSeeds.length} seeded from file routes` : "";
99
+ logger.info(`[prerender] rendered ${result.pages.length} page(s) (${written} written${seeded}), ` +
100
+ `${result.files.length} file(s) emitted -> ${path.relative(root, clientOut)}`);
101
+ for (const miss of result.skipped) {
102
+ logger.warn(`[prerender] skipped ${miss.path}: ${describe(miss.error)}`);
103
+ }
104
+ }
105
+ }
106
+ };
107
+ }
108
+ async function loadHandler(entry) {
109
+ let serverModule;
110
+ try {
111
+ serverModule = await import(__rewriteRelativeImportExtension(pathToFileURL(entry).href));
112
+ }
113
+ catch (cause) {
114
+ throw new Error(`prerender could not import the built server entry at ${entry}. Prerendering renders ` +
115
+ `pages through the server build — make sure an SSR build runs (the server build is a ` +
116
+ `build-time tool here; it need not be deployed) or point \`serverEntry\` at a module ` +
117
+ `exporting handleRequest/fetch.`, { cause });
118
+ }
119
+ const handler = serverModule.handleRequest ??
120
+ serverModule.fetch ??
121
+ serverModule.default?.fetch;
122
+ if (typeof handler !== "function") {
123
+ throw new Error(`The server entry at ${entry} exports none of handleRequest, fetch, or default.fetch — ` +
124
+ `prerender needs a Request -> Response handler to render pages through.`);
125
+ }
126
+ return handler;
127
+ }
128
+ async function fileRouteSeeds(root, option) {
129
+ if (option === false)
130
+ return [];
131
+ const explicit = typeof option === "object" ? option : undefined;
132
+ if (!explicit) {
133
+ // auto mode: only when the project actually uses file routing
134
+ const dir = path.resolve(root, "src/routes");
135
+ if (!hasFileSystemRouting(root) || !existsSync(dir))
136
+ return [];
137
+ }
138
+ return fileRoutePages({ root, ...explicit })();
139
+ }
140
+ const describe = (error) => (error instanceof Error ? error.message : String(error));
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "prerender-crawler",
3
+ "version": "0.1.0",
4
+ "description": "Framework-agnostic build-time prerendering. Point it at anything fetch-shaped (Request in, Response out) and it crawls the site into static HTML: seeds, link discovery, header hints, redirects, retries, throttling, and an integration seam for build-time data capture. Ships a Vite plugin at prerender-crawler/vite.",
5
+ "license": "MIT",
6
+ "author": "Ryan Carniato",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/solidjs/prerender-crawler",
10
+ "directory": "packages/crawler"
11
+ },
12
+ "homepage": "https://github.com/solidjs/prerender-crawler/tree/main/packages/crawler#readme",
13
+ "bugs": "https://github.com/solidjs/prerender-crawler/issues",
14
+ "keywords": [
15
+ "prerender",
16
+ "prerendering",
17
+ "static site generation",
18
+ "ssg",
19
+ "crawler",
20
+ "build-time",
21
+ "vite",
22
+ "vite-plugin"
23
+ ],
24
+ "type": "module",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "default": "./dist/index.js"
29
+ },
30
+ "./vite": {
31
+ "types": "./dist/vite.d.ts",
32
+ "default": "./dist/vite.js"
33
+ }
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "LICENSE",
38
+ "README.md"
39
+ ],
40
+ "engines": {
41
+ "node": ">=20"
42
+ },
43
+ "peerDependencies": {
44
+ "filesystem-routing": ">=0.2.0",
45
+ "vite": "^7.0.0 || ^8.0.0"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "filesystem-routing": {
49
+ "optional": true
50
+ },
51
+ "vite": {
52
+ "optional": true
53
+ }
54
+ },
55
+ "devDependencies": {
56
+ "@types/node": "^22.0.0",
57
+ "filesystem-routing": "0.2.1",
58
+ "typescript": "^5.8.0",
59
+ "vite": "^8.0.0",
60
+ "vitest": "^4.0.0"
61
+ },
62
+ "scripts": {
63
+ "build": "rm -rf dist && tsc",
64
+ "test": "vitest run",
65
+ "test:watch": "vitest"
66
+ }
67
+ }