import { BlockType, defaultNodeTypes, LeafType, NodeTypes } from './ast-types' import escapeHtml from 'escape-html' interface Options { nodeTypes: NodeTypes flavour?: 'common' | 'whatsapp' listDepth?: number ignoreParagraphNewline?: boolean } const isLeafNode = (node: BlockType | LeafType): node is LeafType => { return typeof (node as LeafType).text === 'string' } const VOID_ELEMENTS: Array = ['thematic_break', 'image'] const BREAK_TAG = '
' export default function serialize( chunk: BlockType | LeafType, opts: Options = { nodeTypes: defaultNodeTypes, } ): string | undefined { const { nodeTypes: userNodeTypes = defaultNodeTypes, ignoreParagraphNewline = false, listDepth = -1, } = opts let text = (chunk as LeafType).text || '' let type = (chunk as BlockType).type || '' const nodeTypes: NodeTypes = { ...defaultNodeTypes, ...userNodeTypes, heading: { ...defaultNodeTypes.heading, ...userNodeTypes.heading, }, } if ('type' in chunk && chunk.type === nodeTypes['inline-variable']) return chunk.children .map((child) => serialize( { ...child, parentType: nodeTypes['inline-variable'], }, opts ) ) .join('') const LIST_TYPES = [nodeTypes.ul_list, nodeTypes.ol_list] let children = text if (!isLeafNode(chunk)) { children = chunk.children .map((c: BlockType | LeafType, index) => { const isList = !isLeafNode(c) ? (LIST_TYPES as string[]).includes(c.type || '') : false const selfIsList = (LIST_TYPES as string[]).includes(chunk.type || '') // Links can have the following shape // In which case we don't want to surround // with break tags // { // type: 'paragraph', // children: [ // { text: '' }, // { type: 'link', children: [{ text: foo.com }]} // { text: '' } // ] // } let childrenHasLink = false if (!isLeafNode(chunk) && Array.isArray(chunk.children)) { childrenHasLink = chunk.children.some( (f) => !isLeafNode(f) && f.type === nodeTypes.link ) } return serialize( { ...c, parentType: type === nodeTypes.listItem ? chunk.parentType : type, listIndex: type === nodeTypes.listItem ? chunk.listIndex : index + 1, }, { flavour: opts.flavour, nodeTypes, // WOAH. // what we're doing here is pretty tricky, it relates to the block below where // we check for ignoreParagraphNewline and set type to paragraph. // We want to strip out empty paragraphs sometimes, but other times we don't. // If we're the descendant of a list, we know we don't want a bunch // of whitespace. If we're parallel to a link we also don't want // to respect neighboring paragraphs ignoreParagraphNewline: (ignoreParagraphNewline || isList || selfIsList || childrenHasLink) && // if we have c.break, never ignore empty paragraph new line !(c as BlockType).break, // track depth of nested lists so we can add proper spacing listDepth: (LIST_TYPES as string[]).includes( (c as BlockType).type || '' ) ? listDepth + 1 : listDepth, } ) }) .join('') } // This is pretty fragile code, check the long comment where we iterate over children if ( !ignoreParagraphNewline && (text === '' || text === '\n') && chunk.parentType === nodeTypes.paragraph ) { type = nodeTypes.paragraph children = BREAK_TAG } if (children === '' && !VOID_ELEMENTS.find((k) => nodeTypes[k] === type)) return // Never allow decorating break tags with rich text formatting, // this can malform generated markdown // Also ensure we're only ever applying text formatting to leaf node // level chunks, otherwise we can end up in a situation where // we try applying formatting like to a node like this: // "Text foo bar **baz**" resulting in "**Text foo bar **baz****" // which is invalid markup and can mess everything up if (children !== BREAK_TAG && isLeafNode(chunk)) { if (chunk.strikeThrough && chunk.bold && chunk.italic) { if (opts.flavour === 'whatsapp') { children = retainWhitespaceAndFormat(children, '*_~') } else { children = retainWhitespaceAndFormat(children, '~~***') } } else if (chunk.bold && chunk.italic) { if (opts.flavour === 'whatsapp') { children = retainWhitespaceAndFormat(children, '*_') } else { children = retainWhitespaceAndFormat(children, '***') } } else { if (chunk.bold) { if (opts.flavour === 'whatsapp') { children = retainWhitespaceAndFormat(children, '*') } else { children = retainWhitespaceAndFormat(children, '**') } } if (chunk.italic) { children = retainWhitespaceAndFormat(children, '_') } if (chunk.strikeThrough) { children = retainWhitespaceAndFormat(children, '~~') } if (chunk.code) { if (opts.flavour === 'whatsapp') { children = retainWhitespaceAndFormat(children, '```') } else { children = retainWhitespaceAndFormat(children, '`') } } } } if (chunk.parentType === nodeTypes['inline-variable']) { if (opts.flavour === 'whatsapp') { return children } return escapeHtml(children) } switch (type) { case nodeTypes.heading[1]: return `# ${children}\n` case nodeTypes.heading[2]: return `## ${children}\n` case nodeTypes.heading[3]: return `### ${children}\n` case nodeTypes.heading[4]: return `#### ${children}\n` case nodeTypes.heading[5]: return `##### ${children}\n` case nodeTypes.heading[6]: return `###### ${children}\n` case nodeTypes.blockquote: return `> ${children}\n` case nodeTypes.code_block: return `\`\`\`${ (chunk as BlockType).language || '' }\n${children}\n\`\`\`\n` case nodeTypes.link: return `[${children}](${(chunk as any).url || ''})` case nodeTypes.image: return `![${(chunk as BlockType).caption}](${ (chunk as BlockType).link || '' })` case nodeTypes.listItemChild: const isOL = chunk && chunk.parentType === nodeTypes.ol_list const listIndex = 'listIndex' in chunk ? chunk.listIndex : undefined const treatAsLeaf = (chunk as BlockType).children.length === 1 && isLeafNode((chunk as BlockType).children[0]) let spacer = '' for (let k = 0; listDepth > k; k++) { if (isOL) { // https://github.com/remarkjs/remark-react/issues/65 spacer += ' ' } else { spacer += ' ' } } return `${spacer}${isOL ? `${listIndex}.` : '-'} ${children}${ treatAsLeaf ? '\n' : '' }` case nodeTypes.paragraph: return `${children}\n` case nodeTypes.thematic_break: return `---\n` default: { if (opts.flavour === 'whatsapp') { return children } return escapeHtml(children) } } } // This function handles the case of a string like this: " foo " // Where it would be invalid markdown to generate this: "** foo **" // We instead, want to trim the whitespace out, apply formatting, and then // bring the whitespace back. So our returned string looks like this: " **foo** " function retainWhitespaceAndFormat(string: string, format: string) { // we keep this for a comparison later const frozenString = string.trim() // children will be mutated let children = frozenString // We reverse the right side formatting, to properly handle bold/italic and strikeThrough // formats, so we can create ~~***FooBar***~~ const fullFormat = `${format}${children}${reverseStr(format)}` // This conditions accounts for no whitespace in our string // if we don't have any, we can return early. if (children.length === string.length) { return fullFormat } // if we do have whitespace, let's add our formatting around our trimmed string // We reverse the right side formatting, to properly handle bold/italic and strikeThrough // formats, so we can create ~~***FooBar***~~ const formattedString = format + children + reverseStr(format) // and replace the non-whitespace content of the string return string.replace(frozenString, formattedString) } const reverseStr = (string: string) => string.split('').reverse().join('')