feat(schema): v0.6.0 — PSA backfill migration + Yoast-integrated Drug/MedicalWebPage/FAQ schema

Piece 1 — Backfill migration 0004: copies PSA psa_molecular_formula/weight/aliases
into PR Core _pr_* meta. Idempotent + re-runnable. PubChem REST fallback for 4
posts missing PSA data (MAX_RETRIES=3, exp backoff). All PSA values UNTRUSTED.
PR_CORE_TARGET_SCHEMA_VERSION bumped 3 -> 4.

Piece 2 — Drug node enrichment + stable @id = {permalink}#drug (ratified contract).
Reads _pr_* schema-input meta. molecularWeight in g/mol. Empty-prop guard throughout.

Piece 3 — Yoast-integrated emission via wpseo_schema_graph_pieces (priority 11),
wpseo_schema_webpage_type (MedicalWebPage retype), wpseo_schema_graph (enrichment).
Standalone @graph fallback retained. No duplicate WebPage/BreadcrumbList.
FAQPage emitted only when _pr_faq_items populated.

Supporting: PR_Core_Schema_Sanitizers, PR_Core_Pubchem_Client, CONTEXT.md,
ARCHITECTURE.md, CONVENTIONS.md, CHANGELOG.md, uninstall.php purge.
Tests: test-migration-0004.php (20 assertions), test-jsonld.php (29 assertions).

Agent-Session: peptide-schema-backfill v0.6.0
This commit is contained in:
peptiderepo
2026-06-13 05:01:59 +00:00
parent cc5d06b970
commit 1e5999e663
17 changed files with 1919 additions and 117 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,108 @@
<?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 {
/**
* Sanitize a molecular formula string (for _pr_molecular_formula).
*
* Strips HTML tags and allows only characters valid in a chemical formula.
* Input is treated as UNTRUSTED.
*
* @param mixed $value Raw input value.
* @return string Sanitized formula string (e.g., "C62H98N16O22").
*/
public static function sanitize_molecular_formula( $value ): string {
$value = sanitize_text_field( wp_strip_all_tags( (string) $value ) );
return preg_replace( '/[^A-Za-z0-9().\[\]{}]/', '', $value ) ?? '';
}
/**
* 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 ) ?: '[]';
}
}