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.

https://github.com/tailwindlabs/tailwindcss.com/blob/86161204e51b74d1f21b5d73e34f894baa87d61b/next.config.js#L249-L334

// 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).

https://github.com/tailwindlabs/tailwindcss.com/blob/86161204e51b74d1f21b5d73e34f894baa87d61b/next.config.js#L29-L32

// 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).

https://github.com/tailwindlabs/tailwindcss.com/blob/6d6ee63ba619a78955e6e39a46535f80128d839d/src/layouts/ContentsLayout.js

// 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>
    </>
  )
}

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.

https://github.com/tailwindlabs/tailwindcss.com/blob/86161204e51b74d1f21b5d73e34f894baa87d61b/next.config.js#L178-L202

// 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.

https://github.com/tailwindlabs/tailwindcss.com/blob/86161204e51b74d1f21b5d73e34f894baa87d61b/remark/withTableOfContents.js

// /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.

https://github.com/tailwindlabs/tailwindcss.com/blob/6d6ee63ba619a78955e6e39a46535f80128d839d/src/components/Heading.js#L1-L80

// 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).

© 2024 All rights reservedBuilt with Find, Share and Publish Quality Data with Datahub

Built with Find, Share and Publish Quality Data with DatahubFind, Share and Publish Quality Data with Datahub