snappy-prerender 2.6.0-rc.3 → 2.6.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/CHANGELOG.md +140 -0
- package/README.md +42 -21
- package/package.json +8 -5
- package/src/cli.js +95 -9
- package/src/core/artifacts.js +110 -0
- package/src/core/blocking.js +53 -1
- package/src/core/browser.js +109 -39
- package/src/core/build-origin.js +19 -0
- package/src/core/config.js +128 -2
- package/src/core/css-url.js +34 -0
- package/src/core/destination.js +28 -3
- package/src/core/init.js +44 -1
- package/src/core/inline-css.js +34 -13
- package/src/core/metadata-origin.js +11 -2
- package/src/core/normalize.js +52 -2
- package/src/core/output.js +32 -9
- package/src/core/page-content.js +13 -0
- package/src/core/preload-manifest.js +61 -7
- package/src/core/prune.js +65 -0
- package/src/core/render.js +64 -10
- package/src/core/resources.js +1 -0
- package/src/core/routes.js +9 -0
- package/src/core/run.js +177 -304
- package/src/core/server.js +30 -4
- package/src/core/sitemap.js +49 -17
- package/src/core/url-policy.js +32 -0
- package/src/core/verify.js +14 -4
- package/src/core/write.js +278 -0
- package/src/head/index.js +1 -1
- package/src/head/tags.js +19 -26
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { writeFileIfChanged } from './output.js';
|
|
4
|
+
import { buildPreloadManifest } from './preload-manifest.js';
|
|
5
|
+
import { toPublicPath } from './routes.js';
|
|
6
|
+
import { buildSitemap, withSitemapDirective } from './sitemap.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Writes the preload manifest: per route, the scripts and stylesheets the final HTML
|
|
10
|
+
* references, as Link header values a host can serve for Early Hints or HTTP headers.
|
|
11
|
+
*/
|
|
12
|
+
export async function writePreloadManifest({ manifestEntries, outputDir, config, log, report }) {
|
|
13
|
+
const manifest = buildPreloadManifest(manifestEntries, {
|
|
14
|
+
ignoreForPreload: config.ignoreForPreload,
|
|
15
|
+
base: config.base,
|
|
16
|
+
flatOutput: config.flatOutput,
|
|
17
|
+
trailingSlash: config.metadata?.trailingSlash,
|
|
18
|
+
notFoundRoute: config.notFoundRoute,
|
|
19
|
+
});
|
|
20
|
+
const file = 'preload-manifest.json';
|
|
21
|
+
const contents = `${JSON.stringify(manifest, null, 2)}\n`;
|
|
22
|
+
if (config.dryRun) {
|
|
23
|
+
report.files.push({ route: null, file, status: 'dry-run', bytes: Buffer.byteLength(contents) });
|
|
24
|
+
log.info(`Dry run: would write preload manifest covering ${manifest.length} route(s)`);
|
|
25
|
+
return { file, routes: manifest.length, status: 'dry-run' };
|
|
26
|
+
}
|
|
27
|
+
const written = await writeFileIfChanged({ dir: outputDir, file, contents });
|
|
28
|
+
report.files.push({ route: null, ...written });
|
|
29
|
+
log.info(
|
|
30
|
+
`${written.status === 'unchanged' ? 'Unchanged' : 'Wrote'} preload manifest covering ${manifest.length} route(s)`,
|
|
31
|
+
);
|
|
32
|
+
return { file, routes: manifest.length, status: written.status };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Writes sitemap.xml from the routes the run produced, and points an existing robots.txt at
|
|
37
|
+
* it. Every loc must be absolute, which is why the option needs metadata.siteUrl.
|
|
38
|
+
*/
|
|
39
|
+
export async function writeSitemap({ outputDir, config, log, report }) {
|
|
40
|
+
if (config.saveAs !== 'html') {
|
|
41
|
+
log.warn(
|
|
42
|
+
`sitemap is skipped when saveAs writes ${config.saveAs} files, as there are no pages to list`,
|
|
43
|
+
);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const sitemap = buildSitemap({ files: report.files, config });
|
|
47
|
+
if (!sitemap) {
|
|
48
|
+
log.warn('sitemap is skipped: no pages were written');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const siteUrl = config.metadata.siteUrl.replace(/\/$/, '');
|
|
53
|
+
const sitemapUrl = `${siteUrl}${toPublicPath('/sitemap.xml', config.base)}`;
|
|
54
|
+
const robots = await fs.readFile(path.join(outputDir, 'robots.txt'), 'utf8').catch(() => null);
|
|
55
|
+
const patched = robots === null ? null : withSitemapDirective(robots, sitemapUrl);
|
|
56
|
+
const names = sitemap.files.map((entry) => entry.name);
|
|
57
|
+
|
|
58
|
+
if (config.dryRun) {
|
|
59
|
+
for (const entry of sitemap.files) {
|
|
60
|
+
report.files.push({
|
|
61
|
+
route: null,
|
|
62
|
+
file: entry.name,
|
|
63
|
+
status: 'dry-run',
|
|
64
|
+
bytes: Buffer.byteLength(entry.contents),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
report.sitemap = {
|
|
68
|
+
file: 'sitemap.xml',
|
|
69
|
+
routes: sitemap.routes,
|
|
70
|
+
robotsUpdated: patched !== null,
|
|
71
|
+
files: names,
|
|
72
|
+
};
|
|
73
|
+
log.info(`Dry run: would write ${names.join(', ')} listing ${sitemap.routes} route(s)`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const statuses = [];
|
|
78
|
+
for (const entry of sitemap.files) {
|
|
79
|
+
const written = await writeFileIfChanged({
|
|
80
|
+
dir: outputDir,
|
|
81
|
+
file: entry.name,
|
|
82
|
+
contents: entry.contents,
|
|
83
|
+
});
|
|
84
|
+
statuses.push(written.status);
|
|
85
|
+
report.files.push({ route: null, ...written });
|
|
86
|
+
}
|
|
87
|
+
report.sitemap = {
|
|
88
|
+
file: 'sitemap.xml',
|
|
89
|
+
routes: sitemap.routes,
|
|
90
|
+
robotsUpdated: patched !== null,
|
|
91
|
+
files: names,
|
|
92
|
+
};
|
|
93
|
+
log.info(
|
|
94
|
+
`${statuses.every((status) => status === 'unchanged') ? 'Unchanged' : 'Wrote'} ${names.join(', ')} listing ${sitemap.routes} route(s)`,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
if (robots === null) {
|
|
98
|
+
log.info(`Add "Sitemap: ${sitemapUrl}" to a robots.txt so crawlers find it`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (patched !== null) {
|
|
102
|
+
const written = await writeFileIfChanged({
|
|
103
|
+
dir: outputDir,
|
|
104
|
+
file: 'robots.txt',
|
|
105
|
+
contents: patched,
|
|
106
|
+
});
|
|
107
|
+
report.files.push({ route: null, ...written });
|
|
108
|
+
log.info('Added the sitemap location to robots.txt');
|
|
109
|
+
}
|
|
110
|
+
}
|
package/src/core/blocking.js
CHANGED
|
@@ -1,3 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reports whether a host is on the allowlist. Entries may be a hostname, a hostname
|
|
3
|
+
* with a port, a leading-dot or wildcard suffix, or a full URL; matching is
|
|
4
|
+
* case-insensitive.
|
|
5
|
+
*/
|
|
6
|
+
export function isHostAllowed(hostname, port, allowedHosts) {
|
|
7
|
+
const host = hostname.toLowerCase();
|
|
8
|
+
const withPort = port ? `${host}:${port}` : host;
|
|
9
|
+
for (const raw of allowedHosts) {
|
|
10
|
+
const entry = String(raw).trim().toLowerCase();
|
|
11
|
+
if (entry.length === 0) continue;
|
|
12
|
+
if (entry.includes('://')) {
|
|
13
|
+
try {
|
|
14
|
+
const parsed = new URL(entry);
|
|
15
|
+
if (parsed.hostname.toLowerCase() === host && (!parsed.port || parsed.port === port)) {
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
} catch {
|
|
19
|
+
// A malformed entry is simply not a match.
|
|
20
|
+
}
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (entry === withPort || entry === host) return true;
|
|
24
|
+
if (entry.startsWith('*.') && host.endsWith(entry.slice(1)) && host !== entry.slice(2)) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
if (entry.startsWith('.') && host.endsWith(entry) && host !== entry.slice(1)) {
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
1
34
|
/**
|
|
2
35
|
* Aborts requests to third-party origins so analytics, ads and external widgets
|
|
3
36
|
* cannot mutate the DOM mid-capture or make output non-deterministic. Used by both
|
|
@@ -15,11 +48,30 @@ export async function blockThirdPartyRequests(page, origin, allowedHosts) {
|
|
|
15
48
|
return allow(route);
|
|
16
49
|
}
|
|
17
50
|
if (parsed.origin === origin) return allow(route);
|
|
18
|
-
if (
|
|
51
|
+
if (isHostAllowed(parsed.hostname, parsed.port, allowed)) return allow(route);
|
|
19
52
|
// A route can already be handled or cancelled, and that rejection is not this hook's to
|
|
20
53
|
// report, so it is swallowed rather than surfacing as an unhandled rejection.
|
|
21
54
|
return route.abort().catch(() => {});
|
|
22
55
|
});
|
|
56
|
+
|
|
57
|
+
// page.route does not see WebSockets; route them separately when the Playwright
|
|
58
|
+
// version provides the hook.
|
|
59
|
+
if (typeof page.routeWebSocket === 'function') {
|
|
60
|
+
await page.routeWebSocket('**/*', (socket) => {
|
|
61
|
+
let parsed;
|
|
62
|
+
try {
|
|
63
|
+
parsed = new URL(socket.url());
|
|
64
|
+
} catch {
|
|
65
|
+
socket.close();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (parsed.origin === origin || isHostAllowed(parsed.hostname, parsed.port, allowed)) {
|
|
69
|
+
socket.connectToServer();
|
|
70
|
+
} else {
|
|
71
|
+
socket.close();
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
23
75
|
}
|
|
24
76
|
|
|
25
77
|
// Continues a request, ignoring the rejection that follows an already-handled route.
|
package/src/core/browser.js
CHANGED
|
@@ -1,14 +1,26 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import * as browsers from '@puppeteer/browsers';
|
|
5
4
|
import { chromium } from 'playwright-core';
|
|
6
5
|
import { isExecutablePath } from './config.js';
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* Lists the resolution steps for a browser option. Explicit choices only ever try the
|
|
9
|
+
* browser they name, so a machine without Chrome cannot silently prerender in Edge;
|
|
10
|
+
* `auto` is the only choice that falls through the machine, the Playwright cache, a
|
|
11
|
+
* cached Chrome for Testing, and finally a download.
|
|
12
|
+
*/
|
|
13
|
+
export function browserSteps(browser) {
|
|
14
|
+
if (browser === 'auto') return ['chrome', 'msedge', 'playwright', 'cache', 'download'];
|
|
15
|
+
if (browser === 'chromium') return ['playwright'];
|
|
16
|
+
if (browser === 'chrome' || browser === 'msedge') return [browser];
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolves a usable Chromium-compatible browser. Explicit channel choices fail rather
|
|
22
|
+
* than falling back, while `auto` prefers the machine's browser, then any cached
|
|
23
|
+
* download, then a one-time Chrome for Testing install.
|
|
12
24
|
*/
|
|
13
25
|
export async function launchBrowser(config, log) {
|
|
14
26
|
if (isExecutablePath(config.browser)) {
|
|
@@ -19,40 +31,54 @@ export async function launchBrowser(config, log) {
|
|
|
19
31
|
}
|
|
20
32
|
|
|
21
33
|
const attempts = [];
|
|
22
|
-
const
|
|
23
|
-
config.browser === 'auto'
|
|
24
|
-
? ['chrome', 'msedge']
|
|
25
|
-
: config.browser === 'chromium'
|
|
26
|
-
? []
|
|
27
|
-
: [config.browser];
|
|
28
|
-
|
|
29
|
-
for (const channel of channels) {
|
|
34
|
+
for (const step of browserSteps(config.browser)) {
|
|
30
35
|
try {
|
|
31
|
-
const
|
|
32
|
-
|
|
36
|
+
const launched = await runStep(step, config, log);
|
|
37
|
+
if (launched) return launched;
|
|
33
38
|
} catch (error) {
|
|
34
|
-
attempts.push(`${
|
|
39
|
+
attempts.push(`${step}: ${firstLine(error.message)}`);
|
|
35
40
|
}
|
|
36
41
|
}
|
|
37
42
|
|
|
38
|
-
try {
|
|
39
|
-
const browser = await launch(config, {});
|
|
40
|
-
return { browser, source: 'playwright cache' };
|
|
41
|
-
} catch (error) {
|
|
42
|
-
attempts.push(`playwright cache: ${firstLine(error.message)}`);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
43
|
if (!config.browserDownload) {
|
|
46
44
|
throw new Error(
|
|
47
45
|
`No usable browser found and browserDownload is disabled. Tried:\n ${attempts.join('\n ')}`,
|
|
48
46
|
);
|
|
49
47
|
}
|
|
48
|
+
if (config.browser === 'chromium') {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`No Playwright-managed Chromium is installed. Run "npx playwright install chromium", or set browser to auto or chrome. Tried:\n ${attempts.join('\n ')}`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
throw new Error(`No usable browser found. Tried:\n ${attempts.join('\n ')}`);
|
|
54
|
+
}
|
|
50
55
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
// Runs one browser resolution step and returns the launched browser, or null when the
|
|
57
|
+
// step has nothing to offer on this machine.
|
|
58
|
+
async function runStep(step, config, log) {
|
|
59
|
+
if (step === 'chrome' || step === 'msedge') {
|
|
60
|
+
return { browser: await launch(config, { channel: step }), source: `system ${step}` };
|
|
61
|
+
}
|
|
62
|
+
if (step === 'playwright') {
|
|
63
|
+
return { browser: await launch(config, {}), source: 'playwright cache' };
|
|
64
|
+
}
|
|
65
|
+
if (step === 'cache') {
|
|
66
|
+
const cached = await findCachedChrome(config);
|
|
67
|
+
if (!cached) return null;
|
|
68
|
+
return {
|
|
69
|
+
browser: await launch(config, { executablePath: cached.executablePath }),
|
|
70
|
+
source: `cached Chrome for Testing ${cached.buildId}`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (step === 'download') {
|
|
74
|
+
if (!config.browserDownload) return null;
|
|
75
|
+
const executablePath = await downloadChrome(config, log);
|
|
76
|
+
return {
|
|
77
|
+
browser: await launch(config, { executablePath }),
|
|
78
|
+
source: `downloaded ${executablePath}`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
56
82
|
}
|
|
57
83
|
|
|
58
84
|
// Launches Chromium with the configured headless mode, extra args and target.
|
|
@@ -70,20 +96,64 @@ function firstLine(message) {
|
|
|
70
96
|
}
|
|
71
97
|
|
|
72
98
|
/**
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
99
|
+
* Chooses a cached Chrome for Testing install. Kept pure so the offline/version-pinned
|
|
100
|
+
* selection can be tested without touching the machine's browser cache.
|
|
101
|
+
*/
|
|
102
|
+
export function selectCachedChrome(
|
|
103
|
+
installed,
|
|
104
|
+
{ browser = 'chrome', platform, buildId = null, executableExists = () => true },
|
|
105
|
+
) {
|
|
106
|
+
return (
|
|
107
|
+
installed.find(
|
|
108
|
+
(entry) =>
|
|
109
|
+
entry.browser === browser &&
|
|
110
|
+
entry.platform === platform &&
|
|
111
|
+
(!buildId || entry.buildId === buildId) &&
|
|
112
|
+
executableExists(entry.executablePath),
|
|
113
|
+
) ?? null
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Formats download progress for the debug log; a missing content length reports 0%.
|
|
118
|
+
export function downloadPercent(downloaded, total) {
|
|
119
|
+
return total > 0 ? Math.round((downloaded / total) * 100) : 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Finds a Chrome for Testing build already present in the tool's cache, so a rebuild
|
|
124
|
+
* never needs the version feed or a download when a browser is available offline.
|
|
125
|
+
* Honours browserDownloadVersion when one is configured.
|
|
126
|
+
*/
|
|
127
|
+
async function findCachedChrome(config) {
|
|
128
|
+
const browsers = await import('@puppeteer/browsers');
|
|
129
|
+
const platform = browsers.detectBrowserPlatform();
|
|
130
|
+
if (!platform) return null;
|
|
131
|
+
const installed = browsers.getInstalledBrowsers({ cacheDir: browserCacheDir() });
|
|
132
|
+
return selectCachedChrome(installed, {
|
|
133
|
+
browser: browsers.Browser.CHROME,
|
|
134
|
+
platform,
|
|
135
|
+
buildId: config.browserDownloadVersion,
|
|
136
|
+
executableExists: fs.existsSync,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Downloads Chrome for Testing into the tool's own cache directory. The archive comes
|
|
142
|
+
* from Google's version-pinned HTTPS bucket via @puppeteer/browsers, which verifies it
|
|
143
|
+
* against browserDownloadHash when one is configured and deletes it on a mismatch.
|
|
144
|
+
* Without a configured hash the download is trusted on the strength of HTTPS alone,
|
|
145
|
+
* which is logged as a warning.
|
|
79
146
|
*/
|
|
80
147
|
async function downloadChrome(config, log) {
|
|
148
|
+
const browsers = await import('@puppeteer/browsers');
|
|
149
|
+
const platform = browsers.detectBrowserPlatform();
|
|
150
|
+
if (!platform) {
|
|
151
|
+
throw new Error(`Cannot download a browser for ${os.platform()} (${os.arch()})`);
|
|
152
|
+
}
|
|
81
153
|
const cacheDir = browserCacheDir();
|
|
82
|
-
const buildId =
|
|
83
|
-
|
|
84
|
-
browsers.
|
|
85
|
-
'stable',
|
|
86
|
-
);
|
|
154
|
+
const buildId =
|
|
155
|
+
config.browserDownloadVersion ??
|
|
156
|
+
(await browsers.resolveBuildId(browsers.Browser.CHROME, platform, 'stable'));
|
|
87
157
|
if (config.browserDownloadHash) {
|
|
88
158
|
log.info(
|
|
89
159
|
`Downloading Chrome for Testing ${buildId}, verifying SHA-256 against browserDownloadHash`,
|
|
@@ -99,7 +169,7 @@ async function downloadChrome(config, log) {
|
|
|
99
169
|
cacheDir,
|
|
100
170
|
expectedHash: config.browserDownloadHash ?? undefined,
|
|
101
171
|
downloadProgressCallback: (downloaded, total) =>
|
|
102
|
-
log.debug(` downloaded ${
|
|
172
|
+
log.debug(` downloaded ${downloadPercent(downloaded, total)}%`),
|
|
103
173
|
});
|
|
104
174
|
if (!fs.existsSync(installed.executablePath)) {
|
|
105
175
|
throw new Error(`Chrome download reported success but ${installed.executablePath} is missing`);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { parse } from 'parse5';
|
|
2
|
+
import { collectBuildOrigins, isMetadataUrlNode, metadataUrlValue } from './metadata-origin.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Collects build-server origins from the metadata URLs in a serialized document.
|
|
6
|
+
* Only canonical and URL-bearing social tags are inspected, so a loopback API URL in
|
|
7
|
+
* application state does not trip the optional build-origin gate.
|
|
8
|
+
*/
|
|
9
|
+
export function findBuildOriginUrls(html) {
|
|
10
|
+
const origins = new Set();
|
|
11
|
+
const walk = (node) => {
|
|
12
|
+
if (isMetadataUrlNode(node)) {
|
|
13
|
+
for (const origin of collectBuildOrigins(metadataUrlValue(node))) origins.add(origin);
|
|
14
|
+
}
|
|
15
|
+
for (const child of node.childNodes ?? []) walk(child);
|
|
16
|
+
};
|
|
17
|
+
walk(parse(html));
|
|
18
|
+
return [...origins].slice(0, 3);
|
|
19
|
+
}
|
package/src/core/config.js
CHANGED
|
@@ -24,6 +24,7 @@ const NULLABLE_KEYS = [
|
|
|
24
24
|
'userAgent',
|
|
25
25
|
'destination',
|
|
26
26
|
'browserDownloadHash',
|
|
27
|
+
'browserDownloadVersion',
|
|
27
28
|
'url',
|
|
28
29
|
'serveCmd',
|
|
29
30
|
];
|
|
@@ -39,6 +40,7 @@ const ARRAY_KEYS = [
|
|
|
39
40
|
export const DEFAULTS = Object.freeze({
|
|
40
41
|
sourceDir: 'dist',
|
|
41
42
|
destination: null,
|
|
43
|
+
cleanDestination: false,
|
|
42
44
|
base: '/',
|
|
43
45
|
include: ['/'],
|
|
44
46
|
exclude: [],
|
|
@@ -59,6 +61,7 @@ export const DEFAULTS = Object.freeze({
|
|
|
59
61
|
browser: 'auto',
|
|
60
62
|
browserDownload: true,
|
|
61
63
|
browserDownloadHash: null,
|
|
64
|
+
browserDownloadVersion: null,
|
|
62
65
|
browserArgs: [],
|
|
63
66
|
headless: true,
|
|
64
67
|
ignoreHTTPSErrors: false,
|
|
@@ -94,6 +97,15 @@ export const DEFAULTS = Object.freeze({
|
|
|
94
97
|
failOnHydrationError: false,
|
|
95
98
|
failOnRerender: false,
|
|
96
99
|
failOnPageError: false,
|
|
100
|
+
failOnEmptyPage: true,
|
|
101
|
+
failOnUndetected: false,
|
|
102
|
+
failOnVerificationError: false,
|
|
103
|
+
failOnConsoleError: false,
|
|
104
|
+
failOnFailedRequest: false,
|
|
105
|
+
failOnDuplicateContent: false,
|
|
106
|
+
stripBuildOriginMetadata: true,
|
|
107
|
+
failOnBuildOriginMetadata: false,
|
|
108
|
+
pruneStaleRoutes: false,
|
|
97
109
|
metadata: null,
|
|
98
110
|
failOnError: true,
|
|
99
111
|
dryRun: false,
|
|
@@ -107,6 +119,19 @@ export const DEFAULTS = Object.freeze({
|
|
|
107
119
|
// below decide what is allowed.
|
|
108
120
|
const MULTI_TYPE_KEYS = new Set(['inlineCss', 'minifyHtml', 'minifyCss']);
|
|
109
121
|
|
|
122
|
+
// Freeze the nested list defaults as well, so a consumer cannot mutate the defaults
|
|
123
|
+
// used by every later run in the same process.
|
|
124
|
+
for (const key of [
|
|
125
|
+
'include',
|
|
126
|
+
'exclude',
|
|
127
|
+
'allowedHosts',
|
|
128
|
+
'browserArgs',
|
|
129
|
+
'ignoreForPreload',
|
|
130
|
+
'removeAttributes',
|
|
131
|
+
]) {
|
|
132
|
+
Object.freeze(DEFAULTS[key]);
|
|
133
|
+
}
|
|
134
|
+
|
|
110
135
|
// Every boolean option is validated, derived from the defaults so a new flag cannot be
|
|
111
136
|
// forgotten in a hand-maintained list.
|
|
112
137
|
const BOOLEAN_KEYS = Object.entries(DEFAULTS)
|
|
@@ -126,7 +151,9 @@ export function resolveConfig(userOptions = {}) {
|
|
|
126
151
|
config.concurrency =
|
|
127
152
|
userOptions.concurrency ??
|
|
128
153
|
Math.max(1, Math.min(os.availableParallelism(), MAX_DEFAULT_CONCURRENCY));
|
|
129
|
-
config.includeProvided =
|
|
154
|
+
config.includeProvided =
|
|
155
|
+
userOptions.include !== undefined &&
|
|
156
|
+
!(config.include.length === 1 && config.include[0] === '/');
|
|
130
157
|
// The CSP dial decides the defaults for the pieces that can move. Explicit options still
|
|
131
158
|
// win, so a project can take 'strict' and put one part back.
|
|
132
159
|
if (userOptions.externalScripts === undefined) config.externalScripts = config.csp === 'strict';
|
|
@@ -135,9 +162,87 @@ export function resolveConfig(userOptions = {}) {
|
|
|
135
162
|
}
|
|
136
163
|
config.warnings = [];
|
|
137
164
|
validateConfig(config);
|
|
165
|
+
addConfigWarnings(config, userOptions);
|
|
138
166
|
return config;
|
|
139
167
|
}
|
|
140
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Reports options that do nothing in the current combination, so a build does not
|
|
171
|
+
* quietly skip a gate the caller believes is active.
|
|
172
|
+
*/
|
|
173
|
+
function addConfigWarnings(config, userOptions) {
|
|
174
|
+
const warn = (message) => config.warnings.push(message);
|
|
175
|
+
for (const key of Object.keys(userOptions)) {
|
|
176
|
+
if (!Object.hasOwn(DEFAULTS, key)) {
|
|
177
|
+
warn(`unknown option "${key}" is ignored; check the spelling`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (!config.verify) {
|
|
181
|
+
for (const key of [
|
|
182
|
+
'verifyChangedOnly',
|
|
183
|
+
'failOnHydrationError',
|
|
184
|
+
'failOnRerender',
|
|
185
|
+
'failOnUndetected',
|
|
186
|
+
'failOnVerificationError',
|
|
187
|
+
]) {
|
|
188
|
+
if (config[key]) warn(`${key} has no effect because verify is false`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (config.verify && (config.serveCmd || config.saveAs !== 'html')) {
|
|
192
|
+
for (const key of [
|
|
193
|
+
'failOnHydrationError',
|
|
194
|
+
'failOnRerender',
|
|
195
|
+
'failOnUndetected',
|
|
196
|
+
'failOnVerificationError',
|
|
197
|
+
]) {
|
|
198
|
+
if (config[key]) {
|
|
199
|
+
warn(
|
|
200
|
+
`${key} has no effect because hydration verification is skipped with ${config.serveCmd ? 'serveCmd' : `saveAs: '${config.saveAs}'`}`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (
|
|
206
|
+
userOptions.criticalCssPreload !== undefined &&
|
|
207
|
+
config.criticalCssPreload !== false &&
|
|
208
|
+
config.inlineCss !== 'critical'
|
|
209
|
+
) {
|
|
210
|
+
warn('criticalCssPreload has no effect unless inlineCss is "critical"');
|
|
211
|
+
}
|
|
212
|
+
if (config.minifyCss && !config.minifyHtml && !config.inlineCss) {
|
|
213
|
+
warn('minifyCss has no effect unless minifyHtml or inlineCss is enabled');
|
|
214
|
+
}
|
|
215
|
+
if (config.allowedHosts.length > 0 && !config.blockThirdParty) {
|
|
216
|
+
warn('allowedHosts has no effect unless blockThirdParty is true');
|
|
217
|
+
}
|
|
218
|
+
if (config.csp === 'strict' && config.inlineCss) {
|
|
219
|
+
warn(
|
|
220
|
+
"csp controls script-src; inlineCss emits inline styles, which need style-src 'unsafe-inline' or hashes",
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
if (
|
|
224
|
+
config.csp === 'strict' &&
|
|
225
|
+
userOptions.criticalCssPreload !== undefined &&
|
|
226
|
+
config.criticalCssPreload !== false
|
|
227
|
+
) {
|
|
228
|
+
warn(
|
|
229
|
+
"criticalCssPreload emits an inline onload handler that a script-src 'self' policy blocks",
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
if (config.removeStyleTags && config.inlineCss === 'critical') {
|
|
233
|
+
warn('removeStyleTags removes CSS-in-JS styles before critical CSS is extracted');
|
|
234
|
+
}
|
|
235
|
+
if (config.sitemap && config.saveAs !== 'html') {
|
|
236
|
+
warn(`sitemap has no effect when saveAs is '${config.saveAs}'`);
|
|
237
|
+
}
|
|
238
|
+
const metadataBase = config.metadata?.base;
|
|
239
|
+
if (metadataBase && metadataBase !== config.base) {
|
|
240
|
+
warn(
|
|
241
|
+
`metadata.base (${metadataBase}) differs from base (${config.base}); canonical, social URLs and routing will disagree`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
141
246
|
/**
|
|
142
247
|
* Ensures the base path always carries a leading and trailing slash so route and
|
|
143
248
|
* URL helpers can strip or append it without branching on user input shape.
|
|
@@ -201,7 +306,7 @@ export function validateConfig(config) {
|
|
|
201
306
|
stringList(config.ignoreForPreload, 'ignoreForPreload');
|
|
202
307
|
patternList(config.removeAttributes, 'removeAttributes');
|
|
203
308
|
for (const key of ['readyFlag', 'readySelector', 'waitFor', 'notFoundRoute']) nullableString(key);
|
|
204
|
-
for (const key of ['userAgent', 'destination']) nullableString(key);
|
|
309
|
+
for (const key of ['userAgent', 'destination', 'browserDownloadVersion']) nullableString(key);
|
|
205
310
|
for (const key of BOOLEAN_KEYS) booleanOption(key);
|
|
206
311
|
|
|
207
312
|
if (config.browserDownloadHash !== null && !/^[a-f0-9]{64}$/i.test(config.browserDownloadHash)) {
|
|
@@ -257,6 +362,14 @@ export function validateConfig(config) {
|
|
|
257
362
|
if (config.url !== null && !/^https?:\/\//.test(config.url)) {
|
|
258
363
|
throw new TypeError('url must be an absolute http(s) URL');
|
|
259
364
|
}
|
|
365
|
+
if (config.serveCmd !== null && config.url) {
|
|
366
|
+
const parsed = new URL(config.url);
|
|
367
|
+
if (parsed.pathname !== '/' && parsed.pathname !== '') {
|
|
368
|
+
throw new TypeError(
|
|
369
|
+
'url must be an origin without a path when serveCmd is used; set base for a sub-path deployment',
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
260
373
|
if (!INLINE_CSS_STRATEGIES.includes(config.inlineCss)) {
|
|
261
374
|
throw new TypeError("inlineCss must be false, true, 'inline' or 'critical'");
|
|
262
375
|
}
|
|
@@ -281,6 +394,11 @@ export function validateConfig(config) {
|
|
|
281
394
|
if (!SAVE_AS_CHOICES.includes(config.saveAs)) {
|
|
282
395
|
throw new TypeError(`saveAs must be one of: ${SAVE_AS_CHOICES.join(', ')}`);
|
|
283
396
|
}
|
|
397
|
+
if (config.removeStyleTags && (config.inlineCss === true || config.inlineCss === 'inline')) {
|
|
398
|
+
throw new TypeError(
|
|
399
|
+
"removeStyleTags cannot be combined with inlineCss 'inline' or true: it would strip the CSS the inline step just added",
|
|
400
|
+
);
|
|
401
|
+
}
|
|
284
402
|
validateMetadata(config);
|
|
285
403
|
if (config.sitemap && !config.metadata?.siteUrl) {
|
|
286
404
|
throw new TypeError('sitemap needs metadata.siteUrl so the sitemap can list absolute URLs');
|
|
@@ -319,6 +437,14 @@ function validateMetadata(config) {
|
|
|
319
437
|
if (metadata.siteUrl && !/^https?:\/\//.test(metadata.siteUrl)) {
|
|
320
438
|
throw new TypeError('metadata.siteUrl must be an absolute http(s) URL');
|
|
321
439
|
}
|
|
440
|
+
if (metadata.siteUrl) {
|
|
441
|
+
const parsed = new URL(metadata.siteUrl);
|
|
442
|
+
if (parsed.pathname !== '/' && parsed.pathname !== '') {
|
|
443
|
+
throw new TypeError(
|
|
444
|
+
'metadata.siteUrl must be an origin without a path; set base for a sub-path deployment',
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
322
448
|
if (metadata.base && !metadata.base.startsWith('/')) {
|
|
323
449
|
throw new TypeError('metadata.base must start with a slash, for example /app/');
|
|
324
450
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const URL_TOKEN = /url\(\s*(['"]?)([^'")]+)\1\s*\)/gi;
|
|
2
|
+
const NON_LOCAL = /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Rebases the relative url() values in a stylesheet against the stylesheet's own URL.
|
|
6
|
+
* Moving a sheet from /assets/app.css into a <style> element would otherwise resolve
|
|
7
|
+
* its fonts and images against the page URL, which differs on nested routes. Local
|
|
8
|
+
* URLs become root-relative so they work from any page depth.
|
|
9
|
+
*/
|
|
10
|
+
export function rewriteCssUrls(css, stylesheetUrl, pageOrigin) {
|
|
11
|
+
return css.replace(URL_TOKEN, (match, quote, value) => {
|
|
12
|
+
const target = value.trim();
|
|
13
|
+
if (target.length === 0 || NON_LOCAL.test(target)) return match;
|
|
14
|
+
let resolved;
|
|
15
|
+
try {
|
|
16
|
+
resolved = new URL(target, stylesheetUrl);
|
|
17
|
+
} catch {
|
|
18
|
+
return match;
|
|
19
|
+
}
|
|
20
|
+
const next =
|
|
21
|
+
resolved.origin === pageOrigin
|
|
22
|
+
? `${resolved.pathname}${resolved.search}${resolved.hash}`
|
|
23
|
+
: resolved.href;
|
|
24
|
+
return `url(${quote}${next}${quote})`;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Reports whether a stylesheet uses @import. Inlining cannot preserve the rule's
|
|
30
|
+
* position in the cascade, so the caller leaves that sheet as a link instead.
|
|
31
|
+
*/
|
|
32
|
+
export function hasImportRule(css) {
|
|
33
|
+
return /@import\b/i.test(css);
|
|
34
|
+
}
|
package/src/core/destination.js
CHANGED
|
@@ -7,9 +7,34 @@ import path from 'node:path';
|
|
|
7
7
|
* HTML and the source directory stays untouched. An existing destination is reported
|
|
8
8
|
* before it is overwritten, and entries that cannot be copied are counted.
|
|
9
9
|
*/
|
|
10
|
-
export async function prepareDestination({
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
export async function prepareDestination({
|
|
11
|
+
sourceDir,
|
|
12
|
+
destination,
|
|
13
|
+
log,
|
|
14
|
+
cleanDestination = false,
|
|
15
|
+
}) {
|
|
16
|
+
if (!destination) return path.resolve(sourceDir);
|
|
17
|
+
const source = path.resolve(sourceDir);
|
|
18
|
+
const target = path.resolve(destination);
|
|
19
|
+
if (target === source) return source;
|
|
20
|
+
if (target.startsWith(`${source}${path.sep}`)) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
`destination cannot be inside sourceDir: ${target} is inside ${source}. Use a sibling directory.`,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
if (cleanDestination) {
|
|
26
|
+
const sourceInsideTarget = path.relative(target, source);
|
|
27
|
+
if (
|
|
28
|
+
sourceInsideTarget.length > 0 &&
|
|
29
|
+
!sourceInsideTarget.startsWith('..') &&
|
|
30
|
+
!path.isAbsolute(sourceInsideTarget)
|
|
31
|
+
) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`cleanDestination would delete sourceDir: ${source} is inside ${target}. Choose a destination outside the build output.`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
await fs.rm(target, { recursive: true, force: true });
|
|
37
|
+
log?.debug(`cleaned destination ${destination}`);
|
|
13
38
|
}
|
|
14
39
|
const existing = await fs.readdir(destination).catch(() => []);
|
|
15
40
|
if (existing.length > 0) {
|