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 / editor / class-blocks-manager.php

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

637 lines 22.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Gutenberg Blocks Manager
5 *
6 * Registers ThinkRank's editor blocks: enqueues their editor + front-end
7 * assets and injects block-level structured data.
8 *
9 * @package ThinkRank
10 * @subpackage Editor
11 * @since 1.15.x
12 */
13
14 declare(strict_types=1);
15
16 namespace ThinkRank\Editor;
17
18 // Prevent direct access
19 if (!defined('ABSPATH')) {
20 exit;
21 }
22
23 /**
24 * Blocks Manager.
25 *
26 * @since 1.15.x
27 */
28 class Blocks_Manager {
29
30 /**
31 * FAQ block name.
32 */
33 private const FAQ_BLOCK = 'thinkrank/faq';
34
35 /**
36 * HowTo block name.
37 */
38 private const HOWTO_BLOCK = 'thinkrank/howto';
39
40 /**
41 * TOC block name.
42 */
43 private const TOC_BLOCK = 'thinkrank/toc';
44
45 /**
46 * Block name → webpack asset handle. Each entry builds
47 * assets/{handle}.js / .css / .asset.php.
48 *
49 * @var array<string,string>
50 */
51 private const BLOCK_ASSETS = [
52 self::FAQ_BLOCK => 'faq-block',
53 self::HOWTO_BLOCK => 'howto-block',
54 self::TOC_BLOCK => 'toc-block',
55 ];
56
57 /**
58 * Webpack asset handle for the Rank Math block converter. Not a block of
59 * its own — it is an editor extension, so it is deliberately outside
60 * BLOCK_ASSETS, which also drives block stylesheet loading.
61 */
62 private const CONVERTER_ASSET = 'rank-math-block-converter';
63
64 /**
65 * Wire up hooks.
66 *
67 * @return void
68 */
69 public function init(): void {
70 add_action('enqueue_block_editor_assets', [$this, 'enqueue_editor_assets']);
71 add_action('enqueue_block_assets', [$this, 'enqueue_block_styles']);
72 add_filter('render_block', [$this, 'inject_block_schema'], 10, 2);
73 }
74
75 /**
76 * Enqueue each block's editor script.
77 *
78 * @return void
79 */
80 public function enqueue_editor_assets(): void {
81 // The converter is not a block; it repairs Rank Math's FAQ / HowTo
82 // blocks in place (#777) and so has to load wherever those blocks might
83 // be edited, alongside the block scripts themselves.
84 $handles = array_merge(array_values(self::BLOCK_ASSETS), [self::CONVERTER_ASSET]);
85
86 foreach ($handles as $handle) {
87 $asset_path = THINKRANK_PLUGIN_DIR . "assets/{$handle}.asset.php";
88 $asset = file_exists($asset_path)
89 ? include $asset_path
90 : ['dependencies' => ['wp-blocks', 'wp-element', 'wp-block-editor', 'wp-components', 'wp-i18n'], 'version' => THINKRANK_VERSION];
91
92 wp_enqueue_script(
93 "thinkrank-{$handle}",
94 THINKRANK_PLUGIN_URL . "assets/{$handle}.js",
95 $asset['dependencies'] ?? [],
96 $asset['version'] ?? THINKRANK_VERSION,
97 true
98 );
99
100 wp_set_script_translations("thinkrank-{$handle}", 'thinkrank');
101 }
102 }
103
104 /**
105 * Enqueue block stylesheets where blocks render.
106 *
107 * Hooked to enqueue_block_assets — not enqueue_block_editor_assets — so
108 * core mirrors them into the iframed editor canvas instead of only the
109 * editor's outer document. On the front end each loads only when its
110 * block is present.
111 *
112 * @return void
113 */
114 public function enqueue_block_styles(): void {
115 // Dashicons, for the editor canvas only.
116 //
117 // Our block UIs use icon-only <Button icon="..."> controls, which
118 // render as <span class="dashicons dashicons-...">, so without the
119 // font they are present and clickable but have no glyph — the FAQ
120 // block's whole per-item action row (add image, move up/down,
121 // duplicate, remove) was invisible (#417).
122 //
123 // Since WP 6.3 the post editor canvas is an iframe, and core mirrors
124 // only styles enqueued on THIS hook into it. dashicons is registered
125 // by core but never enqueued for that context, and wp-components does
126 // not pull it in — enqueueing it on admin_enqueue_scripts or
127 // enqueue_block_editor_assets loads it into the parent document,
128 // where our buttons are not.
129 if (is_admin()) {
130 wp_enqueue_style('dashicons');
131 }
132
133 foreach (self::BLOCK_ASSETS as $block_name => $handle) {
134 if (!is_admin() && (!function_exists('has_block') || !has_block($block_name))) {
135 continue;
136 }
137
138 $css = THINKRANK_PLUGIN_DIR . "assets/{$handle}.css";
139 if (!file_exists($css)) {
140 continue;
141 }
142
143 $asset_path = THINKRANK_PLUGIN_DIR . "assets/{$handle}.asset.php";
144 $asset = file_exists($asset_path) ? include $asset_path : [];
145
146 wp_enqueue_style(
147 "thinkrank-{$handle}",
148 THINKRANK_PLUGIN_URL . "assets/{$handle}.css",
149 [],
150 $asset['version'] ?? THINKRANK_VERSION
151 );
152 }
153 }
154
155 /**
156 * Append block-level JSON-LD after a ThinkRank block's rendered output.
157 *
158 * Done server-side (not in the blocks' save output) so the schema is not
159 * stripped by KSES for users without unfiltered_html.
160 *
161 * @param string $block_content Rendered block HTML.
162 * @param array $block Parsed block (name + attrs).
163 * @return string
164 */
165 public function inject_block_schema(string $block_content, array $block): string {
166 $name = $block['blockName'] ?? '';
167 $attrs = is_array($block['attrs'] ?? null) ? $block['attrs'] : [];
168
169 // A leftover Rank Math FAQ / HowTo block is treated as the ThinkRank
170 // block it converts to, so its schema comes back on a site that has not
171 // run the migration yet. Returns null while Rank Math is active, since
172 // Rank Math is still publishing its own copy (#777).
173 $is_converted_source = false;
174 if (\ThinkRank\Integrations\Rank_Math_Blocks::is_source_block($name)) {
175 $fallback = \ThinkRank\Integrations\Rank_Math_Blocks::schema_fallback($name, $attrs);
176 if (null === $fallback) {
177 return $block_content;
178 }
179
180 $name = $fallback['name'];
181 $attrs = $fallback['attrs'];
182 $is_converted_source = true;
183 }
184
185 if (!isset(self::BLOCK_ASSETS[$name])) {
186 return $block_content;
187 }
188
189 // Schema output is on by default; only skip when explicitly disabled.
190 if (array_key_exists('outputSchema', $attrs) && false === $attrs['outputSchema']) {
191 return $block_content;
192 }
193
194 // The Schema master switch and the matrix's per-content-type switch.
195 // This producer writes its own <script> into the block's markup rather
196 // than registering with Schema_Graph, so gating the graph does not
197 // reach it — a block kept publishing FAQPage/HowTo/ItemList with Schema
198 // switched off (#688).
199 if (class_exists('ThinkRank\\Frontend\\Schema_Graph')
200 && !\ThinkRank\Frontend\Schema_Graph::output_allowed()) {
201 return $block_content;
202 }
203
204 if (self::FAQ_BLOCK === $name && !$is_converted_source) {
205 // Saved markup carries a bare <img src>, because save.js output is
206 // what the block validates against and cannot be changed without
207 // invalidating every FAQ block already in the wild. Upgrading it
208 // here gives srcset/sizes and intrinsic dimensions from the stored
209 // attachment id, and drops the image entirely when the attachment
210 // has since been deleted (#418).
211 //
212 // Skipped for a Rank Math block rendering through the fallback: the
213 // markup on the page is Rank Math's, not save.js's, so the image
214 // rewrite has nothing it can match and no business editing it. The
215 // schema below is the only thing the fallback contributes.
216 $block_content = $this->upgrade_faq_images($block_content, $attrs);
217 }
218
219 switch ($name) {
220 case self::FAQ_BLOCK:
221 // Only the post being viewed may claim to be an FAQPage. On an
222 // archive or the blog home the graph's collection pass skips
223 // (it is not is_singular()), so absorption never happens and
224 // every listed post carrying an FAQ block used to emit its own
225 // standalone FAQPage beside a head that already declares
226 // CollectionPage — N FAQPage scripts on one URL.
227 if (!$this->is_faq_schema_context()) {
228 return $block_content;
229 }
230 // The request's schema graph already merged this block's questions
231 // into its single FAQPage, so emitting here would recreate the
232 // duplicate FAQPage the graph exists to prevent (#355).
233 if ($this->faq_absorbed_by_graph()) {
234 return $block_content;
235 }
236 $schema = $this->build_faq_schema($attrs);
237 break;
238 case self::HOWTO_BLOCK:
239 $schema = $this->build_howto_schema($attrs);
240 break;
241 case self::TOC_BLOCK:
242 $schema = $this->build_toc_schema($attrs);
243 break;
244 default:
245 $schema = null;
246 }
247
248 if (null === $schema) {
249 return $block_content;
250 }
251
252 $json = wp_json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
253 if (false === $json) {
254 return $block_content;
255 }
256
257 return $block_content . "\n" . '<script type="application/ld+json" data-thinkrank="block">' . $json . '</script>';
258 }
259
260 /**
261 * Resolve a FAQ item's image to what should actually be rendered.
262 *
263 * `imageId` was stored from the start but never read — every path used the
264 * raw `imageUrl`, so there was no srcset, no intrinsic dimensions (opening
265 * an accordion item shifted everything below it), and an attachment
266 * deleted from the library left a broken <img> in both the page and the
267 * FAQPage JSON-LD (#418).
268 *
269 * Returns null when there is no image, or when the id names an attachment
270 * that no longer exists — which is what makes deletion degrade gracefully
271 * instead of publishing a dead URL.
272 *
273 * @since 2.1.0
274 *
275 * @param array<string,mixed> $item FAQ item attributes.
276 * @return array{id:int,url:string,alt:string,width:int,height:int}|null
277 */
278 private static function resolve_faq_image(array $item): ?array {
279 $id = isset($item['imageId']) ? (int) $item['imageId'] : 0;
280 $url = isset($item['imageUrl']) ? (string) $item['imageUrl'] : '';
281 $alt = isset($item['imageAlt']) ? (string) $item['imageAlt'] : '';
282
283 if ($id > 0) {
284 $src = wp_get_attachment_image_src($id, 'large');
285
286 if (!is_array($src) || empty($src[0])) {
287 // The attachment is gone. A stored imageUrl pointing at it is
288 // a dead link, so publish nothing rather than something broken.
289 return null;
290 }
291
292 if ($alt === '') {
293 $alt = (string) get_post_meta($id, '_wp_attachment_image_alt', true);
294 }
295
296 return [
297 'id' => $id,
298 'url' => (string) $src[0],
299 'alt' => $alt,
300 'width' => (int) ($src[1] ?? 0),
301 'height' => (int) ($src[2] ?? 0),
302 ];
303 }
304
305 if ($url === '') {
306 return null;
307 }
308
309 // Pre-#418 items, and anything inserted by URL: no id to resolve, so
310 // the stored URL is all there is.
311 return [
312 'id' => 0,
313 'url' => $url,
314 'alt' => $alt,
315 'width' => 0,
316 'height' => 0,
317 ];
318 }
319
320 /**
321 * The <img> appended to an answer's schema text.
322 *
323 * A per-item image travels inside the answer HTML rather than as a
324 * separate ImageObject node. Note this is no longer about Google rich
325 * results: FAQ rich results were removed from Search in May 2026 and the
326 * supporting documentation retired the following month. The markup is
327 * still consumed by other search engines and by LLM crawlers reading the
328 * page's structured data, which is why it stays (#418).
329 *
330 * @since 2.1.0
331 *
332 * @param array<string,mixed> $item FAQ item attributes.
333 * @return string Leading-space-prefixed <img>, or '' when there is none.
334 */
335 public static function faq_image_markup(array $item): string {
336 $image = self::resolve_faq_image($item);
337
338 if (null === $image) {
339 return '';
340 }
341
342 $markup = ' <img src="' . esc_url($image['url']) . '" alt="' . esc_attr($image['alt']) . '"';
343
344 // Intrinsic dimensions, so a consumer laying the answer out does not
345 // have to guess and reflow.
346 if ($image['width'] > 0 && $image['height'] > 0) {
347 $markup .= ' width="' . $image['width'] . '" height="' . $image['height'] . '"';
348 }
349
350 return $markup . ' />';
351 }
352
353 /**
354 * Re-render the saved FAQ images through the media library.
355 *
356 * save.js emits a bare <img src>. That output is what the block validates
357 * against, so it cannot change without invalidating every FAQ block
358 * already saved — the one property the #380 redesign was careful to keep.
359 * Rewriting at render time gets srcset/sizes and width/height without
360 * touching a single stored post.
361 *
362 * @since 2.1.0
363 *
364 * @param string $content Rendered block HTML.
365 * @param array<string,mixed> $attrs Block attributes.
366 * @return string
367 */
368 private function upgrade_faq_images(string $content, array $attrs): string {
369 if (false === strpos($content, 'thinkrank-faq__image')) {
370 return $content;
371 }
372
373 $faqs = isset($attrs['faqs']) && is_array($attrs['faqs']) ? $attrs['faqs'] : [];
374 if (empty($faqs)) {
375 return $content;
376 }
377
378 // Keyed by the src the saved markup carries, which is what ties a
379 // rendered <img> back to the item it came from.
380 $by_url = [];
381 foreach ($faqs as $item) {
382 if (!is_array($item) || empty($item['imageUrl'])) {
383 continue;
384 }
385 $by_url[(string) $item['imageUrl']] = $item;
386 }
387
388 if (empty($by_url)) {
389 return $content;
390 }
391
392 return (string) preg_replace_callback(
393 '#<img\b[^>]*\bclass="[^"]*thinkrank-faq__image[^"]*"[^>]*>#i',
394 static function (array $found) use ($by_url): string {
395 if (!preg_match('#\bsrc="([^"]*)"#i', $found[0], $src)) {
396 return $found[0];
397 }
398
399 $stored = html_entity_decode($src[1], ENT_QUOTES, 'UTF-8');
400 if (!isset($by_url[$stored])) {
401 return $found[0];
402 }
403
404 $image = self::resolve_faq_image($by_url[$stored]);
405
406 // Attachment deleted since: drop the <img> rather than serve
407 // a broken one.
408 if (null === $image) {
409 return '';
410 }
411
412 // No id to resolve (pre-#418 item, or inserted by URL) — the
413 // saved markup is already the best available.
414 if ($image['id'] <= 0) {
415 return $found[0];
416 }
417
418 $rendered = wp_get_attachment_image(
419 $image['id'],
420 'large',
421 false,
422 [
423 'class' => 'thinkrank-faq__image',
424 'alt' => $image['alt'],
425 ]
426 );
427
428 return '' !== $rendered ? $rendered : $found[0];
429 },
430 $content
431 );
432 }
433
434 /**
435 * Whether this render may emit a page-level FAQPage.
436 *
437 * True only while rendering the singular post that is actually being
438 * viewed. A listing (archive, blog home, search) renders many posts under
439 * one URL, and an FAQPage there would describe a document that does not
440 * exist. Outside a front-end query — the editor, a REST render — there is no
441 * page to describe either.
442 *
443 * @since 2.0.1
444 * @return bool
445 */
446 private function is_faq_schema_context(): bool {
447 if (!function_exists('is_singular') || !is_singular()) {
448 return false;
449 }
450
451 $queried_id = (int) get_queried_object_id();
452 $current_id = (int) get_the_ID();
453
454 // A secondary loop inside a singular template can render other posts;
455 // their FAQ content is not this URL's FAQ content.
456 return $queried_id > 0 && $queried_id === $current_id;
457 }
458
459 /**
460 * Whether the schema graph already absorbed this page's FAQ content.
461 *
462 * Falls back to false whenever the graph never ran, so the block keeps its
463 * original standalone behaviour outside a normal front-end render.
464 *
465 * @since 1.32.0
466 * @return bool
467 */
468 private function faq_absorbed_by_graph(): bool {
469 if (!class_exists('ThinkRank\\Frontend\\Schema_Graph')) {
470 return false;
471 }
472
473 return \ThinkRank\Frontend\Schema_Graph::instance()->absorbed_content_faq();
474 }
475
476 /**
477 * FAQPage schema from FAQ block attributes.
478 *
479 * @param array $attrs Block attributes.
480 * @return array|null Schema array, or null when there is nothing to emit.
481 */
482 private function build_faq_schema(array $attrs): ?array {
483 $faqs = $attrs['faqs'] ?? [];
484 if (!is_array($faqs) || empty($faqs)) {
485 return null;
486 }
487
488 $entities = [];
489 foreach ($faqs as $faq) {
490 $question = isset($faq['question']) ? trim(wp_strip_all_tags((string) $faq['question'])) : '';
491 $answer = isset($faq['answer']) ? trim((string) $faq['answer']) : '';
492 if ($question === '' || $answer === '') {
493 continue;
494 }
495
496 $text = wp_kses_post($answer);
497 $text .= self::faq_image_markup($faq);
498
499 $entities[] = [
500 '@type' => 'Question',
501 'name' => $question,
502 'acceptedAnswer' => [
503 '@type' => 'Answer',
504 'text' => $text,
505 ],
506 ];
507 }
508
509 if (empty($entities)) {
510 return null;
511 }
512
513 return [
514 '@context' => 'https://schema.org',
515 '@type' => 'FAQPage',
516 'mainEntity' => $entities,
517 ];
518 }
519
520 /**
521 * HowTo schema from HowTo block attributes.
522 *
523 * Property shape follows Google's HowTo guidelines (and matches what
524 * RankMath emits): name, description, totalTime as ISO 8601, and
525 * HowToStep entries with name/text/image.
526 *
527 * @param array $attrs Block attributes.
528 * @return array|null Schema array, or null when there is nothing to emit.
529 */
530 private function build_howto_schema(array $attrs): ?array {
531 $steps = $attrs['steps'] ?? [];
532 if (!is_array($steps) || empty($steps)) {
533 return null;
534 }
535
536 $step_entities = [];
537 foreach ($steps as $step) {
538 $title = isset($step['title']) ? trim(wp_strip_all_tags((string) $step['title'])) : '';
539 $text = isset($step['text']) ? trim(wp_strip_all_tags((string) $step['text'])) : '';
540 if ($title === '' && $text === '') {
541 continue;
542 }
543
544 $entity = ['@type' => 'HowToStep'];
545 if ($title !== '' && $text !== '') {
546 $entity['name'] = $title;
547 $entity['text'] = $text;
548 } else {
549 // Google requires text; fall back to whichever field is set.
550 $entity['text'] = $text !== '' ? $text : $title;
551 }
552
553 $image_url = isset($step['imageUrl']) ? esc_url_raw((string) $step['imageUrl']) : '';
554 if ($image_url !== '') {
555 $entity['image'] = [
556 '@type' => 'ImageObject',
557 'url' => $image_url,
558 ];
559 }
560
561 $step_entities[] = $entity;
562 }
563
564 if (empty($step_entities)) {
565 return null;
566 }
567
568 $heading = isset($attrs['heading']) ? trim(wp_strip_all_tags((string) $attrs['heading'])) : '';
569 $name = $heading !== '' ? $heading : get_the_title();
570
571 $schema = [
572 '@context' => 'https://schema.org',
573 '@type' => 'HowTo',
574 'name' => $name,
575 'step' => $step_entities,
576 ];
577
578 $description = isset($attrs['description']) ? trim(wp_strip_all_tags((string) $attrs['description'])) : '';
579 if ($description !== '') {
580 $schema['description'] = $description;
581 }
582
583 // ISO 8601 duration, e.g. P1DT2H30M — only when a duration was set.
584 $days = max(0, (int) ($attrs['totalDays'] ?? 0));
585 $hours = max(0, (int) ($attrs['totalHours'] ?? 0));
586 $minutes = max(0, (int) ($attrs['totalMinutes'] ?? 0));
587 if ($days + $hours + $minutes > 0) {
588 $schema['totalTime'] = sprintf('P%dDT%dH%dM', $days, $hours, $minutes);
589 }
590
591 return $schema;
592 }
593
594 /**
595 * SiteNavigationElement schema from TOC block attributes (one element per
596 * listed section — the shape RankMath's TOC block emits).
597 *
598 * @param array $attrs Block attributes.
599 * @return array|null Schema array, or null when there is nothing to emit.
600 */
601 private function build_toc_schema(array $attrs): ?array {
602 $headings = $attrs['headings'] ?? [];
603 if (!is_array($headings) || empty($headings)) {
604 return null;
605 }
606
607 $permalink = get_permalink();
608 if (!is_string($permalink)) {
609 $permalink = '';
610 }
611
612 $elements = [];
613 foreach ($headings as $item) {
614 $content = isset($item['content']) ? trim(wp_strip_all_tags((string) $item['content'])) : '';
615 $anchor = isset($item['anchor']) ? trim((string) $item['anchor']) : '';
616 if ($content === '' || $anchor === '') {
617 continue;
618 }
619
620 $elements[] = [
621 '@type' => 'SiteNavigationElement',
622 'name' => $content,
623 'url' => $permalink . '#' . $anchor,
624 ];
625 }
626
627 if (empty($elements)) {
628 return null;
629 }
630
631 return [
632 '@context' => 'https://schema.org',
633 '@graph' => $elements,
634 ];
635 }
636 }
637