From 6560d7c27757e7ced2bf98ac7bc72f7a360ef6a8 Mon Sep 17 00:00:00 2001 From: arjunpredev <141591528+arjunpredev@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:40:17 -0400 Subject: [PATCH] Keep paragraph text in saved Markdown and in the newsletter text part The image-card extension holds the paragraph Markdown token, so @tiptap/markdown also asked it how to write paragraphs. It had no writer, so every paragraph (and every list item and table cell) saved as an empty string in the post's Markdown copy. The newsletter's text/plain part is built from that copy, so posts saved from the editor mailed a text part with headings and image alt text only. - MarkdownImages writes paragraphs the way the paragraph extension does. - The newsletter text part falls back to the HTML body when the Markdown copy holds clearly fewer words, so posts saved before this fix send their full text. - plainBody writes HTML lists and tables as one line per item and per row. Co-Authored-By: Claude Opus 5.5 --- packages/admin/src/editor/nodes.ts | 9 +++++++++ packages/server/src/email.ts | 22 ++++++++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/admin/src/editor/nodes.ts b/packages/admin/src/editor/nodes.ts index 061863b..8615d2f 100644 --- a/packages/admin/src/editor/nodes.ts +++ b/packages/admin/src/editor/nodes.ts @@ -211,6 +211,15 @@ export const MarkdownImages = Extension.create({ } flush(); return out; + }, + // Holding the paragraph token also makes this the paragraph's writer when saving Markdown, so it + // writes paragraphs as the paragraph extension does; without this every paragraph saved empty. + renderMarkdown: (node: any, h: any, ctx: any) => { + const content = Array.isArray(node?.content) ? node.content : []; + if (content.length) return h.renderChildren(content); + // A second empty paragraph in a row keeps the gap. + const prev = ctx?.previousNode; + return prev?.type === 'paragraph' && !(Array.isArray(prev.content) && prev.content.length) ? ' ' : ''; } }); diff --git a/packages/server/src/email.ts b/packages/server/src/email.ts index 5e73b6f..7c7fc7e 100644 --- a/packages/server/src/email.ts +++ b/packages/server/src/email.ts @@ -203,7 +203,13 @@ function plainBody(markdown: string): string { }) .replace(/]*src="([^"]+)"[^>]*>[\s\S]*?<\/iframe>/gi, '$1') .replace(/]*alt="([^"]*)"[^>]*>/gi, (_m, alt: string) => (alt ? `[${alt}]` : '')) - .replace(/<\/(p|div|figure|figcaption|h\d|li|blockquote)>/gi, '\n') + // Lists and tables from HTML bodies: a line per item and per row. + .replace(/<\/p>\s*(?=<\/(li|td|th)>)/gi, '') + .replace(/]*>/gi, '- ') + .replace(/<\/t[dh]>\s*(?=<\/tr>)/gi, '') + .replace(/<\/t[dh]>/gi, ' | ') + .replace(/|<\/(tr|li)>/gi, '\n') + .replace(/<\/(p|div|figure|figcaption|h\d|blockquote|pre|ul|ol|table)>/gi, '\n\n') .replace(/<[^>]+>/g, '') .replace(/ /g, ' ') .replace(/&/g, '&') @@ -216,6 +222,18 @@ function plainBody(markdown: string): string { .trim(); } +const wordCount = (text: string) => text.match(/[\p{L}\p{N}]+/gu)?.length ?? 0; + +/** + * The text part's body. The Markdown copy reads best, but copies saved by editors before the + * paragraph fix lost their paragraphs; when it holds clearly fewer words than the HTML, use the HTML. + */ +function textPart(markdown: string, html: string): string { + const fromMarkdown = plainBody(markdown); + const fromHtml = plainBody(html); + return wordCount(fromMarkdown) >= wordCount(fromHtml) * 0.8 ? fromMarkdown : fromHtml; +} + /** The newsletter for a post. The unsubscribe placeholder is swapped per recipient. */ export function newsletterEmail(o: { site: SiteSettings; @@ -271,7 +289,7 @@ export function newsletterEmail(o: { }); const byNames = o.authors.map(a => a.name).join(', '); - const text = `${post.title}\n${[byNames, date].filter(Boolean).join(' · ')}\n\n${plainBody(o.markdown)}\n\nRead on the web: ${o.postUrl}\n\n--\n${o.site.title}\nUnsubscribe: ${UNSUBSCRIBE_PLACEHOLDER}${o.postalAddress ? `\n${o.postalAddress}` : ''}\n`; + const text = `${post.title}\n${[byNames, date].filter(Boolean).join(' · ')}\n\n${textPart(o.markdown, o.body)}\n\nRead on the web: ${o.postUrl}\n\n--\n${o.site.title}\nUnsubscribe: ${UNSUBSCRIBE_PLACEHOLDER}${o.postalAddress ? `\n${o.postalAddress}` : ''}\n`; return { subject: post.title, html, text }; }