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.
Files changed (49) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +281 -101
  3. package/assets/snappy-logo.png +0 -0
  4. package/docs/limitations.md +94 -0
  5. package/docs/react-snap-migration.md +45 -0
  6. package/docs/report.md +135 -0
  7. package/package.json +4 -1
  8. package/src/cli-init.js +205 -0
  9. package/src/cli-options.js +258 -0
  10. package/src/cli.js +128 -309
  11. package/src/core/artifacts.js +2 -0
  12. package/src/core/audit/index.js +114 -0
  13. package/src/core/audit/links.js +74 -0
  14. package/src/core/audit/metadata.js +197 -0
  15. package/src/core/audit/sitemap.js +39 -0
  16. package/src/core/clock.js +60 -0
  17. package/src/core/config-validation.js +557 -0
  18. package/src/core/config-warnings.js +150 -0
  19. package/src/core/config.js +189 -136
  20. package/src/core/crawl.js +362 -0
  21. package/src/core/csp.js +138 -0
  22. package/src/core/destination.js +23 -7
  23. package/src/core/frameworks.js +94 -0
  24. package/src/core/hydration-diff.js +93 -0
  25. package/src/core/hydration-policy.js +38 -0
  26. package/src/core/init.js +143 -30
  27. package/src/core/inline-css.js +52 -9
  28. package/src/core/locale-context.js +63 -0
  29. package/src/core/locales.js +385 -0
  30. package/src/core/normalize.js +37 -6
  31. package/src/core/page-content.js +57 -7
  32. package/src/core/page-meta.js +122 -0
  33. package/src/core/preload-links.js +133 -0
  34. package/src/core/preload-manifest.js +6 -2
  35. package/src/core/render.js +126 -35
  36. package/src/core/report.js +116 -0
  37. package/src/core/resources.js +5 -1
  38. package/src/core/route-config.js +17 -0
  39. package/src/core/route-sources.js +220 -0
  40. package/src/core/run.js +156 -131
  41. package/src/core/seo-locale.js +234 -0
  42. package/src/core/settle.js +2 -4
  43. package/src/core/sitemap.js +90 -14
  44. package/src/core/text-separators.js +11 -7
  45. package/src/core/verify.js +164 -71
  46. package/src/core/write.js +314 -52
  47. package/src/head/index.js +104 -6
  48. package/src/head/manager.js +304 -48
  49. package/src/head/tags.js +349 -30
