@pyreon/head 0.18.0 → 0.19.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/lib/analysis/index.js.html +1 -1
- package/lib/analysis/use-head.js.html +1 -1
- package/lib/index.js +7 -0
- package/lib/types/index.d.ts +50 -1
- package/lib/types/use-head.d.ts +49 -0
- package/lib/use-head.js +7 -0
- package/package.json +7 -7
- package/src/context.ts +52 -0
- package/src/dom.ts +4 -0
- package/src/index.ts +3 -0
- package/src/manifest.ts +2 -0
- package/src/tests/head.browser.test.tsx +57 -0
- package/src/tests/head.test.ts +124 -0
- package/src/use-head.ts +8 -0
|
@@ -5386,7 +5386,7 @@ var drawChart = (function (exports) {
|
|
|
5386
5386
|
</script>
|
|
5387
5387
|
<script>
|
|
5388
5388
|
/*<!--*/
|
|
5389
|
-
const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src","children":[{"uid":"
|
|
5389
|
+
const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src","children":[{"uid":"c3ae39c2-1","name":"context.ts"},{"uid":"c3ae39c2-3","name":"provider.ts"},{"uid":"c3ae39c2-5","name":"dom.ts"},{"uid":"c3ae39c2-7","name":"use-head.ts"},{"uid":"c3ae39c2-9","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"c3ae39c2-1":{"renderedLength":1373,"gzipLength":509,"brotliLength":0,"metaUid":"c3ae39c2-0"},"c3ae39c2-3":{"renderedLength":681,"gzipLength":408,"brotliLength":0,"metaUid":"c3ae39c2-2"},"c3ae39c2-5":{"renderedLength":3447,"gzipLength":1292,"brotliLength":0,"metaUid":"c3ae39c2-4"},"c3ae39c2-7":{"renderedLength":2634,"gzipLength":1093,"brotliLength":0,"metaUid":"c3ae39c2-6"},"c3ae39c2-9":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"c3ae39c2-8"}},"nodeMetas":{"c3ae39c2-0":{"id":"/src/context.ts","moduleParts":{"index.js":"c3ae39c2-1"},"imported":[{"uid":"c3ae39c2-10"}],"importedBy":[{"uid":"c3ae39c2-8"},{"uid":"c3ae39c2-2"},{"uid":"c3ae39c2-6"}]},"c3ae39c2-2":{"id":"/src/provider.ts","moduleParts":{"index.js":"c3ae39c2-3"},"imported":[{"uid":"c3ae39c2-10"},{"uid":"c3ae39c2-0"}],"importedBy":[{"uid":"c3ae39c2-8"}]},"c3ae39c2-4":{"id":"/src/dom.ts","moduleParts":{"index.js":"c3ae39c2-5"},"imported":[],"importedBy":[{"uid":"c3ae39c2-6"}]},"c3ae39c2-6":{"id":"/src/use-head.ts","moduleParts":{"index.js":"c3ae39c2-7"},"imported":[{"uid":"c3ae39c2-10"},{"uid":"c3ae39c2-11"},{"uid":"c3ae39c2-0"},{"uid":"c3ae39c2-4"}],"importedBy":[{"uid":"c3ae39c2-8"}]},"c3ae39c2-8":{"id":"/src/index.ts","moduleParts":{"index.js":"c3ae39c2-9"},"imported":[{"uid":"c3ae39c2-0"},{"uid":"c3ae39c2-2"},{"uid":"c3ae39c2-6"}],"importedBy":[],"isEntry":true},"c3ae39c2-10":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"c3ae39c2-0"},{"uid":"c3ae39c2-2"},{"uid":"c3ae39c2-6"}]},"c3ae39c2-11":{"id":"@pyreon/reactivity","moduleParts":{},"imported":[],"importedBy":[{"uid":"c3ae39c2-6"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
|
|
5390
5390
|
|
|
5391
5391
|
const run = () => {
|
|
5392
5392
|
const width = window.innerWidth;
|
|
@@ -5386,7 +5386,7 @@ var drawChart = (function (exports) {
|
|
|
5386
5386
|
</script>
|
|
5387
5387
|
<script>
|
|
5388
5388
|
/*<!--*/
|
|
5389
|
-
const data = {"version":2,"tree":{"name":"root","children":[{"name":"use-head.js","children":[{"name":"src","children":[{"uid":"
|
|
5389
|
+
const data = {"version":2,"tree":{"name":"root","children":[{"name":"use-head.js","children":[{"name":"src","children":[{"uid":"a2d4ff75-1","name":"context.ts"},{"uid":"a2d4ff75-3","name":"dom.ts"},{"uid":"a2d4ff75-5","name":"use-head.ts"}]}]}],"isRoot":true},"nodeParts":{"a2d4ff75-1":{"renderedLength":79,"gzipLength":85,"brotliLength":0,"metaUid":"a2d4ff75-0"},"a2d4ff75-3":{"renderedLength":3447,"gzipLength":1292,"brotliLength":0,"metaUid":"a2d4ff75-2"},"a2d4ff75-5":{"renderedLength":2634,"gzipLength":1093,"brotliLength":0,"metaUid":"a2d4ff75-4"}},"nodeMetas":{"a2d4ff75-0":{"id":"/src/context.ts","moduleParts":{"use-head.js":"a2d4ff75-1"},"imported":[{"uid":"a2d4ff75-6"}],"importedBy":[{"uid":"a2d4ff75-4"}]},"a2d4ff75-2":{"id":"/src/dom.ts","moduleParts":{"use-head.js":"a2d4ff75-3"},"imported":[],"importedBy":[{"uid":"a2d4ff75-4"}]},"a2d4ff75-4":{"id":"/src/use-head.ts","moduleParts":{"use-head.js":"a2d4ff75-5"},"imported":[{"uid":"a2d4ff75-6"},{"uid":"a2d4ff75-7"},{"uid":"a2d4ff75-0"},{"uid":"a2d4ff75-2"}],"importedBy":[],"isEntry":true},"a2d4ff75-6":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"a2d4ff75-4"},{"uid":"a2d4ff75-0"}]},"a2d4ff75-7":{"id":"@pyreon/reactivity","moduleParts":{},"imported":[],"importedBy":[{"uid":"a2d4ff75-4"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
|
|
5390
5390
|
|
|
5391
5391
|
const run = () => {
|
|
5392
5392
|
const width = window.innerWidth;
|
package/lib/index.js
CHANGED
|
@@ -100,6 +100,7 @@ function patchExistingTag(found, tag, kept) {
|
|
|
100
100
|
if (found.textContent !== content) found.textContent = content;
|
|
101
101
|
}
|
|
102
102
|
function createNewTag(tag) {
|
|
103
|
+
if (typeof document === "undefined") return;
|
|
103
104
|
const el = document.createElement(tag.tag);
|
|
104
105
|
const key = tag.key;
|
|
105
106
|
el.setAttribute(ATTR, key);
|
|
@@ -238,6 +239,12 @@ function buildEntry(o) {
|
|
|
238
239
|
props: { type: "application/ld+json" },
|
|
239
240
|
children: JSON.stringify(o.jsonLd)
|
|
240
241
|
});
|
|
242
|
+
if (o.speculationRules) tags.push({
|
|
243
|
+
tag: "script",
|
|
244
|
+
key: "speculationrules",
|
|
245
|
+
props: { type: "speculationrules" },
|
|
246
|
+
children: JSON.stringify(o.speculationRules)
|
|
247
|
+
});
|
|
241
248
|
if (o.base) tags.push({
|
|
242
249
|
tag: "base",
|
|
243
250
|
key: "base",
|
package/lib/types/index.d.ts
CHANGED
|
@@ -104,6 +104,48 @@ interface StyleTag {
|
|
|
104
104
|
/** Render-blocking behavior */
|
|
105
105
|
blocking?: string;
|
|
106
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* How eagerly the browser should act on a speculation rule.
|
|
109
|
+
* Per the W3C Speculation Rules spec.
|
|
110
|
+
*/
|
|
111
|
+
type SpeculationEagerness = 'immediate' | 'eager' | 'moderate' | 'conservative';
|
|
112
|
+
/**
|
|
113
|
+
* A single speculation rule (one entry in a `prefetch` / `prerender` list).
|
|
114
|
+
*
|
|
115
|
+
* - `source: 'list'` + `urls` — prefetch/prerender these explicit URLs.
|
|
116
|
+
* - `source: 'document'` + `where` — let the browser pick links from the
|
|
117
|
+
* current document that match the predicate (e.g. a CSS selector via
|
|
118
|
+
* `{ selector_matches: '.router-link' }`).
|
|
119
|
+
*/
|
|
120
|
+
interface SpeculationRule {
|
|
121
|
+
/** `'list'` (explicit `urls`) or `'document'` (predicate-driven). */
|
|
122
|
+
source?: 'list' | 'document';
|
|
123
|
+
/** Same-origin URLs to prefetch/prerender (for `source: 'list'`). */
|
|
124
|
+
urls?: string[];
|
|
125
|
+
/** Document predicate (for `source: 'document'`) — e.g. `{ selector_matches: 'a.next' }`. */
|
|
126
|
+
where?: Record<string, unknown>;
|
|
127
|
+
/** When the browser should fetch — defaults to the browser's per-source default. */
|
|
128
|
+
eagerness?: SpeculationEagerness;
|
|
129
|
+
/** Capability requirements, e.g. `['anonymous-client-ip-when-cross-origin']`. */
|
|
130
|
+
requires?: string[];
|
|
131
|
+
/** Referrer policy for the speculative request. */
|
|
132
|
+
referrer_policy?: string;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Declarative Speculation Rules — emitted as a single
|
|
136
|
+
* `<script type="speculationrules">` tag. Supported browsers prefetch or
|
|
137
|
+
* fully prerender the next document(s) so navigation is instant. Inert in
|
|
138
|
+
* non-supporting browsers (no polyfill needed). Opt-in: only emitted when
|
|
139
|
+
* `useHead({ speculationRules })` is called.
|
|
140
|
+
*
|
|
141
|
+
* @see https://developer.mozilla.org/docs/Web/API/Speculation_Rules_API
|
|
142
|
+
*/
|
|
143
|
+
interface SpeculationRules {
|
|
144
|
+
/** Lightweight: fetch the response, no rendering. */
|
|
145
|
+
prefetch?: SpeculationRule[];
|
|
146
|
+
/** Heavy: fully render the next document in the background. */
|
|
147
|
+
prerender?: SpeculationRule[];
|
|
148
|
+
}
|
|
107
149
|
/** Standard `<base>` tag attributes. */
|
|
108
150
|
interface BaseTag {
|
|
109
151
|
/** Base URL for relative URLs in the document */
|
|
@@ -128,6 +170,13 @@ interface UseHeadInput {
|
|
|
128
170
|
}[];
|
|
129
171
|
/** Convenience: emits a <script type="application/ld+json"> tag with JSON.stringify'd content */
|
|
130
172
|
jsonLd?: Record<string, unknown> | Record<string, unknown>[];
|
|
173
|
+
/**
|
|
174
|
+
* Convenience: emits a `<script type="speculationrules">` tag with the
|
|
175
|
+
* JSON.stringify'd rules. Supported browsers prefetch/prerender the next
|
|
176
|
+
* document(s) for near-instant navigation; inert elsewhere. Opt-in.
|
|
177
|
+
* @example useHead({ speculationRules: { prerender: [{ source: 'list', urls: ['/about'], eagerness: 'moderate' }] } })
|
|
178
|
+
*/
|
|
179
|
+
speculationRules?: SpeculationRules;
|
|
131
180
|
base?: BaseTag;
|
|
132
181
|
/** Attributes to set on the <html> element (e.g. { lang: "en", dir: "ltr" }) */
|
|
133
182
|
htmlAttrs?: Record<string, string>;
|
|
@@ -190,5 +239,5 @@ declare const HeadProvider: ComponentFn<HeadProviderProps>;
|
|
|
190
239
|
*/
|
|
191
240
|
declare function useHead(input: UseHeadInput | (() => UseHeadInput)): void;
|
|
192
241
|
//#endregion
|
|
193
|
-
export { type BaseTag, HeadContext, type HeadContextValue, type HeadEntry, HeadProvider, type HeadProviderProps, type HeadTag, type LinkTag, type MetaTag, type ScriptTag, type StyleTag, type UseHeadInput, createHeadContext, useHead };
|
|
242
|
+
export { type BaseTag, HeadContext, type HeadContextValue, type HeadEntry, HeadProvider, type HeadProviderProps, type HeadTag, type LinkTag, type MetaTag, type ScriptTag, type SpeculationEagerness, type SpeculationRule, type SpeculationRules, type StyleTag, type UseHeadInput, createHeadContext, useHead };
|
|
194
243
|
//# sourceMappingURL=index2.d.ts.map
|
package/lib/types/use-head.d.ts
CHANGED
|
@@ -87,6 +87,48 @@ interface StyleTag {
|
|
|
87
87
|
/** Render-blocking behavior */
|
|
88
88
|
blocking?: string;
|
|
89
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* How eagerly the browser should act on a speculation rule.
|
|
92
|
+
* Per the W3C Speculation Rules spec.
|
|
93
|
+
*/
|
|
94
|
+
type SpeculationEagerness = 'immediate' | 'eager' | 'moderate' | 'conservative';
|
|
95
|
+
/**
|
|
96
|
+
* A single speculation rule (one entry in a `prefetch` / `prerender` list).
|
|
97
|
+
*
|
|
98
|
+
* - `source: 'list'` + `urls` — prefetch/prerender these explicit URLs.
|
|
99
|
+
* - `source: 'document'` + `where` — let the browser pick links from the
|
|
100
|
+
* current document that match the predicate (e.g. a CSS selector via
|
|
101
|
+
* `{ selector_matches: '.router-link' }`).
|
|
102
|
+
*/
|
|
103
|
+
interface SpeculationRule {
|
|
104
|
+
/** `'list'` (explicit `urls`) or `'document'` (predicate-driven). */
|
|
105
|
+
source?: 'list' | 'document';
|
|
106
|
+
/** Same-origin URLs to prefetch/prerender (for `source: 'list'`). */
|
|
107
|
+
urls?: string[];
|
|
108
|
+
/** Document predicate (for `source: 'document'`) — e.g. `{ selector_matches: 'a.next' }`. */
|
|
109
|
+
where?: Record<string, unknown>;
|
|
110
|
+
/** When the browser should fetch — defaults to the browser's per-source default. */
|
|
111
|
+
eagerness?: SpeculationEagerness;
|
|
112
|
+
/** Capability requirements, e.g. `['anonymous-client-ip-when-cross-origin']`. */
|
|
113
|
+
requires?: string[];
|
|
114
|
+
/** Referrer policy for the speculative request. */
|
|
115
|
+
referrer_policy?: string;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Declarative Speculation Rules — emitted as a single
|
|
119
|
+
* `<script type="speculationrules">` tag. Supported browsers prefetch or
|
|
120
|
+
* fully prerender the next document(s) so navigation is instant. Inert in
|
|
121
|
+
* non-supporting browsers (no polyfill needed). Opt-in: only emitted when
|
|
122
|
+
* `useHead({ speculationRules })` is called.
|
|
123
|
+
*
|
|
124
|
+
* @see https://developer.mozilla.org/docs/Web/API/Speculation_Rules_API
|
|
125
|
+
*/
|
|
126
|
+
interface SpeculationRules {
|
|
127
|
+
/** Lightweight: fetch the response, no rendering. */
|
|
128
|
+
prefetch?: SpeculationRule[];
|
|
129
|
+
/** Heavy: fully render the next document in the background. */
|
|
130
|
+
prerender?: SpeculationRule[];
|
|
131
|
+
}
|
|
90
132
|
/** Standard `<base>` tag attributes. */
|
|
91
133
|
interface BaseTag {
|
|
92
134
|
/** Base URL for relative URLs in the document */
|
|
@@ -111,6 +153,13 @@ interface UseHeadInput {
|
|
|
111
153
|
}[];
|
|
112
154
|
/** Convenience: emits a <script type="application/ld+json"> tag with JSON.stringify'd content */
|
|
113
155
|
jsonLd?: Record<string, unknown> | Record<string, unknown>[];
|
|
156
|
+
/**
|
|
157
|
+
* Convenience: emits a `<script type="speculationrules">` tag with the
|
|
158
|
+
* JSON.stringify'd rules. Supported browsers prefetch/prerender the next
|
|
159
|
+
* document(s) for near-instant navigation; inert elsewhere. Opt-in.
|
|
160
|
+
* @example useHead({ speculationRules: { prerender: [{ source: 'list', urls: ['/about'], eagerness: 'moderate' }] } })
|
|
161
|
+
*/
|
|
162
|
+
speculationRules?: SpeculationRules;
|
|
114
163
|
base?: BaseTag;
|
|
115
164
|
/** Attributes to set on the <html> element (e.g. { lang: "en", dir: "ltr" }) */
|
|
116
165
|
htmlAttrs?: Record<string, string>;
|
package/lib/use-head.js
CHANGED
|
@@ -23,6 +23,7 @@ function patchExistingTag(found, tag, kept) {
|
|
|
23
23
|
if (found.textContent !== content) found.textContent = content;
|
|
24
24
|
}
|
|
25
25
|
function createNewTag(tag) {
|
|
26
|
+
if (typeof document === "undefined") return;
|
|
26
27
|
const el = document.createElement(tag.tag);
|
|
27
28
|
const key = tag.key;
|
|
28
29
|
el.setAttribute(ATTR, key);
|
|
@@ -161,6 +162,12 @@ function buildEntry(o) {
|
|
|
161
162
|
props: { type: "application/ld+json" },
|
|
162
163
|
children: JSON.stringify(o.jsonLd)
|
|
163
164
|
});
|
|
165
|
+
if (o.speculationRules) tags.push({
|
|
166
|
+
tag: "script",
|
|
167
|
+
key: "speculationrules",
|
|
168
|
+
props: { type: "speculationrules" },
|
|
169
|
+
children: JSON.stringify(o.speculationRules)
|
|
170
|
+
});
|
|
164
171
|
if (o.base) tags.push({
|
|
165
172
|
tag: "base",
|
|
166
173
|
key: "base",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/head",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Head tag management for Pyreon — works in SSR and CSR",
|
|
5
5
|
"homepage": "https://github.com/pyreon/pyreon/tree/main/packages/head#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -59,16 +59,16 @@
|
|
|
59
59
|
"prepublishOnly": "bun run build"
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"@pyreon/core": "^0.
|
|
63
|
-
"@pyreon/reactivity": "^0.
|
|
64
|
-
"@pyreon/runtime-server": "^0.
|
|
62
|
+
"@pyreon/core": "^0.19.0",
|
|
63
|
+
"@pyreon/reactivity": "^0.19.0",
|
|
64
|
+
"@pyreon/runtime-server": "^0.19.0"
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
67
67
|
"@happy-dom/global-registrator": "^20.8.9",
|
|
68
68
|
"@pyreon/manifest": "0.13.1",
|
|
69
|
-
"@pyreon/runtime-dom": "^0.
|
|
70
|
-
"@pyreon/runtime-server": "^0.
|
|
71
|
-
"@pyreon/test-utils": "^0.13.
|
|
69
|
+
"@pyreon/runtime-dom": "^0.19.0",
|
|
70
|
+
"@pyreon/runtime-server": "^0.19.0",
|
|
71
|
+
"@pyreon/test-utils": "^0.13.6",
|
|
72
72
|
"@vitest/browser-playwright": "^4.1.4"
|
|
73
73
|
},
|
|
74
74
|
"peerDependenciesMeta": {
|
package/src/context.ts
CHANGED
|
@@ -111,6 +111,51 @@ export interface StyleTag {
|
|
|
111
111
|
blocking?: string
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* How eagerly the browser should act on a speculation rule.
|
|
116
|
+
* Per the W3C Speculation Rules spec.
|
|
117
|
+
*/
|
|
118
|
+
export type SpeculationEagerness = 'immediate' | 'eager' | 'moderate' | 'conservative'
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A single speculation rule (one entry in a `prefetch` / `prerender` list).
|
|
122
|
+
*
|
|
123
|
+
* - `source: 'list'` + `urls` — prefetch/prerender these explicit URLs.
|
|
124
|
+
* - `source: 'document'` + `where` — let the browser pick links from the
|
|
125
|
+
* current document that match the predicate (e.g. a CSS selector via
|
|
126
|
+
* `{ selector_matches: '.router-link' }`).
|
|
127
|
+
*/
|
|
128
|
+
export interface SpeculationRule {
|
|
129
|
+
/** `'list'` (explicit `urls`) or `'document'` (predicate-driven). */
|
|
130
|
+
source?: 'list' | 'document'
|
|
131
|
+
/** Same-origin URLs to prefetch/prerender (for `source: 'list'`). */
|
|
132
|
+
urls?: string[]
|
|
133
|
+
/** Document predicate (for `source: 'document'`) — e.g. `{ selector_matches: 'a.next' }`. */
|
|
134
|
+
where?: Record<string, unknown>
|
|
135
|
+
/** When the browser should fetch — defaults to the browser's per-source default. */
|
|
136
|
+
eagerness?: SpeculationEagerness
|
|
137
|
+
/** Capability requirements, e.g. `['anonymous-client-ip-when-cross-origin']`. */
|
|
138
|
+
requires?: string[]
|
|
139
|
+
/** Referrer policy for the speculative request. */
|
|
140
|
+
referrer_policy?: string
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Declarative Speculation Rules — emitted as a single
|
|
145
|
+
* `<script type="speculationrules">` tag. Supported browsers prefetch or
|
|
146
|
+
* fully prerender the next document(s) so navigation is instant. Inert in
|
|
147
|
+
* non-supporting browsers (no polyfill needed). Opt-in: only emitted when
|
|
148
|
+
* `useHead({ speculationRules })` is called.
|
|
149
|
+
*
|
|
150
|
+
* @see https://developer.mozilla.org/docs/Web/API/Speculation_Rules_API
|
|
151
|
+
*/
|
|
152
|
+
export interface SpeculationRules {
|
|
153
|
+
/** Lightweight: fetch the response, no rendering. */
|
|
154
|
+
prefetch?: SpeculationRule[]
|
|
155
|
+
/** Heavy: fully render the next document in the background. */
|
|
156
|
+
prerender?: SpeculationRule[]
|
|
157
|
+
}
|
|
158
|
+
|
|
114
159
|
/** Standard `<base>` tag attributes. */
|
|
115
160
|
export interface BaseTag {
|
|
116
161
|
/** Base URL for relative URLs in the document */
|
|
@@ -134,6 +179,13 @@ export interface UseHeadInput {
|
|
|
134
179
|
noscript?: { children: string }[]
|
|
135
180
|
/** Convenience: emits a <script type="application/ld+json"> tag with JSON.stringify'd content */
|
|
136
181
|
jsonLd?: Record<string, unknown> | Record<string, unknown>[]
|
|
182
|
+
/**
|
|
183
|
+
* Convenience: emits a `<script type="speculationrules">` tag with the
|
|
184
|
+
* JSON.stringify'd rules. Supported browsers prefetch/prerender the next
|
|
185
|
+
* document(s) for near-instant navigation; inert elsewhere. Opt-in.
|
|
186
|
+
* @example useHead({ speculationRules: { prerender: [{ source: 'list', urls: ['/about'], eagerness: 'moderate' }] } })
|
|
187
|
+
*/
|
|
188
|
+
speculationRules?: SpeculationRules
|
|
137
189
|
base?: BaseTag
|
|
138
190
|
/** Attributes to set on the <html> element (e.g. { lang: "en", dir: "ltr" }) */
|
|
139
191
|
htmlAttrs?: Record<string, string>
|
package/src/dom.ts
CHANGED
|
@@ -29,6 +29,10 @@ function createNewTag(tag: {
|
|
|
29
29
|
children: string
|
|
30
30
|
key: unknown
|
|
31
31
|
}): void {
|
|
32
|
+
// SSR guard: only ever reached via the (also-guarded) `syncDom`, but
|
|
33
|
+
// keep the guard local so the contract is self-evident and SSR-safe
|
|
34
|
+
// even if a future caller invokes this directly.
|
|
35
|
+
if (typeof document === 'undefined') return
|
|
32
36
|
const el = document.createElement(tag.tag)
|
|
33
37
|
const key = tag.key as string
|
|
34
38
|
el.setAttribute(ATTR, key)
|
package/src/index.ts
CHANGED
package/src/manifest.ts
CHANGED
|
@@ -16,6 +16,7 @@ export default defineManifest({
|
|
|
16
16
|
'renderWithHead() for SSR — returns html + head string',
|
|
17
17
|
'Keyed deduplication — innermost component wins per key',
|
|
18
18
|
'JSON-LD shorthand: `jsonLd: {...}` auto-wraps as `<script type="application/ld+json">`',
|
|
19
|
+
'Speculation Rules shorthand: `speculationRules: {...}` auto-wraps as `<script type="speculationrules">` — native browser prefetch/prerender, opt-in',
|
|
19
20
|
],
|
|
20
21
|
longExample: `import { useHead, HeadProvider } from '@pyreon/head'
|
|
21
22
|
import { renderWithHead } from '@pyreon/head'
|
|
@@ -71,6 +72,7 @@ useHead(() => ({
|
|
|
71
72
|
'Calling `useHead()` outside any `HeadProvider` / `renderWithHead()` boundary — silent no-op, the entries simply go nowhere',
|
|
72
73
|
'Wrapping the input in `computed()` instead of a thunk — pass a plain `() => ({...})` arrow; `useHead` registers its own effect',
|
|
73
74
|
'Expecting `</script>` inside an inline script body to render verbatim — the SSR escaper rewrites it as `<\\/script>` to prevent breaking out of the inline tag',
|
|
75
|
+
'Treating `speculationRules` as a guaranteed perf win — it is a declarative HINT (like `<link rel=prefetch>`); supported browsers prefetch/prerender at their own discretion, unsupported ones ignore it. It is opt-in and zero-runtime-JS; it does not replace `RouterLink prefetch` (which warms loader data for client-side nav)',
|
|
74
76
|
],
|
|
75
77
|
seeAlso: ['HeadProvider', 'renderWithHead'],
|
|
76
78
|
},
|
|
@@ -191,4 +191,61 @@ describe('head in real browser', () => {
|
|
|
191
191
|
expect(s?.defer).toBe(true)
|
|
192
192
|
unmount()
|
|
193
193
|
})
|
|
194
|
+
|
|
195
|
+
// E12 — Speculation Rules. Kill-criterion #2: real Chromium must PARSE
|
|
196
|
+
// and ACCEPT the emitted block. happy-dom can't validate this — only a
|
|
197
|
+
// real browser runs the Speculation Rules parser and emits a console
|
|
198
|
+
// error on a malformed block. We assert: (a) the script lands in <head>
|
|
199
|
+
// with the exact type, (b) its body is valid JSON, (c) the browser
|
|
200
|
+
// raises NO "speculation rules" parse error, (d) HTMLScriptElement
|
|
201
|
+
// recognises the type. Whether Chromium then prefetches/prerenders is
|
|
202
|
+
// browser-discretionary (heuristic + headless-flag dependent) and is
|
|
203
|
+
// intentionally NOT asserted — the framework's contract is "emit a
|
|
204
|
+
// correct, valid declarative hint", same as `<link rel=prefetch>`.
|
|
205
|
+
it('emits a real <script type="speculationrules"> Chromium parses without error', () => {
|
|
206
|
+
const specErrors: string[] = []
|
|
207
|
+
const origErr = console.error
|
|
208
|
+
console.error = (...a: unknown[]) => {
|
|
209
|
+
const msg = a.map(String).join(' ')
|
|
210
|
+
if (/speculation\s*rules/i.test(msg)) specErrors.push(msg)
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
const { unmount } = mountInBrowser(
|
|
214
|
+
h(
|
|
215
|
+
HeadProvider,
|
|
216
|
+
null,
|
|
217
|
+
h(Page, {
|
|
218
|
+
setup: () =>
|
|
219
|
+
useHead({
|
|
220
|
+
speculationRules: {
|
|
221
|
+
prefetch: [
|
|
222
|
+
{
|
|
223
|
+
source: 'document',
|
|
224
|
+
where: { selector_matches: 'a[data-spec]' },
|
|
225
|
+
eagerness: 'moderate',
|
|
226
|
+
},
|
|
227
|
+
],
|
|
228
|
+
prerender: [{ source: 'list', urls: ['/about'], eagerness: 'conservative' }],
|
|
229
|
+
},
|
|
230
|
+
}),
|
|
231
|
+
}),
|
|
232
|
+
),
|
|
233
|
+
)
|
|
234
|
+
const el = document.head.querySelector<HTMLScriptElement>(
|
|
235
|
+
'script[type="speculationrules"]',
|
|
236
|
+
)
|
|
237
|
+
expect(el).not.toBeNull()
|
|
238
|
+
// (b) body is valid JSON and round-trips.
|
|
239
|
+
const parsed = JSON.parse(el?.textContent ?? '')
|
|
240
|
+
expect(parsed.prerender[0].urls).toEqual(['/about'])
|
|
241
|
+
expect(parsed.prefetch[0].where).toEqual({ selector_matches: 'a[data-spec]' })
|
|
242
|
+
// (d) real HTMLScriptElement carries the exact type the spec requires.
|
|
243
|
+
expect(el?.type).toBe('speculationrules')
|
|
244
|
+
// (c) Chromium parsed it WITHOUT raising a speculation-rules error.
|
|
245
|
+
expect(specErrors).toEqual([])
|
|
246
|
+
unmount()
|
|
247
|
+
} finally {
|
|
248
|
+
console.error = origErr
|
|
249
|
+
}
|
|
250
|
+
})
|
|
194
251
|
})
|
package/src/tests/head.test.ts
CHANGED
|
@@ -1187,3 +1187,127 @@ describe('useHead — SSR paths (document undefined)', () => {
|
|
|
1187
1187
|
expect(typeof document).toBe('undefined')
|
|
1188
1188
|
})
|
|
1189
1189
|
})
|
|
1190
|
+
|
|
1191
|
+
// ─── speculationRules (E12) ──────────────────────────────────────────────────
|
|
1192
|
+
|
|
1193
|
+
describe('useHead — speculationRules', () => {
|
|
1194
|
+
test('SSR: emits a single <script type="speculationrules"> with valid JSON body', async () => {
|
|
1195
|
+
function Page() {
|
|
1196
|
+
useHead({
|
|
1197
|
+
speculationRules: {
|
|
1198
|
+
prerender: [{ source: 'list', urls: ['/about', '/posts'], eagerness: 'moderate' }],
|
|
1199
|
+
},
|
|
1200
|
+
})
|
|
1201
|
+
return h('div', null)
|
|
1202
|
+
}
|
|
1203
|
+
const { head } = await renderWithHead(h(Page, null))
|
|
1204
|
+
expect(head).toContain('type="speculationrules"')
|
|
1205
|
+
// Body must be valid JSON parseable back to the exact rules.
|
|
1206
|
+
const m = head.match(/<script type="speculationrules">([\s\S]*?)<\/script>/)
|
|
1207
|
+
expect(m).not.toBeNull()
|
|
1208
|
+
const parsed = JSON.parse((m as RegExpMatchArray)[1] as string)
|
|
1209
|
+
expect(parsed).toEqual({
|
|
1210
|
+
prerender: [{ source: 'list', urls: ['/about', '/posts'], eagerness: 'moderate' }],
|
|
1211
|
+
})
|
|
1212
|
+
// Exactly one block — no accidental duplicate.
|
|
1213
|
+
expect(head.match(/type="speculationrules"/g)).toHaveLength(1)
|
|
1214
|
+
})
|
|
1215
|
+
|
|
1216
|
+
test('CSR: syncs the speculationrules script into document.head on mount', () => {
|
|
1217
|
+
const ctx = createHeadContext()
|
|
1218
|
+
const container = document.createElement('div')
|
|
1219
|
+
function Page() {
|
|
1220
|
+
useHead({ speculationRules: { prefetch: [{ source: 'list', urls: ['/next'] }] } })
|
|
1221
|
+
return h('div', null)
|
|
1222
|
+
}
|
|
1223
|
+
mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
|
|
1224
|
+
const script = document.head.querySelector('script[type="speculationrules"]')
|
|
1225
|
+
expect(script).not.toBeNull()
|
|
1226
|
+
expect(JSON.parse(script?.textContent ?? '{}')).toEqual({
|
|
1227
|
+
prefetch: [{ source: 'list', urls: ['/next'] }],
|
|
1228
|
+
})
|
|
1229
|
+
})
|
|
1230
|
+
|
|
1231
|
+
test('deduped by key — innermost component wins, never two blocks', async () => {
|
|
1232
|
+
function Inner() {
|
|
1233
|
+
useHead({ speculationRules: { prerender: [{ source: 'list', urls: ['/inner'] }] } })
|
|
1234
|
+
return h('div', null)
|
|
1235
|
+
}
|
|
1236
|
+
function Outer() {
|
|
1237
|
+
useHead({ speculationRules: { prerender: [{ source: 'list', urls: ['/outer'] }] } })
|
|
1238
|
+
return h('div', null, h(Inner, null))
|
|
1239
|
+
}
|
|
1240
|
+
const { head } = await renderWithHead(h(Outer, null))
|
|
1241
|
+
expect(head.match(/type="speculationrules"/g)).toHaveLength(1)
|
|
1242
|
+
expect(head).toContain('/inner')
|
|
1243
|
+
expect(head).not.toContain('/outer')
|
|
1244
|
+
})
|
|
1245
|
+
|
|
1246
|
+
test('reactive: regenerates when the source signal changes', () => {
|
|
1247
|
+
const ctx = createHeadContext()
|
|
1248
|
+
const container = document.createElement('div')
|
|
1249
|
+
const target = signal('/a')
|
|
1250
|
+
function Page() {
|
|
1251
|
+
useHead(() => ({
|
|
1252
|
+
speculationRules: { prerender: [{ source: 'list', urls: [target()] }] },
|
|
1253
|
+
}))
|
|
1254
|
+
return h('div', null)
|
|
1255
|
+
}
|
|
1256
|
+
mount(h(HeadProvider, { context: ctx, children: h(Page, null) }), container)
|
|
1257
|
+
expect(document.head.querySelector('script[type="speculationrules"]')?.textContent).toContain(
|
|
1258
|
+
'/a',
|
|
1259
|
+
)
|
|
1260
|
+
target.set('/b')
|
|
1261
|
+
expect(document.head.querySelector('script[type="speculationrules"]')?.textContent).toContain(
|
|
1262
|
+
'/b',
|
|
1263
|
+
)
|
|
1264
|
+
})
|
|
1265
|
+
|
|
1266
|
+
test('document-source predicate rules round-trip', async () => {
|
|
1267
|
+
function Page() {
|
|
1268
|
+
useHead({
|
|
1269
|
+
speculationRules: {
|
|
1270
|
+
prefetch: [
|
|
1271
|
+
{
|
|
1272
|
+
source: 'document',
|
|
1273
|
+
where: { selector_matches: 'a.router-link' },
|
|
1274
|
+
eagerness: 'conservative',
|
|
1275
|
+
},
|
|
1276
|
+
],
|
|
1277
|
+
},
|
|
1278
|
+
})
|
|
1279
|
+
return h('div', null)
|
|
1280
|
+
}
|
|
1281
|
+
const { head } = await renderWithHead(h(Page, null))
|
|
1282
|
+
const m = head.match(/<script type="speculationrules">([\s\S]*?)<\/script>/)
|
|
1283
|
+
expect(JSON.parse((m as RegExpMatchArray)[1] as string).prefetch[0].where).toEqual({
|
|
1284
|
+
selector_matches: 'a.router-link',
|
|
1285
|
+
})
|
|
1286
|
+
})
|
|
1287
|
+
|
|
1288
|
+
test('opt-in: no script emitted when speculationRules is absent', async () => {
|
|
1289
|
+
function Page() {
|
|
1290
|
+
useHead({ title: 'No Rules' })
|
|
1291
|
+
return h('div', null)
|
|
1292
|
+
}
|
|
1293
|
+
const { head } = await renderWithHead(h(Page, null))
|
|
1294
|
+
expect(head).not.toContain('speculationrules')
|
|
1295
|
+
})
|
|
1296
|
+
|
|
1297
|
+
test('XSS-safe: a URL containing </script> is escaped, body stays valid JSON', async () => {
|
|
1298
|
+
function Page() {
|
|
1299
|
+
useHead({
|
|
1300
|
+
speculationRules: { prerender: [{ source: 'list', urls: ['/x</script><b>pwn'] }] },
|
|
1301
|
+
})
|
|
1302
|
+
return h('div', null)
|
|
1303
|
+
}
|
|
1304
|
+
const { head } = await renderWithHead(h(Page, null))
|
|
1305
|
+
// The raw closing tag must NOT appear unescaped inside the block.
|
|
1306
|
+
const block = head.match(/<script type="speculationrules">([\s\S]*?)<\/script>/)
|
|
1307
|
+
expect(block).not.toBeNull()
|
|
1308
|
+
expect((block as RegExpMatchArray)[1]).not.toContain('</script>')
|
|
1309
|
+
// And the un-escaped JSON still parses back to the original URL.
|
|
1310
|
+
const unescaped = (block as RegExpMatchArray)[1]!.replace(/<\\\//g, '</')
|
|
1311
|
+
expect(JSON.parse(unescaped).prerender[0].urls[0]).toBe('/x</script><b>pwn')
|
|
1312
|
+
})
|
|
1313
|
+
})
|
package/src/use-head.ts
CHANGED
|
@@ -59,6 +59,14 @@ function buildEntry(o: UseHeadInput): HeadEntry {
|
|
|
59
59
|
children: JSON.stringify(o.jsonLd),
|
|
60
60
|
})
|
|
61
61
|
}
|
|
62
|
+
if (o.speculationRules) {
|
|
63
|
+
tags.push({
|
|
64
|
+
tag: 'script',
|
|
65
|
+
key: 'speculationrules',
|
|
66
|
+
props: { type: 'speculationrules' },
|
|
67
|
+
children: JSON.stringify(o.speculationRules),
|
|
68
|
+
})
|
|
69
|
+
}
|
|
62
70
|
if (o.base)
|
|
63
71
|
tags.push({
|
|
64
72
|
tag: 'base',
|