fix(mdx-loader): the table-of-contents should display toc/headings of imported MDX partials (#9684)

Co-authored-by: Titus <tituswormer@gmail.com>
Co-authored-by: sebastienlorber <lorber.sebastien@gmail.com>
This commit is contained in:
Anatoly Kopyl 2024-01-19 20:58:11 +03:00 committed by GitHub
parent bed11f62bc
commit 3c982127d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1145 additions and 408 deletions

View file

@ -18,8 +18,6 @@
},
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.22.7",
"@babel/traverse": "^7.22.8",
"@docusaurus/logger": "3.0.0",
"@docusaurus/utils": "3.0.0",
"@docusaurus/utils-validation": "3.0.0",

View file

@ -7,7 +7,7 @@
import {mdxLoader} from './loader';
import type {TOCItem as TOCItemImported} from './remark/toc';
import type {TOCItem as TOCItemImported} from './remark/toc/types';
export default mdxLoader;

View file

@ -0,0 +1,5 @@
import React from 'react';
export default function SomeComponent() {
return <div>Some component</div>;
}

View file

@ -0,0 +1,7 @@
## Partial 1
Partial 1
### Partial 1 Sub Heading
Content

View file

@ -0,0 +1,3 @@
## Partial 2 Nested
Partial 2 Nested

View file

@ -0,0 +1,11 @@
## Partial 2
Partial 2
### Partial 2 Sub Heading
Content
import Partial2Nested from './partial2-nested.md';
<Partial2Nested />

View file

@ -0,0 +1,7 @@
## Partial 3
Partial 3
### Partial 3 Sub Heading
Content

View file

@ -0,0 +1,49 @@
import Partial1 from './_partial1.md';
import SomeComponent from './SomeComponent';
# Index
Some text
import Partial2 from './_partial2.md';
## Index section 1
Foo
<Partial1 />
Some text
<SomeComponent />
## Index section 2
<Partial2 />
## Unused partials
Unused partials (that are only imported but not rendered) shouldn't alter the TOC
import UnusedPartialImport from './_partial3.md';
## NonExisting Partials
Partials that do not exist should alter the TOC
It's not the responsibility of the Remark plugin to check for their existence
import DoesNotExist from './_doesNotExist.md';
<DoesNotExist />
## Duplicate partials
It's fine if we use partials at the end
<Partial1 />
And we can use the partial multiple times!
<Partial1 />

View file

@ -0,0 +1,7 @@
# Partial used before import
While it looks weird to import after usage, this remains valid MDX usage.
<Partial />
import Partial from './_partial.md';

View file

