@uniweb/runtime 0.12.9 → 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.
|
|
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.
|
|
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.
|
|
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' }}>
|
package/src/document-tracking.js
CHANGED
|
@@ -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`
|
|
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
|