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 = {
value: toValue(child),
id: child.data!.id as string,
children: [],
};
if (!headings.length || currentDepth >= child.depth) {
headings.push(entry);
current += 1;
currentDepth = child.depth;
} else {
headings[current].children.push(entry);
}
headings.push({
node: {
value: toValue(child),
id: child.data!.id as string,
children: [],
level: child.depth,
},
level: child.depth,
parentIndex: -1,
});
};
visit(node, 'heading', visitor);
return headings;
// Keep track of which previous index would be the current heading's direcy parent.
// Each entry <i> is the last index of the `headings` array at heading level <i>.
// We will modify these indices as we iterate through all headings.
// e.g. if an ### H3 was last seen at index 2, then prevIndexForLevel[3] === 2
// indices 0 and 1 will remain unused.
const prevIndexForLevel = Array(7).fill(-1);
headings.forEach((curr, currIndex) => {
// take the last seen index for each ancestor level. the highest
// index will be the direct ancestor of the current heading.
const ancestorLevelIndexes = prevIndexForLevel.slice(2, curr.level);
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,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',
],
],
});
});

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}}
/>
<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 className={clsx(styles.tableOfContents, 'thin-scrollbar', className)}>
<TOCItems
{...props}
linkClassName={LINK_CLASS_NAME}
linkActiveClassName={LINK_ACTIVE_CLASS_NAME}
/>
</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({
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<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),
};