From ee65b2759578f9cb51e5c05301515695a5e012af Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Thu, 4 Jun 2026 12:34:27 +0200 Subject: [PATCH] feat(web): Highlight (XSS-safe) + SearchResultRow components --- web/src/search/highlight.test.tsx | 16 +++++++++++ web/src/search/highlight.tsx | 42 ++++++++++++++++++++++++++++ web/src/search/search-result-row.tsx | 31 ++++++++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 web/src/search/highlight.test.tsx create mode 100644 web/src/search/highlight.tsx create mode 100644 web/src/search/search-result-row.tsx diff --git a/web/src/search/highlight.test.tsx b/web/src/search/highlight.test.tsx new file mode 100644 index 0000000..4595944 --- /dev/null +++ b/web/src/search/highlight.test.tsx @@ -0,0 +1,16 @@ +import { expect, test } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { Highlight } from "./highlight"; + +test("renders matched segments as and plain text around them", () => { + render(); + const mark = screen.getByText("bronze"); + expect(mark.tagName).toBe("MARK"); + expect(document.body).toHaveTextContent("cast bronze with patina"); +}); + +test("renders plain text unchanged when there are no markers", () => { + render(); + expect(document.body).toHaveTextContent("no markers here"); + expect(screen.queryByRole("mark")).toBeNull(); +}); diff --git a/web/src/search/highlight.tsx b/web/src/search/highlight.tsx new file mode 100644 index 0000000..e8af6d6 --- /dev/null +++ b/web/src/search/highlight.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from "react"; + +// Must match the backend's search::HL_PRE / HL_POST sentinel characters +// (U+0002 / U+0003). Written as escapes so they survive copy-paste. +const PRE = "\x02"; +const POST = "\x03"; + +/** Renders a sentinel-marked snippet: matched spans become , the rest is text. + * Pure string handling — no HTML is injected, so this is XSS-safe. */ +export function Highlight({ text }: { text: string }) { + const nodes: ReactNode[] = []; + let rest = text; + let key = 0; + + while (rest.length > 0) { + const start = rest.indexOf(PRE); + + if (start === -1) { + nodes.push(rest); + break; + } + + if (start > 0) nodes.push(rest.slice(0, start)); + + const end = rest.indexOf(POST, start + PRE.length); + + if (end === -1) { + // Malformed: no closing marker. Emit the remainder verbatim, minus the marker. + nodes.push(rest.slice(start + PRE.length)); + break; + } + + nodes.push( + + {rest.slice(start + PRE.length, end)} + , + ); + rest = rest.slice(end + POST.length); + } + + return <>{nodes}; +} diff --git a/web/src/search/search-result-row.tsx b/web/src/search/search-result-row.tsx new file mode 100644 index 0000000..372c785 --- /dev/null +++ b/web/src/search/search-result-row.tsx @@ -0,0 +1,31 @@ +import { NavLink } from "react-router-dom"; + +import type { components } from "../api/schema"; +import { VisibilityBadge } from "../objects/visibility-badge"; +import { Highlight } from "./highlight"; + +type SearchHitView = components["schemas"]["SearchHitView"]; + +export function SearchResultRow({ hit }: { hit: SearchHitView }) { + return ( +
  • + + `block border-b px-3 py-2 ${isActive ? "bg-indigo-50" : "hover:bg-neutral-50"}` + } + > +
    {hit.object_name}
    +
    + {hit.object_number} + +
    + {hit.snippet && ( +

    + +

    + )} +
    +
  • + ); +}