feat(v2): inline table-of-contents + refactor TOC (#3904)

* Add TOCInline theme component

* Add TOCInline theme component doc + migration guide

* remove useless getPathsToWatch on classic theme

* rename rightToc to toc

* add temp theme-bootstrap TOCInline comp to fix build issue
This commit is contained in:
Sébastien Lorber 2020-12-11 16:30:53 +01:00 committed by GitHub
parent b11c24b752
commit 41ef333e47
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 206 additions and 36 deletions

View file

@ -12,13 +12,13 @@ const emoji = require('remark-emoji');
const matter = require('gray-matter'); const matter = require('gray-matter');
const stringifyObject = require('stringify-object'); const stringifyObject = require('stringify-object');
const slug = require('./remark/slug'); const slug = require('./remark/slug');
const rightToc = require('./remark/rightToc'); const toc = require('./remark/toc');
const transformImage = require('./remark/transformImage'); const transformImage = require('./remark/transformImage');
const transformLinks = require('./remark/transformLinks'); const transformLinks = require('./remark/transformLinks');
const DEFAULT_OPTIONS = { const DEFAULT_OPTIONS = {
rehypePlugins: [], rehypePlugins: [],
remarkPlugins: [emoji, slug, rightToc], remarkPlugins: [emoji, slug, toc],
}; };
module.exports = async function docusaurusMdxLoader(fileString) { module.exports = async function docusaurusMdxLoader(fileString) {

View file

@ -1,7 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`inline code should be escaped 1`] = ` exports[`inline code should be escaped 1`] = `
"export const rightToc = [ "export const toc = [
{ {
value: '<code>&lt;Head /&gt;</code>', value: '<code>&lt;Head /&gt;</code>',
id: 'head-', id: 'head-',
@ -43,7 +43,7 @@ exports[`inline code should be escaped 1`] = `
`; `;
exports[`non text phrasing content 1`] = ` exports[`non text phrasing content 1`] = `
"export const rightToc = [ "export const toc = [
{ {
value: '<em>Emphasis</em>', value: '<em>Emphasis</em>',
id: 'emphasis', id: 'emphasis',

View file

@ -1,4 +1,4 @@
export const rightToc = ['replaceMe']; export const toc = ['replaceMe'];
## Thanos ## Thanos

View file

@ -37,7 +37,7 @@ test('inline code should be escaped', async () => {
test('text content', async () => { test('text content', async () => {
const result = await processFixture('just-content'); const result = await processFixture('just-content');
expect(result).toMatchInlineSnapshot(` expect(result).toMatchInlineSnapshot(`
"export const rightToc = [ "export const toc = [
{ {
value: 'Endi', value: 'Endi',
id: 'endi', id: 'endi',
@ -83,7 +83,7 @@ test('text content', async () => {
test('should export even with existing name', async () => { test('should export even with existing name', async () => {
const result = await processFixture('name-exist'); const result = await processFixture('name-exist');
expect(result).toMatchInlineSnapshot(` expect(result).toMatchInlineSnapshot(`
"export const rightToc = [ "export const toc = [
{ {
value: 'Thanos', value: 'Thanos',
id: 'thanos', id: 'thanos',
@ -167,7 +167,7 @@ test('should insert below imports', async () => {
import somethingElse from 'something-else'; import somethingElse from 'something-else';
export const rightToc = [ export const toc = [
{ {
value: 'Title', value: 'Title',
id: 'title', id: 'title',
@ -200,7 +200,7 @@ test('should insert below imports', async () => {
test('empty headings', async () => { test('empty headings', async () => {
const result = await processFixture('empty-headings'); const result = await processFixture('empty-headings');
expect(result).toMatchInlineSnapshot(` expect(result).toMatchInlineSnapshot(`
"export const rightToc = []; "export const toc = [];
# Ignore this # Ignore this

View file

@ -59,7 +59,7 @@ const getOrCreateExistingTargetIndex = (children, name) => {
}; };
const plugin = (options = {}) => { const plugin = (options = {}) => {
const name = options.name || 'rightToc'; const name = options.name || 'toc';
const transformer = (node) => { const transformer = (node) => {
const headings = search(node); const headings = search(node);

View file

@ -11,7 +11,7 @@ const toString = require('mdast-util-to-string');
const visit = require('unist-util-visit'); const visit = require('unist-util-visit');
const {toValue} = require('../utils'); const {toValue} = require('../utils');
/** @typedef {import('@docusaurus/types').MarkdownRightTableOfContents} TOC */ /** @typedef {import('@docusaurus/types').TOCItem} TOC */
/** @typedef {import('unist').Node} Node */ /** @typedef {import('unist').Node} Node */
/** /**

View file

@ -24,7 +24,7 @@ declare module '@theme/BlogSidebar' {
} }
declare module '@theme/BlogPostPage' { declare module '@theme/BlogPostPage' {
import type {MarkdownRightTableOfContents} from '@docusaurus/types'; import type {TOCItem} from '@docusaurus/types';
import type {BlogSidebar} from '@theme/BlogSidebar'; import type {BlogSidebar} from '@theme/BlogSidebar';
export type FrontMatter = { export type FrontMatter = {
@ -61,7 +61,7 @@ declare module '@theme/BlogPostPage' {
export type Content = { export type Content = {
readonly frontMatter: FrontMatter; readonly frontMatter: FrontMatter;
readonly metadata: Metadata; readonly metadata: Metadata;
readonly rightToc: readonly MarkdownRightTableOfContents[]; readonly toc: readonly TOCItem[];
(): JSX.Element; (): JSX.Element;
}; };

View file

@ -46,7 +46,7 @@ declare module '@docusaurus/plugin-content-docs-types' {
} }
declare module '@theme/DocItem' { declare module '@theme/DocItem' {
import type {MarkdownRightTableOfContents} from '@docusaurus/types'; import type {TOCItem} from '@docusaurus/types';
export type DocumentRoute = { export type DocumentRoute = {
readonly component: () => JSX.Element; readonly component: () => JSX.Element;
@ -80,7 +80,7 @@ declare module '@theme/DocItem' {
readonly content: { readonly content: {
readonly frontMatter: FrontMatter; readonly frontMatter: FrontMatter;
readonly metadata: Metadata; readonly metadata: Metadata;
readonly rightToc: readonly MarkdownRightTableOfContents[]; readonly toc: readonly TOCItem[];
(): JSX.Element; (): JSX.Element;
}; };
}; };

View file

@ -6,7 +6,7 @@
*/ */
declare module '@theme/MDXPage' { declare module '@theme/MDXPage' {
import type {MarkdownRightTableOfContents} from '@docusaurus/types'; import type {TOCItem} from '@docusaurus/types';
export type Props = { export type Props = {
readonly content: { readonly content: {
@ -17,7 +17,7 @@ declare module '@theme/MDXPage' {
readonly hide_table_of_contents?: string; readonly hide_table_of_contents?: string;
}; };
readonly metadata: {readonly permalink: string}; readonly metadata: {readonly permalink: string};
readonly rightToc: readonly MarkdownRightTableOfContents[]; readonly toc: readonly TOCItem[];
(): JSX.Element; (): JSX.Element;
}; };
}; };

View file

@ -0,0 +1,14 @@
/**
* 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 from 'react';
function TOCInline(_props: any): JSX.Element {
return <div>TODO bootstrap toc</div>;
}
export default TOCInline;

View file

@ -15,7 +15,7 @@
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"build": "tsc --noEmit && yarn babel:lib && yarn babel:lib-next && yarn prettier", "build": "tsc --noEmit && yarn babel:lib && yarn babel:lib-next && yarn prettier",
"watch": "yarn babel:lib --watch", "watch": "concurrently -n \"lib,lib-next\" --kill-others \"yarn babel:lib --watch\" \"yarn babel:lib-next --watch\"",
"babel:lib": "cross-env BABEL_ENV=lib babel src -d lib --extensions \".tsx,.ts\" --ignore \"**/*.d.ts\" --copy-files", "babel:lib": "cross-env BABEL_ENV=lib babel src -d lib --extensions \".tsx,.ts\" --ignore \"**/*.d.ts\" --copy-files",
"babel:lib-next": "cross-env BABEL_ENV=lib-next babel src -d lib-next --extensions \".tsx,.ts\" --ignore \"**/*.d.ts\" --copy-files", "babel:lib-next": "cross-env BABEL_ENV=lib-next babel src -d lib-next --extensions \".tsx,.ts\" --ignore \"**/*.d.ts\" --copy-files",
"prettier": "prettier --config ../../.prettierrc --ignore-path ../../.prettierignore --write \"**/*.{js,ts}\"" "prettier": "prettier --config ../../.prettierrc --ignore-path ../../.prettierignore --write \"**/*.{js,ts}\""

View file

@ -72,6 +72,16 @@ export default function docusaurusThemeClassic(
return { return {
name: 'docusaurus-theme-classic', name: 'docusaurus-theme-classic',
/*
Does not seem needed: webpack can already hot reload theme files
getPathsToWatch() {
return [
path.join(__dirname, '..', 'lib'),
path.join(__dirname, '..', 'lib-next'),
];
},
*/
getThemePath() { getThemePath() {
return path.join(__dirname, '..', 'lib-next', 'theme'); return path.join(__dirname, '..', 'lib-next', 'theme');
}, },

View file

@ -53,9 +53,9 @@ function BlogPostPage(props: Props): JSX.Element {
</div> </div>
)} )}
</main> </main>
{!hideTableOfContents && BlogPostContents.rightToc && ( {!hideTableOfContents && BlogPostContents.toc && (
<div className="col col--2"> <div className="col col--2">
<TOC headings={BlogPostContents.rightToc} /> <TOC toc={BlogPostContents.toc} />
</div> </div>
)} )}
</div> </div>

View file

@ -162,9 +162,9 @@ function DocItem(props: Props): JSX.Element {
</div> </div>
</div> </div>
</div> </div>
{!hideTableOfContents && DocContent.rightToc && ( {!hideTableOfContents && DocContent.toc && (
<div className="col col--3"> <div className="col col--3">
<TOC headings={DocContent.rightToc} /> <TOC toc={DocContent.toc} />
</div> </div>
)} )}
</div> </div>

View file

@ -41,9 +41,9 @@ function MDXPage(props: Props): JSX.Element {
</MDXProvider> </MDXProvider>
</div> </div>
</div> </div>
{!hideTableOfContents && MDXPageContent.rightToc && ( {!hideTableOfContents && MDXPageContent.toc && (
<div className="col col--2"> <div className="col col--2">
<TOC headings={MDXPageContent.rightToc} /> <TOC toc={MDXPageContent.toc} />
</div> </div>
)} )}
</div> </div>

View file

@ -10,14 +10,21 @@ import clsx from 'clsx';
import useTOCHighlight from '@theme/hooks/useTOCHighlight'; import useTOCHighlight from '@theme/hooks/useTOCHighlight';
import type {TOCProps} from '@theme/TOC'; import type {TOCProps} from '@theme/TOC';
import styles from './styles.module.css'; import styles from './styles.module.css';
import {TOCItem} from '@docusaurus/types';
const LINK_CLASS_NAME = 'table-of-contents__link'; const LINK_CLASS_NAME = 'table-of-contents__link';
const ACTIVE_LINK_CLASS_NAME = 'table-of-contents__link--active'; const ACTIVE_LINK_CLASS_NAME = 'table-of-contents__link--active';
const TOP_OFFSET = 100; const TOP_OFFSET = 100;
/* eslint-disable jsx-a11y/control-has-associated-label */ /* eslint-disable jsx-a11y/control-has-associated-label */
function Headings({headings, isChild}: TOCProps & {isChild?: boolean}) { function Headings({
if (!headings.length) { toc,
isChild,
}: {
toc: readonly TOCItem[];
isChild?: boolean;
}) {
if (!toc.length) {
return null; return null;
} }
return ( return (
@ -25,7 +32,7 @@ function Headings({headings, isChild}: TOCProps & {isChild?: boolean}) {
className={ className={
isChild ? '' : 'table-of-contents table-of-contents__left-border' isChild ? '' : 'table-of-contents table-of-contents__left-border'
}> }>
{headings.map((heading) => ( {toc.map((heading) => (
<li key={heading.id}> <li key={heading.id}>
<a <a
href={`#${heading.id}`} href={`#${heading.id}`}
@ -34,18 +41,18 @@ function Headings({headings, isChild}: TOCProps & {isChild?: boolean}) {
// eslint-disable-next-line react/no-danger // eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{__html: heading.value}} dangerouslySetInnerHTML={{__html: heading.value}}
/> />
<Headings isChild headings={heading.children} /> <Headings isChild toc={heading.children} />
</li> </li>
))} ))}
</ul> </ul>
); );
} }
function TOC({headings}: TOCProps): JSX.Element { function TOC({toc}: TOCProps): JSX.Element {
useTOCHighlight(LINK_CLASS_NAME, ACTIVE_LINK_CLASS_NAME, TOP_OFFSET); useTOCHighlight(LINK_CLASS_NAME, ACTIVE_LINK_CLASS_NAME, TOP_OFFSET);
return ( return (
<div className={clsx(styles.tableOfContents, 'thin-scrollbar')}> <div className={clsx(styles.tableOfContents, 'thin-scrollbar')}>
<Headings headings={headings} /> <Headings toc={toc} />
</div> </div>
); );
} }

