@uniweb/kit 0.9.37 → 0.9.39
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 +2 -2
- package/src/index.js +1 -1
- package/src/search/client.js +151 -220
- package/src/search/index.js +2 -1
- package/src/search/providers/endpoint-provider.js +149 -0
- package/src/search/providers/index-provider.js +253 -0
- package/src/search/providers/result.js +64 -0
- package/src/styled/Prose/index.jsx +26 -0
- package/src/styled/Section/Render.jsx +37 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/kit",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.39",
|
|
4
4
|
"description": "Standard component library for Uniweb foundations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"fuse.js": "^7.0.0",
|
|
41
41
|
"shiki": "^3.0.0",
|
|
42
42
|
"tailwind-merge": "^3.6.0",
|
|
43
|
-
"@uniweb/core": "0.7.
|
|
43
|
+
"@uniweb/core": "0.7.31",
|
|
44
44
|
"@uniweb/scene": "0.1.2"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
package/src/index.js
CHANGED
|
@@ -132,6 +132,6 @@ export { Scene } from './styled/Scene/index.jsx'
|
|
|
132
132
|
// Search
|
|
133
133
|
// ============================================================================
|
|
134
134
|
|
|
135
|
-
export { createSearchClient, loadSearchIndex, clearSearchCache } from './search/client.js'
|
|
135
|
+
export { createSearchClient, loadSearchIndex, clearSearchCache, emptyResult } from './search/client.js'
|
|
136
136
|
export { buildSnippet, highlightMatches, escapeHtml } from './search/snippets.js'
|
|
137
137
|
export { useSearch, useSearchIndex, useSearchShortcut, useSearchWithIntent } from './search/hooks.js'
|
package/src/search/client.js
CHANGED
|
@@ -1,157 +1,70 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Search Client
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
*
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
includeMatches: true,
|
|
29
|
-
ignoreLocation: true,
|
|
30
|
-
minMatchCharLength: 2
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Get localStorage safely (handles SSR and access errors)
|
|
35
|
-
* @returns {Storage|null}
|
|
36
|
-
*/
|
|
37
|
-
function getStorage() {
|
|
38
|
-
if (typeof window === 'undefined') return null
|
|
39
|
-
try {
|
|
40
|
-
return window.localStorage
|
|
41
|
-
} catch {
|
|
42
|
-
return null
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Load index from localStorage
|
|
48
|
-
* @param {string} cacheKey - Cache key
|
|
49
|
-
* @returns {Object|null}
|
|
4
|
+
* Resolves which provider serves a site's search, then delegates to it. The
|
|
5
|
+
* site declares the provider; a component reads results the same way either
|
|
6
|
+
* way — the same contract `content.data` gives data fetching, applied to search.
|
|
7
|
+
*
|
|
8
|
+
* Providers are loaded dynamically, so a site using a server endpoint never
|
|
9
|
+
* bundles Fuse and a site using the local index never bundles anything else.
|
|
10
|
+
*
|
|
11
|
+
* Resolution order:
|
|
12
|
+
* 1. `site.yml search.provider` — the author's explicit choice
|
|
13
|
+
* 2. `'index'` — the free default, works on any host
|
|
14
|
+
*
|
|
15
|
+
* A future tier sits between them: a host that serves search declaring it in
|
|
16
|
+
* the payload it already sends, the way `config.base` already tells the runtime
|
|
17
|
+
* where the site is being served from. That wire key is the host's to specify,
|
|
18
|
+
* so it is deliberately not invented here.
|
|
19
|
+
*
|
|
20
|
+
* Provider contract (duck-typed, matching how the FetcherDispatcher treats
|
|
21
|
+
* transports):
|
|
22
|
+
*
|
|
23
|
+
* { query(text, opts) → Promise<SearchResult[]>,
|
|
24
|
+
* preload?() → Promise<void>,
|
|
25
|
+
* clearCache?() → void }
|
|
26
|
+
*
|
|
27
|
+
* See ./providers/result.js for the result contract.
|
|
50
28
|
*/
|
|
51
|
-
function loadFromStorage(cacheKey) {
|
|
52
|
-
const storage = getStorage()
|
|
53
|
-
if (!storage) return null
|
|
54
29
|
|
|
55
|
-
|
|
56
|
-
if (!raw) return null
|
|
30
|
+
import { emptyResult } from './providers/result.js'
|
|
57
31
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
return parsed
|
|
62
|
-
}
|
|
63
|
-
return null
|
|
64
|
-
} catch {
|
|
65
|
-
return null
|
|
66
|
-
}
|
|
67
|
-
}
|
|
32
|
+
// Re-exported so existing importers of these keep working; they live with the
|
|
33
|
+
// provider that owns them now.
|
|
34
|
+
export { loadSearchIndex, clearSearchCache } from './providers/index-provider.js'
|
|
68
35
|
|
|
69
36
|
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
} catch {
|
|
81
|
-
// Ignore quota errors
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Load search index for a locale
|
|
87
|
-
* @param {string} indexUrl - URL to fetch the index from
|
|
88
|
-
* @param {Object} options - Options
|
|
89
|
-
* @param {string} [options.cacheKey] - Cache key (defaults to indexUrl)
|
|
90
|
-
* @param {boolean} [options.useStorage=true] - Use localStorage caching
|
|
91
|
-
* @returns {Promise<Object>} Search index
|
|
37
|
+
* Load a provider factory by name.
|
|
38
|
+
*
|
|
39
|
+
* Unknown names resolve to `null` rather than throwing, so a typo or a
|
|
40
|
+
* foundation transport that failed to register degrades to the default instead
|
|
41
|
+
* of taking down the site's search box — the same resilience the fetcher
|
|
42
|
+
* dispatcher applies to a malformed transport.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} name
|
|
45
|
+
* @param {Object} transports - Foundation-supplied search transports
|
|
46
|
+
* @returns {Promise<Function|null>}
|
|
92
47
|
*/
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if (indexCache.has(cacheKey)) {
|
|
98
|
-
return indexCache.get(cacheKey)
|
|
48
|
+
async function loadProviderFactory(name, transports) {
|
|
49
|
+
if (name === 'index') {
|
|
50
|
+
const mod = await import('./providers/index-provider.js')
|
|
51
|
+
return mod.createIndexProvider
|
|
99
52
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const cached = loadFromStorage(cacheKey)
|
|
104
|
-
if (cached) {
|
|
105
|
-
indexCache.set(cacheKey, cached)
|
|
106
|
-
return cached
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Fetch from server
|
|
111
|
-
const response = await fetch(indexUrl, { cache: 'force-cache' })
|
|
112
|
-
if (!response.ok) {
|
|
113
|
-
throw new Error(`Failed to load search index: ${response.status}`)
|
|
53
|
+
if (name === 'endpoint') {
|
|
54
|
+
const mod = await import('./providers/endpoint-provider.js')
|
|
55
|
+
return mod.createEndpointProvider
|
|
114
56
|
}
|
|
115
57
|
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
indexCache.set(cacheKey, payload)
|
|
120
|
-
if (useStorage) {
|
|
121
|
-
saveToStorage(cacheKey, payload)
|
|
58
|
+
const transport = transports?.[name]
|
|
59
|
+
if (transport && typeof transport.query === 'function') {
|
|
60
|
+
return () => transport
|
|
122
61
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Clear all search caches
|
|
129
|
-
* @param {string} [cacheKey] - Specific cache key to clear, or all if omitted
|
|
130
|
-
*/
|
|
131
|
-
export function clearSearchCache(cacheKey) {
|
|
132
|
-
if (cacheKey) {
|
|
133
|
-
indexCache.delete(cacheKey)
|
|
134
|
-
fuseCache.delete(cacheKey)
|
|
135
|
-
const storage = getStorage()
|
|
136
|
-
if (storage) {
|
|
137
|
-
storage.removeItem(`${STORAGE_PREFIX}${cacheKey}`)
|
|
138
|
-
}
|
|
139
|
-
} else {
|
|
140
|
-
indexCache.clear()
|
|
141
|
-
fuseCache.clear()
|
|
142
|
-
const storage = getStorage()
|
|
143
|
-
if (storage) {
|
|
144
|
-
// Clear all search-related storage
|
|
145
|
-
const keysToRemove = []
|
|
146
|
-
for (let i = 0; i < storage.length; i++) {
|
|
147
|
-
const key = storage.key(i)
|
|
148
|
-
if (key?.startsWith(STORAGE_PREFIX)) {
|
|
149
|
-
keysToRemove.push(key)
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
keysToRemove.forEach(key => storage.removeItem(key))
|
|
153
|
-
}
|
|
62
|
+
if (transport) {
|
|
63
|
+
console.warn(
|
|
64
|
+
`[uniweb] Search transport "${name}" has no query(); falling back to the index provider.`
|
|
65
|
+
)
|
|
154
66
|
}
|
|
67
|
+
return null
|
|
155
68
|
}
|
|
156
69
|
|
|
157
70
|
/**
|
|
@@ -159,9 +72,11 @@ export function clearSearchCache(cacheKey) {
|
|
|
159
72
|
*
|
|
160
73
|
* @param {Object} website - Website instance from @uniweb/core
|
|
161
74
|
* @param {Object} options - Configuration options
|
|
162
|
-
* @param {Object} [options.fuseOptions] - Custom Fuse.js options
|
|
163
|
-
* @param {boolean} [options.useStorage=true] - Use localStorage caching
|
|
75
|
+
* @param {Object} [options.fuseOptions] - Custom Fuse.js options (index provider)
|
|
76
|
+
* @param {boolean} [options.useStorage=true] - Use localStorage caching (index provider)
|
|
164
77
|
* @param {number} [options.defaultLimit=10] - Default result limit
|
|
78
|
+
* @param {string} [options.provider] - Override the declared provider
|
|
79
|
+
* @param {Object} [options.transports] - Named search transports from a foundation
|
|
165
80
|
* @returns {Object} Search client with query method
|
|
166
81
|
*
|
|
167
82
|
* @example
|
|
@@ -170,45 +85,66 @@ export function clearSearchCache(cacheKey) {
|
|
|
170
85
|
*/
|
|
171
86
|
export function createSearchClient(website, options = {}) {
|
|
172
87
|
const {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
88
|
+
defaultLimit = 10,
|
|
89
|
+
provider: providerOverride,
|
|
90
|
+
transports,
|
|
91
|
+
...providerOptions
|
|
176
92
|
} = options
|
|
177
93
|
|
|
178
|
-
const
|
|
94
|
+
const searchConfig = website.getSearchConfig?.() || {}
|
|
95
|
+
const declared = providerOverride || searchConfig.provider || 'index'
|
|
179
96
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
*/
|
|
184
|
-
async function getFuse() {
|
|
185
|
-
const indexUrl = website.getSearchIndexUrl()
|
|
186
|
-
const cacheKey = indexUrl
|
|
97
|
+
// One in-flight resolution shared by every caller.
|
|
98
|
+
let providerPromise = null
|
|
99
|
+
let activeName = declared
|
|
187
100
|
|
|
188
|
-
|
|
189
|
-
if (
|
|
190
|
-
return fuseCache.get(cacheKey)
|
|
191
|
-
}
|
|
101
|
+
async function getProvider() {
|
|
102
|
+
if (providerPromise) return providerPromise
|
|
192
103
|
|
|
193
|
-
|
|
194
|
-
|
|
104
|
+
providerPromise = (async () => {
|
|
105
|
+
let factory = await loadProviderFactory(declared, transports)
|
|
195
106
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
107
|
+
if (!factory) {
|
|
108
|
+
if (declared !== 'index') {
|
|
109
|
+
console.warn(
|
|
110
|
+
`[uniweb] Unknown search provider "${declared}"; falling back to the index provider.`
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
const mod = await import('./providers/index-provider.js')
|
|
114
|
+
factory = mod.createIndexProvider
|
|
115
|
+
activeName = 'index'
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return factory(website, { ...providerOptions, endpoint: searchConfig.endpoint })
|
|
119
|
+
})()
|
|
207
120
|
|
|
208
|
-
|
|
209
|
-
|
|
121
|
+
return providerPromise
|
|
122
|
+
}
|
|
210
123
|
|
|
211
|
-
|
|
124
|
+
/**
|
|
125
|
+
* Fall back to the local index when a non-index provider fails.
|
|
126
|
+
*
|
|
127
|
+
* A site that moves off a host serving search still has an index emitted at
|
|
128
|
+
* build, so this is usually a working answer rather than a consolation prize.
|
|
129
|
+
* Standalone-first means the degraded path is part of the design.
|
|
130
|
+
*
|
|
131
|
+
* @param {Error} err
|
|
132
|
+
* @returns {Promise<Object|null>}
|
|
133
|
+
*/
|
|
134
|
+
async function fallbackToIndex(err) {
|
|
135
|
+
if (activeName === 'index') return null
|
|
136
|
+
console.warn(
|
|
137
|
+
`[uniweb] Search provider "${activeName}" failed (${err?.message}); trying the local index.`
|
|
138
|
+
)
|
|
139
|
+
try {
|
|
140
|
+
const mod = await import('./providers/index-provider.js')
|
|
141
|
+
const fallback = mod.createIndexProvider(website, providerOptions)
|
|
142
|
+
activeName = 'index'
|
|
143
|
+
providerPromise = Promise.resolve(fallback)
|
|
144
|
+
return fallback
|
|
145
|
+
} catch {
|
|
146
|
+
return null
|
|
147
|
+
}
|
|
212
148
|
}
|
|
213
149
|
|
|
214
150
|
return {
|
|
@@ -220,6 +156,15 @@ export function createSearchClient(website, options = {}) {
|
|
|
220
156
|
return website.isSearchEnabled()
|
|
221
157
|
},
|
|
222
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Name of the provider serving results. Reflects the active provider, so
|
|
161
|
+
* after a fallback it reports `index` rather than what was declared.
|
|
162
|
+
* @returns {string}
|
|
163
|
+
*/
|
|
164
|
+
getProviderName() {
|
|
165
|
+
return activeName
|
|
166
|
+
},
|
|
167
|
+
|
|
223
168
|
/**
|
|
224
169
|
* Get the search index URL
|
|
225
170
|
* @returns {string}
|
|
@@ -244,10 +189,11 @@ export function createSearchClient(website, options = {}) {
|
|
|
244
189
|
* @param {number} [queryOptions.limit] - Maximum results
|
|
245
190
|
* @param {string} [queryOptions.type] - Filter by type ('page' or 'section')
|
|
246
191
|
* @param {string} [queryOptions.route] - Filter by route prefix
|
|
192
|
+
* @param {AbortSignal} [queryOptions.signal] - Cancel an in-flight query
|
|
247
193
|
* @returns {Promise<Array>} Search results
|
|
248
194
|
*/
|
|
249
195
|
async query(query, queryOptions = {}) {
|
|
250
|
-
const { limit = defaultLimit, type, route } = queryOptions
|
|
196
|
+
const { limit = defaultLimit, type, route, signal } = queryOptions
|
|
251
197
|
|
|
252
198
|
const trimmed = query?.trim()
|
|
253
199
|
if (!trimmed) return []
|
|
@@ -257,69 +203,54 @@ export function createSearchClient(website, options = {}) {
|
|
|
257
203
|
return []
|
|
258
204
|
}
|
|
259
205
|
|
|
260
|
-
const
|
|
261
|
-
let results = fuse.search(trimmed)
|
|
206
|
+
const opts = { limit, type, route, signal }
|
|
262
207
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
}
|
|
208
|
+
try {
|
|
209
|
+
const provider = await getProvider()
|
|
210
|
+
return await provider.query(trimmed, opts)
|
|
211
|
+
} catch (err) {
|
|
212
|
+
// An aborted query is a caller decision, not a provider failure.
|
|
213
|
+
if (err?.name === 'AbortError') throw err
|
|
267
214
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
const snippet = buildSnippet(item.content, matches, { key: 'content' })
|
|
279
|
-
|
|
280
|
-
return {
|
|
281
|
-
// Identity
|
|
282
|
-
id: item.id,
|
|
283
|
-
type: item.type,
|
|
284
|
-
|
|
285
|
-
// Navigation
|
|
286
|
-
route: item.route,
|
|
287
|
-
sectionId: item.sectionId,
|
|
288
|
-
anchor: item.anchor,
|
|
289
|
-
href: item.anchor ? `${item.route}#${item.anchor}` : item.route,
|
|
290
|
-
|
|
291
|
-
// Display
|
|
292
|
-
title: item.title,
|
|
293
|
-
pageTitle: item.pageTitle,
|
|
294
|
-
description: item.description,
|
|
295
|
-
excerpt: item.excerpt,
|
|
296
|
-
component: item.component,
|
|
297
|
-
|
|
298
|
-
// Search result specific
|
|
299
|
-
snippetText: snippet.text,
|
|
300
|
-
snippetHtml: snippet.html,
|
|
301
|
-
matches
|
|
215
|
+
const fallback = await fallbackToIndex(err)
|
|
216
|
+
if (!fallback) {
|
|
217
|
+
console.warn(`[uniweb] Search failed: ${err?.message}`)
|
|
218
|
+
return []
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
return await fallback.query(trimmed, opts)
|
|
222
|
+
} catch (fallbackErr) {
|
|
223
|
+
console.warn(`[uniweb] Search fallback failed: ${fallbackErr?.message}`)
|
|
224
|
+
return []
|
|
302
225
|
}
|
|
303
|
-
}
|
|
226
|
+
}
|
|
304
227
|
},
|
|
305
228
|
|
|
306
229
|
/**
|
|
307
|
-
* Preload the
|
|
230
|
+
* Preload whatever the provider needs to answer quickly.
|
|
231
|
+
* A no-op for providers with nothing to warm.
|
|
308
232
|
* @returns {Promise<void>}
|
|
309
233
|
*/
|
|
310
234
|
async preload() {
|
|
311
235
|
if (!website.isSearchEnabled()) return
|
|
312
|
-
|
|
236
|
+
try {
|
|
237
|
+
const provider = await getProvider()
|
|
238
|
+
await provider.preload?.()
|
|
239
|
+
} catch (err) {
|
|
240
|
+
// Preloading is an optimization; failing it must not surface to a user.
|
|
241
|
+
console.warn(`[uniweb] Search preload failed: ${err?.message}`)
|
|
242
|
+
}
|
|
313
243
|
},
|
|
314
244
|
|
|
315
245
|
/**
|
|
316
|
-
* Clear the
|
|
246
|
+
* Clear the provider's cache, if it keeps one.
|
|
317
247
|
*/
|
|
318
248
|
clearCache() {
|
|
319
|
-
|
|
320
|
-
|
|
249
|
+
if (!providerPromise) return
|
|
250
|
+
providerPromise.then(p => p.clearCache?.()).catch(() => {})
|
|
321
251
|
}
|
|
322
252
|
}
|
|
323
253
|
}
|
|
324
254
|
|
|
255
|
+
export { emptyResult }
|
|
325
256
|
export default createSearchClient
|
package/src/search/index.js
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* })
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
export { createSearchClient, loadSearchIndex, clearSearchCache } from './client.js'
|
|
24
|
+
export { createSearchClient, loadSearchIndex, clearSearchCache, emptyResult } from './client.js'
|
|
25
|
+
export { resolveEndpointUrl } from './providers/endpoint-provider.js'
|
|
25
26
|
export { buildSnippet, highlightMatches, escapeHtml } from './snippets.js'
|
|
26
27
|
export { useSearch, useSearchIndex, useSearchShortcut, useSearchWithIntent } from './hooks.js'
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `endpoint` search provider — ask a server.
|
|
3
|
+
*
|
|
4
|
+
* What this buys over a downloaded index is not better matching; it is *when
|
|
5
|
+
* the index can be built*. A local index contains what existed at build time,
|
|
6
|
+
* so records that arrive from an API afterwards can never be in it. A server
|
|
7
|
+
* can index them, and can be told to re-index without the site being rebuilt.
|
|
8
|
+
*
|
|
9
|
+
* Nothing here is specific to any host. The endpoint is a declared path and the
|
|
10
|
+
* response envelope is sniffed, so this works against a self-hosted search API
|
|
11
|
+
* as readily as against a managed one. A backend whose shape is genuinely
|
|
12
|
+
* different wants a foundation-supplied transport, which is the open end of the
|
|
13
|
+
* seam — the same escalation `fetcher:` offers.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { emptyResult } from './result.js'
|
|
17
|
+
|
|
18
|
+
/** Path used when a site declares `provider: endpoint` without an `endpoint:`. */
|
|
19
|
+
const DEFAULT_ENDPOINT = '_search'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolve the endpoint against the site's base path.
|
|
23
|
+
*
|
|
24
|
+
* This is the whole reason the endpoint is declared base-RELATIVE. One spelling
|
|
25
|
+
* — `_search` — has to work when the site is served from the root, from a
|
|
26
|
+
* subdirectory (`base: /docs/`), and from a backend subpath. It mirrors how the
|
|
27
|
+
* runtime's default fetcher resolves its base rather than assuming the origin
|
|
28
|
+
* root, and it is what lets a backend expose search as a subroute of the path
|
|
29
|
+
* it already serves the site from, with no framework change.
|
|
30
|
+
*
|
|
31
|
+
* An absolute URL is passed through untouched, for a search service on another
|
|
32
|
+
* origin.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} endpoint - Declared endpoint (relative or absolute)
|
|
35
|
+
* @param {string} basePath - `website.basePath`, normalized without a trailing slash
|
|
36
|
+
* @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
export function resolveEndpointUrl(endpoint, basePath = '') {
|
|
39
|
+
const path = endpoint || DEFAULT_ENDPOINT
|
|
40
|
+
if (/^https?:\/\//i.test(path)) return path
|
|
41
|
+
|
|
42
|
+
const base = (basePath || '').replace(/\/+$/, '')
|
|
43
|
+
const rel = path.replace(/^\/+/, '')
|
|
44
|
+
return `${base}/${rel}`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Pull the result array out of a response envelope.
|
|
49
|
+
*
|
|
50
|
+
* Deliberately tolerant: `results` is our own shape, `hits` and `items` are the
|
|
51
|
+
* two next most common spellings, and a bare array covers the rest. Anything
|
|
52
|
+
* further afield is a transport's job, not a guessing game here.
|
|
53
|
+
*
|
|
54
|
+
* @param {*} payload
|
|
55
|
+
* @returns {Object[]}
|
|
56
|
+
*/
|
|
57
|
+
function extractResults(payload) {
|
|
58
|
+
if (Array.isArray(payload)) return payload
|
|
59
|
+
if (Array.isArray(payload?.results)) return payload.results
|
|
60
|
+
if (Array.isArray(payload?.hits)) return payload.hits
|
|
61
|
+
if (Array.isArray(payload?.items)) return payload.items
|
|
62
|
+
return []
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Normalize one server result into the shared contract.
|
|
67
|
+
*
|
|
68
|
+
* @param {Object} raw
|
|
69
|
+
* @returns {Object}
|
|
70
|
+
*/
|
|
71
|
+
function normalize(raw) {
|
|
72
|
+
const route = raw?.route ?? ''
|
|
73
|
+
const anchor = raw?.anchor ?? null
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
...emptyResult(),
|
|
77
|
+
id: raw?.id ?? '',
|
|
78
|
+
type: raw?.type ?? '',
|
|
79
|
+
route,
|
|
80
|
+
// Prefer a server-built href; fall back to composing one, so a backend that
|
|
81
|
+
// returns only route + anchor still yields a working link.
|
|
82
|
+
href: raw?.href ?? (anchor ? `${route}#${anchor}` : route),
|
|
83
|
+
title: raw?.title ?? '',
|
|
84
|
+
pageTitle: raw?.pageTitle ?? '',
|
|
85
|
+
excerpt: raw?.excerpt ?? '',
|
|
86
|
+
snippetHtml: raw?.snippetHtml ?? '',
|
|
87
|
+
sectionId: raw?.sectionId ?? null,
|
|
88
|
+
anchor,
|
|
89
|
+
description: raw?.description ?? null,
|
|
90
|
+
component: raw?.component ?? null,
|
|
91
|
+
snippetText: raw?.snippetText ?? null,
|
|
92
|
+
matches: raw?.matches ?? null,
|
|
93
|
+
collection: raw?.collection ?? null,
|
|
94
|
+
item: raw?.item ?? null
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Create an `endpoint` provider bound to a Website.
|
|
100
|
+
*
|
|
101
|
+
* @param {Object} website - Website instance from @uniweb/core
|
|
102
|
+
* @param {Object} [options]
|
|
103
|
+
* @param {string} [options.endpoint] - Base-relative path or absolute URL
|
|
104
|
+
* @returns {{query: Function, preload: Function, clearCache: Function}}
|
|
105
|
+
*/
|
|
106
|
+
export function createEndpointProvider(website, options = {}) {
|
|
107
|
+
const { endpoint } = options
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
async query(text, { limit = 10, type, route, signal } = {}) {
|
|
111
|
+
const url = new URL(
|
|
112
|
+
resolveEndpointUrl(endpoint, website.basePath),
|
|
113
|
+
// A base is required to parse a relative path; in a non-browser context
|
|
114
|
+
// (tests, SSR) there is no location, so use a placeholder we strip below.
|
|
115
|
+
typeof window !== 'undefined' ? window.location.origin : 'http://localhost'
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
url.searchParams.set('q', text)
|
|
119
|
+
url.searchParams.set('lang', website.getActiveLocale())
|
|
120
|
+
url.searchParams.set('limit', String(limit))
|
|
121
|
+
|
|
122
|
+
const response = await fetch(url.toString(), {
|
|
123
|
+
signal,
|
|
124
|
+
headers: { Accept: 'application/json' }
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
if (!response.ok) {
|
|
128
|
+
throw new Error(`Search endpoint returned ${response.status}`)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const results = extractResults(await response.json()).map(normalize)
|
|
132
|
+
|
|
133
|
+
// Filters are applied client-side because they are not part of the wire
|
|
134
|
+
// contract a third-party endpoint is expected to honor. A server that
|
|
135
|
+
// does support them narrows the set first; re-applying is a no-op.
|
|
136
|
+
let filtered = results
|
|
137
|
+
if (type) filtered = filtered.filter(r => r.type === type)
|
|
138
|
+
if (route) filtered = filtered.filter(r => r.route?.startsWith(route))
|
|
139
|
+
|
|
140
|
+
return filtered.slice(0, limit)
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
// Nothing to warm: there is no index to download. Defined so every provider
|
|
144
|
+
// answers the same calls.
|
|
145
|
+
async preload() {},
|
|
146
|
+
|
|
147
|
+
clearCache() {}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `index` search provider — download a prebuilt index, query it locally.
|
|
3
|
+
*
|
|
4
|
+
* The free path, and the default. Works on any host, including a plain static
|
|
5
|
+
* one with no server at all, which is why it stays the fallback for every other
|
|
6
|
+
* provider (see `resolveProvider` in ../client.js).
|
|
7
|
+
*
|
|
8
|
+
* Its limit is structural rather than a quality gap: the index is built from
|
|
9
|
+
* what existed at build time, so content that arrives from an API afterwards
|
|
10
|
+
* cannot be in it. Fuzzy matching is the thing it does *better* than a server
|
|
11
|
+
* scorer — Fuse tolerates typos.
|
|
12
|
+
*
|
|
13
|
+
* Fuse is imported dynamically so a site using a different provider never
|
|
14
|
+
* bundles it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { buildSnippet } from '../snippets.js'
|
|
18
|
+
import { emptyResult } from './result.js'
|
|
19
|
+
|
|
20
|
+
// In-memory caches, keyed by index URL. Module-scope is correct here: this
|
|
21
|
+
// only ever runs in one browser tab, unlike the edge's per-PoP isolates.
|
|
22
|
+
const indexCache = new Map()
|
|
23
|
+
const fuseCache = new Map()
|
|
24
|
+
|
|
25
|
+
const STORAGE_VERSION = 'v1'
|
|
26
|
+
const STORAGE_PREFIX = `uniweb:search:${STORAGE_VERSION}:`
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Default Fuse.js options optimized for site search
|
|
30
|
+
*/
|
|
31
|
+
const DEFAULT_FUSE_OPTIONS = {
|
|
32
|
+
keys: [
|
|
33
|
+
{ name: 'title', weight: 0.6 },
|
|
34
|
+
{ name: 'content', weight: 0.4 },
|
|
35
|
+
{ name: 'excerpt', weight: 0.3 },
|
|
36
|
+
{ name: 'pageTitle', weight: 0.2 }
|
|
37
|
+
],
|
|
38
|
+
threshold: 0.35,
|
|
39
|
+
includeMatches: true,
|
|
40
|
+
ignoreLocation: true,
|
|
41
|
+
minMatchCharLength: 2
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Get localStorage safely (handles SSR and access errors)
|
|
46
|
+
* @returns {Storage|null}
|
|
47
|
+
*/
|
|
48
|
+
function getStorage() {
|
|
49
|
+
if (typeof window === 'undefined') return null
|
|
50
|
+
try {
|
|
51
|
+
return window.localStorage
|
|
52
|
+
} catch {
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Load index from localStorage
|
|
59
|
+
* @param {string} cacheKey - Cache key
|
|
60
|
+
* @returns {Object|null}
|
|
61
|
+
*/
|
|
62
|
+
function loadFromStorage(cacheKey) {
|
|
63
|
+
const storage = getStorage()
|
|
64
|
+
if (!storage) return null
|
|
65
|
+
|
|
66
|
+
const raw = storage.getItem(`${STORAGE_PREFIX}${cacheKey}`)
|
|
67
|
+
if (!raw) return null
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const parsed = JSON.parse(raw)
|
|
71
|
+
if (Array.isArray(parsed.entries)) {
|
|
72
|
+
return parsed
|
|
73
|
+
}
|
|
74
|
+
return null
|
|
75
|
+
} catch {
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Save index to localStorage
|
|
82
|
+
* @param {string} cacheKey - Cache key
|
|
83
|
+
* @param {Object} payload - Index data
|
|
84
|
+
*/
|
|
85
|
+
function saveToStorage(cacheKey, payload) {
|
|
86
|
+
const storage = getStorage()
|
|
87
|
+
if (!storage) return
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
storage.setItem(`${STORAGE_PREFIX}${cacheKey}`, JSON.stringify(payload))
|
|
91
|
+
} catch {
|
|
92
|
+
// Ignore quota errors
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Load search index for a locale
|
|
98
|
+
* @param {string} indexUrl - URL to fetch the index from
|
|
99
|
+
* @param {Object} options - Options
|
|
100
|
+
* @param {string} [options.cacheKey] - Cache key (defaults to indexUrl)
|
|
101
|
+
* @param {boolean} [options.useStorage=true] - Use localStorage caching
|
|
102
|
+
* @returns {Promise<Object>} Search index
|
|
103
|
+
*/
|
|
104
|
+
export async function loadSearchIndex(indexUrl, options = {}) {
|
|
105
|
+
const { cacheKey = indexUrl, useStorage = true } = options
|
|
106
|
+
|
|
107
|
+
// Check memory cache first
|
|
108
|
+
if (indexCache.has(cacheKey)) {
|
|
109
|
+
return indexCache.get(cacheKey)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Check localStorage cache
|
|
113
|
+
if (useStorage) {
|
|
114
|
+
const cached = loadFromStorage(cacheKey)
|
|
115
|
+
if (cached) {
|
|
116
|
+
indexCache.set(cacheKey, cached)
|
|
117
|
+
return cached
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Fetch from server
|
|
122
|
+
const response = await fetch(indexUrl, { cache: 'force-cache' })
|
|
123
|
+
if (!response.ok) {
|
|
124
|
+
throw new Error(`Failed to load search index: ${response.status}`)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const payload = await response.json()
|
|
128
|
+
|
|
129
|
+
// Cache the result
|
|
130
|
+
indexCache.set(cacheKey, payload)
|
|
131
|
+
if (useStorage) {
|
|
132
|
+
saveToStorage(cacheKey, payload)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return payload
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Clear all search caches
|
|
140
|
+
* @param {string} [cacheKey] - Specific cache key to clear, or all if omitted
|
|
141
|
+
*/
|
|
142
|
+
export function clearSearchCache(cacheKey) {
|
|
143
|
+
if (cacheKey) {
|
|
144
|
+
indexCache.delete(cacheKey)
|
|
145
|
+
fuseCache.delete(cacheKey)
|
|
146
|
+
const storage = getStorage()
|
|
147
|
+
if (storage) {
|
|
148
|
+
storage.removeItem(`${STORAGE_PREFIX}${cacheKey}`)
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
indexCache.clear()
|
|
152
|
+
fuseCache.clear()
|
|
153
|
+
const storage = getStorage()
|
|
154
|
+
if (storage) {
|
|
155
|
+
// Clear all search-related storage
|
|
156
|
+
const keysToRemove = []
|
|
157
|
+
for (let i = 0; i < storage.length; i++) {
|
|
158
|
+
const key = storage.key(i)
|
|
159
|
+
if (key?.startsWith(STORAGE_PREFIX)) {
|
|
160
|
+
keysToRemove.push(key)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
keysToRemove.forEach(key => storage.removeItem(key))
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Create an `index` provider bound to a Website.
|
|
170
|
+
*
|
|
171
|
+
* @param {Object} website - Website instance from @uniweb/core
|
|
172
|
+
* @param {Object} [options]
|
|
173
|
+
* @param {Object} [options.fuseOptions] - Custom Fuse.js options
|
|
174
|
+
* @param {boolean} [options.useStorage=true] - Use localStorage caching
|
|
175
|
+
* @returns {{query: Function, preload: Function, clearCache: Function}}
|
|
176
|
+
*/
|
|
177
|
+
export function createIndexProvider(website, options = {}) {
|
|
178
|
+
const { fuseOptions = {}, useStorage = true } = options
|
|
179
|
+
const mergedFuseOptions = { ...DEFAULT_FUSE_OPTIONS, ...fuseOptions }
|
|
180
|
+
|
|
181
|
+
async function getFuse() {
|
|
182
|
+
// Read the URL per call rather than closing over it: the active locale can
|
|
183
|
+
// change without the client being rebuilt, and each locale has its own index.
|
|
184
|
+
const indexUrl = website.getSearchIndexUrl()
|
|
185
|
+
const cacheKey = indexUrl
|
|
186
|
+
|
|
187
|
+
if (fuseCache.has(cacheKey)) {
|
|
188
|
+
return fuseCache.get(cacheKey)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const index = await loadSearchIndex(indexUrl, { cacheKey, useStorage })
|
|
192
|
+
|
|
193
|
+
let Fuse
|
|
194
|
+
try {
|
|
195
|
+
const fuseMod = await import('fuse.js')
|
|
196
|
+
Fuse = fuseMod.default || fuseMod
|
|
197
|
+
} catch {
|
|
198
|
+
throw new Error(
|
|
199
|
+
'Fuse.js is required for the `index` search provider. ' +
|
|
200
|
+
'Install it with: npm install fuse.js'
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const fuse = new Fuse(index.entries || [], mergedFuseOptions)
|
|
205
|
+
fuseCache.set(cacheKey, fuse)
|
|
206
|
+
|
|
207
|
+
return fuse
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
async query(text, { limit = 10, type, route } = {}) {
|
|
212
|
+
const fuse = await getFuse()
|
|
213
|
+
let results = fuse.search(text)
|
|
214
|
+
|
|
215
|
+
if (type) {
|
|
216
|
+
results = results.filter(({ item }) => item.type === type)
|
|
217
|
+
}
|
|
218
|
+
if (route) {
|
|
219
|
+
results = results.filter(({ item }) => item.route?.startsWith(route))
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return results.slice(0, limit).map(({ item, matches }) => {
|
|
223
|
+
const snippet = buildSnippet(item.content, matches, { key: 'content' })
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
...emptyResult(),
|
|
227
|
+
id: item.id,
|
|
228
|
+
type: item.type,
|
|
229
|
+
route: item.route,
|
|
230
|
+
sectionId: item.sectionId ?? null,
|
|
231
|
+
anchor: item.anchor ?? null,
|
|
232
|
+
href: item.anchor ? `${item.route}#${item.anchor}` : item.route,
|
|
233
|
+
title: item.title ?? '',
|
|
234
|
+
pageTitle: item.pageTitle ?? '',
|
|
235
|
+
description: item.description ?? null,
|
|
236
|
+
excerpt: item.excerpt ?? '',
|
|
237
|
+
component: item.component ?? null,
|
|
238
|
+
snippetText: snippet.text,
|
|
239
|
+
snippetHtml: snippet.html,
|
|
240
|
+
matches
|
|
241
|
+
}
|
|
242
|
+
})
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
async preload() {
|
|
246
|
+
await getFuse()
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
clearCache() {
|
|
250
|
+
clearSearchCache(website.getSearchIndexUrl())
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The search result contract.
|
|
3
|
+
*
|
|
4
|
+
* Every provider returns objects of this shape. The point is that a foundation's
|
|
5
|
+
* search UI is written once and works against any provider — the same reason
|
|
6
|
+
* `prepare-props` guarantees a content shape so components need no null checks.
|
|
7
|
+
*
|
|
8
|
+
* Two tiers, and the distinction is load-bearing:
|
|
9
|
+
*
|
|
10
|
+
* - **Guaranteed** — `id`, `type`, `route`, `href`, `title`, `pageTitle`,
|
|
11
|
+
* `excerpt`, `snippetHtml`. Every provider fills these. A component may
|
|
12
|
+
* render them unconditionally.
|
|
13
|
+
* - **Provider-optional** — everything else. Present when the provider can
|
|
14
|
+
* supply it, `null` when it cannot. A component that renders one must
|
|
15
|
+
* tolerate `null`, because whether it arrives is a *deployment* fact, not a
|
|
16
|
+
* content fact: the same site yields `item` on a server-backed provider and
|
|
17
|
+
* `null` on a static one.
|
|
18
|
+
*
|
|
19
|
+
* Concretely today: a local index can produce `matches` / `snippetText` (Fuse
|
|
20
|
+
* reports match ranges) but knows nothing about API-backed collection records;
|
|
21
|
+
* a server endpoint is the reverse. Neither is a subset of the other, which is
|
|
22
|
+
* why the contract is a union with a guaranteed core rather than a
|
|
23
|
+
* lowest-common-denominator.
|
|
24
|
+
*
|
|
25
|
+
* `snippetHtml` is HTML containing `<mark>` elements. Render it through kit's
|
|
26
|
+
* `SafeHtml`, never as text.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A result with every optional field nulled and every guaranteed field at its
|
|
31
|
+
* empty value. Spread this first so a provider filling a subset still returns
|
|
32
|
+
* the full shape, and adding a field later cannot silently produce `undefined`
|
|
33
|
+
* at one provider and a value at another.
|
|
34
|
+
*
|
|
35
|
+
* @returns {Object}
|
|
36
|
+
*/
|
|
37
|
+
export function emptyResult() {
|
|
38
|
+
return {
|
|
39
|
+
// ── Guaranteed ──────────────────────────────────────────────
|
|
40
|
+
id: '',
|
|
41
|
+
type: '',
|
|
42
|
+
route: '',
|
|
43
|
+
href: '',
|
|
44
|
+
title: '',
|
|
45
|
+
pageTitle: '',
|
|
46
|
+
excerpt: '',
|
|
47
|
+
snippetHtml: '',
|
|
48
|
+
|
|
49
|
+
// ── Provider-optional ───────────────────────────────────────
|
|
50
|
+
// Deep-linking within a page.
|
|
51
|
+
sectionId: null,
|
|
52
|
+
anchor: null,
|
|
53
|
+
// Extra display material.
|
|
54
|
+
description: null,
|
|
55
|
+
component: null,
|
|
56
|
+
// Plain-text twin of snippetHtml, for non-HTML surfaces.
|
|
57
|
+
snippetText: null,
|
|
58
|
+
// Match ranges, when the provider scores locally and can report them.
|
|
59
|
+
matches: null,
|
|
60
|
+
// Collection provenance, when the hit is a record rather than a page.
|
|
61
|
+
collection: null,
|
|
62
|
+
item: null
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -170,6 +170,32 @@ function SequenceElement({ element, block }) {
|
|
|
170
170
|
)
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
case 'inset_block': {
|
|
174
|
+
// A component reference that carries block content — the block form of
|
|
175
|
+
// an inset. Children recurse; flattening them to text is what the
|
|
176
|
+
// sequence's default branch used to do, and it lost the body.
|
|
177
|
+
//
|
|
178
|
+
// Reaching this case means the container was NOT lifted — `@uniweb/core`
|
|
179
|
+
// rewrites `inset_block` to `inset_placeholder` when it builds the render
|
|
180
|
+
// graph, and the `inset` case resolves that against the foundation. This
|
|
181
|
+
// is the fallback for a document rendered without a Block behind it.
|
|
182
|
+
//
|
|
183
|
+
// kit does NOT map the name to one of its own components — see the longer
|
|
184
|
+
// note in Section/Render. A visible generic box; never a drop.
|
|
185
|
+
const body = element.children?.map((el, i) => (
|
|
186
|
+
<SequenceElement key={i} element={el} block={block} />
|
|
187
|
+
))
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<div
|
|
191
|
+
data-inset-block={element.component || 'unknown'}
|
|
192
|
+
className="border border-border rounded-md p-4 my-4"
|
|
193
|
+
>
|
|
194
|
+
{body}
|
|
195
|
+
</div>
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
173
199
|
case 'link': {
|
|
174
200
|
const { href, label, role } = element.attrs || {}
|
|
175
201
|
// Standalone links promoted from paragraphs
|
|
@@ -225,6 +225,43 @@ function RenderNode({ node, block, ...props }) {
|
|
|
225
225
|
)
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
+
case 'inset_block': {
|
|
229
|
+
// The block form of an inset: a fenced `@Component{params}` container
|
|
230
|
+
// whose body is real block content, recursed like a blockquote's rather
|
|
231
|
+
// than flattened to a string.
|
|
232
|
+
//
|
|
233
|
+
// NOTE: a container reaching this case means it was NOT lifted.
|
|
234
|
+
// `@uniweb/core`'s Block rewrites every `inset_block` to an
|
|
235
|
+
// `inset_placeholder` when it builds the render graph, so the normal
|
|
236
|
+
// path is the `inset_placeholder` case below — which resolves the
|
|
237
|
+
// component against the FOUNDATION. This branch is what is left for a
|
|
238
|
+
// document rendered without a Block behind it.
|
|
239
|
+
//
|
|
240
|
+
// `@Component` names foundation vocabulary, so kit must not answer for
|
|
241
|
+
// it. kit's own Details and Alert are not reachable through
|
|
242
|
+
// `getInset()` and must not become reachable here, or a foundation
|
|
243
|
+
// shipping its own Alert would be shadowed by ours. (kit still renders
|
|
244
|
+
// `details` / `alert` above — those are the editor's DOCUMENT node
|
|
245
|
+
// types, a different mechanism that happens to share a name.)
|
|
246
|
+
//
|
|
247
|
+
// So: no name dispatch. A VISIBLE generic box that keeps its body.
|
|
248
|
+
// Never a drop — an unmapped node taking its subtree with it is the
|
|
249
|
+
// failure this container exists to fix.
|
|
250
|
+
const component = attrs?.component
|
|
251
|
+
const body = content?.map((child, i) => (
|
|
252
|
+
<RenderNode key={i} node={child} block={block} />
|
|
253
|
+
))
|
|
254
|
+
|
|
255
|
+
return (
|
|
256
|
+
<div
|
|
257
|
+
data-inset-block={component || 'unknown'}
|
|
258
|
+
className="border border-border rounded-md p-4 my-4"
|
|
259
|
+
>
|
|
260
|
+
{body}
|
|
261
|
+
</div>
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
|
|
228
265
|
case 'horizontalRule':
|
|
229
266
|
case 'divider': {
|
|
230
267
|
return <Divider type={attrs?.type} className="my-6" />
|