Skip to content

Commit

Permalink
Fix markdown and HTML tags bleeding into search output
Browse files Browse the repository at this point in the history
- fixes How to search in the current setup #68
  • Loading branch information
bodobraegger committed Sep 3, 2023
1 parent a3e07c7 commit 6d93bda
Show file tree
Hide file tree
Showing 3 changed files with 183 additions and 71 deletions.
87 changes: 87 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
164 changes: 93 additions & 71 deletions src/components/SearchForm.tsx
Original file line number Diff line number Diff line change
@@ -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[]
Expand All @@ -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<string>('')
const [searchResults, setSearchResults] = useState<SearchResult[]>([])
const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout>()
const [isLoadingResults, setIsLoadingResults] = useState<boolean>(false)
const [keyword, setKeyword] = useState<string>('');
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout>();
const [isLoadingResults, setIsLoadingResults] = useState<boolean>(false);

// memoized searchable chapters
const searchableSectionChapters = useMemo<ChapterT[]>(() => sections.reduce((chapters: ChapterT[], currentSection: SectionT) => chapters.concat(currentSection.chapters), []), [sections]);

// minimum keyword length
const minKeywordLength = props.minKeyWordLength ?? 3;
const searchableSectionChapters = useMemo<ChapterT[]>(
() => 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<HTMLInputElement>): 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<string[]> => {
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 => {
Expand All @@ -119,30 +138,33 @@ function SearchForm(props: Props) {
{result.matchingContents.length > 0 ?
<div className='content-match'>
{result.matchingContents.map((content, idx) => {
return <ReactMarkdown key={idx} remarkPlugins={[remarkGfm]} components={LinkComponent}>{content}</ReactMarkdown>
return <ReactMarkdown key={idx} remarkPlugins={[remarkGfm, strip_md, strip_html]} components={LinkComponent}>{content}</ReactMarkdown>
})}
</div>
: null
}
</div>
})
});
}
// show no results message if no results were found or timeout was reached
return <div>{t('searchPage.noResults')}</div>
return <div>{t('searchPage.noResults')}</div>;
}
// show no keyword message if keyword is empty or is too short
return <div> {t('searchPage.noKeyword', { amountOfCharacters: minKeywordLength })}</div>
return <div> {t('searchPage.noKeyword', { amountOfCharacters: minKeyWordLength })}</div>;
}
return null
return null;
}

return <>
<SearchInput keyword={keyword} onChange={onChangeKeyword} />
<br />
<Loading isLoading={isLoadingResults}></Loading>
<div className='search-results'>
{searchResultViews()}
</div>
</>
return (
<>
<SearchInput keyword={keyword} onChange={onChangeKeyword} />
<br />
<Loading isLoading={isLoadingResults}></Loading>
<div className='search-results'>
{searchResultViews()}
</div>
</>
);
}
export default withTranslation()(SearchForm)

export default withTranslation()(SearchForm);

0 comments on commit 6d93bda

Please sign in to comment.