snappy-prerender 2.7.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 +49 -0
- package/README.md +166 -7
- package/assets/snappy-logo.png +0 -0
- package/docs/limitations.md +40 -4
- package/docs/report.md +36 -3
- package/package.json +3 -1
- package/src/cli-options.js +258 -0
- package/src/cli.js +75 -161
- package/src/core/audit/links.js +12 -1
- package/src/core/audit/metadata.js +113 -4
- package/src/core/audit/sitemap.js +1 -1
- 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 +132 -203
- package/src/core/crawl.js +76 -14
- package/src/core/csp.js +14 -3
- package/src/core/destination.js +23 -7
- package/src/core/hydration-policy.js +38 -0
- 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 +30 -6
- package/src/core/page-content.js +57 -7
- package/src/core/page-meta.js +18 -4
- package/src/core/preload-links.js +24 -11
- package/src/core/preload-manifest.js +6 -2
- package/src/core/render.js +46 -37
- package/src/core/report.js +9 -1
- package/src/core/route-sources.js +14 -3
- package/src/core/run.js +90 -20
- package/src/core/seo-locale.js +234 -0
- package/src/core/settle.js +2 -4
- package/src/core/sitemap.js +1 -1
- package/src/core/text-separators.js +11 -7
- package/src/core/verify.js +108 -59
- package/src/core/write.js +276 -99
- package/src/head/index.js +103 -5
- package/src/head/manager.js +290 -75
- package/src/head/tags.js +305 -32
|
@@ -49,7 +49,7 @@ export function auditMetadata({ pages, config, add }) {
|
|
|
49
49
|
]);
|
|
50
50
|
}
|
|
51
51
|
if (config.metadata?.siteUrl && meta.canonical) {
|
|
52
|
-
const expected = expectedPageUrl(page.route, config);
|
|
52
|
+
const expected = expectedPageUrl(page.route, config, page.locale);
|
|
53
53
|
if (expected && meta.canonical !== expected) {
|
|
54
54
|
add(
|
|
55
55
|
'warning',
|
|
@@ -60,6 +60,7 @@ export function auditMetadata({ pages, config, add }) {
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
|
+
auditHreflang({ pages, config, add });
|
|
63
64
|
for (const [title, routes] of titles) {
|
|
64
65
|
if (routes.length < 2) continue;
|
|
65
66
|
add('error', 'duplicate-title', `routes share the title "${title}": ${routes.join(', ')}`, {
|
|
@@ -77,12 +78,120 @@ export function auditMetadata({ pages, config, add }) {
|
|
|
77
78
|
}
|
|
78
79
|
}
|
|
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
|
+
|
|
80
185
|
// Resolves the public URL a page should canonicalise and be listed at.
|
|
81
|
-
export function expectedPageUrl(route, config) {
|
|
82
|
-
|
|
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;
|
|
83
192
|
const path = pagePath(route, {
|
|
84
193
|
flatOutput: config.flatOutput,
|
|
85
194
|
trailingSlash: config.metadata.trailingSlash,
|
|
86
195
|
});
|
|
87
|
-
return `${
|
|
196
|
+
return `${baseUrl.replace(/\/$/, '')}${toPublicPath(path, config.base)}`;
|
|
88
197
|
}
|
|
@@ -9,7 +9,7 @@ export function auditSitemap({ pages, config, report, add }) {
|
|
|
9
9
|
const indexable = new Map();
|
|
10
10
|
const noindex = new Map();
|
|
11
11
|
for (const page of pages) {
|
|
12
|
-
const loc = expectedPageUrl(page.route, config);
|
|
12
|
+
const loc = expectedPageUrl(page.route, config, page.locale);
|
|
13
13
|
if (!loc) continue;
|
|
14
14
|
if (page.noindex || page.notFound) {
|
|
15
15
|
noindex.set(loc, page.route);
|
|
@@ -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
|
+
}
|