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/seo/class-builder-content.php +427 -23 2.3.02.7.0 View file →
@@ -97,8 +97,41 @@
97 97 */
98 98 private const BRICKS_COMPONENTS_OPTION = 'bricks_components';
99 99
100 100 /**
101 + * Bricks' element that renders the post's own `post_content`.
102 + *
103 + * A Bricks page normally discards `post_content` entirely, which is why
104 + * anything left there is invisible. Dropping this element onto the canvas
105 + * is the one way an author puts it back on the page, so its presence flips
106 + * `post_content` from stale leftovers to content the visitor reads.
107 + *
108 + * @since 2.3.1
109 + * @var string
110 + */
111 + private const BRICKS_POST_CONTENT_ELEMENT = 'post-content';
112 +
113 + /**
114 + * Resolved Bricks trees for this request, keyed by post ID.
115 + *
116 + * Rendering one page asks for the tree about twenty times — every
117 + * description, every schema node, the FAQ guard — and resolving it is not
118 + * free. `bricks_content_source()` clears `Bricks\Database::$active_templates`
119 + * before asking Bricks which content template applies, which defeats
120 + * Bricks' own early-return and re-runs its whole template-condition engine;
121 + * `expand_bricks_components()` then walks the tree again. Measured on a
122 + * Bricks page with no content of its own, that was ten full runs of the
123 + * rules engine per request.
124 + *
125 + * Per-request only, and only ever read back within one page render — a
126 + * request that writes Bricks content does not also render it.
127 + *
128 + * @since 2.3.1
129 + * @var array<int,array<int,mixed>>
130 + */
131 + private static array $bricks_trees = [];
132 +
133 + /**
101 134 * JSON keys whose values are user-visible text.
102 135 *
103 136 * Builder trees mix content with configuration, so a blind string sweep
104 137 * would count CSS classes and option slugs as words. Matching on the key
@@ -126,8 +159,71 @@
126 159 'link', 'url', 'href', 'link_url', 'button_link', 'permalink', 'link_to',
127 160 ];
128 161
129 162 /**
163 + * JSON keys whose values hold an embedded video's source.
164 + *
165 + * A builder's video widget keeps its destination in a provider-specific
166 + * field — Elementor picks `youtube_url`, `vimeo_url`, `dailymotion_url` or
167 + * `hosted_url` according to the chosen source type — none of which is a
168 + * link field or a content field, so a video on a builder page reached the
169 + * analyzers as nothing at all.
170 + *
171 + * These are deliberately kept out of URL_KEYS. A video is an embed, not an
172 + * outbound link: rendering one as `<a href>` would add a spurious external
173 + * link to every page carrying a video and skew the link counts. They are
174 + * reconstructed as `<iframe>`/`<video>` instead, which the video detector
175 + * recognises and the link and image counters ignore.
176 + *
177 + * @since 2.3.1
178 + * @var string[]
179 + */
180 + private const VIDEO_KEYS = [
181 + 'youtube_url', 'vimeo_url', 'dailymotion_url', 'videopress_url',
182 + 'hosted_url', 'video_url', 'video_src', 'video_link',
183 + ];
184 +
185 + /**
186 + * File extensions that mean a video source is a file, not a provider page.
187 + *
188 + * @since 2.3.1
189 + * @var string[]
190 + */
191 + private const VIDEO_FILE_EXTENSIONS = ['mp4', 'webm', 'ogv', 'mov', 'm4v'];
192 +
193 + /**
194 + * Source keys to trust for a declared video source type.
195 + *
196 + * A widget keeps one field per provider and does not clear the others when
197 + * the author switches source: an Elementor video moved from YouTube to Self
198 + * Hosted still carries the earlier `youtube_url`. Reading whichever key
199 + * turns up first then emits the video the author replaced. The widget says
200 + * which one it is actually playing, so that is read first and the flat key
201 + * sweep is only the fallback for a builder that declares nothing.
202 + *
203 + * @since 2.3.1
204 + * @var array<string,string[]>
205 + */
206 + private const VIDEO_KEYS_BY_TYPE = [
207 + 'youtube' => ['youtube_url'],
208 + 'vimeo' => ['vimeo_url'],
209 + 'dailymotion' => ['dailymotion_url'],
210 + 'videopress' => ['videopress_url'],
211 + 'hosted' => ['hosted_url', 'video_url', 'video_src', 'video_link'],
212 + 'media' => ['hosted_url', 'video_url', 'video_src', 'video_link'],
213 + 'file' => ['hosted_url', 'video_url', 'video_src', 'video_link'],
214 + 'self_hosted' => ['hosted_url', 'video_url', 'video_src', 'video_link'],
215 + ];
216 +
217 + /**
218 + * Keys a builder uses to name which video source a widget is playing.
219 + *
220 + * @since 2.3.1
221 + * @var string[]
222 + */
223 + private const VIDEO_TYPE_KEYS = ['video_type', 'videotype', 'video_source', 'source_type'];
224 +
225 + /**
130 226 * JSON keys whose values hold an image, as a URL string or `{ url, alt }`.
131 227 *
132 228 * @var string[]
133 229 */
@@ -144,9 +240,13 @@
144 240 *
145 241 * @var string[]
146 242 */
147 243 private const HEADING_TAG_KEYS = [
148 - 'header_size', 'heading_tag', 'html_tag', 'title_tag', 'tag', 'level', 'size',
244 + // Lower-cased on both sides of the comparison, so `headingtag` is the
245 + // camelCase `headingTag` Bricks uses throughout its own controls and
246 + // which ThinkRank's Bricks elements declare. Without it their section
247 + // headings counted as body copy and never reached heading structure.
248 + 'header_size', 'heading_tag', 'headingtag', 'html_tag', 'title_tag', 'tag', 'level', 'size',
149 249 ];
150 250
151 251 /**
152 252 * Keys whose value is alternative text for a sibling image.
@@ -161,12 +261,231 @@
161 261 * @param \WP_Post $post Post being analyzed.
162 262 * @return string HTML/text to analyze.
163 263 */
164 264 public static function resolve(\WP_Post $post): string {
165 - return self::resolve_markup((string) $post->post_content, $post);
265 + $raw = (string) $post->post_content;
266 +
267 + // A page built in Gutenberg and then switched to Bricks keeps its old
268 + // blocks in `post_content` forever — Bricks never clears them, and
269 + // never renders them either. Resolving that first meant the stale draft
270 + // beat the tree the visitor actually reads, and it did not stop at the
271 + // score: the same string becomes the meta description, og:description,
272 + // twitter:description and the schema description. Starting from nothing
273 + // sends the resolution straight to Bricks' storage, which is where this
274 + // page's words are (#651).
275 + //
276 + // Only for the stored path. `resolve_markup()` is also called with live
277 + // editor content, and the Bricks panel's resolver reads the canvas —
278 + // discarding that would replace what the author is typing with the last
279 + // save.
280 + if (self::bricks_supersedes_post_content((int) $post->ID)) {
281 + $raw = '';
282 + }
283 +
284 + return self::resolve_markup($raw, $post);
166 285 }
167 286
168 287 /**
288 + * Whether Bricks renders this post and throws its `post_content` away.
289 + *
290 + * True means anything still stored in `post_content` is invisible: it is
291 + * not on the page, so it must not be scored, described or published as
292 + * structured data. False covers both a post Bricks does not own and a
293 + * Bricks page that puts `post_content` back with a Post Content element.
294 + *
295 + * @since 2.3.1
296 + *
297 + * @param int $post_id Post being resolved.
298 + * @return bool
299 + */
300 + public static function bricks_supersedes_post_content(int $post_id): bool {
301 + $tree = self::bricks_tree($post_id);
302 +
303 + if (empty($tree)) {
304 + return false;
305 + }
306 +
307 + foreach ($tree as $element) {
308 + if (is_array($element)
309 + && self::BRICKS_POST_CONTENT_ELEMENT === ($element['name'] ?? null)
310 + ) {
311 + return false;
312 + }
313 + }
314 +
315 + return !self::bricks_tree_prints_post_content($tree);
316 + }
317 +
318 + /**
319 + * Whether a Bricks tree prints the body through a dynamic-data tag.
320 + *
321 + * The Post Content element is not the only way back onto the page: Bricks'
322 + * `{post_content}` tag renders the same thing from inside an ordinary text
323 + * element, and a single-post template written that way is a common shape.
324 + * Missing it would mean the post's real body is discarded everywhere —
325 + * scoring, the meta/og/twitter descriptions, the schema description — for a
326 + * page that is displaying it.
327 + *
328 + * Matched over the encoded tree rather than per setting, because the tag can
329 + * sit in any string field of any element and Bricks allows modifiers after
330 + * the name (`{post_content:...}`).
331 + *
332 + * @since 2.3.1
333 + *
334 + * @param array $tree Bricks element tree.
335 + * @return bool
336 + */
337 + private static function bricks_tree_prints_post_content(array $tree): bool {
338 + $encoded = wp_json_encode($tree);
339 +
340 + return is_string($encoded) && false !== stripos($encoded, '{post_content');
341 + }
342 +
343 + /**
344 + * The post's content as the visitor actually receives it.
345 + *
346 + * `post_content` for everything except a Bricks page that discards it, and
347 + * there the Bricks tree's text. Descriptions are derived from a post's body
348 + * in half a dozen places; every one of them wants this rather than the raw
349 + * column (#651).
350 + *
351 + * @since 2.3.1
352 + *
353 + * @param \WP_Post $post Post being described.
354 + * @return string
355 + */
356 + public static function visible_content(\WP_Post $post): string {
357 + $superseding = self::superseding_content($post);
358 +
359 + return '' !== $superseding ? $superseding : (string) $post->post_content;
360 + }
361 +
362 + /**
363 + * Replacement body text for a post whose `post_content` does not render.
364 + *
365 + * Empty for every ordinary post, which is what makes this safe to call from
366 + * paths that already handle excerpts their own way: they keep that handling
367 + * and only a Bricks page is diverted.
368 + *
369 + * @since 2.3.1
370 + *
371 + * @param \WP_Post $post Post being described.
372 + * @return string Visible body text, or '' when `post_content` is fine.
373 + */
374 + public static function superseding_content(\WP_Post $post): string {
375 + if (!self::bricks_supersedes_post_content((int) $post->ID)) {
376 + return '';
377 + }
378 +
379 + $bricks = self::from_bricks((int) $post->ID);
380 +
381 + return self::is_blank($bricks) ? '' : $bricks;
382 + }
383 +
384 + /**
385 + * Body text to derive a description from, when the usual source is wrong.
386 + *
387 + * A hand-written excerpt is the author's own summary and is correct however
388 + * the page is built, so it yields '' here and the caller's normal
389 + * `get_the_excerpt()` path keeps it. Only a Bricks page with no excerpt —
390 + * where core would derive one from discarded `post_content` — gets diverted.
391 + *
392 + * @since 2.3.1
393 + *
394 + * @param \WP_Post $post Post being described.
395 + * @return string Text to summarize, or '' to leave the caller's path alone.
396 + */
397 + public static function superseding_excerpt_source(\WP_Post $post): string {
398 + if ('' !== trim((string) $post->post_excerpt)) {
399 + return '';
400 + }
401 +
402 + return self::superseding_content($post);
403 + }
404 +
405 + /**
406 + * The Bricks element tree that renders for a post.
407 + *
408 + * Public because what Bricks puts on the page is not only a scoring
409 + * question: the schema graph has to know whether a Bricks element already
410 + * publishes the page's FAQ before adding one of its own (#649, #650).
411 + *
412 + * Flat, in Bricks' own storage shape — `expand_bricks_components()`
413 + * appends component definitions to the same list rather than nesting them,
414 + * so one `foreach` reaches every element.
415 + *
416 + * @since 2.3.1
417 + *
418 + * @param int $post_id Post being resolved.
419 + * @return array<int,mixed> Elements, or [] when Bricks renders nothing here.
420 + */
421 + /**
422 + * The builder meta keys, for callers that need to inspect the raw storage
423 + * rather than the text extracted from it.
424 + *
425 + * The SEO Analyzer reads these to answer "is there a ThinkRank FAQ element
426 + * on this post?", which is a question about the stored tree, not about the
427 + * words in it (#686).
428 + *
429 + * @since 2.7.0
430 + * @return string[]
431 + */
432 + public static function builder_meta_keys(): array {
433 + return self::BUILDER_META_KEYS;
434 + }
435 +
436 + public static function bricks_tree(int $post_id): array {
437 + if (array_key_exists($post_id, self::$bricks_trees)) {
438 + return self::$bricks_trees[$post_id];
439 + }
440 +
441 + self::$bricks_trees[$post_id] = self::resolve_bricks_tree($post_id);
442 +
443 + return self::$bricks_trees[$post_id];
444 + }
445 +
446 + /**
447 + * Discard the resolved-tree memo. Test seam.
448 + *
449 + * @since 2.3.1
450 + * @return void
451 + */
452 + public static function flush_bricks_cache(): void {
453 + self::$bricks_trees = [];
454 + }
455 +
456 + /**
457 + * Read and resolve a post's Bricks tree, ignoring the memo.
458 + *
459 + * @since 2.3.1
460 + *
461 + * @param int $post_id Post being resolved.
462 + * @return array<int,mixed>
463 + */
464 + private static function resolve_bricks_tree(int $post_id): array {
465 + if (!self::bricks_owns_post($post_id)) {
466 + return [];
467 + }
468 +
469 + $source = self::bricks_content_source($post_id);
470 + if (!$source) {
471 + return [];
472 + }
473 +
474 + $stored = get_post_meta($source, self::bricks_meta_key(), true);
475 +
476 + if (is_string($stored)) {
477 + $stored = '' === trim($stored) ? null : json_decode($stored, true);
478 + }
479 +
480 + if (!is_array($stored) || empty($stored)) {
481 + return [];
482 + }
483 +
484 + return self::expand_bricks_components($stored);
485 + }
486 +
487 + /**
169 488 * Resolve an arbitrary chunk of editor markup for the given post.
170 489 *
171 490 * The editor sends its live content to the scorer so an author sees their
172 491 * unsaved edits reflected. On a builder page that live string is the raw
@@ -319,31 +638,16 @@
319 638 * @param int $post_id Post being resolved.
320 639 * @return string Extracted text, or '' when Bricks has nothing for it.
321 640 */
322 641 private static function from_bricks(int $post_id): string {
323 - if (!self::bricks_owns_post($post_id)) {
324 - return '';
325 - }
642 + $tree = self::bricks_tree($post_id);
326 643
327 - $source = self::bricks_content_source($post_id);
328 - if (!$source) {
644 + if (empty($tree)) {
329 645 return '';
330 646 }
331 647
332 - $stored = get_post_meta($source, self::bricks_meta_key(), true);
333 -
334 - if (is_string($stored)) {
335 - $stored = '' === trim($stored) ? null : json_decode($stored, true);
336 - }
337 -
338 - if (!is_array($stored) || empty($stored)) {
339 - return '';
340 - }
341 -
342 648 return self::strip_bricks_dynamic_tags(
343 - self::text_from_tree(
344 - self::without_bricks_element_labels(self::expand_bricks_components($stored))
345 - )
649 + self::text_from_tree(self::without_bricks_element_labels($tree))
346 650 );
347 651 }
348 652
349 653 /**
@@ -785,9 +1089,10 @@
785 1089 // would emit the same URL a second time as a bare link, and
786 1090 // would turn an image's own `url` field into a spurious <a>.
787 1091 if (is_string($child_key)
788 1092 && (in_array(strtolower($child_key), self::URL_KEYS, true)
789 - || in_array(strtolower($child_key), self::IMAGE_KEYS, true))
1093 + || in_array(strtolower($child_key), self::IMAGE_KEYS, true)
1094 + || in_array(strtolower($child_key), self::VIDEO_KEYS, true))
790 1095 ) {
791 1096 continue;
792 1097 }
793 1098
@@ -839,8 +1144,79 @@
839 1144 return implode("\n", $collected);
840 1145 }
841 1146
842 1147 /**
1148 + * The video source a node is actually playing, if any.
1149 + *
1150 + * @since 2.3.1
1151 + *
1152 + * @param array $node Builder node.
1153 + * @return string Video source, or '' when the node carries none.
1154 + */
1155 + private static function video_from(array $node): string {
1156 + foreach ($node as $key => $value) {
1157 + if (!is_string($key) || !is_string($value)) {
1158 + continue;
1159 + }
1160 +
1161 + if (!in_array(strtolower($key), self::VIDEO_TYPE_KEYS, true)) {
1162 + continue;
1163 + }
1164 +
1165 + $keys = self::VIDEO_KEYS_BY_TYPE[strtolower(trim($value))] ?? null;
1166 + if (null === $keys) {
1167 + continue;
1168 + }
1169 +
1170 + // A recognised video_type settles it, including when that
1171 + // provider's own field is empty. Falling through to the flat sweep
1172 + // there handed back whichever sibling key happened to come first in
1173 + // node order — the stale youtube_url left behind after switching
1174 + // the widget to a hosted file, which is exactly what keying on the
1175 + // declared type is meant to prevent.
1176 + $declared = self::url_from($node, $keys);
1177 +
1178 + return self::is_video_source($declared) ? $declared : '';
1179 + }
1180 +
1181 + $url = self::url_from($node, self::VIDEO_KEYS);
1182 +
1183 + return self::is_video_source($url) ? $url : '';
1184 + }
1185 +
1186 + /**
1187 + * Whether a value can be a video source.
1188 + *
1189 + * `looks_like_url()` also accepts `#anchor`, `mailto:` and `tel:`, which a
1190 + * link node may legitimately hold but a video cannot: `<iframe src="#top">`
1191 + * is not a video and would reach a video sitemap as one.
1192 + *
1193 + * @since 2.3.1
1194 + *
1195 + * @param string $url Candidate source.
1196 + * @return bool
1197 + */
1198 + private static function is_video_source(string $url): bool {
1199 + return '' !== $url
1200 + && (1 === preg_match('#^(https?:)?//#i', $url) || str_starts_with($url, '/'));
1201 + }
1202 +
1203 + /**
1204 + * Whether a video source points at a file rather than a provider page.
1205 + *
1206 + * @since 2.3.1
1207 + *
1208 + * @param string $url Video source.
1209 + * @return bool
1210 + */
1211 + private static function is_video_file(string $url): bool {
1212 + $path = (string) wp_parse_url($url, PHP_URL_PATH);
1213 + $ext = strtolower((string) pathinfo($path, PATHINFO_EXTENSION));
1214 +
1215 + return in_array($ext, self::VIDEO_FILE_EXTENSIONS, true);
1216 + }
1217 +
1218 + /**
843 1219 * Rebuild the HTML a single builder node represents, if any.
844 1220 *
845 1221 * Looks only at the node's own fields (plus one level of nesting, because
846 1222 * builders commonly wrap a destination as `{ url: … }`). Returns an empty
@@ -856,12 +1232,23 @@
856 1232 private static function markup_for_node(array $node, array &$consumed): string {
857 1233 $text = self::first_value($node, self::CONTENT_KEYS);
858 1234 $url = self::url_from($node, self::URL_KEYS);
859 1235 $image = self::image_from($node);
1236 + $video = self::video_from($node);
860 1237 $tag = self::heading_tag_from($node);
861 1238
862 1239 $parts = [];
863 1240
1241 + // Video: an embed shape rather than a link, so the video detector can
1242 + // see it while the link counters do not mistake it for an outbound
1243 + // link. A file source becomes <video src>, anything else an <iframe>,
1244 + // matching how the builder itself renders the two cases.
1245 + if ('' !== $video) {
1246 + $parts[] = self::is_video_file($video)
1247 + ? sprintf('<video src="%s"></video>', esc_url_raw($video))
1248 + : sprintf('<iframe src="%s"></iframe>', esc_url_raw($video));
1249 + }
1250 +
864 1251 // Image: alt text matters as much as the tag, since alt checks run over
865 1252 // whatever this returns.
866 1253 if ('' !== $image['url']) {
867 1254 $alt = '' !== $image['alt'] ? $image['alt'] : (string) self::first_value($node, self::ALT_KEYS);
@@ -1050,13 +1437,30 @@
1050 1437 return (bool) preg_match('#^(mailto:|tel:|\#)#i', $value);
1051 1438 }
1052 1439
1053 1440 /**
1054 - * Whether a value carries no readable text.
1441 + * Whether a value carries nothing worth analyzing.
1055 1442 *
1443 + * Readable text is the usual signal, but not the only one: a page can be
1444 + * made entirely of media. A builder section holding just a gallery
1445 + * reconstructs to `<img>` tags and one holding just a video widget to a
1446 + * single `<iframe>` — both strip to an empty string, so a text-only test
1447 + * discarded them here and the page fell through to the next builder key,
1448 + * then to the raw markup, and finally reported as having no content at all.
1449 + *
1450 + * Comments are dropped before the tag test: the raw markup this class falls
1451 + * back to on a builder page is unrendered block comments, which must stay
1452 + * blank rather than be mistaken for reconstructed media.
1453 + *
1056 1454 * @param string $value Candidate content.
1057 1455 * @return bool
1058 1456 */
1059 1457 private static function is_blank(string $value): bool {
1060 - return '' === trim(wp_strip_all_tags($value));
1458 + if ('' !== trim(wp_strip_all_tags($value))) {
1459 + return false;
1460 + }
1461 +
1462 + $without_comments = (string) preg_replace('~<!--.*?-->~s', '', $value);
1463 +
1464 + return 1 !== preg_match('~<(?:a|img|iframe|video|source)\b~i', $without_comments);
1061 1465 }
1062 1466 }