@uniweb/kit 0.10.12 → 0.10.14

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.10.12",
3
+ "version": "0.10.14",
4
4
  "description": "Standard component library for Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -43,9 +43,9 @@
43
43
  "fuse.js": "^7.0.0",
44
44
  "shiki": "^3.0.0",
45
45
  "tailwind-merge": "^3.6.0",
46
+ "@uniweb/semantic-parser": "1.2.1",
46
47
  "@uniweb/scene": "0.1.2",
47
- "@uniweb/core": "0.8.1",
48
- "@uniweb/semantic-parser": "1.2.1"
48
+ "@uniweb/core": "0.8.2"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "react": "^19.0.0",
@@ -1,29 +1,45 @@
1
- import { useCallback, useState } from 'react'
1
+ import { useCallback, useRef, useState } from 'react'
2
2
  import { submitForm } from '../utils/submitForm.js'
3
+ import { resolveSubmitTarget } from '../utils/submitTarget.js'
4
+ import { useWebsite } from './useWebsite.js'
3
5
 
4
6
  /**
5
- * React hook wrapping `submitForm()` with state machine for the
6
- * `idle → submitting → success | error` lifecycle most form UIs need.
7
+ * Submit a form, with the `idle submitting success | error` lifecycle most
8
+ * form UIs need.
7
9
  *
8
- * Pass `defaults` once (formId, sectionType, preview-builder, …) and call
9
- * `submit(formData)` from your submit handler. The hook exposes:
10
- * - status: 'idle' | 'submitting' | 'success' | 'error'
11
- * - error: Error | null
12
- * - response: { submissionId, uploadUrls? } | null (on success)
13
- * - submit: async (formData, perCallOverrides?) => response
14
- * - reset: () => void
10
+ * The hook resolves *where* to submit from the site's configuration (`submit:`
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
14
+ * contents have nowhere to go. See `resolveSubmitTarget` for the precedence.
15
15
  *
16
- * `defaults.preview` may be either a static object or a function of formData
17
- * that returns one. The function form is useful when the preview is computed
18
- * from the same fields that just got submitted.
16
+ * ```jsx
17
+ * const { submit, status, error, canSubmit, unavailableReason } =
18
+ * useFormSubmit({ block, context: { formId: 'contact' } })
19
19
  *
20
- * Examples see kit's submitForm() JSDoc for the full payload contract.
20
+ * <button type="submit" disabled={!canSubmit || status === 'submitting'}>
21
+ * {canSubmit ? 'Send' : unavailableReason}
22
+ * </button>
23
+ * ```
24
+ *
25
+ * Pass `block` and the submission carries where it came from — section type,
26
+ * section id, page id and label — without every component assembling that by
27
+ * hand. Anything in `context` wins over what the block supplies.
28
+ *
29
+ * `summary` may be an object or a function of the submitted values, which is
30
+ * the useful form when the digest is built from the fields that were just
31
+ * filled in.
21
32
  *
22
33
  * @param {object} [defaults] — merged into every submit() call
34
+ * @param {object} [defaults.block] — the section's block, for submission context
35
+ * @param {object} [defaults.context] — formId and any explicit overrides
36
+ * @param {object|Function} [defaults.summary] — { title, subtitle, tag? } or (formData) => that
23
37
  * @returns {{
24
38
  * status: 'idle' | 'submitting' | 'success' | 'error',
25
39
  * error: Error | null,
26
40
  * response: object | null,
41
+ * canSubmit: boolean,
42
+ * unavailableReason: string | null,
27
43
  * submit: (formData: object, overrides?: object) => Promise<object>,
28
44
  * reset: () => void,
29
45
  * }}
@@ -33,16 +49,36 @@ export function useFormSubmit(defaults = {}) {
33
49
  const [error, setError] = useState(null)
34
50
  const [response, setResponse] = useState(null)
35
51
 
52
+ const { website } = useWebsite()
53
+ const { url: target, reason: unavailableReason } = resolveSubmitTarget(website)
54
+
55
+ // `defaults` is a fresh object every render, so listing it as a dependency
56
+ // would rebuild the callback on each one. A ref is what actually makes "the
57
+ // newest defaults win" true — with an empty dependency list the callback
58
+ // closes over the *first* render's defaults forever, so a summary or context
59
+ // computed from changing content would silently keep sending the original.
60
+ const defaultsRef = useRef(defaults)
61
+ defaultsRef.current = defaults
62
+
36
63
  const submit = useCallback(
37
64
  async (formData, perCallOverrides = {}) => {
38
65
  setStatus('submitting')
39
66
  setError(null)
40
67
  try {
41
- const merged = { ...defaults, ...perCallOverrides, formData }
42
- // Resolve preview-as-function against the formData being submitted.
43
- if (typeof merged.preview === 'function') {
44
- merged.preview = merged.preview(formData)
68
+ const { block, context, summary, ...rest } = {
69
+ ...defaultsRef.current,
70
+ ...perCallOverrides,
45
71
  }
72
+
73
+ const merged = {
74
+ ...rest,
75
+ formData,
76
+ target,
77
+ // Block-derived context first so an explicit `context` overrides it.
78
+ context: { ...contextFromBlock(block), ...context },
79
+ summary: typeof summary === 'function' ? summary(formData) : summary,
80
+ }
81
+
46
82
  const result = await submitForm(merged)
47
83
  setStatus('success')
48
84
  setResponse(result)
@@ -53,11 +89,7 @@ export function useFormSubmit(defaults = {}) {
53
89
  throw err
54
90
  }
55
91
  },
56
- // `defaults` is referentially unstable across renders; we deliberately
57
- // close over the latest one each render rather than memo the hook.
58
- // The lint rule fires false positives here.
59
- // eslint-disable-next-line react-hooks/exhaustive-deps
60
- [],
92
+ [target],
61
93
  )
62
94
 
63
95
  const reset = useCallback(() => {
@@ -66,5 +98,40 @@ export function useFormSubmit(defaults = {}) {
66
98
  setResponse(null)
67
99
  }, [])
68
100
 
69
- return { status, error, response, submit, reset }
101
+ return {
102
+ status,
103
+ error,
104
+ response,
105
+ canSubmit: !!target,
106
+ unavailableReason,
107
+ submit,
108
+ reset,
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Where a submission came from, read off the block the form is rendered in.
114
+ *
115
+ * `stableId` is preferred over the positional `id` because it survives a
116
+ * section being reordered on its page — a submission's origin should not change
117
+ * because something moved above it. Keys with no value are dropped rather than
118
+ * sent as null, so they never mask a caller-supplied one.
119
+ *
120
+ * @param {object} [block]
121
+ * @returns {object}
122
+ */
123
+ function contextFromBlock(block) {
124
+ if (!block) return {}
125
+
126
+ const page = block.page
127
+ const fields = {
128
+ sectionType: block.type,
129
+ sectionId: block.stableId || block.id,
130
+ pageId: page?.id,
131
+ pageLabel: page?.title,
132
+ }
133
+
134
+ return Object.fromEntries(
135
+ Object.entries(fields).filter(([, v]) => v !== undefined && v !== null && v !== ''),
136
+ )
70
137
  }
