@uniweb/kit 0.10.15 → 0.10.17

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.15",
3
+ "version": "0.10.17",
4
4
  "description": "Standard component library for Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -43,8 +43,8 @@
43
43
  "fuse.js": "^7.0.0",
44
44
  "shiki": "^3.0.0",
45
45
  "tailwind-merge": "^3.6.0",
46
- "@uniweb/core": "0.8.2",
47
46
  "@uniweb/scene": "0.1.2",
47
+ "@uniweb/core": "0.8.2",
48
48
  "@uniweb/semantic-parser": "1.2.1"
49
49
  },
50
50
  "peerDependencies": {
@@ -104,6 +104,20 @@ export function useFormSubmit(defaults = {}) {
104
104
  response,
105
105
  canSubmit: !!target,
106
106
  unavailableReason,
107
+ // Whether attachments can be delivered. True once there is a target: the
108
+ // client sends the manifest, then the bytes, then finalizes.
109
+ //
110
+ // It tracks `canSubmit` because it is a statement about THIS client, not
111
+ // about the endpoint — whether a given endpoint accepts uploads is
112
+ // discovered on submit, and a failure there throws with a message saying
113
+ // the submission landed and the attachment did not. What this rules out is
114
+ // the case that has no honest report: offering a file input when nothing
115
+ // could ever send the bytes.
116
+ //
117
+ // Kept as its own field rather than folded into `canSubmit` because a
118
+ // component decides *whether to render a file input* separately from
119
+ // whether to render the form, and that decision belongs at render time.
120
+ canUploadFiles: !!target,
107
121
  submit,
108
122
  reset,
109
123
  }
@@ -28,6 +28,7 @@
28
28
  */
29
29
 
30
30
  import { emptyResult } from './providers/result.js'
31
+ import { resolveService } from '../utils/services.js'
31
32
 
32
33
  // Re-exported so existing importers of these keep working; they live with the
33
34
  // provider that owns them now.
@@ -92,7 +93,24 @@ export function createSearchClient(website, options = {}) {
92
93
  } = options
93
94
 
94
95
  const searchConfig = website.getSearchConfig?.() || {}
95
- const declared = providerOverride || searchConfig.provider || 'index'
96
+
97
+ // Where search is answered: the site's own `search.endpoint`, else the host's
98
+ // `services.search`, else nothing. Same rule every site service resolves by.
99
+ const service = resolveService(website, 'search')
100
+
101
+ // The AUTHORED provider, read from config rather than from getSearchConfig(),
102
+ // which fills in 'index' before kit sees it (core/website.js). That default
103
+ // would make "the author chose index" and "the author said nothing"
104
+ // indistinguishable — and the difference is precisely what decides whether a
105
+ // host's offer applies. An author who picked a provider means it; one who
106
+ // picked nothing gets whatever the host serves, and the local index if the
107
+ // host serves none.
108
+ const authoredProvider = website?.config?.search?.provider
109
+
110
+ const declared =
111
+ providerOverride ||
112
+ authoredProvider ||
113
+ (service.source === 'host' ? 'endpoint' : 'index')
96
114
 
97
115
  // One in-flight resolution shared by every caller.
98
116
  let providerPromise = null
@@ -115,7 +133,14 @@ export function createSearchClient(website, options = {}) {
115
133
  activeName = 'index'
116
134
  }
117
135
 
118
- return factory(website, { ...providerOptions, endpoint: searchConfig.endpoint })
136
+ // `service.url` is already joined to the base; the endpoint provider
137
+ // joins again, which is a no-op because the join is idempotent. Falls
138
+ // back to the raw authored value, and to the provider's own default when
139
+ // an author selects `endpoint` without naming one.
140
+ return factory(website, {
141
+ ...providerOptions,
142
+ endpoint: service.url || searchConfig.endpoint,
143
+ })
119
144
  })()
120
145
 
121
146
  return providerPromise
@@ -35,8 +35,17 @@
35
35
  * pageId, pageLabel
36
36
  * @param {string} [args.verificationToken] — bot-protection token, when the
37
37
  * endpoint verifies one
38
+ * @param {Array<File|{file:File,field?:string}>} [args.files]
39
+ * — attachments to upload. The
40
+ * manifest is derived from
41
+ * these; phase two sends the
42
+ * bytes. The `{file, field}`
43
+ * form records which field an
44
+ * attachment came from.
38
45
  * @param {Array<{name:string,size:number,mime?:string}>} [args.fileSlots]
39
- * — declared file uploads
46
+ * — a manifest with no bytes.
47
+ * Accepted, but ONLY sends the
48
+ * declaration; prefer `files`
40
49
  * @param {typeof fetch} [args.fetchFn=fetch] — fetch override (testing / SSR)
41
50
  *
42
51
  * @returns {Promise<{ submissionId: string, uploadUrls?: Array }>}
