Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/admin/src/editor/nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ? ' ' : '';
}
});

Expand Down
22 changes: 20 additions & 2 deletions packages/server/src/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,13 @@ function plainBody(markdown: string): string {
})
.replace(/<iframe\b[^>]*src="([^"]+)"[^>]*>[\s\S]*?<\/iframe>/gi, '$1')
.replace(/<img\b[^>]*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(/<li\b[^>]*>/gi, '- ')
.replace(/<\/t[dh]>\s*(?=<\/tr>)/gi, '')
.replace(/<\/t[dh]>/gi, ' | ')
.replace(/<br\s*\/?>|<\/(tr|li)>/gi, '\n')
.replace(/<\/(p|div|figure|figcaption|h\d|blockquote|pre|ul|ol|table)>/gi, '\n\n')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
Expand All @@ -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;
Expand Down Expand Up @@ -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 };
}

Expand Down
Loading