feat(share): show matched content snippet in share search results#9962
feat(share): show matched content snippet in share search results#9962perfectra1n wants to merge 2 commits into
Conversation
The share search popout previously rendered only the title and path of each hit, so users had no way to tell *what* in the note matched their query (especially when the title alone did not contain the term). Server-side dynamic share search: - /share/api/notes now calls the existing extractContentSnippet + highlightSearchResults helpers (already used by the in-app autocomplete) for the top 20 results, and returns the plain-text snippet plus an HTML-highlighted variant where matched tokens are wrapped in <b>...</b>. Static (Fuse) share-export search: - The Fuse instance now opts in to includeMatches, and the client builds a windowed snippet centred on the first content match with matched ranges wrapped in <b>...</b>. Share-theme client: - buildResultItem renders a new .search-result-snippet line under the path. All user-supplied strings (title, path, snippet text) are now HTML-escaped before injection. The server-rendered highlighted snippet is already sanitized by the search service (only <b> / <br> tags) and is inserted as-is. - New .search-result-snippet styling: 12px, two-line clamp, bolded match runs that recolor on hover so highlights stay legible against the active-row background.
There was a problem hiding this comment.
Code Review
This pull request introduces search result content snippets with highlighted matching tokens for both the server-rendered share viewer and the static-export search index (using Fuse.js). It adds HTML escaping, snippet extraction, and CSS styling to display the matched snippets cleanly. A review comment identifies a bug in the static snippet generation where overlapping or adjacent match ranges can cause duplicated characters in the output, suggesting a range-merging algorithm to resolve the issue.
| const ranges = contentMatch.indices | ||
| .map(([s, e]) => [Math.max(s, from), Math.min(e + 1, to)] as [number, number]) | ||
| .filter(([s, e]) => e > s) | ||
| .sort((a, b) => a[0] - b[0]); | ||
| for (const [s, e] of ranges) { | ||
| if (s > cursor) out += escapeHtml(content.slice(cursor, s)); | ||
| out += `<b>${escapeHtml(content.slice(s, e))}</b>`; | ||
| cursor = e; | ||
| } |
There was a problem hiding this comment.
The current implementation of snippet generation has a correctness bug when match ranges overlap or are adjacent. Because it processes each range independently without merging, any overlapping parts will result in duplicated characters in the rendered HTML snippet. Additionally, it maps and filters all match indices in the document, even those far outside the snippet window, which can be inefficient for large documents.
We can fix both issues by:
- Filtering the indices to only those intersecting the snippet window before mapping.
- Merging overlapping or adjacent ranges before rendering.
const ranges = contentMatch.indices
.filter(([s, e]) => s < to && e >= from)
.map(([s, e]) => [Math.max(s, from), Math.min(e + 1, to)] as [number, number])
.sort((a, b) => a[0] - b[0]);
const mergedRanges: [number, number][] = [];
for (const [s, e] of ranges) {
if (mergedRanges.length === 0) {
mergedRanges.push([s, e]);
} else {
const last = mergedRanges[mergedRanges.length - 1];
if (s <= last[1]) {
last[1] = Math.max(last[1], e);
} else {
mergedRanges.push([s, e]);
}
}
}
for (const [s, e] of mergedRanges) {
if (s > cursor) out += escapeHtml(content.slice(cursor, s));
out += `<b>${escapeHtml(content.slice(s, e))}</b>`;
cursor = e;
}
Summary
When you "share" notes from Trilium as a static site (or via the dynamic share viewer) and use the search box, the result list shows only the note title and its path. If the search term doesn't appear in the title, there's no way to tell why a result matched — which made the search feel broken even when it was finding the right notes.
Example: searching for
Magnesiumon a shared subtree returns five hospital/SRI reports, none of whose titles contain "Magnesium". The user has no idea what each note matched on:This PR adds a content snippet under each result, with the matched terms bolded.
Changes
Server-side (dynamic share)
apps/server/src/share/routes.ts—/share/api/notesnow calls the existingsearchService.extractContentSnippet+searchService.highlightSearchResultshelpers (already used by the in-app autocomplete) for the top 20 results, and returns:snippet— plain-text snippethighlightedSnippet— HTML where matched tokens are wrapped in<b>...</b>Snippet extraction is capped at 20 results (the popout only renders 5) so the existing payload size for large result sets stays the same. Results beyond the cap are still returned, just without a snippet.
Static (Fuse) share-export
packages/share-theme/src/scripts/modules/search.ts— the Fuse instance now opts in toincludeMatches. The client builds a windowed snippet centred on the firstcontentmatch, wraps every match-range inside the window in<b>...</b>, and falls back to a head-of-content preview when only the title matched.Share-theme client
buildResultItemrenders a new.search-result-snippetline under the path.title,path, plainsnippet) are now HTML-escaped before injection. The server-rendered highlighted snippet is already sanitized by the search service (only<b>/<br>tags are inserted, and the service strips</>/{/}from the source content first) and is inserted as-is..search-result-snippetstyling inpackages/share-theme/src/styles/popouts/search.css: 12 px, two-line clamp, bolded match runs that recolor on hover so highlights stay legible against the active-row background.Notes
searchService.GET /share/api/notes?ancestorNoteId=...&search=magnesiumnow includessnippet/highlightedSnippet.search-index.jsonis unchanged and the popout shows highlighted snippets from the Fusematchespayload.