@@ -48,6 +57,7 @@ export async function submitForm({
48
57
  summary,
49
58
  context = {},
50
59
  verificationToken,
60
+ files,
51
61
  fileSlots,
52
62
  fetchFn = typeof fetch === 'function' ? fetch : null,
53
63
  } = {}) {
@@ -64,12 +74,33 @@ export async function submitForm({
64
74
  throw new Error('submitForm: fetch is unavailable in this environment')
65
75
  }
66
76
 
77
+ const entries = normalizeFiles(files)
78
+
79
+ // A manifest without the files it describes cannot be delivered — the bytes
80
+ // are what phase two sends. Passing `fileSlots` alone declares attachments
81
+ // nobody receives, which is a success that is not one, so say so.
82
+ if (entries.length === 0 && Array.isArray(fileSlots) && fileSlots.length > 0) {
83
+ console.warn(
84
+ `[uniweb] submitForm: ${fileSlots.length} file(s) declared via \`fileSlots\` with no ` +
85
+ '`files` — the manifest is sent and the bytes are NOT. Pass `files` so they upload.',
86
+ )
87
+ }
88
+
89
+ const slots = entries.length
90
+ ? entries.map(({ file, field }) => ({
91
+ name: file.name,
92
+ size: file.size,
93
+ mime: file.type || 'application/octet-stream',
94
+ ...(field ? { field } : {}),
95
+ }))
96
+ : fileSlots
97
+
67
98
  // ── API name → wire name. See the header before "correcting" these. ──
68
99
  const body = {
69
100
  formData,
70
101
  metadata: { ...context, preview: summary || deriveSummary(formData) },
71
102
  ...(verificationToken ? { turnstileToken: verificationToken } : {}),
72
- ...(Array.isArray(fileSlots) && fileSlots.length ? { fileSlots } : {}),
103
+ ...(Array.isArray(slots) && slots.length ? { fileSlots: slots } : {}),
73
104
  }
74
105
 
75
106
  const res = await fetchFn(target, {
@@ -84,7 +115,95 @@ export async function submitForm({
84
115
  throw new Error(serverMessage || `Submission failed (HTTP ${res.status})`)
85
116
  }
86
117
 
87
- return res.json()
118
+ const result = await res.json()
119
+ if (entries.length === 0) return result
120
+
121
+ await uploadFiles(entries, result, target, fetchFn)
122
+ return { ...result, filesUploaded: entries.length }
123
+ }
124
+
125
+ /**
126
+ * Accept either bare `File`s or `{ file, field }` pairs, and drop anything that
127
+ * is not a file. The pair form exists so a submission can say WHICH field an
128
+ * attachment came from — a form may have more than one file input.
129
+ */
130
+ function normalizeFiles(files) {
131
+ if (!Array.isArray(files)) return []
132
+ return files
133
+ .map((f) => (f && typeof f === 'object' && 'file' in f ? f : { file: f }))
134
+ .filter(({ file }) => file && typeof file === 'object' && 'name' in file)
135
+ }
136
+
137
+ /**
138
+ * Phase two — send the bytes.
139
+ *
140
+ * Phase one posts a *manifest* and gets back a submission id; the bytes go
141
+ * separately so they never ride inside the JSON. Two shapes are honoured:
142
+ *
143
+ * - **`uploadUrls` in the response** — one per slot, in slot order. Used when
144
+ * present, because an endpoint returning them is telling you where to write.
145
+ * - **Otherwise `{target}/upload`** — raw body, `X-Submission-Id` and `X-Slot`
146
+ * headers, then `{target}/finalize`. This is the shape the endpoint
147
+ * documents, and it is the default rather than a fallback.
148
+ *
149
+ * `X-Slot` is the index into the manifest sent in phase one, which is why the
150
+ * entries and the slots are built from one list in one order.
151
+ *
152
+ * **Failures throw, and the message says what did land.** The submission row
153
+ * already exists at this point, so a silent failure here is the same
154
+ * discarded-attachment bug in a new place — a caller must be able to tell the
155
+ * visitor that their message arrived and their file did not.
156
+ */
157
+ async function uploadFiles(entries, result, target, fetchFn) {
158
+ const submissionId = result?.submissionId
159
+ if (!submissionId) {
160
+ throw new Error(
161
+ 'submitForm: the submission was recorded but returned no submissionId, so its ' +
162
+ `${entries.length} attachment(s) could not be uploaded.`,
163
+ )
164
+ }
165
+
166
+ const base = String(target).replace(/\/+$/, '')
167
+ const urls = Array.isArray(result?.uploadUrls) ? result.uploadUrls : []
168
+
169
+ for (const [slot, { file }] of entries.entries()) {
170
+ const url = urls[slot] || `${base}/upload`
171
+ let res
172
+ try {
173
+ res = await fetchFn(url, {
174
+ method: 'POST',
175
+ headers: {
176
+ 'Content-Type': file.type || 'application/octet-stream',
177
+ 'X-Submission-Id': submissionId,
178
+ 'X-Slot': String(slot),
179
+ },
180
+ body: file,
181
+ })
182
+ } catch (err) {
183
+ throw new Error(
184
+ `submitForm: submission ${submissionId} was recorded, but uploading ` +
185
+ `"${file.name}" failed — ${err.message}`,
186
+ )
187
+ }
188
+ if (!res.ok) {
189
+ throw new Error(
190
+ `submitForm: submission ${submissionId} was recorded, but uploading ` +
191
+ `"${file.name}" failed (HTTP ${res.status}).`,
192
+ )
193
+ }
194
+ }
195
+
196
+ const done = await fetchFn(`${base}/finalize`, {
197
+ method: 'POST',
198
+ headers: { 'Content-Type': 'application/json' },
199
+ body: JSON.stringify({ submissionId }),
200
+ })
201
+ if (!done.ok) {
202
+ throw new Error(
203
+ `submitForm: submission ${submissionId} and its ${entries.length} attachment(s) ` +
204
+ `were uploaded, but finalizing failed (HTTP ${done.status}).`,
205
+ )
206
+ }
88
207
  }
89
208
 
90
209
  /**