Compare commits

...

29 Commits

Author SHA1 Message Date
peptiderepo
9d4ce64c1c test(cpt): update rest_namespace assertion — now absent, defaults to wp/v2 2026-04-25 01:55:41 +07:00
peptiderepo
c24259f1d7 fix(cpt): bump version to 0.3.2, update CHANGELOG 2026-04-25 01:53:31 +07:00
peptiderepo
6ab828087d fix(cpt): bump version to 0.3.2, update CHANGELOG 2026-04-25 01:53:27 +07:00
peptiderepo
79259bf5ac fix(cpt): remove rest_namespace so Gutenberg block editor can load peptide posts (v0.3.2) 2026-04-25 01:52:43 +07:00
peptiderepo
deadccc27b ci: split deploy.yml into staging→production pipeline with approval gate 2026-04-24 13:11:24 +07:00
peptiderepo
05cb3caf0d hotfix: CHANGELOG v0.3.1 2026-04-24 12:07:42 +07:00
peptiderepo
21d05427d8 hotfix: bump to v0.3.1 2026-04-24 12:07:40 +07:00
peptiderepo
9c59fe6706 hotfix: add missing PR_Core_Related_Posts_Provider interface (site down — v0.3.1) 2026-04-24 12:07:23 +07:00
peptiderepo
e0fcace223 feat: add Related Articles Section feature (v0.3.0)
Merges feature/related-articles-section.

Adds peptide_topic taxonomy, provider interface, internal posts provider, card grid renderer, admin settings, CSS, and unit tests.

