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/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
  /**
@@ -1,4 +1,4 @@
1
- const BUILD_ORIGIN = /https?:\/\/(?:127\.0\.0\.1|localhost|\[::1\])(?::\d+)?/gi;
1
+ const BUILD_ORIGIN = /https?:\/\/(?:127\.0\.0\.1|localhost|\[::1\])(?::\d+)?(?![\w.:-])/gi;
2
2
 
3
3
  /**
4
4
  * Recognises the metadata tags whose value is an absolute URL, and finds URLs pointing at a
@@ -25,12 +25,21 @@ export function collectBuildOrigins(text, limit = 3) {
25
25
  return [...new Set(matches)].slice(0, limit);
26
26
  }
27
27
 
28
+ /**
29
+ * Scans a serialized document for build-server origins. Used only when the optional
30
+ * `failOnBuildOriginMetadata` gate is enabled, so a caller that turns stripping off can
31
+ * still make loopback metadata a build failure.
32
+ */
33
+ export function findBuildOriginUrls(html) {
34
+ return collectBuildOrigins(html);
35
+ }
36
+
28
37
  // Reports whether a meta key names a URL, such as og:url, og:image or twitter:image.
29
38
  function isUrlKey(key) {
30
39
  const [prefix, ...rest] = key.toLowerCase().split(':');
31
40
  if (prefix !== 'og' && prefix !== 'twitter') return false;
32
41
  const last = rest.at(-1);
33
- return last === 'url' || last === 'image';
42
+ return last === 'url' || last === 'image' || last === 'secure_url';
34
43
  }
35
44
 
36
45
  // Reads an attribute value, defaulting to an empty string.