@ -1,238 +1,601 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`toc remark plugin does not overwrite TOC var if no TOC 1`] = `
"foo
\`bar\`
\`\`\`js
baz
\`\`\`
"import {Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs} from "react/jsx-runtime";
export const toc = 1;
function _createMdxContent(props) {
const _components = {
code: "code",
p: "p",
pre: "pre",
...props.components
};
return _jsxs(_Fragment, {
children: [_jsx(_components.p, {
children: "foo"
}), "/n", _jsx(_components.p, {
children: _jsx(_components.code, {
children: "bar"
})
}), "/n", _jsx(_components.pre, {
children: _jsx(_components.code, {
className: "language-js",
children: "baz/n"
})
})]
});
}
export default function MDXContent(props = {}) {
const {wrapper: MDXLayout} = props.components || ({});
return MDXLayout ? _jsx(MDXLayout, {
...props,
children: _jsx(_createMdxContent, {
...props
})
}) : _createMdxContent(props);
}
"
`;
exports[`toc remark plugin escapes inline code 1`] = `
"export const toc = [
{
value: '<code>&lt;Head /&gt;</code>',
id: 'head-',
level: 2
},
{
value: '<code>&lt;Head&gt;Test&lt;/Head&gt;</code>',
id: 'headtesthead',
level: 3
},
{
value: '<code>&lt;div /&gt;</code>',
id: 'div-',
level: 2
},
{
value: '<code>&lt;div&gt; Test &lt;/div&gt;</code>',
id: 'div-test-div',
level: 2
},
{
value: '<code>&lt;div&gt;&lt;i&gt;Test&lt;/i&gt;&lt;/div&gt;</code>',
id: 'divitestidiv',
level: 2
},
{
value: '<code>&lt;div&gt;&lt;i&gt;Test&lt;/i&gt;&lt;/div&gt;</code>',
id: 'divitestidiv-1',
level: 2
"import {Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs} from "react/jsx-runtime";
export const toc = [{
"value": "<code>&lt;Head /&gt;</code>",
"id": "head-",
"level": 2
}, {
"value": "<code>&lt;Head&gt;Test&lt;/Head&gt;</code>",
"id": "headtesthead",
"level": 3
}, {
"value": "<code>&lt;div /&gt;</code>",
"id": "div-",
"level": 2
}, {
"value": "<code>&lt;div&gt; Test &lt;/div&gt;</code>",
"id": "div-test-div",
"level": 2
}, {
"value": "<code>&lt;div&gt;&lt;i&gt;Test&lt;/i&gt;&lt;/div&gt;</code>",
"id": "divitestidiv",
"level": 2
}, {
"value": "<code>&lt;div&gt;&lt;i&gt;Test&lt;/i&gt;&lt;/div&gt;</code>",
"id": "divitestidiv-1",
"level": 2
}];
function _createMdxContent(props) {
const _components = {
a: "a",
code: "code",
h2: "h2",
h3: "h3",
...props.components
};
return _jsxs(_Fragment, {
children: [_jsx(_components.h2, {
id: "head-",
children: _jsx(_components.code, {
children: "<Head />"
})
}), "/n", _jsx(_components.h3, {
id: "headtesthead",
children: _jsx(_components.code, {
children: "<Head>Test</Head>"
})
}), "/n", _jsx(_components.h2, {
id: "div-",
children: _jsx(_components.code, {
children: "<div />"
})
}), "/n", _jsx(_components.h2, {
id: "div-test-div",
children: _jsx(_components.code, {
children: "<div> Test </div>"
})
}), "/n", _jsx(_components.h2, {
id: "divitestidiv",
children: _jsx(_components.code, {
children: "<div><i>Test</i></div>"
})
}), "/n", _jsx(_components.h2, {
id: "divitestidiv-1",
children: _jsx(_components.a, {
href: "/some/link",
children: _jsx(_components.code, {
children: "<div><i>Test</i></div>"
})
})
})]
});
}
export default function MDXContent(props = {}) {
const {wrapper: MDXLayout} = props.components || ({});
return MDXLayout ? _jsx(MDXLayout, {
...props,
children: _jsx(_createMdxContent, {
...props
})
}) : _createMdxContent(props);
}
]
## \`<Head />\`
### \`<Head>Test</Head>\`
## \`<div />\`
## \`<div> Test </div>\`
## \`<div><i>Test</i></div>\`
## [\`<div><i>Test</i></div>\`](/some/link)
"
`;
exports[`toc remark plugin exports even with existing name 1`] = `
"export const toc = [
{
value: 'Thanos',
id: 'thanos',
level: 2
},
{
value: 'Tony Stark',
id: 'tony-stark',
level: 2
},
{
value: 'Avengers',
id: 'avengers',
level: 3
"import {Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs} from "react/jsx-runtime";
export const toc = ['replaceMe'];
function _createMdxContent(props) {
const _components = {
h2: "h2",
h3: "h3",
...props.components
};
return _jsxs(_Fragment, {
children: [_jsx(_components.h2, {
id: "thanos",
children: "Thanos"
}), "/n", _jsx(_components.h2, {
id: "tony-stark",
children: "Tony Stark"
}), "/n", _jsx(_components.h3, {
id: "avengers",
children: "Avengers"
})]
});
}
export default function MDXContent(props = {}) {
const {wrapper: MDXLayout} = props.components || ({});
return MDXLayout ? _jsx(MDXLayout, {
...props,
children: _jsx(_createMdxContent, {
...props
})
}) : _createMdxContent(props);
}
]
## Thanos
## Tony Stark
### Avengers
"
`;
exports[`toc remark plugin handles empty headings 1`] = `
"export const toc = []
# Ignore this
##
## ![](an-image.svg)
"import {Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs} from "react/jsx-runtime";
export const toc = [];
function _createMdxContent(props) {
const _components = {
h1: "h1",
h2: "h2",
img: "img",
...props.components
};
return _jsxs(_Fragment, {
children: [_jsx(_components.h1, {
id: "ignore-this",
children: "Ignore this"
}), "/n", _jsx(_components.h2, {
id: ""
}), "/n", _jsx(_components.h2, {
id: "-1",
children: _jsx(_components.img, {
src: "an-image.svg",
alt: ""
})
})]
});
}
export default function MDXContent(props = {}) {
const {wrapper: MDXLayout} = props.components || ({});
return MDXLayout ? _jsx(MDXLayout, {
...props,
children: _jsx(_createMdxContent, {
...props
})
}) : _createMdxContent(props);
}
"
`;
exports[`toc remark plugin inserts below imports 1`] = `
"import something from 'something';
"import {Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs} from "react/jsx-runtime";
import something from 'something';
import somethingElse from 'something-else';
export const toc = [
{
value: 'Title',
id: 'title',
level: 2
},
{
value: 'Test',
id: 'test',
level: 2
},
{
value: 'Again',
id: 'again',
level: 3
export const toc = [{
"value": "Title",
"id": "title",
"level": 2
}, {
"value": "Test",
"id": "test",
"level": 2
}, {
"value": "Again",
"id": "again",
"level": 3
}];
function _createMdxContent(props) {
const _components = {
h2: "h2",
h3: "h3",
p: "p",
...props.components
};
return _jsxs(_Fragment, {
children: [_jsx(_components.h2, {
id: "title",
children: "Title"
}), "/n", _jsx(_components.h2, {
id: "test",
children: "Test"
}), "/n", _jsx(_components.h3, {
id: "again",
children: "Again"
}), "/n", _jsx(_components.p, {
children: "Content."
})]
});
}
export default function MDXContent(props = {}) {
const {wrapper: MDXLayout} = props.components || ({});
return MDXLayout ? _jsx(MDXLayout, {
...props,
children: _jsx(_createMdxContent, {
...props
})
}) : _createMdxContent(props);
}
]
## Title
## Test
### Again
Content.
"
`;
exports[`toc remark plugin outputs empty array for no TOC 1`] = `
"export const toc = []
foo
\`bar\`
\`\`\`js
baz
\`\`\`
"import {Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs} from "react/jsx-runtime";
export const toc = [];
function _createMdxContent(props) {
const _components = {
code: "code",
p: "p",
pre: "pre",
...props.components
};
return _jsxs(_Fragment, {
children: [_jsx(_components.p, {
children: "foo"
}), "/n", _jsx(_components.p, {
children: _jsx(_components.code, {
children: "bar"
})
}), "/n", _jsx(_components.pre, {
children: _jsx(_components.code, {
className: "language-js",
children: "baz/n"
})
})]
});
}
export default function MDXContent(props = {}) {
const {wrapper: MDXLayout} = props.components || ({});
return MDXLayout ? _jsx(MDXLayout, {
...props,
children: _jsx(_createMdxContent, {
...props
})
}) : _createMdxContent(props);
}
"
`;
exports[`toc remark plugin works on non text phrasing content 1`] = `
"export const toc = [
{
value: '<em>Emphasis</em>',
id: 'emphasis',
level: 2
"import {Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs} from "react/jsx-runtime";
export const toc = [{
"value": "<em>Emphasis</em>",
"id": "emphasis",
"level": 2
}, {
"value": "<strong>Importance</strong>",
"id": "importance",
"level": 3
}, {
"value": "<del>Strikethrough</del>",
"id": "strikethrough",
"level": 2
}, {
"value": "<i>HTML</i>",
"id": "html",
"level": 2
}, {
"value": "<code>inline.code()</code>",
"id": "inlinecode",
"level": 2
}, {
"value": "some <span class=\\"some-class\\">styled</span> <strong>heading</strong> <span class=\\"myClassName &lt;&gt; weird char\\"></span> test",
"id": "some-styled-heading--test",
"level": 2
}];
function _createMdxContent(props) {
const _components = {
code: "code",
del: "del",
em: "em",
h2: "h2",
h3: "h3",
strong: "strong",
...props.components
};
return _jsxs(_Fragment, {
children: [_jsx(_components.h2, {
id: "emphasis",
children: _jsx(_components.em, {
children: "Emphasis"
})
}), "/n", _jsx(_components.h3, {
id: "importance",
children: _jsx(_components.strong, {
children: "Importance"
})
}), "/n", _jsx(_components.h2, {
id: "strikethrough",
children: _jsx(_components.del, {
children: "Strikethrough"
})
}), "/n", _jsx(_components.h2, {
id: "html",
children: _jsx("i", {
children: "HTML"
})
}), "/n", _jsx(_components.h2, {
id: "inlinecode",
children: _jsx(_components.code, {
children: "inline.code()"
})
}), "/n", _jsxs(_components.h2, {
id: "some-styled-heading--test",
children: ["some ", _jsx("span", {
className: "some-class",
style: {
border: "solid"
},
{
value: '<strong>Importance</strong>',
id: 'importance',
level: 3
},
{
value: '<del>Strikethrough</del>',
id: 'strikethrough',
level: 2
},
{
value: '<i>HTML</i>',
id: 'html',
level: 2
},
{
value: '<code>inline.code()</code>',
id: 'inlinecode',
level: 2
},
{
value: 'some <span class="some-class">styled</span> <strong>heading</strong> <span class="myClassName &lt;&gt; weird char"></span> test',
id: 'some-styled-heading--test',
level: 2
children: "styled"
}), " ", _jsx("strong", {
children: "heading"
}), " ", _jsx("span", {
class: "myClass",
className: "myClassName <> weird char",
"data-random-attr": "456"
}), " test"]
})]
});
}
export default function MDXContent(props = {}) {
const {wrapper: MDXLayout} = props.components || ({});
return MDXLayout ? _jsx(MDXLayout, {
...props,
children: _jsx(_createMdxContent, {
...props
})
}) : _createMdxContent(props);
}
]
## *Emphasis*
### **Importance**
## ~~Strikethrough~~
## <i>HTML</i>
## \`inline.code()\`
## some <span className="some-class" style={{border: "solid"}}>styled</span> <strong>heading</strong> <span class="myClass" className="myClassName <> weird char" data-random-attr="456" /> test
"
`;
exports[`toc remark plugin works on text content 1`] = `
"export const toc = [
{
value: 'Endi',
id: 'endi',
level: 3
},
{
value: 'Endi',
id: 'endi-1',
level: 2
},
{
value: 'Yangshun',
id: 'yangshun',
level: 3
},
{
value: 'I ♥ unicode.',
id: 'i--unicode',
level: 2
}
]
### Endi
\`\`\`md
## This is ignored
\`\`\`
## Endi
Lorem ipsum
### Yangshun
Some content here
## I ♥ unicode.
"import {Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs} from "react/jsx-runtime";
export const c = 1;
export const toc = [{
"value": "Endi",
"id": "endi",
"level": 3
}, {
"value": "Endi",
"id": "endi-1",
"level": 2
}, {
"value": "Yangshun",
"id": "yangshun",
"level": 3
}, {
"value": "I ♥ unicode.",
"id": "i--unicode",
"level": 2
}];
function _createMdxContent(props) {
const _components = {
code: "code",
h2: "h2",
h3: "h3",
p: "p",
pre: "pre",
...props.components
};
return _jsxs(_Fragment, {
children: [_jsx(_components.h3, {
id: "endi",
children: "Endi"
}), "/n", _jsx(_components.pre, {
children: _jsx(_components.code, {
className: "language-md",
children: "## This is ignored/n"
})
}), "/n", _jsx(_components.h2, {
id: "endi-1",
children: "Endi"
}), "/n", _jsx(_components.p, {
children: "Lorem ipsum"
}), "/n", _jsx(_components.h3, {
id: "yangshun",
children: "Yangshun"
}), "/n", _jsx(_components.p, {
children: "Some content here"
}), "/n", _jsx(_components.h2, {
id: "i--unicode",
children: "I ♥ unicode."
})]
});
}
export default function MDXContent(props = {}) {
const {wrapper: MDXLayout} = props.components || ({});
return MDXLayout ? _jsx(MDXLayout, {
...props,
children: _jsx(_createMdxContent, {
...props
})
}) : _createMdxContent(props);
}
"
`;
exports[`toc remark plugin works with imported markdown 1`] = `
"import {Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs} from "react/jsx-runtime";
import Partial1, {toc as __tocPartial1} from './_partial1.md';
import SomeComponent from './SomeComponent';
import Partial2, {toc as __tocPartial2} from './_partial2.md';
import UnusedPartialImport from './_partial3.md';
import DoesNotExist, {toc as __tocDoesNotExist} from './_doesNotExist.md';
export const toc = [{
"value": "Index section 1",
"id": "index-section-1",
"level": 2
}, ...__tocPartial1, {
"value": "Index section 2",
"id": "index-section-2",
"level": 2
}, ...__tocPartial2, {
"value": "Unused partials",
"id": "unused-partials",
"level": 2
}, {
"value": "NonExisting Partials",
"id": "nonexisting-partials",
"level": 2
}, ...__tocDoesNotExist, {
"value": "Duplicate partials",
"id": "duplicate-partials",
"level": 2
}, ...__tocPartial1, ...__tocPartial1];
function _createMdxContent(props) {
const _components = {
h1: "h1",
h2: "h2",
p: "p",
...props.components
};
return _jsxs(_Fragment, {
children: [_jsx(_components.h1, {
id: "index",
children: "Index"
}), "/n", _jsx(_components.p, {
children: "Some text"
}), "/n", "/n", _jsx(_components.h2, {
id: "index-section-1",
children: "Index section 1"
}), "/n", _jsx(_components.p, {
children: "Foo"
}), "/n", _jsx(Partial1, {}), "/n", _jsx(_components.p, {
children: "Some text"
}), "/n", _jsx(SomeComponent, {}), "/n", _jsx(_components.h2, {
id: "index-section-2",
children: "Index section 2"
}), "/n", _jsx(Partial2, {}), "/n", _jsx(_components.h2, {
id: "unused-partials",
children: "Unused partials"
}), "/n", _jsx(_components.p, {
children: "Unused partials (that are only imported but not rendered) shouldn't alter the TOC"
}), "/n", "/n", _jsx(_components.h2, {
id: "nonexisting-partials",
children: "NonExisting Partials"
}), "/n", _jsx(_components.p, {
children: "Partials that do not exist should alter the TOC"
}), "/n", _jsx(_components.p, {
children: "It's not the responsibility of the Remark plugin to check for their existence"
}), "/n", "/n", _jsx(DoesNotExist, {}), "/n", _jsx(_components.h2, {
id: "duplicate-partials",
children: "Duplicate partials"
}), "/n", _jsx(_components.p, {
children: "It's fine if we use partials at the end"
}), "/n", _jsx(Partial1, {}), "/n", _jsx(_components.p, {
children: "And we can use the partial multiple times!"
}), "/n", _jsx(Partial1, {})]
});
}
export default function MDXContent(props = {}) {
const {wrapper: MDXLayout} = props.components || ({});
return MDXLayout ? _jsx(MDXLayout, {
...props,
children: _jsx(_createMdxContent, {
...props
})
}) : _createMdxContent(props);
}
"
`;
exports[`toc remark plugin works with partial imported after its usage 1`] = `
"import {Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs} from "react/jsx-runtime";
import Partial, {toc as __tocPartial} from './_partial.md';
export const toc = [...__tocPartial];
function _createMdxContent(props) {
const _components = {
h1: "h1",
p: "p",
...props.components
};
return _jsxs(_Fragment, {
children: [_jsx(_components.h1, {
id: "partial-used-before-import",
children: "Partial used before import"
}), "/n", _jsx(_components.p, {
children: "While it looks weird to import after usage, this remains valid MDX usage."
}), "/n", _jsx(Partial, {})]
});
}
export default function MDXContent(props = {}) {
const {wrapper: MDXLayout} = props.components || ({});
return MDXLayout ? _jsx(MDXLayout, {
...props,
children: _jsx(_createMdxContent, {
...props
})
}) : _createMdxContent(props);
}
"
`;
exports[`toc remark plugin works with partials importing other partials 1`] = `
"import {Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs} from "react/jsx-runtime";
import Partial2Nested, {toc as __tocPartial2Nested} from './partial2-nested.md';
export const toc = [{
"value": "Partial 2",
"id": "partial-2",
"level": 2
}, {
"value": "Partial 2 Sub Heading",
"id": "partial-2-sub-heading",
"level": 3
}, ...__tocPartial2Nested];
function _createMdxContent(props) {
const _components = {
h2: "h2",
h3: "h3",
p: "p",
...props.components
};
return _jsxs(_Fragment, {
children: [_jsx(_components.h2, {
id: "partial-2",
children: "Partial 2"
}), "/n", _jsx(_components.p, {
children: "Partial 2"
}), "/n", _jsx(_components.h3, {
id: "partial-2-sub-heading",
children: "Partial 2 Sub Heading"
}), "/n", _jsx(_components.p, {
children: "Content"
}), "/n", "/n", _jsx(Partial2Nested, {})]
});
}
export default function MDXContent(props = {}) {
const {wrapper: MDXLayout} = props.components || ({});
return MDXLayout ? _jsx(MDXLayout, {
...props,
children: _jsx(_createMdxContent, {
...props
})
}) : _createMdxContent(props);
}
"
`;

