From 3e82c9501a4b3aa959e06ae79f3d0d43ad10d2b3 Mon Sep 17 00:00:00 2001 From: Antoine BERNIER Date: Sat, 24 Aug 2024 19:52:29 +0200 Subject: [PATCH] fix: search inside content (#295) * search inside content * sanitizeAllHtmlButMark * escape disallowedTagsMode * clean --- package-lock.json | 125 +++++++++++++++++- package.json | 2 + src/app/[...slug]/DocsContext.tsx | 3 +- src/components/Search/SearchItem.tsx | 37 ++++-- .../Search/SearchModalContainer.tsx | 2 +- src/components/mdx/Toc/rehypeToc.ts | 18 ++- src/utils/docs.tsx | 2 +- src/utils/text.ts | 5 +- 8 files changed, 167 insertions(+), 27 deletions(-) diff --git a/package-lock.json b/package-lock.json index d67286a1..71bbbe49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "rehype-github-alerts": "^3.0.0", "rehype-prism-plus": "^2.0.0", "remark-gfm": "^4.0.0", + "sanitize-html": "^2.13.0", "tailwind-merge": "^2.5.2" }, "bin": { @@ -32,6 +33,7 @@ "@types/node": "^18.19.44", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", + "@types/sanitize-html": "^2.13.0", "autoprefixer": "^10.4.20", "eslint": "^8.57.0", "eslint-config-next": "^14.2.5", @@ -1245,6 +1247,16 @@ "@types/react": "*" } }, + "node_modules/@types/sanitize-html": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.13.0.tgz", + "integrity": "sha512-X31WxbvW9TjIhZZNyNBZ/p5ax4ti7qsNDBDEnH4zAgmEh35YnFD1UiS6z9Cd34kKm0LslFW0KPmTQzu/oGtsqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "htmlparser2": "^8.0.0" + } + }, "node_modules/@types/semver": { "version": "7.5.8", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", @@ -2851,6 +2863,15 @@ "dev": true, "license": "MIT" }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -2947,6 +2968,61 @@ "node": ">=6.0.0" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dot-prop": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", @@ -3343,7 +3419,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -4985,6 +5060,25 @@ "node": "^16.14.0 || >=18.0.0" } }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -5545,6 +5639,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-reference": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.2.tgz", @@ -10938,6 +11041,12 @@ "integrity": "sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ==", "license": "ISC" }, + "node_modules/parse-srcset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==", + "license": "MIT" + }, "node_modules/parse5": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", @@ -12155,6 +12264,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sanitize-html": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.13.0.tgz", + "integrity": "sha512-Xff91Z+4Mz5QiNSLdLWwjgBDm5b1RU6xBT0+12rapjiaR7SwfRdjw8f+6Rir2MXKLrDicRFHdb51hGOAxmsUIA==", + "license": "MIT", + "dependencies": { + "deepmerge": "^4.2.2", + "escape-string-regexp": "^4.0.0", + "htmlparser2": "^8.0.0", + "is-plain-object": "^5.0.0", + "parse-srcset": "^1.0.2", + "postcss": "^8.3.11" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", diff --git a/package.json b/package.json index b086d379..5b813e08 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "@types/node": "^18.19.44", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", + "@types/sanitize-html": "^2.13.0", "autoprefixer": "^10.4.20", "eslint": "^8.57.0", "eslint-config-next": "^14.2.5", @@ -41,6 +42,7 @@ "rehype-github-alerts": "^3.0.0", "rehype-prism-plus": "^2.0.0", "remark-gfm": "^4.0.0", + "sanitize-html": "^2.13.0", "tailwind-merge": "^2.5.2" }, "scripts": { diff --git a/src/app/[...slug]/DocsContext.tsx b/src/app/[...slug]/DocsContext.tsx index e56f1a63..c51de098 100644 --- a/src/app/[...slug]/DocsContext.tsx +++ b/src/app/[...slug]/DocsContext.tsx @@ -7,8 +7,7 @@ export type DocToC = { id: string level: number title: string - description: string - // content: string + content: string url: string parent: DocToC | null label: string diff --git a/src/components/Search/SearchItem.tsx b/src/components/Search/SearchItem.tsx index 45d63328..b6bd324e 100644 --- a/src/components/Search/SearchItem.tsx +++ b/src/components/Search/SearchItem.tsx @@ -1,10 +1,11 @@ import Icon from '@/components/Icon' import { highlight } from '@/utils/text' import Link from 'next/link' +import sanitizeHtml from 'sanitize-html' export interface SearchResult { title: string - description: string + content: string url: string label: string image?: string @@ -14,6 +15,13 @@ export interface SearchItemProps { search: string result: SearchResult } +function sanitizeAllHtmlButMark(str: string) { + return sanitizeHtml(str, { + allowedTags: ['mark'], + allowedAttributes: false, + disallowedTagsMode: 'escape', + }) +} function SearchItem({ search, result }: SearchItemProps) { return ( @@ -24,18 +32,21 @@ function SearchItem({ search, result }: SearchItemProps) { >
  • - ${result.label} - ${highlight(result.title, search)} - - ${highlight(result.description, search)} - - `, - }} - /> +
    +
    {result.label}
    + +
    + +
    +
    {result.image ? ( // eslint-disable-next-line @next/next/no-img-element {result.title} diff --git a/src/components/Search/SearchModalContainer.tsx b/src/components/Search/SearchModalContainer.tsx index 98f83ff2..51530d86 100644 --- a/src/components/Search/SearchModalContainer.tsx +++ b/src/components/Search/SearchModalContainer.tsx @@ -36,7 +36,7 @@ export const SearchModalContainer = ({ onClose }: SearchModalContainerProps) => ({ tableOfContents }) => tableOfContents, ) satisfies SearchResult[] // console.log('candidateResults', candidateResults) - candidateResults = candidateResults.filter((entry) => entry.description.length > 0) + // candidateResults = candidateResults.filter((entry) => entry.description.length > 0) // .concat( // Object.entries(boxes).flatMap(([id, data]) => ({ // ...data, diff --git a/src/components/mdx/Toc/rehypeToc.ts b/src/components/mdx/Toc/rehypeToc.ts index f297c3d7..0fa4780b 100644 --- a/src/components/mdx/Toc/rehypeToc.ts +++ b/src/components/mdx/Toc/rehypeToc.ts @@ -25,7 +25,7 @@ const slugify = (title: string) => title.toLowerCase().replace(/\s+|-+/g, '-') /** * Generates a table of contents from page headings. */ -export const rehypeToc = (target: DocToC[] = [], url: string, page: string, content: string) => { +export const rehypeToc = (target: DocToC[] = [], url: string, page: string) => { return () => (root: Node) => { const previous: Record = {} @@ -39,10 +39,19 @@ export const rehypeToc = (target: DocToC[] = [], url: string, page: string, cont const id = slugify(title) node.properties.id = id + // + // Extract content for each heading + // + let siblingIndex = i + 1 + const content: string[] = [] let sibling: Node | undefined = root.children[siblingIndex] - while (sibling?.type === 'text') sibling = root.children[siblingIndex++] - const description = sibling?.tagName === 'p' ? toString(sibling) : '' + while (sibling) { + if (RegExp(`^h${level}$`).test(sibling.tagName)) break // stop at the next (same-level) heading + + content.push(toString(sibling)) + sibling = root.children[siblingIndex++] + } const item: DocToC = { id, @@ -50,8 +59,7 @@ export const rehypeToc = (target: DocToC[] = [], url: string, page: string, cont label: page, url: `${url}#${id}`, title, - description, - // content, // potentially too big to be in the ToC (perfs issue) + content: content.join(''), parent: previous[level - 2] ?? null, } previous[level - 1] = item diff --git a/src/utils/docs.tsx b/src/utils/docs.tsx index e741a2d9..4e2cca74 100644 --- a/src/utils/docs.tsx +++ b/src/utils/docs.tsx @@ -159,7 +159,7 @@ async function _getDocs( rehypeGha, rehypePrismPlus, rehypeCodesandbox(boxes), // 1. put all Codesandbox[id] into `doc.boxes` - rehypeToc(tableOfContents, url, title, content), // 2. will populate `doc.tableOfContents` + rehypeToc(tableOfContents, url, title), // 2. will populate `doc.tableOfContents` ], }, }, diff --git a/src/utils/text.ts b/src/utils/text.ts index 42a639d7..fda82168 100644 --- a/src/utils/text.ts +++ b/src/utils/text.ts @@ -7,7 +7,4 @@ export const escape = (text: string) => text.replace(/[|\\{}()[\]^$+*?.]/g, '\\$ * Bolds matching text, returning HTML. */ export const highlight = (text: string, target: string) => - text.replace( - new RegExp(target, 'gi'), - (match: string) => `${match}`, - ) + text.replace(new RegExp(target, 'gi'), (match: string) => `${match}`)