@uniweb/kit 0.12.0 → 0.12.2

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/kit",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "description": "Standard component library for Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -44,9 +44,9 @@
44
44
  "shiki": "^3.0.0",
45
45
  "tailwind-merge": "^3.6.0",
46
46
  "temml": "^0.13.2",
47
- "@uniweb/semantic-parser": "^1.2.2",
47
+ "@uniweb/core": "^0.10.0",
48
48
  "@uniweb/scene": "^0.1.3",
49
- "@uniweb/core": "^0.8.5"
49
+ "@uniweb/semantic-parser": "^1.2.2"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "react": "^19.0.0",
@@ -38,3 +38,10 @@ export {
38
38
  // Form submission lifecycle for foundation Form components
39
39
  export { useFormSubmit } from './useFormSubmit.js'
40
40
  export { useFormValues, valueAt } from './useFormValues.js'
41
+
42
+ // Site tracking — one event stream (`kb/framework/plans/tracking.md`).
43
+ // `block.track(name, data)` is the common case and needs no hook; these cover
44
+ // events with no block in hand, the consent gate, and opt-in scroll reporting.
45
+ export { useTracker } from './useTracker.js'
46
+ export { useTrackingConsent } from './useTrackingConsent.js'
47
+ export { useScrollDepth } from './useScrollDepth.js'
@@ -0,0 +1,97 @@
1
+ /**
2
+ * useScrollDepth — report how far down the page a visitor got.
3
+ *
4
+ * ```jsx
5
+ * useScrollDepth() // 25 / 50 / 75 / 100, once each per page
6
+ * ```
7
+ *
8
+ * ⭐ **Opt-in, and in kit rather than the runtime, on purpose.** The runtime
9
+ * auto-emits only what requires runtime privilege — a page view needs the
10
+ * router, so nothing else can emit it. Scroll depth needs nothing but the
11
+ * window, so it is a foundation's choice and lives where tree-shaking can drop
12
+ * it for the foundations that never call it.
13
+ *
14
+ * *(It previously lived in `@uniweb/runtime` as an unexported, unreachable file
15
+ * that reported to a metric baked into the transport. The milestones are now an
16
+ * ordinary event, which is both smaller and more general.)*
17
+ *
18
+ * ⛔ **No guard is needed.** With no tracking destination the report is a
19
+ * silent no-op — but the scroll listener is skipped too, so an unconfigured
20
+ * site pays nothing for the call.
21
+ *
22
+ * @module @uniweb/kit/hooks/useScrollDepth
23
+ */
24
+
25
+ import { useEffect, useRef } from 'react'
26
+ import { getUniweb } from '@uniweb/core'
27
+ import { useRouting } from './useRouting.js'
28
+
29
+ const MILESTONES = [25, 50, 75, 100]
30
+
31
+ /** @returns {number} 0-100; 100 when the page fits in the viewport */
32
+ function getScrollDepth() {
33
+ const scrollTop = window.scrollY
34
+ const docHeight = document.documentElement.scrollHeight - window.innerHeight
35
+ if (docHeight <= 0) return 100
36
+ return Math.min(100, Math.round((scrollTop / docHeight) * 100))
37
+ }
38
+
39
+ /**
40
+ * @param {Object} [options]
41
+ * @param {boolean} [options.enabled=true]
42
+ * @param {number} [options.throttleMs=200]
43
+ * @param {string} [options.event='scroll_depth'] - override the event name
44
+ */
45
+ export function useScrollDepth(options = {}) {
46
+ const { enabled = true, throttleMs = 200, event = 'scroll_depth' } = options
47
+
48
+ const lastCheck = useRef(0)
49
+ const reported = useRef(new Set())
50
+
51
+ // ⛔ **Keyed on the path, or "once each per page" is not true.** The milestone
52
+ // set lives in a ref, so without this the effect runs once at mount and the
53
+ // set is never cleared again — a second page reports nothing at all. That bites
54
+ // exactly where this hook is most naturally called: once, in a layout
55
+ // component, which persists across SPA navigation (`PageRenderer` does not
56
+ // remount — the router declares a single catch-all route). Calling it in every
57
+ // section would have hidden the bug and multiplied the events instead.
58
+ //
59
+ // ⭐ `pathname` rather than a route object, deliberately: it is the same
60
+ // boundary `usePageView` emits on, so a `scroll_depth` always pairs with the
61
+ // `page_view` it belongs to. `useRouting` returns a default location when there
62
+ // is no Router, so this stays SSG-safe.
63
+ const { useLocation } = useRouting()
64
+ const { pathname } = useLocation()
65
+
66
+ useEffect(() => {
67
+ const tracking = getUniweb()?.tracking
68
+ // Skip the listener entirely when there is nowhere to report — the calls
69
+ // would be no-ops, but the scroll handler would still run on every frame.
70
+ if (!enabled || !tracking?.isEnabled?.()) return
71
+
72
+ reported.current.clear()
73
+ // Reset the throttle too: a navigation within the throttle window would
74
+ // otherwise make the immediate check below return early and miss the fold.
75
+ lastCheck.current = 0
76
+
77
+ const handleScroll = () => {
78
+ const now = Date.now()
79
+ if (now - lastCheck.current < throttleMs) return
80
+ lastCheck.current = now
81
+
82
+ const depth = getScrollDepth()
83
+ for (const milestone of MILESTONES) {
84
+ if (depth >= milestone && !reported.current.has(milestone)) {
85
+ reported.current.add(milestone)
86
+ tracking.track(event, { depth: milestone })
87
+ }
88
+ }
89
+ }
90
+
91
+ handleScroll() // a page that fits the viewport is already at 100
92
+ window.addEventListener('scroll', handleScroll, { passive: true })
93
+ return () => window.removeEventListener('scroll', handleScroll)
94
+ }, [enabled, throttleMs, event, pathname])
95
+ }
96
+
97
+ export default useScrollDepth
@@ -0,0 +1,40 @@
1
+ /**
2
+ * useTracker — report an event from a foundation component.
3
+ *
4
+ * ```jsx
5
+ * const { track } = useTracker()
6
+ * <button onClick={() => track('brochure_download', { file: 'specs.pdf' })}>…</button>
7
+ * ```
8
+ *
9
+ * ⛔ **No guard is needed.** A site with no tracking destination is the default
10
+ * and the majority: the call returns having done nothing, opened no connection
11
+ * and thrown nothing. Absent is the normal state, not an error — so never wrap
12
+ * this in a "is tracking on?" check, and never render differently because of it.
13
+ *
14
+ * ⭐ **Prefer `block.track(name, data)` when you have a block**, which almost
15
+ * every section type does. It attaches the section type and the page path for
16
+ * you. Reach for this hook for events with no block in hand — a site-level
17
+ * control, a layout element, a modal.
18
+ *
19
+ * The event name is yours: the registry is open, and the framework keeps no
20
+ * list of permitted names. What a host does with an event it does not recognise
21
+ * is the host's business.
22
+ *
23
+ * @module @uniweb/kit/hooks/useTracker
24
+ */
25
+
26
+ import { useCallback } from 'react'
27
+ import { getUniweb } from '@uniweb/core'
28
+
29
+ /**
30
+ * @returns {{ track: (event: string, data?: object) => void }}
31
+ */
32
+ export function useTracker() {
33
+ const track = useCallback((event, data = {}) => {
34
+ getUniweb()?.tracking?.track(event, data)
35
+ }, [])
36
+
37
+ return { track }
38
+ }
39
+
40
+ export default useTracker
@@ -0,0 +1,68 @@
1
+ /**
2
+ * useTrackingConsent — the visitor's decision, for a consent component to set.
3
+ *
4
+ * ```jsx
5
+ * const { status, grant, deny } = useTrackingConsent()
6
+ * if (status !== 'pending') return null // nothing to ask
7
+ * return <Banner onAccept={grant} onReject={deny} />
8
+ * ```
9
+ *
10
+ * ## ⛔ Why this exists at all
11
+ *
12
+ * Tracking used to arrive as a third-party `<script>`, which consent tooling —
13
+ * banners, browser blockers, CMPs — works by blocking. Emitting from inside the
14
+ * site's own bundle means **none of that can see it**, so a site that was
15
+ * compliant by virtue of its banner would silently stop being so, with no
16
+ * symptom. The framework moved the capability in, so the framework owes the
17
+ * gate.
18
+ *
19
+ * ## Status values
20
+ *
21
+ * - `'pending'` — the site declared `tracking: { consent: required }` and nobody
22
+ * has answered. Events are **buffered, not sent**; granting flushes them, so
23
+ * the views before the click are not lost, and denying discards them. Nothing
24
+ * leaves the device before the decision.
25
+ * - `'granted'` — sending. **This is the default** when a site does not ask for
26
+ * a consent gate: declaring a destination is itself the operator's decision,
27
+ * and the framework does not presume a jurisdiction on their behalf.
28
+ * - `'denied'` — nothing is sent and nothing accumulates.
29
+ *
30
+ * ⚖️ **Single-owner assumption.** The status is mirrored into component state so
31
+ * a banner re-renders when it changes; two independent components calling this
32
+ * will not observe each other's grant. Consent is a one-banner concern, so that
33
+ * is the intended shape rather than a limitation to design around.
34
+ *
35
+ * @module @uniweb/kit/hooks/useTrackingConsent
36
+ */
37
+
38
+ import { useCallback, useState } from 'react'
39
+ import { getUniweb } from '@uniweb/core'
40
+
41
+ function readStatus() {
42
+ return getUniweb()?.tracking?.consentStatus?.() || 'granted'
43
+ }
44
+
45
+ /**
46
+ * @returns {{ status: 'granted'|'denied'|'pending', grant: () => void, deny: () => void }}
47
+ */
48
+ export function useTrackingConsent() {
49
+ const [status, setStatus] = useState(readStatus)
50
+
51
+ const set = useCallback((granted) => {
52
+ getUniweb()?.tracking?.setConsent(granted)
53
+ // Read back rather than assuming. The tracker is the authority on its own
54
+ // state, and a site that never asked for a gate starts — and stays —
55
+ // 'granted', so a banner following the pattern above never renders and this
56
+ // never runs. Recording a decision does not depend on the tracker having
57
+ // anywhere to send, so the status moves in a framed document too — where
58
+ // the tracker is disabled and nothing is transmitted either way.
59
+ setStatus(readStatus())
60
+ }, [])
61
+
62
+ const grant = useCallback(() => set(true), [set])
63
+ const deny = useCallback(() => set(false), [set])
64
+
65
+ return { status, grant, deny }
66
+ }
67
+
68
+ export default useTrackingConsent
package/src/index.js CHANGED
@@ -93,7 +93,13 @@ export {
93
93
  // The state of an AUTHORED form — seeds defaults, tracks edits, keeps Files
94
94
  // out of the JSON payload. The foundation writes the controls and nothing else.
95
95
  useFormValues,
96
- valueAt
96
+ valueAt,
97
+ // Site tracking. `block.track(name, data)` is the common case and needs no
98
+ // hook — these are for events with no block in hand, the consent gate a
99
+ // banner sets, and opt-in scroll reporting.
100
+ useTracker,
101
+ useTrackingConsent,
102
+ useScrollDepth
97
103
  } from './hooks/index.js'
98
104
 
99
105
  // ============================================================================
@@ -186,6 +186,11 @@ function PlayButton({ onClick, className }) {
186
186
  * @param {string} [props.className] - Additional CSS classes
187
187
  * @param {Function} [props.onProgress] - Progress callback for tracking
188
188
  * @param {Object} [props.block] - Block object for event tracking
189
+ * @param {boolean} [props.track=true] - Report `video_milestone` events to the
190
+ * site's tracking destination (requires `block`). On by default because the
191
+ * point of the capability is that a foundation should not have to wire it,
192
+ * and it is inert on a site with no destination configured. Pass `false` to
193
+ * keep video out of the event stream on a site that does track.
189
194
  *
190
195
  * @example
191
196
  * // YouTube video
@@ -239,6 +244,7 @@ export function Media({
239
244
  className,
240
245
  onProgress,
241
246
  block,
247
+ track = true,
242
248
  ...props
243
249
  }) {
244
250
  const [showVideo, setShowVideo] = useState(!facade)
@@ -263,14 +269,19 @@ export function Media({
263
269
  const handleProgress = useCallback((data) => {
264
270
  onProgress?.(data)
265
271
 
266
- // Track via block if available
267
- if (block?.trackEvent && typeof window !== 'undefined' && window.uniweb?.analytics?.initialized) {
268
- block.trackEvent(`video_milestone_${data.milestone}`, {
269
- milestone: `${data.milestone}%`,
270
- src: videoSrc
271
- })
272
+ // Report the milestone through the site's tracking destination, if it has
273
+ // one. `block.track` attaches the section type and page path itself, and is
274
+ // a silent no-op when nothing is configured — which is the default and the
275
+ // majority — so no guard belongs here.
276
+ //
277
+ // ⭐ The milestone is DATA, not part of the event name. This used to emit
278
+ // `video_milestone_25` / `_50` / `_75` / `_100`, which makes four names out
279
+ // of one event and turns any consumer's event dimension into a cardinality
280
+ // problem. One name, one field.
281
+ if (track) {
282
+ block?.track?.('video_milestone', { milestone: data.milestone, src: videoSrc })
272
283
  }
273
- }, [onProgress, block, videoSrc])
284
+ }, [onProgress, block, videoSrc, track])
274
285
 
275
286
  // Render facade (thumbnail with play button)
276
287
  if (facade && !showVideo && thumbnailSrc) {
package/src/utils/href.js CHANGED
@@ -36,6 +36,9 @@
36
36
  */
37
37
 
38
38
  import { isFileUrl } from './url.js'
39
+ // Imported rather than only re-exported: `resolveHref` below calls it, and a
40
+ // bare `export … from` creates no local binding.
41
+ import { applyBasePath } from '@uniweb/core/base-path'
39
42
 
40
43
  // An <a> tag's href attribute. Captures the prefix, the quote style, and the
41
44
  // value, so the replacement can preserve the original quoting.
@@ -48,24 +51,15 @@ const NON_ROUTE_HREF_RE = /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i
48
51
  /**
49
52
  * Prefix a site-root-relative href with the deployment base path.
50
53
  *
51
- * The invariant this encodes — a base is only ever joined to a path that
52
- * starts at the site root — is the whole point of routing every caller
53
- * through here. A bare `basePath + href` concatenation produces garbage the
54
- * moment href turns out to be absolute (`/basehttps://example.com/x`), and
55
- * whether it is absolute depends on a classification that has been wrong
56
- * before. Guarding at the join makes the failure impossible rather than
57
- * unlikely.
58
- *
59
- * @param {string} href - Href to prefix
60
- * @param {string} basePath - Deployment base (no trailing slash), '' for root
61
- * @returns {string} Href with the base applied, or unchanged if not applicable
54
+ * **Implementation moved to `@uniweb/core/base-path`; edit it there.**
55
+ * Re-exported here because this is where every caller reaches it and should
56
+ * keep reaching it. It came down one layer because `@uniweb/core/services`
57
+ * needs it and **`@uniweb/runtime` does not depend on kit** — so a service
58
+ * address resolved in the runtime could not have reached this copy. The
59
+ * invariant it encodes, and why the join is guarded rather than concatenated,
60
+ * are in the module header there.
62
61
  */
63
- export function applyBasePath(href, basePath) {
64
- if (!href || typeof href !== 'string' || !basePath) return href
65
- if (!href.startsWith('/') || href.startsWith('//')) return href
66
- if (href === basePath || href.startsWith(basePath + '/')) return href // already based
67
- return basePath + href
68
- }
62
+ export { applyBasePath }
69
63
 
70
64
  /**
71
65
  * Translate a route slug and prefix the active locale, when the site is
@@ -1,168 +1,27 @@
1
- import { applyBasePath } from './href.js'
2
-
3
- /**
4
- * Site services — where a site's search, form submissions, assistant, or
5
- * 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:` in site.yml.
16
- * 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 component renders
21
- * for that rather than guessing an address. That is the same rule for every service, and
22
- * it is why this module exists: it was previously implemented three times — the
23
- * 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); it ships **resolution** for anything. A foundation
31
- * that invents `assistant`, `booking` or `translate` gets the same precedence,
32
- * the same base handling and the same absent-means-absent behaviour, and a host
33
- * can 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
- * ## What this deliberately does not model
39
- *
40
- * **Entitlement.** A host that will not serve a service omits it, or declares
41
- * the name with no address. The framework never learns why — no plan names, no
42
- * tiers, no "paid" anywhere. That is not squeamishness: `@uniweb/kit` is
43
- * public, and a framework that encodes which capabilities cost money ships the
44
- * business model into open source.
45
- *
46
- * ⛔ **There is deliberately no explanatory string, and there was one — it was
47
- * a mistake.** Until 2026-08-13 a declining host could supply a `reason` that
48
- * this module relayed "to the UI verbatim", with an English default
49
- * (`NO_SERVICE_REASON`) when nothing did. Removed, on two counts:
50
- *
51
- * 1. **Wrong audience.** A visitor has no stake in which services an operator
52
- * provisioned. "Submissions are not enabled for this site" reports someone's
53
- * billing state to the public and reads like a breakage. It is neither — it
54
- * is a service that was not bought, and **a generic component is supposed to
55
- * be smart about that.**
56
- * 2. **Wrong language, unfixably.** Sites here are multilingual, or unilingual
57
- * and not English. A host-supplied sentence bypasses the site's entire
58
- * localization pipeline, and a canned constant in a public package cannot
59
- * be translated at all. Any text a visitor should read is *site content*,
60
- * which is authored and localized — never a string a service layer invents.
61
- *
62
- * ⇒ **`url` is the whole answer, and absence is a rendering decision rather
63
- * than a message.** No submit endpoint → render no form, or degrade to
64
- * something that still serves the visitor: a `mailto:` or a number the site
65
- * already carries in its content. No assistant → render no Ask-AI affordance.
66
- * Nobody is told why, because nobody visiting needs to know.
67
- *
68
- * **The site's own base.** `config.base` is where the site *lives*, not a
69
- * service it consumes — it is load-bearing for routing and asset URLs too. It
70
- * stays where it is and is an input here, not an entry.
71
- */
72
-
73
- /** Anything with a scheme, or protocol-relative — never joined to a base. */
74
- const ABSOLUTE_URL_RE = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i
75
-
76
- /**
77
- * Read an endpoint out of either declaration form.
78
- *
79
- * A site may write the shorthand (`submit: /forms`) or the object
80
- * (`submit: { endpoint: /forms }`); a host emits JSON and normally writes the
81
- * object. Both are accepted from both sides — one reader, no per-side rules to
82
- * remember.
83
- *
84
- * @param {*} declaration
85
- * @returns {string} the endpoint, or '' when there is none
86
- */
87
- function readEndpoint(declaration) {
88
- if (typeof declaration === 'string') return declaration.trim()
89
- if (typeof declaration?.endpoint === 'string') return declaration.endpoint.trim()
90
- return ''
91
- }
92
-
93
1
  /**
94
- * Join a service endpoint to the site's base path.
95
- *
96
- * Three cases, and the middle one is why this is not simply `applyBasePath`:
2
+ * Site services re-export shim.
97
3
  *
98
- * - **Absolute** (`https://…`, `//host/…`, any scheme) passed through. A
99
- * service on another origin is not the site's to relocate.
100
- * - **Bare relative** (`_search`) rooted first. This spelling is documented
101
- * and in use, and `applyBasePath` alone would leave it untouched, silently
102
- * producing a request relative to whatever page the visitor is on.
103
- * - **Root-relative** (`/forms`) — the ordinary case.
4
+ * **The implementation moved to `@uniweb/core/services`. Edit it there, not
5
+ * here.** This file exists so that no foundation's import had to change:
6
+ * `import { resolveService } from '@uniweb/kit'` is still the way a foundation
7
+ * reaches it, and still should be.
104
8
  *
105
- * The join itself goes through `applyBasePath` rather than concatenation,
106
- * because that is where the invariant "a base is only ever joined to a path that
107
- * starts at the site root" is enforced, and it is idempotent an
108
- * already-based path is not based twice.
9
+ * **Why it moved:** `@uniweb/runtime` resolves a service address itself (for
10
+ * `tracking`) and **does not depend on `@uniweb/kit`** only on core and
11
+ * theming. The alternative was a second resolver with the same job and its own
12
+ * base-joining rules, which is precisely the defect `@uniweb/core/route-match`
13
+ * was created to end after one matcher was implemented twice and the copies
14
+ * diverged by one character.
109
15
  *
110
- * @param {string} endpoint
111
- * @param {string} [basePath] - `website.basePath`
112
- * @returns {string}
113
- */
114
- export function resolveServiceUrl(endpoint, basePath = '') {
115
- if (!endpoint) return ''
116
- if (ABSOLUTE_URL_RE.test(endpoint)) return endpoint
117
-
118
- const rooted = endpoint.startsWith('/') ? endpoint : `/${endpoint}`
119
- // `applyBasePath` concatenates and documents its input as carrying no
120
- // trailing slash, so normalizing is the caller's job — skip it and
121
- // `base: /docs/` yields `/docs//forms`.
122
- const base = (basePath || '').replace(/\/+$/, '')
123
- return applyBasePath(rooted, base)
124
- }
125
-
126
- /**
127
- * Resolve where a named service lives for this site.
16
+ * The full contract — two tiers, site-outranks-host, open registry,
17
+ * absent-means-absent, and why entitlement and decline strings are deliberately
18
+ * unmodelled — lives in the module header there.
128
19
  *
129
- * ```js
130
- * const { url } = resolveService(website, 'submit')
131
- * if (!url) return null // no endpoint — render no form, or degrade
132
- * ```
133
- *
134
- * @param {object} website - the active Website
135
- * @param {string} name - service name, e.g. 'submit' · 'search' · 'assistant'
136
- * @returns {{ url: string|null, source: 'site'|'host'|null }}
137
- * `url` is the whole answer for rendering. `source` says which declaration
138
- * answered — a diagnostic, and the thing to check when a host's value appears
139
- * not to be taking effect. `'host'` with a null `url` means the host answered
140
- * and offered no address; `null` means nothing declared the service at all.
20
+ * @module @uniweb/kit/utils/services
141
21
  */
142
- export function resolveService(website, name) {
143
- const config = website?.config
144
- const basePath = website?.basePath
145
-
146
- // 1 — the site's own declaration wins. An operator who named an endpoint
147
- // means it, including on a host that offers one.
148
- const authored = readEndpoint(config?.[name])
149
- if (authored) {
150
- return { url: resolveServiceUrl(authored, basePath), source: 'site' }
151
- }
152
-
153
- // 2 — what the host says it offers.
154
- const hostDeclaration = config?.services?.[name]
155
- const hostEndpoint = readEndpoint(hostDeclaration)
156
- if (hostEndpoint) {
157
- return { url: resolveServiceUrl(hostEndpoint, basePath), source: 'host' }
158
- }
159
-
160
- // A host may declare the name while offering no address — a decline. It is
161
- // still the host answering, which is all a caller can use: any *wording* for
162
- // that state would be ours to invent, in one language, for a visitor who has
163
- // no stake in it. See the entitlement note above.
164
- if (hostDeclaration !== undefined) return { url: null, source: 'host' }
165
22
 
166
- // 3 — nobody supplied one.
167
- return { url: null, source: null }
168
- }
23
+ export {
24
+ resolveService,
25
+ resolveServiceUrl,
26
+ readServiceOptions
27
+ } from '@uniweb/core/services'