How table of contents is created in tailwindcss.com
How table of contents is created in tailwindcss.com
some of the parts in the code blocks below has been removed to make it easier to read and reason about
TL;DR
Tailwind is using 1. custom remark plugin to mdx-js webpack loader to replace headings with their custom Heading
component and 2. a custom webpack loader to to wrap content in the layout with the ToC. Custom Headings and the ToC is are glued together with react's context (in this case named ContentsContext
). This context is created by the hook used in the layout component and it provides functions to Heading component, that allow them to add/remove themselves from the ToC.
Webpack loaders
There are two loaders related to adding links to headings and creating a table of contents: @mdx-js/loader
(returned by the mdx()
call; marked as 2nd loader below) and another custom loader (marked as 1st loader below). They will be executed in the reverse order, thus the reversed numbering.
// next.config.js
...
config.module.rules.push({
test: { and: [/\.mdx$/], not: [/snippets/] },
resourceQuery: { not: [/rss/, /preview/] },
use: [
...
// 2nd loader (more precisely this function will return an array of loder objects, one of which is @mdx-js/loader config
...mdx(),
// 1st loader
createLoader(function (source) {
...
}),
],
})
...
Wrapping content in the layout with ToC (1st loader)
The first loader is responsible for adding a default export with a layout component to all the pages matching the webpack rule. This layout will be used to wrap the content. We're interested in ContentsLayout
here, since this is the one that includes a table of contents (and the one that is used for all their docs pages).
// next.config.js
...
const fallbackDefaultExports = {
'src/pages/{docs,components}/**/*': ['@/layouts/ContentsLayout', 'ContentsLayout'],
'src/pages/blog/**/*': ['@/layouts/BlogPostLayout', 'BlogPostLayout'],
}
...
// next.config.js
...
createLoader(function (source) {
...
// if there is no default export in the source file,
// import a relevant layout (ContentLayout or BlogLayout) and default-export it (so that it is used as a wrapper for all the content)
if (!/^\s*export\s+default\s+/m.test(source.replace(/```(.*?)```/gs, ''))) {
for (let glob in fallbackDefaultExports) {
if (minimatch(resourcePath, glob)) {
extra.push(
`import { ${fallbackDefaultExports[glob][1]} as _Default } from '${fallbackDefaultExports[glob][0]}'`,
'export default _Default'
)
break
}
}
}
...
}),
...
Layout component with ToC
The ContentsLayout
component added by the above loader, receives the following props: children
, meta
, classes
, tableOfContents
, section
. They are passed to the layout by exporting them from each page - either manually or by adding export statements by a webpack loader. For example, the tableOfContents
variable export is added by a custom remark
plugin - withTableOfContents
- added to the @mdx-js/loader
(see next section).
// layouts/ContentsLayout.js
...
import { createContext } from 'react'
// this is an important variable - a glue between custom headings and the ToC
export const ContentsContext = createContext()
export function ContentsLayout({ children, meta, classes, tableOfContents, section }) {
...
const toc = [
...(classes ? [{ title: 'Quick reference', slug: 'class-reference', children: [] }] : []),
// tableOfContents is an (nested) array of headings (and their children) and is exported from each page (export statement added automatically by the remark plugin)
...tableOfContents,
]
// useTableOfContents hook returns the currently active section and two functions for adding/removing headings from the ToC (used by Heading components, passed to them through ContentsContext.Provider - see below)
const { currentSection, registerHeading, unregisterHeading } = useTableOfContents(toc)
return (
<div>
<PageHeader
...
/>
<ContentsContext.Provider value={{ registerHeading, unregisterHeading }}>
...
<div id="content">
// the MDX content component will include custom Heading components that will make use of the functions passed by the provider (Headings are added by another loader - mdx-js loader; see sections below)
<MDXProvider>{children}</MDXProvider>
</div>
...
</ContentsContext.Provider>
...
<div>
{toc.length > 0 && (
<TableOfContents tableOfContents={toc} currentSection={currentSection} />
)}
</div>
</div>
)
}
useTableOfContents
hook:
https://github.com/tailwindlabs/tailwindcss.com/blob/6d6ee63ba619a78955e6e39a46535f80128d839d/src/layouts/ContentsLayout.js#L91-L135
// layouts/ContentsLayout.js
function useTableOfContents(tableOfContents) {
// tableOfContents array is only used for setting the initial value of the currently active section
let [currentSection, setCurrentSection] = useState(tableOfContents[0]?.slug)
// headings are populated by Heading components themselves - they have access to registerHeading and unregisterHeading functions through ContentsContext
let [headings, setHeadings] = useState([])
// these two functions are used by Heading components
const registerHeading = useCallback((id, top) => {
setHeadings((headings) => [...headings.filter((h) => id !== h.id), { id, top }])
}, [])
const unregisterHeading = useCallback((id) => {
setHeadings((headings) => headings.filter((h) => id !== h.id))
}, [])
useEffect(() => {
...
function onScroll() {
... // logic for setting the currently active heading
setCurrentSection(current)
}
window.addEventListener('scroll', onScroll, {
...
})
onScroll()
return () => window.removeEventListener('scroll', onScroll, true)
}, [headings, tableOfContents])
return { currentSection, registerHeading, unregisterHeading }
}
TableOfContents
component:
https://github.com/tailwindlabs/tailwindcss.com/blob/6d6ee63ba619a78955e6e39a46535f80128d839d/src/layouts/ContentsLayout.js#L15-L89
// layouts/ContentsLayout.js
function TableOfContents({ tableOfContents, currentSection }) {
...
// this is niccce!
// this simple logic makes both the section and it's parent (if the section is a child, not a top level section) highlighted in the ToC
function isActive(section) {
// if the section is the current section
if (section.slug === currentSection) {
return true
}
// if it's not and it doesn't have any children
if (!section.children) {
return false
}
//
return section.children.findIndex(isActive) > -1
}
let pageHasSubsections = tableOfContents.some((section) => section.children.length > 0)
return (
<>
<h5>On this page</h5>
<ul>
{tableOfContents.map((section) => (
<Fragment key={section.slug}>
<li>
<a
href={`#${section.slug}`}
className={clsx(
'block py-1',
pageHasSubsections ? 'font-medium' : '',
isActive(section) ? 'font-medium text-sky-500' : 'hover:text-gray-900'
)}
>
{section.title}
</a>
</li>
{section.children.map((subsection) => (
<li className="ml-4" key={subsection.slug}>
<a
href={`#${subsection.slug}`}
className={clsx(
'group flex items-start py-1',
isActive(subsection) ? 'text-sky-500' : 'hover:text-gray-900'
)}
>
<svg>
...
</svg>
{subsection.title}
</a>
</li>
))}
</Fragment>
))}
</ul>
</>
)
}
Wrapping headings in links and registering them for the ToC (2nd loader)
And here is how headings register/unregister (add/remove) themselves from the ToC.
Tailwind uses a custom remark
plugin - withTableOfContents
- for the @mdx-js/loader
that its using in one of it's webpack rules, that target most of .mdx
files.
// next.config.js
let mdx = () => [
{
loader: '@mdx-js/loader',
options: {
remarkPlugins: [
withTableOfContents, // <---
...
],
...
},
},
...
]
How it works
withTableOfContents
receives an mdast tree and searches for nodes of type 'heading'
, but only with levels: 2,3,4. If you take a look at one of their docs pages, indeed they don't include the first heading in the table of contents. It makes sense, as there should only be one major title h1
per page, and obviously it will be at the very top of it.
// /remark/withTableOfContents.js
module.exports.withTableOfContents = () => {
return (tree) => {
// prepend a node of type 'import' to the mdast and return it's name
// (`import { Heading as _Heading } from '@/components/Heading')
const component = addImport(tree, '@/components/Heading', 'Heading')
// this is a variable holding slugs of all the headings (and their child headings) that will be displayed in the table of contents
const contents = []
for (let nodeIndex = 0; nodeIndex < tree.children.length; nodeIndex++) {
let node = tree.children[nodeIndex]
if (node.type === 'heading' && [2, 3, 4].includes(node.depth)) {
let level = node.depth
let title = node.children
... // some code for extracting title
let slug = slugify(title)
... // some code for handling headings with equal slugs
// change the node type from 'heading' to 'jsx'
node.type = 'jsx'
let props = {
level,
id: slug,
}
... // code for getting the next element after the heading
if (node.children[0].type === 'jsx' && /^\s*<Heading[\s>]/.test(node.children[0].value)) {
... // code used to apply some transformations when heading title is wrapped in <Heading /> component directly in .mdx file:
// e.g. ### <Heading ignore>Hover, focus, and other states</Heading>
} else {
// else (the majority of cases), wrap the heading content (title) in <Heading> with props
node.value = `<${component} ${stringifyProps(props)}>${node.children
.map(({ value }) => value)
.join('')}</${component}>`
}
// the following conditional statement are responsible for creating an array of nested headings, held in `contents` variable
if (level === 2) {
contents.push({ title, slug, children: [] })
} else if (level === 3) {
contents[contents.length - 1].children.push({ title, slug, children: [] })
} else {
contents[contents.length - 1].children[
contents[contents.length - 1].children.length - 1
].children.push({ title, slug })
}
// if the node is not of heading type (i.e. not ""## Some heading" or "## <Heading>Some heading</Heading>") but it is a JSX Heading component (e.g. <Heading>Some heading</Heading>)
} else if (
node.type === 'jsx' &&
/^\s*<Heading[\s>]/.test(node.value) &&
!/^\s*<Heading[^>]*\sid=/.test(node.value)
) {
... // do some stuff for <Heading>Some heading</Heading> (not ## <Heading>Some heading</Heading>)
}
}
// add node of type 'export' to the mdast
// (`export const tableOfContents = ${JSON.stringify(contents)}`)
addExport(tree, 'tableOfContents', contents)
}
}
Custom Heading
component
As seen above, all HTML headings (of levels 2,3 and 4) will be replaced by custom Heading
component.
// ContentsContext is a React context created by `createContext`
...
import { ContentsContext } from '@/layouts/ContentsLayout'
import { useTop } from '@/hooks/useTop'
...
export function Heading({
level,
id,
children,
...
...props
}) {
let Component = `h${level}`
// get the current value of ContentsContext
const context = useContext(ContentsContext)
let ref = useRef()
// useTop returns the position of the heading relative to the beginning of the page (not the viewport, i.e. it will not change on scroll)
let top = useTop(ref)
useEffect(() => {
if (!context) return
if (typeof top !== 'undefined') {
// register the heading if it's found on the page
context.registerHeading(id, top)
}
// otherwise, unregister
return () => {
context.unregisterHeading(id)
}
}, [top, id, context?.registerHeading, context?.unregisterHeading])
return (
<Component
id={id}
ref={ref}
...
{...props}
>
...
</Component>
)
}
ContentsContex
is used in the Heading
component, to access these two functions: registerHeading
and unregisterHeading
(set by useTableOfContents
hook).