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 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`) and a Vite plugin built on it (`prerender-crawler/vite`).
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 three things about the app:
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 is a thin driver. The engine works with any transport:
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[] — path, referrers, filename, emitted, html
65
- result.files; // EmittedFile[] what integrations emitted
66
- result.skipped; // SkippedPage[] failures left out (failOnError: false)
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"` | 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. |
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
- - `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.
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). `filesystem-routing` ≥ 0.2 for route seeding (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;
@@ -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>;
@@ -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
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "./cli-main.js";
3
+ process.exitCode = await main(process.argv.slice(2), {
4
+ stdout: line => console.log(line),
5
+ stderr: line => console.error(line)
6
+ });