@pyreon/head 0.18.0 → 0.20.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.
@@ -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":"198a0340-1","name":"context.ts"},{"uid":"198a0340-3","name":"provider.ts"},{"uid":"198a0340-5","name":"dom.ts"},{"uid":"198a0340-7","name":"use-head.ts"},{"uid":"198a0340-9","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"198a0340-1":{"renderedLength":1373,"gzipLength":509,"brotliLength":0,"metaUid":"198a0340-0"},"198a0340-3":{"renderedLength":681,"gzipLength":408,"brotliLength":0,"metaUid":"198a0340-2"},"198a0340-5":{"renderedLength":3401,"gzipLength":1293,"brotliLength":0,"metaUid":"198a0340-4"},"198a0340-7":{"renderedLength":2462,"gzipLength":1063,"brotliLength":0,"metaUid":"198a0340-6"},"198a0340-9":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"198a0340-8"}},"nodeMetas":{"198a0340-0":{"id":"/src/context.ts","moduleParts":{"index.js":"198a0340-1"},"imported":[{"uid":"198a0340-10"}],"importedBy":[{"uid":"198a0340-8"},{"uid":"198a0340-2"},{"uid":"198a0340-6"}]},"198a0340-2":{"id":"/src/provider.ts","moduleParts":{"index.js":"198a0340-3"},"imported":[{"uid":"198a0340-10"},{"uid":"198a0340-0"}],"importedBy":[{"uid":"198a0340-8"}]},"198a0340-4":{"id":"/src/dom.ts","moduleParts":{"index.js":"198a0340-5"},"imported":[],"importedBy":[{"uid":"198a0340-6"}]},"198a0340-6":{"id":"/src/use-head.ts","moduleParts":{"index.js":"198a0340-7"},"imported":[{"uid":"198a0340-10"},{"uid":"198a0340-11"},{"uid":"198a0340-0"},{"uid":"198a0340-4"}],"importedBy":[{"uid":"198a0340-8"}]},"198a0340-8":{"id":"/src/index.ts","moduleParts":{"index.js":"198a0340-9"},"imported":[{"uid":"198a0340-0"},{"uid":"198a0340-2"},{"uid":"198a0340-6"}],"importedBy":[],"isEntry":true},"198a0340-10":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"198a0340-0"},{"uid":"198a0340-2"},{"uid":"198a0340-6"}]},"198a0340-11":{"id":"@pyreon/reactivity","moduleParts":{},"imported":[],"importedBy":[{"uid":"198a0340-6"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
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":"2d2aa494-1","name":"context.ts"},{"uid":"2d2aa494-3","name":"dom.ts"},{"uid":"2d2aa494-5","name":"use-head.ts"}]}]}],"isRoot":true},"nodeParts":{"2d2aa494-1":{"renderedLength":79,"gzipLength":85,"brotliLength":0,"metaUid":"2d2aa494-0"},"2d2aa494-3":{"renderedLength":3401,"gzipLength":1293,"brotliLength":0,"metaUid":"2d2aa494-2"},"2d2aa494-5":{"renderedLength":2462,"gzipLength":1063,"brotliLength":0,"metaUid":"2d2aa494-4"}},"nodeMetas":{"2d2aa494-0":{"id":"/src/context.ts","moduleParts":{"use-head.js":"2d2aa494-1"},"imported":[{"uid":"2d2aa494-6"}],"importedBy":[{"uid":"2d2aa494-4"}]},"2d2aa494-2":{"id":"/src/dom.ts","moduleParts":{"use-head.js":"2d2aa494-3"},"imported":[],"importedBy":[{"uid":"2d2aa494-4"}]},"2d2aa494-4":{"id":"/src/use-head.ts","moduleParts":{"use-head.js":"2d2aa494-5"},"imported":[{"uid":"2d2aa494-6"},{"uid":"2d2aa494-7"},{"uid":"2d2aa494-0"},{"uid":"2d2aa494-2"}],"importedBy":[],"isEntry":true},"2d2aa494-6":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"2d2aa494-4"},{"uid":"2d2aa494-0"}]},"2d2aa494-7":{"id":"@pyreon/reactivity","moduleParts":{},"imported":[],"importedBy":[{"uid":"2d2aa494-4"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
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",
@@ -1,4 +1,3 @@
1
- import * as _$_pyreon_core0 from "@pyreon/core";
2
1
  import { ComponentFn, Props, VNodeChild } from "@pyreon/core";
3
2
 
4
3
  //#region src/context.d.ts
@@ -104,6 +103,48 @@ interface StyleTag {
104
103
  /** Render-blocking behavior */
105
104
  blocking?: string;
106
105
  }
106
+ /**
107
+ * How eagerly the browser should act on a speculation rule.
108
+ * Per the W3C Speculation Rules spec.
109
+ */
110
+ type SpeculationEagerness = 'immediate' | 'eager' | 'moderate' | 'conservative';
111
+ /**
112
+ * A single speculation rule (one entry in a `prefetch` / `prerender` list).
113
+ *
114
+ * - `source: 'list'` + `urls` — prefetch/prerender these explicit URLs.
115
+ * - `source: 'document'` + `where` — let the browser pick links from the
116
+ * current document that match the predicate (e.g. a CSS selector via
117
+ * `{ selector_matches: '.router-link' }`).
118
+ */
119
+ interface SpeculationRule {
120
+ /** `'list'` (explicit `urls`) or `'document'` (predicate-driven). */
121
+ source?: 'list' | 'document';
122
+ /** Same-origin URLs to prefetch/prerender (for `source: 'list'`). */
123
+ urls?: string[];
124
+ /** Document predicate (for `source: 'document'`) — e.g. `{ selector_matches: 'a.next' }`. */
125
+ where?: Record<string, unknown>;
126
+ /** When the browser should fetch — defaults to the browser's per-source default. */
127
+ eagerness?: SpeculationEagerness;
128
+ /** Capability requirements, e.g. `['anonymous-client-ip-when-cross-origin']`. */
129
+ requires?: string[];
130
+ /** Referrer policy for the speculative request. */
131
+ referrer_policy?: string;
132
+ }
133
+ /**
134
+ * Declarative Speculation Rules — emitted as a single
135
+ * `<script type="speculationrules">` tag. Supported browsers prefetch or
136
+ * fully prerender the next document(s) so navigation is instant. Inert in
137
+ * non-supporting browsers (no polyfill needed). Opt-in: only emitted when
138
+ * `useHead({ speculationRules })` is called.
139
+ *
140
+ * @see https://developer.mozilla.org/docs/Web/API/Speculation_Rules_API
141
+ */
142
+ interface SpeculationRules {
143
+ /** Lightweight: fetch the response, no rendering. */
144
+ prefetch?: SpeculationRule[];
145
+ /** Heavy: fully render the next document in the background. */
146
+ prerender?: SpeculationRule[];
147
+ }
107
148
  /** Standard `<base>` tag attributes. */
108
149
  interface BaseTag {
109
150
  /** Base URL for relative URLs in the document */
@@ -128,6 +169,13 @@ interface UseHeadInput {
128
169
  }[];
129
170
  /** Convenience: emits a <script type="application/ld+json"> tag with JSON.stringify'd content */
130
171
  jsonLd?: Record<string, unknown> | Record<string, unknown>[];
172
+ /**
173
+ * Convenience: emits a `<script type="speculationrules">` tag with the
174
+ * JSON.stringify'd rules. Supported browsers prefetch/prerender the next
175
+ * document(s) for near-instant navigation; inert elsewhere. Opt-in.
176
+ * @example useHead({ speculationRules: { prerender: [{ source: 'list', urls: ['/about'], eagerness: 'moderate' }] } })
177
+ */
178
+ speculationRules?: SpeculationRules;
131
179
  base?: BaseTag;
132
180
  /** Attributes to set on the <html> element (e.g. { lang: "en", dir: "ltr" }) */
133
181
  htmlAttrs?: Record<string, string>;
@@ -153,7 +201,7 @@ interface HeadContextValue {
153
201
  resolveBodyAttrs(): Record<string, string>;
154
202
  }
155
203
  declare function createHeadContext(): HeadContextValue;
156
- declare const HeadContext: _$_pyreon_core0.Context<HeadContextValue | null>;
204
+ declare const HeadContext: import("@pyreon/core").Context<HeadContextValue | null>;
157
205
  //#endregion
158
206
  //#region src/provider.d.ts
159
207
  interface HeadProviderProps extends Props {
@@ -190,5 +238,5 @@ declare const HeadProvider: ComponentFn<HeadProviderProps>;
190
238
  */
191
239
  declare function useHead(input: UseHeadInput | (() => UseHeadInput)): void;
192
240
  //#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 };
241
+ 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
242
  //# sourceMappingURL=index2.d.ts.map
@@ -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.18.0",
3
+ "version": "0.20.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.18.0",
63
- "@pyreon/reactivity": "^0.18.0",
64
- "@pyreon/runtime-server": "^0.18.0"
62
+ "@pyreon/core": "^0.20.0",
63
+ "@pyreon/reactivity": "^0.20.0",
64
+ "@pyreon/runtime-server": "^0.20.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.18.0",
70
- "@pyreon/runtime-server": "^0.18.0",
71
- "@pyreon/test-utils": "^0.13.5",
69
+ "@pyreon/runtime-dom": "^0.20.0",
70
+ "@pyreon/runtime-server": "^0.20.0",
71
+ "@pyreon/test-utils": "^0.13.7",
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
@@ -6,6 +6,9 @@ export type {
6
6
  LinkTag,
7
7
  MetaTag,
8
8
  ScriptTag,
9
+ SpeculationEagerness,
10
+ SpeculationRule,
11
+ SpeculationRules,
9
12
  StyleTag,
10
13
  UseHeadInput,
11
14
  } from './context'
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
  })
@@ -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',