loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); return self::process_node($dom); } private static function process_node($node) { if ($node instanceof \DOMText) { return $node->wholeText; // Preserve whitespace logic in render } if (!$node instanceof \DOMElement && !$node instanceof \DOMDocument) { return ''; } $output = ''; foreach ($node->childNodes as $child) { $output .= self::process_node($child); } if ($node instanceof \DOMDocument) { return trim($output); } $tag = strtolower($node->tagName); switch ($tag) { case 'h1': return "\n# " . trim($output) . "\n\n"; case 'h2': return "\n## " . trim($output) . "\n\n"; case 'h3': return "\n### " . trim($output) . "\n\n"; case 'h4': return "\n#### " . trim($output) . "\n\n"; case 'h5': return "\n##### " . trim($output) . "\n\n"; case 'h6': return "\n###### " . trim($output) . "\n\n"; case 'p': if (trim($output) === '') return ''; return trim($output) . "\n\n"; case 'br': return " \n"; case 'hr': return "\n---\n\n"; case 'strong': case 'b': // Extract whitespace $leading = ''; $trailing = ''; if (preg_match('/^(\s+)/', $output, $matches)) { $leading = $matches[1]; } if (preg_match('/(\s+)$/', $output, $matches)) { $trailing = $matches[1]; } $trimmed = trim($output); if ($trimmed === '') return $output; // Just whitespace or empty return $leading . "**" . $trimmed . "**" . $trailing; case 'em': case 'i': // Extract whitespace $leading = ''; $trailing = ''; if (preg_match('/^(\s+)/', $output, $matches)) { $leading = $matches[1]; } if (preg_match('/(\s+)$/', $output, $matches)) { $trailing = $matches[1]; } $trimmed = trim($output); if ($trimmed === '') return $output; // Just whitespace or empty return $leading . "*" . $trimmed . "*" . $trailing; case 'code': // Check if inside pre (handled by pre) if ($node->parentNode && $node->parentNode instanceof \DOMElement && strtolower($node->parentNode->tagName) === 'pre') { return $output; } return "`" . $output . "`"; case 'pre': // Attempt to guess language or just use generic return "\n```\n" . trim($output) . "\n```\n\n"; case 'blockquote': $lines = explode("\n", trim($output)); $quoted = array_map(function ($line) { return "> " . $line; }, $lines); return "\n" . implode("\n", $quoted) . "\n\n"; case 'ul': case 'ol': return "\n" . trim($output) . "\n\n"; case 'li': // Determine list type from parent $marker = '-'; if ($node->parentNode && $node->parentNode instanceof \DOMElement && strtolower($node->parentNode->tagName) === 'ol') { // Calculate index $index = 1; $prev = $node->previousSibling; while ($prev) { if ($prev instanceof \DOMElement && strtolower($prev->tagName) === 'li') { $index++; } $prev = $prev->previousSibling; } $marker = "$index."; } return $marker . " " . trim($output) . "\n"; case 'a': $href = $node->getAttribute('href'); $title = $node->getAttribute('title'); $text = $output; if (!$href) return $text; $markdown = "[$text]($href"; if ($title) { $markdown .= " \"$title\""; } $markdown .= ")"; return $markdown; case 'img': $src = $node->getAttribute('src'); $alt = $node->getAttribute('alt'); $title = $node->getAttribute('title'); if (!$src) return ''; $markdown = "![$alt]($src"; if ($title) { $markdown .= " \"$title\""; } $markdown .= ")"; return $markdown; case 'div': case 'span': case 'article': case 'section': return $output; // Just pass through text content default: return $output; } } }