PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.15
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.15
2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 2.6.4 2.6.3 2.5.23 All 138 releases
metasync / includes / class-metasync-html-to-markdown.php

class-metasync-html-to-markdown.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.15, at includes/class-metasync-html-to-markdown.php

456 lines 15.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // If this file is called directly, abort.
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * HTML to Markdown converter utility.
9 *
10 * Reusable utility class for converting WordPress/HTML content into
11 * clean markdown. Used by the LLMs.txt generator and the
12 * wordpress_get_post_markdown MCP tool.
13 *
14 * @package Metasync
15 * @subpackage Metasync/includes
16 */
17 class Metasync_Html_To_Markdown
18 {
19 /**
20 * Convert an HTML string to markdown.
21 *
22 * @param string $html Raw HTML.
23 * @return string Markdown output.
24 */
25 public static function convert($html)
26 {
27 if (!is_string($html) || $html === '') {
28 return '';
29 }
30
31 // Normalise whitespace and strip dangerous / noisy elements first.
32 $html = preg_replace('/<!--(.|\s)*?-->/', '', $html);
33 $html = preg_replace('#<script\b[^>]*>(.*?)</script>#is', '', $html);
34 $html = preg_replace('#<style\b[^>]*>(.*?)</style>#is', '', $html);
35 $html = preg_replace('#<noscript\b[^>]*>(.*?)</noscript>#is', '', $html);
36
37 // Convert YouTube/Vimeo iframes to markdown links (must run before generic iframe strip).
38 $html = preg_replace_callback(
39 '#<iframe\b[^>]*src=["\']([^"\']+)["\'][^>]*(?:title=["\']([^"\']*)["\'])?[^>]*>.*?</iframe>#is',
40 function ($m) {
41 $src = $m[1];
42 $title = isset($m[2]) && $m[2] !== '' ? $m[2] : 'Video';
43 if (preg_match('#(youtube\.com|youtu\.be|vimeo\.com)#i', $src)) {
44 return "\n[" . $title . '](' . $src . ")\n";
45 }
46 return '';
47 },
48 $html
49 );
50
51 // Convert <br> to newline and <hr> to ---.
52 $html = preg_replace('#<br\s*/?>#i', "\n", $html);
53 $html = preg_replace('#<hr\s*/?>#i', "\n\n---\n\n", $html);
54
55 // Headings h1-h6.
56 for ($i = 1; $i <= 6; $i++) {
57 $hashes = str_repeat('#', $i);
58 $html = preg_replace_callback(
59 '#<h' . $i . '\b[^>]*>(.*?)</h' . $i . '>#is',
60 function ($m) use ($hashes) {
61 $text = self::strip_tags_preserve(trim($m[1]));
62 return "\n\n" . $hashes . ' ' . $text . "\n\n";
63 },
64 $html
65 );
66 }
67
68 // Bold.
69 $html = preg_replace_callback(
70 '#<(?:strong|b)\b[^>]*>(.*?)</(?:strong|b)>#is',
71 function ($m) { return '**' . trim(self::strip_tags_preserve($m[1])) . '**'; },
72 $html
73 );
74
75 // Italic.
76 $html = preg_replace_callback(
77 '#<(?:em|i)\b[^>]*>(.*?)</(?:em|i)>#is',
78 function ($m) { return '*' . trim(self::strip_tags_preserve($m[1])) . '*'; },
79 $html
80 );
81
82 // Images -> ![alt](src).
83 $html = preg_replace_callback(
84 '#<img\b([^>]*)/?>#i',
85 function ($m) {
86 $attrs = $m[1];
87 $alt = '';
88 $src = '';
89 if (preg_match('#\balt=["\']([^"\']*)["\']#i', $attrs, $a)) {
90 $alt = $a[1];
91 }
92 if (preg_match('#\bsrc=["\']([^"\']+)["\']#i', $attrs, $s)) {
93 $src = $s[1];
94 }
95 if ($src === '') {
96 return '';
97 }
98 return '![' . $alt . '](' . $src . ')';
99 },
100 $html
101 );
102
103 // Links -> [text](url).
104 $html = preg_replace_callback(
105 '#<a\b[^>]*href=["\']([^"\']+)["\'][^>]*>(.*?)</a>#is',
106 function ($m) {
107 $href = $m[1];
108 $text = trim(self::strip_tags_preserve($m[2]));
109 if ($text === '') {
110 $text = $href;
111 }
112 return '[' . $text . '](' . $href . ')';
113 },
114 $html
115 );
116
117 // Fenced code blocks.
118 $html = preg_replace_callback(
119 '#<pre\b[^>]*>\s*<code\b[^>]*>(.*?)</code>\s*</pre>#is',
120 function ($m) {
121 $code = html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
122 return "\n\n```\n" . trim($code) . "\n```\n\n";
123 },
124 $html
125 );
126 $html = preg_replace_callback(
127 '#<pre\b[^>]*>(.*?)</pre>#is',
128 function ($m) {
129 $code = html_entity_decode(strip_tags($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8');
130 return "\n\n```\n" . trim($code) . "\n```\n\n";
131 },
132 $html
133 );
134
135 // Inline code.
136 $html = preg_replace_callback(
137 '#<code\b[^>]*>(.*?)</code>#is',
138 function ($m) {
139 $code = html_entity_decode(strip_tags($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8');
140 return '`' . $code . '`';
141 },
142 $html
143 );
144
145 // Blockquotes.
146 $html = preg_replace_callback(
147 '#<blockquote\b[^>]*>(.*?)</blockquote>#is',
148 function ($m) {
149 $inner = trim(self::strip_tags_preserve($m[1]));
150 $lines = preg_split('/\r?\n/', $inner);
151 $lines = array_map(function ($l) { return '> ' . ltrim($l); }, $lines);
152 return "\n\n" . implode("\n", $lines) . "\n\n";
153 },
154 $html
155 );
156
157 // Tables.
158 $html = preg_replace_callback(
159 '#<table\b[^>]*>(.*?)</table>#is',
160 [self::class, 'convert_table'],
161 $html
162 );
163
164 // Lists (nested supported via recursion).
165 $html = self::convert_lists($html);
166
167 // Paragraphs.
168 $html = preg_replace_callback(
169 '#<p\b[^>]*>(.*?)</p>#is',
170 function ($m) {
171 $text = trim(self::strip_tags_preserve($m[1]));
172 if ($text === '') {
173 return '';
174 }
175 return "\n\n" . $text . "\n\n";
176 },
177 $html
178 );
179
180 // Strip any remaining tags but keep the inner text.
181 $html = self::strip_tags_preserve($html);
182
183 // Decode entities after stripping tags.
184 $html = html_entity_decode($html, ENT_QUOTES | ENT_HTML5, 'UTF-8');
185
186 // Collapse excessive blank lines.
187 $html = preg_replace("/\n{3,}/", "\n\n", $html);
188
189 return trim($html);
190 }
191
192 /**
193 * Convert a WordPress post to markdown.
194 *
195 * Runs `the_content` filter and do_shortcode() first so page-builder
196 * content (Elementor, Divi, Beaver Builder, etc.) is rendered before
197 * conversion.
198 *
199 * @param int $post_id Post ID.
200 * @param array $options {
201 * Optional. {
202 * @type bool $include_frontmatter Prepend YAML frontmatter. Default true.
203 * @type bool $include_featured_image Prepend featured image as markdown. Default true.
204 * }
205 * }
206 * @return string Markdown output (empty string if post not found).
207 */
208 public static function convert_post($post_id, $options = [])
209 {
210 $post = get_post(absint($post_id));
211 if (!$post) {
212 return '';
213 }
214
215 $options = wp_parse_args($options, [
216 'include_frontmatter' => true,
217 'include_featured_image' => true,
218 ]);
219
220 $raw_content = $post->post_content;
221 $filtered = apply_filters('the_content', $raw_content);
222 $filtered = do_shortcode($filtered);
223
224 $markdown = self::convert($filtered);
225
226 $prefix = '';
227
228 if (!empty($options['include_frontmatter'])) {
229 $author_name = '';
230 if (!empty($post->post_author)) {
231 $author = get_userdata($post->post_author);
232 if ($author && !empty($author->display_name)) {
233 $author_name = $author->display_name;
234 }
235 }
236
237 $frontmatter = "---\n";
238 $frontmatter .= 'title: ' . self::yaml_escape($post->post_title) . "\n";
239 $frontmatter .= 'slug: ' . self::yaml_escape($post->post_name) . "\n";
240 $frontmatter .= 'date: ' . self::yaml_escape($post->post_date) . "\n";
241 if ($author_name !== '') {
242 $frontmatter .= 'author: ' . self::yaml_escape($author_name) . "\n";
243 }
244 $frontmatter .= "---\n\n";
245 $prefix .= $frontmatter;
246 }
247
248 if (!empty($options['include_featured_image'])) {
249 $thumb_id = get_post_thumbnail_id($post_id);
250 if ($thumb_id) {
251 $src = wp_get_attachment_url($thumb_id);
252 if ($src) {
253 $alt = get_post_meta($thumb_id, '_wp_attachment_image_alt', true);
254 if (!is_string($alt)) {
255 $alt = '';
256 }
257 if ($alt === '') {
258 $alt = $post->post_title;
259 }
260 $prefix .= '![' . $alt . '](' . $src . ")\n\n";
261 }
262 }
263 }
264
265 return $prefix . $markdown;
266 }
267
268 /**
269 * Escape a string for safe use as a YAML scalar value.
270 *
271 * @param string $value
272 * @return string
273 */
274 private static function yaml_escape($value)
275 {
276 $value = (string) $value;
277 // Wrap in double quotes and escape backslashes and quotes.
278 $escaped = str_replace(['\\', '"'], ['\\\\', '\\"'], $value);
279 return '"' . $escaped . '"';
280 }
281
282 /**
283 * Strip HTML tags but leave a usable inline form (decodes entities later).
284 *
285 * @param string $html
286 * @return string
287 */
288 private static function strip_tags_preserve($html)
289 {
290 // Replace block-ish containers with newlines so stripping doesn't mash content.
291 $html = preg_replace('#</(?:div|section|article|aside|header|footer|main|nav|figure|figcaption)>#i', "\n", $html);
292 $html = preg_replace('#<(?:div|section|article|aside|header|footer|main|nav|figure|figcaption)\b[^>]*>#i', '', $html);
293 // Strip remaining tags.
294 return strip_tags($html);
295 }
296
297 /**
298 * Convert a <table> block into a pipe-style markdown table.
299 *
300 * @param array $matches Regex matches from convert(); index 1 is inner HTML.
301 * @return string
302 */
303 private static function convert_table($matches)
304 {
305 $inner = $matches[1];
306
307 // Flatten any thead/tbody wrappers for easier row extraction.
308 $inner = preg_replace('#</?(?:thead|tbody|tfoot)\b[^>]*>#i', '', $inner);
309
310 if (!preg_match_all('#<tr\b[^>]*>(.*?)</tr>#is', $inner, $rows_match)) {
311 return '';
312 }
313
314 $rows = [];
315 $header_used = false;
316 foreach ($rows_match[1] as $row_index => $row_html) {
317 $cells = [];
318 $is_header_row = false;
319 if (preg_match_all('#<(th|td)\b[^>]*>(.*?)</\1>#is', $row_html, $cell_match, PREG_SET_ORDER)) {
320 foreach ($cell_match as $cm) {
321 $cell_text = trim(self::strip_tags_preserve($cm[2]));
322 $cell_text = html_entity_decode($cell_text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
323 $cell_text = str_replace(['|', "\n", "\r"], ['\\|', ' ', ' '], $cell_text);
324 $cells[] = $cell_text;
325 if (strtolower($cm[1]) === 'th') {
326 $is_header_row = true;
327 }
328 }
329 }
330 if (empty($cells)) {
331 continue;
332 }
333 $rows[] = ['cells' => $cells, 'is_header' => $is_header_row];
334 }
335
336 if (empty($rows)) {
337 return '';
338 }
339
340 $output = "\n\n";
341 $col_count = 0;
342 foreach ($rows as $row) {
343 if (count($row['cells']) > $col_count) {
344 $col_count = count($row['cells']);
345 }
346 }
347
348 // Header row: use first header row, else synthesise from first row.
349 $header_row = null;
350 foreach ($rows as $idx => $row) {
351 if ($row['is_header']) {
352 $header_row = $row;
353 unset($rows[$idx]);
354 break;
355 }
356 }
357 if ($header_row === null) {
358 $header_row = array_shift($rows);
359 }
360
361 $header_cells = array_pad($header_row['cells'], $col_count, '');
362 $output .= '| ' . implode(' | ', $header_cells) . " |\n";
363 $output .= '|' . str_repeat(' --- |', $col_count) . "\n";
364
365 foreach ($rows as $row) {
366 $cells = array_pad($row['cells'], $col_count, '');
367 $output .= '| ' . implode(' | ', $cells) . " |\n";
368 }
369
370 return $output . "\n";
371 }
372
373 /**
374 * Convert <ul>/<ol> lists to markdown, preserving nesting.
375 *
376 * Iteratively replaces the inner-most list until no lists remain.
377 *
378 * @param string $html
379 * @return string
380 */
381 private static function convert_lists($html)
382 {
383 // Loop until there are no lists left (handles nesting).
384 $guard = 0;
385 while ($guard < 50 && preg_match('#<(ul|ol)\b[^>]*>((?:(?!<ul|<ol).)*?)</\1>#is', $html, $m, PREG_OFFSET_CAPTURE)) {
386 $tag = strtolower($m[1][0]);
387 $inner = $m[2][0];
388 $full = $m[0][0];
389 $offset = $m[0][1];
390
391 $converted = self::render_list_items($inner, $tag, 0);
392 $html = substr($html, 0, $offset) . "\n\n" . $converted . "\n\n" . substr($html, $offset + strlen($full));
393 $guard++;
394 }
395
396 // Clean up anything left unconverted (defensive).
397 $html = preg_replace('#</?(?:ul|ol)\b[^>]*>#i', '', $html);
398 $html = preg_replace('#<li\b[^>]*>#i', "- ", $html);
399 $html = preg_replace('#</li>#i', "\n", $html);
400
401 return $html;
402 }
403
404 /**
405 * Render <li> items for a given list tag.
406 *
407 * @param string $inner Inner HTML of the list.
408 * @param string $tag 'ul' or 'ol'.
409 * @param int $depth Current nesting depth.
410 * @return string
411 */
412 private static function render_list_items($inner, $tag, $depth)
413 {
414 if (!preg_match_all('#<li\b[^>]*>(.*?)</li>#is', $inner, $items)) {
415 return '';
416 }
417
418 $out = '';
419 $indent = str_repeat(' ', max(0, $depth));
420 $counter = 1;
421
422 foreach ($items[1] as $item_html) {
423 // Recursively convert nested lists inside the <li>.
424 $nested_converted = $item_html;
425 while (preg_match('#<(ul|ol)\b[^>]*>((?:(?!<ul|<ol).)*?)</\1>#is', $nested_converted, $m, PREG_OFFSET_CAPTURE)) {
426 $inner_nested = $m[2][0];
427 $inner_tag = strtolower($m[1][0]);
428 $sub = self::render_list_items($inner_nested, $inner_tag, $depth + 1);
429 $nested_converted = substr($nested_converted, 0, $m[0][1])
430 . "\n" . $sub
431 . substr($nested_converted, $m[0][1] + strlen($m[0][0]));
432 }
433
434 $text = trim(self::strip_tags_preserve($nested_converted));
435 if ($text === '') {
436 continue;
437 }
438
439 $marker = ($tag === 'ol') ? ($counter . '.') : '-';
440 // The line itself.
441 $lines = preg_split('/\r?\n/', $text);
442 $first = array_shift($lines);
443 $out .= $indent . $marker . ' ' . ltrim($first) . "\n";
444 foreach ($lines as $line) {
445 if (trim($line) === '') {
446 continue;
447 }
448 $out .= $indent . ' ' . ltrim($line) . "\n";
449 }
450 $counter++;
451 }
452
453 return rtrim($out, "\n");
454 }
455 }
456