@uniweb/kit 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/kit",
3
- "version": "0.11.3",
3
+ "version": "0.12.1",
4
4
  "description": "Standard component library for Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -44,7 +44,7 @@
44
44
  "shiki": "^3.0.0",
45
45
  "tailwind-merge": "^3.6.0",
46
46
  "temml": "^0.13.2",
47
- "@uniweb/core": "^0.8.5",
47
+ "@uniweb/core": "^0.9.0",
48
48
  "@uniweb/semantic-parser": "^1.2.2",
49
49
  "@uniweb/scene": "^0.1.3"
50
50
  },
@@ -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'
@@ -9,19 +9,27 @@ import { useWebsite } from './useWebsite.js'
9
9
  *
10
10
  * The hook resolves *where* to submit from the site's configuration (`submit:`
11
11
  * in site.yml) or from its host, so a component never names an endpoint. When
12
- * neither supplies one, `canSubmit` is false and `unavailableReason` says why
13
- * render the control disabled rather than letting someone fill in a form whose
12
+ * neither supplies one, `canSubmit` is false don't render a form whose
14
13
  * contents have nowhere to go. See `resolveSubmitTarget` for the precedence.
15
14
  *
16
15
  * ```jsx
17
- * const { submit, status, error, canSubmit, unavailableReason } =
16
+ * const { submit, status, error, canSubmit } =
18
17
  * useFormSubmit({ block, context: { formId: 'contact' } })
19
18
  *
20
- * <button type="submit" disabled={!canSubmit || status === 'submitting'}>
21
- * {canSubmit ? 'Send' : unavailableReason}
22
- * </button>
19
+ * if (!canSubmit) return null // or fall back to contact details the
20
+ * // site already carries in its content
21
+ *
22
+ * <button type="submit" disabled={status === 'submitting'}>Send</button>
23
23
  * ```
24
24
  *
25
+ * ⛔ **There is no `unavailableReason`, and there was one — it was a mistake.**
26
+ * It carried a canned English sentence and this very example rendered it as
27
+ * button copy. A visitor has no stake in which services the operator bought,
28
+ * the string reads like a breakage when nothing is broken, and it cannot be
29
+ * translated on a framework whose sites are usually multilingual and often not
30
+ * English. Text a visitor reads is *site content*. `canSubmit` is the whole
31
+ * signal.
32
+ *
25
33
  * Pass `block` and the submission carries where it came from — section type,
26
34
  * section id, page id and label — without every component assembling that by
27
35
  * hand. Anything in `context` wins over what the block supplies.
@@ -39,7 +47,6 @@ import { useWebsite } from './useWebsite.js'
39
47
  * error: Error | null,
40
48
  * response: object | null,
41
49
  * canSubmit: boolean,
42
- * unavailableReason: string | null,
43
50
  * submit: (formData: object, overrides?: object) => Promise<object>,
44
51
  * reset: () => void,
45
52
  * }}
@@ -50,7 +57,7 @@ export function useFormSubmit(defaults = {}) {
50
57
  const [response, setResponse] = useState(null)
51
58
 
52
59
  const { website } = useWebsite()
53
- const { url: target, reason: unavailableReason } = resolveSubmitTarget(website)
60
+ const { url: target } = resolveSubmitTarget(website)
54
61
 
55
62
  // `defaults` is a fresh object every render, so listing it as a dependency
56
63
  // would rebuild the callback on each one. A ref is what actually makes "the
@@ -103,7 +110,6 @@ export function useFormSubmit(defaults = {}) {
103
110
  error,
104
111
  response,
105
112
  canSubmit: !!target,
106
- unavailableReason,
107
113
  // Whether attachments can be delivered. True once there is a target: the
108
114
  // client sends the manifest, then the bytes, then finalizes.
109
115
  //
@@ -51,7 +51,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
51
51
  * It lists the paths of `required` controls that are still empty. It does not
52
52
  * block anything: whether an incomplete form disables the button, shows a
53
53
  * message, or submits anyway is a design decision. `useFormSubmit` draws the
54
- * same line with `canSubmit` / `unavailableReason` — the kit works out the
54
+ * same line with `canSubmit` — the kit works out the
55
55
  * fact, the foundation decides what it looks like.
56
56
  *
57
57
  * Empty means `undefined`, `null`, `''`, or `[]`. A `false` boolean is a VALUE,
@@ -0,0 +1,78 @@
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
+
28
+ const MILESTONES = [25, 50, 75, 100]
29
+
30
+ /** @returns {number} 0-100; 100 when the page fits in the viewport */
31
+ function getScrollDepth() {
32
+ const scrollTop = window.scrollY
33
+ const docHeight = document.documentElement.scrollHeight - window.innerHeight
34
+ if (docHeight <= 0) return 100
35
+ return Math.min(100, Math.round((scrollTop / docHeight) * 100))
36
+ }
37
+
38
+ /**
39
+ * @param {Object} [options]
40
+ * @param {boolean} [options.enabled=true]
41
+ * @param {number} [options.throttleMs=200]
42
+ * @param {string} [options.event='scroll_depth'] - override the event name
43
+ */
44
+ export function useScrollDepth(options = {}) {
45
+ const { enabled = true, throttleMs = 200, event = 'scroll_depth' } = options
46
+
47
+ const lastCheck = useRef(0)
48
+ const reported = useRef(new Set())
49
+
50
+ useEffect(() => {
51
+ const tracking = getUniweb()?.tracking
52
+ // Skip the listener entirely when there is nowhere to report — the calls
53
+ // would be no-ops, but the scroll handler would still run on every frame.
54
+ if (!enabled || !tracking?.isEnabled?.()) return
55
+
56
+ reported.current.clear()
57
+
58
+ const handleScroll = () => {
59
+ const now = Date.now()
60
+ if (now - lastCheck.current < throttleMs) return
61
+ lastCheck.current = now
62
+
63
+ const depth = getScrollDepth()
64
+ for (const milestone of MILESTONES) {
65
+ if (depth >= milestone && !reported.current.has(milestone)) {
66
+ reported.current.add(milestone)
67
+ tracking.track(event, { depth: milestone })
68
+ }
69
+ }
70
+ }
71
+
72
+ handleScroll() // a page that fits the viewport is already at 100
73
+ window.addEventListener('scroll', handleScroll, { passive: true })
74
+ return () => window.removeEventListener('scroll', handleScroll)
75
+ }, [enabled, throttleMs, event])
76
+ }
77
+
78
+ 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,64 @@
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: a tracker with no destination stays
54
+ // 'granted' and never moves, and the banner should not claim otherwise.
55
+ setStatus(readStatus())
56
+ }, [])
57
+
58
+ const grant = useCallback(() => set(true), [set])
59
+ const deny = useCallback(() => set(false), [set])
60
+
61
+ return { status, grant, deny }
62
+ }
63
+
64
+ 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
  // ============================================================================
