snappy-prerender 2.6.0 → 2.8.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 +100 -0
- package/README.md +281 -101
- package/assets/snappy-logo.png +0 -0
- package/docs/limitations.md +94 -0
- package/docs/react-snap-migration.md +45 -0
- package/docs/report.md +135 -0
- package/package.json +4 -1
- package/src/cli-init.js +205 -0
- package/src/cli-options.js +258 -0
- package/src/cli.js +128 -309
- package/src/core/artifacts.js +2 -0
- package/src/core/audit/index.js +114 -0
- package/src/core/audit/links.js +74 -0
- package/src/core/audit/metadata.js +197 -0
- package/src/core/audit/sitemap.js +39 -0
- package/src/core/clock.js +60 -0
- package/src/core/config-validation.js +557 -0
- package/src/core/config-warnings.js +150 -0
- package/src/core/config.js +189 -136
- package/src/core/crawl.js +362 -0
- package/src/core/csp.js +138 -0
- package/src/core/destination.js +23 -7
- package/src/core/frameworks.js +94 -0
- package/src/core/hydration-diff.js +93 -0
- package/src/core/hydration-policy.js +38 -0
- package/src/core/init.js +143 -30
- package/src/core/inline-css.js +52 -9
- package/src/core/locale-context.js +63 -0
- package/src/core/locales.js +385 -0
- package/src/core/normalize.js +37 -6
- package/src/core/page-content.js +57 -7
- package/src/core/page-meta.js +122 -0
- package/src/core/preload-links.js +133 -0
- package/src/core/preload-manifest.js +6 -2
- package/src/core/render.js +126 -35
- package/src/core/report.js +116 -0
- package/src/core/resources.js +5 -1
- package/src/core/route-config.js +17 -0
- package/src/core/route-sources.js +220 -0
- package/src/core/run.js +156 -131
- package/src/core/seo-locale.js +234 -0
- package/src/core/settle.js +2 -4
- package/src/core/sitemap.js +90 -14
- package/src/core/text-separators.js +11 -7
- package/src/core/verify.js +164 -71
- package/src/core/write.js +314 -52
- package/src/head/index.js +104 -6
- package/src/head/manager.js +304 -48
- package/src/head/tags.js +349 -30
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { routeToFile } from '../output.js';
|
|
4
|
+
import { matchesPattern, normaliseRoute } from '../routes.js';
|
|
5
|
+
import { mapLimit } from '../scheduler.js';
|
|
6
|
+
|
|
7
|
+
const LINK_CHECK_CONCURRENCY = 16;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Audits every internal link the rendered pages emitted and warns about indexable pages
|
|
11
|
+
* no other page links to.
|
|
12
|
+
*/
|
|
13
|
+
export async function auditLinks({ report, config, outputDir, add }) {
|
|
14
|
+
const routes = new Set(report.routeDetails.map((detail) => detail.route));
|
|
15
|
+
const files = new Set(report.files.map((file) => file.file).filter(Boolean));
|
|
16
|
+
const cache = new Map();
|
|
17
|
+
const seenPairs = new Set();
|
|
18
|
+
let checkedLinks = 0;
|
|
19
|
+
const pending = [];
|
|
20
|
+
|
|
21
|
+
const exists = async (target) => {
|
|
22
|
+
if (cache.has(target)) return cache.get(target);
|
|
23
|
+
const result = await fs
|
|
24
|
+
.stat(path.join(outputDir, target))
|
|
25
|
+
.then((stats) => stats.isFile())
|
|
26
|
+
.catch(() => false);
|
|
27
|
+
cache.set(target, result);
|
|
28
|
+
return result;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
for (const link of report.links) {
|
|
32
|
+
const key = `${link.from}|${link.to}`;
|
|
33
|
+
if (seenPairs.has(key)) continue;
|
|
34
|
+
seenPairs.add(key);
|
|
35
|
+
if (config.auditIgnoreLinks.some((pattern) => matchesPattern(link.to, pattern))) continue;
|
|
36
|
+
checkedLinks += 1;
|
|
37
|
+
const route = normaliseRoute(link.to);
|
|
38
|
+
if (routes.has(route)) continue;
|
|
39
|
+
const file = routeToFile(route, config);
|
|
40
|
+
if (files.has(file)) continue;
|
|
41
|
+
const asset = route.startsWith('/') ? route.slice(1) : route;
|
|
42
|
+
pending.push({ link, asset });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const targets = [...new Set(pending.map((entry) => entry.asset).filter(Boolean))];
|
|
46
|
+
const { results } = await mapLimit(targets, LINK_CHECK_CONCURRENCY, exists);
|
|
47
|
+
const existsSet = new Set(targets.filter((_target, index) => results[index] === true));
|
|
48
|
+
for (const { link, asset } of pending) {
|
|
49
|
+
if (asset && existsSet.has(asset)) continue;
|
|
50
|
+
add('error', 'broken-internal-link', `${link.from} links to missing ${link.to}`, {
|
|
51
|
+
from: link.from,
|
|
52
|
+
to: link.to,
|
|
53
|
+
reason: 'missing',
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return { checked: checkedLinks };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Warns about indexable pages no rendered page links to. A link from a page outside the
|
|
61
|
+
* include list is legitimately absent, so this stays a warning.
|
|
62
|
+
*/
|
|
63
|
+
export function auditOrphans({ pages, report, add }) {
|
|
64
|
+
const linked = new Set();
|
|
65
|
+
for (const link of report.links) linked.add(normaliseRoute(link.to));
|
|
66
|
+
for (const page of pages) {
|
|
67
|
+
if (page.route === '/' || page.notFound || page.noindex) continue;
|
|
68
|
+
if (!linked.has(page.route)) {
|
|
69
|
+
add('warning', 'orphan-page', `${page.route} is not linked from another page`, {
|
|
70
|
+
route: page.route,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { toPublicPath } from '../routes.js';
|
|
2
|
+
import { pagePath } from '../url-policy.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Checks the metadata a crawler reads on every written page: titles, descriptions,
|
|
6
|
+
* canonicals, headings, build-origin leakage and duplicate values across routes.
|
|
7
|
+
*/
|
|
8
|
+
export function auditMetadata({ pages, config, add }) {
|
|
9
|
+
const titles = new Map();
|
|
10
|
+
const descriptions = new Map();
|
|
11
|
+
for (const page of pages) {
|
|
12
|
+
const meta = page.meta ?? {};
|
|
13
|
+
if (!meta.title) {
|
|
14
|
+
add('warning', 'missing-title', `no <title> on ${page.route}`, { route: page.route });
|
|
15
|
+
}
|
|
16
|
+
if (!meta.description) {
|
|
17
|
+
add('warning', 'missing-description', `no meta description on ${page.route}`, {
|
|
18
|
+
route: page.route,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
if (!meta.canonical) {
|
|
22
|
+
add('warning', 'missing-canonical', `no canonical link on ${page.route}`, {
|
|
23
|
+
route: page.route,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
if (meta.titleCount > 1) {
|
|
27
|
+
add('error', 'multiple-titles', `${page.route} contains more than one <title>`, {
|
|
28
|
+
route: page.route,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
if (meta.h1Count !== 1) {
|
|
32
|
+
add('warning', 'heading-count', `${page.route} has ${meta.h1Count} <h1> element(s)`, {
|
|
33
|
+
route: page.route,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
if ((page.buildOriginUrls ?? []).length > 0) {
|
|
37
|
+
add(
|
|
38
|
+
'warning',
|
|
39
|
+
'build-origin-metadata',
|
|
40
|
+
`${page.route} still points at the build server: ${page.buildOriginUrls.join(', ')}`,
|
|
41
|
+
{ route: page.route },
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
if (meta.title) titles.set(meta.title, [...(titles.get(meta.title) ?? []), page.route]);
|
|
45
|
+
if (meta.description) {
|
|
46
|
+
descriptions.set(meta.description, [
|
|
47
|
+
...(descriptions.get(meta.description) ?? []),
|
|
48
|
+
page.route,
|
|
49
|
+
]);
|
|
50
|
+
}
|
|
51
|
+
if (config.metadata?.siteUrl && meta.canonical) {
|
|
52
|
+
const expected = expectedPageUrl(page.route, config, page.locale);
|
|
53
|
+
if (expected && meta.canonical !== expected) {
|
|
54
|
+
add(
|
|
55
|
+
'warning',
|
|
56
|
+
'canonical-mismatch',
|
|
57
|
+
`${page.route} canonical is ${meta.canonical}, expected ${expected}`,
|
|
58
|
+
{ route: page.route },
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
auditHreflang({ pages, config, add });
|
|
64
|
+
for (const [title, routes] of titles) {
|
|
65
|
+
if (routes.length < 2) continue;
|
|
66
|
+
add('error', 'duplicate-title', `routes share the title "${title}": ${routes.join(', ')}`, {
|
|
67
|
+
routes,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
for (const [description, routes] of descriptions) {
|
|
71
|
+
if (routes.length < 2) continue;
|
|
72
|
+
add(
|
|
73
|
+
'warning',
|
|
74
|
+
'duplicate-description',
|
|
75
|
+
`routes share the meta description "${description}": ${routes.join(', ')}`,
|
|
76
|
+
{ routes },
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Checks generated and app-owned hreflang links for validity, self-reference, x-default
|
|
83
|
+
* and reciprocal links inside the run.
|
|
84
|
+
* @param {{ pages: Array<object>, config: object, add: Function }} input
|
|
85
|
+
*/
|
|
86
|
+
function auditHreflang({ pages, config, add }) {
|
|
87
|
+
if (!config.locales) return;
|
|
88
|
+
const byUrl = new Map();
|
|
89
|
+
for (const page of pages) {
|
|
90
|
+
if (page.noindex || page.notFound) continue;
|
|
91
|
+
const url = expectedPageUrl(page.route, config, page.locale) ?? page.meta?.canonical;
|
|
92
|
+
if (url) byUrl.set(url, page);
|
|
93
|
+
}
|
|
94
|
+
for (const page of pages) {
|
|
95
|
+
if (page.noindex || page.notFound) continue;
|
|
96
|
+
const meta = page.meta ?? {};
|
|
97
|
+
const code = page.locale ?? config.locales.active ?? config.locales.default;
|
|
98
|
+
const locale = config.locales.list.find((entry) => entry.code === code) ?? null;
|
|
99
|
+
const expectedLang = locale?.htmlLang ?? code;
|
|
100
|
+
if (!meta.lang) {
|
|
101
|
+
add('warning', 'html-lang-missing', `${page.route} has no <html lang>`, {
|
|
102
|
+
route: page.route,
|
|
103
|
+
});
|
|
104
|
+
} else if (meta.lang !== expectedLang && meta.lang !== code) {
|
|
105
|
+
add(
|
|
106
|
+
'warning',
|
|
107
|
+
'html-lang-mismatch',
|
|
108
|
+
`${page.route} has <html lang="${meta.lang}">, expected "${expectedLang}"`,
|
|
109
|
+
{ route: page.route },
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
if (locale?.ogLocale && !meta.ogLocale) {
|
|
113
|
+
add('warning', 'og-locale-missing', `${page.route} has no og:locale for ${code}`, {
|
|
114
|
+
route: page.route,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
if (config.locales.hreflang === false) continue;
|
|
118
|
+
const alternates = meta.alternates ?? [];
|
|
119
|
+
const seen = new Set();
|
|
120
|
+
for (const alternate of alternates) {
|
|
121
|
+
const value = String(alternate.hreflang);
|
|
122
|
+
if (value !== 'x-default' && !validHreflang(value)) {
|
|
123
|
+
add('error', 'hreflang-invalid', `${page.route} has invalid hreflang "${value}"`, {
|
|
124
|
+
route: page.route,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
const key = value.toLowerCase();
|
|
128
|
+
if (seen.has(key)) {
|
|
129
|
+
add('error', 'hreflang-duplicate', `${page.route} repeats hreflang "${value}"`, {
|
|
130
|
+
route: page.route,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
seen.add(key);
|
|
134
|
+
}
|
|
135
|
+
const selfUrl = meta.canonical ?? expectedPageUrl(page.route, config, page.locale);
|
|
136
|
+
const self = alternates.find((alternate) => alternate.hreflang === code);
|
|
137
|
+
if (!self) {
|
|
138
|
+
add('warning', 'hreflang-missing-self', `${page.route} has no self hreflang "${code}"`, {
|
|
139
|
+
route: page.route,
|
|
140
|
+
});
|
|
141
|
+
} else if (selfUrl && self.href !== selfUrl) {
|
|
142
|
+
add(
|
|
143
|
+
'warning',
|
|
144
|
+
'hreflang-canonical-mismatch',
|
|
145
|
+
`${page.route} hreflang "${code}" points at ${self.href}, expected ${selfUrl}`,
|
|
146
|
+
{ route: page.route },
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
if (!seen.has('x-default') && config.locales.xDefault !== false) {
|
|
150
|
+
add('warning', 'hreflang-missing-x-default', `${page.route} has no x-default hreflang`, {
|
|
151
|
+
route: page.route,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
for (const alternate of alternates) {
|
|
155
|
+
const target = byUrl.get(alternate.href);
|
|
156
|
+
if (!target) continue;
|
|
157
|
+
const targetSelf = target.meta?.alternates?.some(
|
|
158
|
+
(entry) => entry.hreflang === code && entry.href === selfUrl,
|
|
159
|
+
);
|
|
160
|
+
if (!targetSelf) {
|
|
161
|
+
add(
|
|
162
|
+
'error',
|
|
163
|
+
'hreflang-missing-return',
|
|
164
|
+
`${page.route} links to ${target.route} for ${alternate.hreflang}, but ${target.route} does not link back`,
|
|
165
|
+
{ route: page.route, to: target.route },
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Reports whether a value is a valid hreflang language tag.
|
|
174
|
+
* @param {string} code
|
|
175
|
+
* @returns {boolean}
|
|
176
|
+
*/
|
|
177
|
+
function validHreflang(code) {
|
|
178
|
+
try {
|
|
179
|
+
return Intl.getCanonicalLocales(code).length === 1;
|
|
180
|
+
} catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Resolves the public URL a page should canonicalise and be listed at.
|
|
186
|
+
export function expectedPageUrl(route, config, localeCode = null) {
|
|
187
|
+
const locale = localeCode
|
|
188
|
+
? config.locales?.list?.find((entry) => entry.code === localeCode)
|
|
189
|
+
: null;
|
|
190
|
+
const baseUrl = locale?.url ?? config.metadata?.siteUrl;
|
|
191
|
+
if (!baseUrl) return null;
|
|
192
|
+
const path = pagePath(route, {
|
|
193
|
+
flatOutput: config.flatOutput,
|
|
194
|
+
trailingSlash: config.metadata.trailingSlash,
|
|
195
|
+
});
|
|
196
|
+
return `${baseUrl.replace(/\/$/, '')}${toPublicPath(path, config.base)}`;
|
|
197
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { expectedPageUrl } from './metadata.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Ensures the sitemap and the indexable pages describe the same set of URLs. A page
|
|
5
|
+
* missing from the sitemap, a loc with no page, or a noindex page listed are all errors.
|
|
6
|
+
*/
|
|
7
|
+
export function auditSitemap({ pages, config, report, add }) {
|
|
8
|
+
if (!config.sitemap) return;
|
|
9
|
+
const indexable = new Map();
|
|
10
|
+
const noindex = new Map();
|
|
11
|
+
for (const page of pages) {
|
|
12
|
+
const loc = expectedPageUrl(page.route, config, page.locale);
|
|
13
|
+
if (!loc) continue;
|
|
14
|
+
if (page.noindex || page.notFound) {
|
|
15
|
+
noindex.set(loc, page.route);
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
indexable.set(loc, page.route);
|
|
19
|
+
}
|
|
20
|
+
const locs = new Set(report.sitemap?.locs ?? []);
|
|
21
|
+
for (const [loc, route] of indexable) {
|
|
22
|
+
if (!locs.has(loc)) {
|
|
23
|
+
add('error', 'sitemap-missing-page', `${route} is missing from the sitemap`, { route });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
for (const loc of locs) {
|
|
27
|
+
if (noindex.has(loc)) {
|
|
28
|
+
add('error', 'sitemap-noindex-page', `sitemap lists noindex ${noindex.get(loc)}`, {
|
|
29
|
+
route: noindex.get(loc),
|
|
30
|
+
});
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (!indexable.has(loc)) {
|
|
34
|
+
add('error', 'sitemap-unknown-page', `sitemap lists ${loc} with no indexable page`, {
|
|
35
|
+
to: loc,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves the fixed build time used by both the render and verification passes. Explicit
|
|
3
|
+
* values win, then SOURCE_DATE_EPOCH, then the supplied fallback (the run start time).
|
|
4
|
+
* @param {object} config
|
|
5
|
+
* @param {number} fallbackTime
|
|
6
|
+
* @returns {number | null}
|
|
7
|
+
*/
|
|
8
|
+
export function resolveFixedTime(config, fallbackTime = Date.now()) {
|
|
9
|
+
if (config.freezeTime === false) return null;
|
|
10
|
+
if (typeof config.freezeTime === 'number') return config.freezeTime;
|
|
11
|
+
if (typeof config.freezeTime === 'string') {
|
|
12
|
+
const parsed = Date.parse(config.freezeTime);
|
|
13
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
14
|
+
}
|
|
15
|
+
const rawSourceDate = process.env.SOURCE_DATE_EPOCH;
|
|
16
|
+
if (rawSourceDate && /^\d+$/.test(rawSourceDate)) {
|
|
17
|
+
return Number.parseInt(rawSourceDate, 10) * 1000;
|
|
18
|
+
}
|
|
19
|
+
return fallbackTime;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Fixes Date in one page while leaving real timers running, so settling and scheduled
|
|
24
|
+
* work keep the same timing they have in a normal browser.
|
|
25
|
+
* @param {import('playwright-core').Page} page
|
|
26
|
+
* @param {number | null} fixedTime
|
|
27
|
+
*/
|
|
28
|
+
export async function installClock(page, fixedTime) {
|
|
29
|
+
if (fixedTime === null) return;
|
|
30
|
+
await page.clock.setFixedTime(fixedTime);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Adds the build-time globals and an optional deterministic Math.random. The PRNG is for
|
|
35
|
+
* reproducible demo content, never for security-sensitive values.
|
|
36
|
+
* @param {import('playwright-core').Page} page
|
|
37
|
+
* @param {{ fixedTime: number | null, randomSeed: number | null }} options
|
|
38
|
+
*/
|
|
39
|
+
export async function installClockScripts(page, { fixedTime, randomSeed }) {
|
|
40
|
+
if (fixedTime === null && randomSeed === null) return;
|
|
41
|
+
await page.addInitScript(
|
|
42
|
+
({ time, seed }) => {
|
|
43
|
+
if (time !== null) {
|
|
44
|
+
window.__SNAPPY_NOW__ = time;
|
|
45
|
+
window.__SNAPPY_NOW_ISO__ = new Date(time).toISOString();
|
|
46
|
+
}
|
|
47
|
+
if (seed !== null) {
|
|
48
|
+
let state = seed >>> 0;
|
|
49
|
+
Math.random = () => {
|
|
50
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
51
|
+
let value = state;
|
|
52
|
+
value = Math.imul(value ^ (value >>> 15), value | 1);
|
|
53
|
+
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
|
|
54
|
+
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
{ time: fixedTime, seed: randomSeed },
|
|
59
|
+
);
|
|
60
|
+
}
|