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.
@@ -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
+ }
@@ -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 (allowed.has(parsed.hostname.toLowerCase())) return allow(route);
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.
@@ -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
- * Resolves a usable Chromium-compatible browser, preferring the browser already on
10
- * the machine, then any Playwright cache, then a one-time Chrome for Testing
11
- * download. Returns the launched browser plus a human-readable source for logs.
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 channels =
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 browser = await launch(config, { channel });
32
- return { browser, source: `system ${channel}` };
36
+ const launched = await runStep(step, config, log);
37
+ if (launched) return launched;
33
38
  } catch (error) {
34
- attempts.push(`${channel}: ${firstLine(error.message)}`);
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
- const executablePath = await downloadChrome(config, log);
52
- return {
53
- browser: await launch(config, { executablePath }),
54
- source: `downloaded ${executablePath}`,
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
- * Downloads Chrome for Testing into the tool's own cache directory. Used only when
74
- * neither a system browser nor a Playwright-managed browser is available. The
75
- * archive comes from Google's version-pinned HTTPS bucket via @puppeteer/browsers,
76
- * which verifies it against browserDownloadHash when one is configured and deletes
77
- * it on a mismatch. Without a configured hash the download is trusted on the
78
- * strength of HTTPS alone, which is logged as a warning.
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 = await browsers.resolveBuildId(
83
- browsers.Browser.CHROME,
84
- browsers.detectBrowserPlatform(),
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 ${total > 0 ? Math.round((downloaded / total) * 100) : 0}%`),
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
+ }
@@ -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 = userOptions.include !== undefined;
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
+ }
@@ -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({ sourceDir, destination, log }) {
11
- if (!destination || path.resolve(destination) === path.resolve(sourceDir)) {
12
- return sourceDir;
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) {