@uniweb/kit 0.10.21 → 0.10.23
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/README.md +7 -1
- package/package.json +2 -2
- package/src/hooks/useFormValues.js +13 -1
- package/src/search/client.js +52 -7
- package/src/search/hooks.js +15 -2
- package/src/search/providers/endpoint-provider.js +19 -2
- package/src/search/providers/index-provider.js +9 -1
- package/src/utils/submitForm.js +53 -4
package/README.md
CHANGED
|
@@ -476,9 +476,15 @@ const client = createSearchClient(website, {
|
|
|
476
476
|
defaultLimit: 10
|
|
477
477
|
})
|
|
478
478
|
|
|
479
|
-
// Query
|
|
479
|
+
// Query — returns SearchResult[]
|
|
480
480
|
const results = await client.query('authentication', { limit: 5 })
|
|
481
481
|
|
|
482
|
+
// Same query, plus how many matched before `limit` — the 47 in "showing 10 of 47".
|
|
483
|
+
// `total` is null when the active provider cannot say (a deployment fact, not an
|
|
484
|
+
// error): the local index always knows it; an endpoint knows it only if it
|
|
485
|
+
// reports one. Render the count conditionally, the results unconditionally.
|
|
486
|
+
const { results: page, total } = await client.queryWithTotal('authentication', { limit: 5 })
|
|
487
|
+
|
|
482
488
|
// Preload index
|
|
483
489
|
await client.preload()
|
|
484
490
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/kit",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.23",
|
|
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/semantic-parser": "1.2.1",
|
|
47
46
|
"@uniweb/scene": "0.1.3",
|
|
47
|
+
"@uniweb/semantic-parser": "1.2.1",
|
|
48
48
|
"@uniweb/core": "0.8.2"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
@@ -170,6 +170,18 @@ function setIn(node, [key, ...rest], value) {
|
|
|
170
170
|
return { ...base, [key]: setIn(base[key], rest, value) }
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Which control kinds hold uploads.
|
|
175
|
+
*
|
|
176
|
+
* BOTH `file` and `image` — they are two words in the authoring vocabulary for
|
|
177
|
+
* the same control, and the visual editor draws a file picker for either. Only
|
|
178
|
+
* `file` was checked here, so an `image` control's `File` objects went into
|
|
179
|
+
* `formData` and `JSON.stringify` turned each into `{}`: an attachment the
|
|
180
|
+
* visitor chose, reported as sent, arriving empty. Exactly the failure the
|
|
181
|
+
* split below exists to prevent, reachable through the other spelling.
|
|
182
|
+
*/
|
|
183
|
+
const isUpload = (control) => control.type === 'file' || control.type === 'image'
|
|
184
|
+
|
|
173
185
|
/**
|
|
174
186
|
* Split the held values into what is submitted and what is uploaded.
|
|
175
187
|
*
|
|
@@ -186,7 +198,7 @@ function split(controls, values) {
|
|
|
186
198
|
const value = valueAt(values, control.path)
|
|
187
199
|
if (value === undefined) continue
|
|
188
200
|
|
|
189
|
-
if (control
|
|
201
|
+
if (isUpload(control)) {
|
|
190
202
|
for (const file of [].concat(value).filter(isFile)) {
|
|
191
203
|
files.push({ file, field: control.path })
|
|
192
204
|
}
|
package/src/search/client.js
CHANGED
|
@@ -84,6 +84,25 @@ async function loadProviderFactory(name, transports) {
|
|
|
84
84
|
* const search = createSearchClient(website)
|
|
85
85
|
* const results = await search.query('authentication')
|
|
86
86
|
*/
|
|
87
|
+
/**
|
|
88
|
+
* Accept either provider return shape.
|
|
89
|
+
*
|
|
90
|
+
* A provider may return `SearchResult[]` or `{ results, total }`. Both are
|
|
91
|
+
* valid and the array form is not deprecated: `transport.query` is a public
|
|
92
|
+
* seam a third party implements, and its documented contract has always been
|
|
93
|
+
* "returns results". Widening that to an envelope would break every custom
|
|
94
|
+
* transport in the field to add a number most of them cannot supply.
|
|
95
|
+
*
|
|
96
|
+
* An array therefore means "no count offered" → `total: null`, which is exactly
|
|
97
|
+
* what null is for here. Our own two providers return the envelope.
|
|
98
|
+
*/
|
|
99
|
+
function normalizeQueryResult(returned) {
|
|
100
|
+
if (Array.isArray(returned)) return { results: returned, total: null }
|
|
101
|
+
const results = Array.isArray(returned?.results) ? returned.results : []
|
|
102
|
+
const total = Number.isInteger(returned?.total) ? returned.total : null
|
|
103
|
+
return { results, total }
|
|
104
|
+
}
|
|
105
|
+
|
|
87
106
|
export function createSearchClient(website, options = {}) {
|
|
88
107
|
const {
|
|
89
108
|
defaultLimit = 10,
|
|
@@ -207,7 +226,7 @@ export function createSearchClient(website, options = {}) {
|
|
|
207
226
|
},
|
|
208
227
|
|
|
209
228
|
/**
|
|
210
|
-
* Perform a search query
|
|
229
|
+
* Perform a search query.
|
|
211
230
|
*
|
|
212
231
|
* @param {string} query - Search query
|
|
213
232
|
* @param {Object} queryOptions - Query options
|
|
@@ -218,21 +237,47 @@ export function createSearchClient(website, options = {}) {
|
|
|
218
237
|
* @returns {Promise<Array>} Search results
|
|
219
238
|
*/
|
|
220
239
|
async query(query, queryOptions = {}) {
|
|
240
|
+
return (await this.queryWithTotal(query, queryOptions)).results
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The same query, plus how many matched.
|
|
245
|
+
*
|
|
246
|
+
* Exists because `query()` returns an array and a count cannot ride on one
|
|
247
|
+
* without being silently dropped by `.slice()`/`.filter()`/spread — and
|
|
248
|
+
* `query()` is a published surface returning `SearchResult[]`, so widening
|
|
249
|
+
* its return would break every foundation iterating it.
|
|
250
|
+
*
|
|
251
|
+
* **`total` is the match count BEFORE `limit`** — the 47 in "showing 10 of
|
|
252
|
+
* 47". It is `null` when unknowable, and null is a real answer rather than
|
|
253
|
+
* a failure: a provider-optional field in the same sense as `matches` or
|
|
254
|
+
* `item` on a result. Whether a count arrives is a DEPLOYMENT fact — the
|
|
255
|
+
* local index always knows it, an endpoint knows it only if it says so —
|
|
256
|
+
* so a UI must render the count conditionally and the bare result list
|
|
257
|
+
* unconditionally.
|
|
258
|
+
*
|
|
259
|
+
* ⚠️ `total >= results.length` is NOT guaranteed to be checkable: a
|
|
260
|
+
* provider reporting a count for a set we then filtered locally reports
|
|
261
|
+
* null instead, precisely so the two numbers are never inconsistent.
|
|
262
|
+
*
|
|
263
|
+
* @returns {Promise<{results: Array, total: number|null}>}
|
|
264
|
+
*/
|
|
265
|
+
async queryWithTotal(query, queryOptions = {}) {
|
|
221
266
|
const { limit = defaultLimit, type, route, signal } = queryOptions
|
|
222
267
|
|
|
223
268
|
const trimmed = query?.trim()
|
|
224
|
-
if (!trimmed) return []
|
|
269
|
+
if (!trimmed) return { results: [], total: 0 }
|
|
225
270
|
|
|
226
271
|
if (!website.isSearchEnabled()) {
|
|
227
272
|
console.warn('Search is not enabled for this site')
|
|
228
|
-
return []
|
|
273
|
+
return { results: [], total: 0 }
|
|
229
274
|
}
|
|
230
275
|
|
|
231
276
|
const opts = { limit, type, route, signal }
|
|
232
277
|
|
|
233
278
|
try {
|
|
234
279
|
const provider = await getProvider()
|
|
235
|
-
return await provider.query(trimmed, opts)
|
|
280
|
+
return normalizeQueryResult(await provider.query(trimmed, opts))
|
|
236
281
|
} catch (err) {
|
|
237
282
|
// An aborted query is a caller decision, not a provider failure.
|
|
238
283
|
if (err?.name === 'AbortError') throw err
|
|
@@ -240,13 +285,13 @@ export function createSearchClient(website, options = {}) {
|
|
|
240
285
|
const fallback = await fallbackToIndex(err)
|
|
241
286
|
if (!fallback) {
|
|
242
287
|
console.warn(`[uniweb] Search failed: ${err?.message}`)
|
|
243
|
-
return []
|
|
288
|
+
return { results: [], total: null }
|
|
244
289
|
}
|
|
245
290
|
try {
|
|
246
|
-
return await fallback.query(trimmed, opts)
|
|
291
|
+
return normalizeQueryResult(await fallback.query(trimmed, opts))
|
|
247
292
|
} catch (fallbackErr) {
|
|
248
293
|
console.warn(`[uniweb] Search fallback failed: ${fallbackErr?.message}`)
|
|
249
|
-
return []
|
|
294
|
+
return { results: [], total: null }
|
|
250
295
|
}
|
|
251
296
|
}
|
|
252
297
|
},
|
package/src/search/hooks.js
CHANGED
|
@@ -17,12 +17,13 @@ import { useShortcut } from '../hooks/useShortcut.js'
|
|
|
17
17
|
*
|
|
18
18
|
* @example
|
|
19
19
|
* function SearchComponent() {
|
|
20
|
-
* const { query, results, isLoading, error } = useSearch(website)
|
|
20
|
+
* const { query, results, total, isLoading, error } = useSearch(website)
|
|
21
21
|
*
|
|
22
22
|
* return (
|
|
23
23
|
* <div>
|
|
24
24
|
* <input onChange={e => query(e.target.value)} />
|
|
25
25
|
* {isLoading && <span>Searching...</span>}
|
|
26
|
+
* {total !== null && <span>Showing {results.length} of {total}</span>}
|
|
26
27
|
* {results.map(r => <SearchResult key={r.id} result={r} />)}
|
|
27
28
|
* </div>
|
|
28
29
|
* )
|
|
@@ -32,6 +33,7 @@ export function useSearch(website, options = {}) {
|
|
|
32
33
|
const { debounceMs = 150, ...clientOptions } = options
|
|
33
34
|
|
|
34
35
|
const [results, setResults] = useState([])
|
|
36
|
+
const [total, setTotal] = useState(null)
|
|
35
37
|
const [isLoading, setIsLoading] = useState(false)
|
|
36
38
|
const [error, setError] = useState(null)
|
|
37
39
|
const [lastQuery, setLastQuery] = useState('')
|
|
@@ -71,6 +73,7 @@ export function useSearch(website, options = {}) {
|
|
|
71
73
|
// Empty query - clear results immediately
|
|
72
74
|
if (!trimmed) {
|
|
73
75
|
setResults([])
|
|
76
|
+
setTotal(0)
|
|
74
77
|
setIsLoading(false)
|
|
75
78
|
setError(null)
|
|
76
79
|
return []
|
|
@@ -99,7 +102,8 @@ export function useSearch(website, options = {}) {
|
|
|
99
102
|
setError(null)
|
|
100
103
|
|
|
101
104
|
try {
|
|
102
|
-
const searchResults
|
|
105
|
+
const { results: searchResults, total: matchTotal } =
|
|
106
|
+
await client.queryWithTotal(trimmed, queryOptions)
|
|
103
107
|
|
|
104
108
|
// Skip if a newer search was started
|
|
105
109
|
if (pendingRef.current !== searchId) {
|
|
@@ -108,7 +112,10 @@ export function useSearch(website, options = {}) {
|
|
|
108
112
|
}
|
|
109
113
|
|
|
110
114
|
setResults(searchResults)
|
|
115
|
+
setTotal(matchTotal)
|
|
111
116
|
setIsLoading(false)
|
|
117
|
+
// Resolves with the ARRAY, unchanged — this hook's promise is a
|
|
118
|
+
// published surface and the count is available as state instead.
|
|
112
119
|
resolve(searchResults)
|
|
113
120
|
} catch (err) {
|
|
114
121
|
// Skip if a newer search was started
|
|
@@ -119,6 +126,7 @@ export function useSearch(website, options = {}) {
|
|
|
119
126
|
|
|
120
127
|
setError(err)
|
|
121
128
|
setResults([])
|
|
129
|
+
setTotal(null)
|
|
122
130
|
setIsLoading(false)
|
|
123
131
|
resolve([])
|
|
124
132
|
}
|
|
@@ -136,6 +144,7 @@ export function useSearch(website, options = {}) {
|
|
|
136
144
|
}
|
|
137
145
|
pendingRef.current = null
|
|
138
146
|
setResults([])
|
|
147
|
+
setTotal(null)
|
|
139
148
|
setLastQuery('')
|
|
140
149
|
setError(null)
|
|
141
150
|
setIsLoading(false)
|
|
@@ -156,6 +165,10 @@ export function useSearch(website, options = {}) {
|
|
|
156
165
|
return {
|
|
157
166
|
// State
|
|
158
167
|
results,
|
|
168
|
+
// How many matched before `limit` — the 47 in "showing 10 of 47".
|
|
169
|
+
// `null` means the active provider cannot say, which is a normal state and
|
|
170
|
+
// not an error: render the count conditionally, the results always.
|
|
171
|
+
total,
|
|
159
172
|
isLoading,
|
|
160
173
|
error,
|
|
161
174
|
lastQuery,
|
|
@@ -149,7 +149,8 @@ export function createEndpointProvider(website, options = {}) {
|
|
|
149
149
|
)
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
const
|
|
152
|
+
const payload = await response.json()
|
|
153
|
+
const results = extractResults(payload).map(normalize)
|
|
153
154
|
|
|
154
155
|
// Filters are applied client-side because they are not part of the wire
|
|
155
156
|
// contract a third-party endpoint is expected to honor. A server that
|
|
@@ -158,7 +159,23 @@ export function createEndpointProvider(website, options = {}) {
|
|
|
158
159
|
if (type) filtered = filtered.filter(r => r.type === type)
|
|
159
160
|
if (route) filtered = filtered.filter(r => r.route?.startsWith(route))
|
|
160
161
|
|
|
161
|
-
|
|
162
|
+
// How many matched, when the endpoint says. Null when it does not, and
|
|
163
|
+
// null is a real answer — see `total` in client.js.
|
|
164
|
+
//
|
|
165
|
+
// ⚠️ Discarded when a local filter removed anything, because then the
|
|
166
|
+
// server counted a DIFFERENT set: it does not know about `type`/`route`,
|
|
167
|
+
// so its number describes matches we just narrowed away. Reporting it
|
|
168
|
+
// would render "showing 3 of 47" beside a filter that produced the 3 —
|
|
169
|
+
// a number that is not wrong about anything the reader can see, which is
|
|
170
|
+
// the worst kind. We cannot recompute it either: what arrived was already
|
|
171
|
+
// capped at `limit`, so the filtered count is a floor, not a total.
|
|
172
|
+
const narrowed = filtered.length !== results.length
|
|
173
|
+
const stated = Number.isInteger(payload?.total) ? payload.total : null
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
results: filtered.slice(0, limit),
|
|
177
|
+
total: narrowed ? null : stated
|
|
178
|
+
}
|
|
162
179
|
},
|
|
163
180
|
|
|
164
181
|
// Nothing to warm: there is no index to download. Defined so every provider
|
|
@@ -342,7 +342,13 @@ export function createIndexProvider(website, options = {}) {
|
|
|
342
342
|
.sort((a, b) => a.tier - b.tier || a.i - b.i)
|
|
343
343
|
.map(({ r }) => r)
|
|
344
344
|
|
|
345
|
-
|
|
345
|
+
// Exact here, unlike the endpoint provider: the whole corpus is local, so
|
|
346
|
+
// this counts every match after filtering and before the cut — the number
|
|
347
|
+
// a "showing 10 of 47" needs, and 47 is knowable only on this side of the
|
|
348
|
+
// slice.
|
|
349
|
+
const total = results.length
|
|
350
|
+
|
|
351
|
+
const page = results.slice(0, limit).map(({ item, matches }) => {
|
|
346
352
|
const snippet = buildSnippet(item.content, matches, { key: 'content' })
|
|
347
353
|
|
|
348
354
|
return {
|
|
@@ -363,6 +369,8 @@ export function createIndexProvider(website, options = {}) {
|
|
|
363
369
|
matches
|
|
364
370
|
}
|
|
365
371
|
})
|
|
372
|
+
|
|
373
|
+
return { results: page, total }
|
|
366
374
|
},
|
|
367
375
|
|
|
368
376
|
async preload() {
|
package/src/utils/submitForm.js
CHANGED
|
@@ -32,7 +32,10 @@
|
|
|
32
32
|
* when omitted
|
|
33
33
|
* @param {object} [args.context] — where the submission came from:
|
|
34
34
|
* formId, sectionType, sectionId,
|
|
35
|
-
* pageId, pageLabel
|
|
35
|
+
* pageId, pageLabel. `formId` is
|
|
36
|
+
* sent at the top level of the
|
|
37
|
+
* body; the rest ride in
|
|
38
|
+
* `metadata` (see below)
|
|
36
39
|
* @param {string} [args.verificationToken] — bot-protection token, when the
|
|
37
40
|
* endpoint verifies one
|
|
38
41
|
* @param {Array<File|{file:File,field?:string}>} [args.files]
|
|
@@ -95,10 +98,20 @@ export async function submitForm({
|
|
|
95
98
|
}))
|
|
96
99
|
: fileSlots
|
|
97
100
|
|
|
101
|
+
// `formId` rides at the TOP LEVEL, not inside `metadata` with the rest of the
|
|
102
|
+
// context. It is the only part of a submission's origin an endpoint stores as
|
|
103
|
+
// its own field rather than in an opaque blob, because it is what submissions
|
|
104
|
+
// are grouped BY — every other origin key is decoration read back for display.
|
|
105
|
+
// Nesting it means the endpoint's own column is never filled, and nothing on
|
|
106
|
+
// either side reports that: the value is present, one level down, and the
|
|
107
|
+
// column is simply null forever.
|
|
108
|
+
const { formId, ...origin } = context || {}
|
|
109
|
+
|
|
98
110
|
// ── API name → wire name. See the header before "correcting" these. ──
|
|
99
111
|
const body = {
|
|
100
112
|
formData,
|
|
101
|
-
|
|
113
|
+
...(formId ? { formId } : {}),
|
|
114
|
+
metadata: { ...origin, preview: summary || deriveSummary(formData) },
|
|
102
115
|
...(verificationToken ? { turnstileToken: verificationToken } : {}),
|
|
103
116
|
...(Array.isArray(slots) && slots.length ? { fileSlots: slots } : {}),
|
|
104
117
|
}
|
|
@@ -126,6 +139,35 @@ export async function submitForm({
|
|
|
126
139
|
return { ...result, filesUploaded: entries.length, ...report }
|
|
127
140
|
}
|
|
128
141
|
|
|
142
|
+
/**
|
|
143
|
+
* One entry of an endpoint's `uploadUrls`, as a URL.
|
|
144
|
+
*
|
|
145
|
+
* Two shapes are in the wild and both mean the same thing: a bare URL string,
|
|
146
|
+
* or a **record** describing the slot — `{slot, name, uploadUrl}` is what the
|
|
147
|
+
* endpoint this client is built against actually returns. Reading only the
|
|
148
|
+
* string form does not degrade, it *breaks*: a record is truthy, so it was used
|
|
149
|
+
* as the URL directly and `fetch` stringified it to `[object Object]`, turning
|
|
150
|
+
* every upload into a request for a path that cannot exist. The submission row
|
|
151
|
+
* was already written by then, so the visitor's message arrived and their files
|
|
152
|
+
* did not.
|
|
153
|
+
*
|
|
154
|
+
* Anything that does not yield a non-empty string returns `''`, so the caller
|
|
155
|
+
* falls back to the documented `{target}/upload` — which is where the bytes were
|
|
156
|
+
* going anyway in every deployment seen so far.
|
|
157
|
+
*
|
|
158
|
+
* @param {*} entry
|
|
159
|
+
* @returns {string}
|
|
160
|
+
*/
|
|
161
|
+
function readUploadUrl(entry) {
|
|
162
|
+
if (typeof entry === 'string') return entry.trim()
|
|
163
|
+
if (entry && typeof entry === 'object') {
|
|
164
|
+
for (const key of ['uploadUrl', 'url', 'href']) {
|
|
165
|
+
if (typeof entry[key] === 'string' && entry[key].trim()) return entry[key].trim()
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return ''
|
|
169
|
+
}
|
|
170
|
+
|
|
129
171
|
/**
|
|
130
172
|
* Accept either bare `File`s or `{ file, field }` pairs, and drop anything that
|
|
131
173
|
* is not a file. The pair form exists so a submission can say WHICH field an
|
|
@@ -176,7 +218,7 @@ async function uploadFiles(entries, result, target, fetchFn) {
|
|
|
176
218
|
const urls = Array.isArray(result?.uploadUrls) ? result.uploadUrls : []
|
|
177
219
|
|
|
178
220
|
for (const [slot, { file }] of entries.entries()) {
|
|
179
|
-
const url = urls[slot] || `${base}/upload`
|
|
221
|
+
const url = readUploadUrl(urls[slot]) || `${base}/upload`
|
|
180
222
|
let res
|
|
181
223
|
try {
|
|
182
224
|
res = await fetchFn(url, {
|
|
@@ -208,11 +250,18 @@ async function uploadFiles(entries, result, target, fetchFn) {
|
|
|
208
250
|
// count is what a quota or an invoice would otherwise derive from. Sending it
|
|
209
251
|
// costs a few bytes and satisfies the stricter reading of the contract, in
|
|
210
252
|
// which `files` is required and its absence is a malformed call.
|
|
211
|
-
|
|
253
|
+
// Carries `field` for the same reason the create manifest does — which form
|
|
254
|
+
// control an attachment answers is the difference between a readable
|
|
255
|
+
// submission and two anonymous blobs. The two manifests describe the same
|
|
256
|
+
// files and now describe them with the same keys; a receiver that built its
|
|
257
|
+
// stored record from this one rather than from the create manifest would
|
|
258
|
+
// otherwise lose the association, silently and only for uploads.
|
|
259
|
+
const manifest = entries.map(({ file, field }, slot) => ({
|
|
212
260
|
slot,
|
|
213
261
|
name: file.name,
|
|
214
262
|
size: file.size,
|
|
215
263
|
mime: file.type || 'application/octet-stream',
|
|
264
|
+
...(field ? { field } : {}),
|
|
216
265
|
}))
|
|
217
266
|
|
|
218
267
|
const done = await fetchFn(`${base}/finalize`, {
|