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 }; }