Files
SRCmail/lib/html-to-text.ts
T

165 lines
4.2 KiB
TypeScript

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(''));
}