*/ private function languageAliases(): array { return [ 'js' => 'javascript', 'javascript' => 'javascript', 'xml' => 'xml', 'html' => 'xml', 'xhtml' => 'xml', 'css' => 'css', 'php' => 'php', 'sql' => 'sql', 'java' => 'java', 'py' => 'python', 'python' => 'python', 'c#' => 'csharp', 'cs' => 'csharp', 'bash' => 'bash', 'shell' => 'bash', 'sh' => 'bash', 'text' => 'text', ]; } public function normalize(string $content): string { if ($content === '') { return ''; } $content = $this->decodeNbsp($content); if (! preg_match('/<\s*(div|p|ol|ul|li|pre|code|span|br|table)\b/i', $content)) { return $this->recoverFlattenedShell($content); } try { $content = $this->transformHtml($content); } catch (Throwable) { // Keep the decoded text when HTML parsing fails. } return $this->recoverFlattenedShell($content); } public function toMarkdown(string $html, HtmlConverter $converter, PostContentRenderer $renderer): string { return $converter->convert($renderer->toMarkdownSafeTokens($this->normalize($html))); } public function plainText(string $content): string { $text = $this->decodeNbsp(strip_tags($content)); $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $text = str_replace("\xC2\xA0", ' ', $text); $text = preg_replace('/\s+/u', ' ', $text) ?? $text; return trim($text); } public function decodeNbsp(string $content): string { $content = str_replace( ['&nbsp;', ' ', ' ', ' ', ' ', "\xC2\xA0"], ' ', $content ); return str_replace('&nbsp;', ' ', $content); } private function transformHtml(string $html): string { $document = $this->loadDocument($html); $xpath = new DOMXPath($document); $this->convertHighlighterBlocks($document, $xpath); $this->convertHighlighterLists($document, $xpath); $this->convertStackedCodeDivs($document); $this->removeEmptyBlocks($xpath); return $this->innerHtml($document); } private function convertHighlighterBlocks(DOMDocument $document, DOMXPath $xpath): void { $nodes = []; foreach ($xpath->query('//*[contains(concat(" ", normalize-space(@class), " "), " codeText ")]') ?: [] as $node) { if ($node instanceof DOMElement) { $nodes[] = $node; } } foreach ($nodes as $node) { if ($node->parentNode === null) { continue; } $this->replaceWithPre($document, $xpath, $node); } } private function convertHighlighterLists(DOMDocument $document, DOMXPath $xpath): void { $lists = []; foreach ($xpath->query('//ol[contains(@class, "dp-")]') ?: [] as $node) { if (! $node instanceof DOMElement || $node->parentNode === null) { continue; } if ($this->closestTag($node, 'pre') !== null) { continue; } $lists[] = $node; } foreach ($lists as $list) { $this->replaceWithPre($document, $xpath, $list); } } private function convertStackedCodeDivs(DOMDocument $document): void { $xpath = new DOMXPath($document); $parents = []; foreach ($xpath->query('//*') ?: [] as $node) { if ($node instanceof DOMElement) { $parents[] = $node; } } foreach ($parents as $parent) { if ($parent->parentNode === null) { continue; } if (in_array(strtolower($parent->nodeName), ['pre', 'code'], true)) { continue; } $run = []; $flush = function () use (&$run, $document): void { if (count($run) < 2) { $run = []; return; } $lines = array_map(fn (DOMElement $div): string => $this->plainText($this->innerHtmlOf($div)), $run); if (! $this->looksLikeCodeLines($lines)) { $run = []; return; } $first = $run[0]; $pre = $this->makePre($document, implode("\n", $lines), $this->detectLanguage('', implode("\n", $lines))); $first->parentNode?->insertBefore($pre, $first); foreach ($run as $div) { $div->parentNode?->removeChild($div); } $run = []; }; foreach (iterator_to_array($parent->childNodes) as $child) { if ($child instanceof DOMNode && $child->nodeType === XML_TEXT_NODE && trim((string) $child->textContent) === '') { continue; } if ($child instanceof DOMElement && strtolower($child->nodeName) === 'div' && $this->isSimpleLineDiv($child)) { $run[] = $child; continue; } $flush(); } $flush(); } } private function removeEmptyBlocks(DOMXPath $xpath): void { foreach ($xpath->query('//p|//div') ?: [] as $node) { if (! $node instanceof DOMElement || $node->parentNode === null) { continue; } if ($node->getElementsByTagName('*')->length > 0) { continue; } $text = trim(str_replace("\xC2\xA0", ' ', $node->textContent)); if ($text === '') { $node->parentNode->removeChild($node); } } } private function replaceWithPre(DOMDocument $document, DOMXPath $xpath, DOMElement $node): void { $hint = ''; if (preg_match('/dp-([a-z0-9#+]+)/i', $node->getAttribute('class'), $match)) { $hint = $match[1]; } $lines = []; foreach ($xpath->query('.//li', $node) ?: [] as $item) { $lines[] = $this->plainText($document->saveHTML($item) ?: ''); } if ($lines === []) { foreach ($xpath->query('.//*[contains(@class, "codeHead")]', $node) ?: [] as $head) { $head->parentNode?->removeChild($head); } $lines = preg_split('/\R/u', $this->plainText($node->textContent)) ?: []; } $code = implode("\n", $lines); $pre = $this->makePre($document, $code, $this->detectLanguage($hint, $code)); $node->parentNode?->replaceChild($pre, $node); } private function makePre(DOMDocument $document, string $code, string $language): DOMElement { $pre = $document->createElement('pre'); $codeNode = $document->createElement('code'); $codeNode->setAttribute('class', 'language-'.$language); $codeNode->appendChild($document->createTextNode($code)); $pre->appendChild($codeNode); return $pre; } /** * @param list $lines */ private function looksLikeCodeLines(array $lines): bool { $codeish = 0; foreach ($lines as $line) { if ($line === '') { continue; } if (preg_match('/\\\\$/', $line) || preg_match('/^\s*(docker|npm|npx|pnpm|yarn|git|curl|wget|sudo|ssh|kubectl|composer|php|python|pip|node)\b/i', $line) || preg_match('/^\s+--[\w-]+/', $line) || preg_match('/^\s*\|/', $line) || preg_match('/[{};]$/', $line) ) { $codeish++; } } return $codeish >= 2; } private function isSimpleLineDiv(DOMElement $div): bool { foreach ($div->childNodes as $child) { if ($child instanceof DOMElement && ! in_array(strtolower($child->nodeName), ['br', 'span', 'code', 'b', 'i', 'em', 'strong'], true)) { return false; } } $text = $this->plainText($div->textContent); return $text !== ''; } private function detectLanguage(string $hint, string $code): string { if (preg_match('/^\s*(docker|kubectl|npm|npx|pnpm|yarn|composer|curl|wget|sudo|ssh)\b/m', $code)) { return 'bash'; } $aliases = $this->languageAliases(); $key = strtolower(trim($hint)); return $aliases[$key] ?? ($key !== '' ? $key : 'text'); } /** * Recover a docker/shell command that HtmlConverter flattened into one line. */ private function recoverFlattenedShell(string $content): string { if (str_contains($content, 'plainText($match[0])); $code = preg_replace('/\\\\\s+/', "\\\n", $code) ?? $code; return '
'.e($code).'
'; }, $content ) ?? $content; } private function closestTag(DOMNode $node, string $tag): ?DOMElement { $current = $node->parentNode; while ($current instanceof DOMElement) { if (strtolower($current->nodeName) === $tag) { return $current; } $current = $current->parentNode; } return null; } private function loadDocument(string $html): DOMDocument { $document = new DOMDocument('1.0', 'UTF-8'); $previous = libxml_use_internal_errors(true); $wrapped = '
'.$html.'
'; $document->loadHTML(''.$wrapped, LIBXML_NOERROR | LIBXML_HTML_NODEFDTD); libxml_clear_errors(); libxml_use_internal_errors($previous); return $document; } private function innerHtml(DOMDocument $document): string { $root = $document->getElementById('ll-root'); if (! $root instanceof DOMElement) { $root = (new DOMXPath($document))->query('//*[@id="ll-root"]')->item(0); } if (! $root instanceof DOMElement) { return ''; } return $this->innerHtmlOf($root); } private function innerHtmlOf(DOMElement $element): string { $html = ''; foreach ($element->childNodes as $child) { $html .= $element->ownerDocument?->saveHTML($child) ?: ''; } return $html; } }