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

* feat: add all TOC levels to MDX loader

* feat: add theme-level config for heading depth

* test: add remark MDX loader test

* fix: limit maxDepth validation to H2 - H6

* refactor: set default `maxDepth` using `joi`

* refactor: `maxDepth` -> `maxHeadingLevel

* refactor: invert underlying TOC depth API

* refactor: make TOC algorithm level-aware

* feat: add support for per-doc TOC heading levels

* feat: support document-level heading levels for blog

* fix: correct validation for toc level frontmatter

* fix: ensure TOC doesn't generate redundant DOM

* perf: simpler TOC heading search alg

* docs: document heading level props for `TOCInline`

* Update website/docs/guides/markdown-features/markdown-features-inline-toc.mdx

Co-authored-by: HonkingGoose <34918129+HonkingGoose@users.noreply.github.com>

* docs: fix docs (again)

* create dedicated  test file for heading searching logic: exhaustive tests will be simpler to write

* toc search: add real-world test

* fix test

* add dogfooding tests for toc min/max

* add test for min/max toc frontmatter

* reverse min/max order

* add theme minHeadingLevel + tests

* simpler TOC rendering logic

* simplify TOC implementation (temp, WIP)

* reverse unnatural order for minHeadingLevel/maxHeadingLevel

* add TOC dogfooding tests to all content plugins

* expose toc min/max heading level frontmatter to all 3 content plugins

* refactor blogLayout: accept toc ReactElement directly

* move toc utils to theme-common

* add tests for filterTOC

* create new generic TOCItems component

* useless css file copied

* fix toc highlighting className conflicts

* update doc

* fix types

Co-authored-by: HonkingGoose <34918129+HonkingGoose@users.noreply.github.com>
Co-authored-by: slorber <lorber.sebastien@gmail.com>
This commit is contained in:
Erick Zhao 2021-09-29 02:19:11 -07:00 committed by GitHub
parent caba1e4908
commit c86dfbda61
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
50 changed files with 1522 additions and 214 deletions

View file

@ -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
}
];
@ -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
}
];

View file

@ -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()
@ -41,7 +41,8 @@ test('text content', async () => {
{
value: 'Endi',
id: 'endi',
children: []
children: [],
level: 3
},
{
value: 'Endi',
@ -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
}
];
@ -87,7 +91,8 @@ test('should export even with existing name', async () => {
{
value: 'Thanos',
id: 'thanos',
children: []
children: [],
level: 2
},
{
value: 'Tony Stark',
@ -96,9 +101,11 @@ test('should export even with existing name', async () => {
{
value: 'Avengers',
id: 'avengers',
children: []
children: [],
level: 3
}
]
],
level: 2
}
];
@ -121,7 +128,8 @@ test('should export with custom name', async () => {
{
value: 'Endi',
id: 'endi',
children: []
children: [],
level: 3
},
{
value: 'Endi',
@ -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
}
];
@ -171,7 +182,8 @@ test('should insert below imports', async () => {
{
value: 'Title',
id: 'title',
children: []
children: [],
level: 2
},
{
value: 'Test',
@ -180,9 +192,11 @@ test('should insert below imports', async () => {
{
value: 'Again',
id: 'again',
children: []
children: [],
level: 3
}
]
],
level: 2
}
];

View file

@ -0,0 +1,182 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import remark from 'remark';
import mdx from 'remark-mdx';
import search from '../search';
import headings from '../../headings/index';
const getHeadings = async (mdText: string) => {
const node = remark().parse(mdText);
const result = await remark().use(headings).use(mdx).run(node);
return search(result);
};
test('should process all heading levels', async () => {
const md = `
# Alpha
## Bravo
### Charlie
#### Delta
##### Echo
###### Foxtrot
`;
expect(await getHeadings(md)).toEqual([
{
children: [
{
children: [
{
children: [
{
children: [
{
children: [],
id: 'foxtrot',
level: 6,
value: 'Foxtrot',
},
],
id: 'echo',
level: 5,
value: 'Echo',
},
],
id: 'delta',
level: 4,
value: 'Delta',
},
],
id: 'charlie',
level: 3,
value: 'Charlie',
},
],
id: 'bravo',
level: 2,
value: 'Bravo',
},
]);
});
test('should process real-world well-formatted md', async () => {
const md = `
# title
some text
## section 1
some text
### subsection 1-1
some text
#### subsection 1-1-1
some text
#### subsection 1-1-2
some text
### subsection 1-2
some text
### subsection 1-3
some text
## section 2
some text
### subsection 2-1
some text
### subsection 2-1
some text
## section 3
some text
### subsection 3-1
some text
### subsection 3-2
some text
`;
expect(await getHeadings(md)).toEqual([
{
children: [
{
children: [
{
children: [],
id: 'subsection-1-1-1',
level: 4,
value: 'subsection 1-1-1',
},
{
children: [],
id: 'subsection-1-1-2',
level: 4,
value: 'subsection 1-1-2',
},
],
id: 'subsection-1-1',
level: 3,
value: 'subsection 1-1',
},
{children: [], id: 'subsection-1-2', level: 3, value: 'subsection 1-2'},
{children: [], id: 'subsection-1-3', level: 3, value: 'subsection 1-3'},
],
id: 'section-1',
level: 2,
value: 'section 1',
},
{
children: [
{children: [], id: 'subsection-2-1', level: 3, value: 'subsection 2-1'},
{
children: [],
id: 'subsection-2-1-1',
level: 3,
value: 'subsection 2-1',
},
],
id: 'section-2',
level: 2,
value: 'section 2',
},
{
children: [
{children: [], id: 'subsection-3-1', level: 3, value: 'subsection 3-1'},
{children: [], id: 'subsection-3-2', level: 3, value: 'subsection 3-2'},
],
id: 'section-3',
level: 2,
value: 'section 3',
},
]);
});

View file

@ -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) {
return;
}
const entry: TOC = {
headings.push({
node: {
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);
}
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);
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;
}

View file

@ -10,6 +10,7 @@ import {
URISchema,
validateFrontMatter,
FrontMatterTagsSchema,
FrontMatterTOCHeadingLevels,
} from '@docusaurus/utils-validation';
import type {FrontMatterTag} from '@docusaurus/utils';
@ -65,6 +66,8 @@ export type BlogPostFrontMatter = {
image?: string;
keywords?: string[];
hide_table_of_contents?: boolean;
toc_min_heading_level?: number;
toc_max_heading_level?: number;
/* eslint-enable camelcase */
};
@ -111,6 +114,8 @@ const BlogFrontMatterSchema = Joi.object<BlogPostFrontMatter>({
image: URISchema,
keywords: Joi.array().items(Joi.string().required()),
hide_table_of_contents: Joi.boolean(),
...FrontMatterTOCHeadingLevels,
}).messages({
'deprecate.error':
'{#label} blog frontMatter field is deprecated. Please use {#alternative} instead.',

View file

@ -10,7 +10,7 @@ import {DocFrontMatter} from '../types';
import escapeStringRegexp from 'escape-string-regexp';
function testField(params: {
fieldName: keyof DocFrontMatter;
prefix: string;
validFrontMatters: DocFrontMatter[];
convertibleFrontMatter?: [
ConvertableFrontMatter: Record<string, unknown>,
@ -21,14 +21,13 @@ function testField(params: {
ErrorMessage: string,
][];
}) {
describe(`"${params.fieldName}" field`, () => {
test('accept valid values', () => {
test(`[${params.prefix}] accept valid values`, () => {
params.validFrontMatters.forEach((frontMatter) => {
expect(validateDocFrontMatter(frontMatter)).toEqual(frontMatter);
});
});
test('convert valid values', () => {
test(`[${params.prefix}] convert valid values`, () => {
params.convertibleFrontMatter?.forEach(
([convertibleFrontMatter, convertedFrontMatter]) => {
expect(validateDocFrontMatter(convertibleFrontMatter)).toEqual(
@ -38,7 +37,7 @@ function testField(params: {
);
});
test('throw error for values', () => {
test(`[${params.prefix}] throw error for values`, () => {
params.invalidFrontMatters?.forEach(([frontMatter, message]) => {
try {
validateDocFrontMatter(frontMatter);
@ -56,7 +55,6 @@ function testField(params: {
}
});
});
});
}
describe('validateDocFrontMatter', () => {
@ -73,7 +71,7 @@ describe('validateDocFrontMatter', () => {
describe('validateDocFrontMatter id', () => {
testField({
fieldName: 'id',
prefix: 'id',
validFrontMatters: [{id: '123'}, {id: 'unique_id'}],
invalidFrontMatters: [[{id: ''}, 'is not allowed to be empty']],
});
@ -81,7 +79,7 @@ describe('validateDocFrontMatter id', () => {
describe('validateDocFrontMatter title', () => {
testField({
fieldName: 'title',
prefix: 'title',
validFrontMatters: [
// See https://github.com/facebook/docusaurus/issues/4591#issuecomment-822372398
{title: ''},
@ -92,7 +90,7 @@ describe('validateDocFrontMatter title', () => {
describe('validateDocFrontMatter hide_title', () => {
testField({
fieldName: 'hide_title',
prefix: 'hide_title',
validFrontMatters: [{hide_title: true}, {hide_title: false}],
convertibleFrontMatter: [
[{hide_title: 'true'}, {hide_title: true}],
@ -108,7 +106,7 @@ describe('validateDocFrontMatter hide_title', () => {
describe('validateDocFrontMatter hide_table_of_contents', () => {
testField({
fieldName: 'hide_table_of_contents',
prefix: 'hide_table_of_contents',
validFrontMatters: [
{hide_table_of_contents: true},
{hide_table_of_contents: false},
@ -127,7 +125,7 @@ describe('validateDocFrontMatter hide_table_of_contents', () => {
describe('validateDocFrontMatter keywords', () => {
testField({
fieldName: 'keywords',
prefix: 'keywords',
validFrontMatters: [
{keywords: ['hello']},
{keywords: ['hello', 'world']},
@ -144,7 +142,7 @@ describe('validateDocFrontMatter keywords', () => {
describe('validateDocFrontMatter image', () => {
testField({
fieldName: 'image',
prefix: 'image',
validFrontMatters: [
{image: 'https://docusaurus.io/blog/image.png'},
{image: '/absolute/image.png'},
@ -158,7 +156,7 @@ describe('validateDocFrontMatter image', () => {
describe('validateDocFrontMatter description', () => {
testField({
fieldName: 'description',
prefix: 'description',
validFrontMatters: [
// See https://github.com/facebook/docusaurus/issues/4591#issuecomment-822372398
{description: ''},
@ -169,7 +167,7 @@ describe('validateDocFrontMatter description', () => {
describe('validateDocFrontMatter slug', () => {
testField({
fieldName: 'slug',
prefix: 'slug',
validFrontMatters: [
{slug: '/'},
{slug: 'slug'},
@ -186,7 +184,7 @@ describe('validateDocFrontMatter slug', () => {
describe('validateDocFrontMatter sidebar_label', () => {
testField({
fieldName: 'sidebar_label',
prefix: 'sidebar_label',
validFrontMatters: [{sidebar_label: 'Awesome docs'}],
invalidFrontMatters: [[{sidebar_label: ''}, 'is not allowed to be empty']],
});
@ -194,7 +192,7 @@ describe('validateDocFrontMatter sidebar_label', () => {
describe('validateDocFrontMatter sidebar_position', () => {
testField({
fieldName: 'sidebar_position',
prefix: 'sidebar_position',
validFrontMatters: [
{sidebar_position: -5},
{sidebar_position: -3.5},
@ -212,7 +210,7 @@ describe('validateDocFrontMatter sidebar_position', () => {
describe('validateDocFrontMatter custom_edit_url', () => {
testField({
fieldName: 'custom_edit_url',
prefix: 'custom_edit_url',
validFrontMatters: [
// See https://github.com/demisto/content-docs/pull/616#issuecomment-827087566
{custom_edit_url: ''},
@ -226,7 +224,7 @@ describe('validateDocFrontMatter custom_edit_url', () => {
describe('validateDocFrontMatter parse_number_prefixes', () => {
testField({
fieldName: 'parse_number_prefixes',
prefix: 'parse_number_prefixes',
validFrontMatters: [
{parse_number_prefixes: true},
{parse_number_prefixes: false},
@ -245,7 +243,7 @@ describe('validateDocFrontMatter parse_number_prefixes', () => {
describe('validateDocFrontMatter tags', () => {
testField({
fieldName: 'tags',
prefix: 'tags',
validFrontMatters: [{}, {tags: undefined}, {tags: ['tag1', 'tag2']}],
convertibleFrontMatter: [[{tags: ['tag1', 42]}, {tags: ['tag1', '42']}]],
invalidFrontMatters: [
@ -263,3 +261,99 @@ describe('validateDocFrontMatter tags', () => {
],
});
});
describe('validateDocFrontMatter toc_min_heading_level', () => {
testField({
prefix: 'toc_min_heading_level',
validFrontMatters: [
{},
{toc_min_heading_level: undefined},
{toc_min_heading_level: 2},
{toc_min_heading_level: 3},
{toc_min_heading_level: 4},
{toc_min_heading_level: 5},
{toc_min_heading_level: 6},
],
convertibleFrontMatter: [
[{toc_min_heading_level: '2'}, {toc_min_heading_level: 2}],
],
invalidFrontMatters: [
[
{toc_min_heading_level: 1},
'"toc_min_heading_level" must be greater than or equal to 2',
],
[
{toc_min_heading_level: 7},
'"toc_min_heading_level" must be less than or equal to 6',
],
[
{toc_min_heading_level: 'hello'},
'"toc_min_heading_level" must be a number',
],
[
{toc_min_heading_level: true},
'"toc_min_heading_level" must be a number',
],
],
});
});
describe('validateDocFrontMatter toc_max_heading_level', () => {
testField({
prefix: 'toc_max_heading_level',
validFrontMatters: [
{},
{toc_max_heading_level: undefined},
{toc_max_heading_level: 2},
{toc_max_heading_level: 3},
{toc_max_heading_level: 4},
{toc_max_heading_level: 5},
{toc_max_heading_level: 6},
],
convertibleFrontMatter: [
[{toc_max_heading_level: '2'}, {toc_max_heading_level: 2}],
],
invalidFrontMatters: [
[
{toc_max_heading_level: 1},
'"toc_max_heading_level" must be greater than or equal to 2',
],
[
{toc_max_heading_level: 7},
'"toc_max_heading_level" must be less than or equal to 6',
],
[
{toc_max_heading_level: 'hello'},
'"toc_max_heading_level" must be a number',
],
[
{toc_max_heading_level: true},
'"toc_max_heading_level" must be a number',
],
],
});
});
describe('validateDocFrontMatter toc min/max consistency', () => {
testField({
prefix: 'toc min/max',
validFrontMatters: [
{},
{toc_min_heading_level: undefined, toc_max_heading_level: undefined},
{toc_min_heading_level: 2, toc_max_heading_level: 2},
{toc_min_heading_level: 2, toc_max_heading_level: 6},
{toc_min_heading_level: 2, toc_max_heading_level: 3},
{toc_min_heading_level: 3, toc_max_heading_level: 3},
],
invalidFrontMatters: [
[
{toc_min_heading_level: 4, toc_max_heading_level: 3},
'"toc_min_heading_level" must be less than or equal to ref:toc_max_heading_level',
],
[
{toc_min_heading_level: 6, toc_max_heading_level: 2},
'"toc_min_heading_level" must be less than or equal to ref:toc_max_heading_level',
],
],
});
});

View file

@ -9,6 +9,7 @@ import {
JoiFrontMatter as Joi, // Custom instance for frontmatter
URISchema,
FrontMatterTagsSchema,
FrontMatterTOCHeadingLevels,
validateFrontMatter,
} from '@docusaurus/utils-validation';
import {DocFrontMatter} from './types';
@ -32,6 +33,7 @@ const DocFrontMatterSchema = Joi.object<DocFrontMatter>({
pagination_label: Joi.string(),
custom_edit_url: URISchema.allow('', null),
parse_number_prefixes: Joi.boolean(),
...FrontMatterTOCHeadingLevels,
}).unknown();
export function validateDocFrontMatter(

View file

@ -94,6 +94,8 @@ declare module '@theme/DocItem' {
/* eslint-disable camelcase */
readonly hide_title?: boolean;
readonly hide_table_of_contents?: boolean;
readonly toc_min_heading_level?: number;
readonly toc_max_heading_level?: number;
/* eslint-enable camelcase */
};

View file

@ -220,6 +220,8 @@ export type DocFrontMatter = {
pagination_label?: string;
custom_edit_url?: string | null;
parse_number_prefixes?: boolean;
toc_min_heading_level?: number;
toc_max_heading_level?: number;
/* eslint-enable camelcase */
};

View file

@ -121,6 +121,7 @@ export default function pluginContentPages(
encodePath(fileToPath(relativeSource)),
]);
if (isMarkdownSource(relativeSource)) {
// TODO: missing frontmatter validation/normalization here
return {
type: 'mdx',
permalink,

View file

@ -18,8 +18,11 @@ declare module '@theme/MDXPage' {
readonly title: string;
readonly description: string;
readonly wrapperClassName?: string;
// eslint-disable-next-line camelcase
/* eslint-disable camelcase */
readonly hide_table_of_contents?: string;
readonly toc_min_heading_level?: number;
readonly toc_max_heading_level?: number;
/* eslint-enable camelcase */
};
readonly metadata: {readonly permalink: string};
readonly toc: readonly TOCItem[];

View file

@ -91,6 +91,10 @@ describe('themeConfig', () => {
},
copyright: `Copyright © ${new Date().getFullYear()} Facebook, Inc. Built with Docusaurus.`,
},
tableOfContents: {
minHeadingLevel: 2,
maxHeadingLevel: 5,
},
};
expect(testValidateThemeConfig(userConfig)).toEqual({
...DEFAULT_CONFIG,
@ -453,3 +457,131 @@ describe('themeConfig', () => {
});
});
});
describe('themeConfig tableOfContents', () => {
test('toc undefined', () => {
const tableOfContents = undefined;
expect(testValidateThemeConfig({tableOfContents})).toEqual({
...DEFAULT_CONFIG,
tableOfContents: {
minHeadingLevel: DEFAULT_CONFIG.tableOfContents.minHeadingLevel,
maxHeadingLevel: DEFAULT_CONFIG.tableOfContents.maxHeadingLevel,
},
});
});
test('toc empty', () => {
const tableOfContents = {};
expect(testValidateThemeConfig({tableOfContents})).toEqual({
...DEFAULT_CONFIG,
tableOfContents: {
minHeadingLevel: DEFAULT_CONFIG.tableOfContents.minHeadingLevel,
maxHeadingLevel: DEFAULT_CONFIG.tableOfContents.maxHeadingLevel,
},
});
});
test('toc with min', () => {
const tableOfContents = {
minHeadingLevel: 3,
};
expect(testValidateThemeConfig({tableOfContents})).toEqual({
...DEFAULT_CONFIG,
tableOfContents: {
minHeadingLevel: 3,
maxHeadingLevel: DEFAULT_CONFIG.tableOfContents.maxHeadingLevel,
},
});
});
test('toc with max', () => {
const tableOfContents = {
maxHeadingLevel: 5,
};
expect(testValidateThemeConfig({tableOfContents})).toEqual({
...DEFAULT_CONFIG,
tableOfContents: {
minHeadingLevel: DEFAULT_CONFIG.tableOfContents.minHeadingLevel,
maxHeadingLevel: 5,
},
});
});
test('toc with min 2.5', () => {
const tableOfContents = {
minHeadingLevel: 2.5,
};
expect(() =>
testValidateThemeConfig({tableOfContents}),
).toThrowErrorMatchingInlineSnapshot(
`"\\"tableOfContents.minHeadingLevel\\" must be an integer"`,
);
});
test('toc with max 2.5', () => {
const tableOfContents = {
maxHeadingLevel: 2.5,
};
expect(() =>
testValidateThemeConfig({tableOfContents}),
).toThrowErrorMatchingInlineSnapshot(
`"\\"tableOfContents.maxHeadingLevel\\" must be an integer"`,
);
});
test('toc with min 1', () => {
const tableOfContents = {
minHeadingLevel: 1,
};
expect(() =>
testValidateThemeConfig({tableOfContents}),
).toThrowErrorMatchingInlineSnapshot(
`"\\"tableOfContents.minHeadingLevel\\" must be greater than or equal to 2"`,
);
});
test('toc with min 7', () => {
const tableOfContents = {
minHeadingLevel: 7,
};
expect(() =>
testValidateThemeConfig({tableOfContents}),
).toThrowErrorMatchingInlineSnapshot(
`"\\"tableOfContents.minHeadingLevel\\" must be less than or equal to ref:maxHeadingLevel"`,
);
});
test('toc with max 1', () => {
const tableOfContents = {
maxHeadingLevel: 1,
};
expect(() =>
testValidateThemeConfig({tableOfContents}),
).toThrowErrorMatchingInlineSnapshot(
`"\\"tableOfContents.maxHeadingLevel\\" must be greater than or equal to 2"`,
);
});
test('toc with max 7', () => {
const tableOfContents = {
maxHeadingLevel: 7,
};
expect(() =>
testValidateThemeConfig({tableOfContents}),
).toThrowErrorMatchingInlineSnapshot(
`"\\"tableOfContents.maxHeadingLevel\\" must be less than or equal to 6"`,
);
});
test('toc with bad min 5 + max 3', () => {
const tableOfContents = {
minHeadingLevel: 5,
maxHeadingLevel: 3,
};
expect(() =>
testValidateThemeConfig({tableOfContents}),
).toThrowErrorMatchingInlineSnapshot(
`"\\"tableOfContents.minHeadingLevel\\" must be less than or equal to ref:maxHeadingLevel"`,
);
});
});

View file

@ -7,10 +7,8 @@
import React from 'react';
import clsx from 'clsx';
import Layout from '@theme/Layout';
import BlogSidebar from '@theme/BlogSidebar';
import TOC from '@theme/TOC';
import type {Props} from '@theme/BlogLayout';
@ -36,11 +34,7 @@ function BlogLayout(props: Props): JSX.Element {
itemType="http://schema.org/Blog">
{children}
</main>
{toc && (
<div className="col col--2">
<TOC toc={toc} />
</div>
)}
{toc && <div className="col col--2">{toc}</div>}
</div>
</div>
</Layout>

View file

@ -12,10 +12,16 @@ import BlogPostItem from '@theme/BlogPostItem';
import BlogPostPaginator from '@theme/BlogPostPaginator';
import type {Props} from '@theme/BlogPostPage';
import {ThemeClassNames} from '@docusaurus/theme-common';
import TOC from '@theme/TOC';
function BlogPostPage(props: Props): JSX.Element {
const {content: BlogPostContents, sidebar} = props;
const {frontMatter, assets, metadata} = BlogPostContents;
const {
// TODO this frontmatter is not validated/normalized, it's the raw user-provided one. We should expose normalized one too!
frontMatter,
assets,
metadata,
} = BlogPostContents;
const {
title,
description,
@ -25,7 +31,12 @@ function BlogPostPage(props: Props): JSX.Element {
tags,
authors,
} = metadata;
const {hide_table_of_contents: hideTableOfContents, keywords} = frontMatter;
const {
hide_table_of_contents: hideTableOfContents,
keywords,
toc_min_heading_level: tocMinHeadingLevel,
toc_max_heading_level: tocMaxHeadingLevel,
} = frontMatter;
const image = assets.image ?? frontMatter.image;
@ -35,9 +46,15 @@ function BlogPostPage(props: Props): JSX.Element {
pageClassName={ThemeClassNames.page.blogPostPage}
sidebar={sidebar}
toc={
!hideTableOfContents && BlogPostContents.toc
? BlogPostContents.toc
: undefined
!hideTableOfContents &&
BlogPostContents.toc &&
BlogPostContents.toc.length > 0 ? (
<TOC
toc={BlogPostContents.toc}
minHeadingLevel={tocMinHeadingLevel}
maxHeadingLevel={tocMaxHeadingLevel}
/>
) : undefined
}>
<Seo
// TODO refactor needed: it's a bit annoying but Seo MUST be inside BlogLayout

View file

@ -7,7 +7,6 @@
import React from 'react';
import clsx from 'clsx';
import useWindowSize from '@theme/hooks/useWindowSize';
import DocPaginator from '@theme/DocPaginator';
import DocVersionBanner from '@theme/DocVersionBanner';
@ -17,7 +16,6 @@ import DocItemFooter from '@theme/DocItemFooter';
import TOC from '@theme/TOC';
import TOCCollapsible from '@theme/TOCCollapsible';
import {MainHeading} from '@theme/Heading';
import styles from './styles.module.css';
import {ThemeClassNames} from '@docusaurus/theme-common';
@ -29,6 +27,8 @@ export default function DocItem(props: Props): JSX.Element {
keywords,
hide_title: hideTitle,
hide_table_of_contents: hideTableOfContents,
toc_min_heading_level: tocMinHeadingLevel,
toc_max_heading_level: tocMaxHeadingLevel,
} = frontMatter;
const {description, title} = metadata;
@ -71,6 +71,8 @@ export default function DocItem(props: Props): JSX.Element {
{canRenderTOC && (
<TOCCollapsible
toc={DocContent.toc}
minHeadingLevel={tocMinHeadingLevel}
maxHeadingLevel={tocMaxHeadingLevel}
className={clsx(
ThemeClassNames.docs.docTocMobile,
styles.tocMobile,
@ -100,6 +102,8 @@ export default function DocItem(props: Props): JSX.Element {
<div className="col col--3">
<TOC
toc={DocContent.toc}
minHeadingLevel={tocMinHeadingLevel}
maxHeadingLevel={tocMaxHeadingLevel}
className={ThemeClassNames.docs.docTocDesktop}
/>
</div>

View file

@ -18,7 +18,11 @@ import styles from './styles.module.css';
function MDXPage(props: Props): JSX.Element {
const {content: MDXPageContent} = props;
const {frontMatter, metadata} = MDXPageContent;
const {
// TODO this frontmatter is not validated/normalized, it's the raw user-provided one. We should expose normalized one too!
frontMatter,
metadata,
} = MDXPageContent;
const {
title,
@ -44,7 +48,11 @@ function MDXPage(props: Props): JSX.Element {
</div>
{!hideTableOfContents && MDXPageContent.toc && (
<div className="col col--2">
<TOC toc={MDXPageContent.toc} />
<TOC
toc={MDXPageContent.toc}
minHeadingLevel={frontMatter.toc_min_heading_level}
maxHeadingLevel={frontMatter.toc_max_heading_level}
/>
</div>
)}
</div>

View file

@ -7,53 +7,23 @@
import React from 'react';
import clsx from 'clsx';
import useTOCHighlight, {
Params as TOCHighlightParams,
} from '@theme/hooks/useTOCHighlight';
import type {TOCProps, TOCHeadingsProps} from '@theme/TOC';
import type {TOCProps} from '@theme/TOC';
import TOCItems from '@theme/TOCItems';
import styles from './styles.module.css';
const LINK_CLASS_NAME = 'table-of-contents__link';
// Using a custom className
// This prevents TOC highlighting to highlight TOCInline/TOCCollapsible by mistake
const LINK_CLASS_NAME = 'table-of-contents__link toc-highlight';
const LINK_ACTIVE_CLASS_NAME = 'table-of-contents__link--active';
const TOC_HIGHLIGHT_PARAMS: TOCHighlightParams = {
linkClassName: LINK_CLASS_NAME,
linkActiveClassName: 'table-of-contents__link--active',
};
/* eslint-disable jsx-a11y/control-has-associated-label */
export function TOCHeadings({
toc,
isChild,
}: TOCHeadingsProps): JSX.Element | null {
if (!toc.length) {
return null;
}
function TOC({className, ...props}: TOCProps): JSX.Element {
return (
<ul
className={
isChild ? '' : 'table-of-contents table-of-contents__left-border'
}>
{toc.map((heading) => (
<li key={heading.id}>
<a
href={`#${heading.id}`}
className={LINK_CLASS_NAME}
// Developer provided the HTML, so assume it's safe.
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{__html: heading.value}}
<div className={clsx(styles.tableOfContents, 'thin-scrollbar', className)}>
<TOCItems
{...props}
linkClassName={LINK_CLASS_NAME}
linkActiveClassName={LINK_ACTIVE_CLASS_NAME}
/>
<TOCHeadings isChild toc={heading.children} />
</li>
))}
</ul>
);
}
function TOC({toc}: TOCProps): JSX.Element {
useTOCHighlight(TOC_HIGHLIGHT_PARAMS);
return (
<div className={clsx(styles.tableOfContents, 'thin-scrollbar')}>
<TOCHeadings toc={toc} />
</div>
);
}

