@uniweb/core 0.11.3 → 0.12.1

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.11.3",
3
+ "version": "0.12.1",
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.0",
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
- // 3nobody supplied one.
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
  //
@@ -454,10 +472,34 @@ export default class Tracker {
454
472
  * @param {string} path
455
473
  */
456
474
  trackPageView(path) {
457
- if (!this.arms('page_view') || !path) return
475
+ if (!path) return
458
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()
459
498
  const firstOfLoad = this.currentPath === null
460
499
  this.currentPath = path
500
+ this.startTimeOnPage()
501
+
502
+ if (!this.arms('page_view')) return
461
503
  // Promptly, rather than waiting out the batch window: a page view is the
462
504
  // event most likely to be the only one of a short visit.
463
505
  //
@@ -560,15 +602,85 @@ export default class Tracker {
560
602
  *
561
603
  * @private
562
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 */
563
663
  armFlushInterval() {
564
664
  setInterval(() => this.flush(), this.flushIntervalMs)
565
665
  }
566
666
 
567
667
  /** @private */
568
668
  armUnloadHandlers() {
569
- window.addEventListener('pagehide', () => this.flush(true))
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
+ })
570
675
  window.addEventListener('visibilitychange', () => {
571
- if (document.visibilityState === 'hidden') this.flush(true)
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
+ }
572
684
  })
573
685
  }
574
686
  }
package/src/website.js CHANGED
@@ -12,6 +12,7 @@ import ObservableState from './observable-state.js'
12
12
  import { normalizeSeo } from './seo.js'
13
13
  import { resolveDefaultLocale, localeLabel } from './locale-config.js'
14
14
  import { matchDynamicRoute, decodeRouteValue } from './route-match.js'
15
+ import { resolveService } from './services.js'
15
16
 
16
17
  /**
17
18
  * Website — orchestration root for a single site instance.
@@ -1016,12 +1017,63 @@ export default class Website {
1016
1017
  // ─────────────────────────────────────────────────────────────────
1017
1018
 
1018
1019
  /**
1019
- * Check if search is enabled for this site
1020
+ * Check if search is enabled for this site.
1021
+ *
1022
+ * ⛔ `search: false` USED TO LEAVE SEARCH ON. The predicate was
1023
+ * `config?.search?.enabled !== false`, and optional chaining short-circuits
1024
+ * on `null`/`undefined` only — so `false?.enabled` evaluates `false.enabled`
1025
+ * to `undefined`, and `undefined !== false` is `true`. An author writing the
1026
+ * natural shorthand for "off" got search **on**, silently.
1027
+ *
1028
+ * ⚠️ Measured on a live hosted payload 2026-08-25: the boolean form is what
1029
+ * actually arrives — that site carried `config.search === true`, which
1030
+ * worked only by
1031
+ * the same accident. Only the object form is documented, so the boolean is
1032
+ * either authored or synthesized upstream; either way it reaches here.
1033
+ *
1034
+ * ⭐ Why this mattered more than its size: on a backend-hosted site the
1035
+ * search index is not emitted (see `getSearchIndexUrl` below), so an operator
1036
+ * whose search box is failing reaches for exactly this switch — and it was
1037
+ * the one input that did nothing.
1038
+ *
1039
+ * Both forms now work, and absent still means enabled.
1040
+ *
1020
1041
  * @returns {boolean}
1021
1042
  */
1022
1043
  isSearchEnabled() {
1023
- // Search is enabled by default unless explicitly disabled
1024
- return this.config?.search?.enabled !== false
1044
+ const search = this.config?.search
1045
+ if (typeof search === 'boolean') {
1046
+ if (!search) return false
1047
+ } else if (search?.enabled === false) {
1048
+ return false
1049
+ }
1050
+
1051
+ // ⭐ THE HOST GETS A SAY, and this is the half that reaches an already-
1052
+ // published foundation. A foundation bundles its own frozen copy of
1053
+ // `@uniweb/kit`, so a fix made in kit never reaches one built before it.
1054
+ // `@uniweb/core` is different: the runtime carries it and re-exports it
1055
+ // through the import map, so a foundation of any age calls THIS method.
1056
+ //
1057
+ // A host that publishes a services block is stating what it offers. If it
1058
+ // does not name `search`, the site has no search — and a control for a
1059
+ // service the site does not have must not be drawn. That is the rule for
1060
+ // every service (`submit` draws no form, `assistant` and `tracking` draw
1061
+ // nothing); search was the outlier only because it had a legacy local
1062
+ // index to fall through to, and on a host-served site nothing emits one.
1063
+ //
1064
+ // ⛔ Deliberately no reason and no message: not-provisioned is not an
1065
+ // error, and any text a visitor reads is site content — authored and
1066
+ // localized — never a string a service layer invents.
1067
+ //
1068
+ // A site's OWN `search.endpoint` still wins: `resolveService` answers from
1069
+ // the site tier first, so self-hosted search on a host that does not sell
1070
+ // it is untouched.
1071
+ const { url, source } = resolveService(this, 'search')
1072
+ if (source === 'host' && !url) return false
1073
+
1074
+ // Enabled by default — absent means enabled, and a host that publishes no
1075
+ // services block at all has declined nothing (the static-host path).
1076
+ return true
1025
1077
  }
1026
1078
 
1027
1079
  /**
@@ -1029,7 +1081,12 @@ export default class Website {
1029
1081
  * @returns {Object} Search configuration
1030
1082
  */
1031
1083
  getSearchConfig() {
1032
- const config = this.config?.search || {}
1084
+ // A boolean `search:` carries no options — normalize it away so every
1085
+ // read below (`config.provider`, `config.include?.…`) sees an object.
1086
+ // `true || {}` would otherwise yield `true` and every option read would
1087
+ // land on a boolean.
1088
+ const raw = this.config?.search
1089
+ const config = (raw && typeof raw === 'object') ? raw : {}
1033
1090
 
1034
1091
  return {
1035
1092
  enabled: this.isSearchEnabled(),