@@ -0,0 +1,234 @@
1
+ import { matchesPattern } from './routes.js';
2
+
3
+ /**
4
+ * Resolves the SEO locale recipe for one route. The code comes from the route locale in
5
+ * path mode, from the active locale in single mode, or from seoLocale.code standalone.
6
+ * @param {object} config
7
+ * @param {object | null} locale
8
+ * @param {string} route
9
+ * @returns {object | null}
10
+ */
11
+ export function resolveSeoLocale(config, locale, route) {
12
+ const seo = config.seoLocale;
13
+ if (!seo) return null;
14
+ if (!(seo.routes ?? []).some((pattern) => matchesPattern(route, pattern))) return null;
15
+ const code =
16
+ config.locales?.mode === 'path'
17
+ ? locale?.code
18
+ : (config.locales?.active ?? seo.code ?? locale?.code);
19
+ if (!code) return null;
20
+ return { ...seo, code, value: seo.value ?? code };
21
+ }
22
+
23
+ /**
24
+ * Seeds local storage before the app bundle runs. The init script runs on the page's
25
+ * origin, which is what makes it work with the ephemeral prerender server.
26
+ * @param {import('playwright-core').Page} page
27
+ * @param {object} seo
28
+ */
29
+ export async function seedPageStorage(page, seo) {
30
+ if (seo.source !== 'localStorage' && seo.source !== 'both') return;
31
+ await seedLocalStorage(page, { [seo.localStorageKey]: seo.value }, seo.overwrite);
32
+ }
33
+
34
+ /**
35
+ * Seeds a fixed set of local storage entries before the app boots.
36
+ * @param {import('playwright-core').Page} page
37
+ * @param {Record<string, string>} entries
38
+ * @param {boolean} overwrite
39
+ */
40
+ export async function seedLocalStorage(page, entries, overwrite = true) {
41
+ if (!entries || Object.keys(entries).length === 0) return;
42
+ await page.addInitScript(
43
+ ({ values, replace }) => {
44
+ try {
45
+ for (const [key, value] of Object.entries(values)) {
46
+ if (replace || window.localStorage.getItem(key) === null) {
47
+ window.localStorage.setItem(key, value);
48
+ }
49
+ }
50
+ } catch {
51
+ // Local storage can be unavailable in private contexts; the app still renders its default.
52
+ }
53
+ },
54
+ { values: entries, replace: overwrite },
55
+ );
56
+ }
57
+
58
+ /**
59
+ * Seeds the locale cookie in the browser context, using the current origin when no
60
+ * explicit domain is configured.
61
+ * @param {import('playwright-core').BrowserContext} context
62
+ * @param {string} origin
63
+ * @param {object} seo
64
+ */
65
+ export async function seedContextCookie(context, origin, seo) {
66
+ if (seo.source !== 'cookie' && seo.source !== 'both') return;
67
+ if (!seo.overwrite) {
68
+ const existing = await context.cookies();
69
+ if (existing.some((cookie) => cookie.name === seo.cookie.name)) return;
70
+ }
71
+ await seedCookies(context, origin, [
72
+ {
73
+ ...seo.cookie,
74
+ value: seo.value,
75
+ httpOnly: false,
76
+ },
77
+ ]);
78
+ }
79
+
80
+ /**
81
+ * Adds cookies with the same attributes to the render and verification contexts.
82
+ * @param {import('playwright-core').BrowserContext} context
83
+ * @param {string} origin
84
+ * @param {Array<object>} cookies
85
+ */
86
+ export async function seedCookies(context, origin, cookies = []) {
87
+ if (!Array.isArray(cookies) || cookies.length === 0) return;
88
+ const entries = cookies.map((cookie) => {
89
+ const secure = cookie.secure === true && !origin.startsWith('http://');
90
+ const sameSite = normaliseSameSite(cookie.sameSite);
91
+ const entry = {
92
+ name: cookie.name,
93
+ value: String(cookie.value),
94
+ httpOnly: cookie.httpOnly === true,
95
+ secure,
96
+ // Browsers reject SameSite=None without Secure; keep local HTTP previews usable.
97
+ sameSite: sameSite === 'None' && !secure ? 'Lax' : sameSite,
98
+ expires:
99
+ cookie.maxAge !== null && cookie.maxAge !== undefined
100
+ ? Math.floor(Date.now() / 1000) + cookie.maxAge
101
+ : -1,
102
+ };
103
+ if (cookie.domain) {
104
+ entry.domain = cookie.domain;
105
+ entry.path = cookie.path ?? '/';
106
+ } else {
107
+ entry.url = origin;
108
+ }
109
+ return entry;
110
+ });
111
+ await context.addCookies(entries);
112
+ }
113
+
114
+ /**
115
+ * Builds the small bootstrap script written into localized pages. Values are serialised as
116
+ * strings and cookie values are percent-encoded; no configuration value is interpolated
117
+ * as raw JavaScript.
118
+ * @param {object} seo
119
+ * @returns {string}
120
+ */
121
+ export function buildSeoLocaleScript(seo) {
122
+ const key = JSON.stringify(seo.localStorageKey);
123
+ const value = JSON.stringify(seo.value);
124
+ const cookieName = JSON.stringify(seo.cookie.name);
125
+ const cookieAttributes =
126
+ `; path=${seo.cookie.path ?? '/'}` +
127
+ (seo.cookie.domain ? `; domain=${seo.cookie.domain}` : '') +
128
+ (seo.cookie.maxAge !== null && seo.cookie.maxAge !== undefined
129
+ ? `; max-age=${seo.cookie.maxAge}`
130
+ : '') +
131
+ `; SameSite=${normaliseSameSite(seo.cookie.sameSite)}` +
132
+ (seo.cookie.secure ? '; Secure' : '');
133
+ const cookieExpression = `${cookieName} + "=" + encodeURIComponent(${value}) + ${JSON.stringify(cookieAttributes)}`;
134
+ const localCondition = seo.overwrite ? 'true' : `window.localStorage.getItem(${key}) === null`;
135
+ const parts = ['(function () {'];
136
+ if (seo.source === 'localStorage' || seo.source === 'both') {
137
+ parts.push(
138
+ ' try {',
139
+ ` if (${localCondition}) {`,
140
+ ` window.localStorage.setItem(${key}, ${value});`,
141
+ ' }',
142
+ ' } catch (error) {}',
143
+ );
144
+ }
145
+ if (seo.source === 'cookie' || seo.source === 'both') {
146
+ parts.push(
147
+ ' try {',
148
+ ` var name = ${cookieName};`,
149
+ ' var has = document.cookie.split("; ").some(function (part) {',
150
+ ' return part.split("=")[0] === name;',
151
+ ' });',
152
+ ` if (${seo.overwrite ? 'true' : '!has'}) {`,
153
+ ` document.cookie = ${cookieExpression};`,
154
+ ' }',
155
+ ' } catch (error) {}',
156
+ );
157
+ }
158
+ parts.push('})();');
159
+ return parts.join('\n');
160
+ }
161
+
162
+ /**
163
+ * Inserts the bootstrap before the first head script, or at the end of the head when the
164
+ * document has no script. A generated marker makes reruns replace the same script.
165
+ * @param {object} document parse5 document
166
+ * @param {string} script
167
+ * @param {string | null} externalHref
168
+ */
169
+ export function injectSeoLocaleScript(document, script, externalHref = null) {
170
+ const head = findHead(document);
171
+ if (!head) return;
172
+ head.childNodes = (head.childNodes ?? []).filter(
173
+ (node) => !node.attrs?.some((entry) => entry.name === 'data-snappy-seo-locale'),
174
+ );
175
+ const node = externalHref
176
+ ? {
177
+ nodeName: 'script',
178
+ tagName: 'script',
179
+ attrs: [
180
+ { name: 'src', value: externalHref },
181
+ { name: 'data-snappy-seo-locale', value: '' },
182
+ ],
183
+ namespaceURI: 'http://www.w3.org/1999/xhtml',
184
+ childNodes: [],
185
+ parentNode: null,
186
+ }
187
+ : {
188
+ nodeName: 'script',
189
+ tagName: 'script',
190
+ attrs: [{ name: 'data-snappy-seo-locale', value: '' }],
191
+ namespaceURI: 'http://www.w3.org/1999/xhtml',
192
+ childNodes: [{ nodeName: '#text', value: script, parentNode: null }],
193
+ parentNode: null,
194
+ };
195
+ const scriptIndex = (head.childNodes ?? []).findIndex((child) => child.tagName === 'script');
196
+ if (scriptIndex === -1) head.childNodes.push(node);
197
+ else head.childNodes.splice(scriptIndex, 0, node);
198
+ }
199
+
200
+ /**
201
+ * Returns the current head language and direction controls for metadata injection.
202
+ * @param {object | null} seo
203
+ * @returns {{ htmlLang: string | boolean, dir: string | null, forceHtmlLang: boolean }}
204
+ */
205
+ export function seoLocaleMetadataOptions(seo) {
206
+ if (!seo) return { htmlLang: true, dir: null, forceHtmlLang: false };
207
+ return {
208
+ htmlLang: seo.htmlLang,
209
+ dir: seo.dir,
210
+ forceHtmlLang: seo.forceHtmlLang === true,
211
+ };
212
+ }
213
+
214
+ /**
215
+ * Normalises a cookie SameSite value to the form Playwright and document.cookie expect.
216
+ * @param {string | undefined} value
217
+ * @returns {'Strict' | 'Lax' | 'None'}
218
+ */
219
+ function normaliseSameSite(value) {
220
+ const text = String(value ?? 'Lax').toLowerCase();
221
+ if (text === 'strict') return 'Strict';
222
+ if (text === 'none') return 'None';
223
+ return 'Lax';
224
+ }
225
+
226
+ /**
227
+ * Finds the document head in a parsed document.
228
+ * @param {object} document
229
+ * @returns {object | null}
230
+ */
231
+ function findHead(document) {
232
+ const html = (document.childNodes ?? []).find((node) => node.tagName === 'html');
233
+ return (html?.childNodes ?? []).find((node) => node.tagName === 'head') ?? null;
234
+ }
@@ -112,15 +112,13 @@ export async function settlePage(page, config, tracker) {
112
112
  * normalisation.
113
113
  */
114
114
  export async function freezeAnimations(page) {
115
- await page.evaluate((css) => {
115
+ await page.evaluate(async (css) => {
116
116
  const style = document.createElement('style');
117
117
  style.id = 'snappy-freeze';
118
118
  style.textContent = css;
119
119
  document.head.appendChild(style);
120
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
120
121
  }, FREEZE_CSS);
121
- await page.evaluate(
122
- () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))),
123
- );
124
122
  }
