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.
@@ -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,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 = ['include', 'exclude', 'allowedHosts', 'browserArgs', 'ignoreForPreload'];
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 = userOptions.include !== undefined;
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
+ }
@@ -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) {
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.
@@ -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 place.
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 parts = [];
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
- parts.push(await response.text());
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 { css: parts.join('\n'), skipped };
33
+ return { sheets, skipped };
32
34
  });
33
35
 
34
- if (collected.css.trim().length === 0) return { skipped: collected.skipped };
35
- const css = minifyCssOptions ? minifyCss(collected.css, minifyCssOptions) : collected.css;
36
- await page.evaluate((text) => {
37
- const style = document.createElement('style');
38
- style.textContent = text;
39
- document.head.appendChild(style);
40
- }, css);
41
- return { skipped: collected.skipped };
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
  /**