diff --git a/components/email/__tests__/plugin-search-results.test.tsx b/components/email/__tests__/plugin-search-results.test.tsx
index b7f80893d..454ed6ba9 100644
--- a/components/email/__tests__/plugin-search-results.test.tsx
+++ b/components/email/__tests__/plugin-search-results.test.tsx
@@ -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 pixel grid is <b>coarse</b>' }],
+ });
+ render();
+
+ const mark = screen.getByText('pixel grid');
+ expect(mark.tagName).toBe('MARK');
+ expect(mark.parentElement).toHaveTextContent('The pixel grid is coarse');
+ 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();
diff --git a/components/email/plugin-search-results.tsx b/components/email/plugin-search-results.tsx
index 9f71b1505..1c24218e0 100644
--- a/components/email/plugin-search-results.tsx
+++ b/components/email/plugin-search-results.tsx
@@ -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
)}
>
{r.title}
- {r.snippet && {r.snippet}}
+ {r.snippet && (
+
+
+
+ )}
);
diff --git a/lib/__tests__/plugin-search-results.test.ts b/lib/__tests__/plugin-search-results.test.ts
index 1b92e9c62..44c2061d0 100644
--- a/lib/__tests__/plugin-search-results.test.ts
+++ b/lib/__tests__/plugin-search-results.test.ts
@@ -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 pixel\n grid & more ' },
+ ]);
+ expect(entry.snippet).toBe('The pixel grid & <b>more</b>');
+ });
+
+ 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 & Jerry <3');
+ });
+
+ it('cuts a snippet by its text, closing a mark it cuts', () => {
+ const snippet = `${'x'.repeat(290)}${'y'.repeat(20)}`;
+ const [entry] = checkSearchResults('p', [{ key: 'a', title: 'A', snippet }]);
+ expect(entry.snippet).toBe(`${'x'.repeat(290)}${'y'.repeat(9)}…`);
+ });
+
it('ignores an answer that is not a list', () => {
expect(checkSearchResults('p', { key: 'a', title: 'A' })).toEqual([]);
expect(checkSearchResults('p', undefined)).toEqual([]);
diff --git a/lib/plugin-search-results.ts b/lib/plugin-search-results.ts
index 91b5f2120..6bad2bd99 100644
--- a/lib/plugin-search-results.ts
+++ b/lib/plugin-search-results.ts
@@ -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, '&').replace(//g, '>');
+}
+
+/**
+ * A snippet in SearchSnippet/get's form: text with `` 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 ? `${escaped(kept)}` : 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 } : {}),
});
diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts
index 81bedd346..2cae31efe 100644
--- a/lib/plugin-types.ts
+++ b/lib/plugin-types.ts
@@ -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 ``…``, highlighted as the list's
+ * own search snippets; `&`, `<`, `>` meant as text escaped as `&`,
+ * `<`, `>`. 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;