import { canSerializeMobileEditorTable, isMobileEditorTableBlock, mobileEditorTableMarkdown, } from './mobileEditorTableMarkdown' type EditorHtmlInput = { editorHtml: string } type HtmlInput = { html: string } type ListItemInput = HtmlInput & { ordered: boolean } const supportedHtmlTags = new Set([ 'a', 'b', 'blockquote', 'br', 'code', 'del', 'div', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'i', 'img', 'input', 'label', 'li', 'ol', 'p', 'pre', 's', 'strike', 'strong', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'ul', ]) export function serializeSupportedMobileEditorHtml(input: EditorHtmlInput) { const html = normalizeBlockSpacing(input) const blocks = html.match(/<(h[1-6]|p|ul|ol|blockquote|pre|table)(?:\s[^>]*)?>[\s\S]*?<\/\1>/gi) if (!blocks) { return null } if (!canSerializeBlocks({ blocks, html })) { return null } return blocks.map((block) => serializeBlock({ html: block })).join('\n\n') } function canSerializeBlocks({ blocks, html, }: { blocks: RegExpMatchArray html: string }) { return blocks.join('') === html && !blocks.some((block) => blocksUnsafeEditorOutput({ html: block })) } function serializeBlock(input: HtmlInput) { const headingLevel = headingMarkdownLevel(input) if (headingLevel) { return `${'#'.repeat(headingLevel)} ${inlineMarkdown(input)}` } if (isListBlock(input)) { return listItemMarkdown(input).join('\n') } if (isBlockquote(input)) { return blockquoteMarkdown(input) } if (isCodeBlock(input)) { return codeBlockMarkdown(input) } if (isMobileEditorTableBlock(input)) { return mobileEditorTableMarkdown(input) } return inlineMarkdown(input) } function normalizeBlockSpacing(input: EditorHtmlInput) { return input.editorHtml.trim().replace(/>\s+<') } function headingMarkdownLevel(input: HtmlInput) { const match = input.html.match(/^]*)?>([\s\S]*?)<\/li>/gi)].map((match) => formatListItem({ ordered: Boolean(ordered), html: match[0] }), ) } function formatListItem(input: ListItemInput) { const taskMarker = markdownTaskMarker(input) const prefix = taskMarker ? `- ${taskMarker}` : input.ordered ? '1.' : '-' return `${prefix} ${inlineMarkdown(input)}` } function markdownTaskMarker(input: HtmlInput) { if (input.html.match(/data-checked=["']true/i) || input.html.match(/]+checked/i)) { return '[x]' } if (input.html.match(/data-checked=["']false/i) || input.html.match(/]+type=["']checkbox/i)) { return '[ ]' } return null } function blockquoteMarkdown(input: HtmlInput) { const paragraphLines = [...input.html.matchAll(/]*)?>([\s\S]*?)<\/p>/gi)].map((match) => inlineMarkdown({ html: match[0] }), ) const lines = paragraphLines.length > 0 ? paragraphLines : [inlineMarkdown(input)] return lines.map((line) => `> ${line}`).join('\n') } function codeBlockMarkdown(input: HtmlInput) { return [ `\`\`\`${codeBlockLanguage(input)}`, decodeHtmlEntities({ text: codeBlockText(input) }).trimEnd(), '```', ].join('\n') } function codeBlockLanguage(input: HtmlInput) { return input.html.match(/language-([A-Za-z0-9_-]+)/)?.[1] ?? '' } function codeBlockText(input: HtmlInput) { const code = input.html.match(/]*)?>([\s\S]*?)<\/code>/i)?.[1] ?? input.html return stripRemainingTags(code.replace(//gi, '\n')) } function inlineMarkdown(input: HtmlInput) { return decodeHtmlEntities({ text: stripRemainingTags(markInlineHtml(input)).trim() }) } function markInlineHtml(input: HtmlInput) { return input.html .replace(//gi, '\n') .replace(/]*>/gi, (tag) => imageMarkdown({ tag }) ?? tag) .replace(/<(strong|b)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/gi, '**$2**') .replace(/<(em|i)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/gi, '*$2*') .replace(/<(s|strike|del)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/gi, '~~$2~~') .replace(/]*)?>([\s\S]*?)<\/code>/gi, '`$1`') .replace(/]*)?href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, '[$2]($1)') } function containsUnsupportedTag(input: HtmlInput) { return [...input.html.matchAll(/<\/?([a-z0-9-]+)/gi)] .some((match) => !supportedHtmlTags.has(match[1].toLowerCase())) } function blocksUnsafeEditorOutput(input: HtmlInput) { return containsUnsupportedTag(input) || containsUnsafeImage(input) || containsUnsafeTable(input) } function containsUnsafeImage(input: HtmlInput) { return [...input.html.matchAll(/]*>/gi)].some((match) => !imageMarkdown({ tag: match[0] })) } function containsUnsafeTable(input: HtmlInput) { return Boolean(isMobileEditorTableBlock(input)) && !canSerializeMobileEditorTable(input) } function imageMarkdown(input: { tag: string }) { const src = imageSource(input) if (!src) { return null } const alt = htmlAttribute({ tag: input.tag, name: 'alt' }) ?? '' return `![${decodeHtmlEntities({ text: alt })}](${decodeHtmlEntities({ text: src })})` } function imageSource(input: { tag: string }) { const src = htmlAttribute({ tag: input.tag, name: 'src' }) return src && isPersistableImageSource({ src }) ? src : null } function htmlAttribute(input: { tag: string; name: string }) { const match = input.tag.match(new RegExp(`${input.name}=["']([^"']+)["']`, 'i')) return match?.[1] ?? null } function isPersistableImageSource(input: { src: string }) { if (input.src.match(/[\n\r]/)) { return false } return isRemoteImageSource(input) || isRelativeImageSource(input) } function isRemoteImageSource(input: { src: string }) { return input.src.startsWith('https://') || input.src.startsWith('http://') } function isRelativeImageSource(input: { src: string }) { return !input.src.startsWith('/') && !input.src.startsWith('//') && !input.src.match(/^[A-Za-z][A-Za-z0-9+.-]*:/) } function stripRemainingTags(value: string) { return value.replace(/<[^>]+>/g, '') } function decodeHtmlEntities(input: { text: string }) { return input.text .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/&/g, '&') }