View file

@ -11,18 +11,23 @@ import plugin from '../index';
import headings from '../../headings/index';
const processFixture = async (name: string) => {
const {remark} = await import('remark');
const {default: gfm} = await import('remark-gfm');
const {default: mdx} = await import('remark-mdx');
const filePath = path.join(__dirname, '__fixtures__', `${name}.md`);
const {compile} = await import('@mdx-js/mdx');
const filePath = path.join(
__dirname,
'__fixtures__',
name.endsWith('.mdx') ? name : `${name}.md`,
);
const file = await vfile.read(filePath);
const result = await remark()
.use(headings)
.use(gfm)
.use(mdx)
.use(plugin)
.process(file);
const result = await compile(file, {
format: 'mdx',
remarkPlugins: [headings, gfm, plugin],
rehypePlugins: [],
});
return result.value;
};
@ -70,4 +75,21 @@ describe('toc remark plugin', () => {
const result = await processFixture('empty-headings');
expect(result).toMatchSnapshot();
});
it('works with imported markdown', async () => {
const result = await processFixture('partials/index.mdx');
expect(result).toMatchSnapshot();
});
it('works with partials importing other partials', async () => {
const result = await processFixture('partials/_partial2.mdx');
expect(result).toMatchSnapshot();
});
it('works with partial imported after its usage', async () => {
const result = await processFixture(
'partials/partial-used-before-import.mdx',
);
expect(result).toMatchSnapshot();
});
});

