@uniweb/core 0.11.2 → 0.12.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 +2 -2
- package/src/services.js +26 -1
- package/src/tracker.js +165 -4
- package/src/website.js +31 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"vitest": "^4.1.7"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@uniweb/semantic-parser": "^1.3.
|
|
43
|
+
"@uniweb/semantic-parser": "^1.3.1",
|
|
44
44
|
"@uniweb/theming": "^0.1.15"
|
|
45
45
|
},
|
|
46
46
|
"scripts": {
|
package/src/services.js
CHANGED
|
@@ -175,7 +175,32 @@ export function resolveService(website, name) {
|
|
|
175
175
|
// no stake in it. See the entitlement note above.
|
|
176
176
|
if (hostDeclaration !== undefined) return { url: null, source: 'host' }
|
|
177
177
|
|
|
178
|
-
//
|
|
178
|
+
// ⭐ A HOST THAT EMITTED A SERVICES BLOCK IS ANSWERING — for every service,
|
|
179
|
+
// not only the ones it named. The block is the host's statement of what it
|
|
180
|
+
// offers, so a name ABSENT from it carries the same answer as a name present
|
|
181
|
+
// with no address: this host does not offer that service.
|
|
182
|
+
//
|
|
183
|
+
// Without this, "the host offers tracking and not search" and "there is no
|
|
184
|
+
// host at all" are the same value, and a caller with a fallback takes it. A
|
|
185
|
+
// caller with no fallback cannot tell the difference and never could, which
|
|
186
|
+
// is why this was invisible until search — the one service with a legacy
|
|
187
|
+
// zero-config default to fall through to — started 404ing on hosted sites.
|
|
188
|
+
//
|
|
189
|
+
// ⇒ The rule this implements: **a control for a service the site does not
|
|
190
|
+
// have must not be drawn** — uniformly, for every service, and without any
|
|
191
|
+
// explanation offered to a visitor. Not-provisioned is not an error and not a
|
|
192
|
+
// thing to apologise for; it is simply a feature the site does not have, the
|
|
193
|
+
// same way it has no contact form when `submit` is absent.
|
|
194
|
+
//
|
|
195
|
+
// ⚠️ This deliberately reads the SITE CONFIG the runtime already holds rather
|
|
196
|
+
// than asking a host for a new signal. The payload states what is on and what
|
|
197
|
+
// is off; the renderer's job is to not draw what cannot be used.
|
|
198
|
+
if (config?.services && typeof config.services === 'object' && !Array.isArray(config.services)) {
|
|
199
|
+
return { url: null, source: 'host' }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// 3 — nobody supplied one. No host is speaking, so a caller's own default
|
|
203
|
+
// (search's local index, say) is still correct — that is the static-host path.
|
|
179
204
|
return { url: null, source: null }
|
|
180
205
|
}
|
|
181
206
|
|
package/src/tracker.js
CHANGED
|
@@ -247,6 +247,24 @@ export default class Tracker {
|
|
|
247
247
|
// reports three times.
|
|
248
248
|
this.currentPath = null
|
|
249
249
|
|
|
250
|
+
// ── time_on_page ─────────────────────────────────────────────────────
|
|
251
|
+
// ⛔ Declared here because the instance is SEALED below — an assignment to
|
|
252
|
+
// an undeclared property throws in module code. Same reason `onGranted` and
|
|
253
|
+
// `Uniweb.defaultInsets` are pre-declared.
|
|
254
|
+
//
|
|
255
|
+
// ⭐ No new listener is needed for any of this: `armUnloadHandlers` already
|
|
256
|
+
// registers `pagehide` and `visibilitychange`, and `trackPageView` already
|
|
257
|
+
// knows the SPA route boundary. This is arithmetic inside handlers that
|
|
258
|
+
// already run.
|
|
259
|
+
/** When the current path became active. */
|
|
260
|
+
this.pageEnteredAt = null
|
|
261
|
+
/** Hidden time accrued on the current path, in ms. */
|
|
262
|
+
this.hiddenMs = 0
|
|
263
|
+
/** When the document last became hidden, or `null` while visible. */
|
|
264
|
+
this.hiddenSince = null
|
|
265
|
+
/** Guards against a second report for one page visit — see `reportTimeOnPage`. */
|
|
266
|
+
this.pageReported = false
|
|
267
|
+
|
|
250
268
|
// What the runtime may ARM, as two independent narrowings. Both are `null`
|
|
251
269
|
// when nothing narrows, which is the common case and the cheap one.
|
|
252
270
|
//
|
|
@@ -414,15 +432,88 @@ export default class Tracker {
|
|
|
414
432
|
* Report a page view. Framework-owned: it carries the acquisition context and
|
|
415
433
|
* dedupes consecutive reports of the same path.
|
|
416
434
|
*
|
|
435
|
+
* ⭐ **`first_of_load` marks the one view that opened this document**, and it
|
|
436
|
+
* is computed here rather than living in `acquisition` — precisely because
|
|
437
|
+
* everything in `acquisition` is *replayed* onto every view, and this must
|
|
438
|
+
* not be. Exactly one emission per `Tracker` can carry it: `currentPath` is
|
|
439
|
+
* `null` only before the first report and is never reset.
|
|
440
|
+
*
|
|
441
|
+
* ⛔ **Why it exists at all, since a consumer holding the events can derive
|
|
442
|
+
* it from `visit`.** Not every consumer holds the events. A counter store
|
|
443
|
+
* that keeps no per-event rows cannot ask *"was this the first `page_view`
|
|
444
|
+
* with this `visit`?"* without remembering which `visit` values it has seen —
|
|
445
|
+
* which is retaining `visit`, arrived at by the back door. This bit makes the
|
|
446
|
+
* document-scoped question answerable with **no collector state at all**, and
|
|
447
|
+
* a store that deliberately retains nothing is a supported consumer, not an
|
|
448
|
+
* unusual one.
|
|
449
|
+
*
|
|
450
|
+
* ⛔ **The name carries the unit on purpose.** Bare `first` reads as *the
|
|
451
|
+
* visitor's first ever view*, which is a claim this file refuses to make and
|
|
452
|
+
* could not make — nothing here survives the document. The unit is **one
|
|
453
|
+
* document load**, so a panel built on it counts loads, never people and
|
|
454
|
+
* never sessions. *(`lane-chain.md` §4a: when a misread name is paid by
|
|
455
|
+
* another lane, put the constraint in the name.)*
|
|
456
|
+
*
|
|
457
|
+
* ⛔ **IT MUST NEVER SHIP IN A RELEASE THAT LACKS `continues`, and that is a
|
|
458
|
+
* standing contract rather than an accident of ordering.** A consumer uses
|
|
459
|
+
* its *presence* as proof that this emitter is new enough to have sent
|
|
460
|
+
* `continues` had the referrer been same-origin — which is what lets an
|
|
461
|
+
* entry-page metric drop the "runtime too old to say" case entirely instead
|
|
462
|
+
* of caveating it. Backport `first_of_load` to a line without `continues`
|
|
463
|
+
* and every such consumer starts counting continuations as arrivals, with
|
|
464
|
+
* nothing anywhere reporting an error. *(The dependency is one-way:
|
|
465
|
+
* `continues` without `first_of_load` is fine and shipped that way.)*
|
|
466
|
+
*
|
|
467
|
+
* ⚖️ **It states a FACT, not a metric** — the same discipline as `continues`.
|
|
468
|
+
* Whether a first-of-load view is an "entry page" also depends on
|
|
469
|
+
* `continues`, and that combination is the consumer's to make; a field named
|
|
470
|
+
* `entry` would age badly the moment a second metric wanted this bit.
|
|
471
|
+
*
|
|
417
472
|
* @param {string} path
|
|
418
473
|
*/
|
|
419
474
|
trackPageView(path) {
|
|
420
|
-
if (!
|
|
475
|
+
if (!path) return
|
|
421
476
|
if (path === this.currentPath) return
|
|
477
|
+
|
|
478
|
+
// ⛔ **EVERYTHING DOWN TO THE `arms` CHECK IS PAGE-BOUNDARY BOOKKEEPING, NOT
|
|
479
|
+
// AN EMISSION.** It must not sit behind `arms('page_view')`, because two
|
|
480
|
+
// other things read it and neither is `page_view`:
|
|
481
|
+
//
|
|
482
|
+
// - `currentPath` is the default `path` for EVERY event `track()` sends —
|
|
483
|
+
// a foundation's own events and `outbound_click` among them. Gated, a
|
|
484
|
+
// site that selects `outbound_click` without `page_view` reports every
|
|
485
|
+
// event with `path: undefined`, silently.
|
|
486
|
+
// - `time_on_page` is armed independently, so gating its clock behind a
|
|
487
|
+
// different event's selection means a site that asks for it gets
|
|
488
|
+
// nothing at all.
|
|
489
|
+
//
|
|
490
|
+
// ⚖️ Both failures are invisible: no error, no warning, a plausible payload
|
|
491
|
+
// with a field quietly missing. The bookkeeping is cheap and unconditional;
|
|
492
|
+
// only the EMISSION below is a decision.
|
|
493
|
+
//
|
|
494
|
+
// The OUTGOING path's dwell closes before `currentPath` moves — the SPA
|
|
495
|
+
// boundary an unload-only implementation misses, which would report one
|
|
496
|
+
// duration per document and silently attribute it to the last page seen.
|
|
497
|
+
this.reportTimeOnPage()
|
|
498
|
+
const firstOfLoad = this.currentPath === null
|
|
422
499
|
this.currentPath = path
|
|
500
|
+
this.startTimeOnPage()
|
|
501
|
+
|
|
502
|
+
if (!this.arms('page_view')) return
|
|
423
503
|
// Promptly, rather than waiting out the batch window: a page view is the
|
|
424
504
|
// event most likely to be the only one of a short visit.
|
|
425
|
-
|
|
505
|
+
//
|
|
506
|
+
// Emitted only when true, like `continues` — absent is the negative, and it
|
|
507
|
+
// keeps every later view the size it already was.
|
|
508
|
+
this.enqueue(
|
|
509
|
+
{
|
|
510
|
+
event: 'page_view',
|
|
511
|
+
path,
|
|
512
|
+
...(this.acquisition || {}),
|
|
513
|
+
...(firstOfLoad ? { first_of_load: true } : {})
|
|
514
|
+
},
|
|
515
|
+
true
|
|
516
|
+
)
|
|
426
517
|
}
|
|
427
518
|
|
|
428
519
|
/**
|
|
@@ -511,15 +602,85 @@ export default class Tracker {
|
|
|
511
602
|
*
|
|
512
603
|
* @private
|
|
513
604
|
*/
|
|
605
|
+
/**
|
|
606
|
+
* Begin measuring dwell on the path that just became active.
|
|
607
|
+
*
|
|
608
|
+
* @private
|
|
609
|
+
*/
|
|
610
|
+
startTimeOnPage() {
|
|
611
|
+
this.pageEnteredAt = Date.now()
|
|
612
|
+
this.hiddenMs = 0
|
|
613
|
+
this.hiddenSince = null
|
|
614
|
+
this.pageReported = false
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Report how long the visitor spent on the current path, once.
|
|
619
|
+
*
|
|
620
|
+
* ⭐ **A RAW scalar, with no floor and no clamp.** Both belong to the
|
|
621
|
+
* collector: a threshold baked in here is frozen at the speed of framework
|
|
622
|
+
* release → foundation rebuild → site republish, where the same threshold
|
|
623
|
+
* applied at write time changes when the host deploys. Dwell is also violently
|
|
624
|
+
* skewed — a tab left open overnight sits in the same mean as forty readers —
|
|
625
|
+
* so a clamp is genuinely needed; it is just not needed *here*.
|
|
626
|
+
*
|
|
627
|
+
* ⛔ **Hidden time is subtracted**, or this measures tab-open rather than
|
|
628
|
+
* reading. A backgrounded tab accrues nothing.
|
|
629
|
+
*
|
|
630
|
+
* ⛔ **Reported at a route change and at `pagehide` — NOT when the document
|
|
631
|
+
* merely becomes hidden.** Visibility is a *pause*, not an end: someone who
|
|
632
|
+
* switches tabs to look something up and comes back is still reading. Emitting
|
|
633
|
+
* on hidden would truncate exactly the engaged readers this metric exists to
|
|
634
|
+
* find, and — because the consumer derives a mean from a sum and a count —
|
|
635
|
+
* emitting more than once per page visit would inflate the count and make
|
|
636
|
+
* every mean wrong. Hence `pageReported`: **at most one per page visit.**
|
|
637
|
+
*
|
|
638
|
+
* ⚠️ **The cost of that choice, stated rather than hidden:** a page discarded
|
|
639
|
+
* while hidden, without `pagehide`, is never reported. That is an
|
|
640
|
+
* under-count, it is bounded, and it fails in the direction that does not
|
|
641
|
+
* corrupt the statistic.
|
|
642
|
+
*
|
|
643
|
+
* @private
|
|
644
|
+
*/
|
|
645
|
+
reportTimeOnPage() {
|
|
646
|
+
if (this.pageReported || this.pageEnteredAt === null || !this.currentPath) return
|
|
647
|
+
if (!this.arms('time_on_page')) return
|
|
648
|
+
|
|
649
|
+
const now = Date.now()
|
|
650
|
+
// A tab hidden at this instant has not yet had its span folded in.
|
|
651
|
+
const openHidden = this.hiddenSince === null ? 0 : now - this.hiddenSince
|
|
652
|
+
const durationMs = now - this.pageEnteredAt - this.hiddenMs - openHidden
|
|
653
|
+
|
|
654
|
+
this.pageReported = true
|
|
655
|
+
// Negative is not reachable by arithmetic, but a clock adjustment mid-visit
|
|
656
|
+
// would produce one, and a negative duration poisons a sum the consumer
|
|
657
|
+
// cannot repair.
|
|
658
|
+
if (durationMs < 0) return
|
|
659
|
+
this.enqueue({ event: 'time_on_page', path: this.currentPath, durationMs })
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** @private */
|
|
514
663
|
armFlushInterval() {
|
|
515
664
|
setInterval(() => this.flush(), this.flushIntervalMs)
|
|
516
665
|
}
|
|
517
666
|
|
|
518
667
|
/** @private */
|
|
519
668
|
armUnloadHandlers() {
|
|
520
|
-
window.addEventListener('pagehide', () =>
|
|
669
|
+
window.addEventListener('pagehide', () => {
|
|
670
|
+
// Order matters: the report must be QUEUED before the beacon goes, or it
|
|
671
|
+
// rides the next flush — and for a page being unloaded there is no next.
|
|
672
|
+
this.reportTimeOnPage()
|
|
673
|
+
this.flush(true)
|
|
674
|
+
})
|
|
521
675
|
window.addEventListener('visibilitychange', () => {
|
|
522
|
-
if (document.visibilityState === 'hidden')
|
|
676
|
+
if (document.visibilityState === 'hidden') {
|
|
677
|
+
// A pause, not an end — see `reportTimeOnPage`. Only the clock stops.
|
|
678
|
+
if (this.hiddenSince === null) this.hiddenSince = Date.now()
|
|
679
|
+
this.flush(true)
|
|
680
|
+
} else if (this.hiddenSince !== null) {
|
|
681
|
+
this.hiddenMs += Date.now() - this.hiddenSince
|
|
682
|
+
this.hiddenSince = null
|
|
683
|
+
}
|
|
523
684
|
})
|
|
524
685
|
}
|
|
525
686
|
}
|
package/src/website.js
CHANGED
|
@@ -1016,12 +1016,34 @@ export default class Website {
|
|
|
1016
1016
|
// ─────────────────────────────────────────────────────────────────
|
|
1017
1017
|
|
|
1018
1018
|
/**
|
|
1019
|
-
* Check if search is enabled for this site
|
|
1019
|
+
* Check if search is enabled for this site.
|
|
1020
|
+
*
|
|
1021
|
+
* ⛔ `search: false` USED TO LEAVE SEARCH ON. The predicate was
|
|
1022
|
+
* `config?.search?.enabled !== false`, and optional chaining short-circuits
|
|
1023
|
+
* on `null`/`undefined` only — so `false?.enabled` evaluates `false.enabled`
|
|
1024
|
+
* to `undefined`, and `undefined !== false` is `true`. An author writing the
|
|
1025
|
+
* natural shorthand for "off" got search **on**, silently.
|
|
1026
|
+
*
|
|
1027
|
+
* ⚠️ Measured on a live hosted payload 2026-08-25: the boolean form is what
|
|
1028
|
+
* actually arrives — that site carried `config.search === true`, which
|
|
1029
|
+
* worked only by
|
|
1030
|
+
* the same accident. Only the object form is documented, so the boolean is
|
|
1031
|
+
* either authored or synthesized upstream; either way it reaches here.
|
|
1032
|
+
*
|
|
1033
|
+
* ⭐ Why this mattered more than its size: on a backend-hosted site the
|
|
1034
|
+
* search index is not emitted (see `getSearchIndexUrl` below), so an operator
|
|
1035
|
+
* whose search box is failing reaches for exactly this switch — and it was
|
|
1036
|
+
* the one input that did nothing.
|
|
1037
|
+
*
|
|
1038
|
+
* Both forms now work, and absent still means enabled.
|
|
1039
|
+
*
|
|
1020
1040
|
* @returns {boolean}
|
|
1021
1041
|
*/
|
|
1022
1042
|
isSearchEnabled() {
|
|
1023
|
-
|
|
1024
|
-
|
|
1043
|
+
const search = this.config?.search
|
|
1044
|
+
if (typeof search === 'boolean') return search
|
|
1045
|
+
// Enabled by default unless explicitly disabled.
|
|
1046
|
+
return search?.enabled !== false
|
|
1025
1047
|
}
|
|
1026
1048
|
|
|
1027
1049
|
/**
|
|
@@ -1029,7 +1051,12 @@ export default class Website {
|
|
|
1029
1051
|
* @returns {Object} Search configuration
|
|
1030
1052
|
*/
|
|
1031
1053
|
getSearchConfig() {
|
|
1032
|
-
|
|
1054
|
+
// A boolean `search:` carries no options — normalize it away so every
|
|
1055
|
+
// read below (`config.provider`, `config.include?.…`) sees an object.
|
|
1056
|
+
// `true || {}` would otherwise yield `true` and every option read would
|
|
1057
|
+
// land on a boolean.
|
|
1058
|
+
const raw = this.config?.search
|
|
1059
|
+
const config = (raw && typeof raw === 'object') ? raw : {}
|
|
1033
1060
|
|
|
1034
1061
|
return {
|
|
1035
1062
|
enabled: this.isSearchEnabled(),
|