@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 +3 -3
- package/src/hooks/index.js +7 -0
- package/src/hooks/useScrollDepth.js +97 -0
- package/src/hooks/useTracker.js +40 -0
- package/src/hooks/useTrackingConsent.js +68 -0
- package/src/index.js +7 -1
- package/src/styled/Media/Media.jsx +18 -7
- package/src/utils/href.js +11 -17
- package/src/utils/services.js +20 -161
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/kit",
|
|
3
|
-
"version": "0.12.
|
|
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/
|
|
47
|
+
"@uniweb/core": "^0.10.0",
|
|
48
48
|
"@uniweb/scene": "^0.1.3",
|
|
49
|
-
"@uniweb/
|
|
49
|
+
"@uniweb/semantic-parser": "^1.2.2"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
52
|
"react": "^19.0.0",
|
package/src/hooks/index.js
CHANGED
|
@@ -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
|
-
//
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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
|
|
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
|
package/src/utils/services.js
CHANGED
|
@@ -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
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* Three cases, and the middle one is why this is not simply `applyBasePath`:
|
|
2
|
+
* Site services — re-export shim.
|
|
97
3
|
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
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
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
23
|
+
export {
|
|
24
|
+
resolveService,
|
|
25
|
+
resolveServiceUrl,
|
|
26
|
+
readServiceOptions
|
|
27
|
+
} from '@uniweb/core/services'
|