125
123
 
126
124
  /**
@@ -15,7 +15,7 @@ export function buildSitemap({ files, config, maxUrls = SITEMAP_MAX_URLS }) {
15
15
  if (!siteUrl) return null;
16
16
  const notFound = config.notFoundRoute ? normaliseRoute(config.notFoundRoute) : null;
17
17
 
18
- const locs = new Set();
18
+ const entries = [];
19
19
  for (const file of files) {
20
20
  if (!file.file.endsWith('.html') || file.noindex) continue;
21
21
  if (file.route && notFound && normaliseRoute(file.route) === notFound) continue;
@@ -25,31 +25,40 @@ export function buildSitemap({ files, config, maxUrls = SITEMAP_MAX_URLS }) {
25
25
  trailingSlash: config.metadata?.trailingSlash,
26
26
  })
27
27
  : publicPath(file.file);
28
- locs.add(`${siteUrl}${toPublicPath(path, config.base)}`);
28
+ entries.push({
29
+ loc: file.localeUrl ?? `${siteUrl}${toPublicPath(path, config.base)}`,
30
+ alternates: normaliseAlternates(file.alternates ?? [], siteUrl, config.base),
31
+ images: config.imageSitemap ? normaliseImages(file.images ?? [], siteUrl, config.base) : [],
32
+ });
29
33
  }
30
- if (locs.size === 0) return null;
34
+ if (entries.length === 0) return null;
31
35
 
32
- const sorted = [...locs].sort();
36
+ entries.sort((a, b) => a.loc.localeCompare(b.loc));
33
37
  const chunks = [];
34
- for (let index = 0; index < sorted.length; index += maxUrls) {
35
- chunks.push(sorted.slice(index, index + maxUrls));
38
+ for (let index = 0; index < entries.length; index += maxUrls) {
39
+ chunks.push(entries.slice(index, index + maxUrls));
36
40
  }
37
41
 
38
42
  if (chunks.length === 1) {
39
- return { files: [{ name: 'sitemap.xml', contents: urlSet(chunks[0]) }], routes: sorted.length };
43
+ return {
44
+ files: [{ name: 'sitemap.xml', contents: urlSet(chunks[0]) }],
45
+ routes: entries.length,
46
+ locs: entries.map((entry) => entry.loc),
47
+ };
40
48
  }
41
49
 
42
- const entries = chunks.map((chunk, index) => {
50
+ const chunksWithNames = chunks.map((chunk, index) => {
43
51
  const name = `sitemap-${index + 1}.xml`;
44
52
  const loc = `${siteUrl}${toPublicPath(`/${name}`, config.base)}`;
45
53
  return { name, contents: urlSet(chunk), loc };
46
54
  });
47
55
  return {
48
56
  files: [
49
- { name: 'sitemap.xml', contents: sitemapIndex(entries) },
50
- ...entries.map(({ name, contents }) => ({ name, contents })),
57
+ { name: 'sitemap.xml', contents: sitemapIndex(chunksWithNames) },
58
+ ...chunksWithNames.map(({ name, contents }) => ({ name, contents })),
51
59
  ],
52
- routes: sorted.length,
60
+ routes: entries.length,
61
+ locs: entries.map((entry) => entry.loc),
53
62
  };
54
63
  }
55
64
 
@@ -75,9 +84,76 @@ export function withSitemapDirective(robots, sitemapUrl) {
75
84
  }
76
85
 
77
86
  // 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`;
87
+ function urlSet(entries) {
88
+ const hasAlternates = entries.some((entry) => entry.alternates.length > 0);
89
+ const hasImages = entries.some((entry) => entry.images.length > 0);
90
+ const namespaces = [
91
+ 'xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"',
92
+ hasAlternates ? 'xmlns:xhtml="http://www.w3.org/1999/xhtml"' : null,
93
+ hasImages ? 'xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"' : null,
94
+ ]
95
+ .filter(Boolean)
96
+ .join(' ');
97
+ const urls = entries
98
+ .map((entry) => {
99
+ const lines = [` <loc>${escapeXml(entry.loc)}</loc>`];
100
+ for (const alternate of entry.alternates) {
101
+ lines.push(
102
+ ` <xhtml:link rel="alternate" hreflang="${escapeXml(alternate.hreflang)}" href="${escapeXml(alternate.href)}"/>`,
103
+ );
104
+ }
105
+ for (const image of entry.images) {
106
+ lines.push(
107
+ ` <image:image>\n <image:loc>${escapeXml(image)}</image:loc>\n </image:image>`,
108
+ );
109
+ }
110
+ return ` <url>\n${lines.join('\n')}\n </url>`;
111
+ })
112
+ .join('\n');
113
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset ${namespaces}>\n${urls}\n</urlset>\n`;
114
+ }
115
+
116
+ // Resolves hreflang links and removes duplicates.
117
+ function normaliseAlternates(alternates, siteUrl, base) {
118
+ const seen = new Set();
119
+ const result = [];
120
+ for (const alternate of alternates) {
121
+ const href = absoluteSitemapUrl(alternate.href, siteUrl, base);
122
+ if (!href) continue;
123
+ const key = `${alternate.hreflang}|${href}`;
124
+ if (seen.has(key)) continue;
125
+ seen.add(key);
126
+ result.push({ hreflang: alternate.hreflang, href });
127
+ }
128
+ return result;
129
+ }
130
+
131
+ // Resolves image URLs to absolute same-origin URLs.
132
+ function normaliseImages(images, siteUrl, base) {
133
+ const seen = new Set();
134
+ const result = [];
135
+ for (const image of images) {
136
+ const absolute = absoluteSitemapUrl(image, siteUrl, base);
137
+ if (!absolute || new URL(absolute).origin !== new URL(siteUrl).origin) continue;
138
+ if (seen.has(absolute)) continue;
139
+ seen.add(absolute);
140
+ result.push(absolute);
141
+ }
142
+ return result;
143
+ }
144
+
145
+ // Resolves a site-relative path against the public base, leaving absolute URLs alone.
146
+ function absoluteSitemapUrl(value, siteUrl, base) {
147
+ if (!value) return null;
148
+ const raw = String(value);
149
+ if (/^https?:\/\//i.test(raw)) return raw;
150
+ if (raw.startsWith('//')) return `https:${raw}`;
151
+ const basePrefix = base === '/' ? '' : base.slice(0, -1);
152
+ if (basePrefix && (raw === basePrefix || raw.startsWith(`${basePrefix}/`))) {
153
+ return `${siteUrl}${raw}`;
154
+ }
155
+ const path = raw.startsWith('/') ? raw : `/${raw}`;
156
+ return `${siteUrl}${basePrefix}${path}`;
81
157
  }
82
158
 
83
159
  // Builds the sitemap index that points at the numbered sitemaps.
@@ -1,12 +1,12 @@
1
1
  /**
2
- * Inserts comment separators between adjacent text nodes before the DOM is
3
- * serialised. HTML cannot express two neighbouring text nodes, so serialising a
4
- * client-rendered DOM merges them, and hydration then finds one node where the
5
- * client expects two — which React reports as a failed hydration. React's own server
6
- * rendering emits the same separators, so this makes captured markup equivalent.
2
+ * Final page snapshot: inserts comment separators between adjacent text nodes and
3
+ * returns the LCP URL and anchor hrefs in the same browser round trip. HTML cannot
4
+ * express two neighbouring text nodes, so serialising a client-rendered DOM merges
5
+ * them and hydration then finds one node where the client expects two. React's own
6
+ * server rendering emits the same separators.
7
7
  */
8
- export async function separateTextNodes(page) {
9
- await page.evaluate(() => {
8
+ export async function readRenderSnapshot(page) {
9
+ return page.evaluate(() => {
10
10
  // Elements whose children are raw text, where a comment would corrupt the content.
11
11
  const RAW_TEXT = new Set(['SCRIPT', 'STYLE', 'TEXTAREA', 'TITLE']);
12
12
 
@@ -26,5 +26,9 @@ export async function separateTextNodes(page) {
26
26
  };
27
27
 
28
28
  separate(document.documentElement);
29
+ return {
30
+ lcpUrl: window.__snappyLcp?.at(-1)?.url ?? null,
31
+ hrefs: Array.from(document.querySelectorAll('a[href]'), (anchor) => anchor.href),
32
+ };
29
33
  });
30
34
  }