UNPKG

@portabletext/block-tools

Version:

Can format HTML, Slate JSON or Sanity block array into any other format.

259 lines (245 loc) 7.33 kB
import type {ArraySchemaType, TypedObject} from '@sanity/types' import { DEFAULT_BLOCK, DEFAULT_SPAN, HTML_BLOCK_TAGS, HTML_DECORATOR_TAGS, HTML_HEADER_TAGS, HTML_LIST_CONTAINER_TAGS, HTML_LIST_ITEM_TAGS, HTML_SPAN_TAGS, type PartialBlock, } from '../../constants' import type {BlockEnabledFeatures, DeserializerRule} from '../../types' import {keyGenerator} from '../../util/randomKey' import {isElement, tagName} from '../helpers' import {whitespaceTextNodeRule} from './whitespace-text-node' export function resolveListItem( listNodeTagName: string, enabledListTypes: string[], ): string | undefined { if (listNodeTagName === 'ul' && enabledListTypes.includes('bullet')) { return 'bullet' } if (listNodeTagName === 'ol' && enabledListTypes.includes('number')) { return 'number' } return undefined } export default function createHTMLRules( _blockContentType: ArraySchemaType, options: BlockEnabledFeatures & {keyGenerator?: () => string}, ): DeserializerRule[] { return [ whitespaceTextNodeRule, { // Pre element deserialize(el) { if (tagName(el) !== 'pre') { return undefined } const isCodeEnabled = options.enabledBlockStyles.includes('code') return { _type: 'block', style: 'normal', markDefs: [], children: [ { ...DEFAULT_SPAN, marks: isCodeEnabled ? ['code'] : [], text: el.textContent || '', }, ], } }, }, // Blockquote element { deserialize(el, next) { if (tagName(el) !== 'blockquote') { return undefined } const blocks: Record<string, PartialBlock | undefined> = { ...HTML_BLOCK_TAGS, ...HTML_HEADER_TAGS, } delete blocks.blockquote const nonBlockquoteBlocks = Object.keys(blocks) const children: HTMLElement[] = [] el.childNodes.forEach((node, index) => { if (!el.ownerDocument) { return } if ( node.nodeType === 1 && nonBlockquoteBlocks.includes( (node as Element).localName.toLowerCase(), ) ) { const span = el.ownerDocument.createElement('span') const previousChild = children[children.length - 1] if ( previousChild && previousChild.nodeType === 3 && previousChild.textContent?.trim() ) { // Only prepend line break if the previous node is a non-empty // text node. span.appendChild(el.ownerDocument.createTextNode('\r')) } node.childNodes.forEach((cn) => { span.appendChild(cn.cloneNode(true)) }) if (index !== el.childNodes.length) { // Only append line break if this is not the last child span.appendChild(el.ownerDocument.createTextNode('\r')) } children.push(span) } else { children.push(node as HTMLElement) } }) return { _type: 'block', style: 'blockquote', markDefs: [], children: next(children), } }, }, // Block elements { deserialize(el, next) { const blocks: Record<string, PartialBlock | undefined> = { ...HTML_BLOCK_TAGS, ...HTML_HEADER_TAGS, } const tag = tagName(el) let block = tag ? blocks[tag] : undefined if (!block) { return undefined } // Don't add blocks into list items if (el.parentNode && tagName(el.parentNode) === 'li') { return next(el.childNodes) } // If style is not supported, return a defaultBlockType if (!options.enabledBlockStyles.includes(block.style)) { block = DEFAULT_BLOCK } return { ...block, children: next(el.childNodes), } }, }, // Ignore span tags { deserialize(el, next) { const tag = tagName(el) if (!tag || !(tag in HTML_SPAN_TAGS)) { return undefined } return next(el.childNodes) }, }, // Ignore div tags { deserialize(el, next) { const div = tagName(el) === 'div' if (!div) { return undefined } return next(el.childNodes) }, }, // Ignore list containers { deserialize(el, next) { const tag = tagName(el) if (!tag || !(tag in HTML_LIST_CONTAINER_TAGS)) { return undefined } return next(el.childNodes) }, }, // Deal with br's { deserialize(el) { if (tagName(el) === 'br') { return { ...DEFAULT_SPAN, text: '\n', } } return undefined }, }, // Deal with list items { deserialize(el, next, block) { const tag = tagName(el) const listItem = tag ? HTML_LIST_ITEM_TAGS[tag] : undefined const parentTag = tagName(el.parentNode) || '' if ( !listItem || !el.parentNode || !HTML_LIST_CONTAINER_TAGS[parentTag] ) { return undefined } const enabledListItem = resolveListItem( parentTag, options.enabledListTypes, ) // If the list item style is not supported, return a new default block if (!enabledListItem) { return block({_type: 'block', children: next(el.childNodes)}) } listItem.listItem = enabledListItem return { ...listItem, children: next(el.childNodes), } }, }, // Deal with decorators - this is a limited set of known html elements that we know how to deserialize { deserialize(el, next) { const decorator = HTML_DECORATOR_TAGS[tagName(el) || ''] if (!decorator || !options.enabledSpanDecorators.includes(decorator)) { return undefined } return { _type: '__decorator', name: decorator, children: next(el.childNodes), } }, }, // Special case for hyperlinks, add annotation (if allowed by schema), // If not supported just write out the link text and href in plain text. { deserialize(el, next) { if (tagName(el) !== 'a') { return undefined } const linkEnabled = options.enabledBlockAnnotations.includes('link') const href = isElement(el) && el.getAttribute('href') if (!href) { return next(el.childNodes) } let markDef: TypedObject | undefined if (linkEnabled) { markDef = { _key: options.keyGenerator ? options.keyGenerator() : keyGenerator(), _type: 'link', href: href, } return { _type: '__annotation', markDef: markDef, children: next(el.childNodes), } } return ( el.appendChild(el.ownerDocument.createTextNode(` (${href})`)) && next(el.childNodes) ) }, }, ] }