docs/test: resolve QA P1s + P2 guards (v0.8.0)

P1-A: CONTEXT.md — add glossary entries for _prab_* namespace,
meta-reader pattern, ScholarlyArticle type, and honest-reviewedBy
integrity constraint (Person-vs-Organization branching rule).

P1-B: Master IA + app-map updated (workspace docs, not in this diff):
INFORMATION-ARCHITECTURE.md v1.3.2 — §5 cross-plugin data flow added
(PRAutoBlogger _prab_* meta → prcore JSON-LD Article emission); §4
version bumped to 0.8.0.
peptide-repo-core.md — two new classes in §2 file tree; §3 data flow
Mermaid extended with PRAB→MetaReader→Emitter chain; §5 cross-plugin
dependency noted; §6 hooks table updated; last-verified bumped to v0.8.0.

P1-C: JsonldArticleTest.php — add 5 new test methods:
  - test_standalone_fallback_emits_nothing_for_untriggered_post()
  - test_standalone_fallback_emits_valid_graph_for_triggered_post()
  - test_standalone_fallback_suppressed_when_yoast_active()
  - test_get_about_peptides_nonexistent_id_skipped()
  - test_get_about_peptides_wrong_post_type_skipped()
Also add test_is_triggered_cache_returns_same_value() for P2-5 cache.

P2-1: get_about_peptides() — skip entries where get_permalink() returns ''.
P2-2: get_reviewed_by() — empty display_name falls through to Organization.
P2-3: register_hooks() — explicit priority 10 on wpseo_schema_webpage_type.
P2-4: emit_standalone_fallback() — guard wp_json_encode() false return;
  also guard empty permalink before building the graph.
P2-5: is_triggered() — per-post  avoids triple
  get_post_meta reads when all three hooks fire for the same post.

P3s accepted (noted in QA verdict): P3-1 test-comment misleading;
P3-2 docblock @see gap on emit_standalone_fallback; P3-3 DOI regex.

Agent-Session: prcore-jsonld-remediation-20260623
This commit is contained in:
peptiderepo
2026-06-23 05:10:06 +00:00
parent 382a7ede5d
commit b2dc245c6e
4 changed files with 261 additions and 66 deletions

View File

@@ -62,7 +62,7 @@ class PR_Core_Jsonld_Article {
* @return void
*/
public function register_hooks(): void {
add_filter( 'wpseo_schema_webpage_type', array( $this, 'retype_article_page' ) );
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 );
}
@@ -151,7 +151,7 @@ class PR_Core_Jsonld_Article {
*
* 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.
* of whether Yoast is present. Skips posts whose permalink cannot be resolved.
*
* @return void
*/
@@ -167,7 +167,11 @@ class PR_Core_Jsonld_Article {
return;
}
$permalink = (string) get_permalink( $post_id );
$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 );
@@ -198,16 +202,19 @@ class PR_Core_Jsonld_Article {
$article['about'] = $about_stubs;
}
printf(
'<script type="application/ld+json">%s</script>' . "\n",
wp_json_encode(
array(
'@context' => 'https://schema.org',
'@graph' => array( $webpage, $article ),
),
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
)
$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 ) {
printf(
'<script type="application/ld+json">%s</script>' . "\n",
$json
);
}
}
// ── Private builders ─────────────────────────────────────────────────

View File