View file

@ -5,154 +5,183 @@
* LICENSE file in the root directory of this source tree.
*/
import {parse, type ParserOptions} from '@babel/parser';
import traverse from '@babel/traverse';
import stringifyObject from 'stringify-object';
import {toValue} from '../utils';
import type {Identifier} from '@babel/types';
import type {Node, Parent} from 'unist';
import type {Heading, Literal} from 'mdast';
import {
addTocSliceImportIfNeeded,
createTOCExportNodeAST,
findDefaultImportName,
getImportDeclarations,
isMarkdownImport,
isNamedExport,
} from './utils';
import type {Heading, Root} from 'mdast';
// @ts-expect-error: TODO see https://github.com/microsoft/TypeScript/issues/49721
import type {Transformer} from 'unified';
import type {
MdxjsEsm,
MdxJsxFlowElement,
// @ts-expect-error: TODO see https://github.com/microsoft/TypeScript/issues/49721
} from 'mdast-util-mdx';
// TODO as of April 2023, no way to import/re-export this ESM type easily :/
// TODO upgrade to TS 5.3
// See https://github.com/microsoft/TypeScript/issues/49721#issuecomment-1517839391
// import type {Plugin} from 'unified';
type Plugin = any; // TODO fix this asap
export type TOCItem = {
readonly value: string;
readonly id: string;
readonly level: number;
};
const parseOptions: ParserOptions = {
plugins: ['jsx'],
sourceType: 'module',
};
const isImport = (child: any): child is Literal =>
child.type === 'mdxjsEsm' && child.value.startsWith('import');
const hasImports = (index: number) => index > -1;
const isExport = (child: any): child is Literal =>
child.type === 'mdxjsEsm' && child.value.startsWith('export');
import type {TOCItems} from './types';
import type {ImportDeclaration} from 'estree';
interface PluginOptions {
name?: string;
}
const isTarget = (child: Literal, name: string) => {
let found = false;
const ast = parse(child.value, parseOptions);
traverse(ast, {
VariableDeclarator: (path) => {
if ((path.node.id as Identifier).name === name) {
found = true;
// ComponentName (default export) => ImportDeclaration mapping
type MarkdownImports = Map<string, {declaration: ImportDeclaration}>;
// MdxjsEsm node representing an already existing "export const toc" declaration
type ExistingTOCExport = MdxjsEsm | null;
function createTocSliceImportName({
tocExportName,
componentName,
}: {
tocExportName: string;
componentName: string;
}) {
// The name of the toc slice import alias doesn't matter much
// We just need to ensure it's valid and won't conflict with other names
return `__${tocExportName}${componentName}`;
}
},
async function collectImportsExports({
root,
tocExportName,
}: {
root: Root;
tocExportName: string;
}): Promise<{
markdownImports: MarkdownImports;
existingTocExport: ExistingTOCExport;
}> {
const {visit} = await import('unist-util-visit');
const markdownImports = new Map<string, {declaration: ImportDeclaration}>();
let existingTocExport: MdxjsEsm | null = null;
visit(root, 'mdxjsEsm', (node) => {
if (!node.data?.estree) {
return;
}
if (isNamedExport(node, tocExportName)) {
existingTocExport = node;
}
getImportDeclarations(node.data.estree).forEach((declaration) => {
if (!isMarkdownImport(declaration)) {
return;
}
const componentName = findDefaultImportName(declaration);
if (!componentName) {
return;
}
markdownImports.set(componentName, {
declaration,
});
});
return found;
};
const getOrCreateExistingTargetIndex = async (
children: Node[],
name: string,
) => {
let importsIndex = -1;
let targetIndex = -1;
children.forEach((child, index) => {
if (isImport(child)) {
importsIndex = index;
} else if (isExport(child) && isTarget(child, name)) {
targetIndex = index;
}
});
if (targetIndex === -1) {
const target = await createExportNode(name, []);
targetIndex = hasImports(importsIndex) ? importsIndex + 1 : 0;
children.splice(targetIndex, 0, target);
return {markdownImports, existingTocExport};
}
return targetIndex;
};
const plugin: Plugin = function plugin(
options: PluginOptions = {},
): Transformer {
const name = options.name || 'toc';
return async (root) => {
async function collectTOCItems({
root,
tocExportName,
markdownImports,
}: {
root: Root;
tocExportName: string;
markdownImports: MarkdownImports;
}): Promise<{
// The toc items we collected in the tree
tocItems: TOCItems;
}> {
const {toString} = await import('mdast-util-to-string');
const {visit} = await import('unist-util-visit');
const headings: TOCItem[] = [];
const tocItems: TOCItems = [];
visit(root, 'heading', (child: Heading) => {
const value = toString(child);
visit(root, (child) => {
if (child.type === 'heading') {
visitHeading(child);
} else if (child.type === 'mdxJsxFlowElement') {
visitJSXElement(child);
}
});
return {tocItems};
// Visit Markdown headings
function visitHeading(node: Heading) {
const value = toString(node);
// depth:1 headings are titles and not included in the TOC
if (!value || child.depth < 2) {
if (!value || node.depth < 2) {
return;
}
tocItems.push({
type: 'heading',
heading: node,
});
}
// Visit JSX elements, such as <Partial/>
function visitJSXElement(node: MdxJsxFlowElement) {
const componentName = node.name;
if (!componentName) {
return;
}
const importDeclaration = markdownImports.get(componentName)?.declaration;
if (!importDeclaration) {
return;
}
headings.push({
value: toValue(child, toString),
id: child.data!.id!,
level: child.depth,
});
const tocSliceImportName = createTocSliceImportName({
tocExportName,
componentName,
});
const {children} = root as Parent;
const targetIndex = await getOrCreateExistingTargetIndex(children, name);
tocItems.push({
type: 'slice',
importName: tocSliceImportName,
});
if (headings?.length) {
children[targetIndex] = await createExportNode(name, headings);
addTocSliceImportIfNeeded({
importDeclaration,
tocExportName,
tocSliceImportName,
});
}
}
};
};
export default plugin;
export default function plugin(options: PluginOptions = {}): Transformer<Root> {
const tocExportName = options.name || 'toc';
async function createExportNode(name: string, object: any): Promise<MdxjsEsm> {
const {valueToEstree} = await import('estree-util-value-to-estree');
return async (root) => {
const {markdownImports, existingTocExport} = await collectImportsExports({
root,
tocExportName,
});
return {
type: 'mdxjsEsm',
value: `export const ${name} = ${stringifyObject(object)}`,
data: {
estree: {
type: 'Program',
body: [
{
type: 'ExportNamedDeclaration',
declaration: {
type: 'VariableDeclaration',
declarations: [
{
type: 'VariableDeclarator',
id: {
type: 'Identifier',
name,
},
init: valueToEstree(object),
},
],
kind: 'const',
},
specifiers: [],
source: null,
},
],
sourceType: 'module',
},
},
// If user explicitly writes "export const toc" in his mdx file
// We keep it as is do not override their explicit toc structure
// See https://github.com/facebook/docusaurus/pull/7530#discussion_r1458087876
if (existingTocExport) {
return;
}
const {tocItems} = await collectTOCItems({
root,
tocExportName,
markdownImports,
});
root.children.push(
await createTOCExportNodeAST({
tocExportName,
tocItems,
}),
);
};
}

View file

@ -0,0 +1,29 @@
/**
* 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 type {Heading} from 'mdast';
// Note: this type is exported from mdx-loader and used in theme
// Need to keep it retro compatible
export type TOCItem = {
readonly value: string;
readonly id: string;
readonly level: number;
};
export type TOCHeading = {
readonly type: 'heading';
readonly heading: Heading;
};
// A TOC slice represents a TOCItem[] imported from a partial
export type TOCSlice = {
readonly type: 'slice';
readonly importName: string;
};
export type TOCItems = (TOCHeading | TOCSlice)[];

View file

@ -0,0 +1,177 @@
/**
* 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 {toValue} from '../utils';
import type {Node} from 'unist';
import type {
MdxjsEsm,
// @ts-expect-error: TODO see https://github.com/microsoft/TypeScript/issues/49721
} from 'mdast-util-mdx';
import type {TOCHeading, TOCItem, TOCItems, TOCSlice} from './types';
import type {
Program,
SpreadElement,
ImportDeclaration,
ImportSpecifier,
} from 'estree';
export function getImportDeclarations(program: Program): ImportDeclaration[] {
return program.body.filter(
(item): item is ImportDeclaration => item.type === 'ImportDeclaration',
);
}
export function isMarkdownImport(node: Node): node is ImportDeclaration {
if (node.type !== 'ImportDeclaration') {
return false;
}
const importPath = (node as ImportDeclaration).source.value;
return typeof importPath === 'string' && /\.mdx?$/.test(importPath);
}
export function findDefaultImportName(
importDeclaration: ImportDeclaration,
): string | undefined {
return importDeclaration.specifiers.find(
(o: Node) => o.type === 'ImportDefaultSpecifier',
)?.local.name;
}
export function findNamedImportSpecifier(
importDeclaration: ImportDeclaration,
localName: string,
): ImportSpecifier | undefined {
return importDeclaration?.specifiers.find(
(specifier): specifier is ImportSpecifier =>
specifier.type === 'ImportSpecifier' &&
specifier.local.name === localName,
);
}
// Before: import Partial from "partial"
// After: import Partial, {toc as __tocPartial} from "partial"
export function addTocSliceImportIfNeeded({
importDeclaration,
tocExportName,
tocSliceImportName,
}: {
importDeclaration: ImportDeclaration;
tocExportName: string;
tocSliceImportName: string;
}): void {
// We only add the toc slice named import if it doesn't exist already
if (!findNamedImportSpecifier(importDeclaration, tocSliceImportName)) {
importDeclaration.specifiers.push({
type: 'ImportSpecifier',
imported: {type: 'Identifier', name: tocExportName},
local: {type: 'Identifier', name: tocSliceImportName},
});
}
}
export function isNamedExport(
node: Node,
exportName: string,
): node is MdxjsEsm {
if (node.type !== 'mdxjsEsm') {
return false;
}
const program = (node as MdxjsEsm).data?.estree;
if (!program) {
return false;
}
if (program.body.length !== 1) {
return false;
}
const exportDeclaration = program.body[0]!;
if (exportDeclaration.type !== 'ExportNamedDeclaration') {
return false;
}
const variableDeclaration = exportDeclaration.declaration;
if (variableDeclaration?.type !== 'VariableDeclaration') {
return false;
}
const {id} = variableDeclaration.declarations[0]!;
if (id.type !== 'Identifier') {
return false;
}
return id.name === exportName;
}
export async function createTOCExportNodeAST({
tocExportName,
tocItems,
}: {
tocExportName: string;
tocItems: TOCItems;
}): Promise<MdxjsEsm> {
function createTOCSliceAST(tocSlice: TOCSlice): SpreadElement {
return {
type: 'SpreadElement',
argument: {type: 'Identifier', name: tocSlice.importName},
};
}
async function createTOCHeadingAST({heading}: TOCHeading) {
const {toString} = await import('mdast-util-to-string');
const {valueToEstree} = await import('estree-util-value-to-estree');
const value: TOCItem = {
value: toValue(heading, toString),
id: heading.data!.id!,
level: heading.depth,
};
return valueToEstree(value);
}
async function createTOCItemAST(tocItem: TOCItems[number]) {
switch (tocItem.type) {
case 'slice':
return createTOCSliceAST(tocItem);
case 'heading':
return createTOCHeadingAST(tocItem);
default: {
throw new Error(`unexpected toc item type`);
}
}
}
return {
type: 'mdxjsEsm',
value: '', // See https://github.com/facebook/docusaurus/pull/9684#discussion_r1457595181
data: {
estree: {
type: 'Program',
body: [
{
type: 'ExportNamedDeclaration',
declaration: {
type: 'VariableDeclaration',
declarations: [
{
type: 'VariableDeclarator',
id: {
type: 'Identifier',
name: tocExportName,
},
init: {
type: 'ArrayExpression',
elements: await Promise.all(tocItems.map(createTOCItemAST)),
},
},
],
kind: 'const',
},
specifiers: [],
source: null,
},
],
sourceType: 'module',
},
},
};
}

View file

@ -9,10 +9,6 @@ tags: [paginated-tag]
{/* truncate */}
import Content, {
toc as ContentToc,
} from '@site/_dogfooding/_partials/toc-tests.mdx';
import Content from '@site/_dogfooding/_partials/toc-tests.mdx';
<Content />
export const toc = ContentToc;

View file

@ -0,0 +1,7 @@
import SecondLevelPartial from './_second-level-partial.mdx';
## 1st level partial
I'm 1 level deep.
<SecondLevelPartial />

View file

@ -0,0 +1,19 @@
## Partial
Partial intro
### Partial Sub Heading 1
Partial Sub Heading 1 content
#### Partial Sub Sub Heading 1
Partial Sub Sub Heading 1 content
### Partial Sub Heading 2
Partial Sub Heading 2 content
#### Partial Sub Sub Heading 2
Partial Sub Sub Heading 2 content

View file

@ -0,0 +1,3 @@
### 2nd level partial
I'm 2 levels deep.

View file

@ -0,0 +1,46 @@
import Partial from './_partial.mdx';
# TOC partial test
This page tests that MDX-imported content appears correctly in the table-of-contents
See also:
- https://github.com/facebook/docusaurus/issues/3915
- https://github.com/facebook/docusaurus/pull/9684
---
**The table of contents should include headings of this partial**:
<Partial />
---
**We can import the same partial using a different name and it still works**:
import WeirdLocalName from './_partial.mdx';
<WeirdLocalName />
---
**We can import a partial and not use it, the TOC remains unaffected**:
import UnusedPartial from './_partial.mdx';
---
import FirstLevelPartial from './_first-level-partial.mdx';
**It also works for partials importing other partials**
<FirstLevelPartial />
---
**And we can even use the same partial twice!**
**(although it's useless and not particularly recommended because headings will have the same ids)**
<FirstLevelPartial />

View file

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

View file

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

View file

@ -3,10 +3,6 @@ toc_min_heading_level: 2
toc_max_heading_level: 4
---
import Content, {
toc as ContentToc,
} from '@site/_dogfooding/_partials/toc-tests.mdx';
import Content from '@site/_dogfooding/_partials/toc-tests.mdx';
<Content />
export const toc = ContentToc;

View file

@ -3,10 +3,6 @@ toc_min_heading_level: 2
toc_max_heading_level: 5
---
import Content, {
toc as ContentToc,
} from '@site/_dogfooding/_partials/toc-tests.mdx';
import Content from '@site/_dogfooding/_partials/toc-tests.mdx';
<Content />
export const toc = ContentToc;

View file

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

View file

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

View file

@ -3,10 +3,6 @@ toc_min_heading_level: 4
toc_max_heading_level: 5
---
import Content, {
toc as ContentToc,
} from '@site/_dogfooding/_partials/toc-tests.mdx';
import Content from '@site/_dogfooding/_partials/toc-tests.mdx';
<Content />
export const toc = ContentToc;

View file

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

View file

@ -3,10 +3,6 @@
toc_max_heading_level: 5
---
import Content, {
toc as ContentToc,
} from '@site/_dogfooding/_partials/toc-tests.mdx';
import Content from '@site/_dogfooding/_partials/toc-tests.mdx';
<Content />
export const toc = ContentToc;

View file

@ -3,10 +3,6 @@
# toc_max_heading_level:
---
import Content, {
toc as ContentToc,
} from '@site/_dogfooding/_partials/toc-tests.mdx';
import Content from '@site/_dogfooding/_partials/toc-tests.mdx';
<Content />
export const toc = ContentToc;

View file

@ -3,10 +3,6 @@ toc_min_heading_level: 2
toc_max_heading_level: 4
---
import Content, {
toc as ContentToc,
} from '@site/_dogfooding/_partials/toc-tests.mdx';
import Content from '@site/_dogfooding/_partials/toc-tests.mdx';
<Content />
export const toc = ContentToc;

View file

@ -5,9 +5,7 @@ sidebar_label: Contributing
---
```mdx-code-block
import Contributing, {toc as ContributingTOC} from "@site/../CONTRIBUTING.md"
import Contributing from "@site/../CONTRIBUTING.md"
<Contributing />
export const toc = ContributingTOC;
```

View file

@ -190,10 +190,14 @@ export default async function createConfigAsync() {
preprocessor: ({filePath, fileContent}) => {
let result = fileContent;
// This fixes Crowdin bug altering MDX comments on i18n sites...
// https://github.com/facebook/docusaurus/pull/9220
result = result.replaceAll('{/_', '{/*');
result = result.replaceAll('_/}', '*/}');
if (isDev) {
const isPartial = path.basename(filePath).startsWith('_');
if (!isPartial) {
// "vscode://file/${projectPath}${filePath}:${line}:${column}",
// "webstorm://open?file=${projectPath}${filePath}&line=${line}&column=${column}",
const vscodeLink = `vscode://file/${filePath}`;
@ -201,6 +205,7 @@ export default async function createConfigAsync() {
const intellijLink = `idea://open?file=${filePath}`;
result = `${result}\n\n---\n\n**DEV**: open this file in [VSCode](<${vscodeLink}>) | [WebStorm](<${webstormLink}>) | [IntelliJ](<${intellijLink}>)\n`;
}
}
return result;
},

View file

@ -434,7 +434,7 @@
chalk "^2.4.2"
js-tokens "^4.0.0"
"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.20.7", "@babel/parser@^7.22.15", "@babel/parser@^7.22.7", "@babel/parser@^7.23.3":
"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.20.7", "@babel/parser@^7.22.15", "@babel/parser@^7.23.3":
version "7.23.3"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.23.3.tgz#0ce0be31a4ca4f1884b5786057cadcb6c3be58f9"
integrity sha512-uVsWNvlVsIninV2prNz/3lHCb+5CJ+e+IUBfbjToAHODtfGYLfCFuY4AU7TskI+dAKk+njsPiBjq1gKTvZOBaw==