PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.12.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.12.0
2.12.0 2.11.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 All 53 releases
← All changes | includes/frontend/class-schema-graph.php +286 -225 2.6.0 → 2.12.0 View file →
@@ -87,33 +87,34 @@
87 87 'Book', 'Movie', 'Service', 'ImageObject', 'VideoObject',
88 88 ];
89 89
90 90 /**
91 - * Gutenberg FAQ block name.
92 - */
93 - private const FAQ_BLOCK = 'thinkrank/faq';
94 -
95 - /**
96 - * Elementor FAQ widget name.
97 - */
98 - private const FAQ_WIDGET = 'thinkrank-faq';
99 -
100 - /**
101 - * Bricks FAQ element name.
91 + * Types that may carry a `breadcrumb` property.
102 92 *
103 - * @since 2.3.1
93 + * Schema.org limits `breadcrumb` to WebPage and its subtypes. Attaching it
94 + * to a BlogPosting, Product or Recipe primary fails validation with
95 + * "Unexpected property" on every URL; the standalone BreadcrumbList node is
96 + * emitted either way (#693).
97 + *
98 + * @since 2.7.0
99 + * @var array<int,string>
104 100 */
105 - private const FAQ_BRICKS_ELEMENT = 'thinkrank-faq';
101 + private const BREADCRUMB_TYPES = [
102 + 'WebPage', 'AboutPage', 'CheckoutPage', 'CollectionPage', 'ContactPage',
103 + 'FAQPage', 'ItemPage', 'MedicalWebPage', 'ProfilePage', 'QAPage',
104 + 'RealEstateListing', 'SearchResultsPage', 'MediaGallery', 'ImageGallery',
105 + 'VideoGallery',
106 + ];
106 107
107 108 /**
108 - * The Beaver Builder FAQ module's slug, as stored in its layout nodes.
109 + * The FAQ surfaces themselves are read by FAQ_Content, which owns their
110 + * names, their stored shapes and their per-surface schema toggles. Only the
111 + * *foreign* FAQ producers are listed below, because standing down for them
112 + * is this class's decision rather than a fact about where ThinkRank keeps
113 + * its own content (#767).
109 114 *
110 - * Matches `ThinkRank_Beaver_FAQ_Module::SLUG`. Duplicated as a literal
111 - * rather than referenced, because that class extends `FLBuilderModule` and
112 - * so cannot be loaded at all when Beaver Builder is inactive — which is
113 - * exactly the site that still has a stored layout, after a builder switch.
115 + * @since 2.10.1
114 116 */
115 - private const FAQ_BEAVER_MODULE = 'thinkrank-faq';
116 117
117 118 /**
118 119 * Third-party Elementor widgets that publish their own FAQPage.
119 120 *
@@ -149,8 +150,16 @@
149 150 */
150 151 private static ?self $instance = null;
151 152
152 153 /**
154 + * Memoised master switch, or null when it has not been read this request.
155 + *
156 + * @since 2.7.0
157 + * @var bool|null
158 + */
159 + private static ?bool $master_switch_on = null;
160 +
161 + /**
153 162 * Competing page-level entities: ['rank' => int, 'schema' => array, 'type' => string].
154 163 *
155 164 * @var array<int,array>
156 165 */
@@ -232,8 +241,10 @@
232 241 * @return void
233 242 */
234 243 public static function reset(): void {
235 244 self::$instance = null;
245 + // Or a test that seeds the switch inherits the previous test's answer.
246 + self::$master_switch_on = null;
236 247 }
237 248
238 249 /**
239 250 * Register a candidate for the page's single page-level entity.
@@ -302,8 +313,61 @@
302 313 return (is_string($actual) && $actual !== '') ? $actual : $declared;
303 314 }
304 315
305 316 /**
317 + * Whether a page entity may carry a `breadcrumb` property.
318 + *
319 + * Reading the node's own `@type` key is not enough, and every case it
320 + * misses ends with a real WebPage losing a valid property:
321 + *
322 + * - A deployed schema whose stored JSON omits `@type` carries the type in
323 + * the `schema_type` column instead. `effective_type()` already resolves
324 + * that, which is why the node's `@id` reads `#webpage` even though the
325 + * node itself has no `@type` — so the resolved value is what has to be
326 + * consulted here too.
327 + * - JSON-LD permits several types on one node. `["WebPage", "FAQPage"]` is
328 + * a WebPage, but a strict in_array() against the array as a whole is
329 + * false, so the trail would be dropped from a page that may carry it.
330 + * - A list naming nothing usable (`[]`, `[null]`) is the first case wearing
331 + * the second's clothes, and resolves the same way.
332 + *
333 + * @since 2.7.0
334 + * @param array $node The page entity.
335 + * @param string $resolved_type Type the graph resolved for it.
336 + * @return bool
337 + */
338 + private function allows_breadcrumb(array $node, string $resolved_type): bool {
339 + $declared = $node['@type'] ?? '';
340 +
341 + // One path for both shapes. Splitting them invites the list branch to
342 + // grow its own idea of what an absent type means, which is the mistake
343 + // being corrected here in the first place.
344 + $named = false;
345 +
346 + foreach (is_array($declared) ? $declared : [$declared] as $type) {
347 + if (!is_string($type) || '' === $type) {
348 + continue;
349 + }
350 +
351 + $named = true;
352 +
353 + if (in_array($type, self::BREADCRUMB_TYPES, true)) {
354 + return true;
355 + }
356 + }
357 +
358 + // The node named a type, and none of them may carry a breadcrumb.
359 + if ($named) {
360 + return false;
361 + }
362 +
363 + // It named none, so it is whatever the graph resolved for it — the same
364 + // value its @id was minted from. An empty list is no more informative
365 + // than a missing key and must not read as "definitely not a WebPage".
366 + return in_array($resolved_type, self::BREADCRUMB_TYPES, true);
367 + }
368 +
369 + /**
306 370 * Register a node that does not compete for the page-level slot.
307 371 *
308 372 * @since 1.32.0
309 373 * @param array $schema Schema array.
@@ -484,252 +548,151 @@
484 548 // page throws `post_content` away, so a FAQ block left there when the
485 549 // page was switched over never renders. Publishing its questions would
486 550 // put schema on the page for content no visitor can see — which Google
487 551 // treats as a violation, not merely a duplicate (#650).
488 - if (!$this->bricks_supersedes_post_content((int) $post->ID)) {
489 - $this->collect_block_faq($post);
490 - }
552 + foreach (self::faq_groups($post) as $group) {
553 + // The stored toggle decides whether a producer contributes schema.
554 + // FAQ_Content reports it rather than applying it, because a block
555 + // with schema switched off is still visible FAQ content that the
556 + // abilities have to describe (#767).
557 + if (empty($group['schema'])) {
558 + continue;
559 + }
491 560
492 - $this->collect_elementor_faq($post);
493 - $this->collect_bricks_faq($post);
494 - $this->collect_beaver_faq($post);
495 - }
496 -
497 - /**
498 - * Whether Bricks renders this post and discards its `post_content`.
499 - *
500 - * @since 2.3.1
501 - * @param int $post_id Post being viewed.
502 - * @return bool
503 - */
504 - private function bricks_supersedes_post_content(int $post_id): bool {
505 - if (!class_exists('ThinkRank\\SEO\\Builder_Content')) {
506 - $file = THINKRANK_PLUGIN_DIR . 'includes/seo/class-builder-content.php';
507 - if (!file_exists($file)) {
508 - return false;
509 - }
510 - require_once $file;
561 + $this->absorb_content_faq($this->questions_from_pairs($group['pairs']));
511 562 }
512 -
513 - return \ThinkRank\SEO\Builder_Content::bricks_supersedes_post_content($post_id);
514 563 }
515 564
516 565 /**
517 - * Record that a body FAQ producer's content is represented in the graph.
566 + * Whether ThinkRank would publish a FAQPage for this post.
518 567 *
519 - * Deliberately not keyed on the entity count growing: when a block asks the
520 - * same question as the per-post deployment, dedup means nothing is added,
521 - * but the block's content *is* covered and it must still stay quiet.
568 + * Answers the question `get-faq` has to put to an agent — "is this actually
569 + * being emitted?" — without rendering the page. Kept here rather than in the
570 + * ability because every term of it is this class's own rule: the password
571 + * form, the master switch, the per-content-type Schema switch, the other
572 + * plugins whose FAQ output makes ThinkRank stand down, and the filter that
573 + * overrides all of it.
522 574 *
523 - * @since 1.32.0
524 - * @param array $entities Questions found on that producer.
525 - * @return void
526 - */
527 - private function absorb_content_faq(array $entities): void {
528 - if (empty($entities)) {
529 - return;
530 - }
531 -
532 - $this->add_faq_entities($entities);
533 - $this->absorbed_content_faq = true;
534 - }
535 -
536 - /**
537 - * Collect FAQ questions from thinkrank/faq blocks, including nested ones.
575 + * It answers for the post as a visitor gets it. A draft or a scheduled post
576 + * is reported on what it will emit once it is served, because that is the
577 + * question an agent preparing one is asking; a password-protected post is
578 + * not, because publishing it changes nothing — the form stays.
538 579 *
539 - * @since 1.32.0
540 - * @param \WP_Post $post Post being viewed.
541 - * @return void
580 + * @since 2.10.1
581 + * @param \WP_Post $post Post to test.
582 + * @return bool
542 583 */
543 - private function collect_block_faq(\WP_Post $post): void {
544 - if (!function_exists('parse_blocks') || !has_blocks($post->post_content)) {
545 - return;
584 + public static function will_emit_faqpage(\WP_Post $post): bool {
585 + // The same gate collect_post_faq() applies, and for the same reason:
586 + // behind a password form the block never renders, so no FAQPage is
587 + // published. Without this the ability answered "yes, it is emitted"
588 + // about a page that emits nothing — which is the answer that stops an
589 + // agent looking any further.
590 + if (function_exists('post_password_required') && post_password_required($post)) {
591 + return false;
546 592 }
547 593
548 - $this->walk_blocks(parse_blocks($post->post_content));
549 - }
594 + $has_questions = false;
550 595
551 - /**
552 - * Recurse a parsed block tree collecting FAQ entries.
553 - *
554 - * @since 1.32.0
555 - * @param array $blocks Parsed blocks.
556 - * @return void
557 - */
558 - private function walk_blocks(array $blocks): void {
559 - foreach ($blocks as $block) {
560 - if (!is_array($block)) {
596 + foreach (self::faq_groups($post) as $group) {
597 + if (empty($group['schema'])) {
561 598 continue;
562 599 }
563 600
564 - if (($block['blockName'] ?? '') === self::FAQ_BLOCK) {
565 - $attrs = $block['attrs'] ?? [];
566 -
567 - // Mirrors Blocks_Manager: schema is on unless explicitly disabled.
568 - $disabled = array_key_exists('outputSchema', $attrs) && false === $attrs['outputSchema'];
569 -
570 - if (!$disabled) {
571 - $this->absorb_content_faq($this->questions_from_pairs($attrs['faqs'] ?? []));
601 + foreach ($group['pairs'] as $pair) {
602 + if ('' !== trim((string) ($pair['question'] ?? '')) && '' !== trim((string) ($pair['answer'] ?? ''))) {
603 + $has_questions = true;
604 + break 2;
572 605 }
573 606 }
574 -
575 - if (!empty($block['innerBlocks']) && is_array($block['innerBlocks'])) {
576 - $this->walk_blocks($block['innerBlocks']);
577 - }
578 607 }
579 - }
580 608
581 - /**
582 - * Collect FAQ questions from Elementor FAQ widgets.
583 - *
584 - * @since 1.32.0
585 - * @param \WP_Post $post Post being viewed.
586 - * @return void
587 - */
588 - private function collect_elementor_faq(\WP_Post $post): void {
589 - $raw = get_post_meta($post->ID, '_elementor_data', true);
590 - if (empty($raw) || !is_string($raw)) {
591 - return;
609 + if (!$has_questions) {
610 + return false;
592 611 }
593 612
594 - $elements = json_decode($raw, true);
595 - if (!is_array($elements)) {
596 - return;
613 + if (!self::schema_allowed_for_post($post)) {
614 + return false;
597 615 }
598 616
599 - $this->walk_elementor($elements);
600 - }
617 + $emit = !(new self())->has_foreign_faq_source($post);
601 618
602 - /**
603 - * Collect FAQ questions from Bricks FAQ elements.
604 - *
605 - * Reads the tree Bricks will actually render — resolved through
606 - * `Builder_Content`, so a page whose content lives on a content template or
607 - * inside a component is covered, and one switched back to the block editor
608 - * is not.
609 - *
610 - * Unlike the block, this is not gated on Bricks owning `post_content`: a
611 - * Bricks element is on the page whenever Bricks renders the page, which is
612 - * exactly what resolving the tree already establishes (#626).
613 - *
614 - * @since 2.3.1
615 - * @param \WP_Post $post Post being viewed.
616 - * @return void
617 - */
618 - private function collect_bricks_faq(\WP_Post $post): void {
619 - $this->walk_bricks($this->bricks_tree((int) $post->ID));
619 + /** This filter is documented in includes/frontend/class-schema-graph.php */
620 + return (bool) apply_filters('thinkrank_emit_faqpage', $emit, $post);
620 621 }
621 622
622 623 /**
623 - * Collect FAQ questions from Beaver Builder FAQ modules.
624 + * output_allowed(), asked about a named post instead of the current query.
624 625 *
625 - * Beaver Builder keeps its layout in postmeta as a map of node objects and
626 - * leaves `post_content` alone, so — unlike Bricks — there is no
627 - * "supersedes post_content" gate to apply: a block FAQ left in the body and
628 - * a module FAQ in the layout can both genuinely be on the page, and both
629 - * belong in the one FAQPage.
626 + * output_allowed() resolves the content type from the main query, which in
627 + * an admin or MCP request is not the post being asked about.
630 628 *
631 - * The published layout is preferred over the draft for the same reason the
632 - * rest of the plugin prefers it: a draft holds edits no visitor has been
633 - * served yet, and schema must describe the page as delivered.
634 - *
635 - * @since 2.5.0
636 - * @param \WP_Post $post Post being viewed.
637 - * @return void
629 + * @since 2.10.1
630 + * @param \WP_Post $post Post to test.
631 + * @return bool
638 632 */
639 - private function collect_beaver_faq(\WP_Post $post): void {
640 - $layout = get_post_meta($post->ID, '_fl_builder_data', true);
633 + private static function schema_allowed_for_post(\WP_Post $post): bool {
634 + if (null === self::$master_switch_on) {
635 + // Resolve the site-wide half through the existing reader.
636 + self::output_allowed();
637 + }
641 638
642 - if (!is_array($layout) || empty($layout)) {
643 - return;
639 + if (!self::$master_switch_on) {
640 + return false;
644 641 }
645 642
646 - foreach ($layout as $node) {
647 - $settings = is_object($node) ? ($node->settings ?? null) : ($node['settings'] ?? null);
648 - $settings = is_object($settings) ? get_object_vars($settings) : $settings;
643 + if (class_exists('ThinkRank\\SEO\\Content_Type_Settings')) {
644 + return \ThinkRank\SEO\Content_Type_Settings::is_enabled(
645 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_SCHEMA,
646 + (string) $post->post_type,
647 + true
648 + );
649 + }
649 650
650 - if (!is_array($settings) || ($settings['type'] ?? '') !== self::FAQ_BEAVER_MODULE) {
651 - continue;
652 - }
653 -
654 - // Mirrors ThinkRank_Beaver_FAQ_Module::schema_enabled(): Beaver
655 - // Builder stores a cleared toggle as the string '0'.
656 - if (empty($settings['output_schema'])) {
657 - continue;
658 - }
659 -
660 - $rows = $settings['faqs'] ?? [];
661 - $rows = is_array($rows) ? array_map(
662 - static function ($row) {
663 - return is_object($row) ? get_object_vars($row) : $row;
664 - },
665 - $rows
666 - ) : [];
667 -
668 - $this->absorb_content_faq($this->questions_from_pairs($rows));
669 - }
651 + return true;
670 652 }
671 653
672 654 /**
673 - * Collect FAQ entries from a resolved Bricks tree.
655 + * Every FAQ producer on a post, loaded defensively.
674 656 *
675 - * The tree is flat, so no recursion: `Builder_Content::bricks_tree()`
676 - * splices component definitions into the same list.
657 + * The reader lives in the SEO namespace, and this class runs in contexts
658 + * where that autoloader is not guaranteed — which is why the Bricks gate it
659 + * replaced carried the same require.
677 660 *
678 - * The element's own settings are read here rather than through
679 - * `FAQ_Element`, whose class extends `Bricks\Element` and so cannot even be
680 - * loaded when the theme is inactive — which is exactly the case that still
681 - * has a stored tree, on a site that has since switched themes. The repeater
682 - * uses the same `question` / `answer` keys as the block, so the shared
683 - * builder below already understands it.
684 - *
685 - * @since 2.3.1
686 - * @param array $elements Bricks elements.
687 - * @return void
661 + * @since 2.10.1
662 + * @param \WP_Post $post Post being read.
663 + * @return array<int, array{source: string, schema: bool, pairs: array}>
688 664 */
689 - private function walk_bricks(array $elements): void {
690 - foreach ($elements as $element) {
691 - if (!is_array($element) || ($element['name'] ?? '') !== self::FAQ_BRICKS_ELEMENT) {
692 - continue;
665 + private static function faq_groups(\WP_Post $post): array {
666 + if (!class_exists('ThinkRank\\SEO\\FAQ_Content')) {
667 + $file = THINKRANK_PLUGIN_DIR . 'includes/seo/class-faq-content.php';
668 + if (!file_exists($file)) {
669 + return [];
693 670 }
671 + require_once $file;
672 + }
694 673
695 - $settings = is_array($element['settings'] ?? null) ? $element['settings'] : [];
696 -
697 - // Mirrors FAQ_Element: a cleared Bricks checkbox loses its key.
698 - if (empty($settings['outputSchema'])) {
699 - continue;
700 - }
701 -
702 - $this->absorb_content_faq($this->questions_from_pairs($settings['faqs'] ?? []));
703 - }
674 + return \ThinkRank\SEO\FAQ_Content::groups($post);
704 675 }
705 676
706 677 /**
707 - * Recurse an Elementor element tree collecting FAQ entries.
678 + * Record that a body FAQ producer's content is represented in the graph.
708 679 *
680 + * Deliberately not keyed on the entity count growing: when a block asks the
681 + * same question as the per-post deployment, dedup means nothing is added,
682 + * but the block's content *is* covered and it must still stay quiet.
683 + *
709 684 * @since 1.32.0
710 - * @param array $elements Elementor elements.
685 + * @param array $entities Questions found on that producer.
711 686 * @return void
712 687 */
713 - private function walk_elementor(array $elements): void {
714 - foreach ($elements as $element) {
715 - if (!is_array($element)) {
716 - continue;
717 - }
688 + private function absorb_content_faq(array $entities): void {
689 + if (empty($entities)) {
690 + return;
691 + }
718 692
719 - if (($element['widgetType'] ?? '') === self::FAQ_WIDGET) {
720 - $settings = $element['settings'] ?? [];
721 -
722 - // Mirrors FAQ_Widget: schema unless the toggle is off.
723 - if ('yes' === ($settings['output_schema'] ?? 'yes')) {
724 - $this->absorb_content_faq($this->questions_from_pairs($settings['faqs'] ?? []));
725 - }
726 - }
727 -
728 - if (!empty($element['elements']) && is_array($element['elements'])) {
729 - $this->walk_elementor($element['elements']);
730 - }
731 - }
693 + $this->add_faq_entities($entities);
694 + $this->absorbed_content_faq = true;
732 695 }
733 696
734 697 /**
735 698 * Turn stored question/answer pairs into Question entities.
@@ -788,8 +751,59 @@
788 751 return !empty($this->primary_candidates) || !empty($this->supporting) || !empty($this->faq_entities);
789 752 }
790 753
791 754 /**
755 + * Whether ThinkRank may emit structured data for this request at all.
756 + *
757 + * The master switch and the matrix's per-content-type Schema switch, asked
758 + * once. #461 put the master switch inside output_site_schema_markup(),
759 + * which is one of four producers; the other three never learned about it,
760 + * so turning Schema off removed the deployed rows and left the live
761 + * generator running — the page emitted *more* types with the switch off
762 + * than with it on (#688).
763 + *
764 + * Static because the producers that need it do not share a base class: the
765 + * three graph producers converge on render(), but Blocks_Manager emits its
766 + * own script tag from a content filter and never touches the graph, so it
767 + * has to ask the same question independently.
768 + *
769 + * @since 2.7.0
770 + * @return bool True when structured data may be emitted.
771 + */
772 + public static function output_allowed(): bool {
773 + // Memoised because inject_block_schema() asks once per matching block,
774 + // and Schema_Management_System's constructor builds a schema builder and
775 + // a cache manager and registers listeners — it is not something to spin
776 + // up per block. The switch is site-wide, so it cannot change within a
777 + // request; the per-content-type check below is query-dependent and stays
778 + // live.
779 + if (null === self::$master_switch_on) {
780 + self::$master_switch_on = true;
781 +
782 + if (class_exists('ThinkRank\\SEO\\Schema_Management_System')) {
783 + $settings = (new \ThinkRank\SEO\Schema_Management_System())->get_settings('site', null);
784 +
785 + // Absent means "not configured", which every other reader treats
786 + // as enabled; only a value that is present and off disables.
787 + self::$master_switch_on = !(array_key_exists('enabled', $settings) && empty($settings['enabled']));
788 + }
789 + }
790 +
791 + if (!self::$master_switch_on) {
792 + return false;
793 + }
794 +
795 + if (class_exists('ThinkRank\\SEO\\Content_Type_Settings')) {
796 + return \ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
797 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_SCHEMA,
798 + true
799 + );
800 + }
801 +
802 + return true;
803 + }
804 +
805 + /**
792 806 * Assemble and emit the graph. Safe to call more than once.
793 807 *
794 808 * @since 1.32.0
795 809 * @return void
@@ -798,8 +812,14 @@
798 812 if ($this->rendered || !$this->has_nodes()) {
799 813 return;
800 814 }
801 815
816 + // Every graph producer converges here, so this is the one place the
817 + // master switch has to hold for all of them (#688).
818 + if (!self::output_allowed()) {
819 + return;
820 + }
821 +
802 822 // A 404 response represents no content, so there is nothing for
803 823 // structured data to describe. The page-level producers already skip
804 824 // this context, but the site-identity entity does not, so without this
805 825 // guard every miss — including crawlers probing URLs that never existed
@@ -834,8 +854,18 @@
834 854 if (empty($graph)) {
835 855 return;
836 856 }
837 857
858 + // One pass over the assembled graph, rather than at each producer.
859 + // @id and url values arrive from a dozen of them — some derived from
860 + // WordPress, some read straight out of stored settings — and on a
861 + // misconfigured site that produced a single graph carrying both
862 + // schemes at once, with @ids that no longer matched the canonical they
863 + // are supposed to identify (#638). Normalizing where the graph is
864 + // serialized is the only place that catches all of them, including
865 + // nodes an add-on added through the filter above.
866 + $graph = \ThinkRank\SEO\Url_Scheme::apply_deep($graph);
867 +
838 868 $json = wp_json_encode(
839 869 ['@context' => self::SCHEMA_CONTEXT, '@graph' => array_values($graph)],
840 870 JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
841 871 | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
@@ -845,9 +875,9 @@
845 875 return;
846 876 }
847 877
848 878 echo "<!-- ThinkRank Schema Graph -->\n";
849 - echo '<script type="application/ld+json">' . "\n";
879 + echo '<script type="application/ld+json" data-thinkrank="schema-graph">' . "\n";
850 880 echo $json . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_json_encode with JSON_HEX_* cannot break out of the script block.
851 881 echo '</script>' . "\n";
852 882 echo "<!-- /ThinkRank Schema Graph -->\n";
853 883 }
@@ -958,11 +988,12 @@
958 988 $primary = ['schema' => $faq, 'type' => 'FAQPage'];
959 989 $faq = null;
960 990 }
961 991
962 - $nodes = [];
963 - $primary_id = '';
964 - $used_ids = [];
992 + $nodes = [];
993 + $primary_id = '';
994 + $primary_type = '';
995 + $used_ids = [];
965 996
966 997 if (null !== $primary) {
967 998 $node = $primary['schema'];
968 999
@@ -970,9 +1001,10 @@
970 1001 // so an "Article" setting that renders BlogPosting reads #blogposting.
971 1002 $resolved_type = $this->effective_type($node, $primary['type']);
972 1003
973 1004 $node = $this->assign_id($node, $base . '#' . strtolower($resolved_type), $used_ids);
974 - $primary_id = $node['@id'];
1005 + $primary_id = $node['@id'];
1006 + $primary_type = $resolved_type;
975 1007 $nodes['primary'] = $node;
976 1008 }
977 1009
978 1010 // Entities deployed alongside the winner (Pro's Multi-Schema lets a post
@@ -1013,8 +1045,9 @@
1013 1045 $person_nodes = [];
1014 1046
1015 1047 foreach ($this->supporting as $index => $node) {
1016 1048 $type = $node['@type'] ?? '';
1049 + $slot = $this->site_level_slot($type);
1017 1050
1018 1051 if ('BreadcrumbList' === $type) {
1019 1052 $node = $this->assign_id($node, $base . '#breadcrumb', $used_ids);
1020 1053 $breadcrumb_id = $node['@id'];
@@ -1023,9 +1056,9 @@
1023 1056 $website_id = $node['@id'];
1024 1057 } elseif ('Organization' === $type) {
1025 1058 $node = $this->assign_id($node, home_url('/#organization'), $used_ids);
1026 1059 $organization_nodes[] = $node;
1027 - } elseif (in_array($type, self::SITE_LEVEL_TYPES, true)) {
1060 + } elseif ('' !== $slot) {
1028 1061 // Site-level entities describe the site, not the page, so their
1029 1062 // @id must be stable across URLs. Falling through to the
1030 1063 // page-scoped branch minted a fresh identity on every URL, so
1031 1064 // one business became N entities in a crawler's graph and
@@ -1035,9 +1068,9 @@
1035 1068 // arrive here claiming the same @id. assign_id() would resolve
1036 1069 // that collision by minting "#person-2", turning a duplicate
1037 1070 // into two competing entities that split the identity a
1038 1071 // knowledge graph is meant to consolidate (#479).
1039 - $duplicate_key = $this->find_same_entity($nodes, $type, $node);
1072 + $duplicate_key = $this->find_same_entity($nodes, $slot, $node);
1040 1073
1041 1074 if (null !== $duplicate_key) {
1042 1075 $nodes[$duplicate_key] = $this->merge_entity($nodes[$duplicate_key], $node);
1043 1076 continue;
@@ -1042,11 +1075,11 @@
1042 1075 $nodes[$duplicate_key] = $this->merge_entity($nodes[$duplicate_key], $node);
1043 1076 continue;
1044 1077 }
1045 1078
1046 - $node = $this->assign_id($node, home_url('/#' . strtolower($type)), $used_ids);
1079 + $node = $this->assign_id($node, home_url('/#' . strtolower($slot)), $used_ids);
1047 1080
1048 - if ('Person' === $type) {
1081 + if ('Person' === $slot) {
1049 1082 $person_nodes[] = $node;
1050 1083 }
1051 1084 } elseif (is_string($type) && $type !== '') {
1052 1085 $node = $this->assign_id($node, $base . '#' . strtolower($type), $used_ids);
@@ -1059,9 +1092,13 @@
1059 1092 if (isset($nodes['primary'])) {
1060 1093 if ($website_id !== '' && !isset($nodes['primary']['isPartOf'])) {
1061 1094 $nodes['primary']['isPartOf'] = ['@id' => $website_id];
1062 1095 }
1063 - if ($breadcrumb_id !== '' && !isset($nodes['primary']['breadcrumb'])) {
1096 + if (
1097 + $breadcrumb_id !== ''
1098 + && !isset($nodes['primary']['breadcrumb'])
1099 + && $this->allows_breadcrumb($nodes['primary'], $primary_type)
1100 + ) {
1064 1101 $nodes['primary']['breadcrumb'] = ['@id' => $breadcrumb_id];
1065 1102 }
1066 1103
1067 1104 // Point publisher/author at the full nodes already in the graph.
@@ -1135,8 +1172,32 @@
1135 1172 return ['winner' => array_shift($kept), 'siblings' => array_values($kept)];
1136 1173 }
1137 1174
1138 1175 /**
1176 + * The site-level entity a node's @type makes it, or '' for none.
1177 + *
1178 + * A LocalBusiness is published under the subtype the site chose in Local
1179 + * SEO ("Dentist", "Restaurant"), but it is still the one business entity,
1180 + * so every subtype shares the LocalBusiness slot: the same home-scoped
1181 + * `/#localbusiness` @id it always had, and the same entity for
1182 + * find_same_entity() to fold a per-post copy into. Matching the literal
1183 + * type instead would have given a Dentist a fresh page-scoped @id on every
1184 + * URL, the exact split #471 closed.
1185 + *
1186 + * @since 2.10.0
1187 + *
1188 + * @param mixed $type Node @type.
1189 + * @return string 'LocalBusiness', 'Person' or ''.
1190 + */
1191 + private function site_level_slot($type): string {
1192 + if (\ThinkRank\Config\Local_Business_Types_Config::is_local_business($type)) {
1193 + return 'LocalBusiness';
1194 + }
1195 +
1196 + return (is_string($type) && in_array($type, self::SITE_LEVEL_TYPES, true)) ? $type : '';
1197 + }
1198 +
1199 + /**
1139 1200 * Find an already-placed node describing the same entity as $node.
1140 1201 *
1141 1202 * Identity is `email` when both carry one — two people can share a name,
1142 1203 * but not a mailbox — and a case-insensitive `name` match otherwise. A node
@@ -1145,9 +1206,9 @@
1145 1206 *
1146 1207 * @since 2.0.2
1147 1208 *
1148 1209 * @param array $nodes Nodes placed so far, keyed.
1149 - * @param string $type Schema type to match within.
1210 + * @param string $type Site-level slot to match within (see site_level_slot()).
1150 1211 * @param array $node Candidate node.
1151 1212 * @return string|null Key of the matching node, or null.
1152 1213 */
1153 1214 private function find_same_entity(array $nodes, string $type, array $node): ?string {
@@ -1158,9 +1219,9 @@
1158 1219 return null;
1159 1220 }
1160 1221
1161 1222 foreach ($nodes as $key => $placed) {
1162 - if (($placed['@type'] ?? '') !== $type) {
1223 + if ($this->site_level_slot($placed['@type'] ?? '') !== $type) {
1163 1224 continue;
1164 1225 }
1165 1226
1166 1227 $placed_email = isset($placed['email']) ? strtolower(trim((string) $placed['email'])) : '';