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", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/parser": "^7.22.7",
"@babel/traverse": "^7.22.8",
"@docusaurus/logger": "3.0.0", "@docusaurus/logger": "3.0.0",
"@docusaurus/utils": "3.0.0", "@docusaurus/utils": "3.0.0",
"@docusaurus/utils-validation": "3.0.0", "@docusaurus/utils-validation": "3.0.0",

View file

@ -7,7 +7,7 @@
import {mdxLoader} from './loader'; import {mdxLoader} from './loader';
import type {TOCItem as TOCItemImported} from './remark/toc'; import type {TOCItem as TOCItemImported} from './remark/toc/types';
export default mdxLoader; 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 // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`toc remark plugin does not overwrite TOC var if no TOC 1`] = ` exports[`toc remark plugin does not overwrite TOC var if no TOC 1`] = `
"foo "import {Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs} from "react/jsx-runtime";
\`bar\`
\`\`\`js
baz
\`\`\`
export const toc = 1; 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`] = ` exports[`toc remark plugin escapes inline code 1`] = `
"export const toc = [ "import {Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs} from "react/jsx-runtime";
{ export const toc = [{
value: '<code>&lt;Head /&gt;</code>', "value": "<code>&lt;Head /&gt;</code>",
id: 'head-', "id": "head-",
level: 2 "level": 2
}, }, {
{ "value": "<code>&lt;Head&gt;Test&lt;/Head&gt;</code>",
value: '<code>&lt;Head&gt;Test&lt;/Head&gt;</code>', "id": "headtesthead",
id: 'headtesthead', "level": 3
level: 3 }, {
}, "value": "<code>&lt;div /&gt;</code>",
{ "id": "div-",
value: '<code>&lt;div /&gt;</code>', "level": 2
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; Test &lt;/div&gt;</code>', }, {
id: 'div-test-div', "value": "<code>&lt;div&gt;&lt;i&gt;Test&lt;/i&gt;&lt;/div&gt;</code>",
level: 2 "id": "divitestidiv",
}, "level": 2
{ }, {
value: '<code>&lt;div&gt;&lt;i&gt;Test&lt;/i&gt;&lt;/div&gt;</code>', "value": "<code>&lt;div&gt;&lt;i&gt;Test&lt;/i&gt;&lt;/div&gt;</code>",
id: 'divitestidiv', "id": "divitestidiv-1",
level: 2 "level": 2
}, }];
{ function _createMdxContent(props) {
value: '<code>&lt;div&gt;&lt;i&gt;Test&lt;/i&gt;&lt;/div&gt;</code>', const _components = {
id: 'divitestidiv-1', a: "a",
level: 2 code: "code",
} h2: "h2",
] h3: "h3",
...props.components
## \`<Head />\` };
return _jsxs(_Fragment, {
### \`<Head>Test</Head>\` children: [_jsx(_components.h2, {
id: "head-",
## \`<div />\` children: _jsx(_components.code, {
children: "<Head />"
## \`<div> Test </div>\` })
}), "/n", _jsx(_components.h3, {
## \`<div><i>Test</i></div>\` id: "headtesthead",
children: _jsx(_components.code, {
## [\`<div><i>Test</i></div>\`](/some/link) 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);
}
" "
`; `;
exports[`toc remark plugin exports even with existing name 1`] = ` exports[`toc remark plugin exports even with existing name 1`] = `
"export const toc = [ "import {Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs} from "react/jsx-runtime";
{ export const toc = ['replaceMe'];
value: 'Thanos', function _createMdxContent(props) {
id: 'thanos', const _components = {
level: 2 h2: "h2",
}, h3: "h3",
{ ...props.components
value: 'Tony Stark', };
id: 'tony-stark', return _jsxs(_Fragment, {
level: 2 children: [_jsx(_components.h2, {
}, id: "thanos",
{ children: "Thanos"
value: 'Avengers', }), "/n", _jsx(_components.h2, {
id: 'avengers', id: "tony-stark",
level: 3 children: "Tony Stark"
} }), "/n", _jsx(_components.h3, {
] id: "avengers",
children: "Avengers"
## Thanos })]
});
## Tony Stark }
export default function MDXContent(props = {}) {
### Avengers const {wrapper: MDXLayout} = props.components || ({});
return MDXLayout ? _jsx(MDXLayout, {
...props,
children: _jsx(_createMdxContent, {
...props
})
}) : _createMdxContent(props);
}
" "
`; `;
exports[`toc remark plugin handles empty headings 1`] = ` exports[`toc remark plugin handles empty headings 1`] = `
"export const toc = [] "import {Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs} from "react/jsx-runtime";
export const toc = [];
# Ignore this function _createMdxContent(props) {
const _components = {
## h1: "h1",
h2: "h2",
## ![](an-image.svg) 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`] = ` 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'; import somethingElse from 'something-else';
export const toc = [{
export const toc = [ "value": "Title",
{ "id": "title",
value: 'Title', "level": 2
id: 'title', }, {
level: 2 "value": "Test",
}, "id": "test",
{ "level": 2
value: 'Test', }, {
id: 'test', "value": "Again",
level: 2 "id": "again",
}, "level": 3
{ }];
value: 'Again', function _createMdxContent(props) {
id: 'again', const _components = {
level: 3 h2: "h2",
} h3: "h3",
] p: "p",
...props.components
## Title };
return _jsxs(_Fragment, {
## Test children: [_jsx(_components.h2, {
id: "title",
### Again children: "Title"
}), "/n", _jsx(_components.h2, {
Content. 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);
}
" "
`; `;
exports[`toc remark plugin outputs empty array for no TOC 1`] = ` exports[`toc remark plugin outputs empty array for no TOC 1`] = `
"export const toc = [] "import {Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs} from "react/jsx-runtime";
export const toc = [];
foo function _createMdxContent(props) {
const _components = {
\`bar\` code: "code",
p: "p",
\`\`\`js pre: "pre",
baz ...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`] = ` exports[`toc remark plugin works on non text phrasing content 1`] = `
"export const toc = [ "import {Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs} from "react/jsx-runtime";
{ export const toc = [{
value: '<em>Emphasis</em>', "value": "<em>Emphasis</em>",
id: 'emphasis', "id": "emphasis",
level: 2 "level": 2
}, }, {
{ "value": "<strong>Importance</strong>",
value: '<strong>Importance</strong>', "id": "importance",
id: 'importance', "level": 3
level: 3 }, {
}, "value": "<del>Strikethrough</del>",
{ "id": "strikethrough",
value: '<del>Strikethrough</del>', "level": 2
id: 'strikethrough', }, {
level: 2 "value": "<i>HTML</i>",
}, "id": "html",
{ "level": 2
value: '<i>HTML</i>', }, {
id: 'html', "value": "<code>inline.code()</code>",
level: 2 "id": "inlinecode",
}, "level": 2
{ }, {
value: '<code>inline.code()</code>', "value": "some <span class=\\"some-class\\">styled</span> <strong>heading</strong> <span class=\\"myClassName &lt;&gt; weird char\\"></span> test",
id: 'inlinecode', "id": "some-styled-heading--test",
level: 2 "level": 2
}, }];
{ function _createMdxContent(props) {
value: 'some <span class="some-class">styled</span> <strong>heading</strong> <span class="myClassName &lt;&gt; weird char"></span> test', const _components = {
id: 'some-styled-heading--test', code: "code",
level: 2 del: "del",
} em: "em",
] h2: "h2",
h3: "h3",
## *Emphasis* strong: "strong",
...props.components
### **Importance** };
return _jsxs(_Fragment, {
## ~~Strikethrough~~ children: [_jsx(_components.h2, {
id: "emphasis",
## <i>HTML</i> children: _jsx(_components.em, {
children: "Emphasis"
## \`inline.code()\` })
}), "/n", _jsx(_components.h3, {
## 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 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"
},
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);
}
" "
`; `;
exports[`toc remark plugin works on text content 1`] = ` exports[`toc remark plugin works on text content 1`] = `
"export const toc = [ "import {Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs} from "react/jsx-runtime";
{
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.
export const c = 1; 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'; import headings from '../../headings/index';
const processFixture = async (name: string) => { const processFixture = async (name: string) => {
const {remark} = await import('remark');
const {default: gfm} = await import('remark-gfm'); 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 file = await vfile.read(filePath);
const result = await remark()
.use(headings) const result = await compile(file, {
.use(gfm) format: 'mdx',
.use(mdx) remarkPlugins: [headings, gfm, plugin],
.use(plugin) rehypePlugins: [],
.process(file); });
return result.value; return result.value;
}; };
@ -70,4 +75,21 @@ describe('toc remark plugin', () => {
const result = await processFixture('empty-headings'); const result = await processFixture('empty-headings');
expect(result).toMatchSnapshot(); 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. * LICENSE file in the root directory of this source tree.
*/ */
import {parse, type ParserOptions} from '@babel/parser'; import {
import traverse from '@babel/traverse'; addTocSliceImportIfNeeded,
import stringifyObject from 'stringify-object'; createTOCExportNodeAST,
import {toValue} from '../utils'; findDefaultImportName,
import type {Identifier} from '@babel/types'; getImportDeclarations,
import type {Node, Parent} from 'unist'; isMarkdownImport,
import type {Heading, Literal} from 'mdast'; isNamedExport,
} from './utils';
import type {Heading, Root} from 'mdast';
// @ts-expect-error: TODO see https://github.com/microsoft/TypeScript/issues/49721 // @ts-expect-error: TODO see https://github.com/microsoft/TypeScript/issues/49721
import type {Transformer} from 'unified'; import type {Transformer} from 'unified';
import type { import type {
MdxjsEsm, MdxjsEsm,
MdxJsxFlowElement,
// @ts-expect-error: TODO see https://github.com/microsoft/TypeScript/issues/49721 // @ts-expect-error: TODO see https://github.com/microsoft/TypeScript/issues/49721
} from 'mdast-util-mdx'; } from 'mdast-util-mdx';
import type {TOCItems} from './types';
// TODO as of April 2023, no way to import/re-export this ESM type easily :/ import type {ImportDeclaration} from 'estree';
// 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');
interface PluginOptions { interface PluginOptions {
name?: string; name?: string;
} }
const isTarget = (child: Literal, name: string) => { // ComponentName (default export) => ImportDeclaration mapping
let found = false; type MarkdownImports = Map<string, {declaration: ImportDeclaration}>;
const ast = parse(child.value, parseOptions);
traverse(ast, {
VariableDeclarator: (path) => {
if ((path.node.id as Identifier).name === name) {
found = true;
}
},
});
return found;
};
const getOrCreateExistingTargetIndex = async ( // MdxjsEsm node representing an already existing "export const toc" declaration
children: Node[], type ExistingTOCExport = MdxjsEsm | null;
name: string,
) => {
let importsIndex = -1;
let targetIndex = -1;
children.forEach((child, index) => { function createTocSliceImportName({
if (isImport(child)) { tocExportName,
importsIndex = index; componentName,
} else if (isExport(child) && isTarget(child, name)) { }: {
targetIndex = index; 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;
} }
});
if (targetIndex === -1) { getImportDeclarations(node.data.estree).forEach((declaration) => {
const target = await createExportNode(name, []); if (!isMarkdownImport(declaration)) {
targetIndex = hasImports(importsIndex) ? importsIndex + 1 : 0;
children.splice(targetIndex, 0, target);
}
return targetIndex;
};
const plugin: Plugin = function plugin(
options: PluginOptions = {},
): Transformer {
const name = options.name || 'toc';
return async (root) => {
const {toString} = await import('mdast-util-to-string');
const {visit} = await import('unist-util-visit');
const headings: TOCItem[] = [];
visit(root, 'heading', (child: Heading) => {
const value = toString(child);
// depth:1 headings are titles and not included in the TOC
if (!value || child.depth < 2) {
return; return;
} }
const componentName = findDefaultImportName(declaration);
headings.push({ if (!componentName) {
value: toValue(child, toString), return;
id: child.data!.id!, }
level: child.depth, markdownImports.set(componentName, {
declaration,
}); });
}); });
});
const {children} = root as Parent; return {markdownImports, existingTocExport};
const targetIndex = await getOrCreateExistingTargetIndex(children, name); }
if (headings?.length) { async function collectTOCItems({
children[targetIndex] = await createExportNode(name, headings); 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 tocItems: TOCItems = [];
visit(root, (child) => {
if (child.type === 'heading') {
visitHeading(child);
} else if (child.type === 'mdxJsxFlowElement') {
visitJSXElement(child);
} }
}; });
};
export default plugin; return {tocItems};
async function createExportNode(name: string, object: any): Promise<MdxjsEsm> { // Visit Markdown headings
const {valueToEstree} = await import('estree-util-value-to-estree'); function visitHeading(node: Heading) {
const value = toString(node);
// depth:1 headings are titles and not included in the TOC
if (!value || node.depth < 2) {
return;
}
tocItems.push({
type: 'heading',
heading: node,
});
}
return { // Visit JSX elements, such as <Partial/>
type: 'mdxjsEsm', function visitJSXElement(node: MdxJsxFlowElement) {
value: `export const ${name} = ${stringifyObject(object)}`, const componentName = node.name;
data: { if (!componentName) {
estree: { return;
type: 'Program', }
body: [ const importDeclaration = markdownImports.get(componentName)?.declaration;
{ if (!importDeclaration) {
type: 'ExportNamedDeclaration', return;
declaration: { }
type: 'VariableDeclaration',
declarations: [ const tocSliceImportName = createTocSliceImportName({
{ tocExportName,
type: 'VariableDeclarator', componentName,
id: { });
type: 'Identifier',
name, tocItems.push({
}, type: 'slice',
init: valueToEstree(object), importName: tocSliceImportName,
}, });
],
kind: 'const', addTocSliceImportIfNeeded({
}, importDeclaration,
specifiers: [], tocExportName,
source: null, tocSliceImportName,
}, });
], }
sourceType: 'module', }
},
}, export default function plugin(options: PluginOptions = {}): Transformer<Root> {
const tocExportName = options.name || 'toc';
return async (root) => {
const {markdownImports, existingTocExport} = await collectImportsExports({
root,
tocExportName,
});
// 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 */} {/* truncate */}
import Content, { import Content from '@site/_dogfooding/_partials/toc-tests.mdx';
toc as ContentToc,
} from '@site/_dogfooding/_partials/toc-tests.mdx';
<Content /> <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 toc_max_heading_level: 2
--- ---
import Content, { import Content from '@site/_dogfooding/_partials/toc-tests.mdx';
toc as ContentToc,
} from '@site/_dogfooding/_partials/toc-tests.mdx';
<Content /> <Content />
export const toc = ContentToc;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -190,16 +190,21 @@ export default async function createConfigAsync() {
preprocessor: ({filePath, fileContent}) => { preprocessor: ({filePath, fileContent}) => {
let result = 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('{/_', '{/*');
result = result.replaceAll('_/}', '*/}'); result = result.replaceAll('_/}', '*/}');
if (isDev) { if (isDev) {
// "vscode://file/${projectPath}${filePath}:${line}:${column}", const isPartial = path.basename(filePath).startsWith('_');
// "webstorm://open?file=${projectPath}${filePath}&line=${line}&column=${column}", if (!isPartial) {
const vscodeLink = `vscode://file/${filePath}`; // "vscode://file/${projectPath}${filePath}:${line}:${column}",
const webstormLink = `webstorm://open?file=${filePath}`; // "webstorm://open?file=${projectPath}${filePath}&line=${line}&column=${column}",
const intellijLink = `idea://open?file=${filePath}`; const vscodeLink = `vscode://file/${filePath}`;
result = `${result}\n\n---\n\n**DEV**: open this file in [VSCode](<${vscodeLink}>) | [WebStorm](<${webstormLink}>) | [IntelliJ](<${intellijLink}>)\n`; const webstormLink = `webstorm://open?file=${filePath}`;
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; return result;

View file

@ -434,7 +434,7 @@
chalk "^2.4.2" chalk "^2.4.2"
js-tokens "^4.0.0" 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" version "7.23.3"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.23.3.tgz#0ce0be31a4ca4f1884b5786057cadcb6c3be58f9" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.23.3.tgz#0ce0be31a4ca4f1884b5786057cadcb6c3be58f9"
integrity sha512-uVsWNvlVsIninV2prNz/3lHCb+5CJ+e+IUBfbjToAHODtfGYLfCFuY4AU7TskI+dAKk+njsPiBjq1gKTvZOBaw== integrity sha512-uVsWNvlVsIninV2prNz/3lHCb+5CJ+e+IUBfbjToAHODtfGYLfCFuY4AU7TskI+dAKk+njsPiBjq1gKTvZOBaw==