// Serialize the constrained contenteditable composer DOM to the contact // message Markdown subset (emphasis, links, lists, code, /_f/ images and // file chips). The wire format is text: the server re-renders it with a // strict, html-less MarkdownIt (pagerite/messages.py), so no user markup // of any kind ever reaches a page. This serializer is for fidelity, not // safety — anything it does not know is simply unwrapped. const ESCAPE_RE = /([\\`*_[\]])/g export function escapeMd(text) { return text.replace(ESCAPE_RE, '\\$1') } // Tags that open a new block at the composer's top level. Everything else // there — text, B/I/A/SPAN formatting elements, inline images — is an // inline run and joins the paragraph being accumulated: browsers happily // put formatting directly under the editable root (a bolded segment of an // undivided first line is a top-level ), and treating those as blocks // would split the paragraph and drop the formatting. const BLOCK_TAGS = new Set([ 'DIV', 'P', 'UL', 'OL', 'BLOCKQUOTE', 'PRE', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HR', 'TABLE', ]) // One message's Markdown from the composer root: inline runs become // paragraphs, block elements become their own blocks, blank lines between. export function serializeRich(root) { const blocks = [] let run = [] const flush = () => { const text = run.join('').trim() run = [] if (text) blocks.push(text) } for (const node of root.childNodes) { if (node.nodeType === Node.ELEMENT_NODE && BLOCK_TAGS.has(node.tagName)) { flush() block(node, blocks) } else { run.push(inline(node)) } } flush() return blocks.join('\n\n').replace(/\n{3,}/g, '\n\n').trim() } function block(node, out) { switch (node.tagName) { case 'UL': out.push([...node.children].map((li) => `- ${inlineChildren(li).trim()}`).join('\n')) break case 'OL': out.push([...node.children].map((li, i) => `${i + 1}. ${inlineChildren(li).trim()}`).join('\n')) break case 'BLOCKQUOTE': out.push(inlineChildren(node).split('\n').map((l) => `> ${l}`).join('\n')) break case 'PRE': out.push('```\n' + node.textContent + '\n```') break case 'HR': break default: { // DIV, P, headings: a paragraph of inline content. const text = inlineChildren(node).trim() if (text) out.push(text) } } } function inlineChildren(node) { return [...node.childNodes].map(inline).join('') } function inline(node) { if (node.nodeType === Node.TEXT_NODE) return escapeMd(node.textContent) if (node.nodeType !== Node.ELEMENT_NODE) return '' switch (node.tagName) { case 'B': case 'STRONG': return `**${inlineChildren(node)}**` case 'I': case 'EM': return `*${inlineChildren(node)}*` case 'CODE': return '`' + node.textContent.replace(/\\/g, '\\\\').replace(/`/g, '\\`') + '`' case 'BR': return '\n' case 'A': { const text = inlineChildren(node) const href = node.getAttribute('href') || '' return /^(https?:\/\/|mailto:|\/)/.test(href) ? `[${text}](${href})` : text } case 'IMG': // Uploaded image (data-path set by the composer on insert). return node.dataset.path ? `![${escapeMd(node.alt || 'image')}](${node.dataset.path})` : '' case 'SPAN': // Attachment chip (data-path + data-name); other spans unwrap. if (node.dataset.path && node.dataset.name) { return `[📎 ${escapeMd(node.dataset.name)}](${node.dataset.path})` } return wrapStyle(node, inlineChildren(node)) default: return wrapStyle(node, inlineChildren(node)) } } // Some browsers style formatting instead of tagging it (Firefox's // execCommand produces ): keep span-level // bold/italic alive into the Markdown. function wrapStyle(node, text) { if (!text.trim()) return text const s = node.style if (s) { if (/bold|bolder|[6-9]00/.test(s.fontWeight)) text = `**${text}**` if (s.fontStyle === 'italic' || s.fontStyle === 'oblique') text = `*${text}*` } return text }