snappy-prerender 2.6.0-rc.3 → 2.7.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 +191 -0
- package/README.md +149 -107
- package/docs/limitations.md +58 -0
- package/docs/react-snap-migration.md +45 -0
- package/docs/report.md +102 -0
- package/package.json +9 -5
- package/src/cli-init.js +205 -0
- package/src/cli.js +159 -168
- package/src/core/artifacts.js +112 -0
- package/src/core/audit/index.js +114 -0
- package/src/core/audit/links.js +63 -0
- package/src/core/audit/metadata.js +88 -0
- package/src/core/audit/sitemap.js +39 -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 +256 -6
- package/src/core/crawl.js +300 -0
- package/src/core/csp.js +127 -0
- package/src/core/css-url.js +34 -0
- package/src/core/destination.js +28 -3
- package/src/core/frameworks.js +94 -0
- package/src/core/hydration-diff.js +93 -0
- package/src/core/init.js +187 -31
- package/src/core/inline-css.js +34 -13
- package/src/core/metadata-origin.js +11 -2
- package/src/core/normalize.js +59 -2
- package/src/core/output.js +32 -9
- package/src/core/page-content.js +13 -0
- package/src/core/page-meta.js +108 -0
- package/src/core/preload-links.js +120 -0
- package/src/core/preload-manifest.js +61 -7
- package/src/core/prune.js +65 -0
- package/src/core/render.js +147 -11
- package/src/core/report.js +108 -0
- package/src/core/resources.js +6 -1
- package/src/core/route-config.js +17 -0
- package/src/core/route-sources.js +209 -0
- package/src/core/routes.js +9 -0
- package/src/core/run.js +186 -358
- package/src/core/server.js +30 -4
- package/src/core/sitemap.js +128 -20
- package/src/core/url-policy.js +32 -0
- package/src/core/verify.js +103 -49
- package/src/core/write.js +363 -0
- package/src/head/index.js +2 -2
- package/src/head/manager.js +54 -13
- package/src/head/tags.js +71 -32
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { findBuildOriginUrls } from './build-origin.js';
|
|
3
|
+
import { applyCspHashes } from './csp.js';
|
|
4
|
+
import { inlineCriticalCss } from './inline-css.js';
|
|
5
|
+
import { minifyHtml } from './minify.js';
|
|
6
|
+
import { normaliseHtml } from './normalize.js';
|
|
7
|
+
import {
|
|
8
|
+
findOutputCollisions,
|
|
9
|
+
routeToFile,
|
|
10
|
+
routeToScreenshotFile,
|
|
11
|
+
writeBufferIfChanged,
|
|
12
|
+
writeFileIfChanged,
|
|
13
|
+
writeRouteHtml,
|
|
14
|
+
} from './output.js';
|
|
15
|
+
import { collectPageMeta } from './page-meta.js';
|
|
16
|
+
import { collectScriptSources, injectPreloadLinks } from './preload-links.js';
|
|
17
|
+
import { collectPageResources } from './preload-manifest.js';
|
|
18
|
+
import { resolveRouteConfig } from './route-config.js';
|
|
19
|
+
import { normaliseRoute, toPublicPath } from './routes.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Writes every rendered route and returns the routes that can be verified. A write
|
|
23
|
+
* failure is recorded against its route instead of aborting the remaining writes.
|
|
24
|
+
*/
|
|
25
|
+
export async function writeAllRoutes({
|
|
26
|
+
rendered,
|
|
27
|
+
outputDir,
|
|
28
|
+
config,
|
|
29
|
+
log,
|
|
30
|
+
report,
|
|
31
|
+
state,
|
|
32
|
+
manifestEntries,
|
|
33
|
+
}) {
|
|
34
|
+
const sorted = [...report.routes].sort();
|
|
35
|
+
const conflicted = reportOutputCollisions({ routes: sorted, config, log, report });
|
|
36
|
+
|
|
37
|
+
await writeStateScripts({ rendered, outputDir, config, log, report });
|
|
38
|
+
|
|
39
|
+
const verifiable = [];
|
|
40
|
+
const hashes = new Map();
|
|
41
|
+
for (const route of sorted) {
|
|
42
|
+
if (conflicted.has(route)) continue;
|
|
43
|
+
try {
|
|
44
|
+
const routeConfig = resolveRouteConfig(config, route);
|
|
45
|
+
const result = rendered.get(route);
|
|
46
|
+
const { file, resources } = await writeRouteOutput({
|
|
47
|
+
route,
|
|
48
|
+
result,
|
|
49
|
+
outputDir,
|
|
50
|
+
config: routeConfig,
|
|
51
|
+
log,
|
|
52
|
+
state,
|
|
53
|
+
report,
|
|
54
|
+
});
|
|
55
|
+
report.files.push(file);
|
|
56
|
+
report.routeDetails.push({
|
|
57
|
+
route,
|
|
58
|
+
order: result.order ?? null,
|
|
59
|
+
file: file.file,
|
|
60
|
+
status: file.status,
|
|
61
|
+
bytes: file.bytes,
|
|
62
|
+
depth: result.depth ?? null,
|
|
63
|
+
attempts: result.attempts ?? 1,
|
|
64
|
+
httpStatus: result.httpStatus ?? null,
|
|
65
|
+
finalUrl: result.finalUrl ?? null,
|
|
66
|
+
empty: result.empty === true,
|
|
67
|
+
notFound: file.notFound === true,
|
|
68
|
+
noindex: file.noindex === true,
|
|
69
|
+
meta: file.meta ?? null,
|
|
70
|
+
images: file.images ?? [],
|
|
71
|
+
alternates: file.alternates ?? [],
|
|
72
|
+
mode: null,
|
|
73
|
+
framework: null,
|
|
74
|
+
hydrationErrors: [],
|
|
75
|
+
pageErrors: result.pageErrors ?? [],
|
|
76
|
+
consoleErrors: result.consoleErrors ?? [],
|
|
77
|
+
failedRequests: result.failedRequests ?? [],
|
|
78
|
+
firstMismatch: null,
|
|
79
|
+
buildOriginUrls: file.buildOriginUrls ?? [],
|
|
80
|
+
});
|
|
81
|
+
if (resources) manifestEntries.push({ route, ...resources });
|
|
82
|
+
if (file.hash) {
|
|
83
|
+
const group = hashes.get(file.hash) ?? [];
|
|
84
|
+
group.push(route);
|
|
85
|
+
hashes.set(file.hash, group);
|
|
86
|
+
}
|
|
87
|
+
verifiable.push(route);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
report.errors.push({ route, message: `write failed: ${error.message}` });
|
|
90
|
+
log.error(` write failed for ${route}: ${error.message}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
reportDuplicatePages({ groups: [...hashes.values()], config, log, report });
|
|
94
|
+
return verifiable;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Reports routes that produced byte-identical HTML. The usual cause is an SPA fallback
|
|
99
|
+
* serving the shell for a path the app does not route, which would otherwise publish a
|
|
100
|
+
* copy of another page under a dead URL.
|
|
101
|
+
*/
|
|
102
|
+
function reportDuplicatePages({ groups, config, log, report }) {
|
|
103
|
+
for (const routes of groups) {
|
|
104
|
+
if (routes.length < 2) continue;
|
|
105
|
+
report.duplicatePages.push({ routes });
|
|
106
|
+
log.warn(
|
|
107
|
+
`identical output for ${routes.join(', ')}: check for a dead link served by the SPA fallback`,
|
|
108
|
+
);
|
|
109
|
+
if (config.failOnDuplicateContent) {
|
|
110
|
+
for (const route of routes) {
|
|
111
|
+
report.errors.push({
|
|
112
|
+
route,
|
|
113
|
+
message: `identical output to ${routes.filter((other) => other !== route).join(', ')}`,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Writes the captured-state scripts routes reference when externalScripts is on. The payload
|
|
122
|
+
* is content-addressed, so routes that inject the same state share one file and a rerun
|
|
123
|
+
* leaves it untouched.
|
|
124
|
+
*/
|
|
125
|
+
async function writeStateScripts({ rendered, outputDir, config, log, report }) {
|
|
126
|
+
const seen = new Set();
|
|
127
|
+
for (const [route, result] of rendered) {
|
|
128
|
+
const stateScript = result?.stateScript;
|
|
129
|
+
if (!stateScript || seen.has(stateScript.file)) continue;
|
|
130
|
+
seen.add(stateScript.file);
|
|
131
|
+
if (config.dryRun) {
|
|
132
|
+
report.files.push({
|
|
133
|
+
route,
|
|
134
|
+
file: stateScript.file,
|
|
135
|
+
status: 'dry-run',
|
|
136
|
+
bytes: Buffer.byteLength(stateScript.contents),
|
|
137
|
+
});
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
const written = await writeFileIfChanged({
|
|
142
|
+
dir: outputDir,
|
|
143
|
+
file: stateScript.file,
|
|
144
|
+
contents: stateScript.contents,
|
|
145
|
+
});
|
|
146
|
+
report.files.push({ route, ...written });
|
|
147
|
+
log.debug(` wrote ${stateScript.file}: ${written.status}`);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
report.errors.push({ route, message: `write failed: ${error.message}` });
|
|
150
|
+
log.error(` write failed for ${stateScript.file}: ${error.message}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Records an error for every route involved in an output file collision and returns
|
|
157
|
+
* the routes to skip, so an ambiguous write can never overwrite another route.
|
|
158
|
+
*/
|
|
159
|
+
function reportOutputCollisions({ routes, config, log, report }) {
|
|
160
|
+
const conflicted = new Set();
|
|
161
|
+
for (const [file, colliding] of findOutputCollisions(routes, config)) {
|
|
162
|
+
for (const route of colliding) {
|
|
163
|
+
conflicted.add(route);
|
|
164
|
+
const others = colliding.filter((other) => other !== route).join(', ');
|
|
165
|
+
report.errors.push({
|
|
166
|
+
route,
|
|
167
|
+
message: `output file collision: ${file} is also written by ${others}`,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
log.error(` output file collision on ${file}: ${colliding.join(', ')}`);
|
|
171
|
+
}
|
|
172
|
+
return conflicted;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Applies the configured post-processing to one rendered route and writes it: DOM
|
|
177
|
+
* cleanups and link hints, critical CSS extraction, then optional minification.
|
|
178
|
+
*/
|
|
179
|
+
async function writeRouteOutput({ route, result, outputDir, config, log, state, report }) {
|
|
180
|
+
if (result.screenshot) {
|
|
181
|
+
const file = routeToScreenshotFile(route, config);
|
|
182
|
+
if (config.dryRun) {
|
|
183
|
+
return {
|
|
184
|
+
file: {
|
|
185
|
+
route,
|
|
186
|
+
file,
|
|
187
|
+
status: 'dry-run',
|
|
188
|
+
bytes: result.screenshot.length,
|
|
189
|
+
},
|
|
190
|
+
resources: null,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
const written = await writeBufferIfChanged({
|
|
194
|
+
dir: outputDir,
|
|
195
|
+
file,
|
|
196
|
+
buffer: result.screenshot,
|
|
197
|
+
});
|
|
198
|
+
return { file: { route, ...written }, resources: null };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const { html, stats } = normaliseHtml(result.html, {
|
|
202
|
+
removeStyleTags: config.removeStyleTags,
|
|
203
|
+
removeScriptTags: config.removeScriptTags,
|
|
204
|
+
asyncScriptTags: config.asyncScriptTags,
|
|
205
|
+
removeBlobs: config.removeBlobs,
|
|
206
|
+
removeAttributes: config.removeAttributes,
|
|
207
|
+
stripBuildOriginMetadata: config.stripBuildOriginMetadata,
|
|
208
|
+
noindex:
|
|
209
|
+
config.notFoundRoute !== null &&
|
|
210
|
+
normaliseRoute(route) === normaliseRoute(config.notFoundRoute),
|
|
211
|
+
preconnectOrigins: config.preconnectThirdParty
|
|
212
|
+
? [...result.collector.thirdPartyOrigins].sort()
|
|
213
|
+
: [],
|
|
214
|
+
preloadImages: config.preloadImages
|
|
215
|
+
? [...result.collector.images].sort().map((image) => toPublicPath(image, config.base))
|
|
216
|
+
: [],
|
|
217
|
+
});
|
|
218
|
+
if (
|
|
219
|
+
stats.removedElements > 0 ||
|
|
220
|
+
stats.removedAttributes > 0 ||
|
|
221
|
+
stats.dedupedStyles > 0 ||
|
|
222
|
+
stats.hints > 0 ||
|
|
223
|
+
stats.textSeparators > 0
|
|
224
|
+
) {
|
|
225
|
+
log.debug(
|
|
226
|
+
` 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)`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
if ((result.inlineCssSkipped ?? []).length > 0) {
|
|
230
|
+
log.debug(` inline css: left ${result.inlineCssSkipped.length} stylesheet(s) as links`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
let output = html;
|
|
234
|
+
if (config.inlineCss === 'critical') {
|
|
235
|
+
output = await applyCriticalCss({ output, outputDir, config, log, state });
|
|
236
|
+
}
|
|
237
|
+
if (config.minifyHtml) {
|
|
238
|
+
output = await minifyHtml(output, config.minifyHtml, config.minifyCss);
|
|
239
|
+
}
|
|
240
|
+
const preTransformScripts = config.transformHtml ? collectScriptSources(output) : new Set();
|
|
241
|
+
if (config.transformHtml) {
|
|
242
|
+
const transformed = await config.transformHtml(output, {
|
|
243
|
+
route,
|
|
244
|
+
file: routeToFile(route, config),
|
|
245
|
+
stats,
|
|
246
|
+
config,
|
|
247
|
+
});
|
|
248
|
+
if (transformed !== null && transformed !== undefined && typeof transformed !== 'string') {
|
|
249
|
+
throw new TypeError('transformHtml must return a string, null or undefined');
|
|
250
|
+
}
|
|
251
|
+
output = transformed ?? output;
|
|
252
|
+
}
|
|
253
|
+
if (config.preloadLinks) {
|
|
254
|
+
const basePrefix = config.base === '/' ? '' : config.base.slice(0, -1);
|
|
255
|
+
const skipScripts = new Set(
|
|
256
|
+
[...preTransformScripts].map((src) => {
|
|
257
|
+
if (!src.startsWith('/')) return src;
|
|
258
|
+
const appRelative =
|
|
259
|
+
basePrefix && (src === basePrefix || src.startsWith(`${basePrefix}/`))
|
|
260
|
+
? src.slice(basePrefix.length)
|
|
261
|
+
: src;
|
|
262
|
+
return toPublicPath(appRelative, config.base);
|
|
263
|
+
}),
|
|
264
|
+
);
|
|
265
|
+
output = injectPreloadLinks(output, {
|
|
266
|
+
scripts: config.removeScriptTags ? [] : [...result.collector.scripts],
|
|
267
|
+
fonts: result.collector.fonts,
|
|
268
|
+
lcpImage: result.lcpImage,
|
|
269
|
+
base: config.base,
|
|
270
|
+
ignoreForPreload: config.ignoreForPreload,
|
|
271
|
+
skipScripts,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
if (config.csp === 'hash') {
|
|
275
|
+
const policy = applyCspHashes(output);
|
|
276
|
+
if (policy.existingPolicy) {
|
|
277
|
+
log.warn(` ${route}: kept the existing CSP meta and injected no hashes`);
|
|
278
|
+
report.cspSkipped.push(route);
|
|
279
|
+
} else {
|
|
280
|
+
output = policy.html;
|
|
281
|
+
log.debug(
|
|
282
|
+
` csp ${route}: hashed ${policy.hashedScripts} script(s) and ${policy.hashedStyles} style(s)`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const meta = collectPageMeta(output);
|
|
287
|
+
warnOnBuildOriginMetadata({
|
|
288
|
+
origins: stats.metadataOrigins,
|
|
289
|
+
removed: stats.removedMetadata,
|
|
290
|
+
route,
|
|
291
|
+
log,
|
|
292
|
+
state,
|
|
293
|
+
});
|
|
294
|
+
if (meta.titleCount > 1) report.duplicateTitles.push(route);
|
|
295
|
+
const buildOrigins = findBuildOriginUrls(output);
|
|
296
|
+
if (config.failOnBuildOriginMetadata) {
|
|
297
|
+
if (buildOrigins.length > 0) {
|
|
298
|
+
const message = `metadata still points at the build server: ${buildOrigins.join(', ')}`;
|
|
299
|
+
report.errors.push({ route, message });
|
|
300
|
+
log.error(` ${route}: ${message}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const file = await writeRouteHtml({ dir: outputDir, route, html: output, config });
|
|
304
|
+
const resources = config.preloadManifest
|
|
305
|
+
? collectPageResources(output, { base: config.base, route })
|
|
306
|
+
: null;
|
|
307
|
+
return {
|
|
308
|
+
file: {
|
|
309
|
+
...file,
|
|
310
|
+
// Hash the captured DOM before post-processing adds route-specific extras such as
|
|
311
|
+
// the not-found noindex, so a dead link serving the 404 page is still detected.
|
|
312
|
+
hash: createHash('sha256')
|
|
313
|
+
.update(result.html ?? output)
|
|
314
|
+
.digest('hex'),
|
|
315
|
+
noindex: meta.noindex,
|
|
316
|
+
notFound: result.notFound === true,
|
|
317
|
+
meta,
|
|
318
|
+
images: meta.images,
|
|
319
|
+
alternates: meta.alternates,
|
|
320
|
+
inlineStyleAttributes: meta.inlineStyleAttributes,
|
|
321
|
+
buildOriginUrls: buildOrigins,
|
|
322
|
+
},
|
|
323
|
+
resources,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Inlines critical CSS, or explains once why it cannot: the strategy reads stylesheets from
|
|
329
|
+
* the output directory, which a command server does not have to serve.
|
|
330
|
+
*/
|
|
331
|
+
async function applyCriticalCss({ output, outputDir, config, log, state }) {
|
|
332
|
+
if (!config.serveCmd) {
|
|
333
|
+
return inlineCriticalCss({
|
|
334
|
+
html: output,
|
|
335
|
+
outputDir,
|
|
336
|
+
base: config.base,
|
|
337
|
+
preload: config.criticalCssPreload,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
if (!state.warnedCriticalCssSkipped) {
|
|
341
|
+
state.warnedCriticalCssSkipped = true;
|
|
342
|
+
log.warn(
|
|
343
|
+
"inlineCss: 'critical' is skipped with serveCmd: stylesheets are read from the output directory, which the command server does not have to serve",
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
return output;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Warns once when metadata contains the build server's origin, which means the page was
|
|
351
|
+
* composed without a public siteUrl and og:url, og:image or canonical would ship wrong.
|
|
352
|
+
*/
|
|
353
|
+
function warnOnBuildOriginMetadata({ origins, removed = 0, route, log, state }) {
|
|
354
|
+
if (state.warnedBuildOriginMetadata || origins.length === 0) return;
|
|
355
|
+
state.warnedBuildOriginMetadata = true;
|
|
356
|
+
log.warn(
|
|
357
|
+
`metadata on ${route} points at the build server (${origins.join(', ')}); ${
|
|
358
|
+
removed > 0
|
|
359
|
+
? `removed ${removed} affected tag(s). Set metadata.siteUrl so canonical, og:url and og:image match the deployed site`
|
|
360
|
+
: 'set metadata.siteUrl so og:url, og:image and canonical match the deployed site'
|
|
361
|
+
}`,
|
|
362
|
+
);
|
|
363
|
+
}
|
package/src/head/index.js
CHANGED
|
@@ -26,7 +26,7 @@ export function Head(props) {
|
|
|
26
26
|
sequenceRef.current = sequence;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
const entries = buildHeadEntries(props, readMetadataDefaults());
|
|
29
|
+
const entries = buildHeadEntries(props, readMetadataDefaults(), idRef.current);
|
|
30
30
|
|
|
31
31
|
// A layout effect so a route change updates the head before the browser paints, and a
|
|
32
32
|
// plain effect on the server, where layout effects never run.
|
|
@@ -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(), id));
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
/**
|
package/src/head/manager.js
CHANGED
|
@@ -5,6 +5,7 @@ const CREATED_ATTRIBUTE = 'data-snappy-head-created';
|
|
|
5
5
|
const stack = [];
|
|
6
6
|
const snapshots = new WeakMap();
|
|
7
7
|
const createdElements = new WeakSet();
|
|
8
|
+
const htmlOriginals = new Map();
|
|
8
9
|
const originals = readRecordedOriginals();
|
|
9
10
|
publishOriginals();
|
|
10
11
|
|
|
@@ -56,7 +57,9 @@ function applyMerged() {
|
|
|
56
57
|
if (typeof document === 'undefined' || !document.head) return;
|
|
57
58
|
const head = document.head;
|
|
58
59
|
const merged = mergeHeadEntries(stack);
|
|
59
|
-
const
|
|
60
|
+
const headEntries = merged.filter((entry) => entry.kind !== 'html');
|
|
61
|
+
const htmlEntries = merged.filter((entry) => entry.kind === 'html');
|
|
62
|
+
const desired = new Map(headEntries.map((entry) => [headKey(entry), entry]));
|
|
60
63
|
|
|
61
64
|
for (const [key, entry] of desired) {
|
|
62
65
|
const matches = findTags(head, key);
|
|
@@ -74,7 +77,27 @@ function applyMerged() {
|
|
|
74
77
|
if (!desired.has(key)) releaseTag(element, key);
|
|
75
78
|
}
|
|
76
79
|
|
|
77
|
-
|
|
80
|
+
applyHtmlAttributes(htmlEntries);
|
|
81
|
+
orderTags(head, headEntries);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Applies lang/dir to the document element and restores the original value when no Head
|
|
85
|
+
// wants the attribute any more.
|
|
86
|
+
function applyHtmlAttributes(entries) {
|
|
87
|
+
if (typeof document === 'undefined' || !document.documentElement) return;
|
|
88
|
+
const root = document.documentElement;
|
|
89
|
+
const desired = new Map(entries.map((entry) => [entry.key, entry.value]));
|
|
90
|
+
for (const [key, value] of desired) {
|
|
91
|
+
if (!htmlOriginals.has(key)) htmlOriginals.set(key, root.getAttribute(key));
|
|
92
|
+
root.setAttribute(key, value);
|
|
93
|
+
}
|
|
94
|
+
for (const key of [...htmlOriginals.keys()]) {
|
|
95
|
+
if (desired.has(key)) continue;
|
|
96
|
+
const previous = htmlOriginals.get(key);
|
|
97
|
+
if (previous === null) root.removeAttribute(key);
|
|
98
|
+
else root.setAttribute(key, previous);
|
|
99
|
+
htmlOriginals.delete(key);
|
|
100
|
+
}
|
|
78
101
|
}
|
|
79
102
|
|
|
80
103
|
/**
|
|
@@ -87,6 +110,9 @@ function findTags(head, key) {
|
|
|
87
110
|
const title = head.querySelector('title');
|
|
88
111
|
return title ? [title] : [];
|
|
89
112
|
}
|
|
113
|
+
const owned = head.querySelector(`[${OWNER_ATTRIBUTE}="${cssEscape(key)}"]`);
|
|
114
|
+
if (owned) return [owned];
|
|
115
|
+
if (key.startsWith('link:')) return findLinkTags(head, key.slice(5));
|
|
90
116
|
const [kind, attribute, ...rest] = key.split(':');
|
|
91
117
|
const value = rest.join(':');
|
|
92
118
|
if (
|
|
@@ -95,18 +121,32 @@ function findTags(head, key) {
|
|
|
95
121
|
) {
|
|
96
122
|
return [...head.querySelectorAll(`meta[${attribute}="${cssEscape(value)}"]`)];
|
|
97
123
|
}
|
|
98
|
-
|
|
99
|
-
const owned = head.querySelector(`[${OWNER_ATTRIBUTE}="${cssEscape(key)}"]`);
|
|
100
|
-
return owned ? [owned] : [];
|
|
124
|
+
return [];
|
|
101
125
|
}
|
|
102
126
|
|
|
103
|
-
// Matches links
|
|
104
|
-
//
|
|
105
|
-
function
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
127
|
+
// Matches links that a previous run or the app itself wrote. Head-owned links carry the
|
|
128
|
+
// owner attribute and are found above; this handles adoption on the first run.
|
|
129
|
+
function findLinkTags(head, spec) {
|
|
130
|
+
const links = [...head.querySelectorAll('link')];
|
|
131
|
+
if (spec.startsWith('rel:')) {
|
|
132
|
+
const [rel, hreflang, href] = spec.slice(4).split('|');
|
|
133
|
+
return links.filter(
|
|
134
|
+
(link) =>
|
|
135
|
+
link.getAttribute('rel') === rel &&
|
|
136
|
+
(hreflang === undefined || link.getAttribute('hreflang') === hreflang) &&
|
|
137
|
+
(href === undefined || link.getAttribute('href') === href),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
if (!spec.includes('=')) return [];
|
|
141
|
+
const attrs = Object.fromEntries(
|
|
142
|
+
spec.split('|').map((pair) => {
|
|
143
|
+
const index = pair.indexOf('=');
|
|
144
|
+
return [pair.slice(0, index), pair.slice(index + 1)];
|
|
145
|
+
}),
|
|
146
|
+
);
|
|
147
|
+
return links.filter((link) =>
|
|
148
|
+
Object.entries(attrs).every(([name, value]) => link.getAttribute(name) === value),
|
|
149
|
+
);
|
|
110
150
|
}
|
|
111
151
|
|
|
112
152
|
function createTag(head, entry) {
|
|
@@ -140,6 +180,7 @@ function writeTag(element, entry, key) {
|
|
|
140
180
|
if (!Object.hasOwn(entry.attrs, attribute.name)) element.removeAttribute(attribute.name);
|
|
141
181
|
}
|
|
142
182
|
for (const [name, value] of Object.entries(entry.attrs)) element.setAttribute(name, value);
|
|
183
|
+
if (entry.kind === 'script') element.textContent = entry.text;
|
|
143
184
|
}
|
|
144
185
|
|
|
145
186
|
// Remembers what a tag looked like before the head layer first touched it, so an app-owned
|
|
@@ -155,7 +196,7 @@ function snapshotTag(element, key) {
|
|
|
155
196
|
}
|
|
156
197
|
const snapshot = {
|
|
157
198
|
attrs: Object.fromEntries(attrs),
|
|
158
|
-
text: element.tagName === 'TITLE' ? element.textContent : null,
|
|
199
|
+
text: element.tagName === 'TITLE' || element.tagName === 'SCRIPT' ? element.textContent : null,
|
|
159
200
|
};
|
|
160
201
|
snapshots.set(element, snapshot);
|
|
161
202
|
if (createdElements.has(element) || element.hasAttribute(CREATED_ATTRIBUTE)) return;
|
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 = /^\/\//;
|
|
@@ -11,12 +11,19 @@ const SINGLETON_LINK_RELS = new Set(['canonical']);
|
|
|
11
11
|
* caller did not supply are simply absent, so the component never invents metadata the app
|
|
12
12
|
* did not ask for.
|
|
13
13
|
*/
|
|
14
|
-
export function buildHeadEntries(props = {}, defaults = {}) {
|
|
14
|
+
export function buildHeadEntries(props = {}, defaults = {}, id = 'default') {
|
|
15
15
|
const entries = [];
|
|
16
16
|
const template = props.titleTemplate ?? defaults.titleTemplate ?? null;
|
|
17
17
|
const siteName = props.siteName ?? defaults.siteName ?? null;
|
|
18
18
|
const image = props.image ?? defaults.defaultImage ?? null;
|
|
19
19
|
|
|
20
|
+
if (typeof props.lang === 'string' && props.lang.length > 0) {
|
|
21
|
+
entries.push({ kind: 'html', key: 'lang', value: props.lang });
|
|
22
|
+
}
|
|
23
|
+
if (props.dir === 'ltr' || props.dir === 'rtl' || props.dir === 'auto') {
|
|
24
|
+
entries.push({ kind: 'html', key: 'dir', value: props.dir });
|
|
25
|
+
}
|
|
26
|
+
|
|
20
27
|
const title = props.title ? applyTitleTemplate(props.title, template, siteName) : null;
|
|
21
28
|
if (title) entries.push({ kind: 'title', text: title });
|
|
22
29
|
if (title && props.openGraph !== false) entries.push(metaProp('og:title', title));
|
|
@@ -34,7 +41,7 @@ export function buildHeadEntries(props = {}, defaults = {}) {
|
|
|
34
41
|
entries.push(metaProp('og:type', props.type ?? 'website'));
|
|
35
42
|
if (siteName) entries.push(metaProp('og:site_name', siteName));
|
|
36
43
|
if (props.locale) entries.push(metaProp('og:locale', props.locale));
|
|
37
|
-
const url = pageUrl(props.canonical ??
|
|
44
|
+
const url = pageUrl(props.canonical ?? defaultPagePath(defaults), defaults);
|
|
38
45
|
if (url) entries.push(metaProp('og:url', url));
|
|
39
46
|
if (image) entries.push(metaProp('og:image', absoluteUrl(image, defaults)));
|
|
40
47
|
}
|
|
@@ -45,11 +52,29 @@ export function buildHeadEntries(props = {}, defaults = {}) {
|
|
|
45
52
|
}
|
|
46
53
|
|
|
47
54
|
if (props.canonical !== false) {
|
|
48
|
-
const url = pageUrl(props.canonical ??
|
|
55
|
+
const url = pageUrl(props.canonical ?? defaultPagePath(defaults), defaults);
|
|
49
56
|
if (url)
|
|
50
57
|
entries.push({ kind: 'link', key: 'rel:canonical', attrs: { rel: 'canonical', href: url } });
|
|
51
58
|
}
|
|
52
59
|
|
|
60
|
+
for (const alternate of props.alternates ?? []) {
|
|
61
|
+
const href = pageUrl(alternate.href, defaults);
|
|
62
|
+
const hreflang = alternate.hreflang ?? alternate.lang;
|
|
63
|
+
if (!href || !hreflang) continue;
|
|
64
|
+
const attrs = { rel: 'alternate', hreflang, href };
|
|
65
|
+
if (alternate.type) attrs.type = alternate.type;
|
|
66
|
+
entries.push({ kind: 'link', key: `rel:alternate|${hreflang}|${href}`, attrs });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (props.structuredData !== undefined && props.structuredData !== null) {
|
|
70
|
+
entries.push({
|
|
71
|
+
kind: 'script',
|
|
72
|
+
key: `script:ld+json:${id}`,
|
|
73
|
+
attrs: { type: 'application/ld+json' },
|
|
74
|
+
text: serializeStructuredData(props.structuredData),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
53
78
|
for (const extra of props.extra ?? []) entries.push(extraEntry(extra));
|
|
54
79
|
|
|
55
80
|
return entries;
|
|
@@ -77,6 +102,8 @@ export function mergeHeadEntries(stack = []) {
|
|
|
77
102
|
*/
|
|
78
103
|
export function headKey(entry) {
|
|
79
104
|
if (entry.kind === 'title') return 'title';
|
|
105
|
+
if (entry.kind === 'html') return `html:${entry.key}`;
|
|
106
|
+
if (entry.kind === 'script') return entry.key;
|
|
80
107
|
if (entry.kind === 'link') return `link:${entry.key ?? attrsKey(entry.attrs)}`;
|
|
81
108
|
if (entry.key) return `meta:${entry.key}`;
|
|
82
109
|
const name = attrsValue(entry.attrs, 'name');
|
|
@@ -88,14 +115,6 @@ export function headKey(entry) {
|
|
|
88
115
|
return `meta:${attrsKey(entry.attrs)}`;
|
|
89
116
|
}
|
|
90
117
|
|
|
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
118
|
// Applies the site's title template unless the title already carries its suffix or is the
|
|
100
119
|
// site name itself, so an app that composed its own title never gets it suffixed twice.
|
|
101
120
|
function applyTitleTemplate(title, template, siteName) {
|
|
@@ -139,23 +158,24 @@ function encodeSpaces(url) {
|
|
|
139
158
|
return url.replace(/ /g, '%20');
|
|
140
159
|
}
|
|
141
160
|
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
const
|
|
147
|
-
if (
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const next = path.replace(/\/+$/, '');
|
|
153
|
-
return query ? `${next}?${query}` : next;
|
|
161
|
+
// Reads the route being rendered, which under prerendering is the page being captured,
|
|
162
|
+
// and removes the public base path so absoluteUrl does not add it a second time.
|
|
163
|
+
function currentPath(defaults = {}) {
|
|
164
|
+
const pathname = typeof location === 'undefined' ? '/' : location.pathname;
|
|
165
|
+
const base = defaults.base ?? '/';
|
|
166
|
+
if (base === '/') return pathname;
|
|
167
|
+
const prefix = base.replace(/\/$/, '');
|
|
168
|
+
if (pathname === prefix) return '/';
|
|
169
|
+
if (pathname.startsWith(`${prefix}/`)) return pathname.slice(prefix.length);
|
|
170
|
+
return pathname;
|
|
154
171
|
}
|
|
155
172
|
|
|
156
|
-
//
|
|
157
|
-
function
|
|
158
|
-
return
|
|
173
|
+
// Builds the default canonical path from the current route and the output's URL policy.
|
|
174
|
+
function defaultPagePath(defaults) {
|
|
175
|
+
return pagePath(currentPath(defaults), {
|
|
176
|
+
flatOutput: defaults.flatOutput === true,
|
|
177
|
+
trailingSlash: defaults.trailingSlash,
|
|
178
|
+
});
|
|
159
179
|
}
|
|
160
180
|
|
|
161
181
|
function metaName(name, content) {
|
|
@@ -202,10 +222,29 @@ function attrsValue(attrs = {}, name) {
|
|
|
202
222
|
return attrs[name];
|
|
203
223
|
}
|
|
204
224
|
|
|
205
|
-
//
|
|
225
|
+
// Serialises JSON-LD so a value can never close the script element. The escapes remain
|
|
226
|
+
// valid JSON, so crawlers parse the same object the caller supplied.
|
|
227
|
+
function serializeStructuredData(value) {
|
|
228
|
+
return JSON.stringify(value).replace(
|
|
229
|
+
/[<>&\u2028\u2029]/g,
|
|
230
|
+
(char) =>
|
|
231
|
+
({
|
|
232
|
+
'<': '\\u003C',
|
|
233
|
+
'>': '\\u003E',
|
|
234
|
+
'&': '\\u0026',
|
|
235
|
+
'\u2028': '\\u2028',
|
|
236
|
+
'\u2029': '\\u2029',
|
|
237
|
+
})[char],
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Keeps output deterministic: title, metas, links, JSON-LD scripts, then html attributes.
|
|
206
242
|
function sortMetasFirst(entries) {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
243
|
+
const rank = (entry) => {
|
|
244
|
+
if (entry.kind === 'meta') return 0;
|
|
245
|
+
if (entry.kind === 'link') return 1;
|
|
246
|
+
if (entry.kind === 'script') return 2;
|
|
247
|
+
return 3;
|
|
248
|
+
};
|
|
249
|
+
return [...entries].sort((a, b) => rank(a) - rank(b));
|
|
211
250
|
}
|