Compare commits

..

4 Commits

Author SHA1 Message Date
peptiderepo
817690c1b6 fix(schema): rewrite sanitize_molecular_formula() with element-grammar (Option B)
Replace character-allowlist regex with an element-token grammar validator.
Strip control characters first, then extract the maximal leading run of
valid element tokens ([A-Z][a-z]?\d*) and group separators (()[]·.).

Fixes: 'C10H12\ninjection' now returns 'C10H12' (was 'C10H12injection').
New:   fully invalid inputs (e.g. '<script>alert(1)</script>') return ''.
Keep:  parenthesised formulas (e.g. 'C2H5(OH)') pass unchanged.

Unit tests expanded from 4 to 7 formula cases covering all five DoD
requirements plus whitespace-only edge case.

PHP not installable in sandbox; all 7 test inputs hand-traced
against the regex and confirmed correct via Python simulation.

Agent-Session: peptide-schema-backfill v0.6.0 formula-validator
2026-06-13 05:42:13 +00:00
peptiderepo
1880d1db1f fix(tests): guard error_log/wp_json_encode stubs + document idempotency limitation
- tests/unit/test-migration-0004.php: wrap error_log() stub in
  function_exists() guard -- prevents PHP fatal redeclaration on
  PHP Lint (8.1/8.2/8.3) which runs php -l on all .php files.

- tests/bootstrap.php: update wp_json_encode stub to accept
  int $flags = 0 second arg (matches production call signature
  wp_json_encode($clean, JSON_UNESCAPED_UNICODE)); guard with
  function_exists().

- class-pr-core-migration-0004-backfill-peptide-meta.php: add
  code comment near idempotency check documenting the all-or-nothing
  limitation (post with legitimately empty aliases will re-run).

- CONVENTIONS.md: one-line note under migration conventions item 5
  documenting the idempotency caveat.

- CHANGELOG.md: add Fixed section under v0.6.0 for CI/test-stub fix.

