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/csp.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { parse, serialize } from 'parse5';
|
|
3
|
+
|
|
4
|
+
// Types that execute as JavaScript. A script with any other type is data, not code.
|
|
5
|
+
const EXECUTABLE_TYPES = new Set([
|
|
6
|
+
'',
|
|
7
|
+
'text/javascript',
|
|
8
|
+
'application/javascript',
|
|
9
|
+
'application/ecmascript',
|
|
10
|
+
'text/ecmascript',
|
|
11
|
+
'module',
|
|
12
|
+
]);
|
|
13
|
+
const CSP_MARKER = 'data-snappy-csp';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Adds a hash-based Content-Security-Policy meta tag covering every inline script and
|
|
17
|
+
* style in the final document. A policy this function wrote on an earlier run is
|
|
18
|
+
* replaced; a policy the app wrote is kept untouched, so the prerenderer never merges
|
|
19
|
+
* two policies silently.
|
|
20
|
+
*/
|
|
21
|
+
export function applyCspHashes(html) {
|
|
22
|
+
const document = parse(html);
|
|
23
|
+
const head = findHead(document);
|
|
24
|
+
if (!head) return { html, hashedScripts: 0, hashedStyles: 0, existingPolicy: false };
|
|
25
|
+
removeGeneratedPolicy(head);
|
|
26
|
+
const existing = findPolicyMeta(head);
|
|
27
|
+
if (existing) return { html, hashedScripts: 0, hashedStyles: 0, existingPolicy: true };
|
|
28
|
+
|
|
29
|
+
const scriptHashes = [];
|
|
30
|
+
const styleHashes = [];
|
|
31
|
+
walk(document, (node) => {
|
|
32
|
+
if (node.tagName === 'script' && isExecutableInline(node)) {
|
|
33
|
+
scriptHashes.push(hashOf(textContent(node)));
|
|
34
|
+
}
|
|
35
|
+
if (node.tagName === 'style') {
|
|
36
|
+
styleHashes.push(hashOf(textContent(node)));
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const directives = [
|
|
41
|
+
`script-src 'self'${hashList(scriptHashes)}`,
|
|
42
|
+
`style-src 'self'${hashList(styleHashes)}`,
|
|
43
|
+
];
|
|
44
|
+
head.childNodes.unshift(metaNode(directives.join('; ')));
|
|
45
|
+
return {
|
|
46
|
+
html: serialize(document),
|
|
47
|
+
hashedScripts: scriptHashes.length,
|
|
48
|
+
hashedStyles: styleHashes.length,
|
|
49
|
+
existingPolicy: false,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A script is executable inline code when it has no src and no data type.
|
|
54
|
+
function isExecutableInline(node) {
|
|
55
|
+
if (attribute(node, 'src').length > 0) return false;
|
|
56
|
+
const type = attribute(node, 'type').trim().toLowerCase();
|
|
57
|
+
return EXECUTABLE_TYPES.has(type);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Renders the hash list, deduplicated and in document order.
|
|
61
|
+
function hashList(hashes) {
|
|
62
|
+
return [...new Set(hashes)].map((hash) => ` '${hash}'`).join('');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Base64 SHA-256, the form a CSP hash source uses.
|
|
66
|
+
function hashOf(text) {
|
|
67
|
+
return `sha256-${createHash('sha256').update(text).digest('base64')}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function findHead(document) {
|
|
71
|
+
const html = (document.childNodes ?? []).find((node) => node.tagName === 'html');
|
|
72
|
+
return (html?.childNodes ?? []).find((node) => node.tagName === 'head') ?? null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function findPolicyMeta(head) {
|
|
76
|
+
return (head.childNodes ?? []).some(
|
|
77
|
+
(node) =>
|
|
78
|
+
node.tagName === 'meta' &&
|
|
79
|
+
attribute(node, 'http-equiv').toLowerCase() === 'content-security-policy' &&
|
|
80
|
+
!hasAttribute(node, CSP_MARKER),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Removes the policy this prerenderer wrote on an earlier run, so it is recomputed against
|
|
85
|
+
// the current document instead of being mistaken for an app-owned policy.
|
|
86
|
+
function removeGeneratedPolicy(head) {
|
|
87
|
+
head.childNodes = (head.childNodes ?? []).filter(
|
|
88
|
+
(node) => !(node.tagName === 'meta' && hasAttribute(node, CSP_MARKER)),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function metaNode(content) {
|
|
93
|
+
return {
|
|
94
|
+
nodeName: 'meta',
|
|
95
|
+
tagName: 'meta',
|
|
96
|
+
attrs: [
|
|
97
|
+
{ name: 'http-equiv', value: 'Content-Security-Policy' },
|
|
98
|
+
{ name: 'content', value: content },
|
|
99
|
+
{ name: CSP_MARKER, value: '' },
|
|
100
|
+
],
|
|
101
|
+
namespaceURI: 'http://www.w3.org/1999/xhtml',
|
|
102
|
+
childNodes: [],
|
|
103
|
+
parentNode: null,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function walk(node, visit) {
|
|
108
|
+
visit(node);
|
|
109
|
+
for (const child of node.childNodes ?? []) walk(child, visit);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function textContent(node) {
|
|
113
|
+
let text = '';
|
|
114
|
+
for (const child of node.childNodes ?? []) {
|
|
115
|
+
if (child.nodeName === '#text') text += child.value;
|
|
116
|
+
else text += textContent(child);
|
|
117
|
+
}
|
|
118
|
+
return text;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function attribute(node, name) {
|
|
122
|
+
return node.attrs?.find((entry) => entry.name === name)?.value ?? '';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function hasAttribute(node, name) {
|
|
126
|
+
return Boolean(node.attrs?.some((entry) => entry.name === name));
|
|
127
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const URL_TOKEN = /url\(\s*(['"]?)([^'")]+)\1\s*\)/gi;
|
|
2
|
+
const NON_LOCAL = /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Rebases the relative url() values in a stylesheet against the stylesheet's own URL.
|
|
6
|
+
* Moving a sheet from /assets/app.css into a <style> element would otherwise resolve
|
|
7
|
+
* its fonts and images against the page URL, which differs on nested routes. Local
|
|
8
|
+
* URLs become root-relative so they work from any page depth.
|
|
9
|
+
*/
|
|
10
|
+
export function rewriteCssUrls(css, stylesheetUrl, pageOrigin) {
|
|
11
|
+
return css.replace(URL_TOKEN, (match, quote, value) => {
|
|
12
|
+
const target = value.trim();
|
|
13
|
+
if (target.length === 0 || NON_LOCAL.test(target)) return match;
|
|
14
|
+
let resolved;
|
|
15
|
+
try {
|
|
16
|
+
resolved = new URL(target, stylesheetUrl);
|
|
17
|
+
} catch {
|
|
18
|
+
return match;
|
|
19
|
+
}
|
|
20
|
+
const next =
|
|
21
|
+
resolved.origin === pageOrigin
|
|
22
|
+
? `${resolved.pathname}${resolved.search}${resolved.hash}`
|
|
23
|
+
: resolved.href;
|
|
24
|
+
return `url(${quote}${next}${quote})`;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Reports whether a stylesheet uses @import. Inlining cannot preserve the rule's
|
|
30
|
+
* position in the cascade, so the caller leaves that sheet as a link instead.
|
|
31
|
+
*/
|
|
32
|
+
export function hasImportRule(css) {
|
|
33
|
+
return /@import\b/i.test(css);
|
|
34
|
+
}
|
package/src/core/destination.js
CHANGED
|
@@ -7,9 +7,34 @@ import path from 'node:path';
|
|
|
7
7
|
* HTML and the source directory stays untouched. An existing destination is reported
|
|
8
8
|
* before it is overwritten, and entries that cannot be copied are counted.
|
|
9
9
|
*/
|
|
10
|
-
export async function prepareDestination({
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
export async function prepareDestination({
|
|
11
|
+
sourceDir,
|
|
12
|
+
destination,
|
|
13
|
+
log,
|
|
14
|
+
cleanDestination = false,
|
|
15
|
+
}) {
|
|
16
|
+
if (!destination) return path.resolve(sourceDir);
|
|
17
|
+
const source = path.resolve(sourceDir);
|
|
18
|
+
const target = path.resolve(destination);
|
|
19
|
+
if (target === source) return source;
|
|
20
|
+
if (target.startsWith(`${source}${path.sep}`)) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
`destination cannot be inside sourceDir: ${target} is inside ${source}. Use a sibling directory.`,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
if (cleanDestination) {
|
|
26
|
+
const sourceInsideTarget = path.relative(target, source);
|
|
27
|
+
if (
|
|
28
|
+
sourceInsideTarget.length > 0 &&
|
|
29
|
+
!sourceInsideTarget.startsWith('..') &&
|
|
30
|
+
!path.isAbsolute(sourceInsideTarget)
|
|
31
|
+
) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`cleanDestination would delete sourceDir: ${source} is inside ${target}. Choose a destination outside the build output.`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
await fs.rm(target, { recursive: true, force: true });
|
|
37
|
+
log?.debug(`cleaned destination ${destination}`);
|
|
13
38
|
}
|
|
14
39
|
const existing = await fs.readdir(destination).catch(() => []);
|
|
15
40
|
if (existing.length > 0) {
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
const REACT = (container) =>
|
|
2
|
+
Object.keys(container).some(
|
|
3
|
+
(key) => key.startsWith('__reactContainer') || key === '_reactRootContainer',
|
|
4
|
+
);
|
|
5
|
+
const VUE = (container) =>
|
|
6
|
+
Boolean(
|
|
7
|
+
container.__vue_app__ ||
|
|
8
|
+
container.__vueParentComponent ||
|
|
9
|
+
container._vnode ||
|
|
10
|
+
container.__vue__,
|
|
11
|
+
);
|
|
12
|
+
const PREACT = (container) =>
|
|
13
|
+
Boolean(
|
|
14
|
+
container.__k ||
|
|
15
|
+
container._children ||
|
|
16
|
+
container.firstElementChild?.__k ||
|
|
17
|
+
container.firstElementChild?._children,
|
|
18
|
+
);
|
|
19
|
+
const SVELTE = (container) =>
|
|
20
|
+
Boolean(container.__svelte_meta || container.querySelector?.('[data-svelte-h]'));
|
|
21
|
+
|
|
22
|
+
const DETECTORS = { react: REACT, vue: VUE, preact: PREACT, svelte: SVELTE };
|
|
23
|
+
const AUTO_ORDER = ['react', 'vue', 'preact', 'svelte'];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Detects the framework that mounted into a container from its DOM markers. This is
|
|
27
|
+
* observation only: no framework is imported or executed by the prerenderer.
|
|
28
|
+
*/
|
|
29
|
+
export function detectFramework(container, requested = 'auto') {
|
|
30
|
+
if (!container) return { name: null, mounted: false };
|
|
31
|
+
if (requested !== 'auto') {
|
|
32
|
+
return { name: requested, mounted: Boolean(DETECTORS[requested]?.(container)) };
|
|
33
|
+
}
|
|
34
|
+
const markers = {
|
|
35
|
+
react: REACT(container),
|
|
36
|
+
vue: VUE(container),
|
|
37
|
+
preact: PREACT(container),
|
|
38
|
+
svelte: SVELTE(container),
|
|
39
|
+
};
|
|
40
|
+
return frameworkFromMarkers(markers, requested);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Selects a framework from a plain marker map produced inside the page, where DOM
|
|
45
|
+
* object identity is not available to Node.
|
|
46
|
+
*/
|
|
47
|
+
export function frameworkFromMarkers(markers, requested = 'auto') {
|
|
48
|
+
if (requested !== 'auto') {
|
|
49
|
+
return { name: requested, mounted: markers?.[requested] === true };
|
|
50
|
+
}
|
|
51
|
+
for (const name of AUTO_ORDER) {
|
|
52
|
+
if (markers?.[name] === true) return { name, mounted: true };
|
|
53
|
+
}
|
|
54
|
+
return { name: null, mounted: false };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const COMMON_PATTERNS = [
|
|
58
|
+
/react\.dev\/errors\/(418|419|421|422|423|424|425)/i,
|
|
59
|
+
/Minified React error #(418|419|421|422|423|424|425)\b/i,
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const FRAMEWORK_PATTERNS = {
|
|
63
|
+
react: [
|
|
64
|
+
...COMMON_PATTERNS,
|
|
65
|
+
/hydrat/i,
|
|
66
|
+
/text content did not match/i,
|
|
67
|
+
/prop [`"].+?[`"] did not match/i,
|
|
68
|
+
/server-rendered HTML/i,
|
|
69
|
+
/did not expect server HTML/i,
|
|
70
|
+
/expected server HTML to contain/i,
|
|
71
|
+
],
|
|
72
|
+
preact: [
|
|
73
|
+
...COMMON_PATTERNS,
|
|
74
|
+
/hydrat/i,
|
|
75
|
+
/text content did not match/i,
|
|
76
|
+
/did not expect server HTML/i,
|
|
77
|
+
],
|
|
78
|
+
vue: [/hydration (?:node|text|children) mismatch/i, /hydration-mismatch/i, /hydrat/i],
|
|
79
|
+
svelte: [/hydration_mismatch/i, /hydration failed/i],
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Classifies a console or page error as a hydration failure for the detected framework.
|
|
84
|
+
* Patterns stay narrow so an unrelated application error is not treated as a build gate.
|
|
85
|
+
* With `auto`, every framework set is tried because the framework was not established.
|
|
86
|
+
*/
|
|
87
|
+
export function isHydrationError(message, framework = 'auto') {
|
|
88
|
+
const text = String(message);
|
|
89
|
+
const patterns =
|
|
90
|
+
framework === 'auto'
|
|
91
|
+
? Object.values(FRAMEWORK_PATTERNS).flat()
|
|
92
|
+
: [...(FRAMEWORK_PATTERNS[framework] ?? [])];
|
|
93
|
+
return patterns.some((pattern) => pattern.test(text));
|
|
94
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { parse } from 'parse5';
|
|
2
|
+
|
|
3
|
+
const CONTAINER_IDS = new Set(['root', 'app', '__next']);
|
|
4
|
+
const STABLE_ATTRIBUTES = ['id', 'class', 'href', 'src', 'type', 'role', 'name'];
|
|
5
|
+
const MAX_NODES = 5000;
|
|
6
|
+
const MAX_DEPTH = 20;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Finds the first meaningful difference between the prerendered document and the live
|
|
10
|
+
* post-boot container. Bounded so a large app cannot turn verification into a memory
|
|
11
|
+
* problem.
|
|
12
|
+
*/
|
|
13
|
+
export function compareContainers(expectedHtml, liveHtml) {
|
|
14
|
+
const expected = containerOf(parse(expectedHtml));
|
|
15
|
+
const actual = containerOf(parse(liveHtml));
|
|
16
|
+
if (!expected || !actual) return null;
|
|
17
|
+
const state = { visited: 0 };
|
|
18
|
+
return diffNode(expected, actual, 'body', 0, state);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Finds the app container in a parsed document, falling back to the body's first child.
|
|
22
|
+
function containerOf(document) {
|
|
23
|
+
const html = (document.childNodes ?? []).find((node) => node.tagName === 'html');
|
|
24
|
+
const body = (html?.childNodes ?? []).find((node) => node.tagName === 'body');
|
|
25
|
+
if (!body) return null;
|
|
26
|
+
const container = (body.childNodes ?? []).find(
|
|
27
|
+
(node) => node.tagName && CONTAINER_IDS.has(attribute(node, 'id')),
|
|
28
|
+
);
|
|
29
|
+
return container ?? (body.childNodes ?? []).find((node) => node.tagName) ?? null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Compares one node pair and descends into the first differing child.
|
|
33
|
+
function diffNode(expected, actual, path, depth, state) {
|
|
34
|
+
if (state.visited > MAX_NODES || depth > MAX_DEPTH) return null;
|
|
35
|
+
state.visited += 1;
|
|
36
|
+
const here = `${path}>${expected.tagName ?? expected.nodeName}`;
|
|
37
|
+
if (!actual || expected.tagName !== actual.tagName) {
|
|
38
|
+
return {
|
|
39
|
+
path,
|
|
40
|
+
kind: 'tag',
|
|
41
|
+
expected: expected.tagName ?? expected.nodeName,
|
|
42
|
+
actual: actual?.tagName ?? actual?.nodeName ?? null,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
for (const name of STABLE_ATTRIBUTES) {
|
|
46
|
+
const left = attribute(expected, name);
|
|
47
|
+
const right = attribute(actual, name);
|
|
48
|
+
if (left !== right)
|
|
49
|
+
return { path: here, kind: 'attribute', name, expected: left, actual: right };
|
|
50
|
+
}
|
|
51
|
+
const expectedChildren = elementChildren(expected);
|
|
52
|
+
const actualChildren = elementChildren(actual);
|
|
53
|
+
for (let index = 0; index < expectedChildren.length; index += 1) {
|
|
54
|
+
const child = expectedChildren[index];
|
|
55
|
+
const other = actualChildren[index];
|
|
56
|
+
if (!other) {
|
|
57
|
+
return {
|
|
58
|
+
path: here,
|
|
59
|
+
kind: 'missing',
|
|
60
|
+
expected: child.tagName ?? child.nodeName,
|
|
61
|
+
actual: null,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const difference = diffNode(child, other, `${here}[${index}]`, depth + 1, state);
|
|
65
|
+
if (difference) return difference;
|
|
66
|
+
}
|
|
67
|
+
if (actualChildren.length > expectedChildren.length) {
|
|
68
|
+
const extra = actualChildren[expectedChildren.length];
|
|
69
|
+
return { path: here, kind: 'extra', expected: null, actual: extra.tagName ?? extra.nodeName };
|
|
70
|
+
}
|
|
71
|
+
const expectedText = directText(expected);
|
|
72
|
+
const actualText = directText(actual);
|
|
73
|
+
if (expectedText !== actualText) {
|
|
74
|
+
return { path: here, kind: 'text', expected: expectedText, actual: actualText };
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function elementChildren(node) {
|
|
80
|
+
return (node.childNodes ?? []).filter((child) => child.tagName);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function directText(node) {
|
|
84
|
+
return (node.childNodes ?? [])
|
|
85
|
+
.filter((child) => child.nodeName === '#text')
|
|
86
|
+
.map((child) => child.value)
|
|
87
|
+
.join('')
|
|
88
|
+
.trim();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function attribute(node, name) {
|
|
92
|
+
return node.attrs?.find((entry) => entry.name === name)?.value ?? '';
|
|
93
|
+
}
|
package/src/core/init.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
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';
|
|
5
|
+
import { DEFAULTS } from './config.js';
|
|
3
6
|
import { normaliseRoute } from './routes.js';
|
|
4
7
|
|
|
5
8
|
export const CONFIG_FILE = 'snappy.config.js';
|
|
@@ -13,6 +16,47 @@ const RUNNERS = [
|
|
|
13
16
|
['package-lock.json', 'npm'],
|
|
14
17
|
];
|
|
15
18
|
|
|
19
|
+
// react-snap options whose name differs here, mapped to this package's option.
|
|
20
|
+
const REACT_SNAP_RENAMED = {
|
|
21
|
+
source: 'sourceDir',
|
|
22
|
+
puppeteerArgs: 'browserArgs',
|
|
23
|
+
puppeteerIgnoreHTTPSErrors: 'ignoreHTTPSErrors',
|
|
24
|
+
skipThirdPartyRequests: 'blockThirdParty',
|
|
25
|
+
fixInsertRule: 'captureRuntimeStyles',
|
|
26
|
+
fixFormFields: 'captureFormState',
|
|
27
|
+
http2PushManifest: 'preloadManifest',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// react-snap options that keep their name and meaning.
|
|
31
|
+
const REACT_SNAP_PASSTHROUGH = new Set([
|
|
32
|
+
'include',
|
|
33
|
+
'exclude',
|
|
34
|
+
'crawl',
|
|
35
|
+
'concurrency',
|
|
36
|
+
'timeout',
|
|
37
|
+
'waitFor',
|
|
38
|
+
'inlineCss',
|
|
39
|
+
'minifyHtml',
|
|
40
|
+
'minifyCss',
|
|
41
|
+
'cacheAjaxRequests',
|
|
42
|
+
'preconnectThirdParty',
|
|
43
|
+
'preloadImages',
|
|
44
|
+
'removeBlobs',
|
|
45
|
+
'removeStyleTags',
|
|
46
|
+
'removeScriptTags',
|
|
47
|
+
'asyncScriptTags',
|
|
48
|
+
'userAgent',
|
|
49
|
+
'viewport',
|
|
50
|
+
'saveAs',
|
|
51
|
+
'destination',
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
// react-snap options that cannot be carried over, with the reason shown to the user.
|
|
55
|
+
const REACT_SNAP_IGNORED = {
|
|
56
|
+
port: 'the prerenderer serves the build itself on an ephemeral port',
|
|
57
|
+
sourceMaps: 'react-snap declares it but never uses it',
|
|
58
|
+
};
|
|
59
|
+
|
|
16
60
|
/**
|
|
17
61
|
* Reads what the project already says about itself: the framework that decides the build
|
|
18
62
|
* directory, whether Vite is in use, which package runner it uses, and whether this package
|
|
@@ -23,12 +67,101 @@ export async function detectProjectDefaults(dir = process.cwd()) {
|
|
|
23
67
|
const dependencies = { ...manifest?.dependencies, ...manifest?.devDependencies };
|
|
24
68
|
const usesVite = Boolean(dependencies.vite) || (await anyExists(dir, VITE_CONFIGS));
|
|
25
69
|
const usesCreateReactApp = Boolean(dependencies['react-scripts']) && !usesVite;
|
|
26
|
-
|
|
27
|
-
|
|
70
|
+
const vite = usesVite ? await detectViteConfig(dir) : null;
|
|
71
|
+
const detected = {
|
|
72
|
+
sourceDir: vite?.sourceDir ?? (usesCreateReactApp ? 'build' : 'dist'),
|
|
73
|
+
base: vite?.base ?? homepageBase(manifest?.homepage) ?? '/',
|
|
28
74
|
usesVite,
|
|
29
75
|
runner: await detectRunner(dir),
|
|
30
76
|
installed: PACKAGE_NAME in dependencies,
|
|
31
77
|
};
|
|
78
|
+
const reactSnap = mapReactSnapOptions(manifest?.reactSnap);
|
|
79
|
+
if (reactSnap) detected.reactSnap = reactSnap;
|
|
80
|
+
return detected;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Maps a legacy react-snap block onto this package's options. The result lists what
|
|
85
|
+
* carried over, what was deliberately dropped and what was not recognised, so the
|
|
86
|
+
* wizard can explain the migration instead of guessing.
|
|
87
|
+
*/
|
|
88
|
+
export function mapReactSnapOptions(block) {
|
|
89
|
+
if (typeof block !== 'object' || block === null || Array.isArray(block)) return null;
|
|
90
|
+
const values = {};
|
|
91
|
+
const mapping = [];
|
|
92
|
+
const ignored = [];
|
|
93
|
+
const unknown = [];
|
|
94
|
+
for (const [key, value] of Object.entries(block)) {
|
|
95
|
+
if (Object.hasOwn(REACT_SNAP_IGNORED, key)) {
|
|
96
|
+
ignored.push({ key, reason: REACT_SNAP_IGNORED[key] });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const target = REACT_SNAP_RENAMED[key] ?? (REACT_SNAP_PASSTHROUGH.has(key) ? key : null);
|
|
100
|
+
if (target === null) {
|
|
101
|
+
unknown.push(key);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
// JSON cannot carry functions or RegExp, but a hand-written manifest may.
|
|
105
|
+
if (!isSerialisable(value)) {
|
|
106
|
+
unknown.push(`${key} (unsupported value)`);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const mappedValue =
|
|
110
|
+
target === 'include' && typeof value === 'string' ? parseRouteList(value) : value;
|
|
111
|
+
values[target] = mappedValue;
|
|
112
|
+
mapping.push({ from: key, to: target, value: mappedValue });
|
|
113
|
+
}
|
|
114
|
+
return { values, mapping, ignored, unknown };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Reports whether a value can be written into a JavaScript config file.
|
|
118
|
+
function isSerialisable(value) {
|
|
119
|
+
if (value === null) return true;
|
|
120
|
+
if (['string', 'number', 'boolean'].includes(typeof value)) return true;
|
|
121
|
+
if (Array.isArray(value)) return value.every(isSerialisable);
|
|
122
|
+
if (typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
|
|
123
|
+
return Object.values(value).every(isSerialisable);
|
|
124
|
+
}
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Reads the project's real Vite output directory and base when Vite can be resolved
|
|
130
|
+
* from that project. Falls back to the conventional defaults when the config is
|
|
131
|
+
* missing, imports an unavailable plugin, or Vite is not installed.
|
|
132
|
+
*/
|
|
133
|
+
async function detectViteConfig(dir) {
|
|
134
|
+
try {
|
|
135
|
+
const require = createRequire(path.join(dir, 'package.json'));
|
|
136
|
+
const entry = require.resolve('vite');
|
|
137
|
+
const { resolveConfig } = await import(pathToFileURL(entry).href);
|
|
138
|
+
const resolved = await resolveConfig({ root: dir, logLevel: 'silent' }, 'build');
|
|
139
|
+
const relative = path.relative(dir, resolved.build.outDir);
|
|
140
|
+
return {
|
|
141
|
+
sourceDir: relative.startsWith('..') ? resolved.build.outDir : relative || '.',
|
|
142
|
+
base: detectedBase(resolved.base),
|
|
143
|
+
};
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Keeps a detected base usable by normaliseBase: only root-relative bases apply here.
|
|
150
|
+
function detectedBase(base) {
|
|
151
|
+
if (typeof base !== 'string' || !base.startsWith('/') || base.startsWith('//')) return '/';
|
|
152
|
+
return base.endsWith('/') ? base : `${base}/`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Reads Create React App's homepage into a base path, ignoring absolute deployments
|
|
156
|
+
// and values that are not a path.
|
|
157
|
+
function homepageBase(homepage) {
|
|
158
|
+
if (typeof homepage !== 'string' || homepage.length === 0) return null;
|
|
159
|
+
try {
|
|
160
|
+
const url = new URL(homepage, 'https://example.invalid');
|
|
161
|
+
return url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`;
|
|
162
|
+
} catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
32
165
|
}
|
|
33
166
|
|
|
34
167
|
/**
|
|
@@ -44,46 +177,74 @@ export function parseRouteList(input) {
|
|
|
44
177
|
return routes.length > 0 ? routes : ['/'];
|
|
45
178
|
}
|
|
46
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Turns the wizard's answers into the options object the generated config will export.
|
|
182
|
+
* Only values that change a default are included, and only options the resolver knows.
|
|
183
|
+
*/
|
|
184
|
+
export function configValuesFromAnswers(answers = {}) {
|
|
185
|
+
const values = { sourceDir: answers.sourceDir ?? 'dist' };
|
|
186
|
+
if (answers.base && answers.base !== '/') values.base = answers.base;
|
|
187
|
+
// A supplied include list acts as an allowlist, so writing the default would stop discovery.
|
|
188
|
+
const includeValue = answers.include ?? ['/'];
|
|
189
|
+
const include = parseRouteList(
|
|
190
|
+
Array.isArray(includeValue) ? includeValue.join(',') : includeValue,
|
|
191
|
+
);
|
|
192
|
+
const includeIsDefault = include.length === 1 && include[0] === '/';
|
|
193
|
+
if (!includeIsDefault) values.include = include;
|
|
194
|
+
if (answers.crawl === false) values.crawl = false;
|
|
195
|
+
if (answers.csp === 'strict' || answers.csp === 'hash') values.csp = answers.csp;
|
|
196
|
+
if (answers.externalScripts === true) values.externalScripts = true;
|
|
197
|
+
if (answers.criticalCssPreload !== undefined && answers.criticalCssPreload !== 'media') {
|
|
198
|
+
values.criticalCssPreload = answers.criticalCssPreload;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const metadata = METADATA_KEYS.filter((key) => answers[key]).map((key) => [key, answers[key]]);
|
|
202
|
+
if (metadata.length > 0) values.metadata = Object.fromEntries(metadata);
|
|
203
|
+
|
|
204
|
+
for (const [key, value] of Object.entries(answers.extraOptions ?? {})) {
|
|
205
|
+
if (!Object.hasOwn(DEFAULTS, key) || key === 'metadata') continue;
|
|
206
|
+
if (Object.hasOwn(values, key)) continue;
|
|
207
|
+
if (!isSerialisable(value)) continue;
|
|
208
|
+
values[key] = value;
|
|
209
|
+
}
|
|
210
|
+
return values;
|
|
211
|
+
}
|
|
212
|
+
|
|
47
213
|
/**
|
|
48
214
|
* Renders the config file for the wizard's answers, writing only the options the answers
|
|
49
215
|
* actually set so every line in the file does something.
|
|
50
216
|
*/
|
|
51
217
|
export function renderConfigFile(answers = {}) {
|
|
218
|
+
const values = configValuesFromAnswers(answers);
|
|
52
219
|
const lines = [
|
|
53
220
|
'// Created by `snappy-prerender init`. Every option is documented in the snappy-prerender README.',
|
|
54
221
|
'export default {',
|
|
55
|
-
` sourceDir: ${quote(answers.sourceDir ?? 'dist')},`,
|
|
56
222
|
];
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (!includeIsDefault) lines.push(` include: [${include.map(quote).join(', ')}],`);
|
|
63
|
-
if (answers.crawl === false) lines.push(' crawl: false,');
|
|
64
|
-
// The CSP dial and the parts it was tuned with, so a strict-CSP project keeps the choice.
|
|
65
|
-
if (answers.csp === 'strict') lines.push(' csp: "strict",');
|
|
66
|
-
if (answers.externalScripts === true) lines.push(' externalScripts: true,');
|
|
67
|
-
if (answers.criticalCssPreload !== undefined && answers.criticalCssPreload !== 'media') {
|
|
68
|
-
// A boolean stays a literal; a mode name is quoted.
|
|
69
|
-
const value =
|
|
70
|
-
typeof answers.criticalCssPreload === 'boolean'
|
|
71
|
-
? String(answers.criticalCssPreload)
|
|
72
|
-
: quote(answers.criticalCssPreload);
|
|
73
|
-
lines.push(` criticalCssPreload: ${value},`);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const metadata = METADATA_KEYS.filter((key) => answers[key]).map((key) => [key, answers[key]]);
|
|
77
|
-
if (metadata.length > 0) {
|
|
223
|
+
for (const [key, value] of Object.entries(values)) {
|
|
224
|
+
if (key !== 'metadata') {
|
|
225
|
+
lines.push(` ${key}: ${serializeValue(value)},`);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
78
228
|
lines.push(' metadata: {');
|
|
79
|
-
for (const [
|
|
229
|
+
for (const [metadataKey, metadataValue] of Object.entries(value)) {
|
|
230
|
+
lines.push(` ${metadataKey}: ${serializeValue(metadataValue)},`);
|
|
231
|
+
}
|
|
80
232
|
lines.push(' },');
|
|
81
233
|
}
|
|
82
|
-
|
|
83
234
|
lines.push('};', '');
|
|
84
235
|
return lines.join('\n');
|
|
85
236
|
}
|
|
86
237
|
|
|
238
|
+
// Writes a JSON-compatible value as JavaScript source with a stable, readable shape.
|
|
239
|
+
function serializeValue(value) {
|
|
240
|
+
if (Array.isArray(value)) return `[${value.map(serializeValue).join(', ')}]`;
|
|
241
|
+
if (value !== null && typeof value === 'object') {
|
|
242
|
+
const entries = Object.entries(value).map(([key, entry]) => `${key}: ${serializeValue(entry)}`);
|
|
243
|
+
return `{ ${entries.join(', ')} }`;
|
|
244
|
+
}
|
|
245
|
+
return JSON.stringify(value);
|
|
246
|
+
}
|
|
247
|
+
|
|
87
248
|
/**
|
|
88
249
|
* Writes the config file into the project, refusing to replace an existing one unless the
|
|
89
250
|
* caller asked for it, so a wizard run can never silently destroy hand-written settings.
|
|
@@ -97,11 +258,6 @@ export async function writeConfigFile({ dir, contents, force = false }) {
|
|
|
97
258
|
return target;
|
|
98
259
|
}
|
|
99
260
|
|
|
100
|
-
// Quotes a string for the generated module.
|
|
101
|
-
function quote(value) {
|
|
102
|
-
return JSON.stringify(String(value));
|
|
103
|
-
}
|
|
104
|
-
|
|
105
261
|
// Reads a JSON file, treating a missing or malformed file as absent.
|
|
106
262
|
async function readJson(file) {
|
|
107
263
|
try {
|