diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e6f5dbe..8132fe8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -215,14 +215,25 @@ Drug schema on single pages increases LLM citation rate. Filter hook allows cons - **FAQ emission:** `_pr_faq_items` post-meta (JSON array of `{question, answer}` objects). Node emitted only when items exist. - **Dosing omitted from schema:** YMYL constraint — no dosing data in JSON-LD. -JSON-LD class tree (v0.6.0): +JSON-LD class tree (v0.8.0): | Class | File | Responsibility | |---|---|---| | `PR_Core_Jsonld` | `frontend/class-pr-core-jsonld.php` | Orchestrator: registers hooks, Yoast vs. standalone routing | | `PR_Core_Jsonld_Drug` | `frontend/class-pr-core-jsonld-drug.php` | Builds Drug (+ MolecularEntity) schema node | -| `PR_Core_Jsonld_Webpage` | `frontend/class-pr-core-jsonld-webpage.php` | MedicalWebPage retype + lastReviewed/reviewedBy enrichment | +| `PR_Core_Jsonld_Webpage` | `frontend/class-pr-core-jsonld-webpage.php` | MedicalWebPage retype + lastReviewed/reviewedBy enrichment (peptide pages) | | `PR_Core_Jsonld_Faq` | `frontend/class-pr-core-jsonld-faq.php` | FAQPage node (emit-only-when-populated) | +| `PR_Core_Jsonld_Article` | `frontend/class-pr-core-jsonld-article.php` | PRAB article emitter: MedicalWebPage retype + Article citation/about/reviewedBy enrichment | +| `PR_Core_Prab_Meta_Reader` | `frontend/class-pr-core-prab-meta-reader.php` | Reads and sanitises _prab_* meta (contract v1 reader) | + +**PRAB article emission (v0.8.0, contract v1):** +- Trigger: `_prab_schema_version=1` on a standard `post`. +- Yoast path (priority 13, after peptide enrichment at 11+12): retypes page node to `MedicalWebPage`; enriches Yoast's existing Article piece with `citation[]`, `about[]` (Drug @id references), `lastReviewed`, honest `reviewedBy`. +- No-Yoast fallback: standalone `@graph` (Article + MedicalWebPage) on `wp_head` priority 99. +- **Honest reviewedBy:** Person only when `_prab_review_mode=human` + valid `_prab_reviewed_by` WP user ID. All other cases: Organization "Peptide Repo". Never a fabricated person. +- **`about` linkage:** Drug stubs reference `{peptide_permalink}#drug` (stable @id from v0.6.0). +- prcore reads `_prab_*` meta only; PRAutoBlogger writes only. uninstall.php unaffected. +- All `_prab_*` meta treated as untrusted at read time. ### #7: PR Core owns the `peptide` CPT and `peptide_category` taxonomy (v0.2.0) Prior to v0.2.0, PR Core registered `pr_peptide` while Peptide Search AI registered `peptide` — both claimed the public rewrite slug `peptides`, and WP's rewrite resolver picked PR Core's empty CPT, 404'ing all 89 production peptide detail pages. v0.2.0 consolidates both registrations onto a single `peptide` CPT, owned by PR Core. PSA v4.5.0 drops its CPT/taxonomy registration; its meta boxes (`psa_peptide_data`, `psa_extended_data`), directory shortcode, KB article renderer, and search widget continue operating on the shared `peptide` CPT regardless of who registers it. Registration on both sides is guarded with `post_type_exists()` / `taxonomy_exists()` so deploy order is forgiving. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d2eb59..972499c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,52 @@ All notable changes to Peptide Repo Core are documented here. Format: [Semantic Versioning](https://semver.org/). +## [0.8.0] — 2026-06-23 + +### Added +- **PRAutoBlogger article JSON-LD emitter** (ratified contract v1 — `convo/prcore/decisions/2026-06-11-jsonld-contract-v1.md`): + - `PR_Core_Jsonld_Article` (`frontend/class-pr-core-jsonld-article.php`, ~235 lines) — meta-triggered + emitter for `post` posts carrying `_prab_schema_version=1`. Hooks into Yoast's graph via + `wpseo_schema_webpage_type` (priority 10) and `wpseo_schema_graph` (priority 13). Retypes the + page node to `MedicalWebPage`; enriches the Yoast `Article` piece with `citation[]`, + `about[]` (Drug `@id` stubs), `lastReviewed`, and `reviewedBy`. Standalone `@graph` fallback + (Article + MedicalWebPage) when Yoast is absent. Posts without `_prab_schema_version` meta + are byte-unchanged. + - `PR_Core_Prab_Meta_Reader` (`frontend/class-pr-core-prab-meta-reader.php`, ~268 lines) — reads + and sanitises all contract-v1 `_prab_*` meta at read time (not trusting PRAB's write-time + sanitization). URLs validated (http/https only), DOIs matched against canonical pattern, JSON + arrays validated per-element, WP user ID resolved via `get_userdata()`. Malformed values + degrade gracefully (field skipped) — no page-render fatals. + - **Honest `reviewedBy`:** Person node emitted ONLY when `_prab_review_mode=human` AND + `_prab_reviewed_by` resolves to an existing WP user. All other cases (editorial-system mode, + missing/zero/nonexistent user ID) emit the Organization node ("Peptide Repo"). Never emits a + fabricated person — the core contract guarantee. + - **`about` Drug linkage:** `_prab_about_peptides` (JSON array of peptide post IDs) is resolved + to Drug stub arrays using the stable `{permalink}#drug` @id established in v0.6.0. Each ID + must resolve to a published `peptide` post; unresolvable IDs are skipped. + - **Citation nodes:** `ScholarlyArticle` when DOI present (DOI normalised to https://doi.org/ + URI as `sameAs`), else `CreativeWork`. `quality_score` never emitted (no schema.org mapping + per contract v1). + - **No duplicate nodes:** integrates via Yoast mutation (priority 13, after PR_Core_Jsonld + priority-12 Drug/FAQ injection). Never adds new WebPage or Article pieces. + - Unit tests: `tests/unit/JsonldArticleTest.php` — 22 assertions covering is_triggered, + review_mode, reviewed_by (Person/Org decision tree), citation sanitization, about linkage, + retype, enrich, no-new-nodes, untriggered passthrough, editorial-system-never-person. + +### Changed +- `class-pr-core.php`: boots `PR_Core_Jsonld_Article` alongside `PR_Core_Jsonld` in `init()`. +- `tests/bootstrap.php`: enhanced `get_userdata()` stub to read from `$GLOBALS['pr_test_userdata']`; + added `WP_User` class stub; initialised `$GLOBALS['pr_test_userdata']` global. +- `ARCHITECTURE.md`: JSON-LD class table updated with new classes; §2.7 PRAB article emission added. +- `CONVENTIONS.md`: JSON-LD extension guide updated with PRAB-article hook pattern. + +### Technical +- Meta keys `_prab_*` are owned by PRAutoBlogger namespace (PRAB registers/writes; prcore reads + only). `uninstall.php` unaffected — prcore never writes `_prab_*` meta, so no teardown needed. +- Yoast hook order: `wpseo_schema_graph` priority 11 (peptide MedicalWebPage enrich) → priority 12 + (Drug/FAQ inject) → priority 13 (PRAB Article enrich + page retype). Three priorities prevent + race conditions between the peptide and article enrichment paths. + ## [0.7.1] - 2026-06-17 ### Fixed diff --git a/CONTEXT.md b/CONTEXT.md index 41c326c..c625801 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -113,3 +113,62 @@ Gutenberg block. Not surfaced in JSON-LD (dosing YMYL constraint). `PR_Core_Migration_Runner` — runs numbered migrations (0001–N) sequentially on every `plugins_loaded`. Compares `pr_core_schema_version` wp_option against `PR_CORE_TARGET_SCHEMA_VERSION` constant. All migrations are idempotent and re-runnable. + +## _prab_* meta namespace + +Post-meta keys written exclusively by PRAutoBlogger (PRAB) and read by prcore's +`PR_Core_Prab_Meta_Reader` at render time. The namespace is `_prab_` (PRAutoBlogger +private). Ownership split: **PRAB writes, prcore reads**. No other plugin reads or +writes `_prab_*` meta. Contract v1 keys (frozen; additive evolution only, gated by +`_prab_schema_version`): + +| Key | Type | Purpose | +|---|---|---| +| `_prab_schema_version` | int (1) | Opt-in trigger; presence + value = 1 enables JSON-LD emission | +| `_prab_citations` | JSON array | `[{url, doi?, title, quality_score?}]` — validated sources | +| `_prab_about_peptides` | JSON array | Peptide post IDs → Drug `@id` references in `about[]` | +| `_prab_review_mode` | string | `human` \| `editorial-system` | +| `_prab_reviewed_at` | string | ISO 8601 datetime → `lastReviewed` | +| `_prab_reviewed_by` | int | WP user ID of Review Queue approver (required when mode=`human`) | + +All `_prab_*` values are treated as **untrusted at read time**: sanitized and +validated by `PR_Core_Prab_Meta_Reader` before any schema.org output is produced. +Malformed values degrade gracefully (skipped/null); no fatal path exists. +Canonical contract: `convo/prcore/decisions/2026-06-11-jsonld-contract-v1.md`. + +## Meta-reader pattern + +`PR_Core_Prab_Meta_Reader` is the dedicated sanitizer class that isolates all +`_prab_*` meta reading and validation from the emitter (`PR_Core_Jsonld_Article`). +The pattern: **one sanitizer class per untrusted meta namespace**. The reader owns +all `get_post_meta` calls for the namespace, validates every field, and returns +typed clean values. The emitter (consumer) never calls `get_post_meta` directly. + +This keeps the emitter testable without the meta contract details and ensures that +adding a new `_prab_*` field never touches the emission logic. Future `_prab_v2_*` +fields would use a v2 reader that the emitter selects by version. + +## ScholarlyArticle + +A `schema.org/ScholarlyArticle` node emitted inside a citation array (`citation[]`) +on the `Article` graph piece when a validated DOI is present in the PRAB citation +record. Emitted with `url` (the citation URL), `name` (the citation title), and +`sameAs` (the canonical `https://doi.org/` URI). When no DOI is available, +`CreativeWork` is emitted instead. Per contract v1, `quality_score` is never +emitted (no schema.org mapping). + +## Honest reviewedBy (integrity constraint) + +The `reviewedBy` field on the `MedicalWebPage` node emits a `schema.org/Person` +node **only** when all three conditions hold simultaneously: +1. `_prab_review_mode` = `human` +2. `_prab_reviewed_by` resolves to a WP user ID > 0 +3. `get_userdata()` returns a valid `WP_User` instance with a non-empty + `display_name` + +Any other combination (missing mode, zero user ID, unresolvable user, or +editorial-system mode) produces a `schema.org/Organization` node (Peptide Repo +editorial). This is an **integrity constraint**, not a UI choice: emitting a +fabricated Person would constitute misleading structured data. The constraint is +enforced in `PR_Core_Prab_Meta_Reader::get_reviewed_by()` and covered by dedicated +unit tests. diff --git a/CONVENTIONS.md b/CONVENTIONS.md index 625083c..87d09a6 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -63,6 +63,26 @@ v0.6.0 uses four focused classes (see ARCHITECTURE.md §#6). To add a new field 4. To add a new schema piece (not Drug), create a new `PR_Core_Jsonld_` class in `frontend/` and inject it from `PR_Core_Jsonld::inject_graph_pieces()`. 5. The `pr_core_jsonld_peptide` filter on the Drug node array is preserved for consumer plugins. + +## How To: Add Fields to the PRAB Article JSON-LD (contract v2+) + +The `_prab_*` meta contract is versioned (`_prab_schema_version`). The contract is frozen at v1 +until prcore and PRAB coordinate a version bump. To add new fields: + +1. Propose the new field in `convo/prcore/decisions/` (CTO ratifies; PRAB and prcore must agree). +2. Bump `_prab_schema_version` to 2 in PRAB's meta write code — do NOT bump before prcore ships support. +3. Add the new field to `PR_Core_Prab_Meta_Reader` (sanitize at read time, degrade gracefully). +4. Add emission logic to `PR_Core_Jsonld_Article` — emit only when field is non-empty. +5. Add unit tests for the new field in `tests/unit/JsonldArticleTest.php`. +6. Bump `SUPPORTED_VERSION` in `PR_Core_Prab_Meta_Reader::SUPPORTED_VERSION` to 2. +7. Update the contract decision doc and CHANGELOG. + +Key rules: +- prcore **reads** `_prab_*` meta only. PRAB **writes** only. The namespace boundary is hard. +- `uninstall.php` must NOT touch `_prab_*` keys — they belong to PRAB's namespace. +- `quality_score` and similar audit fields must never be emitted (no schema.org mapping). +- The `reviewedBy` Person guard is non-negotiable: Person only with human mode + valid user ID. + ## Error Handling - Repository methods return `null` or `0` on failure, never throw. diff --git a/includes/class-pr-core.php b/includes/class-pr-core.php index dd70de1..8d46cb7 100644 --- a/includes/class-pr-core.php +++ b/includes/class-pr-core.php @@ -19,7 +19,7 @@ declare(strict_types=1); * What: Registers all hooks, runs migrations, boots subsystems. * Who calls it: peptide-repo-core.php on plugins_loaded. * Dependencies: PR_Core_Migration_Runner, PR_Core_Peptide_CPT, PR_Core_Topic_Taxonomy, - * PR_Core_Admin, PR_Core_Disclaimer, PR_Core_Jsonld, + * PR_Core_Admin, PR_Core_Disclaimer, PR_Core_Jsonld, PR_Core_Jsonld_Article, * PR_Core_Rest_Controller, PR_Core_Related_Posts_Section. * * @see peptide-repo-core.php — Bootstrap that instantiates this class. @@ -91,6 +91,9 @@ class PR_Core { $jsonld = new PR_Core_Jsonld(); $jsonld->register_hooks(); + $jsonld_article = new PR_Core_Jsonld_Article(); + $jsonld_article->register_hooks(); + // Related Articles section (frontend). $related_posts = new PR_Core_Related_Posts_Section( new PR_Core_Internal_Posts_Provider() diff --git a/includes/frontend/class-pr-core-jsonld-article.php b/includes/frontend/class-pr-core-jsonld-article.php new file mode 100644 index 0000000..dc116de --- /dev/null +++ b/includes/frontend/class-pr-core-jsonld-article.php @@ -0,0 +1,243 @@ +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; + } +} diff --git a/includes/frontend/class-pr-core-prab-meta-reader.php b/includes/frontend/class-pr-core-prab-meta-reader.php new file mode 100644 index 0000000..c4b3121 --- /dev/null +++ b/includes/frontend/class-pr-core-prab-meta-reader.php @@ -0,0 +1,299 @@ + + */ + private array $triggered_cache = array(); + + /** + * Return true when this post carries a supported _prab_schema_version meta. + * + * Cached per post ID: all three Yoast hooks call this for the same post, + * so caching avoids triple get_post_meta reads per request. + * + * @param int $post_id WordPress post ID. + * @return bool + */ + public function is_triggered( int $post_id ): bool { + if ( isset( $this->triggered_cache[ $post_id ] ) ) { + return $this->triggered_cache[ $post_id ]; + } + $raw = get_post_meta( $post_id, self::META_SCHEMA_VERSION, true ); + if ( '' === $raw || false === $raw ) { + $this->triggered_cache[ $post_id ] = false; + return false; + } + $version = (int) $raw; + if ( self::SUPPORTED_VERSION === $version ) { + $this->triggered_cache[ $post_id ] = true; + return true; + } + error_log( sprintf( '[PR Core] Unsupported _prab_schema_version=%d on post %d. Supports v%d only. Emission suppressed.', $version, $post_id, self::SUPPORTED_VERSION ) ); + $this->triggered_cache[ $post_id ] = false; + return false; + } + + /** + * Read and validate _prab_citations meta. + * + * Each entry needs valid http/https `url` and non-empty `title`; others skipped. + * `quality_score` never emitted — no schema.org mapping (contract v1). + * + * @param int $post_id WordPress post ID. + * @return array Validated. + */ + public function get_citations( int $post_id ): array { + $decoded = $this->decode_json_meta( $post_id, self::META_CITATIONS ); + if ( empty( $decoded ) ) { + return array(); + } + $valid = array(); + foreach ( $decoded as $item ) { + if ( ! is_array( $item ) ) { + continue; + } + $url = $this->sanitize_url( (string) ( $item['url'] ?? '' ) ); + $title = sanitize_text_field( (string) ( $item['title'] ?? '' ) ); + if ( '' === $url || '' === $title ) { + continue; + } + $valid[] = array( + 'url' => $url, + 'title' => $title, + 'doi' => isset( $item['doi'] ) ? $this->sanitize_doi( (string) $item['doi'] ) : null, + ); + } + return $valid; + } + + /** + * Read and validate _prab_about_peptides meta. + * + * Returns Drug stubs for schema `about`. Each ID must resolve to a published + * `peptide` post; unresolvable IDs and empty-permalink posts are skipped. + * + * @param int $post_id WordPress post ID. + * @return array> Drug stubs. + */ + public function get_about_peptides( int $post_id ): array { + $decoded = $this->decode_json_meta( $post_id, self::META_ABOUT_PEPTIDES ); + if ( empty( $decoded ) ) { + return array(); + } + $stubs = array(); + foreach ( $decoded as $raw_id ) { + if ( ! is_numeric( $raw_id ) ) { + continue; + } + $pid = absint( $raw_id ); + $post = $pid > 0 ? get_post( $pid ) : null; + if ( ! $post instanceof WP_Post + || 'publish' !== $post->post_status + || PR_Core_Peptide_CPT::POST_TYPE !== $post->post_type ) { + continue; + } + $link = (string) get_permalink( $pid ); + if ( '' === $link ) { + continue; + } + $stubs[] = array( + '@type' => 'Drug', + '@id' => $link . '#drug', + 'name' => sanitize_text_field( $post->post_title ), + 'url' => $link, + ); + } + return $stubs; + } + + /** + * Read and validate _prab_review_mode meta. + * + * Unknown values are treated as 'editorial-system' (safe floor, contract v1). + * + * @param int $post_id WordPress post ID. + * @return string 'human' | 'editorial-system'. + */ + public function get_review_mode( int $post_id ): string { + $raw = sanitize_text_field( (string) get_post_meta( $post_id, self::META_REVIEW_MODE, true ) ); + if ( 'human' === $raw ) { + return 'human'; + } + if ( '' !== $raw && 'editorial-system' !== $raw ) { + error_log( sprintf( '[PR Core] Unknown _prab_review_mode="%s" on post %d. Treating as editorial-system.', $raw, $post_id ) ); + } + return 'editorial-system'; + } + + /** + * Read and validate _prab_reviewed_at meta. + * + * Returns null when absent, empty, or unparseable as a date. + * + * @param int $post_id WordPress post ID. + * @return string|null Sanitised datetime string, or null. + */ + public function get_reviewed_at( int $post_id ): ?string { + $raw = sanitize_text_field( (string) get_post_meta( $post_id, self::META_REVIEWED_AT, true ) ); + if ( '' === $raw || false === strtotime( $raw ) ) { + return null; + } + return $raw; + } + + /** + * Resolve the honest reviewedBy value for a PRAB article. + * + * Person only when mode='human', user resolves, and display_name non-empty. + * All other cases → Organization. Never fabricated. Core contract guarantee. + * + * @param int $post_id WordPress post ID. + * @param string $review_mode Result of get_review_mode() for this post. + * @return array Schema Person or Organization node. + */ + public function get_reviewed_by( int $post_id, string $review_mode ): array { + if ( 'human' === $review_mode ) { + $uid = absint( get_post_meta( $post_id, self::META_REVIEWED_BY, true ) ); + $user = $uid > 0 ? get_userdata( $uid ) : false; + if ( $user instanceof WP_User ) { + $display = sanitize_text_field( $user->display_name ); + if ( '' !== $display ) { + return array( + '@type' => 'Person', + '@id' => home_url( '/#/schema/person/' . $uid ), + 'name' => $display, + ); + } + } + error_log( sprintf( '[PR Core] _prab_review_mode=human but reviewed_by user (ID=%d) unresolvable on post %d. Emitting Organization.', $uid, $post_id ) ); + } + return array( + '@type' => 'Organization', + '@id' => home_url( '/#organization' ), + 'name' => 'Peptide Repo', + 'url' => home_url(), + ); + } + + // ── Private helpers ────────────────────────────────────────────────── + + /** + * Decode a JSON array from post meta, returning empty array on any error. + * + * @param int $post_id WordPress post ID. + * @param string $meta_key Meta key to read. + * @return array Decoded array, or empty array on failure. + */ + private function decode_json_meta( int $post_id, string $meta_key ): array { + $raw = get_post_meta( $post_id, $meta_key, true ); + if ( ! is_string( $raw ) || '' === $raw ) { + return array(); + } + $decoded = json_decode( $raw, true ); + return is_array( $decoded ) ? $decoded : array(); + } + + /** + * Sanitize and validate a URL, requiring http or https scheme. + * + * @param string $raw Raw URL string. + * @return string Sanitised URL, or '' when invalid. + */ + private function sanitize_url( string $raw ): string { + $url = esc_url_raw( trim( $raw ) ); + if ( '' === $url || ! filter_var( $url, FILTER_VALIDATE_URL ) ) { + return ''; + } + $scheme = (string) wp_parse_url( $url, PHP_URL_SCHEME ); + return in_array( $scheme, array( 'http', 'https' ), true ) ? $url : ''; + } + + /** + * Sanitize a DOI, returning a https://doi.org/ URI or null. + * + * Accepts bare DOI ("10.1234/abc"), doi:-prefixed, or full URI forms. + * + * @param string $raw Raw DOI string. + * @return string|null DOI URI, or null if pattern does not match. + */ + private function sanitize_doi( string $raw ): ?string { + $doi = trim( $raw ); + foreach ( array( 'https://doi.org/', 'http://doi.org/', 'doi:' ) as $pfx ) { + if ( str_starts_with( strtolower( $doi ), strtolower( $pfx ) ) ) { + $doi = substr( $doi, strlen( $pfx ) ); + break; + } + } + return preg_match( '/^10\.\d{4,}\/\S+$/', $doi ) ? 'https://doi.org/' . $doi : null; + } +} diff --git a/peptide-repo-core.php b/peptide-repo-core.php index 11d7b9a..f5b73d2 100644 --- a/peptide-repo-core.php +++ b/peptide-repo-core.php @@ -29,7 +29,7 @@ if ( ! defined( 'ABSPATH' ) ) { /* ── Constants ────────────────────────────────────────────────────────── */ -define( 'PR_CORE_VERSION', '0.7.1' ); +define( 'PR_CORE_VERSION', '0.8.0' ); 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/bootstrap.php b/tests/bootstrap.php index a2a416e..5479ada 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -61,6 +61,7 @@ $GLOBALS['pr_core_options'] = []; $GLOBALS['pr_core_cron_calls'] = []; $GLOBALS['pr_test_postmeta'] = []; $GLOBALS['pr_test_posts'] = []; +$GLOBALS['pr_test_userdata'] = []; $GLOBALS['pr_test_updated_meta'] = []; $GLOBALS['pr_test_is_singular'] = false; $GLOBALS['pr_test_singular_type'] = ''; @@ -169,6 +170,11 @@ if ( ! function_exists( 'is_email' ) ) { return (bool) filter_var( $email, FILTER_VALIDATE_EMAIL ); } } +if ( ! function_exists( 'wp_parse_url' ) ) { + function wp_parse_url( string $url, int $component = -1 ) { + return parse_url( $url, $component ); + } +} // ── Option / cron stubs ───────────────────────────────────────────────. @@ -325,9 +331,28 @@ if ( ! function_exists( 'get_post_modified_time' ) ) { } if ( ! function_exists( 'get_userdata' ) ) { function get_userdata( int $id ) { + $data = $GLOBALS['pr_test_userdata'][ $id ] ?? null; + if ( null !== $data ) { + return $data instanceof WP_User ? $data : new WP_User( (array) $data ); + } return false; } } + +if ( ! class_exists( 'WP_User' ) ) { + class WP_User { + public int $ID = 0; + public string $display_name = ''; + public string $user_login = ''; + public string $user_email = ''; + /** @param array $data */ + public function __construct( array $data = [] ) { + foreach ( $data as $key => $value ) { + $this->$key = $value; + } + } + } +} if ( ! function_exists( 'get_author_posts_url' ) ) { function get_author_posts_url( int $id ): string { return 'https://peptiderepo.com/author/' . $id; diff --git a/tests/unit/JsonldArticleTest.php b/tests/unit/JsonldArticleTest.php new file mode 100644 index 0000000..572e5af --- /dev/null +++ b/tests/unit/JsonldArticleTest.php @@ -0,0 +1,484 @@ + array(), + 'existing_taxonomies' => array(), + 'registered_post_types' => array(), + 'registered_taxonomies' => array(), + 'registered_meta' => array(), + 'added_actions' => array(), + 'added_filters' => array(), + ); + + require_once PR_CORE_PLUGIN_DIR . 'includes/cpt/class-pr-core-peptide-cpt.php'; + require_once PR_CORE_PLUGIN_DIR . 'includes/frontend/class-pr-core-prab-meta-reader.php'; + require_once PR_CORE_PLUGIN_DIR . 'includes/frontend/class-pr-core-jsonld-article.php'; + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + /** @return PR_Core_Prab_Meta_Reader */ + private function reader(): PR_Core_Prab_Meta_Reader { + return new PR_Core_Prab_Meta_Reader(); + } + + /** + * @param array $meta + */ + private function set_meta( int $post_id, array $meta ): void { + $GLOBALS['pr_test_postmeta'][ $post_id ] = $meta; + } + + // ── is_triggered ───────────────────────────────────────────────────── + + public function test_is_triggered_version_1(): void { + $this->set_meta( 1, array( '_prab_schema_version' => '1' ) ); + $this->assertTrue( $this->reader()->is_triggered( 1 ) ); + } + + public function test_is_triggered_absent(): void { + $this->set_meta( 1, array() ); + $this->assertFalse( $this->reader()->is_triggered( 1 ) ); + } + + public function test_is_triggered_unsupported_version(): void { + $this->set_meta( 1, array( '_prab_schema_version' => '2' ) ); + $this->assertFalse( $this->reader()->is_triggered( 1 ) ); + } + + public function test_is_triggered_cache_returns_same_value(): void { + $this->set_meta( 5, array( '_prab_schema_version' => '1' ) ); + $r = $this->reader(); + $first = $r->is_triggered( 5 ); + $second = $r->is_triggered( 5 ); + $this->assertSame( $first, $second ); + $this->assertTrue( $first ); + } + + // ── get_review_mode ─────────────────────────────────────────────────── + + public function test_review_mode_human(): void { + $this->set_meta( 1, array( '_prab_review_mode' => 'human' ) ); + $this->assertSame( 'human', $this->reader()->get_review_mode( 1 ) ); + } + + public function test_review_mode_editorial_system(): void { + $this->set_meta( 1, array( '_prab_review_mode' => 'editorial-system' ) ); + $this->assertSame( 'editorial-system', $this->reader()->get_review_mode( 1 ) ); + } + + public function test_review_mode_unknown_falls_back(): void { + $this->set_meta( 1, array( '_prab_review_mode' => 'robo-editor' ) ); + $this->assertSame( 'editorial-system', $this->reader()->get_review_mode( 1 ) ); + } + + public function test_review_mode_empty_falls_back(): void { + $this->set_meta( 1, array() ); + $this->assertSame( 'editorial-system', $this->reader()->get_review_mode( 1 ) ); + } + + // ── get_reviewed_by ─────────────────────────────────────────────────── + + public function test_reviewed_by_human_valid_user(): void { + $this->set_meta( 1, array( '_prab_reviewed_by' => '42' ) ); + $GLOBALS['pr_test_userdata'][42] = new WP_User( array( 'display_name' => 'Dr Alice', 'ID' => 42 ) ); + $node = $this->reader()->get_reviewed_by( 1, 'human' ); + $this->assertSame( 'Person', $node['@type'] ); + $this->assertSame( 'Dr Alice', $node['name'] ); + $this->assertStringContainsString( '42', $node['@id'] ); + } + + public function test_reviewed_by_human_missing_user_returns_org(): void { + $this->set_meta( 1, array( '_prab_reviewed_by' => '99' ) ); + // user 99 not in $GLOBALS['pr_test_userdata'] → get_userdata returns false. + $node = $this->reader()->get_reviewed_by( 1, 'human' ); + $this->assertSame( 'Organization', $node['@type'] ); + } + + public function test_reviewed_by_human_zero_user_id_returns_org(): void { + $this->set_meta( 1, array( '_prab_reviewed_by' => '0' ) ); + $node = $this->reader()->get_reviewed_by( 1, 'human' ); + $this->assertSame( 'Organization', $node['@type'] ); + } + + public function test_reviewed_by_human_empty_display_name_returns_org(): void { + $this->set_meta( 1, array( '_prab_reviewed_by' => '77' ) ); + $GLOBALS['pr_test_userdata'][77] = new WP_User( array( 'display_name' => '', 'ID' => 77 ) ); + $node = $this->reader()->get_reviewed_by( 1, 'human' ); + $this->assertSame( 'Organization', $node['@type'] ); + } + + public function test_reviewed_by_editorial_system_returns_org(): void { + $node = $this->reader()->get_reviewed_by( 1, 'editorial-system' ); + $this->assertSame( 'Organization', $node['@type'] ); + $this->assertSame( 'Peptide Repo', $node['name'] ); + } + + public function test_reviewed_by_org_never_person(): void { + // Ensure editorial-system never emits a Person even if reviewed_by meta present. + $this->set_meta( 1, array( '_prab_reviewed_by' => '42' ) ); + $GLOBALS['pr_test_userdata'][42] = new WP_User( array( 'display_name' => 'Dr Alice', 'ID' => 42 ) ); + $node = $this->reader()->get_reviewed_by( 1, 'editorial-system' ); + $this->assertSame( 'Organization', $node['@type'] ); + } + + // ── get_citations ───────────────────────────────────────────────────── + + public function test_citations_valid(): void { + $this->set_meta( 1, array( + '_prab_citations' => wp_json_encode( array( + array( 'url' => 'https://pubmed.ncbi.nlm.nih.gov/12345', 'title' => 'BPC-157 study' ), + array( 'url' => 'https://doi.org/10.1016/j.abc.2020.01', 'title' => 'RCT', 'doi' => '10.1016/j.abc.2020.01' ), + ) ), + ) ); + $cits = $this->reader()->get_citations( 1 ); + $this->assertCount( 2, $cits ); + $this->assertSame( 'BPC-157 study', $cits[0]['title'] ); + $this->assertNull( $cits[0]['doi'] ); + $this->assertSame( 'https://doi.org/10.1016/j.abc.2020.01', $cits[1]['doi'] ); + } + + public function test_citations_missing_url_dropped(): void { + $this->set_meta( 1, array( + '_prab_citations' => wp_json_encode( array( + array( 'title' => 'No URL entry' ), + ) ), + ) ); + $this->assertCount( 0, $this->reader()->get_citations( 1 ) ); + } + + public function test_citations_missing_title_dropped(): void { + $this->set_meta( 1, array( + '_prab_citations' => wp_json_encode( array( + array( 'url' => 'https://example.com' ), + ) ), + ) ); + $this->assertCount( 0, $this->reader()->get_citations( 1 ) ); + } + + public function test_citations_invalid_url_scheme_dropped(): void { + $this->set_meta( 1, array( + '_prab_citations' => wp_json_encode( array( + array( 'url' => 'javascript:alert(1)', 'title' => 'XSS attempt' ), + ) ), + ) ); + $this->assertCount( 0, $this->reader()->get_citations( 1 ) ); + } + + public function test_citations_bad_doi_emits_null_doi(): void { + $this->set_meta( 1, array( + '_prab_citations' => wp_json_encode( array( + array( 'url' => 'https://example.com', 'title' => 'Test', 'doi' => 'not-a-doi' ), + ) ), + ) ); + $cits = $this->reader()->get_citations( 1 ); + $this->assertCount( 1, $cits ); + $this->assertNull( $cits[0]['doi'] ); + } + + public function test_citations_doi_normalised_from_uri(): void { + $this->set_meta( 1, array( + '_prab_citations' => wp_json_encode( array( + array( 'url' => 'https://example.com', 'title' => 'Test', 'doi' => 'https://doi.org/10.9999/x' ), + ) ), + ) ); + $cits = $this->reader()->get_citations( 1 ); + $this->assertSame( 'https://doi.org/10.9999/x', $cits[0]['doi'] ); + } + + public function test_citations_malformed_json_returns_empty(): void { + $this->set_meta( 1, array( '_prab_citations' => 'not-json' ) ); + $this->assertCount( 0, $this->reader()->get_citations( 1 ) ); + } + + // ── get_about_peptides edge cases ───────────────────────────────────── + + public function test_get_about_peptides_nonexistent_id_skipped(): void { + // post ID 999 does not exist in $GLOBALS['pr_test_posts'] → get_post returns null. + $this->set_meta( 1, array( + '_prab_schema_version' => '1', + '_prab_about_peptides' => wp_json_encode( array( 999 ) ), + ) ); + $stubs = $this->reader()->get_about_peptides( 1 ); + $this->assertCount( 0, $stubs ); + } + + public function test_get_about_peptides_wrong_post_type_skipped(): void { + // A published post of type 'post' (not 'peptide') should be excluded. + $GLOBALS['pr_test_posts'][50] = new WP_Post( array( + 'ID' => 50, + 'post_type' => 'post', + 'post_status' => 'publish', + 'post_title' => 'Not a peptide', + ) ); + $this->set_meta( 1, array( + '_prab_schema_version' => '1', + '_prab_about_peptides' => wp_json_encode( array( 50 ) ), + ) ); + $stubs = $this->reader()->get_about_peptides( 1 ); + $this->assertCount( 0, $stubs ); + } + + // ── get_reviewed_at ─────────────────────────────────────────────────── + + public function test_reviewed_at_valid_iso(): void { + $this->set_meta( 1, array( '_prab_reviewed_at' => '2026-06-11T08:00:00+00:00' ) ); + $this->assertSame( '2026-06-11T08:00:00+00:00', $this->reader()->get_reviewed_at( 1 ) ); + } + + public function test_reviewed_at_empty_returns_null(): void { + $this->set_meta( 1, array() ); + $this->assertNull( $this->reader()->get_reviewed_at( 1 ) ); + } + + public function test_reviewed_at_unparseable_returns_null(): void { + $this->set_meta( 1, array( '_prab_reviewed_at' => 'not-a-date' ) ); + $this->assertNull( $this->reader()->get_reviewed_at( 1 ) ); + } + + // ── retype_article_page ─────────────────────────────────────────────── + + public function test_retype_triggered_post(): void { + $GLOBALS['pr_test_is_singular'] = true; + $GLOBALS['pr_test_singular_type'] = 'post'; + $GLOBALS['pr_test_the_id'] = 1; + $this->set_meta( 1, array( '_prab_schema_version' => '1' ) ); + $emitter = new PR_Core_Jsonld_Article(); + $this->assertSame( 'MedicalWebPage', $emitter->retype_article_page( 'WebPage' ) ); + } + + public function test_retype_untriggered_passthrough(): void { + $GLOBALS['pr_test_is_singular'] = true; + $GLOBALS['pr_test_singular_type'] = 'post'; + $GLOBALS['pr_test_the_id'] = 2; + $this->set_meta( 2, array() ); // no _prab_schema_version. + $emitter = new PR_Core_Jsonld_Article(); + $this->assertSame( 'WebPage', $emitter->retype_article_page( 'WebPage' ) ); + } + + public function test_retype_non_post_passthrough(): void { + $GLOBALS['pr_test_is_singular'] = true; + $GLOBALS['pr_test_singular_type'] = 'peptide'; + $GLOBALS['pr_test_the_id'] = 1; + $this->set_meta( 1, array( '_prab_schema_version' => '1' ) ); + $emitter = new PR_Core_Jsonld_Article(); + $this->assertSame( 'CollectionPage', $emitter->retype_article_page( 'CollectionPage' ) ); + } + + // ── enrich_article_graph ────────────────────────────────────────────── + + public function test_enrich_adds_citation_and_about_to_article(): void { + $GLOBALS['pr_test_is_singular'] = true; + $GLOBALS['pr_test_singular_type'] = 'post'; + $GLOBALS['pr_test_the_id'] = 1; + + // Set up a published peptide post for about linkage. + $GLOBALS['pr_test_posts'][10] = new WP_Post( array( + 'ID' => 10, + 'post_type' => 'peptide', + 'post_status' => 'publish', + 'post_title' => 'BPC-157', + ) ); + + $this->set_meta( 1, array( + '_prab_schema_version' => '1', + '_prab_citations' => wp_json_encode( array( + array( 'url' => 'https://pubmed.ncbi.nlm.nih.gov/1', 'title' => 'Study A' ), + ) ), + '_prab_about_peptides' => wp_json_encode( array( 10 ) ), + '_prab_review_mode' => 'editorial-system', + '_prab_reviewed_at' => '2026-06-11T08:00:00+00:00', + ) ); + + $graph = array( + array( '@type' => 'MedicalWebPage', '@id' => 'https://peptiderepo.com/post/1/#webpage' ), + array( '@type' => 'Article', '@id' => 'https://peptiderepo.com/post/1/#article' ), + ); + + $emitter = new PR_Core_Jsonld_Article(); + $result_graph = $emitter->enrich_article_graph( $graph, null ); + + // Article should have citation and about. + $article = null; + $webpage = null; + foreach ( $result_graph as $piece ) { + if ( 'Article' === ( $piece['@type'] ?? '' ) ) { + $article = $piece; + } + if ( 'MedicalWebPage' === ( $piece['@type'] ?? '' ) ) { + $webpage = $piece; + } + } + + $this->assertNotNull( $article, 'Article piece found in graph' ); + $this->assertArrayHasKey( 'citation', $article ); + $this->assertCount( 1, $article['citation'] ); + $this->assertArrayHasKey( 'about', $article ); + $this->assertCount( 1, $article['about'] ); + $this->assertSame( 'Drug', $article['about'][0]['@type'] ); + + // WebPage should have lastReviewed and reviewedBy (Organization). + $this->assertNotNull( $webpage, 'MedicalWebPage piece found in graph' ); + $this->assertArrayHasKey( 'lastReviewed', $webpage ); + $this->assertSame( 'Organization', $webpage['reviewedBy']['@type'] ); + } + + public function test_enrich_untriggered_graph_unchanged(): void { + $GLOBALS['pr_test_is_singular'] = true; + $GLOBALS['pr_test_singular_type'] = 'post'; + $GLOBALS['pr_test_the_id'] = 2; + $this->set_meta( 2, array() ); + + $graph = array( + array( '@type' => 'WebPage', '@id' => 'https://peptiderepo.com/post/2/#webpage' ), + array( '@type' => 'Article', '@id' => 'https://peptiderepo.com/post/2/#article' ), + ); + $emitter = new PR_Core_Jsonld_Article(); + $result = $emitter->enrich_article_graph( $graph, null ); + + // Graph must be identical — no new keys. + $this->assertSame( $graph, $result ); + } + + public function test_no_new_article_or_webpage_nodes_added(): void { + $GLOBALS['pr_test_is_singular'] = true; + $GLOBALS['pr_test_singular_type'] = 'post'; + $GLOBALS['pr_test_the_id'] = 1; + $this->set_meta( 1, array( '_prab_schema_version' => '1', '_prab_review_mode' => 'editorial-system' ) ); + + $graph = array( + array( '@type' => 'MedicalWebPage', '@id' => 'x#w' ), + array( '@type' => 'Article', '@id' => 'x#a' ), + ); + $emitter = new PR_Core_Jsonld_Article(); + $result = $emitter->enrich_article_graph( $graph, null ); + + // Still exactly 2 pieces — no new nodes injected. + $this->assertCount( 2, $result ); + } + + // ── emit_standalone_fallback ────────────────────────────────────────── + + public function test_standalone_fallback_emits_nothing_for_untriggered_post(): void { + $GLOBALS['pr_test_is_singular'] = true; + $GLOBALS['pr_test_singular_type'] = 'post'; + $GLOBALS['pr_test_the_id'] = 3; + $this->set_meta( 3, array() ); // no _prab_schema_version. + + $emitter = new PR_Core_Jsonld_Article(); + ob_start(); + $emitter->emit_standalone_fallback(); + $output = ob_get_clean(); + + $this->assertSame( '', $output, 'Untriggered post must produce no output' ); + } + + public function test_standalone_fallback_emits_valid_graph_for_triggered_post(): void { + $GLOBALS['pr_test_is_singular'] = true; + $GLOBALS['pr_test_singular_type'] = 'post'; + $GLOBALS['pr_test_the_id'] = 4; + $GLOBALS['pr_test_posts'][4] = new WP_Post( array( + 'ID' => 4, + 'post_type' => 'post', + 'post_status' => 'publish', + 'post_title' => 'BPC-157 Article', + ) ); + $this->set_meta( 4, array( + '_prab_schema_version' => '1', + '_prab_review_mode' => 'editorial-system', + '_prab_reviewed_at' => '2026-06-20T10:00:00+00:00', + '_prab_citations' => wp_json_encode( array( + array( 'url' => 'https://example.com/study', 'title' => 'Study One', 'doi' => '10.1234/abc' ), + ) ), + ) ); + + $emitter = new PR_Core_Jsonld_Article(); + ob_start(); + $emitter->emit_standalone_fallback(); + $output = ob_get_clean(); + + // Must contain a script tag. + $this->assertStringContainsString( '', $output ); + + // Extract JSON between script tags. + preg_match( '#]*>(.*?)#s', $output, $matches ); + $this->assertNotEmpty( $matches[1], 'JSON body found inside script tag' ); + + $decoded = json_decode( $matches[1], true ); + $this->assertIsArray( $decoded, 'Output is valid JSON' ); + $this->assertSame( 'https://schema.org', $decoded['@context'] ); + $this->assertArrayHasKey( '@graph', $decoded ); + + // Collect types. + $types = array_column( $decoded['@graph'], '@type' ); + $this->assertContains( 'MedicalWebPage', $types, '@graph contains MedicalWebPage' ); + $this->assertContains( 'Article', $types, '@graph contains Article' ); + + // Article must have citation with ScholarlyArticle (DOI present). + $article_node = null; + foreach ( $decoded['@graph'] as $piece ) { + if ( 'Article' === ( $piece['@type'] ?? '' ) ) { + $article_node = $piece; + } + } + $this->assertNotNull( $article_node ); + $this->assertArrayHasKey( 'citation', $article_node ); + $this->assertSame( 'ScholarlyArticle', $article_node['citation'][0]['@type'] ); + } + public function test_standalone_fallback_suppressed_when_yoast_active(): void { + // YoastSEO() is defined here so it runs LAST — once defined it persists for + // the whole PHPUnit process and would suppress the output assertions above. + if ( ! function_exists( 'YoastSEO' ) ) { + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound + eval( 'function YoastSEO() { return new stdClass(); }' ); + } + $GLOBALS['pr_test_is_singular'] = true; + $GLOBALS['pr_test_singular_type'] = 'post'; + $GLOBALS['pr_test_the_id'] = 1; + $this->set_meta( 1, array( '_prab_schema_version' => '1' ) ); + + $emitter = new PR_Core_Jsonld_Article(); + ob_start(); + $emitter->emit_standalone_fallback(); + $output = ob_get_clean(); + + $this->assertSame( '', $output, 'Yoast-active path must produce no output' ); + } +}