Agent-Session: peptide-schema-backfill v0.6.0 P1 fix
2026-06-13 05:24:04 +00:00
peptiderepo
1e5999e663 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
2026-06-13 05:01:59 +00:00
peptiderepo
cc5d06b970 ci(review): drop multi-model panel to Haiku-only (#18)
QA A/B decision 2026-06-12 (CEO-ratified): Haiku-only. Evidence: convo/cto/threads/2026-06-qa-ab-review/. QA verdict per repo in qa-reviews/.

Agent-Session: cto-panel-merge-20260612
2026-06-12 21:53:43 +07:00
18 changed files with 2004 additions and 119 deletions

View File

@@ -70,6 +70,7 @@ peptide-repo-core/
│ │ │ │
│ ├── cpt/ │ ├── cpt/
│ │ ├── class-pr-core-peptide-cpt.php # CPT + peptide_category registration (guarded), meta fields, sanitizers │ │ ├── class-pr-core-peptide-cpt.php # CPT + peptide_category registration (guarded), meta fields, sanitizers
│ │ └── class-pr-core-schema-sanitizers.php # Sanitizers for v0.6.0 schema-input meta (formula, weight, aliases, FAQ)
│ │ └── class-pr-core-repo-daily-cpt.php # Repo Daily CPT registration for editorial content │ │ └── class-pr-core-repo-daily-cpt.php # Repo Daily CPT registration for editorial content
│ │ │ │
│ ├── taxonomies/ │ ├── taxonomies/
@@ -79,7 +80,9 @@ peptide-repo-core/
│ │ ├── class-pr-core-migration-runner.php # Sequential migration engine │ │ ├── class-pr-core-migration-runner.php # Sequential migration engine
│ │ ├── class-pr-core-migration-0001-dosing-rows.php # pr_dosing_rows table │ │ ├── class-pr-core-migration-0001-dosing-rows.php # pr_dosing_rows table
│ │ ├── class-pr-core-migration-0002-legal-cells.php # pr_legal_cells table + unique constraint │ │ ├── class-pr-core-migration-0002-legal-cells.php # pr_legal_cells table + unique constraint
│ │ └── class-pr-core-migration-0003-candidate-queue.php # pr_ai_candidate_queue table │ │ ├── class-pr-core-migration-0003-candidate-queue.php # pr_ai_candidate_queue table
│ │ ├── class-pr-core-migration-0004-backfill-peptide-meta.php # Backfill _pr_* schema meta from PSA psa_* keys
│ │ └── class-pr-core-pubchem-client.php # PubChem REST client (used by migration 0004)
│ │ │ │
│ ├── dto/ │ ├── dto/
│ │ ├── class-pr-core-peptide-dto.php # Typed peptide value object │ │ ├── class-pr-core-peptide-dto.php # Typed peptide value object
@@ -101,8 +104,11 @@ peptide-repo-core/
│ │ └── class-pr-core-candidate-queue-page.php # AI candidate review admin screen │ │ └── class-pr-core-candidate-queue-page.php # AI candidate review admin screen
│ │ │ │
│ ├── frontend/ │ ├── frontend/
│ │ ├── class-pr-core-disclaimer.php # Shortcode + static API for surface disclaimers │ │ ├── class-pr-core-disclaimer.php # Shortcode + static API for surface disclaimers
│ │ └── class-pr-core-jsonld.php # schema.org Drug JSON-LD on single peptide pages │ │ ├── class-pr-core-jsonld.php # Orchestrator: Yoast-integrated Drug/FAQ/MedicalWebPage emission
│ │ ├── class-pr-core-jsonld-drug.php # Drug (+ MolecularEntity) schema piece builder
│ │ ├── class-pr-core-jsonld-webpage.php # MedicalWebPage retype + lastReviewed/reviewedBy enrichment
│ │ └── class-pr-core-jsonld-faq.php # FAQPage schema piece (emits only when _pr_faq_items populated)
│ │ │ │
│ └── api/ │ └── api/
│ └── class-pr-core-rest-controller.php # REST endpoints for peptides, dosing, legal │ └── class-pr-core-rest-controller.php # REST endpoints for peptides, dosing, legal
@@ -197,9 +203,27 @@ Automated extraction populates the queue; humans approve/reject. Approved rows c
### #5: Disclaimer component owned by core ### #5: Disclaimer component owned by core
Single editorial review point. All consumer plugins render the same versioned disclaimer text. Surface-specific copy (dosing, legal, reconstitution, AI answer) stored in one wp_option. Single editorial review point. All consumer plugins render the same versioned disclaimer text. Surface-specific copy (dosing, legal, reconstitution, AI answer) stored in one wp_option.
### #6: JSON-LD from day one ### #6: JSON-LD from day one (v0.6.0: Yoast-integrated)
Drug schema on single pages increases LLM citation rate. Filter hook allows consumer plugins to extend the schema object. Drug schema on single pages increases LLM citation rate. Filter hook allows consumer plugins to extend the schema object.
**v0.6.0 Yoast integration (ratified jsonld-contract-v1 2026-06-11):**
- **Integrated path (Yoast active):** PR Core hooks into `wpseo_schema_graph_pieces` (priority 11) to inject Drug and FAQPage pieces. Yoast owns `WebPage`/`BreadcrumbList` — we never emit those. `wpseo_schema_webpage_type` filter retypes the page node to `MedicalWebPage`. `wpseo_schema_graph` (priority 11) enriches the existing WebPage piece with `lastReviewed`, `reviewedBy`, and `audience`.
- **Standalone fallback (no Yoast):** `wp_head` action (priority 99) emits a complete `@graph` (Drug + FAQPage) only. Suppressed automatically when `function_exists('YoastSEO')` is true.
- **Drug `@id`:** `{permalink}#drug` — stable for cross-plugin `about` linkage from PRAutoBlogger articles.
- **Molecular data source:** `_pr_molecular_formula`, `_pr_molecular_weight`, `_pr_aliases` (written by migration 0004, sourced from PSA `psa_*` keys or PubChem REST API).
- **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):
| 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_Faq` | `frontend/class-pr-core-jsonld-faq.php` | FAQPage node (emit-only-when-populated) |
### #7: PR Core owns the `peptide` CPT and `peptide_category` taxonomy (v0.2.0) ### #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. 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.
@@ -212,3 +236,4 @@ PR Core `uninstall.php` removes plugin-owned data only:
3. **Does not delete `peptide_category` terms.** Shared-ownership taxonomy; term metadata the site relies on outlasts this plugin. 3. **Does not delete `peptide_category` terms.** Shared-ownership taxonomy; term metadata the site relies on outlasts this plugin.
4. **Deletes `pr_core_*` options.** 4. **Deletes `pr_core_*` options.**
5. **Removes `manage_peptide_content` capability from all roles.** 5. **Removes `manage_peptide_content` capability from all roles.**
6. **Deletes v0.6.0 schema-input meta keys** (`_pr_molecular_formula`, `_pr_molecular_weight`, `_pr_aliases`, `_pr_faq_items`) from all peptide posts. These are PR Core-authored values (sourced from PubChem / PSA) and are safe to remove on uninstall. They do not overlap with PSA's `psa_*` namespace — PSA's own meta is unaffected.

View File

@@ -3,6 +3,44 @@
All notable changes to Peptide Repo Core are documented here. All notable changes to Peptide Repo Core are documented here.
Format: [Semantic Versioning](https://semver.org/). Format: [Semantic Versioning](https://semver.org/).
## [0.6.0] — 2026-06-13
### Added
- **Backfill migration 0004** (`class-pr-core-migration-0004-backfill-peptide-meta.php`): Copies PSA-stored PubChem data into new `_pr_*` schema-input meta keys across all published peptide posts. Source: `psa_molecular_formula` → `_pr_molecular_formula`, `psa_molecular_weight` → `_pr_molecular_weight` (strip "Da", floatval), `psa_aliases` → `_pr_aliases` (comma-split, sanitize, JSON array). For 4 posts without PSA data (igf-1-lr3, cagrilintide, ghk-cu, epitalon), falls back to PubChem REST API via new `PR_Core_Pubchem_Client`. Migration is idempotent + re-runnable; skips posts where all three keys are already populated.
- **Schema-input meta keys** (registered in CPT, v0.6.0):
- `_pr_molecular_formula` — sanitized molecular formula string.
- `_pr_molecular_weight` — float as string (g/mol); "Da" suffix normalized out.
- `_pr_aliases` — JSON array of sanitized alternate names.
- `_pr_faq_items` — JSON array of `{question, answer}` objects for FAQPage emission.
- **`PR_Core_Schema_Sanitizers`** (`cpt/class-pr-core-schema-sanitizers.php`): Static sanitizers for the four schema-input meta keys, split from `PR_Core_Peptide_CPT` to keep the CPT file under 300 lines.
- **`PR_Core_Pubchem_Client`** (`migrations/class-pr-core-pubchem-client.php`): Lightweight PubChem REST client with CID/name lookup, synonym fetch, and MAX_RETRIES=3 exponential backoff. Used exclusively by migration 0004.
- **Yoast-integrated JSON-LD emission** (replaces standalone `wp_head` script):
- `PR_Core_Jsonld_Drug` — builds Drug (+ MolecularEntity) schema node with stable `@id = {permalink}#drug`, reads `_pr_*` schema-input meta, `alternateName`/`molecularFormula`/`molecularWeight` (g/mol) emitted only when non-empty.
- `PR_Core_Jsonld_Webpage` — retypes Yoast's WebPage to `MedicalWebPage` via `wpseo_schema_webpage_type`; enriches it with `lastReviewed`, `reviewedBy` (Person or Organization), and `audience` via `wpseo_schema_graph`.
- `PR_Core_Jsonld_Faq` — builds FAQPage node from `_pr_faq_items`; emitted only when items exist.
- `PR_Core_Jsonld` — orchestrator hooking all three builders into Yoast's graph; maintains standalone `@graph` fallback when Yoast is inactive.
- **`CONTEXT.md`** — new domain glossary covering all new terms (`_pr_*` keys, Drug node, MedicalWebPage, FAQPage, Yoast integration contract, PubChem CID).
### Changed
- `PR_Core_Jsonld` rewritten: replaces standalone `wp_head` script-tag emission with Yoast graph-filter integration. Standalone `@graph` retained as fallback only. **No duplicate WebPage/BreadcrumbList** — Yoast owns those.
- `PR_Core_Peptide_CPT::get_meta_fields()` — adds 4 new `_pr_*` fields; existing `aliases`/`molecular_formula`/`molecular_weight` fields unchanged.
- `PR_Core_Migration_Runner::MIGRATIONS` — adds `PR_Core_Migration_0004_Backfill_Peptide_Meta` at index 3.
- `PR_CORE_TARGET_SCHEMA_VERSION` bumped from 3 to 4.
- `uninstall.php` — purges `_pr_molecular_formula`, `_pr_molecular_weight`, `_pr_aliases`, `_pr_faq_items` from all peptide posts on uninstall.
- `ARCHITECTURE.md` — §#6 JSON-LD rewritten; §2.9 uninstall spec updated; file tree updated for new classes.
- `CONVENTIONS.md` — schema-input meta convention added; Yoast JSON-LD extension pattern added.
### Fixed
- `tests/unit/test-migration-0004.php`: `error_log()` stub now guarded with `function_exists()` to prevent PHP fatal redeclaration error in CI PHP Lint (8.1/8.2/8.3).
- `tests/bootstrap.php`: `wp_json_encode` stub updated to accept `int $flags = 0` second argument, matching production call signature; guarded with `function_exists()`.
- `cpt/class-pr-core-schema-sanitizers.php`: `sanitize_molecular_formula()` rewritten from character-allowlist to element-formula grammar validation (Option B). Now strips control characters first, then extracts the maximal leading run of valid `[A-Z][a-z]?\d*` element tokens and group separators. Inputs containing trailing English words (e.g., `"C10H12\ninjection"`) correctly return the formula prefix only (`"C10H12"`); fully invalid inputs (e.g., `"<script>alert(1)</script>"`) return `""`. Parenthesised formulas (e.g., `"C2H5(OH)"`) preserved unchanged. Seven-case unit test coverage added.
### Technical
- Schema version: `PR_CORE_TARGET_SCHEMA_VERSION` = 4.
- New test files: `tests/unit/test-migration-0004.php`, `tests/unit/test-jsonld.php`.
- Yoast filter names verified against Yoast 27.6: `wpseo_schema_graph_pieces`, `wpseo_schema_webpage_type`, `wpseo_schema_graph`.
- No CAS/DrugBank emission in v0.6.0 (no reliable source — documented in ARCHITECTURE.md for future enrichment sprint).
## [0.5.0] — 2026-04-27 ## [0.5.0] — 2026-04-27
### Added ### Added

