| 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 `&` stays `&` rather than |
| 769 |
* becoming `&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 |
'&', |
| 778 |
$value |
| 779 |
); |
| 780 |
|
| 781 |
return str_replace('"', '"', $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(['<', '>'], ['<', '>'], $value); |
| 794 |
} |
| 795 |
} |
| 796 |
|