@portabletext/toolkit
Version:
Toolkit of handy utility functions for dealing with Portable Text
203 lines (179 loc) • 6.82 kB
text/typescript
import type {PortableTextBlock, PortableTextListItemBlock, TypedObject} from '@portabletext/types'
import {
isPortableTextListItemBlock,
isPortableTextSpan,
isPortableTextToolkitList,
} from './asserters'
import type {
ToolkitListNestMode,
ToolkitPortableTextDirectList,
ToolkitPortableTextHtmlList,
ToolkitPortableTextList,
ToolkitPortableTextListItem,
} from './types'
export type ToolkitNestListsOutputNode<T> =
| T
| ToolkitPortableTextHtmlList
| ToolkitPortableTextDirectList
/**
* Takes an array of blocks and returns an array of nodes optimized for rendering in HTML-like
* environment, where lists are nested inside of eachother instead of appearing "flat" as in
* native Portable Text data structures.
*
* Note that the list node is not a native Portable Text node type, and thus is represented
* using the {@link ToolkitPortableTextList | `@list`} type name (`{_type: '@list'}`).
*
* The nesting can be configured in two modes:
*
* - `direct`: deeper list nodes will appear as a direct child of the parent list
* - `html`, deeper list nodes will appear as a child of the last _list item_ in the parent list
*
* When using `direct`, all list nodes will be of type {@link ToolkitPortableTextDirectList},
* while with `html` they will be of type {@link ToolkitPortableTextHtmlList}
*
* These modes are available as {@link LIST_NEST_MODE_HTML} and {@link LIST_NEST_MODE_DIRECT}.
*
* @param blocks - Array of Portable Text blocks and other arbitrary types
* @param mode - Mode to use for nesting, `direct` or `html`
* @returns Array of potentially nested nodes optimized for rendering
*/
export function nestLists<T extends TypedObject = PortableTextBlock | TypedObject>(
blocks: T[],
mode: 'direct',
): (T | ToolkitPortableTextDirectList)[]
export function nestLists<T extends TypedObject = PortableTextBlock | TypedObject>(
blocks: T[],
mode: 'html',
): (T | ToolkitPortableTextHtmlList)[]
export function nestLists<T extends TypedObject = PortableTextBlock | TypedObject>(
blocks: T[],
mode: 'direct' | 'html',
): (T | ToolkitPortableTextHtmlList | ToolkitPortableTextDirectList)[]
export function nestLists<T extends TypedObject = PortableTextBlock | TypedObject>(
blocks: T[],
mode: ToolkitListNestMode,
): ToolkitNestListsOutputNode<T>[] {
const tree: ToolkitNestListsOutputNode<T>[] = []
let currentList: ToolkitPortableTextList | undefined
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i]
if (!block) {
continue
}
if (!isPortableTextListItemBlock(block)) {
tree.push(block)
currentList = undefined
continue
}
// Start of a new list?
if (!currentList) {
currentList = listFromBlock(block, i, mode)
tree.push(currentList)
continue
}
// New list item within same list?
if (blockMatchesList(block, currentList)) {
currentList.children.push(block)
continue
}
// Different list props, are we going deeper?
if ((block.level || 1) > currentList.level) {
const newList = listFromBlock(block, i, mode)
if (mode === 'html') {
// Because HTML is kinda weird, nested lists needs to be nested within list items.
// So while you would think that we could populate the parent list with a new sub-list,
// we actually have to target the last list element (child) of the parent.
// However, at this point we need to be very careful - simply pushing to the list of children
// will mutate the input, and we don't want to blindly clone the entire tree.
// Clone the last child while adding our new list as the last child of it
const lastListItem = currentList.children[
currentList.children.length - 1
] as ToolkitPortableTextListItem
const newLastChild: ToolkitPortableTextListItem = {
...lastListItem,
children: [...lastListItem.children, newList],
}
// Swap the last child
currentList.children[currentList.children.length - 1] = newLastChild
} else {
;(currentList as ToolkitPortableTextDirectList).children.push(
newList as ToolkitPortableTextDirectList,
)
}
// Set the newly created, deeper list as the current
currentList = newList
continue
}
// Different list props, are we going back up the tree?
if ((block.level || 1) < currentList.level) {
// Current list has ended, and we need to hook up with a parent of the same level and type
const matchingBranch = tree[tree.length - 1]
const match = matchingBranch && findListMatching(matchingBranch, block)
if (match) {
currentList = match
currentList.children.push(block)
continue
}
// Similar parent can't be found, assume new list
currentList = listFromBlock(block, i, mode)
tree.push(currentList)
continue
}
// Different list props, different list style?
if (block.listItem !== currentList.listItem) {
const matchingBranch = tree[tree.length - 1]
const match = matchingBranch && findListMatching(matchingBranch, {level: block.level || 1})
if (match && match.listItem === block.listItem) {
currentList = match
currentList.children.push(block)
continue
} else {
currentList = listFromBlock(block, i, mode)
tree.push(currentList)
continue
}
}
// eslint-disable-next-line no-console
console.warn('Unknown state encountered for block', block)
tree.push(block)
}
return tree
}
function blockMatchesList(block: PortableTextBlock, list: ToolkitPortableTextList) {
return (block.level || 1) === list.level && block.listItem === list.listItem
}
function listFromBlock(
block: PortableTextListItemBlock,
index: number,
mode: ToolkitListNestMode,
): ToolkitPortableTextList {
return {
_type: '@list',
_key: `${block._key || `${index}`}-parent`,
mode,
level: block.level || 1,
listItem: block.listItem,
children: [block],
}
}
function findListMatching<T extends TypedObject | PortableTextBlock>(
rootNode: T,
matching: Partial<PortableTextListItemBlock>,
): ToolkitPortableTextList | undefined {
const level = matching.level || 1
const style = matching.listItem || 'normal'
const filterOnType = typeof matching.listItem === 'string'
if (
isPortableTextToolkitList(rootNode) &&
(rootNode.level || 1) === level &&
filterOnType &&
(rootNode.listItem || 'normal') === style
) {
return rootNode
}
if (!('children' in rootNode)) {
return undefined
}
const node = rootNode.children[rootNode.children.length - 1]
return node && !isPortableTextSpan(node) ? findListMatching(node, matching) : undefined
}