@uniweb/core 0.10.1 → 0.11.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,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/core",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
5
5
  "type": "module",
6
6
  "exports": {
package/src/page.js CHANGED
@@ -48,13 +48,24 @@ export default class Page {
48
48
  // Rewrite target (if set, this route is served by an external site)
49
49
  this.rewrite = pageData.rewrite || null
50
50
 
51
- // Opt this page into section-level instrumentation (`trackSections` in
52
- // page.yml). Off by default, and PAGE-LEVEL ONLY — there is deliberately no
53
- // site-wide or folder-level spelling, because one event per section on every
54
- // page of a site produces far more distinct values than an analytics
55
- // consumer can reasonably retain. The runtime reads it to decide whether to
56
- // arm an observer; it means nothing without a tracking destination.
57
- this.trackSections = pageData.trackSections || false
51
+ // This page's answer on section-level instrumentation (`trackSections` in
52
+ // page.yml) an OVERRIDE, not a switch.
53
+ //
54
+ // **Three states, and the third is the point.** `undefined` means the page
55
+ // said nothing and the site's own `tracking.emit` decides; `true` and
56
+ // `false` override it in either direction, so an owner can instrument one
57
+ // page of a quiet site or exempt a noisy page of a loud one.
58
+ //
59
+ // ⛔ **`|| false` was wrong here and collapsed the tri-state**, which made
60
+ // page-level the ONLY spelling and forced every owner to tick pages
61
+ // individually. The wire already carried all three — `content-collector`
62
+ // emits the key only when it is `!= null` — so nothing but this line had to
63
+ // change. ⇒ Read `?? undefined`, never a boolean coercion.
64
+ //
65
+ // The runtime passes it to `Tracker.arms('section_view', …)`, which is where
66
+ // the precedence against the host's own list is resolved. It means nothing
67
+ // without a tracking destination.
68
+ this.trackSections = pageData.trackSections ?? undefined
58
69
 
59
70
  // Two orthogonal visibility axes:
60
71
  // • `hidden` — REACHABILITY. When true the page is excluded from the published
package/src/tracker.js CHANGED
@@ -171,13 +171,28 @@ export default class Tracker {
171
171
  * @param {Object} options
172
172
  * @param {string} [options.endpoint] - destination; **required to enable**
173
173
  * @param {boolean} [options.consentRequired=false] - hold everything until granted
174
- * @param {number} [options.flushInterval=5000]
174
+ * @param {number} [options.flushIntervalMs=5000] - batch window, MILLISECONDS
175
175
  * @param {number} [options.maxQueueSize=10]
176
176
  * @param {boolean} [options.debug=false]
177
177
  */
178
178
  constructor(options = {}) {
179
179
  this.endpoint = options.endpoint || null
180
- this.flushInterval = options.flushInterval || 5000
180
+ // **`Ms` is in the name because the unit cannot be inferred from the
181
+ // value, and the failure is silent and inverted.** A bare `flushInterval:
182
+ // 30` meaning *thirty seconds* reads here as 30 **milliseconds** — flushing
183
+ // ~33x a second, the opposite of the intent, indistinguishable from working
184
+ // config, and paid for by the host rather than by whoever typed it. The wire
185
+ // field the host emits is `flushIntervalMs` for the same reason; this option
186
+ // is spelled to match so nothing has to translate between them.
187
+ //
188
+ // ⛔ **No floor is imposed, deliberately.** A minimum here would be a second
189
+ // copy of a policy the host already owns, able only to disagree with theirs —
190
+ // the same reason this framework never co-owns a serve location. What is
191
+ // rejected is not a *small* value but an *invalid* one: anything non-finite
192
+ // or <= 0 would arm a spinning or never-firing timer, so it falls back to the
193
+ // default rather than being honoured.
194
+ const interval = options.flushIntervalMs
195
+ this.flushIntervalMs = Number.isFinite(interval) && interval > 0 ? interval : 5000
181
196
  this.maxQueueSize = options.maxQueueSize || 10
182
197
  this.debug = options.debug || false
183
198
 
@@ -207,6 +222,17 @@ export default class Tracker {
207
222
  // reports three times.
208
223
  this.currentPath = null
209
224
 
225
+ // What the runtime may ARM, as two independent narrowings. Both are `null`
226
+ // when nothing narrows, which is the common case and the cheap one.
227
+ //
228
+ // ⛔ **Neither ever filters `track()`.** The registry is open by design, and
229
+ // a client-side allowlist over a foundation's own events would export one
230
+ // host's policy onto every host — including hosts that sent no list, whose
231
+ // sites would silently drop everything their foundation emits. These gate
232
+ // the runtime's OWN emissions and nothing else. See `arms()`.
233
+ this.hostEvents = options.hostEvents ? new Set(options.hostEvents) : null
234
+ this.siteEmit = options.siteEmit ? new Set(options.siteEmit) : null
235
+
210
236
  this.framed = detectFramed()
211
237
 
212
238
  // Called once when consent moves to granted, and never otherwise. The
@@ -259,6 +285,39 @@ export default class Tracker {
259
285
  return !!this.endpoint && this.isLiveDocument()
260
286
  }
261
287
 
288
+ /**
289
+ * Whether the runtime should ARM one of its own automatic emitters.
290
+ *
291
+ * ⭐ **Three questions, in the order that makes each one's absence safe:**
292
+ *
293
+ * 1. **Is there anywhere to send, in a live document?** — `isEnabled()`.
294
+ * 2. **Will the host consume this?** `hostEvents` is the host's cost switch:
295
+ * no point arming an observer for a row nobody stores. ⛔ **Absent means NO
296
+ * NARROWING, never an empty set** — a host that sends no list is an older
297
+ * or simpler one, and reading absence as "consume nothing" would take
298
+ * every site on that host dark with every gate reading yes.
299
+ * 3. **Did the site ask for it?** `siteEmit` is the operator's own selection.
300
+ * Absent means everything.
301
+ *
302
+ * `override` is the per-page answer where one exists (`page.trackSections`).
303
+ * It replaces the SITE's answer and cannot escape the host's: a page may
304
+ * widen what its own site configured, and may not conjure a row the host
305
+ * declined to store.
306
+ *
307
+ * ⛔ **Not consulted by `track()`.** A foundation's events are never gated —
308
+ * see the constructor.
309
+ *
310
+ * @param {string} event
311
+ * @param {boolean} [override] - the per-page decision, when the caller has one
312
+ * @returns {boolean}
313
+ */
314
+ arms(event, override) {
315
+ if (!this.isEnabled()) return false
316
+ if (this.hostEvents && !this.hostEvents.has(event)) return false
317
+ if (override != null) return !!override
318
+ return !this.siteEmit || this.siteEmit.has(event)
319
+ }
320
+
262
321
  /** @returns {'granted'|'denied'|'pending'} */
263
322
  consentStatus() {
264
323
  return this.consent
@@ -333,7 +392,7 @@ export default class Tracker {
333
392
  * @param {string} path
334
393
  */
335
394
  trackPageView(path) {
336
- if (!this.isEnabled() || !path) return
395
+ if (!this.arms('page_view') || !path) return
337
396
  if (path === this.currentPath) return
338
397
  this.currentPath = path
339
398
  // Promptly, rather than waiting out the batch window: a page view is the
@@ -428,7 +487,7 @@ export default class Tracker {
428
487
  * @private
429
488
  */
430
489
  armFlushInterval() {
431
- setInterval(() => this.flush(), this.flushInterval)
490
+ setInterval(() => this.flush(), this.flushIntervalMs)
432
491
  }
433
492
 
434
493
  /** @private */