@uniweb/core 0.8.5 → 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 CHANGED
@@ -1,15 +1,19 @@
1
1
  {
2
2
  "name": "@uniweb/core",
3
- "version": "0.8.5",
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"
@@ -35,8 +39,8 @@
35
39
  "vitest": "^4.1.7"
36
40
  },
37
41
  "dependencies": {
38
- "@uniweb/theming": "^0.1.15",
39
- "@uniweb/semantic-parser": "^1.2.2"
42
+ "@uniweb/semantic-parser": "^1.2.2",
43
+ "@uniweb/theming": "^0.1.15"
40
44
  },
41
45
  "scripts": {
42
46
  "test": "vitest run"
@@ -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
+ }
@@ -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
- * Three forms of `detail:`:
161
- * - `'rest'` — append paramValue as a path segment.
162
- * - `'query'` — append `?paramName=paramValue`.
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
- const { detail } = collectionConfig
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
@@ -100,6 +100,35 @@ export function routePatternToRegex(pattern) {
100
100
  return { regex: new RegExp(`^${source}$`), paramNames }
101
101
  }
102
102
 
103
+ /**
104
+ * Decode a value that arrived from a URL, falling back to the raw input.
105
+ *
106
+ * Guarded rather than bare, for two independent reasons:
107
+ *
108
+ * A `%` that is not an escape is legitimate content — `/100%-Guide` authored by
109
+ * hand, or a value that has already been decoded once — and `decodeURIComponent`
110
+ * throws `URIError` on those. Falling back to the input keeps such a route
111
+ * matching exactly as well as it did before.
112
+ *
113
+ * And the input is attacker-controlled: `/blog/%zz` is a URL anyone can paste or
114
+ * link. This module is called by hosts that resolve a path to a page *per
115
+ * request*, where a throw out of the matcher is a visitor-triggerable 500 rather
116
+ * than a client-side error. A malformed escape is not a reason to lose an
117
+ * otherwise-good match, so the fallback is the raw capture rather than a miss —
118
+ * a route miss would turn a typo'd escape into a 404 on a page that exists.
119
+ *
120
+ * @param {string} value
121
+ * @returns {string}
122
+ */
123
+ export function decodeRouteValue(value) {
124
+ if (typeof value !== 'string' || !value.includes('%')) return value
125
+ try {
126
+ return decodeURIComponent(value)
127
+ } catch {
128
+ return value
129
+ }
130
+ }
131
+
103
132
  /**
104
133
  * Match a concrete path against a route pattern.
105
134
  *
@@ -109,8 +138,9 @@ export function routePatternToRegex(pattern) {
109
138
  * matchDynamicRoute('/blog/:slug', '/blog/') // → null (a param is non-empty)
110
139
  * ```
111
140
  *
112
- * Captured values are `decodeURIComponent`-ed, so a path carries percent
113
- * encoding and the param does not.
141
+ * Captured values are decoded, so a path carries percent encoding and the param
142
+ * does not. A malformed escape falls back to the raw capture rather than
143
+ * throwing — see `decodeRouteValue`. This function does not throw.
114
144
  *
115
145
  * @param {string} pattern - Route pattern with `:param` placeholders
116
146
  * @param {string} path - Concrete path to match
@@ -123,7 +153,7 @@ export function matchDynamicRoute(pattern, path) {
123
153
 
124
154
  const params = {}
125
155
  paramNames.forEach((name, i) => {
126
- params[name] = decodeURIComponent(match[i + 1])
156
+ params[name] = decodeRouteValue(match[i + 1])
127
157
  })
128
158
  return { params }
129
159
  }
@@ -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 Analytics from './analytics.js'
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
- this.analytics = new Analytics(content?.analytics || content?.config?.analytics || {})
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/website.js CHANGED
@@ -11,28 +11,7 @@ import FetcherDispatcher from './fetcher-dispatcher.js'
11
11
  import ObservableState from './observable-state.js'
12
12
  import { normalizeSeo } from './seo.js'
13
13
  import { resolveDefaultLocale } from './locale-config.js'
14
- import { matchDynamicRoute } from './route-match.js'
15
-
16
- /**
17
- * Decode a route that arrived from a URL.
18
- *
19
- * `location.pathname` is percent-encoded per RFC 3986, while page routes and
20
- * `i18n.routeTranslations` are authored as plain text — so the two are only
21
- * comparable once the incoming side is decoded.
22
- *
23
- * Guarded rather than bare: a route may legitimately contain a `%` that is not
24
- * an escape (`/100%-Guide` authored by hand, or a value already decoded once),
25
- * and `decodeURIComponent` throws `URIError` on those. Falling back to the input
26
- * keeps a malformed route matching exactly as well as it did before.
27
- */
28
- function decodeRoute(route) {
29
- if (typeof route !== 'string' || !route.includes('%')) return route
30
- try {
31
- return decodeURIComponent(route)
32
- } catch {
33
- return route
34
- }
35
- }
14
+ import { matchDynamicRoute, decodeRouteValue } from './route-match.js'
36
15
 
37
16
  /**
38
17
  * Website — orchestration root for a single site instance.
@@ -354,9 +333,9 @@ export default class Website {
354
333
  // Without it translateRoute() emits a URL this method cannot read back —
355
334
  // every translated route carrying a non-ASCII character or an apostrophe
356
335
  // resolved to nothing and rendered the 404 page, while the SAME route with
357
- // an all-ASCII slug worked. `route-match.js` already decodes captured
358
- // params for exactly this reason.
359
- const route = decodeRoute(displayRoute)
336
+ // an all-ASCII slug worked. The helper is shared with the captured-param
337
+ // decode in `route-match.js`, which needs the identical guard.
338
+ const route = decodeRouteValue(displayRoute)
360
339
 
361
340
  // Exact match
362
341
  const canonical = entry.reverse.get(route)
@@ -452,7 +431,7 @@ export default class Website {
452
431
  // Decode before ANY comparison: a published payload can hold translated
453
432
  // display routes verbatim, so the direct match below needs the same plain
454
433
  // text form the reverse-translate path does.
455
- let stripped = decodeRoute(route)
434
+ let stripped = decodeRouteValue(route)
456
435
  if (this.activeLocale && this.activeLocale !== this.defaultLocale) {
457
436
  const prefix = `/${this.activeLocale}`
458
437
  if (stripped === prefix || stripped === `${prefix}/`) {
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
- }