From 37b327fb64b2d96bb71d231af87d0680f5a2a563 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Fri, 29 Nov 2024 13:56:13 +0200 Subject: [PATCH] feat: add toc to article page --- plugins/qeta-react/package.json | 6 +- .../ArticleContent/ArticleContent.tsx | 1 + .../MarkdownRenderer/MarkdownRenderer.tsx | 199 ++++++++++++------ plugins/qeta-react/src/translation.ts | 3 + yarn.lock | 67 ++++++ 5 files changed, 215 insertions(+), 61 deletions(-) diff --git a/plugins/qeta-react/package.json b/plugins/qeta-react/package.json index e36d25b2..1c11ee6b 100644 --- a/plugins/qeta-react/package.json +++ b/plugins/qeta-react/package.json @@ -57,12 +57,14 @@ "@backstage/plugin-permission-react": "backstage:^", "@backstage/plugin-signals-react": "backstage:^", "@drodil/backstage-plugin-qeta-common": "workspace:^", + "@jsdevtools/rehype-toc": "^3.0.2", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.11.3", "@material-ui/lab": "4.0.0-alpha.61", "dataloader": "^2.2.2", "dompurify": "^3.1.3", "file-type": "16.5.4", + "github-slugger": "^2.0.0", "i18next": "^23.16.2", "lodash": "^4.17.21", "numeral": "^2.0.6", @@ -74,7 +76,9 @@ "react-use": "^17.4.0", "react-window": "^1.8.10", "recharts": "^2.13.0", - "remark-gfm": "^4.0.0" + "rehype-slug": "^6.0.0", + "remark-gfm": "^4.0.0", + "unist-util-find": "^3.0.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0", diff --git a/plugins/qeta-react/src/components/ArticleContent/ArticleContent.tsx b/plugins/qeta-react/src/components/ArticleContent/ArticleContent.tsx index 9c5df82f..386d9dba 100644 --- a/plugins/qeta-react/src/components/ArticleContent/ArticleContent.tsx +++ b/plugins/qeta-react/src/components/ArticleContent/ArticleContent.tsx @@ -105,6 +105,7 @@ export const ArticleContent = (props: { {t('common.comments')} { @@ -127,6 +141,32 @@ const useStyles = makeStyles( display: 'inline-block', }, }, + tocHeader: { + marginTop: '0.5em', + marginBottom: 0, + }, + toc: { + marginTop: '0.5em', + marginLeft: '0.2em', + paddingBottom: '1em', + borderBottom: `1px solid ${theme.palette.divider}`, + }, + tocList: { + marginLeft: '0 !important', + marginTop: '0.5em !important', + paddingInlineStart: '1em', + counterReset: 'item', + }, + tocListItem: { + display: 'block', + '&:before': { + content: 'counters(item, ".") " "', + counterIncrement: 'item', + }, + }, + tocLink: { + color: theme.palette.link, + }, }; }, { name: 'QetaMarkdownContent' }, @@ -143,12 +183,14 @@ const flatten = (text: string, child: any): string => { export const MarkdownRenderer = (props: { content: string; className?: string; + showToc?: boolean; }) => { - const { content, className: mainClassName } = props; + const { content, className: mainClassName, showToc } = props; const darkTheme = useIsDarkTheme(); const { t } = useTranslation(); const classes = useStyles(); const alertApi = useApi(alertApiRef); + slugger.reset(); const copyToClipboard = (slug: string) => { const url = new URL(window.location.href); @@ -167,7 +209,7 @@ export const MarkdownRenderer = (props: { const { node, children } = hProps; const childrenArray = React.Children.toArray(children); const text = childrenArray.reduce(flatten, ''); - const slug = text.toLocaleLowerCase('en-US').replace(/\W/g, '-'); + const slug = slugger.slug(text); const link = ( headingRenderer(p), - h2: (p: any) => headingRenderer(p), - h3: (p: any) => headingRenderer(p), - h4: (p: any) => headingRenderer(p), - h5: (p: any) => headingRenderer(p), - h6: (p: any) => headingRenderer(p), - p: (p: any) => { - const { children } = p; - const arr = React.Children.toArray(children); - const formatted = arr.map((child: any) => { - if (typeof child !== 'string') { - return child; - } - const mentions = findUserMentions(child); - if (mentions.length === 0) { - return child; - } + const rehypePlugins: import('unified').PluggableList = [[rehypeSlug]]; + if (showToc) { + rehypePlugins.push([ + rehypeToc, + { + cssClasses: { + toc: classes.toc, + list: classes.tocList, + listItem: classes.tocListItem, + link: classes.tocLink, + }, + customizeTOC: (toc: HtmlElementNode) => { + const listItems = find(toc, { tagName: 'li' }); + if (!toc.children || !listItems) { + return false; + } + const tocHeader: TextNode = { + type: 'text', + value: t('markdown.toc'), + }; + const heading: HeadingNode = { + type: 'element', + tagName: 'h3', + properties: {}, + children: [tocHeader], + }; - return child.split(' ').map((word: string) => { - const mention = mentions.find(m => word.includes(m)); - if (mention) { - return ( - <> - {' '} - - ); + toc.children.unshift(heading); + return toc; + }, + }, + ]); + } + + return ( + <> + headingRenderer(p), + h2: (p: any) => headingRenderer(p), + h3: (p: any) => headingRenderer(p), + h4: (p: any) => headingRenderer(p), + h5: (p: any) => headingRenderer(p), + h6: (p: any) => headingRenderer(p), + p: (p: any) => { + const { children } = p; + const arr = React.Children.toArray(children); + const formatted = arr.map((child: any) => { + if (typeof child !== 'string') { + return child; } - return <>{word} ; + const mentions = findUserMentions(child); + if (mentions.length === 0) { + return child; + } + + return child.split(' ').map((word: string) => { + const mention = mentions.find(m => word.includes(m)); + if (mention) { + return ( + <> + {' '} + + ); + } + return <>{word} ; + }); }); - }); - return

