@uniweb/kit 0.1.3 → 0.1.4
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 +11 -4
- package/src/search/client.js +325 -0
- package/src/search/hooks.js +239 -0
- package/src/search/index.js +26 -0
- package/src/search/snippets.js +191 -0
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/kit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Standard component library for Uniweb foundations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./src/index.js",
|
|
8
|
-
"./styles": "./src/styles/index.css"
|
|
8
|
+
"./styles": "./src/styles/index.css",
|
|
9
|
+
"./search": "./src/search/index.js"
|
|
9
10
|
},
|
|
10
11
|
"files": [
|
|
11
12
|
"src",
|
|
@@ -35,11 +36,17 @@
|
|
|
35
36
|
},
|
|
36
37
|
"dependencies": {
|
|
37
38
|
"tailwind-merge": "^2.6.0",
|
|
38
|
-
"@uniweb/core": "0.1.
|
|
39
|
+
"@uniweb/core": "0.1.6"
|
|
39
40
|
},
|
|
40
41
|
"peerDependencies": {
|
|
41
42
|
"react": "^18.0.0 || ^19.0.0",
|
|
42
|
-
"react-dom": "^18.0.0 || ^19.0.0"
|
|
43
|
+
"react-dom": "^18.0.0 || ^19.0.0",
|
|
44
|
+
"fuse.js": "^7.0.0"
|
|
45
|
+
},
|
|
46
|
+
"peerDependenciesMeta": {
|
|
47
|
+
"fuse.js": {
|
|
48
|
+
"optional": true
|
|
49
|
+
}
|
|
43
50
|
},
|
|
44
51
|
"devDependencies": {
|
|
45
52
|
"tailwindcss": "^3.4.0"
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search Client
|
|
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}
|
|
50
|
+
*/
|
|
51
|
+
function loadFromStorage(cacheKey) {
|
|
52
|
+
const storage = getStorage()
|
|
53
|
+
if (!storage) return null
|
|
54
|
+
|
|
55
|
+
const raw = storage.getItem(`${STORAGE_PREFIX}${cacheKey}`)
|
|
56
|
+
if (!raw) return null
|
|
57
|
+
|
|
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
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
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
|
|
92
|
+
*/
|
|
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)
|
|
99
|
+
}
|
|
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}`)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const payload = await response.json()
|
|
117
|
+
|
|
118
|
+
// Cache the result
|
|
119
|
+
indexCache.set(cacheKey, payload)
|
|
120
|
+
if (useStorage) {
|
|
121
|
+
saveToStorage(cacheKey, payload)
|
|
122
|
+
}
|
|
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
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Create a search client for a Website instance
|
|
159
|
+
*
|
|
160
|
+
* @param {Object} website - Website instance from @uniweb/core
|
|
161
|
+
* @param {Object} options - Configuration options
|
|
162
|
+
* @param {Object} [options.fuseOptions] - Custom Fuse.js options
|
|
163
|
+
* @param {boolean} [options.useStorage=true] - Use localStorage caching
|
|
164
|
+
* @param {number} [options.defaultLimit=10] - Default result limit
|
|
165
|
+
* @returns {Object} Search client with query method
|
|
166
|
+
*
|
|
167
|
+
* @example
|
|
168
|
+
* const search = createSearchClient(website)
|
|
169
|
+
* const results = await search.query('authentication')
|
|
170
|
+
*/
|
|
171
|
+
export function createSearchClient(website, options = {}) {
|
|
172
|
+
const {
|
|
173
|
+
fuseOptions = {},
|
|
174
|
+
useStorage = true,
|
|
175
|
+
defaultLimit = 10
|
|
176
|
+
} = options
|
|
177
|
+
|
|
178
|
+
const mergedFuseOptions = { ...DEFAULT_FUSE_OPTIONS, ...fuseOptions }
|
|
179
|
+
|
|
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
|
|
187
|
+
|
|
188
|
+
// Check Fuse cache
|
|
189
|
+
if (fuseCache.has(cacheKey)) {
|
|
190
|
+
return fuseCache.get(cacheKey)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Load index and create Fuse instance
|
|
194
|
+
const index = await loadSearchIndex(indexUrl, { cacheKey, useStorage })
|
|
195
|
+
|
|
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
|
+
}
|
|
207
|
+
|
|
208
|
+
const fuse = new Fuse(index.entries || [], mergedFuseOptions)
|
|
209
|
+
fuseCache.set(cacheKey, fuse)
|
|
210
|
+
|
|
211
|
+
return fuse
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
/**
|
|
216
|
+
* Check if search is enabled
|
|
217
|
+
* @returns {boolean}
|
|
218
|
+
*/
|
|
219
|
+
isEnabled() {
|
|
220
|
+
return website.isSearchEnabled()
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Get the search index URL
|
|
225
|
+
* @returns {string}
|
|
226
|
+
*/
|
|
227
|
+
getIndexUrl() {
|
|
228
|
+
return website.getSearchIndexUrl()
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Get search configuration
|
|
233
|
+
* @returns {Object}
|
|
234
|
+
*/
|
|
235
|
+
getConfig() {
|
|
236
|
+
return website.getSearchConfig()
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Perform a search query
|
|
241
|
+
*
|
|
242
|
+
* @param {string} query - Search query
|
|
243
|
+
* @param {Object} queryOptions - Query options
|
|
244
|
+
* @param {number} [queryOptions.limit] - Maximum results
|
|
245
|
+
* @param {string} [queryOptions.type] - Filter by type ('page' or 'section')
|
|
246
|
+
* @param {string} [queryOptions.route] - Filter by route prefix
|
|
247
|
+
* @returns {Promise<Array>} Search results
|
|
248
|
+
*/
|
|
249
|
+
async query(query, queryOptions = {}) {
|
|
250
|
+
const { limit = defaultLimit, type, route } = queryOptions
|
|
251
|
+
|
|
252
|
+
const trimmed = query?.trim()
|
|
253
|
+
if (!trimmed) return []
|
|
254
|
+
|
|
255
|
+
if (!website.isSearchEnabled()) {
|
|
256
|
+
console.warn('Search is not enabled for this site')
|
|
257
|
+
return []
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const fuse = await getFuse()
|
|
261
|
+
let results = fuse.search(trimmed)
|
|
262
|
+
|
|
263
|
+
// Apply type filter
|
|
264
|
+
if (type) {
|
|
265
|
+
results = results.filter(({ item }) => item.type === type)
|
|
266
|
+
}
|
|
267
|
+
|
|
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
|
|
302
|
+
}
|
|
303
|
+
})
|
|
304
|
+
},
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Preload the search index (call this to warm the cache)
|
|
308
|
+
* @returns {Promise<void>}
|
|
309
|
+
*/
|
|
310
|
+
async preload() {
|
|
311
|
+
if (!website.isSearchEnabled()) return
|
|
312
|
+
await getFuse()
|
|
313
|
+
},
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Clear the search cache
|
|
317
|
+
*/
|
|
318
|
+
clearCache() {
|
|
319
|
+
const indexUrl = website.getSearchIndexUrl()
|
|
320
|
+
clearSearchCache(indexUrl)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export default createSearchClient
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Hooks for Search
|
|
3
|
+
*
|
|
4
|
+
* Provides hooks for easily integrating search into React components.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
|
8
|
+
import { createSearchClient, loadSearchIndex } from './client.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Hook to create and use a search client
|
|
12
|
+
*
|
|
13
|
+
* @param {Object} website - Website instance from @uniweb/core
|
|
14
|
+
* @param {Object} options - Options passed to createSearchClient
|
|
15
|
+
* @returns {Object} Search state and methods
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* function SearchComponent() {
|
|
19
|
+
* const { query, results, isLoading, error } = useSearch(website)
|
|
20
|
+
*
|
|
21
|
+
* return (
|
|
22
|
+
* <div>
|
|
23
|
+
* <input onChange={e => query(e.target.value)} />
|
|
24
|
+
* {isLoading && <span>Searching...</span>}
|
|
25
|
+
* {results.map(r => <SearchResult key={r.id} result={r} />)}
|
|
26
|
+
* </div>
|
|
27
|
+
* )
|
|
28
|
+
* }
|
|
29
|
+
*/
|
|
30
|
+
export function useSearch(website, options = {}) {
|
|
31
|
+
const { debounceMs = 150, ...clientOptions } = options
|
|
32
|
+
|
|
33
|
+
const [results, setResults] = useState([])
|
|
34
|
+
const [isLoading, setIsLoading] = useState(false)
|
|
35
|
+
const [error, setError] = useState(null)
|
|
36
|
+
const [lastQuery, setLastQuery] = useState('')
|
|
37
|
+
|
|
38
|
+
// Create search client (memoized)
|
|
39
|
+
const client = useMemo(() => {
|
|
40
|
+
if (!website) return null
|
|
41
|
+
return createSearchClient(website, clientOptions)
|
|
42
|
+
}, [website]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
43
|
+
|
|
44
|
+
// Track pending search for debounce/cancellation
|
|
45
|
+
const pendingRef = useRef(null)
|
|
46
|
+
const timeoutRef = useRef(null)
|
|
47
|
+
|
|
48
|
+
// Cleanup on unmount
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
return () => {
|
|
51
|
+
if (timeoutRef.current) {
|
|
52
|
+
clearTimeout(timeoutRef.current)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}, [])
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Execute a search query
|
|
59
|
+
*/
|
|
60
|
+
const query = useCallback(async (searchQuery, queryOptions = {}) => {
|
|
61
|
+
const trimmed = searchQuery?.trim() || ''
|
|
62
|
+
setLastQuery(trimmed)
|
|
63
|
+
|
|
64
|
+
// Clear pending timeout
|
|
65
|
+
if (timeoutRef.current) {
|
|
66
|
+
clearTimeout(timeoutRef.current)
|
|
67
|
+
timeoutRef.current = null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Empty query - clear results immediately
|
|
71
|
+
if (!trimmed) {
|
|
72
|
+
setResults([])
|
|
73
|
+
setIsLoading(false)
|
|
74
|
+
setError(null)
|
|
75
|
+
return []
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Check if search is enabled
|
|
79
|
+
if (!client?.isEnabled()) {
|
|
80
|
+
setError(new Error('Search is not enabled'))
|
|
81
|
+
return []
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Mark this search as pending
|
|
85
|
+
const searchId = Symbol('search')
|
|
86
|
+
pendingRef.current = searchId
|
|
87
|
+
|
|
88
|
+
// Debounce the actual search
|
|
89
|
+
return new Promise((resolve) => {
|
|
90
|
+
timeoutRef.current = setTimeout(async () => {
|
|
91
|
+
// Skip if a newer search was started
|
|
92
|
+
if (pendingRef.current !== searchId) {
|
|
93
|
+
resolve([])
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
setIsLoading(true)
|
|
98
|
+
setError(null)
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const searchResults = await client.query(trimmed, queryOptions)
|
|
102
|
+
|
|
103
|
+
// Skip if a newer search was started
|
|
104
|
+
if (pendingRef.current !== searchId) {
|
|
105
|
+
resolve([])
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
setResults(searchResults)
|
|
110
|
+
setIsLoading(false)
|
|
111
|
+
resolve(searchResults)
|
|
112
|
+
} catch (err) {
|
|
113
|
+
// Skip if a newer search was started
|
|
114
|
+
if (pendingRef.current !== searchId) {
|
|
115
|
+
resolve([])
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
setError(err)
|
|
120
|
+
setResults([])
|
|
121
|
+
setIsLoading(false)
|
|
122
|
+
resolve([])
|
|
123
|
+
}
|
|
124
|
+
}, debounceMs)
|
|
125
|
+
})
|
|
126
|
+
}, [client, debounceMs])
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Clear search results
|
|
130
|
+
*/
|
|
131
|
+
const clear = useCallback(() => {
|
|
132
|
+
if (timeoutRef.current) {
|
|
133
|
+
clearTimeout(timeoutRef.current)
|
|
134
|
+
timeoutRef.current = null
|
|
135
|
+
}
|
|
136
|
+
pendingRef.current = null
|
|
137
|
+
setResults([])
|
|
138
|
+
setLastQuery('')
|
|
139
|
+
setError(null)
|
|
140
|
+
setIsLoading(false)
|
|
141
|
+
}, [])
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Preload the search index
|
|
145
|
+
*/
|
|
146
|
+
const preload = useCallback(async () => {
|
|
147
|
+
if (!client) return
|
|
148
|
+
try {
|
|
149
|
+
await client.preload()
|
|
150
|
+
} catch (err) {
|
|
151
|
+
console.warn('Failed to preload search index:', err)
|
|
152
|
+
}
|
|
153
|
+
}, [client])
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
// State
|
|
157
|
+
results,
|
|
158
|
+
isLoading,
|
|
159
|
+
error,
|
|
160
|
+
lastQuery,
|
|
161
|
+
isEnabled: client?.isEnabled() ?? false,
|
|
162
|
+
|
|
163
|
+
// Actions
|
|
164
|
+
query,
|
|
165
|
+
clear,
|
|
166
|
+
preload,
|
|
167
|
+
|
|
168
|
+
// Client access (for advanced use)
|
|
169
|
+
client
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Hook to load and access the raw search index
|
|
175
|
+
*
|
|
176
|
+
* Useful for custom search implementations or displaying index stats.
|
|
177
|
+
*
|
|
178
|
+
* @param {Object} website - Website instance
|
|
179
|
+
* @param {Object} options - Options
|
|
180
|
+
* @param {boolean} [options.autoLoad=true] - Automatically load on mount
|
|
181
|
+
* @returns {Object} Index state and methods
|
|
182
|
+
*
|
|
183
|
+
* @example
|
|
184
|
+
* function SearchStats() {
|
|
185
|
+
* const { index, isLoading } = useSearchIndex(website)
|
|
186
|
+
*
|
|
187
|
+
* if (isLoading) return <span>Loading...</span>
|
|
188
|
+
* return <span>{index?.count || 0} searchable items</span>
|
|
189
|
+
* }
|
|
190
|
+
*/
|
|
191
|
+
export function useSearchIndex(website, options = {}) {
|
|
192
|
+
const { autoLoad = true } = options
|
|
193
|
+
|
|
194
|
+
const [index, setIndex] = useState(null)
|
|
195
|
+
const [isLoading, setIsLoading] = useState(false)
|
|
196
|
+
const [error, setError] = useState(null)
|
|
197
|
+
|
|
198
|
+
const load = useCallback(async () => {
|
|
199
|
+
if (!website?.isSearchEnabled()) {
|
|
200
|
+
setError(new Error('Search is not enabled'))
|
|
201
|
+
return null
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
setIsLoading(true)
|
|
205
|
+
setError(null)
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
const indexUrl = website.getSearchIndexUrl()
|
|
209
|
+
const data = await loadSearchIndex(indexUrl)
|
|
210
|
+
setIndex(data)
|
|
211
|
+
setIsLoading(false)
|
|
212
|
+
return data
|
|
213
|
+
} catch (err) {
|
|
214
|
+
setError(err)
|
|
215
|
+
setIsLoading(false)
|
|
216
|
+
return null
|
|
217
|
+
}
|
|
218
|
+
}, [website])
|
|
219
|
+
|
|
220
|
+
// Auto-load on mount if enabled
|
|
221
|
+
useEffect(() => {
|
|
222
|
+
if (autoLoad && website?.isSearchEnabled()) {
|
|
223
|
+
load()
|
|
224
|
+
}
|
|
225
|
+
}, [autoLoad, website, load])
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
index,
|
|
229
|
+
isLoading,
|
|
230
|
+
error,
|
|
231
|
+
isEnabled: website?.isSearchEnabled() ?? false,
|
|
232
|
+
load,
|
|
233
|
+
entries: index?.entries || [],
|
|
234
|
+
count: index?.count || 0,
|
|
235
|
+
locale: index?.locale || null
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export default useSearch
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search Utilities for Uniweb Foundations
|
|
3
|
+
*
|
|
4
|
+
* Provides helpers for implementing search functionality in foundations.
|
|
5
|
+
* Uses Fuse.js for fuzzy search (peer dependency - must be installed by foundation).
|
|
6
|
+
*
|
|
7
|
+
* @module @uniweb/kit/search
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* import { createSearchClient, buildSnippet } from '@uniweb/kit/search'
|
|
11
|
+
*
|
|
12
|
+
* // Create a search client for your site
|
|
13
|
+
* const search = createSearchClient(website)
|
|
14
|
+
*
|
|
15
|
+
* // Perform a search
|
|
16
|
+
* const results = await search.query('hello world')
|
|
17
|
+
*
|
|
18
|
+
* // Results include highlighted snippets
|
|
19
|
+
* results.forEach(r => {
|
|
20
|
+
* console.log(r.title, r.snippetHtml)
|
|
21
|
+
* })
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export { createSearchClient, loadSearchIndex, clearSearchCache } from './client.js'
|
|
25
|
+
export { buildSnippet, highlightMatches, escapeHtml } from './snippets.js'
|
|
26
|
+
export { useSearch, useSearchIndex } from './hooks.js'
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snippet and Highlighting Utilities
|
|
3
|
+
*
|
|
4
|
+
* Functions for generating search result snippets with highlighted matches.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Escape HTML special characters
|
|
9
|
+
* @param {string} str - String to escape
|
|
10
|
+
* @returns {string} Escaped string
|
|
11
|
+
*/
|
|
12
|
+
export function escapeHtml(str) {
|
|
13
|
+
if (!str) return ''
|
|
14
|
+
return String(str)
|
|
15
|
+
.replace(/&/g, '&')
|
|
16
|
+
.replace(/</g, '<')
|
|
17
|
+
.replace(/>/g, '>')
|
|
18
|
+
.replace(/"/g, '"')
|
|
19
|
+
.replace(/'/g, ''')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Build a snippet from content with optional match highlighting
|
|
24
|
+
*
|
|
25
|
+
* @param {string} text - Full text content
|
|
26
|
+
* @param {Array} matches - Fuse.js matches array
|
|
27
|
+
* @param {Object} options - Options
|
|
28
|
+
* @param {string} [options.key='content'] - Key to find matches for
|
|
29
|
+
* @param {number} [options.maxLength=160] - Maximum snippet length
|
|
30
|
+
* @param {number} [options.contextChars=60] - Characters before/after match
|
|
31
|
+
* @param {string} [options.highlightTag='mark'] - HTML tag for highlights
|
|
32
|
+
* @returns {{ text: string, html: string }} Plain text and HTML snippets
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* const snippet = buildSnippet(
|
|
36
|
+
* 'This is a long text about authentication and security.',
|
|
37
|
+
* matches,
|
|
38
|
+
* { key: 'content' }
|
|
39
|
+
* )
|
|
40
|
+
* // snippet.html contains '<mark>authentication</mark>' highlighted
|
|
41
|
+
*/
|
|
42
|
+
export function buildSnippet(text, matches, options = {}) {
|
|
43
|
+
const {
|
|
44
|
+
key = 'content',
|
|
45
|
+
maxLength = 160,
|
|
46
|
+
contextChars = 60,
|
|
47
|
+
highlightTag = 'mark'
|
|
48
|
+
} = options
|
|
49
|
+
|
|
50
|
+
if (!text) {
|
|
51
|
+
return { text: '', html: '' }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Find matches for the specified key
|
|
55
|
+
const keyMatch = matches?.find(match => match.key === key)
|
|
56
|
+
|
|
57
|
+
// No matches - return truncated content
|
|
58
|
+
if (!keyMatch || !keyMatch.indices || keyMatch.indices.length === 0) {
|
|
59
|
+
const snippet = text.slice(0, maxLength)
|
|
60
|
+
const suffix = text.length > maxLength ? '…' : ''
|
|
61
|
+
return {
|
|
62
|
+
text: snippet + suffix,
|
|
63
|
+
html: escapeHtml(snippet) + suffix
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Get the first match position
|
|
68
|
+
const [firstStart, firstEnd] = keyMatch.indices[0]
|
|
69
|
+
|
|
70
|
+
// Calculate slice boundaries around the first match
|
|
71
|
+
const sliceStart = Math.max(0, firstStart - contextChars)
|
|
72
|
+
const sliceEnd = Math.min(text.length, firstEnd + contextChars + 1)
|
|
73
|
+
|
|
74
|
+
// Extract the snippet
|
|
75
|
+
const snippet = text.slice(sliceStart, sliceEnd)
|
|
76
|
+
|
|
77
|
+
// Adjust match indices to be relative to the snippet
|
|
78
|
+
const adjustedIndices = keyMatch.indices
|
|
79
|
+
.map(([start, end]) => [start - sliceStart, end - sliceStart])
|
|
80
|
+
.filter(([start, end]) => end >= 0 && start < snippet.length)
|
|
81
|
+
|
|
82
|
+
// Build highlighted HTML
|
|
83
|
+
const html = highlightMatches(snippet, adjustedIndices, { tag: highlightTag })
|
|
84
|
+
|
|
85
|
+
// Add ellipsis indicators
|
|
86
|
+
const prefix = sliceStart > 0 ? '…' : ''
|
|
87
|
+
const suffix = sliceEnd < text.length ? '…' : ''
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
text: `${prefix}${snippet}${suffix}`,
|
|
91
|
+
html: `${prefix}${html}${suffix}`
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Highlight matches in text using HTML tags
|
|
97
|
+
*
|
|
98
|
+
* @param {string} text - Text to highlight
|
|
99
|
+
* @param {Array<[number, number]>} indices - Array of [start, end] index pairs
|
|
100
|
+
* @param {Object} options - Options
|
|
101
|
+
* @param {string} [options.tag='mark'] - HTML tag to use
|
|
102
|
+
* @param {string} [options.className] - Optional CSS class for the tag
|
|
103
|
+
* @returns {string} HTML string with highlighted matches
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* highlightMatches('hello world', [[0, 4]], { tag: 'mark' })
|
|
107
|
+
* // Returns: '<mark>hello</mark> world'
|
|
108
|
+
*/
|
|
109
|
+
export function highlightMatches(text, indices, options = {}) {
|
|
110
|
+
const { tag = 'mark', className } = options
|
|
111
|
+
|
|
112
|
+
if (!text || !indices || indices.length === 0) {
|
|
113
|
+
return escapeHtml(text || '')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Sort indices by start position
|
|
117
|
+
const sortedIndices = [...indices].sort((a, b) => a[0] - b[0])
|
|
118
|
+
|
|
119
|
+
// Build HTML string
|
|
120
|
+
let cursor = 0
|
|
121
|
+
let html = ''
|
|
122
|
+
const openTag = className ? `<${tag} class="${className}">` : `<${tag}>`
|
|
123
|
+
const closeTag = `</${tag}>`
|
|
124
|
+
|
|
125
|
+
for (const [start, end] of sortedIndices) {
|
|
126
|
+
// Ensure valid bounds
|
|
127
|
+
const safeStart = Math.max(0, Math.min(start, text.length))
|
|
128
|
+
const safeEnd = Math.max(0, Math.min(end + 1, text.length))
|
|
129
|
+
|
|
130
|
+
// Skip invalid ranges
|
|
131
|
+
if (safeStart >= safeEnd || safeStart < cursor) continue
|
|
132
|
+
|
|
133
|
+
// Add text before the match
|
|
134
|
+
if (safeStart > cursor) {
|
|
135
|
+
html += escapeHtml(text.slice(cursor, safeStart))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Add highlighted match
|
|
139
|
+
html += openTag + escapeHtml(text.slice(safeStart, safeEnd)) + closeTag
|
|
140
|
+
cursor = safeEnd
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Add remaining text
|
|
144
|
+
if (cursor < text.length) {
|
|
145
|
+
html += escapeHtml(text.slice(cursor))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return html
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Extract plain text from a search result for display
|
|
153
|
+
*
|
|
154
|
+
* @param {Object} result - Search result object
|
|
155
|
+
* @param {Object} options - Options
|
|
156
|
+
* @param {number} [options.maxLength=200] - Maximum text length
|
|
157
|
+
* @returns {string} Plain text excerpt
|
|
158
|
+
*/
|
|
159
|
+
export function getResultText(result, options = {}) {
|
|
160
|
+
const { maxLength = 200 } = options
|
|
161
|
+
|
|
162
|
+
// Prefer snippet, fall back to excerpt, then content
|
|
163
|
+
const text = result.snippetText || result.excerpt || result.content || ''
|
|
164
|
+
|
|
165
|
+
if (text.length <= maxLength) {
|
|
166
|
+
return text
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return text.slice(0, maxLength).trim() + '…'
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Format search result for display in a list
|
|
174
|
+
*
|
|
175
|
+
* @param {Object} result - Search result from createSearchClient.query()
|
|
176
|
+
* @returns {Object} Formatted result for UI display
|
|
177
|
+
*/
|
|
178
|
+
export function formatResultForDisplay(result) {
|
|
179
|
+
return {
|
|
180
|
+
id: result.id,
|
|
181
|
+
href: result.href,
|
|
182
|
+
title: result.title || result.pageTitle || 'Untitled',
|
|
183
|
+
subtitle: result.type === 'section' ? result.pageTitle : null,
|
|
184
|
+
text: result.snippetText || result.excerpt || '',
|
|
185
|
+
html: result.snippetHtml || escapeHtml(result.excerpt || ''),
|
|
186
|
+
type: result.type,
|
|
187
|
+
component: result.component
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export default buildSnippet
|