PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / trunk
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO vtrunk
2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 All 51 releases
thinkrank / includes / admin / importers / class-block-converter.php

class-block-converter.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO trunk, at includes/admin/importers/class-block-converter.php

796 lines 28.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Rank Math FAQ / HowTo block converter.
5 *
6 * Rewrites `rank-math/faq-block` and `rank-math/howto-block` in `post_content`
7 * into ThinkRank's own `thinkrank/faq` and `thinkrank/howto` blocks, so the
8 * questions and steps keep rendering AND regain their FAQPage / HowTo schema
9 * once Rank Math is gone (#777).
10 *
11 * Two properties matter more than anything else here:
12 *
13 * 1. **Only the matched blocks are touched.** The rewrite is a delimiter-level
14 * replacement, not a parse_blocks() / serialize_blocks() round trip. A round
15 * trip re-serializes every block in the post and quietly normalises markup
16 * all over it; here, every byte outside a Rank Math FAQ/HowTo block is left
17 * exactly as the author saved it. The blocks are located with core's own
18 * block tokenizer (WP_Block_Parser::next_token()), and a post whose Rank
19 * Math markup does not close cleanly is refused whole rather than guessed
20 * at.
21 *
22 * 2. **The generated markup is byte-identical to what the block's save.js would
23 * produce.** Gutenberg validates a block by re-running save() and comparing
24 * with the stored HTML, so markup that is merely equivalent still opens as
25 * "this block contains unexpected or invalid content". The renderers below
26 * therefore mirror `src/blocks/faq-block/save.js` and
27 * `src/blocks/howto-block/save.js` — including @wordpress/element's
28 * serializer rules: bare boolean attributes (` open`, not `open=""`),
29 * self-closing void tags with no space (`<img .../>`), inline styles as
30 * `prop:value` joined by `;` with no trailing separator, and the style
31 * attribute omitted entirely when every value is undefined.
32 *
33 * Any change to those save.js files must be mirrored here, and the byte-parity
34 * tests in tests/Unit/ are what catch it when it is not.
35 *
36 * @package ThinkRank\Admin\Importers
37 * @since 2.10.0
38 */
39
40 declare(strict_types=1);
41
42 namespace ThinkRank\Admin\Importers;
43
44 use ThinkRank\Integrations\Rank_Math_Blocks;
45
46 if (!defined('ABSPATH')) {
47 exit;
48 }
49
50 /**
51 * Block Converter Class
52 *
53 * @since 2.10.0
54 */
55 class Block_Converter {
56
57 /**
58 * Migration type slug this converter backs.
59 */
60 public const TYPE = 'content_blocks';
61
62 /**
63 * Post meta holding the pre-conversion content, written only when the site
64 * has revisions disabled and there is therefore no other way back.
65 * restore_post(), exposed as POST /thinkrank/v1/import/content-blocks/restore,
66 * puts it back.
67 */
68 public const BACKUP_META = '_thinkrank_rank_math_blocks_backup';
69
70 /**
71 * Post meta holding an md5 of the content the converter wrote, next to the
72 * backup. restore_post() compares it with the post as it stands, so a post
73 * edited after the conversion is not silently rolled back over the edits.
74 *
75 * @since 2.10.0
76 */
77 public const BACKUP_HASH_META = '_thinkrank_rank_math_blocks_backup_hash';
78
79 /**
80 * Posts converted per migrate chunk.
81 */
82 private const CHUNK_SIZE = 50;
83
84 /**
85 * Post statuses that are never scanned: a revision is a copy of a post we
86 * convert anyway, and trash / auto-draft are not published content.
87 *
88 * @var string[]
89 */
90 private const EXCLUDED_STATUSES = ['trash', 'auto-draft', 'inherit'];
91
92 /**
93 * ThinkRank FAQ block defaults that the renderer depends on. Mirrors
94 * src/blocks/faq-block/index.js.
95 */
96 private const FAQ_DEFAULTS = [
97 'firstOpen' => true,
98 'itemSpacing' => 8,
99 'itemBorderColor' => '#e2e4e7',
100 'itemBorderRadius' => 6,
101 'titleFontSize' => 17,
102 ];
103
104 /**
105 * ThinkRank HowTo block defaults. Mirrors src/blocks/howto-block/index.js.
106 */
107 private const HOWTO_DEFAULTS = [
108 'showNumbers' => true,
109 'stepSpacing' => 12,
110 'stepBorderRadius' => 6,
111 'stepTitleFontSize' => 17,
112 ];
113
114 /**
115 * How many posts still carry a convertible Rank Math block.
116 *
117 * @return int
118 */
119 public static function count_posts(): int {
120 global $wpdb;
121
122 // where_clause() is built entirely from $wpdb->prepare() fragments and
123 // esc_sql()'d literals, so there is no caller input left to place; the
124 // sniff cannot see through the helper.
125 $sql = "SELECT COUNT(ID) FROM {$wpdb->posts} WHERE " . self::where_clause();
126
127 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared
128 return (int) $wpdb->get_var($sql);
129 }
130
131 /**
132 * One page of post ids still carrying a convertible block.
133 *
134 * Ordered by ID so paging is stable while earlier pages are being written.
135 *
136 * `$per_page` is caller-supplied because the exporter decides whether
137 * another page follows by comparing the returned row count against its own
138 * `chunk_size`. A converter paging in smaller units than the exporter
139 * expects would look like a short final page on the very first call, and
140 * every post after it would be dropped without a word.
141 *
142 * @param int $page Page number (1-indexed).
143 * @param int|null $per_page Rows per page; defaults to this class's chunk size.
144 * @return int[]
145 */
146 public static function get_post_ids(int $page, ?int $per_page = null): array {
147 global $wpdb;
148
149 $page = max(1, $page);
150 $per_page = max(1, $per_page ?? self::CHUNK_SIZE);
151 $offset = ($page - 1) * $per_page;
152
153 // As above: the only caller-supplied values here are the two integers,
154 // and both are passed as placeholders.
155 $sql = "SELECT ID FROM {$wpdb->posts} WHERE " . self::where_clause()
156 . ' ORDER BY ID ASC LIMIT %d OFFSET %d';
157
158 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
159 $ids = $wpdb->get_col($wpdb->prepare($sql, $per_page, $offset));
160
161 return array_map('intval', $ids ?: []);
162 }
163
164 /**
165 * Number of posts handled per chunk, so callers can page in step.
166 *
167 * @return int
168 */
169 public static function chunk_size(): int {
170 return self::CHUNK_SIZE;
171 }
172
173 /**
174 * The shared WHERE clause for "this post holds a Rank Math FAQ/HowTo block".
175 *
176 * Matches on the opening block delimiter rather than the rendered class, so
177 * a post whose block was already converted stops matching immediately.
178 *
179 * @return string
180 */
181 private static function where_clause(): string {
182 global $wpdb;
183
184 $statuses = implode(
185 ',',
186 array_map(
187 static fn(string $status): string => "'" . esc_sql($status) . "'",
188 self::EXCLUDED_STATUSES
189 )
190 );
191
192 $likes = [];
193 foreach (array_keys(Rank_Math_Blocks::BLOCK_MAP) as $block_name) {
194 $likes[] = $wpdb->prepare(
195 'post_content LIKE %s',
196 '%' . $wpdb->esc_like('<!-- wp:' . $block_name) . '%'
197 );
198 }
199
200 return '(' . implode(' OR ', $likes) . ")"
201 . " AND post_type != 'revision'"
202 . " AND post_status NOT IN ({$statuses})";
203 }
204
205 /**
206 * Convert one post in place.
207 *
208 * A post with nothing left to convert is reported as `unchanged` and is
209 * never written, which is what makes re-running the migration free of extra
210 * revisions. A post whose Rank Math markup cannot be converted safely
211 * (an unclosed block, attributes that are not JSON, a PCRE failure) is
212 * reported as `error` and also never written: it needs a human, and
213 * counting it as a skip would hide it among the posts that were simply
214 * already done.
215 *
216 * @param int $post_id Post id.
217 * @return array{status:string,converted:int,message:string}
218 */
219 public static function convert_post(int $post_id): array {
220 $post = get_post($post_id);
221 if (!$post instanceof \WP_Post) {
222 return ['status' => 'error', 'converted' => 0, 'message' => 'Post not found.'];
223 }
224
225 $result = self::convert_content((string) $post->post_content);
226
227 if ('' !== $result['error']) {
228 return [
229 'status' => 'error',
230 'converted' => 0,
231 'message' => sprintf('Post %d left unchanged: %s', $post_id, $result['error']),
232 ];
233 }
234
235 if (0 === $result['converted']) {
236 return ['status' => 'unchanged', 'converted' => 0, 'message' => ''];
237 }
238
239 // Revisions are the natural undo. Where the site has turned them off,
240 // stash the original once so the conversion is still reversible; never
241 // overwrite an earlier backup, or a second run would bury the original.
242 //
243 // update_post_meta() unslashes its value, so the raw content has to be
244 // slashed first. Without it every `"` / `<` escape in the
245 // block attribute JSON lost its backslash and the backup could not be
246 // restored into a valid post.
247 $backup = !wp_revisions_enabled($post) && '' === (string) get_post_meta($post_id, self::BACKUP_META, true);
248 if ($backup) {
249 update_post_meta($post_id, self::BACKUP_META, wp_slash($post->post_content));
250 }
251
252 $updated = wp_update_post(
253 [
254 'ID' => $post_id,
255 'post_content' => wp_slash($result['content']),
256 ],
257 true
258 );
259
260 if (is_wp_error($updated)) {
261 // Nothing was written, so a backup of it would only make a later
262 // restore look necessary when it is not.
263 if ($backup) {
264 delete_post_meta($post_id, self::BACKUP_META);
265 }
266
267 return [
268 'status' => 'error',
269 'converted' => 0,
270 'message' => $updated->get_error_message(),
271 ];
272 }
273
274 if ($backup) {
275 // Hash what was actually stored, not what we asked for: content
276 // filters (kses for a user without unfiltered_html) may have
277 // adjusted it on the way in.
278 $saved = get_post($post_id);
279 $stored = $saved instanceof \WP_Post ? (string) $saved->post_content : $result['content'];
280 update_post_meta($post_id, self::BACKUP_HASH_META, md5($stored));
281 }
282
283 return ['status' => 'converted', 'converted' => $result['converted'], 'message' => ''];
284 }
285
286 /**
287 * Put a converted post back the way it was before the conversion.
288 *
289 * Only posts converted while revisions were disabled carry a backup; on
290 * every other site the post's revision history is the undo. A post edited
291 * since the conversion is refused with `modified` unless `$force` is set,
292 * because restoring it would throw those edits away.
293 *
294 * @since 2.10.0
295 *
296 * @param int $post_id Post id.
297 * @param bool $force Restore even when the post changed after conversion.
298 * @return array{status:string,message:string} Status is one of `restored`,
299 * `no_backup`, `modified` or `error`.
300 */
301 public static function restore_post(int $post_id, bool $force = false): array {
302 $backup = get_post_meta($post_id, self::BACKUP_META, true);
303 if (!is_string($backup) || '' === $backup) {
304 return ['status' => 'no_backup', 'message' => ''];
305 }
306
307 $post = get_post($post_id);
308 if (!$post instanceof \WP_Post) {
309 return ['status' => 'error', 'message' => 'Post not found.'];
310 }
311
312 $hash = (string) get_post_meta($post_id, self::BACKUP_HASH_META, true);
313 if (!$force && md5((string) $post->post_content) !== $hash) {
314 return [
315 'status' => 'modified',
316 'message' => sprintf('Post %d was edited after the conversion; pass force to restore it anyway.', $post_id),
317 ];
318 }
319
320 $updated = wp_update_post(
321 [
322 'ID' => $post_id,
323 'post_content' => wp_slash($backup),
324 ],
325 true
326 );
327
328 if (is_wp_error($updated)) {
329 return ['status' => 'error', 'message' => $updated->get_error_message()];
330 }
331
332 delete_post_meta($post_id, self::BACKUP_META);
333 delete_post_meta($post_id, self::BACKUP_HASH_META);
334
335 return ['status' => 'restored', 'message' => ''];
336 }
337
338 /**
339 * One page of post ids that still hold a conversion backup.
340 *
341 * Keyset-paged on the post id rather than by offset: a restored post drops
342 * out of the set, so an offset would skip rows, while a `modified` post
343 * stays in it and would otherwise be returned forever.
344 *
345 * @since 2.10.0
346 *
347 * @param int $after Only ids greater than this.
348 * @param int $limit Maximum ids returned.
349 * @return int[]
350 */
351 public static function get_backup_post_ids(int $after = 0, int $limit = 50): array {
352 global $wpdb;
353
354 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
355 $ids = $wpdb->get_col(
356 $wpdb->prepare(
357 "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s AND post_id > %d ORDER BY post_id ASC LIMIT %d",
358 self::BACKUP_META,
359 max(0, $after),
360 max(1, $limit)
361 )
362 );
363
364 return array_map('intval', is_array($ids) ? $ids : []);
365 }
366
367 /**
368 * Rewrite every convertible Rank Math block in a content string.
369 *
370 * The blocks are found with core's block tokenizer, so a delimiter is
371 * recognised (and its attribute JSON decoded) exactly as the editor would.
372 * Each Rank Math block's span runs from its opener to its closer, which
373 * must be the very next delimiter: these blocks have no inner blocks, so
374 * anything else in between means the markup is broken. The previous regex
375 * let a block with no closer run on to the next Rank Math block's closer
376 * and replaced everything in between, author paragraphs included.
377 *
378 * On any structural problem, or a PCRE failure inside the tokenizer, the
379 * content is returned untouched with `error` set; nothing is converted
380 * partially.
381 *
382 * @param string $content Post content.
383 * @return array{content:string,converted:int,error:string}
384 */
385 public static function convert_content(string $content): array {
386 $unchanged = ['content' => $content, 'converted' => 0, 'error' => ''];
387
388 if ('' === $content || !class_exists('WP_Block_Parser')) {
389 return $unchanged;
390 }
391
392 $spans = self::find_source_blocks($content);
393
394 if (is_string($spans)) {
395 return ['content' => $content, 'converted' => 0, 'error' => $spans];
396 }
397
398 if (empty($spans)) {
399 return $unchanged;
400 }
401
402 $out = '';
403 $cursor = 0;
404 $converted = 0;
405
406 foreach ($spans as $span) {
407 $rendered = self::render_block($span['name'], $span['attrs']);
408 if (null === $rendered) {
409 continue;
410 }
411
412 $out .= substr($content, $cursor, $span['start'] - $cursor) . $rendered;
413 $cursor = $span['end'];
414 $converted++;
415 }
416
417 if (0 === $converted) {
418 return $unchanged;
419 }
420
421 return [
422 'content' => $out . substr($content, $cursor),
423 'converted' => $converted,
424 'error' => '',
425 ];
426 }
427
428 /**
429 * Byte spans of every convertible block, in document order.
430 *
431 * @param string $content Post content.
432 * @return array<int,array{start:int,end:int,name:string,attrs:array<string,mixed>}>|string
433 * The spans, or a message describing why the content is unsafe to convert.
434 */
435 private static function find_source_blocks(string $content) {
436 $parser = new \WP_Block_Parser();
437 $parser->document = $content;
438 $parser->offset = 0;
439
440 $spans = [];
441
442 while (true) {
443 list($type, $name, $attrs, $start, $length) = $parser->next_token();
444
445 if ('no-more-tokens' === $type) {
446 // next_token() reports a PCRE failure (backtrack or JIT stack
447 // limit on a very large post) as the end of the document.
448 // Taking it at its word would convert only the blocks before
449 // the failure point and report the rest as done.
450 if (PREG_NO_ERROR !== preg_last_error()) {
451 return 'the block tokenizer failed (' . self::pcre_error_name() . ').';
452 }
453 break;
454 }
455
456 $parser->offset = $start + $length;
457
458 // A stray Rank Math closer with no opener has nothing to convert,
459 // and removing it is not ours to decide.
460 if ('block-closer' === $type || !Rank_Math_Blocks::is_source_block((string) $name)) {
461 continue;
462 }
463
464 // Attribute text that is not valid JSON decodes to null. Mapping
465 // that as "no attributes" would convert a block with questions in
466 // it into an empty one and delete them.
467 if (!is_array($attrs)) {
468 return sprintf('%s at byte %d has attributes that are not valid JSON.', $name, $start);
469 }
470
471 if ('void-block' === $type) {
472 $spans[] = ['start' => $start, 'end' => $start + $length, 'name' => $name, 'attrs' => $attrs];
473 continue;
474 }
475
476 list($next_type, $next_name, , $next_start, $next_length) = $parser->next_token();
477
478 if ('block-closer' !== $next_type || $next_name !== $name) {
479 if ('no-more-tokens' === $next_type && PREG_NO_ERROR !== preg_last_error()) {
480 return 'the block tokenizer failed (' . self::pcre_error_name() . ').';
481 }
482
483 return sprintf('%s opened at byte %d is never closed.', $name, $start);
484 }
485
486 $parser->offset = $next_start + $next_length;
487 $spans[] = [
488 'start' => $start,
489 'end' => $next_start + $next_length,
490 'name' => $name,
491 'attrs' => $attrs,
492 ];
493 }
494
495 return $spans;
496 }
497
498 /**
499 * Readable name for the last PCRE error.
500 *
501 * @return string
502 */
503 private static function pcre_error_name(): string {
504 return function_exists('preg_last_error_msg') ? preg_last_error_msg() : 'PCRE error ' . preg_last_error();
505 }
506
507 /**
508 * Serialize one converted block, or null when it carries nothing to keep.
509 *
510 * A Rank Math block whose every item was hidden still gets replaced — with
511 * nothing. Leaving it in place would keep showing the editor's "your site
512 * doesn't include support for this block" warning for content that was
513 * never on the page to begin with.
514 *
515 * @param string $block_name Rank Math block name.
516 * @param array<string,mixed> $attrs Rank Math attributes.
517 * @return string|null
518 */
519 private static function render_block(string $block_name, array $attrs): ?string {
520 $mapped = Rank_Math_Blocks::map_block($block_name, $attrs);
521
522 if (null === $mapped) {
523 return '';
524 }
525
526 if ('thinkrank/faq' === $mapped['name']) {
527 return self::serialize_block('thinkrank/faq', $mapped['attrs'], self::render_faq_html($mapped['attrs']));
528 }
529
530 // The HowTo block has no field for Rank Math's lead image, so it is
531 // preserved as a core/image block above the steps rather than dropped.
532 $prefix = '';
533 $image = Rank_Math_Blocks::howto_main_image($attrs);
534 if (null !== $image) {
535 $prefix = self::serialize_core_image($image) . "\n\n";
536 }
537
538 return $prefix . self::serialize_block(
539 'thinkrank/howto',
540 $mapped['attrs'],
541 self::render_howto_html($mapped['attrs'])
542 );
543 }
544
545 /**
546 * Wrap rendered HTML in the block delimiters the editor would write.
547 *
548 * @param string $name Block name.
549 * @param array<string,mixed> $attrs Block attributes.
550 * @param string $html Saved markup ('' when save() returns null).
551 * @return string
552 */
553 private static function serialize_block(string $name, array $attrs, string $html): string {
554 $encoded = serialize_block_attributes($attrs);
555
556 if ('' === $html) {
557 return "<!-- wp:{$name} {$encoded} /-->";
558 }
559
560 return "<!-- wp:{$name} {$encoded} -->\n{$html}\n<!-- /wp:{$name} -->";
561 }
562
563 /**
564 * A `core/image` block for Rank Math's HowTo lead image.
565 *
566 * @param array{id:int,url:string,alt:string,width:int,height:int} $image Image details.
567 * @return string
568 */
569 private static function serialize_core_image(array $image): string {
570 $attrs = [
571 'id' => $image['id'],
572 'sizeSlug' => 'full',
573 'linkDestination' => 'none',
574 ];
575
576 $img = '<img src="' . self::escape_attribute($image['url']) . '"'
577 . ' alt="' . self::escape_attribute($image['alt']) . '"'
578 . ' class="wp-image-' . $image['id'] . '"/>';
579
580 return '<!-- wp:image ' . serialize_block_attributes($attrs) . " -->\n"
581 . '<figure class="wp-block-image size-full">' . $img . '</figure>'
582 . "\n<!-- /wp:image -->";
583 }
584
585 /**
586 * Render `thinkrank/faq` save markup.
587 *
588 * Mirrors src/blocks/faq-block/save.js exactly. See the class docblock for
589 * why byte parity is the requirement rather than equivalence.
590 *
591 * @param array<string,mixed> $attrs ThinkRank FAQ attributes.
592 * @return string
593 */
594 public static function render_faq_html(array $attrs): string {
595 $items = [];
596 foreach ($attrs['faqs'] ?? [] as $faq) {
597 if ('' !== ($faq['question'] ?? '') || '' !== ($faq['answer'] ?? '') || '' !== ($faq['imageUrl'] ?? '')) {
598 $items[] = $faq;
599 }
600 }
601
602 if (empty($items)) {
603 return '';
604 }
605
606 $item_style = self::style([
607 'margin-bottom' => self::FAQ_DEFAULTS['itemSpacing'] . 'px',
608 'background' => null,
609 'border' => '1px solid ' . self::FAQ_DEFAULTS['itemBorderColor'],
610 'border-radius' => self::FAQ_DEFAULTS['itemBorderRadius'] . 'px',
611 ]);
612 $question_style = self::style([
613 'color' => null,
614 'background' => null,
615 'font-size' => self::FAQ_DEFAULTS['titleFontSize'] . 'px',
616 ]);
617 $answer_style = self::style(['color' => null]);
618
619 $html = '<div class="wp-block-thinkrank-faq thinkrank-faq">';
620
621 foreach ($items as $index => $faq) {
622 // `open` is a boolean attribute, so the serializer emits the bare
623 // name — `open`, never `open=""`.
624 $open = (self::FAQ_DEFAULTS['firstOpen'] && 0 === $index) ? ' open' : '';
625
626 $html .= '<details class="thinkrank-faq__item"' . $item_style . $open . '>';
627 $html .= '<summary class="thinkrank-faq__question"' . $question_style . '>'
628 . (string) ($faq['question'] ?? '') . '</summary>';
629 $html .= '<div class="thinkrank-faq__answer"' . $answer_style . '>'
630 . (string) ($faq['answer'] ?? '') . '</div>';
631
632 if ('' !== ($faq['imageUrl'] ?? '')) {
633 $html .= '<img class="thinkrank-faq__image"'
634 . ' src="' . self::escape_attribute((string) $faq['imageUrl']) . '"'
635 . ' alt="' . self::escape_attribute((string) ($faq['imageAlt'] ?? '')) . '"/>';
636 }
637
638 $html .= '</details>';
639 }
640
641 return $html . '</div>';
642 }
643
644 /**
645 * Render `thinkrank/howto` save markup.
646 *
647 * Mirrors src/blocks/howto-block/save.js exactly.
648 *
649 * @param array<string,mixed> $attrs ThinkRank HowTo attributes.
650 * @return string
651 */
652 public static function render_howto_html(array $attrs): string {
653 $items = [];
654 foreach ($attrs['steps'] ?? [] as $step) {
655 if ('' !== ($step['title'] ?? '') || '' !== ($step['text'] ?? '') || '' !== ($step['imageUrl'] ?? '')) {
656 $items[] = $step;
657 }
658 }
659
660 if (empty($items)) {
661 return '';
662 }
663
664 $step_style = self::style([
665 'margin-bottom' => self::HOWTO_DEFAULTS['stepSpacing'] . 'px',
666 'background' => null,
667 'border' => null,
668 'border-radius' => self::HOWTO_DEFAULTS['stepBorderRadius'] . 'px',
669 ]);
670 $title_style = self::style([
671 'color' => null,
672 'font-size' => self::HOWTO_DEFAULTS['stepTitleFontSize'] . 'px',
673 ]);
674 $text_style = self::style(['color' => null]);
675
676 $html = '<div class="wp-block-thinkrank-howto thinkrank-howto">';
677
678 $description = (string) ($attrs['description'] ?? '');
679 if ('' !== $description) {
680 $html .= '<p class="thinkrank-howto__description">' . $description . '</p>';
681 }
682
683 $total_time = self::format_total_time($attrs);
684 if ('' !== $total_time) {
685 $html .= '<p class="thinkrank-howto__duration"><strong>Total time:</strong> '
686 . self::escape_html($total_time) . '</p>';
687 }
688
689 $list_tag = self::HOWTO_DEFAULTS['showNumbers'] ? 'ol' : 'ul';
690 $html .= '<' . $list_tag . ' class="thinkrank-howto__steps">';
691
692 foreach ($items as $step) {
693 $html .= '<li class="thinkrank-howto__step"' . $step_style . '>';
694 $html .= '<div class="thinkrank-howto__step-title"' . $title_style . '>'
695 . (string) ($step['title'] ?? '') . '</div>';
696
697 if ('' !== ($step['imageUrl'] ?? '')) {
698 $html .= '<img class="thinkrank-howto__step-image"'
699 . ' src="' . self::escape_attribute((string) $step['imageUrl']) . '"'
700 . ' alt="' . self::escape_attribute((string) ($step['imageAlt'] ?? '')) . '"/>';
701 }
702
703 $html .= '<div class="thinkrank-howto__step-text"' . $text_style . '>'
704 . (string) ($step['text'] ?? '') . '</div>';
705 $html .= '</li>';
706 }
707
708 return $html . '</' . $list_tag . '></div>';
709 }
710
711 /**
712 * PHP port of the HowTo block's formatTotalTime() helper.
713 *
714 * @param array<string,mixed> $attrs ThinkRank HowTo attributes.
715 * @return string
716 */
717 public static function format_total_time(array $attrs): string {
718 $parts = [];
719
720 $days = (int) ($attrs['totalDays'] ?? 0);
721 if ($days > 0) {
722 $parts[] = 1 === $days ? '1 day' : "{$days} days";
723 }
724
725 $hours = (int) ($attrs['totalHours'] ?? 0);
726 if ($hours > 0) {
727 $parts[] = 1 === $hours ? '1 hour' : "{$hours} hours";
728 }
729
730 $minutes = (int) ($attrs['totalMinutes'] ?? 0);
731 if ($minutes > 0) {
732 $parts[] = 1 === $minutes ? '1 minute' : "{$minutes} minutes";
733 }
734
735 return implode(', ', $parts);
736 }
737
738 /**
739 * Serialize an inline style object the way @wordpress/element does.
740 *
741 * Null values are skipped (they are the `undefined` the style helpers
742 * return for unset colours), and when nothing survives the whole attribute
743 * is omitted rather than rendered empty.
744 *
745 * @param array<string,string|null> $declarations Property => value.
746 * @return string Leading-space attribute, or '' when there is nothing to set.
747 */
748 private static function style(array $declarations): string {
749 $parts = [];
750 foreach ($declarations as $property => $value) {
751 if (null === $value) {
752 continue;
753 }
754 $parts[] = $property . ':' . $value;
755 }
756
757 if (empty($parts)) {
758 return '';
759 }
760
761 return ' style="' . self::escape_attribute(implode(';', $parts)) . '"';
762 }
763
764 /**
765 * Port of @wordpress/escape-html's escapeAttribute().
766 *
767 * Escapes the quotation mark, and only those ampersands that do not already
768 * start a character reference — so `&amp;` stays `&amp;` rather than
769 * becoming `&amp;amp;` and doubling on every pass.
770 *
771 * @param string $value Attribute value.
772 * @return string
773 */
774 private static function escape_attribute(string $value): string {
775 $value = (string) preg_replace(
776 '/&(?!([a-zA-Z0-9]+|#[0-9]+|#x[a-fA-F0-9]+);)/',
777 '&amp;',
778 $value
779 );
780
781 return str_replace('"', '&quot;', $value);
782 }
783
784 /**
785 * Port of @wordpress/escape-html's escapeHTML() for text nodes.
786 *
787 * @param string $value Text value.
788 * @return string
789 */
790 private static function escape_html(string $value): string {
791 $value = self::escape_attribute($value);
792
793 return str_replace(['<', '>'], ['&lt;', '&gt;'], $value);
794 }
795 }
796