diff --git a/packages/docusaurus-mdx-loader/src/remark/toc/__tests__/__snapshots__/index.test.ts.snap b/packages/docusaurus-mdx-loader/src/remark/toc/__tests__/__snapshots__/index.test.ts.snap index 36725f1c984c..f3d93c058555 100644 --- a/packages/docusaurus-mdx-loader/src/remark/toc/__tests__/__snapshots__/index.test.ts.snap +++ b/packages/docusaurus-mdx-loader/src/remark/toc/__tests__/__snapshots__/index.test.ts.snap @@ -9,24 +9,29 @@ exports[`inline code should be escaped 1`] = ` { value: '<Head>Test</Head>', id: 'headtesthead', - children: [] + children: [], + level: 3 } - ] + ], + level: 2 }, { value: '<div />', id: 'div-', - children: [] + children: [], + level: 2 }, { value: '<div> Test </div>', id: 'div-test-div', - children: [] + children: [], + level: 2 }, { value: '<div><i>Test</i></div>', id: 'divitestidiv', - children: [] + children: [], + level: 2 } ]; @@ -51,24 +56,29 @@ exports[`non text phrasing content 1`] = ` { value: 'Importance', id: 'importance', - children: [] + children: [], + level: 3 } - ] + ], + level: 2 }, { value: 'Strikethrough', id: 'strikethrough', - children: [] + children: [], + level: 2 }, { value: 'HTML', id: 'html', - children: [] + children: [], + level: 2 }, { value: 'inline.code()', id: 'inlinecode', - children: [] + children: [], + level: 2 } ]; diff --git a/packages/docusaurus-mdx-loader/src/remark/toc/__tests__/index.test.ts b/packages/docusaurus-mdx-loader/src/remark/toc/__tests__/index.test.ts index e9df4502b208..dd786b95ad73 100644 --- a/packages/docusaurus-mdx-loader/src/remark/toc/__tests__/index.test.ts +++ b/packages/docusaurus-mdx-loader/src/remark/toc/__tests__/index.test.ts @@ -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 } ]; diff --git a/packages/docusaurus-mdx-loader/src/remark/toc/__tests__/search.test.ts b/packages/docusaurus-mdx-loader/src/remark/toc/__tests__/search.test.ts new file mode 100644 index 000000000000..c37ae54f9189 --- /dev/null +++ b/packages/docusaurus-mdx-loader/src/remark/toc/__tests__/search.test.ts @@ -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', + }, + ]); +}); diff --git a/packages/docusaurus-mdx-loader/src/remark/toc/search.ts b/packages/docusaurus-mdx-loader/src/remark/toc/search.ts index 112a56c3fab2..fa0712024adf 100644 --- a/packages/docusaurus-mdx-loader/src/remark/toc/search.ts +++ b/packages/docusaurus-mdx-loader/src/remark/toc/search.ts @@ -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 = (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 = { - 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 is the last index of the `headings` array at heading level . + // 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; } diff --git a/packages/docusaurus-plugin-content-blog/src/blogFrontMatter.ts b/packages/docusaurus-plugin-content-blog/src/blogFrontMatter.ts index 0ea91930a074..edd40f537c49 100644 --- a/packages/docusaurus-plugin-content-blog/src/blogFrontMatter.ts +++ b/packages/docusaurus-plugin-content-blog/src/blogFrontMatter.ts @@ -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({ 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.', diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/docFrontMatter.test.ts b/packages/docusaurus-plugin-content-docs/src/__tests__/docFrontMatter.test.ts index f1205f4eca97..a00367efcb99 100644 --- a/packages/docusaurus-plugin-content-docs/src/__tests__/docFrontMatter.test.ts +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/docFrontMatter.test.ts @@ -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, @@ -21,40 +21,38 @@ function testField(params: { ErrorMessage: string, ][]; }) { - describe(`"${params.fieldName}" field`, () => { - test('accept valid values', () => { - params.validFrontMatters.forEach((frontMatter) => { - expect(validateDocFrontMatter(frontMatter)).toEqual(frontMatter); - }); + test(`[${params.prefix}] accept valid values`, () => { + params.validFrontMatters.forEach((frontMatter) => { + expect(validateDocFrontMatter(frontMatter)).toEqual(frontMatter); }); + }); - test('convert valid values', () => { - params.convertibleFrontMatter?.forEach( - ([convertibleFrontMatter, convertedFrontMatter]) => { - expect(validateDocFrontMatter(convertibleFrontMatter)).toEqual( - convertedFrontMatter, - ); - }, - ); - }); + test(`[${params.prefix}] convert valid values`, () => { + params.convertibleFrontMatter?.forEach( + ([convertibleFrontMatter, convertedFrontMatter]) => { + expect(validateDocFrontMatter(convertibleFrontMatter)).toEqual( + convertedFrontMatter, + ); + }, + ); + }); - test('throw error for values', () => { - params.invalidFrontMatters?.forEach(([frontMatter, message]) => { - try { - validateDocFrontMatter(frontMatter); - fail( - new Error( - `Doc frontmatter is expected to be rejected, but was accepted successfully:\n ${JSON.stringify( - frontMatter, - null, - 2, - )}`, - ), - ); - } catch (e) { - expect(e.message).toMatch(new RegExp(escapeStringRegexp(message))); - } - }); + test(`[${params.prefix}] throw error for values`, () => { + params.invalidFrontMatters?.forEach(([frontMatter, message]) => { + try { + validateDocFrontMatter(frontMatter); + fail( + new Error( + `Doc frontmatter is expected to be rejected, but was accepted successfully:\n ${JSON.stringify( + frontMatter, + null, + 2, + )}`, + ), + ); + } catch (e) { + expect(e.message).toMatch(new RegExp(escapeStringRegexp(message))); + } }); }); } @@ -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', + ], + ], + }); +}); diff --git a/packages/docusaurus-plugin-content-docs/src/docFrontMatter.ts b/packages/docusaurus-plugin-content-docs/src/docFrontMatter.ts index 32980256abc8..501ef6deba99 100644 --- a/packages/docusaurus-plugin-content-docs/src/docFrontMatter.ts +++ b/packages/docusaurus-plugin-content-docs/src/docFrontMatter.ts @@ -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({ pagination_label: Joi.string(), custom_edit_url: URISchema.allow('', null), parse_number_prefixes: Joi.boolean(), + ...FrontMatterTOCHeadingLevels, }).unknown(); export function validateDocFrontMatter( diff --git a/packages/docusaurus-plugin-content-docs/src/plugin-content-docs.d.ts b/packages/docusaurus-plugin-content-docs/src/plugin-content-docs.d.ts index 4c0495de7361..c30737826678 100644 --- a/packages/docusaurus-plugin-content-docs/src/plugin-content-docs.d.ts +++ b/packages/docusaurus-plugin-content-docs/src/plugin-content-docs.d.ts @@ -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 */ }; diff --git a/packages/docusaurus-plugin-content-docs/src/types.ts b/packages/docusaurus-plugin-content-docs/src/types.ts index 16f977883caf..1911ebfd82c3 100644 --- a/packages/docusaurus-plugin-content-docs/src/types.ts +++ b/packages/docusaurus-plugin-content-docs/src/types.ts @@ -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 */ }; diff --git a/packages/docusaurus-plugin-content-pages/src/index.ts b/packages/docusaurus-plugin-content-pages/src/index.ts index a58b66b8cdeb..82c95d08f76b 100644 --- a/packages/docusaurus-plugin-content-pages/src/index.ts +++ b/packages/docusaurus-plugin-content-pages/src/index.ts @@ -121,6 +121,7 @@ export default function pluginContentPages( encodePath(fileToPath(relativeSource)), ]); if (isMarkdownSource(relativeSource)) { + // TODO: missing frontmatter validation/normalization here return { type: 'mdx', permalink, diff --git a/packages/docusaurus-plugin-content-pages/src/plugin-content-pages.d.ts b/packages/docusaurus-plugin-content-pages/src/plugin-content-pages.d.ts index a52baa298379..1e406dec9b81 100644 --- a/packages/docusaurus-plugin-content-pages/src/plugin-content-pages.d.ts +++ b/packages/docusaurus-plugin-content-pages/src/plugin-content-pages.d.ts @@ -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[]; diff --git a/packages/docusaurus-theme-classic/src/__tests__/validateThemeConfig.test.js b/packages/docusaurus-theme-classic/src/__tests__/validateThemeConfig.test.js index f927038965a5..c5fdbf04fae0 100644 --- a/packages/docusaurus-theme-classic/src/__tests__/validateThemeConfig.test.js +++ b/packages/docusaurus-theme-classic/src/__tests__/validateThemeConfig.test.js @@ -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"`, + ); + }); +}); diff --git a/packages/docusaurus-theme-classic/src/theme/BlogLayout/index.tsx b/packages/docusaurus-theme-classic/src/theme/BlogLayout/index.tsx index e5c29fcdc03e..623b112c629f 100644 --- a/packages/docusaurus-theme-classic/src/theme/BlogLayout/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/BlogLayout/index.tsx @@ -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} - {toc && ( -
- -
- )} + {toc &&
{toc}
} diff --git a/packages/docusaurus-theme-classic/src/theme/BlogPostPage/index.tsx b/packages/docusaurus-theme-classic/src/theme/BlogPostPage/index.tsx index 8cabdad67d29..7b06e7b26750 100644 --- a/packages/docusaurus-theme-classic/src/theme/BlogPostPage/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/BlogPostPage/index.tsx @@ -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 ? ( + + ) : undefined }> diff --git a/packages/docusaurus-theme-classic/src/theme/MDXPage/index.tsx b/packages/docusaurus-theme-classic/src/theme/MDXPage/index.tsx index 6305ed272808..5cb38278fb1e 100644 --- a/packages/docusaurus-theme-classic/src/theme/MDXPage/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/MDXPage/index.tsx @@ -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 { {!hideTableOfContents && MDXPageContent.toc && (
- +
)} diff --git a/packages/docusaurus-theme-classic/src/theme/TOC/index.tsx b/packages/docusaurus-theme-classic/src/theme/TOC/index.tsx index b069a41aa50b..efc6beebc53e 100644 --- a/packages/docusaurus-theme-classic/src/theme/TOC/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/TOC/index.tsx @@ -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; - } - return ( - - ); -} - -function TOC({toc}: TOCProps): JSX.Element { - useTOCHighlight(TOC_HIGHLIGHT_PARAMS); +function TOC({className, ...props}: TOCProps): JSX.Element { return ( -
- +
+
); } diff --git a/packages/docusaurus-theme-classic/src/theme/TOCCollapsible/index.tsx b/packages/docusaurus-theme-classic/src/theme/TOCCollapsible/index.tsx index 29764d2a3152..507f6be105e5 100644 --- a/packages/docusaurus-theme-classic/src/theme/TOCCollapsible/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/TOCCollapsible/index.tsx @@ -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}> - +
); diff --git a/packages/docusaurus-theme-classic/src/theme/TOCInline/index.tsx b/packages/docusaurus-theme-classic/src/theme/TOCInline/index.tsx index 1753f0d4c57c..5210ae2e7e06 100644 --- a/packages/docusaurus-theme-classic/src/theme/TOCInline/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/TOCInline/index.tsx @@ -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 ( -
- ); -} - -function TOCInline({toc}: TOCInlineProps): JSX.Element { + minHeadingLevel, + maxHeadingLevel, +}: TOCInlineProps): JSX.Element { return (
- +
); } diff --git a/packages/docusaurus-theme-classic/src/theme/TOCItems/index.tsx b/packages/docusaurus-theme-classic/src/theme/TOCItems/index.tsx new file mode 100644 index 000000000000..d93850f31c97 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/TOCItems/index.tsx @@ -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 ( +
+ ); +} + +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 ( + + ); +} diff --git a/packages/docusaurus-theme-classic/src/types.d.ts b/packages/docusaurus-theme-classic/src/types.d.ts index e4aa3fd53976..384cfb146b6e 100644 --- a/packages/docusaurus-theme-classic/src/types.d.ts +++ b/packages/docusaurus-theme-classic/src/types.d.ts @@ -77,13 +77,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; @@ -257,14 +257,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}; @@ -580,17 +572,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; @@ -604,6 +617,8 @@ declare module '@theme/TOCInline' { export type TOCInlineProps = { readonly toc: readonly TOCItem[]; + readonly minHeadingLevel?: number; + readonly maxHeadingLevel?: number; }; const TOCInline: (props: TOCInlineProps) => JSX.Element; @@ -615,6 +630,8 @@ declare module '@theme/TOCCollapsible' { export type TOCCollapsibleProps = { readonly className?: string; + readonly minHeadingLevel?: number; + readonly maxHeadingLevel?: number; readonly toc: readonly TOCItem[]; }; diff --git a/packages/docusaurus-theme-classic/src/validateThemeConfig.ts b/packages/docusaurus-theme-classic/src/validateThemeConfig.ts index 4e15ff468821..458ac90e9ab3 100644 --- a/packages/docusaurus-theme-classic/src/validateThemeConfig.ts +++ b/packages/docusaurus-theme-classic/src/validateThemeConfig.ts @@ -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}; diff --git a/packages/docusaurus-theme-common/src/index.ts b/packages/docusaurus-theme-common/src/index.ts index d2ca0db42dd4..fe50023fc802 100644 --- a/packages/docusaurus-theme-common/src/index.ts +++ b/packages/docusaurus-theme-common/src/index.ts @@ -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'; diff --git a/packages/docusaurus-theme-common/src/utils/__tests__/tocUtils.test.ts b/packages/docusaurus-theme-common/src/utils/__tests__/tocUtils.test.ts new file mode 100644 index 000000000000..6d5b26451718 --- /dev/null +++ b/packages/docusaurus-theme-common/src/utils/__tests__/tocUtils.test.ts @@ -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: [], + }, + ]); + }); +}); diff --git a/packages/docusaurus-theme-common/src/utils/tocUtils.ts b/packages/docusaurus-theme-common/src/utils/tocUtils.ts new file mode 100644 index 000000000000..66addeb377e2 --- /dev/null +++ b/packages/docusaurus-theme-common/src/utils/tocUtils.ts @@ -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]); +} diff --git a/packages/docusaurus-theme-classic/src/theme/hooks/useTOCHighlight.ts b/packages/docusaurus-theme-common/src/utils/useTOCHighlight.ts similarity index 79% rename from packages/docusaurus-theme-classic/src/theme/hooks/useTOCHighlight.ts rename to packages/docusaurus-theme-common/src/utils/useTOCHighlight.ts index 6d46cdea2b6c..06d8f901e633 100644 --- a/packages/docusaurus-theme-classic/src/theme/hooks/useTOCHighlight.ts +++ b/packages/docusaurus-theme-common/src/utils/useTOCHighlight.ts @@ -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({ - anchorTopOffset, -}: { - anchorTopOffset: number; -}): Element | null { - const anchors = getAnchors(); - +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 @@ -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(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; diff --git a/packages/docusaurus-theme-common/src/utils/useThemeConfig.ts b/packages/docusaurus-theme-common/src/utils/useThemeConfig.ts index fe192e31b3fc..922a2db46d45 100644 --- a/packages/docusaurus-theme-common/src/utils/useThemeConfig.ts +++ b/packages/docusaurus-theme-common/src/utils/useThemeConfig.ts @@ -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>; sidebarCollapsible: boolean; + tableOfContents: TableOfContents; }; export function useThemeConfig(): ThemeConfig { diff --git a/packages/docusaurus-types/src/index.d.ts b/packages/docusaurus-types/src/index.d.ts index 25a1e354bbb6..eb4f9e07008e 100644 --- a/packages/docusaurus-types/src/index.d.ts +++ b/packages/docusaurus-types/src/index.d.ts @@ -421,4 +421,5 @@ export interface TOCItem { readonly value: string; readonly id: string; readonly children: TOCItem[]; + readonly level: number; } diff --git a/packages/docusaurus-utils-validation/src/validationSchemas.ts b/packages/docusaurus-utils-validation/src/validationSchemas.ts index a048c2c220db..ffb34f9198e3 100644 --- a/packages/docusaurus-utils-validation/src/validationSchemas.ts +++ b/packages/docusaurus-utils-validation/src/validationSchemas.ts @@ -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), +}; diff --git a/website/_dogfooding/_blog tests/2021-08-21-blog-post-toc-tests.mdx b/website/_dogfooding/_blog tests/2021-08-21-blog-post-toc-tests.mdx new file mode 100644 index 000000000000..2245ed3dd1b9 --- /dev/null +++ b/website/_dogfooding/_blog tests/2021-08-21-blog-post-toc-tests.mdx @@ -0,0 +1,17 @@ +--- +title: Blog TOC FrontMatter tests +authors: + - slorber +toc_min_heading_level: 2 +toc_max_heading_level: 4 +--- + + + +import Content, { + toc as ContentToc, +} from '@site/_dogfooding/_partials/toc-tests.md'; + + + +export const toc = ContentToc; diff --git a/website/_dogfooding/_docs tests/toc/toc-2-2.mdx b/website/_dogfooding/_docs tests/toc/toc-2-2.mdx new file mode 100644 index 000000000000..456cf928e6bb --- /dev/null +++ b/website/_dogfooding/_docs tests/toc/toc-2-2.mdx @@ -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'; + + + +export const toc = ContentToc; diff --git a/website/_dogfooding/_docs tests/toc/toc-2-3.mdx b/website/_dogfooding/_docs tests/toc/toc-2-3.mdx new file mode 100644 index 000000000000..9e008e596306 --- /dev/null +++ b/website/_dogfooding/_docs tests/toc/toc-2-3.mdx @@ -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'; + + + +export const toc = ContentToc; diff --git a/website/_dogfooding/_docs tests/toc/toc-2-4.mdx b/website/_dogfooding/_docs tests/toc/toc-2-4.mdx new file mode 100644 index 000000000000..d7ae81514536 --- /dev/null +++ b/website/_dogfooding/_docs tests/toc/toc-2-4.mdx @@ -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'; + + + +export const toc = ContentToc; diff --git a/website/_dogfooding/_docs tests/toc/toc-2-5.mdx b/website/_dogfooding/_docs tests/toc/toc-2-5.mdx new file mode 100644 index 000000000000..a6f504bf09b2 --- /dev/null +++ b/website/_dogfooding/_docs tests/toc/toc-2-5.mdx @@ -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'; + + + +export const toc = ContentToc; diff --git a/website/_dogfooding/_docs tests/toc/toc-3-5.mdx b/website/_dogfooding/_docs tests/toc/toc-3-5.mdx new file mode 100644 index 000000000000..484471023b9a --- /dev/null +++ b/website/_dogfooding/_docs tests/toc/toc-3-5.mdx @@ -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'; + + + +export const toc = ContentToc; diff --git a/website/_dogfooding/_docs tests/toc/toc-3-_.mdx b/website/_dogfooding/_docs tests/toc/toc-3-_.mdx new file mode 100644 index 000000000000..1c173c6e7da4 --- /dev/null +++ b/website/_dogfooding/_docs tests/toc/toc-3-_.mdx @@ -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'; + + + +export const toc = ContentToc; diff --git a/website/_dogfooding/_docs tests/toc/toc-4-5.mdx b/website/_dogfooding/_docs tests/toc/toc-4-5.mdx new file mode 100644 index 000000000000..c49c0aeab6de --- /dev/null +++ b/website/_dogfooding/_docs tests/toc/toc-4-5.mdx @@ -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'; + + + +export const toc = ContentToc; diff --git a/website/_dogfooding/_docs tests/toc/toc-5-5.mdx b/website/_dogfooding/_docs tests/toc/toc-5-5.mdx new file mode 100644 index 000000000000..120e6d2c8684 --- /dev/null +++ b/website/_dogfooding/_docs tests/toc/toc-5-5.mdx @@ -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'; + + + +export const toc = ContentToc; diff --git a/website/_dogfooding/_docs tests/toc/toc-_-5.mdx b/website/_dogfooding/_docs tests/toc/toc-_-5.mdx new file mode 100644 index 000000000000..d9527a909511 --- /dev/null +++ b/website/_dogfooding/_docs tests/toc/toc-_-5.mdx @@ -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'; + + + +export const toc = ContentToc; diff --git a/website/_dogfooding/_docs tests/toc/toc-_-_.mdx b/website/_dogfooding/_docs tests/toc/toc-_-_.mdx new file mode 100644 index 000000000000..77f08672ecc7 --- /dev/null +++ b/website/_dogfooding/_docs tests/toc/toc-_-_.mdx @@ -0,0 +1,12 @@ +--- +# toc_min_heading_level: +# toc_max_heading_level: +--- + +import Content, { + toc as ContentToc, +} from '@site/_dogfooding/_partials/toc-tests.md'; + + + +export const toc = ContentToc; diff --git a/website/_dogfooding/_docs tests/toc/toc-test-bad.mdx b/website/_dogfooding/_docs tests/toc/toc-test-bad.mdx new file mode 100644 index 000000000000..b894a0a17ec3 --- /dev/null +++ b/website/_dogfooding/_docs tests/toc/toc-test-bad.mdx @@ -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'; + + + + + + +``` + +--- + +COLLAPSIBLE: + +```mdx-code-block +import TOCCollapsible from '@theme/TOCCollapsible'; + + + + + + +``` diff --git a/website/_dogfooding/_docs tests/toc/toc-test-good.mdx b/website/_dogfooding/_docs tests/toc/toc-test-good.mdx new file mode 100644 index 000000000000..6f85a62fea2a --- /dev/null +++ b/website/_dogfooding/_docs tests/toc/toc-test-good.mdx @@ -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'; + + + + + + +``` + +--- + +COLLAPSIBLE: + +```mdx-code-block +import TOCCollapsible from '@theme/TOCCollapsible'; + + + + + + +``` diff --git a/website/_dogfooding/_pages tests/page-toc-tests.mdx b/website/_dogfooding/_pages tests/page-toc-tests.mdx new file mode 100644 index 000000000000..d7ae81514536 --- /dev/null +++ b/website/_dogfooding/_pages tests/page-toc-tests.mdx @@ -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'; + + + +export const toc = ContentToc; diff --git a/website/_dogfooding/_partials/toc-tests.md b/website/_dogfooding/_partials/toc-tests.md new file mode 100644 index 000000000000..e9f9b15b84a8 --- /dev/null +++ b/website/_dogfooding/_partials/toc-tests.md @@ -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 diff --git a/website/_dogfooding/docs-tests-sidebars.js b/website/_dogfooding/docs-tests-sidebars.js index b841c1e3ebbd..3d1907903b76 100644 --- a/website/_dogfooding/docs-tests-sidebars.js +++ b/website/_dogfooding/docs-tests-sidebars.js @@ -23,6 +23,16 @@ module.exports = { label: 'Huge sidebar category', items: generateHugeSidebarItems(4), }, + { + type: 'category', + label: 'TOC tests', + items: [ + { + type: 'autogenerated', + dirName: 'toc', + }, + ], + }, ], }; diff --git a/website/docs/api/plugins/plugin-content-blog.md b/website/docs/api/plugins/plugin-content-blog.md index 58af14d1dd19..3e61baaa4f80 100644 --- a/website/docs/api/plugins/plugin-content-blog.md +++ b/website/docs/api/plugins/plugin-content-blog.md @@ -186,6 +186,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 `` in ``, used by search engines. | | `description` | `string` | The first line of Markdown content | The description of your document, which will become the `` and `` in ``, used by search engines. | | `image` | `string` | `undefined` | Cover or thumbnail image that will be used when displaying the link to your post. | diff --git a/website/docs/api/plugins/plugin-content-docs.md b/website/docs/api/plugins/plugin-content-docs.md index 59cae99b86be..920b943ca0bc 100644 --- a/website/docs/api/plugins/plugin-content-docs.md +++ b/website/docs/api/plugins/plugin-content-docs.md @@ -247,6 +247,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. | diff --git a/website/docs/api/themes/theme-configuration.md b/website/docs/api/themes/theme-configuration.md index c22cdbe65266..572f0b4bc965 100644 --- a/website/docs/api/themes/theme-configuration.md +++ b/website/docs/api/themes/theme-configuration.md @@ -757,6 +757,34 @@ module.exports = { }; ``` +## Table of Contents {#table-of-contents} + +You can adjust the default table of contents via `themeConfig.tableOfContents`. + + + +| 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. | + + + +Example configuration: + +```js title="docusaurus.config.js" +module.exports = { + themeConfig: { + // highlight-start + tableOfContents: { + minHeadingLevel: 2, + maxHeadingLevel: 5, + }, + // highlight-end + }, +}; +``` + ## Hooks {#hooks} ### `useThemeContext` {#usethemecontext} diff --git a/website/docs/guides/docs/docs-create-doc.mdx b/website/docs/guides/docs/docs-create-doc.mdx index c42b1c32b005..3b3a141a9264 100644 --- a/website/docs/guides/docs/docs-create-doc.mdx +++ b/website/docs/guides/docs/docs-create-doc.mdx @@ -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. -

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. diff --git a/website/docs/guides/markdown-features/markdown-features-inline-toc.mdx b/website/docs/guides/markdown-features/markdown-features-inline-toc.mdx index 5f839da3b835..3726f048421f 100644 --- a/website/docs/guides/markdown-features/markdown-features-inline-toc.mdx +++ b/website/docs/guides/markdown-features/markdown-features-inline-toc.mdx @@ -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; }; ```