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
← All changes | includes/editor/class-blocks-manager.php +283 -11 2.0.0 → 2.10.0 View file →
@@ -54,8 +54,15 @@
54 54 self::TOC_BLOCK => 'toc-block',
55 55 ];
56 56
57 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 + /**
58 65 * Wire up hooks.
59 66 *
60 67 * @return void
61 68 */
@@ -70,9 +77,14 @@
70 77 *
71 78 * @return void
72 79 */
73 80 public function enqueue_editor_assets(): void {
74 - foreach (self::BLOCK_ASSETS as $handle) {
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) {
75 87 $asset_path = THINKRANK_PLUGIN_DIR . "assets/{$handle}.asset.php";
76 88 $asset = file_exists($asset_path)
77 89 ? include $asset_path
78 90 : ['dependencies' => ['wp-blocks', 'wp-element', 'wp-block-editor', 'wp-components', 'wp-i18n'], 'version' => THINKRANK_VERSION];
@@ -99,8 +111,26 @@
99 111 *
100 112 * @return void
101 113 */
102 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 +
103 133 foreach (self::BLOCK_ASSETS as $block_name => $handle) {
104 134 if (!is_admin() && (!function_exists('has_block') || !has_block($block_name))) {
105 135 continue;
106 136 }
@@ -133,10 +163,26 @@
133 163 * @return string
134 164 */
135 165 public function inject_block_schema(string $block_content, array $block): string {
136 166 $name = $block['blockName'] ?? '';
137 - $attrs = $block['attrs'] ?? [];
167 + $attrs = is_array($block['attrs'] ?? null) ? $block['attrs'] : [];
138 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 +
139 185 if (!isset(self::BLOCK_ASSETS[$name])) {
140 186 return $block_content;
141 187 }
142 188
@@ -144,10 +190,44 @@
144 190 if (array_key_exists('outputSchema', $attrs) && false === $attrs['outputSchema']) {
145 191 return $block_content;
146 192 }
147 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 +
148 219 switch ($name) {
149 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 + }
150 230 // The request's schema graph already merged this block's questions
151 231 // into its single FAQPage, so emitting here would recreate the
152 232 // duplicate FAQPage the graph exists to prevent (#355).
153 233 if ($this->faq_absorbed_by_graph()) {
@@ -173,12 +253,211 @@
173 253 if (false === $json) {
174 254 return $block_content;
175 255 }
176 256
177 - return $block_content . "\n" . '<script type="application/ld+json">' . $json . '</script>';
257 + return $block_content . "\n" . '<script type="application/ld+json" data-thinkrank="block">' . $json . '</script>';
178 258 }
179 259
180 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 + /**
181 460 * Whether the schema graph already absorbed this page's FAQ content.
182 461 *
183 462 * Falls back to false whenever the graph never ran, so the block keeps its
184 463 * original standalone behaviour outside a normal front-end render.
@@ -214,16 +493,9 @@
214 493 continue;
215 494 }
216 495
217 496 $text = wp_kses_post($answer);
218 -
219 - // Yoast-style: a per-item image travels inside the answer HTML, so
220 - // rich results can surface it without a separate ImageObject node.
221 - $image_url = isset($faq['imageUrl']) ? esc_url((string) $faq['imageUrl']) : '';
222 - if ($image_url !== '') {
223 - $image_alt = isset($faq['imageAlt']) ? esc_attr((string) $faq['imageAlt']) : '';
224 - $text .= ' <img src="' . $image_url . '" alt="' . $image_alt . '" />';
225 - }
497 + $text .= self::faq_image_markup($faq);
226 498
227 499 $entities[] = [
228 500 '@type' => 'Question',
229 501 'name' => $question,