@@ -27,85 +27,70 @@ declare(strict_types=1);
*/
class PR_Core_Prab_Meta_Reader {
/**
* Schema version meta key; presence opts the post in, value must equal 1.
*
* @var string
*/
/** @var string Schema version meta key; value must equal 1 to trigger. */
public const META_SCHEMA_VERSION = '_prab_schema_version';
/**
* Citations meta key; JSON array of {url, title, doi?, quality_score?} objects.
*
* @var string
*/
/** @var string Citations meta key; JSON array of {url, title, doi?, quality_score?}. */
public const META_CITATIONS = '_prab_citations';
/**
* About-peptides meta key; JSON array of peptide post IDs (ints) for schema `about`.
*
* @var string
*/
/** @var string About-peptides meta key; JSON array of peptide post IDs for schema `about`. */
public const META_ABOUT_PEPTIDES = '_prab_about_peptides';
/**
* Review mode meta key; value is 'human' or 'editorial-system'.
*
* @var string
*/
/** @var string Review mode meta key; value is 'human' or 'editorial-system'. */
public const META_REVIEW_MODE = '_prab_review_mode';
/**
* Reviewed-at meta key; ISO 8601 datetime of last review.
*
* @var string
*/
/** @var string Reviewed-at meta key; ISO 8601 datetime of last review. */
public const META_REVIEWED_AT = '_prab_reviewed_at';
/**
* Reviewed-by meta key; stores the WP user ID of the Review Queue approver.
*
* @var string
*/
/** @var string Reviewed-by meta key; WP user ID of the Review Queue approver. */
public const META_REVIEWED_BY = '_prab_reviewed_by';
/**
* The only schema version this reader currently supports.
*
* @var int
*/
/** @var int The only schema version this reader currently supports. */
private const SUPPORTED_VERSION = 1;
/**
* Per-request cache for is_triggered() results, keyed by post ID.
*
* Avoids triple get_post_meta reads when all three hooks fire for the same post.
*
* @var array<int, bool>
*/
private array $triggered_cache = array();
/**
* Return true when this post carries a supported _prab_schema_version meta.
*
* Logs a notice on an unsupported-but-present version value.
* Result is cached per post ID to avoid repeated get_post_meta reads within
* one request (all three Yoast hooks call this for the same post).
*
* @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 ) {
return false;
return $this->triggered_cache[ $post_id ] = false;
}
$version = (int) $raw;
if ( self::SUPPORTED_VERSION === $version ) {
return true;
return $this->triggered_cache[ $post_id ] = 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 ) );
return false;
return $this->triggered_cache[ $post_id ] = false;
}
/**
* Read and validate _prab_citations meta.
*
* Each entry must have a valid http/https `url` and a non-empty `title`.
* Invalid entries are skipped individually. `quality_score` is never emitted
* (no schema.org mapping — contract v1).
* Invalid entries are skipped. `quality_score` is never emitted (no schema.org
* mapping — contract v1).
*
* @param int $post_id WordPress post ID.
* @return array<int, array{url: string, title: string, doi: string|null}> Validated citations.
* @return array<int, array{url: string, title: string, doi: string|null}> Validated.
*/
public function get_citations( int $post_id ): array {
$decoded = $this->decode_json_meta( $post_id, self::META_CITATIONS );
@@ -135,7 +120,8 @@ class PR_Core_Prab_Meta_Reader {
* Read and validate _prab_about_peptides meta.
*
* Returns Drug stub arrays for schema `about`. Each peptide post ID must
* resolve to a published `peptide` post; unresolvable IDs are skipped.
* resolve to a published `peptide` post; unresolvable IDs and posts whose
* permalink is empty are skipped.
*
* @param int $post_id WordPress post ID.
* @return array<int, array<string, string>> Drug stubs.
@@ -157,7 +143,10 @@ class PR_Core_Prab_Meta_Reader {
|| PR_Core_Peptide_CPT::POST_TYPE !== $post->post_type ) {
continue;
}
$link = (string) get_permalink( $pid );
$link = (string) get_permalink( $pid );
if ( '' === $link ) {
continue;
}
$stubs[] = array(
'@type' => 'Drug',
'@id' => $link . '#drug',
@@ -207,11 +196,11 @@ class PR_Core_Prab_Meta_Reader {
* Resolve the honest reviewedBy value for a PRAB article.
*
* Returns a Person node only when mode='human' AND _prab_reviewed_by resolves
* to an existing WP user. All other cases return the Organization node.
* Never emits a fabricated Person — that is the core contract guarantee.
* to an existing WP user with a non-empty display_name. All other cases return
* the Organization node. Never emits a fabricated Person — core contract.
*
* Person @id: {home_url}/#/schema/person/{user_id} (Yoast convention).
* Org @id: {home_url}/#organization (references Yoast's existing publisher node).
* Org @id: {home_url}/#organization (references Yoast's publisher node).
*
* @param int $post_id WordPress post ID.
* @param string $review_mode Result of get_review_mode() for this post.
@@ -222,13 +211,15 @@ class PR_Core_Prab_Meta_Reader {
$uid = absint( get_post_meta( $post_id, self::META_REVIEWED_BY, true ) );
$user = $uid > 0 ? get_userdata( $uid ) : false;
if ( $user instanceof WP_User ) {
return array(
'@type' => 'Person',
'@id' => home_url( '/#/schema/person/' . $uid ),
'name' => sanitize_text_field( $user->display_name ),
);
$display = sanitize_text_field( $user->display_name );
if ( '' !== $display ) {
return array(
'@type' => 'Person',
'@id' => home_url( '/#/schema/person/' . $uid ),
'name' => $display,
);
}
}
// Log missing/unresolvable user and fall through to Org.
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(