@uniweb/core 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/core",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
5
5
  "type": "module",
6
6
  "exports": {
package/src/services.js CHANGED
@@ -180,26 +180,62 @@ export function resolveService(website, name) {
180
180
  }
181
181
 
182
182
  /**
183
- * Read a service's declaration object, whichever tier supplied it.
183
+ * Read a service's options, filling each key from the first tier that declares
184
+ * it — **the site's value wins per key, and the host fills the gaps.**
184
185
  *
185
186
  * `resolveService` answers *where*; this answers *with what options*. Only the
186
187
  * 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
188
  *
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.
189
+ * ## Why per-key rather than all-or-nothing
190
+ *
191
+ * This used to return the site's object whole whenever the site declared
192
+ * *anything*, so a single authored key hid every option the host offered. That
193
+ * put the two readers in this file on different rules, and the disagreement was
194
+ * not cosmetic:
195
+ *
196
+ * - `resolveService` already falls through **per key** — a site declaration
197
+ * carrying no `endpoint` lets the host's endpoint answer.
198
+ * - `readServiceOptions` fell through **not at all**.
199
+ *
200
+ * ⇒ A site declaring only `tracking: { tags: [...] }` therefore kept sending to
201
+ * the **host's** endpoint while discarding the **host's** `consent` setting —
202
+ * using someone's collector while ignoring their gate. Not a corner case: it is
203
+ * what an operator gets by turning on a third-party tag while their host
204
+ * supplies the collector.
205
+ *
206
+ * One rule now covers both readers, and it is the one a reader of two-tier
207
+ * config already expects: the more specific tier wins where it speaks, and says
208
+ * nothing where it is silent.
209
+ *
210
+ * ⚖️ **Consequence worth stating, because it decides a question that would
211
+ * otherwise need its own rule:** a host's `consent` applies only when the site
212
+ * declared none. That is the host *filling a gap*, never overriding an
213
+ * operator's decision — so there is no "most restrictive wins" special case,
214
+ * and an operator who wants no gate on a host that asks for one writes
215
+ * `consent: none` and is done.
216
+ *
217
+ * ⚠️ **The merge is shallow and deliberately so.** Keys replace, they do not
218
+ * combine: a site's `tags` replaces a host's rather than concatenating with it.
219
+ * Combining would make the result depend on what a host happens to offer, which
220
+ * is precisely the unpredictability a site's own config should not have.
192
221
  *
193
222
  * @param {object} website
194
223
  * @param {string} name
195
- * @returns {object} the declaration object, or `{}` when there is none
224
+ * @returns {object} the effective options, or `{}` when no tier declares any
196
225
  */
197
226
  export function readServiceOptions(website, name) {
198
227
  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 : {}
228
+ return { ...asOptions(config?.services?.[name]), ...asOptions(config?.[name]) }
229
+ }
230
+
231
+ /**
232
+ * A declaration contributes options only in its object form. A string is an
233
+ * address, an array is malformed, and neither carries a key worth spreading.
234
+ *
235
+ * @param {*} declaration
236
+ * @returns {object}
237
+ */
238
+ function asOptions(declaration) {
239
+ if (!declaration || typeof declaration !== 'object' || Array.isArray(declaration)) return {}
240
+ return declaration
205
241
  }
package/src/tracker.js CHANGED
@@ -160,8 +160,11 @@ export default class Tracker {
160
160
  // 'granted' | 'denied' | 'pending'. Without a consent requirement the
161
161
  // operator's act of declaring a destination IS the decision, and the
162
162
  // framework does not presume a jurisdiction on their behalf.
163
+ // ⛔ The requirement is consumed HERE and not stored. A `consentRequired`
164
+ // field was kept alongside and read by nothing — dead instance state in a
165
+ // class every site loads, which is what `destroy()` and its three fields
166
+ // were removed for. The starting status is the whole of what the flag means.
163
167
  this.consent = options.consentRequired ? 'pending' : 'granted'
164
- this.consentRequired = !!options.consentRequired
165
168
 
166
169
  this.queue = []
167
170
  this.acquisition = null
@@ -180,11 +183,17 @@ export default class Tracker {
180
183
  // reports three times.
181
184
  this.currentPath = null
182
185
 
183
- this.flushIntervalId = null
184
- this.onPageHide = null
185
- this.onVisibilityChange = null
186
186
  this.framed = detectFramed()
187
187
 
188
+ // Called once when consent moves to granted, and never otherwise. The
189
+ // runtime uses it to load a site's declared third-party tags at the moment
190
+ // they become permitted; nothing in core knows or cares what it does.
191
+ //
192
+ // ⛔ Declared HERE because the instance is sealed below — an assignment to
193
+ // an undeclared property throws in module code, and the caller assigns this
194
+ // after construction. Same reason `Uniweb.defaultInsets` is pre-declared.
195
+ this.onGranted = null
196
+
188
197
  if (isBrowser && this.isEnabled()) {
189
198
  this.acquisition = captureAcquisition()
190
199
  // Minted even when consent is pending: events buffered before the visitor
@@ -200,14 +209,30 @@ export default class Tracker {
200
209
  }
201
210
 
202
211
  /**
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.
212
+ * Whether this document is one where the site's telemetry should run at all
213
+ * a real visit in a browser, rather than a server render or a framed
214
+ * authoring preview. Says nothing about whether anything is *configured*.
215
+ *
216
+ * Split out from `isEnabled()` because a second consumer needs exactly this
217
+ * half: the runtime loads a site's declared third-party tags, which have no
218
+ * endpoint of ours to check but must be suppressed in the same contexts and
219
+ * for the same reason. One predicate, so the two cannot drift.
220
+ *
221
+ * @returns {boolean}
222
+ */
223
+ isLiveDocument() {
224
+ return isBrowser && !this.framed
225
+ }
226
+
227
+ /**
228
+ * Enabled means: a destination exists, and this is a live document. Consent
229
+ * is checked separately — a consent-pending tracker is *enabled* and
230
+ * buffering, which is a different state from off.
206
231
  *
207
232
  * @returns {boolean}
208
233
  */
209
234
  isEnabled() {
210
- return !!this.endpoint && isBrowser && !this.framed
235
+ return !!this.endpoint && this.isLiveDocument()
211
236
  }
212
237
 
213
238
  /** @returns {'granted'|'denied'|'pending'} */
@@ -223,15 +248,44 @@ export default class Tracker {
223
248
  * decision, and the views that preceded the click are not lost. Denying
224
249
  * discards the buffer and stops accepting.
225
250
  *
251
+ * ⛔ **Recording the decision is NOT gated on `isEnabled()`, deliberately.**
252
+ * Consent is *the visitor's answer*; enablement is *whether we have anywhere
253
+ * to send*. Two different questions, and conflating them meant a decision
254
+ * could not be recorded **when no destination resolved** — benign while our
255
+ * own queue is the only thing gated on consent, a correctness bug the moment
256
+ * anything else is.
257
+ *
258
+ * The same conflation suppressed recording inside a framed document. That
259
+ * suppression exists so an authoring session cannot inflate a site's own
260
+ * numbers (see `detectFramed`), and it belongs on the *sending*: with
261
+ * `consentRequired` the status starts `'pending'` and could not move at all,
262
+ * so a banner following the documented pattern would render and then never
263
+ * dismiss.
264
+ *
265
+ * Nothing is sent as a result: `flush()` keeps its own `isEnabled()` guard,
266
+ * so a disabled tracker still transmits nothing no matter what is recorded
267
+ * here. This changes what the tracker *remembers*, never what it *emits*.
268
+ *
226
269
  * @param {boolean} granted
227
270
  */
228
271
  setConsent(granted) {
229
- if (!this.isEnabled()) return
272
+ const wasGranted = this.consent === 'granted'
230
273
  this.consent = granted ? 'granted' : 'denied'
231
- if (granted) {
232
- this.flush()
233
- } else {
274
+
275
+ if (!granted) {
234
276
  this.queue = []
277
+ return
278
+ }
279
+
280
+ this.flush()
281
+
282
+ // Fires on the TRANSITION only, so a component calling grant() twice does
283
+ // not load a site's tags twice. The callback is cleared as it runs: this is
284
+ // a one-time permission, not a subscription.
285
+ if (!wasGranted && this.onGranted) {
286
+ const notify = this.onGranted
287
+ this.onGranted = null
288
+ notify()
235
289
  }
236
290
  }
237
291
 
@@ -330,35 +384,34 @@ export default class Tracker {
330
384
  })
331
385
  }
332
386
 
333
- /** @private */
387
+ /**
388
+ * Both of these are armed once, for the life of the document, and are never
389
+ * detached — so neither the interval id nor the handler references are kept.
390
+ *
391
+ * ⛔ **There is deliberately no `destroy()`.** One shipped, was called by
392
+ * nothing, and cost 334 bytes minified in a package **every site loads
393
+ * whether it tracks or not** (`@uniweb/core` is not tree-shaken — the
394
+ * singleton's constructor holds a `Tracker`, so the class can never be
395
+ * dropped). Removing it took three instance fields with it, since they
396
+ * existed only to serve it.
397
+ *
398
+ * ⭐ The precedent is the thing this class replaced: `analytics.js` was dead
399
+ * code that shipped to every site for months because nobody deleted it.
400
+ * Adding a never-called teardown method would have been the same mistake at
401
+ * smaller scale. If a lifecycle that needs teardown ever appears, it arrives
402
+ * *with* its call site — which is the order that keeps this honest.
403
+ *
404
+ * @private
405
+ */
334
406
  armFlushInterval() {
335
- this.flushIntervalId = setInterval(() => this.flush(), this.flushInterval)
407
+ setInterval(() => this.flush(), this.flushInterval)
336
408
  }
337
409
 
338
410
  /** @private */
339
411
  armUnloadHandlers() {
340
- this.onPageHide = () => this.flush(true)
341
- this.onVisibilityChange = () => {
412
+ window.addEventListener('pagehide', () => this.flush(true))
413
+ window.addEventListener('visibilitychange', () => {
342
414
  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)
415
+ })
363
416
  }
364
417
  }