Compare commits
5 Commits
feature/re
...
fix/v0.2.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
224fae5c2b | ||
|
|
3382cd3c96 | ||
|
|
4dc6436531 | ||
|
|
0519d6ec7d | ||
|
|
c17718b533 |
31
CHANGELOG.md
31
CHANGELOG.md
@@ -3,30 +3,10 @@
|
||||
All notable changes to Peptide Repo Core are documented here.
|
||||
Format: [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [0.3.0] — 2026-04-24
|
||||
## [0.2.2] — 2026-04-25
|
||||
|
||||
### Added
|
||||
- **Related Articles feature** — New `peptide_topic` taxonomy links blog posts to peptides by slug. Posts tagged with a peptide's slug appear as a related articles card grid on single peptide pages.
|
||||
- `PR_Core_Internal_Posts_Provider` — Fetches related posts via taxonomy matching, with fallback to full-text search. Results cached 1 hour per peptide.
|
||||
- `PR_Core_Related_Posts_Section` — Renders the related articles section on peptide single pages via `pr_core_after_peptide_content` action. Respects admin settings for feature toggle and display limit.
|
||||
- `PR_Core_Settings` — Admin settings page under Peptides menu. Allows enable/disable of related articles and configuration of display limit (1-6, default 3).
|
||||
- Template part `template-parts/related-posts/card.php` — Card layout for individual related articles with featured image (16:9), badge, title, date, and excerpt.
|
||||
- CSS file `assets/css/related-posts.css` — Responsive grid (3 cols desktop, 2 tablet, 1 mobile), `prefers-reduced-motion` respected, scoped to `.pr-related-posts`.
|
||||
- Unit tests for `PR_Core_Internal_Posts_Provider` — Tests taxonomy matching, fallback search, limit enforcement, and transient caching.
|
||||
- `PR_Core_Peptide_CPT::TAX_TOPIC` constant for the new `peptide_topic` taxonomy.
|
||||
|
||||
### Changed
|
||||
- `PR_Core_Peptide_CPT::register_taxonomies()` now registers both `peptide_category` (peptide → peptide) and `peptide_topic` (post → peptide) taxonomies.
|
||||
- `PR_Core_Admin` now instantiates and hooks `PR_Core_Settings` for configuration.
|
||||
- `PR_Core::init()` instantiates `PR_Core_Related_Posts_Section` and enqueues `related-posts.css` on single peptide pages.
|
||||
- `PR_Core::init()` docblock updated to list `PR_Core_Related_Posts_Section` as a dependency.
|
||||
- Version bumped to `0.3.0`.
|
||||
|
||||
### Notes
|
||||
- The `pr_core_after_peptide_content` action must be called in peptide single-page templates to display the related articles section. This hook fires after the main peptide content.
|
||||
- Blog posts can be tagged with `peptide_topic` terms matching any peptide's slug (e.g., 'bpc-157', 'tb-500') to appear as related articles on that peptide's page.
|
||||
- Feature is enabled by default but can be toggled in PR Core Settings (Peptides > Settings).
|
||||
- Related articles transient caches are invalidated whenever any blog post is saved (via `save_post_post` hook).
|
||||
### Fixed
|
||||
- Remove `rest_namespace` from peptide CPT registration. Custom namespace prevented Gutenberg block editor from loading peptide posts for editing (404 on wp/v2 REST route). All 89 peptide entries are now editable in the block editor.
|
||||
|
||||
## [0.2.1] — 2026-04-22
|
||||
|
||||
@@ -90,7 +70,4 @@ Format: [Semantic Versioning](https://semver.org/).
|
||||
- JSON-LD emission: schema.org `Drug` type on single peptide pages with `pr_core_jsonld_peptide` filter.
|
||||
- Public API filters: `pr_core_get_indexable_corpus`, `pr_core_disclaimer_for_surface`, `pr_core_evidence_strength_label`.
|
||||
- Public API actions: `pr_core_before/after_peptide_publish`, `pr_core_before/after_dosing_row_publish`, `pr_core_before/after_legal_cell_publish`, `pr_core_candidate_approved/rejected`.
|
||||
- `manage_peptide_content` capability granted to administrators and editors on activation.
|
||||
- Full teardown in `uninstall.php` (tables, posts, terms, options, capabilities).
|
||||
- Seed data fixture: 3 peptides (BPC-157, Semaglutide, TB-500), 10 dosing rows, 5 legal cells.
|
||||
- CI workflow: PHP lint (8.0/8.1/8.2), PHPCS (WordPress standard), 300-line file check.
|
||||
- `manage_peptide_content` capability granted to administrators and editors on activ
|
||||
@@ -1,192 +0,0 @@
|
||||
/**
|
||||
* Related Posts Section Styles
|
||||
* Scoped to .pr-related-posts
|
||||
*/
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
.pr-related-posts {
|
||||
margin-top: 3rem;
|
||||
padding: 2rem 0;
|
||||
border-top: 1px solid var(--wp--preset--color--border, #e5e5e5);
|
||||
}
|
||||
|
||||
.pr-related-posts h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.5rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.pr-related-posts__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.pr-related-posts__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: white;
|
||||
border: 1px solid var(--wp--preset--color--border, #e5e5e5);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
.pr-related-posts__card:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pr-related-posts__card {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.pr-related-posts__card:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.pr-related-posts__image {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding-bottom: 56.25%;
|
||||
/* 16:9 aspect ratio */
|
||||
background: var(--wp--preset--color--base, #f5f5f5);
|
||||
}
|
||||
|
||||
.pr-related-posts__image--fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-bottom: 0;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.pr-related-posts__image-element {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.pr-related-posts__image--fallback .pr-related-posts__image-element {
|
||||
position: static;
|
||||
width: auto;
|
||||
height: 80px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.pr-related-posts__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.pr-related-posts__badge {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: #666;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.pr-related-posts__title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.pr-related-posts__title a {
|
||||
color: var(--wp--preset--color--heading, #000);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.pr-related-posts__title a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.pr-related-posts__date {
|
||||
font-size: 0.875rem;
|
||||
color: #999;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.pr-related-posts__excerpt {
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.5;
|
||||
color: #666;
|
||||
margin: 0;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.pr-related-posts__view-all {
|
||||
text-align: center;
|
||||
margin-top: 1rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.pr-related-posts__view-all a {
|
||||
color: var(--wp--preset--color--primary, #0073aa);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pr-related-posts__view-all a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Tablet: 2 columns */
|
||||
@media (max-width: 768px) {
|
||||
.pr-related-posts__grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.pr-related-posts h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.pr-related-posts {
|
||||
margin-top: 2rem;
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile: 1 column */
|
||||
@media (max-width: 480px) {
|
||||
.pr-related-posts__grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.pr-related-posts h2 {
|
||||
font-size: 1.125rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.pr-related-posts {
|
||||
margin-top: 1.5rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.pr-related-posts__content {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.pr-related-posts__title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -4,16 +4,13 @@ declare(strict_types=1);
|
||||
/**
|
||||
* Admin initialization and hook registration.
|
||||
*
|
||||
* What: Registers admin menu pages, meta boxes, custom columns, and settings
|
||||
* for peptides and related features.
|
||||
* What: Registers admin menu pages, meta boxes, and custom columns for peptides.
|
||||
* Who calls it: PR_Core::init() when is_admin().
|
||||
* Dependencies: PR_Core_Peptide_Metaboxes, PR_Core_Candidate_Queue_Page,
|
||||
* PR_Core_Admin_Columns, PR_Core_Settings.
|
||||
* Dependencies: PR_Core_Peptide_Metaboxes, PR_Core_Candidate_Queue_Page, PR_Core_Admin_Columns.
|
||||
*
|
||||
* @see admin/class-pr-core-peptide-metaboxes.php — Dosing + legal meta boxes.
|
||||
* @see admin/class-pr-core-candidate-queue-page.php — Candidate review screen.
|
||||
* @see admin/class-pr-core-admin-columns.php — Custom list-table columns.
|
||||
* @see admin/class-pr-core-settings.php — Plugin settings page.
|
||||
*/
|
||||
class PR_Core_Admin {
|
||||
|
||||
@@ -33,9 +30,6 @@ class PR_Core_Admin {
|
||||
add_filter( 'manage_' . PR_Core_Peptide_CPT::POST_TYPE . '_posts_columns', [ $columns, 'add_columns' ] );
|
||||
add_action( 'manage_' . PR_Core_Peptide_CPT::POST_TYPE . '_posts_custom_column', [ $columns, 'render_column' ], 10, 2 );
|
||||
|
||||
$settings = new PR_Core_Settings();
|
||||
$settings->register_hooks();
|
||||
|
||||
add_action( 'admin_menu', [ $this, 'add_admin_pages' ] );
|
||||
add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_admin_styles' ] );
|
||||
}
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Settings page for PR Core plugin features.
|
||||
*
|
||||
* What: Registers admin menu, renders settings form, and handles option saves
|
||||
* for features like Related Articles.
|
||||
* Who calls it: PR_Core_Admin::register_hooks().
|
||||
* Dependencies: None (reads/writes wp_options).
|
||||
*/
|
||||
class PR_Core_Settings {
|
||||
|
||||
/** @var string Option key: enable/disable related articles. */
|
||||
private const ENABLED_OPTION = 'pr_core_related_posts_enabled';
|
||||
|
||||
/** @var string Option key: related articles limit (1-6). */
|
||||
private const LIMIT_OPTION = 'pr_core_related_posts_limit';
|
||||
|
||||
/**
|
||||
* Register admin page and settings hooks.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register_hooks(): void {
|
||||
add_action( 'admin_menu', [ $this, 'add_settings_page' ] );
|
||||
add_action( 'admin_init', [ $this, 'register_settings' ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Add submenu page under Peptides.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function add_settings_page(): void {
|
||||
add_submenu_page(
|
||||
'edit.php?post_type=' . PR_Core_Peptide_CPT::POST_TYPE,
|
||||
__( 'PR Core Settings', 'peptide-repo-core' ),
|
||||
__( 'Settings', 'peptide-repo-core' ),
|
||||
'manage_options',
|
||||
'pr-core-settings',
|
||||
[ $this, 'render_page' ]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register settings fields.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register_settings(): void {
|
||||
register_setting(
|
||||
'pr_core_settings',
|
||||
self::ENABLED_OPTION,
|
||||
[
|
||||
'type' => 'boolean',
|
||||
'default' => true,
|
||||
'sanitize_callback' => 'rest_sanitize_boolean',
|
||||
]
|
||||
);
|
||||
|
||||
register_setting(
|
||||
'pr_core_settings',
|
||||
self::LIMIT_OPTION,
|
||||
[
|
||||
'type' => 'integer',
|
||||
'default' => 3,
|
||||
'sanitize_callback' => [ $this, 'sanitize_limit' ],
|
||||
]
|
||||
);
|
||||
|
||||
add_settings_section(
|
||||
'pr_core_related_articles',
|
||||
__( 'Related Articles', 'peptide-repo-core' ),
|
||||
[ $this, 'render_section' ],
|
||||
'pr_core_settings'
|
||||
);
|
||||
|
||||
add_settings_field(
|
||||
self::ENABLED_OPTION,
|
||||
__( 'Enable Related Articles', 'peptide-repo-core' ),
|
||||
[ $this, 'render_enabled_field' ],
|
||||
'pr_core_settings',
|
||||
'pr_core_related_articles'
|
||||
);
|
||||
|
||||
add_settings_field(
|
||||
self::LIMIT_OPTION,
|
||||
__( 'Number of Articles to Display', 'peptide-repo-core' ),
|
||||
[ $this, 'render_limit_field' ],
|
||||
'pr_core_settings',
|
||||
'pr_core_related_articles'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the settings page.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function render_page(): void {
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e( 'Peptide Repo Core Settings', 'peptide-repo-core' ); ?></h1>
|
||||
<form method="post" action="options.php">
|
||||
<?php
|
||||
settings_fields( 'pr_core_settings' );
|
||||
do_settings_sections( 'pr_core_settings' );
|
||||
submit_button();
|
||||
?>
|
||||
</form>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Render section description.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function render_section(): void {
|
||||
echo '<p>' . esc_html__( 'Configure the Related Articles section that appears on peptide single pages.', 'peptide-repo-core' ) . '</p>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render enabled toggle field.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function render_enabled_field(): void {
|
||||
$checked = (bool) get_option( self::ENABLED_OPTION, true );
|
||||
?>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="<?php echo esc_attr( self::ENABLED_OPTION ); ?>"
|
||||
value="1"
|
||||
<?php checked( $checked, true ); ?>
|
||||
/>
|
||||
<label>
|
||||
<?php esc_html_e( 'Display related articles on peptide pages', 'peptide-repo-core' ); ?>
|
||||
</label>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Render limit field.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function render_limit_field(): void {
|
||||
$limit = (int) get_option( self::LIMIT_OPTION, 3 );
|
||||
?>
|
||||
<input
|
||||
type="number"
|
||||
name="<?php echo esc_attr( self::LIMIT_OPTION ); ?>"
|
||||
value="<?php echo esc_attr( (string) $limit ); ?>"
|
||||
min="1"
|
||||
max="6"
|
||||
/>
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'Number of articles to display (1-6). Default: 3.', 'peptide-repo-core' ); ?>
|
||||
</p>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize limit to 1-6 range.
|
||||
*
|
||||
* @param mixed $value Raw input.
|
||||
* @return int Sanitized value.
|
||||
*/
|
||||
public function sanitize_limit( $value ): int {
|
||||
$limit = (int) $value;
|
||||
return min( 6, max( 1, $limit ) );
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,11 @@ 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_Rest_Controller, PR_Core_Related_Posts_Section.
|
||||
* Dependencies: PR_Core_Migration_Runner, PR_Core_Peptide_CPT, PR_Core_Admin,
|
||||
* PR_Core_Disclaimer, PR_Core_Jsonld, PR_Core_Rest_Controller.
|
||||
*
|
||||
* @see peptide-repo-core.php — Bootstrap that instantiates this class.
|
||||
* @see ARCHITECTURE.md — Full data flow diagram.
|
||||
* @see ARCHITECTURE.md — Full data flow diagram.
|
||||
*/
|
||||
class PR_Core {
|
||||
|
||||
@@ -31,10 +30,6 @@ class PR_Core {
|
||||
$cpt = new PR_Core_Peptide_CPT();
|
||||
$cpt->register_hooks();
|
||||
|
||||
// Register peptide_topic taxonomy (fires on init).
|
||||
$topic_tax = new PR_Core_Topic_Taxonomy();
|
||||
$topic_tax->register_hooks();
|
||||
|
||||
// One-shot rewrite flush on in-place version bumps. Runs at the very
|
||||
// end of init (priority 999) so all CPTs/taxonomies — ours and
|
||||
// anyone else's — are registered first. Handles updates deployed
|
||||
@@ -54,15 +49,6 @@ class PR_Core {
|
||||
$jsonld = new PR_Core_Jsonld();
|
||||
$jsonld->register_hooks();
|
||||
|
||||
// Related Articles section (frontend).
|
||||
$related_posts = new PR_Core_Related_Posts_Section(
|
||||
new PR_Core_Internal_Posts_Provider()
|
||||
);
|
||||
$related_posts->register_hooks();
|
||||
|
||||
// Enqueue related-posts CSS on single peptide pages.
|
||||
add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_frontend_styles' ] );
|
||||
|
||||
// REST API.
|
||||
$rest = new PR_Core_Rest_Controller();
|
||||
$rest->register_hooks();
|
||||
@@ -71,24 +57,6 @@ class PR_Core {
|
||||
$this->register_public_filters();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue frontend CSS on single peptide pages.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function enqueue_frontend_styles(): void {
|
||||
if ( ! is_singular( PR_Core_Peptide_CPT::POST_TYPE ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
wp_enqueue_style(
|
||||
'pr-core-related-posts',
|
||||
PR_CORE_PLUGIN_URL . 'assets/css/related-posts.css',
|
||||
[],
|
||||
PR_CORE_VERSION
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the public filter hooks that consumer plugins rely on.
|
||||
*
|
||||
@@ -133,7 +101,7 @@ class PR_Core {
|
||||
/**
|
||||
* Return disclaimer text for a given surface identifier.
|
||||
*
|
||||
* @param string $text Existing text (empty string default).
|
||||
* @param string $text Existing text (empty string default).
|
||||
* @param string $surface Surface identifier (dosing, legal, reconstitution, ai-answer).
|
||||
* @return string Disclaimer HTML.
|
||||
*/
|
||||
@@ -144,18 +112,18 @@ class PR_Core {
|
||||
/**
|
||||
* Map evidence_strength enum value to human-readable label.
|
||||
*
|
||||
* @param string $label Existing label (empty string default).
|
||||
* @param string $label Existing label (empty string default).
|
||||
* @param string $strength Enum value.
|
||||
* @return string Localized label.
|
||||
*/
|
||||
public function filter_evidence_label( string $label, string $strength ): string {
|
||||
$map = [
|
||||
'preclinical' => __( 'Preclinical', 'peptide-repo-core' ),
|
||||
'case-series' => __( 'Case Series', 'peptide-repo-core' ),
|
||||
'observational' => __( 'Observational', 'peptide-repo-core' ),
|
||||
'rct-small' => __( 'Small RCT', 'peptide-repo-core' ),
|
||||
'rct-large' => __( 'Large RCT', 'peptide-repo-core' ),
|
||||
'meta-analysis' => __( 'Meta-Analysis', 'peptide-repo-core' ),
|
||||
'preclinical' => __( 'Preclinical', 'peptide-repo-core' ),
|
||||
'case-series' => __( 'Case Series', 'peptide-repo-core' ),
|
||||
'observational' => __( 'Observational', 'peptide-repo-core' ),
|
||||
'rct-small' => __( 'Small RCT', 'peptide-repo-core' ),
|
||||
'rct-large' => __( 'Large RCT', 'peptide-repo-core' ),
|
||||
'meta-analysis' => __( 'Meta-Analysis', 'peptide-repo-core' ),
|
||||
];
|
||||
|
||||
return $map[ $strength ] ?? $strength;
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Fetches posts related to a peptide via taxonomy and fallback text search.
|
||||
*
|
||||
* What: Implements PR_Core_Related_Posts_Provider by querying posts tagged
|
||||
* with the peptide's slug as a peptide_topic term. Falls back to
|
||||
* full-text search if no taxonomy matches found.
|
||||
* Who calls it: PR_Core_Related_Posts_Section instantiates and calls this.
|
||||
* Dependencies: PR_Core_Related_Posts_Provider interface, WP_Query,
|
||||
* peptide_topic taxonomy.
|
||||
*
|
||||
* @see includes/interfaces/interface-related-posts-provider.php
|
||||
* @see includes/core/class-related-posts-section.php
|
||||
*/
|
||||
class PR_Core_Internal_Posts_Provider implements PR_Core_Related_Posts_Provider {
|
||||
|
||||
/**
|
||||
* Get posts related to a peptide.
|
||||
*
|
||||
* 1. Queries posts tagged with the peptide slug as a peptide_topic term.
|
||||
* 2. If 0 posts found, falls back to full-text search on post_title.
|
||||
* 3. Caches result as a transient (1 hour TTL).
|
||||
* 4. Never exceeds $limit posts.
|
||||
*
|
||||
* Side effects: Sets/updates transient cache for this peptide.
|
||||
*
|
||||
* @param int $peptide_id The peptide post ID.
|
||||
* @param int $limit Maximum number of posts to return.
|
||||
* @return array<int, \WP_Post> Array of WP_Post objects (may be empty).
|
||||
*/
|
||||
public function get_posts( int $peptide_id, int $limit ): array {
|
||||
$peptide = get_post( $peptide_id );
|
||||
if ( ! $peptide || 'peptide' !== $peptide->post_type ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$cache_key = 'pr_core_related_' . $peptide_id;
|
||||
$cached = get_transient( $cache_key );
|
||||
if ( is_array( $cached ) ) {
|
||||
return array_slice( $cached, 0, $limit );
|
||||
}
|
||||
|
||||
// Primary: query posts tagged with peptide's slug as peptide_topic.
|
||||
$posts = $this->query_by_taxonomy( $peptide->post_name, $limit );
|
||||
|
||||
// Fallback: if empty, search by peptide title.
|
||||
if ( empty( $posts ) ) {
|
||||
$posts = $this->query_by_search( $peptide->post_title, $limit );
|
||||
}
|
||||
|
||||
// Cache for 1 hour.
|
||||
set_transient( $cache_key, $posts, HOUR_IN_SECONDS );
|
||||
|
||||
return $posts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query posts tagged with a specific peptide_topic term.
|
||||
*
|
||||
* @param string $peptide_slug The peptide's post_name (slug).
|
||||
* @param int $limit Maximum posts to return.
|
||||
* @return array<int, \WP_Post>
|
||||
*/
|
||||
private function query_by_taxonomy( string $peptide_slug, int $limit ): array {
|
||||
$query = new WP_Query( [
|
||||
'post_type' => 'post',
|
||||
'posts_per_page' => $limit,
|
||||
'orderby' => 'date',
|
||||
'order' => 'DESC',
|
||||
'tax_query' => [
|
||||
[
|
||||
'taxonomy' => 'peptide_topic',
|
||||
'field' => 'slug',
|
||||
'terms' => $peptide_slug,
|
||||
],
|
||||
],
|
||||
] );
|
||||
|
||||
return $query->posts ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: query posts by full-text search on post_title.
|
||||
*
|
||||
* @param string $search_term The peptide's display title.
|
||||
* @param int $limit Maximum posts to return.
|
||||
* @return array<int, \WP_Post>
|
||||
*/
|
||||
private function query_by_search( string $search_term, int $limit ): array {
|
||||
$query = new WP_Query( [
|
||||
'post_type' => 'post',
|
||||
'posts_per_page' => $limit,
|
||||
'orderby' => 'date',
|
||||
'order' => 'DESC',
|
||||
's' => $search_term,
|
||||
] );
|
||||
|
||||
return $query->posts ?: [];
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Renders the related articles section on peptide single pages.
|
||||
*
|
||||
* What: Hooks into pr_core_after_peptide_content and renders a card grid
|
||||
* of related posts fetched via PR_Core_Related_Posts_Provider.
|
||||
* Who calls it: PR_Core::init() instantiates and registers hooks.
|
||||
* Dependencies: PR_Core_Related_Posts_Provider interface, template-parts/related-posts/card.php.
|
||||
*
|
||||
* Settings read:
|
||||
* - pr_core_related_posts_enabled (bool, default true)
|
||||
* - pr_core_related_posts_limit (int, default 3, range 1-6)
|
||||
*/
|
||||
class PR_Core_Related_Posts_Section {
|
||||
|
||||
/** @var PR_Core_Related_Posts_Provider */
|
||||
private $provider;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param PR_Core_Related_Posts_Provider $provider Provider instance.
|
||||
*/
|
||||
public function __construct( PR_Core_Related_Posts_Provider $provider ) {
|
||||
$this->provider = $provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the action hook.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register_hooks(): void {
|
||||
add_action( 'pr_core_after_peptide_content', [ $this, 'render' ] );
|
||||
add_action( 'save_post_post', [ $this, 'invalidate_caches' ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the related posts section.
|
||||
*
|
||||
* Called via do_action( 'pr_core_after_peptide_content', $peptide_id ) in templates.
|
||||
* If feature disabled or no posts found, renders nothing.
|
||||
*
|
||||
* @param int $peptide_id The peptide post ID.
|
||||
* @return void
|
||||
*/
|
||||
public function render( int $peptide_id ): void {
|
||||
$enabled = (bool) get_option( 'pr_core_related_posts_enabled', true );
|
||||
if ( ! $enabled ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$limit = (int) get_option( 'pr_core_related_posts_limit', 3 );
|
||||
$limit = min( 6, max( 1, $limit ) );
|
||||
|
||||
$posts = $this->provider->get_posts( $peptide_id, $limit + 5 );
|
||||
if ( empty( $posts ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$peptide = get_post( $peptide_id );
|
||||
?>
|
||||
<section class="pr-related-posts" aria-label="<?php esc_attr_e( 'Related Articles', 'peptide-repo-core' ); ?>">
|
||||
<h2><?php esc_html_e( 'Related Articles', 'peptide-repo-core' ); ?></h2>
|
||||
<div class="pr-related-posts__grid">
|
||||
<?php
|
||||
$count = 0;
|
||||
foreach ( $posts as $post ) {
|
||||
if ( $count >= $limit ) {
|
||||
break;
|
||||
}
|
||||
setup_postdata( $post );
|
||||
get_template_part( 'template-parts/related-posts/card', null, [ 'post' => $post ] );
|
||||
++$count;
|
||||
}
|
||||
wp_reset_postdata();
|
||||
?>
|
||||
</div>
|
||||
<?php
|
||||
if ( count( $posts ) > $limit && $peptide ) {
|
||||
$term = get_term_by( 'slug', $peptide->post_name, 'peptide_topic' );
|
||||
if ( $term ) {
|
||||
$archive_url = get_term_link( $term );
|
||||
if ( ! is_wp_error( $archive_url ) ) {
|
||||
?>
|
||||
<p class="pr-related-posts__view-all">
|
||||
<a href="<?php echo esc_url( $archive_url ); ?>">
|
||||
<?php
|
||||
echo wp_kses_post(
|
||||
sprintf(
|
||||
__( 'View all %s articles →', 'peptide-repo-core' ),
|
||||
esc_html( $peptide->post_title )
|
||||
)
|
||||
);
|
||||
?>
|
||||
</a>
|
||||
</p>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
</section>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate related posts transient caches when a post is saved.
|
||||
*
|
||||
* Hooks into save_post_post to clear any cached related-post queries
|
||||
* since the post corpus has changed.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function invalidate_caches(): void {
|
||||
global $wpdb;
|
||||
$cache_keys = $wpdb->get_col(
|
||||
"SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE 'pr_core_related_%'"
|
||||
);
|
||||
foreach ( $cache_keys as $key ) {
|
||||
delete_transient( str_replace( '_transient_', '', $key ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,7 +184,8 @@ class PR_Core_Peptide_CPT {
|
||||
'show_in_nav_menus' => true,
|
||||
'show_in_rest' => true,
|
||||
'rest_base' => 'peptides',
|
||||
'rest_namespace' => 'pr-core/v1',
|
||||
// 'rest_namespace' omitted — custom namespaces break Gutenberg (it
|
||||
// fetches wp/v2 hardcoded). WordPress defaults to wp/v2. (#4)
|
||||
'menu_position' => 25,
|
||||
'menu_icon' => 'dashicons-database',
|
||||
'capability_type' => 'post',
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Registers the peptide_topic taxonomy for linking blog posts to peptides.
|
||||
*
|
||||
* What: Defines the `peptide_topic` taxonomy attached to the post CPT.
|
||||
* Non-hierarchical, uses peptide slugs as terms (e.g., 'bpc-157', 'tb-500').
|
||||
* Enables the Related Articles feature for blog posts tagged with a peptide topic.
|
||||
* Who calls it: PR_Core::init() on plugins_loaded.
|
||||
* Dependencies: None.
|
||||
*
|
||||
* Ownership: As of v0.3.0, PR Core owns `peptide_topic` taxonomy.
|
||||
* Registration is guarded by `taxonomy_exists()` so deploy order does not matter.
|
||||
*
|
||||
* @see ARCHITECTURE.md — Taxonomy specification.
|
||||
* @see CONVENTIONS.md — Taxonomy ownership rule.
|
||||
*/
|
||||
class PR_Core_Topic_Taxonomy {
|
||||
|
||||
/** @var string Taxonomy: topic (links blog posts to peptides by slug). */
|
||||
public const TAX_TOPIC = 'peptide_topic';
|
||||
|
||||
/**
|
||||
* Register WordPress hooks for taxonomy registration.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register_hooks(): void {
|
||||
add_action( 'init', [ __CLASS__, 'register_topic_taxonomy' ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the `peptide_topic` taxonomy.
|
||||
*
|
||||
* Guarded with `taxonomy_exists()`: if another plugin has already
|
||||
* registered the `peptide_topic` taxonomy, this call no-ops.
|
||||
*
|
||||
* Side effects: registers taxonomy with WordPress.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register_topic_taxonomy(): void {
|
||||
if ( taxonomy_exists( self::TAX_TOPIC ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
register_taxonomy( self::TAX_TOPIC, 'post', [
|
||||
'labels' => [
|
||||
'name' => __( 'Peptide Topics', 'peptide-repo-core' ),
|
||||
'singular_name' => __( 'Peptide Topic', 'peptide-repo-core' ),
|
||||
],
|
||||
'public' => true,
|
||||
'publicly_queryable' => true,
|
||||
'show_in_rest' => true,
|
||||
'show_ui' => true,
|
||||
'show_admin_column' => true,
|
||||
'hierarchical' => false,
|
||||
'rewrite' => [ 'slug' => 'peptide-topic', 'with_front' => false ],
|
||||
] );
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Interface for fetching related posts for a peptide.
|
||||
*
|
||||
* What: Defines the contract for retrieving posts related to a given peptide.
|
||||
* Who calls it: PR_Core_Related_Posts_Section uses implementations to fetch related articles.
|
||||
* Dependencies: None.
|
||||
*/
|
||||
interface PR_Core_Related_Posts_Provider {
|
||||
|
||||
/**
|
||||
* Get posts related to a peptide.
|
||||
*
|
||||
* @param int $peptide_id The peptide post ID.
|
||||
* @param int $limit Maximum number of posts to return.
|
||||
* @return array<int, \WP_Post> Array of WP_Post objects (may be empty).
|
||||
*/
|
||||
public function get_posts( int $peptide_id, int $limit ): array;
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* Plugin Name: Peptide Repo Core
|
||||
* 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.
|
||||
* Version: 0.3.0
|
||||
* Version: 0.2.2
|
||||
* Author: peptiderepo
|
||||
* Author URI: https://peptiderepo.com
|
||||
* License: GPL-2.0-or-later
|
||||
@@ -22,7 +22,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
|
||||
/* ── Constants ────────────────────────────────────────────────────────── */
|
||||
|
||||
define( 'PR_CORE_VERSION', '0.3.0' );
|
||||
define( 'PR_CORE_VERSION', '0.2.2' );
|
||||
define( 'PR_CORE_PLUGIN_FILE', __FILE__ );
|
||||
define( 'PR_CORE_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
||||
define( 'PR_CORE_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Related posts card template part.
|
||||
*
|
||||
* Variables:
|
||||
* - $post: WP_Post object with thumbnail, title, excerpt, date.
|
||||
*
|
||||
* @package peptide-repo-core
|
||||
*/
|
||||
|
||||
if ( ! isset( $args['post'] ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$post = $args['post'];
|
||||
?>
|
||||
<article class="pr-related-posts__card">
|
||||
<?php
|
||||
if ( has_post_thumbnail( $post ) ) {
|
||||
?>
|
||||
<div class="pr-related-posts__image">
|
||||
<?php
|
||||
echo wp_kses_post(
|
||||
get_the_post_thumbnail(
|
||||
$post,
|
||||
'post-thumbnail',
|
||||
[
|
||||
'class' => 'pr-related-posts__image-element',
|
||||
'loading' => 'lazy',
|
||||
'decoding' => 'async',
|
||||
]
|
||||
)
|
||||
);
|
||||
?>
|
||||
</div>
|
||||
<?php
|
||||
} else {
|
||||
?>
|
||||
<div class="pr-related-posts__image pr-related-posts__image--fallback">
|
||||
<img src="<?php echo esc_url( get_site_icon_url( 512 ) ); ?>" alt="<?php esc_attr_e( 'Site logo', 'peptide-repo-core' ); ?>" class="pr-related-posts__image-element" loading="lazy" decoding="async" />
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
?>
|
||||
<div class="pr-related-posts__content">
|
||||
<span class="pr-related-posts__badge"><?php esc_html_e( 'Article', 'peptide-repo-core' ); ?></span>
|
||||
<h3 class="pr-related-posts__title">
|
||||
<a href="<?php echo esc_url( get_permalink( $post ) ); ?>">
|
||||
<?php echo esc_html( $post->post_title ); ?>
|
||||
</a>
|
||||
</h3>
|
||||
<time class="pr-related-posts__date" datetime="<?php echo esc_attr( mysql2date( 'c', $post->post_date ) ); ?>">
|
||||
<?php echo esc_html( mysql2date( get_option( 'date_format' ), $post->post_date ) ); ?>
|
||||
</time>
|
||||
<?php
|
||||
if ( $post->post_excerpt ) {
|
||||
$excerpt = wp_strip_all_tags( $post->post_excerpt );
|
||||
$excerpt = wp_kses_post( wp_trim_words( $excerpt, 20 ) );
|
||||
?>
|
||||
<p class="pr-related-posts__excerpt">
|
||||
<?php echo wp_kses_post( $excerpt ); ?>
|
||||
</p>
|
||||
<?php
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
</article>
|
||||
@@ -1,168 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Unit tests for PR_Core_Internal_Posts_Provider.
|
||||
*
|
||||
* Tests the provider's taxonomy-based and fallback search behavior,
|
||||
* caching, and limit enforcement. Uses mocked WP_Query.
|
||||
*/
|
||||
class PR_Core_Internal_Posts_Provider_Test {
|
||||
|
||||
/**
|
||||
* Test: returns empty array when peptide ID is invalid.
|
||||
*/
|
||||
public function test_returns_empty_when_peptide_not_found() {
|
||||
$provider = new PR_Core_Internal_Posts_Provider();
|
||||
$result = $provider->get_posts( 99999, 3 );
|
||||
|
||||
assert( [] === $result, 'Expected empty array for invalid peptide ID' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: returns posts matching peptide_topic taxonomy.
|
||||
*/
|
||||
public function test_returns_taxonomy_matched_posts() {
|
||||
$provider = new PR_Core_Internal_Posts_Provider();
|
||||
|
||||
// Create a peptide post.
|
||||
$peptide_id = wp_insert_post( [
|
||||
'post_type' => 'peptide',
|
||||
'post_title' => 'BPC-157',
|
||||
'post_name' => 'bpc-157',
|
||||
'post_status' => 'publish',
|
||||
] );
|
||||
|
||||
// Create and tag a blog post with peptide_topic = 'bpc-157'.
|
||||
$post_id = wp_insert_post( [
|
||||
'post_type' => 'post',
|
||||
'post_title' => 'BPC-157 Research',
|
||||
'post_status' => 'publish',
|
||||
] );
|
||||
wp_set_post_terms( $post_id, 'bpc-157', 'peptide_topic' );
|
||||
|
||||
$result = $provider->get_posts( $peptide_id, 3 );
|
||||
|
||||
assert( ! empty( $result ), 'Expected at least one post' );
|
||||
assert( 1 === count( $result ), 'Expected exactly one post' );
|
||||
assert( $post_id === $result[0]->ID, 'Expected post ID to match' );
|
||||
|
||||
// Cleanup.
|
||||
wp_delete_post( $peptide_id, true );
|
||||
wp_delete_post( $post_id, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: falls back to text search when taxonomy returns nothing.
|
||||
*/
|
||||
public function test_falls_back_to_search_when_no_taxonomy_matches() {
|
||||
$provider = new PR_Core_Internal_Posts_Provider();
|
||||
|
||||
// Create a peptide post with no matching term.
|
||||
$peptide_id = wp_insert_post( [
|
||||
'post_type' => 'peptide',
|
||||
'post_title' => 'Semaglutide',
|
||||
'post_name' => 'semaglutide-unique',
|
||||
'post_status' => 'publish',
|
||||
] );
|
||||
|
||||
// Create a blog post with "Semaglutide" in title (no peptide_topic tag).
|
||||
$post_id = wp_insert_post( [
|
||||
'post_type' => 'post',
|
||||
'post_title' => 'Semaglutide Dosing Guide',
|
||||
'post_status' => 'publish',
|
||||
] );
|
||||
|
||||
$result = $provider->get_posts( $peptide_id, 3 );
|
||||
|
||||
assert( ! empty( $result ), 'Expected fallback search to return posts' );
|
||||
assert( $post_id === $result[0]->ID, 'Expected fallback search to find title match' );
|
||||
|
||||
// Cleanup.
|
||||
wp_delete_post( $peptide_id, true );
|
||||
wp_delete_post( $post_id, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: never returns more posts than $limit.
|
||||
*/
|
||||
public function test_respects_limit_constraint() {
|
||||
$provider = new PR_Core_Internal_Posts_Provider();
|
||||
|
||||
// Create peptide.
|
||||
$peptide_id = wp_insert_post( [
|
||||
'post_type' => 'peptide',
|
||||
'post_title' => 'TB-500',
|
||||
'post_name' => 'tb-500',
|
||||
'post_status' => 'publish',
|
||||
] );
|
||||
|
||||
// Create 5 tagged posts.
|
||||
for ( $i = 0; $i < 5; ++$i ) {
|
||||
$post_id = wp_insert_post( [
|
||||
'post_type' => 'post',
|
||||
'post_title' => 'TB-500 Article ' . $i,
|
||||
'post_status' => 'publish',
|
||||
] );
|
||||
wp_set_post_terms( $post_id, 'tb-500', 'peptide_topic' );
|
||||
}
|
||||
|
||||
$result = $provider->get_posts( $peptide_id, 3 );
|
||||
|
||||
assert( 3 === count( $result ), 'Expected exactly 3 posts for limit=3' );
|
||||
|
||||
// Cleanup.
|
||||
wp_delete_post( $peptide_id, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: returns cached transient on second call.
|
||||
*/
|
||||
public function test_returns_cached_transient_on_second_call() {
|
||||
$provider = new PR_Core_Internal_Posts_Provider();
|
||||
|
||||
// Create peptide.
|
||||
$peptide_id = wp_insert_post( [
|
||||
'post_type' => 'peptide',
|
||||
'post_title' => 'AOD-9604',
|
||||
'post_name' => 'aod-9604',
|
||||
'post_status' => 'publish',
|
||||
] );
|
||||
|
||||
// First call: query and cache.
|
||||
$result_1 = $provider->get_posts( $peptide_id, 3 );
|
||||
|
||||
// Verify transient was set.
|
||||
$cache_key = 'pr_core_related_' . $peptide_id;
|
||||
$cached = get_transient( $cache_key );
|
||||
assert( ! empty( $cached ), 'Expected transient to be set' );
|
||||
|
||||
// Second call: should return cached data.
|
||||
$result_2 = $provider->get_posts( $peptide_id, 3 );
|
||||
|
||||
// Results should match.
|
||||
if ( ! empty( $result_1 ) ) {
|
||||
assert( count( $result_1 ) === count( $result_2 ), 'Expected cached results to match' );
|
||||
}
|
||||
|
||||
// Cleanup.
|
||||
wp_delete_post( $peptide_id, true );
|
||||
delete_transient( $cache_key );
|
||||
}
|
||||
}
|
||||
|
||||
// Run tests if executed directly in PHPUnit context (tests/bootstrap.php will autoload).
|
||||
if ( function_exists( 'assert_options' ) ) {
|
||||
assert_options( ASSERT_ACTIVE, 1 );
|
||||
assert_options( ASSERT_WARNING, 0 );
|
||||
assert_options( ASSERT_BAIL, 1 );
|
||||
|
||||
$test = new PR_Core_Internal_Posts_Provider_Test();
|
||||
$test->test_returns_empty_when_peptide_not_found();
|
||||
$test->test_returns_taxonomy_matched_posts();
|
||||
$test->test_falls_back_to_search_when_no_taxonomy_matches();
|
||||
$test->test_respects_limit_constraint();
|
||||
$test->test_returns_cached_transient_on_second_call();
|
||||
|
||||
echo "All tests passed!\n";
|
||||
}
|
||||
Reference in New Issue
Block a user