| 1 |
<?php |
| 2 |
|
| 3 |
namespace FluentSupport\App\Modules\MCP\Helpers; |
| 4 |
|
| 5 |
/** |
| 6 |
* Small DOM-based HTML to Markdown converter for MCP output. |
| 7 |
* |
| 8 |
* Agents read ticket threads and integration widgets as text. Flattening HTML |
| 9 |
* to one line destroys pasted logs, lists and tables, so this keeps the |
| 10 |
* structure as Markdown. It is a reader, not a round-trip converter: nothing |
| 11 |
* is escaped and unknown tags just render their children. |
| 12 |
* |
| 13 |
* Bundled instead of a Composer package because vendor libraries are not |
| 14 |
* namespace-scoped and would clash with other plugins shipping the same one. |
| 15 |
*/ |
| 16 |
class HtmlToMarkdown |
| 17 |
{ |
| 18 |
// Placeholders survive the whitespace cleanup and are swapped back at the end. |
| 19 |
const INDENT = "\x01"; |
| 20 |
const PRE = "\x02"; |
| 21 |
// A line break that merges with its neighbours, so stacked <div>s read as lines, not paragraphs. |
| 22 |
const SOFT = "\x03"; |
| 23 |
|
| 24 |
private static $skipTags = ['script', 'style', 'head', 'title', 'meta', 'link', 'noscript', 'template']; |
| 25 |
|
| 26 |
private static $blockTags = [ |
| 27 |
'p', 'section', 'article', 'header', 'footer', 'main', 'aside', 'nav', |
| 28 |
'address', 'figure', 'figcaption', 'dl', 'dt', 'dd', 'center', 'form', 'fieldset', |
| 29 |
]; |
| 30 |
|
| 31 |
/** @var string[] */ |
| 32 |
private $preBlocks = []; |
| 33 |
|
| 34 |
public static function convert($html) |
| 35 |
{ |
| 36 |
$html = (string) $html; |
| 37 |
if (trim($html) === '') { |
| 38 |
return ''; |
| 39 |
} |
| 40 |
|
| 41 |
// Plain text: keep its own line breaks. |
| 42 |
if (strpos($html, '<') === false) { |
| 43 |
$text = html_entity_decode($html, ENT_QUOTES, 'UTF-8'); |
| 44 |
return self::tidy(str_replace(["\r\n", "\r"], "\n", $text)); |
| 45 |
} |
| 46 |
|
| 47 |
// ext/dom is enabled by default but can be compiled out; degrade to text. |
| 48 |
if (!class_exists('\DOMDocument')) { |
| 49 |
return self::tidy(wp_strip_all_tags($html)); |
| 50 |
} |
| 51 |
|
| 52 |
$dom = new \DOMDocument(); |
| 53 |
$libXmlState = libxml_use_internal_errors(true); |
| 54 |
$loaded = $dom->loadHTML( |
| 55 |
'<?xml encoding="UTF-8"?>' . $html, |
| 56 |
LIBXML_NOERROR | LIBXML_NOWARNING |
| 57 |
); |
| 58 |
libxml_clear_errors(); |
| 59 |
libxml_use_internal_errors($libXmlState); |
| 60 |
|
| 61 |
// Walk the document element rather than a wrapper <div> of our own: an |
| 62 |
// unbalanced </div> in stored email HTML would close the wrapper early and |
| 63 |
// everything after it would be silently dropped. <head> is skipped below and |
| 64 |
// <html>/<body> just render their children, so this covers content libxml |
| 65 |
// places after a stray </body> too. |
| 66 |
$root = $loaded ? $dom->documentElement : null; |
| 67 |
if (!$root) { |
| 68 |
return self::tidy(wp_strip_all_tags($html)); |
| 69 |
} |
| 70 |
|
| 71 |
$converter = new self(); |
| 72 |
$markdown = $converter->children($root); |
| 73 |
|
| 74 |
return $converter->restorePre(self::tidy($markdown)); |
| 75 |
} |
| 76 |
|
| 77 |
private static function tidy($text) |
| 78 |
{ |
| 79 |
$text = preg_replace('/[^\S\n]+/u', ' ', $text); |
| 80 |
$text = preg_replace_callback('/ *(?:[\n' . self::SOFT . '] *)+/', function ($match) { |
| 81 |
return substr_count($match[0], "\n") >= 2 ? "\n\n" : "\n"; |
| 82 |
}, $text); |
| 83 |
$text = str_replace(self::INDENT, ' ', $text); |
| 84 |
|
| 85 |
return trim($text); |
| 86 |
} |
| 87 |
|
| 88 |
private function restorePre($text) |
| 89 |
{ |
| 90 |
return preg_replace_callback('/' . self::PRE . '(\d+)' . self::PRE . '/', function ($match) { |
| 91 |
return $this->preBlocks[(int) $match[1]] ?? ''; |
| 92 |
}, $text); |
| 93 |
} |
| 94 |
|
| 95 |
private function children(\DOMNode $node) |
| 96 |
{ |
| 97 |
$out = ''; |
| 98 |
foreach ($node->childNodes as $child) { |
| 99 |
$out .= $this->node($child); |
| 100 |
} |
| 101 |
|
| 102 |
return $out; |
| 103 |
} |
| 104 |
|
| 105 |
private function node(\DOMNode $node) |
| 106 |
{ |
| 107 |
if ($node instanceof \DOMText) { |
| 108 |
$text = str_replace([self::INDENT, self::PRE, self::SOFT], '', $node->nodeValue); |
| 109 |
return preg_replace('/[\s\x{00A0}]+/u', ' ', $text); |
| 110 |
} |
| 111 |
|
| 112 |
if (!$node instanceof \DOMElement) { |
| 113 |
return ''; |
| 114 |
} |
| 115 |
|
| 116 |
$tag = strtolower($node->tagName); |
| 117 |
|
| 118 |
if (in_array($tag, self::$skipTags, true)) { |
| 119 |
return ''; |
| 120 |
} |
| 121 |
|
| 122 |
switch ($tag) { |
| 123 |
case 'br': |
| 124 |
return "\n"; |
| 125 |
case 'hr': |
| 126 |
return "\n\n---\n\n"; |
| 127 |
case 'div': |
| 128 |
$content = trim($this->children($node)); |
| 129 |
if ($content === '') { |
| 130 |
// Gmail writes a blank line as <div><br></div>. |
| 131 |
return $node->getElementsByTagName('br')->length ? "\n\n" : ''; |
| 132 |
} |
| 133 |
return self::SOFT . $content . self::SOFT; |
| 134 |
case 'h1': |
| 135 |
case 'h2': |
| 136 |
case 'h3': |
| 137 |
case 'h4': |
| 138 |
case 'h5': |
| 139 |
case 'h6': |
| 140 |
$text = $this->inline($node); |
| 141 |
return $text === '' ? '' : "\n\n" . str_repeat('#', (int) $tag[1]) . ' ' . $text . "\n\n"; |
| 142 |
case 'strong': |
| 143 |
case 'b': |
| 144 |
return $this->wrap($node, '**'); |
| 145 |
case 'em': |
| 146 |
case 'i': |
| 147 |
return $this->wrap($node, '_'); |
| 148 |
case 's': |
| 149 |
case 'del': |
| 150 |
case 'strike': |
| 151 |
return $this->wrap($node, '~~'); |
| 152 |
case 'code': |
| 153 |
return $this->wrap($node, '`'); |
| 154 |
case 'pre': |
| 155 |
return $this->pre($node); |
| 156 |
case 'a': |
| 157 |
return $this->link($node); |
| 158 |
case 'img': |
| 159 |
return $this->image($node); |
| 160 |
case 'ul': |
| 161 |
case 'ol': |
| 162 |
return $this->listBlock($node, $tag === 'ol'); |
| 163 |
case 'li': |
| 164 |
return "\n- " . $this->itemContent($node) . "\n"; |
| 165 |
case 'blockquote': |
| 166 |
return $this->blockquote($node); |
| 167 |
case 'table': |
| 168 |
return $this->table($node); |
| 169 |
} |
| 170 |
|
| 171 |
if (in_array($tag, self::$blockTags, true)) { |
| 172 |
return "\n\n" . trim($this->children($node)) . "\n\n"; |
| 173 |
} |
| 174 |
|
| 175 |
return $this->children($node); |
| 176 |
} |
| 177 |
|
| 178 |
// Inline contexts (headings, links, table cells) have no line breaks: a soft |
| 179 |
// break from a nested <div> folds into a space like a newline does. |
| 180 |
private function inline(\DOMNode $node) |
| 181 |
{ |
| 182 |
return trim(preg_replace('/[\s' . self::SOFT . ']*[\n' . self::SOFT . '][\s' . self::SOFT . ']*/', ' ', $this->children($node))); |
| 183 |
} |
| 184 |
|
| 185 |
// List items keep line breaks but a soft break is a plain newline there. |
| 186 |
private function itemContent(\DOMNode $node) |
| 187 |
{ |
| 188 |
$content = str_replace(self::SOFT, "\n", $this->children($node)); |
| 189 |
|
| 190 |
return trim(preg_replace('/\n{2,}/', "\n", $content)); |
| 191 |
} |
| 192 |
|
| 193 |
// Keep the surrounding spaces outside the markers: "a **b** c", not "a** b **c". |
| 194 |
private function wrap(\DOMNode $node, $marker) |
| 195 |
{ |
| 196 |
$raw = $this->children($node); |
| 197 |
$text = trim($raw); |
| 198 |
if ($text === '') { |
| 199 |
return $raw; |
| 200 |
} |
| 201 |
|
| 202 |
$lead = preg_match('/^\s/u', $raw) ? ' ' : ''; |
| 203 |
$trail = preg_match('/\s$/u', $raw) ? ' ' : ''; |
| 204 |
|
| 205 |
return $lead . $marker . $text . $marker . $trail; |
| 206 |
} |
| 207 |
|
| 208 |
private function pre(\DOMElement $node) |
| 209 |
{ |
| 210 |
// Some editors put <br> inside <pre>; textContent would drop them. |
| 211 |
foreach (iterator_to_array($node->getElementsByTagName('br')) as $br) { |
| 212 |
$br->parentNode->replaceChild($node->ownerDocument->createTextNode("\n"), $br); |
| 213 |
} |
| 214 |
|
| 215 |
$text = str_replace(["\r\n", "\r", "\xC2\xA0"], ["\n", "\n", ' '], $node->textContent); |
| 216 |
$text = trim($text, "\n"); |
| 217 |
if (trim($text) === '') { |
| 218 |
return ''; |
| 219 |
} |
| 220 |
|
| 221 |
$this->preBlocks[] = "```\n" . $text . "\n```"; |
| 222 |
|
| 223 |
return "\n\n" . self::PRE . (count($this->preBlocks) - 1) . self::PRE . "\n\n"; |
| 224 |
} |
| 225 |
|
| 226 |
private function link(\DOMElement $node) |
| 227 |
{ |
| 228 |
$text = $this->inline($node); |
| 229 |
$href = trim($node->getAttribute('href')); |
| 230 |
|
| 231 |
if ($href === '' || $href[0] === '#' || stripos($href, 'javascript:') === 0) { |
| 232 |
return $text; |
| 233 |
} |
| 234 |
|
| 235 |
$bareHref = preg_replace('#^(mailto:|tel:)#i', '', $href); |
| 236 |
if ($text === '' || $text === $href || $text === $bareHref) { |
| 237 |
return $bareHref; |
| 238 |
} |
| 239 |
|
| 240 |
return '[' . $text . '](' . $href . ')'; |
| 241 |
} |
| 242 |
|
| 243 |
private function image(\DOMElement $node) |
| 244 |
{ |
| 245 |
// Tracking pixels. |
| 246 |
if (in_array($node->getAttribute('width'), ['0', '1'], true) || in_array($node->getAttribute('height'), ['0', '1'], true)) { |
| 247 |
return ''; |
| 248 |
} |
| 249 |
|
| 250 |
$alt = trim(preg_replace('/\s+/', ' ', $node->getAttribute('alt'))); |
| 251 |
$src = trim($node->getAttribute('src')); |
| 252 |
|
| 253 |
if ($src === '' || stripos($src, 'data:') === 0) { |
| 254 |
return $alt !== '' ? '[image: ' . $alt . ']' : '[image]'; |
| 255 |
} |
| 256 |
|
| 257 |
return ''; |
| 258 |
} |
| 259 |
|
| 260 |
private function listBlock(\DOMElement $node, $ordered) |
| 261 |
{ |
| 262 |
$lines = []; |
| 263 |
$number = $ordered && is_numeric($node->getAttribute('start')) ? (int) $node->getAttribute('start') : 1; |
| 264 |
|
| 265 |
foreach ($node->childNodes as $child) { |
| 266 |
if (!$child instanceof \DOMElement || strtolower($child->tagName) !== 'li') { |
| 267 |
// Stray text or a nested list without an <li> wrapper. |
| 268 |
$stray = trim($this->node($child)); |
| 269 |
if ($stray !== '') { |
| 270 |
$lines[] = $stray; |
| 271 |
} |
| 272 |
continue; |
| 273 |
} |
| 274 |
|
| 275 |
$prefix = $ordered ? ($number++) . '. ' : '- '; |
| 276 |
$content = $this->itemContent($child); |
| 277 |
$indent = str_repeat(self::INDENT, strlen($prefix)); |
| 278 |
|
| 279 |
$lines[] = $prefix . str_replace("\n", "\n" . $indent, $content); |
| 280 |
} |
| 281 |
|
| 282 |
return $lines ? "\n\n" . implode("\n", $lines) . "\n\n" : ''; |
| 283 |
} |
| 284 |
|
| 285 |
private function blockquote(\DOMElement $node) |
| 286 |
{ |
| 287 |
$content = trim(self::tidy($this->children($node))); |
| 288 |
if ($content === '') { |
| 289 |
return ''; |
| 290 |
} |
| 291 |
|
| 292 |
return "\n\n> " . str_replace("\n", "\n> ", $content) . "\n\n"; |
| 293 |
} |
| 294 |
|
| 295 |
private function table(\DOMElement $node) |
| 296 |
{ |
| 297 |
$rows = []; |
| 298 |
foreach ($this->tableRows($node) as $tr) { |
| 299 |
$cells = []; |
| 300 |
foreach ($tr->childNodes as $cell) { |
| 301 |
if ($cell instanceof \DOMElement && in_array(strtolower($cell->tagName), ['td', 'th'], true)) { |
| 302 |
$cells[] = $cell; |
| 303 |
} |
| 304 |
} |
| 305 |
if ($cells) { |
| 306 |
$rows[] = $cells; |
| 307 |
} |
| 308 |
} |
| 309 |
|
| 310 |
if (!$rows) { |
| 311 |
return $this->children($node); |
| 312 |
} |
| 313 |
|
| 314 |
$columns = max(array_map('count', $rows)); |
| 315 |
|
| 316 |
// Email layout tables (nested tables, single column) read better as plain blocks. |
| 317 |
if ($columns < 2 || $node->getElementsByTagName('table')->length > 0) { |
| 318 |
$out = ''; |
| 319 |
foreach ($rows as $cells) { |
| 320 |
foreach ($cells as $cell) { |
| 321 |
$out .= self::SOFT . trim($this->children($cell)) . self::SOFT; |
| 322 |
} |
| 323 |
} |
| 324 |
return "\n" . $out . "\n"; |
| 325 |
} |
| 326 |
|
| 327 |
$lines = []; |
| 328 |
foreach ($rows as $index => $cells) { |
| 329 |
$values = []; |
| 330 |
foreach ($cells as $cell) { |
| 331 |
$values[] = str_replace('|', '\|', $this->inline($cell)); |
| 332 |
} |
| 333 |
$values = array_pad($values, $columns, ''); |
| 334 |
$lines[] = '| ' . implode(' | ', $values) . ' |'; |
| 335 |
|
| 336 |
if ($index === 0) { |
| 337 |
$lines[] = '|' . str_repeat(' --- |', $columns); |
| 338 |
} |
| 339 |
} |
| 340 |
|
| 341 |
return "\n\n" . implode("\n", $lines) . "\n\n"; |
| 342 |
} |
| 343 |
|
| 344 |
/** |
| 345 |
* Rows of this table only, not of tables nested inside its cells. |
| 346 |
* |
| 347 |
* @return \DOMElement[] |
| 348 |
*/ |
| 349 |
private function tableRows(\DOMElement $table) |
| 350 |
{ |
| 351 |
$rows = []; |
| 352 |
foreach ($table->childNodes as $child) { |
| 353 |
if (!$child instanceof \DOMElement) { |
| 354 |
continue; |
| 355 |
} |
| 356 |
$tag = strtolower($child->tagName); |
| 357 |
if ($tag === 'tr') { |
| 358 |
$rows[] = $child; |
| 359 |
} elseif (in_array($tag, ['thead', 'tbody', 'tfoot'], true)) { |
| 360 |
foreach ($child->childNodes as $tr) { |
| 361 |
if ($tr instanceof \DOMElement && strtolower($tr->tagName) === 'tr') { |
| 362 |
$rows[] = $tr; |
| 363 |
} |
| 364 |
} |
| 365 |
} |
| 366 |
} |
| 367 |
|
| 368 |
return $rows; |
| 369 |
} |
| 370 |
} |
| 371 |
|