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
|
@@ -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;
|
package/dist/sitemap.js
ADDED
|
@@ -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, "&")
|
|
84
|
+
.replace(/</g, "<")
|
|
85
|
+
.replace(/>/g, ">")
|
|
86
|
+
.replace(/"/g, """)
|
|
87
|
+
.replace(/'/g, "'");
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
146
|
-
|
|
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 {
|
|
5
|
-
export type {
|
|
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
|
|
32
|
-
* same-origin link discovered along the way) and
|
|
33
|
-
* and whatever the integrations emit — into the
|
|
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
|
|
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)
|
|
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 {
|
|
38
|
-
export {
|
|
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
|
|
44
|
-
* same-origin link discovered along the way) and
|
|
45
|
-
* and whatever the integrations emit — into the
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
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.
|
|
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"
|
package/dist/file-routes.d.ts
DELETED
|
@@ -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;
|