snappy-prerender 2.6.0-rc.2
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/LICENSE +21 -0
- package/README.md +502 -0
- package/package.json +89 -0
- package/src/cli.js +476 -0
- package/src/core/blocking.js +28 -0
- package/src/core/browser.js +122 -0
- package/src/core/config.js +320 -0
- package/src/core/destination.js +45 -0
- package/src/core/form-state.js +23 -0
- package/src/core/index.js +2 -0
- package/src/core/init.js +136 -0
- package/src/core/inline-css.js +67 -0
- package/src/core/log.js +45 -0
- package/src/core/metadata-origin.js +39 -0
- package/src/core/minify.js +42 -0
- package/src/core/normalize.js +197 -0
- package/src/core/output.js +81 -0
- package/src/core/preload-manifest.js +28 -0
- package/src/core/render.js +228 -0
- package/src/core/resources.js +113 -0
- package/src/core/routes.js +150 -0
- package/src/core/run.js +523 -0
- package/src/core/runtime-styles.js +42 -0
- package/src/core/scheduler.js +83 -0
- package/src/core/server.js +216 -0
- package/src/core/settle.js +141 -0
- package/src/core/text-separators.js +30 -0
- package/src/core/verify.js +148 -0
- package/src/head/index.js +56 -0
- package/src/head/manager.js +230 -0
- package/src/head/tags.js +211 -0
- package/src/index.js +2 -0
- package/src/vite-plugin.js +49 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef } from 'react';
|
|
2
|
+
import { readMetadataDefaults, registerHead, unregisterHead } from './manager.js';
|
|
3
|
+
import { buildHeadEntries } from './tags.js';
|
|
4
|
+
|
|
5
|
+
const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
|
|
6
|
+
|
|
7
|
+
let sequence = 0;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Declares document metadata for the route being rendered: title, description, robots,
|
|
11
|
+
* canonical, Open Graph and Twitter card tags. The head is reconciled on every render while
|
|
12
|
+
* the component is mounted, so client-side navigation gives each route its own head exactly
|
|
13
|
+
* like a site that renders a page per request.
|
|
14
|
+
*
|
|
15
|
+
* Renders nothing, so it never affects hydration of the page content.
|
|
16
|
+
*/
|
|
17
|
+
export function Head(props) {
|
|
18
|
+
const idRef = useRef(null);
|
|
19
|
+
const sequenceRef = useRef(0);
|
|
20
|
+
|
|
21
|
+
if (idRef.current === null) {
|
|
22
|
+
sequence += 1;
|
|
23
|
+
idRef.current = `snappy-head-${sequence}`;
|
|
24
|
+
// Render order decides precedence: a page's Head is rendered after the layout's, so it
|
|
25
|
+
// wins for any tag they both set.
|
|
26
|
+
sequenceRef.current = sequence;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const entries = buildHeadEntries(props, readMetadataDefaults());
|
|
30
|
+
|
|
31
|
+
// A layout effect so a route change updates the head before the browser paints, and a
|
|
32
|
+
// plain effect on the server, where layout effects never run.
|
|
33
|
+
useIsomorphicLayoutEffect(() => {
|
|
34
|
+
registerHead(idRef.current, sequenceRef.current, entries);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
useEffect(() => () => unregisterHead(idRef.current), []);
|
|
38
|
+
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Imperative equivalent for code that is not a React component, for example a vanilla
|
|
44
|
+
* router or an analytics callback.
|
|
45
|
+
*/
|
|
46
|
+
export function setHead(id, props) {
|
|
47
|
+
sequence += 1;
|
|
48
|
+
registerHead(id, sequence, buildHeadEntries(props, readDefaults()));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Removes metadata previously set with setHead.
|
|
53
|
+
*/
|
|
54
|
+
export function clearHead(id) {
|
|
55
|
+
unregisterHead(id);
|
|
56
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { headKey, mergeHeadEntries } from './tags.js';
|
|
2
|
+
|
|
3
|
+
const OWNER_ATTRIBUTE = 'data-snappy-head';
|
|
4
|
+
const CREATED_ATTRIBUTE = 'data-snappy-head-created';
|
|
5
|
+
const stack = [];
|
|
6
|
+
const snapshots = new WeakMap();
|
|
7
|
+
const createdElements = new WeakSet();
|
|
8
|
+
const originals = readRecordedOriginals();
|
|
9
|
+
publishOriginals();
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Reads the metadata defaults the prerenderer persisted: an inert JSON script once the page
|
|
13
|
+
* carries one, or the init-script global while prerendering, before that element exists.
|
|
14
|
+
*/
|
|
15
|
+
export function readMetadataDefaults() {
|
|
16
|
+
const element =
|
|
17
|
+
typeof document === 'undefined' ? null : document.querySelector('script[data-snappy-meta]');
|
|
18
|
+
if (element) {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(element.textContent) ?? {};
|
|
21
|
+
} catch {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return typeof window === 'undefined' ? {} : (window.__SNAPPY_META__ ?? {});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Registers one mounted Head's tag list and reconciles the document head with the merged
|
|
30
|
+
* result. Called on every render, so a client-side route change updates the head without a
|
|
31
|
+
* page load.
|
|
32
|
+
*/
|
|
33
|
+
export function registerHead(id, sequence, entries) {
|
|
34
|
+
const index = stack.findIndex((item) => item.id === id);
|
|
35
|
+
const item = { id, sequence, entries };
|
|
36
|
+
if (index === -1) stack.push(item);
|
|
37
|
+
else stack[index] = item;
|
|
38
|
+
applyMerged();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Removes a Head's contribution and releases the tags it owned, so leaving a route does not
|
|
43
|
+
* leave that route's metadata behind.
|
|
44
|
+
*/
|
|
45
|
+
export function unregisterHead(id) {
|
|
46
|
+
const index = stack.findIndex((item) => item.id === id);
|
|
47
|
+
if (index === -1) return;
|
|
48
|
+
stack.splice(index, 1);
|
|
49
|
+
applyMerged();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Brings the head in line with the merged entries: create, adopt or update each tag, drop
|
|
53
|
+
// duplicates of the same identity, release tags that are no longer wanted, then restore a
|
|
54
|
+
// deterministic order.
|
|
55
|
+
function applyMerged() {
|
|
56
|
+
if (typeof document === 'undefined' || !document.head) return;
|
|
57
|
+
const head = document.head;
|
|
58
|
+
const merged = mergeHeadEntries(stack);
|
|
59
|
+
const desired = new Map(merged.map((entry) => [headKey(entry), entry]));
|
|
60
|
+
|
|
61
|
+
for (const [key, entry] of desired) {
|
|
62
|
+
const matches = findTags(head, key);
|
|
63
|
+
const element = matches[0] ?? createTag(head, entry);
|
|
64
|
+
writeTag(element, entry, key);
|
|
65
|
+
// A second tag with the same identity is one the page should not ship twice. Titles are
|
|
66
|
+
// exempt: React 19 native metadata and the head layer can each write one.
|
|
67
|
+
if (key !== 'title') {
|
|
68
|
+
for (const duplicate of matches.slice(1)) removeDuplicate(duplicate, key);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const element of [...head.querySelectorAll(`[${OWNER_ATTRIBUTE}]`)]) {
|
|
73
|
+
const key = element.getAttribute(OWNER_ATTRIBUTE);
|
|
74
|
+
if (!desired.has(key)) releaseTag(element, key);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
orderTags(head, merged);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Finds every tag a key refers to, in document order. Matching on the attribute a crawler
|
|
82
|
+
* reads means a tag already in the document — including one in prerendered HTML — is
|
|
83
|
+
* updated in place instead of duplicated.
|
|
84
|
+
*/
|
|
85
|
+
function findTags(head, key) {
|
|
86
|
+
if (key === 'title') {
|
|
87
|
+
const title = head.querySelector('title');
|
|
88
|
+
return title ? [title] : [];
|
|
89
|
+
}
|
|
90
|
+
const [kind, attribute, ...rest] = key.split(':');
|
|
91
|
+
const value = rest.join(':');
|
|
92
|
+
if (
|
|
93
|
+
kind === 'meta' &&
|
|
94
|
+
(attribute === 'name' || attribute === 'property' || attribute === 'http-equiv')
|
|
95
|
+
) {
|
|
96
|
+
return [...head.querySelectorAll(`meta[${attribute}="${cssEscape(value)}"]`)];
|
|
97
|
+
}
|
|
98
|
+
if (kind === 'link' && attribute === 'rel') return findRelLinks(head, value);
|
|
99
|
+
const owned = head.querySelector(`[${OWNER_ATTRIBUTE}="${cssEscape(key)}"]`);
|
|
100
|
+
return owned ? [owned] : [];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Matches links by rel, and by href when the key carries one, so two links that share a rel
|
|
104
|
+
// but point elsewhere stay distinct.
|
|
105
|
+
function findRelLinks(head, value) {
|
|
106
|
+
const [rel, href] = value.split('|');
|
|
107
|
+
const links = [...head.querySelectorAll(`link[rel="${cssEscape(rel)}"]`)];
|
|
108
|
+
if (href === undefined) return links;
|
|
109
|
+
return links.filter((link) => link.getAttribute('href') === href);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function createTag(head, entry) {
|
|
113
|
+
const element = document.createElement(entry.kind);
|
|
114
|
+
createdElements.add(element);
|
|
115
|
+
head.appendChild(element);
|
|
116
|
+
return element;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Drops a tag that repeats an identity already rendered, leaving tags owned under another
|
|
120
|
+
// key alone so a shared element is never removed by mistake.
|
|
121
|
+
function removeDuplicate(element, key) {
|
|
122
|
+
const owner = element.getAttribute(OWNER_ATTRIBUTE);
|
|
123
|
+
if (owner !== null && owner !== key) return;
|
|
124
|
+
element.remove();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Writes the entry onto its element and marks the element as owned, pruning attributes that
|
|
128
|
+
// are no longer part of the entry. A tag the head layer created is also marked, so a later
|
|
129
|
+
// page load can still tell it apart from one the app wrote and remove rather than restore it.
|
|
130
|
+
function writeTag(element, entry, key) {
|
|
131
|
+
snapshotTag(element, key);
|
|
132
|
+
element.setAttribute(OWNER_ATTRIBUTE, key);
|
|
133
|
+
if (createdElements.has(element)) element.setAttribute(CREATED_ATTRIBUTE, '');
|
|
134
|
+
if (entry.kind === 'title') {
|
|
135
|
+
element.textContent = entry.text;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
for (const attribute of [...element.attributes]) {
|
|
139
|
+
if (attribute.name === OWNER_ATTRIBUTE || attribute.name === CREATED_ATTRIBUTE) continue;
|
|
140
|
+
if (!Object.hasOwn(entry.attrs, attribute.name)) element.removeAttribute(attribute.name);
|
|
141
|
+
}
|
|
142
|
+
for (const [name, value] of Object.entries(entry.attrs)) element.setAttribute(name, value);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Remembers what a tag looked like before the head layer first touched it, so an app-owned
|
|
146
|
+
// tag can be handed back unchanged once no Head wants it any more. App-owned tags are also
|
|
147
|
+
// recorded under a global the prerenderer picks up, so the generated HTML can carry the
|
|
148
|
+
// original and a later page load can still hand the app's tag back.
|
|
149
|
+
function snapshotTag(element, key) {
|
|
150
|
+
if (snapshots.has(element)) return;
|
|
151
|
+
const attrs = new Map();
|
|
152
|
+
for (const attribute of element.attributes) {
|
|
153
|
+
if (attribute.name === OWNER_ATTRIBUTE || attribute.name === CREATED_ATTRIBUTE) continue;
|
|
154
|
+
attrs.set(attribute.name, attribute.value);
|
|
155
|
+
}
|
|
156
|
+
const snapshot = {
|
|
157
|
+
attrs: Object.fromEntries(attrs),
|
|
158
|
+
text: element.tagName === 'TITLE' ? element.textContent : null,
|
|
159
|
+
};
|
|
160
|
+
snapshots.set(element, snapshot);
|
|
161
|
+
if (createdElements.has(element) || element.hasAttribute(CREATED_ATTRIBUTE)) return;
|
|
162
|
+
// A record from an earlier prerender stays as it is, so a rerun over already generated
|
|
163
|
+
// output reproduces the same bytes instead of recording its own output as the original.
|
|
164
|
+
if (key in originals) return;
|
|
165
|
+
originals[key] = snapshot;
|
|
166
|
+
publishOriginals();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Mirrors the recorded originals into a global the prerenderer picks up, so the generated HTML
|
|
170
|
+
// can carry them and a later page load can hand the app's own tags back. Published once at
|
|
171
|
+
// module load as well, because a rerun must not lose records it did not add itself.
|
|
172
|
+
function publishOriginals() {
|
|
173
|
+
if (typeof window !== 'undefined') window.__snappyHeadOriginals = originals;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Reads the originals an earlier prerender baked into the page.
|
|
177
|
+
function readRecordedOriginals() {
|
|
178
|
+
const recorded = readMetadataDefaults().originals;
|
|
179
|
+
return recorded && typeof recorded === 'object' ? { ...recorded } : {};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Removes a tag the head layer created, including one generated in an earlier prerender, or
|
|
183
|
+
// restores an adopted tag to the state it was in before the head layer wrote to it, which
|
|
184
|
+
// hands the app's own metadata back.
|
|
185
|
+
function releaseTag(element, key) {
|
|
186
|
+
if (createdElements.has(element) || element.hasAttribute(CREATED_ATTRIBUTE)) {
|
|
187
|
+
element.remove();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
// A record baked into the page by a prerender beats this session's snapshot, because the
|
|
191
|
+
// markup already holds the head layer's output rather than the app's own value.
|
|
192
|
+
const restore = readRecordedOriginal(key) ?? snapshots.get(element) ?? null;
|
|
193
|
+
if (!restore) {
|
|
194
|
+
element.remove();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
applySnapshot(element, restore);
|
|
198
|
+
snapshots.delete(element);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Reads the original attributes a previous prerender recorded for a key.
|
|
202
|
+
function readRecordedOriginal(key) {
|
|
203
|
+
return key in originals ? originals[key] : null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Replaces every attribute and the text of a tag with a recorded snapshot.
|
|
207
|
+
function applySnapshot(element, snapshot) {
|
|
208
|
+
for (const name of [...element.attributes].map((attribute) => attribute.name)) {
|
|
209
|
+
element.removeAttribute(name);
|
|
210
|
+
}
|
|
211
|
+
for (const [name, value] of Object.entries(snapshot.attrs ?? {})) {
|
|
212
|
+
element.setAttribute(name, value);
|
|
213
|
+
}
|
|
214
|
+
if (snapshot.text !== null && snapshot.text !== undefined) element.textContent = snapshot.text;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Re-appends owned tags in merged order, so the emitted head is identical between runs
|
|
218
|
+
// regardless of the order in which components first rendered their tags.
|
|
219
|
+
function orderTags(head, merged) {
|
|
220
|
+
const order = merged.flatMap((entry) => findTags(head, headKey(entry)));
|
|
221
|
+
const owned = [...head.querySelectorAll(`[${OWNER_ATTRIBUTE}]`)];
|
|
222
|
+
const sameOrder =
|
|
223
|
+
owned.length === order.length && owned.every((element, index) => element === order[index]);
|
|
224
|
+
if (sameOrder) return;
|
|
225
|
+
for (const element of order) head.appendChild(element);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function cssEscape(value) {
|
|
229
|
+
return String(value).replace(/["\\]/g, '\\$&');
|
|
230
|
+
}
|
package/src/head/tags.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { collectBuildOrigins } from '../core/metadata-origin.js';
|
|
2
|
+
|
|
3
|
+
const ABSOLUTE = /^https?:\/\//i;
|
|
4
|
+
const PROTOCOL_RELATIVE = /^\/\//;
|
|
5
|
+
const URL_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
|
6
|
+
const SINGLETON_LINK_RELS = new Set(['canonical']);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Turns component props plus project defaults into the tag list the document head should
|
|
10
|
+
* hold: title, description, robots, canonical, Open Graph and Twitter card. Values the
|
|
11
|
+
* caller did not supply are simply absent, so the component never invents metadata the app
|
|
12
|
+
* did not ask for.
|
|
13
|
+
*/
|
|
14
|
+
export function buildHeadEntries(props = {}, defaults = {}) {
|
|
15
|
+
const entries = [];
|
|
16
|
+
const template = props.titleTemplate ?? defaults.titleTemplate ?? null;
|
|
17
|
+
const siteName = props.siteName ?? defaults.siteName ?? null;
|
|
18
|
+
const image = props.image ?? defaults.defaultImage ?? null;
|
|
19
|
+
|
|
20
|
+
const title = props.title ? applyTitleTemplate(props.title, template, siteName) : null;
|
|
21
|
+
if (title) entries.push({ kind: 'title', text: title });
|
|
22
|
+
if (title && props.openGraph !== false) entries.push(metaProp('og:title', title));
|
|
23
|
+
if (title && props.twitter !== false) entries.push(metaName('twitter:title', title));
|
|
24
|
+
|
|
25
|
+
if (props.description) {
|
|
26
|
+
entries.push(metaName('description', props.description));
|
|
27
|
+
if (props.openGraph !== false) entries.push(metaProp('og:description', props.description));
|
|
28
|
+
if (props.twitter !== false) entries.push(metaName('twitter:description', props.description));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (props.robots) entries.push(metaName('robots', props.robots));
|
|
32
|
+
|
|
33
|
+
if (props.openGraph !== false) {
|
|
34
|
+
entries.push(metaProp('og:type', props.type ?? 'website'));
|
|
35
|
+
if (siteName) entries.push(metaProp('og:site_name', siteName));
|
|
36
|
+
if (props.locale) entries.push(metaProp('og:locale', props.locale));
|
|
37
|
+
const url = pageUrl(props.canonical ?? currentPath(), defaults);
|
|
38
|
+
if (url) entries.push(metaProp('og:url', url));
|
|
39
|
+
if (image) entries.push(metaProp('og:image', absoluteUrl(image, defaults)));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (props.twitter !== false) {
|
|
43
|
+
entries.push(metaName('twitter:card', image ? 'summary_large_image' : 'summary'));
|
|
44
|
+
if (image) entries.push(metaName('twitter:image', absoluteUrl(image, defaults)));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (props.canonical !== false) {
|
|
48
|
+
const url = pageUrl(props.canonical ?? currentPath(), defaults);
|
|
49
|
+
if (url)
|
|
50
|
+
entries.push({ kind: 'link', key: 'rel:canonical', attrs: { rel: 'canonical', href: url } });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (const extra of props.extra ?? []) entries.push(extraEntry(extra));
|
|
54
|
+
|
|
55
|
+
return entries;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Merges every mounted Head into one tag list. Later entries win per tag key, and the
|
|
60
|
+
* render order recorded by each component decides "later", so a page's Head overrides the
|
|
61
|
+
* layout's Head rather than the other way around.
|
|
62
|
+
*/
|
|
63
|
+
export function mergeHeadEntries(stack = []) {
|
|
64
|
+
const merged = new Map();
|
|
65
|
+
for (const item of [...stack].sort((a, b) => a.sequence - b.sequence)) {
|
|
66
|
+
for (const entry of item.entries) merged.set(headKey(entry), entry);
|
|
67
|
+
}
|
|
68
|
+
const values = [...merged.values()];
|
|
69
|
+
const titles = values.filter((entry) => entry.kind === 'title');
|
|
70
|
+
const rest = values.filter((entry) => entry.kind !== 'title');
|
|
71
|
+
return [...titles, ...sortMetasFirst(rest)];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Identifies a tag by what a browser or crawler matches on, not by who wrote it, so an
|
|
76
|
+
* existing tag is updated in place rather than duplicated.
|
|
77
|
+
*/
|
|
78
|
+
export function headKey(entry) {
|
|
79
|
+
if (entry.kind === 'title') return 'title';
|
|
80
|
+
if (entry.kind === 'link') return `link:${entry.key ?? attrsKey(entry.attrs)}`;
|
|
81
|
+
if (entry.key) return `meta:${entry.key}`;
|
|
82
|
+
const name = attrsValue(entry.attrs, 'name');
|
|
83
|
+
if (name) return `meta:name:${name}`;
|
|
84
|
+
const property = attrsValue(entry.attrs, 'property');
|
|
85
|
+
if (property) return `meta:property:${property}`;
|
|
86
|
+
const httpEquiv = attrsValue(entry.attrs, 'http-equiv');
|
|
87
|
+
if (httpEquiv) return `meta:http-equiv:${httpEquiv}`;
|
|
88
|
+
return `meta:${attrsKey(entry.attrs)}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
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
|
+
// Applies the site's title template unless the title already carries its suffix or is the
|
|
100
|
+
// site name itself, so an app that composed its own title never gets it suffixed twice.
|
|
101
|
+
function applyTitleTemplate(title, template, siteName) {
|
|
102
|
+
if (!template || !template.includes('%s')) return title;
|
|
103
|
+
if (alreadyTemplated(title, template, siteName)) return title;
|
|
104
|
+
return template.replaceAll('%s', title);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Reports whether the title already ends with the template's fixed part, compared as a
|
|
108
|
+
// whole suffix rather than a substring, so a site name inside a title does not skip it.
|
|
109
|
+
function alreadyTemplated(title, template, siteName) {
|
|
110
|
+
if (siteName && title === siteName) return true;
|
|
111
|
+
const suffix = template.replaceAll('%s', '').trim();
|
|
112
|
+
return suffix.length > 0 && title.endsWith(suffix);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Resolves a metadata URL. Protocol-relative and non-http values stay as given, paths are
|
|
116
|
+
// resolved against the public site URL, and spaces are encoded so the value is a valid URL.
|
|
117
|
+
function absoluteUrl(value, defaults) {
|
|
118
|
+
if (!value) return null;
|
|
119
|
+
const raw = String(value);
|
|
120
|
+
if (ABSOLUTE.test(raw) || PROTOCOL_RELATIVE.test(raw) || URL_SCHEME.test(raw)) {
|
|
121
|
+
return encodeSpaces(raw);
|
|
122
|
+
}
|
|
123
|
+
const base = defaults.siteUrl ?? (typeof location === 'undefined' ? null : location.origin);
|
|
124
|
+
if (!base) return null;
|
|
125
|
+
const prefix = defaults.base && defaults.base !== '/' ? defaults.base.replace(/\/$/, '') : '';
|
|
126
|
+
const path = raw.startsWith('/') ? raw : `/${raw}`;
|
|
127
|
+
return encodeSpaces(`${base.replace(/\/$/, '')}${prefix}${path}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Resolves a page URL and applies the trailing slash convention to it. Asset URLs never go
|
|
131
|
+
// through this, because a file path must not gain or lose a slash.
|
|
132
|
+
function pageUrl(value, defaults) {
|
|
133
|
+
const url = absoluteUrl(value, defaults);
|
|
134
|
+
return url ? withTrailingSlash(url, defaults.trailingSlash) : null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Encodes space characters so a value pasted into metadata still serialises as a valid URL.
|
|
138
|
+
function encodeSpaces(url) {
|
|
139
|
+
return url.replace(/ /g, '%20');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Applies the site's trailing slash convention to a URL, leaving the root alone.
|
|
143
|
+
function withTrailingSlash(url, convention) {
|
|
144
|
+
if (convention !== 'always' && convention !== 'never') return url;
|
|
145
|
+
const [path, query] = url.split('?');
|
|
146
|
+
const isRoot = /^https?:\/\/[^/]+\/?$/i.test(path);
|
|
147
|
+
if (convention === 'always') {
|
|
148
|
+
const next = path.endsWith('/') ? path : `${path}/`;
|
|
149
|
+
return query ? `${next}?${query}` : next;
|
|
150
|
+
}
|
|
151
|
+
if (isRoot) return query ? `${path.replace(/\/$/, '/')}?${query}` : path.replace(/\/?$/, '/');
|
|
152
|
+
const next = path.replace(/\/+$/, '');
|
|
153
|
+
return query ? `${next}?${query}` : next;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Reads the route being rendered, which under prerendering is the page being captured.
|
|
157
|
+
function currentPath() {
|
|
158
|
+
return typeof location === 'undefined' ? '/' : location.pathname;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function metaName(name, content) {
|
|
162
|
+
return { kind: 'meta', key: `name:${name}`, attrs: { name, content } };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function metaProp(property, content) {
|
|
166
|
+
return { kind: 'meta', key: `property:${property}`, attrs: { property, content } };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Builds an entry from the caller's escape hatch for tags the model does not cover.
|
|
170
|
+
function extraEntry(extra) {
|
|
171
|
+
const attrs = { ...extra };
|
|
172
|
+
delete attrs.key;
|
|
173
|
+
const kind = attrs.rel && !attrs.content ? 'link' : 'meta';
|
|
174
|
+
const key = extra.key ?? (kind === 'link' ? linkKey(attrs) : identityKey(attrs));
|
|
175
|
+
return { kind, key, attrs };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Keys a meta tag the same way the model keys its own, so a later Head can override it
|
|
179
|
+
// instead of adding a duplicate.
|
|
180
|
+
function identityKey(attrs) {
|
|
181
|
+
if (attrs.name) return `name:${attrs.name}`;
|
|
182
|
+
if (attrs.property) return `property:${attrs.property}`;
|
|
183
|
+
if (attrs['http-equiv']) return `http-equiv:${attrs['http-equiv']}`;
|
|
184
|
+
return attrsKey(attrs);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Keys a link by rel, plus href for every rel a page may legitimately use more than once
|
|
188
|
+
// (preload, alternate, icon). Only singleton rels collapse to one tag per document.
|
|
189
|
+
function linkKey(attrs) {
|
|
190
|
+
const rel = String(attrs.rel);
|
|
191
|
+
if (SINGLETON_LINK_RELS.has(rel.toLowerCase()) || !attrs.href) return `rel:${rel}`;
|
|
192
|
+
return `rel:${rel}|${attrs.href}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function attrsKey(attrs) {
|
|
196
|
+
return Object.entries(attrs)
|
|
197
|
+
.map(([name, value]) => `${name}=${value}`)
|
|
198
|
+
.join('|');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function attrsValue(attrs = {}, name) {
|
|
202
|
+
return attrs[name];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Keeps output deterministic: title, then name/property metas, then links.
|
|
206
|
+
function sortMetasFirst(entries) {
|
|
207
|
+
return [...entries].sort((a, b) => {
|
|
208
|
+
const rank = (entry) => (entry.kind === 'meta' ? 0 : 1);
|
|
209
|
+
return rank(a) - rank(b);
|
|
210
|
+
});
|
|
211
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { prerender } from './core/run.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Decides whether a build should be prerendered at all. Skipped for SSR, library
|
|
6
|
+
* and non-writing builds, and for any environment other than the client one so a
|
|
7
|
+
* multi-environment build cannot run the prerenderer more than once.
|
|
8
|
+
*/
|
|
9
|
+
export function shouldPrerender(viteConfig, environmentName) {
|
|
10
|
+
if (!viteConfig) return false;
|
|
11
|
+
if (viteConfig.build.ssr || viteConfig.build.lib || viteConfig.build.write === false) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
if (environmentName && environmentName !== 'client') return false;
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Vite plugin wrapper around the prerenderer. Runs after the client build has been
|
|
20
|
+
* written, prerenders the output directory, and fails the build when rendering or
|
|
21
|
+
* hydration verification fails.
|
|
22
|
+
*/
|
|
23
|
+
export default function snappyPrerender(options = {}) {
|
|
24
|
+
let viteConfig = null;
|
|
25
|
+
let done = false;
|
|
26
|
+
return {
|
|
27
|
+
name: 'snappy-prerender',
|
|
28
|
+
apply: 'build',
|
|
29
|
+
configResolved(config) {
|
|
30
|
+
viteConfig = config;
|
|
31
|
+
},
|
|
32
|
+
async closeBundle() {
|
|
33
|
+
if (done || !shouldPrerender(viteConfig, this.environment?.name)) return;
|
|
34
|
+
done = true;
|
|
35
|
+
|
|
36
|
+
const report = await prerender({
|
|
37
|
+
sourceDir: path.resolve(viteConfig.root, viteConfig.build.outDir),
|
|
38
|
+
base: viteConfig.base,
|
|
39
|
+
...options,
|
|
40
|
+
});
|
|
41
|
+
if (!report.ok) {
|
|
42
|
+
const hydrationFailed = report.verification && !report.verification.ok;
|
|
43
|
+
throw new Error(
|
|
44
|
+
`snappy-prerender failed: ${report.errors.length} route error(s)${hydrationFailed ? ', hydration verification failed' : ''}`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|