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/CHANGELOG.md +140 -0
- package/README.md +42 -21
- package/package.json +8 -5
- package/src/cli.js +95 -9
- package/src/core/artifacts.js +110 -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 +128 -2
- package/src/core/css-url.js +34 -0
- package/src/core/destination.js +28 -3
- package/src/core/init.js +44 -1
- package/src/core/inline-css.js +34 -13
- package/src/core/metadata-origin.js +11 -2
- package/src/core/normalize.js +52 -2
- package/src/core/output.js +32 -9
- package/src/core/page-content.js +13 -0
- package/src/core/preload-manifest.js +61 -7
- package/src/core/prune.js +65 -0
- package/src/core/render.js +64 -10
- package/src/core/resources.js +1 -0
- package/src/core/routes.js +9 -0
- package/src/core/run.js +177 -304
- package/src/core/server.js +30 -4
- package/src/core/sitemap.js +49 -17
- package/src/core/url-policy.js +32 -0
- package/src/core/verify.js +14 -4
- package/src/core/write.js +278 -0
- package/src/head/index.js +1 -1
- package/src/head/tags.js +19 -26
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.
|
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,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
|
-
|
|
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);
|
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
|
+
}
|
|
@@ -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
|
}
|
|
@@ -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
|
+
}
|
package/src/core/render.js
CHANGED
|
@@ -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,
|
|
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
|
|
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 (
|
|
86
|
-
await
|
|
87
|
-
|
|
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:
|
|
127
|
+
screenshot: buffer,
|
|
128
|
+
empty,
|
|
96
129
|
};
|
|
97
130
|
}
|
|
98
131
|
|
|
99
132
|
const html = await page.content();
|
|
100
|
-
return {
|
|
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
|
+
}
|
package/src/core/resources.js
CHANGED