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 +21 -0
- package/README.md +122 -0
- package/dist/crawl.d.ts +12 -0
- package/dist/crawl.js +222 -0
- package/dist/file-routes.d.ts +35 -0
- package/dist/file-routes.js +84 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/links.d.ts +24 -0
- package/dist/links.js +67 -0
- package/dist/output.d.ts +7 -0
- package/dist/output.js +16 -0
- package/dist/types.d.ts +180 -0
- package/dist/types.js +1 -0
- package/dist/vite.d.ts +42 -0
- package/dist/vite.js +140 -0
- package/package.json +67 -0
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
|
package/dist/crawl.d.ts
ADDED
|
@@ -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, """);
|
|
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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
package/dist/links.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/output.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -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
|
+
}
|