@uniweb/runtime 0.12.8 → 0.12.10

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": "0.12.8",
3
+ "version": "0.12.10",
4
4
  "description": "Minimal runtime for loading Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -36,7 +36,7 @@
36
36
  "node": ">=20.19"
37
37
  },
38
38
  "dependencies": {
39
- "@uniweb/core": "^0.11.1",
39
+ "@uniweb/core": "^0.12.0",
40
40
  "@uniweb/theming": "^0.1.15"
41
41
  },
42
42
  "devDependencies": {
@@ -44,7 +44,7 @@
44
44
  "esbuild": "^0.21.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.27.0",
45
45
  "vite": "^7.3.1",
46
46
  "vitest": "^4.1.7",
47
- "@uniweb/build": "0.25.0"
47
+ "@uniweb/build": "0.25.3"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "react": "^19.0.0",
@@ -12,6 +12,7 @@ import { useRememberScroll } from '../hooks/useRememberScroll.js'
12
12
  import { useLinkInterceptor } from '../hooks/useLinkInterceptor.js'
13
13
  import { usePageView } from '../hooks/usePageView.js'
14
14
  import { useOutboundClicks } from '../hooks/useOutboundClicks.js'
15
+ import { useSectionClicks } from '../hooks/useSectionClicks.js'
15
16
 
16
17
  /**
17
18
  * WebsiteRenderer component
@@ -41,6 +42,11 @@ export default function WebsiteRenderer() {
41
42
  // listener is delegated on `document` and outlives SPA navigation.
42
43
  useOutboundClicks()
43
44
 
45
+ // Which sections visitors interact with. Same contract and the same arming
46
+ // condition, so it shares the chunk — but a separate `arms()` call, because a
47
+ // host may consume one of the two and not the other.
48
+ useSectionClicks()
49
+
44
50
  if (!website) {
45
51
  return (
46
52
  <div className="website-loading" style={{ padding: '2rem', textAlign: 'center', color: '#64748b' }}>
@@ -4,10 +4,15 @@
4
4
  * ⭐ **The grouping rule, which is what this file is really for.** Everything
5
5
  * here needs exactly `tracking.arms(<event>)` with no further condition: a
6
6
  * destination exists, this is a live document, and neither the host nor the site
7
- * narrowed the event away. Today that is `outbound_click` alone; the next
8
- * emitter meeting the same condition belongs in this chunk, and one meeting a
7
+ * narrowed the event away. Today that is `outbound_click` and `section_click`;
8
+ * an emitter meeting the same condition belongs in this chunk, and one meeting a
9
9
  * different condition does not.
10
10
  *
11
+ * ⛔ **Two events, two independent arming calls, two listeners — deliberately.**
12
+ * A host may name one and not the other, so they cannot share a registration:
13
+ * folding them into one listener would arm both whenever either was selected.
14
+ * The grouping is about WHEN THE CHUNK LOADS, never about sharing a callback.
15
+ *
11
16
  * ⛔ **`section-views.js` stays separate** — its condition is narrower (a page
12
17
  * may override, so it is armed per page rather than per document). ⛔ **And
13
18
  * `script-loader.js` stays separate on a different axis entirely** — a site can
@@ -136,3 +141,115 @@ export function observeOutboundClicks(tracking) {
136
141
  document.removeEventListener('auxclick', onClick, true)
137
142
  }
138
143
  }
144
+
145
+ /* ------------------------------------------------------------------ *
146
+ * section_click — which sections a visitor actually interacts with.
147
+ *
148
+ * ⭐ **Armed per DOCUMENT, not per page — unlike `section_view`.** That event
149
+ * needs `trackSections` because it emits **once per section per page view**, so
150
+ * a long page costs many times what a short one does and the cardinality had to
151
+ * be capped at the granularity the cost varies at. **A click is bounded by what
152
+ * a visitor does**, exactly like `outbound_click`, so the cost shape that forced
153
+ * the page flag does not arise here.
154
+ *
155
+ * ⇒ **And therefore no delivery projection.** A page-level flag would need the
156
+ * stored flag carried onto the runtime payload — a distinct item owned by
157
+ * another lane and invisible from this one. That gap shipped once already and
158
+ * cost a day of a working emitter reporting nothing.
159
+ *
160
+ * ⛔ **THE ELEMENT LOOKUP GOES THROUGH THE GRAPH, never through a selector.**
161
+ * There is no `data-section-id` attribute in this renderer — a section wrapper
162
+ * carries `id="section-<stableId>"`, written by `sectionDomId()`. And the
163
+ * obvious repair, `closest('[id^="section-"]')`, is also wrong: a hardcoded
164
+ * prefix is a second copy of a rule that already drifted once — the search
165
+ * extractor kept emitting `Section1` while the DOM said
166
+ * `section-what-is-uniweb`, every section-level result on every site pointed at
167
+ * a fragment that did not exist, and no test failed.
168
+ *
169
+ * ⚖️ **Both wrong versions fail the same way and it is the worst way**: a
170
+ * selector that matches nothing throws nothing and logs nothing. It reports
171
+ * zero clicks on every section of every site, with the payload internally
172
+ * consistent and every check green.
173
+ *
174
+ * ⭐ **Double-counting with `outbound_click` is DELIBERATE.** A link inside a
175
+ * section is both a section interaction and a departure; both answers are true,
176
+ * and deduping would under-count whichever lost the tie-break. ⛔ The constraint
177
+ * is a *reporting* one — the two must never be summed into a "clicks" total.
178
+ * ------------------------------------------------------------------ */
179
+
180
+ import { sectionDomId } from '@uniweb/core/section-id'
181
+
182
+ /**
183
+ * The rendered sections of one page, as a DOM-id → Block lookup.
184
+ *
185
+ * Derived from the graph and keyed by the same helper the renderers write the
186
+ * id with, so this cannot drift from what is in the document. A block whose
187
+ * element is absent is skipped — in split-content mode a page's sections can
188
+ * arrive later, and a click before then simply does not resolve.
189
+ *
190
+ * @param {object} page - the active `Page`
191
+ * @returns {Map<string, object>}
192
+ */
193
+ function sectionsById(page) {
194
+ const map = new Map()
195
+ for (const block of page?.bodyBlocks || []) {
196
+ const id = sectionDomId(block)
197
+ if (document.getElementById(id)) map.set(id, block)
198
+ }
199
+ return map
200
+ }
201
+
202
+ /**
203
+ * Report clicks landing inside a section, attributed to that section.
204
+ *
205
+ * @param {() => object} getPage - reads the CURRENTLY active page. A function
206
+ * rather than a value because one listener outlives every SPA
207
+ * navigation; capturing a page would attribute every later click to the
208
+ * page the visitor first landed on.
209
+ * @returns {(() => void) | null} teardown, or null when there is no DOM
210
+ */
211
+ export function observeSectionClicks(getPage) {
212
+ if (typeof document === 'undefined' || typeof getPage !== 'function') return null
213
+
214
+ // Rebuilt when the page identity changes, not on every click. Navigation is
215
+ // the only thing that invalidates it, and it is the cheap moment to notice.
216
+ let cachedPage = null
217
+ let cachedMap = null
218
+
219
+ const onClick = (event) => {
220
+ const page = getPage()
221
+ if (!page) return
222
+ if (page !== cachedPage) {
223
+ cachedPage = page
224
+ cachedMap = sectionsById(page)
225
+ }
226
+ if (!cachedMap.size) return
227
+
228
+ // Walk up rather than `closest(selector)`: the ids come from the graph, so
229
+ // there is no selector to write that is not a second copy of the id rule.
230
+ // `id` values can also contain characters a selector would have to escape.
231
+ let el = event.target
232
+ while (el && el.nodeType === 1) {
233
+ const block = el.id ? cachedMap.get(el.id) : undefined
234
+ if (block) {
235
+ // Through the BLOCK, so `path`, `section` (the type) and `section_id`
236
+ // (this instance) are attached by the one envelope every section-scoped
237
+ // event shares. The consumer joins clicks to views on those fields.
238
+ block.track('section_click')
239
+ return
240
+ }
241
+ el = el.parentElement
242
+ }
243
+ }
244
+
245
+ // ⛔ CAPTURE, and `auxclick` alongside `click` — same two reasons as
246
+ // `outbound_click` above: a foundation may `stopPropagation()` on its own
247
+ // handlers, and a middle click fires only `auxclick`.
248
+ document.addEventListener('click', onClick, true)
249
+ document.addEventListener('auxclick', onClick, true)
250
+
251
+ return () => {
252
+ document.removeEventListener('click', onClick, true)
253
+ document.removeEventListener('auxclick', onClick, true)
254
+ }
255
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * useSectionClicks — arm section-click reporting for the document.
3
+ *
4
+ * ⭐ **This file is the only part always loaded.** The listener lives behind a
5
+ * dynamic import into `document-tracking.js` — the same chunk `outbound_click`
6
+ * uses, because the two arm on the same condition — so a site with no tracking
7
+ * destination downloads none of it.
8
+ *
9
+ * ⛔ **One call, three questions.** `tracking.arms('section_click')` asks all of
10
+ * them: is there somewhere to send in a live document (so a framed authoring
11
+ * preview arms nothing), will the host consume this row, and did the site select
12
+ * it.
13
+ *
14
+ * ## ⛔ THIS IS NOT IN THE `standard` PRESET, and that is deliberate
15
+ *
16
+ * ⭐ **`section_click` is the FIRST event where `standard` and `all` diverge** —
17
+ * the divergence `wire-foundation.js`'s `EMIT_PRESETS` was written in
18
+ * anticipation of. `standard` is a **curated** set that a framework release must
19
+ * not grow behind an operator's back; `all` is the standing yes. So:
20
+ *
21
+ * ```
22
+ * emit: (absent) / standard -> does NOT arm <- the DEFAULT
23
+ * emit: all -> arms
24
+ * emit: [ ..., section_click] -> arms
25
+ * ```
26
+ *
27
+ * ⛔ **Do NOT "fix" this by adding the name to `standard`.** That would make a
28
+ * site which changed nothing start sending more, which is the exact surprise the
29
+ * two presets exist to prevent — and it is a promise the docs already make to
30
+ * operators.
31
+ *
32
+ * ⚠️ **The consequence a consumer must be told, because nothing surfaces it:** a
33
+ * host may declare this event and a collector may store it, and **a default site
34
+ * still emits none of it**. Every gate reads yes and the series is flat. That is
35
+ * correct behaviour, not a fault, but it is indistinguishable from a broken
36
+ * emitter unless someone says so first.
37
+ *
38
+ * ⛔ **NO `trackSections` override is passed, and that is deliberate.** That
39
+ * flag exists because `section_view` emits **once per section per page view**,
40
+ * so its cost scales with page length and had to be capped where the cost
41
+ * varies. A click is bounded by what a visitor does, so the same control would
42
+ * buy nothing here — and it would cost a delivery projection to carry.
43
+ *
44
+ * ⚠️ **Consequence worth knowing when reading the numbers:** on a page with
45
+ * `trackSections: false` this still reports clicks, so the click population is
46
+ * strictly larger than the view population and **a click can exist with no view
47
+ * row to divide by.** A per-section CTR is therefore only sound on sections
48
+ * present in both. *(Named here because it is the emitter's doing, not the
49
+ * collector's.)*
50
+ *
51
+ * ## Armed ONCE for the document, not per page
52
+ *
53
+ * ⭐ Same as `useOutboundClicks`, and for the same reason: this is a single
54
+ * delegated listener on `document`, which outlives every SPA navigation.
55
+ * Re-arming per route would tear down and rebuild the same listener for no
56
+ * reason, and a click landing mid-swap would be lost. The *page* it attributes
57
+ * to is read at click time instead — see `observeSectionClicks`.
58
+ *
59
+ * ## SSR
60
+ *
61
+ * There is no SSR twin, and that IS the suppression — effects do not run under
62
+ * `renderToString` and a Worker isolate has no `document`.
63
+ */
64
+
65
+ import { useEffect } from 'react'
66
+
67
+ /**
68
+ * Safe to call unconditionally: it decides *when*, never *whether*.
69
+ */
70
+ export function useSectionClicks() {
71
+ useEffect(() => {
72
+ const tracking = globalThis.uniweb?.tracking
73
+ if (!tracking?.arms?.('section_click')) return
74
+
75
+ // `cancelled` guards the gap between asking for the module and getting it —
76
+ // a fast unmount would otherwise install a listener nothing ever removes.
77
+ let cancelled = false
78
+ let stop = null
79
+
80
+ import('../document-tracking.js')
81
+ .then((m) => {
82
+ if (cancelled) return
83
+ // Read the active page at CLICK time, not now: one listener spans every
84
+ // navigation, and capturing the page here would attribute every later
85
+ // click to whichever page the visitor first landed on.
86
+ stop = m.observeSectionClicks(() => globalThis.uniweb?.activeWebsite?.activePage)
87
+ })
88
+ .catch(() => {
89
+ // Telemetry must never surface to a visitor. A chunk that fails to load
90
+ // costs some counts; nothing else about the page is affected.
91
+ })
92
+
93
+ return () => {
94
+ cancelled = true
95
+ if (stop) stop()
96
+ }
97
+ }, [])
98
+ }
99
+
100
+ export default useSectionClicks