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.
@@ -0,0 +1,56 @@
1
+ import type { PrerenderIntegration, RenderedPage } from "./types.ts";
2
+ /**
3
+ * The sitemap integration: every rendered page becomes a `<url>` entry.
4
+ *
5
+ * The crawl already knows the one thing a sitemap needs and a route
6
+ * manifest cannot supply — which pages actually exist, dynamic segments
7
+ * expanded — so the integration is a formatter over `context.pages`.
8
+ * Pages are dropped when they are redirects, are not HTML, carry a query
9
+ * (a static host cannot serve them, see `keepQuery`), or ask not to be
10
+ * indexed (`<meta name="robots" content="noindex">` or an `X-Robots-Tag`
11
+ * header) — the same signals a search engine would honor on the live site.
12
+ */
13
+ export type SitemapChangeFrequency = "always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never";
14
+ export interface SitemapEntry {
15
+ /** Absolute URL of the page. */
16
+ loc: string;
17
+ /** Last-modification date, as a `Date` or an already-formatted W3C datetime. */
18
+ lastmod?: Date | string;
19
+ changefreq?: SitemapChangeFrequency;
20
+ /** 0.0 – 1.0 */
21
+ priority?: number;
22
+ }
23
+ export interface SitemapIntegrationOptions {
24
+ /**
25
+ * The site's public origin — `https://example.com`. Sitemap entries are
26
+ * absolute URLs, and the crawl only knows the loopback origin it
27
+ * rendered against.
28
+ */
29
+ hostname: string;
30
+ /** @default "sitemap.xml" */
31
+ filename?: string;
32
+ /**
33
+ * Spell entries with a trailing slash (`/about/`) — for hosts that
34
+ * canonicalize that way. The root is `/` either way.
35
+ * @default false
36
+ */
37
+ trailingSlash?: boolean;
38
+ /**
39
+ * Which pages are listed. Runs after the built-in exclusions (redirects,
40
+ * non-HTML, query spellings, `noindex`); return false to drop more.
41
+ */
42
+ filter?(page: RenderedPage): boolean;
43
+ /**
44
+ * Per-page metadata — `lastmod`, `changefreq`, `priority` — or a
45
+ * replacement `loc`. Return nothing to list the page with its URL alone.
46
+ */
47
+ entry?(page: RenderedPage): Partial<SitemapEntry> | void;
48
+ }
49
+ export declare function sitemap(options: SitemapIntegrationOptions): PrerenderIntegration;
50
+ /**
51
+ * Whether a rendered page belongs in a sitemap by the signals the page
52
+ * itself gives: an HTML document, not a redirect, not a query spelling,
53
+ * not marked `noindex`.
54
+ */
55
+ export declare function indexable(page: RenderedPage): boolean;
56
+ export declare function formatSitemap(entries: readonly SitemapEntry[]): string;
@@ -0,0 +1,88 @@
1
+ export function sitemap(options) {
2
+ const { filename = "sitemap.xml", trailingSlash = false, filter, entry } = options;
3
+ if (!options.hostname) {
4
+ throw new Error("sitemap(): `hostname` is required — entries must be absolute URLs");
5
+ }
6
+ const hostname = new URL(options.hostname);
7
+ return {
8
+ name: "sitemap",
9
+ teardown(context) {
10
+ const entries = [];
11
+ for (const page of context.pages) {
12
+ if (!indexable(page) || (filter && !filter(page)))
13
+ continue;
14
+ const path = trailingSlash && page.path !== "/" ? `${page.path}/` : page.path;
15
+ entries.push({ loc: new URL(path, hostname).href, ...entry?.(page) });
16
+ }
17
+ entries.sort((a, b) => (a.loc < b.loc ? -1 : a.loc > b.loc ? 1 : 0));
18
+ context.emitFile({ filename, contents: formatSitemap(entries) });
19
+ }
20
+ };
21
+ }
22
+ /**
23
+ * Whether a rendered page belongs in a sitemap by the signals the page
24
+ * itself gives: an HTML document, not a redirect, not a query spelling,
25
+ * not marked `noindex`.
26
+ */
27
+ export function indexable(page) {
28
+ if (page.redirect || page.path.includes("?"))
29
+ return false;
30
+ if (!(page.response.headers.get("content-type") ?? "").includes("text/html"))
31
+ return false;
32
+ if (/\bnoindex\b/i.test(page.response.headers.get("x-robots-tag") ?? ""))
33
+ return false;
34
+ return !robotsMeta(page.html).some(content => /\bnoindex\b/i.test(content));
35
+ }
36
+ const META_PATTERN = /<meta\s[^>]*>/gi;
37
+ const ATTRIBUTE = (name) => new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, "i");
38
+ const NAME = ATTRIBUTE("name");
39
+ const CONTENT = ATTRIBUTE("content");
40
+ /** The `content` of every `<meta name="robots">` in the document's head, any attribute order. */
41
+ function robotsMeta(html) {
42
+ const head = html.slice(0, headEnd(html));
43
+ const found = [];
44
+ for (const [tag] of head.matchAll(META_PATTERN)) {
45
+ const name = NAME.exec(tag);
46
+ if (!name || (name[1] ?? name[2] ?? name[3]).trim().toLowerCase() !== "robots")
47
+ continue;
48
+ const content = CONTENT.exec(tag);
49
+ if (content)
50
+ found.push(content[1] ?? content[2] ?? content[3]);
51
+ }
52
+ return found;
53
+ }
54
+ function headEnd(html) {
55
+ const at = html.search(/<\/head\s*>|<body\b/i);
56
+ return at === -1 ? html.length : at;
57
+ }
58
+ export function formatSitemap(entries) {
59
+ const lines = ['<?xml version="1.0" encoding="UTF-8"?>'];
60
+ lines.push('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">');
61
+ for (const entry of entries) {
62
+ lines.push(" <url>");
63
+ lines.push(` <loc>${escapeXml(entry.loc)}</loc>`);
64
+ if (entry.lastmod !== undefined) {
65
+ const lastmod = entry.lastmod instanceof Date ? entry.lastmod.toISOString() : String(entry.lastmod);
66
+ lines.push(` <lastmod>${escapeXml(lastmod)}</lastmod>`);
67
+ }
68
+ if (entry.changefreq)
69
+ lines.push(` <changefreq>${entry.changefreq}</changefreq>`);
70
+ if (entry.priority !== undefined) {
71
+ lines.push(` <priority>${clampPriority(entry.priority)}</priority>`);
72
+ }
73
+ lines.push(" </url>");
74
+ }
75
+ lines.push("</urlset>");
76
+ return lines.join("\n") + "\n";
77
+ }
78
+ function clampPriority(priority) {
79
+ return Math.min(1, Math.max(0, priority)).toFixed(1);
80
+ }
81
+ function escapeXml(value) {
82
+ return value
83
+ .replace(/&/g, "&amp;")
84
+ .replace(/</g, "&lt;")
85
+ .replace(/>/g, "&gt;")
86
+ .replace(/"/g, "&quot;")
87
+ .replace(/'/g, "&apos;");
88
+ }
@@ -0,0 +1,35 @@
1
+ import type { Transport } from "./types.ts";
2
+ export interface HttpTransportOptions {
3
+ /** Headers added to every request (an auth token for a preview deploy, say). */
4
+ headers?: HeadersInit;
5
+ /** The fetch implementation to use. @default globalThis.fetch */
6
+ fetch?: typeof fetch;
7
+ }
8
+ /**
9
+ * Prerenders a RUNNING server over HTTP: every request the crawl mints is
10
+ * re-addressed to `target`'s origin (path and query kept) and sent with
11
+ * `fetch`. Works against anything that speaks HTTP — a framework's preview
12
+ * server, a container, a staging deploy — with no knowledge of what it is.
13
+ *
14
+ * Redirects are delivered to the engine as the 3xx responses the server
15
+ * sent (`redirect: "manual"`), not followed here: the engine records them,
16
+ * stubs them, and crawls their targets as pages in their own right.
17
+ *
18
+ * Set the run's `origin` to the same value so absolute links in the
19
+ * rendered HTML count as same-origin — the CLI does this for you.
20
+ */
21
+ export declare function httpTransport(target: string | URL, options?: HttpTransportOptions): Transport;
22
+ /** A `Request -> Response` handler, as a built server entry exports it. */
23
+ export type RequestHandler = (request: Request) => Response | Promise<Response>;
24
+ /**
25
+ * Imports a built server module and returns its request handler:
26
+ * `handleRequest`, `fetch`, or `default.fetch` — the shapes SSR entries and
27
+ * WinterCG-style servers export.
28
+ */
29
+ export declare function loadHandler(entry: string | URL): Promise<RequestHandler>;
30
+ /**
31
+ * Prerenders in-process against a built server module — no HTTP server,
32
+ * no subprocess: the crawl calls the handler directly. This is what the
33
+ * Vite plugin drives after the build.
34
+ */
35
+ export declare function moduleTransport(entry: string | URL): Promise<Transport>;
@@ -0,0 +1,74 @@
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 two transports the engine ships. Both are small because the engine's
10
+ // contract is small — `Request` in, `Response` out — and that is the point:
11
+ // anything answering that shape is prerenderable, whether it is a module
12
+ // in this process or a server on the other side of a socket.
13
+ import { pathToFileURL } from "node:url";
14
+ /**
15
+ * Prerenders a RUNNING server over HTTP: every request the crawl mints is
16
+ * re-addressed to `target`'s origin (path and query kept) and sent with
17
+ * `fetch`. Works against anything that speaks HTTP — a framework's preview
18
+ * server, a container, a staging deploy — with no knowledge of what it is.
19
+ *
20
+ * Redirects are delivered to the engine as the 3xx responses the server
21
+ * sent (`redirect: "manual"`), not followed here: the engine records them,
22
+ * stubs them, and crawls their targets as pages in their own right.
23
+ *
24
+ * Set the run's `origin` to the same value so absolute links in the
25
+ * rendered HTML count as same-origin — the CLI does this for you.
26
+ */
27
+ export function httpTransport(target, options = {}) {
28
+ const base = new URL(target);
29
+ const send = options.fetch ?? globalThis.fetch;
30
+ return {
31
+ async fetch(request) {
32
+ const url = new URL(request.url);
33
+ url.protocol = base.protocol;
34
+ url.host = base.host;
35
+ const headers = new Headers(request.headers);
36
+ new Headers(options.headers).forEach((value, key) => headers.set(key, value));
37
+ return send(new Request(url, { method: request.method, headers, redirect: "manual" }));
38
+ }
39
+ };
40
+ }
41
+ /**
42
+ * Imports a built server module and returns its request handler:
43
+ * `handleRequest`, `fetch`, or `default.fetch` — the shapes SSR entries and
44
+ * WinterCG-style servers export.
45
+ */
46
+ export async function loadHandler(entry) {
47
+ const href = entry instanceof URL ? entry.href : pathToFileURL(entry).href;
48
+ let serverModule;
49
+ try {
50
+ serverModule = await import(__rewriteRelativeImportExtension(href));
51
+ }
52
+ catch (cause) {
53
+ throw new Error(`prerender could not import the server entry at ${entry}. Prerendering renders pages ` +
54
+ `through a Request -> Response handler — point it at a module exporting ` +
55
+ `handleRequest, fetch, or default.fetch.`, { cause });
56
+ }
57
+ const handler = serverModule.handleRequest ??
58
+ serverModule.fetch ??
59
+ serverModule.default?.fetch;
60
+ if (typeof handler !== "function") {
61
+ throw new Error(`The server entry at ${entry} exports none of handleRequest, fetch, or default.fetch — ` +
62
+ `prerender needs a Request -> Response handler to render pages through.`);
63
+ }
64
+ return handler;
65
+ }
66
+ /**
67
+ * Prerenders in-process against a built server module — no HTTP server,
68
+ * no subprocess: the crawl calls the handler directly. This is what the
69
+ * Vite plugin drives after the build.
70
+ */
71
+ export async function moduleTransport(entry) {
72
+ const handler = await loadHandler(entry);
73
+ return { fetch: async (request) => handler(request) };
74
+ }
package/dist/types.d.ts CHANGED
@@ -36,6 +36,18 @@ export interface PageEntry {
36
36
  * shaped `{ path }` is accepted alongside plain strings.
37
37
  */
38
38
  export type PagesSource = Array<string | PageEntry> | (() => Array<string | PageEntry> | Promise<Array<string | PageEntry>>);
39
+ /**
40
+ * One redirect the crawl observed: a request for `from` answered 3xx. `to`
41
+ * is a normalized same-origin path, or an absolute URL when the redirect
42
+ * leaves the origin. A chain (`/a` -> `/b` -> `/c`) is recorded hop by
43
+ * hop, one record per path, exactly as a host's redirect rules would
44
+ * express it.
45
+ */
46
+ export interface RedirectRecord {
47
+ from: string;
48
+ to: string;
49
+ status: number;
50
+ }
39
51
  /** A page the engine rendered (and, when `emitted`, wrote). */
40
52
  export interface RenderedPage {
41
53
  /** The normalized route path (`/about`), origin and query stripped. */
@@ -52,8 +64,19 @@ export interface RenderedPage {
52
64
  emitted: boolean;
53
65
  /** The response the transport answered with (body consumed). */
54
66
  response: Response;
55
- /** The rendered HTML. */
67
+ /** Milliseconds from the request's start to its body fully read (the successful attempt). */
68
+ duration: number;
69
+ /**
70
+ * The rendered HTML — or, for a redirected path, the meta-refresh stub
71
+ * that stands in for it (see `redirect`).
72
+ */
56
73
  html: string;
74
+ /**
75
+ * Set when the path answered a redirect instead of a page. The stub in
76
+ * `html` points at the chain's FINAL destination; `redirect` records this
77
+ * path's own hop. Sitemap tooling should skip these.
78
+ */
79
+ redirect?: RedirectRecord;
57
80
  }
58
81
  /** An extra artifact an integration ships alongside the rendered pages. */
59
82
  export interface EmittedFile {
@@ -82,6 +105,19 @@ export interface PrerenderContext {
82
105
  mode: PrerenderMode;
83
106
  origin: string;
84
107
  outDir: string;
108
+ /** Every page rendered so far — complete by `teardown`. Live view; do not mutate. */
109
+ pages: readonly RenderedPage[];
110
+ /** Every redirect observed so far — complete by `teardown`. Live view; do not mutate. */
111
+ redirects: readonly RedirectRecord[];
112
+ /** Pages that failed and were skipped (`failOnError: false`) — complete by `teardown`. */
113
+ skipped: readonly SkippedPage[];
114
+ /** Files emitted so far by integrations — those before this one, at `teardown`. */
115
+ files: readonly EmittedFile[];
116
+ /**
117
+ * Queues a file to be written with the pages. `filename` is resolved
118
+ * against the output directory; a `../` or absolute path lands outside
119
+ * it (a build report that should not deploy, say).
120
+ */
85
121
  emitFile(file: EmittedFile): void;
86
122
  }
87
123
  /** A hook bundle participating in the run without owning any of it. */
@@ -95,6 +131,14 @@ export interface PrerenderIntegration {
95
131
  * produced everything its runtime half will need.
96
132
  */
97
133
  teardown?(context: PrerenderContext): void | Promise<void>;
134
+ /**
135
+ * Declares that this integration turns the run's redirects into the
136
+ * host's own rules (a `_redirects` file, say). The engine then skips its
137
+ * meta-refresh stubs at redirected paths: they would be redundant, and on
138
+ * hosts where an existing file shadows a rule (Netlify) they would
139
+ * defeat it. Equivalent to `redirectStubs: false` on the run.
140
+ */
141
+ handlesRedirects?: boolean;
98
142
  /**
99
143
  * A module specifier the bundler integration imports for side effects
100
144
  * into the CLIENT build when this integration is active — how an
@@ -123,6 +167,18 @@ export interface PrerenderOptions {
123
167
  hintHeader?: string;
124
168
  /** Drops a discovered path before it is fetched. */
125
169
  filter?(path: string): boolean;
170
+ /**
171
+ * Treat `/posts?page=2` as a page distinct from `/posts`. Off, the query
172
+ * is stripped everywhere and one render stands for every spelling. On,
173
+ * each query spelling renders separately — its links are followed and
174
+ * its data captured — but is written only when its entry names a
175
+ * `filename`: a static host serves a path the same regardless of query,
176
+ * so there is nothing correct to write by default. Meant for hybrid
177
+ * builds baking per-query data, and for sites that map queries to
178
+ * files themselves.
179
+ * @default false
180
+ */
181
+ keepQuery?: boolean;
126
182
  /** Pages in flight at once. @default 8 */
127
183
  concurrency?: number;
128
184
  /**
@@ -142,8 +198,16 @@ export interface PrerenderOptions {
142
198
  * @default true
143
199
  */
144
200
  failOnError?: boolean;
145
- /** Internal redirect hops followed for one page. @default 5 */
146
- maxRedirects?: number;
201
+ /**
202
+ * Whether a redirected path gets a meta-refresh stub file pointing at the
203
+ * chain's final destination, so the old URL keeps working on hosts with
204
+ * no redirect support of their own. Turn it off when redirects are
205
+ * expressed as host rules instead (an integration declaring
206
+ * `handlesRedirects` does so implicitly): a stub file next to a rule is
207
+ * redundant at best and, on hosts where files shadow rules, defeats it.
208
+ * @default true unless an integration declares `handlesRedirects`
209
+ */
210
+ redirectStubs?: boolean;
147
211
  /**
148
212
  * Whether rendered pages are written to disk: a blanket policy, or a
149
213
  * per-path predicate; per-entry `emit` flags override it either way. A
@@ -173,6 +237,8 @@ export interface SkippedPage {
173
237
  /** What a finished run reports. */
174
238
  export interface PrerenderResult {
175
239
  pages: RenderedPage[];
240
+ /** Every redirect the crawl observed, one record per redirected path. */
241
+ redirects: RedirectRecord[];
176
242
  /** Extra files integrations emitted. */
177
243
  files: EmittedFile[];
178
244
  /** Paths that failed and were skipped (only with `failOnError: false`). */
package/dist/vite.d.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  import type { Plugin } from "vite";
2
- import type { FileRoutePagesOptions } from "./file-routes.ts";
3
2
  import type { PrerenderOptions } from "./types.ts";
4
- export { fileRoutePages, staticRoutePaths } from "./file-routes.ts";
5
- export type { FileRoutePagesOptions, RouteEntryLike } from "./file-routes.ts";
3
+ export { redirects } from "./redirects.ts";
4
+ export type { RedirectsIntegrationOptions } from "./redirects.ts";
5
+ export { report } from "./report.ts";
6
+ export type { PrerenderReport, ReportIntegrationOptions, ReportPage, ReportSkip } from "./report.ts";
7
+ export { sitemap } from "./sitemap.ts";
8
+ export type { SitemapChangeFrequency, SitemapEntry, SitemapIntegrationOptions } from "./sitemap.ts";
6
9
  export type * from "./types.ts";
7
10
  /** The `import.meta.env` key the plugin defines with the build's `PrerenderMode`. */
8
11
  export declare const PRERENDER_MODE_ENV = "PRERENDER_MODE";
@@ -13,24 +16,13 @@ export interface PrerenderPluginOptions extends PrerenderOptions {
13
16
  * Defaults to `server.js` inside the `ssr` environment's output directory.
14
17
  */
15
18
  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
19
  }
29
20
  /**
30
21
  * 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.
22
+ * `pages` (default `["/"]`, plus every page the server announces on the
23
+ * hint header, plus every same-origin link discovered along the way) and
24
+ * writes each page's HTML — and whatever the integrations emit — into the
25
+ * client output.
34
26
  *
35
27
  * ```ts
36
28
  * import { prerender } from "prerender-crawler/vite";
package/dist/vite.js CHANGED
@@ -1,21 +1,13 @@
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
1
  // The Vite plugin: `prerender()` from `prerender-crawler/vite`.
10
2
  //
11
- // Framework-agnostic by construction. It knows three things about the app:
12
- // that `vite build` produces a client output directory, that some
3
+ // Framework-agnostic by construction. It knows two things about the app:
4
+ // that `vite build` produces a client output directory, and that some
13
5
  // 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
6
+ // (`handleRequest` or `fetch`: Request in, Response out). Anything
16
7
  // framework-specific rides along as an integration (see
17
8
  // `PrerenderIntegration`), the same seam the engine exposes to non-Vite
18
- // drivers.
9
+ // drivers. Which pages exist is the server's to say — through links and
10
+ // the hint header (see ./announce.ts) — not something read off the disk.
19
11
  //
20
12
  // Responsibilities, all build-only:
21
13
  //
@@ -28,21 +20,20 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
28
20
  // environment so runtime code can ask "am I a prerendered build, and of
29
21
  // which kind" — the one bit of client-side knowledge integrations need.
30
22
  // 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
23
  import path from "node:path";
35
- import { pathToFileURL } from "node:url";
36
24
  import { runPrerender } from "./crawl.js";
37
- import { fileRoutePages, hasFileSystemRouting } from "./file-routes.js";
38
- export { fileRoutePages, staticRoutePaths } from "./file-routes.js";
25
+ import { moduleTransport } from "./transports.js";
26
+ export { redirects } from "./redirects.js";
27
+ export { report } from "./report.js";
28
+ export { sitemap } from "./sitemap.js";
39
29
  /** The `import.meta.env` key the plugin defines with the build's `PrerenderMode`. */
40
30
  export const PRERENDER_MODE_ENV = "PRERENDER_MODE";
41
31
  /**
42
32
  * 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.
33
+ * `pages` (default `["/"]`, plus every page the server announces on the
34
+ * hint header, plus every same-origin link discovered along the way) and
35
+ * writes each page's HTML — and whatever the integrations emit — into the
36
+ * client output.
46
37
  *
47
38
  * ```ts
48
39
  * import { prerender } from "prerender-crawler/vite";
@@ -81,23 +72,23 @@ export function prerender(options = {}) {
81
72
  const entry = options.serverEntry
82
73
  ? path.resolve(root, options.serverEntry)
83
74
  : 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
- });
75
+ let transport;
76
+ try {
77
+ transport = await moduleTransport(entry);
78
+ }
79
+ catch (error) {
80
+ throw new Error(`${describe(error)} Prerendering renders pages through the server build — make ` +
81
+ `sure an SSR build runs (the server build is a build-time tool here; it need not ` +
82
+ `be deployed) or point \`serverEntry\` at the module.`, { cause: error });
83
+ }
84
+ const { serverEntry: _entry, ...crawl } = options;
85
+ const result = await runPrerender({ ...crawl, mode, transport, outDir: clientOut });
97
86
  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)}`);
87
+ const redirected = result.redirects.length
88
+ ? `, ${result.redirects.length} redirect(s)`
89
+ : "";
90
+ logger.info(`[prerender] rendered ${result.pages.length} page(s) (${written} written)` +
91
+ `${redirected}, ${result.files.length} file(s) emitted -> ${path.relative(root, clientOut)}`);
101
92
  for (const miss of result.skipped) {
102
93
  logger.warn(`[prerender] skipped ${miss.path}: ${describe(miss.error)}`);
103
94
  }
@@ -105,36 +96,4 @@ export function prerender(options = {}) {
105
96
  }
106
97
  };
107
98
  }
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
99
  const describe = (error) => (error instanceof Error ? error.message : String(error));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prerender-crawler",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
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
5
  "license": "MIT",
6
6
  "author": "Ryan Carniato",
@@ -30,8 +30,15 @@
30
30
  "./vite": {
31
31
  "types": "./dist/vite.d.ts",
32
32
  "default": "./dist/vite.js"
33
+ },
34
+ "./announce": {
35
+ "types": "./dist/announce.d.ts",
36
+ "default": "./dist/announce.js"
33
37
  }
34
38
  },
39
+ "bin": {
40
+ "prerender-crawler": "./dist/cli.js"
41
+ },
35
42
  "files": [
36
43
  "dist",
37
44
  "LICENSE",
@@ -41,20 +48,15 @@
41
48
  "node": ">=20"
42
49
  },
43
50
  "peerDependencies": {
44
- "filesystem-routing": ">=0.2.0",
45
51
  "vite": "^7.0.0 || ^8.0.0"
46
52
  },
47
53
  "peerDependenciesMeta": {
48
- "filesystem-routing": {
49
- "optional": true
50
- },
51
54
  "vite": {
52
55
  "optional": true
53
56
  }
54
57
  },
55
58
  "devDependencies": {
56
59
  "@types/node": "^22.0.0",
57
- "filesystem-routing": "0.2.1",
58
60
  "typescript": "^5.8.0",
59
61
  "vite": "^8.0.0",
60
62
  "vitest": "^4.0.0"
@@ -1,35 +0,0 @@
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;