Skip to content

Commit 7ddee8b

Browse files
committed
Extract headings without Nextra
1 parent 34a7de5 commit 7ddee8b

File tree

3 files changed

+87
-16
lines changed

3 files changed

+87
-16
lines changed

pnpm-lock.yaml

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/app/conf/2025/code-of-conduct/page.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import { ServerComponentMarkdown } from "@/app/conf/_components/server-component
66
import { Button } from "@/app/conf/_design-system/button"
77

88
import { NavbarPlaceholder } from "../components/navbar"
9-
import "../resources/prose.css"
109
import { Hero, HeroStripes } from "../components/hero"
10+
import "../resources/prose.css"
1111

1212
import markdown from "./code-of-conduct.mdx?raw"
1313

@@ -42,6 +42,29 @@ export default function ResourcesPage() {
4242
<div className="gql-conf-container gql-conf-section gql-prose xl:mb-16 xl:mt-8">
4343
<ServerComponentMarkdown
4444
markdown={markdown}
45+
extractToc
46+
Wrapper={({ children, data }) => {
47+
return (
48+
<div>
49+
<aside className="gql-sticky-aside row-span-8 -mt-1 w-fit sm:max-xl:grid sm:max-xl:grid-cols-2 sm:max-xl:bg-neu-100 sm:max-xl:p-4 dark:sm:max-xl:bg-neu-50/50">
50+
{data.toc.map(({ value, id, depth }) => (
51+
<a
52+
key={id}
53+
data-depth={depth}
54+
className="raw typography-menu block p-4 py-2 text-neu-800 hover:bg-neu-100 hover:text-neu-900 data-[depth=2]:font-semibold dark:hover:bg-neu-50 max-xl:-ml-4 xl:data-[depth=2]:text-lg"
55+
style={{
56+
paddingLeft: (depth - 2) * 16 + 16,
57+
}}
58+
href={`#${id}`}
59+
>
60+
{value}
61+
</a>
62+
))}
63+
</aside>
64+
{children}
65+
</div>
66+
)
67+
}}
4568
components={{
4669
a: (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
4770
return (
Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import "server-only"
22

33
// @ts-expect-error - we already have it in transitive deps and I want to avoid having a duplicate
4-
import { evaluate } from "@mdx-js/mdx"
4+
import { compile, run } from "@mdx-js/mdx"
55
import * as runtime from "react/jsx-runtime"
6+
import { Root, Element } from "hast"
7+
import { visit } from "unist-util-visit"
8+
import { toString } from "hast-util-to-string"
9+
// @ts-expect-error
10+
import Slugger from "github-slugger"
611

712
/**
813
* Nextra's builtin MDX support requires jumping out to a Client component
@@ -14,25 +19,68 @@ import * as runtime from "react/jsx-runtime"
1419
export async function ServerComponentMarkdown({
1520
markdown,
1621
components = {},
22+
extractToc = false,
23+
Wrapper = ({ children }) => children,
1724
}: {
1825
markdown: string
1926
components?: Record<string, React.ComponentType>
27+
extractToc?: boolean
28+
Wrapper?: React.ComponentType<{
29+
children: React.ReactNode
30+
data: { toc: TableOfContents }
31+
}>
2032
}) {
2133
try {
22-
const { default: Mdx } = await evaluate(markdown, {
34+
const rehypePlugins = extractToc ? [rehypeExtractTableOfContents] : []
35+
36+
const vfile = await compile(markdown, {
37+
outputFormat: "function-body",
38+
remarkPlugins: [],
39+
rehypePlugins,
40+
recmaPlugins: [],
41+
})
42+
43+
const { default: Mdx } = await run(String(vfile), {
2344
...runtime,
24-
useMDXComponents: (arg: typeof components) => {
25-
return {
26-
...components,
27-
...arg,
28-
}
29-
},
45+
baseUrl: import.meta.url,
3046
})
31-
return Mdx()
47+
48+
return <Wrapper data={vfile.data}>{Mdx({ components })}</Wrapper>
3249
} catch (error) {
3350
console.error(error)
3451
return (
3552
<div>{error instanceof Error ? error.message : "Error loading MDX"}</div>
3653
)
3754
}
3855
}
56+
57+
type TableOfContents = Array<{ value: string; id: string; depth: number }>
58+
59+
/**
60+
* Nextra has a built-in plugin like this, but it also steals the heading contents
61+
* as is tightly coupled with other Nextra features.
62+
*/
63+
function rehypeExtractTableOfContents() {
64+
const slugger = new Slugger()
65+
66+
return function (tree: Root, file: any) {
67+
const toc: TableOfContents = []
68+
69+
visit(tree, "element", (node: Element) => {
70+
if (node.tagName && /^h[1-6]$/.test(node.tagName)) {
71+
const depth = parseInt(node.tagName.charAt(1), 10)
72+
const value = toString(node)
73+
const slug = slugger.slug(value)
74+
75+
node.properties ||= node.properties || {}
76+
// add id to the heading element if it's not already set
77+
node.properties.id ||= slug
78+
79+
toc.push({ value, id: slug, depth })
80+
}
81+
})
82+
83+
file.data = file.data || {}
84+
file.data.toc = toc
85+
}
86+
}

0 commit comments

Comments
 (0)