PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / trunk
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO vtrunk
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 1.11.0 All 47 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 trunk, at includes/editor/class-blocks-manager.php

594 lines 20.3 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 if (self::FAQ_BLOCK === $name) {
167 // Saved markup carries a bare <img src>, because save.js output is
168 // what the block validates against and cannot be changed without
169 // invalidating every FAQ block already in the wild. Upgrading it
170 // here gives srcset/sizes and intrinsic dimensions from the stored
171 // attachment id, and drops the image entirely when the attachment
172 // has since been deleted (#418).
173 $block_content = $this->upgrade_faq_images($block_content, $attrs);
174 }
175
176 switch ($name) {
177 case self::FAQ_BLOCK:
178 // Only the post being viewed may claim to be an FAQPage. On an
179 // archive or the blog home the graph's collection pass skips
180 // (it is not is_singular()), so absorption never happens and
181 // every listed post carrying an FAQ block used to emit its own
182 // standalone FAQPage beside a head that already declares
183 // CollectionPage — N FAQPage scripts on one URL.
184 if (!$this->is_faq_schema_context()) {
185 return $block_content;
186 }
187 // The request's schema graph already merged this block's questions
188 // into its single FAQPage, so emitting here would recreate the
189 // duplicate FAQPage the graph exists to prevent (#355).
190 if ($this->faq_absorbed_by_graph()) {
191 return $block_content;
192 }
193 $schema = $this->build_faq_schema($attrs);
194 break;
195 case self::HOWTO_BLOCK:
196 $schema = $this->build_howto_schema($attrs);
197 break;
198 case self::TOC_BLOCK:
199 $schema = $this->build_toc_schema($attrs);
200 break;
201 default:
202 $schema = null;
203 }
204
205 if (null === $schema) {
206 return $block_content;
207 }
208
209 $json = wp_json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
210 if (false === $json) {
211 return $block_content;
212 }
213
214 return $block_content . "\n" . '<script type="application/ld+json">' . $json . '</script>';
215 }
216
217 /**
218 * Resolve a FAQ item's image to what should actually be rendered.
219 *
220 * `imageId` was stored from the start but never read — every path used the
221 * raw `imageUrl`, so there was no srcset, no intrinsic dimensions (opening
222 * an accordion item shifted everything below it), and an attachment
223 * deleted from the library left a broken <img> in both the page and the
224 * FAQPage JSON-LD (#418).
225 *
226 * Returns null when there is no image, or when the id names an attachment
227 * that no longer exists — which is what makes deletion degrade gracefully
228 * instead of publishing a dead URL.
229 *
230 * @since 2.1.0
231 *
232 * @param array<string,mixed> $item FAQ item attributes.
233 * @return array{id:int,url:string,alt:string,width:int,height:int}|null
234 */
235 private static function resolve_faq_image(array $item): ?array {
236 $id = isset($item['imageId']) ? (int) $item['imageId'] : 0;
237 $url = isset($item['imageUrl']) ? (string) $item['imageUrl'] : '';
238 $alt = isset($item['imageAlt']) ? (string) $item['imageAlt'] : '';
239
240 if ($id > 0) {
241 $src = wp_get_attachment_image_src($id, 'large');
242
243 if (!is_array($src) || empty($src[0])) {
244 // The attachment is gone. A stored imageUrl pointing at it is
245 // a dead link, so publish nothing rather than something broken.
246 return null;
247 }
248
249 if ($alt === '') {
250 $alt = (string) get_post_meta($id, '_wp_attachment_image_alt', true);
251 }
252
253 return [
254 'id' => $id,
255 'url' => (string) $src[0],
256 'alt' => $alt,
257 'width' => (int) ($src[1] ?? 0),
258 'height' => (int) ($src[2] ?? 0),
259 ];
260 }
261
262 if ($url === '') {
263 return null;
264 }
265
266 // Pre-#418 items, and anything inserted by URL: no id to resolve, so
267 // the stored URL is all there is.
268 return [
269 'id' => 0,
270 'url' => $url,
271 'alt' => $alt,
272 'width' => 0,
273 'height' => 0,
274 ];
275 }
276
277 /**
278 * The <img> appended to an answer's schema text.
279 *
280 * A per-item image travels inside the answer HTML rather than as a
281 * separate ImageObject node. Note this is no longer about Google rich
282 * results: FAQ rich results were removed from Search in May 2026 and the
283 * supporting documentation retired the following month. The markup is
284 * still consumed by other search engines and by LLM crawlers reading the
285 * page's structured data, which is why it stays (#418).
286 *
287 * @since 2.1.0
288 *
289 * @param array<string,mixed> $item FAQ item attributes.
290 * @return string Leading-space-prefixed <img>, or '' when there is none.
291 */
292 public static function faq_image_markup(array $item): string {
293 $image = self::resolve_faq_image($item);
294
295 if (null === $image) {
296 return '';
297 }
298
299 $markup = ' <img src="' . esc_url($image['url']) . '" alt="' . esc_attr($image['alt']) . '"';
300
301 // Intrinsic dimensions, so a consumer laying the answer out does not
302 // have to guess and reflow.
303 if ($image['width'] > 0 && $image['height'] > 0) {
304 $markup .= ' width="' . $image['width'] . '" height="' . $image['height'] . '"';
305 }
306
307 return $markup . ' />';
308 }
309
310 /**
311 * Re-render the saved FAQ images through the media library.
312 *
313 * save.js emits a bare <img src>. That output is what the block validates
314 * against, so it cannot change without invalidating every FAQ block
315 * already saved — the one property the #380 redesign was careful to keep.
316 * Rewriting at render time gets srcset/sizes and width/height without
317 * touching a single stored post.
318 *
319 * @since 2.1.0
320 *
321 * @param string $content Rendered block HTML.
322 * @param array<string,mixed> $attrs Block attributes.
323 * @return string
324 */
325 private function upgrade_faq_images(string $content, array $attrs): string {
326 if (false === strpos($content, 'thinkrank-faq__image')) {
327 return $content;
328 }
329
330 $faqs = isset($attrs['faqs']) && is_array($attrs['faqs']) ? $attrs['faqs'] : [];
331 if (empty($faqs)) {
332 return $content;
333 }
334
335 // Keyed by the src the saved markup carries, which is what ties a
336 // rendered <img> back to the item it came from.
337 $by_url = [];
338 foreach ($faqs as $item) {
339 if (!is_array($item) || empty($item['imageUrl'])) {
340 continue;
341 }
342 $by_url[(string) $item['imageUrl']] = $item;
343 }
344
345 if (empty($by_url)) {
346 return $content;
347 }
348
349 return (string) preg_replace_callback(
350 '#<img\b[^>]*\bclass="[^"]*thinkrank-faq__image[^"]*"[^>]*>#i',
351 static function (array $found) use ($by_url): string {
352 if (!preg_match('#\bsrc="([^"]*)"#i', $found[0], $src)) {
353 return $found[0];
354 }
355
356 $stored = html_entity_decode($src[1], ENT_QUOTES, 'UTF-8');
357 if (!isset($by_url[$stored])) {
358 return $found[0];
359 }
360
361 $image = self::resolve_faq_image($by_url[$stored]);
362
363 // Attachment deleted since: drop the <img> rather than serve
364 // a broken one.
365 if (null === $image) {
366 return '';
367 }
368
369 // No id to resolve (pre-#418 item, or inserted by URL) — the
370 // saved markup is already the best available.
371 if ($image['id'] <= 0) {
372 return $found[0];
373 }
374
375 $rendered = wp_get_attachment_image(
376 $image['id'],
377 'large',
378 false,
379 [
380 'class' => 'thinkrank-faq__image',
381 'alt' => $image['alt'],
382 ]
383 );
384
385 return '' !== $rendered ? $rendered : $found[0];
386 },
387 $content
388 );
389 }
390
391 /**
392 * Whether this render may emit a page-level FAQPage.
393 *
394 * True only while rendering the singular post that is actually being
395 * viewed. A listing (archive, blog home, search) renders many posts under
396 * one URL, and an FAQPage there would describe a document that does not
397 * exist. Outside a front-end query — the editor, a REST render — there is no
398 * page to describe either.
399 *
400 * @since 2.0.1
401 * @return bool
402 */
403 private function is_faq_schema_context(): bool {
404 if (!function_exists('is_singular') || !is_singular()) {
405 return false;
406 }
407
408 $queried_id = (int) get_queried_object_id();
409 $current_id = (int) get_the_ID();
410
411 // A secondary loop inside a singular template can render other posts;
412 // their FAQ content is not this URL's FAQ content.
413 return $queried_id > 0 && $queried_id === $current_id;
414 }
415
416 /**
417 * Whether the schema graph already absorbed this page's FAQ content.
418 *
419 * Falls back to false whenever the graph never ran, so the block keeps its
420 * original standalone behaviour outside a normal front-end render.
421 *
422 * @since 1.32.0
423 * @return bool
424 */
425 private function faq_absorbed_by_graph(): bool {
426 if (!class_exists('ThinkRank\\Frontend\\Schema_Graph')) {
427 return false;
428 }
429
430 return \ThinkRank\Frontend\Schema_Graph::instance()->absorbed_content_faq();
431 }
432
433 /**
434 * FAQPage schema from FAQ block attributes.
435 *
436 * @param array $attrs Block attributes.
437 * @return array|null Schema array, or null when there is nothing to emit.
438 */
439 private function build_faq_schema(array $attrs): ?array {
440 $faqs = $attrs['faqs'] ?? [];
441 if (!is_array($faqs) || empty($faqs)) {
442 return null;
443 }
444
445 $entities = [];
446 foreach ($faqs as $faq) {
447 $question = isset($faq['question']) ? trim(wp_strip_all_tags((string) $faq['question'])) : '';
448 $answer = isset($faq['answer']) ? trim((string) $faq['answer']) : '';
449 if ($question === '' || $answer === '') {
450 continue;
451 }
452
453 $text = wp_kses_post($answer);
454 $text .= self::faq_image_markup($faq);
455
456 $entities[] = [
457 '@type' => 'Question',
458 'name' => $question,
459 'acceptedAnswer' => [
460 '@type' => 'Answer',
461 'text' => $text,
462 ],
463 ];
464 }
465
466 if (empty($entities)) {
467 return null;
468 }
469
470 return [
471 '@context' => 'https://schema.org',
472 '@type' => 'FAQPage',
473 'mainEntity' => $entities,
474 ];
475 }
476
477 /**
478 * HowTo schema from HowTo block attributes.
479 *
480 * Property shape follows Google's HowTo guidelines (and matches what
481 * RankMath emits): name, description, totalTime as ISO 8601, and
482 * HowToStep entries with name/text/image.
483 *
484 * @param array $attrs Block attributes.
485 * @return array|null Schema array, or null when there is nothing to emit.
486 */
487 private function build_howto_schema(array $attrs): ?array {
488 $steps = $attrs['steps'] ?? [];
489 if (!is_array($steps) || empty($steps)) {
490 return null;
491 }
492
493 $step_entities = [];
494 foreach ($steps as $step) {
495 $title = isset($step['title']) ? trim(wp_strip_all_tags((string) $step['title'])) : '';
496 $text = isset($step['text']) ? trim(wp_strip_all_tags((string) $step['text'])) : '';
497 if ($title === '' && $text === '') {
498 continue;
499 }
500
501 $entity = ['@type' => 'HowToStep'];
502 if ($title !== '' && $text !== '') {
503 $entity['name'] = $title;
504 $entity['text'] = $text;
505 } else {
506 // Google requires text; fall back to whichever field is set.
507 $entity['text'] = $text !== '' ? $text : $title;
508 }
509
510 $image_url = isset($step['imageUrl']) ? esc_url_raw((string) $step['imageUrl']) : '';
511 if ($image_url !== '') {
512 $entity['image'] = [
513 '@type' => 'ImageObject',
514 'url' => $image_url,
515 ];
516 }
517
518 $step_entities[] = $entity;
519 }
520
521 if (empty($step_entities)) {
522 return null;
523 }
524
525 $heading = isset($attrs['heading']) ? trim(wp_strip_all_tags((string) $attrs['heading'])) : '';
526 $name = $heading !== '' ? $heading : get_the_title();
527
528 $schema = [
529 '@context' => 'https://schema.org',
530 '@type' => 'HowTo',
531 'name' => $name,
532 'step' => $step_entities,
533 ];
534
535 $description = isset($attrs['description']) ? trim(wp_strip_all_tags((string) $attrs['description'])) : '';
536 if ($description !== '') {
537 $schema['description'] = $description;
538 }
539
540 // ISO 8601 duration, e.g. P1DT2H30M — only when a duration was set.
541 $days = max(0, (int) ($attrs['totalDays'] ?? 0));
542 $hours = max(0, (int) ($attrs['totalHours'] ?? 0));
543 $minutes = max(0, (int) ($attrs['totalMinutes'] ?? 0));
544 if ($days + $hours + $minutes > 0) {
545 $schema['totalTime'] = sprintf('P%dDT%dH%dM', $days, $hours, $minutes);
546 }
547
548 return $schema;
549 }
550
551 /**
552 * SiteNavigationElement schema from TOC block attributes (one element per
553 * listed section — the shape RankMath's TOC block emits).
554 *
555 * @param array $attrs Block attributes.
556 * @return array|null Schema array, or null when there is nothing to emit.
557 */
558 private function build_toc_schema(array $attrs): ?array {
559 $headings = $attrs['headings'] ?? [];
560 if (!is_array($headings) || empty($headings)) {
561 return null;
562 }
563
564 $permalink = get_permalink();
565 if (!is_string($permalink)) {
566 $permalink = '';
567 }
568
569 $elements = [];
570 foreach ($headings as $item) {
571 $content = isset($item['content']) ? trim(wp_strip_all_tags((string) $item['content'])) : '';
572 $anchor = isset($item['anchor']) ? trim((string) $item['anchor']) : '';
573 if ($content === '' || $anchor === '') {
574 continue;
575 }
576
577 $elements[] = [
578 '@type' => 'SiteNavigationElement',
579 'name' => $content,
580 'url' => $permalink . '#' . $anchor,
581 ];
582 }
583
584 if (empty($elements)) {
585 return null;
586 }
587
588 return [
589 '@context' => 'https://schema.org',
590 '@graph' => $elements,
591 ];
592 }
593 }
594