fix: preserve line breaks in generated text/plain alternative #421
This commit is contained in:
@@ -50,11 +50,15 @@ import {
|
|||||||
} from "@/lib/email-composer-utils";
|
} from "@/lib/email-composer-utils";
|
||||||
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
||||||
import type { Editor } from "@tiptap/react";
|
import type { Editor } from "@tiptap/react";
|
||||||
|
import { htmlToPlainText as htmlToPlainTextShared } from "@/lib/html-to-text";
|
||||||
|
|
||||||
/** Strip HTML tags and decode entities to get a plain-text version */
|
/**
|
||||||
|
* Derives the text/plain alternative from the composer's HTML body, preserving
|
||||||
|
* line structure from block elements and <br> tags. Paragraph spacing is on so
|
||||||
|
* <p> blocks are separated by a blank line, matching their visual rendering (#421).
|
||||||
|
*/
|
||||||
function htmlToPlainText(html: string): string {
|
function htmlToPlainText(html: string): string {
|
||||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
return htmlToPlainTextShared(html, { paragraphSpacing: true });
|
||||||
return doc.body.textContent || '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { htmlToPlainText } from '../html-to-text';
|
||||||
|
|
||||||
|
describe('htmlToPlainText', () => {
|
||||||
|
it('preserves line breaks from <br> within a paragraph', () => {
|
||||||
|
expect(htmlToPlainText('<p>Line 1.<br>Line 2.</p>')).toBe('Line 1.\nLine 2.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('separates block elements with newlines instead of running them together (#421)', () => {
|
||||||
|
// The previous textContent-based implementation produced "Line 1.Line 2.Paragraph 2."
|
||||||
|
expect(htmlToPlainText('<div>Line 1.</div><div>Line 2.</div><div>Paragraph 2.</div>')).toBe(
|
||||||
|
'Line 1.\nLine 2.\nParagraph 2.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with paragraphSpacing, separates paragraphs with a blank line', () => {
|
||||||
|
expect(
|
||||||
|
htmlToPlainText('<p>Line 1.<br>Line 2.</p><p>Paragraph 2.</p>', { paragraphSpacing: true })
|
||||||
|
).toBe('Line 1.\nLine 2.\n\nParagraph 2.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('without paragraphSpacing, separates paragraphs with a single newline', () => {
|
||||||
|
expect(htmlToPlainText('<p>A</p><p>B</p>')).toBe('A\nB');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders links as text when the text equals the href', () => {
|
||||||
|
expect(
|
||||||
|
htmlToPlainText('<p><a href="mailto:alice@example.com">alice@example.com</a></p>')
|
||||||
|
).toBe('alice@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders links as "text <href>" when they differ', () => {
|
||||||
|
expect(htmlToPlainText('<p>Visit <a href="https://example.com">our site</a></p>')).toBe(
|
||||||
|
'Visit our site <https://example.com>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses excess whitespace and trims the result', () => {
|
||||||
|
expect(htmlToPlainText(' <p> Hello world </p> ')).toBe('Hello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps consecutive blank lines at one', () => {
|
||||||
|
expect(htmlToPlainText('<p>A</p><br><br><br><p>B</p>', { paragraphSpacing: true })).toBe(
|
||||||
|
'A\n\nB'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty string for empty or whitespace-only HTML', () => {
|
||||||
|
expect(htmlToPlainText('')).toBe('');
|
||||||
|
expect(htmlToPlainText(' <p> </p> ')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles nested lists as separate lines', () => {
|
||||||
|
expect(htmlToPlainText('<ul><li>One</li><li>Two</li></ul>')).toBe('One\nTwo');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { parseHtmlSafely } from '@/lib/email-sanitization';
|
||||||
|
|
||||||
|
// Block-level tags whose boundaries become line breaks in the plain-text
|
||||||
|
// rendering. Without this, the DOM's textContent would run every block
|
||||||
|
// together on a single line (#421).
|
||||||
|
const BLOCK_TAGS = new Set([
|
||||||
|
'address',
|
||||||
|
'article',
|
||||||
|
'aside',
|
||||||
|
'blockquote',
|
||||||
|
'div',
|
||||||
|
'dd',
|
||||||
|
'dl',
|
||||||
|
'dt',
|
||||||
|
'footer',
|
||||||
|
'h1',
|
||||||
|
'h2',
|
||||||
|
'h3',
|
||||||
|
'h4',
|
||||||
|
'h5',
|
||||||
|
'h6',
|
||||||
|
'header',
|
||||||
|
'li',
|
||||||
|
'nav',
|
||||||
|
'ol',
|
||||||
|
'p',
|
||||||
|
'pre',
|
||||||
|
'section',
|
||||||
|
'table',
|
||||||
|
'tr',
|
||||||
|
'ul',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Paragraph-level blocks that, with `paragraphSpacing` enabled, are separated by
|
||||||
|
// a blank line rather than a single newline - matching how they render visually.
|
||||||
|
const PARAGRAPH_TAGS = new Set([
|
||||||
|
'blockquote',
|
||||||
|
'h1',
|
||||||
|
'h2',
|
||||||
|
'h3',
|
||||||
|
'h4',
|
||||||
|
'h5',
|
||||||
|
'h6',
|
||||||
|
'ol',
|
||||||
|
'p',
|
||||||
|
'pre',
|
||||||
|
'table',
|
||||||
|
'ul',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export interface HtmlToPlainTextOptions {
|
||||||
|
/**
|
||||||
|
* Separate paragraph-level blocks (<p>, headings, lists, ...) with a blank
|
||||||
|
* line instead of a single newline. Use for email bodies where paragraphs
|
||||||
|
* are visually spaced; leave off for compact output like signatures.
|
||||||
|
*/
|
||||||
|
paragraphSpacing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLineBreaks(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/\r\n?/g, '\n')
|
||||||
|
.replace(new RegExp(String.fromCharCode(160), 'g'), ' ')
|
||||||
|
.replace(/[ \t]+\n/g, '\n')
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts HTML into readable plain text, preserving line structure from block
|
||||||
|
* elements and <br> tags. Links render as `text <href>` unless the text already
|
||||||
|
* is the href. Used to derive the text/plain alternative of an HTML email.
|
||||||
|
*/
|
||||||
|
export function htmlToPlainText(html: string, options: HtmlToPlainTextOptions = {}): string {
|
||||||
|
const { paragraphSpacing = false } = options;
|
||||||
|
const document = parseHtmlSafely(html);
|
||||||
|
const chunks: string[] = [];
|
||||||
|
|
||||||
|
const appendText = (value: string) => {
|
||||||
|
if (!value) return;
|
||||||
|
const normalized = value.replace(/\s+/g, ' ');
|
||||||
|
if (!normalized.trim()) return;
|
||||||
|
const previous = chunks[chunks.length - 1];
|
||||||
|
if (previous && !previous.endsWith('\n') && !previous.endsWith(' ')) {
|
||||||
|
chunks.push(' ');
|
||||||
|
}
|
||||||
|
chunks.push(normalized);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Number of newline characters already trailing the accumulated output.
|
||||||
|
const trailingNewlines = (): number => {
|
||||||
|
let count = 0;
|
||||||
|
for (let i = chunks.length - 1; i >= 0; i--) {
|
||||||
|
const chunk = chunks[i];
|
||||||
|
let j = chunk.length - 1;
|
||||||
|
let inner = 0;
|
||||||
|
while (j >= 0 && chunk[j] === '\n') {
|
||||||
|
inner++;
|
||||||
|
j--;
|
||||||
|
}
|
||||||
|
count += inner;
|
||||||
|
if (j >= 0) break; // chunk had non-newline content, stop counting
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensures at least `min` trailing newlines, without emitting leading ones.
|
||||||
|
const ensureNewlines = (min: number) => {
|
||||||
|
if (chunks.length === 0) return;
|
||||||
|
for (let current = trailingNewlines(); current < min; current++) {
|
||||||
|
chunks.push('\n');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const walk = (node: Node) => {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
appendText(node.textContent || '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = node as HTMLElement;
|
||||||
|
const tagName = element.tagName.toLowerCase();
|
||||||
|
|
||||||
|
if (tagName === 'br') {
|
||||||
|
// Additive so consecutive <br> can form a blank line (capped by normalize).
|
||||||
|
if (chunks.length > 0) chunks.push('\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tagName === 'a') {
|
||||||
|
const text = element.textContent?.replace(/\s+/g, ' ').trim() || '';
|
||||||
|
const href = element.getAttribute('href')?.trim() || '';
|
||||||
|
const normalizedHref = href.replace(/^mailto:/i, '');
|
||||||
|
if (text && normalizedHref && text === normalizedHref) {
|
||||||
|
appendText(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (text && href && text !== href) {
|
||||||
|
appendText(`${text} <${href}>`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const separator = paragraphSpacing && PARAGRAPH_TAGS.has(tagName) ? 2 : 1;
|
||||||
|
const isBlock = BLOCK_TAGS.has(tagName);
|
||||||
|
|
||||||
|
if (isBlock) {
|
||||||
|
ensureNewlines(separator);
|
||||||
|
}
|
||||||
|
|
||||||
|
Array.from(element.childNodes).forEach(walk);
|
||||||
|
|
||||||
|
if (isBlock) {
|
||||||
|
ensureNewlines(separator);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Array.from(document.body.childNodes).forEach(walk);
|
||||||
|
return normalizeLineBreaks(chunks.join(''));
|
||||||
|
}
|
||||||
+1
-84
@@ -1,25 +1,11 @@
|
|||||||
import { parseHtmlSafely, sanitizeSignatureHtml } from '@/lib/email-sanitization';
|
import { parseHtmlSafely, sanitizeSignatureHtml } from '@/lib/email-sanitization';
|
||||||
|
import { htmlToPlainText } from '@/lib/html-to-text';
|
||||||
|
|
||||||
type SignatureSource = {
|
type SignatureSource = {
|
||||||
textSignature?: string;
|
textSignature?: string;
|
||||||
htmlSignature?: string;
|
htmlSignature?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BLOCK_TAGS = new Set([
|
|
||||||
'address',
|
|
||||||
'article',
|
|
||||||
'aside',
|
|
||||||
'blockquote',
|
|
||||||
'div',
|
|
||||||
'footer',
|
|
||||||
'header',
|
|
||||||
'li',
|
|
||||||
'nav',
|
|
||||||
'p',
|
|
||||||
'section',
|
|
||||||
'tr',
|
|
||||||
]);
|
|
||||||
|
|
||||||
function normalizeSignatureLineBreaks(value: string): string {
|
function normalizeSignatureLineBreaks(value: string): string {
|
||||||
return value
|
return value
|
||||||
.replace(/\r\n?/g, '\n')
|
.replace(/\r\n?/g, '\n')
|
||||||
@@ -29,75 +15,6 @@ function normalizeSignatureLineBreaks(value: string): string {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function htmlToPlainText(html: string): string {
|
|
||||||
const document = parseHtmlSafely(html);
|
|
||||||
const chunks: string[] = [];
|
|
||||||
|
|
||||||
const appendText = (value: string) => {
|
|
||||||
if (!value) return;
|
|
||||||
const normalized = value.replace(/\s+/g, ' ');
|
|
||||||
if (!normalized.trim()) return;
|
|
||||||
const previous = chunks[chunks.length - 1];
|
|
||||||
if (previous && !previous.endsWith('\n') && !previous.endsWith(' ')) {
|
|
||||||
chunks.push(' ');
|
|
||||||
}
|
|
||||||
chunks.push(normalized);
|
|
||||||
};
|
|
||||||
|
|
||||||
const appendNewline = () => {
|
|
||||||
const previous = chunks[chunks.length - 1];
|
|
||||||
if (previous === '\n') return;
|
|
||||||
if (previous?.endsWith('\n')) return;
|
|
||||||
chunks.push('\n');
|
|
||||||
};
|
|
||||||
|
|
||||||
const walk = (node: Node) => {
|
|
||||||
if (node.nodeType === Node.TEXT_NODE) {
|
|
||||||
appendText(node.textContent || '');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.nodeType !== Node.ELEMENT_NODE) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const element = node as HTMLElement;
|
|
||||||
const tagName = element.tagName.toLowerCase();
|
|
||||||
|
|
||||||
if (tagName === 'br') {
|
|
||||||
appendNewline();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tagName === 'a') {
|
|
||||||
const text = element.textContent?.replace(/\s+/g, ' ').trim() || '';
|
|
||||||
const href = element.getAttribute('href')?.trim() || '';
|
|
||||||
const normalizedHref = href.replace(/^mailto:/i, '');
|
|
||||||
if (text && normalizedHref && text === normalizedHref) {
|
|
||||||
appendText(text);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (text && href && text !== href) {
|
|
||||||
appendText(`${text} <${href}>`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (BLOCK_TAGS.has(tagName) && chunks.length > 0) {
|
|
||||||
appendNewline();
|
|
||||||
}
|
|
||||||
|
|
||||||
Array.from(element.childNodes).forEach(walk);
|
|
||||||
|
|
||||||
if (BLOCK_TAGS.has(tagName)) {
|
|
||||||
appendNewline();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Array.from(document.body.childNodes).forEach(walk);
|
|
||||||
return normalizeSignatureLineBreaks(chunks.join(''));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPlainTextSignature(signature?: SignatureSource | null): string {
|
export function getPlainTextSignature(signature?: SignatureSource | null): string {
|
||||||
if (signature?.textSignature?.trim()) {
|
if (signature?.textSignature?.trim()) {
|
||||||
return normalizeSignatureLineBreaks(signature.textSignature);
|
return normalizeSignatureLineBreaks(signature.textSignature);
|
||||||
|
|||||||
Reference in New Issue
Block a user