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 13 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 @@ -9,24 +9,29 @@ exports[`inline code should be escaped 1`] = `
{
value: '<code>&lt;Head&gt;Test&lt;/Head&gt;</code>',
id: 'headtesthead',
children: []
children: [],
level: 3
}
]
],
level: 2
},
{
value: '<code>&lt;div /&gt;</code>',
id: 'div-',
children: []
children: [],
level: 2
},
{
value: '<code>&lt;div&gt; Test &lt;/div&gt;</code>',
id: 'div-test-div',
children: []
children: [],
level: 2
},
{
value: '<code>&lt;div&gt;&lt;i&gt;Test&lt;/i&gt;&lt;/div&gt;</code>',
id: 'divitestidiv',
children: []
children: [],
level: 2
}
];

Expand All @@ -51,24 +56,29 @@ exports[`non text phrasing content 1`] = `
{
value: '<strong>Importance</strong>',
id: 'importance',
children: []
children: [],
level: 3
}
]
],
level: 2
},
{
value: '<del>Strikethrough</del>',
id: 'strikethrough',
children: []
children: [],
level: 2
},
{
value: '<i>HTML</i>',
id: 'html',
children: []
children: [],
level: 2
},
{
value: '<code>inline.code()</code>',
id: 'inlinecode',
children: []
children: [],
level: 2
}
];

Expand All @@ -83,3 +93,55 @@ 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: [],
level: 6
}
],
level: 5
}
],
level: 4
}
],
level: 3
}
],
level: 2
}
];

# 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 @@ -41,7 +41,8 @@ test('text content', async () => {
{
value: 'Endi',
id: 'endi',
children: []
children: [],
level: 3
},
{
value: 'Endi',
Expand All @@ -50,14 +51,17 @@ test('text content', async () => {
{
value: 'Yangshun',
id: 'yangshun',
children: []
children: [],
level: 3
}
]
],
level: 2
},
{
value: 'I ♥ unicode.',
id: 'i--unicode',
children: []
children: [],
level: 2
}
];

Expand Down Expand Up @@ -87,7 +91,8 @@ test('should export even with existing name', async () => {
{
value: 'Thanos',
id: 'thanos',
children: []
children: [],
level: 2
},
{
value: 'Tony Stark',
Expand All @@ -96,9 +101,11 @@ test('should export even with existing name', async () => {
{
value: 'Avengers',
id: 'avengers',
children: []
children: [],
level: 3
}
]
],
level: 2
}
];

Expand All @@ -121,7 +128,8 @@ test('should export with custom name', async () => {
{
value: 'Endi',
id: 'endi',
children: []
children: [],
level: 3
},
{
value: 'Endi',
Expand All @@ -130,14 +138,17 @@ test('should export with custom name', async () => {
{
value: 'Yangshun',
id: 'yangshun',
children: []
children: [],
level: 3
}
]
],
level: 2
},
{
value: 'I ♥ unicode.',
id: 'i--unicode',
children: []
children: [],
level: 2
}
];

Expand Down Expand Up @@ -171,7 +182,8 @@ test('should insert below imports', async () => {
{
value: 'Title',
id: 'title',
children: []
children: [],
level: 2
},
{
value: 'Test',
Expand All @@ -180,9 +192,11 @@ test('should insert below imports', async () => {
{
value: 'Again',
id: 'again',
children: []
children: [],
level: 3
}
]
],
level: 2
}
];

Expand Down Expand Up @@ -210,3 +224,8 @@ test('empty headings', async () => {
"
`);
});

test('should process all heading levels', async () => {
const result = await processFixture('maximum-depth-6');
expect(result).toMatchSnapshot();
});
79 changes: 57 additions & 22 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,75 @@
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: [],
};

if (!headings.length || currentDepth >= child.depth) {
headings.push(entry);
current += 1;
currentDepth = child.depth;
} else {
headings[current].children.push(entry);
}
headings.push({
node: {
value: toValue(child),
id: child.data!.id as string,
children: [],
level: child.depth,
},
level: child.depth,
parentIndex: -1,
});
};

visit(node, 'heading', visitor);

return headings;
// Keep track of which previous index would be the current heading's direcy parent.
// Each entry <i> is the last index of the `headings` array at heading level <i>.
// We will modify these indices as we iterate through all headings.
// e.g. if an ### H3 was last seen at index 2, then prevIndexForLevel[3] === 2
// indices 0 and 1 will remain unused.
const prevIndexForLevel = Array(7).fill(-1);

headings.forEach((curr, currIndex) => {
// take the last seen index for each ancestor level. the highest
// index will be the direct ancestor of the current heading.
const ancestorLevelIndexes = prevIndexForLevel.slice(2, curr.level);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Math.max(...prevIndexForLevel.slice(2, 2)) = Math.max(...[]) = -Infinity, although that doesn't break the algorithm, does it really make sense?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Makes as much sense to me as having indexes for "levels" 0 and 1, I figure. It's nice to be explicit about only starting at index 2 🤔

Copy link
Collaborator

Choose a reason for hiding this comment

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

🤪 my head hurts 🤯 will have to take more time reviewing this.

We need more test cases to cover the edge cases

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I tested a few edge cases locally (never committed the changes) and it seemed to work somewhat well.

My personal take (as a drive-by contributor, feel free to ignore) is that while the TOC should never break, documents that have wonky heading formatting won't suffer too much from having a wonky TOC.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Agree, just added tests to ensure the behavior with wonky TOC stays the same over time :)

curr.parentIndex = Math.max(...ancestorLevelIndexes);
// mark that curr.level was last seen at the current index
prevIndexForLevel[curr.level] = currIndex;
});

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);
}
});

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 @@ -65,6 +65,8 @@ export type BlogPostFrontMatter = {
image?: string;
keywords?: string[];
hide_table_of_contents?: boolean;
toc_max_heading_level?: number;
toc_min_heading_level?: number;
/* eslint-enable camelcase */
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ const DocFrontMatterSchema = Joi.object<DocFrontMatter>({
pagination_label: Joi.string(),
custom_edit_url: URISchema.allow('', null),
parse_number_prefixes: Joi.boolean(),
toc_max_heading_level: Joi.number().min(2).max(6),
toc_min_heading_level: Joi.number().when('toc_max_heading_level', {
is: Joi.exist(),
then: Joi.number().min(2).max(Joi.ref('toc_max_heading_level')),
otherwise: Joi.number().min(2).max(6),
}),
}).unknown();

export function validateDocFrontMatter(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ declare module '@theme/DocItem' {
/* eslint-disable camelcase */
readonly hide_title?: boolean;
readonly hide_table_of_contents?: boolean;
readonly toc_max_heading_level?: number;
readonly toc_min_heading_level?: number;
/* eslint-enable camelcase */
};

Expand Down
Loading