snappy-prerender 2.6.0-rc.3 → 2.7.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/README.md +149 -107
  3. package/docs/limitations.md +58 -0
  4. package/docs/react-snap-migration.md +45 -0
  5. package/docs/report.md +102 -0
  6. package/package.json +9 -5
  7. package/src/cli-init.js +205 -0
  8. package/src/cli.js +159 -168
  9. package/src/core/artifacts.js +112 -0
  10. package/src/core/audit/index.js +114 -0
  11. package/src/core/audit/links.js +63 -0
  12. package/src/core/audit/metadata.js +88 -0
  13. package/src/core/audit/sitemap.js +39 -0
  14. package/src/core/blocking.js +53 -1
  15. package/src/core/browser.js +109 -39
  16. package/src/core/build-origin.js +19 -0
  17. package/src/core/config.js +256 -6
  18. package/src/core/crawl.js +300 -0
  19. package/src/core/csp.js +127 -0
  20. package/src/core/css-url.js +34 -0
  21. package/src/core/destination.js +28 -3
  22. package/src/core/frameworks.js +94 -0
  23. package/src/core/hydration-diff.js +93 -0
  24. package/src/core/init.js +187 -31
  25. package/src/core/inline-css.js +34 -13
  26. package/src/core/metadata-origin.js +11 -2
  27. package/src/core/normalize.js +59 -2
  28. package/src/core/output.js +32 -9
  29. package/src/core/page-content.js +13 -0
  30. package/src/core/page-meta.js +108 -0
  31. package/src/core/preload-links.js +120 -0
  32. package/src/core/preload-manifest.js +61 -7
  33. package/src/core/prune.js +65 -0
  34. package/src/core/render.js +147 -11
  35. package/src/core/report.js +108 -0
  36. package/src/core/resources.js +6 -1
  37. package/src/core/route-config.js +17 -0
  38. package/src/core/route-sources.js +209 -0
  39. package/src/core/routes.js +9 -0
  40. package/src/core/run.js +186 -358
  41. package/src/core/server.js +30 -4
  42. package/src/core/sitemap.js +128 -20
  43. package/src/core/url-policy.js +32 -0
  44. package/src/core/verify.js +103 -49
  45. package/src/core/write.js +363 -0
  46. package/src/head/index.js +2 -2
  47. package/src/head/manager.js +54 -13
  48. package/src/head/tags.js +71 -32
@@ -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,41 @@ 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;
120
+ if (
121
+ node.tagName === 'meta' &&
122
+ attributeValue(node, 'name').toLowerCase() === 'prerender-status-code'
123
+ ) {
124
+ stats.removedElements += 1;
125
+ return true;
126
+ }
94
127
  const reason =
95
128
  (node.tagName === 'style' && hasAttribute(node, 'id', FREEZE_STYLE_ID)) ||
96
129
  hasAttribute(node, REMOVE_ATTRIBUTE, null) ||
@@ -113,6 +146,14 @@ function collectMetadataOrigins(node, stats) {
113
146
  }
114
147
  }
115
148
 
149
+ // Records a robots noindex so the sitemap can leave the page out.
150
+ function collectRobots(node, stats) {
151
+ if (node.tagName !== 'meta') return;
152
+ if (attributeValue(node, 'name').toLowerCase() !== 'robots') return;
153
+ const content = attributeValue(node, 'content').toLowerCase();
154
+ if (/\b(noindex|none)\b/.test(content)) stats.noindex = true;
155
+ }
156
+
116
157
  // Drops a style tag whose text was already seen, reporting it through stats.
