snappy-prerender 2.6.0-rc.2 → 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 +96 -19
- package/package.json +11 -6
- package/src/cli.js +119 -12
- 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 +143 -3
- 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 +71 -1
- 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 +191 -241
- package/src/core/server.js +30 -4
- package/src/core/sitemap.js +94 -0
- package/src/core/url-policy.js +32 -0
- package/src/core/verify.js +18 -4
- package/src/core/write.js +278 -0
- package/src/head/index.js +1 -1
- package/src/head/tags.js +19 -26
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { normaliseRoute, toPublicPath } from './routes.js';
|
|
2
|
+
import { pagePath } from './url-policy.js';
|
|
3
|
+
|
|
4
|
+
const XML_ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
5
|
+
export const SITEMAP_MAX_URLS = 50000;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Builds the sitemap for the routes a run wrote. Locs use the same page path policy as
|
|
9
|
+
* canonical links, so a directory route keeps its slash, a flat route stays
|
|
10
|
+
* extensionless, and a noindex page is never listed. More than maxUrls entries are
|
|
11
|
+
* split into numbered sitemaps behind a sitemap index.
|
|
12
|
+
*/
|
|
13
|
+
export function buildSitemap({ files, config, maxUrls = SITEMAP_MAX_URLS }) {
|
|
14
|
+
const siteUrl = config.metadata?.siteUrl?.replace(/\/$/, '');
|
|
15
|
+
if (!siteUrl) return null;
|
|
16
|
+
const notFound = config.notFoundRoute ? normaliseRoute(config.notFoundRoute) : null;
|
|
17
|
+
|
|
18
|
+
const locs = new Set();
|
|
19
|
+
for (const file of files) {
|
|
20
|
+
if (!file.file.endsWith('.html') || file.noindex) continue;
|
|
21
|
+
if (file.route && notFound && normaliseRoute(file.route) === notFound) continue;
|
|
22
|
+
const path = file.route
|
|
23
|
+
? pagePath(file.route, {
|
|
24
|
+
flatOutput: config.flatOutput,
|
|
25
|
+
trailingSlash: config.metadata?.trailingSlash,
|
|
26
|
+
})
|
|
27
|
+
: publicPath(file.file);
|
|
28
|
+
locs.add(`${siteUrl}${toPublicPath(path, config.base)}`);
|
|
29
|
+
}
|
|
30
|
+
if (locs.size === 0) return null;
|
|
31
|
+
|
|
32
|
+
const sorted = [...locs].sort();
|
|
33
|
+
const chunks = [];
|
|
34
|
+
for (let index = 0; index < sorted.length; index += maxUrls) {
|
|
35
|
+
chunks.push(sorted.slice(index, index + maxUrls));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (chunks.length === 1) {
|
|
39
|
+
return { files: [{ name: 'sitemap.xml', contents: urlSet(chunks[0]) }], routes: sorted.length };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const entries = chunks.map((chunk, index) => {
|
|
43
|
+
const name = `sitemap-${index + 1}.xml`;
|
|
44
|
+
const loc = `${siteUrl}${toPublicPath(`/${name}`, config.base)}`;
|
|
45
|
+
return { name, contents: urlSet(chunk), loc };
|
|
46
|
+
});
|
|
47
|
+
return {
|
|
48
|
+
files: [
|
|
49
|
+
{ name: 'sitemap.xml', contents: sitemapIndex(entries) },
|
|
50
|
+
...entries.map(({ name, contents }) => ({ name, contents })),
|
|
51
|
+
],
|
|
52
|
+
routes: sorted.length,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Maps an output file to the path a host serves it at: index.html is the root,
|
|
58
|
+
* about/index.html is /about/, and anything else keeps its name.
|
|
59
|
+
*/
|
|
60
|
+
export function publicPath(file) {
|
|
61
|
+
const normalised = String(file).replaceAll('\\', '/');
|
|
62
|
+
if (normalised === 'index.html') return '/';
|
|
63
|
+
if (normalised.endsWith('/index.html')) return `/${normalised.slice(0, -'index.html'.length)}`;
|
|
64
|
+
return `/${normalised}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Appends the sitemap location to a robots.txt that exists but does not mention it yet,
|
|
69
|
+
* leaving a file that already points somewhere alone.
|
|
70
|
+
*/
|
|
71
|
+
export function withSitemapDirective(robots, sitemapUrl) {
|
|
72
|
+
if (/^\s*sitemap:/im.test(robots)) return null;
|
|
73
|
+
const separator = robots.endsWith('\n') ? '' : '\n';
|
|
74
|
+
return `${robots}${separator}Sitemap: ${sitemapUrl}\n`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Builds one urlset document.
|
|
78
|
+
function urlSet(locs) {
|
|
79
|
+
const urls = locs.map((loc) => ` <url>\n <loc>${escapeXml(loc)}</loc>\n </url>`).join('\n');
|
|
80
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Builds the sitemap index that points at the numbered sitemaps.
|
|
84
|
+
function sitemapIndex(entries) {
|
|
85
|
+
const sitemaps = entries
|
|
86
|
+
.map((entry) => ` <sitemap>\n <loc>${escapeXml(entry.loc)}</loc>\n </sitemap>`)
|
|
87
|
+
.join('\n');
|
|
88
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${sitemaps}\n</sitemapindex>\n`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Escapes the characters that would break a sitemap element.
|
|
92
|
+
function escapeXml(value) {
|
|
93
|
+
return value.replace(/[&<>"']/g, (char) => XML_ESCAPES[char]);
|
|
94
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { normaliseRoute } from './routes.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Builds the public path for a route using the same policy for canonical, social and
|
|
5
|
+
* sitemap URLs. Directory output keeps the trailing slash it is served with; flat
|
|
6
|
+
* output stays extensionless so `/about` remains the URL even though the file is
|
|
7
|
+
* `about.html`. An explicit trailingSlash convention always wins.
|
|
8
|
+
*/
|
|
9
|
+
export function pagePath(route, { flatOutput = false, trailingSlash = 'preserve' } = {}) {
|
|
10
|
+
const normalised = normaliseRoute(route);
|
|
11
|
+
if (normalised === '/') return '/';
|
|
12
|
+
if (trailingSlash === 'always') return `${normalised}/`;
|
|
13
|
+
if (trailingSlash === 'never') return normalised;
|
|
14
|
+
return flatOutput ? normalised : `${normalised}/`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Applies a trailing slash convention to an absolute URL, leaving the root and asset
|
|
19
|
+
* URLs alone. Used for an explicit canonical that is already a full URL.
|
|
20
|
+
*/
|
|
21
|
+
export function withTrailingSlash(url, convention) {
|
|
22
|
+
if (convention !== 'always' && convention !== 'never') return url;
|
|
23
|
+
const [path, query] = url.split('?');
|
|
24
|
+
const isRoot = /^https?:\/\/[^/]+\/?$/i.test(path);
|
|
25
|
+
if (convention === 'always') {
|
|
26
|
+
const next = path.endsWith('/') ? path : `${path}/`;
|
|
27
|
+
return query ? `${next}?${query}` : next;
|
|
28
|
+
}
|
|
29
|
+
if (isRoot) return query ? `${path.replace(/\/$/, '/')}?${query}` : path.replace(/\/?$/, '/');
|
|
30
|
+
const next = path.replace(/\/+$/, '');
|
|
31
|
+
return query ? `${next}?${query}` : next;
|
|
32
|
+
}
|
package/src/core/verify.js
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import { blockThirdPartyRequests } from './blocking.js';
|
|
2
|
+
import { DEFAULTS } from './config.js';
|
|
3
|
+
import { isPageEmpty } from './page-content.js';
|
|
2
4
|
import { toServerUrl } from './routes.js';
|
|
3
5
|
import { mapLimit } from './scheduler.js';
|
|
4
6
|
import { settlePage, trackNetwork } from './settle.js';
|
|
5
7
|
|
|
6
8
|
const HYDRATION_PATTERNS = [
|
|
7
9
|
/hydrat/i,
|
|
8
|
-
/did not match/i,
|
|
10
|
+
/text content did not match/i,
|
|
11
|
+
/prop [`"].+?[`"] did not match/i,
|
|
9
12
|
/server-rendered HTML/i,
|
|
13
|
+
// React 16 and 17 phrase their mismatch warnings differently and never use the
|
|
14
|
+
// minified codes, which only exist from React 18 on.
|
|
15
|
+
/did not expect server HTML/i,
|
|
16
|
+
/expected server HTML to contain/i,
|
|
10
17
|
/react\.dev\/errors\/(418|419|421|422|423|424|425)/i,
|
|
11
18
|
/Minified React error #(418|419|421|422|423|424|425)\b/i,
|
|
12
19
|
];
|
|
@@ -90,8 +97,11 @@ async function verifyRoute({ browser, origin, route, config }) {
|
|
|
90
97
|
const context = await browser.newContext({
|
|
91
98
|
viewport: config.viewport,
|
|
92
99
|
storageState: config.storageState ?? undefined,
|
|
93
|
-
userAgent:
|
|
100
|
+
userAgent:
|
|
101
|
+
config.userAgent === DEFAULTS.userAgent ? undefined : (config.userAgent ?? undefined),
|
|
94
102
|
ignoreHTTPSErrors: config.ignoreHTTPSErrors,
|
|
103
|
+
reducedMotion: config.freezeAnimations ? 'reduce' : 'no-preference',
|
|
104
|
+
serviceWorkers: 'block',
|
|
95
105
|
});
|
|
96
106
|
const page = await context.newPage();
|
|
97
107
|
const hydrationErrors = [];
|
|
@@ -113,11 +123,13 @@ async function verifyRoute({ browser, origin, route, config }) {
|
|
|
113
123
|
await blockThirdPartyRequests(page, origin, config.allowedHosts);
|
|
114
124
|
}
|
|
115
125
|
await page.addInitScript(bootProbe);
|
|
116
|
-
await page.goto(toServerUrl(route, config.base, origin), {
|
|
126
|
+
const response = await page.goto(toServerUrl(route, config.base, origin), {
|
|
117
127
|
waitUntil: 'domcontentloaded',
|
|
118
128
|
timeout: config.timeout,
|
|
119
129
|
});
|
|
130
|
+
const status = response?.status() ?? null;
|
|
120
131
|
await settlePage(page, config, tracker);
|
|
132
|
+
const empty = await isPageEmpty(page);
|
|
121
133
|
|
|
122
134
|
const boot = await page.evaluate((selector) => {
|
|
123
135
|
const probe = window.__snappyBoot ?? {};
|
|
@@ -134,10 +146,12 @@ async function verifyRoute({ browser, origin, route, config }) {
|
|
|
134
146
|
|
|
135
147
|
return {
|
|
136
148
|
route,
|
|
149
|
+
status,
|
|
150
|
+
empty,
|
|
137
151
|
hydrationErrors,
|
|
138
152
|
consoleErrors,
|
|
139
153
|
pageErrors,
|
|
140
|
-
ok: hydrationErrors.length === 0,
|
|
154
|
+
ok: hydrationErrors.length === 0 && (status === null || status < 400) && !empty,
|
|
141
155
|
mode: classifyBoot(boot),
|
|
142
156
|
markupPreserved: boot.markupPreserved,
|
|
143
157
|
};
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { findBuildOriginUrls } from './build-origin.js';
|
|
3
|
+
import { inlineCriticalCss } from './inline-css.js';
|
|
4
|
+
import { minifyHtml } from './minify.js';
|
|
5
|
+
import { normaliseHtml } from './normalize.js';
|
|
6
|
+
import {
|
|
7
|
+
findOutputCollisions,
|
|
8
|
+
routeToScreenshotFile,
|
|
9
|
+
writeBufferIfChanged,
|
|
10
|
+
writeFileIfChanged,
|
|
11
|
+
writeRouteHtml,
|
|
12
|
+
} from './output.js';
|
|
13
|
+
import { collectPageResources } from './preload-manifest.js';
|
|
14
|
+
import { normaliseRoute, toPublicPath } from './routes.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Writes every rendered route and returns the routes that can be verified. A write
|
|
18
|
+
* failure is recorded against its route instead of aborting the remaining writes.
|
|
19
|
+
*/
|
|
20
|
+
export async function writeAllRoutes({
|
|
21
|
+
rendered,
|
|
22
|
+
outputDir,
|
|
23
|
+
config,
|
|
24
|
+
log,
|
|
25
|
+
report,
|
|
26
|
+
state,
|
|
27
|
+
manifestEntries,
|
|
28
|
+
}) {
|
|
29
|
+
const sorted = [...report.routes].sort();
|
|
30
|
+
const conflicted = reportOutputCollisions({ routes: sorted, config, log, report });
|
|
31
|
+
|
|
32
|
+
await writeStateScripts({ rendered, outputDir, config, log, report });
|
|
33
|
+
|
|
34
|
+
const verifiable = [];
|
|
35
|
+
const hashes = new Map();
|
|
36
|
+
for (const route of sorted) {
|
|
37
|
+
if (conflicted.has(route)) continue;
|
|
38
|
+
try {
|
|
39
|
+
const { file, resources } = await writeRouteOutput({
|
|
40
|
+
route,
|
|
41
|
+
result: rendered.get(route),
|
|
42
|
+
outputDir,
|
|
43
|
+
config,
|
|
44
|
+
log,
|
|
45
|
+
state,
|
|
46
|
+
report,
|
|
47
|
+
});
|
|
48
|
+
report.files.push(file);
|
|
49
|
+
if (resources) manifestEntries.push({ route, ...resources });
|
|
50
|
+
if (file.hash) {
|
|
51
|
+
const group = hashes.get(file.hash) ?? [];
|
|
52
|
+
group.push(route);
|
|
53
|
+
hashes.set(file.hash, group);
|
|
54
|
+
}
|
|
55
|
+
verifiable.push(route);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
report.errors.push({ route, message: `write failed: ${error.message}` });
|
|
58
|
+
log.error(` write failed for ${route}: ${error.message}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
reportDuplicatePages({ groups: [...hashes.values()], config, log, report });
|
|
62
|
+
return verifiable;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Reports routes that produced byte-identical HTML. The usual cause is an SPA fallback
|
|
67
|
+
* serving the shell for a path the app does not route, which would otherwise publish a
|
|
68
|
+
* copy of another page under a dead URL.
|
|
69
|
+
*/
|
|
70
|
+
function reportDuplicatePages({ groups, config, log, report }) {
|
|
71
|
+
for (const routes of groups) {
|
|
72
|
+
if (routes.length < 2) continue;
|
|
73
|
+
report.duplicatePages.push({ routes });
|
|
74
|
+
log.warn(
|
|
75
|
+
`identical output for ${routes.join(', ')}: check for a dead link served by the SPA fallback`,
|
|
76
|
+
);
|
|
77
|
+
if (config.failOnDuplicateContent) {
|
|
78
|
+
for (const route of routes) {
|
|
79
|
+
report.errors.push({
|
|
80
|
+
route,
|
|
81
|
+
message: `identical output to ${routes.filter((other) => other !== route).join(', ')}`,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Writes the captured-state scripts routes reference when externalScripts is on. The payload
|
|
90
|
+
* is content-addressed, so routes that inject the same state share one file and a rerun
|
|
91
|
+
* leaves it untouched.
|
|
92
|
+
*/
|
|
93
|
+
async function writeStateScripts({ rendered, outputDir, config, log, report }) {
|
|
94
|
+
const seen = new Set();
|
|
95
|
+
for (const [route, result] of rendered) {
|
|
96
|
+
const stateScript = result?.stateScript;
|
|
97
|
+
if (!stateScript || seen.has(stateScript.file)) continue;
|
|
98
|
+
seen.add(stateScript.file);
|
|
99
|
+
if (config.dryRun) {
|
|
100
|
+
report.files.push({
|
|
101
|
+
route,
|
|
102
|
+
file: stateScript.file,
|
|
103
|
+
status: 'dry-run',
|
|
104
|
+
bytes: Buffer.byteLength(stateScript.contents),
|
|
105
|
+
});
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
const written = await writeFileIfChanged({
|
|
110
|
+
dir: outputDir,
|
|
111
|
+
file: stateScript.file,
|
|
112
|
+
contents: stateScript.contents,
|
|
113
|
+
});
|
|
114
|
+
report.files.push({ route, ...written });
|
|
115
|
+
log.debug(` wrote ${stateScript.file}: ${written.status}`);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
report.errors.push({ route, message: `write failed: ${error.message}` });
|
|
118
|
+
log.error(` write failed for ${stateScript.file}: ${error.message}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Records an error for every route involved in an output file collision and returns
|
|
125
|
+
* the routes to skip, so an ambiguous write can never overwrite another route.
|
|
126
|
+
*/
|
|
127
|
+
function reportOutputCollisions({ routes, config, log, report }) {
|
|
128
|
+
const conflicted = new Set();
|
|
129
|
+
for (const [file, colliding] of findOutputCollisions(routes, config)) {
|
|
130
|
+
for (const route of colliding) {
|
|
131
|
+
conflicted.add(route);
|
|
132
|
+
const others = colliding.filter((other) => other !== route).join(', ');
|
|
133
|
+
report.errors.push({
|
|
134
|
+
route,
|
|
135
|
+
message: `output file collision: ${file} is also written by ${others}`,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
log.error(` output file collision on ${file}: ${colliding.join(', ')}`);
|
|
139
|
+
}
|
|
140
|
+
return conflicted;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Applies the configured post-processing to one rendered route and writes it: DOM
|
|
145
|
+
* cleanups and link hints, critical CSS extraction, then optional minification.
|
|
146
|
+
*/
|
|
147
|
+
async function writeRouteOutput({ route, result, outputDir, config, log, state, report }) {
|
|
148
|
+
if (result.screenshot) {
|
|
149
|
+
const file = routeToScreenshotFile(route, config);
|
|
150
|
+
if (config.dryRun) {
|
|
151
|
+
return {
|
|
152
|
+
file: {
|
|
153
|
+
route,
|
|
154
|
+
file,
|
|
155
|
+
status: 'dry-run',
|
|
156
|
+
bytes: result.screenshot.length,
|
|
157
|
+
},
|
|
158
|
+
resources: null,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const written = await writeBufferIfChanged({
|
|
162
|
+
dir: outputDir,
|
|
163
|
+
file,
|
|
164
|
+
buffer: result.screenshot,
|
|
165
|
+
});
|
|
166
|
+
return { file: { route, ...written }, resources: null };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const { html, stats } = normaliseHtml(result.html, {
|
|
170
|
+
removeStyleTags: config.removeStyleTags,
|
|
171
|
+
removeScriptTags: config.removeScriptTags,
|
|
172
|
+
asyncScriptTags: config.asyncScriptTags,
|
|
173
|
+
removeBlobs: config.removeBlobs,
|
|
174
|
+
removeAttributes: config.removeAttributes,
|
|
175
|
+
stripBuildOriginMetadata: config.stripBuildOriginMetadata,
|
|
176
|
+
noindex:
|
|
177
|
+
config.notFoundRoute !== null &&
|
|
178
|
+
normaliseRoute(route) === normaliseRoute(config.notFoundRoute),
|
|
179
|
+
preconnectOrigins: config.preconnectThirdParty
|
|
180
|
+
? [...result.collector.thirdPartyOrigins].sort()
|
|
181
|
+
: [],
|
|
182
|
+
preloadImages: config.preloadImages
|
|
183
|
+
? [...result.collector.images].sort().map((image) => toPublicPath(image, config.base))
|
|
184
|
+
: [],
|
|
185
|
+
});
|
|
186
|
+
if (
|
|
187
|
+
stats.removedElements > 0 ||
|
|
188
|
+
stats.removedAttributes > 0 ||
|
|
189
|
+
stats.dedupedStyles > 0 ||
|
|
190
|
+
stats.hints > 0 ||
|
|
191
|
+
stats.textSeparators > 0
|
|
192
|
+
) {
|
|
193
|
+
log.debug(
|
|
194
|
+
` cleaned ${route}: ${stats.removedElements} element(s) removed, ${stats.removedAttributes} attribute(s) removed, ${stats.dedupedStyles} duplicate style(s), ${stats.hints} hint(s), ${stats.textSeparators} text separator(s)`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
if ((result.inlineCssSkipped ?? []).length > 0) {
|
|
198
|
+
log.debug(` inline css: left ${result.inlineCssSkipped.length} stylesheet(s) as links`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let output = html;
|
|
202
|
+
if (config.inlineCss === 'critical') {
|
|
203
|
+
output = await applyCriticalCss({ output, outputDir, config, log, state });
|
|
204
|
+
}
|
|
205
|
+
if (config.minifyHtml) {
|
|
206
|
+
output = await minifyHtml(output, config.minifyHtml, config.minifyCss);
|
|
207
|
+
}
|
|
208
|
+
warnOnBuildOriginMetadata({
|
|
209
|
+
origins: stats.metadataOrigins,
|
|
210
|
+
removed: stats.removedMetadata,
|
|
211
|
+
route,
|
|
212
|
+
log,
|
|
213
|
+
state,
|
|
214
|
+
});
|
|
215
|
+
if (stats.titleElements > 1) report.duplicateTitles.push(route);
|
|
216
|
+
if (config.failOnBuildOriginMetadata) {
|
|
217
|
+
const origins = findBuildOriginUrls(output);
|
|
218
|
+
if (origins.length > 0) {
|
|
219
|
+
const message = `metadata still points at the build server: ${origins.join(', ')}`;
|
|
220
|
+
report.errors.push({ route, message });
|
|
221
|
+
log.error(` ${route}: ${message}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const file = await writeRouteHtml({ dir: outputDir, route, html: output, config });
|
|
225
|
+
const resources = config.preloadManifest
|
|
226
|
+
? collectPageResources(output, { base: config.base, route })
|
|
227
|
+
: null;
|
|
228
|
+
return {
|
|
229
|
+
file: {
|
|
230
|
+
...file,
|
|
231
|
+
// Hash the captured DOM before post-processing adds route-specific extras such as
|
|
232
|
+
// the not-found noindex, so a dead link serving the 404 page is still detected.
|
|
233
|
+
hash: createHash('sha256')
|
|
234
|
+
.update(result.html ?? output)
|
|
235
|
+
.digest('hex'),
|
|
236
|
+
noindex: stats.noindex,
|
|
237
|
+
},
|
|
238
|
+
resources,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Inlines critical CSS, or explains once why it cannot: the strategy reads stylesheets from
|
|
244
|
+
* the output directory, which a command server does not have to serve.
|
|
245
|
+
*/
|
|
246
|
+
async function applyCriticalCss({ output, outputDir, config, log, state }) {
|
|
247
|
+
if (!config.serveCmd) {
|
|
248
|
+
return inlineCriticalCss({
|
|
249
|
+
html: output,
|
|
250
|
+
outputDir,
|
|
251
|
+
base: config.base,
|
|
252
|
+
preload: config.criticalCssPreload,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
if (!state.warnedCriticalCssSkipped) {
|
|
256
|
+
state.warnedCriticalCssSkipped = true;
|
|
257
|
+
log.warn(
|
|
258
|
+
"inlineCss: 'critical' is skipped with serveCmd: stylesheets are read from the output directory, which the command server does not have to serve",
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
return output;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Warns once when metadata contains the build server's origin, which means the page was
|
|
266
|
+
* composed without a public siteUrl and og:url, og:image or canonical would ship wrong.
|
|
267
|
+
*/
|
|
268
|
+
function warnOnBuildOriginMetadata({ origins, removed = 0, route, log, state }) {
|
|
269
|
+
if (state.warnedBuildOriginMetadata || origins.length === 0) return;
|
|
270
|
+
state.warnedBuildOriginMetadata = true;
|
|
271
|
+
log.warn(
|
|
272
|
+
`metadata on ${route} points at the build server (${origins.join(', ')}); ${
|
|
273
|
+
removed > 0
|
|
274
|
+
? `removed ${removed} affected tag(s). Set metadata.siteUrl so canonical, og:url and og:image match the deployed site`
|
|
275
|
+
: 'set metadata.siteUrl so og:url, og:image and canonical match the deployed site'
|
|
276
|
+
}`,
|
|
277
|
+
);
|
|
278
|
+
}
|
package/src/head/index.js
CHANGED
|
@@ -45,7 +45,7 @@ export function Head(props) {
|
|
|
45
45
|
*/
|
|
46
46
|
export function setHead(id, props) {
|
|
47
47
|
sequence += 1;
|
|
48
|
-
registerHead(id, sequence, buildHeadEntries(props,
|
|
48
|
+
registerHead(id, sequence, buildHeadEntries(props, readMetadataDefaults()));
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
/**
|
package/src/head/tags.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { pagePath, withTrailingSlash } from '../core/url-policy.js';
|
|
2
2
|
|
|
3
3
|
const ABSOLUTE = /^https?:\/\//i;
|
|
4
4
|
const PROTOCOL_RELATIVE = /^\/\//;
|
|
@@ -34,7 +34,7 @@ export function buildHeadEntries(props = {}, defaults = {}) {
|
|
|
34
34
|
entries.push(metaProp('og:type', props.type ?? 'website'));
|
|
35
35
|
if (siteName) entries.push(metaProp('og:site_name', siteName));
|
|
36
36
|
if (props.locale) entries.push(metaProp('og:locale', props.locale));
|
|
37
|
-
const url = pageUrl(props.canonical ??
|
|
37
|
+
const url = pageUrl(props.canonical ?? defaultPagePath(defaults), defaults);
|
|
38
38
|
if (url) entries.push(metaProp('og:url', url));
|
|
39
39
|
if (image) entries.push(metaProp('og:image', absoluteUrl(image, defaults)));
|
|
40
40
|
}
|
|
@@ -45,7 +45,7 @@ export function buildHeadEntries(props = {}, defaults = {}) {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
if (props.canonical !== false) {
|
|
48
|
-
const url = pageUrl(props.canonical ??
|
|
48
|
+
const url = pageUrl(props.canonical ?? defaultPagePath(defaults), defaults);
|
|
49
49
|
if (url)
|
|
50
50
|
entries.push({ kind: 'link', key: 'rel:canonical', attrs: { rel: 'canonical', href: url } });
|
|
51
51
|
}
|
|
@@ -88,14 +88,6 @@ export function headKey(entry) {
|
|
|
88
88
|
return `meta:${attrsKey(entry.attrs)}`;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
/**
|
|
92
|
-
* Collects the absolute metadata URLs in a document that point at the machine the
|
|
93
|
-
* prerenderer ran on, which happens when no public siteUrl was configured.
|
|
94
|
-
*/
|
|
95
|
-
export function findBuildOriginUrls(html) {
|
|
96
|
-
return collectBuildOrigins(html);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
91
|
// Applies the site's title template unless the title already carries its suffix or is the
|
|
100
92
|
// site name itself, so an app that composed its own title never gets it suffixed twice.
|
|
101
93
|
function applyTitleTemplate(title, template, siteName) {
|
|
@@ -139,23 +131,24 @@ function encodeSpaces(url) {
|
|
|
139
131
|
return url.replace(/ /g, '%20');
|
|
140
132
|
}
|
|
141
133
|
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
const
|
|
147
|
-
if (
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const next = path.replace(/\/+$/, '');
|
|
153
|
-
return query ? `${next}?${query}` : next;
|
|
134
|
+
// Reads the route being rendered, which under prerendering is the page being captured,
|
|
135
|
+
// and removes the public base path so absoluteUrl does not add it a second time.
|
|
136
|
+
function currentPath(defaults = {}) {
|
|
137
|
+
const pathname = typeof location === 'undefined' ? '/' : location.pathname;
|
|
138
|
+
const base = defaults.base ?? '/';
|
|
139
|
+
if (base === '/') return pathname;
|
|
140
|
+
const prefix = base.replace(/\/$/, '');
|
|
141
|
+
if (pathname === prefix) return '/';
|
|
142
|
+
if (pathname.startsWith(`${prefix}/`)) return pathname.slice(prefix.length);
|
|
143
|
+
return pathname;
|
|
154
144
|
}
|
|
155
145
|
|
|
156
|
-
//
|
|
157
|
-
function
|
|
158
|
-
return
|
|
146
|
+
// Builds the default canonical path from the current route and the output's URL policy.
|
|
147
|
+
function defaultPagePath(defaults) {
|
|
148
|
+
return pagePath(currentPath(defaults), {
|
|
149
|
+
flatOutput: defaults.flatOutput === true,
|
|
150
|
+
trailingSlash: defaults.trailingSlash,
|
|
151
|
+
});
|
|
159
152
|
}
|
|
160
153
|
|
|
161
154
|
function metaName(name, content) {
|