feat: PRAB article JSON-LD emitter + meta reader (v0.8.0) (#25)
prcore emits MedicalWebPage+Article JSON-LD for any post carrying _prab_* meta (ratified contract v1): citation[], about->Drug @ids, honest reviewedBy (Person only with a real human reviewer, else Organization), lastReviewed; integrates into Yoast's graph (no duplicate nodes), standalone @graph fallback; all _prab_* sanitized as untrusted. Unblocks the Phase-2b SEO stage.
QA: REJECT-P1 (DoD doc/test gaps) -> APPROVE @ dbfc50c (qa-reviews/peptide-repo-core/2026-06-23-dbfc50c.md). CEO greenlight 2026-06-22 (complete-all-tasks directive).
Agent-Session: prcore-jsonld-merge-20260623
This commit is contained in:
@@ -19,7 +19,7 @@ declare(strict_types=1);
|
||||
* What: Registers all hooks, runs migrations, boots subsystems.
|
||||
* Who calls it: peptide-repo-core.php on plugins_loaded.
|
||||
* Dependencies: PR_Core_Migration_Runner, PR_Core_Peptide_CPT, PR_Core_Topic_Taxonomy,
|
||||
* PR_Core_Admin, PR_Core_Disclaimer, PR_Core_Jsonld,
|
||||
* PR_Core_Admin, PR_Core_Disclaimer, PR_Core_Jsonld, PR_Core_Jsonld_Article,
|
||||
* PR_Core_Rest_Controller, PR_Core_Related_Posts_Section.
|
||||
*
|
||||
* @see peptide-repo-core.php — Bootstrap that instantiates this class.
|
||||
@@ -91,6 +91,9 @@ class PR_Core {
|
||||
$jsonld = new PR_Core_Jsonld();
|
||||
$jsonld->register_hooks();
|
||||
|
||||
$jsonld_article = new PR_Core_Jsonld_Article();
|
||||
$jsonld_article->register_hooks();
|
||||
|
||||
// Related Articles section (frontend).
|
||||
$related_posts = new PR_Core_Related_Posts_Section(
|
||||
new PR_Core_Internal_Posts_Provider()
|
||||
|
||||
243
includes/frontend/class-pr-core-jsonld-article.php
Normal file
243
includes/frontend/class-pr-core-jsonld-article.php
Normal file
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
/**
|
||||
* Jsonld Article.
|
||||
*
|
||||
* @package Peptide_Repo_Core
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Meta-triggered JSON-LD emitter for PRAutoBlogger articles.
|
||||
*
|
||||
* What: Enriches Yoast's graph for `post` posts carrying _prab_schema_version=1.
|
||||
* When triggered, retypes the page node to MedicalWebPage and adds
|
||||
* citation[], about[] (Drug @id references), lastReviewed, and honest
|
||||
* reviewedBy to Yoast's Article piece. Emits a standalone @graph block
|
||||
* only when Yoast is inactive. Posts without the trigger meta are
|
||||
* completely byte-unchanged.
|
||||
*
|
||||
* Ownership rule: prcore owns ALL JSON-LD; PRAutoBlogger writes post meta only.
|
||||
*
|
||||
* Yoast integration (mirrors the pattern in PR_Core_Jsonld):
|
||||
* - wpseo_schema_webpage_type → retype to MedicalWebPage (priority 10).
|
||||
* - wpseo_schema_graph → enrich Article + MedicalWebPage (priority 13).
|
||||
* - wpseo_schema_graph_pieces is intentionally NOT hooked (Yoast fatals on
|
||||
* plain arrays; see class-pr-core-jsonld.php class-level docblock).
|
||||
* - wp_head (priority 99) → standalone fallback when Yoast absent.
|
||||
*
|
||||
* Contract: convo/prcore/decisions/2026-06-11-jsonld-contract-v1.md
|
||||
*
|
||||
* Who calls it: PR_Core::init() after PR_Core_Jsonld::register_hooks().
|
||||
* Dependencies: PR_Core_Prab_Meta_Reader, Yoast SEO ≥ 27.6 for integrated path.
|
||||
*
|
||||
* @see frontend/class-pr-core-prab-meta-reader.php — Meta reader / sanitizer.
|
||||
* @see frontend/class-pr-core-jsonld.php — Peptide-page emitter (parallel).
|
||||
* @see ARCHITECTURE.md §2.7 — JSON-LD output.
|
||||
*
|
||||
* @package Peptide_Repo_Core
|
||||
*/
|
||||
class PR_Core_Jsonld_Article {
|
||||
|
||||
/**
|
||||
* Meta reader instance.
|
||||
*
|
||||
* @var PR_Core_Prab_Meta_Reader
|
||||
*/
|
||||
private PR_Core_Prab_Meta_Reader $reader;
|
||||
|
||||
/**
|
||||
* Construct the emitter with its meta reader.
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->reader = new PR_Core_Prab_Meta_Reader();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register Yoast and fallback hooks.
|
||||
*
|
||||
* Hooked by PR_Core::init(). Only registers hooks when on a frontend
|
||||
* page — safe to call unconditionally; WP handles the rest.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register_hooks(): void {
|
||||
add_filter( 'wpseo_schema_webpage_type', array( $this, 'retype_article_page' ), 10 );
|
||||
add_filter( 'wpseo_schema_graph', array( $this, 'enrich_article_graph' ), 13, 2 );
|
||||
add_action( 'wp_head', array( $this, 'emit_standalone_fallback' ), 99 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Retype the page node to MedicalWebPage for triggered PRAB articles.
|
||||
*
|
||||
* Hooked on: wpseo_schema_webpage_type (priority 10).
|
||||
* Passes through unchanged on non-triggered pages and non-`post` pages.
|
||||
*
|
||||
* @param string|array $type Existing Yoast page type (string or string[]).
|
||||
* @return string|array 'MedicalWebPage' when triggered, $type otherwise.
|
||||
*/
|
||||
public function retype_article_page( string|array $type ): string|array {
|
||||
if ( ! is_singular( 'post' ) ) {
|
||||
return $type;
|
||||
}
|
||||
$post_id = (int) get_the_ID();
|
||||
if ( $post_id <= 0 || ! $this->reader->is_triggered( $post_id ) ) {
|
||||
return $type;
|
||||
}
|
||||
return 'MedicalWebPage';
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich Yoast's assembled graph for triggered PRAB articles.
|
||||
*
|
||||
* Injects lastReviewed + reviewedBy into the MedicalWebPage node and adds
|
||||
* citation[] + about[] to the Article node. Priority 13 ensures this runs
|
||||
* after PR_Core_Jsonld's priority-12 Drug/FAQ injection.
|
||||
*
|
||||
* Never adds new WebPage or Article nodes — mutates Yoast's existing pieces.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $graph Assembled Yoast graph.
|
||||
* @param \Yoast\WP\SEO\Context\Meta_Tags_Context|null $context Yoast schema context.
|
||||
* @return array<int, array<string, mixed>> Enriched graph.
|
||||
*/
|
||||
public function enrich_article_graph( array $graph, $context ): array {
|
||||
if ( ! is_singular( 'post' ) ) {
|
||||
return $graph;
|
||||
}
|
||||
$post_id = (int) get_the_ID();
|
||||
if ( $post_id <= 0 || ! $this->reader->is_triggered( $post_id ) ) {
|
||||
return $graph;
|
||||
}
|
||||
|
||||
$review_mode = $this->reader->get_review_mode( $post_id );
|
||||
$reviewed_at = $this->reader->get_reviewed_at( $post_id );
|
||||
$reviewed_by = $this->reader->get_reviewed_by( $post_id, $review_mode );
|
||||
$citations = $this->reader->get_citations( $post_id );
|
||||
$about_stubs = $this->reader->get_about_peptides( $post_id );
|
||||
|
||||
foreach ( $graph as &$piece ) {
|
||||
if ( ! is_array( $piece ) ) {
|
||||
continue;
|
||||
}
|
||||
$types = is_array( $piece['@type'] ?? '' ) ? $piece['@type'] : array( $piece['@type'] ?? '' );
|
||||
|
||||
// Enrich MedicalWebPage with lastReviewed + reviewedBy.
|
||||
if ( in_array( 'WebPage', $types, true ) || in_array( 'MedicalWebPage', $types, true ) ) {
|
||||
if ( null !== $reviewed_at ) {
|
||||
$piece['lastReviewed'] = $reviewed_at;
|
||||
}
|
||||
$piece['reviewedBy'] = $reviewed_by;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Enrich Article with citation[] and about[].
|
||||
if ( in_array( 'Article', $types, true ) ) {
|
||||
if ( ! empty( $citations ) ) {
|
||||
$piece['citation'] = $this->build_citations( $citations );
|
||||
}
|
||||
if ( ! empty( $about_stubs ) ) {
|
||||
$piece['about'] = $about_stubs;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
unset( $piece );
|
||||
|
||||
return $graph;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a standalone @graph block via wp_head when Yoast is not active.
|
||||
*
|
||||
* Suppressed when Yoast is loaded. Emits Article + MedicalWebPage nodes
|
||||
* with Yoast-parity @id conventions so consumers see one shape regardless
|
||||
* of whether Yoast is present. Skips posts whose permalink cannot be resolved.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function emit_standalone_fallback(): void {
|
||||
if ( function_exists( 'YoastSEO' ) ) {
|
||||
return;
|
||||
}
|
||||
if ( ! is_singular( 'post' ) ) {
|
||||
return;
|
||||
}
|
||||
$post_id = (int) get_the_ID();
|
||||
if ( $post_id <= 0 || ! $this->reader->is_triggered( $post_id ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$permalink = (string) get_permalink( $post_id );
|
||||
if ( '' === $permalink ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$post = get_post( $post_id );
|
||||
$review_mode = $this->reader->get_review_mode( $post_id );
|
||||
$reviewed_at = $this->reader->get_reviewed_at( $post_id );
|
||||
$reviewed_by = $this->reader->get_reviewed_by( $post_id, $review_mode );
|
||||
$citations = $this->reader->get_citations( $post_id );
|
||||
$about_stubs = $this->reader->get_about_peptides( $post_id );
|
||||
|
||||
$webpage = array(
|
||||
'@type' => 'MedicalWebPage',
|
||||
'@id' => $permalink . '#webpage',
|
||||
'url' => $permalink,
|
||||
);
|
||||
if ( null !== $reviewed_at ) {
|
||||
$webpage['lastReviewed'] = $reviewed_at;
|
||||
}
|
||||
$webpage['reviewedBy'] = $reviewed_by;
|
||||
|
||||
$article = array(
|
||||
'@type' => 'Article',
|
||||
'@id' => $permalink . '#article',
|
||||
'isPartOf' => array( '@id' => $permalink . '#webpage' ),
|
||||
'headline' => $post instanceof WP_Post ? sanitize_text_field( $post->post_title ) : '',
|
||||
);
|
||||
if ( ! empty( $citations ) ) {
|
||||
$article['citation'] = $this->build_citations( $citations );
|
||||
}
|
||||
if ( ! empty( $about_stubs ) ) {
|
||||
$article['about'] = $about_stubs;
|
||||
}
|
||||
|
||||
$json = wp_json_encode(
|
||||
array(
|
||||
'@context' => 'https://schema.org',
|
||||
'@graph' => array( $webpage, $article ),
|
||||
),
|
||||
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
|
||||
);
|
||||
if ( false !== $json ) {
|
||||
echo '<script type="application/ld+json">' . $json . '</script>' . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_json_encode() already escapes for JSON context.
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private builders ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build schema.org citation nodes from validated citation records.
|
||||
*
|
||||
* Emits ScholarlyArticle when doi is present (DOI as sameAs URI),
|
||||
* CreativeWork otherwise. Per contract v1, quality_score is never emitted.
|
||||
*
|
||||
* @param array<int, array{url: string, title: string, doi: string|null}> $citations Validated.
|
||||
* @return array<int, array<string, mixed>> Schema citation nodes.
|
||||
*/
|
||||
private function build_citations( array $citations ): array {
|
||||
$nodes = array();
|
||||
foreach ( $citations as $c ) {
|
||||
$node = array(
|
||||
'@type' => null !== $c['doi'] ? 'ScholarlyArticle' : 'CreativeWork',
|
||||
'url' => $c['url'],
|
||||
'name' => $c['title'],
|
||||
);
|
||||
if ( null !== $c['doi'] ) {
|
||||
$node['sameAs'] = $c['doi'];
|
||||
}
|
||||
$nodes[] = $node;
|
||||
}
|
||||
return $nodes;
|
||||
}
|
||||
}
|
||||
299
includes/frontend/class-pr-core-prab-meta-reader.php
Normal file
299
includes/frontend/class-pr-core-prab-meta-reader.php
Normal file
@@ -0,0 +1,299 @@
|
||||
<?php
|
||||
/**
|
||||
* Prab Meta Reader.
|
||||
*
|
||||
* @package Peptide_Repo_Core
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Reads and validates _prab_* post-meta written by PRAutoBlogger.
|
||||
*
|
||||
* All meta treated as untrusted: URLs validated (http/https), DOIs regex-checked,
|
||||
* JSON arrays decoded per-element, user IDs resolved via get_userdata(). Never throws.
|
||||
*
|
||||
* @see frontend/class-pr-core-jsonld-article.php — Consumer.
|
||||
* @see convo/prcore/decisions/2026-06-11-jsonld-contract-v1.md — Meta contract.
|
||||
*
|
||||
* @package Peptide_Repo_Core
|
||||
*/
|
||||
class PR_Core_Prab_Meta_Reader {
|
||||
/**
|
||||
* Schema version meta key; presence opts the post in, value must equal 1.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const META_SCHEMA_VERSION = '_prab_schema_version';
|
||||
|
||||
/**
|
||||
* Citations meta key; JSON array of {url, title, doi?, quality_score?}.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const META_CITATIONS = '_prab_citations';
|
||||
|
||||
/**
|
||||
* About-peptides meta key; JSON array of peptide post IDs for schema `about`.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const META_ABOUT_PEPTIDES = '_prab_about_peptides';
|
||||
|
||||
/**
|
||||
* Review mode meta key; value is 'human' or 'editorial-system'.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const META_REVIEW_MODE = '_prab_review_mode';
|
||||
|
||||
/**
|
||||
* Reviewed-at meta key; ISO 8601 datetime of last review.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const META_REVIEWED_AT = '_prab_reviewed_at';
|
||||
|
||||
/**
|
||||
* Reviewed-by meta key; WP user ID of the Review Queue approver.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const META_REVIEWED_BY = '_prab_reviewed_by';
|
||||
|
||||
/**
|
||||
* The only schema version this reader currently supports.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private const SUPPORTED_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Per-request cache for is_triggered(), keyed by post ID (avoids triple meta reads).
|
||||
*
|
||||
* @var array<int, bool>
|
||||
*/
|
||||
private array $triggered_cache = array();
|
||||
|
||||
/**
|
||||
* Return true when this post carries a supported _prab_schema_version meta.
|
||||
*
|
||||
* Cached per post ID: all three Yoast hooks call this for the same post,
|
||||
* so caching avoids triple get_post_meta reads per request.
|
||||
*
|
||||
* @param int $post_id WordPress post ID.
|
||||
* @return bool
|
||||
*/
|
||||
public function is_triggered( int $post_id ): bool {
|
||||
if ( isset( $this->triggered_cache[ $post_id ] ) ) {
|
||||
return $this->triggered_cache[ $post_id ];
|
||||
}
|
||||
$raw = get_post_meta( $post_id, self::META_SCHEMA_VERSION, true );
|
||||
if ( '' === $raw || false === $raw ) {
|
||||
$this->triggered_cache[ $post_id ] = false;
|
||||
return false;
|
||||
}
|
||||
$version = (int) $raw;
|
||||
if ( self::SUPPORTED_VERSION === $version ) {
|
||||
$this->triggered_cache[ $post_id ] = true;
|
||||
return true;
|
||||
}
|
||||
error_log( sprintf( '[PR Core] Unsupported _prab_schema_version=%d on post %d. Supports v%d only. Emission suppressed.', $version, $post_id, self::SUPPORTED_VERSION ) );
|
||||
$this->triggered_cache[ $post_id ] = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and validate _prab_citations meta.
|
||||
*
|
||||
* Each entry needs valid http/https `url` and non-empty `title`; others skipped.
|
||||
* `quality_score` never emitted — no schema.org mapping (contract v1).
|
||||
*
|
||||
* @param int $post_id WordPress post ID.
|
||||
* @return array<int, array{url: string, title: string, doi: string|null}> Validated.
|
||||
*/
|
||||
public function get_citations( int $post_id ): array {
|
||||
$decoded = $this->decode_json_meta( $post_id, self::META_CITATIONS );
|
||||
if ( empty( $decoded ) ) {
|
||||
return array();
|
||||
}
|
||||
$valid = array();
|
||||
foreach ( $decoded as $item ) {
|
||||
if ( ! is_array( $item ) ) {
|
||||
continue;
|
||||
}
|
||||
$url = $this->sanitize_url( (string) ( $item['url'] ?? '' ) );
|
||||
$title = sanitize_text_field( (string) ( $item['title'] ?? '' ) );
|
||||
if ( '' === $url || '' === $title ) {
|
||||
continue;
|
||||
}
|
||||
$valid[] = array(
|
||||
'url' => $url,
|
||||
'title' => $title,
|
||||
'doi' => isset( $item['doi'] ) ? $this->sanitize_doi( (string) $item['doi'] ) : null,
|
||||
);
|
||||
}
|
||||
return $valid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and validate _prab_about_peptides meta.
|
||||
*
|
||||
* Returns Drug stubs for schema `about`. Each ID must resolve to a published
|
||||
* `peptide` post; unresolvable IDs and empty-permalink posts are skipped.
|
||||
*
|
||||
* @param int $post_id WordPress post ID.
|
||||
* @return array<int, array<string, string>> Drug stubs.
|
||||
*/
|
||||
public function get_about_peptides( int $post_id ): array {
|
||||
$decoded = $this->decode_json_meta( $post_id, self::META_ABOUT_PEPTIDES );
|
||||
if ( empty( $decoded ) ) {
|
||||
return array();
|
||||
}
|
||||
$stubs = array();
|
||||
foreach ( $decoded as $raw_id ) {
|
||||
if ( ! is_numeric( $raw_id ) ) {
|
||||
continue;
|
||||
}
|
||||
$pid = absint( $raw_id );
|
||||
$post = $pid > 0 ? get_post( $pid ) : null;
|
||||
if ( ! $post instanceof WP_Post
|
||||
|| 'publish' !== $post->post_status
|
||||
|| PR_Core_Peptide_CPT::POST_TYPE !== $post->post_type ) {
|
||||
continue;
|
||||
}
|
||||
$link = (string) get_permalink( $pid );
|
||||
if ( '' === $link ) {
|
||||
continue;
|
||||
}
|
||||
$stubs[] = array(
|
||||
'@type' => 'Drug',
|
||||
'@id' => $link . '#drug',
|
||||
'name' => sanitize_text_field( $post->post_title ),
|
||||
'url' => $link,
|
||||
);
|
||||
}
|
||||
return $stubs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and validate _prab_review_mode meta.
|
||||
*
|
||||
* Unknown values are treated as 'editorial-system' (safe floor, contract v1).
|
||||
*
|
||||
* @param int $post_id WordPress post ID.
|
||||
* @return string 'human' | 'editorial-system'.
|
||||
*/
|
||||
public function get_review_mode( int $post_id ): string {
|
||||
$raw = sanitize_text_field( (string) get_post_meta( $post_id, self::META_REVIEW_MODE, true ) );
|
||||
if ( 'human' === $raw ) {
|
||||
return 'human';
|
||||
}
|
||||
if ( '' !== $raw && 'editorial-system' !== $raw ) {
|
||||
error_log( sprintf( '[PR Core] Unknown _prab_review_mode="%s" on post %d. Treating as editorial-system.', $raw, $post_id ) );
|
||||
}
|
||||
return 'editorial-system';
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and validate _prab_reviewed_at meta.
|
||||
*
|
||||
* Returns null when absent, empty, or unparseable as a date.
|
||||
*
|
||||
* @param int $post_id WordPress post ID.
|
||||
* @return string|null Sanitised datetime string, or null.
|
||||
*/
|
||||
public function get_reviewed_at( int $post_id ): ?string {
|
||||
$raw = sanitize_text_field( (string) get_post_meta( $post_id, self::META_REVIEWED_AT, true ) );
|
||||
if ( '' === $raw || false === strtotime( $raw ) ) {
|
||||
return null;
|
||||
}
|
||||
return $raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the honest reviewedBy value for a PRAB article.
|
||||
*
|
||||
* Person only when mode='human', user resolves, and display_name non-empty.
|
||||
* All other cases → Organization. Never fabricated. Core contract guarantee.
|
||||
*
|
||||
* @param int $post_id WordPress post ID.
|
||||
* @param string $review_mode Result of get_review_mode() for this post.
|
||||
* @return array<string, string> Schema Person or Organization node.
|
||||
*/
|
||||
public function get_reviewed_by( int $post_id, string $review_mode ): array {
|
||||
if ( 'human' === $review_mode ) {
|
||||
$uid = absint( get_post_meta( $post_id, self::META_REVIEWED_BY, true ) );
|
||||
$user = $uid > 0 ? get_userdata( $uid ) : false;
|
||||
if ( $user instanceof WP_User ) {
|
||||
$display = sanitize_text_field( $user->display_name );
|
||||
if ( '' !== $display ) {
|
||||
return array(
|
||||
'@type' => 'Person',
|
||||
'@id' => home_url( '/#/schema/person/' . $uid ),
|
||||
'name' => $display,
|
||||
);
|
||||
}
|
||||
}
|
||||
error_log( sprintf( '[PR Core] _prab_review_mode=human but reviewed_by user (ID=%d) unresolvable on post %d. Emitting Organization.', $uid, $post_id ) );
|
||||
}
|
||||
return array(
|
||||
'@type' => 'Organization',
|
||||
'@id' => home_url( '/#organization' ),
|
||||
'name' => 'Peptide Repo',
|
||||
'url' => home_url(),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Private helpers ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Decode a JSON array from post meta, returning empty array on any error.
|
||||
*
|
||||
* @param int $post_id WordPress post ID.
|
||||
* @param string $meta_key Meta key to read.
|
||||
* @return array<mixed> Decoded array, or empty array on failure.
|
||||
*/
|
||||
private function decode_json_meta( int $post_id, string $meta_key ): array {
|
||||
$raw = get_post_meta( $post_id, $meta_key, true );
|
||||
if ( ! is_string( $raw ) || '' === $raw ) {
|
||||
return array();
|
||||
}
|
||||
$decoded = json_decode( $raw, true );
|
||||
return is_array( $decoded ) ? $decoded : array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize and validate a URL, requiring http or https scheme.
|
||||
*
|
||||
* @param string $raw Raw URL string.
|
||||
* @return string Sanitised URL, or '' when invalid.
|
||||
*/
|
||||
private function sanitize_url( string $raw ): string {
|
||||
$url = esc_url_raw( trim( $raw ) );
|
||||
if ( '' === $url || ! filter_var( $url, FILTER_VALIDATE_URL ) ) {
|
||||
return '';
|
||||
}
|
||||
$scheme = (string) wp_parse_url( $url, PHP_URL_SCHEME );
|
||||
return in_array( $scheme, array( 'http', 'https' ), true ) ? $url : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a DOI, returning a https://doi.org/ URI or null.
|
||||
*
|
||||
* Accepts bare DOI ("10.1234/abc"), doi:-prefixed, or full URI forms.
|
||||
*
|
||||
* @param string $raw Raw DOI string.
|
||||
* @return string|null DOI URI, or null if pattern does not match.
|
||||
*/
|
||||
private function sanitize_doi( string $raw ): ?string {
|
||||
$doi = trim( $raw );
|
||||
foreach ( array( 'https://doi.org/', 'http://doi.org/', 'doi:' ) as $pfx ) {
|
||||
if ( str_starts_with( strtolower( $doi ), strtolower( $pfx ) ) ) {
|
||||
$doi = substr( $doi, strlen( $pfx ) );
|
||||
break;
|
||||
}
|
||||
}
|
||||
return preg_match( '/^10\.\d{4,}\/\S+$/', $doi ) ? 'https://doi.org/' . $doi : null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user