mirror of
https://github.com/facebook/docusaurus.git
synced 2025-05-10 07:37:19 +02:00
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:
parent
caba1e4908
commit
c86dfbda61
50 changed files with 1522 additions and 214 deletions
|
@ -9,24 +9,29 @@ exports[`inline code should be escaped 1`] = `
|
|||
{
|
||||
value: '<code><Head>Test</Head></code>',
|
||||
id: 'headtesthead',
|
||||
children: []
|
||||
children: [],
|
||||
level: 3
|
||||
}
|
||||
]
|
||||
],
|
||||
level: 2
|
||||
},
|
||||
{
|
||||
value: '<code><div /></code>',
|
||||
id: 'div-',
|
||||
children: []
|
||||
children: [],
|
||||
level: 2
|
||||
},
|
||||
{
|
||||
value: '<code><div> Test </div></code>',
|
||||
id: 'div-test-div',
|
||||
children: []
|
||||
children: [],
|
||||
level: 2
|
||||
},
|
||||
{
|
||||
value: '<code><div><i>Test</i></div></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
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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',
|
||||
],
|
||||
],
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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 */
|
||||
};
|
||||
|
||||
|
|
|
@ -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 */
|
||||
};
|
||||
|
||||
|
|
|
@ -121,6 +121,7 @@ export default function pluginContentPages(
|
|||
encodePath(fileToPath(relativeSource)),
|
||||
]);
|
||||
if (isMarkdownSource(relativeSource)) {
|
||||
// TODO: missing frontmatter validation/normalization here
|
||||
return {
|
||||
type: 'mdx',
|
||||
permalink,
|
||||
|
|
|
@ -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[];
|
||||
|
|
|
@ -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"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
39
packages/docusaurus-theme-classic/src/types.d.ts
vendored
39
packages/docusaurus-theme-classic/src/types.d.ts
vendored
|
@ -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[];
|
||||
};
|
||||
|
||||
|
|
|
@ -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};
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
54
packages/docusaurus-theme-common/src/utils/tocUtils.ts
Normal file
54
packages/docusaurus-theme-common/src/utils/tocUtils.ts
Normal 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]);
|
||||
}
|
|
@ -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;
|
|
@ -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 {
|
||||
|
|
1
packages/docusaurus-types/src/index.d.ts
vendored
1
packages/docusaurus-types/src/index.d.ts
vendored
|
@ -421,4 +421,5 @@ export interface TOCItem {
|
|||
readonly value: string;
|
||||
readonly id: string;
|
||||
readonly children: TOCItem[];
|
||||
readonly level: number;
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
title: Blog TOC FrontMatter tests
|
||||
authors:
|
||||
- slorber
|
||||
toc_min_heading_level: 2
|
||||
toc_max_heading_level: 4
|
||||
---
|
||||
|
||||
<!-- truncate -->
|
||||
|
||||
import Content, {
|
||||
toc as ContentToc,
|
||||
} from '@site/_dogfooding/_partials/toc-tests.md';
|
||||
|
||||
<Content />
|
||||
|
||||
export const toc = ContentToc;
|
12
website/_dogfooding/_docs tests/toc/toc-2-2.mdx
Normal file
12
website/_dogfooding/_docs tests/toc/toc-2-2.mdx
Normal file
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
toc_min_heading_level: 2
|
||||
toc_max_heading_level: 2
|
||||
---
|
||||
|
||||
import Content, {
|
||||
toc as ContentToc,
|
||||
} from '@site/_dogfooding/_partials/toc-tests.md';
|
||||
|
||||
<Content />
|
||||
|
||||
export const toc = ContentToc;
|
12
website/_dogfooding/_docs tests/toc/toc-2-3.mdx
Normal file
12
website/_dogfooding/_docs tests/toc/toc-2-3.mdx
Normal file
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
toc_min_heading_level: 2
|
||||
toc_max_heading_level: 3
|
||||
---
|
||||
|
||||
import Content, {
|
||||
toc as ContentToc,
|
||||
} from '@site/_dogfooding/_partials/toc-tests.md';
|
||||
|
||||
<Content />
|
||||
|
||||
export const toc = ContentToc;
|
12
website/_dogfooding/_docs tests/toc/toc-2-4.mdx
Normal file
12
website/_dogfooding/_docs tests/toc/toc-2-4.mdx
Normal file
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
toc_min_heading_level: 2
|
||||
toc_max_heading_level: 4
|
||||
---
|
||||
|
||||
import Content, {
|
||||
toc as ContentToc,
|
||||
} from '@site/_dogfooding/_partials/toc-tests.md';
|
||||
|
||||
<Content />
|
||||
|
||||
export const toc = ContentToc;
|
12
website/_dogfooding/_docs tests/toc/toc-2-5.mdx
Normal file
12
website/_dogfooding/_docs tests/toc/toc-2-5.mdx
Normal file
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
toc_min_heading_level: 2
|
||||
toc_max_heading_level: 5
|
||||
---
|
||||
|
||||
import Content, {
|
||||
toc as ContentToc,
|
||||
} from '@site/_dogfooding/_partials/toc-tests.md';
|
||||
|
||||
<Content />
|
||||
|
||||
export const toc = ContentToc;
|
12
website/_dogfooding/_docs tests/toc/toc-3-5.mdx
Normal file
12
website/_dogfooding/_docs tests/toc/toc-3-5.mdx
Normal file
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
toc_min_heading_level: 3
|
||||
toc_max_heading_level: 5
|
||||
---
|
||||
|
||||
import Content, {
|
||||
toc as ContentToc,
|
||||
} from '@site/_dogfooding/_partials/toc-tests.md';
|
||||
|
||||
<Content />
|
||||
|
||||
export const toc = ContentToc;
|
12
website/_dogfooding/_docs tests/toc/toc-3-_.mdx
Normal file
12
website/_dogfooding/_docs tests/toc/toc-3-_.mdx
Normal file
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
toc_min_heading_level: 3
|
||||
# toc_max_heading_level:
|
||||
---
|
||||
|
||||
import Content, {
|
||||
toc as ContentToc,
|
||||
} from '@site/_dogfooding/_partials/toc-tests.md';
|
||||
|
||||
<Content />
|
||||
|
||||
export const toc = ContentToc;
|
12
website/_dogfooding/_docs tests/toc/toc-4-5.mdx
Normal file
12
website/_dogfooding/_docs tests/toc/toc-4-5.mdx
Normal file
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
toc_min_heading_level: 4
|
||||
toc_max_heading_level: 5
|
||||
---
|
||||
|
||||
import Content, {
|
||||
toc as ContentToc,
|
||||
} from '@site/_dogfooding/_partials/toc-tests.md';
|
||||
|
||||
<Content />
|
||||
|
||||
export const toc = ContentToc;
|
12
website/_dogfooding/_docs tests/toc/toc-5-5.mdx
Normal file
12
website/_dogfooding/_docs tests/toc/toc-5-5.mdx
Normal file
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
toc_min_heading_level: 5
|
||||
toc_max_heading_level: 5
|
||||
---
|
||||
|
||||
import Content, {
|
||||
toc as ContentToc,
|
||||
} from '@site/_dogfooding/_partials/toc-tests.md';
|
||||
|
||||
<Content />
|
||||
|
||||
export const toc = ContentToc;
|
12
website/_dogfooding/_docs tests/toc/toc-_-5.mdx
Normal file
12
website/_dogfooding/_docs tests/toc/toc-_-5.mdx
Normal file
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
# toc_min_heading_level:
|
||||
toc_max_heading_level: 5
|
||||
---
|
||||
|
||||
import Content, {
|
||||
toc as ContentToc,
|
||||
} from '@site/_dogfooding/_partials/toc-tests.md';
|
||||
|
||||
<Content />
|
||||
|
||||
export const toc = ContentToc;
|
12
website/_dogfooding/_docs tests/toc/toc-_-_.mdx
Normal file
12
website/_dogfooding/_docs tests/toc/toc-_-_.mdx
Normal file
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
# toc_min_heading_level:
|
||||
# toc_max_heading_level:
|
||||
---
|
||||
|
||||
import Content, {
|
||||
toc as ContentToc,
|
||||
} from '@site/_dogfooding/_partials/toc-tests.md';
|
||||
|
||||
<Content />
|
||||
|
||||
export const toc = ContentToc;
|
80
website/_dogfooding/_docs tests/toc/toc-test-bad.mdx
Normal file
80
website/_dogfooding/_docs tests/toc/toc-test-bad.mdx
Normal file
|
@ -0,0 +1,80 @@
|
|||
---
|
||||
toc_min_heading_level: 2
|
||||
toc_max_heading_level: 6
|
||||
---
|
||||
|
||||
Test the TOC behavior of a real-world md doc with invalid headings
|
||||
|
||||
---
|
||||
|
||||
BAD HEADINGS:
|
||||
|
||||
###### lvl 6
|
||||
|
||||
##### lvl 5
|
||||
|
||||
#### lvl 4
|
||||
|
||||
##### lvl 5
|
||||
|
||||
#### lvl 4
|
||||
|
||||
### lvl 3
|
||||
|
||||
## lvl 2
|
||||
|
||||
# lvl 1
|
||||
|
||||
---
|
||||
|
||||
GOOD HEADINGS:
|
||||
|
||||
## lvl 2
|
||||
|
||||
### lvl 3
|
||||
|
||||
#### lvl 4
|
||||
|
||||
##### lvl 5
|
||||
|
||||
###### lvl 6
|
||||
|
||||
## lvl 2
|
||||
|
||||
### lvl 3
|
||||
|
||||
#### lvl 4
|
||||
|
||||
##### lvl 5
|
||||
|
||||
###### lvl 6
|
||||
|
||||
---
|
||||
|
||||
INLINE:
|
||||
|
||||
```mdx-code-block
|
||||
import BrowserWindow from '@site/src/components/BrowserWindow';
|
||||
|
||||
import TOCInline from '@theme/TOCInline';
|
||||
|
||||
<BrowserWindow>
|
||||
|
||||
<TOCInline toc={toc} minHeadingLevel={2} maxHeadingLevel={6} />
|
||||
|
||||
</BrowserWindow>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
COLLAPSIBLE:
|
||||
|
||||
```mdx-code-block
|
||||
import TOCCollapsible from '@theme/TOCCollapsible';
|
||||
|
||||
<BrowserWindow>
|
||||
|
||||
<TOCCollapsible toc={toc} minHeadingLevel={2} maxHeadingLevel={6} />
|
||||
|
||||
</BrowserWindow>
|
||||
```
|
58
website/_dogfooding/_docs tests/toc/toc-test-good.mdx
Normal file
58
website/_dogfooding/_docs tests/toc/toc-test-good.mdx
Normal file
|
@ -0,0 +1,58 @@
|
|||
---
|
||||
toc_min_heading_level: 2
|
||||
toc_max_heading_level: 6
|
||||
---
|
||||
|
||||
Test the TOC behavior of a real-world md doc with valid headings
|
||||
|
||||
---
|
||||
|
||||
## lvl 2
|
||||
|
||||
### lvl 3
|
||||
|
||||
#### lvl 4
|
||||
|
||||
##### lvl 5
|
||||
|
||||
###### lvl 6
|
||||
|
||||
## lvl 2
|
||||
|
||||
### lvl 3
|
||||
|
||||
#### lvl 4
|
||||
|
||||
##### lvl 5
|
||||
|
||||
###### lvl 6
|
||||
|
||||
---
|
||||
|
||||
INLINE:
|
||||
|
||||
```mdx-code-block
|
||||
import BrowserWindow from '@site/src/components/BrowserWindow';
|
||||
|
||||
import TOCInline from '@theme/TOCInline';
|
||||
|
||||
<BrowserWindow>
|
||||
|
||||
<TOCInline toc={toc} minHeadingLevel={2} maxHeadingLevel={6} />
|
||||
|
||||
</BrowserWindow>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
COLLAPSIBLE:
|
||||
|
||||
```mdx-code-block
|
||||
import TOCCollapsible from '@theme/TOCCollapsible';
|
||||
|
||||
<BrowserWindow>
|
||||
|
||||
<TOCCollapsible toc={toc} minHeadingLevel={2} maxHeadingLevel={6} />
|
||||
|
||||
</BrowserWindow>
|
||||
```
|
12
website/_dogfooding/_pages tests/page-toc-tests.mdx
Normal file
12
website/_dogfooding/_pages tests/page-toc-tests.mdx
Normal file
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
toc_min_heading_level: 2
|
||||
toc_max_heading_level: 4
|
||||
---
|
||||
|
||||
import Content, {
|
||||
toc as ContentToc,
|
||||
} from '@site/_dogfooding/_partials/toc-tests.md';
|
||||
|
||||
<Content />
|
||||
|
||||
export const toc = ContentToc;
|
67
website/_dogfooding/_partials/toc-tests.md
Normal file
67
website/_dogfooding/_partials/toc-tests.md
Normal file
|
@ -0,0 +1,67 @@
|
|||
# title
|
||||
|
||||
some text
|
||||
|
||||
## section 1
|
||||
|
||||
some text
|
||||
|
||||
### subsection 1-1
|
||||
|
||||
some text
|
||||
|
||||
#### subsection 1-1-1
|
||||
|
||||
some text
|
||||
|
||||
##### subsection 1-1-1-1
|
||||
|
||||
some text
|
||||
|
||||
###### subsection 1-1-1-1-1
|
||||
|
||||
some text
|
||||
|
||||
###### subsection 1-1-1-1-2
|
||||
|
||||
some text
|
||||
|
||||
##### subsection 1-1-1-2
|
||||
|
||||
some text
|
||||
|
||||
#### subsection 1-1-2
|
||||
|
||||
some text
|
||||
|
||||
### subsection 1-2
|
||||
|
||||
some text
|
||||
|
||||
### subsection 1-3
|
||||
|
||||
some text
|
||||
|
||||
## section 2
|
||||
|
||||
some text
|
||||
|
||||
### subsection 2-1
|
||||
|
||||
some text
|
||||
|
||||
### subsection 2-1
|
||||
|
||||
some text
|
||||
|
||||
## section 3
|
||||
|
||||
some text
|
||||
|
||||
### subsection 3-1
|
||||
|
||||
some text
|
||||
|
||||
### subsection 3-2
|
||||
|
||||
some text
|
|
@ -23,6 +23,16 @@ module.exports = {
|
|||
label: 'Huge sidebar category',
|
||||
items: generateHugeSidebarItems(4),
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'TOC tests',
|
||||
items: [
|
||||
{
|
||||
type: 'autogenerated',
|
||||
dirName: 'toc',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
@ -187,6 +187,8 @@ Accepted fields:
|
|||
| `tags` | `Tag[]` | `undefined` | A list of strings or objects of two string fields `label` and `permalink` to tag to your post. |
|
||||
| `draft` | `boolean` | `false` | A boolean flag to indicate that the blog post is work-in-progress and therefore should not be published yet. However, draft blog posts will be displayed during development. |
|
||||
| `hide_table_of_contents` | `boolean` | `false` | Whether to hide the table of contents to the right. |
|
||||
| `toc_min_heading_level` | `number` | `2` | The minimum heading level shown in the table of contents. Must be between 2 and 6 and lower or equal to the max value. |
|
||||
| `toc_max_heading_level` | `number` | `3` | The max heading level shown in the table of contents. Must be between 2 and 6. |
|
||||
| `keywords` | `string[]` | `undefined` | Keywords meta tag, which will become the `<meta name="keywords" content="keyword1,keyword2,..."/>` in `<head>`, used by search engines. |
|
||||
| `description` | `string` | The first line of Markdown content | The description of your document, which will become the `<meta name="description" content="..."/>` and `<meta property="og:description" content="..."/>` in `<head>`, used by search engines. |
|
||||
| `image` | `string` | `undefined` | Cover or thumbnail image that will be used when displaying the link to your post. |
|
||||
|
|
|
@ -248,6 +248,8 @@ Accepted fields:
|
|||
| `sidebar_position` | `number` | Default ordering | Controls the position of a doc inside the generated sidebar slice when using `autogenerated` sidebar items. See also [Autogenerated sidebar metadatas](/docs/sidebar#autogenerated-sidebar-metadatas). |
|
||||
| `hide_title` | `boolean` | `false` | Whether to hide the title at the top of the doc. It only hides a title declared through the frontmatter, and have no effect on a Markdown title at the top of your document. |
|
||||
| `hide_table_of_contents` | `boolean` | `false` | Whether to hide the table of contents to the right. |
|
||||
| `toc_min_heading_level` | `number` | `2` | The minimum heading level shown in the table of contents. Must be between 2 and 6 and lower or equal to the max value. |
|
||||
| `toc_max_heading_level` | `number` | `3` | The max heading level shown in the table of contents. Must be between 2 and 6. |
|
||||
| `parse_number_prefixes` | `boolean` | `numberPrefixParser` plugin option | Whether number prefix parsing is disabled on this doc. See also [Using number prefixes](/docs/sidebar#using-number-prefixes). |
|
||||
| `custom_edit_url` | `string` | Computed using the `editUrl` plugin option | The URL for editing this document. |
|
||||
| `keywords` | `string[]` | `undefined` | Keywords meta tag for the document page, for search engines. |
|
||||
|
|
|
@ -757,6 +757,34 @@ module.exports = {
|
|||
};
|
||||
```
|
||||
|
||||
## Table of Contents {#table-of-contents}
|
||||
|
||||
You can adjust the default table of contents via `themeConfig.tableOfContents`.
|
||||
|
||||
<small>
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `minHeadingLevel` | `number` | `2` | The minimum heading level shown in the table of contents. Must be between 2 and 6 and lower or equal to the max value. |
|
||||
| `maxHeadingLevel` | `number` | `3` | Max heading level displayed in the TOC. Should be an integer between 2 and 6. |
|
||||
|
||||
</small>
|
||||
|
||||
Example configuration:
|
||||
|
||||
```js title="docusaurus.config.js"
|
||||
module.exports = {
|
||||
themeConfig: {
|
||||
// highlight-start
|
||||
tableOfContents: {
|
||||
minHeadingLevel: 2,
|
||||
maxHeadingLevel: 5,
|
||||
},
|
||||
// highlight-end
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Hooks {#hooks}
|
||||
|
||||
### `useThemeContext` {#usethemecontext}
|
||||
|
|
|
@ -35,7 +35,9 @@ will show up on the table of contents on the upper right
|
|||
|
||||
So that your users will know what this page is all about without scrolling down or even without reading too much.
|
||||
|
||||
### Only h2 and h3 will be in the toc
|
||||
### Only h2 and h3 will be in the toc by default.
|
||||
|
||||
You can configure the TOC heading levels either per-document or in the theme configuration.
|
||||
|
||||
The headers are well-spaced so that the hierarchy is clear.
|
||||
|
||||
|
@ -67,7 +69,9 @@ will show up on the table of contents on the upper right
|
|||
|
||||
So that your users will know what this page is all about without scrolling down or even without reading too much.
|
||||
|
||||
<h3>Only h2 and h3 will be in the toc</h3>
|
||||
<h3>Only h2 and h3 will be in the toc by default.</h3>
|
||||
|
||||
You can configure the TOC heading levels either per-document or in the theme configuration.
|
||||
|
||||
The headers are well-spaced so that the hierarchy is clear.
|
||||
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
```
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue