PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.1
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.1
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 3.5.0 3.5.1 All 200 releases
betterdocs / includes / Utils / BlockBuilder.php

BlockBuilder.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.1, at includes/Utils/BlockBuilder.php

1,035 lines 27.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Markdown / HTML → core blocks, and the FAQ block's attribute encoding.
4 *
5 * @package BetterDocs
6 * @since 4.9.0
7 */
8
9 // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- `childNodes`, `nodeType`, `nodeName`, `textContent` and `ownerDocument` are PHP's own DOM API property names; they cannot be renamed.
10
11 namespace WPDeveloper\BetterDocs\Utils;
12
13 if ( ! defined( 'ABSPATH' ) ) {
14 exit; // Exit if accessed directly.
15 }
16
17 /**
18 * An agent writes markdown. Gutenberg reads blocks. This is the seam.
19 *
20 * Everything here is static and hook-free, so it can be unit-tested without
21 * WordPress and called from an ability, a REST handler or wp-cli alike.
22 *
23 * Three jobs:
24 *
25 * 1. **Content.** {@see self::content_to_blocks()} turns markdown (via the
26 * bundled Parsedown, safe mode on) or raw HTML into serialised core blocks —
27 * `paragraph`, `heading`, `list` + `list-item`, `code`, `quote`, `image`,
28 * `table`, `separator`, and `core/html` for anything else. A doc an agent
29 * wrote therefore opens in the block editor as real, editable blocks rather
30 * than one "Classic" lump.
31 * 2. **The FAQ block.** {@see self::faq_block()} writes the object form
32 * (`[{"value":5,"label":"Install"}]`) the editor expects, escaped exactly the
33 * way Gutenberg escapes block attributes, so `parse_blocks()` round-trips and
34 * the editor does not flag the block as invalid.
35 * 3. **Repair.** {@see self::repair_faq_blocks()} rewrites blocks saved with the
36 * bare-id form (`[5]`) into the object form. The *renderer* accepts bare
37 * ids; the *editor* still shows an empty group picker for one, so
38 * a block written by hand or by an older tool is repaired on the way past.
39 *
40 * The serialised output matches what Gutenberg's own JS serialiser produces —
41 * `core/` stripped from the block name, attributes through
42 * `serialize_block_attributes()`, a newline between the opening comment and the
43 * markup, and `\n\n` between sibling blocks — because the editor's validator
44 * compares saved markup against what the block's `save()` would emit.
45 *
46 * **Slash the output before `wp_insert_post()` / `wp_update_post()`.** Block
47 * attributes are full of backslashes (`\u0022`, `\u002d\u002d`), and those two
48 * functions `wp_unslash()` their input — measured on the rig: a `betterdocs/faq`
49 * block stored directly arrived as `u0022value…`, its attribute unparseable and
50 * its group filter silently empty. Either pass `wp_slash( $content )`, or go
51 * through the REST route (`rest_do_request()` on `wp/v2/docs`), which slashes
52 * for you inside `WP_REST_Posts_Controller`. The latter is what
53 * `04-CONVENTIONS.md` requires of an ability anyway.
54 *
55 * @since 4.9.0
56 */
57 final class BlockBuilder {
58
59 /**
60 * The FAQ block's name.
61 *
62 * @since 4.9.0
63 */
64 const FAQ_BLOCK = 'betterdocs/faq';
65
66 /**
67 * The two FAQ block attributes that hold a group list.
68 *
69 * @since 4.9.0
70 *
71 * @var string[]
72 */
73 const FAQ_GROUP_ATTRIBUTES = [ 'includeFaqGroup', 'excludeFaqGroup' ];
74
75 /**
76 * Markdown → sanitised HTML.
77 *
78 * Safe mode is **on**: this text came from an AI agent over the network, and
79 * markdown allows raw HTML by definition. Parsedown escapes embedded markup
80 * and filters link/image URL schemes; `wp_kses_post()` then applies
81 * WordPress' own post allow-list, so the result is no more dangerous than
82 * anything an Author could paste into the editor. Line breaks are **not**
83 * converted to `<br>` — markdown's own rule (a blank line starts a
84 * paragraph) is what an agent writing prose expects.
85 *
86 * @since 4.9.0
87 *
88 * @param string $md Markdown.
89 * @return string HTML.
90 */
91 public static function markdown_to_html( $md ) {
92 $md = (string) $md;
93
94 if ( '' === trim( $md ) ) {
95 return '';
96 }
97
98 if ( ! class_exists( 'Parsedown' ) ) {
99 // The bundled runtime is missing; do not silently drop the content.
100 return function_exists( 'wp_kses_post' ) ? wp_kses_post( $md ) : $md;
101 }
102
103 $parsedown = new \Parsedown();
104 $parsedown->setSafeMode( true );
105 $parsedown->setBreaksEnabled( false );
106
107 $html = $parsedown->text( $md );
108
109 return function_exists( 'wp_kses_post' ) ? wp_kses_post( $html ) : $html;
110 }
111
112 /**
113 * HTML → serialised core blocks.
114 *
115 * Walks the top-level elements only. Anything without a mapping — a `<div>`,
116 * a `<details>`, an embed — becomes a `core/html` block holding its outer
117 * HTML, which is lossless and still editable.
118 *
119 * @since 4.9.0
120 *
121 * @param string $html HTML.
122 * @return string Serialised blocks, separated by a blank line.
123 */
124 public static function html_to_blocks( $html ) {
125 $html = (string) $html;
126
127 if ( '' === trim( $html ) ) {
128 return '';
129 }
130
131 $root = self::load_html( $html );
132
133 if ( null === $root ) {
134 return self::block( 'core/html', [], trim( $html ) );
135 }
136
137 $blocks = [];
138
139 foreach ( $root->childNodes as $node ) {
140 $block = self::node_to_block( $node );
141
142 if ( '' !== $block ) {
143 $blocks[] = $block;
144 }
145 }
146
147 return implode( "\n\n", $blocks );
148 }
149
150 /**
151 * Content in whichever format the caller declared → serialised blocks.
152 *
153 * `blocks` is returned untouched: the caller has asserted the string already
154 * contains block comments, and re-parsing it would only risk changing it.
155 *
156 * @since 4.9.0
157 *
158 * @param string $content Raw content.
159 * @param string $format `markdown` (default), `html` or `blocks`.
160 * @return string
161 */
162 public static function content_to_blocks( $content, $format = 'markdown' ) {
163 $content = (string) $content;
164 $format = strtolower( trim( (string) $format ) );
165
166 switch ( $format ) {
167 case 'blocks':
168 return $content;
169
170 case 'html':
171 return self::html_to_blocks( $content );
172
173 case 'markdown':
174 default:
175 return self::html_to_blocks( self::markdown_to_html( $content ) );
176 }
177 }
178
179 // -------------------------------------------------------------------------
180 // The FAQ block
181 // -------------------------------------------------------------------------
182
183 /**
184 * A `betterdocs/faq` block filtered to the given groups.
185 *
186 * `includeFaqGroup` is a JSON **string** inside the attribute object — a
187 * string containing JSON, not nested JSON — because that is the shape the
188 * block's `attributes.js` declares and the editor's group picker reads. The
189 * value is `[{"value":<int>,"label":"<term name>"}]`.
190 *
191 * @since 4.9.0
192 *
193 * @param array $groups `[ [ 'id' => 5, 'label' => 'Install' ], … ]`.
194 * @param array $extra_attrs Extra block attributes, e.g. `[ 'faqLayout' => 'modern' ]`.
195 * @return string `<!-- wp:betterdocs/faq {…} /-->`
196 */
197 public static function faq_block( array $groups, array $extra_attrs = [] ) {
198 $attrs = $extra_attrs;
199
200 $attrs['includeFaqGroup'] = self::encode_groups( $groups );
201
202 return self::block( self::FAQ_BLOCK, $attrs, '' );
203 }
204
205 /**
206 * Encode a group list the way the block stores it.
207 *
208 * @since 4.9.0
209 *
210 * @param array $groups `[ [ 'id' => 5, 'label' => 'Install' ], … ]`, or bare ids.
211 * @return string JSON string, `[]` when there is nothing to encode.
212 */
213 public static function encode_groups( array $groups ) {
214 $encoded = [];
215
216 foreach ( $groups as $group ) {
217 if ( is_array( $group ) ) {
218 $id = isset( $group['id'] ) ? (int) $group['id'] : ( isset( $group['value'] ) ? (int) $group['value'] : 0 );
219 $label = isset( $group['label'] ) ? (string) $group['label'] : '';
220 } elseif ( is_numeric( $group ) ) {
221 $id = (int) $group;
222 $label = '';
223 } else {
224 continue;
225 }
226
227 if ( $id <= 0 ) {
228 continue;
229 }
230
231 $encoded[] = [
232 'value' => $id,
233 'label' => $label,
234 ];
235 }
236
237 return self::json( $encoded );
238 }
239
240 /**
241 * The groups a parsed FAQ block filters on.
242 *
243 * Accepts every shape the attribute has ever held — the object form, a bare
244 * id array, a JSON string of either, an already-decoded array, `null` and
245 * junk — and answers with a uniform list. Same normalisation as
246 * `Block::normalize_id_list()`, but keeping the labels.
247 *
248 * @since 4.9.0
249 *
250 * @param array $block A parsed block (from `parse_blocks()`).
251 * @param string $attribute Which attribute to read.
252 * @return array `[ [ 'id' => int, 'label' => string|null ], … ]`
253 */
254 public static function parse_faq_block_groups( array $block, $attribute = 'includeFaqGroup' ) {
255 $raw = isset( $block['attrs'][ $attribute ] ) ? $block['attrs'][ $attribute ] : null;
256
257 return self::decode_groups( $raw );
258 }
259
260 /**
261 * Decode a raw `includeFaqGroup`/`excludeFaqGroup` value.
262 *
263 * @since 4.9.0
264 *
265 * @param mixed $raw String, array or anything else.
266 * @return array `[ [ 'id' => int, 'label' => string|null ], … ]`
267 */
268 public static function decode_groups( $raw ) {
269 if ( is_string( $raw ) ) {
270 $raw = json_decode( $raw, true );
271 }
272
273 if ( ! is_array( $raw ) ) {
274 return [];
275 }
276
277 $groups = [];
278
279 foreach ( $raw as $item ) {
280 if ( is_array( $item ) ) {
281 $id = isset( $item['value'] ) ? $item['value'] : ( isset( $item['id'] ) ? $item['id'] : null );
282 $label = isset( $item['label'] ) ? (string) $item['label'] : null;
283 } elseif ( is_scalar( $item ) ) {
284 $id = $item;
285 $label = null;
286 } else {
287 continue;
288 }
289
290 if ( ! is_numeric( $id ) || (int) $id <= 0 ) {
291 continue;
292 }
293
294 $groups[] = [
295 'id' => (int) $id,
296 'label' => $label,
297 ];
298 }
299
300 return $groups;
301 }
302
303 /**
304 * Every `betterdocs/faq` block in a post's content, however deeply nested.
305 *
306 * @since 4.9.0
307 *
308 * @param string $content Post content.
309 * @return array `[ [ 'block' => array, 'include' => array, 'exclude' => array ], … ]`
310 */
311 public static function find_faq_blocks( $content ) {
312 $found = [];
313
314 self::walk_blocks(
315 parse_blocks( (string) $content ),
316 static function ( array $block ) use ( &$found ) {
317 if ( self::FAQ_BLOCK !== ( isset( $block['blockName'] ) ? $block['blockName'] : null ) ) {
318 return;
319 }
320
321 $found[] = [
322 'block' => $block,
323 'include' => self::parse_faq_block_groups( $block, 'includeFaqGroup' ),
324 'exclude' => self::parse_faq_block_groups( $block, 'excludeFaqGroup' ),
325 ];
326 }
327 );
328
329 return $found;
330 }
331
332 /**
333 * Remove every FAQ block and append fresh markup — the `replace_faq_blocks`
334 * mode of `bd-attach-faq`, and what makes that tool idempotent.
335 *
336 * Non-FAQ blocks are re-serialised from their parsed form. Core's
337 * `serialize_blocks( parse_blocks( $x ) )` is the identity on well-formed
338 * content, so a doc with no FAQ block in it comes back byte-identical apart
339 * from the appended markup.
340 *
341 * @since 4.9.0
342 *
343 * @param string $content Post content.
344 * @param string $new_markup Markup to append; `''` to only remove.
345 * @return string
346 */
347 public static function replace_faq_blocks( $content, $new_markup = '' ) {
348 $blocks = self::reject_faq_blocks( parse_blocks( (string) $content ) );
349 $kept = rtrim( serialize_blocks( $blocks ) );
350
351 return self::append_block( $kept, $new_markup );
352 }
353
354 /**
355 * Append a block to a post's content, with one blank line between.
356 *
357 * @since 4.9.0
358 *
359 * @param string $content Post content.
360 * @param string $markup Block markup.
361 * @return string
362 */
363 public static function append_block( $content, $markup ) {
364 $content = rtrim( (string) $content );
365 $markup = trim( (string) $markup );
366
367 if ( '' === $markup ) {
368 return $content;
369 }
370
371 if ( '' === $content ) {
372 return $markup;
373 }
374
375 return $content . "\n\n" . $markup;
376 }
377
378 /**
379 * Rewrite bare-id FAQ blocks into the object form.
380 *
381 * A block saved as `{"includeFaqGroup":"[5]"}` renders correctly but opens
382 * in Gutenberg with an empty group picker, because the editor's
383 * `edit.js` maps `faq.value` over the decoded array. This resolves each id to
384 * its term name through `$label_for_id` and writes the object form back.
385 *
386 * Returns the content **unchanged** — byte-identical, not merely equivalent —
387 * when nothing needed repair, so a caller can report "no change" honestly.
388 *
389 * @since 4.9.0
390 *
391 * @param string $content Post content.
392 * @param callable $label_for_id `fn( int $id ): string` — usually the term name.
393 * @return string
394 */
395 public static function repair_faq_blocks( $content, callable $label_for_id ) {
396 $content = (string) $content;
397 $blocks = parse_blocks( $content );
398 $changed = false;
399
400 $blocks = self::map_blocks(
401 $blocks,
402 static function ( array $block ) use ( $label_for_id, &$changed ) {
403 if ( self::FAQ_BLOCK !== ( isset( $block['blockName'] ) ? $block['blockName'] : null ) ) {
404 return $block;
405 }
406
407 foreach ( self::FAQ_GROUP_ATTRIBUTES as $attribute ) {
408 if ( ! isset( $block['attrs'][ $attribute ] ) ) {
409 continue;
410 }
411
412 $groups = self::decode_groups( $block['attrs'][ $attribute ] );
413
414 if ( empty( $groups ) || ! self::needs_repair( $groups ) ) {
415 continue;
416 }
417
418 $repaired = [];
419
420 foreach ( $groups as $group ) {
421 $label = ( null === $group['label'] || '' === $group['label'] )
422 ? (string) call_user_func( $label_for_id, $group['id'] )
423 : $group['label'];
424
425 $repaired[] = [
426 'id' => $group['id'],
427 'label' => $label,
428 ];
429 }
430
431 $encoded = self::encode_groups( $repaired );
432
433 if ( $encoded !== $block['attrs'][ $attribute ] ) {
434 $block['attrs'][ $attribute ] = $encoded;
435 $changed = true;
436 }
437 }
438
439 return $block;
440 }
441 );
442
443 return $changed ? serialize_blocks( $blocks ) : $content;
444 }
445
446 // -------------------------------------------------------------------------
447 // Block serialisation
448 // -------------------------------------------------------------------------
449
450 /**
451 * Serialise one block the way Gutenberg's JS serialiser does.
452 *
453 * Three details matter, and all three come from
454 * `@wordpress/blocks/src/api/serializer.js`: `core/` is stripped from the
455 * name, attributes go through the same escaping as
456 * `serialize_block_attributes()` (`--`, `<`, `>`, `&`, `\"` and `\\` become
457 * unicode escapes, so the JSON can live inside an HTML comment), and the
458 * inner markup is wrapped in newlines. Getting any of them wrong makes the
459 * editor show "This block contains unexpected or invalid content".
460 *
461 * @since 4.9.0
462 *
463 * @param string $name Full block name, e.g. `core/paragraph`.
464 * @param array $attrs Attributes; `[]` for none.
465 * @param string $inner_html Inner markup; `''` produces the void form.
466 * @return string
467 */
468 public static function block( $name, array $attrs, $inner_html ) {
469 $short = 0 === strpos( $name, 'core/' ) ? substr( $name, 5 ) : $name;
470 $serialized = empty( $attrs ) ? '' : self::serialize_attributes( $attrs ) . ' ';
471
472 if ( '' === $inner_html ) {
473 return sprintf( '<!-- wp:%s %s/-->', $short, $serialized );
474 }
475
476 return sprintf(
477 "<!-- wp:%s %s-->\n%s\n<!-- /wp:%s -->",
478 $short,
479 $serialized,
480 $inner_html,
481 $short
482 );
483 }
484
485 /**
486 * Block attributes as an HTML-comment-safe JSON string.
487 *
488 * Delegates to core's `serialize_block_attributes()` when it is loaded and
489 * reproduces it otherwise, so the unit suite — which runs without WordPress —
490 * exercises the same escaping the editor will see.
491 *
492 * @since 4.9.0
493 *
494 * @param array $attrs Attributes.
495 * @return string
496 */
497 public static function serialize_attributes( array $attrs ) {
498 if ( function_exists( 'serialize_block_attributes' ) ) {
499 return serialize_block_attributes( $attrs );
500 }
501
502 return strtr(
503 self::json( $attrs ),
504 [
505 '\\\\' => '\\u005c',
506 '--' => '\\u002d\\u002d',
507 '<' => '\\u003c',
508 '>' => '\\u003e',
509 '&' => '\\u0026',
510 '\\"' => '\\u0022',
511 ]
512 );
513 }
514
515 // -------------------------------------------------------------------------
516 // HTML → block mapping
517 // -------------------------------------------------------------------------
518
519 /**
520 * Parse an HTML fragment and return the element the nodes hang off.
521 *
522 * The `<?xml encoding>` processing instruction is how you tell libxml the
523 * fragment is UTF-8 without `mb_convert_encoding( …, 'HTML-ENTITIES' )`,
524 * which PHP 8.2 deprecates. The wrapper `<div>` gives a single root, so
525 * `documentElement` is unambiguous.
526 *
527 * @since 4.9.0
528 *
529 * @param string $html HTML fragment.
530 * @return \DOMElement|null
531 */
532 private static function load_html( $html ) {
533 if ( ! class_exists( '\DOMDocument' ) ) {
534 return null;
535 }
536
537 $dom = new \DOMDocument( '1.0', 'UTF-8' );
538 $previous = libxml_use_internal_errors( true );
539
540 $loaded = $dom->loadHTML(
541 '<?xml encoding="utf-8" ?><div>' . $html . '</div>',
542 LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
543 );
544
545 libxml_clear_errors();
546 libxml_use_internal_errors( $previous );
547
548 if ( ! $loaded || ! $dom->documentElement ) {
549 return null;
550 }
551
552 return $dom->documentElement;
553 }
554
555 /**
556 * One top-level DOM node → one serialised block.
557 *
558 * @since 4.9.0
559 *
560 * @param \DOMNode $node Node.
561 * @return string Empty when the node carries nothing.
562 */
563 private static function node_to_block( \DOMNode $node ) {
564 if ( XML_TEXT_NODE === $node->nodeType ) {
565 $text = trim( $node->textContent );
566
567 return '' === $text ? '' : self::block( 'core/paragraph', [], '<p>' . esc_html( $text ) . '</p>' );
568 }
569
570 if ( XML_COMMENT_NODE === $node->nodeType || XML_PI_NODE === $node->nodeType ) {
571 return '';
572 }
573
574 if ( XML_ELEMENT_NODE !== $node->nodeType ) {
575 return '';
576 }
577
578 $tag = strtolower( $node->nodeName );
579
580 switch ( $tag ) {
581 case 'p':
582 return self::paragraph_block( $node );
583
584 case 'h1':
585 case 'h2':
586 case 'h3':
587 case 'h4':
588 case 'h5':
589 case 'h6':
590 $level = (int) substr( $tag, 1 );
591
592 return self::block(
593 'core/heading',
594 [ 'level' => $level ],
595 sprintf( '<h%1$d class="wp-block-heading">%2$s</h%1$d>', $level, self::inner_html( $node ) )
596 );
597
598 case 'ul':
599 case 'ol':
600 return self::list_block( $node );
601
602 case 'pre':
603 return self::block(
604 'core/code',
605 [],
606 '<pre class="wp-block-code"><code>' . self::code_text( $node ) . '</code></pre>'
607 );
608
609 case 'blockquote':
610 return self::block(
611 'core/quote',
612 [],
613 '<blockquote class="wp-block-quote">' . self::inner_blocks_of( $node ) . '</blockquote>'
614 );
615
616 case 'img':
617 return self::image_block( $node );
618
619 case 'figure':
620 return self::figure_block( $node );
621
622 case 'table':
623 return self::block(
624 'core/table',
625 [],
626 '<figure class="wp-block-table">' . self::outer_html( $node ) . '</figure>'
627 );
628
629 case 'hr':
630 return self::block(
631 'core/separator',
632 [],
633 '<hr class="wp-block-separator has-alpha-channel-opacity"/>'
634 );
635
636 default:
637 return self::block( 'core/html', [], self::outer_html( $node ) );
638 }
639 }
640
641 /**
642 * A `<p>` — or, when it holds nothing but an image, an image block.
643 *
644 * Markdown's `![alt](src)` on its own line produces `<p><img …></p>`, and a
645 * paragraph-wrapped image is not what the author meant.
646 *
647 * @since 4.9.0
648 *
649 * @param \DOMNode $node The `<p>`.
650 * @return string
651 */
652 private static function paragraph_block( \DOMNode $node ) {
653 $image = self::only_child_image( $node );
654
655 if ( null !== $image ) {
656 return self::image_block( $image );
657 }
658
659 $inner = self::inner_html( $node );
660
661 if ( '' === trim( $inner ) ) {
662 return '';
663 }
664
665 return self::block( 'core/paragraph', [], '<p>' . $inner . '</p>' );
666 }
667
668 /**
669 * `ul`/`ol` → `core/list` with one `core/list-item` per `li`.
670 *
671 * A nested list becomes a `core/list` **inside** its parent list item, which
672 * is the WP ≥ 6.2 shape.
673 *
674 * @since 4.9.0
675 *
676 * @param \DOMNode $node The list element.
677 * @return string
678 */
679 private static function list_block( \DOMNode $node ) {
680 $ordered = 'ol' === strtolower( $node->nodeName );
681 $items = [];
682
683 foreach ( $node->childNodes as $child ) {
684 if ( XML_ELEMENT_NODE !== $child->nodeType || 'li' !== strtolower( $child->nodeName ) ) {
685 continue;
686 }
687
688 $text = '';
689 $nested = '';
690
691 foreach ( $child->childNodes as $part ) {
692 if ( XML_ELEMENT_NODE === $part->nodeType && in_array( strtolower( $part->nodeName ), [ 'ul', 'ol' ], true ) ) {
693 $nested .= self::list_block( $part );
694 continue;
695 }
696
697 $text .= self::outer_html( $part );
698 }
699
700 $items[] = self::block(
701 'core/list-item',
702 [],
703 '<li>' . trim( $text ) . $nested . '</li>'
704 );
705 }
706
707 if ( empty( $items ) ) {
708 return '';
709 }
710
711 $tag = $ordered ? 'ol' : 'ul';
712 $attrs = $ordered ? [ 'ordered' => true ] : [];
713
714 return self::block(
715 'core/list',
716 $attrs,
717 sprintf( '<%1$s class="wp-block-list">%2$s</%1$s>', $tag, implode( "\n\n", $items ) )
718 );
719 }
720
721 /**
722 * `img` → `core/image`.
723 *
724 * No `id` attribute: the image is a URL, not an attachment in this site's
725 * media library, and claiming an id the site does not have is worse than
726 * claiming none.
727 *
728 * @since 4.9.0
729 *
730 * @param \DOMNode $node The `<img>`.
731 * @return string
732 */
733 private static function image_block( \DOMNode $node ) {
734 $src = $node instanceof \DOMElement ? $node->getAttribute( 'src' ) : '';
735 $alt = $node instanceof \DOMElement ? $node->getAttribute( 'alt' ) : '';
736
737 if ( '' === $src ) {
738 return '';
739 }
740
741 return self::block(
742 'core/image',
743 [],
744 sprintf(
745 '<figure class="wp-block-image"><img src="%s" alt="%s"/></figure>',
746 esc_url( $src ),
747 esc_attr( $alt )
748 )
749 );
750 }
751
752 /**
753 * A `<figure>` — route it by what it wraps, so an HTML-format doc that
754 * already used figures does not become a wall of `core/html`.
755 *
756 * @since 4.9.0
757 *
758 * @param \DOMNode $node The `<figure>`.
759 * @return string
760 */
761 private static function figure_block( \DOMNode $node ) {
762 foreach ( $node->childNodes as $child ) {
763 if ( XML_ELEMENT_NODE !== $child->nodeType ) {
764 continue;
765 }
766
767 $tag = strtolower( $child->nodeName );
768
769 if ( 'img' === $tag ) {
770 return self::image_block( $child );
771 }
772
773 if ( 'table' === $tag ) {
774 return self::block(
775 'core/table',
776 [],
777 '<figure class="wp-block-table">' . self::outer_html( $child ) . '</figure>'
778 );
779 }
780 }
781
782 return self::block( 'core/html', [], self::outer_html( $node ) );
783 }
784
785 /**
786 * The inner paragraphs of a blockquote, as nested blocks.
787 *
788 * @since 4.9.0
789 *
790 * @param \DOMNode $node The `<blockquote>`.
791 * @return string
792 */
793 private static function inner_blocks_of( \DOMNode $node ) {
794 $blocks = [];
795
796 foreach ( $node->childNodes as $child ) {
797 $block = self::node_to_block( $child );
798
799 if ( '' !== $block ) {
800 $blocks[] = $block;
801 }
802 }
803
804 if ( empty( $blocks ) ) {
805 $text = trim( self::inner_html( $node ) );
806
807 if ( '' === $text ) {
808 return '';
809 }
810
811 $blocks[] = self::block( 'core/paragraph', [], '<p>' . $text . '</p>' );
812 }
813
814 return implode( "\n\n", $blocks );
815 }
816
817 /**
818 * The single `<img>` a paragraph wraps, if that is all it holds.
819 *
820 * @since 4.9.0
821 *
822 * @param \DOMNode $node The `<p>`.
823 * @return \DOMNode|null
824 */
825 private static function only_child_image( \DOMNode $node ) {
826 $image = null;
827
828 foreach ( $node->childNodes as $child ) {
829 if ( XML_TEXT_NODE === $child->nodeType ) {
830 if ( '' !== trim( $child->textContent ) ) {
831 return null;
832 }
833
834 continue;
835 }
836
837 if ( XML_ELEMENT_NODE === $child->nodeType && 'img' === strtolower( $child->nodeName ) && null === $image ) {
838 $image = $child;
839 continue;
840 }
841
842 return null;
843 }
844
845 return $image;
846 }
847
848 /**
849 * The text of a `<pre>`, entity-escaped for a `<code>` element.
850 *
851 * @since 4.9.0
852 *
853 * @param \DOMNode $node The `<pre>`.
854 * @return string
855 */
856 private static function code_text( \DOMNode $node ) {
857 return esc_html( $node->textContent );
858 }
859
860 /**
861 * A node's children, serialised.
862 *
863 * @since 4.9.0
864 *
865 * @param \DOMNode $node Node.
866 * @return string
867 */
868 private static function inner_html( \DOMNode $node ) {
869 $html = '';
870
871 foreach ( $node->childNodes as $child ) {
872 $html .= self::outer_html( $child );
873 }
874
875 return $html;
876 }
877
878 /**
879 * A node, serialised.
880 *
881 * @since 4.9.0
882 *
883 * @param \DOMNode $node Node.
884 * @return string
885 */
886 private static function outer_html( \DOMNode $node ) {
887 $html = $node->ownerDocument->saveHTML( $node );
888
889 return false === $html ? '' : $html;
890 }
891
892 // -------------------------------------------------------------------------
893 // Block-tree helpers
894 // -------------------------------------------------------------------------
895
896 /**
897 * Whether any group in the list is missing its label.
898 *
899 * @since 4.9.0
900 *
901 * @param array $groups Decoded groups.
902 * @return bool
903 */
904 private static function needs_repair( array $groups ) {
905 foreach ( $groups as $group ) {
906 if ( null === $group['label'] || '' === $group['label'] ) {
907 return true;
908 }
909 }
910
911 return false;
912 }
913
914 /**
915 * Call `$visitor` on every block, innermost included.
916 *
917 * @since 4.9.0
918 *
919 * @param array $blocks Parsed blocks.
920 * @param callable $visitor `fn( array $block ): void`.
921 * @return void
922 */
923 private static function walk_blocks( array $blocks, callable $visitor ) {
924 foreach ( $blocks as $block ) {
925 call_user_func( $visitor, $block );
926
927 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
928 self::walk_blocks( $block['innerBlocks'], $visitor );
929 }
930 }
931 }
932
933 /**
934 * Rewrite every block through `$mapper`, innermost first.
935 *
936 * @since 4.9.0
937 *
938 * @param array $blocks Parsed blocks.
939 * @param callable $mapper `fn( array $block ): array`.
940 * @return array
941 */
942 private static function map_blocks( array $blocks, callable $mapper ) {
943 $mapped = [];
944
945 foreach ( $blocks as $block ) {
946 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
947 $block['innerBlocks'] = self::map_blocks( $block['innerBlocks'], $mapper );
948 }
949
950 $mapped[] = call_user_func( $mapper, $block );
951 }
952
953 return $mapped;
954 }
955
956 /**
957 * Drop every FAQ block from a parsed tree, at any depth.
958 *
959 * A removed inner block also has to lose its `null` placeholder in the
960 * parent's `innerContent`, or `serialize_block()` walks off the end of
961 * `innerBlocks`.
962 *
963 * @since 4.9.0
964 *
965 * @param array $blocks Parsed blocks.
966 * @return array
967 */
968 private static function reject_faq_blocks( array $blocks ) {
969 $kept = [];
970
971 foreach ( $blocks as $block ) {
972 $name = isset( $block['blockName'] ) ? $block['blockName'] : null;
973
974 if ( self::FAQ_BLOCK === $name ) {
975 continue;
976 }
977
978 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
979 $before = count( $block['innerBlocks'] );
980 $block['innerBlocks'] = self::reject_faq_blocks( $block['innerBlocks'] );
981
982 if ( count( $block['innerBlocks'] ) !== $before ) {
983 $block['innerContent'] = self::rebuild_inner_content( $block, $before - count( $block['innerBlocks'] ) );
984 }
985 }
986
987 $kept[] = $block;
988 }
989
990 return $kept;
991 }
992
993 /**
994 * Drop `$removed` of the `null` placeholders from a block's `innerContent`.
995 *
996 * @since 4.9.0
997 *
998 * @param array $block The parent block.
999 * @param int $removed How many inner blocks went away.
1000 * @return array
1001 */
1002 private static function rebuild_inner_content( array $block, $removed ) {
1003 $content = isset( $block['innerContent'] ) && is_array( $block['innerContent'] ) ? $block['innerContent'] : [];
1004 $rebuilt = [];
1005
1006 foreach ( $content as $chunk ) {
1007 if ( null === $chunk && $removed > 0 ) {
1008 --$removed;
1009 continue;
1010 }
1011
1012 $rebuilt[] = $chunk;
1013 }
1014
1015 return $rebuilt;
1016 }
1017
1018 /**
1019 * `wp_json_encode()` with the two flags block attributes are written with:
1020 * unescaped slashes (a URL in an attribute stays readable) and unescaped
1021 * unicode (so `\u65e5` does not appear where `日` belongs). Both match
1022 * core's `serialize_block_attributes()`.
1023 *
1024 * @since 4.9.0
1025 *
1026 * @param mixed $value Value.
1027 * @return string
1028 */
1029 private static function json( $value ) {
1030 $json = wp_json_encode( $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
1031
1032 return false === $json ? '[]' : $json;
1033 }
1034 }
1035