From d0b5875fd845bc92a3ac2f4a502ecf1c26a771a6 Mon Sep 17 00:00:00 2001 From: peptiderepo <273788462+peptiderepo@users.noreply.github.com> Date: Sat, 13 Jun 2026 14:25:04 +0700 Subject: [PATCH] peptide-repo-core v0.6.2: inject Drug/FAQ via wpseo_schema_graph (fix peptide-page fatal) (#21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.6.0 hooked wpseo_schema_graph_pieces with plain arrays; Yoast expects Abstract_Schema_Piece objects (get_class/is_needed) -> fatal on peptide singles. Inject Drug+FAQ as array nodes via wpseo_schema_graph (prio 12) instead. +regression test. QA: APPROVE — qa-reviews/peptide-repo-core/2026-06-13-0bbb75fd2689f375b9d154fc0ed0b0098a1865b6.md Agent-Session: jsonld-peptide-piece-fix v0.6.2 merge --- CHANGELOG.md | 13 + includes/frontend/class-pr-core-jsonld.php | 69 ++-- peptide-repo-core.php | 4 +- tests/unit/test-jsonld-peptide-piece.php | 366 +++++++++++++++++++++ 4 files changed, 423 insertions(+), 29 deletions(-) create mode 100644 tests/unit/test-jsonld-peptide-piece.php diff --git a/CHANGELOG.md b/CHANGELOG.md index f409319..3c283f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to Peptide Repo Core are documented here. Format: [Semantic Versioning](https://semver.org/). +## [0.6.2] — 2026-06-13 + +### Fixed +- **Critical**: Peptide monograph pages (e.g. `/peptides/bpc-157/`) returned HTTP 500 after v0.6.0 launch. Root cause: `PR_Core_Jsonld::inject_graph_pieces()` appended plain PHP arrays (Drug, FAQ) to Yoast's `wpseo_schema_graph_pieces` filter. Yoast's `Schema_Generator::filter_graph_pieces_to_generate()` then called `get_class()` and `is_needed()` on every piece, expecting `Abstract_Schema_Piece` objects — a plain array causes `PHP Fatal: get_class(): Argument #1 ($object) must be of type object, array given`. Non-peptide pages (/, /about/, /peptides/ archive) were unaffected because the `is_singular('peptide')` guard prevented entry. +- Fix: removed `wpseo_schema_graph_pieces` hook entirely. Drug and FAQPage nodes are now injected via a new `inject_graph_nodes()` method hooked on `wpseo_schema_graph` at priority 12, which receives Yoast's fully-assembled graph as an array of plain arrays — safe for plain array appends. +- Added regression test `tests/unit/test-jsonld-peptide-piece.php` that reproduces the v0.6.1 `get_class(array)` fatal and verifies the v0.6.2 `inject_graph_nodes` path emits Drug + FAQ correctly across peptide, non-peptide, and empty-FAQ scenarios. + +### Changed +- `PR_Core_Jsonld::register_hooks()`: no longer registers `wpseo_schema_graph_pieces`; registers `wpseo_schema_graph` at priority 12 for Drug/FAQ injection (in addition to priority 11 for `enrich_webpage_piece`, unchanged). +- `PR_Core_Jsonld::inject_graph_pieces()` renamed to `inject_graph_nodes()`; signature updated from `(array $pieces, $context)` to `(array $graph, $context)` to reflect that it now receives the assembled graph, not the pieces array. + + + ## [0.6.1] — 2026-06-13 ### Fixed diff --git a/includes/frontend/class-pr-core-jsonld.php b/includes/frontend/class-pr-core-jsonld.php index d1f904d..c9ad16e 100644 --- a/includes/frontend/class-pr-core-jsonld.php +++ b/includes/frontend/class-pr-core-jsonld.php @@ -16,7 +16,13 @@ declare(strict_types=1); * - PR_Core_Jsonld_Drug, PR_Core_Jsonld_Webpage, PR_Core_Jsonld_Faq (builders). * - PR_Core_Peptide_Repository, PR_Core_Peptide_CPT (data). * - * Contract (ratified 2026-06-11, jsonld-contract-v1.md): + * Yoast integration contract (ratified 2026-06-13, v0.6.2): + * - wpseo_schema_graph_pieces delivers OBJECTS (Abstract_Schema_Piece subclasses) + * to Yoast, which calls get_class() + is_needed() on every item. Injecting plain + * arrays there causes a PHP Fatal. Drug/FAQ nodes must be injected via + * wpseo_schema_graph instead, which receives the fully-assembled graph as + * array-of-arrays and is safe for plain array appends. + * - wpseo_schema_graph_pieces is therefore left UNHOOKED by this plugin. * - Integrated path: Yoast owns WebPage and BreadcrumbList — we never duplicate. * - Standalone fallback: emits a complete @graph (Drug + FAQPage) only. * - Drug @id: {permalink}#drug (stable for cross-plugin linking). @@ -49,59 +55,68 @@ class PR_Core_Jsonld { /** * Register hooks for JSON-LD output. * - * Registers both the Yoast-integrated path (active when Yoast is loaded) - * and the standalone wp_head fallback (active only when Yoast is absent). + * Registers the Yoast-integrated path via wpseo_schema_graph (safe for plain + * array appends) and the standalone wp_head fallback (active only when Yoast + * is absent). Does NOT hook wpseo_schema_graph_pieces — Yoast calls + * get_class() + is_needed() on every item in that filter's array and will + * fatal on a plain PHP array (see class-level docblock). * * Side effects: calls add_filter(), add_action(). * * @return void */ public function register_hooks(): void { - // Integrated path: hook into Yoast's schema graph. - // wpseo_schema_graph_pieces: filter on the array of schema piece objects. - // wpseo_schema_webpage_type: filter returning the WebPage @type string. - add_filter( 'wpseo_schema_graph_pieces', [ $this, 'inject_graph_pieces' ], 11, 2 ); + // wpseo_schema_webpage_type: retype WebPage -> MedicalWebPage on peptide singles. add_filter( 'wpseo_schema_webpage_type', [ $this->webpage_enricher, 'retype_to_medical_webpage' ] ); + + // wpseo_schema_graph: two callbacks at different priorities. + // Priority 11 -- enrich_webpage_piece: merge lastReviewed/reviewedBy/audience into the + // existing WebPage node that Yoast already emitted. + // Priority 12 -- inject_graph_nodes: append Drug + FAQPage arrays to the graph. + // Must run after enrich_webpage_piece so the WebPage node is already + // enriched before Drug/FAQ are appended. add_filter( 'wpseo_schema_graph', [ $this->webpage_enricher, 'enrich_webpage_piece' ], 11, 2 ); + add_filter( 'wpseo_schema_graph', [ $this, 'inject_graph_nodes' ], 12, 2 ); // Standalone fallback: emits only when Yoast is not producing output. add_action( 'wp_head', [ $this, 'emit_standalone_fallback' ], 99 ); } /** - * Inject Drug and FAQPage pieces into Yoast's schema graph. + * Append Drug and FAQPage nodes to Yoast's fully-assembled schema graph. * - * Hooked on: wpseo_schema_graph_pieces (priority 11, after Yoast's own pieces). - * This filter receives Yoast's piece objects array. We append plain arrays; - * Yoast 27.6 accepts both objects and plain arrays in this filter. + * Hooked on: wpseo_schema_graph (priority 12, after enrich_webpage_piece). + * This filter receives Yoast's completed graph as an array of plain arrays. + * Appending plain Drug/FAQ arrays here is safe -- unlike wpseo_schema_graph_pieces + * which calls get_class() and is_needed() on each item expecting objects. * - * @param array $pieces Existing graph pieces from Yoast. - * @param \WPSEO_Schema_Context $context Yoast schema context object. - * @return array Pieces array with Drug and FAQPage appended. + * @param array> $graph Assembled Yoast graph. + * @param \Yoast\WP\SEO\Context\Meta_Tags_Context|null $context Yoast schema context. + * @return array> Graph with Drug (and optionally FAQ) appended. */ - public function inject_graph_pieces( array $pieces, $context ): array { + public function inject_graph_nodes( array $graph, $context ): array { if ( ! is_singular( PR_Core_Peptide_CPT::POST_TYPE ) ) { - return $pieces; + return $graph; } $post_id = get_the_ID(); if ( ! $post_id ) { - return $pieces; + return $graph; } $peptide = ( new PR_Core_Peptide_Repository() )->find_by_id( (int) $post_id ); if ( ! $peptide ) { - return $pieces; + return $graph; } - $pieces[] = $this->drug_builder->build( $peptide ); + $graph[] = $this->drug_builder->build( $peptide ); - $faq_piece = $this->faq_builder->build( (int) $post_id, get_permalink( (int) $post_id ) ); - if ( null !== $faq_piece ) { - $pieces[] = $faq_piece; + $faq_node = $this->faq_builder->build( (int) $post_id, get_permalink( (int) $post_id ) ); + if ( null !== $faq_node ) { + $graph[] = $faq_node; } - return $pieces; + return $graph; } /** @@ -109,7 +124,7 @@ class PR_Core_Jsonld { * * Guard: suppressed if Yoast is loaded (function_exists check). This is the * no-Yoast fallback required by the jsonld-contract. When Yoast is present, - * the integrated path (inject_graph_pieces) handles emission. + * the integrated path (inject_graph_nodes via wpseo_schema_graph) handles emission. * * Side effects: outputs a script tag in wp_head. * @@ -137,9 +152,9 @@ class PR_Core_Jsonld { $permalink = get_permalink( (int) $post_id ); $graph = [ $this->drug_builder->build( $peptide ) ]; - $faq_piece = $this->faq_builder->build( (int) $post_id, $permalink ); - if ( null !== $faq_piece ) { - $graph[] = $faq_piece; + $faq_node = $this->faq_builder->build( (int) $post_id, $permalink ); + if ( null !== $faq_node ) { + $graph[] = $faq_node; } printf( diff --git a/peptide-repo-core.php b/peptide-repo-core.php index 40ba309..15e72b3 100644 --- a/peptide-repo-core.php +++ b/peptide-repo-core.php @@ -3,7 +3,7 @@ * Plugin Name: Peptide Repo Core * Plugin URI: https://peptiderepo.com * Description: Canonical peptide schema — shared data layer for the peptiderepo.com ecosystem. Provides the peptide CPT, dosing rows, legal status cells, AI candidate queue, disclaimer component, and JSON-LD output. - * Version: 0.6.1 + * Version: 0.6.2 * Author: peptiderepo * Author URI: https://peptiderepo.com * License: GPL-2.0-or-later @@ -22,7 +22,7 @@ if ( ! defined( 'ABSPATH' ) ) { /* ── Constants ────────────────────────────────────────────────────────── */ -define( 'PR_CORE_VERSION', '0.6.1' ); +define( 'PR_CORE_VERSION', '0.6.2' ); define( 'PR_CORE_PLUGIN_FILE', __FILE__ ); define( 'PR_CORE_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'PR_CORE_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); diff --git a/tests/unit/test-jsonld-peptide-piece.php b/tests/unit/test-jsonld-peptide-piece.php new file mode 100644 index 0000000..b7668d3 --- /dev/null +++ b/tests/unit/test-jsonld-peptide-piece.php @@ -0,0 +1,366 @@ + $overrides Field overrides. + * @return PR_Core_Peptide_DTO + */ +function make_peptide_piece_dto( array $overrides = [] ): PR_Core_Peptide_DTO { + return new PR_Core_Peptide_DTO( array_merge( [ + 'id' => 36, + 'title' => 'BPC-157', + 'slug' => 'bpc-157', + 'content' => '', + 'excerpt' => 'A synthetic pentadecapeptide.', + 'status' => 'publish', + 'display_name' => 'BPC-157', + 'aliases' => [], + 'molecular_formula' => '', + 'molecular_weight' => 0.0, + 'cas_number' => '', + 'drugbank_id' => '', + 'chembl_id' => '', + 'evidence_strength' => 'preclinical', + 'editorial_review_status' => 'published', + 'last_editorial_review_at' => '', + 'medical_editor_id' => 0, + 'categories' => [], + 'families' => [], + ], $overrides ) ); +} + +echo "== JSON-LD peptide-piece regression tests (v0.6.2) ==\n\n"; + +// ── Setup ───────────────────────────────────────────────────────────────── + +$GLOBALS['pr_test_the_id'] = 36; +$GLOBALS['pr_test_is_singular'] = true; +$GLOBALS['pr_test_singular_type'] = PR_Core_Peptide_CPT::POST_TYPE; +$GLOBALS['pr_test_postmeta'][36] = [ + '_pr_molecular_formula' => 'C62H98N16O22', + '_pr_molecular_weight' => '1419.5', + '_pr_aliases' => '["Body Protection Compound"]', +]; +$GLOBALS['pr_test_peptide_dto'] = make_peptide_piece_dto(); + +$jsonld = new PR_Core_Jsonld(); + +// ── Regression: inject_graph_pieces fatal reproduced against old contract ─ + +echo "Regression: inject_graph_pieces (OLD hook) with plain array fatal:\n"; + +// Simulate what Yoast's filter_graph_pieces_to_generate does: +// it calls get_class($piece) on every item. A plain array causes a fatal. +$drug_builder = new PR_Core_Jsonld_Drug(); +$drug_array = $drug_builder->build( $GLOBALS['pr_test_peptide_dto'] ); + +$caught_fatal = false; +try { + // This is the exact operation Yoast does in filter_graph_pieces_to_generate(). + // Against the old v0.6.1 code (inject via wpseo_schema_graph_pieces), this would + // produce: "get_class(): Argument #1 ($object) must be of type object, array given" + $class_result = get_class( $drug_array ); +} catch ( TypeError $e ) { + $caught_fatal = true; +} catch ( Error $e ) { + $caught_fatal = true; +} +pr_assert( $caught_fatal, 'get_class(array) throws TypeError/Error — confirms the v0.6.1 fatal' ); + +// ── Fix: inject_graph_nodes (NEW hook) works without fatal ──────────────── + +echo "\nFix: inject_graph_nodes via wpseo_schema_graph (plain array graph):\n"; + +// The new inject_graph_nodes method receives an already-built graph (array of arrays). +// We simulate the wpseo_schema_graph filter call: $graph = array of plain arrays. +$existing_graph = [ + [ + '@type' => 'MedicalWebPage', + '@id' => 'https://peptiderepo.com/peptides/bpc-157/#webpage', + 'name' => 'BPC-157', + ], + [ + '@type' => 'BreadcrumbList', + '@id' => 'https://peptiderepo.com/peptides/bpc-157/#breadcrumb', + ], +]; + +// This must NOT throw and must append Drug node. +$threw = false; +try { + $result_graph = $jsonld->inject_graph_nodes( $existing_graph, null ); +} catch ( Throwable $e ) { + $threw = true; +} + +pr_assert( ! $threw, 'inject_graph_nodes does NOT throw (no get_class/is_needed calls)' ); +pr_assert( count( $result_graph ) > count( $existing_graph ), 'inject_graph_nodes appended at least one node' ); + +// Drug node must be present. +$drug_nodes = array_filter( + $result_graph, + static function ( $node ) { + $types = is_array( $node['@type'] ?? null ) ? $node['@type'] : [ $node['@type'] ?? '' ]; + return in_array( 'Drug', $types, true ); + } +); +pr_assert( count( $drug_nodes ) === 1, 'exactly one Drug node in graph after inject' ); + +$drug_node = array_values( $drug_nodes )[0]; +pr_assert_equals( + 'https://peptiderepo.com/peptides/bpc-157/#drug', + $drug_node['@id'], + 'Drug @id = {permalink}#drug' +); +pr_assert( isset( $drug_node['molecularFormula'] ), 'Drug has molecularFormula' ); +pr_assert( isset( $drug_node['molecularWeight'] ), 'Drug has molecularWeight (QuantitativeValue)' ); +pr_assert_equals( 'QuantitativeValue', $drug_node['molecularWeight']['@type'], 'molecularWeight @type is QuantitativeValue' ); +pr_assert_equals( 1419.5, $drug_node['molecularWeight']['value'], 'molecularWeight value correct' ); +pr_assert_equals( 'g/mol', $drug_node['molecularWeight']['unitText'], 'molecularWeight unitText = g/mol' ); +pr_assert( isset( $drug_node['alternateName'] ), 'Drug has alternateName' ); + +// Original WebPage + BreadcrumbList nodes must be preserved. +$webpage_nodes = array_filter( $result_graph, static fn( $n ) => ( $n['@type'] ?? '' ) === 'MedicalWebPage' ); +pr_assert( count( $webpage_nodes ) === 1, 'MedicalWebPage node preserved (no duplicate)' ); + +$breadcrumb_nodes = array_filter( $result_graph, static fn( $n ) => ( $n['@type'] ?? '' ) === 'BreadcrumbList' ); +pr_assert( count( $breadcrumb_nodes ) === 1, 'BreadcrumbList node preserved (no duplicate)' ); + +// ── FAQ: present when _pr_faq_items has items ───────────────────────────── + +echo "\nFAQ injection via inject_graph_nodes:\n"; + +$GLOBALS['pr_test_postmeta'][36]['_pr_faq_items'] = wp_json_encode( [ + [ 'question' => 'What is BPC-157?', 'answer' => 'A synthetic pentadecapeptide.' ], + [ 'question' => 'Is it safe?', 'answer' => 'Research use only; not approved for human therapy.' ], +] ); + +$result_with_faq = $jsonld->inject_graph_nodes( $existing_graph, null ); + +$faq_nodes = array_filter( + $result_with_faq, + static fn( $n ) => ( $n['@type'] ?? '' ) === 'FAQPage' +); +pr_assert( count( $faq_nodes ) === 1, 'FAQPage node injected when _pr_faq_items has items' ); + +$faq_node = array_values( $faq_nodes )[0]; +pr_assert_equals( 'https://peptiderepo.com/peptides/bpc-157/#faq', $faq_node['@id'], 'FAQPage @id = {permalink}#faq' ); +pr_assert( isset( $faq_node['mainEntity'] ), 'FAQPage has mainEntity' ); +pr_assert_equals( 2, count( $faq_node['mainEntity'] ), 'FAQPage has 2 questions' ); + +// ── FAQ: absent when _pr_faq_items empty ───────────────────────────────── + +$GLOBALS['pr_test_postmeta'][36]['_pr_faq_items'] = '[]'; + +$result_no_faq = $jsonld->inject_graph_nodes( $existing_graph, null ); +$faq_nodes_empty = array_filter( + $result_no_faq, + static fn( $n ) => ( $n['@type'] ?? '' ) === 'FAQPage' +); +pr_assert( count( $faq_nodes_empty ) === 0, 'FAQPage NOT injected when _pr_faq_items is empty array' ); + +// ── Non-peptide page: no injection ─────────────────────────────────────── + +echo "\nNon-peptide page: no injection:\n"; + +$GLOBALS['pr_test_is_singular'] = false; +$GLOBALS['pr_test_singular_type'] = ''; + +$non_peptide_graph = [ + [ + '@type' => 'WebPage', + '@id' => 'https://peptiderepo.com/about/#webpage', + ], +]; + +$result_non_peptide = $jsonld->inject_graph_nodes( $non_peptide_graph, null ); +pr_assert( count( $result_non_peptide ) === 1, 'inject_graph_nodes no-ops on non-peptide pages (graph unchanged)' ); + +$drug_nodes_np = array_filter( $result_non_peptide, static fn( $n ) => ( is_array( $n['@type'] ?? null ) ? in_array( 'Drug', $n['@type'], true ) : ( $n['@type'] ?? '' ) === 'Drug' ) ); +pr_assert( count( $drug_nodes_np ) === 0, 'Drug NOT injected on non-peptide page' ); + +// ── register_hooks: does NOT hook wpseo_schema_graph_pieces ────────────── + +echo "\nregister_hooks: wpseo_schema_graph_pieces must NOT be hooked:\n"; + +$GLOBALS['pr_core_test_state']['added_actions'] = []; +$GLOBALS['pr_core_test_state']['added_filters'] = []; +$filters_added = []; + +// Override add_filter in the bootstrap to capture calls. +// (bootstrap only stubs add_action; we check via a fresh parse of register_hooks.) +$jsonld2 = new PR_Core_Jsonld(); + +// Use Reflection to inspect what hooks register_hooks would call. +// Instead: inspect the class source for the string — if inject_graph_pieces is +// still registered on wpseo_schema_graph_pieces, the bug is NOT fixed. +$src = file_get_contents( PR_CORE_PLUGIN_DIR . 'includes/frontend/class-pr-core-jsonld.php' ); +pr_assert( + strpos( $src, "'wpseo_schema_graph_pieces'" ) === false, + 'class-pr-core-jsonld.php does NOT add_filter wpseo_schema_graph_pieces (correct hook removed)' +); +pr_assert( + strpos( $src, 'inject_graph_nodes' ) !== false, + 'class-pr-core-jsonld.php references inject_graph_nodes (new method present)' +); +pr_assert( + strpos( $src, "'wpseo_schema_graph'" ) !== false, + 'class-pr-core-jsonld.php hooks wpseo_schema_graph for injection' +); + +// ── Summary ─────────────────────────────────────────────────────────────── + +exit( pr_test_summary() );