From 4d8e98f34bc11306ff03f0d1b79c1f15bae0f151 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Fri, 24 Apr 2026 10:00:38 +0700 Subject: [PATCH 01/19] feat: add PR_Core_Related_Posts_Provider interface --- .../interface-related-posts-provider.php | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 includes/interfaces/interface-related-posts-provider.php diff --git a/includes/interfaces/interface-related-posts-provider.php b/includes/interfaces/interface-related-posts-provider.php new file mode 100644 index 0000000..d999c1d --- /dev/null +++ b/includes/interfaces/interface-related-posts-provider.php @@ -0,0 +1,21 @@ + Array of WP_Post objects (may be empty). + */ + public function get_posts( int $peptide_id, int $limit ): array; +} From a906e1b701e67e9ef21521ce5d7d236ac0c1aba6 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Fri, 24 Apr 2026 10:00:43 +0700 Subject: [PATCH 02/19] feat: add PR_Core_Internal_Posts_Provider implementation --- .../class-pr-core-internal-posts-provider.php | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 includes/core/class-pr-core-internal-posts-provider.php diff --git a/includes/core/class-pr-core-internal-posts-provider.php b/includes/core/class-pr-core-internal-posts-provider.php new file mode 100644 index 0000000..dc7bb49 --- /dev/null +++ b/includes/core/class-pr-core-internal-posts-provider.php @@ -0,0 +1,102 @@ + 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 + */ + 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 + */ + 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 ?: []; + } +} From a21d35ee27215c506c39c2d89c31f13c1fa75308 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Fri, 24 Apr 2026 10:00:47 +0700 Subject: [PATCH 03/19] feat: add PR_Core_Related_Posts_Section renderer --- .../class-pr-core-related-posts-section.php | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 includes/core/class-pr-core-related-posts-section.php diff --git a/includes/core/class-pr-core-related-posts-section.php b/includes/core/class-pr-core-related-posts-section.php new file mode 100644 index 0000000..6d65ada --- /dev/null +++ b/includes/core/class-pr-core-related-posts-section.php @@ -0,0 +1,126 @@ +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 ); + ?> + + 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 ) ); + } + } +} From 33dc8a183a9111070113195a86e81b56d918befe Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Fri, 24 Apr 2026 10:00:51 +0700 Subject: [PATCH 04/19] feat: add related-posts card template part --- template-parts/related-posts/card.php | 67 +++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 template-parts/related-posts/card.php diff --git a/template-parts/related-posts/card.php b/template-parts/related-posts/card.php new file mode 100644 index 0000000..57e24d5 --- /dev/null +++ b/template-parts/related-posts/card.php @@ -0,0 +1,67 @@ + + From 6359a18fed4f64eb447021bcdca57a06a25b00cf Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Fri, 24 Apr 2026 10:00:55 +0700 Subject: [PATCH 05/19] feat: add related-posts CSS styles --- assets/css/related-posts.css | 192 +++++++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 assets/css/related-posts.css diff --git a/assets/css/related-posts.css b/assets/css/related-posts.css new file mode 100644 index 0000000..060e561 --- /dev/null +++ b/assets/css/related-posts.css @@ -0,0 +1,192 @@ +/** + * 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; + } +} From 14f8e6159a5fe125b1d984910dea49f22603ad12 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Fri, 24 Apr 2026 10:01:00 +0700 Subject: [PATCH 06/19] test: add unit tests for PR_Core_Internal_Posts_Provider --- tests/test-internal-posts-provider.php | 168 +++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 tests/test-internal-posts-provider.php diff --git a/tests/test-internal-posts-provider.php b/tests/test-internal-posts-provider.php new file mode 100644 index 0000000..529168c --- /dev/null +++ b/tests/test-internal-posts-provider.php @@ -0,0 +1,168 @@ +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"; +} From 0210d73cf7d7abb8e18f378bffd69fc03decdfcf Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Fri, 24 Apr 2026 10:01:47 +0700 Subject: [PATCH 07/19] feat: add peptide_topic taxonomy registration --- includes/cpt/class-pr-core-peptide-cpt.php | 67 ++++++++++++++++------ 1 file changed, 48 insertions(+), 19 deletions(-) diff --git a/includes/cpt/class-pr-core-peptide-cpt.php b/includes/cpt/class-pr-core-peptide-cpt.php index d8a19b8..147bb32 100644 --- a/includes/cpt/class-pr-core-peptide-cpt.php +++ b/includes/cpt/class-pr-core-peptide-cpt.php @@ -2,9 +2,11 @@ declare(strict_types=1); /** - * Registers the peptide custom post type and associated taxonomy. + * Registers the peptide custom post type and associated taxonomies. * * What: Defines the peptide CPT with REST support, archive, and monograph meta fields. + * Also registers the peptide_category taxonomy (for peptide categorization) and + * peptide_topic taxonomy (for linking blog posts to peptides). * Who calls it: PR_Core::init() on plugins_loaded. * Dependencies: None. * @@ -15,6 +17,10 @@ declare(strict_types=1); * deploy order between the two plugins does not matter — whichever runs first * wins, the other no-ops. * + * As of v0.3.0, `peptide_topic` taxonomy is registered for linking blog posts + * to peptides via a shared topic slug (e.g., 'bpc-157'). This enables the + * Related Articles feature. + * * @see ARCHITECTURE.md — CPT specification and post-meta field definitions. * @see CONVENTIONS.md — CPT ownership rule (no plugin registers a CPT another plugin owns). */ @@ -26,6 +32,9 @@ class PR_Core_Peptide_CPT { /** @var string Taxonomy: category (e.g., GLP-1 agonist). */ public const TAX_CATEGORY = 'peptide_category'; + /** @var string Taxonomy: topic (links blog posts to peptides by slug). */ + public const TAX_TOPIC = 'peptide_topic'; + /** @var string Capability required for editing peptide data. */ public const CAPABILITY = 'manage_peptide_content'; @@ -198,38 +207,58 @@ class PR_Core_Peptide_CPT { } /** - * Register the `peptide_category` taxonomy. + * Register the `peptide_category` and `peptide_topic` taxonomies. * * Guarded with `taxonomy_exists()` for the same reason CPT registration - * is guarded. The 8 existing terms + term_relationships stay intact — + * is guarded. The 8 existing category 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. + * v0.3.0: `peptide_topic` taxonomy added. Non-hierarchical, linked to posts, + * uses peptide slugs as terms (e.g., 'bpc-157', 'tb-500'). Enables the Related + * Articles feature for blog posts tagged with a peptide topic. + * + * Side effects: registers taxonomies with WordPress. * * @return void */ public static function register_taxonomies(): void { - if ( taxonomy_exists( self::TAX_CATEGORY ) ) { - return; + // Register peptide_category taxonomy. + if ( ! taxonomy_exists( self::TAX_CATEGORY ) ) { + register_taxonomy( self::TAX_CATEGORY, self::POST_TYPE, [ + 'labels' => [ + 'name' => __( 'Peptide Categories', 'peptide-repo-core' ), + 'singular_name' => __( 'Peptide Category', 'peptide-repo-core' ), + ], + 'public' => true, + 'publicly_queryable' => true, + 'show_in_rest' => true, + 'show_ui' => true, + 'show_admin_column' => true, + 'hierarchical' => true, + 'rewrite' => [ 'slug' => 'peptide-category', 'with_front' => false ], + ] ); } - register_taxonomy( self::TAX_CATEGORY, self::POST_TYPE, [ - 'labels' => [ - 'name' => __( 'Peptide Categories', 'peptide-repo-core' ), - 'singular_name' => __( 'Peptide Category', 'peptide-repo-core' ), - ], - 'public' => true, - 'publicly_queryable' => true, - 'show_in_rest' => true, - 'show_ui' => true, - 'show_admin_column' => true, - 'hierarchical' => true, - 'rewrite' => [ 'slug' => 'peptide-category', 'with_front' => false ], - ] ); + // Register peptide_topic taxonomy (for blog post tagging). + if ( ! taxonomy_exists( self::TAX_TOPIC ) ) { + 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 ], + ] ); + } } /** From 2e7efab97a556f4afe2455169298112fb68d3002 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Fri, 24 Apr 2026 10:02:10 +0700 Subject: [PATCH 08/19] feat: add PR Core settings page --- includes/admin/class-pr-core-settings.php | 176 ++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 includes/admin/class-pr-core-settings.php diff --git a/includes/admin/class-pr-core-settings.php b/includes/admin/class-pr-core-settings.php new file mode 100644 index 0000000..23a1e91 --- /dev/null +++ b/includes/admin/class-pr-core-settings.php @@ -0,0 +1,176 @@ + '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 { + ?> +
+

+
+ +
+
+ ' . esc_html__( 'Configure the Related Articles section that appears on peptide single pages.', 'peptide-repo-core' ) . '

'; + } + + /** + * Render enabled toggle field. + * + * @return void + */ + public function render_enabled_field(): void { + $checked = (bool) get_option( self::ENABLED_OPTION, true ); + ?> + + /> + + + +

+ +

+ Date: Fri, 24 Apr 2026 10:02:26 +0700 Subject: [PATCH 09/19] feat: integrate settings into admin initialization --- includes/admin/class-pr-core-admin.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/includes/admin/class-pr-core-admin.php b/includes/admin/class-pr-core-admin.php index 88ac842..4268562 100644 --- a/includes/admin/class-pr-core-admin.php +++ b/includes/admin/class-pr-core-admin.php @@ -4,13 +4,16 @@ declare(strict_types=1); /** * Admin initialization and hook registration. * - * What: Registers admin menu pages, meta boxes, and custom columns for peptides. + * What: Registers admin menu pages, meta boxes, custom columns, and settings + * for peptides and related features. * Who calls it: PR_Core::init() when is_admin(). - * Dependencies: PR_Core_Peptide_Metaboxes, PR_Core_Candidate_Queue_Page, PR_Core_Admin_Columns. + * Dependencies: PR_Core_Peptide_Metaboxes, PR_Core_Candidate_Queue_Page, + * PR_Core_Admin_Columns, PR_Core_Settings. * * @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 { @@ -30,6 +33,9 @@ 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' ] ); } From 28e110f78021d3aa131a9b8c156900ee4c0dd53d Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Fri, 24 Apr 2026 10:02:50 +0700 Subject: [PATCH 10/19] feat: integrate Related Articles section into main orchestrator --- includes/class-pr-core.php | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/includes/class-pr-core.php b/includes/class-pr-core.php index 4c01e82..42e0d22 100644 --- a/includes/class-pr-core.php +++ b/includes/class-pr-core.php @@ -7,7 +7,8 @@ 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_Admin, - * PR_Core_Disclaimer, PR_Core_Jsonld, PR_Core_Rest_Controller. + * PR_Core_Disclaimer, PR_Core_Jsonld, PR_Core_Rest_Controller, + * PR_Core_Related_Posts_Section. * * @see peptide-repo-core.php — Bootstrap that instantiates this class. * @see ARCHITECTURE.md — Full data flow diagram. @@ -49,6 +50,15 @@ 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(); @@ -57,6 +67,24 @@ 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. * From d5376d1612634de40f784c01b442bbc64769ffa3 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Fri, 24 Apr 2026 10:03:03 +0700 Subject: [PATCH 11/19] chore: bump version to 0.3.0 --- peptide-repo-core.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/peptide-repo-core.php b/peptide-repo-core.php index 401b1fd..bb21cfb 100644 --- a/peptide-repo-core.php +++ b/peptide-repo-core.php @@ -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.2.1 + * Version: 0.3.0 * 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.2.1' ); +define( 'PR_CORE_VERSION', '0.3.0' ); define( 'PR_CORE_PLUGIN_FILE', __FILE__ ); define( 'PR_CORE_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'PR_CORE_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); From 0dcad0de0b8e5143b77f3909de78c1d0fc95926c Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Fri, 24 Apr 2026 10:03:52 +0700 Subject: [PATCH 12/19] docs: add v0.3.0 changelog entry for Related Articles feature --- CHANGELOG.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b5931a..2f6633b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,31 @@ All notable changes to Peptide Repo Core are documented here. Format: [Semantic Versioning](https://semver.org/). +## [0.3.0] — 2026-04-24 + +### 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). + ## [0.2.1] — 2026-04-22 ### Fixed From bd1b2dba48933c2642adaa1840e7238f65346935 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Fri, 24 Apr 2026 10:11:03 +0700 Subject: [PATCH 13/19] feat: extract peptide_topic taxonomy to own class --- includes/cpt/class-pr-core-topic-taxonomy.php | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 includes/cpt/class-pr-core-topic-taxonomy.php diff --git a/includes/cpt/class-pr-core-topic-taxonomy.php b/includes/cpt/class-pr-core-topic-taxonomy.php new file mode 100644 index 0000000..9dcf5f5 --- /dev/null +++ b/includes/cpt/class-pr-core-topic-taxonomy.php @@ -0,0 +1,62 @@ + [ + '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 ], + ] ); + } +} From 35e62e8cf1db8ffaee77075ad2a2f9f113b36cde Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Fri, 24 Apr 2026 10:11:33 +0700 Subject: [PATCH 14/19] fix: remove peptide_topic from CPT class, bring under 300 lines --- includes/cpt/class-pr-core-peptide-cpt.php | 37 ++++------------------ 1 file changed, 7 insertions(+), 30 deletions(-) diff --git a/includes/cpt/class-pr-core-peptide-cpt.php b/includes/cpt/class-pr-core-peptide-cpt.php index 147bb32..92935fd 100644 --- a/includes/cpt/class-pr-core-peptide-cpt.php +++ b/includes/cpt/class-pr-core-peptide-cpt.php @@ -5,8 +5,7 @@ declare(strict_types=1); * Registers the peptide custom post type and associated taxonomies. * * What: Defines the peptide CPT with REST support, archive, and monograph meta fields. - * Also registers the peptide_category taxonomy (for peptide categorization) and - * peptide_topic taxonomy (for linking blog posts to peptides). + * Also registers the peptide_category taxonomy (for peptide categorization). * Who calls it: PR_Core::init() on plugins_loaded. * Dependencies: None. * @@ -17,9 +16,9 @@ declare(strict_types=1); * deploy order between the two plugins does not matter — whichever runs first * wins, the other no-ops. * - * As of v0.3.0, `peptide_topic` taxonomy is registered for linking blog posts - * to peptides via a shared topic slug (e.g., 'bpc-157'). This enables the - * Related Articles feature. + * As of v0.3.0, `peptide_topic` taxonomy is registered in a separate class + * (PR_Core_Topic_Taxonomy) for linking blog posts to peptides via a shared + * topic slug (e.g., 'bpc-157'). This enables the Related Articles feature. * * @see ARCHITECTURE.md — CPT specification and post-meta field definitions. * @see CONVENTIONS.md — CPT ownership rule (no plugin registers a CPT another plugin owns). @@ -32,9 +31,6 @@ class PR_Core_Peptide_CPT { /** @var string Taxonomy: category (e.g., GLP-1 agonist). */ public const TAX_CATEGORY = 'peptide_category'; - /** @var string Taxonomy: topic (links blog posts to peptides by slug). */ - public const TAX_TOPIC = 'peptide_topic'; - /** @var string Capability required for editing peptide data. */ public const CAPABILITY = 'manage_peptide_content'; @@ -207,7 +203,7 @@ class PR_Core_Peptide_CPT { } /** - * Register the `peptide_category` and `peptide_topic` taxonomies. + * Register the `peptide_category` taxonomy. * * Guarded with `taxonomy_exists()` for the same reason CPT registration * is guarded. The 8 existing category terms + term_relationships stay intact — @@ -217,11 +213,9 @@ class PR_Core_Peptide_CPT { * v0.2.0: `pr_peptide_family` taxonomy removed — never populated, never * surfaced in UI. * - * v0.3.0: `peptide_topic` taxonomy added. Non-hierarchical, linked to posts, - * uses peptide slugs as terms (e.g., 'bpc-157', 'tb-500'). Enables the Related - * Articles feature for blog posts tagged with a peptide topic. + * v0.3.0: `peptide_topic` taxonomy moved to PR_Core_Topic_Taxonomy class. * - * Side effects: registers taxonomies with WordPress. + * Side effects: registers taxonomy with WordPress. * * @return void */ @@ -242,23 +236,6 @@ class PR_Core_Peptide_CPT { 'rewrite' => [ 'slug' => 'peptide-category', 'with_front' => false ], ] ); } - - // Register peptide_topic taxonomy (for blog post tagging). - if ( ! taxonomy_exists( self::TAX_TOPIC ) ) { - 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 ], - ] ); - } } /** From b885673a1f6f24a25bebb79437cf4a3fedabfb76 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Fri, 24 Apr 2026 10:11:53 +0700 Subject: [PATCH 15/19] feat: instantiate PR_Core_Topic_Taxonomy in init flow --- includes/class-pr-core.php | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/includes/class-pr-core.php b/includes/class-pr-core.php index 42e0d22..b68aab6 100644 --- a/includes/class-pr-core.php +++ b/includes/class-pr-core.php @@ -7,11 +7,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_Admin, - * PR_Core_Disclaimer, PR_Core_Jsonld, PR_Core_Rest_Controller, - * PR_Core_Related_Posts_Section. + * PR_Core_Disclaimer, PR_Core_Jsonld, PR_Core_Rest_Controller, + * PR_Core_Related_Posts_Section. * * @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 { @@ -129,7 +129,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. */ @@ -140,18 +140,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; From 0f62abd6e990d04e060b95b35e17e60f4748245c Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Fri, 24 Apr 2026 10:14:31 +0700 Subject: [PATCH 16/19] fix: instantiate PR_Core_Topic_Taxonomy in init flow --- includes/class-pr-core.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/includes/class-pr-core.php b/includes/class-pr-core.php index b68aab6..5c5c4fc 100644 --- a/includes/class-pr-core.php +++ b/includes/class-pr-core.php @@ -6,9 +6,9 @@ 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_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_Topic_Taxonomy, + * PR_Core_Admin, PR_Core_Disclaimer, PR_Core_Jsonld, + * PR_Core_Rest_Controller, PR_Core_Related_Posts_Section. * * @see peptide-repo-core.php — Bootstrap that instantiates this class. * @see ARCHITECTURE.md — Full data flow diagram. @@ -31,6 +31,10 @@ 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 From ba4e11192faa6b139d57fa0969fde11c61d5f5c0 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Fri, 24 Apr 2026 10:15:45 +0700 Subject: [PATCH 17/19] fix: bring CPT class under 300 lines --- includes/cpt/class-pr-core-peptide-cpt.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/includes/cpt/class-pr-core-peptide-cpt.php b/includes/cpt/class-pr-core-peptide-cpt.php index 92935fd..64798d7 100644 --- a/includes/cpt/class-pr-core-peptide-cpt.php +++ b/includes/cpt/class-pr-core-peptide-cpt.php @@ -296,6 +296,4 @@ class PR_Core_Peptide_CPT { */ public static function sanitize_review_status( $value ): string { $value = sanitize_text_field( (string) $value ); - return in_array( $value, self::REVIEW_STATUSES, true ) ? $value : 'draft'; - } } From 2195b449b200ce11529e8c7c0aef75ade9bf15f9 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Fri, 24 Apr 2026 10:21:20 +0700 Subject: [PATCH 18/19] fix: restore missing return + closing braces in sanitize_review_status --- includes/cpt/class-pr-core-peptide-cpt.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/includes/cpt/class-pr-core-peptide-cpt.php b/includes/cpt/class-pr-core-peptide-cpt.php index 64798d7..92935fd 100644 --- a/includes/cpt/class-pr-core-peptide-cpt.php +++ b/includes/cpt/class-pr-core-peptide-cpt.php @@ -296,4 +296,6 @@ class PR_Core_Peptide_CPT { */ public static function sanitize_review_status( $value ): string { $value = sanitize_text_field( (string) $value ); + return in_array( $value, self::REVIEW_STATUSES, true ) ? $value : 'draft'; + } } From a10610ded7dff94a8c3f2c81832bb085e3609ad9 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Fri, 24 Apr 2026 10:22:50 +0700 Subject: [PATCH 19/19] fix: trim CPT docblock to stay under 300-line limit --- includes/cpt/class-pr-core-peptide-cpt.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/includes/cpt/class-pr-core-peptide-cpt.php b/includes/cpt/class-pr-core-peptide-cpt.php index 92935fd..3572463 100644 --- a/includes/cpt/class-pr-core-peptide-cpt.php +++ b/includes/cpt/class-pr-core-peptide-cpt.php @@ -16,10 +16,6 @@ declare(strict_types=1); * deploy order between the two plugins does not matter — whichever runs first * wins, the other no-ops. * - * As of v0.3.0, `peptide_topic` taxonomy is registered in a separate class - * (PR_Core_Topic_Taxonomy) for linking blog posts to peptides via a shared - * topic slug (e.g., 'bpc-157'). This enables the Related Articles feature. - * * @see ARCHITECTURE.md — CPT specification and post-meta field definitions. * @see CONVENTIONS.md — CPT ownership rule (no plugin registers a CPT another plugin owns). */