PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.4
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.4
4.9.4 4.9.3 4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 All 202 releases
betterdocs / includes / Core / MarkdownRenderer.php

MarkdownRenderer.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.4, at includes/Core/MarkdownRenderer.php

897 lines 24.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace WPDeveloper\BetterDocs\Core;
4
5 if ( ! defined( 'ABSPATH' ) ) {
6 exit; // Exit if accessed directly
7 }
8
9 use WPDeveloper\BetterDocs\Utils\Database;
10
11 /**
12 * Render a doc as Markdown.
13 *
14 * AI assistants consume plain Markdown far more reliably than themed HTML, so
15 * every published doc is convertible to a clean Markdown document. Two output
16 * profiles are supported:
17 *
18 * - `endpoint` — YAML front matter + `# Title` + body. What `<doc-url>.md`
19 * serves, and what AI crawlers index.
20 * - `copy` — `# Title`, blockquoted description, `Source:` line, body.
21 * What the "Copy page" button puts on the clipboard; no YAML,
22 * because a human is about to paste this into a chat box.
23 *
24 * The body is produced by running the real `the_content` filter (so blocks,
25 * shortcodes and embeds resolve exactly as they do on the page) and then walking
26 * the resulting DOM. A node-removal pass first strips chrome that is meaningless
27 * outside a browser — code-snippet toolbars, heading anchor links, glossary
28 * tooltips, scripts.
29 *
30 * @since 4.8.0
31 */
32 class MarkdownRenderer {
33 /**
34 * Cached body Markdown (no front matter — that is assembled per context at
35 * request time, so one blob serves both profiles).
36 */
37 const META_BODY = '_betterdocs_markdown';
38
39 /** Signature the cached body was generated from. */
40 const META_SIG = '_betterdocs_markdown_sig';
41
42 /** Cache namespace for Database::get_cache_version(). */
43 const CACHE_NS = 'markdown';
44
45 /**
46 * @var Settings
47 */
48 protected $settings;
49
50 /**
51 * @var Database
52 */
53 protected $database;
54
55 /**
56 * Re-entrancy guard. `the_content` can be filtered from inside a page that is
57 * already rendering `the_content` (a shortcode placed inside a doc), which
58 * would recurse forever.
59 *
60 * @var bool
61 */
62 protected $rendering = false;
63
64 public function __construct( Settings $settings, Database $database ) {
65 $this->settings = $settings;
66 $this->database = $database;
67
68 // Bust one doc's cache when it is saved. `wp_after_insert_post` — NOT
69 // `save_post_docs` — because terms are not written yet at `save_post`, and
70 // the front matter carries the category and tags.
71 add_action( 'wp_after_insert_post', [ $this, 'flush_post' ], 10, 2 );
72
73 // Term renames and settings changes affect every doc's front matter, so bump
74 // the namespace version instead of walking the post table.
75 add_action( 'edited_doc_category', [ $this, 'flush_all' ] );
76 add_action( 'edited_doc_tag', [ $this, 'flush_all' ] );
77 add_action( 'delete_term', [ $this, 'flush_all' ] );
78 add_action( 'update_option_betterdocs_settings', [ $this, 'flush_all' ] );
79 }
80
81 /**
82 * Whether Markdown may be produced for this doc at all.
83 *
84 * The single gate both the `.md` endpoint and the AI Actions UI consult, so a
85 * doc that cannot be served also never renders a button pointing at it.
86 *
87 * @param \WP_Post|int|null $post
88 * @return bool
89 */
90 public function can_read( $post ) {
91 $post = get_post( $post );
92
93 if ( ! $post instanceof \WP_Post || 'docs' !== $post->post_type ) {
94 return false;
95 }
96
97 // Drafts, pending and private docs are readable only by someone who could
98 // read them in the admin.
99 if ( 'publish' !== $post->post_status && ! current_user_can( 'read_post', $post->ID ) ) {
100 return false;
101 }
102
103 // Deliberately no `?password=` query-arg escape hatch (unlike REST\Docs):
104 // the cookie is already present on a front-end request, and a password in a
105 // GET would leak into referrers, server logs and the LLM prompt.
106 if ( post_password_required( $post ) ) {
107 return false;
108 }
109
110 /**
111 * Gate Markdown output for a doc.
112 *
113 * BetterDocs Pro hooks its Content Restriction / Access Control check here so
114 * a restricted doc cannot be exfiltrated through `.md`.
115 *
116 * @since 4.8.0
117 *
118 * @param bool $can_read
119 * @param \WP_Post $post
120 */
121 return (bool) apply_filters( 'betterdocs_markdown_can_read', true, $post );
122 }
123
124 /**
125 * Full Markdown document for a doc.
126 *
127 * @param \WP_Post|int|null $post
128 * @param string $context `endpoint` | `copy`
129 * @return string Empty string when the caller is not entitled to the content.
130 */
131 public function render( $post, $context = 'endpoint' ) {
132 $post = get_post( $post );
133
134 if ( ! $this->can_read( $post ) ) {
135 return '';
136 }
137
138 $body = $this->body( $post );
139
140 return $this->front_matter( $post, $context, $body ) . $body . "\n";
141 }
142
143 /**
144 * Body Markdown only, cached.
145 *
146 * @param \WP_Post $post
147 * @return string
148 */
149 public function body( $post ) {
150 $signature = $this->signature( $post );
151 $cacheable = $this->cacheable( $post );
152
153 if ( $cacheable ) {
154 $cached = get_post_meta( $post->ID, self::META_BODY, true );
155 if ( is_string( $cached ) && '' !== $cached
156 && $signature === get_post_meta( $post->ID, self::META_SIG, true ) ) {
157 return $cached;
158 }
159 }
160
161 $body = $this->html_to_markdown( $this->content_html( $post ) );
162
163 if ( $cacheable && '' !== $body ) {
164 update_post_meta( $post->ID, self::META_BODY, $body );
165 update_post_meta( $post->ID, self::META_SIG, $signature );
166 }
167
168 return $body;
169 }
170
171 /**
172 * Only published, unprotected, non-preview docs are worth persisting. Anything
173 * else is either transient or user-specific.
174 *
175 * The cache is shared by every reader, so it may only hold what an anonymous
176 * visitor would get. A logged-in reader's render can differ — membership
177 * plugins, "logged-in only" blocks and per-user shortcodes run inside
178 * the_content — so it is neither stored (it would be served to guests) nor
179 * read (a guest's render would be served to the member).
180 *
181 * @param \WP_Post $post
182 * @return bool
183 */
184 protected function cacheable( $post ) {
185 $cacheable = 'publish' === $post->post_status
186 && ! post_password_required( $post )
187 && ! is_preview()
188 && ! is_user_logged_in();
189
190 /**
191 * Whether a doc's rendered Markdown may be stored and reused for other
192 * readers. Return false for content that varies by visitor.
193 *
194 * @since 4.8.0
195 *
196 * @param bool $cacheable
197 * @param \WP_Post $post
198 */
199 return (bool) apply_filters( 'betterdocs_markdown_cacheable', $cacheable, $post );
200 }
201
202 /**
203 * @param \WP_Post $post
204 * @return string
205 */
206 protected function signature( $post ) {
207 return md5(
208 implode(
209 '|',
210 [
211 $post->post_content,
212 $post->post_title,
213 $post->post_modified_gmt,
214 defined( 'BETTERDOCS_VERSION' ) ? BETTERDOCS_VERSION : '',
215 get_locale(),
216 (string) $this->database->get_cache_version( self::CACHE_NS )
217 ]
218 )
219 );
220 }
221
222 /**
223 * Drop one doc's cached Markdown.
224 *
225 * @param int $post_id
226 * @param \WP_Post $post
227 */
228 public function flush_post( $post_id, $post = null ) {
229 if ( $post instanceof \WP_Post && 'docs' !== $post->post_type ) {
230 return;
231 }
232 delete_post_meta( $post_id, self::META_BODY );
233 delete_post_meta( $post_id, self::META_SIG );
234 }
235
236 /**
237 * Invalidate every doc's cached Markdown by bumping the namespace version.
238 */
239 public function flush_all() {
240 $this->database->bump_cache_version( self::CACHE_NS );
241 }
242
243 /**
244 * Purge every stored Markdown blob. Called when the endpoint is switched off so
245 * a disabled feature stops occupying postmeta.
246 *
247 * @global \wpdb $wpdb
248 */
249 public function purge() {
250 global $wpdb;
251 $wpdb->delete( $wpdb->postmeta, [ 'meta_key' => self::META_BODY ] ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
252 $wpdb->delete( $wpdb->postmeta, [ 'meta_key' => self::META_SIG ] ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
253 }
254
255 /* ---------------------------------------------------------------------------
256 * Front matter
257 * ------------------------------------------------------------------------- */
258
259 /**
260 * @param \WP_Post $post
261 * @param string $context
262 * @param string $body Already-rendered body, used to derive a description.
263 * @return string
264 */
265 protected function front_matter( $post, $context, $body ) {
266 // get_the_title() runs the `the_title` filters, so an ampersand comes back as
267 // `&#038;`. Markdown is not HTML — entities have to be decoded or they show up
268 // literally in the model's context.
269 $title = $this->decode( get_the_title( $post ) );
270 $permalink = get_permalink( $post );
271 $description = $this->decode( $this->description( $post, $body ) );
272
273 $data = [
274 'title' => $title,
275 'description' => $description,
276 'url' => $permalink,
277 'updated' => get_post_modified_time( 'Y-m-d', true, $post ),
278 'category' => $this->category_path( $post ),
279 'tags' => wp_get_post_terms( $post->ID, 'doc_tag', [ 'fields' => 'names' ] )
280 ];
281
282 if ( is_wp_error( $data['tags'] ) ) {
283 $data['tags'] = [];
284 }
285 $data['tags'] = array_map( [ $this, 'decode' ], $data['tags'] );
286
287 /**
288 * Filter the Markdown front-matter data before it is serialised.
289 *
290 * @since 4.8.0
291 *
292 * @param array $data
293 * @param \WP_Post $post
294 * @param string $context `endpoint` | `copy`
295 */
296 $data = (array) apply_filters( 'betterdocs_markdown_front_matter', $data, $post, $context );
297
298 if ( 'copy' === $context ) {
299 // Human-facing: no YAML, because this is about to be pasted into a chat.
300 $out = '# ' . $data['title'] . "\n\n";
301 if ( ! empty( $data['description'] ) ) {
302 $out .= '> ' . $data['description'] . "\n\n";
303 }
304 if ( ! empty( $data['url'] ) ) {
305 $out .= 'Source: ' . $data['url'] . "\n\n";
306 }
307
308 return $out;
309 }
310
311 $out = "---\n";
312 foreach ( $data as $key => $value ) {
313 if ( is_array( $value ) ) {
314 if ( empty( $value ) ) {
315 continue;
316 }
317 $out .= $key . ":\n";
318 foreach ( $value as $item ) {
319 $out .= ' - ' . $this->yaml_scalar( $item ) . "\n";
320 }
321 continue;
322 }
323 if ( '' === (string) $value ) {
324 continue;
325 }
326 $out .= $key . ': ' . $this->yaml_scalar( $value ) . "\n";
327 }
328 $out .= "---\n\n";
329 $out .= '# ' . $data['title'] . "\n\n";
330
331 return $out;
332 }
333
334 /**
335 * The doc's own excerpt when it has one, otherwise the opening of the body.
336 *
337 * `get_the_excerpt()` is avoided on purpose — it fires the `the_excerpt`
338 * filters, which other plugins use to append read-more markup.
339 *
340 * @param \WP_Post $post
341 * @param string $body
342 * @return string
343 */
344 protected function description( $post, $body ) {
345 if ( ! empty( $post->post_excerpt ) ) {
346 return trim( wp_strip_all_tags( $post->post_excerpt ) );
347 }
348
349 return trim( wp_trim_words( wp_strip_all_tags( $body ), 40, '…' ) );
350 }
351
352 /**
353 * Full hierarchical category path, e.g. "Getting Started / Installation".
354 *
355 * @param \WP_Post $post
356 * @return string
357 */
358 protected function category_path( $post ) {
359 $terms = get_the_terms( $post->ID, 'doc_category' );
360 if ( is_wp_error( $terms ) || empty( $terms ) ) {
361 return '';
362 }
363
364 $term = $terms[0];
365 $names = [ $term->name ];
366
367 foreach ( get_ancestors( $term->term_id, 'doc_category', 'taxonomy' ) as $ancestor_id ) {
368 $ancestor = get_term( $ancestor_id, 'doc_category' );
369 if ( $ancestor && ! is_wp_error( $ancestor ) ) {
370 array_unshift( $names, $ancestor->name );
371 }
372 }
373
374 return $this->decode( implode( ' / ', $names ) );
375 }
376
377 /**
378 * Turn HTML entities back into the characters they stand for.
379 *
380 * @param string $text
381 * @return string
382 */
383 protected function decode( $text ) {
384 return html_entity_decode( (string) $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
385 }
386
387 /**
388 * Quote a YAML scalar only when it would otherwise break the mapping.
389 *
390 * @param mixed $value
391 * @return string
392 */
393 protected function yaml_scalar( $value ) {
394 $value = (string) $value;
395
396 if ( '' === $value ) {
397 return '""';
398 }
399 // Line breaks would end the scalar and start a new key; inside double
400 // quotes a backslash starts an escape, so it is escaped first.
401 if ( preg_match( '/[:#\-\[\]\{\}&\*!\|>\'"%@`\r\n\t]/', $value ) || preg_match( '/^\s|\s$/', $value ) ) {
402 return '"' . str_replace( [ '\\', '"', "\r", "\n", "\t" ], [ '\\\\', '\"', '\r', '\n', '\t' ], $value ) . '"';
403 }
404
405 return $value;
406 }
407
408 /* ---------------------------------------------------------------------------
409 * the_content pipeline
410 * ------------------------------------------------------------------------- */
411
412 /**
413 * Rendered HTML for a doc, with the filters that only make sense in a browser
414 * temporarily unhooked.
415 *
416 * @param \WP_Post $post
417 * @return string
418 */
419 protected function content_html( $post ) {
420 if ( $this->rendering ) {
421 return '';
422 }
423 $this->rendering = true;
424
425 global $wp_query;
426
427 $prev_post = isset( $GLOBALS['post'] ) ? $GLOBALS['post'] : null;
428 $prev_inloop = isset( $wp_query->in_the_loop ) ? $wp_query->in_the_loop : false;
429
430 // Dynamic blocks and Pro's glossary wrapper read the global $post, so the
431 // loop has to be standing before `the_content` runs.
432 $GLOBALS['post'] = $post;
433 if ( isset( $wp_query ) ) {
434 $wp_query->in_the_loop = true;
435 }
436 setup_postdata( $post );
437
438 // Presentation-only filters. `do_blocks`, `wpautop`, `do_shortcode` and
439 // `WP_Embed::autoembed` all stay — removing autoembed makes do_shortcode
440 // delete the bare URL outright instead of leaving it as text.
441 $removed = [
442 [ 'wptexturize', 10 ], // smart quotes corrupt CLI and code samples
443 [ 'capital_P_dangit', 11 ], // rewrites "Wordpress" inside code samples
444 [ 'wp_filter_content_tags', 12 ], // srcset/sizes/loading/decoding noise
445 [ 'convert_smilies', 20 ] // ":)" becomes <img class="wp-smiley">
446 ];
447
448 foreach ( $removed as $filter ) {
449 remove_filter( 'the_content', $filter[0], $filter[1] );
450 }
451
452 try {
453 $html = apply_filters( 'the_content', $post->post_content );
454 } finally {
455 foreach ( $removed as $filter ) {
456 add_filter( 'the_content', $filter[0], $filter[1] );
457 }
458
459 if ( isset( $wp_query ) ) {
460 $wp_query->in_the_loop = $prev_inloop;
461 }
462 $GLOBALS['post'] = $prev_post;
463 wp_reset_postdata();
464
465 $this->rendering = false;
466 }
467
468 return (string) $html;
469 }
470
471 /* ---------------------------------------------------------------------------
472 * HTML -> Markdown
473 * ------------------------------------------------------------------------- */
474
475 /**
476 * Convert an HTML fragment to Markdown by walking the DOM.
477 *
478 * @param string $html
479 * @return string
480 */
481 public function html_to_markdown( $html ) {
482 if ( '' === trim( (string) $html ) ) {
483 return '';
484 }
485
486 // ext-dom is not a declared requirement (see composer.json), and
487 // Core\SampleDocBuilder already guards for it. Degrade to plain text rather
488 // than fataling on a host without php-xml.
489 if ( ! class_exists( '\DOMDocument' ) ) {
490 return trim( wp_strip_all_tags( $html ) );
491 }
492
493 $dom = new \DOMDocument();
494 libxml_use_internal_errors( true );
495 $dom->loadHTML(
496 '<?xml encoding="utf-8"?><body>' . $html . '</body>',
497 LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
498 );
499 libxml_clear_errors();
500
501 $this->prune( $dom );
502
503 $body = $dom->getElementsByTagName( 'body' )->item( 0 );
504 $md = $body ? $this->children_md( $body ) : wp_strip_all_tags( $html );
505
506 $md = preg_replace( "/[ \t]+\n/", "\n", $md ); // trailing spaces
507 $md = preg_replace( "/\n{3,}/", "\n\n", $md ); // collapse blank runs
508
509 return trim( $md );
510 }
511
512 /**
513 * Strip browser-only chrome before the walk, and unwrap glossary tooltips so
514 * the defined term survives as plain text.
515 *
516 * @param \DOMDocument $dom
517 */
518 protected function prune( $dom ) {
519 $xpath = new \DOMXPath( $dom );
520
521 /**
522 * XPath queries for nodes removed before HTML is converted to Markdown.
523 *
524 * @since 4.8.0
525 *
526 * @param string[] $queries
527 */
528 $queries = (array) apply_filters(
529 'betterdocs_markdown_remove_selectors',
530 [
531 '//script',
532 '//style',
533 '//noscript',
534 '//svg',
535 '//form',
536 '//button',
537 '//*[@aria-hidden="true"]',
538 '//*[' . $this->has_class( 'screen-reader-text' ) . ']',
539 '//*[' . $this->has_class( 'betterdocs-sr-only' ) . ']',
540 '//*[' . $this->has_class( 'betterdocs-ai-actions' ) . ']',
541 '//*[' . $this->has_class( 'betterdocs-code-snippet-header' ) . ']',
542 '//*[' . $this->has_class( 'betterdocs-code-snippet-line-numbers' ) . ']',
543 // Heading permalink anchors injected by FrontEnd\TemplateTags.
544 '//a[' . $this->has_class( 'batterdocs-anchor' ) . ']'
545 ]
546 );
547
548 foreach ( $queries as $query ) {
549 $nodes = $xpath->query( $query );
550 if ( ! $nodes ) {
551 continue;
552 }
553 foreach ( iterator_to_array( $nodes ) as $node ) {
554 if ( $node->parentNode ) {
555 $node->parentNode->removeChild( $node );
556 }
557 }
558 }
559
560 // Glossary tooltips are BetterDocs Pro's only `the_content` injector. Keep
561 // the term, drop the tooltip markup wrapped around it.
562 $tooltips = $xpath->query( '//*[' . $this->has_class( 'glossary-tooltip-container' ) . ']' );
563 if ( $tooltips ) {
564 foreach ( iterator_to_array( $tooltips ) as $node ) {
565 $this->unwrap( $node );
566 }
567 }
568 }
569
570 /**
571 * XPath predicate matching one class name, whitespace-safe.
572 *
573 * @param string $class
574 * @return string
575 */
576 protected function has_class( $class ) {
577 return 'contains(concat(" ", normalize-space(@class), " "), " ' . $class . ' ")';
578 }
579
580 /**
581 * Replace an element with its own child nodes.
582 *
583 * @param \DOMNode $node
584 */
585 protected function unwrap( $node ) {
586 if ( ! $node->parentNode ) {
587 return;
588 }
589 while ( $node->firstChild ) {
590 $node->parentNode->insertBefore( $node->firstChild, $node );
591 }
592 $node->parentNode->removeChild( $node );
593 }
594
595 /**
596 * @param \DOMNode $node
597 * @param bool $in_code Inside <pre>/<code>, where Markdown is not escaped.
598 * @return string
599 */
600 protected function children_md( $node, $in_code = false ) {
601 $out = '';
602 foreach ( $node->childNodes as $child ) {
603 $out .= $this->node_to_md( $child, $in_code );
604 }
605
606 return $out;
607 }
608
609 /**
610 * @param \DOMNode $node
611 * @param bool $in_code
612 * @return string
613 */
614 protected function node_to_md( $node, $in_code = false ) {
615 if ( XML_TEXT_NODE === $node->nodeType ) {
616 $text = preg_replace( '/\s+/', ' ', $node->nodeValue );
617
618 return $in_code ? $text : $this->escape( $text );
619 }
620 if ( XML_ELEMENT_NODE !== $node->nodeType ) {
621 return '';
622 }
623
624 $tag = strtolower( $node->nodeName );
625
626 // BetterDocs code snippets carry their own toolbar and line-number column;
627 // the toolbar is already pruned, so emit just the code with its language.
628 if ( 'div' === $tag && $this->node_has_class( $node, 'betterdocs-code-snippet-wrapper' ) ) {
629 return $this->fence( $node->textContent, $node->getAttribute( 'data-language' ) );
630 }
631
632 $inner = $this->children_md( $node, $in_code );
633
634 switch ( $tag ) {
635 case 'h1':
636 case 'h2':
637 case 'h3':
638 case 'h4':
639 case 'h5':
640 case 'h6':
641 return "\n\n" . str_repeat( '#', (int) substr( $tag, 1 ) ) . ' ' . trim( $inner ) . "\n\n";
642
643 case 'p':
644 return "\n\n" . trim( $inner ) . "\n\n";
645
646 case 'br':
647 return " \n";
648
649 case 'hr':
650 return "\n\n---\n\n";
651
652 case 'strong':
653 case 'b':
654 return '' === trim( $inner ) ? '' : '**' . trim( $inner ) . '**';
655
656 case 'em':
657 case 'i':
658 return '' === trim( $inner ) ? '' : '*' . trim( $inner ) . '*';
659
660 case 'del':
661 case 's':
662 case 'strike':
663 return '' === trim( $inner ) ? '' : '~~' . trim( $inner ) . '~~';
664
665 case 'mark':
666 return trim( $inner );
667
668 case 'kbd':
669 case 'samp':
670 return '`' . trim( $node->textContent ) . '`';
671
672 case 'sup':
673 return '^' . trim( $inner );
674
675 case 'sub':
676 return '~' . trim( $inner );
677
678 case 'a':
679 $href = $node->getAttribute( 'href' );
680 $text = trim( $inner );
681 if ( '' === $href ) {
682 return $text;
683 }
684
685 return '[' . ( '' === $text ? $href : $text ) . '](' . $href . ')';
686
687 case 'img':
688 $src = $node->getAttribute( 'src' );
689
690 return '' === $src ? '' : '![' . $this->escape( $node->getAttribute( 'alt' ) ) . '](' . $src . ')';
691
692 case 'figure':
693 return "\n\n" . trim( $inner ) . "\n\n";
694
695 case 'figcaption':
696 return '' === trim( $inner ) ? '' : "\n" . trim( $inner ) . "\n";
697
698 case 'iframe':
699 $src = $node->getAttribute( 'src' );
700 if ( '' === $src ) {
701 return '';
702 }
703 $label = $node->getAttribute( 'title' );
704
705 return "\n\n[" . ( '' === $label ? $src : $this->escape( $label ) ) . '](' . $src . ")\n\n";
706
707 case 'code':
708 // Inline code only; fenced blocks are handled by <pre>.
709 return '`' . trim( $node->textContent ) . '`';
710
711 case 'pre':
712 return $this->fence( $node->textContent, $this->language_of( $node ) );
713
714 case 'blockquote':
715 $quote = trim( $this->children_md( $node, $in_code ) );
716
717 return "\n\n" . preg_replace( '/^/m', '> ', $quote ) . "\n\n";
718
719 case 'ul':
720 return "\n\n" . $this->list_md( $node, false ) . "\n\n";
721
722 case 'ol':
723 return "\n\n" . $this->list_md( $node, true ) . "\n\n";
724
725 case 'table':
726 return $this->table_md( $node );
727
728 case 'dl':
729 return "\n\n" . trim( $inner ) . "\n\n";
730
731 case 'dt':
732 return "\n" . '**' . trim( $inner ) . '**' . "\n";
733
734 case 'dd':
735 return ': ' . trim( $inner ) . "\n";
736
737 default:
738 return $inner; // unwrap unknown containers (div/span/section/…)
739 }
740 }
741
742 /**
743 * Escape the Markdown metacharacters that would otherwise reformat prose.
744 *
745 * Intra-word underscores are deliberately left alone: CommonMark does not treat
746 * them as emphasis, so escaping them only makes identifiers like `my_var` uglier
747 * for the model reading them.
748 *
749 * @param string $text
750 * @return string
751 */
752 protected function escape( $text ) {
753 return preg_replace( '/([\\\\`\*\[\]])/', '\\\\$1', (string) $text );
754 }
755
756 /**
757 * @param \DOMNode $node
758 * @param string $class
759 * @return bool
760 */
761 protected function node_has_class( $node, $class ) {
762 if ( ! $node instanceof \DOMElement ) {
763 return false;
764 }
765
766 return in_array( $class, preg_split( '/\s+/', trim( $node->getAttribute( 'class' ) ) ), true );
767 }
768
769 /**
770 * Language hint for a fenced block, read from the usual `language-*` class on
771 * the <pre> or its child <code>.
772 *
773 * @param \DOMNode $node
774 * @return string
775 */
776 protected function language_of( $node ) {
777 $candidates = [ $node ];
778
779 foreach ( $node->childNodes as $child ) {
780 if ( XML_ELEMENT_NODE === $child->nodeType && 'code' === strtolower( $child->nodeName ) ) {
781 $candidates[] = $child;
782 }
783 }
784
785 foreach ( $candidates as $candidate ) {
786 if ( ! $candidate instanceof \DOMElement ) {
787 continue;
788 }
789 if ( preg_match( '/(?:language|lang|brush:)[-\s]([a-z0-9#+_-]+)/i', $candidate->getAttribute( 'class' ), $m ) ) {
790 return strtolower( $m[1] );
791 }
792 }
793
794 return '';
795 }
796
797 /**
798 * @param string $code
799 * @param string $language
800 * @return string
801 */
802 protected function fence( $code, $language = '' ) {
803 $code = rtrim( ltrim( (string) $code, "\r\n" ) );
804
805 if ( '' === trim( $code ) ) {
806 return '';
807 }
808
809 // Use a longer fence when the snippet itself contains a triple backtick.
810 $fence = preg_match( '/^\s*```/m', $code ) ? '````' : '```';
811
812 return "\n\n" . $fence . $language . "\n" . $code . "\n" . $fence . "\n\n";
813 }
814
815 /**
816 * @param \DOMNode $node The <ul>/<ol> element.
817 * @param bool $ordered
818 * @return string
819 */
820 protected function list_md( $node, $ordered ) {
821 $lines = [];
822 $index = 1;
823
824 foreach ( $node->childNodes as $li ) {
825 if ( XML_ELEMENT_NODE !== $li->nodeType || 'li' !== strtolower( $li->nodeName ) ) {
826 continue;
827 }
828
829 $marker = $ordered ? ( $index++ . '. ' ) : '- ';
830 $content = trim( $this->children_md( $li ) );
831
832 // Collapse blank lines inside the item so a nested list attaches directly
833 // under its parent marker, then indent continuation lines to the marker
834 // width (2 for "- ", 3 for "1. ").
835 $content = preg_replace( "/\n{2,}/", "\n", $content );
836 $content = str_replace( "\n", "\n" . str_repeat( ' ', strlen( $marker ) ), $content );
837
838 $lines[] = $marker . $content;
839 }
840
841 return implode( "\n", $lines );
842 }
843
844 /**
845 * Convert a <table> to a GFM pipe table. Falls back to unwrapped text when the
846 * table has no rows we can line up.
847 *
848 * @param \DOMNode $node
849 * @return string
850 */
851 protected function table_md( $node ) {
852 $dom = $node->ownerDocument;
853 $rows = [];
854
855 $tr_nodes = ( new \DOMXPath( $dom ) )->query( './/tr', $node );
856 if ( ! $tr_nodes || 0 === $tr_nodes->length ) {
857 return "\n\n" . trim( $this->children_md( $node ) ) . "\n\n";
858 }
859
860 foreach ( $tr_nodes as $tr ) {
861 $cells = [];
862 foreach ( $tr->childNodes as $cell ) {
863 if ( XML_ELEMENT_NODE !== $cell->nodeType ) {
864 continue;
865 }
866 $name = strtolower( $cell->nodeName );
867 if ( 'td' !== $name && 'th' !== $name ) {
868 continue;
869 }
870 // A pipe table cell is a single line; a literal pipe must be escaped.
871 $text = trim( preg_replace( "/\s*\n\s*/", ' ', $this->children_md( $cell ) ) );
872 $cells[] = str_replace( '|', '\|', $text );
873 }
874 if ( $cells ) {
875 $rows[] = $cells;
876 }
877 }
878
879 if ( ! $rows ) {
880 return '';
881 }
882
883 $columns = max( array_map( 'count', $rows ) );
884 $header = array_shift( $rows );
885 $header = array_pad( $header, $columns, '' );
886
887 $out = '| ' . implode( ' | ', $header ) . " |\n";
888 $out .= '| ' . implode( ' | ', array_fill( 0, $columns, '---' ) ) . " |\n";
889
890 foreach ( $rows as $row ) {
891 $out .= '| ' . implode( ' | ', array_pad( $row, $columns, '' ) ) . " |\n";
892 }
893
894 return "\n\n" . rtrim( $out ) . "\n\n";
895 }
896 }
897