Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 35 commits into from
Jan 19, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
c6b4b33
Headings from partials appear in ToC
anatolykopyl Dec 31, 2023
aa9caae
Fixed generated mdxjsEsm node value and added test page
anatolykopyl Jan 10, 2024
7f85855
Cleanup code and use astring instead of string concat
anatolykopyl Jan 12, 2024
be539db
Js now generates with import, not just the AST
anatolykopyl Jan 12, 2024
f386394
Update packages/docusaurus-mdx-loader/src/remark/toc/index.ts
anatolykopyl Jan 13, 2024
51f5d41
Added dogfooding and changed unit tests
anatolykopyl Jan 14, 2024
1fdd6af
Moved types into the file where they are used
anatolykopyl Jan 14, 2024
0b6cc68
restore former TOC snapshot format
slorber Jan 18, 2024
bfea370
TOC, revert usage to Markdown snapshots thanks to astring.generate(js…
slorber Jan 18, 2024
604febd
better unit tests
slorber Jan 18, 2024
758e39b
Add astring to devDependencies
slorber Jan 18, 2024
c7f5c14
Remove some unnecessary type casts
anatolykopyl Jan 18, 2024
3121dce
typo
slorber Jan 18, 2024
9fec883
typo
slorber Jan 18, 2024
baeb5bd
typo
slorber Jan 18, 2024
04636de
Minor changes to site preprocessor
slorber Jan 18, 2024
1e00363
better dogfooding case
slorber Jan 18, 2024
5d68e76
use MdxjsEsm type guard
slorber Jan 18, 2024
084f029
more refactors and function extractions
slorber Jan 18, 2024
2c0df85
more refactors
slorber Jan 18, 2024
763d208
more refactors + remove useless babel parser dependency
slorber Jan 18, 2024
418d95e
refactor handling of toc export node
slorber Jan 19, 2024
9e4f8aa
refactor handling of toc export node
slorber Jan 19, 2024
25c6a70
update yarn lock
slorber Jan 19, 2024
925661d
refactor implementation, solve edge case where partial import is done…
slorber Jan 19, 2024
9d3c544
add missing typeguard
slorber Jan 19, 2024
65592a6
minor naming refactors
slorber Jan 19, 2024
0348aa4
minor refactoring
slorber Jan 19, 2024
d070950
refactor TOC snapshots to use JS instead of MDX
slorber Jan 19, 2024
728d705
never override user-provided toc export
slorber Jan 19, 2024
86f7275
add export toc at the end instead of after last import
slorber Jan 19, 2024
f193b12
minor utils refactor
slorber Jan 19, 2024
239550b
rename tocSlice.name to tocSlide.importName
slorber Jan 19, 2024
9b258e7
remove useless toc explicit exports
slorber Jan 19, 2024
3d43631
remove useless toc explicit exports on website
slorber Jan 19, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -236,3 +236,23 @@ Some content here
export const c = 1;
"
`;

exports[`toc remark plugin works with imported markdown 1`] = `
"import Partial from './_partial.md';

export const toc = [
{
value: 'Thanos',
id: 'thanos',
level: 2
},
...toc0
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

toc0 is not imported here, is this expected?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like we're going down the road of only modifying the AST and not the actual js, so this is expected.

]

## Thanos

Foo

<Partial />
"
`;
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,9 @@ describe('toc remark plugin', () => {
const result = await processFixture('empty-headings');
expect(result).toMatchSnapshot();
});

it('works with imported markdown', async () => {
const result = await processFixture('imported-markdown');
expect(result).toMatchSnapshot();
});
});
99 changes: 84 additions & 15 deletions packages/docusaurus-mdx-loader/src/remark/toc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@

import {parse, type ParserOptions} from '@babel/parser';
import traverse from '@babel/traverse';
import stringifyObject from 'stringify-object';
import {toValue} from '../utils';
import {constructArrayString, toValue} from '../utils';
import type {Identifier} from '@babel/types';
import type {Node, Parent} from 'unist';
import type {Heading, Literal} 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';

Expand Down Expand Up @@ -93,21 +93,75 @@ const plugin: Plugin = function plugin(
const {toString} = await import('mdast-util-to-string');
const {visit} = await import('unist-util-visit');

const headings: TOCItem[] = [];
const partialComponentToHeadingsName: {[key: string]: string} =
Object.create(null);
anatolykopyl marked this conversation as resolved.
Show resolved Hide resolved

visit(root, 'heading', (child: Heading) => {
const value = toString(child);
// TOCItem or string already with the spread operator
const headings: (TOCItem | string)[] = [];

// depth:1 headings are titles and not included in the TOC
if (!value || child.depth < 2) {
return;
visit(root, ['heading', 'mdxjsEsm', 'mdxJsxFlowElement'], (child) => {
if (child.type === 'heading') {
const headingNode = child as Heading;
const value = toString(headingNode);

// depth:1 headings are titles and not included in the TOC
if (!value || headingNode.depth < 2) {
return;
}

headings.push({
value: toValue(headingNode, toString),
id: headingNode.data!.id!,
level: headingNode.depth,
});
}

headings.push({
value: toValue(child, toString),
id: child.data!.id!,
level: child.depth,
});
if (child.type === 'mdxjsEsm') {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know the original code was already low-level, but I'd appreciate if we could make it more readable and high-level. I mean the intent of this algorithm should be clear without spending time trying to understand the implementation details. Currently it's a bit hard to see the big picture of it. I would extract things in smaller functions for example.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved the visitor for each type of node into its own function. This is a bit more readable now, but I can try to do some further reorganisation if needed.

const importNode = child as MdxjsEsm;
if (!importNode.data?.estree) {
return;
}

for (const importDeclaration of importNode.data.estree.body) {
if (importDeclaration.type !== 'ImportDeclaration') {
continue;
}
const importPath = importDeclaration.source.value as string;
const isMdxImport = /\.mdx?$/.test(importPath);
if (!isMdxImport) {
continue;
}

const componentName = importDeclaration.specifiers.find(
(o: any) => o.type === 'ImportDefaultSpecifier',
)?.local.name;

if (!componentName) {
continue;
}
const {length} = Object.keys(partialComponentToHeadingsName);
const exportAsName = `${name}${length}`;
anatolykopyl marked this conversation as resolved.
Show resolved Hide resolved
partialComponentToHeadingsName[componentName] = exportAsName;

importDeclaration.specifiers.push({
type: 'ImportSpecifier',
imported: {type: 'Identifier', name},
local: {type: 'Identifier', name: exportAsName},
});
}
}

if (child.type === 'mdxJsxFlowElement') {
const node = child as MdxJsxFlowElement;
const nodeName = node.name;
if (!nodeName) {
return;
}
const headingsName = partialComponentToHeadingsName[nodeName];
if (headingsName) {
headings.push(`...${headingsName}`);
slorber marked this conversation as resolved.
Show resolved Hide resolved
}
}
});

const {children} = root as Parent;
Expand All @@ -124,9 +178,21 @@ export default plugin;
async function createExportNode(name: string, object: any): Promise<MdxjsEsm> {
anatolykopyl marked this conversation as resolved.
Show resolved Hide resolved
const {valueToEstree} = await import('estree-util-value-to-estree');

const tocObject = object.map((heading: TOCItem | string) => {
if (typeof heading === 'string') {
const argumentName = heading.replace('...', '');
anatolykopyl marked this conversation as resolved.
Show resolved Hide resolved
return {
type: 'SpreadElement',
argument: {type: 'Identifier', name: argumentName},
};
}

return valueToEstree(heading);
});

return {
type: 'mdxjsEsm',
value: `export const ${name} = ${stringifyObject(object)}`,
value: `export const ${name} = ${constructArrayString(object)}`,
slorber marked this conversation as resolved.
Show resolved Hide resolved
data: {
estree: {
type: 'Program',
Expand All @@ -142,7 +208,10 @@ async function createExportNode(name: string, object: any): Promise<MdxjsEsm> {
type: 'Identifier',
name,
},
init: valueToEstree(object),
init: {
type: 'ArrayExpression',
elements: tocObject,
},
},
],
kind: 'const',
Expand Down
30 changes: 30 additions & 0 deletions packages/docusaurus-mdx-loader/src/remark/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import escapeHtml from 'escape-html';
import stringifyObject from 'stringify-object';
import type {Parent, Node} from 'unist';
import type {PhrasingContent, Heading} from 'mdast';
import type {
Expand Down Expand Up @@ -154,3 +155,32 @@ export function assetRequireAttributeValue(
},
};
}

/**
* Similar to stringify-object, but keeps spread operators,
* instead of turning them into strings.
* @param objects
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function constructArrayString(objects: any[]): string {
let result = '[';

for (const obj of objects) {
if (typeof obj === 'string') {
result = `${result}\n\t${obj},`;
} else {
result = `${result}\n\t${stringifyObject(obj).replace(/\n/g, '\n\t')},`;
}
}

// Remove trailing coma
result = result.replace(/,$/, '');
// Close array on same line if it is empty
if (result === '[') {
result += ']';
} else {
result += '\n]';
}

return result;
}
slorber marked this conversation as resolved.
Show resolved Hide resolved
7 changes: 7 additions & 0 deletions website/_dogfooding/_pages tests/_anotherPagePartial.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### Another page partial content

This is text coming from another page partial

#### Foo

Level 4 headings don't belong in ToC
1 change: 1 addition & 0 deletions website/_dogfooding/_pages tests/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import Readme from "../README.mdx"
- [Tabs tests](/tests/pages/tabs-tests)
- [z-index tests](/tests/pages/z-index-tests)
- [Head metadata tests](/tests/pages/head-metadata)
- [Partials tests](/tests/pages/partials-tests)
- [Unlisted page](/tests/pages/unlisted)
- [Analytics](/tests/pages/analytics)
- [Embeds](/tests/pages/embeds)
12 changes: 12 additions & 0 deletions website/_dogfooding/_pages tests/partials-tests.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import PagePartial from './_pagePartial.mdx';
anatolykopyl marked this conversation as resolved.
Show resolved Hide resolved
import AnotherPagePartial from './_anotherPagePartial.mdx';

# Partials tests

This page consists of multiple files imported into one. Notice how the table of contents works even for imported headings.

## Imported content

<PagePartial />

<AnotherPagePartial />
Loading