@portabletext/to-html
Version:
Render Portable Text to HTML
236 lines (200 loc) • 6.93 kB
text/typescript
import {
buildMarksTree,
isPortableTextBlock,
isPortableTextListItemBlock,
isPortableTextToolkitList,
isPortableTextToolkitSpan,
isPortableTextToolkitTextNode,
nestLists,
spanToPlainText,
type ToolkitNestedPortableTextSpan,
type ToolkitTextNode,
} from '@portabletext/toolkit'
import type {
ArbitraryTypedObject,
PortableTextBlock,
PortableTextListItemBlock,
PortableTextMarkDefinition,
PortableTextSpan,
TypedObject,
} from '@portabletext/types'
import {defaultComponents} from './components/defaults'
import {mergeComponents} from './components/merge'
import {escapeHTML} from './escape'
import type {
HtmlPortableTextList,
MissingComponentHandler,
NodeRenderer,
PortableTextHtmlComponents,
PortableTextOptions,
Serializable,
SerializedBlock,
} from './types'
import {
printWarning,
unknownBlockStyleWarning,
unknownListItemStyleWarning,
unknownListStyleWarning,
unknownMarkWarning,
unknownTypeWarning,
} from './warnings'
export function toHTML<B extends TypedObject = PortableTextBlock | ArbitraryTypedObject>(
value: B | B[],
options: PortableTextOptions = {},
): string {
const {
components: componentOverrides,
onMissingComponent: missingComponentHandler = printWarning,
} = options
const handleMissingComponent = missingComponentHandler || noop
const blocks = Array.isArray(value) ? value : [value]
const nested = nestLists(blocks, 'html')
const components = componentOverrides
? mergeComponents(defaultComponents, componentOverrides)
: defaultComponents
const renderNode = getNodeRenderer(components, handleMissingComponent)
const rendered = nested.map((node, index) =>
renderNode({node: node, index, isInline: false, renderNode}),
)
return rendered.join('')
}
const getNodeRenderer = (
components: PortableTextHtmlComponents,
handleMissingComponent: MissingComponentHandler,
): NodeRenderer => {
function renderNode<N extends TypedObject>(options: Serializable<N>): string {
const {node, index, isInline} = options
if (isPortableTextToolkitList(node)) {
return renderList(node, index)
}
if (isPortableTextListItemBlock(node)) {
return renderListItem(node, index)
}
if (isPortableTextToolkitSpan(node)) {
return renderSpan(node)
}
if (isPortableTextBlock(node)) {
return renderBlock(node, index, isInline)
}
if (isPortableTextToolkitTextNode(node)) {
return renderText(node)
}
return renderCustomBlock(node, index, isInline)
}
function renderListItem(
node: PortableTextListItemBlock<PortableTextMarkDefinition, PortableTextSpan>,
index: number,
): string {
const tree = serializeBlock({node, index, isInline: false, renderNode})
const renderer = components.listItem
const handler = typeof renderer === 'function' ? renderer : renderer[node.listItem]
const itemHandler = handler || components.unknownListItem
if (itemHandler === components.unknownListItem) {
const style = node.listItem || 'bullet'
handleMissingComponent(unknownListItemStyleWarning(style), {
type: style,
nodeType: 'listItemStyle',
})
}
let children = tree.children
if (node.style && node.style !== 'normal') {
// Wrap any other style in whatever the block component says to use
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const {listItem, ...blockNode} = node
children = renderNode({node: blockNode, index, isInline: false, renderNode})
}
return itemHandler({value: node, index, isInline: false, renderNode, children})
}
function renderList(node: HtmlPortableTextList, index: number): string {
const children = node.children.map((child, childIndex) =>
renderNode({
node: child._key ? child : {...child, _key: `li-${index}-${childIndex}`},
index: index,
isInline: false,
renderNode,
}),
)
const component = components.list
const handler = typeof component === 'function' ? component : component[node.listItem]
const list = handler || components.unknownList
if (list === components.unknownList) {
const style = node.listItem || 'bullet'
handleMissingComponent(unknownListStyleWarning(style), {nodeType: 'listStyle', type: style})
}
return list({value: node, index, isInline: false, renderNode, children: children.join('')})
}
function renderSpan(node: ToolkitNestedPortableTextSpan): string {
const {markDef, markType, markKey} = node
const span = components.marks[markType] || components.unknownMark
const children = node.children.map((child, childIndex) =>
renderNode({node: child, index: childIndex, isInline: true, renderNode}),
)
if (span === components.unknownMark) {
handleMissingComponent(unknownMarkWarning(markType), {nodeType: 'mark', type: markType})
}
return span({
text: spanToPlainText(node),
value: markDef,
markType,
markKey,
renderNode,
children: children.join(''),
})
}
function renderBlock(node: PortableTextBlock, index: number, isInline: boolean): string {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const {_key, ...props} = serializeBlock({node, index, isInline, renderNode})
const style = props.node.style || 'normal'
const handler =
typeof components.block === 'function' ? components.block : components.block[style]
const block = handler || components.unknownBlockStyle
if (block === components.unknownBlockStyle) {
handleMissingComponent(unknownBlockStyleWarning(style), {
nodeType: 'blockStyle',
type: style,
})
}
return block({...props, value: props.node, renderNode})
}
function renderText(node: ToolkitTextNode): string {
if (node.text === '\n') {
const hardBreak = components.hardBreak
return hardBreak ? hardBreak() : '\n'
}
return escapeHTML(node.text)
}
function renderCustomBlock(value: TypedObject, index: number, isInline: boolean): string {
const node = components.types[value._type]
if (!node) {
handleMissingComponent(unknownTypeWarning(value._type), {
nodeType: 'block',
type: value._type,
})
}
const component = node || components.unknownType
return component({
value,
isInline,
index,
renderNode,
})
}
return renderNode
}
function serializeBlock(options: Serializable<PortableTextBlock>): SerializedBlock {
const {node, index, isInline, renderNode} = options
const tree = buildMarksTree(node)
const children = tree.map((child, i) =>
renderNode({node: child, isInline: true, index: i, renderNode}),
)
return {
_key: node._key || `block-${index}`,
children: children.join(''),
index,
isInline,
node,
}
}
function noop() {
// Intentional noop
}