View file

@ -10,12 +10,14 @@ import clsx from 'clsx';
import Translate from '@docusaurus/Translate';
import {useCollapsible, Collapsible} from '@docusaurus/theme-common';
import styles from './styles.module.css';
import {TOCHeadings} from '@theme/TOC';
import TOCItems from '@theme/TOCItems';
import type {TOCCollapsibleProps} from '@theme/TOCCollapsible';
export default function TOCCollapsible({
toc,
className,
minHeadingLevel,
maxHeadingLevel,
}: TOCCollapsibleProps): JSX.Element {
const {collapsed, toggleCollapsed} = useCollapsible({
initialState: true,
@ -45,7 +47,11 @@ export default function TOCCollapsible({
lazy
className={styles.tocCollapsibleContent}
collapsed={collapsed}>
<TOCHeadings toc={toc} />
<TOCItems
toc={toc}
minHeadingLevel={minHeadingLevel}
maxHeadingLevel={maxHeadingLevel}
/>
</Collapsible>
</div>
);

View file

@ -9,40 +9,22 @@ import React from 'react';
import clsx from 'clsx';
import type {TOCInlineProps} from '@theme/TOCInline';
import styles from './styles.module.css';
import {TOCItem} from '@docusaurus/types';
import TOCItems from '@theme/TOCItems';
/* eslint-disable jsx-a11y/control-has-associated-label */
function HeadingsInline({
function TOCInline({
toc,
isChild,
}: {
toc: readonly TOCItem[];
isChild?: boolean;
}) {
if (!toc.length) {
return null;
}
return (
<ul className={isChild ? '' : 'table-of-contents'}>
{toc.map((heading) => (
<li key={heading.id}>
<a
href={`#${heading.id}`}
// Developer provided the HTML, so assume it's safe.
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{__html: heading.value}}
/>
<HeadingsInline isChild toc={heading.children} />
</li>
))}
</ul>
);
}
function TOCInline({toc}: TOCInlineProps): JSX.Element {
minHeadingLevel,
maxHeadingLevel,
}: TOCInlineProps): JSX.Element {
return (
<div className={clsx(styles.tableOfContentsInline)}>
<HeadingsInline toc={toc} />
<TOCItems
toc={toc}
minHeadingLevel={minHeadingLevel}
maxHeadingLevel={maxHeadingLevel}
className="table-of-contents"
linkClassName=""
/>
</div>
);
}

View file

@ -0,0 +1,96 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {useMemo} from 'react';
import type {TOCItemsProps} from '@theme/TOCItems';
import {TOCItem} from '@docusaurus/types';
import {
TOCHighlightConfig,
useThemeConfig,
useTOCFilter,
useTOCHighlight,
} from '@docusaurus/theme-common';
// Recursive component rendering the toc tree
/* eslint-disable jsx-a11y/control-has-associated-label */
function TOCItemList({
toc,
className = 'table-of-contents table-of-contents__left-border',
linkClassName = 'table-of-contents__link',
isChild,
}: {
readonly toc: readonly TOCItem[];
readonly className: string;
readonly linkClassName: string;
readonly isChild?: boolean;
}): JSX.Element | null {
if (!toc.length) {
return null;
}
return (
<ul className={isChild ? '' : className}>
{toc.map((heading) => (
<li key={heading.id}>
<a
href={`#${heading.id}`}
className={linkClassName}
// Developer provided the HTML, so assume it's safe.
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{__html: heading.value}}
/>
<TOCItemList
isChild
toc={heading.children}
className={className}
linkClassName={linkClassName}
/>
</li>
))}
</ul>
);
}
export default function TOCItems({
toc,
className = 'table-of-contents table-of-contents__left-border',
linkClassName = 'table-of-contents__link',
linkActiveClassName = undefined,
minHeadingLevel: minHeadingLevelOption,
maxHeadingLevel: maxHeadingLevelOption,
...props
}: TOCItemsProps): JSX.Element | null {
const themeConfig = useThemeConfig();
const minHeadingLevel =
minHeadingLevelOption ?? themeConfig.tableOfContents.minHeadingLevel;
const maxHeadingLevel =
maxHeadingLevelOption ?? themeConfig.tableOfContents.maxHeadingLevel;
const tocFiltered = useTOCFilter({toc, minHeadingLevel, maxHeadingLevel});
const tocHighlightConfig: TOCHighlightConfig | undefined = useMemo(() => {
if (linkClassName && linkActiveClassName) {
return {
linkClassName,
linkActiveClassName,
minHeadingLevel,
maxHeadingLevel,
};
}
return undefined;
}, [linkClassName, linkActiveClassName, minHeadingLevel, maxHeadingLevel]);
useTOCHighlight(tocHighlightConfig);
return (
<TOCItemList
toc={tocFiltered}
className={className}
linkClassName={linkClassName}
{...props}
/>
);
}

View file

@ -75,13 +75,13 @@ declare module '@theme/BlogPostPaginator' {
}
declare module '@theme/BlogLayout' {
import type {ReactNode} from 'react';
import type {Props as LayoutProps} from '@theme/Layout';
import type {BlogSidebar} from '@theme/BlogSidebar';
import type {TOCItem} from '@docusaurus/types';
export type Props = LayoutProps & {
readonly sidebar?: BlogSidebar;
readonly toc?: readonly TOCItem[];
readonly toc?: ReactNode;
};
const BlogLayout: (props: Props) => JSX.Element;
@ -255,14 +255,6 @@ declare module '@theme/hooks/useThemeContext' {
export default function useThemeContext(): ThemeContextProps;
}
declare module '@theme/hooks/useTOCHighlight' {
export type Params = {
linkClassName: string;
linkActiveClassName: string;
};
export default function useTOCHighlight(params: Params): void;
}
declare module '@theme/hooks/useUserPreferencesContext' {
export type UserPreferencesContextProps = {
tabGroupChoices: {readonly [groupId: string]: string};
@ -578,17 +570,38 @@ declare module '@theme/ThemeProvider' {
export default ThemeProvider;
}
declare module '@theme/TOCItems' {
import type {TOCItem} from '@docusaurus/types';
export type TOCItemsProps = {
readonly toc: readonly TOCItem[];
readonly minHeadingLevel?: number;
readonly maxHeadingLevel?: number;
readonly className?: string;
readonly linkClassName?: string;
readonly linkActiveClassName?: string;
};
export default function TOCItems(props: TOCItemsProps): JSX.Element;
}
declare module '@theme/TOC' {
import type {TOCItem} from '@docusaurus/types';
// minHeadingLevel only exists as a per-doc option,
// and won't have a default set by Joi. See TOC, TOCInline,
// TOCCollapsible for examples
export type TOCProps = {
readonly toc: readonly TOCItem[];
readonly minHeadingLevel?: number;
readonly maxHeadingLevel?: number;
readonly className?: string;
};
export type TOCHeadingsProps = {
readonly toc: readonly TOCItem[];
readonly isChild?: boolean;
readonly minHeadingLevel?: number;
readonly maxHeadingLevel?: number;
};
export const TOCHeadings: (props: TOCHeadingsProps) => JSX.Element;
@ -602,6 +615,8 @@ declare module '@theme/TOCInline' {
export type TOCInlineProps = {
readonly toc: readonly TOCItem[];
readonly minHeadingLevel?: number;
readonly maxHeadingLevel?: number;
};
const TOCInline: (props: TOCInlineProps) => JSX.Element;
@ -613,6 +628,8 @@ declare module '@theme/TOCCollapsible' {
export type TOCCollapsibleProps = {
readonly className?: string;
readonly minHeadingLevel?: number;
readonly maxHeadingLevel?: number;
readonly toc: readonly TOCItem[];
};

View file

@ -41,6 +41,10 @@ const DEFAULT_CONFIG = {
items: [],
},
hideableSidebar: false,
tableOfContents: {
minHeadingLevel: 2,
maxHeadingLevel: 3,
},
};
exports.DEFAULT_CONFIG = DEFAULT_CONFIG;
@ -329,6 +333,24 @@ 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({
minHeadingLevel: Joi.number()
.default(DEFAULT_CONFIG.tableOfContents.minHeadingLevel)
.when('maxHeadingLevel', {
is: Joi.exist(),
then: Joi.number()
.integer()
.min(2)
.max(6)
.max(Joi.ref('maxHeadingLevel')),
otherwise: Joi.number().integer().min(2).max(6),
}),
maxHeadingLevel: Joi.number()
.integer()
.min(2)
.max(6)
.default(DEFAULT_CONFIG.tableOfContents.maxHeadingLevel),
}).default(DEFAULT_CONFIG.tableOfContents),
});
export {ThemeConfigSchema};

View file

@ -73,3 +73,8 @@ export {translateTagsPageTitle, listTagsByLetters} from './utils/tagsUtils';
export type {TagLetterEntry} from './utils/tagsUtils';
export {useHistoryPopHandler} from './utils/historyUtils';
export {default as useTOCHighlight} from './utils/useTOCHighlight';
export type {TOCHighlightConfig} from './utils/useTOCHighlight';
export {useTOCFilter} from './utils/tocUtils';

View file

@ -0,0 +1,197 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {TOCItem} from '@docusaurus/types';
import {filterTOC} from '../tocUtils';
describe('filterTOC', () => {
test('filter a toc with all heading levels', () => {
const toc: TOCItem[] = [
{
id: 'alpha',
level: 1,
value: 'alpha',
children: [
{
id: 'bravo',
level: 2,
value: 'Bravo',
children: [
{
id: 'charlie',
level: 3,
value: 'Charlie',
children: [
{
id: 'delta',
level: 4,
value: 'Delta',
children: [
{
id: 'echo',
level: 5,
value: 'Echo',
children: [
{
id: 'foxtrot',
level: 6,
value: 'Foxtrot',
children: [],
},
],
},
],
},
],
},
],
},
],
},
];
expect(filterTOC({toc, minHeadingLevel: 2, maxHeadingLevel: 2})).toEqual([
{
id: 'bravo',
level: 2,
value: 'Bravo',
children: [],
},
]);
expect(filterTOC({toc, minHeadingLevel: 3, maxHeadingLevel: 3})).toEqual([
{
id: 'charlie',
level: 3,
value: 'Charlie',
children: [],
},
]);
expect(filterTOC({toc, minHeadingLevel: 2, maxHeadingLevel: 3})).toEqual([
{
id: 'bravo',
level: 2,
value: 'Bravo',
children: [
{
id: 'charlie',
level: 3,
value: 'Charlie',
children: [],
},
],
},
]);
expect(filterTOC({toc, minHeadingLevel: 2, maxHeadingLevel: 4})).toEqual([
{
id: 'bravo',
level: 2,
value: 'Bravo',
children: [
{
id: 'charlie',
level: 3,
value: 'Charlie',
children: [
{
id: 'delta',
level: 4,
value: 'Delta',
children: [],
},
],
},
],
},
]);
});
// It's not 100% clear exactly how the TOC should behave under weird heading levels provided by the user
// Adding a test so that behavior stays the same over time
test('filter invalid heading levels (but possible) TOC', () => {
const toc: TOCItem[] = [
{
id: 'charlie',
level: 3,
value: 'Charlie',
children: [],
},
{
id: 'bravo',
level: 2,
value: 'Bravo',
children: [
{
id: 'delta',
level: 4,
value: 'Delta',
children: [],
},
],
},
];
expect(filterTOC({toc, minHeadingLevel: 2, maxHeadingLevel: 2})).toEqual([
{
id: 'bravo',
level: 2,
value: 'Bravo',
children: [],
},
]);
expect(filterTOC({toc, minHeadingLevel: 3, maxHeadingLevel: 3})).toEqual([
{
id: 'charlie',
level: 3,
value: 'Charlie',
children: [],
},
]);
expect(filterTOC({toc, minHeadingLevel: 4, maxHeadingLevel: 4})).toEqual([
{
id: 'delta',
level: 4,
value: 'Delta',
children: [],
},
]);
expect(filterTOC({toc, minHeadingLevel: 2, maxHeadingLevel: 3})).toEqual([
{
id: 'charlie',
level: 3,
value: 'Charlie',
children: [],
},
{
id: 'bravo',
level: 2,
value: 'Bravo',
children: [],
},
]);
expect(filterTOC({toc, minHeadingLevel: 3, maxHeadingLevel: 4})).toEqual([
{
id: 'charlie',
level: 3,
value: 'Charlie',
children: [],
},
{
id: 'delta',
level: 4,
value: 'Delta',
children: [],
},
]);
});
});

View file

@ -0,0 +1,54 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {useMemo} from 'react';
import {TOCItem} from '@docusaurus/types';
type FilterTOCParam = {
toc: readonly TOCItem[];
minHeadingLevel: number;
maxHeadingLevel: number;
};
export function filterTOC({
toc,
minHeadingLevel,
maxHeadingLevel,
}: FilterTOCParam): TOCItem[] {
function isValid(item: TOCItem) {
return item.level >= minHeadingLevel && item.level <= maxHeadingLevel;
}
return toc.flatMap((item) => {
const filteredChildren = filterTOC({
toc: item.children,
minHeadingLevel,
maxHeadingLevel,
});
if (isValid(item)) {
return [
{
...item,
children: filteredChildren,
},
];
} else {
return filteredChildren;
}
});
}
// Memoize potentially expensive filtering logic
export function useTOCFilter({
toc,
minHeadingLevel,
maxHeadingLevel,
}: FilterTOCParam): readonly TOCItem[] {
return useMemo(() => {
return filterTOC({toc, minHeadingLevel, maxHeadingLevel});
}, [toc, minHeadingLevel, maxHeadingLevel]);
}

View file

@ -5,9 +5,13 @@
* LICENSE file in the root directory of this source tree.
*/
import {Params} from '@theme/hooks/useTOCHighlight';
import {useEffect, useRef} from 'react';
import {useThemeConfig} from '@docusaurus/theme-common';
import {useThemeConfig} from './useThemeConfig';
/*
TODO make the hardcoded theme-classic classnames configurable
(or add them to ThemeClassNames?)
*/
// If the anchor has no height and is just a "marker" in the dom; we'll use the parent (normally the link text) rect boundaries instead
function getVisibleBoundingClientRect(element: HTMLElement): DOMRect {
@ -25,19 +29,30 @@ 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';
function getAnchors({
minHeadingLevel,
maxHeadingLevel,
}: {
minHeadingLevel: number;
maxHeadingLevel: number;
}) {
const selectors = [];
for (let i = minHeadingLevel; i <= maxHeadingLevel; i += 1) {
selectors.push(`.anchor.anchor__h${i}`);
}
const selector = selectors.join(', ');
return Array.from(document.querySelectorAll(selector)) as HTMLElement[];
}
function getActiveAnchor({
function getActiveAnchor(
anchors: HTMLElement[],
{
anchorTopOffset,
}: {
anchorTopOffset: number;
}): Element | null {
const anchors = getAnchors();
},
): 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
@ -96,13 +111,30 @@ function useAnchorTopOffsetRef() {
return anchorTopOffsetRef;
}
function useTOCHighlight(params: Params): void {
export type TOCHighlightConfig = {
linkClassName: string;
linkActiveClassName: string;
minHeadingLevel: number;
maxHeadingLevel: number;
};
function useTOCHighlight(config: TOCHighlightConfig | undefined): void {
const lastActiveLinkRef = useRef<HTMLAnchorElement | undefined>(undefined);
const anchorTopOffsetRef = useAnchorTopOffsetRef();
useEffect(() => {
const {linkClassName, linkActiveClassName} = params;
if (!config) {
// no-op, highlighting is disabled
return () => {};
}
const {
linkClassName,
linkActiveClassName,
minHeadingLevel,
maxHeadingLevel,
} = config;
function updateLinkActiveClass(link: HTMLAnchorElement, active: boolean) {
if (active) {
@ -118,7 +150,8 @@ function useTOCHighlight(params: Params): void {
function updateActiveLink() {
const links = getLinks(linkClassName);
const activeAnchor = getActiveAnchor({
const anchors = getAnchors({minHeadingLevel, maxHeadingLevel});
const activeAnchor = getActiveAnchor(anchors, {
anchorTopOffset: anchorTopOffsetRef.current,
});
const activeLink = links.find(
@ -139,7 +172,7 @@ function useTOCHighlight(params: Params): void {
document.removeEventListener('scroll', updateActiveLink);
document.removeEventListener('resize', updateActiveLink);
};
}, [params, anchorTopOffsetRef]);
}, [config, anchorTopOffsetRef]);
}
export default useTOCHighlight;

View file

@ -85,6 +85,11 @@ export type Footer = {
links: FooterLinks[];
};
export type TableOfContents = {
minHeadingLevel: number;
maxHeadingLevel: number;
};
export type ThemeConfig = {
docs: {
versionPersistence: DocsVersionPersistence;
@ -104,6 +109,7 @@ export type ThemeConfig = {
image?: string;
metadatas: Array<Record<string, string>>;
sidebarCollapsible: boolean;
tableOfContents: TableOfContents;
};
export function useThemeConfig(): ThemeConfig {

View file

@ -421,4 +421,5 @@ export interface TOCItem {
readonly value: string;
readonly id: string;
readonly children: TOCItem[];
readonly level: number;
}

View file

@ -80,3 +80,14 @@ export const FrontMatterTagsSchema = JoiFrontMatter.array()
'array.base':
'{{#label}} does not look like a valid FrontMatter Yaml array.',
});
export const FrontMatterTOCHeadingLevels = {
toc_min_heading_level: JoiFrontMatter.number().when('toc_max_heading_level', {
is: JoiFrontMatter.exist(),
then: JoiFrontMatter.number()
.min(2)
.max(JoiFrontMatter.ref('toc_max_heading_level')),
otherwise: JoiFrontMatter.number().min(2).max(6),
}),
toc_max_heading_level: JoiFrontMatter.number().min(2).max(6),
};

View file

@ -0,0 +1,17 @@
---
title: Blog TOC FrontMatter tests
authors:
- slorber
toc_min_heading_level: 2
toc_max_heading_level: 4
---
<!-- truncate -->
import Content, {
toc as ContentToc,
} from '@site/_dogfooding/_partials/toc-tests.md';
<Content />
export const toc = ContentToc;

View file

@ -0,0 +1,12 @@
---
toc_min_heading_level: 2
toc_max_heading_level: 2
---
import Content, {
toc as ContentToc,
} from '@site/_dogfooding/_partials/toc-tests.md';
<Content />
export const toc = ContentToc;

View file

@ -0,0 +1,12 @@
---
toc_min_heading_level: 2
toc_max_heading_level: 3
---
import Content, {
toc as ContentToc,
} from '@site/_dogfooding/_partials/toc-tests.md';
<Content />
export const toc = ContentToc;

View file

@ -0,0 +1,12 @@
---
toc_min_heading_level: 2
toc_max_heading_level: 4
---
import Content, {
toc as ContentToc,
} from '@site/_dogfooding/_partials/toc-tests.md';
<Content />
export const toc = ContentToc;

View file

@ -0,0 +1,12 @@
---
toc_min_heading_level: 2
toc_max_heading_level: 5
---
import Content, {
toc as ContentToc,
} from '@site/_dogfooding/_partials/toc-tests.md';
<Content />
export const toc = ContentToc;

View file

@ -0,0 +1,12 @@
---
toc_min_heading_level: 3
toc_max_heading_level: 5
---
import Content, {
toc as ContentToc,
} from '@site/_dogfooding/_partials/toc-tests.md';
<Content />
export const toc = ContentToc;

View file

@ -0,0 +1,12 @@
---
toc_min_heading_level: 3
# toc_max_heading_level:
---
import Content, {
toc as ContentToc,
} from '@site/_dogfooding/_partials/toc-tests.md';
<Content />
export const toc = ContentToc;

View file

@ -0,0 +1,12 @@
---
toc_min_heading_level: 4
toc_max_heading_level: 5
---
import Content, {
toc as ContentToc,
} from '@site/_dogfooding/_partials/toc-tests.md';
<Content />
export const toc = ContentToc;

View file

@ -0,0 +1,12 @@
---
toc_min_heading_level: 5
toc_max_heading_level: 5
---
import Content, {
toc as ContentToc,
} from '@site/_dogfooding/_partials/toc-tests.md';
<Content />
export const toc = ContentToc;

View file

@ -0,0 +1,12 @@
---
# toc_min_heading_level:
toc_max_heading_level: 5
---
import Content, {
toc as ContentToc,
} from '@site/_dogfooding/_partials/toc-tests.md';
<Content />
export const toc = ContentToc;

View file

@ -0,0 +1,12 @@
---
# toc_min_heading_level:
# toc_max_heading_level:
---
import Content, {
toc as ContentToc,
} from '@site/_dogfooding/_partials/toc-tests.md';
<Content />
export const toc = ContentToc;

View file

@ -0,0 +1,80 @@
---
toc_min_heading_level: 2
toc_max_heading_level: 6
---
Test the TOC behavior of a real-world md doc with invalid headings
---
BAD HEADINGS:
###### lvl 6
##### lvl 5
#### lvl 4
##### lvl 5
#### lvl 4
### lvl 3
## lvl 2
# lvl 1
---
GOOD HEADINGS:
## lvl 2
### lvl 3
#### lvl 4
##### lvl 5
###### lvl 6
## lvl 2
### lvl 3
#### lvl 4
##### lvl 5
###### lvl 6
---
INLINE:
```mdx-code-block
import BrowserWindow from '@site/src/components/BrowserWindow';
import TOCInline from '@theme/TOCInline';
<BrowserWindow>
<TOCInline toc={toc} minHeadingLevel={2} maxHeadingLevel={6} />
</BrowserWindow>
```
---
COLLAPSIBLE:
```mdx-code-block
import TOCCollapsible from '@theme/TOCCollapsible';
<BrowserWindow>
<TOCCollapsible toc={toc} minHeadingLevel={2} maxHeadingLevel={6} />
</BrowserWindow>
```

View file

@ -0,0 +1,58 @@
---
toc_min_heading_level: 2
toc_max_heading_level: 6
---
Test the TOC behavior of a real-world md doc with valid headings
---
## lvl 2
### lvl 3
#### lvl 4
##### lvl 5
###### lvl 6
## lvl 2
### lvl 3
#### lvl 4
##### lvl 5
###### lvl 6
---
INLINE:
```mdx-code-block
import BrowserWindow from '@site/src/components/BrowserWindow';
import TOCInline from '@theme/TOCInline';
<BrowserWindow>
<TOCInline toc={toc} minHeadingLevel={2} maxHeadingLevel={6} />
</BrowserWindow>
```
---
COLLAPSIBLE:
```mdx-code-block
import TOCCollapsible from '@theme/TOCCollapsible';
<BrowserWindow>
<TOCCollapsible toc={toc} minHeadingLevel={2} maxHeadingLevel={6} />
</BrowserWindow>
```

View file

@ -0,0 +1,12 @@
---
toc_min_heading_level: 2
toc_max_heading_level: 4
---
import Content, {
toc as ContentToc,
} from '@site/_dogfooding/_partials/toc-tests.md';
<Content />
export const toc = ContentToc;

View file

@ -0,0 +1,67 @@
# title
some text
## section 1
some text
### subsection 1-1
some text
#### subsection 1-1-1
some text
##### subsection 1-1-1-1
some text
###### subsection 1-1-1-1-1
some text
###### subsection 1-1-1-1-2
some text
##### subsection 1-1-1-2
some text
#### subsection 1-1-2
some text
### subsection 1-2
some text
### subsection 1-3
some text
## section 2
some text
### subsection 2-1
some text
### subsection 2-1
some text
## section 3
some text
### subsection 3-1
some text
### subsection 3-2
some text

View file

@ -23,6 +23,16 @@ module.exports = {
label: 'Huge sidebar category',
items: generateHugeSidebarItems(4),
},
{
type: 'category',
label: 'TOC tests',
items: [
{
type: 'autogenerated',
dirName: 'toc',
},
],
},
],
};

View file

@ -187,6 +187,8 @@ Accepted fields:
| `tags` | `Tag[]` | `undefined` | A list of strings or objects of two string fields `label` and `permalink` to tag to your post. |
| `draft` | `boolean` | `false` | A boolean flag to indicate that the blog post is work-in-progress and therefore should not be published yet. However, draft blog posts will be displayed during development. |
| `hide_table_of_contents` | `boolean` | `false` | Whether to hide the table of contents to the right. |
| `toc_min_heading_level` | `number` | `2` | The minimum heading level shown in the table of contents. Must be between 2 and 6 and lower or equal to the max value. |
| `toc_max_heading_level` | `number` | `3` | The max heading level shown in the table of contents. Must be between 2 and 6. |
| `keywords` | `string[]` | `undefined` | Keywords meta tag, which will become the `<meta name="keywords" content="keyword1,keyword2,..."/>` in `<head>`, used by search engines. |
| `description` | `string` | The first line of Markdown content | The description of your document, which will become the `<meta name="description" content="..."/>` and `<meta property="og:description" content="..."/>` in `<head>`, used by search engines. |
| `image` | `string` | `undefined` | Cover or thumbnail image that will be used when displaying the link to your post. |

View file

@ -248,6 +248,8 @@ Accepted fields:
| `sidebar_position` | `number` | Default ordering | Controls the position of a doc inside the generated sidebar slice when using `autogenerated` sidebar items. See also [Autogenerated sidebar metadatas](/docs/sidebar#autogenerated-sidebar-metadatas). |
| `hide_title` | `boolean` | `false` | Whether to hide the title at the top of the doc. It only hides a title declared through the frontmatter, and have no effect on a Markdown title at the top of your document. |
| `hide_table_of_contents` | `boolean` | `false` | Whether to hide the table of contents to the right. |
| `toc_min_heading_level` | `number` | `2` | The minimum heading level shown in the table of contents. Must be between 2 and 6 and lower or equal to the max value. |
| `toc_max_heading_level` | `number` | `3` | The max heading level shown in the table of contents. Must be between 2 and 6. |
| `parse_number_prefixes` | `boolean` | `numberPrefixParser` plugin option | Whether number prefix parsing is disabled on this doc. See also [Using number prefixes](/docs/sidebar#using-number-prefixes). |
| `custom_edit_url` | `string` | Computed using the `editUrl` plugin option | The URL for editing this document. |
| `keywords` | `string[]` | `undefined` | Keywords meta tag for the document page, for search engines. |

View file

@ -757,6 +757,34 @@ module.exports = {
};
```
## Table of Contents {#table-of-contents}
You can adjust the default table of contents via `themeConfig.tableOfContents`.
<small>
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `minHeadingLevel` | `number` | `2` | The minimum heading level shown in the table of contents. Must be between 2 and 6 and lower or equal to the max value. |
| `maxHeadingLevel` | `number` | `3` | Max heading level displayed in the TOC. Should be an integer between 2 and 6. |
</small>
Example configuration:
```js title="docusaurus.config.js"
module.exports = {
themeConfig: {
// highlight-start
tableOfContents: {
minHeadingLevel: 2,
maxHeadingLevel: 5,
},
// highlight-end
},
};
```
## Hooks {#hooks}
### `useThemeContext` {#usethemecontext}

View file

@ -35,7 +35,9 @@ will show up on the table of contents on the upper right
So that your users will know what this page is all about without scrolling down or even without reading too much.
### Only h2 and h3 will be in the toc
### Only h2 and h3 will be in the toc by default.
You can configure the TOC heading levels either per-document or in the theme configuration.
The headers are well-spaced so that the hierarchy is clear.
@ -67,7 +69,9 @@ will show up on the table of contents on the upper right
So that your users will know what this page is all about without scrolling down or even without reading too much.
<h3>Only h2 and h3 will be in the toc</h3>
<h3>Only h2 and h3 will be in the toc by default.</h3>
You can configure the TOC heading levels either per-document or in the theme configuration.
The headers are well-spaced so that the hierarchy is clear.

View file

@ -13,7 +13,9 @@ But it is also possible to display an inline table of contents directly inside a
## Full table of contents {#full-table-of-contents}
The `toc` variable is available in any MDX document, and contain all the top level headings of a MDX document.
The `toc` variable is available in any MDX document, and contains all the headings of a MDX document.
By default, only `h2` and `h3` headings are displayed in the TOC. You can change which heading levels are visible by setting `minHeadingLevel` or `maxHeadingLevel`.
```jsx
import TOCInline from '@theme/TOCInline';
@ -40,6 +42,7 @@ type TOCItem = {
value: string;
id: string;
children: TOCItem[];
level: number;
};
```