PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.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 1.0.2 1.1.0 1.10.0 All 48 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.7.0, at includes/editor/class-blocks-manager.php

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