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 4 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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/docusaurus-mdx-loader/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@docusaurus/utils-validation": "3.0.0",
"@mdx-js/mdx": "^3.0.0",
"@slorber/remark-comment": "^1.0.0",
"astring": "^1.8.6",
"escape-html": "^1.0.3",
"estree-util-value-to-estree": "^3.0.1",
"file-loader": "^6.2.0",
Expand Down

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,22 @@ Some content here
export const c = 1;
"
`;

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

import {toc as toc0} from './_partial.md';
export const toc = [{
"value": "Thanos",
"id": "thanos",
"level": 2
}, ...toc0];


## 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();
});
});
192 changes: 142 additions & 50 deletions packages/docusaurus-mdx-loader/src/remark/toc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,41 +7,41 @@

import {parse, type ParserOptions} from '@babel/parser';
import traverse from '@babel/traverse';
import stringifyObject from 'stringify-object';
import {generate} from 'astring';
import {toValue} from '../utils';
import {hasImports, isExport, isImport} from './utils';
import type {TOCItem, NestedTOC} from './utils';
import type {
SpreadElement,
Program,
ImportDeclaration,
ImportSpecifier,
} from 'estree';
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';

// Reexport TOCItem, since it's used throughout the project
export type {TOCItem} from './utils';

// 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;
};
type Plugin = any;
slorber marked this conversation as resolved.
Show resolved Hide resolved

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 {
name?: string;
}
Expand Down Expand Up @@ -75,7 +75,7 @@ const getOrCreateExistingTargetIndex = async (
});

if (targetIndex === -1) {
const target = await createExportNode(name, []);
const target = await createExportNode(name, [], []);

targetIndex = hasImports(importsIndex) ? importsIndex + 1 : 0;
children.splice(targetIndex, 0, target);
Expand All @@ -93,66 +93,158 @@ 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

const headings: (TOCItem | NestedTOC)[] = [];
const imports: ImportDeclaration[] = [];

visit(root, 'heading', (child: Heading) => {
const value = toString(child);
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;
}

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

function visitMdxjsEsm(node: MdxjsEsm) {
if (!node.data?.estree) {
return;
}

for (const potentialImportDeclaration of node.data.estree.body) {
if (potentialImportDeclaration.type !== 'ImportDeclaration') {
continue;
}

const importPath = potentialImportDeclaration.source.value as string;
const isMdxImport = /\.mdx?$/.test(importPath);
if (!isMdxImport) {
continue;
}

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

if (!componentName) {
continue;
}
const {length} = Object.keys(partialComponentToHeadingsName);
const exportAsName = `${name}${length}`;
partialComponentToHeadingsName[componentName] = exportAsName;

const specifier: ImportSpecifier = {
type: 'ImportSpecifier',
imported: {type: 'Identifier', name},
local: {type: 'Identifier', name: exportAsName},
};

imports.push({
type: 'ImportDeclaration',
specifiers: [specifier],
source: potentialImportDeclaration.source,
});
potentialImportDeclaration.specifiers.push(specifier);
}
}

function visitMdxJsxFlowElement(node: MdxJsxFlowElement) {
const nodeName = node.name;
if (!nodeName) {
return;
}
const headingsName = partialComponentToHeadingsName[nodeName];
if (headingsName) {
headings.push({
nested: true,
name: headingsName,
});
}
}

visit(root, ['heading', 'mdxjsEsm', 'mdxJsxFlowElement'], (child) => {
slorber marked this conversation as resolved.
Show resolved Hide resolved
if (child.type === 'heading') {
visitHeading(child as Heading);
} else if (child.type === 'mdxjsEsm') {
visitMdxjsEsm(child as MdxjsEsm);
} else if (child.type === 'mdxJsxFlowElement') {
visitMdxJsxFlowElement(child as MdxJsxFlowElement);
}
});

const {children} = root as Parent;
const targetIndex = await getOrCreateExistingTargetIndex(children, name);

if (headings?.length) {
children[targetIndex] = await createExportNode(name, headings);
children[targetIndex] = await createExportNode(name, headings, imports);
}
};
};

export default plugin;

async function createExportNode(name: string, object: any): Promise<MdxjsEsm> {
async function createExportNode(
name: string,
headings: (TOCItem | NestedTOC)[],
imports: ImportDeclaration[],
): Promise<MdxjsEsm> {
const {valueToEstree} = await import('estree-util-value-to-estree');

const tocObject = headings.map((heading) => {
if ('nested' in heading) {
const spreadElement: SpreadElement = {
type: 'SpreadElement',
argument: {type: 'Identifier', name: heading.name},
};
return spreadElement;
}

return valueToEstree(heading);
});

const estree: Program = {
type: 'Program',
body: [
...imports,
{
type: 'ExportNamedDeclaration',
declaration: {
type: 'VariableDeclaration',
declarations: [
{
type: 'VariableDeclarator',
id: {
type: 'Identifier',
name,
},
init: {
type: 'ArrayExpression',
elements: tocObject,
},
},
],
kind: 'const',
},
specifiers: [],
source: null,
},
],
sourceType: 'module',
};

return {
type: 'mdxjsEsm',
value: `export const ${name} = ${stringifyObject(object)}`,
value: generate(estree),
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',
},
estree,
},
};
}
40 changes: 40 additions & 0 deletions packages/docusaurus-mdx-loader/src/remark/toc/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* 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 {Literal} from 'mdast';
import type {Node} from 'unist';
import type {
MdxjsEsm,
// @ts-expect-error: TODO see https://github.com/microsoft/TypeScript/issues/49721
} from 'mdast-util-mdx';

export type TOCItem = {
readonly value: string;
readonly id: string;
readonly level: number;
};

export type NestedTOC = {
readonly nested: true;
readonly name: string;
};

export const isImport = (child: Node): child is Literal => {
slorber marked this conversation as resolved.
Show resolved Hide resolved
if (child.type === 'mdxjsEsm') {
return (child as MdxjsEsm).value.startsWith('import');
}
return false;
};

export const hasImports = (index: number): boolean => index > -1;

export const isExport = (child: Node): child is Literal => {
slorber marked this conversation as resolved.
Show resolved Hide resolved
if (child.type === 'mdxjsEsm') {
return (child as MdxjsEsm).value.startsWith('export');
}
return false;
};
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 />
2 changes: 1 addition & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4423,7 +4423,7 @@ astral-regex@^2.0.0:
resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31"
integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==

astring@^1.8.0:
astring@^1.8.0, astring@^1.8.6:
slorber marked this conversation as resolved.
Show resolved Hide resolved
version "1.8.6"
resolved "https://registry.yarnpkg.com/astring/-/astring-1.8.6.tgz#2c9c157cf1739d67561c56ba896e6948f6b93731"
integrity sha512-ISvCdHdlTDlH5IpxQJIex7BWBywFWgjJSVdwst+/iQCoEYnyOaQ95+X1JGshuBjGp6nxKUy1jMgE3zPqN7fQdg==
Expand Down