/**
* Regex-based HTML-to-Markdown converter.
* No external dependencies — operates on raw HTML strings.
*
* Three phases:
* 1. Strip unwanted elements (script, style, nav, footer, header, aside, hidden, comments)
* 2. Convert remaining HTML to Markdown (pre/code protected via placeholders)
* 3. Normalize (entities, whitespace, trim)
*/
// ── Phase 1: Stripping ──────────────────────────────────────────────
const STRIP_TAGS = ["script", "style", "nav", "footer", "header", "aside"];
const STRIP_PATTERNS: RegExp[] = [
// Unwanted block elements
...STRIP_TAGS.map(
(tag) => new RegExp(`<${tag}[^>]*>[\\s\\S]*?<\\/${tag}>`, "gi"),
),
// Elements with display:none
/<[^>]+style\s*=\s*"[^"]*display\s*:\s*none[^"]*"[^>]*>[\s\S]*?<\/[^>]+>/gi,
// Elements with aria-hidden="true"
/<[^>]+aria-hidden\s*=\s*"true"[^>]*>[\s\S]*?<\/[^>]+>/gi,
// HTML comments
//g,
];
function stripUnwanted(html: string): string {
let result = html;
for (const pattern of STRIP_PATTERNS) {
result = result.replace(pattern, "");
}
return result;
}
// ── Phase 2: Conversion ─────────────────────────────────────────────
type Placeholder = { key: string; content: string };
let placeholderCounter = 0;
function protectPreBlocks(html: string): { html: string; placeholders: Placeholder[] } {
const placeholders: Placeholder[] = [];
// Match
...
// and ...
// and ...
(no nested code)
const result = html.replace(
/]*>\s*(?:]*>([\s\S]*?)<\/code>|([\s\S]*?))\s*<\/pre>/gi,
(_match, codeClass: string | undefined, codeContent: string | undefined, preContent: string | undefined) => {
const raw = codeContent ?? preContent ?? "";
// Decode entities inside code blocks so content is verbatim
const text = decodeEntities(raw).replace(/^\n|\n$/g, "");
let lang = "";
if (codeClass) {
const langMatch = codeClass.match(/language-(\S+)/);
if (langMatch) lang = langMatch[1];
}
const md = lang ? `\`\`\`${lang}\n${text}\n\`\`\`` : `\`\`\`\n${text}\n\`\`\``;
const key = `__PRE_PLACEHOLDER_${placeholderCounter++}__`;
placeholders.push({ key, content: md });
return key;
},
);
return { html: result, placeholders };
}
function restorePlaceholders(md: string, placeholders: Placeholder[]): string {
let result = md;
for (const { key, content } of placeholders) {
result = result.replace(key, content);
}
return result;
}
function convertInlineCode(html: string): string {
return html.replace(/]*>([\s\S]*?)<\/code>/gi, (_m, content: string) => {
return `\`${content.trim()}\``;
});
}
function convertHeadings(html: string): string {
return html.replace(
/]*>([\s\S]*?)<\/h\1>/gi,
(_m, level: string, content: string) => {
const hashes = "#".repeat(Number(level));
return `\n\n${hashes} ${content.trim()}\n\n`;
},
);
}
function convertParagraphs(html: string): string {
return html.replace(/]*>([\s\S]*?)<\/p>/gi, (_m, content: string) => {
return `\n\n${content.trim()}\n\n`;
});
}
function convertLinks(html: string): string {
return html.replace(
/]*href\s*=\s*"([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi,
(_m, href: string, text: string) => `[${text.trim()}](${href})`,
);
}
function convertImages(html: string): string {
return html.replace(
/
]*src\s*=\s*"([^"]*)"[^>]*alt\s*=\s*"([^"]*)"[^>]*\/?>/gi,
(_m, src: string, alt: string) => ``,
);
}
function convertImagesAltFirst(html: string): string {
// alt before src ordering
return html.replace(
/
]*alt\s*=\s*"([^"]*)"[^>]*src\s*=\s*"([^"]*)"[^>]*\/?>/gi,
(_m, alt: string, src: string) => ``,
);
}
function convertBoldItalic(html: string): string {
let result = html;
result = result.replace(/<(?:strong|b)[^>]*>([\s\S]*?)<\/(?:strong|b)>/gi, "**$1**");
result = result.replace(/<(?:em|i)[^>]*>([\s\S]*?)<\/(?:em|i)>/gi, "*$1*");
return result;
}
function convertLists(html: string): string {
let result = html;
// Unordered lists
result = result.replace(/]*>([\s\S]*?)<\/ul>/gi, (_m, inner: string) => {
const items: string[] = [];
inner.replace(/- ]*>([\s\S]*?)<\/li>/gi, (_m2: string, content: string) => {
items.push(`- ${content.trim()}`);
return "";
});
return `\n${items.join("\n")}\n`;
});
// Ordered lists
result = result.replace(/
]*>([\s\S]*?)<\/ol>/gi, (_m, inner: string) => {
const items: string[] = [];
let idx = 1;
inner.replace(/- ]*>([\s\S]*?)<\/li>/gi, (_m2: string, content: string) => {
items.push(`${idx++}. ${content.trim()}`);
return "";
});
return `\n${items.join("\n")}\n`;
});
return result;
}
function convertTables(html: string): string {
return html.replace(/
]*>([\s\S]*?)<\/table>/gi, (_m, tableInner: string) => {
const rows: string[][] = [];
// Extract rows from thead and tbody, or directly
const allRows = tableInner.match(/]*>[\s\S]*?<\/tr>/gi) ?? [];
for (const row of allRows) {
const cells: string[] = [];
const cellPattern = /<(?:th|td)[^>]*>([\s\S]*?)<\/(?:th|td)>/gi;
let cellMatch: RegExpExecArray | null;
while ((cellMatch = cellPattern.exec(row)) !== null) {
cells.push(cellMatch[1].trim());
}
if (cells.length > 0) rows.push(cells);
}
if (rows.length === 0) return "";
const colCount = Math.max(...rows.map((r) => r.length));
const lines: string[] = [];
for (let i = 0; i < rows.length; i++) {
const cells = rows[i];
// Pad to colCount
while (cells.length < colCount) cells.push("");
lines.push(`| ${cells.join(" | ")} |`);
// After the first row (header), insert separator
if (i === 0) {
lines.push(`| ${cells.map(() => "---").join(" | ")} |`);
}
}
return `\n${lines.join("\n")}\n`;
});
}
function convertBlockquotes(html: string): string {
return html.replace(/]*>([\s\S]*?)<\/blockquote>/gi, (_m, content: string) => {
const lines = content.trim().split("\n");
return `\n${lines.map((l) => `> ${l.trim()}`).join("\n")}\n`;
});
}
function convertBrHr(html: string): string {
let result = html;
result = result.replace(/
/gi, "\n");
result = result.replace(/
/gi, "\n---\n");
return result;
}
function unwrapTags(html: string): string {
// Remove wrapper-only tags, keeping inner content
return html.replace(
/<\/?(div|section|article|main|span)[^>]*>/gi,
"",
);
}
function stripRemainingTags(html: string): string {
return html.replace(/<\/?[^>]+(>|$)/g, "");
}
// ── Phase 3: Normalize ──────────────────────────────────────────────
function decodeEntities(html: string): string {
let result = html;
result = result.replace(/&/g, "&");
result = result.replace(/</g, "<");
result = result.replace(/>/g, ">");
result = result.replace(/"/g, '"');
result = result.replace(/'|'/g, "'");
result = result.replace(/ /g, " ");
// Numeric entities NNN;
result = result.replace(/(\d+);/g, (_m, code: string) =>
String.fromCharCode(Number(code)),
);
// Hex entities HHH;
result = result.replace(/([0-9a-fA-F]+);/g, (_m, hex: string) =>
String.fromCharCode(parseInt(hex, 16)),
);
return result;
}
function normalizeWhitespace(md: string): string {
// Collapse 3+ consecutive newlines to 2
return md.replace(/\n{3,}/g, "\n\n");
}
// ── Main ─────────────────────────────────────────────────────────────
export function htmlToMarkdown(html: string): string {
if (!html || !html.trim()) return "";
// Phase 1: Strip unwanted elements
let result = stripUnwanted(html);
// Phase 2: Convert
// Protect / blocks first
const { html: withPlaceholders, placeholders } = protectPreBlocks(result);
result = withPlaceholders;
// Inline code (not inside pre — those are already placeholders)
result = convertInlineCode(result);
// Block-level conversions
result = convertHeadings(result);
result = convertBlockquotes(result);
result = convertTables(result);
result = convertLists(result);
result = convertParagraphs(result);
// Inline conversions
result = convertLinks(result);
result = convertImages(result);
result = convertImagesAltFirst(result);
result = convertBoldItalic(result);
// Line breaks and rules
result = convertBrHr(result);
// Unwrap structural tags
result = unwrapTags(result);
// Strip any remaining HTML tags
result = stripRemainingTags(result);
// Restore protected code blocks
result = restorePlaceholders(result, placeholders);
// Phase 3: Normalize
result = decodeEntities(result);
result = normalizeWhitespace(result);
result = result.trim();
return result;
}