@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.42",
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.32",
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
- const STORAGE_VERSION = 'v1'
26
- const STORAGE_PREFIX = `uniweb:search:${STORAGE_VERSION}:`
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(`${STORAGE_PREFIX}${cacheKey}`)
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(`${STORAGE_PREFIX}${cacheKey}`, JSON.stringify(payload))
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
- // Check memory cache first
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
- // Check localStorage cache
113
- if (useStorage) {
114
- const cached = loadFromStorage(cacheKey)
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
- saveToStorage(cacheKey, payload)
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(`${STORAGE_PREFIX}${cacheKey}`)
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