@@ -130,11 +136,9 @@ export {
130
136
  submitForm,
131
137
  deriveSummary,
132
138
  resolveSubmitTarget,
133
- NO_SUBMIT_TARGET_REASON,
134
139
  // Site services (search / submit / assistant / anything a host offers)
135
140
  resolveService,
136
- resolveServiceUrl,
137
- NO_SERVICE_REASON
141
+ resolveServiceUrl
138
142
  } from './utils/index.js'
139
143
 
140
144
  // ============================================================================
@@ -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
@@ -248,8 +248,11 @@ export function detectMediaType(url) {
248
248
  // ─────────────────────────────────────────────────────────────────
249
249
 
250
250
  export { submitForm, deriveSummary } from './submitForm.js'
251
- export { resolveSubmitTarget, NO_SUBMIT_TARGET_REASON } from './submitTarget.js'
252
- export { resolveService, resolveServiceUrl, NO_SERVICE_REASON } from './services.js'
251
+ export { resolveSubmitTarget } from './submitTarget.js'
252
+ export {
253
+ resolveService,
254
+ resolveServiceUrl
255
+ } from './services.js'
253
256
 
254
257
  /**
255
258
  * The text of a ProseMirror node, flattened.
@@ -1,148 +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 says so
21
- * 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 either omits it or
41
- * supplies a `reason`, which is relayed to the UI verbatim. The framework never
42
- * learns why — no plan names, no tiers, no "paid" anywhere. That is not
43
- * squeamishness: `@uniweb/kit` is public, and a framework that encodes which
44
- * capabilities cost money ships the business model into open source.
45
- *
46
- * **The site's own base.** `config.base` is where the site *lives*, not a
47
- * service it consumes — it is load-bearing for routing and asset URLs too. It
48
- * stays where it is and is an input here, not an entry.
49
- */
50
-
51
- /** Anything with a scheme, or protocol-relative — never joined to a base. */
52
- const ABSOLUTE_URL_RE = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i
53
-
54
- /** Shown when nothing supplies a service and the caller gives no wording. */
55
- export const NO_SERVICE_REASON = 'This site has no such service configured.'
56
-
57
- /**
58
- * Read an endpoint out of either declaration form.
59
- *
60
- * A site may write the shorthand (`submit: /forms`) or the object
61
- * (`submit: { endpoint: /forms }`); a host emits JSON and normally writes the
62
- * object. Both are accepted from both sides — one reader, no per-side rules to
63
- * remember.
64
- *
65
- * @param {*} declaration
66
- * @returns {string} the endpoint, or '' when there is none
67
- */
68
- function readEndpoint(declaration) {
69
- if (typeof declaration === 'string') return declaration.trim()
70
- if (typeof declaration?.endpoint === 'string') return declaration.endpoint.trim()
71
- return ''
72
- }
73
-
74
1
  /**
75
- * Join a service endpoint to the site's base path.
2
+ * Site services re-export shim.
76
3
  *
77
- * Three cases, and the middle one is why this is not simply `applyBasePath`:
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.
78
8
  *
79
- * - **Absolute** (`https://…`, `//host/…`, any scheme) passed through. A
80
- * service on another origin is not the site's to relocate.
81
- * - **Bare relative** (`_search`) rooted first. This spelling is documented
82
- * and in use, and `applyBasePath` alone would leave it untouched, silently
83
- * producing a request relative to whatever page the visitor is on.
84
- * - **Root-relative** (`/forms`) the ordinary case.
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.
85
15
  *
86
- * The join itself goes through `applyBasePath` rather than concatenation,
87
- * because that is where the invariant "a base is only ever joined to a path that
88
- * starts at the site root" is enforced, and it is idempotent — an
89
- * already-based path is not based twice.
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.
90
19
  *
91
- * @param {string} endpoint
92
- * @param {string} [basePath] - `website.basePath`
93
- * @returns {string}
20
+ * @module @uniweb/kit/utils/services
94
21
  */
95
- export function resolveServiceUrl(endpoint, basePath = '') {
96
- if (!endpoint) return ''
97
- if (ABSOLUTE_URL_RE.test(endpoint)) return endpoint
98
-
99
- const rooted = endpoint.startsWith('/') ? endpoint : `/${endpoint}`
100
- // `applyBasePath` concatenates and documents its input as carrying no
101
- // trailing slash, so normalizing is the caller's job — skip it and
102
- // `base: /docs/` yields `/docs//forms`.
103
- const base = (basePath || '').replace(/\/+$/, '')
104
- return applyBasePath(rooted, base)
105
- }
106
-
107
- /**
108
- * Resolve where a named service lives for this site.
109
- *
110
- * ```js
111
- * const { url, reason } = resolveService(website, 'submit')
112
- * if (!url) return renderDisabled(reason)
113
- * ```
114
- *
115
- * @param {object} website - the active Website
116
- * @param {string} name - service name, e.g. 'submit' · 'search' · 'assistant'
117
- * @param {object} [options]
118
- * @param {string} [options.reason] - wording when nothing supplies it
119
- * @returns {{ url: string|null, reason: string|null, source: 'site'|'host'|null }}
120
- * `source` says which declaration answered — useful in diagnostics, and the
121
- * thing to check when a host's value appears not to be taking effect.
122
- */
123
- export function resolveService(website, name, options = {}) {
124
- const config = website?.config
125
- const basePath = website?.basePath
126
-
127
- // 1 — the site's own declaration wins. An operator who named an endpoint
128
- // means it, including on a host that offers one.
129
- const authored = readEndpoint(config?.[name])
130
- if (authored) {
131
- return { url: resolveServiceUrl(authored, basePath), reason: null, source: 'site' }
132
- }
133
-
134
- // 2 — what the host says it offers.
135
- const hostDeclaration = config?.services?.[name]
136
- const hostEndpoint = readEndpoint(hostDeclaration)
137
- if (hostEndpoint) {
138
- return { url: resolveServiceUrl(hostEndpoint, basePath), reason: null, source: 'host' }
139
- }
140
-
141
- // A host that declines may say why, and that reaches the visitor unaltered.
142
- const hostReason =
143
- typeof hostDeclaration?.reason === 'string' ? hostDeclaration.reason.trim() : ''
144
- if (hostReason) return { url: null, reason: hostReason, source: 'host' }
145
22
 
146
- // 3 — nobody supplied one.
147
- return { url: null, reason: options.reason || NO_SERVICE_REASON, source: null }
148
- }
23
+ export {
24
+ resolveService,
25
+ resolveServiceUrl,
26
+ readServiceOptions
27
+ } from '@uniweb/core/services'
@@ -31,24 +31,16 @@ import { resolveService, resolveServiceUrl } from './services.js'
31
31
  * only have come from its host.
32
32
  */
33
33
 
34
- /**
35
- * Shown when a site declares no endpoint and no host offers one. English, and a
36
- * foundation is free to ignore it and render its own copy — check `url` for the
37
- * yes/no and treat this as a default rather than a string to translate.
38
- */
39
- export const NO_SUBMIT_TARGET_REASON =
40
- 'This site has no form submission endpoint configured.'
41
-
42
34
  /**
43
35
  * Resolve the submission target for a site.
44
36
  *
45
37
  * @param {object} website - The active Website instance
46
- * @returns {{ url: string|null, reason: string|null, source: 'site'|'host'|null }}
47
- * `url` is the resolved endpoint, or null when nothing supplies one. `reason`
48
- * is set exactly when `url` is null.
38
+ * @returns {{ url: string|null, source: 'site'|'host'|null }}
39
+ * `url` is the resolved endpoint, or null when nothing supplies one — in which
40
+ * case render no form, or degrade to contact details the site already carries.
49
41
  */
50
42
  export function resolveSubmitTarget(website) {
51
- return resolveService(website, 'submit', { reason: NO_SUBMIT_TARGET_REASON })
43
+ return resolveService(website, 'submit')
52
44
  }
53
45
 
54
46
  /**