Reviewed: qa-reviews/peptide-repo-core/2026-04-24-a10610ded7df.md
CI: PHP 8.1/8.2/8.3 lint, PHPCS, 300-line check — all green.
2026-04-24 10:25:17 +07:00
peptiderepo
a10610ded7 fix: trim CPT docblock to stay under 300-line limit 2026-04-24 10:22:50 +07:00
peptiderepo
2195b449b2 fix: restore missing return + closing braces in sanitize_review_status 2026-04-24 10:21:20 +07:00
peptiderepo
ba4e11192f fix: bring CPT class under 300 lines 2026-04-24 10:15:45 +07:00
peptiderepo
0f62abd6e9 fix: instantiate PR_Core_Topic_Taxonomy in init flow 2026-04-24 10:14:31 +07:00
peptiderepo
b885673a1f feat: instantiate PR_Core_Topic_Taxonomy in init flow 2026-04-24 10:11:53 +07:00
peptiderepo
35e62e8cf1 fix: remove peptide_topic from CPT class, bring under 300 lines 2026-04-24 10:11:33 +07:00
peptiderepo
bd1b2dba48 feat: extract peptide_topic taxonomy to own class 2026-04-24 10:11:03 +07:00
peptiderepo
0dcad0de0b docs: add v0.3.0 changelog entry for Related Articles feature 2026-04-24 10:03:52 +07:00
peptiderepo
d5376d1612 chore: bump version to 0.3.0 2026-04-24 10:03:03 +07:00
peptiderepo
28e110f780 feat: integrate Related Articles section into main orchestrator 2026-04-24 10:02:50 +07:00
peptiderepo
bb34998d2c feat: integrate settings into admin initialization 2026-04-24 10:02:26 +07:00
peptiderepo
2e7efab97a feat: add PR Core settings page 2026-04-24 10:02:10 +07:00
peptiderepo
0210d73cf7 feat: add peptide_topic taxonomy registration 2026-04-24 10:01:47 +07:00
peptiderepo
14f8e6159a test: add unit tests for PR_Core_Internal_Posts_Provider 2026-04-24 10:01:00 +07:00
peptiderepo
6359a18fed feat: add related-posts CSS styles 2026-04-24 10:00:55 +07:00
peptiderepo
33dc8a183a feat: add related-posts card template part 2026-04-24 10:00:51 +07:00
peptiderepo
a21d35ee27 feat: add PR_Core_Related_Posts_Section renderer 2026-04-24 10:00:47 +07:00
peptiderepo
a906e1b701 feat: add PR_Core_Internal_Posts_Provider implementation 2026-04-24 10:00:43 +07:00
peptiderepo
4d8e98f34b feat: add PR_Core_Related_Posts_Provider interface 2026-04-24 10:00:38 +07:00
peptiderepo
bb3967a071 fix(repo): drop stale TAX_FAMILY references (v0.2.1 P1 hotfix) (#2)
v0.2.0 removed TAX_FAMILY from PR_Core_Peptide_CPT but left two stale
references in PR_Core_Peptide_Repository:
  - find_all() family filter branch (class-pr-core-peptide-repository.php:95-102)
  - post_to_dto() families taxonomy lookup (class-pr-core-peptide-repository.php:138)

JSON-LD emission on single-peptide templates called find_by_id() ->
post_to_dto() which hit the undefined constant and killed every peptide
detail page render mid-wp_head().

Fix:
  - family filter is silently ignored until a future minor bump
    removes it from the REST schema.
  - post_to_dto() hardcodes $families = [] so the DTO still carries
    the field (REST response shape unchanged for existing clients).

Out-of-scope follow-ups:
  - Post-mortem on why the v0.2.0 QA gate missed this (unit tests
    exercised the CPT class in isolation, not the repository's
    consumption of its constants).
  - Add an integration-style test for JSON-LD emission on a single
    peptide so any future CPT-class refactor catches repository drift.

Co-authored-by: peptiderepo <peptiderepo@users.noreply.github.com>
2026-04-22 16:56:42 +07:00
17 changed files with 1114 additions and 74 deletions

View File

@@ -7,67 +7,84 @@ on:
permissions:
contents: read
# Cancel previous deploys if a new push arrives while one is running.
concurrency:
group: deploy-peptide-repo-core
cancel-in-progress: true
jobs:
# ── Run CI first ─────────────────────────────────────────────────
ci:
name: Pre-deploy CI
uses: ./.github/workflows/ci.yml
# ── Deploy via rsync over SSH ────────────────────────────────────
deploy:
deploy-staging:
name: Deploy to Staging
needs: ci
name: Deploy to Production
runs-on: ubuntu-latest
environment: staging
steps:
- name: Checkout code
uses: actions/checkout@v4
- uses: actions/checkout@v4
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -p ${{ secrets.SSH_PORT }} ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
- name: Deploy via rsync
- name: Deploy via rsync → staging
run: |
rsync -avz --delete \
--exclude '.git' \
--exclude '.github' \
--exclude 'tests' \
--exclude 'phpunit.xml.dist' \
--exclude 'composer.json' \
--exclude 'composer.lock' \
--exclude 'vendor' \
--exclude 'phpcs.xml.dist' \
--exclude 'ARCHITECTURE.md' \
--exclude 'CONVENTIONS.md' \
--exclude 'CHANGELOG.md' \
--exclude '.git' --exclude '.github' \
--exclude 'tests' --exclude 'phpunit.xml.dist' \
--exclude 'composer.json' --exclude 'composer.lock' \
--exclude 'vendor' --exclude 'phpcs.xml.dist' \
--exclude 'ARCHITECTURE.md' --exclude 'CONVENTIONS.md' --exclude 'CHANGELOG.md' \
-e "ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }}" \
./ ${{ secrets.SSH_USERNAME }}@${{ secrets.SSH_HOST }}:~/domains/peptiderepo.com/public_html/staging/wp-content/plugins/peptide-repo-core/
- name: Staging health check
run: |
sleep 5
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://staging.peptiderepo.com/ || echo "000")
echo "Staging responded: HTTP $STATUS"
- name: Cleanup
if: always()
run: rm -f ~/.ssh/deploy_key
deploy-production:
name: Deploy to Production
needs: [ci, deploy-staging]
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -p ${{ secrets.SSH_PORT }} ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
- name: Deploy via rsync → production
run: |
rsync -avz --delete \
--exclude '.git' --exclude '.github' \
--exclude 'tests' --exclude 'phpunit.xml.dist' \
--exclude 'composer.json' --exclude 'composer.lock' \
--exclude 'vendor' --exclude 'phpcs.xml.dist' \
--exclude 'ARCHITECTURE.md' --exclude 'CONVENTIONS.md' --exclude 'CHANGELOG.md' \
-e "ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }}" \
./ ${{ secrets.SSH_USERNAME }}@${{ secrets.SSH_HOST }}:~/domains/peptiderepo.com/public_html/wp-content/plugins/peptide-repo-core/
- name: Purge LiteSpeed Cache
run: |
ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }} \
${{ secrets.SSH_USERNAME }}@${{ secrets.SSH_HOST }} \
"cd ~/domains/peptiderepo.com/public_html && find wp-content/litespeed/ -type f -delete 2>/dev/null; touch .lscache_purge 2>/dev/null || true"
- name: Health check
- name: Production health check
run: |
sleep 5
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://peptiderepo.com)
if [ "$STATUS" != "200" ] && [ "$STATUS" != "403" ]; then
echo "ERROR: Site returned unexpected HTTP $STATUS after deploy"
echo "ERROR: Site returned HTTP $STATUS"
exit 1
fi
echo "Site is responsive (HTTP $STATUS)"
echo "Production healthy (HTTP $STATUS)"
- name: Cleanup
if: always()
run: rm -f ~/.ssh/deploy_key

