@uniweb/kit 0.9.42 → 0.9.44
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.
|
|
3
|
+
"version": "0.9.44",
|
|
4
4
|
"description": "Standard component library for Uniweb foundations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"fuse.js": "^7.0.0",
|
|
41
41
|
"shiki": "^3.0.0",
|
|
42
42
|
"tailwind-merge": "^3.6.0",
|
|
43
|
-
"@uniweb/core": "0.7.
|
|
43
|
+
"@uniweb/core": "0.7.33",
|
|
44
44
|
"@uniweb/scene": "0.1.2"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
@@ -22,8 +22,12 @@ import { emptyResult } from './result.js'
|
|
|
22
22
|
const indexCache = new Map()
|
|
23
23
|
const fuseCache = new Map()
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
// Bumped to v2 when stored entries gained a validator. The version is part of
|
|
26
|
+
// the key, so every v1 entry — which had no way to be revalidated and could
|
|
27
|
+
// outlive any number of rebuilds — is orphaned rather than trusted.
|
|
28
|
+
const STORAGE_VERSION = 'v2'
|
|
29
|
+
const STORAGE_PREFIX = 'uniweb:search:'
|
|
30
|
+
const STORAGE_KEY = (cacheKey) => `${STORAGE_PREFIX}${STORAGE_VERSION}:${cacheKey}`
|
|
27
31
|
|
|
28
32
|
/**
|
|
29
33
|
* Default Fuse.js options optimized for site search
|
|
@@ -37,10 +41,43 @@ const DEFAULT_FUSE_OPTIONS = {
|
|
|
37
41
|
],
|
|
38
42
|
threshold: 0.35,
|
|
39
43
|
includeMatches: true,
|
|
44
|
+
// Exposed so ranking is inspectable, and so a foundation that wants its own
|
|
45
|
+
// ordering has something to order by. Fuse omits `score` entirely without it,
|
|
46
|
+
// which is why nothing could be ranked on relevance before.
|
|
47
|
+
includeScore: true,
|
|
40
48
|
ignoreLocation: true,
|
|
41
49
|
minMatchCharLength: 2
|
|
42
50
|
}
|
|
43
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Does this entry literally contain every word of the query?
|
|
54
|
+
*
|
|
55
|
+
* Fuse is approximate by design, and on fields the size of a whole page that
|
|
56
|
+
* turns into a real problem: searching "inset" on a documentation site
|
|
57
|
+
* returned 68 hits of which 4 contained the word, and all four ranked below
|
|
58
|
+
* the tenth result — so every result the reader saw was a near-miss on a page
|
|
59
|
+
* that never mentions the term.
|
|
60
|
+
*
|
|
61
|
+
* Fuzzy is the right FALLBACK — it is what tolerates a typo — but it must not
|
|
62
|
+
* outrank an exact match. Checking containment directly is the cheap, honest
|
|
63
|
+
* signal Fuse's score does not provide here.
|
|
64
|
+
*
|
|
65
|
+
* Every token must appear, so "Inset Components" does not match a page that
|
|
66
|
+
* merely says "components". Matching is substring-based rather than
|
|
67
|
+
* word-boundary so that "inset" still finds "insets".
|
|
68
|
+
*/
|
|
69
|
+
function literalTier(item, tokens) {
|
|
70
|
+
if (!tokens.length) return 2
|
|
71
|
+
|
|
72
|
+
const title = `${item.title || ''} ${item.pageTitle || ''}`.toLowerCase()
|
|
73
|
+
if (tokens.every((t) => title.includes(t))) return 0
|
|
74
|
+
|
|
75
|
+
const body = `${title} ${item.content || ''} ${item.excerpt || ''}`.toLowerCase()
|
|
76
|
+
if (tokens.every((t) => body.includes(t))) return 1
|
|
77
|
+
|
|
78
|
+
return 2
|
|
79
|
+
}
|
|
80
|
+
|
|
44
81
|
/**
|
|
45
82
|
* Get localStorage safely (handles SSR and access errors)
|
|
46
83
|
* @returns {Storage|null}
|
|
@@ -63,14 +100,12 @@ function loadFromStorage(cacheKey) {
|
|
|
63
100
|
const storage = getStorage()
|
|
64
101
|
if (!storage) return null
|
|
65
102
|
|
|
66
|
-
const raw = storage.getItem(
|
|
103
|
+
const raw = storage.getItem(STORAGE_KEY(cacheKey))
|
|
67
104
|
if (!raw) return null
|
|
68
105
|
|
|
69
106
|
try {
|
|
70
107
|
const parsed = JSON.parse(raw)
|
|
71
|
-
if (Array.isArray(parsed.entries))
|
|
72
|
-
return parsed
|
|
73
|
-
}
|
|
108
|
+
if (Array.isArray(parsed.payload?.entries)) return parsed
|
|
74
109
|
return null
|
|
75
110
|
} catch {
|
|
76
111
|
return null
|
|
@@ -78,21 +113,52 @@ function loadFromStorage(cacheKey) {
|
|
|
78
113
|
}
|
|
79
114
|
|
|
80
115
|
/**
|
|
81
|
-
* Save index to localStorage
|
|
116
|
+
* Save index to localStorage, together with the validator that lets a later
|
|
117
|
+
* load ask the server whether it is still current.
|
|
118
|
+
*
|
|
82
119
|
* @param {string} cacheKey - Cache key
|
|
83
120
|
* @param {Object} payload - Index data
|
|
121
|
+
* @param {{etag?: string|null, lastModified?: string|null}} [validator]
|
|
84
122
|
*/
|
|
85
|
-
function saveToStorage(cacheKey, payload) {
|
|
123
|
+
function saveToStorage(cacheKey, payload, validator = {}) {
|
|
86
124
|
const storage = getStorage()
|
|
87
125
|
if (!storage) return
|
|
88
126
|
|
|
89
127
|
try {
|
|
90
|
-
storage.setItem(
|
|
128
|
+
storage.setItem(
|
|
129
|
+
STORAGE_KEY(cacheKey),
|
|
130
|
+
JSON.stringify({
|
|
131
|
+
payload,
|
|
132
|
+
etag: validator.etag || null,
|
|
133
|
+
lastModified: validator.lastModified || null,
|
|
134
|
+
})
|
|
135
|
+
)
|
|
91
136
|
} catch {
|
|
92
137
|
// Ignore quota errors
|
|
93
138
|
}
|
|
94
139
|
}
|
|
95
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Drop entries written by an older storage schema.
|
|
143
|
+
*
|
|
144
|
+
* A search index runs to hundreds of kilobytes, so an orphaned one is a real
|
|
145
|
+
* bite out of a origin's storage quota rather than a tidiness issue.
|
|
146
|
+
*/
|
|
147
|
+
function pruneOldVersions() {
|
|
148
|
+
const storage = getStorage()
|
|
149
|
+
if (!storage) return
|
|
150
|
+
|
|
151
|
+
const current = `${STORAGE_PREFIX}${STORAGE_VERSION}:`
|
|
152
|
+
const stale = []
|
|
153
|
+
for (let i = 0; i < storage.length; i++) {
|
|
154
|
+
const key = storage.key(i)
|
|
155
|
+
if (key?.startsWith(STORAGE_PREFIX) && !key.startsWith(current)) stale.push(key)
|
|
156
|
+
}
|
|
157
|
+
stale.forEach((key) => {
|
|
158
|
+
try { storage.removeItem(key) } catch { /* ignore */ }
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
96
162
|
/**
|
|
97
163
|
* Load search index for a locale
|
|
98
164
|
* @param {string} indexUrl - URL to fetch the index from
|
|
@@ -104,32 +170,72 @@ function saveToStorage(cacheKey, payload) {
|
|
|
104
170
|
export async function loadSearchIndex(indexUrl, options = {}) {
|
|
105
171
|
const { cacheKey = indexUrl, useStorage = true } = options
|
|
106
172
|
|
|
107
|
-
//
|
|
173
|
+
// Memory cache: scoped to one page load, where the index cannot change
|
|
174
|
+
// underneath us. This is the only cache read that needs no validation.
|
|
108
175
|
if (indexCache.has(cacheKey)) {
|
|
109
176
|
return indexCache.get(cacheKey)
|
|
110
177
|
}
|
|
111
178
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
179
|
+
if (useStorage) pruneOldVersions()
|
|
180
|
+
const cached = useStorage ? loadFromStorage(cacheKey) : null
|
|
181
|
+
|
|
182
|
+
// ALWAYS ask the server, even holding a stored copy.
|
|
183
|
+
//
|
|
184
|
+
// Returning the stored index unconditionally — what this did before — has no
|
|
185
|
+
// expiry and no way to notice a rebuild, so a visitor who searched once kept
|
|
186
|
+
// answering from that index for as long as the entry survived. A redeploy
|
|
187
|
+
// did not dislodge it.
|
|
188
|
+
//
|
|
189
|
+
// It also collided across sites. The key is the index URL, which is
|
|
190
|
+
// `/search-index.json` for every Uniweb project, and localStorage is scoped
|
|
191
|
+
// to an origin — so two projects sharing a dev port shared one entry, and a
|
|
192
|
+
// search on one could return the other's pages. Revalidating settles that
|
|
193
|
+
// too: a different server answers 200 with its own index and replaces it.
|
|
194
|
+
const headers = {}
|
|
195
|
+
if (cached?.etag) headers['If-None-Match'] = cached.etag
|
|
196
|
+
else if (cached?.lastModified) headers['If-Modified-Since'] = cached.lastModified
|
|
197
|
+
|
|
198
|
+
let response
|
|
199
|
+
try {
|
|
200
|
+
// `no-cache` = revalidate, not "don't cache". `force-cache` (the previous
|
|
201
|
+
// value) told the browser to prefer any stored response regardless of age,
|
|
202
|
+
// which defeated revalidation a second time over.
|
|
203
|
+
response = await fetch(indexUrl, { headers, cache: 'no-cache' })
|
|
204
|
+
} catch (error) {
|
|
205
|
+
// Offline or unreachable. A stored index is better than no search at all,
|
|
206
|
+
// and this is the one path where serving it unvalidated is the right call.
|
|
115
207
|
if (cached) {
|
|
116
|
-
indexCache.set(cacheKey, cached)
|
|
117
|
-
return cached
|
|
208
|
+
indexCache.set(cacheKey, cached.payload)
|
|
209
|
+
return cached.payload
|
|
118
210
|
}
|
|
211
|
+
throw error
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Unchanged since we stored it — the whole point of keeping the validator.
|
|
215
|
+
if (response.status === 304 && cached) {
|
|
216
|
+
indexCache.set(cacheKey, cached.payload)
|
|
217
|
+
return cached.payload
|
|
119
218
|
}
|
|
120
219
|
|
|
121
|
-
// Fetch from server
|
|
122
|
-
const response = await fetch(indexUrl, { cache: 'force-cache' })
|
|
123
220
|
if (!response.ok) {
|
|
221
|
+
if (cached) {
|
|
222
|
+
indexCache.set(cacheKey, cached.payload)
|
|
223
|
+
return cached.payload
|
|
224
|
+
}
|
|
124
225
|
throw new Error(`Failed to load search index: ${response.status}`)
|
|
125
226
|
}
|
|
126
227
|
|
|
127
228
|
const payload = await response.json()
|
|
128
229
|
|
|
129
|
-
// Cache the result
|
|
130
230
|
indexCache.set(cacheKey, payload)
|
|
131
231
|
if (useStorage) {
|
|
132
|
-
|
|
232
|
+
// A host that sends no validator still gets stored — it just costs a full
|
|
233
|
+
// fetch next time instead of a 304. What it never does is get served as
|
|
234
|
+
// though it were known to be current.
|
|
235
|
+
saveToStorage(cacheKey, payload, {
|
|
236
|
+
etag: response.headers?.get?.('etag'),
|
|
237
|
+
lastModified: response.headers?.get?.('last-modified'),
|
|
238
|
+
})
|
|
133
239
|
}
|
|
134
240
|
|
|
135
241
|
return payload
|
|
@@ -145,7 +251,7 @@ export function clearSearchCache(cacheKey) {
|
|
|
145
251
|
fuseCache.delete(cacheKey)
|
|
146
252
|
const storage = getStorage()
|
|
147
253
|
if (storage) {
|
|
148
|
-
storage.removeItem(
|
|
254
|
+
storage.removeItem(STORAGE_KEY(cacheKey))
|
|
149
255
|
}
|
|
150
256
|
} else {
|
|
151
257
|
indexCache.clear()
|
|
@@ -219,6 +325,23 @@ export function createIndexProvider(website, options = {}) {
|
|
|
219
325
|
results = results.filter(({ item }) => item.route?.startsWith(route))
|
|
220
326
|
}
|
|
221
327
|
|
|
328
|
+
// Rank pages that actually contain the words above Fuse's near-misses.
|
|
329
|
+
//
|
|
330
|
+
// Fuse sorts by its own score, which on page-sized content fields rates
|
|
331
|
+
// an approximate match as highly as an exact one — so a page containing
|
|
332
|
+
// the search term could sit below ten pages that never mention it, and
|
|
333
|
+
// the reader concludes the site has nothing on the subject.
|
|
334
|
+
//
|
|
335
|
+
// A stable sort by tier keeps Fuse's ordering *within* each tier, so
|
|
336
|
+
// relevance still decides among equals and the fuzzy tail is preserved
|
|
337
|
+
// rather than discarded. Applied before `limit`, because the cutoff is
|
|
338
|
+
// exactly where the problem showed up.
|
|
339
|
+
const tokens = String(text || '').toLowerCase().split(/\s+/).filter(Boolean)
|
|
340
|
+
results = results
|
|
341
|
+
.map((r, i) => ({ r, i, tier: literalTier(r.item, tokens) }))
|
|
342
|
+
.sort((a, b) => a.tier - b.tier || a.i - b.i)
|
|
343
|
+
.map(({ r }) => r)
|
|
344
|
+
|
|
222
345
|
return results.slice(0, limit).map(({ item, matches }) => {
|
|
223
346
|
const snippet = buildSnippet(item.content, matches, { key: 'content' })
|
|
224
347
|
|