@uniweb/kit 0.9.43 → 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.43",
3
+ "version": "0.9.44",
4
4
  "description": "Standard component library for Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -41,10 +41,43 @@ const DEFAULT_FUSE_OPTIONS = {
41
41
  ],
42
42
  threshold: 0.35,
43
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,
44
48
  ignoreLocation: true,
45
49
  minMatchCharLength: 2
46
50
  }
47
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
+
48
81
  /**
49
82
  * Get localStorage safely (handles SSR and access errors)
50
83
  * @returns {Storage|null}
@@ -292,6 +325,23 @@ export function createIndexProvider(website, options = {}) {
292
325
  results = results.filter(({ item }) => item.route?.startsWith(route))
293
326
  }
294
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
+
295
345
  return results.slice(0, limit).map(({ item, matches }) => {
296
346
  const snippet = buildSnippet(item.content, matches, { key: 'content' })
297
347