@@ -17,14 +17,17 @@ export function normaliseHtml(html, options = {}) {
17
17
  const stats = {
18
18
  removedElements: 0,
19
19
  removedAttributes: 0,
20
+ removedMetadata: 0,
20
21
  dedupedStyles: 0,
21
22
  hints: 0,
22
23
  textSeparators: 0,
23
24
  titleElements: 0,
25
+ noindex: false,
24
26
  metadataOrigins: [],
25
27
  };
26
28
  const seenStyles = new Set();
27
29
  cleanChildren(document, stats, seenStyles, false, options);
30
+ if (options.noindex && !stats.noindex) stats.noindex = appendNoindex(document);
28
31
 
29
32
  const preconnectOrigins = options.preconnectOrigins ?? [];
30
33
  const preloadImages = options.preloadImages ?? [];
@@ -41,6 +44,7 @@ function cleanChildren(parent, stats, seenStyles, inHead, options) {
41
44
  const kept = [];
42
45
  for (const child of children) {
43
46
  const childInHead = inHead || child.tagName === 'head';
47
+ if (shouldStripMetadata(child, options, stats)) continue;
44
48
  if (shouldRemove(child, stats, options)) continue;
45
49
  if (child.attrs && options.removeAttributes?.length > 0) {
46
50
  stripAttributes(child, options.removeAttributes, stats);
@@ -48,6 +52,7 @@ function cleanChildren(parent, stats, seenStyles, inHead, options) {
48
52
  if (childInHead && child.tagName === 'style' && dedupeStyle(child, stats, seenStyles)) continue;
49
53
  if (childInHead && child.tagName === 'title') stats.titleElements += 1;
50
54
  if (childInHead) collectMetadataOrigins(child, stats);
55
+ if (childInHead) collectRobots(child, stats);
51
56
  if (options.asyncScriptTags && child.tagName === 'script') markScriptAsync(child);
52
57
  cleanChildren(child, stats, seenStyles, childInHead, options);
53
58
  if (child.content) cleanChildren(child.content, stats, seenStyles, childInHead, options);
@@ -84,13 +89,34 @@ function stripAttributes(node, patterns, stats) {
84
89
 
85
90
  // Reports whether an attribute name matches a string exactly or a RegExp pattern.
86
91
  function matchesAttribute(name, pattern) {
87
- return pattern instanceof RegExp ? pattern.test(name) : name === pattern;
92
+ if (!(pattern instanceof RegExp)) return name === pattern;
93
+ pattern.lastIndex = 0;
94
+ const matched = pattern.test(name);
95
+ pattern.lastIndex = 0;
96
+ return matched;
97
+ }
98
+
99
+ // Removes metadata whose absolute URL points at the build server, so a loopback
100
+ // canonical or share image never ships. The origin is recorded first so the run can
101
+ // explain what was dropped.
102
+ function shouldStripMetadata(node, options, stats) {
103
+ if (!options.stripBuildOriginMetadata || !isMetadataUrlNode(node)) return false;
104
+ const origins = collectBuildOrigins(metadataUrlValue(node));
105
+ if (origins.length === 0) return false;
106
+ for (const origin of origins) {
107
+ if (stats.metadataOrigins.length >= MAX_METADATA_ORIGINS) break;
108
+ if (!stats.metadataOrigins.includes(origin)) stats.metadataOrigins.push(origin);
109
+ }
110
+ stats.removedMetadata += 1;
111
+ stats.removedElements += 1;
112
+ return true;
88
113
  }
89
114
 
90
115
  // Reports whether a node is removed for any of the configured reasons. The metadata script is
91
116
  // exempt: the head layer reads it on the client, so stripping it would lose the site defaults.
92
117
  function shouldRemove(node, stats, options) {
93
118
  if (node.tagName === 'script' && hasAttribute(node, 'data-snappy-meta', null)) return false;
119
+ if (node.tagName === 'style' && hasAttribute(node, 'data-snappy-inline', null)) return false;
94
120
  const reason =
95
121
  (node.tagName === 'style' && hasAttribute(node, 'id', FREEZE_STYLE_ID)) ||
96
122
  hasAttribute(node, REMOVE_ATTRIBUTE, null) ||
@@ -113,6 +139,14 @@ function collectMetadataOrigins(node, stats) {
113
139
  }
114
140
  }
115
141
 
142
+ // Records a robots noindex so the sitemap can leave the page out.
143
+ function collectRobots(node, stats) {
144
+ if (node.tagName !== 'meta') return;
145
+ if (attributeValue(node, 'name').toLowerCase() !== 'robots') return;
146
+ const content = attributeValue(node, 'content').toLowerCase();
147
+ if (/\b(noindex|none)\b/.test(content)) stats.noindex = true;
148
+ }
149
+
116
150
  // Drops a style tag whose text was already seen, reporting it through stats.
117
151
  function dedupeStyle(node, stats, seenStyles) {
118
152
  const text = textContent(node);
@@ -124,9 +158,11 @@ function dedupeStyle(node, stats, seenStyles) {
124
158
  return false;
125
159
  }
126
160
 
127
- // Adds async to external scripts that are neither async nor defer yet.
161
+ // Adds async to external scripts that are neither async nor defer yet. The injected state
162
+ // script is exempt because it must run before the app bundle so snapStore is available.
128
163
  function markScriptAsync(node) {
129
164
  const attrs = node.attrs ?? [];
165
+ if (attrs.some((attribute) => attribute.name === 'data-snappy-state')) return;
130
166
  if (!attrs.some((attribute) => attribute.name === 'src')) return;
131
167
  if (attrs.some((attribute) => attribute.name === 'async' || attribute.name === 'defer')) return;
132
168
  attrs.push({ name: 'async', value: '' });
@@ -139,6 +175,20 @@ function isBlobStylesheet(node) {
139
175
  return attributeValue(node, 'href').startsWith('blob:');
140
176
  }
141
177
 
178
+ // Adds a robots noindex meta to the not-found page, which may be served with a 200 by
179
+ // a host that does not apply 404 status codes. Returns true when the tag was added.
180
+ function appendNoindex(document) {
181
+ const head = findHead(document);
182
+ if (!head) return false;
183
+ head.childNodes.push(
184
+ createElement('meta', [
185
+ { name: 'name', value: 'robots' },
186
+ { name: 'content', value: 'noindex' },
187
+ ]),
188
+ );
189
+ return true;
190
+ }
191
+
142
192
  // Appends preconnect and image preload links to the head, skipping duplicates.
143
193
  function appendHints(document, { preconnectOrigins, preloadImages }) {
144
194
  const head = findHead(document);
@@ -27,18 +27,24 @@ export function routeToScreenshotFile(route, { saveAs = 'png' } = {}) {
27
27
 
28
28
  /**
29
29
  * Finds routes that would write the same output file. Two routes can collide, most
30
- * obviously with flatOutput where both "/" and "/index" map to index.html. Callers
30
+ * obviously with flatOutput where both "/" and "/index" map to index.html. On a
31
+ * case-insensitive filesystem, paths that differ only by case collide too. Callers
31
32
  * use this to refuse the ambiguous writes instead of letting one overwrite the other.
32
33
  */
33
- export function findOutputCollisions(routes, config) {
34
+ export function findOutputCollisions(
35
+ routes,
36
+ config,
37
+ { caseInsensitive = process.platform === 'darwin' || process.platform === 'win32' } = {},
38
+ ) {
34
39
  const byFile = new Map();
35
40
  const saveAs = config.saveAs ?? 'html';
36
41
  for (const route of routes) {
37
42
  const file =
38
43
  saveAs === 'html' ? routeToFile(route, config) : routeToScreenshotFile(route, config);
39
- const existing = byFile.get(file);
44
+ const key = caseInsensitive ? file.toLowerCase() : file;
45
+ const existing = byFile.get(key);
40
46
  if (existing) existing.push(route);
41
- else byFile.set(file, [route]);
47
+ else byFile.set(key, [route]);
42
48
  }
43
49
  return new Map([...byFile].filter(([, list]) => list.length > 1));
44
50
  }
@@ -61,17 +67,34 @@ export async function writeRouteHtml({ dir, route, html, config }) {
61
67
  * files routes reference both go through here so a rebuild only touches what changed.
62
68
  */
63
69
  export async function writeFileIfChanged({ dir, file, contents }) {
70
+ return writeIfChanged({ dir, file, contents });
71
+ }
72
+
73
+ /**
74
+ * Writes a generated binary file, leaving an identical one untouched. Screenshots use
75
+ * this so a rebuild only replaces images whose pixels changed.
76
+ */
77
+ export async function writeBufferIfChanged({ dir, file, buffer }) {
78
+ return writeIfChanged({ dir, file, contents: buffer });
79
+ }
80
+
81
+ // Compares the existing file with the new value, handling text and binary payloads,
82
+ // then writes through a temporary file so an interrupted run cannot leave a partial
83
+ // file behind for a host to serve.
84
+ async function writeIfChanged({ dir, file, contents }) {
64
85
  const bytes = Buffer.byteLength(contents);
65
86
  const target = path.join(dir, file);
66
- const existing = await fs.readFile(target, 'utf8').catch(() => null);
67
- if (existing === contents) return { file, status: 'unchanged', bytes };
87
+ const existing = await fs.readFile(target).catch(() => null);
88
+ const unchanged =
89
+ typeof contents === 'string'
90
+ ? existing?.toString('utf8') === contents
91
+ : existing !== null && Buffer.compare(existing, contents) === 0;
92
+ if (unchanged) return { file, status: 'unchanged', bytes };
68
93
 
69
94
  await fs.mkdir(path.dirname(target), { recursive: true });
70
- // Written through a temporary file and renamed, so an interrupted run cannot leave a
71
- // half-written file behind for a host to serve.
72
95
  const temporary = `${target}.snappy-tmp`;
73
96
  try {
74
- await fs.writeFile(temporary, contents, 'utf8');
97
+ await fs.writeFile(temporary, contents);
75
98
  await fs.rename(temporary, target);
76
99
  } catch (error) {
77
100
  await fs.rm(temporary, { force: true }).catch(() => {});
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Reports whether a rendered page carries any app content. A missing container and an
3
+ * empty container both mean the browser did not receive an app, which is what a 404,
4
+ * a failed bundle, or an empty build looks like after serialisation.
5
+ */
6
+ export async function isPageEmpty(page) {
7
+ return page.evaluate(() => {
8
+ const container = document.querySelector('#root, #app, #__next') ?? document.body;
9
+ if (!container) return true;
10
+ const text = (container.textContent ?? '').trim();
11
+ return container.childElementCount === 0 && text.length === 0;
12
+ });
13
+ }
@@ -1,17 +1,71 @@
1
1
  import path from 'node:path';
2
- import { toPublicPath } from './routes.js';
2
+ import { parse } from 'parse5';
3
+ import { toAppPath } from './resources.js';
4
+ import { normaliseRoute, toPublicPath } from './routes.js';
5
+ import { pagePath } from './url-policy.js';
6
+
7
+ /** A synthetic origin used only to resolve hrefs while reading the generated HTML. */
8
+ const PAGE_ORIGIN = 'http://snappy.local';
9
+
10
+ /**
11
+ * Reads the scripts and stylesheets the final HTML actually references. Using the
12
+ * output rather than the render-time resource list keeps the manifest correct after
13
+ * inline CSS, tag surgery, or a failed write.
14
+ */
15
+ export function collectPageResources(html, { base = '/', route = '/' } = {}) {
16
+ const document = parse(html);
17
+ const scripts = [];
18
+ const styles = [];
19
+ const pageUrl = new URL(toPublicPath(route, base), PAGE_ORIGIN);
20
+ const add = (node, attribute, target) => {
21
+ const raw = node.attrs?.find((entry) => entry.name === attribute)?.value;
22
+ if (!raw || raw.startsWith('data:') || raw.startsWith('blob:')) return;
23
+ let url;
24
+ try {
25
+ url = new URL(raw, pageUrl);
26
+ } catch {
27
+ return;
28
+ }
29
+ if (url.origin !== PAGE_ORIGIN) return;
30
+ const appPath = toAppPath(url.href, base);
31
+ if (appPath) target.push(appPath);
32
+ };
33
+ const walk = (node) => {
34
+ if (node.tagName === 'template') return;
35
+ if (node.tagName === 'script') add(node, 'src', scripts);
36
+ if (node.tagName === 'link') {
37
+ const rel = node.attrs?.find((entry) => entry.name === 'rel')?.value ?? '';
38
+ if (rel.split(/\s+/).includes('stylesheet')) add(node, 'href', styles);
39
+ }
40
+ for (const child of node.childNodes ?? []) walk(child);
41
+ };
42
+ walk(document);
43
+ return { scripts: [...new Set(scripts)], styles: [...new Set(styles)] };
44
+ }
3
45
 
4
46
  /**
5
47
  * Builds the preload manifest: for every route, the same-origin scripts and
6
- * stylesheets it loaded, rendered as a Link header value. This replaces react-snap's
7
- * http2PushManifest because browsers removed HTTP/2 push (Chrome in v106); hosts can
8
- * still use these values for Early Hints or Link response headers. Hints and route keys
9
- * both carry the base path so they match the URLs a host actually sees.
48
+ * stylesheets the final page references, rendered as a Link header value. This
49
+ * replaces react-snap's http2PushManifest because browsers removed HTTP/2 push
50
+ * (Chrome in v106); hosts can still use these values for Early Hints or Link response
51
+ * headers. Hints and route keys both carry the base path so they match the URLs a host
52
+ * actually sees.
10
53
  */
11
- export function buildPreloadManifest(entries, { ignoreForPreload = [], base = '/' } = {}) {
54
+ export function buildPreloadManifest(
55
+ entries,
56
+ {
57
+ ignoreForPreload = [],
58
+ base = '/',
59
+ flatOutput = false,
60
+ trailingSlash = 'preserve',
61
+ notFoundRoute = null,
62
+ } = {},
63
+ ) {
12
64
  const ignored = new Set(ignoreForPreload);
65
+ const notFound = notFoundRoute ? normaliseRoute(notFoundRoute) : null;
13
66
  const manifest = [];
14
67
  for (const entry of entries) {
68
+ if (notFound && normaliseRoute(entry.route) === notFound) continue;
15
69
  const hints = [];
16
70
  for (const asset of [...entry.scripts, ...entry.styles]) {
17
71
  if (ignored.has(path.posix.basename(asset))) continue;
@@ -20,7 +74,7 @@ export function buildPreloadManifest(entries, { ignoreForPreload = [], base = '/
20
74
  }
21
75
  if (hints.length === 0) continue;
22
76
  manifest.push({
23
- source: toPublicPath(entry.route, base),
77
+ source: toPublicPath(pagePath(entry.route, { flatOutput, trailingSlash }), base),
24
78
  headers: [{ key: 'Link', value: hints.join(',') }],
25
79
  });
26
80
  }
@@ -0,0 +1,65 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ export const MANIFEST_FILE = '.snappy-manifest.json';
5
+
6
+ /**
7
+ * Reads the file list the previous run wrote. A missing or malformed manifest simply
8
+ * disables pruning, so an older output directory can be upgraded safely.
9
+ */
10
+ export async function readPruneManifest(dir) {
11
+ try {
12
+ const parsed = JSON.parse(await fs.readFile(path.join(dir, MANIFEST_FILE), 'utf8'));
13
+ return Array.isArray(parsed.files) ? parsed.files : null;
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Deletes files the previous run generated that this run no longer produces, then
21
+ * removes emptied route directories. Only paths recorded in the manifest are touched,
22
+ * so assets and files the user added are never at risk.
23
+ */
24
+ export async function pruneStaleFiles({ dir, previous, current, log }) {
25
+ if (!Array.isArray(previous)) return [];
26
+ const keep = new Set(current);
27
+ const root = path.resolve(dir);
28
+ const removed = [];
29
+ for (const file of previous) {
30
+ if (typeof file !== 'string' || keep.has(file)) continue;
31
+ if (file === MANIFEST_FILE || file === 'robots.txt') continue;
32
+ const target = path.resolve(root, file);
33
+ if (target !== root && !target.startsWith(`${root}${path.sep}`)) continue;
34
+ try {
35
+ await fs.rm(target, { force: true });
36
+ removed.push(file);
37
+ await removeEmptyParents(root, path.dirname(target));
38
+ } catch (error) {
39
+ log?.warn(`could not prune ${file}: ${error.message}`);
40
+ }
41
+ }
42
+ return removed;
43
+ }
44
+
45
+ // Writes the manifest for the next run. Files that are not ours to own are excluded.
46
+ export async function writePruneManifest({ dir, files }) {
47
+ const owned = [...new Set(files)].filter(
48
+ (file) => file !== MANIFEST_FILE && file !== 'robots.txt',
49
+ );
50
+ const contents = `${JSON.stringify({ version: 1, files: owned.sort() }, null, 2)}\n`;
51
+ await fs.writeFile(path.join(dir, MANIFEST_FILE), contents, 'utf8');
52
+ }
53
+
54
+ // Removes parent directories that became empty, stopping at the output root.
55
+ async function removeEmptyParents(root, start) {
56
+ let current = start;
57
+ while (current !== root && current.startsWith(`${root}${path.sep}`)) {
58
+ try {
59
+ await fs.rmdir(current);
60
+ } catch {
61
+ return;
62
+ }
63
+ current = path.dirname(current);
64
+ }
65
+ }
@@ -1,7 +1,6 @@
1
- import fs from 'node:fs/promises';
2
- import path from 'node:path';
3
1
  import { captureFormState } from './form-state.js';
4
2
  import { inlineStylesheets } from './inline-css.js';
3
+ import { isPageEmpty } from './page-content.js';
5
4
  import { createResourceCollector } from './resources.js';
6
5
  import { toAppRoute, toServerUrl } from './routes.js';
7
6
  import { captureRuntimeStyles } from './runtime-styles.js';
@@ -27,20 +26,43 @@ function escapeForScript(value) {
27
26
  * storage from leaking between routes. When a screenshot path is supplied the route
28
27
  * is captured as an image instead of HTML.
29
28
  */
30
- export async function renderRoute({ browser, origin, route, config, screenshotPath }) {
29
+ export async function renderRoute({ browser, origin, route, config, screenshot = false }) {
31
30
  const context = await browser.newContext({
32
31
  viewport: config.viewport,
33
32
  storageState: config.storageState ?? undefined,
34
33
  userAgent: config.userAgent ?? undefined,
35
34
  ignoreHTTPSErrors: config.ignoreHTTPSErrors,
36
35
  reducedMotion: config.freezeAnimations ? 'reduce' : 'no-preference',
36
+ serviceWorkers: 'block',
37
37
  });
38
38
  const page = await context.newPage();
39
39
  const pageErrors = [];
40
+ const consoleErrors = [];
41
+ const failedRequests = [];
40
42
  let inlineCssSkipped = [];
41
43
  page.on('pageerror', (error) => pageErrors.push(error.message));
44
+ page.on('console', (message) => {
45
+ if (message.type() === 'error') consoleErrors.push(message.text());
46
+ });
47
+ // Only same-origin failures are the build's own to fix; a blocked analytics host is
48
+ // not a broken page. Aborted requests are navigations or cancelled fetches, not 404s.
49
+ page.on('response', (response) => {
50
+ if (response.status() < 400) return;
51
+ const parsed = parseHttpUrl(response.url());
52
+ if (parsed?.origin !== origin) return;
53
+ failedRequests.push({ url: response.url(), status: response.status() });
54
+ });
55
+ page.on('requestfailed', (request) => {
56
+ const parsed = parseHttpUrl(request.url());
57
+ if (parsed?.origin !== origin) return;
58
+ const error = request.failure()?.errorText ?? 'request failed';
59
+ if (error.includes('ERR_ABORTED')) return;
60
+ failedRequests.push({ url: request.url(), status: null, error });
61
+ });
42
62
 
43
- const metadataDefaults = config.metadata ? { base: config.base, ...config.metadata } : null;
63
+ const metadataDefaults = config.metadata
64
+ ? { base: config.base, flatOutput: config.flatOutput, ...config.metadata }
65
+ : null;
44
66
  if (metadataDefaults) {
45
67
  await page.addInitScript((defaults) => {
46
68
  window.__SNAPPY_META__ = defaults;
@@ -58,11 +80,17 @@ export async function renderRoute({ browser, origin, route, config, screenshotPa
58
80
  await collector.install(page);
59
81
  const tracker = trackNetwork(page);
60
82
  try {
61
- await page.goto(toServerUrl(route, config.base, origin), {
83
+ const response = await page.goto(toServerUrl(route, config.base, origin), {
62
84
  waitUntil: 'domcontentloaded',
63
85
  timeout: config.timeout,
64
86
  });
87
+ if (response && response.status() >= 400) {
88
+ throw new Error(
89
+ `route responded with HTTP ${response.status()}${response.statusText() ? ` ${response.statusText()}` : ''}`,
90
+ );
91
+ }
65
92
  await settlePage(page, config, tracker);
93
+ const empty = await isPageEmpty(page);
66
94
 
67
95
  if (config.captureRuntimeStyles) await captureRuntimeStyles(page);
68
96
  if (config.captureFormState) await captureFormState(page);
@@ -82,22 +110,38 @@ export async function renderRoute({ browser, origin, route, config, screenshotPa
82
110
 
83
111
  const links = await collectLinks(page, config.base, origin);
84
112
 
85
- if (screenshotPath) {
86
- await fs.mkdir(path.dirname(screenshotPath), { recursive: true });
87
- await page.screenshot({ path: screenshotPath, fullPage: true });
113
+ if (screenshot) {
114
+ const buffer = await page.screenshot({
115
+ fullPage: true,
116
+ type: config.saveAs === 'jpeg' ? 'jpeg' : 'png',
117
+ });
88
118
  return {
89
119
  route,
90
120
  links,
91
121
  pageErrors,
122
+ consoleErrors,
123
+ failedRequests,
92
124
  collector,
93
125
  inlineCssSkipped,
94
126
  stateScript,
95
- screenshot: true,
127
+ screenshot: buffer,
128
+ empty,
96
129
  };
97
130
  }
98
131
 
99
132
  const html = await page.content();
100
- return { route, html, links, pageErrors, collector, inlineCssSkipped, stateScript };
133
+ return {
134
+ route,
135
+ html,
136
+ links,
137
+ pageErrors,
138
+ consoleErrors,
139
+ failedRequests,
140
+ collector,
141
+ inlineCssSkipped,
142
+ stateScript,
143
+ empty,
144
+ };
101
145
  } finally {
102
146
  tracker.stop();
103
147
  await context.close();
@@ -226,3 +270,13 @@ async function collectLinks(page, base, origin) {
226
270
  }
227
271
  return routes;
228
272
  }
273
+
274
+ // Parses an HTTP URL, returning null for data, blob and other non-network URLs.
275
+ function parseHttpUrl(value) {
276
+ try {
277
+ const parsed = new URL(value);
278
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed : null;
279
+ } catch {
280
+ return null;
281
+ }
282
+ }
@@ -49,6 +49,7 @@ export function createResourceCollector({
49
49
  const url = request.url();
50
50
  const parsed = parseUrl(url);
51
51
  if (!parsed || parsed.origin !== origin) return;
52
+ if (response.status() >= 400) return;
52
53
  const path = toAppPath(url, base);
53
54
  if (!path) return;
54
55