@uniweb/runtime 1.1.20 → 1.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/runtime",
3
- "version": "1.1.20",
3
+ "version": "1.2.0",
4
4
  "description": "Minimal runtime for loading Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -36,7 +36,7 @@
36
36
  "node": ">=22.12"
37
37
  },
38
38
  "dependencies": {
39
- "@uniweb/core": "~1.4.4",
39
+ "@uniweb/core": "~1.4.5",
40
40
  "@uniweb/theming": "~1.2.1"
41
41
  },
42
42
  "devDependencies": {
@@ -48,6 +48,7 @@
48
48
  import { resolveFetchConfigs } from '@uniweb/core/fetch-config'
49
49
  import { resolveRecordsService } from '@uniweb/core/records-service'
50
50
  import { createDefaultFetcher } from './default-fetcher.js'
51
+ import { requireTransport } from './require-transport.js'
51
52
 
52
53
  /**
53
54
  * Ask a site's records service for every record its declared queries return.
@@ -56,7 +57,8 @@ import { createDefaultFetcher } from './default-fetcher.js'
56
57
  * @param {Object} options
57
58
  * @param {string} options.locale - the locale to ask in; it is a route segment
58
59
  * of the service's address, so there is no asking without one
59
- * @param {Function} options.fetch - the transport, `(url, init) => Response`
60
+ * @param {Function} options.fetch - the transport, `(url, init) => Response`. Required: a missing
61
+ * one is refused, never replaced by the ambient `fetch` (`require-transport.js`)
60
62
  * @param {AbortSignal} [options.signal]
61
63
  * @param {string[]} [options.only] - restrict to these query names
62
64
  * @param {boolean} [options.whole=false] - what to ask for. `false` is the brief a
@@ -84,6 +86,10 @@ export async function collectSiteRecords(
84
86
  content,
85
87
  { locale, fetch, signal, only = null, whole = false, maxPages } = {},
86
88
  ) {
89
+ // ⛔ Before the empties below: those are states of a site, while a lost transport is the caller's
90
+ // wiring, and a site with no live records must not hide it.
91
+ requireTransport(fetch, 'collectSiteRecords')
92
+
87
93
  const config = content?.config
88
94
  const services = config?.services ?? null
89
95
  const queries = config?.queries ?? null
@@ -12,6 +12,7 @@ import { prepareProps, getComponentMeta } from '../prepare-props.js'
12
12
  import Background from './Background.jsx'
13
13
  import { sectionStickyProps, sectionScrollMargin } from '../sticky.js'
14
14
  import { noteBodySection } from '../sticky-top.js'
15
+ import { noteSectionWrapper } from '../sticky-check.js'
15
16
 
16
17
  /**
17
18
  * Valid color contexts
@@ -172,8 +173,15 @@ export default function BlockRenderer({ block, as = 'section', stickySelf = fals
172
173
  // On a page whose layout has a sticky area, a link to this section lands below it.
173
174
  const margin = sectionScrollMargin(block, stickySelf)
174
175
  if (margin) wrapperProps.style = { ...wrapperProps.style, ...margin }
175
- // A body section's element, for the measurement of what is stuck over the body (`../sticky-top.js`).
176
- if (stickySelf === 'section' || stickySelf === true) wrapperProps.ref = (element) => noteBodySection(block, element)
176
+ // A body section's element, for the measurement of what is stuck over the body (`../sticky-top.js`);
177
+ // in dev, every section's, so `uniweb dev`'s sticky check knows a component's outermost element.
178
+ const bodySection = stickySelf === 'section' || stickySelf === true
179
+ if (bodySection || import.meta.env?.DEV) {
180
+ wrapperProps.ref = (element) => {
181
+ if (bodySection) noteBodySection(block, element)
182
+ if (import.meta.env?.DEV) noteSectionWrapper(element)
183
+ }
184
+ }
177
185
 
178
186
  // Merge Component.className (static classes declared on the component function)
179
187
  // Order: context-{theme} + block.state.className + Component.className
@@ -117,9 +117,10 @@ export default function Layout({ page, website }) {
117
117
  useEffect(() => watchStickyTop(document, bodySectionOf(bodyBlocks?.[0])), [page])
118
118
 
119
119
  // `uniweb dev` names a sticky element that cannot move — a component's own `sticky`, an area a
120
- // layout wraps in an element of its own, a box that clips. Dropped from a published page's runtime.
120
+ // layout wraps in an element of its own, a box that clips — measured once the page's data is in.
121
+ // Dropped from a published page's runtime.
121
122
  useEffect(() => {
122
- if (import.meta.env?.DEV) return warnUnmovableSticky()
123
+ if (import.meta.env?.DEV) return warnUnmovableSticky(document, { settled: () => website.dataStore.whenIdle() })
123
124
  }, [page])
124
125
 
125
126
  // Use foundation's custom Layout if provided
@@ -70,7 +70,10 @@ import {
70
70
  * fetches outside a browser (an SSR isolate) decides how a site-relative
71
71
  * address is dispatched — through its own origin or a service binding — and
72
72
  * hands that in. Defaults to the global `fetch`, resolved at call time so a
73
- * test stub installed later is honoured.
73
+ * test stub installed later is honoured. ⛔ The entries a host calls
74
+ * (`loadPageData`, `prefetchAndHydrate`, `collectSiteRecords`) never reach this
75
+ * default: they refuse a missing transport (`require-transport.js`). It serves
76
+ * the browser and a harness that builds its own fetcher.
74
77
  * @returns {{ cacheKey: (req: Object) => string, resolve: (req: Object, ctx: Object) => Promise<{ data, error?, meta? }> }}
75
78
  */
76
79
  export function createDefaultFetcher({ basePath = '', dev = false, fetch: fetchImpl = null } = {}) {
@@ -3,6 +3,13 @@
3
3
  *
4
4
  * Manages document head meta tags for SEO and social sharing.
5
5
  * Updates meta tags when page changes in SPA navigation.
6
+ *
7
+ * ⭐ The browser twin of `injectPageContent`'s head tags, from the same source
8
+ * (`Page#getHeadMeta`), so a crawler that runs the page's script reads what one that
9
+ * does not read. These tags are the framework's: each is set when the page has a value
10
+ * and removed when it has none — as it must be, or a tag of the page before survives a
11
+ * navigation. ⛔ Until 2026-10-25 this removed the canonical of every page that did not
12
+ * author one, after the prerendered HTML had stated it.
6
13
  */
7
14
 
8
15
  import { useEffect, useRef } from 'react'
@@ -20,7 +27,9 @@ const META_TAGS = {
20
27
  'og:image': { property: 'og:image' },
21
28
  'og:url': { property: 'og:url' },
22
29
  'og:type': { property: 'og:type' },
30
+ 'og:site_name': { property: 'og:site_name' },
23
31
  'twitter:card': { name: 'twitter:card' },
32
+ 'twitter:site': { name: 'twitter:site' },
24
33
  'twitter:title': { name: 'twitter:title' },
25
34
  'twitter:description': { name: 'twitter:description' },
26
35
  'twitter:image': { name: 'twitter:image' }
@@ -72,13 +81,17 @@ function getOrCreateLinkTag(rel) {
72
81
  }
73
82
 
74
83
  /**
75
- * Set or remove a meta tag's content
84
+ * Set or remove a meta tag's content — one tag of each name, whoever wrote the others
76
85
  * @param {string} key - Meta tag key
77
86
  * @param {string|null} content - Content value (null to remove)
78
87
  */
79
88
  function setMetaContent(key, content) {
80
89
  const element = getOrCreateMetaTag(key)
81
90
  if (!element) return
91
+ const config = META_TAGS[key]
92
+ for (const extra of document.querySelectorAll(`meta[${config.property ? 'property' : 'name'}="${config.property || config.name}"]`)) {
93
+ if (extra !== element) extra.remove()
94
+ }
82
95
 
83
96
  if (content) {
84
97
  element.setAttribute('content', content)
@@ -88,6 +101,21 @@ function setMetaContent(key, content) {
88
101
  }
89
102
  }
90
103
 
104
+ /**
105
+ * The page's URL in each locale (`hreflang`), replacing whatever the document carried
106
+ * @param {Array<{hreflang: string, url: string}>} alternates
107
+ */
108
+ function setAlternates(alternates) {
109
+ for (const link of document.querySelectorAll('link[rel="alternate"][hreflang]')) link.remove()
110
+ for (const { hreflang, url } of alternates || []) {
111
+ const link = document.createElement('link')
112
+ link.setAttribute('rel', 'alternate')
113
+ link.setAttribute('hreflang', hreflang)
114
+ link.setAttribute('href', url)
115
+ document.head.appendChild(link)
116
+ }
117
+ }
118
+
91
119
  /**
92
120
  * useHeadMeta hook
93
121
  *
@@ -138,26 +166,30 @@ export function useHeadMeta(meta, options = {}) {
138
166
  setMetaContent('og:image', meta.og.image || null)
139
167
  setMetaContent('og:url', meta.og.url || null)
140
168
  setMetaContent('og:type', 'website')
169
+ setMetaContent('og:site_name', meta.og.siteName || null)
141
170
 
142
171
  // Twitter cards (fallback to OG values)
143
172
  setMetaContent('twitter:card', meta.og.image ? 'summary_large_image' : 'summary')
173
+ setMetaContent('twitter:site', meta.twitter?.site || null)
144
174
  setMetaContent('twitter:title', meta.og.title || meta.title || null)
145
175
  setMetaContent('twitter:description', meta.og.description || meta.description || null)
146
176
  setMetaContent('twitter:image', meta.og.image || null)
147
177
  }
148
178
 
149
- // Update canonical link
179
+ // Update canonical link — the page's own URL unless it authors one, and none when the site's
180
+ // address is unknown (`Page#getHeadMeta`)
150
181
  if (meta.canonical) {
151
182
  const canonicalLink = getOrCreateLinkTag('canonical')
152
183
  canonicalLink.setAttribute('href', meta.canonical)
153
- } else {
154
- // Remove canonical if not set
155
- const existingCanonical = document.querySelector('link[rel="canonical"]')
156
- if (existingCanonical) {
157
- existingCanonical.remove()
184
+ for (const extra of document.querySelectorAll('link[rel="canonical"]')) {
185
+ if (extra !== canonicalLink) extra.remove()
158
186
  }
187
+ } else {
188
+ for (const existing of document.querySelectorAll('link[rel="canonical"]')) existing.remove()
159
189
  }
160
190
 
191
+ setAlternates(meta.alternates)
192
+
161
193
  // Cleanup function - reset to defaults on unmount
162
194
  return () => {
163
195
  // We don't remove tags on cleanup because another page will set them
@@ -174,6 +206,9 @@ export function useHeadMeta(meta, options = {}) {
174
206
  meta?.og?.description,
175
207
  meta?.og?.image,
176
208
  meta?.og?.url,
209
+ meta?.og?.siteName,
210
+ meta?.twitter?.site,
211
+ JSON.stringify(meta?.alternates || []),
177
212
  siteName
178
213
  ])
179
214
  }
@@ -26,6 +26,7 @@ import DataStore, { deriveCacheKey } from '@uniweb/core/datastore'
26
26
  import { resolvePage } from './resolve-page.js'
27
27
  import { getComponentMeta } from './prepare-props.js'
28
28
  import { createDefaultFetcher } from './default-fetcher.js'
29
+ import { requireTransport } from './require-transport.js'
29
30
 
30
31
  /**
31
32
  * Every block the page's render can reach: its body and the layout areas it uses (the runtime's own
@@ -53,7 +54,8 @@ function blocksOf(page) {
53
54
  * @param {Object} options
54
55
  * @param {Object} options.website - the initialized Website the render will use (`initPrerender`)
55
56
  * @param {string|Object} options.route - the route, or an already-resolved Page
56
- * @param {Function} options.fetch - the transport, per request
57
+ * @param {Function} options.fetch - the transport, per request. Required: a missing one is refused,
58
+ * never replaced by the ambient `fetch` (`require-transport.js`)
57
59
  * @param {boolean} [options.dev]
58
60
  * @param {string|null} [options.locale] - ⛔ NOT a locale to fetch in: the Website is already
59
61
  * locale-sliced (`initPrerenderForLocale`) and every address is resolved in ITS locale. Passing
@@ -74,6 +76,9 @@ export async function loadPageData({ website, route, fetch, dev = false, locale
74
76
  if (prerender !== 'author' && prerender !== 'always') {
75
77
  throw new Error(`loadPageData: prerender must be 'author' or 'always', got ${JSON.stringify(prerender)}`)
76
78
  }
79
+ // Before the route is resolved: a lost transport is the caller's wiring, and an unknown route
80
+ // must not hide it.
81
+ requireTransport(fetch, 'loadPageData')
77
82
  const active = website?.getActiveLocale?.() ?? null
78
83
  if (locale && active && locale !== active) {
79
84
  throw new Error(
@@ -22,17 +22,19 @@
22
22
  * modelling a deployment it cannot see (the host drew this line itself,
23
23
  * 2026-09-03: a serve location is read from the payload, never constructed here).
24
24
  * - **Init and hydration.** The two lanes differ REALLY here, not incidentally: a
25
- * build initializes once and hydrates every collection up front, an isolate
26
- * initializes per locale and prefetches per route. Folding either in would fit
27
- * one caller and lie to the other. `initPrerender` / `initPrerenderForLocale` /
28
- * `prefetchPageData` / `hydrateDataStore` stay separate exports, and
29
- * `prefetchAndHydrate` below is the isolate's two-step, not a general one.
25
+ * build initializes once per locale, hydrates its executor's answers up front and
26
+ * then runs the data step for each page over one cache for the whole run; an
27
+ * isolate initializes per locale and runs the step fresh for each request. Folding
28
+ * either in would fit one caller and lie to the other. `initPrerender` /
29
+ * `initPrerenderForLocale` / `loadPageData` / `hydrateDataStore` stay separate
30
+ * exports, and `prefetchAndHydrate` below is the isolate's two-step, not a general one.
30
31
  * - **Anything build-only.** `injectBuildData` stays in the build lane; it is the
31
32
  * other half of the head seam and has its own parity guard.
32
33
  */
33
34
  import { resolvePage, renderPage, classifyRenderError, injectPageContent } from './ssr-renderer.js'
34
35
  import { hydrateDataStore } from './wire-foundation.js'
35
36
  import { loadPageData } from './load-page-data.js'
37
+ import { requireTransport } from './require-transport.js'
36
38
 
37
39
  /**
38
40
  * A renderer bound to one initialized Website and one shell.
@@ -55,8 +57,8 @@ export function createPageRenderer({ website, shell }) {
55
57
  * ⭐ Returns an OUTCOME rather than throwing or returning a bare string, because
56
58
  * the two callers branch differently on the same three cases and neither wants an
57
59
  * exception: a build logs and keeps going so one broken section cannot fail a whole
58
- * site, an isolate decides a status code and a cache policy. Same reasoning as
59
- * `prefetchPageData`'s per-entry outcome.
60
+ * site, an isolate decides a status code and a cache policy. Same reasoning as the
61
+ * per-entry outcome `loadPageData` returns.
60
62
  *
61
63
  * @param {string|Object} target - a route (`/blog/1`, resolved through the same
62
64
  * matcher the browser uses, so a dynamic route works) or an already-resolved
@@ -143,13 +145,14 @@ export function createPageRenderer({ website, shell }) {
143
145
  * on the Website it is handed: the keys each component declares, the layout the page actually
144
146
  * draws, and the transport the site chose for a key. `content` is no longer read — the graph
145
147
  * carries everything — and stays in the signature for callers that pass it.
146
- * ⛔ `prefetchPageData` works from the payload alone and cannot know any of those; it asks for
147
- * every fetch on every level instead, and still gets four cases wrong. Prefer this entry.
148
+ * ⛔ `prefetchPageData`, which it replaced, worked from the payload alone and could know none of
149
+ * those: it asked for every fetch on every level and still got four cases wrong. It was removed
150
+ * on 2026-09-20.
148
151
  *
149
- * ⛔ The build lane does NOT call this: it hydrates every collection once, before
150
- * its page loop, from its own executor that honours the author's `prerender:` flag.
151
- * That difference is why this is a named isolate helper and not a step inside
152
- * `render`.
152
+ * ⛔ The build lane does NOT call this: it runs `loadPageData` itself for each page, with one
153
+ * cache for the whole run (which this entry does not take) and `prerender: 'author'`, and keeps
154
+ * the entries to embed in that page's HTML. That difference is why this is a named isolate
155
+ * helper and not a step inside `render`.
153
156
  *
154
157
  * @returns {Promise<Array<{config: Object, outcome: string, data: any, error?: string}>>}
155
158
  */
@@ -162,33 +165,11 @@ export async function prefetchAndHydrate({ website, content, route, locale = nul
162
165
  throw new Error('prefetchAndHydrate: `website` must be an initialized Website with a dataStore')
163
166
  }
164
167
 
165
- // ⛔ THE TRANSPORT IS REQUIRED HERE, unlike on `prefetchPageData`, and this is the
166
- // one place the difference matters.
167
- //
168
- // A function does NOT survive every isolate boundary. Measured by a host against
169
- // a real isolate loader, 2026-09-03: passed through an
170
- // entrypoint's `fetch(Request)` with a JSON body the transport arrives
171
- // **`undefined`**; passed as an argument to an RPC method it arrives as a callable
172
- // function and the isolate invokes it. Only the RPC shape carries it.
173
- //
174
- // ⚠️ And `undefined` is not where it stops, which is the part their measurement
175
- // could not see from outside our code. `createDefaultFetcher` resolves the
176
- // transport as `fetchImpl || globalThis.fetch`, so a transport that failed to cross
177
- // silently becomes THE ISOLATE'S OWN NETWORK — outside the host's timeout, byte
178
- // budget and site-relative address resolution. With an absolute address it does not
179
- // even fail: the request goes out from the wrong place and comes back
180
- // `outcome: 'fetched'`. A wiring mistake wearing a success.
181
- //
182
- // ⇒ So the entry whose only caller crosses that boundary demands a real function
183
- // rather than defaulting. A Node or browser caller that genuinely wants the global
184
- // passes `fetch: globalThis.fetch` — one word, and it says so.
185
- if (typeof fetch !== 'function') {
186
- throw new Error(
187
- 'prefetchAndHydrate: `fetch` must be a function. A transport does not survive a ' +
188
- 'JSON-serialized isolate boundary — pass it as an RPC method argument. ' +
189
- 'To use the ambient fetch deliberately, pass `fetch: globalThis.fetch`.'
190
- )
191
- }
168
+ // ⛔ THE TRANSPORT IS REQUIRED, as on every entry a host calls to have the runtime fetch for
169
+ // it: one that did not cross the isolate boundary is refused here, never replaced by the
170
+ // isolate's own network (`require-transport.js`, which records the measurement). Checked here
171
+ // as well as in `loadPageData` so the message names the entry the caller called.
172
+ requireTransport(fetch, 'prefetchAndHydrate')
192
173
  // ⛔ `locale` is forwarded to be CHECKED, not applied: the Website is locale-sliced already, so a
193
174
  // caller whose two disagree gets an error rather than a page prerendered in one language with the
194
175
  // data of another.
@@ -133,9 +133,10 @@ export function applyDefaults(params, defaults) {
133
133
  *
134
134
  * A key declared `single: true` then holds one record, or `null` (`holdOneRecord`).
135
135
  *
136
- * A tagged data block under a key the component does not declare is left out, and said so
137
- * in dev (`warnUndeclaredBlocks`); it stays in `content.sequence`. The data object is
138
- * rebuilt on each render from those sources, never from the last render's.
136
+ * A tagged block the section holds under a key the component does not declare is left out, and
137
+ * said so in dev (`warnUndeclaredBlocks`); it stays in `content.sequence`. A block inside an item
138
+ * is that item's (`content.items[n].data`), never held here. The data object is rebuilt on each
139
+ * render from those sources, never from the last render's.
139
140
  *
140
141
  * ⛔ Until 2026-09-14 this merged every delivered key into what the section held, so a
141
142
  * section received every fetch that reached it and every tagged block it had.
@@ -149,7 +150,8 @@ function assembleData(block, meta, entityData) {
149
150
  else if (entityData && entityData[key] !== undefined) data[key] = entityData[key]
150
151
  else data[key] = null
151
152
  }
152
- warnUndeclaredBlocks(block, declared)
153
+ // What the section holds and this leaves out — the keys the dev warning reports.
154
+ warnUndeclaredBlocks(block, Object.keys(held).filter((key) => !Object.hasOwn(data, key)))
153
155
  block.parsedContent.data = data
154
156
  return declared
155
157
  }
@@ -177,26 +179,36 @@ function holdOneRecord(block, declared) {
177
179
  }
178
180
 
179
181
  /**
180
- * Say once, in dev, that a tagged block — a data block or a concept block — did not reach its
181
- * section's component: its key is not declared. The block is still in `content.sequence`, where a component that
182
- * renders the sequence finds it.
182
+ * Say once, in dev, that a tagged block the section holds — a data block or a concept block — did
183
+ * not reach its component: its key is not declared. `assembleData` passes the keys it left out, so
184
+ * this names exactly what the component did not receive. The block is still in `content.sequence`,
185
+ * where a component that renders the sequence finds it.
186
+ *
187
+ * ⭐ AN ITEM'S BLOCK IS NEVER ONE OF THEM. The parser keeps a block inside an item with that item
188
+ * (`content.items[n].data`), which asks no declaration, and only the section's own blocks reach
189
+ * `block.heldData`. ⛔ Until 2026-10-25 this walked `content.sequence`, which lists every element of
190
+ * the section, its items' included, and so reported each card's `meta` block in a card grid as left
191
+ * out of `content.data`, where it never belonged.
183
192
  */
184
193
  const warnedBlocks = new Set()
185
- function warnUndeclaredBlocks(block, declared) {
186
- if (!block.website?.entityStore?.dev) return
187
- const keys = new Set(declared.map(([key]) => key))
188
- for (const item of block.parsedContent?.sequence || []) {
189
- // A data block (```yaml:tag) and a concept block (```md:tag) both land under their tag, so
190
- // both need the key declared. ⛔ Until 2026-09-29 only a data block was reported, and an
191
- // undeclared concept block left content.data without a word.
192
- const kind = item?.type === 'dataBlock' ? 'data block' : item?.type === 'concept_block' ? 'concept block' : null
193
- if (!kind || typeof item.tag !== 'string' || keys.has(item.tag)) continue
194
- const memo = `${block.type}::${item.tag}`
194
+ function warnUndeclaredBlocks(block, leftOut) {
195
+ if (!leftOut.length || !block.website?.entityStore?.dev) return
196
+ const sequence = block.parsedContent?.sequence || []
197
+ for (const tag of leftOut) {
198
+ const memo = `${block.type}::${tag}`
195
199
  if (warnedBlocks.has(memo)) continue
200
+ // The section's own block under the tag is the first in the sequence: the parser cuts the
201
+ // sequence into slices, and the section's own content is the first (`groups.js`). None means
202
+ // the key holds a fetch's prerendered answer, which is not a block.
203
+ // A data block (```yaml:tag) and a concept block (```md:tag) both land under their tag, so
204
+ // both need the key declared. ⛔ Until 2026-09-29 only a data block was reported.
205
+ const element = sequence.find((e) => (e?.type === 'dataBlock' || e?.type === 'concept_block') && e.tag === tag)
206
+ if (!element) continue
196
207
  warnedBlocks.add(memo)
208
+ const kind = element.type === 'dataBlock' ? 'data block' : 'concept block'
197
209
  console.warn(
198
- `[uniweb] ${block.type}: the \`${item.tag}\` ${kind} is not in content.data — ${block.type} does not ` +
199
- `declare \`${item.tag}\` in its meta.js \`data:\`. It stays in content.sequence.`
210
+ `[uniweb] ${block.type}: the \`${tag}\` ${kind} is not in content.data — ${block.type} does not ` +
211
+ `declare \`${tag}\` in its meta.js \`data:\`. It stays in content.sequence.`
200
212
  )
201
213
  }
202
214
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * THE HOST'S TRANSPORT, REQUIRED — on every entry a host calls to have the runtime fetch on its
3
+ * behalf: `loadPageData`, `prefetchAndHydrate` and `collectSiteRecords`.
4
+ *
5
+ * ⛔ The failure this prevents is not "no transport". `createDefaultFetcher` resolves its transport
6
+ * as `fetchImpl || globalThis.fetch`, so a missing one becomes the ambient network — in an isolate,
7
+ * the isolate's own, outside the host's timeout and byte budget and outside its resolution of
8
+ * site-relative addresses. With an absolute address the request does not even fail: it leaves from
9
+ * the wrong place and comes back `fetched`. A wiring mistake wearing a success.
10
+ *
11
+ * Reachable, not hypothetical: a host measured against a real isolate loader (2026-09-03) that a
12
+ * function passed through an entrypoint's `fetch(Request)` with a JSON body arrives `undefined`,
13
+ * and survives only as an RPC method argument.
14
+ *
15
+ * ⛔ Until 2026-10-25 only `prefetchAndHydrate` refused one, while the entries a host's isolate
16
+ * calls — the data step and the corpus entry — fell back to the ambient network silently.
17
+ *
18
+ * The default stays where it is right: in the browser, which has one network, and in a harness
19
+ * that builds a default fetcher itself. A caller of these entries that wants the ambient fetch
20
+ * passes `fetch: globalThis.fetch`, which is one word and says so.
21
+ *
22
+ * Zero-dependency leaf, so the data step's graph stays renderer-free (`tests/data-step-graph.test.js`).
23
+ *
24
+ * @param {*} fetch - what the caller passed as `fetch`
25
+ * @param {string} entry - the entry's name, for the message
26
+ * @returns {Function} the transport
27
+ */
28
+ export function requireTransport(fetch, entry) {
29
+ if (typeof fetch === 'function') return fetch
30
+ throw new Error(
31
+ `${entry}: \`fetch\` must be a function. A transport does not survive a ` +
32
+ 'JSON-serialized isolate boundary — pass it as an RPC method argument. ' +
33
+ 'To use the ambient fetch deliberately, pass `fetch: globalThis.fetch`.'
34
+ )
35
+ }
@@ -342,8 +342,8 @@ export function renderLayout(page, website) {
342
342
  }
343
343
 
344
344
  // Mirror Layout.jsx: wrap body + each area in a thin div carrying its
345
- // view-transition-name, so the prerendered HTML matches what the SPA hydrates
346
- // and the browser can animate regions independently on client navigation.
345
+ // view-transition-name, so the prerendered HTML matches the page the SPA renders
346
+ // in its place and the browser can animate regions independently on client navigation.
347
347
  const areaNames = Object.keys(areas)
348
348
  const transitions = website.viewTransitions
349
349
  ? resolveLayoutTransitions(areaNames, layoutMeta?.transitions)
@@ -674,7 +674,7 @@ export { escapeHtml }
674
674
  * Common operations shared by both build and cloud:
675
675
  * - Replace #root div with rendered HTML
676
676
  * - Update page title
677
- * - Add/update meta description
677
+ * - Write the page's head tags — description, canonical, hreflang, Open Graph (`writeHeadMeta`)
678
678
  * - Inject section override CSS
679
679
  * - Inject the pre-paint appearance script
680
680
  *
@@ -701,7 +701,7 @@ export function injectPageContent(html, renderedContent, page, options = {}) {
701
701
 
702
702
  // Pre-paint appearance script. Prerendered HTML carries real content styled
703
703
  // from :root (light) tokens, so a dark visitor would see a flash of light
704
- // before the bundle hydrates and applies the class. Read off the page's
704
+ // before the bundle boots and applies the class. Read off the page's
705
705
  // website back-ref rather than a parameter: every lane builds the same graph,
706
706
  // so this needs no plumbing and no per-lane opt-in. Idempotent, because
707
707
  // @uniweb/build's injectBuildData may run over this HTML afterwards.
@@ -766,45 +766,66 @@ export function injectPageContent(html, renderedContent, page, options = {}) {
766
766
  )
767
767
  }
768
768
 
769
- // Add/update meta description
770
- if (page.description) {
771
- const metaDesc = `<meta name="description" content="${escapeHtml(page.description)}">`
772
- if (result.includes('<meta name="description"')) {
773
- result = result.replace(/<meta name="description"[^>]*>/, metaDesc)
774
- } else {
775
- result = result.replace('</head>', `${metaDesc}\n</head>`)
776
- }
777
- }
778
-
779
- // Social / SEO meta from the page's effective head metadata (page seo
780
- // cascading over site-level seo — see Page.getHeadMeta). The SPA emits these
781
- // client-side via useHeadMeta; this is the SSR twin, so crawlers and social
782
- // unfurlers (which don't run JS) get them in the static HTML too.
783
- const headMeta = page.getHeadMeta?.()
784
- if (headMeta) {
785
- const og = headMeta.og || {}
786
- const keywords = Array.isArray(headMeta.keywords)
787
- ? headMeta.keywords.join(', ')
788
- : headMeta.keywords
789
- const tags = []
790
- if (keywords) tags.push(`<meta name="keywords" content="${escapeHtml(keywords)}">`)
791
- if (headMeta.robots) tags.push(`<meta name="robots" content="${escapeHtml(headMeta.robots)}">`)
792
- if (og.title) tags.push(`<meta property="og:title" content="${escapeHtml(og.title)}">`)
793
- if (og.description) tags.push(`<meta property="og:description" content="${escapeHtml(og.description)}">`)
794
- if (og.image) tags.push(`<meta property="og:image" content="${escapeHtml(og.image)}">`)
795
- if (og.url) tags.push(`<meta property="og:url" content="${escapeHtml(og.url)}">`)
796
- tags.push('<meta property="og:type" content="website">')
797
- tags.push(`<meta name="twitter:card" content="${og.image ? 'summary_large_image' : 'summary'}">`)
798
- if (og.title) tags.push(`<meta name="twitter:title" content="${escapeHtml(og.title)}">`)
799
- if (og.description) tags.push(`<meta name="twitter:description" content="${escapeHtml(og.description)}">`)
800
- if (og.image) tags.push(`<meta name="twitter:image" content="${escapeHtml(og.image)}">`)
801
- if (headMeta.canonical) tags.push(`<link rel="canonical" href="${escapeHtml(headMeta.canonical)}">`)
802
- if (tags.length) result = result.replace('</head>', `${tags.join('\n')}\n</head>`)
803
- }
769
+ // ⭐ THE PAGE'S HEAD TAGS — description, canonical, hreflang, Open Graph, Twitter — from the
770
+ // page's own head metadata (`Page#getHeadMeta`, which the browser writes the same tags from in
771
+ // `useHeadMeta`), so crawlers and social unfurlers, which run no script, read the page's own.
772
+ // ⛔ Until 2026-10-25 these were APPENDED, after the static build had written the HOME page's
773
+ // into the one shell every page is rendered into: each page named the home page its canonical
774
+ // and carried two og:title and two og:url, the home page's first.
775
+ result = writeHeadMeta(result, page)
804
776
 
805
777
  return result
806
778
  }
807
779
 
780
+ /**
781
+ * Write a page's head tags into `html`'s head. These tags are the framework's: each is set to the
782
+ * page's value, replacing any of its name the shell carries, and removed when the page has none —
783
+ * as the browser does on every navigation (`useHeadMeta`), so hydrating the page changes nothing a
784
+ * crawler read.
785
+ *
786
+ * @param {string} html
787
+ * @param {Object} page
788
+ * @returns {string}
789
+ */
790
+ function writeHeadMeta(html, page) {
791
+ // A Page states its own (`getHeadMeta`); plain page data, which callers may hand in, its fields.
792
+ const meta = typeof page?.getHeadMeta === 'function' ? page.getHeadMeta() : { description: page?.description || null }
793
+ const end = html.indexOf('</head>')
794
+ if (!meta || end === -1) return html
795
+ const og = meta.og || {}
796
+ const keywords = Array.isArray(meta.keywords) ? meta.keywords.join(', ') : meta.keywords
797
+ const metaTags = [
798
+ ['name', 'description', meta.description],
799
+ ['name', 'keywords', keywords],
800
+ ['name', 'robots', meta.robots],
801
+ ['property', 'og:type', 'website'],
802
+ ['property', 'og:site_name', og.siteName],
803
+ ['property', 'og:title', og.title],
804
+ ['property', 'og:description', og.description],
805
+ ['property', 'og:image', og.image],
806
+ ['property', 'og:url', og.url],
807
+ ['name', 'twitter:card', og.image ? 'summary_large_image' : 'summary'],
808
+ ['name', 'twitter:site', meta.twitter?.site],
809
+ ['name', 'twitter:title', og.title],
810
+ ['name', 'twitter:description', og.description],
811
+ ['name', 'twitter:image', og.image],
812
+ ]
813
+ // The head alone: the page's content is in the body, and none of it is edited here.
814
+ let head = html.slice(0, end)
815
+ const written = []
816
+ for (const [attr, key, value] of metaTags) {
817
+ head = head.replace(new RegExp(`<meta\\b[^>]*\\b(?:name|property)=["']${key}["'][^>]*>\\s*`, 'gi'), '')
818
+ if (value) written.push(`<meta ${attr}="${key}" content="${escapeHtml(value)}">`)
819
+ }
820
+ head = head.replace(/<link\b[^>]*\brel=["']canonical["'][^>]*>\s*/gi, '')
821
+ if (meta.canonical) written.push(`<link rel="canonical" href="${escapeHtml(meta.canonical)}">`)
822
+ head = head.replace(/<link\b[^>]*\bhreflang=["'][^"']*["'][^>]*>\s*/gi, '')
823
+ for (const { hreflang, url } of meta.alternates || []) {
824
+ written.push(`<link rel="alternate" hreflang="${escapeHtml(hreflang)}" href="${escapeHtml(url)}">`)
825
+ }
826
+ return `${head}${written.join('\n')}\n${html.slice(end)}`
827
+ }
828
+
808
829
  // ============================================================================
809
830
  // 404 fallback generation
810
831
  // ============================================================================