@uniweb/runtime 1.1.19 → 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.
@@ -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
  // ============================================================================
@@ -4,13 +4,23 @@
4
4
  *
5
5
  * `position: sticky` keeps an element inside its parent's box. When the parent is no taller than
6
6
  * the element (no wider, for one that sticks sideways), the element has no room and scrolls away
7
- * with the page, and nothing says so. The two ways it happens:
7
+ * with the page, and nothing says so. Whose box the parent is says what to do (`unmovableAdvice`):
8
8
  *
9
- * - a component's own `sticky top-0`: a section's markup sits in boxes as tall as itself, so it
10
- * never moves. The section's `sticky: true` asks the runtime to make sticky the box that can
11
- * (`sticky.js`).
12
- * - a layout that wraps a sticky area in an element of its own, which confines the area's box.
13
- * The layout makes its element sticky when `page.isSticky(area)`.
9
+ * - a component's outermost element — a header's own `sticky top-0` — sits in the section's
10
+ * wrapper, as tall as itself, so it never moves. The section's `sticky: true` asks the runtime
11
+ * to make sticky the box that can (`sticky.js`).
12
+ * - a box the runtime made sticky (`data-uw-sticky`) in an element a layout wraps it in, which
13
+ * confines it. The layout makes its element sticky when `page.isSticky(area)`.
14
+ * - an element inside a component sits in a box the component draws. A side panel beside a list
15
+ * sticks within the row the two share, and cannot when its box is only as tall as the panel:
16
+ * the fix is the component's, to give that box room or make it the sticky one.
17
+ *
18
+ * ⭐ MEASURED ON THE SETTLED PAGE. A box's height can wait on the page's data — a list fills its
19
+ * column once its records arrive — so the check runs once nothing is in flight (the layout passes
20
+ * `website.dataStore.whenIdle` as `settled`), after the page's fonts, in the frames after that:
21
+ * React has committed what arrived, and the browser has laid it out. ⛔ Until 2026-10-25 it
22
+ * measured in the first frame after the page mounted, while the page's fetches were still out,
23
+ * and named a side panel whose column was waiting for its records as one that could never move.
14
24
  *
15
25
  * Table parts are left out: a sticky table header cell sticks within its table, though its row is
16
26
  * no taller than it is. So is a child section the runtime made sticky (`data-uw-sticky="child"`):
@@ -25,6 +35,22 @@
25
35
 
26
36
  const warned = new WeakSet()
27
37
 
38
+ /** Every section wrapper the browser's renderer drew, noted in dev (`BlockRenderer.jsx`). */
39
+ const sectionWrappers = new WeakSet()
40
+
41
+ /** How long the check waits on the page's fetches before it measures anyway. */
42
+ const SETTLE_CAP_MS = 10_000
43
+
44
+ /**
45
+ * Note a section's wrapper, so the check can tell a component's outermost element — the one in
46
+ * it — from an element inside a component. A ref callback's argument: null on unmount.
47
+ *
48
+ * @param {Element|null} element
49
+ */
50
+ export function noteSectionWrapper(element) {
51
+ if (element) sectionWrappers.add(element)
52
+ }
53
+
28
54
  /** The nearest ancestor that draws a box — `display: contents` draws none. */
