Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(v2): allow specifying TOC max depth (themeConfig + frontMatter) #5578

Merged
merged 36 commits into from
Sep 29, 2021
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
b9266ad
feat: add all TOC levels to MDX loader
erickzhao Sep 16, 2021
9fbef88
feat: add theme-level config for heading depth
erickzhao Sep 16, 2021
fda5103
test: add remark MDX loader test
erickzhao Sep 16, 2021
fd1b5f7
fix: limit maxDepth validation to H2 - H6
erickzhao Sep 16, 2021
0785423
refactor: set default `maxDepth` using `joi`
erickzhao Sep 17, 2021
7269c0f
refactor: `maxDepth` -> `maxHeadingLevel
erickzhao Sep 17, 2021
213289e
refactor: invert underlying TOC depth API
erickzhao Sep 19, 2021
9c363cc
refactor: make TOC algorithm level-aware
erickzhao Sep 20, 2021
f4973c2
feat: add support for per-doc TOC heading levels
erickzhao Sep 20, 2021
7a1eeeb
feat: support document-level heading levels for blog
erickzhao Sep 20, 2021
f3d61b2
fix: correct validation for toc level frontmatter
erickzhao Sep 20, 2021
d2b234a
fix: ensure TOC doesn't generate redundant DOM
erickzhao Sep 20, 2021
0a1e91e
perf: simpler TOC heading search alg
erickzhao Sep 21, 2021
6ff892d
docs: document heading level props for `TOCInline`
erickzhao Sep 21, 2021
2abd6a8
Update website/docs/guides/markdown-features/markdown-features-inline…
erickzhao Sep 21, 2021
a00e19e
docs: fix docs (again)
erickzhao Sep 21, 2021
7d39b26
create dedicated test file for heading searching logic: exhaustive t…
slorber Sep 24, 2021
d3fe29f
toc search: add real-world test
slorber Sep 24, 2021
787057a
fix test
slorber Sep 24, 2021
9ef5679
add dogfooding tests for toc min/max
slorber Sep 24, 2021
d9fbbd6
add test for min/max toc frontmatter
slorber Sep 24, 2021
c581d56
reverse min/max order
slorber Sep 24, 2021
8e8e311
add theme minHeadingLevel + tests
slorber Sep 24, 2021
73ad639
simpler TOC rendering logic
slorber Sep 24, 2021
90c0943
simplify TOC implementation (temp, WIP)
slorber Sep 24, 2021
40cdce6
reverse unnatural order for minHeadingLevel/maxHeadingLevel
slorber Sep 28, 2021
f8026f9
add TOC dogfooding tests to all content plugins
slorber Sep 28, 2021
ee8f31c
expose toc min/max heading level frontmatter to all 3 content plugins
slorber Sep 28, 2021
f222017
refactor blogLayout: accept toc ReactElement directly
slorber Sep 28, 2021
7e2c562
move toc utils to theme-common
slorber Sep 28, 2021
9e99c69
add tests for filterTOC
slorber Sep 28, 2021
11b78da
create new generic TOCItems component
slorber Sep 28, 2021
91248eb
useless css file copied
slorber Sep 28, 2021
e01ad12
fix toc highlighting className conflicts
slorber Sep 28, 2021
7988a43
update doc
slorber Sep 28, 2021
3db8779
fix types
slorber Sep 29, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,50 @@ exports[`non text phrasing content 1`] = `
## \`inline.code()\`
"
`;

exports[`should process all heading levels 1`] = `
"export const toc = [
{
value: 'Bravo',
id: 'bravo',
children: [
{
value: 'Charlie',
id: 'charlie',
children: [
{
value: 'Delta',
id: 'delta',
children: [
{
value: 'Echo',
id: 'echo',
children: [
{
value: 'Foxtrot',
id: 'foxtrot',
children: []
}
]
}
]
}
]
}
]
}
];

# Alpha

## Bravo

### Charlie

#### Delta

##### Echo

###### Foxtrot
"
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Alpha

## Bravo

### Charlie

#### Delta

##### Echo

###### Foxtrot
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import vfile from 'to-vfile';
import plugin from '../index';
import headings from '../../headings/index';