View File

@@ -3,6 +3,51 @@
All notable changes to Peptide Repo Core are documented here.
Format: [Semantic Versioning](https://semver.org/).
## [0.3.2] — 2026-04-25
### 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. (#5)
## [0.3.1] — 2026-04-24
### Fixed
- Add missing `PR_Core_Related_Posts_Provider` interface file (`class-pr-core-related-posts-provider.php`).
Omitted from v0.3.0 deploy caused a fatal on every page load. Site was down until this fix.
## [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
- **P1 hotfix: fatal `Undefined constant PR_Core_Peptide_CPT::TAX_FAMILY` on every peptide page.** The v0.2.0 release removed `TAX_FAMILY` from `PR_Core_Peptide_CPT` but left two stale references in `PR_Core_Peptide_Repository` (the `family` filter branch in `find_all()` and the families lookup in `post_to_dto()`). JSON-LD emission on single-peptide templates called `find_by_id()`, which called `post_to_dto()`, which hit the undefined constant and killed page rendering mid-`wp_head()`. QA gate on `874f93b` missed this because the unit tests cover the CPT class in isolation, not the repository's consumption of its constants. Post-mortem + QA checklist update to follow.
### Changed
- `PR_Core_Peptide_Repository::find_all()` — `family` filter is now silently ignored rather than throwing. Next minor bump will remove it from the REST schema.
- `PR_Core_Peptide_DTO::$families` is preserved but always populated as `[]`. Keeps REST response shape stable for existing clients; will be dropped with a release note.
## [0.2.0] — 2026-04-22
### Changed (BREAKING)

View File

@@ -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;
}
}

View File

@@ -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' ] );
}

View File

@@ -0,0 +1,176 @@
<?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 ) );
}
}

View File

@@ -6,11 +6,12 @@ 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.
* 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.
* @see ARCHITECTURE.md — Full data flow diagram.
*/
class PR_Core {
@@ -30,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
@@ -49,6 +54,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 +71,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.
*
@@ -101,7 +133,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.
*/
@@ -112,18 +144,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;

View File

@@ -0,0 +1,102 @@
<?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 ?: [];
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
/**
* Contract for any provider that retrieves posts related to a peptide.
*
* What: Interface defining the get_posts() contract for related-post providers.
* Who calls it: PR_Core_Related_Posts_Section accepts any implementation.
* Dependencies: None.
*
* @see includes/core/class-pr-core-internal-posts-provider.php — default implementation.
* @see includes/core/class-pr-core-related-posts-section.php — consumer.
*/
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;
}

View File

@@ -0,0 +1,126 @@
<?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 &rarr;', '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 ) );
}
}
}

View File

