Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion apps/server/src/share/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,12 +322,34 @@ function register(router: Router) {

const searchContext = new SearchContext({ ancestorNoteId });
const searchResults = searchService.findResultsWithQuery(search, searchContext);

// Extract content snippets so the share viewer can show what matched.
// Snippet extraction reads each note's content and runs token highlighting, so we
// cap it to the first SNIPPET_LIMIT results (the share popout only renders 5).
// Results beyond the cap are still returned, just without a snippet.
const SNIPPET_LIMIT = 20;
const snippetTargets = searchResults.slice(0, SNIPPET_LIMIT);
for (const result of snippetTargets) {
result.contentSnippet = searchService.extractContentSnippet(result.noteId, searchContext.highlightedTokens);
}
// highlightSearchResults wraps matched tokens in <b>...</b> on highlightedContentSnippet.
// We pass ignoreInternalAttributes=true to match the share UX (no internal attrs).
searchService.highlightSearchResults(snippetTargets, searchContext.highlightedTokens, true);

const filteredResults = searchResults.map((sr) => {
const fullNote = shaca.notes[sr.noteId];
const startIndex = sr.notePathArray.indexOf(ancestorNoteId);
const localPathArray = sr.notePathArray.slice(startIndex + 1).filter((id) => shaca.notes[id]);
const pathTitle = localPathArray.map((id) => shaca.notes[id].title).join(" / ");
return { id: fullNote.shareId, title: fullNote.title, score: sr.score, path: pathTitle };
return {
id: fullNote.shareId,
title: fullNote.title,
score: sr.score,
path: pathTitle,
// Plain-text fallback and the <b>-highlighted variant produced by the search service.
snippet: sr.contentSnippet,
highlightedSnippet: sr.highlightedContentSnippet
};
});

res.json({ results: filteredResults });
Expand Down
81 changes: 74 additions & 7 deletions packages/share-theme/src/scripts/modules/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,77 @@ interface SearchResult {
title: string;
score?: number;
path: string;
/** Plain-text snippet of the matching content. */
snippet?: string;
/** HTML snippet with matched tokens wrapped in <b>...</b>. Pre-sanitized by the server. */
highlightedSnippet?: string;
}

/** Escape user-supplied/match text before injecting into innerHTML. */
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) => (
c === "&" ? "&amp;" :
c === "<" ? "&lt;" :
c === ">" ? "&gt;" :
c === "\"" ? "&quot;" :
"&#39;"
));
}

function buildResultItem(result: SearchResult) {
// Prefer the server-rendered highlighted snippet (it only contains <b>/<br> tags that
// the search service inserts). For static (Fuse) mode we build a plain snippet below
// and the highlight pass wraps matched substrings in <b>.
const snippetHtml = result.highlightedSnippet ?? (result.snippet ? escapeHtml(result.snippet) : "");
const snippetBlock = snippetHtml
? `<div class="search-result-snippet">${snippetHtml}</div>`
: "";
return `<a class="search-result-item" href="./${result.id}">
<div class="search-result-title">${result.title}</div>
<div class="search-result-note">${result.path || "Home"}</div>
<div class="search-result-title">${escapeHtml(result.title)}</div>
<div class="search-result-note">${escapeHtml(result.path || "Home")}</div>
${snippetBlock}
</a>`;
}

/**
* Build a content snippet around the first Fuse match for the static-export search index.
* Returns an HTML string with the matched ranges wrapped in <b>...</b>.
*/
function buildStaticSnippet(content: string | undefined, matches: ReadonlyArray<{ key?: string; indices: ReadonlyArray<[number, number]>; }> | undefined, maxLength = 160): string | undefined {
if (!content) return undefined;
const contentMatch = matches?.find((m) => m.key === "content" && m.indices && m.indices.length > 0);
if (!contentMatch) {
// No content match (e.g. matched only on title) — return a small head-of-content preview.
const head = content.slice(0, maxLength).trim();
return head ? escapeHtml(head) + (content.length > maxLength ? "\u2026" : "") : undefined;
}

// Centre the window on the first match and wrap every match-range that falls inside it.
const [firstStart] = contentMatch.indices[0];
const half = Math.floor(maxLength / 2);
let from = Math.max(0, firstStart - half);
let to = Math.min(content.length, from + maxLength);
// Re-extend left if we hit the right end early.
from = Math.max(0, to - maxLength);

// Build the snippet by walking the window and inserting <b>...</b> around any
// match-range that intersects it. Indices are inclusive on both ends per Fuse.
let out = "";
let cursor = from;
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;
}
Comment on lines +83 to +91

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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:

  1. Filtering the indices to only those intersecting the snippet window before mapping.
  2. 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;
    }

if (cursor < to) out += escapeHtml(content.slice(cursor, to));

return (from > 0 ? "\u2026" : "") + out + (to < content.length ? "\u2026" : "");
}


export default function setupSearch() {
const searchInput: HTMLInputElement | null = document.querySelector(".search-input");
Expand Down Expand Up @@ -78,6 +140,7 @@ async function fetchResults(query: string): Promise<SearchResults> {
"content"
],
includeScore: true,
includeMatches: true,
threshold: 0.65,
ignoreDiacritics: true,
ignoreLocation: true,
Expand All @@ -89,11 +152,15 @@ async function fetchResults(query: string): Promise<SearchResults> {
// Do the search.
const results = fuseInstance.search(query, { limit: 5 });
console.debug("Search results:", results);
const processedResults = results.map(({ item, score }) => ({
...item,
id: rootUrl + "/" + item.id,
score
}));
const processedResults = results.map(({ item, score, matches }) => {
const highlightedSnippet = buildStaticSnippet((item as SearchResult & { content?: string }).content, matches as any);
return {
...item,
id: rootUrl + "/" + item.id,
score,
highlightedSnippet
};
});
return { results: processedResults };
} else {
const ancestor = document.body.dataset.ancestorNoteId;
Expand Down
28 changes: 28 additions & 0 deletions packages/share-theme/src/styles/popouts/search.css
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,32 @@
.search-result-note {
font-size: 12px;
color: var(--text-menu);
}

.search-result-snippet {
font-size: 12px;
color: var(--text-menu);
margin-top: 2px;
/* Clamp to two lines so long matches don't blow up the popout. */
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
white-space: normal;
line-height: 1.35;
}

.search-result-snippet b {
color: var(--text-primary);
font-weight: 600;
}

.search-result-item:hover .search-result-snippet {
color: var(--text-menu-active);
}

.search-result-item:hover .search-result-snippet b {
color: var(--text-menu-active);
}
Loading