@uniweb/core 0.8.6 → 0.9.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 +205 -0
- package/src/tracker.js +364 -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.9.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,205 @@
|
|
|
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 declaration object, whichever tier supplied it.
|
|
184
|
+
*
|
|
185
|
+
* `resolveService` answers *where*; this answers *with what options*. Only the
|
|
186
|
+
* object form carries any — a shorthand string is an address and nothing else.
|
|
187
|
+
* Used by `tracking:` for `consent:`; a future service with its own options
|
|
188
|
+
* reads them the same way rather than inventing a second lookup.
|
|
189
|
+
*
|
|
190
|
+
* The tiers are checked in the same order and for the same reason, so a site
|
|
191
|
+
* that authors the object form is not silently merged with a host's.
|
|
192
|
+
*
|
|
193
|
+
* @param {object} website
|
|
194
|
+
* @param {string} name
|
|
195
|
+
* @returns {object} the declaration object, or `{}` when there is none
|
|
196
|
+
*/
|
|
197
|
+
export function readServiceOptions(website, name) {
|
|
198
|
+
const config = website?.config
|
|
199
|
+
const authored = config?.[name]
|
|
200
|
+
if (authored !== undefined) {
|
|
201
|
+
return authored && typeof authored === 'object' ? authored : {}
|
|
202
|
+
}
|
|
203
|
+
const hosted = config?.services?.[name]
|
|
204
|
+
return hosted && typeof hosted === 'object' ? hosted : {}
|
|
205
|
+
}
|
package/src/tracker.js
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
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
|
+
this.consent = options.consentRequired ? 'pending' : 'granted'
|
|
164
|
+
this.consentRequired = !!options.consentRequired
|
|
165
|
+
|
|
166
|
+
this.queue = []
|
|
167
|
+
this.acquisition = null
|
|
168
|
+
|
|
169
|
+
// One key per document, minted only when there is somewhere to send it.
|
|
170
|
+
// Rides the ENVELOPE beside `event` rather than inside a payload, because
|
|
171
|
+
// every event needs it for the same reason `event` itself is there — and
|
|
172
|
+
// because `page_view`'s payload is closed at `{ path, referrer?, utm_* }`
|
|
173
|
+
// and this must not widen it.
|
|
174
|
+
this.visit = null
|
|
175
|
+
|
|
176
|
+
// The last path a `page_view` reported. Guards consecutive duplicates —
|
|
177
|
+
// React StrictMode double-invokes effects, and this makes "one report per
|
|
178
|
+
// path change" true here rather than contingent on every caller's
|
|
179
|
+
// dependency array. NOT revisit-dedupe: it is overwritten, so A→B→A
|
|
180
|
+
// reports three times.
|
|
181
|
+
this.currentPath = null
|
|
182
|
+
|
|
183
|
+
this.flushIntervalId = null
|
|
184
|
+
this.onPageHide = null
|
|
185
|
+
this.onVisibilityChange = null
|
|
186
|
+
this.framed = detectFramed()
|
|
187
|
+
|
|
188
|
+
if (isBrowser && this.isEnabled()) {
|
|
189
|
+
this.acquisition = captureAcquisition()
|
|
190
|
+
// Minted even when consent is pending: events buffered before the visitor
|
|
191
|
+
// answers must carry the SAME key as those after it, or granting consent
|
|
192
|
+
// would split one visit in two. It never leaves the device until the
|
|
193
|
+
// buffer flushes, so minting early costs nothing.
|
|
194
|
+
this.visit = mintVisitKey()
|
|
195
|
+
this.armFlushInterval()
|
|
196
|
+
this.armUnloadHandlers()
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
Object.seal(this)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Enabled means: a destination exists, we are in a browser, and we are not
|
|
204
|
+
* inside someone's iframe. Consent is checked separately — a consent-pending
|
|
205
|
+
* tracker is *enabled* and buffering, which is a different state from off.
|
|
206
|
+
*
|
|
207
|
+
* @returns {boolean}
|
|
208
|
+
*/
|
|
209
|
+
isEnabled() {
|
|
210
|
+
return !!this.endpoint && isBrowser && !this.framed
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** @returns {'granted'|'denied'|'pending'} */
|
|
214
|
+
consentStatus() {
|
|
215
|
+
return this.consent
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Record the visitor's decision. A consent component calls this through
|
|
220
|
+
* kit's `useTrackingConsent()`; foundations never touch this object.
|
|
221
|
+
*
|
|
222
|
+
* Granting flushes what was buffered — nothing left the device before the
|
|
223
|
+
* decision, and the views that preceded the click are not lost. Denying
|
|
224
|
+
* discards the buffer and stops accepting.
|
|
225
|
+
*
|
|
226
|
+
* @param {boolean} granted
|
|
227
|
+
*/
|
|
228
|
+
setConsent(granted) {
|
|
229
|
+
if (!this.isEnabled()) return
|
|
230
|
+
this.consent = granted ? 'granted' : 'denied'
|
|
231
|
+
if (granted) {
|
|
232
|
+
this.flush()
|
|
233
|
+
} else {
|
|
234
|
+
this.queue = []
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Report an event.
|
|
240
|
+
*
|
|
241
|
+
* @param {string} event - event name, e.g. 'video_milestone'. Open registry.
|
|
242
|
+
* @param {Object} [data] - the caller's own fields; `path` overrides the
|
|
243
|
+
* current route, which is what block-scoped callers supply.
|
|
244
|
+
*/
|
|
245
|
+
track(event, data = {}) {
|
|
246
|
+
if (!this.isEnabled() || !event) return
|
|
247
|
+
const { path, ...rest } = data
|
|
248
|
+
this.enqueue({ event, path: path || this.currentPath || undefined, ...rest })
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Report a page view. Framework-owned: it carries the acquisition context and
|
|
253
|
+
* dedupes consecutive reports of the same path.
|
|
254
|
+
*
|
|
255
|
+
* @param {string} path
|
|
256
|
+
*/
|
|
257
|
+
trackPageView(path) {
|
|
258
|
+
if (!this.isEnabled() || !path) return
|
|
259
|
+
if (path === this.currentPath) return
|
|
260
|
+
this.currentPath = path
|
|
261
|
+
// Promptly, rather than waiting out the batch window: a page view is the
|
|
262
|
+
// event most likely to be the only one of a short visit.
|
|
263
|
+
this.enqueue({ event: 'page_view', path, ...(this.acquisition || {}) }, true)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* @param {Object} event
|
|
268
|
+
* @param {boolean} [immediate=false]
|
|
269
|
+
* @private
|
|
270
|
+
*/
|
|
271
|
+
enqueue(event, immediate = false) {
|
|
272
|
+
if (this.consent === 'denied') return
|
|
273
|
+
|
|
274
|
+
// Envelope: the visit key is stamped here, at the one choke point every
|
|
275
|
+
// event passes through, so no caller can forget it or override it.
|
|
276
|
+
const envelope = { ...event, visit: this.visit }
|
|
277
|
+
|
|
278
|
+
if (this.consent === 'pending') {
|
|
279
|
+
if (this.queue.length >= MAX_PENDING) return
|
|
280
|
+
this.queue.push(envelope)
|
|
281
|
+
if (this.debug) console.log('[Tracker] Buffered pending consent:', envelope)
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
this.queue.push(envelope)
|
|
286
|
+
if (this.debug) console.log('[Tracker] Queued:', envelope)
|
|
287
|
+
|
|
288
|
+
if (immediate || this.queue.length >= this.maxQueueSize) this.flush()
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Send whatever is queued.
|
|
293
|
+
*
|
|
294
|
+
* ⛔ **A failed send is dropped, not retried.** The class this replaced put
|
|
295
|
+
* the events back with `queue.unshift(...)`, which left the queue at or above
|
|
296
|
+
* `maxQueueSize` so the *next* event flushed immediately — a tight loop
|
|
297
|
+
* against a dead endpoint, with `maxQueueSize` triggering a flush but never
|
|
298
|
+
* bounding the queue. Best-effort delivery is the norm for this shape, and
|
|
299
|
+
* `sendBeacon` is fire-and-forget regardless.
|
|
300
|
+
*
|
|
301
|
+
* @param {boolean} [useBeacon=false] - force `sendBeacon` (unload)
|
|
302
|
+
*/
|
|
303
|
+
flush(useBeacon = false) {
|
|
304
|
+
if (!this.isEnabled() || this.consent !== 'granted' || this.queue.length === 0) return
|
|
305
|
+
|
|
306
|
+
const events = this.queue
|
|
307
|
+
this.queue = []
|
|
308
|
+
|
|
309
|
+
const payload = JSON.stringify({ events })
|
|
310
|
+
if (this.debug) console.log('[Tracker] Flushing', events.length, 'event(s)')
|
|
311
|
+
|
|
312
|
+
if (useBeacon && typeof navigator !== 'undefined' && navigator.sendBeacon) {
|
|
313
|
+
const blob = new Blob([payload], { type: 'application/json' })
|
|
314
|
+
const sent = navigator.sendBeacon(this.endpoint, blob)
|
|
315
|
+
if (!sent && this.debug) console.warn('[Tracker] sendBeacon refused the payload')
|
|
316
|
+
return
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// The response is ignored entirely — no retry, no branch, no surfaced
|
|
320
|
+
// error. A host's 204, 403, 503 and a network failure are indistinguishable
|
|
321
|
+
// here by construction, which is what lets a host put its own preconditions
|
|
322
|
+
// in front of the collector without the client needing to know.
|
|
323
|
+
fetch(this.endpoint, {
|
|
324
|
+
method: 'POST',
|
|
325
|
+
headers: { 'Content-Type': 'application/json' },
|
|
326
|
+
body: payload,
|
|
327
|
+
keepalive: true
|
|
328
|
+
}).catch((error) => {
|
|
329
|
+
if (this.debug) console.warn('[Tracker] Flush failed:', error.message)
|
|
330
|
+
})
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** @private */
|
|
334
|
+
armFlushInterval() {
|
|
335
|
+
this.flushIntervalId = setInterval(() => this.flush(), this.flushInterval)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** @private */
|
|
339
|
+
armUnloadHandlers() {
|
|
340
|
+
this.onPageHide = () => this.flush(true)
|
|
341
|
+
this.onVisibilityChange = () => {
|
|
342
|
+
if (document.visibilityState === 'hidden') this.flush(true)
|
|
343
|
+
}
|
|
344
|
+
window.addEventListener('pagehide', this.onPageHide)
|
|
345
|
+
window.addEventListener('visibilitychange', this.onVisibilityChange)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Stop the interval, detach listeners, and send what is left. */
|
|
349
|
+
destroy() {
|
|
350
|
+
if (this.flushIntervalId) {
|
|
351
|
+
clearInterval(this.flushIntervalId)
|
|
352
|
+
this.flushIntervalId = null
|
|
353
|
+
}
|
|
354
|
+
if (isBrowser) {
|
|
355
|
+
if (this.onPageHide) window.removeEventListener('pagehide', this.onPageHide)
|
|
356
|
+
if (this.onVisibilityChange) {
|
|
357
|
+
window.removeEventListener('visibilitychange', this.onVisibilityChange)
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
this.onPageHide = null
|
|
361
|
+
this.onVisibilityChange = null
|
|
362
|
+
this.flush(true)
|
|
363
|
+
}
|
|
364
|
+
}
|
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
|
-
}
|