117
158
  function dedupeStyle(node, stats, seenStyles) {
118
159
  const text = textContent(node);
@@ -124,9 +165,11 @@ function dedupeStyle(node, stats, seenStyles) {
124
165
  return false;
125
166
  }
126
167
 
127
- // Adds async to external scripts that are neither async nor defer yet.
168
+ // Adds async to external scripts that are neither async nor defer yet. The injected state
169
+ // script is exempt because it must run before the app bundle so snapStore is available.
128
170
  function markScriptAsync(node) {
129
171
  const attrs = node.attrs ?? [];
172
+ if (attrs.some((attribute) => attribute.name === 'data-snappy-state')) return;
130
173
  if (!attrs.some((attribute) => attribute.name === 'src')) return;
131
174
  if (attrs.some((attribute) => attribute.name === 'async' || attribute.name === 'defer')) return;
132
175
  attrs.push({ name: 'async', value: '' });
@@ -139,6 +182,20 @@ function isBlobStylesheet(node) {
139
182
  return attributeValue(node, 'href').startsWith('blob:');
140
183
  }
141
184
 
185
+ // Adds a robots noindex meta to the not-found page, which may be served with a 200 by
186
+ // a host that does not apply 404 status codes. Returns true when the tag was added.
187
+ function appendNoindex(document) {
188
+ const head = findHead(document);
189
+ if (!head) return false;
190
+ head.childNodes.push(
191
+ createElement('meta', [
192
+ { name: 'name', value: 'robots' },
193
+ { name: 'content', value: 'noindex' },
194
+ ]),
195
+ );
196
+ return true;
197
+ }
198
+
142
199
  // Appends preconnect and image preload links to the head, skipping duplicates.
143
200
  function appendHints(document, { preconnectOrigins, preloadImages }) {
144
201
  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
+ }
@@ -0,0 +1,108 @@
1
+ import { parse } from 'parse5';
2
+
3
+ const DATA_URL = /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i;
4
+ const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
5
+
6
+ /**
7
+ * Reads the metadata and structural markers from a final HTML document. This runs
8
+ * after post-processing so a transform that changes the head is reflected in the
9
+ * sitemap, the audit and the report.
10
+ */
11
+ export function collectPageMeta(html) {
12
+ const document = parse(html);
13
+ const meta = {
14
+ title: null,
15
+ titleCount: 0,
16
+ description: null,
17
+ canonical: null,
18
+ robots: null,
19
+ noindex: false,
20
+ h1Count: 0,
21
+ alternates: [],
22
+ images: [],
23
+ inlineStyleAttributes: 0,
24
+ };
25
+ const visitedLinks = new Set();
26
+
27
+ const walk = (node) => {
28
+ // An SVG <title> names a drawing, not the document, so it must not count as a page title.
29
+ if (node.tagName === 'title' && node.namespaceURI !== SVG_NAMESPACE) {
30
+ meta.titleCount += 1;
31
+ if (meta.title === null) meta.title = textContent(node);
32
+ }
33
+ if (node.tagName === 'meta') {
34
+ const name = attribute(node, 'name').toLowerCase();
35
+ const content = attribute(node, 'content');
36
+ if (name === 'description' && meta.description === null) meta.description = content;
37
+ if (name === 'robots') {
38
+ meta.robots ??= content;
39
+ if (/\b(noindex|none)\b/i.test(content)) meta.noindex = true;
40
+ }
41
+ }
42
+ if (node.tagName === 'link') {
43
+ const rel = attribute(node, 'rel').toLowerCase();
44
+ if (rel === 'canonical' && meta.canonical === null) meta.canonical = attribute(node, 'href');
45
+ if (rel.split(/\s+/).includes('alternate')) {
46
+ const hreflang = attribute(node, 'hreflang');
47
+ const href = attribute(node, 'href');
48
+ if (hreflang && href) {
49
+ const key = `${hreflang}|${href}`;
50
+ if (!visitedLinks.has(key)) {
51
+ visitedLinks.add(key);
52
+ meta.alternates.push({ hreflang, href });
53
+ }
54
+ }
55
+ }
56
+ }
57
+ if (node.tagName === 'h1') meta.h1Count += 1;
58
+ if (node.tagName === 'img') {
59
+ addImage(meta, attribute(node, 'src'));
60
+ for (const candidate of srcsetCandidates(attribute(node, 'srcset')))
61
+ addImage(meta, candidate);
62
+ }
63
+ if (node.tagName === 'source' && node.parentNode?.tagName === 'picture') {
64
+ addImage(meta, attribute(node, 'src'));
65
+ for (const candidate of srcsetCandidates(attribute(node, 'srcset')))
66
+ addImage(meta, candidate);
67
+ }
68
+ for (const attr of node.attrs ?? []) {
69
+ if (attr.name === 'style' && attr.value.trim().length > 0) meta.inlineStyleAttributes += 1;
70
+ }
71
+ for (const child of node.childNodes ?? []) walk(child);
72
+ };
73
+
74
+ walk(document);
75
+ return meta;
76
+ }
77
+
78
+ // Adds a usable image URL once. Data URLs have no host to list in a sitemap.
79
+ function addImage(meta, value) {
80
+ if (!value || DATA_URL.test(value) || meta.images.includes(value)) return;
81
+ meta.images.push(value);
82
+ }
83
+
84
+ // Extracts URL candidates from a srcset, ignoring their density or width descriptors.
85
+ function srcsetCandidates(value) {
86
+ if (!value) return [];
87
+ return value
88
+ .split(',')
89
+ .map((candidate) => candidate.trim().split(/\s+/)[0])
90
+ .filter(
91
+ (candidate) => candidate.length > 0 && !DATA_URL.test(candidate) && !/[<>]/.test(candidate),
92
+ );
93
+ }
94
+
95
+ // Reads an attribute value, defaulting to an empty string.
96
+ function attribute(node, name) {
97
+ return node.attrs?.find((entry) => entry.name === name)?.value ?? '';
98
+ }
99
+
100
+ // Concatenates the text beneath a node.
101
+ function textContent(node) {
102
+ let text = '';
103
+ for (const child of node.childNodes ?? []) {
104
+ if (child.nodeName === '#text') text += child.value;
105
+ else text += textContent(child);
106
+ }
107
+ return text.trim();
108
+ }
@@ -0,0 +1,120 @@
1
+ import path from 'node:path';
2
+ import { parse, serialize } from 'parse5';
3
+ import { toPublicPath } from './routes.js';
4
+
5
+ /**
6
+ * Injects static preload hints into the final HTML. Static hosts cannot send the
7
+ * preload-manifest headers, so this is the page-level equivalent for runtime chunks,
8
+ * fonts and the LCP image.
9
+ */
10
+ export function injectPreloadLinks(
11
+ html,
12
+ {
13
+ scripts = [],
14
+ fonts = new Map(),
15
+ lcpImage = null,
16
+ base = '/',
17
+ ignoreForPreload = [],
18
+ skipScripts = new Set(),
19
+ } = {},
20
+ ) {
21
+ const document = parse(html);
22
+ const head = findHead(document);
23
+ if (!head) return html;
24
+ const existingScripts = new Set();
25
+ const hints = new Set();
26
+
27
+ const walk = (node) => {
28
+ if (node.tagName === 'script') {
29
+ const src = attribute(node, 'src');
30
+ if (src) existingScripts.add(src);
31
+ }
32
+ if (node.tagName === 'link') {
33
+ const href = attribute(node, 'href');
34
+ if (href && attribute(node, 'rel').split(/\s+/).includes('preload')) hints.add(href);
35
+ }
36
+ for (const child of node.childNodes ?? []) walk(child);
37
+ };
38
+ walk(document);
39
+
40
+ const ignored = new Set(ignoreForPreload);
41
+ let added = 0;
42
+ const add = (element, href) => {
43
+ if (hints.has(href) || ignored.has(path.posix.basename(href))) return;
44
+ hints.add(href);
45
+ head.childNodes.push(element);
46
+ added += 1;
47
+ };
48
+
49
+ for (const script of scripts) {
50
+ const href = toPublicPath(script, base);
51
+ if (existingScripts.has(href) || skipScripts.has(href)) continue;
52
+ add(
53
+ linkNode([
54
+ { name: 'rel', value: 'modulepreload' },
55
+ { name: 'href', value: href },
56
+ ]),
57
+ href,
58
+ );
59
+ }
60
+ for (const [font, contentType] of fonts) {
61
+ const href = toPublicPath(font, base);
62
+ const attrs = [
63
+ { name: 'rel', value: 'preload' },
64
+ { name: 'as', value: 'font' },
65
+ { name: 'href', value: href },
66
+ { name: 'crossorigin', value: '' },
67
+ ];
68
+ if (contentType) attrs.push({ name: 'type', value: contentType });
69
+ add(linkNode(attrs), href);
70
+ }
71
+ if (lcpImage) {
72
+ const href = toPublicPath(lcpImage, base);
73
+ add(
74
+ linkNode([
75
+ { name: 'rel', value: 'preload' },
76
+ { name: 'as', value: 'image' },
77
+ { name: 'href', value: href },
78
+ { name: 'fetchpriority', value: 'high' },
79
+ ]),
80
+ href,
81
+ );
82
+ }
83
+ return added > 0 ? serialize(document) : html;
84
+ }
85
+
86
+ // Collects script src values from a document, used to tell a script a transform
87
+ // removed from a chunk the app loads at runtime.
88
+ export function collectScriptSources(html) {
89
+ const document = parse(html);
90
+ const sources = new Set();
91
+ const walk = (node) => {
92
+ if (node.tagName === 'script') {
93
+ const src = attribute(node, 'src');
94
+ if (src) sources.add(src);
95
+ }
96
+ for (const child of node.childNodes ?? []) walk(child);
97
+ };
98
+ walk(document);
99
+ return sources;
100
+ }
101
+
102
+ function findHead(document) {
103
+ const html = (document.childNodes ?? []).find((node) => node.tagName === 'html');
104
+ return (html?.childNodes ?? []).find((node) => node.tagName === 'head') ?? null;
105
+ }
106
+
107
+ function linkNode(attrs) {
108
+ return {
109
+ nodeName: 'link',
110
+ tagName: 'link',
111
+ attrs,
112
+ namespaceURI: 'http://www.w3.org/1999/xhtml',
113
+ childNodes: [],
114
+ parentNode: null,
115
+ };
116
+ }
117
+
118
+ function attribute(node, name) {
119
+ return node.attrs?.find((entry) => entry.name === name)?.value ?? '';
120
+ }
@@ -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
  }