const processFixture = async (name, options) => {
const processFixture = async (name, options?) => {
const path = join(__dirname, 'fixtures', `${name}.md`);
const file = await vfile.read(path);
const result = await remark()
Expand Down Expand Up @@ -210,3 +210,8 @@ test('empty headings', async () => {
"
`);
});

test('should process all heading levels', async () => {
const result = await processFixture('maximum-depth-6');
expect(result).toMatchSnapshot();
});
85 changes: 64 additions & 21 deletions packages/docusaurus-mdx-loader/src/remark/toc/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,40 +8,83 @@
import toString from 'mdast-util-to-string';
import visit, {Visitor} from 'unist-util-visit';
import {toValue} from '../utils';
import type {TOCItem as TOC} from '@docusaurus/types';
import type {TOCItem} from '@docusaurus/types';
import type {Node} from 'unist';
import type {Heading} from 'mdast';

// Visit all headings. We `slug` all headings (to account for
// duplicates), but only take h2 and h3 headings.
export default function search(node: Node): TOC[] {
const headings: TOC[] = [];
let current = -1;
let currentDepth = 0;
// Intermediate interface for TOC algorithm
interface SearchItem {
node: TOCItem;
level: number;
parentIndex: number;
}

/**
*
* Generate a TOC AST from the raw Markdown contents
*/
export default function search(node: Node): TOCItem[] {
const headings: SearchItem[] = [];

const visitor: Visitor<Heading> = (child, _index, parent) => {
const value = toString(child);

if (parent !== node || !value || child.depth > 3 || child.depth < 2) {
// depth:1 headings are titles and not included in the TOC
if (parent !== node || !value || child.depth < 2) {
slorber marked this conversation as resolved.
Show resolved Hide resolved
return;
}

const entry: TOC = {
value: toValue(child),
id: child.data!.id as string,
children: [],
};
headings.push({
node: {
value: toValue(child),
id: child.data!.id as string,
children: [],
},
level: child.depth,
parentIndex: -1,
});
};

if (!headings.length || currentDepth >= child.depth) {
headings.push(entry);
current += 1;
currentDepth = child.depth;
} else {
headings[current].children.push(entry);
visit(node, 'heading', visitor);

const getParentIndex = (prevParentIndex: number, currLevel: number) => {
erickzhao marked this conversation as resolved.
Show resolved Hide resolved
let parentIndex = prevParentIndex;
// We start at the parent of the previous heading
// Recurse through its ancestors until the current heading would be a child
while (parentIndex >= 0 && currLevel < headings[parentIndex].level) {
parentIndex = headings[parentIndex].parentIndex;
}
return parentIndex >= 0 ? headings[parentIndex].parentIndex : parentIndex;
};

visit(node, 'heading', visitor);
// Assign the correct `parentIndex` for each heading.
headings.forEach((curr, currIndex) => {
if (currIndex > 0) {
const prevIndex = currIndex - 1;
const prev = headings[prevIndex];
if (curr.level > prev.level) {
curr.parentIndex = prevIndex;
} else if (curr.level < prev.level) {
curr.parentIndex = getParentIndex(prev.parentIndex, curr.level);
} else {
curr.parentIndex = prev.parentIndex;
}
}
});
Copy link
Collaborator

@Josh-Cena Josh-Cena Sep 16, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see a better solution here: record "the nearest h2~h6 node indices" as an array closestNode[] where closestNode[level] is the index of the closest node with level h<level> and closestNode[0] = closestNode[1] = -1.

On each node you find the value in closestNode with a smaller level than curr.level and the largest index possible, and then update closestNode:

const closestNode = Array(7).fill(-1);
headings.forEach((curr, currIndex) => {
  curr.parentIndex = Math.max(...closestNode.slice(0, curr.level));
  closestNode[curr.level] = currIndex;
});

Of course this would mean a little inline comments, but the code is much shorter and also more straightforward IMO


const rootNodeIndexes: number[] = [];

// For a given parentIndex, add each Node into that parent's `children` array
headings.forEach((heading, i) => {
if (heading.parentIndex >= 0) {
headings[heading.parentIndex].node.children.push(heading.node);
} else {
rootNodeIndexes.push(i);
}
});

return headings;
const toc = headings
.filter((_, k) => rootNodeIndexes.includes(k)) // only return root nodes
.map((heading) => heading.node); // only return Node, no metadata
return toc;
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ describe('themeConfig', () => {
},
copyright: `Copyright © ${new Date().getFullYear()} Facebook, Inc. Built with Docusaurus.`,
},
tableOfContents: {
maxDepth: 4,
},
};
expect(testValidateThemeConfig(userConfig)).toEqual({
...DEFAULT_CONFIG,
Expand Down
15 changes: 13 additions & 2 deletions packages/docusaurus-theme-classic/src/theme/TOC/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import useTOCHighlight, {
} from '@theme/hooks/useTOCHighlight';
import type {TOCProps, TOCHeadingsProps} from '@theme/TOC';
import styles from './styles.module.css';
import {useThemeConfig} from '@docusaurus/theme-common';

const LINK_CLASS_NAME = 'table-of-contents__link';

Expand All @@ -24,10 +25,16 @@ const TOC_HIGHLIGHT_PARAMS: TOCHighlightParams = {
export function TOCHeadings({
toc,
isChild,
depth: headingLevel,
}: TOCHeadingsProps): JSX.Element | null {
const {tableOfContents} = useThemeConfig();
const maxDepth = tableOfContents?.maxDepth ?? 3;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a cleaner way of handling default config values? I couldn't find where exactly.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you've already added a default value in Joi validation, there's no need to duplicate it here (Joi validation actually mutates the object)
Would it make sense to add a minDepth option as well? Would the user sometimes not want to have h2 headings?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you've already added a default value in Joi validation, there's no need to duplicate it here (Joi validation actually mutates the object)

Weird because I get the following error when I don't have the null coalescing part:

TypeError: Cannot destructure property 'maxDepth' of 'tableOfContents' as it is undefined.

image

git diff of the code change:

diff --git a/packages/docusaurus-theme-classic/src/theme/TOC/index.tsx b/packages/docusaurus-theme-classic/src/theme/TOC/index.tsx
index 83c3d2930..4dfe9038c 100644
--- a/packages/docusaurus-theme-classic/src/theme/TOC/index.tsx
+++ b/packages/docusaurus-theme-classic/src/theme/TOC/index.tsx
@@ -28,7 +28,7 @@ export function TOCHeadings({
   depth: headingLevel,
 }: TOCHeadingsProps): JSX.Element | null {
   const {tableOfContents} = useThemeConfig();
-  const maxDepth = tableOfContents?.maxDepth ?? 3;
+  const { maxDepth } = tableOfContents;
   if (!toc.length) {
     return null;
   }

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to add a minDepth option as well? Would the user sometimes not want to have h2 headings?

Personally I think this feels like an anti-pattern since you generally want to utilize all markdown heading levels. I could see it for some niche cases like having a single h2 on a page and not wanting to nest all of your h3 inside, but feels funny to have it on site-wide in the theme config.

Happy to implement it if others feel differently.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Weird because I get the following error when I don't have the null coalescing part

Oh yeah, you need to provide a default value for the entire tableOfContents config object as well

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but feels funny to have it on site-wide in the theme config.

I'm thinking about adding a per-doc option minLevel as well, and just for the sake of uniformity, I think a site-level config is worth it. But seems @slorber doesn't particularly like too many APIs, so let's see🤷‍♂️

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you've already added a default value in Joi validation, there's no need to duplicate it here (Joi validation actually mutates the object)

Addressed this in 0785423

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For consistency I'd rather have min/max everywhere, even if min is less likely to be used and mostly for weird use-cases

if (!toc.length) {
slorber marked this conversation as resolved.
Show resolved Hide resolved
return null;
}
if (headingLevel > maxDepth) {
return null;
}
return (
<ul
className={
Expand All @@ -42,7 +49,11 @@ export function TOCHeadings({
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{__html: heading.value}}
/>
<TOCHeadings isChild toc={heading.children} />
<TOCHeadings
isChild
toc={heading.children}
depth={headingLevel + 1}
slorber marked this conversation as resolved.
Show resolved Hide resolved
/>
</li>
))}
</ul>
Expand All @@ -53,7 +64,7 @@ function TOC({toc}: TOCProps): JSX.Element {
useTOCHighlight(TOC_HIGHLIGHT_PARAMS);
return (
<div className={clsx(styles.tableOfContents, 'thin-scrollbar')}>
<TOCHeadings toc={toc} />
<TOCHeadings toc={toc} depth={2} />
</div>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export default function TOCCollapsible({
lazy
className={styles.tocCollapsibleContent}
collapsed={collapsed}>
<TOCHeadings toc={toc} />
<TOCHeadings toc={toc} depth={2} />
</Collapsible>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,26 @@ function isInViewportTopHalf(boundingRect: DOMRect) {
return boundingRect.top > 0 && boundingRect.bottom < window.innerHeight / 2;
}

function getAnchors() {
// For toc highlighting, we only consider h2/h3 anchors
const selector = '.anchor.anchor__h2, .anchor.anchor__h3';
return Array.from(document.querySelectorAll(selector)) as HTMLElement[];
}
function getAnchors(maxDepth: number = 3) {
const selectors = [];

for (let i = 2; i <= maxDepth; i += 1) {
selectors.push(`.anchor.anchor__h${i}`);
}

function getActiveAnchor({
anchorTopOffset,
}: {
anchorTopOffset: number;
}): Element | null {
const anchors = getAnchors();
return Array.from(
document.querySelectorAll(selectors.join(', ')),
) as HTMLElement[];
}

function getActiveAnchor(
anchors: HTMLElement[],
{
anchorTopOffset,
}: {
anchorTopOffset: number;
},
): Element | null {
// Naming is hard
// The "nextVisibleAnchor" is the first anchor that appear under the viewport top boundary
// Note: it does not mean this anchor is visible yet, but if user continues scrolling down, it will be the first to become visible
Expand Down Expand Up @@ -100,6 +107,7 @@ function useTOCHighlight(params: Params): void {
const lastActiveLinkRef = useRef<HTMLAnchorElement | undefined>(undefined);

const anchorTopOffsetRef = useAnchorTopOffsetRef();
const {tableOfContents} = useThemeConfig();

useEffect(() => {
const {linkClassName, linkActiveClassName} = params;
Expand All @@ -118,7 +126,8 @@ function useTOCHighlight(params: Params): void {

function updateActiveLink() {
const links = getLinks(linkClassName);
const activeAnchor = getActiveAnchor({
const anchors = getAnchors(tableOfContents?.maxDepth);
erickzhao marked this conversation as resolved.
Show resolved Hide resolved
const activeAnchor = getActiveAnchor(anchors, {
anchorTopOffset: anchorTopOffsetRef.current,
});
const activeLink = links.find(
Expand Down
1 change: 1 addition & 0 deletions packages/docusaurus-theme-classic/src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,7 @@ declare module '@theme/TOC' {
export type TOCHeadingsProps = {
readonly toc: readonly TOCItem[];
readonly isChild?: boolean;
readonly depth: number;
};

export const TOCHeadings: (props: TOCHeadingsProps) => JSX.Element;
Expand Down
10 changes: 10 additions & 0 deletions packages/docusaurus-theme-classic/src/validateThemeConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ const DEFAULT_CONFIG = {
items: [],
},
hideableSidebar: false,
tableOfContents: {
maxDepth: 3,
},
};
exports.DEFAULT_CONFIG = DEFAULT_CONFIG;

Expand Down Expand Up @@ -329,6 +332,13 @@ const ThemeConfigSchema = Joi.object({
'any.unknown':
'The themeConfig.sidebarCollapsible has been moved to docs plugin options. See: https://docusaurus.io/docs/api/plugins/@docusaurus/plugin-content-docs',
}),
tableOfContents: Joi.object({
maxDepth: Joi.number()
.integer()
.min(2)
.max(6)
.default(DEFAULT_CONFIG.tableOfContents.maxDepth),
}),
});

export {ThemeConfigSchema};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ export type Footer = {
links: FooterLinks[];
};

export type TableOfContents = {
maxDepth: number;
};

export type ThemeConfig = {
docs: {
versionPersistence: DocsVersionPersistence;
Expand All @@ -104,6 +108,7 @@ export type ThemeConfig = {
image?: string;
metadatas: Array<Record<string, string>>;
sidebarCollapsible: boolean;
tableOfContents: TableOfContents;
};

export function useThemeConfig(): ThemeConfig {
Expand Down
26 changes: 26 additions & 0 deletions website/docs/api/themes/theme-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,32 @@ module.exports = {
};
```

## Table of Contents {#table-of-contents}
slorber marked this conversation as resolved.
Show resolved Hide resolved

You can adjust the table of contents via `themeConfig.tableOfContents`.

<small>

| Name | Type | Default | Description |
| ---------- | -------- | ------- | -------------------------------------- |
| `maxDepth` | `number` | `3` | Max heading level displayed in the TOC |
slorber marked this conversation as resolved.
Show resolved Hide resolved

</small>

Example configuration:

```js title="docusaurus.config.js"
module.exports = {
themeConfig: {
// highlight-start
tableOfContents: {
maxDepth: 5,
},
// highlight-end
},
};
```

## Hooks {#hooks}

### `useThemeContext` {#usethemecontext}
Expand Down