View file

@ -0,0 +1,53 @@
/**
* 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 from 'react';
import clsx from 'clsx';
import type {TOCProps} from '@theme/TOC';
import styles from './styles.module.css';
import {TOCItem} from '@docusaurus/types';
const LINK_CLASS_NAME = styles['table-of-contents__link--inline'];
/* eslint-disable jsx-a11y/control-has-associated-label */
function HeadingsInline({
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}`}
className={LINK_CLASS_NAME}
// 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}: TOCProps): JSX.Element {
return (
<div className={clsx(styles.tableOfContentsInline)}>
<HeadingsInline toc={toc} />
</div>
);
}
export default TOCInline;

View file

@ -0,0 +1,14 @@
/**
* 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.
*/
.tableOfContentsInline ul {
list-style-type: disc;
}
.table-of-contents__link--inline {
color: var(--ifm-toc-link-color);
}

View file

@ -413,16 +413,27 @@ declare module '@theme/ThemeProvider' {
} }
declare module '@theme/TOC' { declare module '@theme/TOC' {
import type {MarkdownRightTableOfContents} from '@docusaurus/types'; import type {TOCItem} from '@docusaurus/types';
export type TOCProps = { export type TOCProps = {
readonly headings: readonly MarkdownRightTableOfContents[]; readonly toc: readonly TOCItem[];
}; };
const TOC: (props: TOCProps) => JSX.Element; const TOC: (props: TOCProps) => JSX.Element;
export default TOC; export default TOC;
} }
declare module '@theme/TOCInline' {
import type {TOCItem} from '@docusaurus/types';
export type TOCInlineProps = {
readonly toc: readonly TOCItem[];
};
const TOCInline: (props: TOCInlineProps) => JSX.Element;
export default TOCInline;
}
declare module '@theme/Toggle' { declare module '@theme/Toggle' {
import {ComponentProps} from 'react'; import {ComponentProps} from 'react';
import ReactToggle from 'react-toggle'; import ReactToggle from 'react-toggle';

View file

@ -344,8 +344,8 @@ export interface ValidationSchema<T> {
append(data: any): ValidationSchema<T>; append(data: any): ValidationSchema<T>;
} }
export interface MarkdownRightTableOfContents { export interface TOCItem {
readonly value: string; readonly value: string;
readonly id: string; readonly id: string;
readonly children: MarkdownRightTableOfContents[]; readonly children: TOCItem[];
} }

View file

@ -20,6 +20,12 @@ Once your website is bootstrapped, the website source will contain the Docusauru
} }
``` ```
## Index
import TOCInline from "@theme/TOCInline"
<TOCInline toc={toc[1].children}/>
## Docusaurus CLI commands ## Docusaurus CLI commands
Below is a list of Docusaurus CLI commands and their usages: Below is a list of Docusaurus CLI commands and their usages:

View file

@ -471,6 +471,61 @@ import TabItem from '@theme/TabItem';
<TabItem value="banana">This is a banana 🍌</TabItem> <TabItem value="banana">This is a banana 🍌</TabItem>
</Tabs> </Tabs>
## Inline table of contents
Each markdown document displays a tab of content on the top-right corner.
But it is also possible to display an inline table of contents directly inside a markdown document, thanks to MDX.
### Full table of contents
The `toc` variable is available in any MDX document, and contain all the top level headings of a MDX document.
```jsx
import TOCInline from '@theme/TOCInline';
<TOCInline toc={toc} />;
```
import TOCInline from '@theme/TOCInline';
<BrowserWindow>
<TOCInline toc={toc} />
</BrowserWindow>
### Custom table of contents
The `toc` props is just a list of table of contents items:
```ts
type TOCItem = {
value: string;
id: string;
children: TOCItem[];
};
```
You can create this TOC tree manually, or derive a new TOC tree from the `toc` variable:
```jsx
import TOCInline from '@theme/TOCInline';
<TOCInline
toc={
// Only show 4th and 5th top-level heading
[toc[3], toc[4]]
}
/>;
```
<BrowserWindow>
<TOCInline toc={[toc[3], toc[4]]} />
</BrowserWindow>
## Callouts/admonitions ## Callouts/admonitions
In addition to the basic Markdown syntax, we use [remark-admonitions](https://github.com/elviswolcott/remark-admonitions) alongside MDX to add support for admonitions. Admonitions are wrapped by a set of 3 colons. In addition to the basic Markdown syntax, we use [remark-admonitions](https://github.com/elviswolcott/remark-admonitions) alongside MDX to add support for admonitions. Admonitions are wrapped by a set of 3 colons.

View file

@ -554,9 +554,9 @@ The following code could be helpful for migration of various pages:
## Content ## Content
### Remove AUTOGENERATED_TABLE_OF_CONTENTS ### Replace AUTOGENERATED_TABLE_OF_CONTENTS
This feature is deprecated. You may read more about it in [this issue](https://github.com/facebook/docusaurus/issues/1549). If you need the feature, use [remark-toc](https://github.com/remarkjs/remark-toc) instead and pass it to docs plugin's `remarkPlugins` option. This feature is replaced by [inline table of content](../markdown-features.mdx#inline-table-of-contents)
### Update Markdown syntax to be MDX-compatible ### Update Markdown syntax to be MDX-compatible