package/src/index.js CHANGED
@@ -124,7 +124,9 @@ export {
124
124
  getLocaleLabel,
125
125
  // Form submission utilities (low-level companion of useFormSubmit hook)
126
126
  submitForm,
127
- derivePreviewFromFormData
127
+ deriveSummary,
128
+ resolveSubmitTarget,
129
+ NO_SUBMIT_TARGET_REASON
128
130
  } from './utils/index.js'
129
131
 
130
132
  // ============================================================================
@@ -247,7 +247,8 @@ export function detectMediaType(url) {
247
247
  // Form Submission
248
248
  // ─────────────────────────────────────────────────────────────────
249
249
 
250
- export { submitForm, derivePreviewFromFormData } from './submitForm.js'
250
+ export { submitForm, deriveSummary } from './submitForm.js'
251
+ export { resolveSubmitTarget, NO_SUBMIT_TARGET_REASON } from './submitTarget.js'
251
252
 
252
253
  /**
253
254
  * The text of a ProseMirror node, flattened.
@@ -1,63 +1,78 @@
1
1
  /**
2
- * Submit a form to the Uniweb submission endpoint.
2
+ * POST a form's values to a submission endpoint.
3
3
  *
4
- * Form components in a foundation collect field values and call this to
5
- * deliver them to the platform's submission pipeline. The submission lands
6
- * keyed by the site's identity (resolved by the platform from the
7
- * visitor's hostname), so this function takes no siteId — the page's
8
- * own URL determines the destination.
4
+ * This is the low-level companion of `useFormSubmit()`. Most components want
5
+ * the hook it resolves the target from the site's configuration and tracks
6
+ * the request's lifecycle. Reach for this directly when you are submitting
7
+ * outside React, or already hold a resolved target.
9
8
  *
10
- * The default `submitPath` is `/_submit`, served on the visitor's hostname
11
- * by the Uniweb runtime infrastructure. Override only if you're testing
12
- * against a non-standard endpoint.
9
+ * ## `target` is required, and there is no default
13
10
  *
14
- * The `preview` object becomes the {title, subtitle, tag} that the
15
- * editor's inbox row displays for this submission. If a foundation
16
- * doesn't pass one, a fallback is derived from the first two non-empty
17
- * string fields of `formData` so the row is always meaningful.
11
+ * Where a site's submissions go is the site's declaration (`submit:` in
12
+ * site.yml see `resolveSubmitTarget`), never this function's guess. Calling
13
+ * without a target throws rather than falling back to a path, because a form
14
+ * POSTing into a 404 loses what a visitor typed and reports success-shaped
15
+ * failure. Resolve first, disable the control if there is nowhere to send.
18
16
  *
19
- * Optional `turnstileToken` is forwarded as-is for bot-protection
20
- * verification when the platform has Turnstile enabled.
17
+ * ## API names and wire names differ, on purpose
18
+ *
19
+ * The request body's field names are a contract with already-deployed
20
+ * endpoints, so they are kept exactly as those endpoints read them. This
21
+ * function's *arguments* are named for what they mean to a foundation
22
+ * developer. The mapping is applied in one place below and is not a bug to
23
+ * tidy up — changing the body's spelling would require every deployed endpoint
24
+ * to change with it.
21
25
  *
22
26
  * @param {object} args
23
- * @param {Record<string, unknown>} args.formData — field values
24
- * @param {object} [args.preview] { title, subtitle, tag? }
25
- * @param {object} [args.metadata] formId, sectionType, sectionId, pageId, pageLabel,
26
- * @param {string} [args.turnstileToken] — Cloudflare Turnstile token
27
+ * @param {Record<string, unknown>} args.formData — field values
28
+ * @param {string} args.target resolved endpoint URL (required)
29
+ * @param {object} [args.summary] { title, subtitle, tag? }, a short
30
+ * human-readable digest of this
31
+ * submission; derived from formData
32
+ * when omitted
33
+ * @param {object} [args.context] — where the submission came from:
34
+ * formId, sectionType, sectionId,
35
+ * pageId, pageLabel
36
+ * @param {string} [args.verificationToken] — bot-protection token, when the
37
+ * endpoint verifies one
27
38
  * @param {Array<{name:string,size:number,mime?:string}>} [args.fileSlots]
28
- * — declared file uploads (multi-step ingestion)
29
- * @param {string} [args.submitPath='/_submit'] endpoint override (testing)
30
- * @param {typeof fetch} [args.fetchFn=fetch] — fetch override (testing / SSR)
39
+ * — declared file uploads
40
+ * @param {typeof fetch} [args.fetchFn=fetch] fetch override (testing / SSR)
31
41
  *
32
42
  * @returns {Promise<{ submissionId: string, uploadUrls?: Array }>}
33
- * @throws {Error} on non-2xx with the server's `error` message when present.
43
+ * @throws {Error} with no target, and on non-2xx with the server's message when present.
34
44
  */
35
45
  export async function submitForm({
36
46
  formData,
37
- preview,
38
- metadata = {},
39
- turnstileToken,
47
+ target,
48
+ summary,
49
+ context = {},
50
+ verificationToken,
40
51
  fileSlots,
41
- submitPath = '/_submit',
42
52
  fetchFn = typeof fetch === 'function' ? fetch : null,
43
53
  } = {}) {
44
54
  if (!formData || typeof formData !== 'object') {
45
55
  throw new Error('submitForm: formData object is required')
46
56
  }
57
+ if (!target || typeof target !== 'string') {
58
+ throw new Error(
59
+ 'submitForm: no submission target. Declare `submit:` in site.yml, or ' +
60
+ 'check `canSubmit` from useFormSubmit() before calling.',
61
+ )
62
+ }
47
63
  if (!fetchFn) {
48
64
  throw new Error('submitForm: fetch is unavailable in this environment')
49
65
  }
50
66
 
51
- const finalPreview = preview || derivePreviewFromFormData(formData)
52
-
67
+ // ── API name wire name. See the header before "correcting" these. ──
53
68
  const body = {
54
69
  formData,
55
- metadata: { ...metadata, preview: finalPreview },
56
- ...(turnstileToken ? { turnstileToken } : {}),
70
+ metadata: { ...context, preview: summary || deriveSummary(formData) },
71
+ ...(verificationToken ? { turnstileToken: verificationToken } : {}),
57
72
  ...(Array.isArray(fileSlots) && fileSlots.length ? { fileSlots } : {}),
58
73
  }
59
74
 
60
- const res = await fetchFn(submitPath, {
75
+ const res = await fetchFn(target, {
61
76
  method: 'POST',
62
77
  headers: { 'Content-Type': 'application/json' },
63
78
  body: JSON.stringify(body),
@@ -73,14 +88,14 @@ export async function submitForm({
73
88
  }
74
89
 
75
90
  /**
76
- * Build a default preview from a form's field values: first two non-empty
77
- * string fields become the title / subtitle. Mirrors the legacy
78
- * getStandardPreview() convention from the prior Form class.
91
+ * Build a default summary from a form's values: the first two non-empty string
92
+ * fields become the title and subtitle, so whoever reads submissions sees
93
+ * something meaningful even when a component passes no summary of its own.
79
94
  *
80
95
  * @param {Record<string, unknown>} data
81
96
  * @returns {{ title: string, subtitle: string }}
82
97
  */
83
- export function derivePreviewFromFormData(data) {
98
+ export function deriveSummary(data) {
84
99
  if (!data || typeof data !== 'object') return { title: 'Submission', subtitle: '' }
85
100
  const entries = Object.entries(data).filter(
86
101
  ([, v]) => typeof v === 'string' && v.trim().length > 0,
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Resolve where this site's form submissions go.
3
+ *
4
+ * A site declares its endpoint under `submit:` in site.yml, and the value
5
+ * arrives here as `website.config.submit` — the same passthrough every other
6
+ * site.yml key rides (`collections:`, `search:`, `fetcher:`). Two spellings,
7
+ * because one destination is the common case and the object form leaves room
8
+ * to grow:
9
+ *
10
+ * submit: /forms # shorthand
11
+ * submit: { endpoint: /forms } # object form
12
+ * submit: https://forms.example.com/intake
13
+ *
14
+ * A relative endpoint resolves against `website.basePath`, so one spelling
15
+ * works whether the site is served from the root, from a subdirectory
16
+ * (`base: /docs/`), or from a host subpath. An absolute URL passes through
17
+ * untouched, for a form service on another origin.
18
+ *
19
+ * ## Precedence — and why the framework never picks one itself
20
+ *
21
+ * A destination comes from the first of these that applies:
22
+ *
23
+ * 1. `submit:` here — the site operator's declaration.
24
+ * 2. `forms: { endpoint, reason }` in the served payload config — what the
25
+ * HOST reports about this site. A host that accepts submissions gives an
26
+ * endpoint; one that does not may give a reason, which is relayed verbatim.
27
+ * 3. Neither — `url: null` plus a generic reason, so the form renders disabled.
28
+ *
29
+ * What the framework must never do is invent one. It cannot know where a given
30
+ * site can accept a submission, and guessing is worse than having none: on a
31
+ * static host, or any deployment with nothing listening, the form POSTs a
32
+ * visitor's data into a 404 and nothing here can tell. The asymmetry with
33
+ * reading matters — a fetch that 404s degrades to `[]` and the page still
34
+ * renders, which is a documented guarantee, but a write that 404s loses what
35
+ * someone typed.
36
+ *
37
+ * Note that a host supplying a site-local path is NOT the same as the framework
38
+ * defaulting to one, even when the two produce an identical request: a host only
39
+ * offers the path when something is actually there to catch it. Same bytes, and
40
+ * only one of them is correct.
41
+ *
42
+ * ## Simulating a host locally
43
+ *
44
+ * The bundle lane spreads all of site.yml into the payload config, so writing
45
+ * `forms: { endpoint: … }` in site.yml exercises tier 2 end to end with no
46
+ * framework change. It cannot leak into a real deployment: the sync lane is an
47
+ * explicit allowlist and does not carry `forms`, so a synced site's value can
48
+ * only have come from its host.
49
+ */
50
+
51
+ /**
52
+ * Shown when a site declares no endpoint. English, and a foundation is free to
53
+ * ignore it and render its own copy — check `url` for the yes/no and treat this
54
+ * as a default rather than a string to translate.
55
+ */
56
+ export const NO_SUBMIT_TARGET_REASON =
57
+ 'This site has no form submission endpoint configured.'
58
+
59
+ /**
60
+ * Resolve the submission target for a site.
61
+ *
62
+ * @param {object} website - The active Website instance
63
+ * @returns {{ url: string|null, reason: string|null }}
64
+ * `url` is the resolved absolute-or-base-relative endpoint, or null when the
65
+ * site declares none. `reason` is set exactly when `url` is null.
66
+ */
67
+ export function resolveSubmitTarget(website) {
68
+ const config = website?.config
69
+ const basePath = website?.basePath
70
+
71
+ // Tier 1 — the site operator's own declaration (`submit:` in site.yml).
72
+ const declared = config?.submit
73
+ const declaredEndpoint =
74
+ typeof declared === 'string'
75
+ ? declared.trim()
76
+ : typeof declared?.endpoint === 'string'
77
+ ? declared.endpoint.trim()
78
+ : ''
79
+
80
+ if (declaredEndpoint) {
81
+ return { url: resolveAgainstBase(declaredEndpoint, basePath), reason: null }
82
+ }
83
+
84
+ // Tier 2 — a destination the HOST reports, under `forms` in the served
85
+ // payload config. Named for the capability rather than the action so it
86
+ // cannot be misread as the authored `submit` sitting beside it in the same
87
+ // flat object.
88
+ const host = config?.forms
89
+ const hostEndpoint = typeof host?.endpoint === 'string' ? host.endpoint.trim() : ''
90
+ if (hostEndpoint) {
91
+ return { url: resolveAgainstBase(hostEndpoint, basePath), reason: null }
92
+ }
93
+
94
+ // A host that reports WHY it cannot accept submissions gets that relayed
95
+ // verbatim. The framework does not interpret, reword, or second-guess it: it
96
+ // has no standing to judge why a given site can or cannot accept them, and
97
+ // guessing at the reason in public code would mean encoding someone else's
98
+ // policy here.
99
+ const hostReason = typeof host?.reason === 'string' ? host.reason.trim() : ''
100
+ if (hostReason) return { url: null, reason: hostReason }
101
+
102
+ // Tier 3 — nobody supplied one.
103
+ return { url: null, reason: NO_SUBMIT_TARGET_REASON }
104
+ }
105
+
106
+ /**
107
+ * Join a declared endpoint to the site's base path.
108
+ *
109
+ * This is the same shape as `resolveEndpointUrl` in the search provider, and is
110
+ * deliberately NOT shared with it: that one falls back to a default endpoint
111
+ * when given nothing, which is precisely the behaviour a submission target must
112
+ * not have (see the header). Three lines of duplication is the cheaper mistake.
113
+ *
114
+ * @param {string} endpoint - Declared endpoint, relative or absolute
115
+ * @param {string} [basePath] - `website.basePath`, normalized without a trailing slash
116
+ * @returns {string}
117
+ */
118
+ export function resolveAgainstBase(endpoint, basePath = '') {
119
+ if (/^https?:\/\//i.test(endpoint)) return endpoint
120
+
121
+ const base = (basePath || '').replace(/\/+$/, '')
122
+ const rel = endpoint.replace(/^\/+/, '')
123
+ return `${base}/${rel}`
124
+ }