@uniweb/kit 0.1.3 → 0.1.5

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,11 +1,12 @@
1
1
  {
2
2
  "name": "@uniweb/kit",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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.5"
39
+ "@uniweb/core": "0.1.8"
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"
@@ -1 +1,2 @@
1
1
  export { useWebsite, default } from './useWebsite.js'
2
+ export { useRouting } from './useRouting.js'
@@ -0,0 +1,119 @@
1
+ /**
2
+ * useRouting Hook
3
+ *
4
+ * Provides SSG-safe access to routing functionality.
5
+ *
6
+ * The runtime registers routing components (Link, useNavigate, useLocation, useParams)
7
+ * via the bridge pattern. This hook provides safe access that gracefully handles
8
+ * SSG/SSR contexts where the Router context isn't available.
9
+ *
10
+ * @example
11
+ * function NavItem({ route }) {
12
+ * const { useLocation } = useRouting()
13
+ * const location = useLocation()
14
+ * const isActive = location.pathname === route
15
+ * return <Link to={route} className={isActive ? 'active' : ''}>...</Link>
16
+ * }
17
+ */
18
+
19
+ import { getUniweb } from '@uniweb/core'
20
+
21
+ /**
22
+ * Default location object for SSG/SSR contexts
23
+ */
24
+ const DEFAULT_LOCATION = {
25
+ pathname: '/',
26
+ search: '',
27
+ hash: '',
28
+ state: null,
29
+ key: 'default'
30
+ }
31
+
32
+ /**
33
+ * Default params object for SSG/SSR contexts
34
+ */
35
+ const DEFAULT_PARAMS = {}
36
+
37
+ /**
38
+ * Get routing utilities with SSG-safe fallbacks
39
+ * @returns {Object} Routing utilities
40
+ */
41
+ export function useRouting() {
42
+ const uniweb = getUniweb()
43
+ const routing = uniweb?.routingComponents || {}
44
+
45
+ return {
46
+ /**
47
+ * SSG-safe useLocation hook
48
+ * Returns current location or defaults during SSG
49
+ * @returns {Object} Location object { pathname, search, hash, state, key }
50
+ */
51
+ useLocation: () => {
52
+ if (!routing.useLocation) {
53
+ return DEFAULT_LOCATION
54
+ }
55
+ try {
56
+ return routing.useLocation()
57
+ } catch {
58
+ // Router context not available (SSG/SSR)
59
+ return DEFAULT_LOCATION
60
+ }
61
+ },
62
+
63
+ /**
64
+ * SSG-safe useParams hook
65
+ * Returns route params or empty object during SSG
66
+ * @returns {Object} Params object
67
+ */
68
+ useParams: () => {
69
+ if (!routing.useParams) {
70
+ return DEFAULT_PARAMS
71
+ }
72
+ try {
73
+ return routing.useParams()
74
+ } catch {
75
+ // Router context not available (SSG/SSR)
76
+ return DEFAULT_PARAMS
77
+ }
78
+ },
79
+
80
+ /**
81
+ * SSG-safe useNavigate hook
82
+ * Returns navigate function or no-op during SSG
83
+ * @returns {Function} Navigate function
84
+ */
85
+ useNavigate: () => {
86
+ if (!routing.useNavigate) {
87
+ return () => {} // No-op during SSG
88
+ }
89
+ try {
90
+ return routing.useNavigate()
91
+ } catch {
92
+ // Router context not available (SSG/SSR)
93
+ return () => {}
94
+ }
95
+ },
96
+
97
+ /**
98
+ * Router Link component (or fallback to 'a')
99
+ * Use Kit's Link component instead for most cases
100
+ */
101
+ Link: routing.Link || 'a',
102
+
103
+ /**
104
+ * Check if routing is available (browser with Router context)
105
+ * @returns {boolean}
106
+ */
107
+ isRoutingAvailable: () => {
108
+ if (!routing.useLocation) return false
109
+ try {
110
+ routing.useLocation()
111
+ return true
112
+ } catch {
113
+ return false
114
+ }
115
+ }
116
+ }
117
+ }
118
+
119
+ export default useRouting
package/src/index.js CHANGED
@@ -52,7 +52,7 @@ export { Disclaimer } from './components/Disclaimer/index.js'
52
52
  // Hooks
53
53
  // ============================================================================
54
54
 
55
- export { useWebsite } from './hooks/index.js'
55
+ export { useWebsite, useRouting } from './hooks/index.js'
56
56
 
57
57
  // ============================================================================
58
58
  // Utilities
@@ -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, '&amp;')
16
+ .replace(/</g, '&lt;')
17
+ .replace(/>/g, '&gt;')
18
+ .replace(/"/g, '&quot;')
19
+ .replace(/'/g, '&#39;')
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