@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/kit",
3
- "version": "0.9.37",
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.29",
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'
@@ -1,157 +1,70 @@
1
1
  /**
2
2
  * Search Client
3
3
  *
4
- * Manages search index loading, caching, and querying using Fuse.js.
5
- */
6
-
7
- import { buildSnippet } from './snippets.js'
8
-
9
- // Storage versioning for cache invalidation
10
- const STORAGE_VERSION = 'v1'
11
- const STORAGE_PREFIX = `uniweb:search:${STORAGE_VERSION}:`
12
-
13
- // In-memory caches
14
- const indexCache = new Map()
15
- const fuseCache = new Map()
16
-
17
- /**
18
- * Default Fuse.js options optimized for site search
19
- */
20
- const DEFAULT_FUSE_OPTIONS = {
21
- keys: [
22
- { name: 'title', weight: 0.6 },
23
- { name: 'content', weight: 0.4 },
24
- { name: 'excerpt', weight: 0.3 },
25
- { name: 'pageTitle', weight: 0.2 }
26
- ],
27
- threshold: 0.35,
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
- const raw = storage.getItem(`${STORAGE_PREFIX}${cacheKey}`)
56
- if (!raw) return null
30
+ import { emptyResult } from './providers/result.js'
57
31
 
58
- try {
59
- const parsed = JSON.parse(raw)
60
- if (Array.isArray(parsed.entries)) {
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
- * Save index to localStorage
71
- * @param {string} cacheKey - Cache key
72
- * @param {Object} payload - Index data
73
- */
74
- function saveToStorage(cacheKey, payload) {
75
- const storage = getStorage()
76
- if (!storage) return
77
-
78
- try {
79
- storage.setItem(`${STORAGE_PREFIX}${cacheKey}`, JSON.stringify(payload))
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
- export async function loadSearchIndex(indexUrl, options = {}) {
94
- const { cacheKey = indexUrl, useStorage = true } = options
95
-
96
- // Check memory cache first
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
- // Check localStorage cache
102
- if (useStorage) {
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 payload = await response.json()
117
-
118
- // Cache the result
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
- return payload
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
- fuseOptions = {},
174
- useStorage = true,
175
- defaultLimit = 10
88
+ defaultLimit = 10,
89
+ provider: providerOverride,
90
+ transports,
91
+ ...providerOptions
176
92
  } = options
177
93
 
178
- const mergedFuseOptions = { ...DEFAULT_FUSE_OPTIONS, ...fuseOptions }
94
+ const searchConfig = website.getSearchConfig?.() || {}
95
+ const declared = providerOverride || searchConfig.provider || 'index'
179
96
 
180
- /**
181
- * Get or create Fuse instance for the current locale
182
- * @returns {Promise<Fuse>}
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
- // Check Fuse cache
189
- if (fuseCache.has(cacheKey)) {
190
- return fuseCache.get(cacheKey)
191
- }
101
+ async function getProvider() {
102
+ if (providerPromise) return providerPromise
192
103
 
193
- // Load index and create Fuse instance
194
- const index = await loadSearchIndex(indexUrl, { cacheKey, useStorage })
104
+ providerPromise = (async () => {
105
+ let factory = await loadProviderFactory(declared, transports)
195
106
 
196
- // Dynamically import Fuse.js (peer dependency)
197
- let Fuse
198
- try {
199
- const fuseMod = await import('fuse.js')
200
- Fuse = fuseMod.default || fuseMod
201
- } catch (err) {
202
- throw new Error(
203
- 'Fuse.js is required for search functionality. ' +
204
- 'Install it with: npm install fuse.js'
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
- const fuse = new Fuse(index.entries || [], mergedFuseOptions)
209
- fuseCache.set(cacheKey, fuse)
121
+ return providerPromise
122
+ }
210
123
 
211
- return fuse
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 fuse = await getFuse()
261
- let results = fuse.search(trimmed)
206
+ const opts = { limit, type, route, signal }
262
207
 
263
- // Apply type filter
264
- if (type) {
265
- results = results.filter(({ item }) => item.type === type)
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
- // Apply route filter
269
- if (route) {
270
- results = results.filter(({ item }) => item.route?.startsWith(route))
271
- }
272
-
273
- // Apply limit
274
- const limited = results.slice(0, limit)
275
-
276
- // Transform results
277
- return limited.map(({ item, matches }) => {
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 search index (call this to warm the cache)
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
- await getFuse()
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 search cache
246
+ * Clear the provider's cache, if it keeps one.
317
247
  */
318
248
  clearCache() {
319
- const indexUrl = website.getSearchIndexUrl()
320
- clearSearchCache(indexUrl)
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
@@ -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" />