snappy-prerender 2.7.0 → 2.8.1

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.
@@ -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
- if (!config.metadata?.siteUrl) return null;
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 `${config.metadata.siteUrl.replace(/\/$/, '')}${toPublicPath(path, config.base)}`;
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
+ }