{formatted}

; - }, - code(p: any) { - const { children, className, node, ...rest } = p; - const match = /language-(\w+)/.exec(className || ''); - return match ? ( - - {String(children).replace(/\n$/, '')} - - ) : ( - - {children} - - ); - }, - }} - > - {content} -
+ return

{formatted}

; + }, + code(p: any) { + const { children, className, node, ...rest } = p; + const match = /language-(\w+)/.exec(className || ''); + return match ? ( + + {String(children).replace(/\n$/, '')} + + ) : ( + + {children} + + ); + }, + }} + > + {content} + + ); }; diff --git a/plugins/qeta-react/src/translation.ts b/plugins/qeta-react/src/translation.ts index 11665ca8..3122b2c6 100644 --- a/plugins/qeta-react/src/translation.ts +++ b/plugins/qeta-react/src/translation.ts @@ -156,6 +156,9 @@ export const qetaTranslationRef = createTranslationRef({ commentList: { deleteLink: 'delete', }, + markdown: { + toc: 'Table of contents', + }, commentSection: { input: { placeholder: 'Your comment', diff --git a/yarn.lock b/yarn.lock index 798bb92c..e888ceaf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4997,6 +4997,7 @@ __metadata: "@backstage/plugin-signals-react": "backstage:^" "@backstage/test-utils": "backstage:^" "@drodil/backstage-plugin-qeta-common": "workspace:^" + "@jsdevtools/rehype-toc": "npm:^3.0.2" "@material-ui/core": "npm:^4.12.2" "@material-ui/icons": "npm:^4.11.3" "@material-ui/lab": "npm:4.0.0-alpha.61" @@ -5011,6 +5012,7 @@ __metadata: dataloader: "npm:^2.2.2" dompurify: "npm:^3.1.3" file-type: "npm:16.5.4" + github-slugger: "npm:^2.0.0" i18next: "npm:^23.16.2" lodash: "npm:^4.17.21" numeral: "npm:^2.0.6" @@ -5022,7 +5024,9 @@ __metadata: react-use: "npm:^17.4.0" react-window: "npm:^1.8.10" recharts: "npm:^2.13.0" + rehype-slug: "npm:^6.0.0" remark-gfm: "npm:^4.0.0" + unist-util-find: "npm:^3.0.0" peerDependencies: react: ^17.0.0 || ^18.0.0 || ^19.0.0 react-router-dom: 6.0.0-beta.0 || ^6.3.0 @@ -6182,6 +6186,13 @@ __metadata: languageName: node linkType: hard +"@jsdevtools/rehype-toc@npm:^3.0.2": + version: 3.0.2 + resolution: "@jsdevtools/rehype-toc@npm:3.0.2" + checksum: 10c0/3c2ae6a20b101ef2df981142693c00e3f54e45bdd91e504b0faadb9925733b5a9ed2a41cfbd5c8e7ea21e63b4f09827d8e8ddd21b87e02357e990d7a03becef2 + languageName: node + linkType: hard + "@jsonjoy.com/base64@npm:^1.1.1": version: 1.1.2 resolution: "@jsonjoy.com/base64@npm:1.1.2" @@ -16371,6 +16382,13 @@ __metadata: languageName: node linkType: hard +"github-slugger@npm:^2.0.0": + version: 2.0.0 + resolution: "github-slugger@npm:2.0.0" + checksum: 10c0/21b912b6b1e48f1e5a50b2292b48df0ff6abeeb0691b161b3d93d84f4ae6b1acd6ae23702e914af7ea5d441c096453cf0f621b72d57893946618d21dd1a1c486 + languageName: node + linkType: hard + "glob-parent@npm:^5.1.2, glob-parent@npm:~5.1.2": version: 5.1.2 resolution: "glob-parent@npm:5.1.2" @@ -16834,6 +16852,15 @@ __metadata: languageName: node linkType: hard +"hast-util-heading-rank@npm:^3.0.0": + version: 3.0.0 + resolution: "hast-util-heading-rank@npm:3.0.0" + dependencies: + "@types/hast": "npm:^3.0.0" + checksum: 10c0/1879c84f629e73f1f13247ab349324355cd801363b44e3d46f763aa5c0ea3b42dcd47b46e5643a0502cf01a6b1fdb9208fd12852e44ca6c671b3e4bccf9369a1 + languageName: node + linkType: hard + "hast-util-parse-selector@npm:^2.0.0": version: 2.2.5 resolution: "hast-util-parse-selector@npm:2.2.5" @@ -16864,6 +16891,15 @@ __metadata: languageName: node linkType: hard +"hast-util-to-string@npm:^3.0.0": + version: 3.0.1 + resolution: "hast-util-to-string@npm:3.0.1" + dependencies: + "@types/hast": "npm:^3.0.0" + checksum: 10c0/b5fa1912a6ba6131affae52a0f4394406c4c0d23c2b0307f1d69988f1030c7bb830289303e67c5ad8f674f5f23a454c1dcd492c39e45a22c1f46d3c9bce5bd0c + languageName: node + linkType: hard + "hast-util-whitespace@npm:^2.0.0": version: 2.0.1 resolution: "hast-util-whitespace@npm:2.0.1" @@ -19839,6 +19875,13 @@ __metadata: languageName: node linkType: hard +"lodash.iteratee@npm:^4.0.0": + version: 4.7.0 + resolution: "lodash.iteratee@npm:4.7.0" + checksum: 10c0/19d9345c4ee9de99f7d292cb771aa6de35af8b90e62250cf098201c5bc97cc5a47db275d8ebd09d08e64d91b957082883d2ef64bff7be243174616d51d048aaa + languageName: node + linkType: hard + "lodash.kebabcase@npm:^4.1.1": version: 4.1.1 resolution: "lodash.kebabcase@npm:4.1.1" @@ -25122,6 +25165,19 @@ __metadata: languageName: node linkType: hard +"rehype-slug@npm:^6.0.0": + version: 6.0.0 + resolution: "rehype-slug@npm:6.0.0" + dependencies: + "@types/hast": "npm:^3.0.0" + github-slugger: "npm:^2.0.0" + hast-util-heading-rank: "npm:^3.0.0" + hast-util-to-string: "npm:^3.0.0" + unist-util-visit: "npm:^5.0.0" + checksum: 10c0/51303c33d039c271cabe62161b49fa737be488f70ced62f00c165e47a089a99de2060050385e5c00d0df83ed30c7fa1c79a51b78508702836aefa51f7e7a6760 + languageName: node + linkType: hard + "relateurl@npm:^0.2.7": version: 0.2.7 resolution: "relateurl@npm:0.2.7" @@ -28235,6 +28291,17 @@ __metadata: languageName: node linkType: hard +"unist-util-find@npm:^3.0.0": + version: 3.0.0 + resolution: "unist-util-find@npm:3.0.0" + dependencies: + "@types/unist": "npm:^3.0.0" + lodash.iteratee: "npm:^4.0.0" + unist-util-visit: "npm:^5.0.0" + checksum: 10c0/6c2ebd41730a80db12756a62dc8a9c15488378042271e94f39179ff42c07711eaad898adc2fe57afb69990c3973bf6c0b1c9b7c9b589d7760c095c01fd458bc1 + languageName: node + linkType: hard + "unist-util-generated@npm:^2.0.0": version: 2.0.1 resolution: "unist-util-generated@npm:2.0.1"