@@ -2,9 +2,10 @@
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).
* Who calls it: PR_Core::init() on plugins_loaded.
* Dependencies: None.
*
@@ -183,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. (#5)
'menu_position' => 25,
'menu_icon' => 'dashicons-database',
'capability_type' => 'post',
@@ -201,35 +203,36 @@ class PR_Core_Peptide_CPT {
* Register the `peptide_category` taxonomy.
*
* Guarded with `taxonomy_exists()` for the same reason CPT registration
* is guarded. The 8 existing terms + term_relationships stay intact —
* 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.
*
* v0.3.0: `peptide_topic` taxonomy moved to PR_Core_Topic_Taxonomy class.
*
* Side effects: registers taxonomy 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 ],
] );
}
/**

View File

@@ -0,0 +1,62 @@
<?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 ],
] );
}
}

View File

@@ -0,0 +1,21 @@
<?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;
}

View File

@@ -92,14 +92,10 @@ class PR_Core_Peptide_Repository {
] ];
}
if ( ! empty( $filters['family'] ) ) {
$args['tax_query'] = $args['tax_query'] ?? [];
$args['tax_query'][] = [
'taxonomy' => PR_Core_Peptide_CPT::TAX_FAMILY,
'field' => 'slug',
'terms' => sanitize_text_field( $filters['family'] ),
];
}
// v0.2.0: `family` filter silently ignored — `pr_peptide_family` taxonomy
// was removed with the CPT consolidation. Callers passing `family` get
// all-category results rather than a fatal. Remove this key from the
// REST schema in a future minor bump.
if ( ! empty( $filters['evidence_strength'] ) ) {
$args['meta_query'] = [ [
@@ -135,7 +131,10 @@ class PR_Core_Peptide_Repository {
$aliases = json_decode( $aliases_raw ?: '[]', true ) ?: [];
$categories = wp_get_post_terms( $post->ID, PR_Core_Peptide_CPT::TAX_CATEGORY, [ 'fields' => 'names' ] );
$families = wp_get_post_terms( $post->ID, PR_Core_Peptide_CPT::TAX_FAMILY, [ 'fields' => 'names' ] );
// v0.2.0: `pr_peptide_family` taxonomy removed. DTO `families` field
// preserved as empty array to keep the REST response shape stable for
// clients; will be dropped in a future minor bump with a release note.
$families = [];
return new PR_Core_Peptide_DTO( [
'id' => $post->ID,
@@ -156,7 +155,7 @@ class PR_Core_Peptide_Repository {
'last_editorial_review_at' => get_post_meta( $post->ID, 'last_editorial_review_at', true ) ?: '',
'medical_editor_id' => (int) get_post_meta( $post->ID, 'medical_editor_id', true ),
'categories' => is_array( $categories ) ? $categories : [],
'families' => is_array( $families ) ? $families : [],
'families' => $families,
] );
}
}

View File

@@ -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.0
* Version: 0.3.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.2.0' );
define( 'PR_CORE_VERSION', '0.3.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__ ) );

View File

@@ -0,0 +1,67 @@
<?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>

View File

@@ -0,0 +1,168 @@
<?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";
}

View File

@@ -10,7 +10,7 @@
* - TAX_FAMILY constant is removed.
* - register_peptide_post_type() guard: no-op when post_type_exists returns true.
* - register_peptide_post_type() payload: args match the harmonized contract
* (thumbnail in supports, rewrite slug peptides, pr-core/v1 REST namespace).
* (thumbnail in supports, rewrite slug peptides, rest_namespace absent — Gutenberg requires wp/v2).
* - register_taxonomies() guard: no-op when taxonomy_exists returns true.
* - register_taxonomies() only registers peptide_category; pr_peptide_family is gone.
*
@@ -65,7 +65,7 @@ pr_assert_equals( true, $args['public'] ?? null, 'public = true' );
pr_assert_equals( true, $args['publicly_queryable'] ?? null, 'publicly_queryable = true' );
pr_assert_equals( true, $args['show_in_rest'] ?? null, 'show_in_rest = true' );
pr_assert_equals( 'peptides', $args['rest_base'] ?? null, 'rest_base = "peptides"' );
pr_assert_equals( 'pr-core/v1', $args['rest_namespace'] ?? null, 'rest_namespace = "pr-core/v1"' );
pr_assert( ! array_key_exists( 'rest_namespace', $args ), 'rest_namespace absent — defaults to wp/v2 (Gutenberg requirement)' );
pr_assert_equals( 'post', $args['capability_type'] ?? null, 'capability_type = "post"' );
pr_assert_equals( true, $args['map_meta_cap'] ?? null, 'map_meta_cap = true' );
pr_assert_equals( false, $args['hierarchical'] ?? null, 'hierarchical = false' );