diff --git a/frontend/components/common/Content/index.tsx b/frontend/components/common/Content/index.tsx index c62932a2..f1569cee 100644 --- a/frontend/components/common/Content/index.tsx +++ b/frontend/components/common/Content/index.tsx @@ -1,3 +1,5 @@ +import { markdown2html } from '@utils/parser'; + import { ContentBody, ContentTitle, ContentWrapper } from './styled'; import 'highlight.js/styles/github.css'; @@ -13,7 +15,7 @@ export default function Content({ title, content }: ContentProps) { {title && {title}} diff --git a/frontend/components/edit/Editor/index.tsx b/frontend/components/edit/Editor/index.tsx index 90b3d8cd..99fab747 100644 --- a/frontend/components/edit/Editor/index.tsx +++ b/frontend/components/edit/Editor/index.tsx @@ -20,7 +20,6 @@ import EditBar from '@components/edit/EditBar'; import useCodeMirror from '@components/edit/Editor/core/useCodeMirror'; import useInput from '@hooks/useInput'; import { IArticle } from '@interfaces'; -import { html2markdown, markdown2html } from '@utils/parser'; import { EditorButtonWrapper, @@ -68,7 +67,7 @@ export default function Editor({ handleModalOpen, originalArticle }: EditorProps if (!buffer.title && !buffer.content) return; title.setValue(buffer.title); - replaceDocument(html2markdown(buffer.content)); + replaceDocument(buffer.content); setBuffer({ title: '', content: '' }); }, [buffer]); @@ -77,7 +76,7 @@ export default function Editor({ handleModalOpen, originalArticle }: EditorProps setArticle({ ...article, title: title.value, - content: markdown2html(document), + content: document, }); }, [title.value, document]); diff --git a/frontend/components/search/ArticleList/index.tsx b/frontend/components/search/ArticleList/index.tsx index e59f4919..55779c4b 100644 --- a/frontend/components/search/ArticleList/index.tsx +++ b/frontend/components/search/ArticleList/index.tsx @@ -1,5 +1,6 @@ import ArticleItem from '@components/search/ArticleItem'; import { IArticleBook } from '@interfaces'; +import { markdown2text } from '@utils/parser'; interface ArticleListProps { articles: IArticleBook[]; @@ -24,7 +25,7 @@ export default function ArticleList({ articles, keywords }: ArticleListProps) { let paddingIndex = 0; if (isFirst) { - const regex = /(<([^>]+)>)/g; + const regex = /\n/g; while (regex.test(text.slice(0, startIndex))) paddingIndex = regex.lastIndex; } @@ -33,7 +34,7 @@ export default function ArticleList({ articles, keywords }: ArticleListProps) { <> {text.slice(paddingIndex, startIndex)} {text.slice(startIndex, endIndex)} - {highlightWord(text.slice(endIndex).replace(/(<([^>]+)>)/gi, ''), words)} + {highlightWord(text.slice(endIndex), words)} ); }; @@ -44,7 +45,7 @@ export default function ArticleList({ articles, keywords }: ArticleListProps) { ( diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 08e0cf5b..eec9f602 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -35,14 +35,18 @@ "react-dom": "18.2.0", "react-toastify": "^9.1.1", "recoil": "^0.7.6", + "rehype": "^12.0.1", "rehype-highlight": "^6.0.0", "rehype-parse": "^8.0.4", "rehype-remark": "^9.1.2", + "rehype-slug": "^5.1.0", "rehype-stringify": "^9.0.3", + "remark": "^14.0.2", "remark-breaks": "^3.0.2", "remark-parse": "^10.0.1", "remark-rehype": "^10.1.0", "remark-stringify": "^10.0.2", + "strip-markdown": "^5.0.0", "styled-components": "^5.3.6", "styled-reset": "^4.4.2", "typescript": "4.8.4", @@ -3218,6 +3222,11 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==" + }, "node_modules/glob": { "version": "7.1.7", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", @@ -3417,6 +3426,18 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-heading-rank": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/hast-util-heading-rank/-/hast-util-heading-rank-2.1.0.tgz", + "integrity": "sha512-w+Rw20Q/iWp2Bcnr6uTrYU6/ftZLbHKhvc8nM26VIWpDqDMlku2iXUVTeOlsdoih/UKQhY7PHQ+vZ0Aqq8bxtQ==", + "dependencies": { + "@types/hast": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-is-body-ok-link": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/hast-util-is-body-ok-link/-/hast-util-is-body-ok-link-2.0.0.tgz", @@ -3518,6 +3539,18 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-2.0.0.tgz", + "integrity": "sha512-02AQ3vLhuH3FisaMM+i/9sm4OXGSq1UhOOCpTLLQtHdL3tZt7qil69r8M8iDkZYyC0HCFylcYoP+8IO7ddta1A==", + "dependencies": { + "@types/hast": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-text": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-3.1.1.tgz", @@ -5319,6 +5352,21 @@ "url": "https://github.com/sponsors/mysticatea" } }, + "node_modules/rehype": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/rehype/-/rehype-12.0.1.tgz", + "integrity": "sha512-ey6kAqwLM3X6QnMDILJthGvG1m1ULROS9NT4uG9IDCuv08SFyLlreSuvOa//DgEvbXx62DS6elGVqusWhRUbgw==", + "dependencies": { + "@types/hast": "^2.0.0", + "rehype-parse": "^8.0.0", + "rehype-stringify": "^9.0.0", + "unified": "^10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/rehype-highlight": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/rehype-highlight/-/rehype-highlight-6.0.0.tgz", @@ -5382,6 +5430,24 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/rehype-slug": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/rehype-slug/-/rehype-slug-5.1.0.tgz", + "integrity": "sha512-Gf91dJoXneiorNEnn+Phx97CO7oRMrpi+6r155tTxzGuLtm+QrI4cTwCa9e1rtePdL4i9tSO58PeSS6HWfgsiw==", + "dependencies": { + "@types/hast": "^2.0.0", + "github-slugger": "^2.0.0", + "hast-util-has-property": "^2.0.0", + "hast-util-heading-rank": "^2.0.0", + "hast-util-to-string": "^2.0.0", + "unified": "^10.0.0", + "unist-util-visit": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/rehype-stringify": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-9.0.3.tgz", @@ -5396,6 +5462,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/remark/-/remark-14.0.2.tgz", + "integrity": "sha512-A3ARm2V4BgiRXaUo5K0dRvJ1lbogrbXnhkJRmD0yw092/Yl0kOCZt1k9ZeElEwkZsWGsMumz6qL5MfNJH9nOBA==", + "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-breaks": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-3.0.2.tgz", @@ -5783,6 +5864,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-markdown": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/strip-markdown/-/strip-markdown-5.0.0.tgz", + "integrity": "sha512-PXSts6Ta9A/TwGxVVSRlQs1ukJTAwwtbip2OheJEjPyfykaQ4sJSTnQWjLTI2vYWNts/R/91/csagp15W8n9gA==", + "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-mod": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.0.0.tgz", @@ -8891,6 +8986,11 @@ "integrity": "sha512-X8u8fREiYOE6S8hLbq99PeykTDoLVnxvF4DjWKJmz9xy2nNRdUcV8ZN9tniJFeKyTU3qnC9lL8n4Chd6LmVKHg==", "dev": true }, + "github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==" + }, "glob": { "version": "7.1.7", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", @@ -9033,6 +9133,14 @@ "resolved": "https://registry.npmjs.org/hast-util-has-property/-/hast-util-has-property-2.0.0.tgz", "integrity": "sha512-4Qf++8o5v14us4Muv3HRj+Er6wTNGA/N9uCaZMty4JWvyFKLdhULrv4KE1b65AthsSO9TXSZnjuxS8ecIyhb0w==" }, + "hast-util-heading-rank": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/hast-util-heading-rank/-/hast-util-heading-rank-2.1.0.tgz", + "integrity": "sha512-w+Rw20Q/iWp2Bcnr6uTrYU6/ftZLbHKhvc8nM26VIWpDqDMlku2iXUVTeOlsdoih/UKQhY7PHQ+vZ0Aqq8bxtQ==", + "requires": { + "@types/hast": "^2.0.0" + } + }, "hast-util-is-body-ok-link": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/hast-util-is-body-ok-link/-/hast-util-is-body-ok-link-2.0.0.tgz", @@ -9110,6 +9218,14 @@ "unist-util-visit": "^4.0.0" } }, + "hast-util-to-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-2.0.0.tgz", + "integrity": "sha512-02AQ3vLhuH3FisaMM+i/9sm4OXGSq1UhOOCpTLLQtHdL3tZt7qil69r8M8iDkZYyC0HCFylcYoP+8IO7ddta1A==", + "requires": { + "@types/hast": "^2.0.0" + } + }, "hast-util-to-text": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-3.1.1.tgz", @@ -10242,6 +10358,17 @@ "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==" }, + "rehype": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/rehype/-/rehype-12.0.1.tgz", + "integrity": "sha512-ey6kAqwLM3X6QnMDILJthGvG1m1ULROS9NT4uG9IDCuv08SFyLlreSuvOa//DgEvbXx62DS6elGVqusWhRUbgw==", + "requires": { + "@types/hast": "^2.0.0", + "rehype-parse": "^8.0.0", + "rehype-stringify": "^9.0.0", + "unified": "^10.0.0" + } + }, "rehype-highlight": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/rehype-highlight/-/rehype-highlight-6.0.0.tgz", @@ -10289,6 +10416,20 @@ "unified": "^10.0.0" } }, + "rehype-slug": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/rehype-slug/-/rehype-slug-5.1.0.tgz", + "integrity": "sha512-Gf91dJoXneiorNEnn+Phx97CO7oRMrpi+6r155tTxzGuLtm+QrI4cTwCa9e1rtePdL4i9tSO58PeSS6HWfgsiw==", + "requires": { + "@types/hast": "^2.0.0", + "github-slugger": "^2.0.0", + "hast-util-has-property": "^2.0.0", + "hast-util-heading-rank": "^2.0.0", + "hast-util-to-string": "^2.0.0", + "unified": "^10.0.0", + "unist-util-visit": "^4.0.0" + } + }, "rehype-stringify": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-9.0.3.tgz", @@ -10299,6 +10440,17 @@ "unified": "^10.0.0" } }, + "remark": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/remark/-/remark-14.0.2.tgz", + "integrity": "sha512-A3ARm2V4BgiRXaUo5K0dRvJ1lbogrbXnhkJRmD0yw092/Yl0kOCZt1k9ZeElEwkZsWGsMumz6qL5MfNJH9nOBA==", + "requires": { + "@types/mdast": "^3.0.0", + "remark-parse": "^10.0.0", + "remark-stringify": "^10.0.0", + "unified": "^10.0.0" + } + }, "remark-breaks": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-3.0.2.tgz", @@ -10557,6 +10709,16 @@ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==" }, + "strip-markdown": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/strip-markdown/-/strip-markdown-5.0.0.tgz", + "integrity": "sha512-PXSts6Ta9A/TwGxVVSRlQs1ukJTAwwtbip2OheJEjPyfykaQ4sJSTnQWjLTI2vYWNts/R/91/csagp15W8n9gA==", + "requires": { + "@types/mdast": "^3.0.0", + "@types/unist": "^2.0.6", + "unified": "^10.0.0" + } + }, "style-mod": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7622954b..cdcfaf54 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -36,14 +36,18 @@ "react-dom": "18.2.0", "react-toastify": "^9.1.1", "recoil": "^0.7.6", + "rehype": "^12.0.1", "rehype-highlight": "^6.0.0", "rehype-parse": "^8.0.4", "rehype-remark": "^9.1.2", + "rehype-slug": "^5.1.0", "rehype-stringify": "^9.0.3", + "remark": "^14.0.2", "remark-breaks": "^3.0.2", "remark-parse": "^10.0.1", "remark-rehype": "^10.1.0", "remark-stringify": "^10.0.2", + "strip-markdown": "^5.0.0", "styled-components": "^5.3.6", "styled-reset": "^4.4.2", "typescript": "4.8.4", diff --git a/frontend/pages/viewer/[...data].tsx b/frontend/pages/viewer/[...data].tsx index 347f098e..b5097ed5 100644 --- a/frontend/pages/viewer/[...data].tsx +++ b/frontend/pages/viewer/[...data].tsx @@ -13,7 +13,7 @@ import ViewerHead from '@components/viewer/ViewerHead'; import useFetch from '@hooks/useFetch'; import { IArticleBook, IBookScraps } from '@interfaces'; import { Flex, PageGNBHide, PageNoScrollWrapper } from '@styles/layout'; -import { articleToc, articleConversion } from '@utils/articleConversion'; +import { parseHeadings } from '@utils/toc'; interface ViewerProps { article: IArticleBook; @@ -82,7 +82,7 @@ export default function Viewer({ article }: ViewerProps) { diff --git a/frontend/utils/parser.ts b/frontend/utils/parser.ts index 42fea91b..6803d3a0 100644 --- a/frontend/utils/parser.ts +++ b/frontend/utils/parser.ts @@ -1,11 +1,15 @@ +import { rehype } from 'rehype'; import rehypeHighlight from 'rehype-highlight'; import rehypeParse from 'rehype-parse'; import rehypeRemark from 'rehype-remark'; +import rehypeSlug from 'rehype-slug'; import rehypeStringify from 'rehype-stringify'; +import { remark } from 'remark'; import remarkBreaks from 'remark-breaks'; import remarkParse from 'remark-parse'; import remarkRehype from 'remark-rehype'; import remarkStringify from 'remark-stringify'; +import stripMarkdown from 'strip-markdown'; import { unified } from 'unified'; export const markdown2html = (markdown: string) => { @@ -17,19 +21,28 @@ export const markdown2html = (markdown: string) => { .processSync(markdown) .toString(); - return unified() - .use(rehypeParse) + const htmlWithSyntaxHighlight = rehype() + .use(rehypeSlug) .use(rehypeHighlight, { ignoreMissing: true }) - .use(rehypeStringify) .processSync(html) .toString(); + + return htmlWithSyntaxHighlight; }; export const html2markdown = (html: string) => { - return unified() + const markdown = unified() .use(rehypeParse) .use(rehypeRemark) .use(remarkStringify) .processSync(html) .toString(); + + return markdown; +}; + +export const markdown2text = (markdown: string) => { + const text = remark().use(stripMarkdown).processSync(markdown).toString(); + + return text; }; diff --git a/frontend/utils/toc.ts b/frontend/utils/toc.ts new file mode 100644 index 00000000..505550f8 --- /dev/null +++ b/frontend/utils/toc.ts @@ -0,0 +1,27 @@ +export const parseHeadings = (content: string) => { + // 게시물 본문을 줄바꿈 기준으로 나누고, 제목 요소인 것만 저장 + const headings = content.split('\n').filter((line) => line.includes('# ')); + + // 예외처리 - 제목은 문자열 시작부터 #을 써야함 + const parsedHeadings = headings + .filter((heading) => heading.startsWith('#')) + .map((heading) => { + // #의 갯수에 따라 제목의 크기가 달라지므로 갯수를 센다. + let count = heading.match(/#/g)?.length; + + // 갯수에 따라 목차에 그릴때 들여쓰기 하기위해 *10을 함. + if (count) count *= 16; + + // 제목의 내용물만 꺼내기 위해 '# '을 기준으로 나누고, 백틱과 공백을 없애주고 count와 묶어서 리턴 + return { + title: heading.split('# ')[1].trim(), + count, + }; + }); + + return parsedHeadings; +}; + +export const text2link = (text: string) => { + return `#${text.replace(/ /g, '-').replace(/[^\uAC00-\uD7A30-9a-zA-Z_-]/g, '')}`; +};