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