diff --git a/package-lock.json b/package-lock.json index b3cdf5d..4d999b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,10 @@ "react-quiz-component": "^0.5.1", "react-router": "^6.8.0", "react-router-dom": "^6.8.0", + "remark": "^14.0.3", "remark-gfm": "^3.0.1", + "remark-strip-html": "^1.0.2", + "strip-markdown": "^5.0.1", "styled-components": "^5.3.11", "typescript": "^4.9.4", "web-vitals": "^3.1.1" @@ -20493,6 +20496,21 @@ "node": ">= 0.10" } }, + "node_modules/remark": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/remark/-/remark-14.0.3.tgz", + "integrity": "sha512-bfmJW1dmR2LvaMJuAnE88pZP9DktIFYXazkTfOIKZzi3Knk9lT0roItIA24ydOucI3bV/g/tXBA6hzqq3FV9Ew==", + "dependencies": { + "@types/mdast": "^3.0.0", + "remark-parse": "^10.0.0", + "remark-stringify": "^10.0.0", + "unified": "^10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-gfm": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-3.0.1.tgz", @@ -20537,6 +20555,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-stringify": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-10.0.3.tgz", + "integrity": "sha512-koyOzCMYoUHudypbj4XpnAKFbkddRMYZHwghnxd7ue5210WzGw6kOBwauJTRUMq16jsovXx8dYNvSSWP89kZ3A==", + "dependencies": { + "@types/mdast": "^3.0.0", + "mdast-util-to-markdown": "^1.0.0", + "unified": "^10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-strip-html": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/remark-strip-html/-/remark-strip-html-1.0.2.tgz", + "integrity": "sha512-lzOlh4gp/sd9qYVUO0QNNQbUIDYM35XhBZnvp4S4bkM3OCfrvd2GQlQVPAuneeI/K5jbxTfCGDvb9UT9IEPIwA==" + }, "node_modules/renderkid": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", @@ -21693,6 +21730,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-markdown": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/strip-markdown/-/strip-markdown-5.0.1.tgz", + "integrity": "sha512-IvoKZrXtWAnlEjRfDlT3yRtGRvpX3RSg+nwAHONmshpSCoxgjZV2xX9ZYvEmwupmYobJtws9oDdTwLUu/5PoMQ==", + "dependencies": { + "@types/mdast": "^3.0.0", + "@types/unist": "^2.0.6", + "unified": "^10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/style-loader": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.1.tgz", @@ -38909,6 +38960,17 @@ "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", "dev": true }, + "remark": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/remark/-/remark-14.0.3.tgz", + "integrity": "sha512-bfmJW1dmR2LvaMJuAnE88pZP9DktIFYXazkTfOIKZzi3Knk9lT0roItIA24ydOucI3bV/g/tXBA6hzqq3FV9Ew==", + "requires": { + "@types/mdast": "^3.0.0", + "remark-parse": "^10.0.0", + "remark-stringify": "^10.0.0", + "unified": "^10.0.0" + } + }, "remark-gfm": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-3.0.1.tgz", @@ -38941,6 +39003,21 @@ "unified": "^10.0.0" } }, + "remark-stringify": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-10.0.3.tgz", + "integrity": "sha512-koyOzCMYoUHudypbj4XpnAKFbkddRMYZHwghnxd7ue5210WzGw6kOBwauJTRUMq16jsovXx8dYNvSSWP89kZ3A==", + "requires": { + "@types/mdast": "^3.0.0", + "mdast-util-to-markdown": "^1.0.0", + "unified": "^10.0.0" + } + }, + "remark-strip-html": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/remark-strip-html/-/remark-strip-html-1.0.2.tgz", + "integrity": "sha512-lzOlh4gp/sd9qYVUO0QNNQbUIDYM35XhBZnvp4S4bkM3OCfrvd2GQlQVPAuneeI/K5jbxTfCGDvb9UT9IEPIwA==" + }, "renderkid": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", @@ -39814,6 +39891,16 @@ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true }, + "strip-markdown": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/strip-markdown/-/strip-markdown-5.0.1.tgz", + "integrity": "sha512-IvoKZrXtWAnlEjRfDlT3yRtGRvpX3RSg+nwAHONmshpSCoxgjZV2xX9ZYvEmwupmYobJtws9oDdTwLUu/5PoMQ==", + "requires": { + "@types/mdast": "^3.0.0", + "@types/unist": "^2.0.6", + "unified": "^10.0.0" + } + }, "style-loader": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.1.tgz", diff --git a/package.json b/package.json index 8d22067..e5cc298 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,10 @@ "react-quiz-component": "^0.5.1", "react-router": "^6.8.0", "react-router-dom": "^6.8.0", + "remark": "^14.0.3", "remark-gfm": "^3.0.1", + "remark-strip-html": "^1.0.2", + "strip-markdown": "^5.0.1", "styled-components": "^5.3.11", "typescript": "^4.9.4", "web-vitals": "^3.1.1" diff --git a/src/components/SearchForm.tsx b/src/components/SearchForm.tsx index c46cf53..bf4b0be 100644 --- a/src/components/SearchForm.tsx +++ b/src/components/SearchForm.tsx @@ -1,16 +1,21 @@ -import React, { useEffect, useMemo, useState } from 'react' -import { withTranslation } from "react-i18next" -import Loading from './Loading' +import React, { useEffect, useMemo, useState } from 'react'; +import { withTranslation } from "react-i18next"; +import Loading from './Loading'; import { SearchHelper } from '../utils/SearchHelper'; import { useNavigate, useLocation } from 'react-router'; import type { ChapterT } from '../components/Chapter'; import type { SectionT } from '../components/Section'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; +import strip_md from 'strip-markdown'; +import { remark } from 'remark'; + import { LinkComponent } from '../utils/MarkdownComponents'; import SearchInput from './SearchInput'; import { Link } from 'react-router-dom'; +var strip_html = require('remark-strip-html'); + type Props = { t: any, sections: SectionT[] @@ -24,91 +29,105 @@ type SearchResult = { slug_with_section: string } +function SearchForm({ t, sections, minKeyWordLength = 3 }: Props) { + const location = useLocation(); + const navigate = useNavigate(); -function SearchForm(props: Props) { - const location = useLocation() - const navigate = useNavigate() - const { t, sections } = props; - // stateful keyword, search results and timeout id - const [keyword, setKeyword] = useState('') - const [searchResults, setSearchResults] = useState([]) - const [timeoutId, setTimeoutId] = useState() - const [isLoadingResults, setIsLoadingResults] = useState(false) - + const [keyword, setKeyword] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [timeoutId, setTimeoutId] = useState(); + const [isLoadingResults, setIsLoadingResults] = useState(false); + // memoized searchable chapters - const searchableSectionChapters = useMemo(() => sections.reduce((chapters: ChapterT[], currentSection: SectionT) => chapters.concat(currentSection.chapters), []), [sections]); - - // minimum keyword length - const minKeywordLength = props.minKeyWordLength ?? 3; + const searchableSectionChapters = useMemo( + () => sections.reduce( + (chapters: ChapterT[], currentSection: SectionT) => + chapters.concat(currentSection.chapters), [] + ), [sections] + ); // update keyword from route if it changes useEffect(() => { - const routeParams = new URLSearchParams(location.search) - const keywordFromRoute = routeParams.get('keyword') + const routeParams = new URLSearchParams(location.search); + const keywordFromRoute = routeParams.get('keyword'); if (keywordFromRoute) { - setKeyword(keywordFromRoute) + setKeyword(keywordFromRoute); } - }, [location.search]) + }, [location.search]); // build search results when keyword changes or searchable chapters change useEffect(() => { const buildSearchResults = () => { - setIsLoadingResults(true) + setIsLoadingResults(true); if (timeoutId) { - clearTimeout(timeoutId) + clearTimeout(timeoutId); } - if (!keyword || keyword.length < minKeywordLength) { + if (!keyword || keyword.length < minKeyWordLength) { // only search for keywords with where keyword length is met - setSearchResults([]) - setIsLoadingResults(false) + setSearchResults([]); + setIsLoadingResults(false); // else show empty search page - navigate({ search: '' }) - return + navigate({ search: '' }); + return; } // search for keyword in chapters for up to 500ms - setTimeoutId(setTimeout(() => { - const searchResults = searchableSectionChapters - .filter(chapter => SearchHelper.matches(keyword, [chapter.title, chapter.content])) - .map(chapter => { - return { - id: chapter.id, - title: chapter.title, - matchingContents: findMatchingContents(keyword, chapter.content), - slug_with_section: chapter.slug_with_section - } as SearchResult - }) - setSearchResults(searchResults) - setIsLoadingResults(false) - - // update route if keyword changed - const routeParams = new URLSearchParams(location.search) - if (routeParams.get('keyword') !== keyword) { - routeParams.set('keyword', keyword) - navigate({ search: routeParams.toString() }) - } - }, 500)) - } - buildSearchResults() - }, [keyword, searchableSectionChapters]) // eslint-disable-line react-hooks/exhaustive-deps + setTimeoutId( + setTimeout(async () => { + const searchResults = await Promise.all( + searchableSectionChapters + .filter(chapter => SearchHelper.matches(keyword, [chapter.title, chapter.content])) + .map(async (chapter) => { + const matchingContents = await findMatchingContents(keyword, chapter.content); + return { + id: chapter.id, + title: chapter.title, + matchingContents, + slug_with_section: chapter.slug_with_section + } as SearchResult; + }) + ); + setSearchResults(searchResults); + setIsLoadingResults(false); + + // update route if keyword changed + const routeParams = new URLSearchParams(location.search); + if (routeParams.get('keyword') !== keyword) { + routeParams.set('keyword', keyword); + navigate({ search: routeParams.toString() }); + } + }, 500) + ); + }; + buildSearchResults(); + // one of the dependencies would constantly cause a rerender, so ignore! + // eslint-disable-next-line + }, [keyword, searchableSectionChapters]); // update keyword when input changes const onChangeKeyword = (e: React.FormEvent): void => { - setKeyword(e.currentTarget?.value ?? '') + setKeyword(e.currentTarget?.value ?? ''); } // find matching contents in chapter content for keyword - const findMatchingContents = (keyword: string, content: string): string[] => { - const matches = Array.from(content.matchAll(new RegExp(`[^.!?:;#\n]*(?=${keyword}).*?[.!?](?=\s?|\p{Lu}|$)`, 'gmi'))) // eslint-disable-line no-useless-escape - return matches.reduce((searchResults: string[], currentMatches: RegExpMatchArray) => searchResults.concat(currentMatches), []) + const findMatchingContents = (keyword: string, content: string): Promise => { + let result = remark().use(strip_md).use(strip_html).process(content).then( + (stripped_content) => + { + const matches = Array.from(String(stripped_content).matchAll(new RegExp(`[^.!?:;#\n]*(?=${keyword}).*?[.!?](?=\s?|\p{Lu}|$)`, 'gmi'))) // eslint-disable-line no-useless-escape + const result = matches.reduce((searchResults: string[], currentMatches: RegExpMatchArray) => searchResults.concat(currentMatches), []) + return result; + } + ); + return result; } // render search results or loading indicator or no results message or no keyword message const searchResultViews = () => { if (!isLoadingResults) { - if (keyword.length >= minKeywordLength) { + if (keyword.length >= minKeyWordLength) { if (searchResults.length > 0) { // render results return searchResults.map(result => { @@ -119,30 +138,33 @@ function SearchForm(props: Props) { {result.matchingContents.length > 0 ?
{result.matchingContents.map((content, idx) => { - return {content} + return {content} })}
: null } - }) + }); } // show no results message if no results were found or timeout was reached - return
{t('searchPage.noResults')}
+ return
{t('searchPage.noResults')}
; } // show no keyword message if keyword is empty or is too short - return
{t('searchPage.noKeyword', { amountOfCharacters: minKeywordLength })}
+ return
{t('searchPage.noKeyword', { amountOfCharacters: minKeyWordLength })}
; } - return null + return null; } - return <> - -
- -
- {searchResultViews()} -
- + return ( + <> + +
+ +
+ {searchResultViews()} +
+ + ); } -export default withTranslation()(SearchForm) + +export default withTranslation()(SearchForm);