snappy-prerender 2.6.0-rc.2 → 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 +96 -19
- package/package.json +11 -6
- package/src/cli.js +119 -12
- 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 +143 -3
- 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 +71 -1
- 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 +191 -241
- package/src/core/server.js +30 -4
- package/src/core/sitemap.js +94 -0
- package/src/core/url-policy.js +32 -0
- package/src/core/verify.js +18 -4
- package/src/core/write.js +278 -0
- package/src/head/index.js +1 -1
- package/src/head/tags.js +19 -26
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,14 +24,23 @@ const NULLABLE_KEYS = [
|
|
|
24
24
|
'userAgent',
|
|
25
25
|
'destination',
|
|
26
26
|
'browserDownloadHash',
|
|
27
|
+
'browserDownloadVersion',
|
|
27
28
|
'url',
|
|
28
29
|
'serveCmd',
|
|
29
30
|
];
|
|
30
|
-
const ARRAY_KEYS = [
|
|
31
|
+
const ARRAY_KEYS = [
|
|
32
|
+
'include',
|
|
33
|
+
'exclude',
|
|
34
|
+
'allowedHosts',
|
|
35
|
+
'browserArgs',
|
|
36
|
+
'ignoreForPreload',
|
|
37
|
+
'removeAttributes',
|
|
38
|
+
];
|
|
31
39
|
|
|
32
40
|
export const DEFAULTS = Object.freeze({
|
|
33
41
|
sourceDir: 'dist',
|
|
34
42
|
destination: null,
|
|
43
|
+
cleanDestination: false,
|
|
35
44
|
base: '/',
|
|
36
45
|
include: ['/'],
|
|
37
46
|
exclude: [],
|
|
@@ -52,6 +61,7 @@ export const DEFAULTS = Object.freeze({
|
|
|
52
61
|
browser: 'auto',
|
|
53
62
|
browserDownload: true,
|
|
54
63
|
browserDownloadHash: null,
|
|
64
|
+
browserDownloadVersion: null,
|
|
55
65
|
browserArgs: [],
|
|
56
66
|
headless: true,
|
|
57
67
|
ignoreHTTPSErrors: false,
|
|
@@ -68,6 +78,7 @@ export const DEFAULTS = Object.freeze({
|
|
|
68
78
|
minifyHtml: false,
|
|
69
79
|
minifyCss: false,
|
|
70
80
|
removeBlobs: true,
|
|
81
|
+
removeAttributes: [],
|
|
71
82
|
removeStyleTags: false,
|
|
72
83
|
removeScriptTags: false,
|
|
73
84
|
asyncScriptTags: false,
|
|
@@ -75,15 +86,26 @@ export const DEFAULTS = Object.freeze({
|
|
|
75
86
|
preconnectThirdParty: true,
|
|
76
87
|
preloadImages: false,
|
|
77
88
|
preloadManifest: false,
|
|
89
|
+
sitemap: false,
|
|
78
90
|
ignoreForPreload: ['service-worker.js'],
|
|
79
91
|
cacheAjaxRequests: false,
|
|
80
92
|
maxCachedBytes: 5 * 1024 * 1024,
|
|
81
93
|
flatOutput: false,
|
|
82
94
|
notFoundRoute: '/404',
|
|
83
95
|
verify: true,
|
|
96
|
+
verifyChangedOnly: false,
|
|
84
97
|
failOnHydrationError: false,
|
|
85
98
|
failOnRerender: false,
|
|
86
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,
|
|
87
109
|
metadata: null,
|
|
88
110
|
failOnError: true,
|
|
89
111
|
dryRun: false,
|
|
@@ -97,6 +119,19 @@ export const DEFAULTS = Object.freeze({
|
|
|
97
119
|
// below decide what is allowed.
|
|
98
120
|
const MULTI_TYPE_KEYS = new Set(['inlineCss', 'minifyHtml', 'minifyCss']);
|
|
99
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
|
+
|
|
100
135
|
// Every boolean option is validated, derived from the defaults so a new flag cannot be
|
|
101
136
|
// forgotten in a hand-maintained list.
|
|
102
137
|
const BOOLEAN_KEYS = Object.entries(DEFAULTS)
|
|
@@ -116,7 +151,9 @@ export function resolveConfig(userOptions = {}) {
|
|
|
116
151
|
config.concurrency =
|
|
117
152
|
userOptions.concurrency ??
|
|
118
153
|
Math.max(1, Math.min(os.availableParallelism(), MAX_DEFAULT_CONCURRENCY));
|
|
119
|
-
config.includeProvided =
|
|
154
|
+
config.includeProvided =
|
|
155
|
+
userOptions.include !== undefined &&
|
|
156
|
+
!(config.include.length === 1 && config.include[0] === '/');
|
|
120
157
|
// The CSP dial decides the defaults for the pieces that can move. Explicit options still
|
|
121
158
|
// win, so a project can take 'strict' and put one part back.
|
|
122
159
|
if (userOptions.externalScripts === undefined) config.externalScripts = config.csp === 'strict';
|
|
@@ -125,9 +162,87 @@ export function resolveConfig(userOptions = {}) {
|
|
|
125
162
|
}
|
|
126
163
|
config.warnings = [];
|
|
127
164
|
validateConfig(config);
|
|
165
|
+
addConfigWarnings(config, userOptions);
|
|
128
166
|
return config;
|
|
129
167
|
}
|
|
130
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
|
+
|
|
131
246
|
/**
|
|
132
247
|
* Ensures the base path always carries a leading and trailing slash so route and
|
|
133
248
|
* URL helpers can strip or append it without branching on user input shape.
|
|
@@ -189,8 +304,9 @@ export function validateConfig(config) {
|
|
|
189
304
|
stringList(config.allowedHosts, 'allowedHosts');
|
|
190
305
|
stringList(config.browserArgs, 'browserArgs');
|
|
191
306
|
stringList(config.ignoreForPreload, 'ignoreForPreload');
|
|
307
|
+
patternList(config.removeAttributes, 'removeAttributes');
|
|
192
308
|
for (const key of ['readyFlag', 'readySelector', 'waitFor', 'notFoundRoute']) nullableString(key);
|
|
193
|
-
for (const key of ['userAgent', 'destination']) nullableString(key);
|
|
309
|
+
for (const key of ['userAgent', 'destination', 'browserDownloadVersion']) nullableString(key);
|
|
194
310
|
for (const key of BOOLEAN_KEYS) booleanOption(key);
|
|
195
311
|
|
|
196
312
|
if (config.browserDownloadHash !== null && !/^[a-f0-9]{64}$/i.test(config.browserDownloadHash)) {
|
|
@@ -246,6 +362,14 @@ export function validateConfig(config) {
|
|
|
246
362
|
if (config.url !== null && !/^https?:\/\//.test(config.url)) {
|
|
247
363
|
throw new TypeError('url must be an absolute http(s) URL');
|
|
248
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
|
+
}
|
|
249
373
|
if (!INLINE_CSS_STRATEGIES.includes(config.inlineCss)) {
|
|
250
374
|
throw new TypeError("inlineCss must be false, true, 'inline' or 'critical'");
|
|
251
375
|
}
|
|
@@ -270,7 +394,15 @@ export function validateConfig(config) {
|
|
|
270
394
|
if (!SAVE_AS_CHOICES.includes(config.saveAs)) {
|
|
271
395
|
throw new TypeError(`saveAs must be one of: ${SAVE_AS_CHOICES.join(', ')}`);
|
|
272
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
|
+
}
|
|
273
402
|
validateMetadata(config);
|
|
403
|
+
if (config.sitemap && !config.metadata?.siteUrl) {
|
|
404
|
+
throw new TypeError('sitemap needs metadata.siteUrl so the sitemap can list absolute URLs');
|
|
405
|
+
}
|
|
274
406
|
}
|
|
275
407
|
|
|
276
408
|
// The metadata keys the head component reads, so a typo is reported instead of ignored.
|
|
@@ -305,6 +437,14 @@ function validateMetadata(config) {
|
|
|
305
437
|
if (metadata.siteUrl && !/^https?:\/\//.test(metadata.siteUrl)) {
|
|
306
438
|
throw new TypeError('metadata.siteUrl must be an absolute http(s) URL');
|
|
307
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
|
+
}
|
|
308
448
|
if (metadata.base && !metadata.base.startsWith('/')) {
|
|
309
449
|
throw new TypeError('metadata.base must start with a slash, for example /app/');
|
|
310
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) {
|
package/src/core/init.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
2
3
|
import path from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
3
5
|
import { normaliseRoute } from './routes.js';
|
|
4
6
|
|
|
5
7
|
export const CONFIG_FILE = 'snappy.config.js';
|
|
@@ -23,14 +25,55 @@ export async function detectProjectDefaults(dir = process.cwd()) {
|
|
|
23
25
|
const dependencies = { ...manifest?.dependencies, ...manifest?.devDependencies };
|
|
24
26
|
const usesVite = Boolean(dependencies.vite) || (await anyExists(dir, VITE_CONFIGS));
|
|
25
27
|
const usesCreateReactApp = Boolean(dependencies['react-scripts']) && !usesVite;
|
|
28
|
+
const vite = usesVite ? await detectViteConfig(dir) : null;
|
|
26
29
|
return {
|
|
27
|
-
sourceDir: usesCreateReactApp ? 'build' : 'dist',
|
|
30
|
+
sourceDir: vite?.sourceDir ?? (usesCreateReactApp ? 'build' : 'dist'),
|
|
31
|
+
base: vite?.base ?? homepageBase(manifest?.homepage) ?? '/',
|
|
28
32
|
usesVite,
|
|
29
33
|
runner: await detectRunner(dir),
|
|
30
34
|
installed: PACKAGE_NAME in dependencies,
|
|
31
35
|
};
|
|
32
36
|
}
|
|
33
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Reads the project's real Vite output directory and base when Vite can be resolved
|
|
40
|
+
* from that project. Falls back to the conventional defaults when the config is
|
|
41
|
+
* missing, imports an unavailable plugin, or Vite is not installed.
|
|
42
|
+
*/
|
|
43
|
+
async function detectViteConfig(dir) {
|
|
44
|
+
try {
|
|
45
|
+
const require = createRequire(path.join(dir, 'package.json'));
|
|
46
|
+
const entry = require.resolve('vite');
|
|
47
|
+
const { resolveConfig } = await import(pathToFileURL(entry).href);
|
|
48
|
+
const resolved = await resolveConfig({ root: dir, logLevel: 'silent' }, 'build');
|
|
49
|
+
const relative = path.relative(dir, resolved.build.outDir);
|
|
50
|
+
return {
|
|
51
|
+
sourceDir: relative.startsWith('..') ? resolved.build.outDir : relative || '.',
|
|
52
|
+
base: detectedBase(resolved.base),
|
|
53
|
+
};
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Keeps a detected base usable by normaliseBase: only root-relative bases apply here.
|
|
60
|
+
function detectedBase(base) {
|
|
61
|
+
if (typeof base !== 'string' || !base.startsWith('/') || base.startsWith('//')) return '/';
|
|
62
|
+
return base.endsWith('/') ? base : `${base}/`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Reads Create React App's homepage into a base path, ignoring absolute deployments
|
|
66
|
+
// and values that are not a path.
|
|
67
|
+
function homepageBase(homepage) {
|
|
68
|
+
if (typeof homepage !== 'string' || homepage.length === 0) return null;
|
|
69
|
+
try {
|
|
70
|
+
const url = new URL(homepage, 'https://example.invalid');
|
|
71
|
+
return url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`;
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
34
77
|
/**
|
|
35
78
|
* Normalises a comma separated route list into include patterns, keeping wildcards as the
|
|
36
79
|
* user wrote them and falling back to the root route so the config always renders something.
|
package/src/core/inline-css.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { hasImportRule, rewriteCssUrls } from './css-url.js';
|
|
1
2
|
import { minifyCss } from './minify.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Inlines every same-origin stylesheet the page loaded, replacing the links with a
|
|
5
6
|
* single style tag. Blob-backed stylesheets work too because the text is read from
|
|
6
|
-
* the live page. Stylesheets that cannot be fetched are left in
|
|
7
|
+
* the live page. Stylesheets that cannot be fetched, or that use @import, are left in
|
|
8
|
+
* place. Relative url() values are rebased against each sheet before joining so fonts
|
|
9
|
+
* and images keep working on nested routes.
|
|
7
10
|
*/
|
|
8
11
|
export async function inlineStylesheets({ page, minifyCssOptions }) {
|
|
9
12
|
const collected = await page.evaluate(async () => {
|
|
@@ -11,7 +14,7 @@ export async function inlineStylesheets({ page, minifyCssOptions }) {
|
|
|
11
14
|
// applies, so a print stylesheet is left as a link instead of becoming active.
|
|
12
15
|
const INLINEABLE_MEDIA = new Set(['', 'all', 'screen']);
|
|
13
16
|
const links = Array.from(document.querySelectorAll('link[rel="stylesheet"]'));
|
|
14
|
-
const
|
|
17
|
+
const sheets = [];
|
|
15
18
|
const skipped = [];
|
|
16
19
|
for (const link of links) {
|
|
17
20
|
const media = (link.getAttribute('media') ?? '').trim().toLowerCase();
|
|
@@ -22,23 +25,41 @@ export async function inlineStylesheets({ page, minifyCssOptions }) {
|
|
|
22
25
|
try {
|
|
23
26
|
const response = await fetch(link.href);
|
|
24
27
|
if (!response.ok) throw new Error(String(response.status));
|
|
25
|
-
|
|
26
|
-
link.remove();
|
|
28
|
+
sheets.push({ css: await response.text(), href: link.href });
|
|
27
29
|
} catch {
|
|
28
30
|
skipped.push(link.href);
|
|
29
31
|
}
|
|
30
32
|
}
|
|
31
|
-
return {
|
|
33
|
+
return { sheets, skipped };
|
|
32
34
|
});
|
|
33
35
|
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
36
|
+
const inlineable = [];
|
|
37
|
+
const skipped = [...collected.skipped];
|
|
38
|
+
for (const sheet of collected.sheets) {
|
|
39
|
+
if (hasImportRule(sheet.css)) skipped.push(sheet.href);
|
|
40
|
+
else inlineable.push(sheet);
|
|
41
|
+
}
|
|
42
|
+
if (inlineable.length === 0) return { skipped };
|
|
43
|
+
|
|
44
|
+
const pageOrigin = new URL(page.url()).origin;
|
|
45
|
+
const joined = inlineable
|
|
46
|
+
.map((sheet) => rewriteCssUrls(sheet.css, sheet.href, pageOrigin))
|
|
47
|
+
.join('\n');
|
|
48
|
+
const css = minifyCssOptions ? minifyCss(joined, minifyCssOptions) : joined;
|
|
49
|
+
await page.evaluate(
|
|
50
|
+
({ text, hrefs }) => {
|
|
51
|
+
const style = document.createElement('style');
|
|
52
|
+
style.setAttribute('data-snappy-inline', '');
|
|
53
|
+
style.textContent = text;
|
|
54
|
+
document.head.appendChild(style);
|
|
55
|
+
const inlined = new Set(hrefs);
|
|
56
|
+
for (const link of document.querySelectorAll('link[rel="stylesheet"]')) {
|
|
57
|
+
if (inlined.has(link.href)) link.remove();
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
{ text: css, hrefs: inlineable.map((sheet) => sheet.href) },
|
|
61
|
+
);
|
|
62
|
+
return { skipped };
|
|
42
63
|
}
|
|
43
64
|
|
|
44
65
|
/**
|