Skip to content

Commit e7e8e84

Browse files
author
Dimitri POSTOLOV
authoredSep 29, 2023
[v3] show react components, variable interpolation and latex in toc (#2359)

File tree

19 files changed

+911
-400
lines changed

19 files changed

+911
-400
lines changed
 

‎.changeset/brave-clocks-call.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'nextra-theme-docs': major
3+
'nextra': major
4+
---
5+
6+
show react components, variable interpolation and latex in toc

‎examples/swr-site/pages/en/docs/getting-started.mdx

+14-2
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,23 @@ https://google.com/da;djaldhksagfugsufgasuyfgyuasgfuasgdjasbdjasdjkasfuydfasyrdy
1313

1414
<Link href="https://google.com">Link</Link>
1515

16-
export const myVar = 'Foo'
16+
export const myVar = '"I am from export const"'
1717

1818
## Foo {myVar}
1919

20-
## Bar `Baz`
20+
### Bar `code`
21+
22+
#### Latex $latex^2$
23+
24+
###### <strong>Da</strong>
25+
26+
###### _Ma_**Chi**<s>na</s>
27+
28+
export const Test = props => <b>{props.someProp}</b>
29+
30+
#### Qux <Test someProp="someVal" />
31+
32+
##### My file is `<MyFile />{:js}`
2133

2234
Inside your React project directory, run the following:
2335

‎examples/swr-site/pages/en/remote/graphql-eslint/[[...slug]].mdx

+4-3
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,13 @@ export async function getStaticProps({ params }) {
1313
const response = await fetch(url)
1414
const data = await response.text()
1515
const { __nextra_pageMap } = await buildDynamicMeta('en')
16+
const dynamicMdx = await buildDynamicMDX(data, {
17+
defaultShowCopyCode: true
18+
})
1619
return {
1720
props: {
1821
__nextra_pageMap,
19-
...(await buildDynamicMDX(data, {
20-
defaultShowCopyCode: true
21-
}))
22+
...dynamicMdx
2223
}
2324
}
2425
}

‎packages/nextra-theme-docs/src/index.tsx

+25-25
Original file line numberDiff line numberDiff line change
@@ -187,34 +187,34 @@ function InnerLayout({
187187
asPopover={hideSidebar}
188188
includePlaceholder={themeContext.layout === 'default'}
189189
/>
190-
{tocEl}
191-
<SkipNavContent />
192-
<Body
193-
themeContext={themeContext}
194-
breadcrumb={
195-
activeType !== 'page' && themeContext.breadcrumb ? (
196-
<Breadcrumb activePath={activePath} />
197-
) : null
198-
}
199-
timestamp={timestamp}
200-
navigation={
201-
activeType !== 'page' && themeContext.pagination ? (
202-
<NavLinks
203-
flatDirectories={flatDocsDirectories}
204-
currentIndex={activeIndex}
205-
/>
206-
) : null
207-
}
190+
<MDXProvider
191+
components={getComponents({
192+
isRawLayout: themeContext.layout === 'raw',
193+
components: config.components
194+
})}
208195
>
209-
<MDXProvider
210-
components={getComponents({
211-
isRawLayout: themeContext.layout === 'raw',
212-
components: config.components
213-
})}
196+
{tocEl}
197+
<SkipNavContent />
198+
<Body
199+
themeContext={themeContext}
200+
breadcrumb={
201+
activeType !== 'page' && themeContext.breadcrumb ? (
202+
<Breadcrumb activePath={activePath} />
203+
) : null
204+
}
205+
timestamp={timestamp}
206+
navigation={
207+
activeType !== 'page' && themeContext.pagination ? (
208+
<NavLinks
209+
flatDirectories={flatDocsDirectories}
210+
currentIndex={activeIndex}
211+
/>
212+
) : null
213+
}
214214
>
215215
{children}
216-
</MDXProvider>
217-
</Body>
216+
</Body>
217+
</MDXProvider>
218218
</ActiveAnchorProvider>
219219
</div>
220220
{themeContext.footer &&
Original file line numberDiff line numberDiff line change
@@ -1,127 +1,134 @@
11
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
22

33
exports[`Process heading > code-h1 1`] = `
4-
{
5-
"frontMatter": {},
6-
"result": "/*@jsxRuntime automatic @jsxImportSource react*/
7-
import {useMDXComponents as _provideComponents} from \\"nextra/mdx\\";
4+
"import { useMDXComponents as _provideComponents } from \\"nextra/mdx\\";
85
export const frontMatter = {};
9-
export const toc = [];
10-
function _createMdxContent(props) {
11-
const _components = Object.assign({
12-
h1: \\"h1\\",
13-
code: \\"code\\"
14-
}, _provideComponents(), props.components);
15-
return <_components.h1><_components.code>{\\"codegen.yml\\"}</_components.code></_components.h1>;
16-
}
17-
function MDXContent(props = {}) {
18-
const {wrapper: MDXLayout} = Object.assign({}, _provideComponents(), props.components);
19-
return MDXLayout ? <MDXLayout {...props}><_createMdxContent {...props} /></MDXLayout> : _createMdxContent(props);
6+
export function useTOC(props) {
7+
return [];
208
}
21-
export default MDXContent;
22-
",
23-
"title": "codegen.yml",
9+
function _createMdxContent(props) {
10+
const { toc } = props;
11+
const _components = Object.assign(
12+
{
13+
h1: \\"h1\\",
14+
code: \\"code\\",
15+
},
16+
_provideComponents(),
17+
props.components,
18+
);
19+
return (
20+
<_components.h1>
21+
<_components.code>{\\"codegen.yml\\"}</_components.code>
22+
</_components.h1>
23+
);
2424
}
25+
"
2526
`;
2627

2728
exports[`Process heading > code-with-text-h1 1`] = `
28-
{
29-
"frontMatter": {},
30-
"result": "/*@jsxRuntime automatic @jsxImportSource react*/
31-
import {useMDXComponents as _provideComponents} from \\"nextra/mdx\\";
29+
"import { useMDXComponents as _provideComponents } from \\"nextra/mdx\\";
3230
export const frontMatter = {};
33-
export const toc = [];
34-
function _createMdxContent(props) {
35-
const _components = Object.assign({
36-
h1: \\"h1\\",
37-
code: \\"code\\"
38-
}, _provideComponents(), props.components);
39-
return <_components.h1><_components.code>{\\"codegen.yml\\"}</_components.code>{\\" file\\"}</_components.h1>;
40-
}
41-
function MDXContent(props = {}) {
42-
const {wrapper: MDXLayout} = Object.assign({}, _provideComponents(), props.components);
43-
return MDXLayout ? <MDXLayout {...props}><_createMdxContent {...props} /></MDXLayout> : _createMdxContent(props);
31+
export function useTOC(props) {
32+
return [];
4433
}
45-
export default MDXContent;
46-
",
47-
"title": "codegen.yml file",
34+
function _createMdxContent(props) {
35+
const { toc } = props;
36+
const _components = Object.assign(
37+
{
38+
h1: \\"h1\\",
39+
code: \\"code\\",
40+
},
41+
_provideComponents(),
42+
props.components,
43+
);
44+
return (
45+
<_components.h1>
46+
<_components.code>{\\"codegen.yml\\"}</_components.code>
47+
{\\" file\\"}
48+
</_components.h1>
49+
);
4850
}
51+
"
4952
`;
5053

5154
exports[`Process heading > dynamic-h1 1`] = `
5255
{
5356
"frontMatter": {},
5457
"hasJsxInH1": true,
55-
"result": "/*@jsxRuntime automatic @jsxImportSource react*/
56-
import {useMDXComponents as _provideComponents} from \\"nextra/mdx\\";
58+
"result": "import { useMDXComponents as _provideComponents } from \\"nextra/mdx\\";
5759
export const frontMatter = {};
58-
import {useRouter} from 'next/router';
60+
import { useRouter } from \\"next/router\\";
5961
export const TagName = () => {
60-
const {tag} = useRouter().query;
62+
const { tag } = useRouter().query;
6163
return tag || null;
6264
};
63-
export const toc = [];
64-
function _createMdxContent(props) {
65-
const _components = Object.assign({
66-
h1: \\"h1\\"
67-
}, _provideComponents(), props.components);
68-
return <_components.h1>{\\"Posts Tagged with “\\"}<TagName />{\\"\\"}</_components.h1>;
65+
export function useTOC(props) {
66+
return [];
6967
}
70-
function MDXContent(props = {}) {
71-
const {wrapper: MDXLayout} = Object.assign({}, _provideComponents(), props.components);
72-
return MDXLayout ? <MDXLayout {...props}><_createMdxContent {...props} /></MDXLayout> : _createMdxContent(props);
68+
function _createMdxContent(props) {
69+
const { toc } = props;
70+
const _components = Object.assign(
71+
{
72+
h1: \\"h1\\",
73+
},
74+
_provideComponents(),
75+
props.components,
76+
);
77+
return (
78+
<_components.h1>
79+
{\\"Posts Tagged with “\\"}
80+
<TagName />
81+
{\\"\\"}
82+
</_components.h1>
83+
);
7384
}
74-
export default MDXContent;
7585
",
7686
"title": "Posts Tagged with “”",
7787
}
7888
`;
7989

8090
exports[`Process heading > no-h1 1`] = `
81-
{
82-
"frontMatter": {},
83-
"result": "/*@jsxRuntime automatic @jsxImportSource react*/
84-
import {useMDXComponents as _provideComponents} from \\"nextra/mdx\\";
91+
"import { useMDXComponents as _provideComponents } from \\"nextra/mdx\\";
8592
export const frontMatter = {};
86-
export const toc = [{
87-
depth: 2,
88-
value: \\"H2\\",
89-
id: \\"h2\\"
90-
}];
91-
function _createMdxContent(props) {
92-
const _components = Object.assign({
93-
h2: \\"h2\\"
94-
}, _provideComponents(), props.components);
95-
return <_components.h2 id=\\"h2\\">{\\"H2\\"}</_components.h2>;
93+
export function useTOC(props) {
94+
return [
95+
{
96+
value: \\"H2\\",
97+
id: \\"h2\\",
98+
depth: 2,
99+
},
100+
];
96101
}
97-
function MDXContent(props = {}) {
98-
const {wrapper: MDXLayout} = Object.assign({}, _provideComponents(), props.components);
99-
return MDXLayout ? <MDXLayout {...props}><_createMdxContent {...props} /></MDXLayout> : _createMdxContent(props);
100-
}
101-
export default MDXContent;
102-
",
102+
function _createMdxContent(props) {
103+
const { toc } = props;
104+
const _components = Object.assign(
105+
{
106+
h2: \\"h2\\",
107+
},
108+
_provideComponents(),
109+
props.components,
110+
);
111+
return <_components.h2 id={toc[0].id}>{toc[0].value}</_components.h2>;
103112
}
113+
"
104114
`;
105115

106116
exports[`Process heading > static-h1 1`] = `
107-
{
108-
"frontMatter": {},
109-
"result": "/*@jsxRuntime automatic @jsxImportSource react*/
110-
import {useMDXComponents as _provideComponents} from \\"nextra/mdx\\";
117+
"import { useMDXComponents as _provideComponents } from \\"nextra/mdx\\";
111118
export const frontMatter = {};
112-
export const toc = [];
119+
export function useTOC(props) {
120+
return [];
121+
}
113122
function _createMdxContent(props) {
114-
const _components = Object.assign({
115-
h1: \\"h1\\"
116-
}, _provideComponents(), props.components);
123+
const { toc } = props;
124+
const _components = Object.assign(
125+
{
126+
h1: \\"h1\\",
127+
},
128+
_provideComponents(),
129+
props.components,
130+
);
117131
return <_components.h1>{\\"Hello World\\"}</_components.h1>;
118132
}
119-
function MDXContent(props = {}) {
120-
const {wrapper: MDXLayout} = Object.assign({}, _provideComponents(), props.components);
121-
return MDXLayout ? <MDXLayout {...props}><_createMdxContent {...props} /></MDXLayout> : _createMdxContent(props);
122-
}
123-
export default MDXContent;
124-
",
125-
"title": "Hello World",
126-
}
133+
"
127134
`;

‎packages/nextra/__test__/compile.test.ts

+189-149
Large diffs are not rendered by default.
+9-4
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import prettier from 'prettier'
22

3-
export function clean(content: any): Promise<string> {
4-
const cleanedContent = content
5-
.slice(content.indexOf('\n'), content.lastIndexOf('function MDXContent'))
6-
.trim()
3+
export async function clean(content: any, minify = true): Promise<string> {
4+
if (minify) {
5+
content = content.slice(
6+
content.indexOf('\n'),
7+
content.lastIndexOf('function MDXContent')
8+
)
9+
}
10+
11+
const cleanedContent = content.trim()
712

813
return prettier.format(cleanedContent, { parser: 'typescript' })
914
}

‎packages/nextra/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@
125125
"github-slugger": "^2.0.0",
126126
"graceful-fs": "^4.2.11",
127127
"gray-matter": "^4.0.3",
128+
"hast-util-to-estree": "^3.1.0",
128129
"katex": "^0.16.8",
129130
"next-mdx-remote": "^4.2.1",
130131
"p-limit": "^4.0.0",

‎packages/nextra/src/client/components/code.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import cn from 'clsx'
22
import type { ComponentProps, ReactElement } from 'react'
33

4-
export const Code = ({
4+
export function Code({
55
children,
66
className,
77
'data-language': _language,
88
...props
99
}: ComponentProps<'code'> & {
1010
'data-language'?: string
11-
}): ReactElement => {
11+
}): ReactElement {
1212
return (
1313
<code
1414
className={cn(

‎packages/nextra/src/client/setup-page.tsx

+10-2
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
DynamicMetaItem,
1515
DynamicMetaJsonFile,
1616
Folder,
17+
Heading,
1718
NextraInternalGlobal,
1819
PageMapItem,
1920
PageOpts
@@ -124,6 +125,7 @@ export const resolvePageMap =
124125

125126
export function setupNextraPage(
126127
MDXContent: FC,
128+
useTOC: () => Heading[],
127129
route: string,
128130
pageOpts: PageOpts
129131
) {
@@ -137,7 +139,8 @@ export function setupNextraPage(
137139
__nextra_internal__.pageMap = pageOpts.pageMap
138140
__nextra_internal__.context[route] = {
139141
Content: MDXContent,
140-
pageOpts
142+
pageOpts,
143+
useTOC
141144
}
142145
return NextraLayout
143146
}
@@ -171,6 +174,8 @@ function NextraLayout({
171174
folder.children = children
172175
}
173176

177+
const { Content, useTOC } = pageContext
178+
174179
if (__nextra_dynamic_opts) {
175180
const { toc, title, frontMatter } = __nextra_dynamic_opts
176181
pageOpts = {
@@ -179,12 +184,15 @@ function NextraLayout({
179184
title,
180185
frontMatter
181186
}
187+
} else {
188+
// eslint-disable-next-line react-hooks/rules-of-hooks -- this is not really hook
189+
pageOpts.toc = useTOC()
182190
}
183191

184192
return (
185193
<Layout themeConfig={themeConfig} pageOpts={pageOpts} pageProps={props}>
186194
<DataProvider value={props}>
187-
<pageContext.Content />
195+
<Content />
188196
</DataProvider>
189197
</Layout>
190198
)

‎packages/nextra/src/server/compile.ts

+5-4
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
remarkStaticImage,
4343
remarkStructurize
4444
} from './mdx-plugins/index.js'
45+
import { rehypeExtractTocContent } from './mdx-plugins/rehype-extract-toc-content.js'
4546
import { truthy } from './utils.js'
4647

4748
export const DEFAULT_REHYPE_PRETTY_CODE_OPTIONS: RehypePrettyCodeOptions = {
@@ -203,9 +204,8 @@ export async function compileMdx(
203204
const processor = compiler()
204205

205206
try {
206-
const vFile = await processor.process(
207-
filePath ? { value: source, path: filePath } : source
208-
)
207+
const fileCompatible = filePath ? { value: source, path: filePath } : source
208+
const vFile = await processor.process(fileCompatible)
209209

210210
const { title, hasJsxInH1, readingTime, structurizedData } = vFile.data as {
211211
readingTime?: ReadingTime
@@ -297,7 +297,8 @@ export async function compileMdx(
297297
!isRemoteContent && rehypeIcon,
298298
attachMeta
299299
]),
300-
latex && rehypeKatex
300+
latex && rehypeKatex,
301+
!isRemoteContent && rehypeExtractTocContent
301302
].filter(truthy),
302303
recmaPlugins: [!isRemoteContent && recmaRewriteJsx].filter(truthy)
303304
})

‎packages/nextra/src/server/constants.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,10 @@ export const DEFAULT_LOCALES = ['']
4646
// reload while navigating between pages every time
4747
export const IMPORT_FRONTMATTER = false
4848

49-
export const DEFAULT_PROPERTY_PROPS: Omit<Property, 'key' | 'value'> = {
49+
export const DEFAULT_PROPERTY_PROPS = {
5050
type: 'Property',
5151
kind: 'init',
5252
method: false,
5353
shorthand: false,
5454
computed: false
55-
}
55+
} satisfies Omit<Property, 'key' | 'value'>

‎packages/nextra/src/server/loader.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -234,8 +234,9 @@ ${mdxContent}
234234
235235
export default setupNextraPage(
236236
MDXContent,
237+
useTOC,
237238
'${route}',
238-
${stringifiedPageOpts},toc,pageMap,frontMatter}
239+
${stringifiedPageOpts},pageMap,frontMatter}
239240
)`
240241

241242
return rawJs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
import { clean } from '../../../../__test__/test-utils.js'
2+
import { compileMdx } from '../../compile.js'
3+
4+
const opts = {
5+
mdxOptions: {
6+
jsx: true,
7+
outputFormat: 'program'
8+
},
9+
latex: true
10+
} as const
11+
12+
describe('rehypeExtractTocContent', () => {
13+
it('should fill heading deeply', async () => {
14+
const { result } = await compileMdx(
15+
`
16+
import { Steps } from 'nextra/components'
17+
18+
## baz qux
19+
20+
<Steps>
21+
<div>
22+
### foo bar
23+
</div>
24+
</Steps>
25+
`,
26+
opts
27+
)
28+
expect(clean(result)).resolves.toMatchInlineSnapshot(`
29+
"import { useMDXComponents as _provideComponents } from \\"nextra/mdx\\";
30+
export const frontMatter = {};
31+
import { Steps } from \\"nextra/components\\";
32+
export function useTOC(props) {
33+
return [
34+
{
35+
value: \\"baz qux\\",
36+
id: \\"baz-qux\\",
37+
depth: 2,
38+
},
39+
{
40+
value: \\"foo bar\\",
41+
id: \\"foo-bar\\",
42+
depth: 3,
43+
},
44+
];
45+
}
46+
function _createMdxContent(props) {
47+
const { toc } = props;
48+
const _components = Object.assign(
49+
{
50+
h2: \\"h2\\",
51+
h3: \\"h3\\",
52+
},
53+
_provideComponents(),
54+
props.components,
55+
);
56+
return (
57+
<>
58+
<_components.h2 id={toc[0].id}>{toc[0].value}</_components.h2>
59+
{\\"\\\\n\\"}
60+
<Steps>
61+
<div>
62+
<_components.h3 id={toc[1].id}>{toc[1].value}</_components.h3>
63+
</div>
64+
</Steps>
65+
</>
66+
);
67+
}
68+
"
69+
`)
70+
})
71+
72+
it('should extract', async () => {
73+
const { result } = await compileMdx(
74+
`
75+
# Heading 1
76+
77+
export const myVar = 'interpolated'
78+
79+
## Heading {myVar}
80+
81+
### Heading $latex$
82+
83+
### Heading \`<Code />:{jsx}\`
84+
85+
export const Test = () => <span>Hello</span>
86+
87+
#### <Test /> World
88+
89+
##### String
90+
91+
###### 123
92+
93+
###### Dada 123 true
94+
95+
export const frontMatter = {
96+
test: 'extract toc content'
97+
}
98+
`,
99+
opts
100+
)
101+
expect(await clean(result, false)).toMatchInlineSnapshot(`
102+
"/*@jsxRuntime automatic @jsxImportSource react*/
103+
import { useMDXComponents as _provideComponents } from \\"nextra/mdx\\";
104+
export const myVar = \\"interpolated\\";
105+
export const Test = () => {
106+
const _components = Object.assign(
107+
{
108+
span: \\"span\\",
109+
},
110+
_provideComponents(),
111+
);
112+
return <_components.span>Hello</_components.span>;
113+
};
114+
export const frontMatter = {
115+
test: \\"extract toc content\\",
116+
};
117+
export function useTOC(props) {
118+
const _components = Object.assign(
119+
{
120+
span: \\"span\\",
121+
math: \\"math\\",
122+
semantics: \\"semantics\\",
123+
mrow: \\"mrow\\",
124+
mi: \\"mi\\",
125+
annotation: \\"annotation\\",
126+
code: \\"code\\",
127+
},
128+
_provideComponents(),
129+
);
130+
return [
131+
{
132+
value: (
133+
<>
134+
{\\"Heading \\"}
135+
{myVar}
136+
</>
137+
),
138+
id: \\"heading-myvar\\",
139+
depth: 2,
140+
},
141+
{
142+
value: (
143+
<>
144+
{\\"Heading \\"}
145+
<_components.span className=\\"katex\\">
146+
<_components.span className=\\"katex-mathml\\">
147+
<_components.math xmlns=\\"http://www.w3.org/1998/Math/MathML\\">
148+
<_components.semantics>
149+
<_components.mrow>
150+
<_components.mi>{\\"l\\"}</_components.mi>
151+
<_components.mi>{\\"a\\"}</_components.mi>
152+
<_components.mi>{\\"t\\"}</_components.mi>
153+
<_components.mi>{\\"e\\"}</_components.mi>
154+
<_components.mi>{\\"x\\"}</_components.mi>
155+
</_components.mrow>
156+
<_components.annotation encoding=\\"application/x-tex\\">
157+
{\\"latex\\"}
158+
</_components.annotation>
159+
</_components.semantics>
160+
</_components.math>
161+
</_components.span>
162+
<_components.span className=\\"katex-html\\" aria-hidden=\\"true\\">
163+
<_components.span className=\\"base\\">
164+
<_components.span
165+
className=\\"strut\\"
166+
style={{
167+
height: \\"0.6944em\\",
168+
}}
169+
/>
170+
<_components.span
171+
className=\\"mord mathnormal\\"
172+
style={{
173+
marginRight: \\"0.01968em\\",
174+
}}
175+
>
176+
{\\"l\\"}
177+
</_components.span>
178+
<_components.span className=\\"mord mathnormal\\">
179+
{\\"a\\"}
180+
</_components.span>
181+
<_components.span className=\\"mord mathnormal\\">
182+
{\\"t\\"}
183+
</_components.span>
184+
<_components.span className=\\"mord mathnormal\\">
185+
{\\"e\\"}
186+
</_components.span>
187+
<_components.span className=\\"mord mathnormal\\">
188+
{\\"x\\"}
189+
</_components.span>
190+
</_components.span>
191+
</_components.span>
192+
</_components.span>
193+
</>
194+
),
195+
id: \\"heading-latex\\",
196+
depth: 3,
197+
},
198+
{
199+
value: (
200+
<>
201+
{\\"Heading \\"}
202+
<_components.code>{\\"<Code />:{jsx}\\"}</_components.code>
203+
</>
204+
),
205+
id: \\"heading-code-jsx\\",
206+
depth: 3,
207+
},
208+
{
209+
value: (
210+
<>
211+
<Test />
212+
{\\" World\\"}
213+
</>
214+
),
215+
id: \\"-world\\",
216+
depth: 4,
217+
},
218+
{
219+
value: \\"String\\",
220+
id: \\"string\\",
221+
depth: 5,
222+
},
223+
{
224+
value: \\"123\\",
225+
id: \\"123\\",
226+
depth: 6,
227+
},
228+
{
229+
value: \\"Dada 123 true\\",
230+
id: \\"dada-123-true\\",
231+
depth: 6,
232+
},
233+
];
234+
}
235+
function _createMdxContent(props) {
236+
const { toc } = props;
237+
const _components = Object.assign(
238+
{
239+
h1: \\"h1\\",
240+
h2: \\"h2\\",
241+
h3: \\"h3\\",
242+
h4: \\"h4\\",
243+
h5: \\"h5\\",
244+
h6: \\"h6\\",
245+
},
246+
_provideComponents(),
247+
props.components,
248+
);
249+
return (
250+
<>
251+
<_components.h1>{\\"Heading 1\\"}</_components.h1>
252+
{\\"\\\\n\\"}
253+
{\\"\\\\n\\"}
254+
<_components.h2 id={toc[0].id}>{toc[0].value}</_components.h2>
255+
{\\"\\\\n\\"}
256+
<_components.h3 id={toc[1].id}>{toc[1].value}</_components.h3>
257+
{\\"\\\\n\\"}
258+
<_components.h3 id={toc[2].id}>{toc[2].value}</_components.h3>
259+
{\\"\\\\n\\"}
260+
{\\"\\\\n\\"}
261+
<_components.h4 id={toc[3].id}>{toc[3].value}</_components.h4>
262+
{\\"\\\\n\\"}
263+
<_components.h5 id={toc[4].id}>{toc[4].value}</_components.h5>
264+
{\\"\\\\n\\"}
265+
<_components.h6 id={toc[5].id}>{toc[5].value}</_components.h6>
266+
{\\"\\\\n\\"}
267+
<_components.h6 id={toc[6].id}>{toc[6].value}</_components.h6>
268+
</>
269+
);
270+
}
271+
function MDXContent(props = {}) {
272+
const toc = useTOC(props);
273+
props = {
274+
...props,
275+
toc,
276+
};
277+
const { wrapper: MDXLayout } = Object.assign(
278+
{},
279+
_provideComponents(),
280+
props.components,
281+
);
282+
return MDXLayout ? (
283+
<MDXLayout {...props}>
284+
<_createMdxContent {...props} />
285+
</MDXLayout>
286+
) : (
287+
_createMdxContent(props)
288+
);
289+
}
290+
export default MDXContent;
291+
"
292+
`)
293+
})
294+
})
Original file line numberDiff line numberDiff line change
@@ -1,113 +1,74 @@
1-
import type {
2-
BaseNode,
3-
FunctionDeclaration,
4-
Identifier,
5-
Literal,
6-
ObjectExpression,
7-
Program,
8-
Property,
9-
ReturnStatement,
10-
SpreadElement
11-
} from 'estree'
12-
import type {
13-
JsxAttribute,
14-
JsxExpressionContainer
15-
} from 'estree-util-to-js/lib/jsx'
1+
import type { FunctionDeclaration, Program, ReturnStatement } from 'estree'
2+
import type { JsxAttribute } from 'estree-util-to-js/lib/jsx'
163
import type { Plugin } from 'unified'
4+
import { visit } from 'unist-util-visit'
5+
import { DEFAULT_PROPERTY_PROPS } from '../constants.js'
176

187
const HEADING_NAMES = new Set(['h2', 'h3', 'h4', 'h5', 'h6'])
198

20-
export const recmaRewriteJsx: Plugin<[], Program> = () => ast => {
9+
export const recmaRewriteJsx: Plugin<[], Program> = () => (ast, file) => {
2110
const createMdxContent = ast.body.find(
22-
// @ts-expect-error
23-
o => o.type === 'FunctionDeclaration' && o.id.name === '_createMdxContent'
11+
o => o.type === 'FunctionDeclaration' && o.id!.name === '_createMdxContent'
2412
) as FunctionDeclaration
25-
const returnStatementIndex = createMdxContent.body.body.findIndex(
26-
o => o.type === 'ReturnStatement'
27-
)
2813

29-
const returnStatement = createMdxContent.body.body[
30-
returnStatementIndex
31-
] as ReturnStatement
14+
const mdxContent = ast.body.find(
15+
node =>
16+
node.type === 'FunctionDeclaration' && node.id!.name === 'MDXContent'
17+
) as FunctionDeclaration
3218

33-
// @ts-expect-error
34-
function isHeading(o): boolean {
35-
const name = o.openingElement?.name.property?.name
36-
return name && HEADING_NAMES.has(name)
19+
if (!mdxContent) {
20+
throw new Error('`MDXContent` not found!')
3721
}
3822

39-
// @ts-expect-error
40-
const headings = returnStatement.argument.children.filter(isHeading)
41-
const toc = ast.body.find(
42-
node =>
43-
node.type === 'ExportNamedDeclaration' &&
44-
node.declaration &&
45-
'declarations' in
46-
node.declaration /* doesn't exist for FunctionDeclaration */ &&
47-
// @ts-expect-error
48-
node.declaration.declarations[0].id.name === 'toc'
49-
) as any
23+
const returnStatement = createMdxContent.body.body.find(
24+
o => o.type === 'ReturnStatement'
25+
) as ReturnStatement
5026

51-
const tocProperties = toc.declaration.declarations[0].init.elements as (
52-
| ObjectExpression
53-
| SpreadElement
54-
)[]
27+
const { argument } = returnStatement as any
5528

56-
for (const heading of headings) {
57-
const idNode = heading.openingElement.attributes.find(
58-
(attr: JsxAttribute) => attr.name.name === 'id'
59-
)
29+
// if return statements doesn't wrap in fragment children will be []
30+
const returnBody = argument.children.length ? argument.children : [argument]
6031

61-
const id = idNode.value.value
32+
const tocProperties = file.data.toc as (
33+
| { properties: { id: string } }
34+
| string
35+
)[]
6236

63-
const foundIndex = tocProperties.findIndex(node => {
64-
if (node.type !== 'ObjectExpression') return
65-
const object = Object.fromEntries(
66-
// @ts-expect-error
67-
node.properties.map(prop => [prop.key.name, prop.value.value])
37+
visit(
38+
// @ts-expect-error -- fixes type error
39+
{ children: returnBody },
40+
'JSXElement',
41+
(heading: any, _index, parent) => {
42+
const { openingElement } = heading
43+
const name = openingElement?.name.property?.name
44+
const isHeading = name && HEADING_NAMES.has(name)
45+
// @ts-expect-error -- fixes type error
46+
const isFootnotes = parent.openingElement?.attributes.some(
47+
(attr: JsxAttribute) => attr.name.name === 'data-footnotes'
6848
)
69-
return object.id === id
70-
})
71-
72-
if (foundIndex === -1) continue
73-
74-
// @ts-expect-error
75-
const valueNode = tocProperties[foundIndex].properties.find(
76-
(node: Property) => (node.key as Identifier).name === 'value'
77-
)
78-
79-
const isExpressionContainer = (
80-
node: BaseNode
81-
): node is JsxExpressionContainer => node.type === 'JSXExpressionContainer'
82-
83-
const isIdentifier = (node: BaseNode): node is Identifier =>
84-
isExpressionContainer(node) && node.expression.type === 'Identifier'
49+
if (!isHeading || isFootnotes) return
50+
const idNode = openingElement.attributes.find(
51+
(attr: JsxAttribute) => attr.name.name === 'id'
52+
)
53+
if (!idNode) return
8554

86-
const isLiteral = (node: BaseNode): node is Literal =>
87-
isExpressionContainer(node) && node.expression.type === 'Literal'
55+
const id = idNode.value.value
8856

89-
idNode.value = {
90-
type: 'JSXExpressionContainer',
91-
expression: {
92-
type: 'Identifier',
93-
name: `toc[${foundIndex}].id`
94-
}
95-
}
57+
const foundIndex = tocProperties.findIndex(node => {
58+
if (typeof node === 'string') return
59+
return node.properties.id === id
60+
})
9661

97-
if (
98-
heading.children.every(
99-
(node: BaseNode) => isLiteral(node) || isIdentifier(node)
100-
)
101-
) {
102-
if (!heading.children.every(isLiteral)) {
103-
valueNode.value = {
104-
type: 'JSXFragment',
105-
openingFragment: { type: 'JSXOpeningFragment' },
106-
closingFragment: { type: 'JSXClosingFragment' },
107-
children: heading.children
62+
if (foundIndex === -1) return
63+
idNode.value = {
64+
type: 'JSXExpressionContainer',
65+
expression: {
66+
type: 'Identifier',
67+
name: `toc[${foundIndex}].id`
10868
}
10969
}
11070

71+
delete openingElement.selfClosing
11172
heading.children = [
11273
{
11374
type: 'JSXExpressionContainer',
@@ -117,6 +78,75 @@ export const recmaRewriteJsx: Plugin<[], Program> = () => ast => {
11778
}
11879
}
11980
]
81+
heading.closingElement = {
82+
...openingElement,
83+
type: 'JSXClosingElement',
84+
attributes: []
85+
}
12086
}
121-
}
87+
)
88+
89+
mdxContent.body.body.unshift(
90+
{
91+
type: 'VariableDeclaration',
92+
kind: 'const',
93+
declarations: [
94+
{
95+
type: 'VariableDeclarator',
96+
id: { type: 'Identifier', name: 'toc' },
97+
init: {
98+
type: 'CallExpression',
99+
callee: { type: 'Identifier', name: 'useTOC' },
100+
arguments: [{ type: 'Identifier', name: 'props' }],
101+
optional: false
102+
}
103+
}
104+
]
105+
},
106+
{
107+
type: 'ExpressionStatement',
108+
expression: {
109+
type: 'AssignmentExpression',
110+
operator: '=',
111+
left: { type: 'Identifier', name: 'props' },
112+
right: {
113+
type: 'ObjectExpression',
114+
properties: [
115+
{
116+
type: 'SpreadElement',
117+
argument: { type: 'Identifier', name: 'props' }
118+
},
119+
{
120+
...DEFAULT_PROPERTY_PROPS,
121+
key: { type: 'Identifier', name: 'toc' },
122+
value: { type: 'Identifier', name: 'toc' },
123+
shorthand: true
124+
}
125+
]
126+
}
127+
}
128+
}
129+
)
130+
131+
createMdxContent.body.body.unshift({
132+
type: 'VariableDeclaration',
133+
kind: 'const',
134+
declarations: [
135+
{
136+
type: 'VariableDeclarator',
137+
id: {
138+
type: 'ObjectPattern',
139+
properties: [
140+
{
141+
...DEFAULT_PROPERTY_PROPS,
142+
key: { type: 'Identifier', name: 'toc' },
143+
value: { type: 'Identifier', name: 'toc' },
144+
shorthand: true
145+
}
146+
]
147+
},
148+
init: { type: 'Identifier', name: 'props' }
149+
}
150+
]
151+
})
122152
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { toEstree } from 'hast-util-to-estree'
2+
import type { Plugin } from 'unified'
3+
import { visit } from 'unist-util-visit'
4+
5+
export const rehypeExtractTocContent: Plugin<[], any> = () => (ast, file) => {
6+
const toc: any[] = []
7+
8+
visit(ast, 'element', node => {
9+
if (!/^h[2-6]$/.test(node.tagName)) return
10+
11+
const { id } = node.properties
12+
if (typeof id === 'string') {
13+
toc.push(structuredClone(node))
14+
node.children = []
15+
}
16+
})
17+
18+
const TocToExpression = Object.fromEntries(
19+
toc.map(node =>
20+
// @ts-expect-error
21+
[node.properties.id, toEstree(node).body[0].expression]
22+
)
23+
)
24+
25+
// @ts-expect-error
26+
const elements = file.data.toc.map(n => {
27+
if (typeof n === 'string') {
28+
return {
29+
type: 'SpreadElement',
30+
argument: {
31+
type: 'CallExpression',
32+
callee: { type: 'Identifier', name: n }
33+
}
34+
}
35+
}
36+
37+
const node = TocToExpression[n.id]
38+
39+
const isText = node.children.every(
40+
// @ts-expect-error
41+
child =>
42+
child.type === 'JSXExpressionContainer' &&
43+
child.expression.type === 'Literal'
44+
)
45+
46+
const result = isText
47+
? // @ts-expect-error
48+
node.children.map(n => n.expression)[0]
49+
: {
50+
type: 'JSXFragment',
51+
openingFragment: { type: 'JSXOpeningFragment' },
52+
closingFragment: { type: 'JSXClosingFragment' },
53+
children: node.children
54+
}
55+
56+
return {
57+
type: 'ObjectExpression',
58+
properties: [
59+
{
60+
type: 'Property',
61+
key: { type: 'Identifier', name: 'value' },
62+
value: result,
63+
kind: 'init'
64+
},
65+
{
66+
type: 'Property',
67+
key: { type: 'Identifier', name: 'id' },
68+
value: {
69+
type: 'Literal',
70+
value: node.openingElement.attributes.find(
71+
// @ts-expect-error
72+
attr => attr.name.name === 'id'
73+
).value.value
74+
},
75+
kind: 'init'
76+
},
77+
{
78+
type: 'Property',
79+
key: { type: 'Identifier', name: 'depth' },
80+
value: {
81+
type: 'Literal',
82+
value: Number(node.openingElement.name.name[1])
83+
},
84+
kind: 'init'
85+
}
86+
]
87+
}
88+
})
89+
90+
ast.children.push({
91+
type: 'mdxjsEsm',
92+
data: {
93+
estree: {
94+
body: [
95+
{
96+
type: 'ExportNamedDeclaration',
97+
specifiers: [],
98+
declaration: {
99+
type: 'FunctionDeclaration',
100+
id: { type: 'Identifier', name: 'useTOC' },
101+
params: [{ type: 'Identifier', name: 'props' }],
102+
body: {
103+
type: 'BlockStatement',
104+
body: [
105+
{
106+
type: 'ReturnStatement',
107+
argument: { type: 'ArrayExpression', elements }
108+
}
109+
]
110+
}
111+
}
112+
}
113+
]
114+
}
115+
}
116+
})
117+
118+
file.data.toc = toc
119+
}

‎packages/nextra/src/server/mdx-plugins/remark-headings.ts

+5-29
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
1-
import type { SpreadElement } from 'estree'
21
import Slugger from 'github-slugger'
32
import type { Parent, Root } from 'mdast'
43
import type { Plugin } from 'unified'
54
import { visit } from 'unist-util-visit'
65
import type { Heading } from '../../types'
76
import { MARKDOWN_EXTENSION_REGEX } from '../constants.js'
8-
import { createAstExportConst, createAstObject } from '../utils.js'
97
import type { HProperties } from './remark-custom-heading-id'
108

119
const getFlattenedValue = (node: Parent): string =>
@@ -24,7 +22,7 @@ const SKIP_FOR_PARENT_NAMES = new Set(['Tab', 'Tabs.Tab'])
2422
export const remarkHeadings: Plugin<
2523
[{ exportName?: string; isRemoteContent?: boolean }],
2624
Root
27-
> = ({ exportName = 'toc', isRemoteContent }) => {
25+
> = ({ exportName = 'useTOC', isRemoteContent }) => {
2826
const headings: (Heading | string)[] = []
2927
let hasJsxInH1: boolean
3028
let title: string
@@ -38,9 +36,9 @@ export const remarkHeadings: Plugin<
3836
ast,
3937
[
4038
'heading',
41-
// push partial component's `toc` export name to headings list
39+
// push partial component's `useTOC` export name to headings list
4240
'mdxJsxFlowElement',
43-
// verify .md/.mdx exports and attach named `toc` export
41+
// verify .md/.mdx exports and attach named `useTOC` export
4442
'mdxjsEsm'
4543
],
4644
(node, _index, parent) => {
@@ -108,33 +106,11 @@ export const remarkHeadings: Plugin<
108106
file.data.hasJsxInH1 = hasJsxInH1
109107
file.data.title = title
110108

109+
file.data.toc = headings
111110
if (isRemoteContent) {
112-
// Attach headings for remote content, because we can't access to `toc` variable
111+
// Attach headings for remote content, because we can't access to `useTOC` fn
113112
file.data.headings = headings
114113
return
115114
}
116-
117-
const headingElements = headings.map(heading =>
118-
typeof heading === 'string'
119-
? ({
120-
type: 'SpreadElement',
121-
argument: { type: 'Identifier', name: heading }
122-
} satisfies SpreadElement)
123-
: createAstObject(heading)
124-
)
125-
126-
ast.children.push({
127-
type: 'mdxjsEsm',
128-
data: {
129-
estree: {
130-
body: [
131-
createAstExportConst(exportName, {
132-
type: 'ArrayExpression',
133-
elements: headingElements
134-
})
135-
]
136-
}
137-
}
138-
} as any)
139115
}
140116
}

‎packages/nextra/src/types.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,14 @@ export type NextraInternalGlobal = typeof globalThis & {
9999
[NEXTRA_INTERNAL]: {
100100
pageMap: PageMapItem[]
101101
route: string
102-
context: Record<string, { Content: FC; pageOpts: PageOpts }>
102+
context: Record<
103+
string,
104+
{
105+
Content: FC
106+
pageOpts: PageOpts
107+
useTOC: () => Heading[]
108+
}
109+
>
103110
Layout: FC<NextraThemeLayoutProps>
104111
themeConfig?: ThemeConfig
105112
}

‎pnpm-lock.yaml

+3
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)
Please sign in to comment.