reader = new PR_Core_Prab_Meta_Reader(); } /** * Register Yoast and fallback hooks. * * Hooked by PR_Core::init(). Only registers hooks when on a frontend * page — safe to call unconditionally; WP handles the rest. * * @return void */ public function register_hooks(): void { add_filter( 'wpseo_schema_webpage_type', array( $this, 'retype_article_page' ), 10 ); add_filter( 'wpseo_schema_graph', array( $this, 'enrich_article_graph' ), 13, 2 ); add_action( 'wp_head', array( $this, 'emit_standalone_fallback' ), 99 ); } /** * Retype the page node to MedicalWebPage for triggered PRAB articles. * * Hooked on: wpseo_schema_webpage_type (priority 10). * Passes through unchanged on non-triggered pages and non-`post` pages. * * @param string|array $type Existing Yoast page type (string or string[]). * @return string|array 'MedicalWebPage' when triggered, $type otherwise. */ public function retype_article_page( string|array $type ): string|array { if ( ! is_singular( 'post' ) ) { return $type; } $post_id = (int) get_the_ID(); if ( $post_id <= 0 || ! $this->reader->is_triggered( $post_id ) ) { return $type; } return 'MedicalWebPage'; } /** * Enrich Yoast's assembled graph for triggered PRAB articles. * * Injects lastReviewed + reviewedBy into the MedicalWebPage node and adds * citation[] + about[] to the Article node. Priority 13 ensures this runs * after PR_Core_Jsonld's priority-12 Drug/FAQ injection. * * Never adds new WebPage or Article nodes — mutates Yoast's existing pieces. * * @param array> $graph Assembled Yoast graph. * @param \Yoast\WP\SEO\Context\Meta_Tags_Context|null $context Yoast schema context. * @return array> Enriched graph. */ public function enrich_article_graph( array $graph, $context ): array { if ( ! is_singular( 'post' ) ) { return $graph; } $post_id = (int) get_the_ID(); if ( $post_id <= 0 || ! $this->reader->is_triggered( $post_id ) ) { return $graph; } $review_mode = $this->reader->get_review_mode( $post_id ); $reviewed_at = $this->reader->get_reviewed_at( $post_id ); $reviewed_by = $this->reader->get_reviewed_by( $post_id, $review_mode ); $citations = $this->reader->get_citations( $post_id ); $about_stubs = $this->reader->get_about_peptides( $post_id ); foreach ( $graph as &$piece ) { if ( ! is_array( $piece ) ) { continue; } $types = is_array( $piece['@type'] ?? '' ) ? $piece['@type'] : array( $piece['@type'] ?? '' ); // Enrich MedicalWebPage with lastReviewed + reviewedBy. if ( in_array( 'WebPage', $types, true ) || in_array( 'MedicalWebPage', $types, true ) ) { if ( null !== $reviewed_at ) { $piece['lastReviewed'] = $reviewed_at; } $piece['reviewedBy'] = $reviewed_by; continue; } // Enrich Article with citation[] and about[]. if ( in_array( 'Article', $types, true ) ) { if ( ! empty( $citations ) ) { $piece['citation'] = $this->build_citations( $citations ); } if ( ! empty( $about_stubs ) ) { $piece['about'] = $about_stubs; } continue; } } unset( $piece ); return $graph; } /** * Emit a standalone @graph block via wp_head when Yoast is not active. * * Suppressed when Yoast is loaded. Emits Article + MedicalWebPage nodes * with Yoast-parity @id conventions so consumers see one shape regardless * of whether Yoast is present. Skips posts whose permalink cannot be resolved. * * @return void */ public function emit_standalone_fallback(): void { if ( function_exists( 'YoastSEO' ) ) { return; } if ( ! is_singular( 'post' ) ) { return; } $post_id = (int) get_the_ID(); if ( $post_id <= 0 || ! $this->reader->is_triggered( $post_id ) ) { return; } $permalink = (string) get_permalink( $post_id ); if ( '' === $permalink ) { return; } $post = get_post( $post_id ); $review_mode = $this->reader->get_review_mode( $post_id ); $reviewed_at = $this->reader->get_reviewed_at( $post_id ); $reviewed_by = $this->reader->get_reviewed_by( $post_id, $review_mode ); $citations = $this->reader->get_citations( $post_id ); $about_stubs = $this->reader->get_about_peptides( $post_id ); $webpage = array( '@type' => 'MedicalWebPage', '@id' => $permalink . '#webpage', 'url' => $permalink, ); if ( null !== $reviewed_at ) { $webpage['lastReviewed'] = $reviewed_at; } $webpage['reviewedBy'] = $reviewed_by; $article = array( '@type' => 'Article', '@id' => $permalink . '#article', 'isPartOf' => array( '@id' => $permalink . '#webpage' ), 'headline' => $post instanceof WP_Post ? sanitize_text_field( $post->post_title ) : '', ); if ( ! empty( $citations ) ) { $article['citation'] = $this->build_citations( $citations ); } if ( ! empty( $about_stubs ) ) { $article['about'] = $about_stubs; } $json = wp_json_encode( array( '@context' => 'https://schema.org', '@graph' => array( $webpage, $article ), ), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT ); if ( false !== $json ) { echo '' . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_json_encode() already escapes for JSON context. } } // ── Private builders ───────────────────────────────────────────────── /** * Build schema.org citation nodes from validated citation records. * * Emits ScholarlyArticle when doi is present (DOI as sameAs URI), * CreativeWork otherwise. Per contract v1, quality_score is never emitted. * * @param array $citations Validated. * @return array> Schema citation nodes. */ private function build_citations( array $citations ): array { $nodes = array(); foreach ( $citations as $c ) { $node = array( '@type' => null !== $c['doi'] ? 'ScholarlyArticle' : 'CreativeWork', 'url' => $c['url'], 'name' => $c['title'], ); if ( null !== $c['doi'] ) { $node['sameAs'] = $c['doi']; } $nodes[] = $node; } return $nodes; } }