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 +249 -11 2.0.1 → 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,8 +190,33 @@
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:
150 221 // Only the post being viewed may claim to be an FAQPage. On an
151 222 // archive or the blog home the graph's collection pass skips
@@ -182,12 +253,186 @@
182 253 if (false === $json) {
183 254 return $block_content;
184 255 }
185 256
186 - 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>';
187 258 }
188 259
189 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 + /**
190 435 * Whether this render may emit a page-level FAQPage.
191 436 *
192 437 * True only while rendering the singular post that is actually being
193 438 * viewed. A listing (archive, blog home, search) renders many posts under
@@ -248,16 +493,9 @@
248 493 continue;
249 494 }
250 495
251 496 $text = wp_kses_post($answer);
252 -
253 - // Yoast-style: a per-item image travels inside the answer HTML, so
254 - // rich results can surface it without a separate ImageObject node.
255 - $image_url = isset($faq['imageUrl']) ? esc_url((string) $faq['imageUrl']) : '';
256 - if ($image_url !== '') {
257 - $image_alt = isset($faq['imageAlt']) ? esc_attr((string) $faq['imageAlt']) : '';
258 - $text .= ' <img src="' . $image_url . '" alt="' . $image_alt . '" />';
259 - }
497 + $text .= self::faq_image_markup($faq);
260 498
261 499 $entities[] = [
262 500 '@type' => 'Question',
263 501 'name' => $question,