PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.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 1.0.1 All 51 releases
thinkrank / includes / integrations / class-rank-math-blocks.php

class-rank-math-blocks.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.10.0, at includes/integrations/class-rank-math-blocks.php

395 lines 13.1 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 compatibility.
5 *
6 * Rank Math's FAQ and HowTo blocks keep their questions and steps in the block
7 * comment's attributes and generate their schema in PHP. Deactivating Rank Math
8 * therefore takes the FAQPage / HowTo JSON-LD with it while leaving the block's
9 * saved HTML in `post_content` — the page still shows the questions, and search
10 * engines stop seeing them (#777).
11 *
12 * This class is the single place that knows how to read those attributes. It
13 * serves two callers:
14 *
15 * - Block_Converter, which rewrites the blocks into ThinkRank's own FAQ / HowTo
16 * blocks as a migration step.
17 * - Blocks_Manager and Schema_Graph, which use it as a *fallback* so a site that
18 * has not run (or cannot run) the migration still publishes the schema.
19 *
20 * The fallback deliberately does nothing while Rank Math is active: Rank Math is
21 * still emitting its own FAQPage / HowTo for these blocks, and a second copy
22 * from us would be a duplicate on the same URL.
23 *
24 * @package ThinkRank\Integrations
25 * @since 2.10.0
26 */
27
28 declare(strict_types=1);
29
30 namespace ThinkRank\Integrations;
31
32 if (!defined('ABSPATH')) {
33 exit;
34 }
35
36 /**
37 * Rank Math Blocks Class
38 *
39 * @since 2.10.0
40 */
41 class Rank_Math_Blocks {
42
43 /**
44 * Rank Math's FAQ block name.
45 */
46 public const FAQ_BLOCK = 'rank-math/faq-block';
47
48 /**
49 * Rank Math's HowTo block name.
50 */
51 public const HOWTO_BLOCK = 'rank-math/howto-block';
52
53 /**
54 * Rank Math block name => the ThinkRank block it converts to.
55 *
56 * @var array<string,string>
57 */
58 public const BLOCK_MAP = [
59 self::FAQ_BLOCK => 'thinkrank/faq',
60 self::HOWTO_BLOCK => 'thinkrank/howto',
61 ];
62
63 /**
64 * Rank Math main plugin files (free and Pro).
65 *
66 * @var string[]
67 */
68 private const PLUGIN_FILES = [
69 'seo-by-rank-math/rank-math.php',
70 'seo-by-rank-math-pro/rank-math-pro.php',
71 ];
72
73 /**
74 * Whether Rank Math is currently active, in which case it still emits its
75 * own schema for these blocks and ThinkRank must not add a second copy.
76 *
77 * @return bool
78 */
79 public static function is_source_active(): bool {
80 if (!function_exists('is_plugin_active')) {
81 if (!defined('ABSPATH') || !file_exists(ABSPATH . 'wp-admin/includes/plugin.php')) {
82 return false;
83 }
84 require_once ABSPATH . 'wp-admin/includes/plugin.php';
85 }
86
87 foreach (self::PLUGIN_FILES as $file) {
88 if (is_plugin_active($file)) {
89 return true;
90 }
91 }
92
93 return false;
94 }
95
96 /**
97 * Whether a block name is one of Rank Math's convertible blocks.
98 *
99 * @param string $block_name Parsed block name.
100 * @return bool
101 */
102 public static function is_source_block(string $block_name): bool {
103 return isset(self::BLOCK_MAP[$block_name]);
104 }
105
106 /**
107 * Translate a Rank Math block's attributes into the ThinkRank block's
108 * attribute shape.
109 *
110 * @param string $block_name Rank Math block name.
111 * @param array<string,mixed> $attrs Rank Math block attributes.
112 * @return array{name:string,attrs:array<string,mixed>}|null Null when the
113 * block is not ours, or when it carries nothing worth converting.
114 */
115 public static function map_block(string $block_name, array $attrs): ?array {
116 if (!self::is_source_block($block_name)) {
117 return null;
118 }
119
120 $mapped = self::FAQ_BLOCK === $block_name
121 ? self::faq_attributes($attrs)
122 : self::howto_attributes($attrs);
123
124 if (null === $mapped) {
125 return null;
126 }
127
128 return [
129 'name' => self::BLOCK_MAP[$block_name],
130 'attrs' => $mapped,
131 ];
132 }
133
134 /**
135 * The attribute payload the schema producers should treat this leftover
136 * Rank Math block as, or null when it must stay silent.
137 *
138 * Returns null while Rank Math is active, so the fallback never doubles up
139 * on schema Rank Math is already publishing.
140 *
141 * @param string $block_name Rank Math block name.
142 * @param array<string,mixed> $attrs Rank Math block attributes.
143 * @return array{name:string,attrs:array<string,mixed>}|null
144 */
145 public static function schema_fallback(string $block_name, array $attrs): ?array {
146 if (self::is_source_active()) {
147 return null;
148 }
149
150 return self::map_block($block_name, $attrs);
151 }
152
153 /**
154 * Map Rank Math FAQ attributes to `thinkrank/faq` attributes.
155 *
156 * Only questions Rank Math actually rendered are carried over. Its save
157 * output skips an item when `visible === false` or when either the title or
158 * the content is empty, so those were never on the page and must not appear
159 * in ours — carrying them would publish text the author had hidden.
160 *
161 * Note that Rank Math's *schema* is stricter still: it skips any falsy
162 * `visible`, including a missing one, so a hand-built or imported block with
163 * no `visible` key renders but produces no FAQPage entry there. We follow
164 * the rendered semantics, because the visible page is what the author sees
165 * and what the schema is supposed to describe.
166 *
167 * @param array<string,mixed> $attrs Rank Math FAQ attributes.
168 * @return array<string,mixed>|null Null when no question survives.
169 */
170 public static function faq_attributes(array $attrs): ?array {
171 $questions = isset($attrs['questions']) && is_array($attrs['questions'])
172 ? $attrs['questions']
173 : [];
174
175 $faqs = [];
176 foreach ($questions as $question) {
177 if (!is_array($question)) {
178 continue;
179 }
180
181 if (array_key_exists('visible', $question) && false === $question['visible']) {
182 continue;
183 }
184
185 $title = isset($question['title']) ? (string) $question['title'] : '';
186 $content = isset($question['content']) ? (string) $question['content'] : '';
187
188 if ('' === $title || '' === $content) {
189 continue;
190 }
191
192 $faqs[] = array_merge(
193 [
194 'question' => $title,
195 'answer' => $content,
196 ],
197 self::image_attributes($question['imageID'] ?? 0, ['imageId', 'imageUrl', 'imageAlt'])
198 );
199 }
200
201 if (empty($faqs)) {
202 return null;
203 }
204
205 $mapped = ['faqs' => $faqs];
206
207 // Rank Math's default question wrapper is h3; ThinkRank's default
208 // heading tag is h2 and applies to the block's own heading, not to each
209 // question, so the wrapper is intentionally not carried over. The block
210 // has no heading of its own to set.
211 return $mapped;
212 }
213
214 /**
215 * Map Rank Math HowTo attributes to `thinkrank/howto` attributes.
216 *
217 * Rank Math's save output skips a step only when `visible === false`, and
218 * keeps steps that have just a title or just a body, so the filter here is
219 * looser than the FAQ's.
220 *
221 * @param array<string,mixed> $attrs Rank Math HowTo attributes.
222 * @return array<string,mixed>|null Null when no step survives.
223 */
224 public static function howto_attributes(array $attrs): ?array {
225 $source_steps = isset($attrs['steps']) && is_array($attrs['steps'])
226 ? $attrs['steps']
227 : [];
228
229 $steps = [];
230 foreach ($source_steps as $step) {
231 if (!is_array($step)) {
232 continue;
233 }
234
235 if (array_key_exists('visible', $step) && false === $step['visible']) {
236 continue;
237 }
238
239 $title = isset($step['title']) ? (string) $step['title'] : '';
240 $content = isset($step['content']) ? (string) $step['content'] : '';
241 $image = self::image_attributes($step['imageID'] ?? 0, ['imageId', 'imageUrl', 'imageAlt']);
242
243 if ('' === $title && '' === $content && 0 === $image['imageId']) {
244 continue;
245 }
246
247 $steps[] = array_merge(
248 [
249 'title' => $title,
250 'text' => $content,
251 ],
252 $image
253 );
254 }
255
256 if (empty($steps)) {
257 return null;
258 }
259
260 // Key order follows the block's own attribute declaration in
261 // src/blocks/howto-block/index.js (description, steps, then the
262 // totals). The editor re-serializes a block comment in declaration
263 // order, so building it in any other order leaves a post that is valid
264 // but gets its block comment rewritten the first time someone opens and
265 // saves it — a diff with no change in it.
266 $mapped = [];
267
268 $description = isset($attrs['description']) ? (string) $attrs['description'] : '';
269 if ('' !== $description) {
270 $mapped['description'] = $description;
271 }
272
273 $mapped['steps'] = $steps;
274
275 // Rank Math stores the duration as three strings and only honours them
276 // when hasDuration is on; ThinkRank stores three numbers and derives
277 // "is there a duration" from them being non-zero.
278 //
279 // A negative value is clamped to 0, not made positive: absint() turned
280 // "-2" into 2 days while the editor-side mapper (rank-math-mapping.js)
281 // kept -2, so the same block converted two different ways depending on
282 // which path reached it. duration_part() and the JS clamp must agree.
283 if (!empty($attrs['hasDuration'])) {
284 $mapped['totalDays'] = self::duration_part($attrs['days'] ?? 0);
285 $mapped['totalHours'] = self::duration_part($attrs['hours'] ?? 0);
286 $mapped['totalMinutes'] = self::duration_part($attrs['minutes'] ?? 0);
287 }
288
289 return $mapped;
290 }
291
292 /**
293 * One HowTo duration field as a non-negative whole number.
294 *
295 * Mirrors durationPart() in src/editor/rank-math-mapping.js, which is
296 * `Math.max(0, parseInt(value, 10) || 0)`: the leading integer of the
297 * string, anything unreadable is 0, negatives are 0. A plain (int) cast
298 * is not quite parseInt(): it reads "1e3" as 1000 and `true` as 1.
299 *
300 * @since 2.10.0
301 *
302 * @param mixed $value Rank Math duration string (or number).
303 * @return int
304 */
305 public static function duration_part($value): int {
306 if (is_int($value) || is_float($value)) {
307 return max(0, (int) $value);
308 }
309
310 if (!is_string($value) || !preg_match('/^\s*([+-]?\d+)/', $value, $m)) {
311 return 0;
312 }
313
314 return max(0, (int) $m[1]);
315 }
316
317 /**
318 * The HowTo block's own lead image, which ThinkRank's HowTo block has no
319 * field for. Block_Converter emits it as a `core/image` block above the
320 * steps rather than dropping it.
321 *
322 * @param array<string,mixed> $attrs Rank Math HowTo attributes.
323 * @return array{id:int,url:string,alt:string,width:int,height:int}|null
324 */
325 public static function howto_main_image(array $attrs): ?array {
326 $id = isset($attrs['imageID']) ? absint($attrs['imageID']) : 0;
327 if ($id < 1) {
328 return null;
329 }
330
331 return self::attachment_details($id);
332 }
333
334 /**
335 * Resolve a Rank Math `imageID` into ThinkRank's id/url/alt attribute trio.
336 *
337 * An id naming an attachment that no longer exists resolves to "no image"
338 * rather than a dead URL, matching how the FAQ block already degrades (#418).
339 *
340 * @param mixed $image_id Raw Rank Math imageID.
341 * @param string[] $keys Attribute names for [id, url, alt].
342 * @return array<string,mixed>
343 */
344 private static function image_attributes($image_id, array $keys): array {
345 [$id_key, $url_key, $alt_key] = $keys;
346
347 $empty = [$id_key => 0, $url_key => '', $alt_key => ''];
348
349 $id = absint($image_id);
350 if ($id < 1) {
351 return $empty;
352 }
353
354 $details = self::attachment_details($id);
355 if (null === $details) {
356 return $empty;
357 }
358
359 return [
360 $id_key => $details['id'],
361 $url_key => $details['url'],
362 $alt_key => $details['alt'],
363 ];
364 }
365
366 /**
367 * Attachment url/alt/dimensions, or null when the attachment is gone.
368 *
369 * @param int $id Attachment id.
370 * @return array{id:int,url:string,alt:string,width:int,height:int}|null
371 */
372 private static function attachment_details(int $id): ?array {
373 if (!function_exists('wp_get_attachment_image_src')) {
374 return null;
375 }
376
377 $src = wp_get_attachment_image_src($id, 'full');
378 if (!is_array($src) || empty($src[0])) {
379 return null;
380 }
381
382 $alt = function_exists('get_post_meta')
383 ? (string) get_post_meta($id, '_wp_attachment_image_alt', true)
384 : '';
385
386 return [
387 'id' => $id,
388 'url' => (string) $src[0],
389 'alt' => $alt,
390 'width' => isset($src[1]) ? (int) $src[1] : 0,
391 'height' => isset($src[2]) ? (int) $src[2] : 0,
392 ];
393 }
394 }
395