|
114 | 114 | } |
115 | 115 | ]; |
116 | 116 |
|
| 117 | + /** Rank a result for "Best match": 0 = title starts with query, 1 = title contains query, -1 = no match */ |
| 118 | + function bestMatchRank(result: SearchEntry, query: string): number { |
| 119 | + if (result.type === 'heading') return -1; |
| 120 | + const plainTitle = result.title.replace(/<[^>]*>/g, '').toLowerCase(); |
| 121 | + const q = query.toLowerCase().trim(); |
| 122 | + if (plainTitle.startsWith(q)) return 0; |
| 123 | + if (plainTitle.includes(q)) return 1; |
| 124 | + return -1; |
| 125 | + } |
| 126 | +
|
117 | 127 | // Convert search results to MenuOption format with grouping |
118 | 128 | const options = $derived.by((): SearchOption[] => { |
119 | 129 | // Show default options when no search query |
120 | 130 | if (!searchQuery) return defaultOptions; |
121 | 131 |
|
122 | 132 | if (!searchResults.length) return []; |
123 | 133 |
|
124 | | - // Sort by group order, then by parent slug (so headings appear after their parent page) |
125 | | - const sorted = [...searchResults].sort((a, b) => { |
126 | | - const aGroupType = getGroupType(a); |
127 | | - const bGroupType = getGroupType(b); |
| 134 | + // Split results into best matches (starts with, then contains) and the rest |
| 135 | + const startsWithMatches: SearchEntry[] = []; |
| 136 | + const containsMatches: SearchEntry[] = []; |
| 137 | + const rest: SearchEntry[] = []; |
| 138 | + for (const result of searchResults) { |
| 139 | + const rank = bestMatchRank(result, searchQuery); |
| 140 | + if (rank === 0) startsWithMatches.push(result); |
| 141 | + else if (rank === 1) containsMatches.push(result); |
| 142 | + else rest.push(result); |
| 143 | + } |
128 | 144 |
|
129 | | - // First sort by group |
130 | | - if (groupOrder[aGroupType] !== groupOrder[bGroupType]) { |
131 | | - return groupOrder[aGroupType] - groupOrder[bGroupType]; |
132 | | - } |
| 145 | + // Sort helper: by group order, then parent slug, then headings after parent |
| 146 | + function sortEntries(entries: SearchEntry[]) { |
| 147 | + return [...entries].sort((a, b) => { |
| 148 | + const aGroupType = getGroupType(a); |
| 149 | + const bGroupType = getGroupType(b); |
133 | 150 |
|
134 | | - // Within the same group, sort by parent slug so headings follow their parent |
135 | | - const aParentSlug = a.type === 'heading' ? a.parentSlug : a.slug; |
136 | | - const bParentSlug = b.type === 'heading' ? b.parentSlug : b.slug; |
| 151 | + if (groupOrder[aGroupType] !== groupOrder[bGroupType]) { |
| 152 | + return groupOrder[aGroupType] - groupOrder[bGroupType]; |
| 153 | + } |
137 | 154 |
|
138 | | - if (aParentSlug !== bParentSlug) { |
139 | | - return (aParentSlug ?? '').localeCompare(bParentSlug ?? ''); |
140 | | - } |
| 155 | + const aParentSlug = a.type === 'heading' ? a.parentSlug : a.slug; |
| 156 | + const bParentSlug = b.type === 'heading' ? b.parentSlug : b.slug; |
141 | 157 |
|
142 | | - // Parent pages come before their headings |
143 | | - if (a.type !== 'heading' && b.type === 'heading') return -1; |
144 | | - if (a.type === 'heading' && b.type !== 'heading') return 1; |
| 158 | + if (aParentSlug !== bParentSlug) { |
| 159 | + return (aParentSlug ?? '').localeCompare(bParentSlug ?? ''); |
| 160 | + } |
| 161 | +
|
| 162 | + if (a.type !== 'heading' && b.type === 'heading') return -1; |
| 163 | + if (a.type === 'heading' && b.type !== 'heading') return 1; |
| 164 | +
|
| 165 | + return 0; |
| 166 | + }); |
| 167 | + } |
145 | 168 |
|
146 | | - return 0; |
147 | | - }); |
| 169 | + const bestMatches = [...sortEntries(startsWithMatches), ...sortEntries(containsMatches)]; |
| 170 | + const sorted = [...bestMatches, ...sortEntries(rest)]; |
148 | 171 |
|
149 | 172 | // Convert to MenuOption format, deduplicating by slug |
150 | 173 | const seen = new Set<string>(); |
151 | 174 | const opts: SearchOption[] = []; |
152 | 175 | for (const result of sorted) { |
153 | 176 | if (seen.has(result.slug)) continue; |
154 | 177 | seen.add(result.slug); |
| 178 | + const isBest = bestMatches.includes(result); |
155 | 179 | opts.push({ |
156 | 180 | label: result.title, |
157 | 181 | value: result.slug, |
158 | | - group: groupLabels[getGroupType(result)], |
| 182 | + group: isBest ? 'Best match' : groupLabels[getGroupType(result)], |
159 | 183 | result |
160 | 184 | }); |
161 | 185 | } |
|
0 commit comments