diff --git a/src/client/lib/markdown.test.ts b/src/client/lib/markdown.test.ts new file mode 100644 index 0000000..8e19320 --- /dev/null +++ b/src/client/lib/markdown.test.ts @@ -0,0 +1,68 @@ +// @vitest-environment jsdom +import { describe, it, expect } from "vitest"; +import { highlightSearchText } from "./markdown"; + +describe("highlightSearchText", () => { + it("highlights plain text matches", () => { + const result = highlightSearchText("

hello world

", "world"); + expect(result).toBe( + '

hello world

' + ); + }); + + it("returns html unchanged when query is empty", () => { + const html = "

hello

"; + expect(highlightSearchText(html, "")).toBe(html); + }); + + it("does not match inside HTML tags", () => { + const html = 'text'; + const result = highlightSearchText(html, "class"); + // "class" appears in the href attribute but must not be highlighted there + expect(result).toBe('text'); + }); + + it("does not corrupt HTML entities when searching for entity content", () => { + const html = "

A & B

"; + const result = highlightSearchText(html, "amp"); + // Must NOT produce & — entity must remain intact + expect(result).toBe("

A & B

"); + }); + + it("does not corrupt < entity", () => { + const html = "

a < b

"; + const result = highlightSearchText(html, "lt"); + expect(result).toBe("

a < b

"); + }); + + it("does not corrupt > entity", () => { + const html = "

a > b

"; + const result = highlightSearchText(html, "gt"); + expect(result).toBe("

a > b

"); + }); + + it("does not corrupt numeric entities", () => { + const html = "

'quoted'

"; + const result = highlightSearchText(html, "039"); + expect(result).toBe("

'quoted'

"); + }); + + it("highlights text adjacent to entities", () => { + const html = "

foo & bar

"; + const result = highlightSearchText(html, "foo"); + expect(result).toBe( + '

foo & bar

' + ); + }); + + it("is case-insensitive", () => { + const result = highlightSearchText("

Hello World

", "hello"); + expect(result).toContain('Hello'); + }); + + it("escapes regex special characters in query", () => { + const html = "

price is $100.00

"; + const result = highlightSearchText(html, "$100.00"); + expect(result).toContain('$100.00'); + }); +}); diff --git a/src/client/lib/markdown.ts b/src/client/lib/markdown.ts index 7d80e86..c730150 100644 --- a/src/client/lib/markdown.ts +++ b/src/client/lib/markdown.ts @@ -1,6 +1,8 @@ import { marked } from "marked"; import hljs from "highlight.js"; import { markedHighlight } from "marked-highlight"; +import "highlight.js/styles/github-dark.css"; +import { escapeHtml } from "../../shared/escape-html"; marked.use( markedHighlight({ @@ -8,7 +10,10 @@ marked.use( if (lang && hljs.getLanguage(lang)) { return hljs.highlight(code, { language: lang }).value; } - return hljs.highlightAuto(code).value; + // Plain-text fallback: highlightAuto tries every grammar (~6.7ms/block) + // vs explicit highlight (~0.04ms). With thousands of unlabeled blocks + // this dominates render time. Escaping is sufficient. + return escapeHtml(code); }, }) ); @@ -22,13 +27,6 @@ export function renderMarkdown(text: string): string { } } -function escapeHtml(text: string): string { - return text - .replace(/&/g, "&") - .replace(//g, ">"); -} - export function highlightSearchText(html: string, query: string): string { if (!query) return html; const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -40,10 +38,19 @@ export function highlightSearchText(html: string, query: string): string { for (let i = 0; i < parts.length; i++) { // Even indices are text content, odd indices are tags if (i % 2 === 0 && parts[i]) { - parts[i] = parts[i].replace( - regex, - '$1' - ); + // Further split on HTML entities (& < etc.) to avoid + // matching inside them — e.g. searching "amp" must not corrupt & + const subParts = parts[i].split(/(&[a-zA-Z0-9#]+;)/); + for (let j = 0; j < subParts.length; j++) { + // Odd indices are entities — skip them + if (j % 2 === 0 && subParts[j]) { + subParts[j] = subParts[j].replace( + regex, + '$1' + ); + } + } + parts[i] = subParts.join(""); } } return parts.join("");