const NAMED_HTML_ENTITIES: Record = { amp: '&', apos: "'", hellip: '…', lt: '<', mdash: '—', middot: '·', nbsp: ' ', ndash: '–', gt: '>', quot: '"', }; const HTML_ENTITY_PATTERN = /&(#x[\da-f]+|#\d+|[a-z][a-z\d]+);/gi; const BLOCK_END_TAG_PATTERN = /<\/(?:p|div|li|h[1-6]|tr)>/gi; function decodeHtmlEntity(match: string, entity: string): string { if (!entity.startsWith('#')) { return NAMED_HTML_ENTITIES[entity.toLowerCase()] ?? match; } const isHex = entity[1].toLowerCase() === 'x'; const numberText = entity.slice(isHex ? 2 : 1); const codePoint = Number.parseInt(numberText, isHex ? 16 : 10); if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) { return match; } return String.fromCodePoint(codePoint); } /** * Convert source-provided HTML descriptions or remarks to safe display text. * Tags are removed, common/numeric entities are decoded, and layout whitespace * is normalized without ever injecting markup into the React tree. */ export function htmlToText(value?: string | null): string { if (!value) return ''; const withLineBreaks = value .replace(/<\s*br\s*\/?>/gi, '\n') .replace(BLOCK_END_TAG_PATTERN, '\n'); const withoutTags = withLineBreaks.replace(/<[^>]*>/g, ''); const decoded = withoutTags.replace(HTML_ENTITY_PATTERN, decodeHtmlEntity); return decoded .replace(/\u00a0/g, ' ') .replace(/[ \t]+/g, ' ') .replace(/[ \t]*\n[ \t]*/g, '\n') .replace(/\n{3,}/g, '\n\n') .trim(); }