feat(plugins): let a plugin search result's snippet mark what matched

An entry from onProvideSearchResults may wrap what matched in <mark>, as
SearchSnippet/get does, and the block above the message list highlights
it with the list's own SearchSnippetText. The host keeps the marks,
escapes everything else and cuts the snippet by its text, closing a mark
it cuts, so the snippet is still never rendered as HTML.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jacob Zeitz
2026-10-29 10:03:27 +01:00
co-authored by Claude Opus 5.5
parent 99b6857d90
commit fa4db60032
5 changed files with 77 additions and 4 deletions
@@ -72,6 +72,19 @@ describe('PluginSearchResults', () => {
expect(screen.getByRole('region', { name: 'Team chat' })).toHaveTextContent('Snippet b');
});
it('highlights what a snippet marks, its other text as text', () => {
useEmailStore.setState({
searchQuery: 'pixel grid',
externalSearchResults: [{ ...result('crm', 'a', 'CRM'), snippet: 'The <mark>pixel grid</mark> is &lt;b&gt;coarse&lt;/b&gt;' }],
});
render(<PluginSearchResults surface="search-results" />);
const mark = screen.getByText('pixel grid');
expect(mark.tagName).toBe('MARK');
expect(mark.parentElement).toHaveTextContent('The pixel grid is <b>coarse</b>');
expect(mark.parentElement?.querySelector('b')).toBeNull();
});
it('sends a click to the plugin that gave the entry, with the query', () => {
useEmailStore.setState({ searchQuery: 'resolution', externalSearchResults: [result('crm', 'mail-123', 'CRM')] });
render(<PluginSearchResults surface="search-results" />);
+6 -1
View File
@@ -2,6 +2,7 @@
import { useId, useMemo, useState } from "react";
import { ChevronDown, ChevronRight } from "@/components/icons";
import { SearchSnippetText } from "@/components/email/search-snippet-text";
import { cn } from "@/lib/utils";
import { isFilterEmpty } from "@/lib/jmap/search-utils";
import { runPluginAction } from "@/lib/plugin-sandbox/actions";
@@ -153,7 +154,11 @@ export function PluginSearchResults({ surface, onOpenEmail }: PluginSearchResult
)}
>
<span className="w-full truncate text-sm font-medium text-foreground">{r.title}</span>
{r.snippet && <span className="line-clamp-2 text-xs text-muted-foreground">{r.snippet}</span>}
{r.snippet && (
<span className="line-clamp-2 text-xs text-muted-foreground">
<SearchSnippetText snippet={r.snippet} />
</span>
)}
</button>
</li>
);
@@ -40,6 +40,24 @@ describe('checkSearchResults', () => {
expect(checked.every((r) => !('email' in r))).toBe(true);
});
it('keeps the marks of a snippet and escapes everything else', () => {
const [entry] = checkSearchResults('p', [
{ key: 'a', title: 'A', snippet: ' The <mark>pixel\n grid</mark> &amp; <b>more</b> <mark></mark>' },
]);
expect(entry.snippet).toBe('The <mark>pixel grid</mark> &amp; &lt;b&gt;more&lt;/b&gt;');
});
it('escapes a plain snippet, so it reads back as written', () => {
expect(checkSearchResults('p', [{ key: 'a', title: 'A', snippet: 'Tom & Jerry <3' }])[0].snippet)
.toBe('Tom &amp; Jerry &lt;3');
});
it('cuts a snippet by its text, closing a mark it cuts', () => {
const snippet = `${'x'.repeat(290)}<mark>${'y'.repeat(20)}</mark>`;
const [entry] = checkSearchResults('p', [{ key: 'a', title: 'A', snippet }]);
expect(entry.snippet).toBe(`${'x'.repeat(290)}<mark>${'y'.repeat(9)}</mark>…`);
});
it('ignores an answer that is not a list', () => {
expect(checkSearchResults('p', { key: 'a', title: 'A' })).toEqual([]);
expect(checkSearchResults('p', undefined)).toEqual([]);
+33 -2
View File
@@ -7,6 +7,7 @@
import { emailHooks } from './plugin-hooks';
import type { ExternalSearchResult, SearchResultEmail } from './plugin-types';
import { parseSearchSnippet } from './search-snippet';
/** An entry as the host keeps it: checked, with the plugin that gave it. */
export interface ProvidedSearchResult extends ExternalSearchResult {
@@ -28,6 +29,36 @@ function text(value: unknown, max: number): string {
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
}
/** `&`, `<` and `>` escaped as SearchSnippet/get escapes them, so parseSearchSnippet reads the text back as written. */
function escaped(value: string): string {
return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/**
* A snippet in SearchSnippet/get's form: text with `<mark>` around what
* matched. Flattened and cut to `max` characters of its text as text() cuts,
* each mark kept and closed, everything else escaped, so the list renders it
* with parseSearchSnippet and never as HTML.
*/
function markedText(value: unknown, max: number): string {
if (typeof value !== 'string') return '';
const segments = parseSearchSnippet(value.replace(/\s+/g, ' ')).filter((segment) => segment.text);
if (segments.length > 0) {
segments[0] = { ...segments[0], text: segments[0].text.trimStart() };
const last = segments.length - 1;
segments[last] = { ...segments[last], text: segments[last].text.trimEnd() };
}
const length = segments.reduce((n, segment) => n + segment.text.length, 0);
let left = length > max ? max - 1 : length;
let out = '';
for (const { text: part, marked } of segments) {
const kept = part.slice(0, left);
left -= kept.length;
if (kept) out += marked ? `<mark>${escaped(kept)}</mark>` : escaped(kept);
}
return length > max ? `${out}…` : out;
}
function jmapId(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 && value.length <= MAX_ID ? value : null;
}
@@ -45,7 +76,7 @@ function email(value: unknown): SearchResultEmail | null {
/**
* One plugin's answer, checked: a list of entries with a key and a title,
* plain text cut to its limits, keys unique, at most MAX_RESULTS_PER_PLUGIN.
* plain text cut to its limits (the snippet with its marks), keys unique, at most MAX_RESULTS_PER_PLUGIN.
* Anything else is dropped without failing the rest.
*/
export function checkSearchResults(pluginId: string, value: unknown): ProvidedSearchResult[] {
@@ -63,7 +94,7 @@ export function checkSearchResults(pluginId: string, value: unknown): ProvidedSe
const source = text(entry.source, MAX_SOURCE);
const message = email(entry.email);
out.push({
pluginId, key, title, snippet: text(entry.snippet, MAX_SNIPPET),
pluginId, key, title, snippet: markedText(entry.snippet, MAX_SNIPPET),
...(source ? { source } : {}),
...(message ? { email: message } : {}),
});
+7 -1
View File
@@ -1221,13 +1221,19 @@ export interface ConflictWarning {
* opens it in the reader, as a click on a row of the list does. Any other
* click reaches the plugin that returned the entry as `onAction`, with
* `surface: 'search-results'`, the entry's `key` as `actionId` and the
* search's `query` in the context. Plain text only; see
* search's `query` in the context. Plain text only, but the snippet may
* mark what matched as SearchSnippet/get does; see
* lib/plugin-search-results.ts for the limits.
*/
export interface ExternalSearchResult {
/** Unique among the plugin's entries for this search; comes back as actionId. */
key: string;
title: string;
/**
* May wrap what matched in `<mark>`…`</mark>`, highlighted as the list's
* own search snippets; `&`, `<`, `>` meant as text escaped as `&amp;`,
* `&lt;`, `&gt;`. No other markup: any other tag shows as text.
*/
snippet: string;
/** Group heading, e.g. "CRM", "Slack"; the plugin's name when missing. */
source?: string;