peptide-repo-core v0.6.0: peptide schema backfill + Yoast-integrated Drug/MedicalWebPage/FAQ (#19)

Backfill psa_*->_pr_* meta (migration 0004 + PubChem fallback); enrich Drug node with {permalink}#drug @id + molecularFormula/Weight/alternateName; rewrite emitter into Yoast schema graph (MedicalWebPage retype + FAQPage, no duplicate nodes).

QA: APPROVE (independent) — qa-reviews/peptide-repo-core/2026-06-13-817690c1b6ec2118ddcf3b8cfe8bc5ea7f3933b3.md

Agent-Session: peptide-schema-backfill v0.6.0 merge
This commit is contained in:
peptiderepo
2026-06-13 13:09:56 +07:00
committed by GitHub
parent cc5d06b970
commit e9d97b51e2
18 changed files with 2004 additions and 119 deletions

View File

@@ -68,7 +68,7 @@ class PR_Core_Peptide_CPT {
'aliases' => [
'type' => 'string',
'default' => '[]',
'sanitize' => [ __CLASS__, 'sanitize_json_array' ],
'sanitize' => [ PR_Core_Schema_Sanitizers::class, 'sanitize_json_array' ],
],
'molecular_formula' => [
'type' => 'string',
@@ -145,6 +145,28 @@ class PR_Core_Peptide_CPT {
'default' => 'current',
'sanitize' => [ PR_Core_Verification_Sanitizers::class, 'sanitize_status' ],
],
// Schema-input meta (v0.6.0): molecular data sourced from PSA psa_* keys via migration 0004.
'_pr_molecular_formula' => [
'type' => 'string',
'default' => '',
'sanitize' => [ PR_Core_Schema_Sanitizers::class, 'sanitize_molecular_formula' ],
],
'_pr_molecular_weight' => [
'type' => 'string',
'default' => '',
'sanitize' => [ PR_Core_Schema_Sanitizers::class, 'sanitize_molecular_weight_string' ],
],
'_pr_aliases' => [
'type' => 'string',
'default' => '[]',
'sanitize' => [ PR_Core_Schema_Sanitizers::class, 'sanitize_json_array' ],
],
// FAQ items (v0.6.0): [{question, answer}] JSON for FAQPage schema. Populated by CMO content sprint.
'_pr_faq_items' => [
'type' => 'string',
'default' => '[]',
'sanitize' => [ PR_Core_Schema_Sanitizers::class, 'sanitize_faq_items' ],
],
];
}
@@ -160,12 +182,7 @@ class PR_Core_Peptide_CPT {
}
/**
* Register the `peptide` custom post type.
*
* Guarded with `post_type_exists()`: if another plugin (historically PSA)
* has already registered the `peptide` post type, this call no-ops. This
* makes deploy order between PR Core and PSA irrelevant during the
* PSA v4.5.0 consolidation transition.
* Register the `peptide` CPT. Guarded with post_type_exists() so deploy order is irrelevant.
*
* Side effects: registers CPT with WordPress.
*
@@ -218,15 +235,7 @@ class PR_Core_Peptide_CPT {
}
/**
* Register the `peptide_category` taxonomy.
*
* Guarded with `taxonomy_exists()` for the same reason CPT registration
* is guarded. The 8 existing terms + term_relationships stay intact —
* they key on taxonomy name `peptide_category` in wp_term_taxonomy,
* which is exactly what we register here.
*
* v0.2.0: `pr_peptide_family` taxonomy removed — never populated, never
* surfaced in UI.
* Register the `peptide_category` taxonomy. Guarded with taxonomy_exists().
*
* Side effects: registers taxonomy with WordPress.
*
@@ -272,20 +281,6 @@ class PR_Core_Peptide_CPT {
}
}
/** Sanitize a JSON array string (e.g., aliases field). */
public static function sanitize_json_array( $value ): string {
if ( is_array( $value ) ) {
$value = wp_json_encode( array_map( 'sanitize_text_field', $value ) );
}
$decoded = json_decode( (string) $value, true );
if ( ! is_array( $decoded ) ) {
return '[]';
}
return wp_json_encode( array_values( array_map( 'sanitize_text_field', $decoded ) ) );
}
/** Sanitize evidence_strength to allowed enum values. */
public static function sanitize_evidence_strength( $value ): string {
$value = sanitize_text_field( (string) $value );

View File

@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
/**
* Sanitizers for v0.6.0 schema-input meta fields.
*
* What: Static sanitizer methods for _pr_molecular_formula, _pr_molecular_weight,
* and _pr_faq_items meta fields introduced in v0.6.0.
*
* Who calls it: PR_Core_Peptide_CPT::get_meta_fields() registers these as
* sanitize_callback values for register_post_meta().
*
* Dependencies: WordPress sanitize_text_field, sanitize_textarea_field, wp_json_encode.
*
* @see cpt/class-pr-core-peptide-cpt.php — Meta field registration.
* @see ARCHITECTURE.md — §v0.6.0 schema sprint.
*/
class PR_Core_Schema_Sanitizers {
/**
* Regex token for a single element symbol + optional count (e.g., "C10", "Na", "O").
*
* Matches: one uppercase letter, optionally followed by one lowercase letter,
* optionally followed by one or more digits.
*
* @var string
*/
const FORMULA_TOKEN = '[A-Z][a-z]?\d*';
/**
* Regex character class for allowed group-separator / structural characters.
*
* Covers: parentheses, square brackets, interpunct (·, U+00B7), period, digits.
* These may appear between element tokens in complex or hydrated formulas.
*
* @var string
*/
const FORMULA_SEP = '[().\[\]\xB7\d]';
/**
* Sanitize a molecular formula string (for _pr_molecular_formula).
*
* Strips HTML tags and control characters, then validates the value against
* an element-formula grammar. Returns the longest leading run of valid
* formula tokens and separators; returns '' when nothing valid is found.
*
* Grammar (per token): [A-Z][a-z]?\d* — an element symbol plus optional count.
* Separators: ( ) [ ] · . and standalone digits (for hydrated formulas).
* Input is treated as UNTRUSTED; downstream JSON escaping is unchanged.
*
* Examples:
* "C62H98N16O22" → "C62H98N16O22" (valid, unchanged)
* "C10H12\ninjection" → "C10H12" (injection stripped)
* "C2H5(OH)" → "C2H5(OH)" (parenthesised formula preserved)
* "<script>alert(1)</script>" → "" (no valid leading token)
* "" → "" (empty input)
*
* @param mixed $value Raw input value.
* @return string Sanitized formula string, or '' when no valid formula found.
*/
public static function sanitize_molecular_formula( $value ): string {
// 1. Strip control characters (including newlines, tabs, null bytes).
$value = preg_replace( '/[\x00-\x1F\x7F]/', '', (string) $value ) ?? '';
// 2. Strip HTML tags; then strip remaining unsafe text characters.
$value = sanitize_text_field( wp_strip_all_tags( $value ) );
if ( '' === $value ) {
return '';
}
// 3. Extract the maximal leading run of valid element tokens and separators.
// A valid run must start with an element token ([A-Z][a-z]?\d*), not a separator.
$pattern = '/^((?:' . self::FORMULA_TOKEN . '|' . self::FORMULA_SEP . ')+)/u';
if ( preg_match( $pattern, $value, $m ) ) {
return $m[1];
}
return '';
}
/**
* Sanitize a molecular weight stored as a float string (for _pr_molecular_weight).
*
* Strips any trailing "Da" unit suffix, returns a float string.
* Stored as string in post_meta to avoid float precision issues.
* Returns empty string for invalid/zero inputs.
*
* @param mixed $value Raw input value (e.g., "1419.5" or "1419.5 Da").
* @return string Float string (e.g., "1419.5"), or "" on invalid input.
*/
public static function sanitize_molecular_weight_string( $value ): string {
$clean = trim( preg_replace( '/\s*[Dd][Aa]\s*$/', '', trim( (string) $value ) ) ?? '' );
$f = (float) $clean;
return $f > 0.0 ? (string) $f : '';
}
/**
* Sanitize a JSON array string (e.g., for legacy aliases field or _pr_aliases).
*
* Accepts a JSON string or PHP array. Sanitizes each element individually.
* Input is UNTRUSTED.
*
* @param mixed $value Raw input (JSON string or array).
* @return string JSON array of sanitized strings, or '[]'.
*/
public static function sanitize_json_array( $value ): string {
if ( is_array( $value ) ) {
$value = wp_json_encode( array_map( 'sanitize_text_field', $value ) );
}
$decoded = json_decode( (string) $value, true );
if ( ! is_array( $decoded ) ) {
return '[]';
}
return wp_json_encode( array_values( array_map( 'sanitize_text_field', $decoded ) ) );
}
/**
* Sanitize FAQ items JSON (for _pr_faq_items).
*
* Expects a JSON array of {question: string, answer: string} objects.
* Sanitizes each question and answer individually.
* Input is UNTRUSTED — malformed entries are silently dropped.
*
* @param mixed $value Raw input (JSON string or PHP array).
* @return string JSON array of sanitized FAQ item objects, or '[]'.
*/
public static function sanitize_faq_items( $value ): string {
if ( is_string( $value ) ) {
$value = json_decode( $value, true );
}
if ( ! is_array( $value ) ) {
return '[]';
}
$clean = [];
foreach ( $value as $item ) {
if ( ! is_array( $item ) ) {
continue;
}
$question = sanitize_text_field( (string) ( $item['question'] ?? '' ) );
$answer = sanitize_textarea_field( (string) ( $item['answer'] ?? '' ) );
if ( '' !== $question && '' !== $answer ) {
$clean[] = [
'question' => $question,
'answer' => $answer,
];
}
}
return wp_json_encode( $clean, JSON_UNESCAPED_UNICODE ) ?: '[]';
}
}

View File

@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
/**
* Builds the schema.org Drug graph piece for a peptide page.
*
* What: Assembles a Drug (+ MolecularEntity) schema node from peptide DTO
* and the new _pr_* schema-input meta fields added in v0.6.0.
*
* Who calls it: PR_Core_Jsonld::build_drug_piece() during Yoast graph assembly
* or standalone @graph construction.
*
* Dependencies: PR_Core_Peptide_DTO, WordPress get_post_meta(), get_permalink().
*
* @see frontend/class-pr-core-jsonld.php — Orchestrator that calls this builder.
* @see ARCHITECTURE.md — §2.7 JSON-LD output.
*/
class PR_Core_Jsonld_Drug {
/** @var string Meta key: molecular formula sourced by migration 0004. */
private const META_FORMULA = '_pr_molecular_formula';
/** @var string Meta key: molecular weight (float string) sourced by migration 0004. */
private const META_WEIGHT = '_pr_molecular_weight';
/** @var string Meta key: aliases JSON array sourced by migration 0004. */
private const META_ALIASES = '_pr_aliases';
/** @var string Unit text for molecularWeight QuantitativeValue. */
private const UNIT_TEXT = 'g/mol';
/**
* Build a schema.org Drug piece array for a peptide.
*
* @param PR_Core_Peptide_DTO $peptide Peptide DTO.
* @return array<string, mixed> Schema.org Drug node.
*/
public function build( PR_Core_Peptide_DTO $peptide ): array {
$permalink = get_permalink( $peptide->id );
$node = [
'@type' => [ 'Drug', 'MolecularEntity' ],
'@id' => $permalink . '#drug',
'name' => $peptide->display_name ?: $peptide->title,
'url' => $permalink,
];
if ( '' !== $peptide->excerpt ) {
$node['description'] = $peptide->excerpt;
}
// alternateName: prefer _pr_aliases (v0.6.0), fall back to DTO aliases field.
$aliases = $this->get_aliases( $peptide );
if ( ! empty( $aliases ) ) {
$node['alternateName'] = $aliases;
}
// molecularFormula: prefer _pr_molecular_formula, fall back to DTO field.
$formula = $this->get_formula( $peptide );
if ( '' !== $formula ) {
$node['molecularFormula'] = $formula;
}
// molecularWeight: prefer _pr_molecular_weight, fall back to DTO field.
$weight = $this->get_weight( $peptide );
if ( $weight > 0.0 ) {
$node['molecularWeight'] = [
'@type' => 'QuantitativeValue',
'value' => $weight,
'unitText' => self::UNIT_TEXT,
];
}
// External identifiers: CAS and DrugBank omitted in v0.6.0 (no source).
// @see ARCHITECTURE.md §v0.6.0 schema sprint — future enrichment.
$legal_status = $this->build_legal_status();
if ( null !== $legal_status ) {
$node['legalStatus'] = $legal_status;
}
/**
* Filter the Drug schema node for a peptide.
*
* Known listeners: none.
* Registered at: PR_Core::register_public_filters() — carried forward from v0.5.0.
*
* @param array<string, mixed> $node Drug schema node.
* @param PR_Core_Peptide_DTO $peptide Peptide DTO.
*/
return apply_filters( 'pr_core_jsonld_peptide', $node, $peptide );
}
/**
* Get aliases array from _pr_aliases meta, falling back to DTO aliases.
*
* @param PR_Core_Peptide_DTO $peptide Peptide DTO.
* @return string[] Sanitized alias strings.
*/
private function get_aliases( PR_Core_Peptide_DTO $peptide ): array {
$raw = get_post_meta( $peptide->id, self::META_ALIASES, true );
if ( is_string( $raw ) && '' !== $raw ) {
$decoded = json_decode( $raw, true );
if ( is_array( $decoded ) && ! empty( $decoded ) ) {
return array_values( array_filter( array_map( 'sanitize_text_field', $decoded ) ) );
}
}
// Fall back to DTO aliases (legacy bare-key meta).
return $peptide->aliases;
}
/**
* Get molecular formula from _pr_molecular_formula meta, falling back to DTO.
*
* @param PR_Core_Peptide_DTO $peptide Peptide DTO.
* @return string Sanitized formula string, or '' if not available.
*/
private function get_formula( PR_Core_Peptide_DTO $peptide ): string {
$raw = (string) get_post_meta( $peptide->id, self::META_FORMULA, true );
if ( '' !== $raw ) {
return sanitize_text_field( $raw );
}
return $peptide->molecular_formula;
}
/**
* Get molecular weight float from _pr_molecular_weight meta, falling back to DTO.
*
* @param PR_Core_Peptide_DTO $peptide Peptide DTO.
* @return float Weight in g/mol, or 0.0 if not available.
*/
private function get_weight( PR_Core_Peptide_DTO $peptide ): float {
$raw = (string) get_post_meta( $peptide->id, self::META_WEIGHT, true );
if ( '' !== $raw ) {
$weight = (float) $raw;
if ( $weight > 0.0 ) {
return $weight;
}
}
return $peptide->molecular_weight;
}
/**
* Build a standard legalStatus node for research compounds.
*
* Returns null when legalStatus should be omitted (future: when per-peptide
* status is available from legal cells).
*
* @return array<string, string>|null
*/
private function build_legal_status(): ?array {
return [
'@type' => 'DrugLegalStatus',
'description' => esc_html__( 'Not approved for human therapeutic use; supplied for laboratory research purposes only.', 'peptide-repo-core' ),
];
}
}

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
/**
* Builds the schema.org FAQPage graph piece for a peptide page.
*
* What: Reads _pr_faq_items post-meta (JSON array of {question, answer} objects)
* and produces a FAQPage node suitable for injection into Yoast's graph.
* Emits nothing when the meta is empty or absent.
*
* Who calls it: PR_Core_Jsonld::build_faq_piece() during graph assembly.
* Dependencies: WordPress get_post_meta(), get_permalink().
*
* @see frontend/class-pr-core-jsonld.php — Orchestrator that calls this builder.
* @see cpt/class-pr-core-schema-sanitizers.php — sanitize_faq_items() used on save.
* @see ARCHITECTURE.md — §2.7 JSON-LD output.
*/
class PR_Core_Jsonld_Faq {
/** @var string Meta key holding the FAQ items JSON array. */
public const META_FAQ_ITEMS = '_pr_faq_items';
/**
* Build a FAQPage node for a post, or return null if no items exist.
*
* @param int $post_id WordPress post ID.
* @param string $permalink Canonical permalink for @id construction.
* @return array<string, mixed>|null FAQPage schema node, or null when no items.
*/
public function build( int $post_id, string $permalink ): ?array {
$items = $this->get_validated_items( $post_id );
if ( empty( $items ) ) {
return null;
}
return [
'@type' => 'FAQPage',
'@id' => $permalink . '#faq',
'mainEntity' => array_map( [ $this, 'build_question' ], $items ),
];
}
/**
* Build a single Question node from a FAQ item array.
*
* @param array{question: string, answer: string} $item Sanitized FAQ item.
* @return array<string, mixed> Schema.org Question node.
*/
private function build_question( array $item ): array {
return [
'@type' => 'Question',
'name' => esc_html( $item['question'] ),
'acceptedAnswer' => [
'@type' => 'Answer',
'text' => esc_html( $item['answer'] ),
],
];
}
/**
* Read and validate _pr_faq_items meta, returning only well-formed items.
*
* Returns empty array when meta is absent, empty, or malformed.
* Does not trust stored values — re-validates each item at read time.
*
* @param int $post_id WordPress post ID.
* @return array<int, array{question: string, answer: string}> Validated items.
*/
private function get_validated_items( int $post_id ): array {
$raw = get_post_meta( $post_id, self::META_FAQ_ITEMS, true );
if ( ! is_string( $raw ) || '' === $raw ) {
return [];
}
$decoded = json_decode( $raw, true );
if ( ! is_array( $decoded ) || empty( $decoded ) ) {
return [];
}
$valid = [];
foreach ( $decoded as $item ) {
if ( ! is_array( $item ) ) {
continue;
}
$question = sanitize_text_field( (string) ( $item['question'] ?? '' ) );
$answer = sanitize_textarea_field( (string) ( $item['answer'] ?? '' ) );
if ( '' !== $question && '' !== $answer ) {
$valid[] = [
'question' => $question,
'answer' => $answer,
];
}
}
return $valid;
}
}

View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
/**
* Enriches Yoast's WebPage node to MedicalWebPage for peptide single pages.
*
* What: Hooks into Yoast's wpseo_schema_webpage_type filter to retype the page
* node to MedicalWebPage, and into wpseo_schema_graph to inject
* lastReviewed, reviewedBy, and audience enrichments.
*
* Who calls it: PR_Core_Jsonld::register_hooks() registers both filters.
* Dependencies: Yoast SEO (wordpress-seo) ≥ 27.6 when active; graceful no-op otherwise.
*
* @see frontend/class-pr-core-jsonld.php — Parent orchestrator.
* @see ARCHITECTURE.md — §2.7 JSON-LD output, Yoast integration contract.
*/
class PR_Core_Jsonld_Webpage {
/** @var string Meta key for last editorial review date (ISO date string). */
private const META_LAST_REVIEWED = '_pr_last_reviewed';
/** @var string Meta key for last source verification date (ISO date string). */
private const META_LAST_VERIFIED = '_pr_last_source_verified';
/**
* Return 'MedicalWebPage' as the schema type for peptide single pages.
*
* Hooked on: wpseo_schema_webpage_type (Yoast SEO ≥ 19.x).
* Passes through unchanged on non-peptide pages.
*
* @param string $type Existing Yoast page type (e.g., 'WebPage').
* @return string 'MedicalWebPage' on peptide singles, $type otherwise.
*/
public function retype_to_medical_webpage( string $type ): string {
if ( is_singular( PR_Core_Peptide_CPT::POST_TYPE ) ) {
return 'MedicalWebPage';
}
return $type;
}
/**
* Enrich Yoast's graph pieces array for peptide single pages.
*
* Injects lastReviewed, reviewedBy, and audience into the WebPage/MedicalWebPage
* node already in Yoast's graph. Does NOT add new WebPage or BreadcrumbList nodes.
*
* Hooked on: wpseo_schema_graph (Yoast SEO ≥ 19.x), priority 11.
*
* @param array<int, array<string, mixed>> $graph Yoast schema graph pieces array.
* @param \WPSEO_Schema_Context $context Yoast schema context object.
* @return array<int, array<string, mixed>> Modified graph.
*/
public function enrich_webpage_piece( array $graph, $context ): array {
if ( ! is_singular( PR_Core_Peptide_CPT::POST_TYPE ) ) {
return $graph;
}
$post_id = get_the_ID();
$enrichments = $this->build_webpage_enrichments( (int) $post_id );
if ( empty( $enrichments ) ) {
return $graph;
}
foreach ( $graph as &$piece ) {
if ( ! is_array( $piece ) ) {
continue;
}
$type = $piece['@type'] ?? '';
if ( 'WebPage' === $type || 'MedicalWebPage' === $type ) {
foreach ( $enrichments as $key => $value ) {
$piece[ $key ] = $value;
}
break;
}
}
return $graph;
}
/**
* Build MedicalWebPage enrichment fields for a peptide post.
*
* @param int $post_id WordPress post ID.
* @return array<string, mixed> Enrichment fields to merge into the WebPage node.
*/
private function build_webpage_enrichments( int $post_id ): array {
$enrichments = [];
// lastReviewed: prefer _pr_last_reviewed, fall back to _pr_last_source_verified,
// then post modified date.
$last_reviewed = (string) get_post_meta( $post_id, self::META_LAST_REVIEWED, true );
if ( '' === $last_reviewed ) {
$last_reviewed = (string) get_post_meta( $post_id, self::META_LAST_VERIFIED, true );
}
if ( '' === $last_reviewed ) {
$last_reviewed = get_post_modified_time( 'Y-m-d', false, $post_id ) ?: '';
}
if ( '' !== $last_reviewed ) {
$enrichments['lastReviewed'] = sanitize_text_field( $last_reviewed );
}
// reviewedBy: Person only when a medical editor is assigned; otherwise Organization.
$editor_id = (int) get_post_meta( $post_id, 'medical_editor_id', true );
if ( $editor_id > 0 ) {
$editor = get_userdata( $editor_id );
if ( $editor ) {
$enrichments['reviewedBy'] = [
'@type' => 'Person',
'name' => sanitize_text_field( $editor->display_name ),
'url' => get_author_posts_url( $editor_id ),
];
}
}
if ( ! isset( $enrichments['reviewedBy'] ) ) {
$enrichments['reviewedBy'] = [
'@type' => 'Organization',
'name' => 'Peptide Repo',
'url' => home_url(),
];
}
// audience: MedicalAudience for researchers.
$enrichments['audience'] = [
'@type' => 'MedicalAudience',
'audienceType' => 'Researcher',
];
return $enrichments;
}
}

View File

@@ -4,33 +4,122 @@ declare(strict_types=1);
/**
* JSON-LD / schema.org structured data emission for peptide pages.
*
* What: Emits Drug schema on single peptide pages, Dataset schema on archives.
* Who calls it: PR_Core::init() registers wp_head hook.
* Dependencies: PR_Core_Peptide_Repository for data lookup.
* What: Integrates Drug, MedicalWebPage, and FAQPage schema into Yoast's graph
* on single peptide pages. When Yoast is inactive, emits a standalone
* @graph block via wp_head as a fallback.
*
* Consumer plugins can override via pr_core_jsonld_peptide filter.
* Who calls it: PR_Core::init() calls register_hooks(). Yoast hooks fire on
* every frontend page that Yoast processes.
*
* @see ARCHITECTURE.md — Section 2.7 JSON-LD output.
* Dependencies:
* - Yoast SEO (wordpress-seo) ≥ 27.6 for integrated emission path.
* - 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):
* - 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).
*
* @see frontend/class-pr-core-jsonld-drug.php — Drug piece builder.
* @see frontend/class-pr-core-jsonld-webpage.php — MedicalWebPage enrichment.
* @see frontend/class-pr-core-jsonld-faq.php — FAQPage piece builder.
* @see ARCHITECTURE.md — §2.7 JSON-LD output.
*/
class PR_Core_Jsonld {
/** @var PR_Core_Jsonld_Drug Drug piece builder. */
private PR_Core_Jsonld_Drug $drug_builder;
/** @var PR_Core_Jsonld_Webpage MedicalWebPage enricher. */
private PR_Core_Jsonld_Webpage $webpage_enricher;
/** @var PR_Core_Jsonld_Faq FAQ piece builder. */
private PR_Core_Jsonld_Faq $faq_builder;
/**
* Construct the orchestrator with its piece builders.
*/
public function __construct() {
$this->drug_builder = new PR_Core_Jsonld_Drug();
$this->webpage_enricher = new PR_Core_Jsonld_Webpage();
$this->faq_builder = new PR_Core_Jsonld_Faq();
}
/**
* 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).
*
* Side effects: calls add_filter(), add_action().
*
* @return void
*/
public function register_hooks(): void {
add_action( 'wp_head', [ $this, 'emit_jsonld' ], 99 );
// 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 );
add_filter( 'wpseo_schema_webpage_type', [ $this->webpage_enricher, 'retype_to_medical_webpage' ] );
add_filter( 'wpseo_schema_graph', [ $this->webpage_enricher, 'enrich_webpage_piece' ], 11, 2 );
// Standalone fallback: emits only when Yoast is not producing output.
add_action( 'wp_head', [ $this, 'emit_standalone_fallback' ], 99 );
}
/**
* Emit JSON-LD on peptide single pages.
* Inject Drug and FAQPage pieces into Yoast's schema graph.
*
* Side effects: outputs script tag in wp_head.
* 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.
*
* @param array<int, mixed> $pieces Existing graph pieces from Yoast.
* @param \WPSEO_Schema_Context $context Yoast schema context object.
* @return array<int, mixed> Pieces array with Drug and FAQPage appended.
*/
public function inject_graph_pieces( array $pieces, $context ): array {
if ( ! is_singular( PR_Core_Peptide_CPT::POST_TYPE ) ) {
return $pieces;
}
$post_id = get_the_ID();
if ( ! $post_id ) {
return $pieces;
}
$peptide = ( new PR_Core_Peptide_Repository() )->find_by_id( (int) $post_id );
if ( ! $peptide ) {
return $pieces;
}
$pieces[] = $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;
}
return $pieces;
}
/**
* Emit a standalone @graph block via wp_head when Yoast is not active.
*
* 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.
*
* Side effects: outputs a script tag in wp_head.
*
* @return void
*/
public function emit_jsonld(): void {
public function emit_standalone_fallback(): void {
if ( function_exists( 'YoastSEO' ) ) {
return;
}
if ( ! is_singular( PR_Core_Peptide_CPT::POST_TYPE ) ) {
return;
}
@@ -40,86 +129,25 @@ class PR_Core_Jsonld {
return;
}
$repo = new PR_Core_Peptide_Repository();
$peptide = $repo->find_by_id( $post_id );
$peptide = ( new PR_Core_Peptide_Repository() )->find_by_id( (int) $post_id );
if ( ! $peptide ) {
return;
}
$schema = $this->build_drug_schema( $peptide );
$permalink = get_permalink( (int) $post_id );
$graph = [ $this->drug_builder->build( $peptide ) ];
/**
* Filter the JSON-LD schema for a peptide page.
*
* @param array<string, mixed> $schema Schema.org data array.
* @param PR_Core_Peptide_DTO $peptide The peptide DTO.
*/
$schema = apply_filters( 'pr_core_jsonld_peptide', $schema, $peptide );
if ( empty( $schema ) ) {
return;
$faq_piece = $this->faq_builder->build( (int) $post_id, $permalink );
if ( null !== $faq_piece ) {
$graph[] = $faq_piece;
}
printf(
'<script type="application/ld+json">%s</script>' . "\n",
wp_json_encode( $schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT )
wp_json_encode(
[ '@context' => 'https://schema.org', '@graph' => $graph ],
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
)
);
}
/**
* Build a schema.org Drug object for a peptide.
*
* @param PR_Core_Peptide_DTO $peptide Peptide data.
* @return array<string, mixed> Schema.org JSON-LD structure.
*/
private function build_drug_schema( PR_Core_Peptide_DTO $peptide ): array {
$schema = [
'@context' => 'https://schema.org',
'@type' => 'Drug',
'name' => $peptide->display_name ?: $peptide->title,
'url' => get_permalink( $peptide->id ),
'description' => $peptide->excerpt,
];
if ( ! empty( $peptide->aliases ) ) {
$schema['alternateName'] = $peptide->aliases;
}
if ( '' !== $peptide->molecular_formula ) {
$schema['molecularFormula'] = $peptide->molecular_formula;
}
if ( $peptide->molecular_weight > 0 ) {
$schema['molecularWeight'] = [
'@type' => 'QuantitativeValue',
'value' => $peptide->molecular_weight,
'unitText' => 'Da',
];
}
// External identifiers as codes.
$codes = [];
if ( '' !== $peptide->cas_number ) {
$codes[] = [
'@type' => 'MedicalCode',
'codeValue' => $peptide->cas_number,
'codingSystem' => 'CAS',
];
}
if ( '' !== $peptide->drugbank_id ) {
$codes[] = [
'@type' => 'MedicalCode',
'codeValue' => $peptide->drugbank_id,
'codingSystem' => 'DrugBank',
];
}
if ( ! empty( $codes ) ) {
$schema['code'] = $codes;
}
return $schema;
}
}

View File

@@ -0,0 +1,270 @@
<?php
declare(strict_types=1);
/**
* Migration 0004: Backfill peptide schema meta from PSA psa_* keys.
*
* What: Copies PSA-stored PubChem data (molecular formula, weight, aliases)
* into PR Core's own _pr_molecular_formula, _pr_molecular_weight,
* _pr_aliases meta keys on all published peptide posts.
* For posts missing PSA data, attempts a PubChem REST lookup via
* PR_Core_Pubchem_Client.
*
* Who calls it: PR_Core_Migration_Runner::run_pending().
* Dependencies: WordPress get_post_meta(), update_post_meta(), get_posts().
* PR_Core_Pubchem_Client for PubChem fallback.
* PR_Core_Schema_Sanitizers for formula sanitization.
* PR_Core_Peptide_CPT::POST_TYPE for query.
*
* Idempotent: skips posts where all three PR Core meta keys are already non-empty.
* Re-runnable: safe to run multiple times.
*
* PSA source keys (all treated as UNTRUSTED — sanitized before writing):
* psa_molecular_formula => _pr_molecular_formula (string)
* psa_molecular_weight => _pr_molecular_weight (float string, strip "Da")
* psa_aliases => _pr_aliases (JSON array string)
*
* @see ARCHITECTURE.md — §v0.6.0 schema sprint.
* @see CONVENTIONS.md — How To: Add a New Migration.
* @see class-pr-core-pubchem-client.php — HTTP client for PubChem REST.
*/
class PR_Core_Migration_0004_Backfill_Peptide_Meta {
/** @var string Meta key: molecular formula (PR Core namespace). */
public const META_FORMULA = '_pr_molecular_formula';
/** @var string Meta key: molecular weight as float string (PR Core namespace). */
public const META_WEIGHT = '_pr_molecular_weight';
/** @var string Meta key: aliases JSON array (PR Core namespace). */
public const META_ALIASES = '_pr_aliases';
/** @var PR_Core_Pubchem_Client HTTP client for PubChem REST API. */
private PR_Core_Pubchem_Client $pubchem;
/**
* Construct migration with optional PubChem client (injectable for testing).
*
* @param PR_Core_Pubchem_Client|null $pubchem PubChem client; creates default if null.
*/
public function __construct( ?PR_Core_Pubchem_Client $pubchem = null ) {
$this->pubchem = $pubchem ?? new PR_Core_Pubchem_Client();
}
/**
* Run the backfill migration.
*
* Side effects: reads post meta, writes post meta, logs via error_log.
*
* @return void
*/
public function up(): void {
$peptide_ids = $this->get_all_peptide_ids();
if ( empty( $peptide_ids ) ) {
error_log( '[PR Core Migration 0004] No peptide posts found — nothing to backfill.' );
return;
}
$counts = [ 'skipped' => 0, 'copied_psa' => 0, 'pubchem_ok' => 0, 'pubchem_skip' => 0 ];
foreach ( $peptide_ids as $post_id ) {
$result = $this->backfill_post( $post_id );
$counts[ $result ] += 1;
}
error_log( sprintf(
'[PR Core Migration 0004] Complete. Skipped: %d, PSA-copied: %d, PubChem-ok: %d, PubChem-skip: %d',
$counts['skipped'],
$counts['copied_psa'],
$counts['pubchem_ok'],
$counts['pubchem_skip']
) );
}
/**
* Backfill meta for a single peptide post.
*
* @param int $post_id WordPress post ID.
* @return string Result: skipped|copied_psa|pubchem_ok|pubchem_skip.
*/
private function backfill_post( int $post_id ): string {
$existing_formula = (string) get_post_meta( $post_id, self::META_FORMULA, true );
$existing_weight = (string) get_post_meta( $post_id, self::META_WEIGHT, true );
$existing_aliases = (string) get_post_meta( $post_id, self::META_ALIASES, true );
// Idempotency limitation: this guard is all-or-nothing. A peptide that legitimately
// has no aliases (e.g., a novel compound) will never satisfy all three conditions and
// the migration will re-run on every manual re-invoke. This is safe because
// write_meta_from_psa() / write_meta_from_pubchem() only overwrite with non-empty
// values, but callers should be aware the 'skipped' result requires all three keys set.
if ( '' !== $existing_formula && '' !== $existing_weight && '' !== $existing_aliases ) {
return 'skipped';
}
$psa_formula = (string) get_post_meta( $post_id, 'psa_molecular_formula', true );
if ( '' !== $psa_formula ) {
$psa_weight = (string) get_post_meta( $post_id, 'psa_molecular_weight', true );
$psa_aliases = (string) get_post_meta( $post_id, 'psa_aliases', true );
$this->write_meta_from_psa( $post_id, $psa_formula, $psa_weight, $psa_aliases );
return 'copied_psa';
}
// No PSA data — attempt PubChem REST lookup.
$psa_cid = (string) get_post_meta( $post_id, 'psa_pubchem_cid', true );
$post = get_post( $post_id );
$name = $post ? sanitize_text_field( $post->post_title ) : '';
$data = ( '' !== $psa_cid && ctype_digit( $psa_cid ) )
? $this->pubchem->fetch_by_cid( $psa_cid )
: null;
if ( null === $data && '' !== $name ) {
$data = $this->pubchem->fetch_by_name( $name );
}
if ( null === $data ) {
error_log( sprintf(
'[PR Core Migration 0004] Post ID %d (%s): PubChem returned no data — leaving meta empty.',
$post_id,
esc_html( $name )
) );
return 'pubchem_skip';
}
$formula = PR_Core_Schema_Sanitizers::sanitize_molecular_formula( $data['formula'] );
$weight = (float) $data['weight'];
$aliases_json = $this->synonyms_to_json( $data['synonyms'] ?? [] );
$this->write_pr_meta( $post_id, $formula, $weight, $aliases_json );
error_log( sprintf(
'[PR Core Migration 0004] Post ID %d (%s): PubChem-sourced formula=%s weight=%s',
$post_id,
esc_html( $name ),
$formula,
(string) $weight
) );
return 'pubchem_ok';
}
/**
* Write PR Core meta from PSA source values.
*
* Input is UNTRUSTED — sanitizes before writing.
*
* @param int $post_id WordPress post ID.
* @param string $formula Raw psa_molecular_formula value.
* @param string $weight Raw psa_molecular_weight value (may include " Da").
* @param string $aliases Raw psa_aliases value (comma-separated).
* @return void
*/
private function write_meta_from_psa( int $post_id, string $formula, string $weight, string $aliases ): void {
$clean_formula = PR_Core_Schema_Sanitizers::sanitize_molecular_formula( $formula );
$clean_weight = $this->parse_weight( $weight );
$clean_aliases = $this->parse_aliases_string( $aliases );
$this->write_pr_meta( $post_id, $clean_formula, $clean_weight, $clean_aliases );
}
/**
* Write the three PR Core meta keys for a post.
*
* Only writes a field when it has a usable value to avoid overwriting
* existing data with empty placeholders.
*
* @param int $post_id WordPress post ID.
* @param string $formula Sanitized formula (empty = skip).
* @param float $weight Parsed weight in g/mol (0 = skip).
* @param string $aliases JSON array string (empty array = skip).
* @return void
*/
private function write_pr_meta( int $post_id, string $formula, float $weight, string $aliases ): void {
if ( '' !== $formula ) {
update_post_meta( $post_id, self::META_FORMULA, $formula );
}
if ( $weight > 0.0 ) {
update_post_meta( $post_id, self::META_WEIGHT, (string) $weight );
} elseif ( '' !== $formula ) {
error_log( '[PR Core Migration 0004] Post ID ' . $post_id . ': weight parsed to 0 — check source.' );
}
$decoded = json_decode( $aliases, true );
if ( is_array( $decoded ) && count( $decoded ) > 0 ) {
update_post_meta( $post_id, self::META_ALIASES, $aliases );
}
}
/**
* Parse a molecular weight string to a float.
*
* Strips trailing " Da" suffix (case-insensitive) and whitespace.
* Returns 0.0 on parse failure.
*
* @param string $value Raw weight value (e.g., "1419.5 Da").
* @return float Parsed weight in g/mol, 0.0 on failure.
*/
public function parse_weight( string $value ): float {
$clean = trim( preg_replace( '/\s*[Dd][Aa]\s*$/', '', trim( $value ) ) ?? '' );
return (float) $clean;
}
/**
* Parse a comma-separated alias string into a sanitized JSON array string.
*
* Input is UNTRUSTED — each alias is sanitized individually.
*
* @param string $value Comma-separated aliases.
* @return string JSON array of sanitized strings, or '[]' if empty.
*/
public function parse_aliases_string( string $value ): string {
if ( '' === trim( $value ) ) {
return '[]';
}
$clean = [];
foreach ( explode( ',', $value ) as $part ) {
$alias = sanitize_text_field( trim( $part ) );
if ( '' !== $alias ) {
$clean[] = $alias;
}
}
return wp_json_encode( $clean, JSON_UNESCAPED_UNICODE ) ?: '[]';
}
/**
* Convert a raw synonyms array from PubChem to a JSON string.
*
* Sanitizes each synonym. Returns '[]' on empty input.
*
* @param string[] $synonyms Raw synonym strings from PubChem.
* @return string JSON array of sanitized strings.
*/
private function synonyms_to_json( array $synonyms ): string {
$clean = [];
foreach ( $synonyms as $syn ) {
$alias = sanitize_text_field( (string) $syn );
if ( '' !== $alias ) {
$clean[] = $alias;
}
}
return wp_json_encode( $clean, JSON_UNESCAPED_UNICODE ) ?: '[]';
}
/**
* Get IDs of all published peptide posts.
*
* @return int[]
*/
private function get_all_peptide_ids(): array {
$posts = get_posts( [
'post_type' => PR_Core_Peptide_CPT::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => -1,
'fields' => 'ids',
] );
return array_map( 'intval', is_array( $posts ) ? $posts : [] );
}
}

View File

@@ -29,6 +29,7 @@ class PR_Core_Migration_Runner {
'PR_Core_Migration_0001_Dosing_Rows',
'PR_Core_Migration_0002_Legal_Cells',
'PR_Core_Migration_0003_Candidate_Queue',
'PR_Core_Migration_0004_Backfill_Peptide_Meta',
];
/**

View File

@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
/**
* Lightweight PubChem REST client for the backfill migration.
*
* What: Fetches molecular formula, molecular weight, and synonyms from the
* PubChem REST API for a given compound CID or name. Used exclusively
* by migration 0004 for the 4 peptide posts that lack PSA-sourced data.
*
* Who calls it: PR_Core_Migration_0004_Backfill_Peptide_Meta::fetch_pubchem().
* Dependencies: WordPress wp_remote_get() when available; falls back to
* file_get_contents with stream context (WP-CLI context).
*
* All values from PubChem are returned unsanitized — callers must sanitize
* before writing to post_meta.
*
* @see migrations/class-pr-core-migration-0004-backfill-peptide-meta.php — Caller.
* @see ARCHITECTURE.md — §v0.6.0 schema sprint.
*/
class PR_Core_Pubchem_Client {
/** @var string PubChem REST base URL. */
private const BASE = 'https://pubchem.ncbi.nlm.nih.gov/rest/pug';
/** @var int HTTP timeout per request (seconds). */
private const TIMEOUT = 8;
/** @var int Maximum retry attempts with exponential backoff. */
private const MAX_RETRIES = 3;
/** @var int Maximum synonyms to return (keeps post-meta size reasonable). */
private const MAX_SYNONYMS = 10;
/**
* Fetch properties and synonyms by PubChem CID.
*
* @param string $cid Numeric PubChem CID string.
* @return array{formula: string, weight: float, synonyms: string[]}|null Null on failure.
*/
public function fetch_by_cid( string $cid ): ?array {
if ( '' === $cid || ! ctype_digit( $cid ) ) {
return null;
}
$props = $this->fetch_properties( 'cid', $cid );
if ( null === $props ) {
return null;
}
$props['synonyms'] = $this->fetch_synonyms( 'cid', $cid );
return $props;
}
/**
* Fetch properties and synonyms by compound name.
*
* @param string $name Compound name (e.g., peptide post title).
* @return array{formula: string, weight: float, synonyms: string[]}|null Null on failure.
*/
public function fetch_by_name( string $name ): ?array {
if ( '' === trim( $name ) ) {
return null;
}
$props = $this->fetch_properties( 'name', $name );
if ( null === $props ) {
return null;
}
// If a CID was returned in the property response, use it for synonyms (more reliable).
$cid = $props['cid'] ?? '';
$type = ( '' !== $cid ) ? 'cid' : 'name';
$ref = ( '' !== $cid ) ? $cid : $name;
$props['synonyms'] = $this->fetch_synonyms( $type, $ref );
unset( $props['cid'] );
return $props;
}
/**
* Fetch MolecularFormula and MolecularWeight from PubChem.
*
* @param string $lookup_type 'cid' or 'name'.
* @param string $identifier CID number or compound name.
* @return array{formula: string, weight: float, cid: string}|null
*/
private function fetch_properties( string $lookup_type, string $identifier ): ?array {
$url = self::BASE . '/compound/' . $lookup_type . '/' . rawurlencode( $identifier )
. '/property/MolecularFormula,MolecularWeight/JSON';
$body = $this->get_with_retry( $url );
if ( null === $body ) {
return null;
}
$data = json_decode( $body, true );
$props = $data['PropertyTable']['Properties'][0] ?? null;
if ( ! is_array( $props ) ) {
return null;
}
$formula = (string) ( $props['MolecularFormula'] ?? '' );
$weight = (float) ( $props['MolecularWeight'] ?? 0 );
$cid = (string) ( $props['CID'] ?? '' );
if ( '' === $formula || $weight <= 0.0 ) {
return null;
}
return [
'formula' => $formula,
'weight' => $weight,
'cid' => $cid,
];
}
/**
* Fetch synonyms for a compound, limited to MAX_SYNONYMS.
*
* Returns an empty array on failure — callers treat this gracefully.
*
* @param string $lookup_type 'cid' or 'name'.
* @param string $identifier CID or name value.
* @return string[] Raw synonym strings (unsanitized).
*/
private function fetch_synonyms( string $lookup_type, string $identifier ): array {
$url = self::BASE . '/compound/' . $lookup_type . '/' . rawurlencode( $identifier ) . '/synonyms/JSON';
$body = $this->get_with_retry( $url );
if ( null === $body ) {
return [];
}
$data = json_decode( $body, true );
$synonyms = $data['InformationList']['Information'][0]['Synonym'] ?? [];
if ( ! is_array( $synonyms ) ) {
return [];
}
return array_slice( $synonyms, 0, self::MAX_SYNONYMS );
}
/**
* GET request with exponential-backoff retry (up to MAX_RETRIES).
*
* @param string $url URL to fetch.
* @return string|null Response body on HTTP 200, null after all retries fail.
*/
private function get_with_retry( string $url ): ?string {
for ( $attempt = 0; $attempt < self::MAX_RETRIES; $attempt++ ) {
if ( $attempt > 0 ) {
sleep( (int) pow( 2, $attempt - 1 ) ); // 1s, 2s.
}
$body = $this->get_once( $url );
if ( null !== $body ) {
return $body;
}
}
error_log( '[PR Core PubChem] Request failed after ' . self::MAX_RETRIES . ' attempts: ' . $url );
return null;
}
/**
* Single HTTP GET attempt.
*
* Uses wp_remote_get() when available; falls back to file_get_contents
* for WP-CLI contexts where the HTTP API may not be bootstrapped.
*
* @param string $url URL to fetch.
* @return string|null Body on HTTP 200, null on error or non-200.
*/
private function get_once( string $url ): ?string {
if ( function_exists( 'wp_remote_get' ) ) {
$response = wp_remote_get( $url, [
'timeout' => self::TIMEOUT,
'headers' => [ 'Accept' => 'application/json' ],
] );
if ( is_wp_error( $response ) ) {
return null;
}
if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
return null;
}
$body = wp_remote_retrieve_body( $response );
return is_string( $body ) ? $body : null;
}
// WP-CLI / plain PHP fallback.
$context = stream_context_create( [
'http' => [
'timeout' => self::TIMEOUT,
'header' => "Accept: application/json\r\n",
'method' => 'GET',
],
] );
$body = @file_get_contents( $url, false, $context );
return ( false === $body ) ? null : $body;
}
}