feat(web): Highlight (XSS-safe) + SearchResultRow components
This commit is contained in:
@@ -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 <mark> and plain text around them", () => {
|
||||||
|
render(<Highlight text={"cast \x02bronze\x03 with patina"} />);
|
||||||
|
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(<Highlight text="no markers here" />);
|
||||||
|
expect(document.body).toHaveTextContent("no markers here");
|
||||||
|
expect(screen.queryByRole("mark")).toBeNull();
|
||||||
|
});
|
||||||
@@ -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 <mark>, 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(
|
||||||
|
<mark key={key++} className="bg-yellow-200">
|
||||||
|
{rest.slice(start + PRE.length, end)}
|
||||||
|
</mark>,
|
||||||
|
);
|
||||||
|
rest = rest.slice(end + POST.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{nodes}</>;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<li>
|
||||||
|
<NavLink
|
||||||
|
to={`/search/${hit.id}`}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`block border-b px-3 py-2 ${isActive ? "bg-indigo-50" : "hover:bg-neutral-50"}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="text-sm font-semibold">{hit.object_name}</div>
|
||||||
|
<div className="mt-0.5 flex items-center gap-2 text-xs text-neutral-500">
|
||||||
|
<span>{hit.object_number}</span>
|
||||||
|
<VisibilityBadge visibility={hit.visibility} />
|
||||||
|
</div>
|
||||||
|
{hit.snippet && (
|
||||||
|
<p className="mt-1 line-clamp-2 text-xs text-neutral-600">
|
||||||
|
<Highlight text={hit.snippet} />
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user