/** * HTML to React Native Text Components Parser * Handles inline HTML elements and converts them to React Native Text components * Supports nested tags like bold and italic */ import React from "react"; import { Text, Linking } from "react-native"; // Default styles for inline elements const DEFAULT_STYLES = { b: { fontWeight: "bold" as const }, strong: { fontWeight: "bold" as const }, i: { fontStyle: "italic" as const }, em: { fontStyle: "italic" as const }, u: { textDecorationLine: "underline" as const }, s: { textDecorationLine: "line-through" as const }, strike: { textDecorationLine: "line-through" as const }, a: { color: "#007AFF", textDecorationLine: "underline" as const }, br: {}, span: {}, default: {}, }; interface HtmlToTextProps { html: string; style?: any; containerStyle?: any; } interface ParsedElement { type: "text" | "tag"; content?: string; tag?: string; attributes?: Record; children?: ParsedElement[]; } /** * Parse HTML string into a tree structure that handles nested tags */ function parseHtmlToTree(html: string): ParsedElement[] { if (!html) return []; const elements: ParsedElement[] = []; let currentIndex = 0; while (currentIndex < html.length) { // Find the next tag const tagStart = html.indexOf("<", currentIndex); if (tagStart === -1) { // No more tags, add remaining text const remainingText = html.substring(currentIndex); if (remainingText.trim()) { elements.push({ type: "text", content: remainingText, }); } break; } // Add text before the tag if (tagStart > currentIndex) { const textContent = html.substring(currentIndex, tagStart); if (textContent.trim()) { elements.push({ type: "text", content: textContent, }); } } // Find the end of the tag const tagEnd = html.indexOf(">", tagStart); if (tagEnd === -1) { // Malformed HTML, treat as text const remainingText = html.substring(currentIndex); if (remainingText.trim()) { elements.push({ type: "text", content: remainingText, }); } break; } const fullTag = html.substring(tagStart, tagEnd + 1); const isClosingTag = fullTag.startsWith("]*))?>/); if (tagMatch) { const [, tagName, attributes] = tagMatch; // Parse attributes const attrs: Record = {}; if (attributes) { const attrRegex = /(\w+)=["']([^"']*)["']/g; let attrMatch; while ((attrMatch = attrRegex.exec(attributes)) !== null) { attrs[attrMatch[1]] = attrMatch[2]; } } // Find the matching closing tag const closingTagPattern = new RegExp(``, "gi"); const closingMatch = closingTagPattern.exec(html.substring(tagEnd + 1)); if (closingMatch) { const contentStart = tagEnd + 1; const contentEnd = tagEnd + 1 + closingMatch.index; const content = html.substring(contentStart, contentEnd); // Recursively parse the content const children = parseHtmlToTree(content); elements.push({ type: "tag", tag: tagName, attributes: attrs, children: children, }); currentIndex = tagEnd + 1 + closingMatch.index + closingMatch[0].length; } else { // Self-closing tag or malformed HTML if (tagName === "br") { elements.push({ type: "tag", tag: tagName, attributes: attrs, }); } currentIndex = tagEnd + 1; } } else { currentIndex = tagEnd + 1; } } return elements; } /** * Render parsed elements as React Native Text components with proper nesting */ function renderElements( elements: ParsedElement[], parentStyle: any = {}, keyPrefix: string = "", ): React.ReactNode[] { const components: React.ReactNode[] = []; let keyCounter = 0; for (let i = 0; i < elements.length; i++) { const element = elements[i]; const key = `${keyPrefix}_${i}`; if (element.type === "text") { if (element.content) { components.push( {element.content} , ); } } else if (element.type === "tag") { const tagName = element.tag; if (tagName === "br") { components.push( {"\n"} , ); } else if (tagName === "a") { const href = element.attributes?.href || "#"; const linkStyle = [parentStyle, DEFAULT_STYLES.a]; components.push( { if (href && href !== "#") { Linking.canOpenURL(href) .then((supported) => { if (supported) { return Linking.openURL(href); } else { console.warn("Can't handle url: " + href); } }) .catch((err) => console.error("An error occurred", err)); } }} > {element.children ? renderElements(element.children, linkStyle, `${key}_children`) : href} , ); } else if (tagName && DEFAULT_STYLES[tagName as keyof typeof DEFAULT_STYLES]) { // Handle other inline elements with nesting const tagStyle = [parentStyle, DEFAULT_STYLES[tagName as keyof typeof DEFAULT_STYLES]]; if (element.children && element.children.length > 0) { // Render children with the combined style const childComponents = renderElements(element.children, tagStyle, `${key}_children`); components.push( {childComponents} , ); } else { // Self-closing tag or no content components.push( {/* Empty content */} , ); } } else { // Unknown tag, just render children if (element.children) { const childComponents = renderElements(element.children, parentStyle, `${key}_unknown`); components.push(...childComponents); } } } } return components; } /** * HTML to React Native Text components parser with nested tag support */ export function HtmlToText({ html, style, containerStyle }: HtmlToTextProps): React.ReactElement { if (!html || typeof html !== "string") { return No description available; } // Parse HTML into tree structure const elements = parseHtmlToTree(html); // Render elements with proper nesting const components = renderElements(elements, style, "root"); return {components}; } /** * Simple function to convert HTML to text (backward compatible) */ export function parseHtmlToText(html: string): string { if (!html || typeof html !== "string") { return "No description available"; } return html .replace(//gi, "\n") .replace(/<[^>]*>/g, "") // Remove all HTML tags .replace(/ /g, " ") .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, '"') .replace(/'/g, "'") .replace(/\n\s*\n\s*\n/g, "\n\n") // Remove excessive line breaks .trim(); }