PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.11.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.11.0
2.11.0 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 All 52 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 2.11.0, at includes/admin/importers/class-block-converter.php

844 lines 30.7 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_blocks($content, [Rank_Math_Blocks::class, 'is_source_block']);
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 `thinkrank/faq` block in a post, in document order.
430 *
431 * Exposed so the FAQ abilities can edit FAQ blocks the same way this class
432 * edits Rank Math ones: by replacing exact byte ranges. A
433 * parse_blocks()/serialize_blocks() round trip would rewrite every other
434 * block in the post as a side effect, which is the property the class
435 * docblock above opens with (#767).
436 *
437 * @since 2.10.1
438 * @param string $content Post content.
439 * @return array<int,array{start:int,end:int,name:string,attrs:array<string,mixed>}>|string
440 * The spans, or a message describing why the content is unsafe to edit.
441 */
442 public static function find_faq_blocks(string $content) {
443 if (!class_exists('WP_Block_Parser')) {
444 return [];
445 }
446
447 return self::find_blocks(
448 $content,
449 static fn(string $name): bool => \ThinkRank\SEO\FAQ_Content::FAQ_BLOCK === $name
450 );
451 }
452
453 /**
454 * A `thinkrank/faq` block, serialized exactly as the editor would save it.
455 *
456 * Only `faqs` is written: Gutenberg omits attributes that equal their
457 * registered default, so a block carrying anything else would not match
458 * what the editor regenerates on the next save.
459 *
460 * @since 2.10.1
461 * @param array<int,array<string,mixed>> $faqs Repeater rows.
462 * @return string Block markup, or '' when no row carries anything.
463 */
464 public static function serialize_faq_block(array $faqs): string {
465 $attrs = ['faqs' => array_values($faqs)];
466 $html = self::render_faq_html($attrs);
467
468 if ('' === $html) {
469 return '';
470 }
471
472 return self::serialize_block('thinkrank/faq', $attrs, $html);
473 }
474
475 /**
476 * Byte spans of every matching block, in document order.
477 *
478 * @param string $content Post content.
479 * @param callable $matches Receives a block name, returns whether to collect it.
480 * @return array<int,array{start:int,end:int,name:string,attrs:array<string,mixed>}>|string
481 * The spans, or a message describing why the content is unsafe to convert.
482 */
483 private static function find_blocks(string $content, callable $matches) {
484 $parser = new \WP_Block_Parser();
485 $parser->document = $content;
486 $parser->offset = 0;
487
488 $spans = [];
489
490 while (true) {
491 list($type, $name, $attrs, $start, $length) = $parser->next_token();
492
493 if ('no-more-tokens' === $type) {
494 // next_token() reports a PCRE failure (backtrack or JIT stack
495 // limit on a very large post) as the end of the document.
496 // Taking it at its word would convert only the blocks before
497 // the failure point and report the rest as done.
498 if (PREG_NO_ERROR !== preg_last_error()) {
499 return 'the block tokenizer failed (' . self::pcre_error_name() . ').';
500 }
501 break;
502 }
503
504 $parser->offset = $start + $length;
505
506 // A stray closer with no opener has nothing to convert, and
507 // removing it is not ours to decide.
508 if ('block-closer' === $type || !$matches((string) $name)) {
509 continue;
510 }
511
512 // Attribute text that is not valid JSON decodes to null. Treating
513 // that as "no attributes" would turn a block with questions in it
514 // into an empty one and delete them.
515 if (!is_array($attrs)) {
516 return sprintf('%s at byte %d has attributes that are not valid JSON.', $name, $start);
517 }
518
519 if ('void-block' === $type) {
520 $spans[] = ['start' => $start, 'end' => $start + $length, 'name' => $name, 'attrs' => $attrs];
521 continue;
522 }
523
524 list($next_type, $next_name, , $next_start, $next_length) = $parser->next_token();
525
526 if ('block-closer' !== $next_type || $next_name !== $name) {
527 if ('no-more-tokens' === $next_type && PREG_NO_ERROR !== preg_last_error()) {
528 return 'the block tokenizer failed (' . self::pcre_error_name() . ').';
529 }
530
531 return sprintf('%s opened at byte %d is never closed.', $name, $start);
532 }
533
534 $parser->offset = $next_start + $next_length;
535 $spans[] = [
536 'start' => $start,
537 'end' => $next_start + $next_length,
538 'name' => $name,
539 'attrs' => $attrs,
540 ];
541 }
542
543 return $spans;
544 }
545
546 /**
547 * Readable name for the last PCRE error.
548 *
549 * @return string
550 */
551 private static function pcre_error_name(): string {
552 return function_exists('preg_last_error_msg') ? preg_last_error_msg() : 'PCRE error ' . preg_last_error();
553 }
554
555 /**
556 * Serialize one converted block, or null when it carries nothing to keep.
557 *
558 * A Rank Math block whose every item was hidden still gets replaced — with
559 * nothing. Leaving it in place would keep showing the editor's "your site
560 * doesn't include support for this block" warning for content that was
561 * never on the page to begin with.
562 *
563 * @param string $block_name Rank Math block name.
564 * @param array<string,mixed> $attrs Rank Math attributes.
565 * @return string|null
566 */
567 private static function render_block(string $block_name, array $attrs): ?string {
568 $mapped = Rank_Math_Blocks::map_block($block_name, $attrs);
569
570 if (null === $mapped) {
571 return '';
572 }
573
574 if ('thinkrank/faq' === $mapped['name']) {
575 return self::serialize_block('thinkrank/faq', $mapped['attrs'], self::render_faq_html($mapped['attrs']));
576 }
577
578 // The HowTo block has no field for Rank Math's lead image, so it is
579 // preserved as a core/image block above the steps rather than dropped.
580 $prefix = '';
581 $image = Rank_Math_Blocks::howto_main_image($attrs);
582 if (null !== $image) {
583 $prefix = self::serialize_core_image($image) . "\n\n";
584 }
585
586 return $prefix . self::serialize_block(
587 'thinkrank/howto',
588 $mapped['attrs'],
589 self::render_howto_html($mapped['attrs'])
590 );
591 }
592
593 /**
594 * Wrap rendered HTML in the block delimiters the editor would write.
595 *
596 * @param string $name Block name.
597 * @param array<string,mixed> $attrs Block attributes.
598 * @param string $html Saved markup ('' when save() returns null).
599 * @return string
600 */
601 private static function serialize_block(string $name, array $attrs, string $html): string {
602 $encoded = serialize_block_attributes($attrs);
603
604 if ('' === $html) {
605 return "<!-- wp:{$name} {$encoded} /-->";
606 }
607
608 return "<!-- wp:{$name} {$encoded} -->\n{$html}\n<!-- /wp:{$name} -->";
609 }
610
611 /**
612 * A `core/image` block for Rank Math's HowTo lead image.
613 *
614 * @param array{id:int,url:string,alt:string,width:int,height:int} $image Image details.
615 * @return string
616 */
617 private static function serialize_core_image(array $image): string {
618 $attrs = [
619 'id' => $image['id'],
620 'sizeSlug' => 'full',
621 'linkDestination' => 'none',
622 ];
623
624 $img = '<img src="' . self::escape_attribute($image['url']) . '"'
625 . ' alt="' . self::escape_attribute($image['alt']) . '"'
626 . ' class="wp-image-' . $image['id'] . '"/>';
627
628 return '<!-- wp:image ' . serialize_block_attributes($attrs) . " -->\n"
629 . '<figure class="wp-block-image size-full">' . $img . '</figure>'
630 . "\n<!-- /wp:image -->";
631 }
632
633 /**
634 * Render `thinkrank/faq` save markup.
635 *
636 * Mirrors src/blocks/faq-block/save.js exactly. See the class docblock for
637 * why byte parity is the requirement rather than equivalence.
638 *
639 * @param array<string,mixed> $attrs ThinkRank FAQ attributes.
640 * @return string
641 */
642 public static function render_faq_html(array $attrs): string {
643 $items = [];
644 foreach ($attrs['faqs'] ?? [] as $faq) {
645 if ('' !== ($faq['question'] ?? '') || '' !== ($faq['answer'] ?? '') || '' !== ($faq['imageUrl'] ?? '')) {
646 $items[] = $faq;
647 }
648 }
649
650 if (empty($items)) {
651 return '';
652 }
653
654 $item_style = self::style([
655 'margin-bottom' => self::FAQ_DEFAULTS['itemSpacing'] . 'px',
656 'background' => null,
657 'border' => '1px solid ' . self::FAQ_DEFAULTS['itemBorderColor'],
658 'border-radius' => self::FAQ_DEFAULTS['itemBorderRadius'] . 'px',
659 ]);
660 $question_style = self::style([
661 'color' => null,
662 'background' => null,
663 'font-size' => self::FAQ_DEFAULTS['titleFontSize'] . 'px',
664 ]);
665 $answer_style = self::style(['color' => null]);
666
667 $html = '<div class="wp-block-thinkrank-faq thinkrank-faq">';
668
669 foreach ($items as $index => $faq) {
670 // `open` is a boolean attribute, so the serializer emits the bare
671 // name — `open`, never `open=""`.
672 $open = (self::FAQ_DEFAULTS['firstOpen'] && 0 === $index) ? ' open' : '';
673
674 $html .= '<details class="thinkrank-faq__item"' . $item_style . $open . '>';
675 $html .= '<summary class="thinkrank-faq__question"' . $question_style . '>'
676 . (string) ($faq['question'] ?? '') . '</summary>';
677 $html .= '<div class="thinkrank-faq__answer"' . $answer_style . '>'
678 . (string) ($faq['answer'] ?? '') . '</div>';
679
680 if ('' !== ($faq['imageUrl'] ?? '')) {
681 $html .= '<img class="thinkrank-faq__image"'
682 . ' src="' . self::escape_attribute((string) $faq['imageUrl']) . '"'
683 . ' alt="' . self::escape_attribute((string) ($faq['imageAlt'] ?? '')) . '"/>';
684 }
685
686 $html .= '</details>';
687 }
688
689 return $html . '</div>';
690 }
691
692 /**
693 * Render `thinkrank/howto` save markup.
694 *
695 * Mirrors src/blocks/howto-block/save.js exactly.
696 *
697 * @param array<string,mixed> $attrs ThinkRank HowTo attributes.
698 * @return string
699 */
700 public static function render_howto_html(array $attrs): string {
701 $items = [];
702 foreach ($attrs['steps'] ?? [] as $step) {
703 if ('' !== ($step['title'] ?? '') || '' !== ($step['text'] ?? '') || '' !== ($step['imageUrl'] ?? '')) {
704 $items[] = $step;
705 }
706 }
707
708 if (empty($items)) {
709 return '';
710 }
711
712 $step_style = self::style([
713 'margin-bottom' => self::HOWTO_DEFAULTS['stepSpacing'] . 'px',
714 'background' => null,
715 'border' => null,
716 'border-radius' => self::HOWTO_DEFAULTS['stepBorderRadius'] . 'px',
717 ]);
718 $title_style = self::style([
719 'color' => null,
720 'font-size' => self::HOWTO_DEFAULTS['stepTitleFontSize'] . 'px',
721 ]);
722 $text_style = self::style(['color' => null]);
723
724 $html = '<div class="wp-block-thinkrank-howto thinkrank-howto">';
725
726 $description = (string) ($attrs['description'] ?? '');
727 if ('' !== $description) {
728 $html .= '<p class="thinkrank-howto__description">' . $description . '</p>';
729 }
730
731 $total_time = self::format_total_time($attrs);
732 if ('' !== $total_time) {
733 $html .= '<p class="thinkrank-howto__duration"><strong>Total time:</strong> '
734 . self::escape_html($total_time) . '</p>';
735 }
736
737 $list_tag = self::HOWTO_DEFAULTS['showNumbers'] ? 'ol' : 'ul';
738 $html .= '<' . $list_tag . ' class="thinkrank-howto__steps">';
739
740 foreach ($items as $step) {
741 $html .= '<li class="thinkrank-howto__step"' . $step_style . '>';
742 $html .= '<div class="thinkrank-howto__step-title"' . $title_style . '>'
743 . (string) ($step['title'] ?? '') . '</div>';
744
745 if ('' !== ($step['imageUrl'] ?? '')) {
746 $html .= '<img class="thinkrank-howto__step-image"'
747 . ' src="' . self::escape_attribute((string) $step['imageUrl']) . '"'
748 . ' alt="' . self::escape_attribute((string) ($step['imageAlt'] ?? '')) . '"/>';
749 }
750
751 $html .= '<div class="thinkrank-howto__step-text"' . $text_style . '>'
752 . (string) ($step['text'] ?? '') . '</div>';
753 $html .= '</li>';
754 }
755
756 return $html . '</' . $list_tag . '></div>';
757 }
758
759 /**
760 * PHP port of the HowTo block's formatTotalTime() helper.
761 *
762 * @param array<string,mixed> $attrs ThinkRank HowTo attributes.
763 * @return string
764 */
765 public static function format_total_time(array $attrs): string {
766 $parts = [];
767
768 $days = (int) ($attrs['totalDays'] ?? 0);
769 if ($days > 0) {
770 $parts[] = 1 === $days ? '1 day' : "{$days} days";
771 }
772
773 $hours = (int) ($attrs['totalHours'] ?? 0);
774 if ($hours > 0) {
775 $parts[] = 1 === $hours ? '1 hour' : "{$hours} hours";
776 }
777
778 $minutes = (int) ($attrs['totalMinutes'] ?? 0);
779 if ($minutes > 0) {
780 $parts[] = 1 === $minutes ? '1 minute' : "{$minutes} minutes";
781 }
782
783 return implode(', ', $parts);
784 }
785
786 /**
787 * Serialize an inline style object the way @wordpress/element does.
788 *
789 * Null values are skipped (they are the `undefined` the style helpers
790 * return for unset colours), and when nothing survives the whole attribute
791 * is omitted rather than rendered empty.
792 *
793 * @param array<string,string|null> $declarations Property => value.
794 * @return string Leading-space attribute, or '' when there is nothing to set.
795 */
796 private static function style(array $declarations): string {
797 $parts = [];
798 foreach ($declarations as $property => $value) {
799 if (null === $value) {
800 continue;
801 }
802 $parts[] = $property . ':' . $value;
803 }
804
805 if (empty($parts)) {
806 return '';
807 }
808
809 return ' style="' . self::escape_attribute(implode(';', $parts)) . '"';
810 }
811
812 /**
813 * Port of @wordpress/escape-html's escapeAttribute().
814 *
815 * Escapes the quotation mark, and only those ampersands that do not already
816 * start a character reference — so `&amp;` stays `&amp;` rather than
817 * becoming `&amp;amp;` and doubling on every pass.
818 *
819 * @param string $value Attribute value.
820 * @return string
821 */
822 private static function escape_attribute(string $value): string {
823 $value = (string) preg_replace(
824 '/&(?!([a-zA-Z0-9]+|#[0-9]+|#x[a-fA-F0-9]+);)/',
825 '&amp;',
826 $value
827 );
828
829 return str_replace('"', '&quot;', $value);
830 }
831
832 /**
833 * Port of @wordpress/escape-html's escapeHTML() for text nodes.
834 *
835 * @param string $value Text value.
836 * @return string
837 */
838 private static function escape_html(string $value): string {
839 $value = self::escape_attribute($value);
840
841 return str_replace(['<', '>'], ['&lt;', '&gt;'], $value);
842 }
843 }
844