@uniweb/core 0.8.6 → 0.10.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 +6 -2
- package/src/base-path.js +48 -0
- package/src/block.js +33 -0
- package/src/detail-url.js +102 -0
- package/src/entity-store.js +5 -60
- package/src/index.js +6 -0
- package/src/services.js +241 -0
- package/src/tracker.js +417 -0
- package/src/uniweb.js +16 -2
- package/src/analytics.js +0 -237
package/package.json
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./src/index.js",
|
|
8
|
+
"./base-path": "./src/base-path.js",
|
|
8
9
|
"./data-paths": "./src/data-paths.js",
|
|
10
|
+
"./detail-url": "./src/detail-url.js",
|
|
9
11
|
"./fetch-config": "./src/fetch-config.js",
|
|
10
12
|
"./locale-config": "./src/locale-config.js",
|
|
11
13
|
"./route-match": "./src/route-match.js",
|
|
12
|
-
"./section-id": "./src/section-id.js"
|
|
14
|
+
"./section-id": "./src/section-id.js",
|
|
15
|
+
"./services": "./src/services.js",
|
|
16
|
+
"./tracker": "./src/tracker.js"
|
|
13
17
|
},
|
|
14
18
|
"files": [
|
|
15
19
|
"src"
|
package/src/base-path.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base-path joining — a zero-dependency leaf.
|
|
3
|
+
*
|
|
4
|
+
* A site deployed under a subdirectory (`base: /docs/` in site.yml) serves
|
|
5
|
+
* every root-relative path under that prefix. This is the one function that
|
|
6
|
+
* applies it, and it is idempotent: an already-based path is not based twice.
|
|
7
|
+
*
|
|
8
|
+
* WHY IT LIVES IN CORE
|
|
9
|
+
* It began in `@uniweb/kit/utils/href`, which is where most callers are. But
|
|
10
|
+
* `@uniweb/core/services` needs it too, and **`@uniweb/runtime` does not depend
|
|
11
|
+
* on kit** — so a service address resolved in the runtime could not reach it.
|
|
12
|
+
* Rather than grow a second copy (the failure `@uniweb/core/route-match` was
|
|
13
|
+
* created to end, after one matcher was implemented twice and the copies
|
|
14
|
+
* diverged), it moved down to the layer both sides already depend on. Kit
|
|
15
|
+
* re-exports it, so no existing call site moved.
|
|
16
|
+
*
|
|
17
|
+
* Kept separate from `resolveRoute` deliberately: React Router supplies the
|
|
18
|
+
* base itself through its `basename`, so a Router-rendered link must not have
|
|
19
|
+
* it applied twice.
|
|
20
|
+
*
|
|
21
|
+
* @module @uniweb/core/base-path
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Prefix a site-root-relative href with the deployment base path.
|
|
26
|
+
*
|
|
27
|
+
* The invariant this encodes — a base is only ever joined to a path that
|
|
28
|
+
* starts at the site root — is the whole point of routing every caller
|
|
29
|
+
* through here. A bare `basePath + href` concatenation produces garbage the
|
|
30
|
+
* moment href turns out to be absolute (`/basehttps://example.com/x`), and
|
|
31
|
+
* whether it is absolute depends on a classification that has been wrong
|
|
32
|
+
* before. Guarding at the join makes the failure impossible rather than
|
|
33
|
+
* unlikely.
|
|
34
|
+
*
|
|
35
|
+
* Passed through untouched when: there is no base, the href is empty, the href
|
|
36
|
+
* is not root-relative (a bare relative path, or any absolute/scheme URL), the
|
|
37
|
+
* href is protocol-relative (`//host/…`), or the base is already applied.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} href - Href to prefix
|
|
40
|
+
* @param {string} basePath - Deployment base (no trailing slash), '' for root
|
|
41
|
+
* @returns {string} Href with the base applied, or unchanged if not applicable
|
|
42
|
+
*/
|
|
43
|
+
export function applyBasePath(href, basePath) {
|
|
44
|
+
if (!href || typeof href !== 'string' || !basePath) return href
|
|
45
|
+
if (!href.startsWith('/') || href.startsWith('//')) return href
|
|
46
|
+
if (href === basePath || href.startsWith(basePath + '/')) return href // already based
|
|
47
|
+
return basePath + href
|
|
48
|
+
}
|
package/src/block.js
CHANGED
|
@@ -273,6 +273,39 @@ export default class Block {
|
|
|
273
273
|
return `${this.path}-${this.id}`
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
+
/**
|
|
277
|
+
* Report an event from this section — a video milestone, a download, an
|
|
278
|
+
* expand, anything the foundation considers worth counting.
|
|
279
|
+
*
|
|
280
|
+
* ```js
|
|
281
|
+
* block.track('video_milestone', { milestone: 50 })
|
|
282
|
+
* ```
|
|
283
|
+
*
|
|
284
|
+
* The section type and the page path are attached automatically, because a
|
|
285
|
+
* block already knows both — a foundation should not have to thread context
|
|
286
|
+
* it was handed. Same arrangement as `useFormSubmit({ block })`.
|
|
287
|
+
*
|
|
288
|
+
* ⛔ **No guard is needed at the call site.** A site with no tracking
|
|
289
|
+
* destination is the default: the call returns having done nothing, opened no
|
|
290
|
+
* connection and thrown nothing. Absent is the normal state, not an error.
|
|
291
|
+
*
|
|
292
|
+
* ⭐ **This is the one tracking entry point that is not behind kit**, and that
|
|
293
|
+
* is deliberate rather than an exception: the block **arrives as a prop**
|
|
294
|
+
* (`{ content, params, block }`), so calling a method on it is not reaching
|
|
295
|
+
* for the `uniweb` global — which foundations must never do. For an event
|
|
296
|
+
* with no block in hand, use kit's `useTracker()`.
|
|
297
|
+
*
|
|
298
|
+
* @param {string} event - event name; the registry is open
|
|
299
|
+
* @param {Object} [data] - the caller's own fields
|
|
300
|
+
*/
|
|
301
|
+
track(event, data = {}) {
|
|
302
|
+
globalThis.uniweb?.tracking?.track(event, {
|
|
303
|
+
path: this.path,
|
|
304
|
+
section: this.type,
|
|
305
|
+
...data
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
|
|
276
309
|
/**
|
|
277
310
|
* The parent page's URL path, one level up from the current page.
|
|
278
311
|
* Use this for "Back" links in detail pages: /blog/1 → /blog
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detail-record resolution — the ONE home for turning a collection's `detail:`
|
|
3
|
+
* declaration plus a dynamic route's params into a fetch config.
|
|
4
|
+
*
|
|
5
|
+
* Why this is a subpath rather than an EntityStore internal. A host that
|
|
6
|
+
* renders a detail page server-side has to fetch *the same record* the browser
|
|
7
|
+
* will fetch when it hydrates over that render. The four `detail:` forms below
|
|
8
|
+
* each decide a different URL, so a host that resolves them even slightly
|
|
9
|
+
* differently prerenders record A and hydrates record B — silently, and only on
|
|
10
|
+
* the routes that have a pattern. That is the same failure `./route-match.js`
|
|
11
|
+
* was extracted to end, on the fetch side instead of the routing side.
|
|
12
|
+
*
|
|
13
|
+
* Zero-dependency leaf, like `./route-match.js`, `./data-paths.js` and
|
|
14
|
+
* `./fetch-config.js`: it imports one sibling that itself imports nothing, so a
|
|
15
|
+
* consumer that must not pull core's object graph — an edge worker, a build
|
|
16
|
+
* step — can import `@uniweb/core/detail-url` directly. No `node:*`, no DOM.
|
|
17
|
+
*
|
|
18
|
+
* ## What this module does NOT decide
|
|
19
|
+
*
|
|
20
|
+
* It builds a *request*, not a result. Whether the record exists, whether the
|
|
21
|
+
* fetch is cached, and what happens when it 404s are the caller's, exactly as
|
|
22
|
+
* a matched route pattern says nothing about the record behind it.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { substitutePlaceholders } from './substitute-placeholders.js'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build a detail-URL fetch config from a collection config + dynamic context.
|
|
29
|
+
*
|
|
30
|
+
* Four forms of `detail:`:
|
|
31
|
+
* - `'rest'` — append paramValue as a path segment.
|
|
32
|
+
* - `'query'` — append `?paramName=paramValue`.
|
|
33
|
+
* - `'/articles/{slug}'` — custom URL pattern with {paramName} placeholders.
|
|
34
|
+
* - `{ body, envelope }` — object form. Reuses the collection's url /
|
|
35
|
+
* method / headers / auth; adds per-detail
|
|
36
|
+
* body (with placeholder substitution) and
|
|
37
|
+
* per-detail envelope.
|
|
38
|
+
*
|
|
39
|
+
* Returns `null` — never throws — when the collection declares no `detail:`,
|
|
40
|
+
* when the dynamic context carries no param, or when the collection has
|
|
41
|
+
* neither `url:` nor `path:` to build from. A caller treats `null` as "this
|
|
42
|
+
* collection has no separate detail fetch", which is the common case.
|
|
43
|
+
*
|
|
44
|
+
* @param {Object} collectionConfig - A resolved fetch config for the collection
|
|
45
|
+
* (post-`resolveFetchConfigs`, so `detail` may have been auto-injected for a
|
|
46
|
+
* `deferred:` collection — see `./fetch-config.js`).
|
|
47
|
+
* @param {{ paramName: string, paramValue: string }} dynamicContext
|
|
48
|
+
* @returns {Object|null} A fetch config carrying `url` or `path`, or null.
|
|
49
|
+
*/
|
|
50
|
+
export function buildDetailConfig(collectionConfig, dynamicContext) {
|
|
51
|
+
const { detail } = collectionConfig
|
|
52
|
+
if (!detail) return null
|
|
53
|
+
const { paramName, paramValue } = dynamicContext
|
|
54
|
+
if (!paramName || paramValue === undefined) return null
|
|
55
|
+
|
|
56
|
+
const baseUrl = collectionConfig.url || collectionConfig.path
|
|
57
|
+
if (!baseUrl) return null
|
|
58
|
+
const isLocalPath = !!collectionConfig.path && !collectionConfig.url
|
|
59
|
+
|
|
60
|
+
// Object form: `detail: { body, envelope }`. Reuses collection's URL +
|
|
61
|
+
// method + headers + auth. The body is placeholder-substituted against
|
|
62
|
+
// the dynamic context so `body: { variables: { slug: "{slug}" } }` works.
|
|
63
|
+
if (detail && typeof detail === 'object') {
|
|
64
|
+
const out = {
|
|
65
|
+
...(isLocalPath ? { path: baseUrl } : { url: baseUrl }),
|
|
66
|
+
schema: collectionConfig.schema,
|
|
67
|
+
transform: collectionConfig.transform,
|
|
68
|
+
}
|
|
69
|
+
if (collectionConfig.method) out.method = collectionConfig.method
|
|
70
|
+
if (detail.body !== undefined) {
|
|
71
|
+
out.body = substitutePlaceholders(detail.body, { [paramName]: paramValue }, { encode: false })
|
|
72
|
+
} else if (collectionConfig.body !== undefined) {
|
|
73
|
+
out.body = substitutePlaceholders(collectionConfig.body, { [paramName]: paramValue }, { encode: false })
|
|
74
|
+
}
|
|
75
|
+
if (detail.envelope) out.envelope = detail.envelope
|
|
76
|
+
return out
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// String-form: URL-based conventions.
|
|
80
|
+
let detailUrl
|
|
81
|
+
if (detail === 'rest') {
|
|
82
|
+
const [basePath, queryString] = baseUrl.split('?')
|
|
83
|
+
const cleanBase = basePath.replace(/\/$/, '')
|
|
84
|
+
detailUrl = queryString
|
|
85
|
+
? `${cleanBase}/${encodeURIComponent(paramValue)}?${queryString}`
|
|
86
|
+
: `${cleanBase}/${encodeURIComponent(paramValue)}`
|
|
87
|
+
} else if (detail === 'query') {
|
|
88
|
+
const sep = baseUrl.includes('?') ? '&' : '?'
|
|
89
|
+
detailUrl = `${baseUrl}${sep}${paramName}=${encodeURIComponent(paramValue)}`
|
|
90
|
+
} else {
|
|
91
|
+
// Custom pattern like '/articles/{slug}' — substitute placeholders
|
|
92
|
+
// from the dynamic-route context. Only placeholders matching the
|
|
93
|
+
// active paramName resolve; others pass through as literal `{name}`.
|
|
94
|
+
detailUrl = substitutePlaceholders(detail, { [paramName]: paramValue })
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
...(isLocalPath ? { path: detailUrl } : { url: detailUrl }),
|
|
99
|
+
schema: collectionConfig.schema,
|
|
100
|
+
transform: collectionConfig.transform,
|
|
101
|
+
}
|
|
102
|
+
}
|
package/src/entity-store.js
CHANGED
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
* and in-flight dedup.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { substitutePlaceholders } from './substitute-placeholders.js'
|
|
18
17
|
import { isFetchRefinement, resolveFetchConfigs } from './fetch-config.js'
|
|
18
|
+
import { buildDetailConfig } from './detail-url.js'
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* Is `block.fetch` a per-instance refinement of the ancestor's fetch config
|
|
@@ -157,67 +157,12 @@ export default class EntityStore {
|
|
|
157
157
|
/**
|
|
158
158
|
* Build a detail-URL fetch config from a collection config + dynamic context.
|
|
159
159
|
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
* - `'/articles/{slug}'` — custom URL pattern with {paramName} placeholders.
|
|
164
|
-
* - `{ body, envelope }` — object form. Reuses the collection's url /
|
|
165
|
-
* method / headers / auth; adds per-detail
|
|
166
|
-
* body (with placeholder substitution) and
|
|
167
|
-
* per-detail envelope.
|
|
160
|
+
* Delegates to the exported resolver so a host fetching this record
|
|
161
|
+
* server-side reaches the identical rule — see `./detail-url.js` for why the
|
|
162
|
+
* four `detail:` forms are a contract rather than an implementation detail.
|
|
168
163
|
*/
|
|
169
164
|
_buildDetailConfig(collectionConfig, dynamicContext) {
|
|
170
|
-
|
|
171
|
-
if (!detail) return null
|
|
172
|
-
const { paramName, paramValue } = dynamicContext
|
|
173
|
-
if (!paramName || paramValue === undefined) return null
|
|
174
|
-
|
|
175
|
-
const baseUrl = collectionConfig.url || collectionConfig.path
|
|
176
|
-
if (!baseUrl) return null
|
|
177
|
-
const isLocalPath = !!collectionConfig.path && !collectionConfig.url
|
|
178
|
-
|
|
179
|
-
// Object form: `detail: { body, envelope }`. Reuses collection's URL +
|
|
180
|
-
// method + headers + auth. The body is placeholder-substituted against
|
|
181
|
-
// the dynamic context so `body: { variables: { slug: "{slug}" } }` works.
|
|
182
|
-
if (detail && typeof detail === 'object') {
|
|
183
|
-
const out = {
|
|
184
|
-
...(isLocalPath ? { path: baseUrl } : { url: baseUrl }),
|
|
185
|
-
schema: collectionConfig.schema,
|
|
186
|
-
transform: collectionConfig.transform,
|
|
187
|
-
}
|
|
188
|
-
if (collectionConfig.method) out.method = collectionConfig.method
|
|
189
|
-
if (detail.body !== undefined) {
|
|
190
|
-
out.body = substitutePlaceholders(detail.body, { [paramName]: paramValue }, { encode: false })
|
|
191
|
-
} else if (collectionConfig.body !== undefined) {
|
|
192
|
-
out.body = substitutePlaceholders(collectionConfig.body, { [paramName]: paramValue }, { encode: false })
|
|
193
|
-
}
|
|
194
|
-
if (detail.envelope) out.envelope = detail.envelope
|
|
195
|
-
return out
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// String-form: URL-based conventions.
|
|
199
|
-
let detailUrl
|
|
200
|
-
if (detail === 'rest') {
|
|
201
|
-
const [basePath, queryString] = baseUrl.split('?')
|
|
202
|
-
const cleanBase = basePath.replace(/\/$/, '')
|
|
203
|
-
detailUrl = queryString
|
|
204
|
-
? `${cleanBase}/${encodeURIComponent(paramValue)}?${queryString}`
|
|
205
|
-
: `${cleanBase}/${encodeURIComponent(paramValue)}`
|
|
206
|
-
} else if (detail === 'query') {
|
|
207
|
-
const sep = baseUrl.includes('?') ? '&' : '?'
|
|
208
|
-
detailUrl = `${baseUrl}${sep}${paramName}=${encodeURIComponent(paramValue)}`
|
|
209
|
-
} else {
|
|
210
|
-
// Custom pattern like '/articles/{slug}' — substitute placeholders
|
|
211
|
-
// from the dynamic-route context. Only placeholders matching the
|
|
212
|
-
// active paramName resolve; others pass through as literal `{name}`.
|
|
213
|
-
detailUrl = substitutePlaceholders(detail, { [paramName]: paramValue })
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
return {
|
|
217
|
-
...(isLocalPath ? { path: detailUrl } : { url: detailUrl }),
|
|
218
|
-
schema: collectionConfig.schema,
|
|
219
|
-
transform: collectionConfig.transform,
|
|
220
|
-
}
|
|
165
|
+
return buildDetailConfig(collectionConfig, dynamicContext)
|
|
221
166
|
}
|
|
222
167
|
|
|
223
168
|
/**
|
package/src/index.js
CHANGED
|
@@ -37,6 +37,12 @@ export {
|
|
|
37
37
|
} from './data-paths.js'
|
|
38
38
|
export { evaluate as evaluateWhere, match as matchWhere } from './where.js'
|
|
39
39
|
export { isRichSchema, normalizeSchema } from './schemas.js'
|
|
40
|
+
export { default as Tracker } from './tracker.js'
|
|
41
|
+
// Also available as the zero-dependency leaves `@uniweb/core/services` and
|
|
42
|
+
// `@uniweb/core/base-path` — which is how `@uniweb/runtime` reaches them,
|
|
43
|
+
// since it must not pull the package root into an SSR/Worker bundle.
|
|
44
|
+
export { resolveService, resolveServiceUrl, readServiceOptions } from './services.js'
|
|
45
|
+
export { applyBasePath } from './base-path.js'
|
|
40
46
|
export {
|
|
41
47
|
resolveStyle as resolveRequestStyle,
|
|
42
48
|
listStyleNames as listRequestStyleNames
|
package/src/services.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { applyBasePath } from './base-path.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Site services — where a site's search, form submissions, assistant, tracking,
|
|
5
|
+
* or anything else of that shape actually go.
|
|
6
|
+
*
|
|
7
|
+
* ## The one idea
|
|
8
|
+
*
|
|
9
|
+
* A component must never name a host. Whether this site's search is answered by
|
|
10
|
+
* a prebuilt index, a server endpoint, or a vendor API is a *deployment* fact,
|
|
11
|
+
* and a foundation that hardcodes it is coupled to one deployment. So the
|
|
12
|
+
* address comes from configuration, and there are exactly two places it can
|
|
13
|
+
* come from:
|
|
14
|
+
*
|
|
15
|
+
* 1. **The site**, authored — `search:`, `submit:`, `assistant:`, `tracking:`
|
|
16
|
+
* in site.yml. The operator's own declaration, and it wins.
|
|
17
|
+
* 2. **The host**, served — `config.services.<name>` in the payload. What the
|
|
18
|
+
* deployment offers, which the site never had to know about.
|
|
19
|
+
*
|
|
20
|
+
* Absent from both means the site has no such service, and the caller acts on
|
|
21
|
+
* that rather than guessing an address. That is the same rule for every service,
|
|
22
|
+
* and it is why this module exists: it was previously implemented three times —
|
|
23
|
+
* the search provider, the submit resolver, and a hand-rolled copy inside a
|
|
24
|
+
* foundation — with three slightly different base-joining rules between them.
|
|
25
|
+
*
|
|
26
|
+
* ## The registry is open, not an enum
|
|
27
|
+
*
|
|
28
|
+
* `resolveService(website, name)` takes a *name*, and the framework has no list
|
|
29
|
+
* of permitted ones. It ships **clients** only for what it already implements
|
|
30
|
+
* (search, form submission, tracking); it ships **resolution** for anything. A
|
|
31
|
+
* foundation that invents `booking` or `translate` gets the same precedence, the
|
|
32
|
+
* same base handling and the same absent-means-absent behaviour, and a host can
|
|
33
|
+
* fill the slot without a framework change.
|
|
34
|
+
*
|
|
35
|
+
* This is deliberate and it is the same shape as `fetcher.transports`: the
|
|
36
|
+
* framework owns the seam, not the catalogue.
|
|
37
|
+
*
|
|
38
|
+
* ## ⛔ WHY THIS IS IN CORE AND NOT IN KIT
|
|
39
|
+
*
|
|
40
|
+
* It began in `@uniweb/kit/utils/services.js`, and every foundation still
|
|
41
|
+
* reaches it there — kit re-exports this module unchanged, so no call site
|
|
42
|
+
* moved. It had to come down one layer because **`@uniweb/runtime` does not
|
|
43
|
+
* depend on `@uniweb/kit`** (only on core and theming), and the runtime resolves
|
|
44
|
+
* a service address itself for `tracking`. The alternative was a second resolver
|
|
45
|
+
* with the same job, which is the defect `@uniweb/core/route-match` exists to
|
|
46
|
+
* prevent.
|
|
47
|
+
*
|
|
48
|
+
* ⇒ **Foundations import from `@uniweb/kit`.** This path is the framework's own.
|
|
49
|
+
*
|
|
50
|
+
* ## What this deliberately does not model
|
|
51
|
+
*
|
|
52
|
+
* **Entitlement.** A host that will not serve a service omits it, or declares
|
|
53
|
+
* the name with no address. The framework never learns why — no plan names, no
|
|
54
|
+
* tiers, no "paid" anywhere. That is not squeamishness: this package is public,
|
|
55
|
+
* and a framework that encodes which capabilities cost money ships the business
|
|
56
|
+
* model into open source.
|
|
57
|
+
*
|
|
58
|
+
* ⛔ **There is deliberately no explanatory string, and there was one — it was
|
|
59
|
+
* a mistake.** Until 2026-08-13 a declining host could supply a `reason` that
|
|
60
|
+
* this module relayed "to the UI verbatim", with an English default when nothing
|
|
61
|
+
* did. Removed, on two counts:
|
|
62
|
+
*
|
|
63
|
+
* 1. **Wrong audience.** A visitor has no stake in which services an operator
|
|
64
|
+
* provisioned. "Submissions are not enabled for this site" reports someone's
|
|
65
|
+
* billing state to the public and reads like a breakage. It is neither — it
|
|
66
|
+
* is a service that was not bought, and **a generic component is supposed to
|
|
67
|
+
* be smart about that.**
|
|
68
|
+
* 2. **Wrong language, unfixably.** Sites here are multilingual, or unilingual
|
|
69
|
+
* and not English. A host-supplied sentence bypasses the site's entire
|
|
70
|
+
* localization pipeline, and a canned constant in a public package cannot
|
|
71
|
+
* be translated at all. Any text a visitor should read is *site content*,
|
|
72
|
+
* which is authored and localized — never a string a service layer invents.
|
|
73
|
+
*
|
|
74
|
+
* ⇒ **`url` is the whole answer, and absence is a behavioural decision rather
|
|
75
|
+
* than a message.** No submit endpoint → render no form, or degrade to something
|
|
76
|
+
* that still serves the visitor. No tracking endpoint → report nothing, silently.
|
|
77
|
+
*
|
|
78
|
+
* **The site's own base.** `config.base` is where the site *lives*, not a
|
|
79
|
+
* service it consumes — it is load-bearing for routing and asset URLs too. It
|
|
80
|
+
* stays where it is and is an input here, not an entry.
|
|
81
|
+
*
|
|
82
|
+
* @module @uniweb/core/services
|
|
83
|
+
*/
|
|
84
|
+
|
|
85
|
+
/** Anything with a scheme, or protocol-relative — never joined to a base. */
|
|
86
|
+
const ABSOLUTE_URL_RE = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Read an endpoint out of either declaration form.
|
|
90
|
+
*
|
|
91
|
+
* A site may write the shorthand (`submit: /forms`) or the object
|
|
92
|
+
* (`submit: { endpoint: /forms }`); a host emits JSON and normally writes the
|
|
93
|
+
* object. Both are accepted from both sides — one reader, no per-side rules to
|
|
94
|
+
* remember.
|
|
95
|
+
*
|
|
96
|
+
* @param {*} declaration
|
|
97
|
+
* @returns {string} the endpoint, or '' when there is none
|
|
98
|
+
*/
|
|
99
|
+
function readEndpoint(declaration) {
|
|
100
|
+
if (typeof declaration === 'string') return declaration.trim()
|
|
101
|
+
if (typeof declaration?.endpoint === 'string') return declaration.endpoint.trim()
|
|
102
|
+
return ''
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Join a service endpoint to the site's base path.
|
|
107
|
+
*
|
|
108
|
+
* Three cases, and the middle one is why this is not simply `applyBasePath`:
|
|
109
|
+
*
|
|
110
|
+
* - **Absolute** (`https://…`, `//host/…`, any scheme) — passed through. A
|
|
111
|
+
* service on another origin is not the site's to relocate.
|
|
112
|
+
* - **Bare relative** (`_search`) — rooted first. This spelling is documented
|
|
113
|
+
* and in use, and `applyBasePath` alone would leave it untouched, silently
|
|
114
|
+
* producing a request relative to whatever page the visitor is on.
|
|
115
|
+
* - **Root-relative** (`/forms`) — the ordinary case.
|
|
116
|
+
*
|
|
117
|
+
* The join itself goes through `applyBasePath` rather than concatenation,
|
|
118
|
+
* because that is where the invariant "a base is only ever joined to a path that
|
|
119
|
+
* starts at the site root" is enforced, and it is idempotent — an
|
|
120
|
+
* already-based path is not based twice.
|
|
121
|
+
*
|
|
122
|
+
* @param {string} endpoint
|
|
123
|
+
* @param {string} [basePath] - `website.basePath`
|
|
124
|
+
* @returns {string}
|
|
125
|
+
*/
|
|
126
|
+
export function resolveServiceUrl(endpoint, basePath = '') {
|
|
127
|
+
if (!endpoint) return ''
|
|
128
|
+
if (ABSOLUTE_URL_RE.test(endpoint)) return endpoint
|
|
129
|
+
|
|
130
|
+
const rooted = endpoint.startsWith('/') ? endpoint : `/${endpoint}`
|
|
131
|
+
// `applyBasePath` concatenates and documents its input as carrying no
|
|
132
|
+
// trailing slash, so normalizing is the caller's job — skip it and
|
|
133
|
+
// `base: /docs/` yields `/docs//forms`.
|
|
134
|
+
const base = (basePath || '').replace(/\/+$/, '')
|
|
135
|
+
return applyBasePath(rooted, base)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Resolve where a named service lives for this site.
|
|
140
|
+
*
|
|
141
|
+
* ```js
|
|
142
|
+
* const { url } = resolveService(website, 'submit')
|
|
143
|
+
* if (!url) return null // no endpoint — render no form, or degrade
|
|
144
|
+
* ```
|
|
145
|
+
*
|
|
146
|
+
* @param {object} website - the active Website
|
|
147
|
+
* @param {string} name - service name, e.g. 'submit' · 'search' · 'tracking'
|
|
148
|
+
* @returns {{ url: string|null, source: 'site'|'host'|null }}
|
|
149
|
+
* `url` is the whole answer for acting. `source` says which declaration
|
|
150
|
+
* answered — a diagnostic, and the thing to check when a host's value appears
|
|
151
|
+
* not to be taking effect. `'host'` with a null `url` means the host answered
|
|
152
|
+
* and offered no address; `null` means nothing declared the service at all.
|
|
153
|
+
*/
|
|
154
|
+
export function resolveService(website, name) {
|
|
155
|
+
const config = website?.config
|
|
156
|
+
const basePath = website?.basePath
|
|
157
|
+
|
|
158
|
+
// 1 — the site's own declaration wins. An operator who named an endpoint
|
|
159
|
+
// means it, including on a host that offers one.
|
|
160
|
+
const authored = readEndpoint(config?.[name])
|
|
161
|
+
if (authored) {
|
|
162
|
+
return { url: resolveServiceUrl(authored, basePath), source: 'site' }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 2 — what the host says it offers.
|
|
166
|
+
const hostDeclaration = config?.services?.[name]
|
|
167
|
+
const hostEndpoint = readEndpoint(hostDeclaration)
|
|
168
|
+
if (hostEndpoint) {
|
|
169
|
+
return { url: resolveServiceUrl(hostEndpoint, basePath), source: 'host' }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// A host may declare the name while offering no address — a decline. It is
|
|
173
|
+
// still the host answering, which is all a caller can use: any *wording* for
|
|
174
|
+
// that state would be ours to invent, in one language, for a visitor who has
|
|
175
|
+
// no stake in it. See the entitlement note above.
|
|
176
|
+
if (hostDeclaration !== undefined) return { url: null, source: 'host' }
|
|
177
|
+
|
|
178
|
+
// 3 — nobody supplied one.
|
|
179
|
+
return { url: null, source: null }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Read a service's options, filling each key from the first tier that declares
|
|
184
|
+
* it — **the site's value wins per key, and the host fills the gaps.**
|
|
185
|
+
*
|
|
186
|
+
* `resolveService` answers *where*; this answers *with what options*. Only the
|
|
187
|
+
* object form carries any — a shorthand string is an address and nothing else.
|
|
188
|
+
*
|
|
189
|
+
* ## ⛔ Why per-key rather than all-or-nothing
|
|
190
|
+
*
|
|
191
|
+
* This used to return the site's object whole whenever the site declared
|
|
192
|
+
* *anything*, so a single authored key hid every option the host offered. That
|
|
193
|
+
* put the two readers in this file on different rules, and the disagreement was
|
|
194
|
+
* not cosmetic:
|
|
195
|
+
*
|
|
196
|
+
* - `resolveService` already falls through **per key** — a site declaration
|
|
197
|
+
* carrying no `endpoint` lets the host's endpoint answer.
|
|
198
|
+
* - `readServiceOptions` fell through **not at all**.
|
|
199
|
+
*
|
|
200
|
+
* ⇒ A site declaring only `tracking: { tags: [...] }` therefore kept sending to
|
|
201
|
+
* the **host's** endpoint while discarding the **host's** `consent` setting —
|
|
202
|
+
* using someone's collector while ignoring their gate. Not a corner case: it is
|
|
203
|
+
* what an operator gets by turning on a third-party tag while their host
|
|
204
|
+
* supplies the collector.
|
|
205
|
+
*
|
|
206
|
+
* One rule now covers both readers, and it is the one a reader of two-tier
|
|
207
|
+
* config already expects: the more specific tier wins where it speaks, and says
|
|
208
|
+
* nothing where it is silent.
|
|
209
|
+
*
|
|
210
|
+
* ⚖️ **Consequence worth stating, because it decides a question that would
|
|
211
|
+
* otherwise need its own rule:** a host's `consent` applies only when the site
|
|
212
|
+
* declared none. That is the host *filling a gap*, never overriding an
|
|
213
|
+
* operator's decision — so there is no "most restrictive wins" special case,
|
|
214
|
+
* and an operator who wants no gate on a host that asks for one writes
|
|
215
|
+
* `consent: none` and is done.
|
|
216
|
+
*
|
|
217
|
+
* ⚠️ **The merge is shallow and deliberately so.** Keys replace, they do not
|
|
218
|
+
* combine: a site's `tags` replaces a host's rather than concatenating with it.
|
|
219
|
+
* Combining would make the result depend on what a host happens to offer, which
|
|
220
|
+
* is precisely the unpredictability a site's own config should not have.
|
|
221
|
+
*
|
|
222
|
+
* @param {object} website
|
|
223
|
+
* @param {string} name
|
|
224
|
+
* @returns {object} the effective options, or `{}` when no tier declares any
|
|
225
|
+
*/
|
|
226
|
+
export function readServiceOptions(website, name) {
|
|
227
|
+
const config = website?.config
|
|
228
|
+
return { ...asOptions(config?.services?.[name]), ...asOptions(config?.[name]) }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* A declaration contributes options only in its object form. A string is an
|
|
233
|
+
* address, an array is malformed, and neither carries a key worth spreading.
|
|
234
|
+
*
|
|
235
|
+
* @param {*} declaration
|
|
236
|
+
* @returns {object}
|
|
237
|
+
*/
|
|
238
|
+
function asOptions(declaration) {
|
|
239
|
+
if (!declaration || typeof declaration !== 'object' || Array.isArray(declaration)) return {}
|
|
240
|
+
return declaration
|
|
241
|
+
}
|
package/src/tracker.js
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracker — one event stream for a site.
|
|
3
|
+
*
|
|
4
|
+
* ⭐ **A page visit is a trackable event.** That sentence is the design. There is
|
|
5
|
+
* one destination, one envelope, and one queue; the runtime emits `page_view`
|
|
6
|
+
* automatically and a foundation emits whatever else it likes, through the same
|
|
7
|
+
* path. Design doc: `kb/framework/plans/tracking.md`.
|
|
8
|
+
*
|
|
9
|
+
* ```
|
|
10
|
+
* { event: 'page_view', path: '/about', referrer?, utm_* }
|
|
11
|
+
* { event: 'video_milestone', path: '/about', section: 'Hero', milestone: 50 }
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* ## ⛔ Absent means NO-OP, at every entry point
|
|
15
|
+
*
|
|
16
|
+
* A site with no tracking destination is the DEFAULT and the majority. So with
|
|
17
|
+
* no endpoint: nothing is queued, no interval is armed, no listener is
|
|
18
|
+
* registered, and every method returns immediately. **A caller never needs a
|
|
19
|
+
* guard** — `block.track(…)` on an unconfigured site is normal, not an error.
|
|
20
|
+
* Nothing here throws, rejects, or logs unless `debug` is on.
|
|
21
|
+
*
|
|
22
|
+
* ## ⛔ Nothing PERSISTENT is ever minted here
|
|
23
|
+
*
|
|
24
|
+
* No session id, no visitor id, no fingerprint, and **nothing written to any
|
|
25
|
+
* browser storage** — no cookie, no `localStorage`, no `sessionStorage`, no
|
|
26
|
+
* IndexedDB. The privacy hazard of an identifier is *persistence and
|
|
27
|
+
* cross-context linkage*, not existence, and that is the line this class holds.
|
|
28
|
+
* `tests/tracker.test.js` asserts it mechanically rather than by promise.
|
|
29
|
+
*
|
|
30
|
+
* ✅ **The one thing it does mint is `visit`** — an opaque key generated at
|
|
31
|
+
* construction, held only in this instance, sent on every event so a consumer
|
|
32
|
+
* can tell that these events came from the same page load. It **dies with the
|
|
33
|
+
* document**: a refresh, a new tab, or tomorrow all produce a different one, and
|
|
34
|
+
* nothing links them. It is a correlation token, not an identity — closer to a
|
|
35
|
+
* trace id than to a cookie, and strictly less linkable than the IP-and-UA
|
|
36
|
+
* derivations a collector can already compute for itself.
|
|
37
|
+
*
|
|
38
|
+
* ⚖️ **Why the framework mints it rather than a host supplying one.** A host
|
|
39
|
+
* that renders per request would have to embed the value in the document it
|
|
40
|
+
* serves — and that document is cacheable, so every visitor of one cached
|
|
41
|
+
* render would share a single key and collapse into one visitor, silently, with
|
|
42
|
+
* the numbers staying plausible. Generating it in the browser does not solve
|
|
43
|
+
* that problem, it removes it.
|
|
44
|
+
*
|
|
45
|
+
* *(The ported `Analytics` class this replaces stamped `sessionId` and
|
|
46
|
+
* `sessionDuration` on every payload — a day-scoped identity and a duration.
|
|
47
|
+
* Both are gone deliberately; `visit` is neither.)*
|
|
48
|
+
*
|
|
49
|
+
* ## Field lifetime — captured once, replayed on every page view
|
|
50
|
+
*
|
|
51
|
+
* `document.referrer` and the landing `utm_*` params exist **at arrival and
|
|
52
|
+
* nowhere afterwards**: the referrer never changes across SPA navigation, and
|
|
53
|
+
* the params leave the URL on the first navigation. So they are captured once,
|
|
54
|
+
* here, and attached to every `page_view` of the document.
|
|
55
|
+
*
|
|
56
|
+
* ⇒ **Consequence worth knowing when reading the numbers:** a per-view facet
|
|
57
|
+
* built on them is *derived, not observed*. `utm_source` counts "views by
|
|
58
|
+
* visitors who **arrived** via X", never "views that **carried** X".
|
|
59
|
+
*
|
|
60
|
+
* @module @uniweb/core/tracker
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined'
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A framed document is the editor's live-preview iframe far more often than it
|
|
67
|
+
* is a legitimately embedded site, and the failure modes are asymmetric: an
|
|
68
|
+
* embedded site going uncounted is an undercount — visible and complainable —
|
|
69
|
+
* while a site owner's authoring session inflating their own numbers is a silent
|
|
70
|
+
* overcount that corrupts what the data means. Default to the side that fails
|
|
71
|
+
* loudly.
|
|
72
|
+
*
|
|
73
|
+
* A cross-origin parent throws on `window.top` access; that also means framed.
|
|
74
|
+
*/
|
|
75
|
+
function detectFramed() {
|
|
76
|
+
if (!isBrowser) return false
|
|
77
|
+
try {
|
|
78
|
+
return window.top !== window.self
|
|
79
|
+
} catch {
|
|
80
|
+
return true
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Acquisition context, read once at construction. See "Field lifetime" above. */
|
|
85
|
+
function captureAcquisition() {
|
|
86
|
+
if (!isBrowser) return null
|
|
87
|
+
|
|
88
|
+
const context = {}
|
|
89
|
+
|
|
90
|
+
// Same-origin referrers are dropped: internal navigation is not a referral,
|
|
91
|
+
// and counting it would make a site its own top referrer on every page.
|
|
92
|
+
const referrer = document.referrer
|
|
93
|
+
if (referrer) {
|
|
94
|
+
try {
|
|
95
|
+
if (new URL(referrer).origin !== window.location.origin) {
|
|
96
|
+
context.referrer = referrer
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
// Unparseable — treat as absent rather than forwarding a malformed value.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const params = new URLSearchParams(window.location.search)
|
|
105
|
+
for (const key of ['utm_source', 'utm_medium', 'utm_campaign']) {
|
|
106
|
+
const value = params.get(key)
|
|
107
|
+
if (value) context[key] = value
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
// No parseable query string — nothing to attach.
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return Object.keys(context).length > 0 ? context : null
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* An opaque key for one document lifetime. Never stored, never persisted.
|
|
118
|
+
*
|
|
119
|
+
* `crypto.randomUUID` needs a secure context, so a site served over plain HTTP
|
|
120
|
+
* falls through to a non-cryptographic value — which is correct rather than a
|
|
121
|
+
* compromise: this is a correlation token with no security property to preserve,
|
|
122
|
+
* and a collision only merges two visits in one site's own numbers.
|
|
123
|
+
*/
|
|
124
|
+
function mintVisitKey() {
|
|
125
|
+
try {
|
|
126
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
127
|
+
return crypto.randomUUID()
|
|
128
|
+
}
|
|
129
|
+
} catch {
|
|
130
|
+
// Secure-context restrictions throw rather than return undefined in places.
|
|
131
|
+
}
|
|
132
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* How many events may accumulate while consent is pending.
|
|
137
|
+
*
|
|
138
|
+
* The ordinary `maxQueueSize` triggers a *flush*, which is a no-op while
|
|
139
|
+
* pending — so without a hard cap a site that never answers the consent
|
|
140
|
+
* question would grow the queue for the whole session. Newest are dropped
|
|
141
|
+
* rather than oldest: the first `page_view` is the one worth keeping.
|
|
142
|
+
*/
|
|
143
|
+
const MAX_PENDING = 50
|
|
144
|
+
|
|
145
|
+
export default class Tracker {
|
|
146
|
+
/**
|
|
147
|
+
* @param {Object} options
|
|
148
|
+
* @param {string} [options.endpoint] - destination; **required to enable**
|
|
149
|
+
* @param {boolean} [options.consentRequired=false] - hold everything until granted
|
|
150
|
+
* @param {number} [options.flushInterval=5000]
|
|
151
|
+
* @param {number} [options.maxQueueSize=10]
|
|
152
|
+
* @param {boolean} [options.debug=false]
|
|
153
|
+
*/
|
|
154
|
+
constructor(options = {}) {
|
|
155
|
+
this.endpoint = options.endpoint || null
|
|
156
|
+
this.flushInterval = options.flushInterval || 5000
|
|
157
|
+
this.maxQueueSize = options.maxQueueSize || 10
|
|
158
|
+
this.debug = options.debug || false
|
|
159
|
+
|
|
160
|
+
// 'granted' | 'denied' | 'pending'. Without a consent requirement the
|
|
161
|
+
// operator's act of declaring a destination IS the decision, and the
|
|
162
|
+
// framework does not presume a jurisdiction on their behalf.
|
|
163
|
+
// ⛔ The requirement is consumed HERE and not stored. A `consentRequired`
|
|
164
|
+
// field was kept alongside and read by nothing — dead instance state in a
|
|
165
|
+
// class every site loads, which is what `destroy()` and its three fields
|
|
166
|
+
// were removed for. The starting status is the whole of what the flag means.
|
|
167
|
+
this.consent = options.consentRequired ? 'pending' : 'granted'
|
|
168
|
+
|
|
169
|
+
this.queue = []
|
|
170
|
+
this.acquisition = null
|
|
171
|
+
|
|
172
|
+
// One key per document, minted only when there is somewhere to send it.
|
|
173
|
+
// Rides the ENVELOPE beside `event` rather than inside a payload, because
|
|
174
|
+
// every event needs it for the same reason `event` itself is there — and
|
|
175
|
+
// because `page_view`'s payload is closed at `{ path, referrer?, utm_* }`
|
|
176
|
+
// and this must not widen it.
|
|
177
|
+
this.visit = null
|
|
178
|
+
|
|
179
|
+
// The last path a `page_view` reported. Guards consecutive duplicates —
|
|
180
|
+
// React StrictMode double-invokes effects, and this makes "one report per
|
|
181
|
+
// path change" true here rather than contingent on every caller's
|
|
182
|
+
// dependency array. NOT revisit-dedupe: it is overwritten, so A→B→A
|
|
183
|
+
// reports three times.
|
|
184
|
+
this.currentPath = null
|
|
185
|
+
|
|
186
|
+
this.framed = detectFramed()
|
|
187
|
+
|
|
188
|
+
// Called once when consent moves to granted, and never otherwise. The
|
|
189
|
+
// runtime uses it to load a site's declared third-party tags at the moment
|
|
190
|
+
// they become permitted; nothing in core knows or cares what it does.
|
|
191
|
+
//
|
|
192
|
+
// ⛔ Declared HERE because the instance is sealed below — an assignment to
|
|
193
|
+
// an undeclared property throws in module code, and the caller assigns this
|
|
194
|
+
// after construction. Same reason `Uniweb.defaultInsets` is pre-declared.
|
|
195
|
+
this.onGranted = null
|
|
196
|
+
|
|
197
|
+
if (isBrowser && this.isEnabled()) {
|
|
198
|
+
this.acquisition = captureAcquisition()
|
|
199
|
+
// Minted even when consent is pending: events buffered before the visitor
|
|
200
|
+
// answers must carry the SAME key as those after it, or granting consent
|
|
201
|
+
// would split one visit in two. It never leaves the device until the
|
|
202
|
+
// buffer flushes, so minting early costs nothing.
|
|
203
|
+
this.visit = mintVisitKey()
|
|
204
|
+
this.armFlushInterval()
|
|
205
|
+
this.armUnloadHandlers()
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
Object.seal(this)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Whether this document is one where the site's telemetry should run at all —
|
|
213
|
+
* a real visit in a browser, rather than a server render or a framed
|
|
214
|
+
* authoring preview. Says nothing about whether anything is *configured*.
|
|
215
|
+
*
|
|
216
|
+
* Split out from `isEnabled()` because a second consumer needs exactly this
|
|
217
|
+
* half: the runtime loads a site's declared third-party tags, which have no
|
|
218
|
+
* endpoint of ours to check but must be suppressed in the same contexts and
|
|
219
|
+
* for the same reason. One predicate, so the two cannot drift.
|
|
220
|
+
*
|
|
221
|
+
* @returns {boolean}
|
|
222
|
+
*/
|
|
223
|
+
isLiveDocument() {
|
|
224
|
+
return isBrowser && !this.framed
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Enabled means: a destination exists, and this is a live document. Consent
|
|
229
|
+
* is checked separately — a consent-pending tracker is *enabled* and
|
|
230
|
+
* buffering, which is a different state from off.
|
|
231
|
+
*
|
|
232
|
+
* @returns {boolean}
|
|
233
|
+
*/
|
|
234
|
+
isEnabled() {
|
|
235
|
+
return !!this.endpoint && this.isLiveDocument()
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** @returns {'granted'|'denied'|'pending'} */
|
|
239
|
+
consentStatus() {
|
|
240
|
+
return this.consent
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Record the visitor's decision. A consent component calls this through
|
|
245
|
+
* kit's `useTrackingConsent()`; foundations never touch this object.
|
|
246
|
+
*
|
|
247
|
+
* Granting flushes what was buffered — nothing left the device before the
|
|
248
|
+
* decision, and the views that preceded the click are not lost. Denying
|
|
249
|
+
* discards the buffer and stops accepting.
|
|
250
|
+
*
|
|
251
|
+
* ⛔ **Recording the decision is NOT gated on `isEnabled()`, deliberately.**
|
|
252
|
+
* Consent is *the visitor's answer*; enablement is *whether we have anywhere
|
|
253
|
+
* to send*. Two different questions, and conflating them meant a decision
|
|
254
|
+
* could not be recorded **when no destination resolved** — benign while our
|
|
255
|
+
* own queue is the only thing gated on consent, a correctness bug the moment
|
|
256
|
+
* anything else is.
|
|
257
|
+
*
|
|
258
|
+
* The same conflation suppressed recording inside a framed document. That
|
|
259
|
+
* suppression exists so an authoring session cannot inflate a site's own
|
|
260
|
+
* numbers (see `detectFramed`), and it belongs on the *sending*: with
|
|
261
|
+
* `consentRequired` the status starts `'pending'` and could not move at all,
|
|
262
|
+
* so a banner following the documented pattern would render and then never
|
|
263
|
+
* dismiss.
|
|
264
|
+
*
|
|
265
|
+
* Nothing is sent as a result: `flush()` keeps its own `isEnabled()` guard,
|
|
266
|
+
* so a disabled tracker still transmits nothing no matter what is recorded
|
|
267
|
+
* here. This changes what the tracker *remembers*, never what it *emits*.
|
|
268
|
+
*
|
|
269
|
+
* @param {boolean} granted
|
|
270
|
+
*/
|
|
271
|
+
setConsent(granted) {
|
|
272
|
+
const wasGranted = this.consent === 'granted'
|
|
273
|
+
this.consent = granted ? 'granted' : 'denied'
|
|
274
|
+
|
|
275
|
+
if (!granted) {
|
|
276
|
+
this.queue = []
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
this.flush()
|
|
281
|
+
|
|
282
|
+
// Fires on the TRANSITION only, so a component calling grant() twice does
|
|
283
|
+
// not load a site's tags twice. The callback is cleared as it runs: this is
|
|
284
|
+
// a one-time permission, not a subscription.
|
|
285
|
+
if (!wasGranted && this.onGranted) {
|
|
286
|
+
const notify = this.onGranted
|
|
287
|
+
this.onGranted = null
|
|
288
|
+
notify()
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Report an event.
|
|
294
|
+
*
|
|
295
|
+
* @param {string} event - event name, e.g. 'video_milestone'. Open registry.
|
|
296
|
+
* @param {Object} [data] - the caller's own fields; `path` overrides the
|
|
297
|
+
* current route, which is what block-scoped callers supply.
|
|
298
|
+
*/
|
|
299
|
+
track(event, data = {}) {
|
|
300
|
+
if (!this.isEnabled() || !event) return
|
|
301
|
+
const { path, ...rest } = data
|
|
302
|
+
this.enqueue({ event, path: path || this.currentPath || undefined, ...rest })
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Report a page view. Framework-owned: it carries the acquisition context and
|
|
307
|
+
* dedupes consecutive reports of the same path.
|
|
308
|
+
*
|
|
309
|
+
* @param {string} path
|
|
310
|
+
*/
|
|
311
|
+
trackPageView(path) {
|
|
312
|
+
if (!this.isEnabled() || !path) return
|
|
313
|
+
if (path === this.currentPath) return
|
|
314
|
+
this.currentPath = path
|
|
315
|
+
// Promptly, rather than waiting out the batch window: a page view is the
|
|
316
|
+
// event most likely to be the only one of a short visit.
|
|
317
|
+
this.enqueue({ event: 'page_view', path, ...(this.acquisition || {}) }, true)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* @param {Object} event
|
|
322
|
+
* @param {boolean} [immediate=false]
|
|
323
|
+
* @private
|
|
324
|
+
*/
|
|
325
|
+
enqueue(event, immediate = false) {
|
|
326
|
+
if (this.consent === 'denied') return
|
|
327
|
+
|
|
328
|
+
// Envelope: the visit key is stamped here, at the one choke point every
|
|
329
|
+
// event passes through, so no caller can forget it or override it.
|
|
330
|
+
const envelope = { ...event, visit: this.visit }
|
|
331
|
+
|
|
332
|
+
if (this.consent === 'pending') {
|
|
333
|
+
if (this.queue.length >= MAX_PENDING) return
|
|
334
|
+
this.queue.push(envelope)
|
|
335
|
+
if (this.debug) console.log('[Tracker] Buffered pending consent:', envelope)
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
this.queue.push(envelope)
|
|
340
|
+
if (this.debug) console.log('[Tracker] Queued:', envelope)
|
|
341
|
+
|
|
342
|
+
if (immediate || this.queue.length >= this.maxQueueSize) this.flush()
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Send whatever is queued.
|
|
347
|
+
*
|
|
348
|
+
* ⛔ **A failed send is dropped, not retried.** The class this replaced put
|
|
349
|
+
* the events back with `queue.unshift(...)`, which left the queue at or above
|
|
350
|
+
* `maxQueueSize` so the *next* event flushed immediately — a tight loop
|
|
351
|
+
* against a dead endpoint, with `maxQueueSize` triggering a flush but never
|
|
352
|
+
* bounding the queue. Best-effort delivery is the norm for this shape, and
|
|
353
|
+
* `sendBeacon` is fire-and-forget regardless.
|
|
354
|
+
*
|
|
355
|
+
* @param {boolean} [useBeacon=false] - force `sendBeacon` (unload)
|
|
356
|
+
*/
|
|
357
|
+
flush(useBeacon = false) {
|
|
358
|
+
if (!this.isEnabled() || this.consent !== 'granted' || this.queue.length === 0) return
|
|
359
|
+
|
|
360
|
+
const events = this.queue
|
|
361
|
+
this.queue = []
|
|
362
|
+
|
|
363
|
+
const payload = JSON.stringify({ events })
|
|
364
|
+
if (this.debug) console.log('[Tracker] Flushing', events.length, 'event(s)')
|
|
365
|
+
|
|
366
|
+
if (useBeacon && typeof navigator !== 'undefined' && navigator.sendBeacon) {
|
|
367
|
+
const blob = new Blob([payload], { type: 'application/json' })
|
|
368
|
+
const sent = navigator.sendBeacon(this.endpoint, blob)
|
|
369
|
+
if (!sent && this.debug) console.warn('[Tracker] sendBeacon refused the payload')
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// The response is ignored entirely — no retry, no branch, no surfaced
|
|
374
|
+
// error. A host's 204, 403, 503 and a network failure are indistinguishable
|
|
375
|
+
// here by construction, which is what lets a host put its own preconditions
|
|
376
|
+
// in front of the collector without the client needing to know.
|
|
377
|
+
fetch(this.endpoint, {
|
|
378
|
+
method: 'POST',
|
|
379
|
+
headers: { 'Content-Type': 'application/json' },
|
|
380
|
+
body: payload,
|
|
381
|
+
keepalive: true
|
|
382
|
+
}).catch((error) => {
|
|
383
|
+
if (this.debug) console.warn('[Tracker] Flush failed:', error.message)
|
|
384
|
+
})
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Both of these are armed once, for the life of the document, and are never
|
|
389
|
+
* detached — so neither the interval id nor the handler references are kept.
|
|
390
|
+
*
|
|
391
|
+
* ⛔ **There is deliberately no `destroy()`.** One shipped, was called by
|
|
392
|
+
* nothing, and cost 334 bytes minified in a package **every site loads
|
|
393
|
+
* whether it tracks or not** (`@uniweb/core` is not tree-shaken — the
|
|
394
|
+
* singleton's constructor holds a `Tracker`, so the class can never be
|
|
395
|
+
* dropped). Removing it took three instance fields with it, since they
|
|
396
|
+
* existed only to serve it.
|
|
397
|
+
*
|
|
398
|
+
* ⭐ The precedent is the thing this class replaced: `analytics.js` was dead
|
|
399
|
+
* code that shipped to every site for months because nobody deleted it.
|
|
400
|
+
* Adding a never-called teardown method would have been the same mistake at
|
|
401
|
+
* smaller scale. If a lifecycle that needs teardown ever appears, it arrives
|
|
402
|
+
* *with* its call site — which is the order that keeps this honest.
|
|
403
|
+
*
|
|
404
|
+
* @private
|
|
405
|
+
*/
|
|
406
|
+
armFlushInterval() {
|
|
407
|
+
setInterval(() => this.flush(), this.flushInterval)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** @private */
|
|
411
|
+
armUnloadHandlers() {
|
|
412
|
+
window.addEventListener('pagehide', () => this.flush(true))
|
|
413
|
+
window.addEventListener('visibilitychange', () => {
|
|
414
|
+
if (document.visibilityState === 'hidden') this.flush(true)
|
|
415
|
+
})
|
|
416
|
+
}
|
|
417
|
+
}
|
package/src/uniweb.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import Website from './website.js'
|
|
15
|
-
import
|
|
15
|
+
import Tracker from './tracker.js'
|
|
16
16
|
|
|
17
17
|
export default class Uniweb {
|
|
18
18
|
/**
|
|
@@ -72,7 +72,21 @@ export default class Uniweb {
|
|
|
72
72
|
// Populated by prerender before rendering, read synchronously by Icon.
|
|
73
73
|
this.iconCache = new Map()
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
// Site tracking — one event stream (`kb/framework/plans/tracking.md`).
|
|
76
|
+
//
|
|
77
|
+
// ⛔ Deliberately constructed DISABLED, and the runtime replaces it in L2
|
|
78
|
+
// (`wire-foundation.js` → `wireTracker`). It cannot be configured here even
|
|
79
|
+
// though `activeWebsite` already exists two lines up, because the address is
|
|
80
|
+
// resolved against `website.basePath` and **that is still `''` at this
|
|
81
|
+
// point** — `setBasePath()` runs later, from the runtime. Resolving here
|
|
82
|
+
// would silently drop the base prefix on every subdirectory deployment.
|
|
83
|
+
//
|
|
84
|
+
// The disabled instance is not a placeholder to null-check: it is a working
|
|
85
|
+
// no-op, so `uniweb.tracking.track(…)` is safe in every lane — press,
|
|
86
|
+
// unipress, an SSR isolate, or before wiring — with no guard at the call
|
|
87
|
+
// site. Same slot-declared-here-so-seal-permits-assignment pattern as
|
|
88
|
+
// `defaultInsets` above.
|
|
89
|
+
this.tracking = new Tracker()
|
|
76
90
|
|
|
77
91
|
Object.seal(this)
|
|
78
92
|
}
|
package/src/analytics.js
DELETED
|
@@ -1,237 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Analytics
|
|
3
|
-
*
|
|
4
|
-
* Lightweight analytics class for tracking page views, events, and scroll depth.
|
|
5
|
-
* Uses batched sending with sendBeacon for reliable delivery.
|
|
6
|
-
*
|
|
7
|
-
* Features:
|
|
8
|
-
* - Batched event queue with periodic flush
|
|
9
|
-
* - Page view tracking
|
|
10
|
-
* - Custom event tracking
|
|
11
|
-
* - Scroll depth tracking (25%, 50%, 75%, 100%)
|
|
12
|
-
* - sendBeacon for reliable unload delivery
|
|
13
|
-
* - Optional - silently ignores if not configured
|
|
14
|
-
*
|
|
15
|
-
* Usage via uniweb singleton:
|
|
16
|
-
* ```js
|
|
17
|
-
* // Track events (no-op if analytics not configured)
|
|
18
|
-
* uniweb.analytics.trackPageView('/about', 'About Us')
|
|
19
|
-
* uniweb.analytics.trackEvent('button_click', { buttonId: 'cta' })
|
|
20
|
-
* uniweb.analytics.trackScrollDepth(50)
|
|
21
|
-
* ```
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
// Check if running in browser environment
|
|
25
|
-
const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined'
|
|
26
|
-
|
|
27
|
-
export default class Analytics {
|
|
28
|
-
/**
|
|
29
|
-
* @param {Object} options
|
|
30
|
-
* @param {string} options.endpoint - Analytics endpoint URL (required to enable)
|
|
31
|
-
* @param {number} options.flushInterval - Interval to flush queue in ms (default: 5000)
|
|
32
|
-
* @param {number} options.maxQueueSize - Max events before auto-flush (default: 10)
|
|
33
|
-
* @param {boolean} options.debug - Enable debug logging (default: false)
|
|
34
|
-
*/
|
|
35
|
-
constructor(options = {}) {
|
|
36
|
-
this.endpoint = options.endpoint || null
|
|
37
|
-
this.flushInterval = options.flushInterval || 5000
|
|
38
|
-
this.maxQueueSize = options.maxQueueSize || 10
|
|
39
|
-
this.debug = options.debug || false
|
|
40
|
-
|
|
41
|
-
// Event queue
|
|
42
|
-
this.queue = []
|
|
43
|
-
|
|
44
|
-
// Track scroll depth milestones already sent (to avoid duplicates)
|
|
45
|
-
this.scrollMilestones = new Set()
|
|
46
|
-
|
|
47
|
-
// Session info
|
|
48
|
-
this.sessionId = this.generateSessionId()
|
|
49
|
-
this.sessionStart = Date.now()
|
|
50
|
-
|
|
51
|
-
// Flush interval ID (for cleanup)
|
|
52
|
-
this.flushIntervalId = null
|
|
53
|
-
|
|
54
|
-
// Only set up browser handlers if in browser and configured
|
|
55
|
-
if (isBrowser && this.isEnabled()) {
|
|
56
|
-
this.setupFlushInterval()
|
|
57
|
-
this.setupUnloadHandler()
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
Object.seal(this)
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Check if analytics is enabled
|
|
65
|
-
* @returns {boolean}
|
|
66
|
-
*/
|
|
67
|
-
isEnabled() {
|
|
68
|
-
return !!this.endpoint
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Generate a simple session ID
|
|
73
|
-
* @returns {string}
|
|
74
|
-
*/
|
|
75
|
-
generateSessionId() {
|
|
76
|
-
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Set up periodic flush interval
|
|
81
|
-
*/
|
|
82
|
-
setupFlushInterval() {
|
|
83
|
-
this.flushIntervalId = setInterval(() => {
|
|
84
|
-
this.flush()
|
|
85
|
-
}, this.flushInterval)
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Set up unload handler for final flush
|
|
90
|
-
*/
|
|
91
|
-
setupUnloadHandler() {
|
|
92
|
-
const handleUnload = () => {
|
|
93
|
-
this.flush(true) // Force beacon
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
window.addEventListener('visibilitychange', () => {
|
|
97
|
-
if (document.visibilityState === 'hidden') {
|
|
98
|
-
handleUnload()
|
|
99
|
-
}
|
|
100
|
-
})
|
|
101
|
-
|
|
102
|
-
window.addEventListener('pagehide', handleUnload)
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Add event to queue
|
|
107
|
-
* @param {string} type - Event type
|
|
108
|
-
* @param {Object} data - Event data
|
|
109
|
-
*/
|
|
110
|
-
addToQueue(type, data) {
|
|
111
|
-
if (!this.isEnabled() || !isBrowser) return
|
|
112
|
-
|
|
113
|
-
const event = {
|
|
114
|
-
type,
|
|
115
|
-
data,
|
|
116
|
-
timestamp: Date.now(),
|
|
117
|
-
sessionId: this.sessionId,
|
|
118
|
-
url: window.location.href,
|
|
119
|
-
referrer: document.referrer || null
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
this.queue.push(event)
|
|
123
|
-
|
|
124
|
-
if (this.debug) {
|
|
125
|
-
console.log('[Analytics] Event queued:', event)
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// Auto-flush if queue is full
|
|
129
|
-
if (this.queue.length >= this.maxQueueSize) {
|
|
130
|
-
this.flush()
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Track a page view
|
|
136
|
-
* @param {string} path - Page path
|
|
137
|
-
* @param {string} title - Page title
|
|
138
|
-
* @param {Object} meta - Additional metadata
|
|
139
|
-
*/
|
|
140
|
-
trackPageView(path, title, meta = {}) {
|
|
141
|
-
// Reset scroll milestones for new page
|
|
142
|
-
this.scrollMilestones.clear()
|
|
143
|
-
|
|
144
|
-
this.addToQueue('pageview', {
|
|
145
|
-
path,
|
|
146
|
-
title,
|
|
147
|
-
...meta
|
|
148
|
-
})
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* Track a custom event
|
|
153
|
-
* @param {string} name - Event name
|
|
154
|
-
* @param {Object} data - Event data
|
|
155
|
-
*/
|
|
156
|
-
trackEvent(name, data = {}) {
|
|
157
|
-
this.addToQueue('event', {
|
|
158
|
-
name,
|
|
159
|
-
...data
|
|
160
|
-
})
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* Track scroll depth milestone
|
|
165
|
-
* @param {number} percentage - Scroll depth percentage (25, 50, 75, 100)
|
|
166
|
-
*/
|
|
167
|
-
trackScrollDepth(percentage) {
|
|
168
|
-
// Only track standard milestones
|
|
169
|
-
const milestones = [25, 50, 75, 100]
|
|
170
|
-
if (!milestones.includes(percentage)) return
|
|
171
|
-
|
|
172
|
-
// Don't track the same milestone twice per page
|
|
173
|
-
if (this.scrollMilestones.has(percentage)) return
|
|
174
|
-
|
|
175
|
-
this.scrollMilestones.add(percentage)
|
|
176
|
-
|
|
177
|
-
this.addToQueue('scroll_depth', {
|
|
178
|
-
depth: percentage
|
|
179
|
-
})
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Flush the event queue
|
|
184
|
-
* @param {boolean} useBeacon - Force use of sendBeacon (for unload)
|
|
185
|
-
*/
|
|
186
|
-
flush(useBeacon = false) {
|
|
187
|
-
if (!this.isEnabled() || !isBrowser || this.queue.length === 0) return
|
|
188
|
-
|
|
189
|
-
const events = [...this.queue]
|
|
190
|
-
this.queue = []
|
|
191
|
-
|
|
192
|
-
const payload = JSON.stringify({
|
|
193
|
-
events,
|
|
194
|
-
sessionId: this.sessionId,
|
|
195
|
-
sessionDuration: Date.now() - this.sessionStart
|
|
196
|
-
})
|
|
197
|
-
|
|
198
|
-
if (this.debug) {
|
|
199
|
-
console.log('[Analytics] Flushing', events.length, 'events')
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// Use sendBeacon for reliable delivery on page unload
|
|
203
|
-
if (useBeacon && navigator.sendBeacon) {
|
|
204
|
-
const blob = new Blob([payload], { type: 'application/json' })
|
|
205
|
-
const sent = navigator.sendBeacon(this.endpoint, blob)
|
|
206
|
-
|
|
207
|
-
if (!sent && this.debug) {
|
|
208
|
-
console.warn('[Analytics] sendBeacon failed, events may be lost')
|
|
209
|
-
}
|
|
210
|
-
return
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// Use fetch for normal flush
|
|
214
|
-
fetch(this.endpoint, {
|
|
215
|
-
method: 'POST',
|
|
216
|
-
headers: { 'Content-Type': 'application/json' },
|
|
217
|
-
body: payload,
|
|
218
|
-
keepalive: true // Allows request to outlive page
|
|
219
|
-
}).catch((error) => {
|
|
220
|
-
if (this.debug) {
|
|
221
|
-
console.warn('[Analytics] Flush failed:', error)
|
|
222
|
-
}
|
|
223
|
-
// Put events back in queue for retry
|
|
224
|
-
this.queue.unshift(...events)
|
|
225
|
-
})
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* Clean up (stop interval, flush remaining events)
|
|
230
|
-
*/
|
|
231
|
-
destroy() {
|
|
232
|
-
if (this.flushIntervalId) {
|
|
233
|
-
clearInterval(this.flushIntervalId)
|
|
234
|
-
}
|
|
235
|
-
this.flush(true)
|
|
236
|
-
}
|
|
237
|
-
}
|