2026-05-05 12:28:18 +02:00
|
|
|
import {
|
|
|
|
|
canSerializeMobileEditorTable,
|
|
|
|
|
isMobileEditorTableBlock,
|
|
|
|
|
mobileEditorTableMarkdown,
|
|
|
|
|
} from './mobileEditorTableMarkdown'
|
|
|
|
|
|
2026-05-04 11:01:33 +02:00
|
|
|
type EditorHtmlInput = {
|
|
|
|
|
editorHtml: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type HtmlInput = {
|
|
|
|
|
html: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type ListItemInput = HtmlInput & {
|
|
|
|
|
ordered: boolean
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 10:53:05 +02:00
|
|
|
const supportedHtmlTags = new Set([
|
|
|
|
|
'a',
|
|
|
|
|
'b',
|
|
|
|
|
'blockquote',
|
|
|
|
|
'br',
|
|
|
|
|
'code',
|
|
|
|
|
'del',
|
|
|
|
|
'div',
|
|
|
|
|
'em',
|
|
|
|
|
'h1',
|
|
|
|
|
'h2',
|
|
|
|
|
'h3',
|
|
|
|
|
'h4',
|
|
|
|
|
'h5',
|
|
|
|
|
'h6',
|
|
|
|
|
'i',
|
2026-05-05 12:23:13 +02:00
|
|
|
'img',
|
2026-05-05 10:53:05 +02:00
|
|
|
'input',
|
|
|
|
|
'label',
|
|
|
|
|
'li',
|
|
|
|
|
'ol',
|
|
|
|
|
'p',
|
|
|
|
|
'pre',
|
|
|
|
|
's',
|
|
|
|
|
'strike',
|
|
|
|
|
'strong',
|
2026-05-05 12:28:18 +02:00
|
|
|
'table',
|
|
|
|
|
'tbody',
|
|
|
|
|
'td',
|
|
|
|
|
'th',
|
|
|
|
|
'thead',
|
|
|
|
|
'tr',
|
2026-05-05 10:53:05 +02:00
|
|
|
'ul',
|
|
|
|
|
])
|
|
|
|
|
|
2026-05-04 11:01:33 +02:00
|
|
|
export function serializeSupportedMobileEditorHtml(input: EditorHtmlInput) {
|
|
|
|
|
const html = normalizeBlockSpacing(input)
|
2026-05-05 12:28:18 +02:00
|
|
|
const blocks = html.match(/<(h[1-6]|p|ul|ol|blockquote|pre|table)(?:\s[^>]*)?>[\s\S]*?<\/\1>/gi)
|
2026-05-05 10:53:05 +02:00
|
|
|
if (!blocks) {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!canSerializeBlocks({ blocks, html })) {
|
2026-05-04 11:01:33 +02:00
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return blocks.map((block) => serializeBlock({ html: block })).join('\n\n')
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 10:53:05 +02:00
|
|
|
function canSerializeBlocks({
|
|
|
|
|
blocks,
|
|
|
|
|
html,
|
|
|
|
|
}: {
|
|
|
|
|
blocks: RegExpMatchArray
|
|
|
|
|
html: string
|
|
|
|
|
}) {
|
2026-05-05 12:23:13 +02:00
|
|
|
return blocks.join('') === html && !blocks.some((block) => blocksUnsafeEditorOutput({ html: block }))
|
2026-05-05 10:53:05 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-04 11:01:33 +02:00
|
|
|
function serializeBlock(input: HtmlInput) {
|
|
|
|
|
const headingLevel = headingMarkdownLevel(input)
|
|
|
|
|
if (headingLevel) {
|
|
|
|
|
return `${'#'.repeat(headingLevel)} ${inlineMarkdown(input)}`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isListBlock(input)) {
|
|
|
|
|
return listItemMarkdown(input).join('\n')
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 10:53:05 +02:00
|
|
|
if (isBlockquote(input)) {
|
|
|
|
|
return blockquoteMarkdown(input)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isCodeBlock(input)) {
|
|
|
|
|
return codeBlockMarkdown(input)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 12:28:18 +02:00
|
|
|
if (isMobileEditorTableBlock(input)) {
|
|
|
|
|
return mobileEditorTableMarkdown(input)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-04 11:01:33 +02:00
|
|
|
return inlineMarkdown(input)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeBlockSpacing(input: EditorHtmlInput) {
|
|
|
|
|
return input.editorHtml.trim().replace(/>\s+</g, '><')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function headingMarkdownLevel(input: HtmlInput) {
|
|
|
|
|
const match = input.html.match(/^<h([1-6])/i)
|
|
|
|
|
return match ? Number(match[1]) : null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isListBlock(input: HtmlInput) {
|
|
|
|
|
return input.html.match(/^<(ul|ol)/i)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 10:53:05 +02:00
|
|
|
function isBlockquote(input: HtmlInput) {
|
|
|
|
|
return input.html.match(/^<blockquote/i)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isCodeBlock(input: HtmlInput) {
|
|
|
|
|
return input.html.match(/^<pre/i)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-04 11:01:33 +02:00
|
|
|
function listItemMarkdown(input: HtmlInput) {
|
|
|
|
|
const ordered = input.html.match(/^<ol/i)
|
|
|
|
|
return [...input.html.matchAll(/<li(?:\s[^>]*)?>([\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(/<input[^>]+checked/i)) {
|
|
|
|
|
return '[x]'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (input.html.match(/data-checked=["']false/i) || input.html.match(/<input[^>]+type=["']checkbox/i)) {
|
|
|
|
|
return '[ ]'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 10:53:05 +02:00
|
|
|
function blockquoteMarkdown(input: HtmlInput) {
|
|
|
|
|
const paragraphLines = [...input.html.matchAll(/<p(?:\s[^>]*)?>([\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(/<code(?:\s[^>]*)?>([\s\S]*?)<\/code>/i)?.[1] ?? input.html
|
|
|
|
|
return stripRemainingTags(code.replace(/<br\s*\/?>/gi, '\n'))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-04 11:01:33 +02:00
|
|
|
function inlineMarkdown(input: HtmlInput) {
|
|
|
|
|
return decodeHtmlEntities({ text: stripRemainingTags(markInlineHtml(input)).trim() })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function markInlineHtml(input: HtmlInput) {
|
|
|
|
|
return input.html
|
|
|
|
|
.replace(/<br\s*\/?>/gi, '\n')
|
2026-05-05 12:23:13 +02:00
|
|
|
.replace(/<img\b[^>]*>/gi, (tag) => imageMarkdown({ tag }) ?? tag)
|
2026-05-04 11:01:33 +02:00
|
|
|
.replace(/<(strong|b)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/gi, '**$2**')
|
|
|
|
|
.replace(/<(em|i)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/gi, '*$2*')
|
2026-05-05 10:53:05 +02:00
|
|
|
.replace(/<(s|strike|del)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/gi, '~~$2~~')
|
2026-05-04 11:01:33 +02:00
|
|
|
.replace(/<code(?:\s[^>]*)?>([\s\S]*?)<\/code>/gi, '`$1`')
|
|
|
|
|
.replace(/<a(?:\s[^>]*)?href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, '[$2]($1)')
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 12:23:13 +02:00
|
|
|
function containsUnsupportedTag(input: HtmlInput) {
|
|
|
|
|
return [...input.html.matchAll(/<\/?([a-z0-9-]+)/gi)]
|
2026-05-05 10:53:05 +02:00
|
|
|
.some((match) => !supportedHtmlTags.has(match[1].toLowerCase()))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 12:23:13 +02:00
|
|
|
function blocksUnsafeEditorOutput(input: HtmlInput) {
|
2026-05-05 12:28:18 +02:00
|
|
|
return containsUnsupportedTag(input) || containsUnsafeImage(input) || containsUnsafeTable(input)
|
2026-05-05 12:23:13 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function containsUnsafeImage(input: HtmlInput) {
|
|
|
|
|
return [...input.html.matchAll(/<img\b[^>]*>/gi)].some((match) => !imageMarkdown({ tag: match[0] }))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 12:28:18 +02:00
|
|
|
function containsUnsafeTable(input: HtmlInput) {
|
|
|
|
|
return Boolean(isMobileEditorTableBlock(input)) && !canSerializeMobileEditorTable(input)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 12:23:13 +02:00
|
|
|
function imageMarkdown(input: { tag: string }) {
|
|
|
|
|
const src = imageSource(input)
|
|
|
|
|
if (!src) {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const alt = htmlAttribute({ tag: input.tag, name: 'alt' }) ?? ''
|
|
|
|
|
return `})`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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+.-]*:/)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-04 11:01:33 +02:00
|
|
|
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, '&')
|
|
|
|
|
}
|