Create TOC (table of contents) from Sanity portable text

Create TOC (table of contents) from Sanity portable text

Published on February 1, 2024 (1y ago)

1. Get all headings from the portable text:

*[_type == "post"] {
  "headings": yourContentField[length(style) == 2
    && string::startsWith(style, "h")]
}

the result should be something like this:

{
  0:{} 2 properties
    headings:[] 5 items
      0:{} 5 properties
        style: h2
        _key: 86e626763053
        markDefs:[] 0 items
        children:[] 1 item
          0: {...} 4 properties
            marks: [] 0 items
            text: My extracted header
            _key: 68e626305673
            _type: span
      _type:block
    ... // the rest of the response
}

2. Create a function to parse the headings:

import speakingurl from 'speakingurl'

// Filters the AST based on a given match function.
const filter = (ast, match) =>
  ast.reduce((acc, node) => {
    if (match(node)) acc.push(node)
    if (node.children) acc.push(...filter(node.children, match))
    return acc
  }, [])

/**
 * Finds headings in the given AST and returns
 * an array of objects containing the heading text and slug.
 */
const findHeadings = (ast) =>
  filter(ast, (node) => /h\d/.test(node.style)).map((node) => {
    const text = getChildrenText(node)
    const slug = speakingurl(text)
    return { ...node, text, slug }
  })

// Returns the concatenated text content of the children nodes.
export const getChildrenText = (props) =>
  props.children
    .map((node) =>
      typeof node === 'string' ? node : node.text || ''
    )
    .join('')

// Retrieves a value from an object using a given path.
const get = (object, path) =>
  path.reduce((prev, curr) => prev[curr], object)

// Returns the object path for a given path.
const getObjectPath = (path) =>
  path.length === 0
    ? path
    : ['subheadings'].concat(path.join('.subheadings.').split('.'))

/**
 * Parses the given AST (Abstract Syntax Tree)
 * to create an outline of subheadings.
 */
export const parseOutline = (ast) => {
  const outline = { subheadings: [] }
  const headings = findHeadings(ast)
  const path = []
  let lastLevel = 0

  headings.forEach((heading) => {
    const level = Number(heading.style.slice(1))
    heading.subheadings = []

    if (level < lastLevel)
      for (let i = lastLevel; i >= level; i--) path.pop()
    else if (level === lastLevel) path.pop()

    const prop = get(outline, getObjectPath(path))
    prop.subheadings.push(heading)
    path.push(prop.subheadings.length - 1)
    lastLevel = level
  })

  return outline.subheadings
}

3. Finally, call the function and create TOC:

import { getChildrenText } from 'lib/utils/createNestedSubheads'

/**
 * Using 2 levels of nesting for brevity
 * h2 = level 0, h3 and the rest are level 1
 */
function TOC({ headings }) {
  return (
    <>
      <h3>Table of Contents:</h3>
      <ul>
        {headings.map((heading) => (
          <li
            key={heading._key}
            className={heading.style === 'h2' ? 'ml-2' : 'ml-6'}
          >
            <a href={'#' + heading._key}>
              {getChildrenText(heading)}
            </a>
          </li>
        ))}
      </ul>
    </>
  )
}