prerender-crawler 0.1.0 → 0.2.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/README.md +152 -35
- package/dist/announce.d.ts +39 -0
- package/dist/announce.js +41 -0
- package/dist/cli-main.d.ts +7 -0
- package/dist/cli-main.js +181 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +6 -0
- package/dist/crawl.js +150 -60
- package/dist/index.d.ts +13 -2
- package/dist/index.js +6 -1
- package/dist/links.d.ts +25 -9
- package/dist/links.js +33 -11
- package/dist/redirects.d.ts +33 -0
- package/dist/redirects.js +33 -0
- package/dist/report.d.ts +55 -0
- package/dist/report.js +46 -0
- package/dist/sitemap.d.ts +56 -0
- package/dist/sitemap.js +88 -0
- package/dist/transports.d.ts +35 -0
- package/dist/transports.js +74 -0
- package/dist/types.d.ts +69 -3
- package/dist/vite.d.ts +10 -18
- package/dist/vite.js +29 -70
- package/package.json +8 -6
- package/dist/file-routes.d.ts +0 -35
- package/dist/file-routes.js +0 -84
package/README.md
CHANGED
|
@@ -2,7 +2,44 @@
|
|
|
2
2
|
|
|
3
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
4
|
|
|
5
|
-
Ships as an engine (`prerender-crawler`)
|
|
5
|
+
Ships as an engine (`prerender-crawler`), a Vite plugin built on it (`prerender-crawler/vite`), a CLI for everything else, and one tiny module for the app's own server (`prerender-crawler/announce`) so it can tell the crawl which pages it has.
|
|
6
|
+
|
|
7
|
+
## CLI
|
|
8
|
+
|
|
9
|
+
Prerender any running server, or any module exporting a request handler, with no framework integration at all:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
# a running server — a framework's preview server, a container, a staging deploy
|
|
13
|
+
npx prerender-crawler http://localhost:3000 --out dist
|
|
14
|
+
|
|
15
|
+
# a built server module (handleRequest, fetch, or default.fetch), in-process
|
|
16
|
+
npx prerender-crawler dist/server/server.js --out dist/client --redirects
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
prerender-crawler <target> --out <dir> [options]
|
|
21
|
+
-o, --out <dir> Output directory (required)
|
|
22
|
+
-p, --page <path> Seed page; repeatable. Default: /
|
|
23
|
+
-m, --mode <mode> static (default) or hybrid
|
|
24
|
+
-c, --concurrency <n> Pages in flight at once. Default: 8
|
|
25
|
+
-i, --interval <ms> Minimum ms between request starts. Default: 0
|
|
26
|
+
-r, --retries <n> Re-fetch attempts for a failed page. Default: 2
|
|
27
|
+
--origin <url> Origin requests are minted under (module targets)
|
|
28
|
+
--hint-header <name> Response header naming extra paths. Default: x-prerender
|
|
29
|
+
--redirects Write redirects as _redirects rules instead of stubs
|
|
30
|
+
--redirects-file <f> Rules file name (implies --redirects). Default: _redirects
|
|
31
|
+
--sitemap <origin> Write sitemap.xml with entries under this public origin
|
|
32
|
+
--sitemap-file <f> Sitemap file name. Default: sitemap.xml
|
|
33
|
+
--report Write a JSON report of the crawl (pages, timings, referrers, ...)
|
|
34
|
+
--report-file <f> Report file name (implies --report). Default: prerender-report.json
|
|
35
|
+
--keep-query Render /posts?page=2 apart from /posts (see Query strings)
|
|
36
|
+
--no-links Do not follow links in rendered pages
|
|
37
|
+
--no-redirect-stubs Write no meta-refresh stubs at redirected paths
|
|
38
|
+
--continue Skip pages that fail instead of failing the run
|
|
39
|
+
--flat Write /about as about.html instead of about/index.html
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
For an HTTP target the crawl origin is the target's, so absolute links in the rendered HTML count as same-origin.
|
|
6
43
|
|
|
7
44
|
## Vite plugin
|
|
8
45
|
|
|
@@ -19,22 +56,20 @@ export default defineConfig({
|
|
|
19
56
|
});
|
|
20
57
|
```
|
|
21
58
|
|
|
22
|
-
The plugin is build-only. It assumes
|
|
59
|
+
The plugin is build-only. It assumes two things about the app:
|
|
23
60
|
|
|
24
61
|
1. `vite build` produces a client output directory (the `client` environment's `outDir`, default `dist/client`).
|
|
25
62
|
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
63
|
|
|
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.
|
|
64
|
+
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. Which pages exist is the server's to say — see [Announcing pages](#announcing-pages).
|
|
29
65
|
|
|
30
66
|
### Options
|
|
31
67
|
|
|
32
68
|
Everything from [`PrerenderOptions`](#engine-options) plus:
|
|
33
69
|
|
|
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. |
|
|
70
|
+
| Option | Default | |
|
|
71
|
+
| ------------- | ------------------------ | ----------------------------------- |
|
|
72
|
+
| `serverEntry` | `<ssr outDir>/server.js` | Built module exporting the handler. |
|
|
38
73
|
|
|
39
74
|
### `import.meta.env.PRERENDER_MODE`
|
|
40
75
|
|
|
@@ -49,42 +84,115 @@ The one distinction every downstream policy keys on:
|
|
|
49
84
|
|
|
50
85
|
## Engine
|
|
51
86
|
|
|
52
|
-
The plugin
|
|
87
|
+
The plugin and CLI are thin drivers. The engine works with any transport — anything with a `fetch(request: Request): Promise<Response>`:
|
|
53
88
|
|
|
54
89
|
```ts
|
|
55
|
-
import { runPrerender } from "prerender-crawler";
|
|
90
|
+
import { runPrerender, httpTransport, moduleTransport } from "prerender-crawler";
|
|
56
91
|
|
|
57
92
|
const result = await runPrerender({
|
|
58
|
-
transport: { fetch: request => app.handle(request) },
|
|
93
|
+
transport: { fetch: request => app.handle(request) }, // or:
|
|
94
|
+
// transport: httpTransport("http://localhost:3000"), a running server
|
|
95
|
+
// transport: await moduleTransport("dist/server.js"), a handler module
|
|
59
96
|
outDir: "dist",
|
|
60
97
|
pages: ["/", "/about", { path: "/404", filename: "404.html" }],
|
|
61
98
|
mode: "static"
|
|
62
99
|
});
|
|
63
100
|
|
|
64
|
-
result.pages; // RenderedPage[]
|
|
65
|
-
result.
|
|
66
|
-
result.
|
|
101
|
+
result.pages; // RenderedPage[] — path, referrers, filename, emitted, response, duration, html, redirect?
|
|
102
|
+
result.redirects; // RedirectRecord[] — { from, to, status }, one per redirected path
|
|
103
|
+
result.files; // EmittedFile[] — what integrations emitted
|
|
104
|
+
result.skipped; // SkippedPage[] — failures left out (failOnError: false)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`httpTransport` sends the crawl's requests to the target's origin (path and query kept) and hands redirects back as the 3xx responses the server sent. Pass `{ headers }` for an auth token or `{ fetch }` for a custom implementation. `moduleTransport` imports a module exporting `handleRequest`, `fetch`, or `default.fetch` and calls it directly.
|
|
108
|
+
|
|
109
|
+
### Announcing pages
|
|
110
|
+
|
|
111
|
+
The crawl finds pages by following links and by reading the hint header (`x-prerender`, comma-separated paths) off responses. A page nothing links to is invisible to the first; the second is how a server that knows its routes declares them — and the thing that knows the routes is the router the app built for the request. The crawler knows no router; it only defines the wire. `prerender-crawler/announce` is that wire, free of Node imports so application server code can use it:
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
import { announcePages } from "prerender-crawler/announce";
|
|
115
|
+
|
|
116
|
+
// in the request handler, with whatever the router exposes — e.g. TanStack Router:
|
|
117
|
+
const pages = Object.entries(router.routesByPath)
|
|
118
|
+
.filter(
|
|
119
|
+
([path, route]) => !path.includes("$") && (route.fullPath.endsWith("/") || !route.children)
|
|
120
|
+
)
|
|
121
|
+
.map(([path]) => path);
|
|
122
|
+
announcePages(request, response.headers, pages);
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`announcePages` writes the header only when the request is the crawler's (it carries the hint header) — a visitor's response is untouched. What to announce: the paths that address a static page — no parameters or splats (only a render knows their values; the crawl finds those pages by their links), leaves and indexes (a layout with children but no index has no page of its own). The Vite plugin, the CLI against a module, and the CLI against a running server all send the hint header, so one line in the app seeds all three.
|
|
126
|
+
|
|
127
|
+
Enumerating a specific router's static pages is the framework integration's job, not this package's: [`@solidjs/prerender`](../solid) ships `solidRouterPages`, `tanstackRouterPages`, and `announceRoutes(router)` (which also reads the request from the ambient request event) for the routers Solid apps use.
|
|
128
|
+
|
|
129
|
+
### Redirects
|
|
130
|
+
|
|
131
|
+
A path that answers 3xx is recorded (`result.redirects`, one record per hop — `/a → /b → /c` is two records, the way host rules spell it) and its same-origin target is crawled as a page in its own right, so the destination renders once at its own URL. The redirected path itself gets a **meta-refresh stub** pointing at the chain's final destination, so the old URL keeps working on hosts with no redirect support. A redirect to another spelling of the same page (`/posts → /posts/`) is followed in place, not recorded.
|
|
132
|
+
|
|
133
|
+
Hosts with real redirect rules do better than stubs:
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
import { redirects } from "prerender-crawler";
|
|
137
|
+
|
|
138
|
+
runPrerender({ integrations: [redirects()] }); // or prerender({ integrations: [redirects()] })
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`redirects()` emits a `_redirects` file (`/from /to 301`, the format Netlify and Cloudflare Pages share) and declares `handlesRedirects`, which stops the engine writing stubs — necessary on Netlify, where an existing file shadows the rule. Options: `filename`, `force` (Netlify's `301!`), and `format(records)` for another host's syntax.
|
|
142
|
+
|
|
143
|
+
### Sitemap
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
import { sitemap } from "prerender-crawler";
|
|
147
|
+
|
|
148
|
+
runPrerender({ integrations: [sitemap({ hostname: "https://example.com" })] });
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Every rendered page becomes a `<url>` entry — the crawl knows the one thing a route manifest cannot, which pages actually exist with dynamic segments expanded. Redirect stubs, non-HTML responses, query spellings, and pages marked `noindex` (`<meta name="robots">` in the head or an `X-Robots-Tag` header) are left out, the same signals a search engine honors on the live site. Options: `filename`, `trailingSlash`, `filter(page)` for further exclusions, and `entry(page)` returning `lastmod` / `changefreq` / `priority` per page. `indexable(page)` and `formatSitemap(entries)` are exported for tooling that formats its own.
|
|
152
|
+
|
|
153
|
+
### Report
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
import { report } from "prerender-crawler";
|
|
157
|
+
|
|
158
|
+
runPrerender({ integrations: [report({ filename: "../prerender-report.json" })] });
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Writes what the crawl did as JSON: every page with its status, content type, duration, output file, whether it was written and which pages linked to it; every redirect; every skipped page with its error and referrers; every file other integrations emitted; and totals. It answers "why was this page crawled", "which pages are slow" and "what did the build produce" after the process is gone. The default filename lands in the output directory and deploys with the site — `../` keeps it a build artifact.
|
|
162
|
+
|
|
163
|
+
### Query strings
|
|
164
|
+
|
|
165
|
+
By default the query is stripped from every URL the crawl sees: `/posts`, `/posts?page=2` and `/posts?utm=x` are one page, rendered once. That is what a static host can serve — a file at a path, the same for every query.
|
|
166
|
+
|
|
167
|
+
`keepQuery: true` makes each query spelling a page of its own (parameters sorted, so `?a=1&b=2` and `?b=2&a=1` meet). Each renders separately — its links are followed, its data captured — but is **written only when its seed entry names a `filename`**, because `posts/index.html` is already `/posts`. It exists for hybrid builds baking per-query data, and for sites that map queries onto files themselves:
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
runPrerender({
|
|
171
|
+
keepQuery: true,
|
|
172
|
+
pages: [{ path: "/posts?page=2", filename: "posts/page/2/index.html" }]
|
|
173
|
+
});
|
|
67
174
|
```
|
|
68
175
|
|
|
69
176
|
### Engine options
|
|
70
177
|
|
|
71
|
-
| Option | Default
|
|
72
|
-
| ------------------------ |
|
|
73
|
-
| `mode` | `"static"`
|
|
74
|
-
| `pages` | `["/"]`
|
|
75
|
-
| `crawlLinks` | `true`
|
|
76
|
-
| `hintHeader` | `"x-prerender"`
|
|
77
|
-
| `filter` |
|
|
78
|
-
| `
|
|
79
|
-
| `
|
|
80
|
-
| `
|
|
81
|
-
| `
|
|
82
|
-
| `
|
|
83
|
-
| `
|
|
84
|
-
| `
|
|
85
|
-
| `
|
|
86
|
-
| `
|
|
87
|
-
| `
|
|
178
|
+
| Option | Default | |
|
|
179
|
+
| ------------------------ | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
180
|
+
| `mode` | `"static"` | See [Modes](#modes). Decides the `emitPages` default. |
|
|
181
|
+
| `pages` | `["/"]` | Seeds: strings, `{ path, filename?, emit? }` entries, or a (async) function returning them. Duplicates collapse to one render. |
|
|
182
|
+
| `crawlLinks` | `true` | Follow same-origin links in rendered HTML. The only way dynamic routes are discovered without explicit seeding. |
|
|
183
|
+
| `hintHeader` | `"x-prerender"` | Response header naming additional paths (comma-separated). See [Announcing pages](#announcing-pages). |
|
|
184
|
+
| `filter` | | `(path) => boolean`; drops a discovered path before it's fetched. |
|
|
185
|
+
| `keepQuery` | `false` | Render query spellings as distinct pages. See [Query strings](#query-strings). |
|
|
186
|
+
| `concurrency` | `8` | Pages in flight at once. |
|
|
187
|
+
| `interval` | `0` | Minimum ms between the starts of consecutive requests across all workers — a throttle for renders hitting rate-limited APIs. |
|
|
188
|
+
| `retries` / `retryDelay` | `2` / `500` | Re-fetch attempts for a failed page, and the wait between them. |
|
|
189
|
+
| `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. |
|
|
190
|
+
| `redirectStubs` | `true` unless an integration `handlesRedirects` | Whether redirected paths get a meta-refresh stub file pointing at the chain's end. See [Redirects](#redirects). |
|
|
191
|
+
| `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. |
|
|
192
|
+
| `autoSubfolderIndex` | `true` | `/about` → `about/index.html` (true) or `about.html` (false). |
|
|
193
|
+
| `origin` | `"http://localhost"` | Origin requests are minted under. |
|
|
194
|
+
| `onRendered` | | Observes every rendered page as it lands — for post-processing. Integrations see the same pages on `context.pages`. |
|
|
195
|
+
| `integrations` | `[]` | See below. |
|
|
88
196
|
|
|
89
197
|
### Integrations
|
|
90
198
|
|
|
@@ -95,6 +203,7 @@ interface PrerenderIntegration {
|
|
|
95
203
|
name: string;
|
|
96
204
|
setup?(context: PrerenderContext): void | Promise<void>; // before the first render
|
|
97
205
|
teardown?(context: PrerenderContext): void | Promise<void>; // after the last render, before writes
|
|
206
|
+
handlesRedirects?: boolean; // "I write host redirect rules" — the engine skips its stubs
|
|
98
207
|
client?: string; // module a bundler plugin imports into the client build (reserved)
|
|
99
208
|
}
|
|
100
209
|
|
|
@@ -102,20 +211,28 @@ interface PrerenderContext {
|
|
|
102
211
|
mode: PrerenderMode;
|
|
103
212
|
origin: string;
|
|
104
213
|
outDir: string;
|
|
214
|
+
pages: readonly RenderedPage[]; // complete by teardown
|
|
215
|
+
redirects: readonly RedirectRecord[]; // complete by teardown
|
|
216
|
+
skipped: readonly SkippedPage[]; // complete by teardown
|
|
217
|
+
files: readonly EmittedFile[]; // what earlier integrations emitted
|
|
105
218
|
emitFile(file: { filename: string; contents: string | Uint8Array }): void;
|
|
106
219
|
}
|
|
107
220
|
```
|
|
108
221
|
|
|
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.
|
|
222
|
+
`emitFile` is the channel for artifacts produced during the crawl — captured server-function results, extracted payloads, sitemaps. Filenames resolve against the output directory; `../` or an absolute path lands outside it. Throwing from `teardown` fails the run: the place to verify the crawl produced everything the runtime half will need. `redirects()`, `sitemap()` and `report()` above are the shipped examples, each a formatter over the context; [`@solidjs/prerender`](../solid) is the reference integration with a runtime half.
|
|
110
223
|
|
|
111
224
|
### Utilities
|
|
112
225
|
|
|
113
|
-
- `
|
|
114
|
-
- `
|
|
226
|
+
- `httpTransport(target, { headers?, fetch? })`, `moduleTransport(entry)`, `loadHandler(entry)` — the shipped transports.
|
|
227
|
+
- `redirects(options?)`, `formatRedirectsFile(records, force?)` — the redirects integration and its `_redirects` formatter.
|
|
228
|
+
- `sitemap(options)`, `indexable(page)`, `formatSitemap(entries)` — the sitemap integration and its parts.
|
|
229
|
+
- `report(options?)` — the crawl report integration.
|
|
230
|
+
- `prerender-crawler/announce`: `announcePages(request, headers, paths, { header? })`, `HINT_HEADER` — the wire, for application server code.
|
|
231
|
+
- `extractLinks(html, pageUrl, { keepQuery? })`, `normalizeLink(href, base, origin)`, `normalizeRoute(url)`, `normalizePath(pathname)`, `splitRoute(route)`, `outputFilename(path, autoSubfolderIndex)` — the crawl's own primitives.
|
|
115
232
|
|
|
116
233
|
## Requirements
|
|
117
234
|
|
|
118
|
-
Node 20+. Vite 7 or 8 for the plugin (optional peer).
|
|
235
|
+
Node 20+. Vite 7 or 8 for the plugin (optional peer).
|
|
119
236
|
|
|
120
237
|
## License
|
|
121
238
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The announce protocol: how a server tells a crawl which pages it has.
|
|
3
|
+
*
|
|
4
|
+
* The crawl finds pages by following links and by reading the hint header
|
|
5
|
+
* (`x-prerender`, comma-separated paths) off responses. A route nothing
|
|
6
|
+
* links to is invisible to the first; the second is how a server that
|
|
7
|
+
* KNOWS its routes declares them — and the thing that knows the routes is
|
|
8
|
+
* the router the app built for the request. Which routes count is the
|
|
9
|
+
* router's business, so the enumeration lives with the framework
|
|
10
|
+
* integration (`@solidjs/prerender` ships Solid Router's and TanStack
|
|
11
|
+
* Router's); this module is only the wire: put the paths on the header
|
|
12
|
+
* when the request is the crawler's. Every crawl — the Vite plugin, the
|
|
13
|
+
* CLI against a built module, the CLI against a running server — sends
|
|
14
|
+
* the hint request header and seeds from the answer.
|
|
15
|
+
*
|
|
16
|
+
* What to announce: paths that address a static page. No parameters or
|
|
17
|
+
* splats (only a render knows their values; the crawl finds them by their
|
|
18
|
+
* links), leaves and indexes (a layout with children but no index has no
|
|
19
|
+
* page of its own). One spelling per page — the engine normalizes trailing
|
|
20
|
+
* slashes, so either is fine.
|
|
21
|
+
*
|
|
22
|
+
* Imported by application SERVER code: no Node imports here.
|
|
23
|
+
*/
|
|
24
|
+
/** The hint header the engine reads, and the request header it sends. */
|
|
25
|
+
export declare const HINT_HEADER = "x-prerender";
|
|
26
|
+
export interface AnnounceOptions {
|
|
27
|
+
/** The header name, if the crawl was configured with a custom `hintHeader`. @default "x-prerender" */
|
|
28
|
+
header?: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Puts `paths` on the response's hint header — when the request is the
|
|
32
|
+
* crawler's (it carries the hint header). Returns whether it did. A
|
|
33
|
+
* regular visitor's response is left untouched.
|
|
34
|
+
*
|
|
35
|
+
* ```ts
|
|
36
|
+
* announcePages(event.request, event.response.headers, staticPaths);
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export declare function announcePages(request: Request, headers: Headers, paths: readonly string[], options?: AnnounceOptions): boolean;
|
package/dist/announce.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The announce protocol: how a server tells a crawl which pages it has.
|
|
3
|
+
*
|
|
4
|
+
* The crawl finds pages by following links and by reading the hint header
|
|
5
|
+
* (`x-prerender`, comma-separated paths) off responses. A route nothing
|
|
6
|
+
* links to is invisible to the first; the second is how a server that
|
|
7
|
+
* KNOWS its routes declares them — and the thing that knows the routes is
|
|
8
|
+
* the router the app built for the request. Which routes count is the
|
|
9
|
+
* router's business, so the enumeration lives with the framework
|
|
10
|
+
* integration (`@solidjs/prerender` ships Solid Router's and TanStack
|
|
11
|
+
* Router's); this module is only the wire: put the paths on the header
|
|
12
|
+
* when the request is the crawler's. Every crawl — the Vite plugin, the
|
|
13
|
+
* CLI against a built module, the CLI against a running server — sends
|
|
14
|
+
* the hint request header and seeds from the answer.
|
|
15
|
+
*
|
|
16
|
+
* What to announce: paths that address a static page. No parameters or
|
|
17
|
+
* splats (only a render knows their values; the crawl finds them by their
|
|
18
|
+
* links), leaves and indexes (a layout with children but no index has no
|
|
19
|
+
* page of its own). One spelling per page — the engine normalizes trailing
|
|
20
|
+
* slashes, so either is fine.
|
|
21
|
+
*
|
|
22
|
+
* Imported by application SERVER code: no Node imports here.
|
|
23
|
+
*/
|
|
24
|
+
/** The hint header the engine reads, and the request header it sends. */
|
|
25
|
+
export const HINT_HEADER = "x-prerender";
|
|
26
|
+
/**
|
|
27
|
+
* Puts `paths` on the response's hint header — when the request is the
|
|
28
|
+
* crawler's (it carries the hint header). Returns whether it did. A
|
|
29
|
+
* regular visitor's response is left untouched.
|
|
30
|
+
*
|
|
31
|
+
* ```ts
|
|
32
|
+
* announcePages(event.request, event.response.headers, staticPaths);
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export function announcePages(request, headers, paths, options = {}) {
|
|
36
|
+
const { header = HINT_HEADER } = options;
|
|
37
|
+
if (!request.headers.has(header) || paths.length === 0)
|
|
38
|
+
return false;
|
|
39
|
+
headers.set(header, paths.join(","));
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const USAGE = "Usage: prerender-crawler <target> --out <dir> [options]\n\nPrerenders a site into static files by crawling it.\n\n <target> An http(s) origin of a running server, or the path of a\n module exporting a Request -> Response handler\n (handleRequest, fetch, or default.fetch).\n\nOptions:\n -o, --out <dir> Output directory (required)\n -p, --page <path> Seed page; repeatable. Default: /\n -m, --mode <mode> static (default) or hybrid \u2014 see docs for what each writes\n -c, --concurrency <n> Pages in flight at once. Default: 8\n -i, --interval <ms> Minimum ms between request starts. Default: 0\n -r, --retries <n> Re-fetch attempts for a failed page. Default: 2\n --origin <url> Origin requests are minted under (module targets).\n Default: http://localhost\n --hint-header <name> Response header naming extra paths. Default: x-prerender\n --redirects Write the redirects as host rules (_redirects format,\n Netlify / Cloudflare Pages) instead of meta-refresh stubs\n --redirects-file <f> Rules file name (implies --redirects). Default: _redirects\n --sitemap <origin> Write sitemap.xml with entries under this public origin\n (https://example.com)\n --sitemap-file <f> Sitemap file name. Default: sitemap.xml\n --report Write a JSON report of the crawl (pages, timings, referrers,\n redirects, skips)\n --report-file <f> Report file name (implies --report). Resolved against the\n output dir; ../ keeps it out of the deploy.\n Default: prerender-report.json\n --keep-query Render /posts?page=2 apart from /posts (not written unless\n the app maps queries to files \u2014 see docs)\n --no-links Do not follow links in rendered pages\n --no-redirect-stubs Write no meta-refresh stubs at redirected paths\n --continue Skip pages that fail instead of failing the run\n --flat Write /about as about.html instead of about/index.html\n -h, --help Show this help\n";
|
|
2
|
+
export interface CliIO {
|
|
3
|
+
stdout(line: string): void;
|
|
4
|
+
stderr(line: string): void;
|
|
5
|
+
}
|
|
6
|
+
/** Runs the CLI for `argv` (without the node and script entries). Returns the exit code. */
|
|
7
|
+
export declare function main(argv: string[], io: CliIO): Promise<number>;
|
package/dist/cli-main.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// The CLI's logic, separated from the executable entry (./cli.ts) so tests
|
|
2
|
+
// can drive it with an argv and capture its output.
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import { runPrerender } from "./crawl.js";
|
|
6
|
+
import { redirects } from "./redirects.js";
|
|
7
|
+
import { report } from "./report.js";
|
|
8
|
+
import { sitemap } from "./sitemap.js";
|
|
9
|
+
import { httpTransport, moduleTransport } from "./transports.js";
|
|
10
|
+
export const USAGE = `Usage: prerender-crawler <target> --out <dir> [options]
|
|
11
|
+
|
|
12
|
+
Prerenders a site into static files by crawling it.
|
|
13
|
+
|
|
14
|
+
<target> An http(s) origin of a running server, or the path of a
|
|
15
|
+
module exporting a Request -> Response handler
|
|
16
|
+
(handleRequest, fetch, or default.fetch).
|
|
17
|
+
|
|
18
|
+
Options:
|
|
19
|
+
-o, --out <dir> Output directory (required)
|
|
20
|
+
-p, --page <path> Seed page; repeatable. Default: /
|
|
21
|
+
-m, --mode <mode> static (default) or hybrid — see docs for what each writes
|
|
22
|
+
-c, --concurrency <n> Pages in flight at once. Default: 8
|
|
23
|
+
-i, --interval <ms> Minimum ms between request starts. Default: 0
|
|
24
|
+
-r, --retries <n> Re-fetch attempts for a failed page. Default: 2
|
|
25
|
+
--origin <url> Origin requests are minted under (module targets).
|
|
26
|
+
Default: http://localhost
|
|
27
|
+
--hint-header <name> Response header naming extra paths. Default: x-prerender
|
|
28
|
+
--redirects Write the redirects as host rules (_redirects format,
|
|
29
|
+
Netlify / Cloudflare Pages) instead of meta-refresh stubs
|
|
30
|
+
--redirects-file <f> Rules file name (implies --redirects). Default: _redirects
|
|
31
|
+
--sitemap <origin> Write sitemap.xml with entries under this public origin
|
|
32
|
+
(https://example.com)
|
|
33
|
+
--sitemap-file <f> Sitemap file name. Default: sitemap.xml
|
|
34
|
+
--report Write a JSON report of the crawl (pages, timings, referrers,
|
|
35
|
+
redirects, skips)
|
|
36
|
+
--report-file <f> Report file name (implies --report). Resolved against the
|
|
37
|
+
output dir; ../ keeps it out of the deploy.
|
|
38
|
+
Default: prerender-report.json
|
|
39
|
+
--keep-query Render /posts?page=2 apart from /posts (not written unless
|
|
40
|
+
the app maps queries to files — see docs)
|
|
41
|
+
--no-links Do not follow links in rendered pages
|
|
42
|
+
--no-redirect-stubs Write no meta-refresh stubs at redirected paths
|
|
43
|
+
--continue Skip pages that fail instead of failing the run
|
|
44
|
+
--flat Write /about as about.html instead of about/index.html
|
|
45
|
+
-h, --help Show this help
|
|
46
|
+
`;
|
|
47
|
+
/** Runs the CLI for `argv` (without the node and script entries). Returns the exit code. */
|
|
48
|
+
export async function main(argv, io) {
|
|
49
|
+
const parsed = parse(argv);
|
|
50
|
+
if ("error" in parsed) {
|
|
51
|
+
io.stderr(`${parsed.error}\n\n${USAGE}`);
|
|
52
|
+
return 2;
|
|
53
|
+
}
|
|
54
|
+
const { values, positionals } = parsed;
|
|
55
|
+
if (values.help) {
|
|
56
|
+
io.stdout(USAGE);
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
const target = positionals[0];
|
|
60
|
+
if (!target || positionals.length > 1 || !values.out) {
|
|
61
|
+
io.stderr(!target
|
|
62
|
+
? "Missing <target>."
|
|
63
|
+
: !values.out
|
|
64
|
+
? "Missing --out <dir>."
|
|
65
|
+
: `Unexpected argument: ${positionals[1]}.`);
|
|
66
|
+
io.stderr(`\n${USAGE}`);
|
|
67
|
+
return 2;
|
|
68
|
+
}
|
|
69
|
+
const mode = values.mode ?? "static";
|
|
70
|
+
if (mode !== "static" && mode !== "hybrid") {
|
|
71
|
+
io.stderr(`--mode must be static or hybrid, got ${mode}.`);
|
|
72
|
+
return 2;
|
|
73
|
+
}
|
|
74
|
+
let transport;
|
|
75
|
+
let origin = values.origin;
|
|
76
|
+
try {
|
|
77
|
+
if (/^https?:\/\//.test(target)) {
|
|
78
|
+
transport = httpTransport(target);
|
|
79
|
+
origin ??= new URL(target).origin;
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
transport = await moduleTransport(path.resolve(target));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
io.stderr(describe(error));
|
|
87
|
+
return 1;
|
|
88
|
+
}
|
|
89
|
+
const integrations = [];
|
|
90
|
+
if (values.redirects || values["redirects-file"] !== undefined) {
|
|
91
|
+
integrations.push(redirects({ filename: values["redirects-file"] }));
|
|
92
|
+
}
|
|
93
|
+
if (values.sitemap !== undefined || values["sitemap-file"] !== undefined) {
|
|
94
|
+
if (!values.sitemap || !/^https?:\/\//.test(values.sitemap)) {
|
|
95
|
+
io.stderr(`--sitemap needs the site's public origin (https://example.com).`);
|
|
96
|
+
return 2;
|
|
97
|
+
}
|
|
98
|
+
integrations.push(sitemap({ hostname: values.sitemap, filename: values["sitemap-file"] }));
|
|
99
|
+
}
|
|
100
|
+
if (values.report || values["report-file"] !== undefined) {
|
|
101
|
+
integrations.push(report({ filename: values["report-file"] }));
|
|
102
|
+
}
|
|
103
|
+
const outDir = path.resolve(values.out);
|
|
104
|
+
try {
|
|
105
|
+
const result = await runPrerender({
|
|
106
|
+
transport,
|
|
107
|
+
outDir,
|
|
108
|
+
mode: mode,
|
|
109
|
+
origin,
|
|
110
|
+
pages: values.page?.length ? values.page : undefined,
|
|
111
|
+
concurrency: values.concurrency !== undefined ? integer(values.concurrency) : undefined,
|
|
112
|
+
interval: values.interval !== undefined ? integer(values.interval) : undefined,
|
|
113
|
+
retries: values.retries !== undefined ? integer(values.retries) : undefined,
|
|
114
|
+
hintHeader: values["hint-header"],
|
|
115
|
+
crawlLinks: !values["no-links"],
|
|
116
|
+
keepQuery: values["keep-query"],
|
|
117
|
+
redirectStubs: values["no-redirect-stubs"] ? false : undefined,
|
|
118
|
+
failOnError: !values.continue,
|
|
119
|
+
autoSubfolderIndex: !values.flat,
|
|
120
|
+
integrations
|
|
121
|
+
});
|
|
122
|
+
const written = result.pages.filter(page => page.emitted).length;
|
|
123
|
+
const parts = [`rendered ${result.pages.length} page(s) (${written} written)`];
|
|
124
|
+
if (result.redirects.length)
|
|
125
|
+
parts.push(`${result.redirects.length} redirect(s)`);
|
|
126
|
+
if (result.files.length)
|
|
127
|
+
parts.push(`${result.files.length} file(s) emitted`);
|
|
128
|
+
const relative = path.relative(process.cwd(), outDir);
|
|
129
|
+
const shown = relative === "" ? "." : relative.startsWith("..") ? outDir : relative;
|
|
130
|
+
io.stdout(`[prerender] ${parts.join(", ")} -> ${shown}`);
|
|
131
|
+
for (const miss of result.skipped) {
|
|
132
|
+
io.stderr(`[prerender] skipped ${miss.path}: ${describe(miss.error)}`);
|
|
133
|
+
}
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
io.stderr(`[prerender] ${describe(error)}`);
|
|
138
|
+
return 1;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const spec = {
|
|
142
|
+
allowPositionals: true,
|
|
143
|
+
options: {
|
|
144
|
+
out: { type: "string", short: "o" },
|
|
145
|
+
page: { type: "string", short: "p", multiple: true },
|
|
146
|
+
mode: { type: "string", short: "m" },
|
|
147
|
+
concurrency: { type: "string", short: "c" },
|
|
148
|
+
interval: { type: "string", short: "i" },
|
|
149
|
+
retries: { type: "string", short: "r" },
|
|
150
|
+
origin: { type: "string" },
|
|
151
|
+
"hint-header": { type: "string" },
|
|
152
|
+
redirects: { type: "boolean" },
|
|
153
|
+
"redirects-file": { type: "string" },
|
|
154
|
+
sitemap: { type: "string" },
|
|
155
|
+
"sitemap-file": { type: "string" },
|
|
156
|
+
report: { type: "boolean" },
|
|
157
|
+
"report-file": { type: "string" },
|
|
158
|
+
"keep-query": { type: "boolean" },
|
|
159
|
+
"no-links": { type: "boolean" },
|
|
160
|
+
"no-redirect-stubs": { type: "boolean" },
|
|
161
|
+
continue: { type: "boolean" },
|
|
162
|
+
flat: { type: "boolean" },
|
|
163
|
+
help: { type: "boolean", short: "h" }
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
function parse(argv) {
|
|
167
|
+
try {
|
|
168
|
+
return parseArgs({ args: argv, ...spec });
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
return { error: describe(error) };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function integer(value) {
|
|
175
|
+
const parsed = Number(value);
|
|
176
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
177
|
+
throw new Error(`Expected a non-negative integer, got ${JSON.stringify(value)}.`);
|
|
178
|
+
}
|
|
179
|
+
return parsed;
|
|
180
|
+
}
|
|
181
|
+
const describe = (error) => (error instanceof Error ? error.message : String(error));
|
package/dist/cli.d.ts
ADDED