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
← All changes | includes/editor/class-blocks-manager.php +286 -7 1.30.02.7.0 View file →
@@ -99,8 +99,26 @@
99 99 *
100 100 * @return void
101 101 */
102 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 +
103 121 foreach (self::BLOCK_ASSETS as $block_name => $handle) {
104 122 if (!is_admin() && (!function_exists('has_block') || !has_block($block_name))) {
105 123 continue;
106 124 }
@@ -144,15 +162,57 @@
144 162 if (array_key_exists('outputSchema', $attrs) && false === $attrs['outputSchema']) {
145 163 return $block_content;
146 164 }
147 165
148 - $schema = match ($name) {
149 - self::FAQ_BLOCK => $this->build_faq_schema($attrs),
150 - self::HOWTO_BLOCK => $this->build_howto_schema($attrs),
151 - self::TOC_BLOCK => $this->build_toc_schema($attrs),
152 - default => null,
153 - };
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 + }
154 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 +
155 215 if (null === $schema) {
156 216 return $block_content;
157 217 }
158 218
@@ -164,8 +224,224 @@
164 224 return $block_content . "\n" . '<script type="application/ld+json">' . $json . '</script>';
165 225 }
166 226
167 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 + /**
168 444 * FAQPage schema from FAQ block attributes.
169 445 *
170 446 * @param array $attrs Block attributes.
171 447 * @return array|null Schema array, or null when there is nothing to emit.
@@ -183,14 +459,17 @@
183 459 if ($question === '' || $answer === '') {
184 460 continue;
185 461 }
186 462
463 + $text = wp_kses_post($answer);
464 + $text .= self::faq_image_markup($faq);
465 +
187 466 $entities[] = [
188 467 '@type' => 'Question',
189 468 'name' => $question,
190 469 'acceptedAnswer' => [
191 470 '@type' => 'Answer',
192 - 'text' => wp_kses_post($answer),
471 + 'text' => $text,
193 472 ],
194 473 ];
195 474 }
196 475