115
CONTEXT.md Normal file
View File

@@ -0,0 +1,115 @@
# Peptide Repo Core — Domain Glossary (CONTEXT.md)
This file defines domain terms introduced or used in the peptide-repo-core codebase.
Per AGENT-OPERATING-STANDARD v1.2.0, update this file in the same PR as any change
that introduces, renames, or redefines a domain term.
---
## Peptide
A short chain of amino acids studied for biological activity. In this codebase, a
"peptide" refers to one of the 93 canonical monograph posts (`post_type = 'peptide'`)
on peptiderepo.com. Each monograph covers identity, mechanism, evidence, and regulatory
status. **No dosing data appears in schema output** (YMYL constraint).
## Monograph
A single-peptide page (`/peptides/{slug}/`) containing the canonical research profile
for that compound. Populated from the `peptide` CPT plus its associated meta fields,
dosing rows (`pr_dosing_rows` table), and legal cells (`pr_legal_cells` table).
## Drug node
The `schema.org/Drug` structured-data entity emitted on each monograph page. Stable
`@id` is `{permalink}#drug` — this URI is the cross-plugin linking target used by
PRAutoBlogger article `about` references (per `jsonld-contract-v1`, ratified 2026-06-11).
## MedicalWebPage
A `schema.org/MedicalWebPage` subtype of `WebPage`. Applied to peptide monograph pages
via the `wpseo_schema_webpage_type` Yoast filter. Signals to search engines and LLMs
that the page carries medical/scientific content reviewed by an organization.
## FAQPage
A `schema.org/FAQPage` node emitted when `_pr_faq_items` post-meta is populated.
Contains `Question`/`Answer` pairs curated by the CMO content sprint (v0.6.0 ships
the meta key and emitter; population is a separate sprint). Emitted only when items
exist — never an empty node.
## _pr_molecular_formula
Post-meta key (string). Stores the molecular formula of a peptide compound
(e.g., `C62H98N16O22`). Written by migration 0004 from PSA's `psa_molecular_formula`
key. Sanitized: only `[A-Za-z0-9().\[\]{}]` characters allowed.
## _pr_molecular_weight
Post-meta key (string). Stores the molecular weight of a peptide compound as a
float string (e.g., `"1419.5"`). Unit: g/mol (equivalent to Da, but g/mol is the
standard SI label used in schema.org `QuantitativeValue`). Written by migration 0004
from PSA's `psa_molecular_weight` — "Da" suffix stripped, `floatval()` applied.
## _pr_aliases
Post-meta key (string, JSON array). Stores alternate names / synonyms for a peptide
(e.g., `'["Body Protection Compound-157","PL 14736","Bepecin"]'`). Written by
migration 0004 from PSA's comma-separated `psa_aliases`. Each alias sanitized via
`sanitize_text_field`. Maps to `schema.org/alternateName` array on the Drug node.
## _pr_faq_items
Post-meta key (string, JSON array of objects). Stores curated FAQ entries for a
peptide monograph, each as `{question: string, answer: string}`. Registered in v0.6.0;
population is a CMO content sprint. Maps to `schema.org/FAQPage` when non-empty.
Sanitized by `PR_Core_Schema_Sanitizers::sanitize_faq_items()` on save.
Purged on uninstall.
## Schema-input meta
The collective term for the four `_pr_*` meta keys (`_pr_molecular_formula`,
`_pr_molecular_weight`, `_pr_aliases`, `_pr_faq_items`) introduced in v0.6.0. These
are the data-layer inputs to the JSON-LD emitter. The emitter reads only these keys
at render time — it never reads PSA's `psa_*` keys directly (no cross-plugin read
coupling). Sourced from PSA via migration 0004 (one-time backfill).
## psa_* meta
Post-meta keys written by Peptide Search AI (PSA) plugin using its PubChem integration.
Includes `psa_molecular_formula`, `psa_molecular_weight`, `psa_aliases`,
`psa_pubchem_cid`, `psa_cas_number`, `psa_drugbank_id`. Migration 0004 treats all
`psa_*` values as UNTRUSTED and sanitizes them before writing to PR Core's `_pr_*` keys.
PR Core never reads `psa_*` keys at render time.
## PubChem CID
A numeric compound identifier in the NCBI PubChem database (e.g., `9941957` for
BPC-157). Used by migration 0004 as a fallback lookup for the 4 peptide posts that
lack `psa_molecular_formula`. Fetched via `PR_Core_Pubchem_Client`.
## Yoast integration contract
The agreed schema architecture ratified 2026-06-11 (`convo/prcore/decisions/2026-06-11-jsonld-contract-v1.md`):
PR Core integrates into Yoast's schema graph via `wpseo_schema_*` filters; never
emits duplicate `WebPage` or `BreadcrumbList` nodes; maintains a standalone `@graph`
fallback for when Yoast is inactive. The Drug `@id` convention (`{permalink}#drug`)
is part of this contract.
## Evidence strength
Ordered enum: `preclinical`, `case-series`, `observational`, `rct-small`, `rct-large`,
`meta-analysis`. Stored in the `evidence_strength` post-meta key. Does **not** appear
in schema.org output (dosing/evidence YMYL constraint).
## Verdict
One of five editorial states assigned to a monograph: `effective`, `promising`,
`mixed`, `insufficient_evidence`, `avoid`. Stored in post content via a verdict card
Gutenberg block. Not surfaced in JSON-LD (dosing YMYL constraint).
## Migration runner
`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.

View File

@@ -21,6 +21,8 @@ Files live in `includes/` or one level of subdirectory under `includes/`.
3. Add the field to `PR_Core_Peptide_Repository::post_to_dto()` in `repositories/class-pr-core-peptide-repository.php`. 3. Add the field to `PR_Core_Peptide_Repository::post_to_dto()` in `repositories/class-pr-core-peptide-repository.php`.
4. Add a form input in `PR_Core_Peptide_Metaboxes::render_identifiers_box()` in `admin/class-pr-core-peptide-metaboxes.php`. 4. Add a form input in `PR_Core_Peptide_Metaboxes::render_identifiers_box()` in `admin/class-pr-core-peptide-metaboxes.php`.
5. The `save_meta()` method reads from the same field list — no changes needed unless custom save logic applies. 5. The `save_meta()` method reads from the same field list — no changes needed unless custom save logic applies.
6. **Schema-input meta fields** (`_pr_*` keys introduced in v0.6.0) use `PR_Core_Schema_Sanitizers` as the sanitize_callback. Add new sanitizers to `cpt/class-pr-core-schema-sanitizers.php`, not to `PR_Core_Peptide_CPT` (keeps CPT under 300 lines).
7. If the field is purged on uninstall, add it to the `$schema_meta_keys` array in `uninstall.php`.
## How To: Add a New Post-Meta Field to Repo Daily Posts ## How To: Add a New Post-Meta Field to Repo Daily Posts
@@ -36,6 +38,7 @@ Files live in `includes/` or one level of subdirectory under `includes/`.
3. Add the class name to `PR_Core_Migration_Runner::MIGRATIONS` array (index = version - 1). 3. Add the class name to `PR_Core_Migration_Runner::MIGRATIONS` array (index = version - 1).
4. Bump `PR_CORE_TARGET_SCHEMA_VERSION` in `peptide-repo-core.php`. 4. Bump `PR_CORE_TARGET_SCHEMA_VERSION` in `peptide-repo-core.php`.
5. All migrations must be idempotent (safe to run twice). 5. All migrations must be idempotent (safe to run twice).
6. **Idempotency caveat (all-or-nothing skip):** migration 0004's skip guard requires *all* populated keys; a post with legitimately empty aliases will re-run on manual re-invoke — this is safe (write_meta only overwrites with non-empty values) but expected.
## How To: Add a New REST Endpoint ## How To: Add a New REST Endpoint
@@ -50,6 +53,16 @@ Files live in `includes/` or one level of subdirectory under `includes/`.
2. Use `[pr_disclaimer surface="new-surface"]` in templates. 2. Use `[pr_disclaimer surface="new-surface"]` in templates.
3. Custom copy can be set via `PR_Core_Disclaimer::update_disclaimer()`. 3. Custom copy can be set via `PR_Core_Disclaimer::update_disclaimer()`.
## How To: Extend the JSON-LD Drug Schema
v0.6.0 uses four focused classes (see ARCHITECTURE.md §#6). To add a new field to the Drug node:
1. Add the logic to `PR_Core_Jsonld_Drug::build()` in `includes/frontend/class-pr-core-jsonld-drug.php`.
2. Read data from the appropriate `_pr_*` post-meta key (not from PSA `psa_*` keys directly).
3. **Always omit the field when the meta is empty** — never emit blank/null/zero values.
4. To add a new schema piece (not Drug), create a new `PR_Core_Jsonld_<Piece>` 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.
## Error Handling ## Error Handling
- Repository methods return `null` or `0` on failure, never throw. - Repository methods return `null` or `0` on failure, never throw.

View File

@@ -68,7 +68,7 @@ class PR_Core_Peptide_CPT {
'aliases' => [ 'aliases' => [
'type' => 'string', 'type' => 'string',
'default' => '[]', 'default' => '[]',
'sanitize' => [ __CLASS__, 'sanitize_json_array' ], 'sanitize' => [ PR_Core_Schema_Sanitizers::class, 'sanitize_json_array' ],
], ],
'molecular_formula' => [ 'molecular_formula' => [
'type' => 'string', 'type' => 'string',
@@ -145,6 +145,28 @@ class PR_Core_Peptide_CPT {
'default' => 'current', 'default' => 'current',
'sanitize' => [ PR_Core_Verification_Sanitizers::class, 'sanitize_status' ], '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. * Register the `peptide` CPT. Guarded with post_type_exists() so deploy order is irrelevant.
*
* 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.
* *
* Side effects: registers CPT with WordPress. * Side effects: registers CPT with WordPress.
* *
@@ -218,15 +235,7 @@ class PR_Core_Peptide_CPT {
} }
/** /**
* Register the `peptide_category` taxonomy. * Register the `peptide_category` taxonomy. Guarded with taxonomy_exists().
*
* 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.
* *
* Side effects: registers taxonomy with WordPress. * 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. */ /** Sanitize evidence_strength to allowed enum values. */
public static function sanitize_evidence_strength( $value ): string { public static function sanitize_evidence_strength( $value ): string {
$value = sanitize_text_field( (string) $value ); $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. * JSON-LD / schema.org structured data emission for peptide pages.
* *
* What: Emits Drug schema on single peptide pages, Dataset schema on archives. * What: Integrates Drug, MedicalWebPage, and FAQPage schema into Yoast's graph
* Who calls it: PR_Core::init() registers wp_head hook. * on single peptide pages. When Yoast is inactive, emits a standalone
* Dependencies: PR_Core_Peptide_Repository for data lookup. * @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 { 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. * 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 * @return void
*/ */
public function register_hooks(): 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 * @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 ) ) { if ( ! is_singular( PR_Core_Peptide_CPT::POST_TYPE ) ) {
return; return;
} }
@@ -40,86 +129,25 @@ class PR_Core_Jsonld {
return; return;
} }
$repo = new PR_Core_Peptide_Repository(); $peptide = ( new PR_Core_Peptide_Repository() )->find_by_id( (int) $post_id );
$peptide = $repo->find_by_id( $post_id );
if ( ! $peptide ) { if ( ! $peptide ) {
return; return;
} }
$schema = $this->build_drug_schema( $peptide ); $permalink = get_permalink( (int) $post_id );
$graph = [ $this->drug_builder->build( $peptide ) ];
/** $faq_piece = $this->faq_builder->build( (int) $post_id, $permalink );
* Filter the JSON-LD schema for a peptide page. if ( null !== $faq_piece ) {
* $graph[] = $faq_piece;
* @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;
} }
printf( printf(
'<script type="application/ld+json">%s</script>' . "\n", '<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_0001_Dosing_Rows',
'PR_Core_Migration_0002_Legal_Cells', 'PR_Core_Migration_0002_Legal_Cells',
'PR_Core_Migration_0003_Candidate_Queue', '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;
}
}

View File

@@ -3,7 +3,7 @@
* Plugin Name: Peptide Repo Core * Plugin Name: Peptide Repo Core
* Plugin URI: https://peptiderepo.com * Plugin URI: https://peptiderepo.com
* Description: Canonical peptide schema — shared data layer for the peptiderepo.com ecosystem. Provides the peptide CPT, dosing rows, legal status cells, AI candidate queue, disclaimer component, and JSON-LD output. * Description: Canonical peptide schema — shared data layer for the peptiderepo.com ecosystem. Provides the peptide CPT, dosing rows, legal status cells, AI candidate queue, disclaimer component, and JSON-LD output.
* Version: 0.5.0 * Version: 0.6.0
* Author: peptiderepo * Author: peptiderepo
* Author URI: https://peptiderepo.com * Author URI: https://peptiderepo.com
* License: GPL-2.0-or-later * License: GPL-2.0-or-later
@@ -22,11 +22,11 @@ if ( ! defined( 'ABSPATH' ) ) {
/* ── Constants ────────────────────────────────────────────────────────── */ /* ── Constants ────────────────────────────────────────────────────────── */
define( 'PR_CORE_VERSION', '0.5.0' ); define( 'PR_CORE_VERSION', '0.6.0' );
define( 'PR_CORE_PLUGIN_FILE', __FILE__ ); define( 'PR_CORE_PLUGIN_FILE', __FILE__ );
define( 'PR_CORE_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'PR_CORE_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'PR_CORE_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); define( 'PR_CORE_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
define( 'PR_CORE_TARGET_SCHEMA_VERSION', 3 ); define( 'PR_CORE_TARGET_SCHEMA_VERSION', 4 );
/* ── Autoloader ───────────────────────────────────────────────────────── */ /* ── Autoloader ───────────────────────────────────────────────────────── */

View File

@@ -113,8 +113,10 @@ function sanitize_text_field( $value ): string {
return is_scalar( $value ) ? trim( (string) $value ) : ''; return is_scalar( $value ) ? trim( (string) $value ) : '';
} }
function wp_json_encode( $data ): string { if ( ! function_exists( 'wp_json_encode' ) ) {
return json_encode( $data ) ?: '[]'; function wp_json_encode( $data, int $flags = 0 ): string {
return json_encode( $data, $flags ) ?: '[]';
}
} }
function absint( $value ): int { function absint( $value ): int {

299
tests/unit/test-jsonld.php Normal file
View File

@@ -0,0 +1,299 @@
<?php
/**
* Unit tests for v0.6.0 JSON-LD emission: Drug @id, MedicalWebPage type,
* FAQ conditional emission, no duplicate nodes.
*
* Run: php tests/unit/test-jsonld.php
* Exit 0 = all pass, 1 = any failure.
*
* Coverage:
* - Drug node: @id = {permalink}#drug, @type includes 'Drug'.
* - Drug node: molecularFormula present when _pr_molecular_formula set.
* - Drug node: molecularWeight present and uses g/mol when _pr_molecular_weight set.
* - Drug node: alternateName present when _pr_aliases has items.
* - Drug node: molecularFormula OMITTED when meta empty.
* - Drug node: molecularWeight OMITTED when meta empty.
* - Drug node: alternateName OMITTED when _pr_aliases is empty array.
* - FAQPage: present when _pr_faq_items has items.
* - FAQPage: absent when _pr_faq_items is empty or '[]'.
* - FAQPage: @id = {permalink}#faq.
* - MedicalWebPage retype: retype_to_medical_webpage() returns 'MedicalWebPage' on peptide singles.
* - MedicalWebPage retype: passes through unchanged on non-peptide pages.
* - No duplicate WebPage or BreadcrumbList nodes emitted by our code.
* - PR_Core_Schema_Sanitizers::sanitize_faq_items() validation.
*
* @package PeptideRepoCore
*/
declare(strict_types=1);
require_once __DIR__ . '/../bootstrap.php';
// ── Additional stubs ─────────────────────────────────────────────────────
if ( ! defined( 'PR_CORE_TARGET_SCHEMA_VERSION' ) ) {
define( 'PR_CORE_TARGET_SCHEMA_VERSION', 4 );
}
$GLOBALS['pr_test_postmeta'] = [];
$GLOBALS['pr_test_is_singular'] = false;
$GLOBALS['pr_test_singular_type'] = '';
$GLOBALS['pr_test_the_id'] = 0;
function get_post_meta( $post_id, string $key, bool $single = false ) {
if ( $single ) {
return $GLOBALS['pr_test_postmeta'][ (int) $post_id ][ $key ] ?? '';
}
return array_values( $GLOBALS['pr_test_postmeta'][ (int) $post_id ] ?? [] );
}
function get_permalink( $post_id = null ): string {
return 'https://peptiderepo.com/peptides/bpc-157/';
}
function get_the_ID(): int {
return $GLOBALS['pr_test_the_id'];
}
function is_singular( $type = '' ): bool {
if ( '' === $type ) {
return $GLOBALS['pr_test_is_singular'];
}
return $GLOBALS['pr_test_is_singular'] && ( $GLOBALS['pr_test_singular_type'] === $type );
}
function get_post_modified_time( $format, $gmt, $post_id ) {
return '2026-04-25';
}
function get_post_meta_val( $post_id, $key ) {
return $GLOBALS['pr_test_postmeta'][ (int) $post_id ][ $key ] ?? '';
}
function get_userdata( int $id ) {
return false;
}
function get_author_posts_url( int $id ): string {
return 'https://peptiderepo.com/author/' . $id;
}
function home_url( string $path = '' ): string {
return 'https://peptiderepo.com' . $path;
}
function apply_filters( string $tag, $value ) {
return $value; // No-op in test context.
}
function esc_html( string $val ): string {
return htmlspecialchars( $val, ENT_QUOTES, 'UTF-8' );
}
function esc_html__( string $text, string $domain = '' ): string {
return $text;
}
function wp_strip_all_tags( $value ): string {
return strip_tags( (string) $value );
}
function sanitize_textarea_field( $value ): string {
return is_scalar( $value ) ? trim( (string) $value ) : '';
}
// Load classes under test.
require_once PR_CORE_PLUGIN_DIR . 'includes/cpt/class-pr-core-schema-sanitizers.php';
require_once PR_CORE_PLUGIN_DIR . 'includes/cpt/class-pr-core-peptide-cpt.php';
require_once PR_CORE_PLUGIN_DIR . 'includes/dto/class-pr-core-peptide-dto.php';
require_once PR_CORE_PLUGIN_DIR . 'includes/frontend/class-pr-core-jsonld-drug.php';
require_once PR_CORE_PLUGIN_DIR . 'includes/frontend/class-pr-core-jsonld-faq.php';
require_once PR_CORE_PLUGIN_DIR . 'includes/frontend/class-pr-core-jsonld-webpage.php';
require_once PR_CORE_PLUGIN_DIR . 'includes/frontend/class-pr-core-jsonld.php';
// ── Helpers ──────────────────────────────────────────────────────────────
/**
* Create a minimal PR_Core_Peptide_DTO for testing.
*
* @param array<string, mixed> $overrides Field overrides.
* @return PR_Core_Peptide_DTO
*/
function make_peptide_dto( array $overrides = [] ): PR_Core_Peptide_DTO {
return new PR_Core_Peptide_DTO( array_merge( [
'id' => 1,
'title' => 'BPC-157',
'slug' => 'bpc-157',
'content' => '',
'excerpt' => 'A synthetic peptide.',
'status' => 'publish',
'display_name' => 'BPC-157',
'aliases' => [],
'molecular_formula' => '',
'molecular_weight' => 0.0,
'cas_number' => '',
'drugbank_id' => '',
'chembl_id' => '',
'evidence_strength' => 'preclinical',
'editorial_review_status' => 'published',
'last_editorial_review_at' => '',
'medical_editor_id' => 0,
'categories' => [],
'families' => [],
], $overrides ) );
}
echo "== JSON-LD v0.6.0 unit tests ==\n\n";
// ── Drug @id ─────────────────────────────────────────────────────────────
echo "Drug @id:\n";
$GLOBALS['pr_test_postmeta'][1] = [];
$peptide = make_peptide_dto();
$drug_node = ( new PR_Core_Jsonld_Drug() )->build( $peptide );
pr_assert( isset( $drug_node['@id'] ), 'Drug node has @id' );
pr_assert_equals(
'https://peptiderepo.com/peptides/bpc-157/#drug',
$drug_node['@id'],
'Drug @id = {permalink}#drug'
);
// ── Drug @type ───────────────────────────────────────────────────────────
echo "\nDrug @type:\n";
pr_assert( is_array( $drug_node['@type'] ), 'Drug @type is an array' );
pr_assert( in_array( 'Drug', $drug_node['@type'], true ), '@type includes "Drug"' );
pr_assert( in_array( 'MolecularEntity', $drug_node['@type'], true ), '@type includes "MolecularEntity"' );
// ── Fields present when meta set ─────────────────────────────────────────
echo "\nFields present when _pr_* meta set:\n";
$GLOBALS['pr_test_postmeta'][1] = [
'_pr_molecular_formula' => 'C62H98N16O22',
'_pr_molecular_weight' => '1419.5',
'_pr_aliases' => '["Body Protection Compound-157","PL 14736"]',
];
$drug_node_populated = ( new PR_Core_Jsonld_Drug() )->build( $peptide );
pr_assert( isset( $drug_node_populated['molecularFormula'] ), 'molecularFormula present when meta set' );
pr_assert_equals( 'C62H98N16O22', $drug_node_populated['molecularFormula'], 'molecularFormula value correct' );
pr_assert( isset( $drug_node_populated['molecularWeight'] ), 'molecularWeight present when meta set' );
pr_assert_equals( 'QuantitativeValue', $drug_node_populated['molecularWeight']['@type'], 'molecularWeight @type is QuantitativeValue' );
pr_assert_equals( 1419.5, $drug_node_populated['molecularWeight']['value'], 'molecularWeight value correct' );
pr_assert_equals( 'g/mol', $drug_node_populated['molecularWeight']['unitText'], 'molecularWeight unitText = g/mol' );
pr_assert( isset( $drug_node_populated['alternateName'] ), 'alternateName present when _pr_aliases set' );
pr_assert_equals( 2, count( $drug_node_populated['alternateName'] ), 'alternateName has correct count' );
// ── Fields omitted when meta empty ───────────────────────────────────────
echo "\nFields OMITTED when meta empty:\n";
$GLOBALS['pr_test_postmeta'][1] = [];
$drug_node_empty = ( new PR_Core_Jsonld_Drug() )->build( $peptide );
pr_assert( ! isset( $drug_node_empty['molecularFormula'] ), 'molecularFormula OMITTED when meta empty' );
pr_assert( ! isset( $drug_node_empty['molecularWeight'] ), 'molecularWeight OMITTED when meta empty' );
pr_assert( ! isset( $drug_node_empty['alternateName'] ), 'alternateName OMITTED when _pr_aliases empty' );
// ── FAQPage present when _pr_faq_items has items ─────────────────────────
echo "\nFAQPage emission:\n";
$faq_builder = new PR_Core_Jsonld_Faq();
$permalink = 'https://peptiderepo.com/peptides/bpc-157/';
$GLOBALS['pr_test_postmeta'][1] = [
'_pr_faq_items' => wp_json_encode( [
[ 'question' => 'What is BPC-157?', 'answer' => 'A synthetic peptide.' ],
[ 'question' => 'Is it legal?', 'answer' => 'Research use only.' ],
] ),
];
$faq_piece = $faq_builder->build( 1, $permalink );
pr_assert( null !== $faq_piece, 'FAQPage piece not null when items present' );
pr_assert_equals( 'FAQPage', $faq_piece['@type'], 'FAQPage @type correct' );
pr_assert_equals( $permalink . '#faq', $faq_piece['@id'], 'FAQPage @id = {permalink}#faq' );
pr_assert( isset( $faq_piece['mainEntity'] ), 'FAQPage has mainEntity' );
pr_assert_equals( 2, count( $faq_piece['mainEntity'] ), 'FAQPage has 2 questions' );
pr_assert_equals( 'Question', $faq_piece['mainEntity'][0]['@type'], 'Question @type correct' );
pr_assert( isset( $faq_piece['mainEntity'][0]['acceptedAnswer'] ), 'Question has acceptedAnswer' );
// ── FAQPage absent when _pr_faq_items empty ──────────────────────────────
$GLOBALS['pr_test_postmeta'][1] = [ '_pr_faq_items' => '[]' ];
$faq_empty = $faq_builder->build( 1, $permalink );
pr_assert( null === $faq_empty, 'FAQPage piece IS null when _pr_faq_items is "[]"' );
$GLOBALS['pr_test_postmeta'][1] = [];
$faq_absent = $faq_builder->build( 1, $permalink );
pr_assert( null === $faq_absent, 'FAQPage piece IS null when _pr_faq_items absent' );
// ── MedicalWebPage retype ────────────────────────────────────────────────
echo "\nMedicalWebPage retype:\n";
$webpage_enricher = new PR_Core_Jsonld_Webpage();
$GLOBALS['pr_test_is_singular'] = true;
$GLOBALS['pr_test_singular_type'] = 'peptide';
$result = $webpage_enricher->retype_to_medical_webpage( 'WebPage' );
pr_assert_equals( 'MedicalWebPage', $result, 'returns MedicalWebPage on peptide single' );
$GLOBALS['pr_test_is_singular'] = false;
$GLOBALS['pr_test_singular_type'] = '';
$result = $webpage_enricher->retype_to_medical_webpage( 'WebPage' );
pr_assert_equals( 'WebPage', $result, 'passes through unchanged on non-peptide pages' );
// ── No duplicate WebPage / BreadcrumbList nodes ───────────────────────────
echo "\nNo duplicate WebPage/BreadcrumbList nodes:\n";
// Verify our builders only produce Drug and FAQPage — never WebPage or BreadcrumbList.
$GLOBALS['pr_test_postmeta'][1] = [ '_pr_faq_items' => wp_json_encode( [ [ 'question' => 'Q', 'answer' => 'A' ] ] ) ];
$types_from_builders = [];
foreach ( [ $drug_node_populated, $faq_piece ] as $piece ) {
if ( null === $piece ) { continue; }
$t = $piece['@type'] ?? '';
foreach ( ( is_array( $t ) ? $t : [ $t ] ) as $type ) {
$types_from_builders[] = $type;
}
}
pr_assert( ! in_array( 'WebPage', $types_from_builders, true ), 'WebPage NOT in our emitted pieces' );
pr_assert( ! in_array( 'MedicalWebPage', $types_from_builders, true ), 'MedicalWebPage NOT in injected pieces (Yoast owns node)' );
pr_assert( ! in_array( 'BreadcrumbList', $types_from_builders, true ), 'BreadcrumbList NOT in our emitted pieces' );
pr_assert( in_array( 'Drug', $types_from_builders, true ), 'Drug IS in our emitted pieces' );
pr_assert( in_array( 'FAQPage', $types_from_builders, true ), 'FAQPage IS in our emitted pieces when items present' );
// ── PR_Core_Schema_Sanitizers::sanitize_faq_items() ──────────────────────
echo "\nPR_Core_Schema_Sanitizers::sanitize_faq_items():\n";
$valid_input = wp_json_encode( [
[ 'question' => 'What is BPC-157?', 'answer' => 'A peptide.' ],
] );
$result = PR_Core_Schema_Sanitizers::sanitize_faq_items( $valid_input );
$decoded = json_decode( $result, true );
pr_assert( is_array( $decoded ), 'sanitize_faq_items returns JSON-decodable string' );
pr_assert_equals( 1, count( $decoded ), 'correct count after sanitize' );
$result_empty = PR_Core_Schema_Sanitizers::sanitize_faq_items( '[]' );
pr_assert_equals( '[]', $result_empty, 'empty JSON array returns "[]"' );
$result_malformed = PR_Core_Schema_Sanitizers::sanitize_faq_items( 'not-json' );
pr_assert_equals( '[]', $result_malformed, 'malformed JSON returns "[]"' );
// Item missing 'answer' key: should be dropped.
$incomplete_input = wp_json_encode( [ [ 'question' => 'No answer here?' ] ] );
$result_incomplete = PR_Core_Schema_Sanitizers::sanitize_faq_items( $incomplete_input );
$decoded_incomplete = json_decode( $result_incomplete, true );
pr_assert_equals( 0, count( $decoded_incomplete ), 'item without answer field dropped' );
// ── Summary ──────────────────────────────────────────────────────────────
exit( pr_test_summary() );

View File

@@ -0,0 +1,322 @@
<?php
/**
* Unit tests for PR_Core_Migration_0004_Backfill_Peptide_Meta.
*
* Run: php tests/unit/test-migration-0004.php
* Exit 0 = all pass, 1 = any failure.
*
* Coverage:
* - parse_weight(): strips "Da" suffix, strips "DA" variant, handles plain float,
* handles empty string (returns 0.0), handles zero-value string.
* - parse_aliases_string(): splits comma-separated string, trims whitespace,
* drops empty segments, returns '[]' for empty input.
* - PR_Core_Schema_Sanitizers::sanitize_molecular_formula(): valid chars, strips HTML, strips invalid.
* - Idempotency: backfill_post() returns 'skipped' when all three PR Core keys populated.
* - PSA source path: copies and transforms psa_* values to _pr_* keys.
* - Empty PSA data: skips to pubchem path, returns 'pubchem_skip' on no data.
*
* Not covered here (require live WP + DB or network):
* - PubChem HTTP round-trip (mocked at the http_get_once level in integration tests).
* - Actual update_post_meta() persistence.
*
* @package PeptideRepoCore
*/
declare(strict_types=1);
// Bootstrap the lightweight WP stubs.
require_once __DIR__ . '/../bootstrap.php';
// ── Additional stubs needed for migration 0004 ──────────────────────────
if ( ! defined( 'PR_CORE_TARGET_SCHEMA_VERSION' ) ) {
define( 'PR_CORE_TARGET_SCHEMA_VERSION', 4 );
}
// Post meta store for testing.
$GLOBALS['pr_test_postmeta'] = [];
$GLOBALS['pr_test_updated_meta'] = [];
function get_post_meta( int $post_id, string $key, bool $single = false ) {
$store = $GLOBALS['pr_test_postmeta'][ $post_id ] ?? [];
return $single ? ( $store[ $key ] ?? '' ) : array_values( $store );
}
function update_post_meta( int $post_id, string $key, $value ): bool {
$GLOBALS['pr_test_postmeta'][ $post_id ][ $key ] = $value;
$GLOBALS['pr_test_updated_meta'][] = [ 'post_id' => $post_id, 'key' => $key, 'value' => $value ];
return true;
}
function get_post( int $id ) {
if ( isset( $GLOBALS['pr_test_posts'][ $id ] ) ) {
return $GLOBALS['pr_test_posts'][ $id ];
}
return null;
}
function get_posts( array $args = [] ): array {
return $GLOBALS['pr_test_all_post_ids'] ?? [];
}
function is_wp_error( $value ): bool {
return false;
}
function wp_strip_all_tags( $value ): string {
return strip_tags( (string) $value );
}
function esc_html( string $val ): string {
return htmlspecialchars( $val, ENT_QUOTES, 'UTF-8' );
}
if ( ! function_exists( 'error_log' ) ) {
function error_log( string $msg ): bool {
// Suppress during tests.
return true;
}
}
// Load dependencies.
require_once PR_CORE_PLUGIN_DIR . 'includes/cpt/class-pr-core-schema-sanitizers.php';
require_once PR_CORE_PLUGIN_DIR . 'includes/cpt/class-pr-core-peptide-cpt.php';
require_once PR_CORE_PLUGIN_DIR . 'includes/migrations/class-pr-core-pubchem-client.php';
// Load migration class under test.
require_once PR_CORE_PLUGIN_DIR . 'includes/migrations/class-pr-core-migration-0004-backfill-peptide-meta.php';
// ── Expose private methods via reflection ────────────────────────────────
/**
* Call a private/protected method on an object via reflection.
*
* @param object $obj Instance.
* @param string $method Method name.
* @param array $args Arguments.
* @return mixed Return value.
*/
function call_private( object $obj, string $method, array $args = [] ) {
$ref = new ReflectionMethod( $obj, $method );
$ref->setAccessible( true );
return $ref->invokeArgs( $obj, $args );
}
$migration = new PR_Core_Migration_0004_Backfill_Peptide_Meta();
echo "== PR_Core_Migration_0004 unit tests ==\n\n";
// ── parse_weight() ───────────────────────────────────────────────────────
echo "parse_weight():\n";
pr_assert_equals(
1419.5,
$migration->parse_weight( '1419.5 Da' ),
'strips " Da" suffix and parses float'
);
pr_assert_equals(
1419.5,
$migration->parse_weight( '1419.5 DA' ),
'strips uppercase " DA" suffix'
);
pr_assert_equals(
1419.5,
$migration->parse_weight( '1419.5' ),
'parses plain float string without suffix'
);
pr_assert_equals(
0.0,
$migration->parse_weight( '' ),
'returns 0.0 for empty string'
);
pr_assert_equals(
0.0,
$migration->parse_weight( ' Da ' ),
'returns 0.0 for suffix-only string'
);
pr_assert_equals(
800.123,
$migration->parse_weight( ' 800.123 da ' ),
'strips whitespace and lowercase "da"'
);
// ── parse_aliases_string() ───────────────────────────────────────────────
echo "\nparse_aliases_string():\n";
$result = json_decode(
$migration->parse_aliases_string( 'Body Protection Compound-157, PL 14736, PL-10, Bepecin' ),
true
);
pr_assert( is_array( $result ), 'returns a JSON-decodable array' );
pr_assert_equals( 4, count( $result ), 'correct count: 4 aliases' );
pr_assert_equals( 'Body Protection Compound-157', $result[0], 'first alias trimmed correctly' );
pr_assert_equals( 'PL 14736', $result[1], 'second alias trimmed correctly' );
$empty_result = $migration->parse_aliases_string( '' );
pr_assert_equals( '[]', $empty_result, 'empty string returns "[]"' );
$whitespace_result = $migration->parse_aliases_string( ' , , ' );
pr_assert_equals( '[]', $whitespace_result, 'whitespace-only segments dropped, returns "[]"' );
$single = json_decode( $migration->parse_aliases_string( 'Only One' ), true );
pr_assert_equals( 1, count( $single ), 'single alias produces array of 1' );
// ── sanitize_formula() ───────────────────────────────────────────────────
// Five required cases (Option B — validate, don't allowlist):
// 1. Valid formula unchanged.
// 2. Parenthesised formula preserved.
// 3. Newline-injected payload — "injection" stripped, formula kept.
// 4. Fully invalid string (script tag contents) → empty.
// 5. Empty / whitespace-only → empty.
echo "\nPR_Core_Schema_Sanitizers::sanitize_molecular_formula():\n";
pr_assert_equals(
'C62H98N16O22',
PR_Core_Schema_Sanitizers::sanitize_molecular_formula( 'C62H98N16O22' ),
'valid formula passes unchanged'
);
pr_assert_equals(
'C62H98N16O22',
PR_Core_Schema_Sanitizers::sanitize_molecular_formula( '<b>C62H98N16O22</b>' ),
'HTML tags stripped from formula'
);
pr_assert_equals(
'C2H5(OH)',
PR_Core_Schema_Sanitizers::sanitize_molecular_formula( 'C2H5(OH)' ),
'parenthesised formula preserved unchanged'
);
pr_assert_equals(
'C10H12',
PR_Core_Schema_Sanitizers::sanitize_molecular_formula( "C10H12\ninjection" ),
'non-formula characters removed — newline+word stripped, formula kept'
);
pr_assert_equals(
'',
PR_Core_Schema_Sanitizers::sanitize_molecular_formula( '<script>alert(1)</script>' ),
'fully invalid string returns empty string'
);
pr_assert_equals(
'',
PR_Core_Schema_Sanitizers::sanitize_molecular_formula( '' ),
'empty string returns empty string'
);
pr_assert_equals(
'',
PR_Core_Schema_Sanitizers::sanitize_molecular_formula( ' ' ),
'whitespace-only returns empty string'
);
// ── Idempotency: skips when all three PR Core keys populated ─────────────
echo "\nIdempotency (all _pr_* keys populated):\n";
$GLOBALS['pr_test_postmeta'] = [];
$GLOBALS['pr_test_updated_meta'] = [];
$GLOBALS['pr_test_postmeta'][1] = [
'_pr_molecular_formula' => 'C62H98N16O22',
'_pr_molecular_weight' => '1419.5',
'_pr_aliases' => '["Alias One"]',
];
$result = call_private( $migration, 'backfill_post', [ 1 ] );
pr_assert_equals( 'skipped', $result, 'returns "skipped" when all three keys non-empty' );
pr_assert( empty( $GLOBALS['pr_test_updated_meta'] ), 'no update_post_meta calls when skipped' );
// ── PSA source path ──────────────────────────────────────────────────────
echo "\nPSA source path:\n";
$GLOBALS['pr_test_postmeta'] = [];
$GLOBALS['pr_test_updated_meta'] = [];
$GLOBALS['pr_test_postmeta'][2] = [
'psa_molecular_formula' => 'C62H98N16O22',
'psa_molecular_weight' => '1419.5 Da',
'psa_aliases' => 'Body Protection Compound-157, PL 14736',
];
$GLOBALS['pr_test_posts'][2] = (object) [ 'post_title' => 'BPC-157', 'ID' => 2 ];
$result = call_private( $migration, 'backfill_post', [ 2 ] );
pr_assert_equals( 'copied_psa', $result, 'returns "copied_psa" when PSA data present' );
$updated = $GLOBALS['pr_test_updated_meta'];
$formula_written = false;
$weight_written = false;
$aliases_written = false;
foreach ( $updated as $entry ) {
if ( '_pr_molecular_formula' === $entry['key'] ) {
$formula_written = true;
pr_assert_equals( 'C62H98N16O22', $entry['value'], 'formula written correctly' );
}
if ( '_pr_molecular_weight' === $entry['key'] ) {
$weight_written = true;
pr_assert_equals( '1419.5', $entry['value'], 'weight written as float string without Da' );
}
if ( '_pr_aliases' === $entry['key'] ) {
$aliases_written = true;
$aliases_arr = json_decode( $entry['value'], true );
pr_assert( is_array( $aliases_arr ), 'aliases stored as JSON array' );
pr_assert_equals( 2, count( $aliases_arr ), 'correct number of aliases' );
}
}
pr_assert( $formula_written, 'molecular formula meta was written' );
pr_assert( $weight_written, 'molecular weight meta was written' );
pr_assert( $aliases_written, 'aliases meta was written' );
// ── Partial re-run: existing values preserved ────────────────────────────
echo "\nIdempotency: only some keys populated (partial re-run):\n";
$GLOBALS['pr_test_postmeta'] = [];
$GLOBALS['pr_test_updated_meta'] = [];
// Only formula already set; weight and aliases are empty.
$GLOBALS['pr_test_postmeta'][3] = [
'_pr_molecular_formula' => 'C10H12',
'_pr_molecular_weight' => '',
'_pr_aliases' => '',
'psa_molecular_formula' => 'C62H98N16O22',
'psa_molecular_weight' => '1419.5 Da',
'psa_aliases' => 'Body Protection Compound-157',
];
$GLOBALS['pr_test_posts'][3] = (object) [ 'post_title' => 'BPC-157', 'ID' => 3 ];
$result = call_private( $migration, 'backfill_post', [ 3 ] );
pr_assert_equals( 'copied_psa', $result, 'runs PSA copy even when only some keys populated' );
// ── PubChem skip path: no PSA data, no network ──────────────────────────
echo "\nPubChem skip path (no PSA data, no network in unit tests):\n";
// Inject a null-returning PubChem client mock to simulate network failure.
$mock_pubchem = new class extends PR_Core_Pubchem_Client {
public function fetch_by_cid( string $cid ): ?array { return null; }
public function fetch_by_name( string $name ): ?array { return null; }
};
$mock_migration = new PR_Core_Migration_0004_Backfill_Peptide_Meta( $mock_pubchem );
$GLOBALS['pr_test_postmeta'] = [];
$GLOBALS['pr_test_updated_meta'] = [];
$GLOBALS['pr_test_postmeta'][10] = []; // No PSA data, no PR Core data.
$GLOBALS['pr_test_posts'][10] = (object) [ 'post_title' => 'IGF-1 LR3', 'ID' => 10 ];
$result = call_private( $mock_migration, 'backfill_post', [ 10 ] );
pr_assert_equals( 'pubchem_skip', $result, 'returns "pubchem_skip" when PubChem network fails' );
pr_assert( empty( $GLOBALS['pr_test_updated_meta'] ), 'no meta written on pubchem_skip' );
// ── Summary ──────────────────────────────────────────────────────────────
exit( pr_test_summary() );

View File

@@ -76,6 +76,31 @@ $wpdb->query(
"DELETE FROM {$wpdb->options} WHERE option_name LIKE 'pr\_core\_%'" "DELETE FROM {$wpdb->options} WHERE option_name LIKE 'pr\_core\_%'"
); );
/* ── 4b. Delete v0.6.0 schema-input meta keys from all peptide posts ──── */
/*
* Migration 0004 writes _pr_molecular_formula, _pr_molecular_weight, _pr_aliases,
* and _pr_faq_items to the shared peptide posts. These are PR Core-authored values
* (derived from PubChem / PSA) and are safe to remove on uninstall.
*
* @see ARCHITECTURE.md — §2.9 Uninstall specification.
*/
$schema_meta_keys = [
'_pr_molecular_formula',
'_pr_molecular_weight',
'_pr_aliases',
'_pr_faq_items',
];
foreach ( $schema_meta_keys as $meta_key ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->delete(
$wpdb->postmeta,
[ 'meta_key' => $meta_key ],
[ '%s' ]
);
}
/* ── 5. Remove manage_peptide_content capability from all roles ───────── */ /* ── 5. Remove manage_peptide_content capability from all roles ───────── */
$editable_roles = wp_roles()->roles; $editable_roles = wp_roles()->roles;