29
55
  function boxParent(el) {
30
56
  let parent = el.parentElement
@@ -73,38 +99,85 @@ export function clippingAncestor(el) {
73
99
  }
74
100
 
75
101
  /**
76
- * Warn, once per element, about each sticky element on the page that cannot move.
102
+ * What to do about a sticky element that cannot move, by whose box holds it (see above).
77
103
  *
78
- * @param {Document} [doc]
79
- * @returns {() => void} cancels a check not yet run
104
+ * @param {Element} el
105
+ * @returns {string}
80
106
  */
81
- export function warnUnmovableSticky(doc = globalThis.document) {
82
- if (!doc?.body || typeof requestAnimationFrame !== 'function') return () => {}
83
- const frame = requestAnimationFrame(() => {
84
- for (const el of doc.body.querySelectorAll('*')) {
85
- if (warned.has(el) || getComputedStyle(el).position !== 'sticky') continue
86
- const clip = clippingAncestor(el)
87
- if (clip) {
88
- warned.add(el)
89
- console.warn(
90
- '[uniweb] This element is sticky but sits in a box that clips its overflow (`overflow: hidden`, ' +
91
- 'or `overflow-x: hidden`), so it sticks to that box, which does not scroll, and never moves. ' +
92
- 'Clip with `overflow: clip` instead: it clips the same and keeps the element sticking.',
93
- el,
94
- clip,
95
- )
96
- continue
97
- }
98
- if (el.getAttribute('data-uw-sticky') === 'child' || canStick(el)) continue
107
+ export function unmovableAdvice(el) {
108
+ if (el.getAttribute('data-uw-sticky')) {
109
+ return (
110
+ 'It is the box the runtime makes sticky for a section, and an element of the layout around it ' +
111
+ 'confines it: a layout that wraps an area in an element of its own makes that element sticky ' +
112
+ 'when `page.isSticky(area)`.'
113
+ )
114
+ }
115
+ if (sectionWrappers.has(boxParent(el))) {
116
+ return (
117
+ "It is a section's outermost element, and the section's wrapper is as tall as it is. To keep the " +
118
+ 'section in view, give it `sticky: true` (its type may default it in meta.js), and the runtime ' +
119
+ 'makes sticky the box that can.'
120
+ )
121
+ }
122
+ return (
123
+ 'It sits in a box the component draws. Give that box room — let it stretch with its row rather ' +
124
+ 'than fit its content (`items-start` or `self-start` shrinks it) — or make that box the sticky one.'
125
+ )
126
+ }
127
+
128
+ /** Warn, once per element, about each sticky element on the page that cannot move. */
129
+ function checkPage(doc) {
130
+ for (const el of doc.body.querySelectorAll('*')) {
131
+ if (warned.has(el) || getComputedStyle(el).position !== 'sticky') continue
132
+ const clip = clippingAncestor(el)
133
+ if (clip) {
99
134
  warned.add(el)
100
135
  console.warn(
101
- '[uniweb] This element is sticky but cannot move: its parent is no taller than it is, so it ' +
102
- 'scrolls away with the page. To keep a section in view, give the section `sticky: true` ' +
103
- '(its type may default it in meta.js) — no box a component draws can stick. A layout that ' +
104
- 'wraps an area in an element of its own makes that element sticky when `page.isSticky(area)`.',
136
+ '[uniweb] This element is sticky but sits in a box that clips its overflow (`overflow: hidden`, ' +
137
+ 'or `overflow-x: hidden`), so it sticks to that box, which does not scroll, and never moves. ' +
138
+ 'Clip with `overflow: clip` instead: it clips the same and keeps the element sticking.',
105
139
  el,
140
+ clip,
106
141
  )
142
+ continue
107
143
  }
108
- })
109
- return () => cancelAnimationFrame(frame)
144
+ if (el.getAttribute('data-uw-sticky') === 'child' || canStick(el)) continue
145
+ warned.add(el)
146
+ console.warn(
147
+ '[uniweb] This element is sticky but cannot move: its parent is no taller than it is, so it ' +
148
+ `scrolls away with the page. ${unmovableAdvice(el)}`,
149
+ el,
150
+ )
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Check the page once it has settled: when `settled()` resolves (or after `SETTLE_CAP_MS`, so a
156
+ * fetch that never answers does not silence the check), its fonts have loaded, and two frames have
157
+ * passed — the first for React to commit what arrived, the second to lay it out.
158
+ *
159
+ * @param {Document} [doc]
160
+ * @param {{ settled?: () => Promise<unknown> }} [options] - what the page waits on: its data
161
+ * @returns {() => void} cancels a check not yet run
162
+ */
163
+ export function warnUnmovableSticky(doc = globalThis.document, { settled } = {}) {
164
+ if (!doc?.body || typeof requestAnimationFrame !== 'function') return () => {}
165
+ let cancelled = false
166
+ let frame = 0
167
+ let cap = 0
168
+ const nextFrame = () => new Promise((resolve) => { frame = requestAnimationFrame(resolve) })
169
+ const capped = new Promise((resolve) => { cap = setTimeout(resolve, SETTLE_CAP_MS) })
170
+ ;(async () => {
171
+ await Promise.race([Promise.resolve(settled?.()).catch(() => {}), capped])
172
+ clearTimeout(cap)
173
+ await doc.fonts?.ready
174
+ if (!cancelled) await nextFrame()
175
+ if (!cancelled) await nextFrame()
176
+ if (!cancelled) checkPage(doc)
177
+ })()
178
+ return () => {
179
+ cancelled = true
180
+ clearTimeout(cap)
181
+ cancelAnimationFrame(frame)
182
+ }
110
183
  }
package/src/sticky.js CHANGED
@@ -3,9 +3,11 @@
3
3
  * (`components/Layout.jsx`, `BlockRenderer.jsx`) and the prerender's (`ssr-renderer.js`) make the
4
4
  * same boxes sticky, and a prerendered page does not change when the browser renders over it.
5
5
  *
6
- * ⭐ NO BOX A COMPONENT DRAWS CAN STICK. `position: sticky` moves an element only within its
7
- * parent's box, and a component's markup sits in boxes as tall as itself: the section's wrapper,
8
- * the layout area's wrapper (`area-wrappers.js`), the built-in layout's zone (`default-layout.js`).
6
+ * ⭐ A COMPONENT'S OUTERMOST ELEMENT CANNOT STICK. `position: sticky` moves an element only within
7
+ * its parent's box, and a component sits in boxes as tall as itself: the section's wrapper, the
8
+ * layout area's wrapper (`area-wrappers.js`), the built-in layout's zone (`default-layout.js`). (An
9
+ * element inside a component can stick within a taller box the component draws — a side panel in
10
+ * the row beside a list — which is the component's own layout and asks nothing of this module.)
9
11
  * So a section asks — `sticky:`, one of a section's own settings (`@uniweb/schemas/section`),
10
12
  * defaulted by its type's `meta.js` — and the runtime makes sticky the box that can:
11
13
  *