@uniweb/runtime 0.12.2 → 0.12.3

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.2",
3
+ "version": "0.12.3",
4
4
  "description": "Minimal runtime for loading Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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.24.1"
47
+ "@uniweb/build": "0.24.5"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "react": "^19.0.0",
@@ -11,6 +11,7 @@ import ThemeProvider from './ThemeProvider.jsx'
11
11
  import { useRememberScroll } from '../hooks/useRememberScroll.js'
12
12
  import { useLinkInterceptor } from '../hooks/useLinkInterceptor.js'
13
13
  import { usePageView } from '../hooks/usePageView.js'
14
+ import { useOutboundClicks } from '../hooks/useOutboundClicks.js'
14
15
 
15
16
  /**
16
17
  * WebsiteRenderer component
@@ -35,6 +36,11 @@ export default function WebsiteRenderer() {
35
36
  // is disabled and this does nothing at all.
36
37
  usePageView()
37
38
 
39
+ // Report where visitors go when they leave. Same unconditional-call contract
40
+ // as usePageView, and armed once for the document rather than per route — the
41
+ // listener is delegated on `document` and outlives SPA navigation.
42
+ useOutboundClicks()
43
+
38
44
  if (!website) {
39
45
  return (
40
46
  <div className="website-loading" style={{ padding: '2rem', textAlign: 'center', color: '#64748b' }}>
@@ -0,0 +1,64 @@
1
+ /**
2
+ * useOutboundClicks — arm outbound-link reporting for the document.
3
+ *
4
+ * ⭐ **This file is the only part always loaded.** The listener lives behind a
5
+ * dynamic import, so a site with no tracking destination downloads none of it —
6
+ * the same arrangement as `useSectionViews`.
7
+ *
8
+ * ⛔ **One gate, not two.** `tracking.isEnabled()` is the whole condition: it
9
+ * says there is somewhere to send *and* that this is a live document, so a
10
+ * framed authoring preview arms nothing (`Tracker.isLiveDocument`). Unlike
11
+ * `useSectionViews` there is no per-page flag to check — see the module header
12
+ * of `outbound-clicks.js` for why a page-level opt-in would cost a delivery
13
+ * projection and buy nothing.
14
+ *
15
+ * ## Armed ONCE for the document, not per page
16
+ *
17
+ * ⭐ **This is the difference from `useSectionViews` and it decides where the
18
+ * hook is mounted.** Sections are elements, so their observer must re-arm when
19
+ * the page's elements change. This is a single delegated listener on
20
+ * `document`, which outlives every SPA navigation — re-arming it per route
21
+ * would tear down and rebuild the same listener for no reason, and a click
22
+ * landing mid-swap would be lost. ⇒ It belongs in `WebsiteRenderer` beside
23
+ * `usePageView`, keyed on nothing.
24
+ *
25
+ * ## SSR
26
+ *
27
+ * There is no SSR twin, and that IS the suppression — the same as
28
+ * `usePageView`. Effects do not run under `renderToString` and a Worker isolate
29
+ * has no `document`. Nothing to remember to switch off.
30
+ */
31
+
32
+ import { useEffect } from 'react'
33
+
34
+ /**
35
+ * Safe to call unconditionally: it decides *when*, never *whether*.
36
+ */
37
+ export function useOutboundClicks() {
38
+ useEffect(() => {
39
+ const tracking = globalThis.uniweb?.tracking
40
+ if (!tracking?.isEnabled?.()) return
41
+
42
+ // `cancelled` guards the gap between asking for the module and getting it —
43
+ // a fast unmount would otherwise install a listener nothing ever removes.
44
+ let cancelled = false
45
+ let stop = null
46
+
47
+ import('../outbound-clicks.js')
48
+ .then((m) => {
49
+ if (cancelled) return
50
+ stop = m.observeOutboundClicks(tracking)
51
+ })
52
+ .catch(() => {
53
+ // Telemetry must never surface to a visitor. A chunk that fails to load
54
+ // costs some counts; nothing else about the page is affected.
55
+ })
56
+
57
+ return () => {
58
+ cancelled = true
59
+ if (stop) stop()
60
+ }
61
+ }, [])
62
+ }
63
+
64
+ export default useOutboundClicks
@@ -0,0 +1,117 @@
1
+ /**
2
+ * outbound_click — where a visitor goes when they leave.
3
+ *
4
+ * ⭐ **Reached behind a dynamic import** (`hooks/useOutboundClicks.js`), so a
5
+ * site with no tracking destination never downloads this file. Same arrangement
6
+ * as `section-views.js` and `script-loader.js`.
7
+ *
8
+ * ## ⛔ The HOSTNAME is the event. The URL never leaves the page.
9
+ *
10
+ * A full outbound URL can carry a query string — a search term, a token, a
11
+ * referral id someone pasted — that the site owner has no business collecting
12
+ * merely because a visitor clicked a link. **So the truncation happens HERE, at
13
+ * the emitter, not at the store.** A constraint applied at write time is a
14
+ * promise about someone else's code; applied at emit time the sensitive part
15
+ * never exists as data at all.
16
+ *
17
+ * ⚖️ **This is the one place the constraint can be enforced rather than
18
+ * agreed.** If it lived only in the collector, two producers would hold one
19
+ * rule and could disagree silently — the failure being *a full URL quietly
20
+ * stored*, which nothing surfaces as an error.
21
+ *
22
+ * ## No page-level opt-in, deliberately
23
+ *
24
+ * `section_view` needs one (`trackSections`) because its dimension is unbounded
25
+ * — a site's section types run to the hundreds and hosting capped the cardinality.
26
+ * **An outbound hostname is bounded by how many external sites a page links to**,
27
+ * which is small and does not grow with the site. So the only gate is
28
+ * `tracking.isEnabled()`: declaring a destination is already the operator's
29
+ * decision to measure.
30
+ *
31
+ * ⛔ **And a page flag would not be free — it would need a DELIVERY PROJECTION**
32
+ * (the stored flag → the runtime payload), which is a distinct item owned by a
33
+ * different lane and invisible from this one. That gap shipped once already and
34
+ * cost a day of a working emitter reporting nothing. **Not worth paying for a
35
+ * dimension that was never at risk.**
36
+ *
37
+ * @module @uniweb/runtime/outbound-clicks
38
+ */
39
+
40
+ /**
41
+ * Protocols worth reporting as *traffic leaving the site*.
42
+ *
43
+ * ⛔ `mailto:` and `tel:` are deliberately absent. They are contact intents, not
44
+ * navigations, and folding them in would put `mail` beside real destinations in
45
+ * a chart about where traffic goes. They are worth their own event if anyone
46
+ * asks for one; they are not worth corrupting this one.
47
+ */
48
+ const REPORTED_PROTOCOLS = new Set(['http:', 'https:'])
49
+
50
+ /**
51
+ * The hostname this click leaves for, or `null` if it does not leave.
52
+ *
53
+ * Exported for the tests: the whole privacy claim of this module is that only a
54
+ * hostname is ever produced, and that is worth asserting directly rather than
55
+ * through a listener.
56
+ *
57
+ * @param {string} href - the link's raw `href`, possibly relative
58
+ * @param {Location|URL} here - the document's own location
59
+ * @returns {string|null}
60
+ */
61
+ export function outboundHostname(href, here) {
62
+ if (!href) return null
63
+ let url
64
+ try {
65
+ // Resolving against the document is what makes a relative href resolve to
66
+ // OUR host and therefore drop out below. A bare `new URL(href)` would throw
67
+ // on every internal link and turn the common case into the error path.
68
+ url = new URL(href, here.href)
69
+ } catch {
70
+ return null
71
+ }
72
+ if (!REPORTED_PROTOCOLS.has(url.protocol)) return null
73
+ if (url.hostname === here.hostname) return null
74
+ // `url.hostname` — never `url.host` (which appends a port), never `url.href`,
75
+ // and nothing derived from `search` or `pathname`. This return is the
76
+ // enforcement point named in the module header.
77
+ return url.hostname || null
78
+ }
79
+
80
+ /**
81
+ * Listen for clicks that leave the site and report the destination host.
82
+ *
83
+ * @param {{track: (event: string, data?: object) => void}} tracking
84
+ * @returns {(() => void) | null} teardown, or null when there is no DOM
85
+ */
86
+ export function observeOutboundClicks(tracking) {
87
+ if (typeof document === 'undefined' || !tracking?.track) return null
88
+
89
+ const onClick = (event) => {
90
+ // `closest` rather than the target itself: the click almost always lands on
91
+ // a child of the anchor — an icon, a span, the text node's element.
92
+ const anchor = event.target?.closest?.('a[href]')
93
+ if (!anchor) return
94
+ const hostname = outboundHostname(anchor.getAttribute('href'), document.location)
95
+ if (!hostname) return
96
+ // Nothing is prevented and nothing is awaited. The navigation proceeds at
97
+ // full speed; the queued event survives it because the tracker beacons on
98
+ // `pagehide` and on `visibilitychange` to hidden.
99
+ tracking.track('outbound_click', { hostname })
100
+ }
101
+
102
+ // ⛔ CAPTURE phase. A foundation's own handler may call `stopPropagation()` on
103
+ // its links — a legitimate thing to do — and in the bubble phase that would
104
+ // silently zero the count for exactly the sites that decorate their links.
105
+ document.addEventListener('click', onClick, true)
106
+ // A middle click fires `auxclick`, not `click`. Opening a link in a new tab
107
+ // is a real outbound visit, and omitting it would undercount silently and
108
+ // unevenly — power users do it far more than average visitors.
109
+ document.addEventListener('auxclick', onClick, true)
110
+
111
+ return () => {
112
+ document.removeEventListener('click', onClick, true)
113
+ document.removeEventListener('auxclick', onClick, true)
114
+ }
115
+ }
116
+
117
+ export default observeOutboundClicks