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.
- package/CHANGELOG.md +191 -0
- package/README.md +149 -107
- package/docs/limitations.md +58 -0
- package/docs/react-snap-migration.md +45 -0
- package/docs/report.md +102 -0
- package/package.json +9 -5
- package/src/cli-init.js +205 -0
- package/src/cli.js +159 -168
- package/src/core/artifacts.js +112 -0
- package/src/core/audit/index.js +114 -0
- package/src/core/audit/links.js +63 -0
- package/src/core/audit/metadata.js +88 -0
- package/src/core/audit/sitemap.js +39 -0
- package/src/core/blocking.js +53 -1
- package/src/core/browser.js +109 -39
- package/src/core/build-origin.js +19 -0
- package/src/core/config.js +256 -6
- package/src/core/crawl.js +300 -0
- package/src/core/csp.js +127 -0
- package/src/core/css-url.js +34 -0
- package/src/core/destination.js +28 -3
- package/src/core/frameworks.js +94 -0
- package/src/core/hydration-diff.js +93 -0
- package/src/core/init.js +187 -31
- package/src/core/inline-css.js +34 -13
- package/src/core/metadata-origin.js +11 -2
- package/src/core/normalize.js +59 -2
- package/src/core/output.js +32 -9
- package/src/core/page-content.js +13 -0
- package/src/core/page-meta.js +108 -0
- package/src/core/preload-links.js +120 -0
- package/src/core/preload-manifest.js +61 -7
- package/src/core/prune.js +65 -0
- package/src/core/render.js +147 -11
- package/src/core/report.js +108 -0
- package/src/core/resources.js +6 -1
- package/src/core/route-config.js +17 -0
- package/src/core/route-sources.js +209 -0
- package/src/core/routes.js +9 -0
- package/src/core/run.js +186 -358
- package/src/core/server.js +30 -4
- package/src/core/sitemap.js +128 -20
- package/src/core/url-policy.js +32 -0
- package/src/core/verify.js +103 -49
- package/src/core/write.js +363 -0
- package/src/head/index.js +2 -2
- package/src/head/manager.js +54 -13
- package/src/head/tags.js +71 -32
package/src/core/inline-css.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { hasImportRule, rewriteCssUrls } from './css-url.js';
|
|
1
2
|
import { minifyCss } from './minify.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Inlines every same-origin stylesheet the page loaded, replacing the links with a
|
|
5
6
|
* single style tag. Blob-backed stylesheets work too because the text is read from
|
|
6
|
-
* the live page. Stylesheets that cannot be fetched are left in
|
|
7
|
+
* the live page. Stylesheets that cannot be fetched, or that use @import, are left in
|
|
8
|
+
* place. Relative url() values are rebased against each sheet before joining so fonts
|
|
9
|
+
* and images keep working on nested routes.
|
|
7
10
|
*/
|
|
8
11
|
export async function inlineStylesheets({ page, minifyCssOptions }) {
|
|
9
12
|
const collected = await page.evaluate(async () => {
|
|
@@ -11,7 +14,7 @@ export async function inlineStylesheets({ page, minifyCssOptions }) {
|
|
|
11
14
|
// applies, so a print stylesheet is left as a link instead of becoming active.
|
|
12
15
|
const INLINEABLE_MEDIA = new Set(['', 'all', 'screen']);
|
|
13
16
|
const links = Array.from(document.querySelectorAll('link[rel="stylesheet"]'));
|
|
14
|
-
const
|
|
17
|
+
const sheets = [];
|
|
15
18
|
const skipped = [];
|
|
16
19
|
for (const link of links) {
|
|
17
20
|
const media = (link.getAttribute('media') ?? '').trim().toLowerCase();
|
|
@@ -22,23 +25,41 @@ export async function inlineStylesheets({ page, minifyCssOptions }) {
|
|
|
22
25
|
try {
|
|
23
26
|
const response = await fetch(link.href);
|
|
24
27
|
if (!response.ok) throw new Error(String(response.status));
|
|
25
|
-
|
|
26
|
-
link.remove();
|
|
28
|
+
sheets.push({ css: await response.text(), href: link.href });
|
|
27
29
|
} catch {
|
|
28
30
|
skipped.push(link.href);
|
|
29
31
|
}
|
|
30
32
|
}
|
|
31
|
-
return {
|
|
33
|
+
return { sheets, skipped };
|
|
32
34
|
});
|
|
33
35
|
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
36
|
+
const inlineable = [];
|
|
37
|
+
const skipped = [...collected.skipped];
|
|
38
|
+
for (const sheet of collected.sheets) {
|
|
39
|
+
if (hasImportRule(sheet.css)) skipped.push(sheet.href);
|
|
40
|
+
else inlineable.push(sheet);
|
|
41
|
+
}
|
|
42
|
+
if (inlineable.length === 0) return { skipped };
|
|
43
|
+
|
|
44
|
+
const pageOrigin = new URL(page.url()).origin;
|
|
45
|
+
const joined = inlineable
|
|
46
|
+
.map((sheet) => rewriteCssUrls(sheet.css, sheet.href, pageOrigin))
|
|
47
|
+
.join('\n');
|
|
48
|
+
const css = minifyCssOptions ? minifyCss(joined, minifyCssOptions) : joined;
|
|
49
|
+
await page.evaluate(
|
|
50
|
+
({ text, hrefs }) => {
|
|
51
|
+
const style = document.createElement('style');
|
|
52
|
+
style.setAttribute('data-snappy-inline', '');
|
|
53
|
+
style.textContent = text;
|
|
54
|
+
document.head.appendChild(style);
|
|
55
|
+
const inlined = new Set(hrefs);
|
|
56
|
+
for (const link of document.querySelectorAll('link[rel="stylesheet"]')) {
|
|
57
|
+
if (inlined.has(link.href)) link.remove();
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
{ text: css, hrefs: inlineable.map((sheet) => sheet.href) },
|
|
61
|
+
);
|
|
62
|
+
return { skipped };
|
|
42
63
|
}
|
|
43
64
|
|
|
44
65
|
/**
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const BUILD_ORIGIN = /https?:\/\/(?:127\.0\.0\.1|localhost|\[::1\])(?::\d+)
|
|
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.
|
package/src/core/normalize.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/src/core/output.js
CHANGED
|
@@ -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.
|
|
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(
|
|
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
|
|
44
|
+
const key = caseInsensitive ? file.toLowerCase() : file;
|
|
45
|
+
const existing = byFile.get(key);
|
|
40
46
|
if (existing) existing.push(route);
|
|
41
|
-
else byFile.set(
|
|
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
|
|
67
|
-
|
|
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
|
|
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 {
|
|
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
|
|
7
|
-
* http2PushManifest because browsers removed HTTP/2 push
|
|
8
|
-
* still use these values for Early Hints or Link response
|
|
9
|
-
* both carry the base path so they match the URLs a host
|
|
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(
|
|
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
|
}
|