Skip to content

Commit

Permalink
feat: add toc to article page
Browse files Browse the repository at this point in the history
  • Loading branch information
drodil committed Nov 29, 2024
1 parent 44cec53 commit 37b327f
Show file tree
Hide file tree
Showing 5 changed files with 215 additions and 61 deletions.
6 changes: 5 additions & 1 deletion plugins/qeta-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export const ArticleContent = (props: {
<MarkdownRenderer
content={postEntity.content}
className={styles.content}
showToc
/>
<Typography variant="h6">{t('common.comments')}</Typography>
<CommentSection
Expand Down
199 changes: 139 additions & 60 deletions plugins/qeta-react/src/components/MarkdownRenderer/MarkdownRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,29 @@ import {
import { IconButton, makeStyles, Tooltip, Typography } from '@material-ui/core';
import { findUserMentions } from '@drodil/backstage-plugin-qeta-common';
import gfm from 'remark-gfm';
import rehypeSlug from 'rehype-slug';
import rehypeToc, { HeadingNode, TextNode } from '@jsdevtools/rehype-toc';
import { EntityRefLink } from '@backstage/plugin-catalog-react';
import { useIsDarkTheme } from '../../hooks/useIsDarkTheme';
import { BackstageOverrides } from '@backstage/core-components';
import LinkIcon from '@material-ui/icons/Link';
import { alertApiRef, useApi } from '@backstage/core-plugin-api';
import { useTranslation } from '../../hooks';
import { Variant } from '@material-ui/core/styles/createTypography';
import GithubSlugger from 'github-slugger';
import { HtmlElementNode } from '@jsdevtools/rehype-toc/lib/types';
import { find } from 'unist-util-find';

export type QetaMarkdownContentClassKey = 'markdown';
const slugger = new GithubSlugger();

export type QetaMarkdownContentClassKey =
| 'markdown'
| 'header'
| 'tocHeader'
| 'toc'
| 'tocList'
| 'tocListItem'
| 'tocLink';

const useStyles = makeStyles(
theme => {
Expand Down Expand Up @@ -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' },
Expand All @@ -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);
Expand All @@ -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 = (
<Tooltip title={t('link.aria')}>
<IconButton
Expand Down Expand Up @@ -207,66 +249,103 @@ export const MarkdownRenderer = (props: {
}
}, []);

return (
<ReactMarkdown
remarkPlugins={[gfm]}
className={`${classes.markdown} ${mainClassName ?? ''}`.trim()}
components={{
h1: (p: any) => 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 (
<>
<EntityRefLink entityRef={mention.slice(1)} hideIcon />{' '}
</>
);
toc.children.unshift(heading);
return toc;
},
},
]);
}

return (
<>
<ReactMarkdown
remarkPlugins={[gfm]}
rehypePlugins={rehypePlugins}
className={`${classes.markdown} ${mainClassName ?? ''}`.trim()}
components={{
h1: (p: any) => 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 (
<>
<EntityRefLink entityRef={mention.slice(1)} hideIcon />{' '}
</>
);
}
return <>{word} </>;
});
});
});

return <p>{formatted}</p>;
},
code(p: any) {
const { children, className, node, ...rest } = p;
const match = /language-(\w+)/.exec(className || '');
return match ? (
<SyntaxHighlighter
{...rest}
PreTag="div"
language={match[1]}
style={darkTheme ? a11yDark : a11yLight}
showLineNumbers
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code {...rest} className={className}>
{children}
</code>
);
},
}}
>
{content}
</ReactMarkdown>
return <p>{formatted}</p>;
},
code(p: any) {
const { children, className, node, ...rest } = p;
const match = /language-(\w+)/.exec(className || '');
return match ? (
<SyntaxHighlighter
{...rest}
PreTag="div"
language={match[1]}
style={darkTheme ? a11yDark : a11yLight}
showLineNumbers
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code {...rest} className={className}>
{children}
</code>
);
},
}}
>
{content}
</ReactMarkdown>
</>
);
};
3 changes: 3 additions & 0 deletions plugins/qeta-react/src/translation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,9 @@ export const qetaTranslationRef = createTranslationRef({
commentList: {
deleteLink: 'delete',
},
markdown: {
toc: 'Table of contents',
},
commentSection: {
input: {
placeholder: 'Your comment',
Expand Down
Loading

0 comments on commit 37b327f

Please sign in to comment.