`), inline styles as * `prop:value` joined by `;` with no trailing separator, and the style * attribute omitted entirely when every value is undefined. * * Any change to those save.js files must be mirrored here, and the byte-parity * tests in tests/Unit/ are what catch it when it is not. * * @package ThinkRank\Admin\Importers * @since 2.10.0 */ declare(strict_types=1); namespace ThinkRank\Admin\Importers; use ThinkRank\Integrations\Rank_Math_Blocks; if (!defined('ABSPATH')) { exit; } /** * Block Converter Class * * @since 2.10.0 */ class Block_Converter { /** * Migration type slug this converter backs. */ public const TYPE = 'content_blocks'; /** * Post meta holding the pre-conversion content, written only when the site * has revisions disabled and there is therefore no other way back. * restore_post(), exposed as POST /thinkrank/v1/import/content-blocks/restore, * puts it back. */ public const BACKUP_META = '_thinkrank_rank_math_blocks_backup'; /** * Post meta holding an md5 of the content the converter wrote, next to the * backup. restore_post() compares it with the post as it stands, so a post * edited after the conversion is not silently rolled back over the edits. * * @since 2.10.0 */ public const BACKUP_HASH_META = '_thinkrank_rank_math_blocks_backup_hash'; /** * Posts converted per migrate chunk. */ private const CHUNK_SIZE = 50; /** * Post statuses that are never scanned: a revision is a copy of a post we * convert anyway, and trash / auto-draft are not published content. * * @var string[] */ private const EXCLUDED_STATUSES = ['trash', 'auto-draft', 'inherit']; /** * ThinkRank FAQ block defaults that the renderer depends on. Mirrors * src/blocks/faq-block/index.js. */ private const FAQ_DEFAULTS = [ 'firstOpen' => true, 'itemSpacing' => 8, 'itemBorderColor' => '#e2e4e7', 'itemBorderRadius' => 6, 'titleFontSize' => 17, ]; /** * ThinkRank HowTo block defaults. Mirrors src/blocks/howto-block/index.js. */ private const HOWTO_DEFAULTS = [ 'showNumbers' => true, 'stepSpacing' => 12, 'stepBorderRadius' => 6, 'stepTitleFontSize' => 17, ]; /** * How many posts still carry a convertible Rank Math block. * * @return int */ public static function count_posts(): int { global $wpdb; // where_clause() is built entirely from $wpdb->prepare() fragments and // esc_sql()'d literals, so there is no caller input left to place; the // sniff cannot see through the helper. $sql = "SELECT COUNT(ID) FROM {$wpdb->posts} WHERE " . self::where_clause(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared return (int) $wpdb->get_var($sql); } /** * One page of post ids still carrying a convertible block. * * Ordered by ID so paging is stable while earlier pages are being written. * * `$per_page` is caller-supplied because the exporter decides whether * another page follows by comparing the returned row count against its own * `chunk_size`. A converter paging in smaller units than the exporter * expects would look like a short final page on the very first call, and * every post after it would be dropped without a word. * * @param int $page Page number (1-indexed). * @param int|null $per_page Rows per page; defaults to this class's chunk size. * @return int[] */ public static function get_post_ids(int $page, ?int $per_page = null): array { global $wpdb; $page = max(1, $page); $per_page = max(1, $per_page ?? self::CHUNK_SIZE); $offset = ($page - 1) * $per_page; // As above: the only caller-supplied values here are the two integers, // and both are passed as placeholders. $sql = "SELECT ID FROM {$wpdb->posts} WHERE " . self::where_clause() . ' ORDER BY ID ASC LIMIT %d OFFSET %d'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared $ids = $wpdb->get_col($wpdb->prepare($sql, $per_page, $offset)); return array_map('intval', $ids ?: []); } /** * Number of posts handled per chunk, so callers can page in step. * * @return int */ public static function chunk_size(): int { return self::CHUNK_SIZE; } /** * The shared WHERE clause for "this post holds a Rank Math FAQ/HowTo block". * * Matches on the opening block delimiter rather than the rendered class, so * a post whose block was already converted stops matching immediately. * * @return string */ private static function where_clause(): string { global $wpdb; $statuses = implode( ',', array_map( static fn(string $status): string => "'" . esc_sql($status) . "'", self::EXCLUDED_STATUSES ) ); $likes = []; foreach (array_keys(Rank_Math_Blocks::BLOCK_MAP) as $block_name) { $likes[] = $wpdb->prepare( 'post_content LIKE %s', '%' . $wpdb->esc_like('"; } return "\n{$html}\n"; } /** * A `core/image` block for Rank Math's HowTo lead image. * * @param array{id:int,url:string,alt:string,width:int,height:int} $image Image details. * @return string */ private static function serialize_core_image(array $image): string { $attrs = [ 'id' => $image['id'], 'sizeSlug' => 'full', 'linkDestination' => 'none', ]; $img = '' . self::escape_attribute($image['alt']) . ''; return '\n" . '
' . $img . '
' . "\n"; } /** * Render `thinkrank/faq` save markup. * * Mirrors src/blocks/faq-block/save.js exactly. See the class docblock for * why byte parity is the requirement rather than equivalence. * * @param array $attrs ThinkRank FAQ attributes. * @return string */ public static function render_faq_html(array $attrs): string { $items = []; foreach ($attrs['faqs'] ?? [] as $faq) { if ('' !== ($faq['question'] ?? '') || '' !== ($faq['answer'] ?? '') || '' !== ($faq['imageUrl'] ?? '')) { $items[] = $faq; } } if (empty($items)) { return ''; } $item_style = self::style([ 'margin-bottom' => self::FAQ_DEFAULTS['itemSpacing'] . 'px', 'background' => null, 'border' => '1px solid ' . self::FAQ_DEFAULTS['itemBorderColor'], 'border-radius' => self::FAQ_DEFAULTS['itemBorderRadius'] . 'px', ]); $question_style = self::style([ 'color' => null, 'background' => null, 'font-size' => self::FAQ_DEFAULTS['titleFontSize'] . 'px', ]); $answer_style = self::style(['color' => null]); $html = '
'; foreach ($items as $index => $faq) { // `open` is a boolean attribute, so the serializer emits the bare // name — `open`, never `open=""`. $open = (self::FAQ_DEFAULTS['firstOpen'] && 0 === $index) ? ' open' : ''; $html .= '
'; $html .= '' . (string) ($faq['question'] ?? '') . ''; $html .= '
' . (string) ($faq['answer'] ?? '') . '
'; if ('' !== ($faq['imageUrl'] ?? '')) { $html .= '' . self::escape_attribute((string) ($faq['imageAlt'] ?? '')) . ''; } $html .= '
'; } return $html . '
'; } /** * Render `thinkrank/howto` save markup. * * Mirrors src/blocks/howto-block/save.js exactly. * * @param array $attrs ThinkRank HowTo attributes. * @return string */ public static function render_howto_html(array $attrs): string { $items = []; foreach ($attrs['steps'] ?? [] as $step) { if ('' !== ($step['title'] ?? '') || '' !== ($step['text'] ?? '') || '' !== ($step['imageUrl'] ?? '')) { $items[] = $step; } } if (empty($items)) { return ''; } $step_style = self::style([ 'margin-bottom' => self::HOWTO_DEFAULTS['stepSpacing'] . 'px', 'background' => null, 'border' => null, 'border-radius' => self::HOWTO_DEFAULTS['stepBorderRadius'] . 'px', ]); $title_style = self::style([ 'color' => null, 'font-size' => self::HOWTO_DEFAULTS['stepTitleFontSize'] . 'px', ]); $text_style = self::style(['color' => null]); $html = '
'; $description = (string) ($attrs['description'] ?? ''); if ('' !== $description) { $html .= '

' . $description . '

'; } $total_time = self::format_total_time($attrs); if ('' !== $total_time) { $html .= '

Total time: ' . self::escape_html($total_time) . '

'; } $list_tag = self::HOWTO_DEFAULTS['showNumbers'] ? 'ol' : 'ul'; $html .= '<' . $list_tag . ' class="thinkrank-howto__steps">'; foreach ($items as $step) { $html .= '
  • '; $html .= '
    ' . (string) ($step['title'] ?? '') . '
    '; if ('' !== ($step['imageUrl'] ?? '')) { $html .= '' . self::escape_attribute((string) ($step['imageAlt'] ?? '')) . ''; } $html .= '
    ' . (string) ($step['text'] ?? '') . '
    '; $html .= '
  • '; } return $html . '
    '; } /** * PHP port of the HowTo block's formatTotalTime() helper. * * @param array $attrs ThinkRank HowTo attributes. * @return string */ public static function format_total_time(array $attrs): string { $parts = []; $days = (int) ($attrs['totalDays'] ?? 0); if ($days > 0) { $parts[] = 1 === $days ? '1 day' : "{$days} days"; } $hours = (int) ($attrs['totalHours'] ?? 0); if ($hours > 0) { $parts[] = 1 === $hours ? '1 hour' : "{$hours} hours"; } $minutes = (int) ($attrs['totalMinutes'] ?? 0); if ($minutes > 0) { $parts[] = 1 === $minutes ? '1 minute' : "{$minutes} minutes"; } return implode(', ', $parts); } /** * Serialize an inline style object the way @wordpress/element does. * * Null values are skipped (they are the `undefined` the style helpers * return for unset colours), and when nothing survives the whole attribute * is omitted rather than rendered empty. * * @param array $declarations Property => value. * @return string Leading-space attribute, or '' when there is nothing to set. */ private static function style(array $declarations): string { $parts = []; foreach ($declarations as $property => $value) { if (null === $value) { continue; } $parts[] = $property . ':' . $value; } if (empty($parts)) { return ''; } return ' style="' . self::escape_attribute(implode(';', $parts)) . '"'; } /** * Port of @wordpress/escape-html's escapeAttribute(). * * Escapes the quotation mark, and only those ampersands that do not already * start a character reference — so `&` stays `&` rather than * becoming `&amp;` and doubling on every pass. * * @param string $value Attribute value. * @return string */ private static function escape_attribute(string $value): string { $value = (string) preg_replace( '/&(?!([a-zA-Z0-9]+|#[0-9]+|#x[a-fA-F0-9]+);)/', '&', $value ); return str_replace('"', '"', $value); } /** * Port of @wordpress/escape-html's escapeHTML() for text nodes. * * @param string $value Text value. * @return string */ private static function escape_html(string $value): string { $value = self::escape_attribute($value); return str_replace(['<', '>'], ['<', '>'], $value); } }