112 lines
3.4 KiB
TypeScript
112 lines
3.4 KiB
TypeScript
/**
|
|
* HTML-to-text conversion for PodTUI
|
|
*
|
|
* Converts HTML content from RSS feed descriptions into clean plain text
|
|
* suitable for display in the terminal. Preserves paragraph structure,
|
|
* converts lists to bulleted text, and strips all tags.
|
|
*/
|
|
|
|
/**
|
|
* Convert HTML content to readable plain text.
|
|
*
|
|
* - Block elements (<p>, <div>, <br>, headings, <li>) become line breaks
|
|
* - <li> items get a bullet prefix
|
|
* - <a href="...">text</a> becomes "text (url)"
|
|
* - All other tags are stripped
|
|
* - HTML entities are decoded
|
|
* - Excessive whitespace is collapsed
|
|
*/
|
|
export function htmlToText(html: string): string {
|
|
if (!html) return ""
|
|
|
|
let text = html
|
|
|
|
// Strip CDATA wrappers
|
|
text = text.replace(/<!\[CDATA\[([\s\S]*?)]]>/gi, "$1")
|
|
|
|
// Replace <br> / <br/> with newline
|
|
text = text.replace(/<br\s*\/?>/gi, "\n")
|
|
|
|
// Replace <hr> with a separator line
|
|
text = text.replace(/<hr\s*\/?>/gi, "\n---\n")
|
|
|
|
// Block-level elements get newlines before/after
|
|
text = text.replace(/<\/?(p|div|blockquote|pre|h[1-6]|table|tr|section|article|header|footer)[\s>][^>]*>/gi, "\n")
|
|
|
|
// List items get bullet prefix
|
|
text = text.replace(/<li[^>]*>/gi, "\n - ")
|
|
text = text.replace(/<\/li>/gi, "")
|
|
|
|
// Strip list wrappers
|
|
text = text.replace(/<\/?(ul|ol|dl|dt|dd)[^>]*>/gi, "\n")
|
|
|
|
// Convert links: <a href="url">text</a> -> text (url)
|
|
text = text.replace(/<a\s[^>]*href=["']([^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi, (_, href, linkText) => {
|
|
const cleanText = stripTags(linkText).trim()
|
|
if (!cleanText) return href
|
|
// Don't duplicate if the link text IS the URL
|
|
if (cleanText === href || cleanText === href.replace(/^https?:\/\//, "")) return cleanText
|
|
return `${cleanText} (${href})`
|
|
})
|
|
|
|
// Strip all remaining tags
|
|
text = stripTags(text)
|
|
|
|
// Decode HTML entities
|
|
text = decodeHtmlEntities(text)
|
|
|
|
// Collapse multiple blank lines into at most two newlines
|
|
text = text.replace(/\n{3,}/g, "\n\n")
|
|
|
|
// Collapse runs of spaces/tabs (but not newlines) on each line
|
|
text = text
|
|
.split("\n")
|
|
.map((line) => line.replace(/[ \t]+/g, " ").trim())
|
|
.join("\n")
|
|
|
|
return text.trim()
|
|
}
|
|
|
|
/** Strip all HTML/XML tags from a string */
|
|
function stripTags(html: string): string {
|
|
return html.replace(/<[^>]*>/g, "")
|
|
}
|
|
|
|
/** Decode common HTML entities */
|
|
function decodeHtmlEntities(text: string): string {
|
|
return text
|
|
// Named entities
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/&/g, "&")
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/'/g, "'")
|
|
.replace(/ /g, " ")
|
|
.replace(/—/g, "\u2014")
|
|
.replace(/–/g, "\u2013")
|
|
.replace(/…/g, "\u2026")
|
|
.replace(/«/g, "\u00AB")
|
|
.replace(/»/g, "\u00BB")
|
|
.replace(/“/g, "\u201C")
|
|
.replace(/”/g, "\u201D")
|
|
.replace(/‘/g, "\u2018")
|
|
.replace(/’/g, "\u2019")
|
|
.replace(/•/g, "\u2022")
|
|
.replace(/©/g, "\u00A9")
|
|
.replace(/®/g, "\u00AE")
|
|
.replace(/™/g, "\u2122")
|
|
.replace(/°/g, "\u00B0")
|
|
.replace(/×/g, "\u00D7")
|
|
// Numeric entities (decimal)
|
|
.replace(/&#(\d+);/g, (_, code) => {
|
|
const n = parseInt(code, 10)
|
|
return n > 0 && n < 0x10ffff ? String.fromCodePoint(n) : ""
|
|
})
|
|
// Numeric entities (hex)
|
|
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => {
|
|
const n = parseInt(hex, 16)
|
|
return n > 0 && n < 0x10ffff ? String.fromCodePoint(n) : ""
|
|
})
|
|
}
|