From 5c758e256859f6122bc0c42b1b3c747b4a9dd1ca Mon Sep 17 00:00:00 2001 From: Christian Krakau-Louis Date: Sat, 16 May 2026 22:39:40 +0200 Subject: [PATCH] Add publishing and SEO abilities --- .github/workflows/release.yml | 60 +++ README.md | 24 + ROADMAP.md | 34 ++ codex-blog-abilities.php | 973 +++++++++++++++++++++++++++++++++- 4 files changed, 1081 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 ROADMAP.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..bc9dbc3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,60 @@ +name: Build release ZIP + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.2" + coverage: none + + - name: Lint plugin + run: php -l codex-blog-abilities.php + + - name: Build ZIP + shell: bash + run: | + set -euo pipefail + PLUGIN_SLUG="codex-blog-abilities" + VERSION="${GITHUB_REF_NAME:-manual}" + ZIP_NAME="${PLUGIN_SLUG}-${VERSION}.zip" + + rm -rf build + mkdir -p "build/${PLUGIN_SLUG}" + + rsync -a \ + --exclude ".git" \ + --exclude ".github" \ + --exclude "build" \ + --exclude "*.zip" \ + ./ "build/${PLUGIN_SLUG}/" + + cd build + zip -r "${ZIP_NAME}" "${PLUGIN_SLUG}" + + - name: Upload ZIP artifact + uses: actions/upload-artifact@v4 + with: + name: codex-blog-abilities-zip + path: build/codex-blog-abilities-*.zip + + - name: Create GitHub Release + if: github.ref_type == 'tag' + env: + GH_TOKEN: ${{ github.token }} + run: gh release create "${GITHUB_REF_NAME}" build/codex-blog-abilities-"${GITHUB_REF_NAME}".zip --generate-notes diff --git a/README.md b/README.md index 013635e..a7b3612 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,14 @@ The plugin registers public MCP abilities under the `codex-blog/` namespace: - `create-post` - `update-post` - `delete-post` +- `schedule-post` +- `upload-media-from-url` +- `set-featured-image` +- `set-featured-image-from-url` +- `remove-featured-image` +- `get-seo-meta` +- `update-seo-meta` +- `audit-post-seo` - `list-terms` - `create-term` - `update-term` @@ -47,6 +55,11 @@ For example, post edits require `edit_post`, plugin management requires The option update ability is intentionally restricted to a small allowlist of common site settings. It does not expose arbitrary `update_option()` access. +SEO metadata updates use All in One SEO's runtime API when it is available and +also write known post-meta fallbacks for compatibility. The SEO audit is +deterministic: it checks content and metadata structure but does not generate +ranking claims. + Deactivation of `mcp-adapter` and this plugin is blocked by default so an MCP client does not accidentally remove its own control plane. @@ -72,6 +85,17 @@ Run a PHP syntax check before deploying: php -l codex-blog-abilities.php ``` +## Releases + +Push a semantic version tag to build an installable ZIP through GitHub Actions: + +```bash +git tag v0.2.0 +git push origin main v0.2.0 +``` + +The release artifact is named `codex-blog-abilities-.zip`. + ## License GPL-2.0-or-later. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..cbdf955 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,34 @@ +# Roadmap + +This plugin is meant to become the narrow, trusted control layer for managing a +modern WordPress blog through MCP. + +## 0.2.x: Publishing Essentials + +- Featured image workflows from existing media and remote image URLs. +- Scheduled publishing for posts, pages, and custom post types. +- SEO metadata read/write support with All in One SEO compatibility. +- Deterministic SEO audits for drafts and published posts. +- GitHub Actions release ZIPs. + +## 0.3.x: Editorial Workflows + +- Draft review summaries with before/after diffs. +- Bulk post operations with dry-run support. +- Safer taxonomy workflows for category/tag cleanup. +- Redirect suggestions when slugs change. +- Media replacement and alt-text audit helpers. + +## 0.4.x: Site Health And Performance + +- Cache inspection helpers for common WordPress/Nginx/Cloudflare setups. +- Attachment size and responsive image diagnostics. +- Plugin inventory with update, risk, and redundancy signals. +- Read-only performance snapshots for key pages. + +## Later + +- Optional structured data helpers for articles, recipes, and FAQs. +- Editorial calendar resources for MCP clients. +- Backup/export preflight checks before destructive operations. +- Fine-grained capability profiles for different MCP clients. diff --git a/codex-blog-abilities.php b/codex-blog-abilities.php index c9ba1e8..42a4a32 100644 --- a/codex-blog-abilities.php +++ b/codex-blog-abilities.php @@ -2,7 +2,7 @@ /** * Plugin Name: Codex Blog Abilities * Description: Exposes guarded WordPress administration abilities to the WordPress MCP Adapter. - * Version: 0.1.0 + * Version: 0.2.0 * Author: Codex * License: GPL-2.0-or-later * License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -163,6 +163,11 @@ add_action( 'type' => 'integer', 'minimum' => 0, ), + 'featured_media_id' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => 'Attachment ID to set as the featured image.', + ), 'terms' => array( 'type' => 'object', 'description' => 'Object keyed by taxonomy slug. Values are arrays of term IDs, slugs, or names.', @@ -203,6 +208,11 @@ add_action( 'type' => 'integer', 'minimum' => 0, ), + 'featured_media_id' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => 'Attachment ID to set as the featured image.', + ), 'terms' => array( 'type' => 'object', 'additionalProperties' => array( @@ -246,6 +256,225 @@ add_action( true ); + codex_blog_register_ability( + 'schedule-post', + 'Schedule Post', + 'Schedule an existing post, page, or custom post type item for future publication using the site timezone by default.', + codex_blog_schema( + array( + 'id' => array( + 'type' => 'integer', + 'minimum' => 1, + ), + 'date' => array( + 'type' => 'string', + 'description' => 'Future date/time. ISO 8601 is recommended; date-only strings are interpreted in the provided timezone or site timezone.', + ), + 'timezone' => array( + 'type' => 'string', + 'description' => 'Optional timezone such as Europe/Berlin. Defaults to the site timezone.', + ), + ), + array( 'id', 'date' ) + ), + 'codex_blog_schedule_post', + 'codex_blog_can_schedule_post', + false + ); + + codex_blog_register_ability( + 'upload-media-from-url', + 'Upload Media From URL', + 'Download a remote media file into the WordPress media library and optionally attach metadata.', + codex_blog_schema( + array( + 'url' => array( + 'type' => 'string', + 'format' => 'uri', + 'description' => 'Remote media URL to sideload.', + ), + 'post_id' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => 'Optional parent post ID.', + ), + 'title' => array( 'type' => 'string' ), + 'alt_text' => array( 'type' => 'string' ), + 'caption' => array( 'type' => 'string' ), + 'description' => array( 'type' => 'string' ), + ), + array( 'url' ) + ), + 'codex_blog_upload_media_from_url', + static function() { + return current_user_can( 'upload_files' ); + }, + false + ); + + codex_blog_register_ability( + 'set-featured-image', + 'Set Featured Image', + 'Set an existing image attachment as the featured image for a post, page, or custom post type item.', + codex_blog_schema( + array( + 'post_id' => array( + 'type' => 'integer', + 'minimum' => 1, + ), + 'attachment_id' => array( + 'type' => 'integer', + 'minimum' => 1, + ), + ), + array( 'post_id', 'attachment_id' ) + ), + 'codex_blog_set_featured_image', + 'codex_blog_can_set_featured_image', + false + ); + + codex_blog_register_ability( + 'set-featured-image-from-url', + 'Set Featured Image From URL', + 'Download a remote image, add it to the media library, and set it as the featured image for a post.', + codex_blog_schema( + array( + 'post_id' => array( + 'type' => 'integer', + 'minimum' => 1, + ), + 'url' => array( + 'type' => 'string', + 'format' => 'uri', + ), + 'title' => array( 'type' => 'string' ), + 'alt_text' => array( 'type' => 'string' ), + 'caption' => array( 'type' => 'string' ), + 'description' => array( 'type' => 'string' ), + ), + array( 'post_id', 'url' ) + ), + 'codex_blog_set_featured_image_from_url', + 'codex_blog_can_set_featured_image_from_url', + false + ); + + codex_blog_register_ability( + 'remove-featured-image', + 'Remove Featured Image', + 'Remove the featured image from a post, page, or custom post type item.', + codex_blog_schema( + array( + 'post_id' => array( + 'type' => 'integer', + 'minimum' => 1, + ), + ), + array( 'post_id' ) + ), + 'codex_blog_remove_featured_image', + static function( $input ) { + $args = codex_blog_input( $input ); + return ! empty( $args['post_id'] ) && current_user_can( 'edit_post', (int) $args['post_id'] ); + }, + false + ); + + codex_blog_register_ability( + 'get-seo-meta', + 'Get SEO Meta', + 'Read SEO metadata for a post, including All in One SEO data when available and known post meta fallbacks.', + codex_blog_schema( + array( + 'post_id' => array( + 'type' => 'integer', + 'minimum' => 1, + ), + ), + array( 'post_id' ) + ), + 'codex_blog_get_seo_meta', + static function( $input ) { + $args = codex_blog_input( $input ); + return ! empty( $args['post_id'] ) && current_user_can( 'edit_post', (int) $args['post_id'] ); + }, + true + ); + + codex_blog_register_ability( + 'update-seo-meta', + 'Update SEO Meta', + 'Update SEO title, description, canonical URL, keywords, social metadata, and robots directives where supported.', + codex_blog_schema( + array( + 'post_id' => array( + 'type' => 'integer', + 'minimum' => 1, + ), + 'title' => array( 'type' => 'string' ), + 'description' => array( 'type' => 'string' ), + 'canonical_url' => array( + 'type' => 'string', + 'format' => 'uri', + ), + 'keywords' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + 'focus_keyphrase' => array( 'type' => 'string' ), + 'og_title' => array( 'type' => 'string' ), + 'og_description' => array( 'type' => 'string' ), + 'og_image_url' => array( + 'type' => 'string', + 'format' => 'uri', + ), + 'twitter_title' => array( 'type' => 'string' ), + 'twitter_description' => array( 'type' => 'string' ), + 'twitter_image_url' => array( + 'type' => 'string', + 'format' => 'uri', + ), + 'robots' => array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => array( + 'noindex' => array( 'type' => 'boolean' ), + 'nofollow' => array( 'type' => 'boolean' ), + ), + ), + ), + array( 'post_id' ) + ), + 'codex_blog_update_seo_meta', + static function( $input ) { + $args = codex_blog_input( $input ); + return ! empty( $args['post_id'] ) && current_user_can( 'edit_post', (int) $args['post_id'] ); + }, + false + ); + + codex_blog_register_ability( + 'audit-post-seo', + 'Audit Post SEO', + 'Run a deterministic SEO content audit for a post: metadata length, featured image, headings, links, terms, slug, and word count.', + codex_blog_schema( + array( + 'post_id' => array( + 'type' => 'integer', + 'minimum' => 1, + ), + ), + array( 'post_id' ) + ), + 'codex_blog_audit_post_seo', + static function( $input ) { + $args = codex_blog_input( $input ); + return ! empty( $args['post_id'] ) && current_user_can( 'edit_post', (int) $args['post_id'] ); + }, + true + ); + codex_blog_register_ability( 'list-terms', 'List Terms', @@ -647,6 +876,7 @@ function codex_blog_post_to_array( WP_Post $post, $include_content = false ) { 'link' => get_permalink( $post ), 'parent_id' => (int) $post->post_parent, 'comment_count' => (int) $post->comment_count, + 'featured_image' => codex_blog_get_featured_image( $post->ID ), ); if ( $include_content ) { @@ -684,6 +914,16 @@ function codex_blog_get_post_terms( $post_id ) { return $result; } +function codex_blog_get_featured_image( $post_id ) { + $attachment_id = get_post_thumbnail_id( $post_id ); + + if ( ! $attachment_id ) { + return null; + } + + return codex_blog_attachment_to_array( $attachment_id ); +} + function codex_blog_get_site_info() { $current_user = wp_get_current_user(); $theme = wp_get_theme(); @@ -835,6 +1075,13 @@ function codex_blog_create_post( $input ) { return $terms_result; } + if ( ! empty( $args['featured_media_id'] ) ) { + $featured_result = codex_blog_set_featured_image_for_post( $post_id, (int) $args['featured_media_id'] ); + if ( is_wp_error( $featured_result ) ) { + return $featured_result; + } + } + return array( 'success' => true, 'post' => codex_blog_post_to_array( get_post( $post_id ), true ), @@ -894,6 +1141,13 @@ function codex_blog_update_post( $input ) { return $terms_result; } + if ( ! empty( $args['featured_media_id'] ) ) { + $featured_result = codex_blog_set_featured_image_for_post( $post->ID, (int) $args['featured_media_id'] ); + if ( is_wp_error( $featured_result ) ) { + return $featured_result; + } + } + return array( 'success' => true, 'post' => codex_blog_post_to_array( get_post( $post->ID ), true ), @@ -923,6 +1177,270 @@ function codex_blog_delete_post( $input ) { ); } +function codex_blog_can_schedule_post( $input ) { + $args = codex_blog_input( $input ); + $post = ! empty( $args['id'] ) ? get_post( (int) $args['id'] ) : null; + + if ( ! $post || ! current_user_can( 'edit_post', $post->ID ) ) { + return false; + } + + $type_obj = get_post_type_object( $post->post_type ); + return $type_obj && current_user_can( $type_obj->cap->publish_posts ); +} + +function codex_blog_schedule_post( $input ) { + $args = codex_blog_input( $input ); + $post = get_post( (int) $args['id'] ); + + if ( ! $post ) { + return new WP_Error( 'codex_blog_not_found', 'Post not found.' ); + } + + $date = codex_blog_parse_datetime( $args['date'] ?? '', $args['timezone'] ?? '' ); + if ( is_wp_error( $date ) ) { + return $date; + } + + $site_timezone = wp_timezone(); + $now = new DateTimeImmutable( 'now', $site_timezone ); + if ( $date <= $now ) { + return new WP_Error( 'codex_blog_schedule_past_date', 'Scheduled date must be in the future.' ); + } + + $local = $date->setTimezone( $site_timezone )->format( 'Y-m-d H:i:s' ); + $gmt = $date->setTimezone( new DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' ); + + $result = wp_update_post( + array( + 'ID' => $post->ID, + 'post_status' => 'future', + 'post_date' => $local, + 'post_date_gmt' => $gmt, + 'edit_date' => true, + ), + true + ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + return array( + 'success' => true, + 'post' => codex_blog_post_to_array( get_post( $post->ID ), true ), + ); +} + +function codex_blog_parse_datetime( $date, $timezone ) { + $date = is_string( $date ) ? trim( $date ) : ''; + if ( '' === $date ) { + return new WP_Error( 'codex_blog_missing_date', 'A date value is required.' ); + } + + try { + $tz = $timezone ? new DateTimeZone( sanitize_text_field( $timezone ) ) : wp_timezone(); + return new DateTimeImmutable( $date, $tz ); + } catch ( Exception $e ) { + return new WP_Error( 'codex_blog_invalid_date', 'Invalid date or timezone: ' . $e->getMessage() ); + } +} + +function codex_blog_can_set_featured_image( $input ) { + $args = codex_blog_input( $input ); + return ! empty( $args['post_id'] ) && ! empty( $args['attachment_id'] ) && current_user_can( 'edit_post', (int) $args['post_id'] ); +} + +function codex_blog_can_set_featured_image_from_url( $input ) { + $args = codex_blog_input( $input ); + return ! empty( $args['post_id'] ) && current_user_can( 'edit_post', (int) $args['post_id'] ) && current_user_can( 'upload_files' ); +} + +function codex_blog_upload_media_from_url( $input ) { + $args = codex_blog_input( $input ); + $attachment_id = codex_blog_sideload_attachment_from_url( $args ); + + if ( is_wp_error( $attachment_id ) ) { + return $attachment_id; + } + + return array( + 'success' => true, + 'attachment' => codex_blog_attachment_to_array( $attachment_id ), + ); +} + +function codex_blog_set_featured_image( $input ) { + $args = codex_blog_input( $input ); + $result = codex_blog_set_featured_image_for_post( (int) $args['post_id'], (int) $args['attachment_id'] ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + return array( + 'success' => true, + 'post' => codex_blog_post_to_array( get_post( (int) $args['post_id'] ), true ), + 'featured_image' => codex_blog_attachment_to_array( (int) $args['attachment_id'] ), + ); +} + +function codex_blog_set_featured_image_from_url( $input ) { + $args = codex_blog_input( $input ); + if ( empty( $args['post_id'] ) ) { + return new WP_Error( 'codex_blog_missing_post', 'A post_id is required.' ); + } + + $args['post_id'] = (int) $args['post_id']; + $attachment_id = codex_blog_sideload_attachment_from_url( $args ); + if ( is_wp_error( $attachment_id ) ) { + return $attachment_id; + } + + $result = codex_blog_set_featured_image_for_post( $args['post_id'], $attachment_id ); + if ( is_wp_error( $result ) ) { + return $result; + } + + return array( + 'success' => true, + 'post' => codex_blog_post_to_array( get_post( $args['post_id'] ), true ), + 'featured_image' => codex_blog_attachment_to_array( $attachment_id ), + ); +} + +function codex_blog_remove_featured_image( $input ) { + $args = codex_blog_input( $input ); + $post_id = (int) $args['post_id']; + $old_id = get_post_thumbnail_id( $post_id ); + + delete_post_thumbnail( $post_id ); + + return array( + 'success' => true, + 'post' => codex_blog_post_to_array( get_post( $post_id ), true ), + 'previous_featured_image_id' => $old_id ? (int) $old_id : null, + ); +} + +function codex_blog_sideload_attachment_from_url( $args ) { + $url = isset( $args['url'] ) ? esc_url_raw( $args['url'] ) : ''; + if ( ! $url || ( function_exists( 'wp_http_validate_url' ) && ! wp_http_validate_url( $url ) ) ) { + return new WP_Error( 'codex_blog_invalid_media_url', 'A valid remote media URL is required.' ); + } + + $post_id = ! empty( $args['post_id'] ) ? (int) $args['post_id'] : 0; + if ( $post_id && ! current_user_can( 'edit_post', $post_id ) ) { + return new WP_Error( 'codex_blog_forbidden_parent_post', 'Current user cannot edit the parent post.' ); + } + + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/media.php'; + require_once ABSPATH . 'wp-admin/includes/image.php'; + + $tmp = download_url( $url, 30 ); + if ( is_wp_error( $tmp ) ) { + return $tmp; + } + + $path = wp_parse_url( $url, PHP_URL_PATH ); + $name = $path ? wp_basename( $path ) : 'remote-media'; + $name = sanitize_file_name( $name ? $name : 'remote-media' ); + + $file = array( + 'name' => $name, + 'tmp_name' => $tmp, + ); + + $attachment_id = media_handle_sideload( $file, $post_id, isset( $args['description'] ) ? sanitize_textarea_field( $args['description'] ) : null ); + if ( is_wp_error( $attachment_id ) ) { + @unlink( $tmp ); + return $attachment_id; + } + + $metadata_result = codex_blog_update_attachment_fields( $attachment_id, $args ); + if ( is_wp_error( $metadata_result ) ) { + return $metadata_result; + } + + return $attachment_id; +} + +function codex_blog_update_attachment_fields( $attachment_id, $args ) { + $update = array( 'ID' => (int) $attachment_id ); + + if ( array_key_exists( 'title', $args ) ) { + $update['post_title'] = sanitize_text_field( $args['title'] ); + } + if ( array_key_exists( 'caption', $args ) ) { + $update['post_excerpt'] = sanitize_textarea_field( $args['caption'] ); + } + if ( array_key_exists( 'description', $args ) ) { + $update['post_content'] = sanitize_textarea_field( $args['description'] ); + } + + if ( count( $update ) > 1 ) { + $result = wp_update_post( $update, true ); + if ( is_wp_error( $result ) ) { + return $result; + } + } + + if ( array_key_exists( 'alt_text', $args ) ) { + update_post_meta( $attachment_id, '_wp_attachment_image_alt', sanitize_text_field( $args['alt_text'] ) ); + } + + return true; +} + +function codex_blog_attachment_to_array( $attachment_id ) { + $attachment = get_post( (int) $attachment_id ); + + if ( ! $attachment || 'attachment' !== $attachment->post_type ) { + return null; + } + + $meta = wp_get_attachment_metadata( $attachment->ID ); + + return array( + 'id' => (int) $attachment->ID, + 'title' => get_the_title( $attachment ), + 'mime_type' => $attachment->post_mime_type, + 'url' => wp_get_attachment_url( $attachment->ID ), + 'alt_text' => get_post_meta( $attachment->ID, '_wp_attachment_image_alt', true ), + 'caption' => $attachment->post_excerpt, + 'description' => $attachment->post_content, + 'parent_id' => (int) $attachment->post_parent, + 'width' => isset( $meta['width'] ) ? (int) $meta['width'] : null, + 'height' => isset( $meta['height'] ) ? (int) $meta['height'] : null, + ); +} + +function codex_blog_set_featured_image_for_post( $post_id, $attachment_id ) { + $post = get_post( (int) $post_id ); + $attachment = get_post( (int) $attachment_id ); + + if ( ! $post ) { + return new WP_Error( 'codex_blog_not_found', 'Post not found.' ); + } + if ( ! $attachment || 'attachment' !== $attachment->post_type ) { + return new WP_Error( 'codex_blog_attachment_not_found', 'Attachment not found.' ); + } + if ( ! wp_attachment_is_image( $attachment->ID ) ) { + return new WP_Error( 'codex_blog_attachment_not_image', 'Featured image must be an image attachment.' ); + } + if ( ! current_user_can( 'edit_post', $post->ID ) ) { + return new WP_Error( 'codex_blog_forbidden_post', 'Current user cannot edit this post.' ); + } + + $result = set_post_thumbnail( $post->ID, $attachment->ID ); + if ( ! $result && (int) get_post_thumbnail_id( $post->ID ) !== (int) $attachment->ID ) { + return new WP_Error( 'codex_blog_featured_image_failed', 'Featured image could not be set.' ); + } + + return true; +} + function codex_blog_apply_terms( $post_id, $terms_by_taxonomy ) { if ( empty( $terms_by_taxonomy ) ) { return true; @@ -952,6 +1470,447 @@ function codex_blog_apply_terms( $post_id, $terms_by_taxonomy ) { return true; } +function codex_blog_get_seo_meta( $input ) { + $args = codex_blog_input( $input ); + $post_id = (int) $args['post_id']; + + if ( ! get_post( $post_id ) ) { + return new WP_Error( 'codex_blog_not_found', 'Post not found.' ); + } + + return array( + 'seo' => codex_blog_read_seo_meta( $post_id ), + ); +} + +function codex_blog_update_seo_meta( $input ) { + $args = codex_blog_input( $input ); + $post_id = (int) $args['post_id']; + + if ( ! get_post( $post_id ) ) { + return new WP_Error( 'codex_blog_not_found', 'Post not found.' ); + } + + $updates = codex_blog_collect_seo_updates( $args ); + if ( empty( $updates ) ) { + return array( + 'success' => true, + 'updated' => false, + 'seo' => codex_blog_read_seo_meta( $post_id ), + ); + } + + $aioseo_result = codex_blog_save_aioseo_meta( $post_id, $updates ); + if ( is_wp_error( $aioseo_result ) ) { + return $aioseo_result; + } + + codex_blog_update_fallback_seo_meta( $post_id, $updates ); + + return array( + 'success' => true, + 'updated' => true, + 'source' => $aioseo_result ? 'aioseo' : 'post_meta', + 'seo' => codex_blog_read_seo_meta( $post_id ), + ); +} + +function codex_blog_audit_post_seo( $input ) { + $args = codex_blog_input( $input ); + $post_id = (int) $args['post_id']; + $post = get_post( $post_id ); + + if ( ! $post ) { + return new WP_Error( 'codex_blog_not_found', 'Post not found.' ); + } + + $seo = codex_blog_read_seo_meta( $post_id ); + $rendered = do_blocks( $post->post_content ); + $text = trim( preg_replace( '/\s+/u', ' ', wp_strip_all_tags( strip_shortcodes( $rendered ) ) ) ); + $title = ! empty( $seo['title'] ) ? $seo['title'] : get_the_title( $post ); + $description = ! empty( $seo['description'] ) ? $seo['description'] : $post->post_excerpt; + $link_counts = codex_blog_count_links( $rendered ); + $h2_count = (int) preg_match_all( '/ 65 ) { + $issues[] = 'SEO title length should usually be between 30 and 65 characters.'; + } + + if ( empty( $seo['description'] ) ) { + $issues[] = 'Meta description is missing.'; + } elseif ( $desc_length < 70 || $desc_length > 160 ) { + $issues[] = 'Meta description length should usually be between 70 and 160 characters.'; + } + + if ( ! get_post_thumbnail_id( $post_id ) ) { + $issues[] = 'Featured image is missing.'; + } + if ( $word_count < 300 ) { + $issues[] = 'Content is shorter than 300 words.'; + } + if ( 0 === $h2_count ) { + $issues[] = 'No H2 headings found.'; + } + if ( 0 === $link_counts['internal'] ) { + $issues[] = 'No internal links found.'; + } + if ( empty( $terms['category'] ) ) { + $issues[] = 'No category assigned.'; + } + if ( empty( $terms['post_tag'] ) ) { + $issues[] = 'No tags assigned.'; + } + if ( strlen( $post->post_name ) > 75 ) { + $issues[] = 'Slug is longer than 75 characters.'; + } + + return array( + 'post_id' => $post_id, + 'metrics' => array( + 'title_length' => $title_length, + 'meta_description_length' => $desc_length, + 'word_count' => $word_count, + 'h2_count' => (int) $h2_count, + 'internal_link_count' => $link_counts['internal'], + 'external_link_count' => $link_counts['external'], + 'has_featured_image' => (bool) get_post_thumbnail_id( $post_id ), + 'has_custom_seo_title' => ! empty( $seo['title'] ), + 'has_meta_description' => ! empty( $seo['description'] ), + 'category_count' => empty( $terms['category'] ) ? 0 : count( $terms['category'] ), + 'tag_count' => empty( $terms['post_tag'] ) ? 0 : count( $terms['post_tag'] ), + 'slug_length' => strlen( $post->post_name ), + ), + 'issues' => $issues, + 'seo' => $seo, + ); +} + +function codex_blog_read_seo_meta( $post_id ) { + $model = codex_blog_get_aioseo_post( $post_id ); + $seo = array( + 'post_id' => (int) $post_id, + 'source' => $model ? 'aioseo' : 'post_meta', + 'title' => null, + 'description' => null, + 'canonical_url' => null, + 'keywords' => null, + 'focus_keyphrase' => null, + 'og_title' => null, + 'og_description' => null, + 'og_image_url' => null, + 'twitter_title' => null, + 'twitter_description' => null, + 'twitter_image_url' => null, + 'robots' => array( + 'noindex' => null, + 'nofollow' => null, + ), + ); + + if ( $model ) { + $seo['title'] = codex_blog_object_property( $model, 'title' ); + $seo['description'] = codex_blog_object_property( $model, 'description' ); + $seo['canonical_url'] = codex_blog_object_property( $model, 'canonical_url' ); + $seo['keywords'] = codex_blog_object_property( $model, 'keywords' ); + $seo['og_title'] = codex_blog_object_property( $model, 'og_title' ); + $seo['og_description'] = codex_blog_object_property( $model, 'og_description' ); + $seo['og_image_url'] = codex_blog_object_property( $model, 'og_image_url' ); + $seo['twitter_title'] = codex_blog_object_property( $model, 'twitter_title' ); + $seo['twitter_description'] = codex_blog_object_property( $model, 'twitter_description' ); + $seo['twitter_image_url'] = codex_blog_object_property( $model, 'twitter_image_url' ); + $seo['robots']['noindex'] = codex_blog_bool_from_mixed( codex_blog_object_property( $model, 'robots_noindex' ) ); + $seo['robots']['nofollow'] = codex_blog_bool_from_mixed( codex_blog_object_property( $model, 'robots_nofollow' ) ); + } + + $seo['title'] = codex_blog_value_or_meta( $seo['title'], $post_id, array( '_aioseo_title', '_aioseop_title', '_yoast_wpseo_title' ) ); + $seo['description'] = codex_blog_value_or_meta( $seo['description'], $post_id, array( '_aioseo_description', '_aioseop_description', '_yoast_wpseo_metadesc' ) ); + $seo['canonical_url'] = codex_blog_value_or_meta( $seo['canonical_url'], $post_id, array( '_aioseo_canonical_url', '_aioseop_custom_link', '_yoast_wpseo_canonical' ) ); + $seo['keywords'] = codex_blog_value_or_meta( $seo['keywords'], $post_id, array( '_aioseo_keywords', '_aioseop_keywords', '_yoast_wpseo_metakeywords' ) ); + $seo['focus_keyphrase'] = codex_blog_value_or_meta( $seo['focus_keyphrase'], $post_id, array( '_aioseo_focus_keyphrase', '_yoast_wpseo_focuskw' ) ); + $seo['og_title'] = codex_blog_value_or_meta( $seo['og_title'], $post_id, array( '_aioseo_og_title', '_yoast_wpseo_opengraph-title' ) ); + $seo['og_description'] = codex_blog_value_or_meta( $seo['og_description'], $post_id, array( '_aioseo_og_description', '_yoast_wpseo_opengraph-description' ) ); + $seo['og_image_url'] = codex_blog_value_or_meta( $seo['og_image_url'], $post_id, array( '_aioseo_og_image_url', '_yoast_wpseo_opengraph-image' ) ); + $seo['twitter_title'] = codex_blog_value_or_meta( $seo['twitter_title'], $post_id, array( '_aioseo_twitter_title', '_yoast_wpseo_twitter-title' ) ); + $seo['twitter_description'] = codex_blog_value_or_meta( $seo['twitter_description'], $post_id, array( '_aioseo_twitter_description', '_yoast_wpseo_twitter-description' ) ); + $seo['twitter_image_url'] = codex_blog_value_or_meta( $seo['twitter_image_url'], $post_id, array( '_aioseo_twitter_image_url', '_yoast_wpseo_twitter-image' ) ); + + if ( null === $seo['robots']['noindex'] ) { + $seo['robots']['noindex'] = codex_blog_bool_from_mixed( codex_blog_first_post_meta( $post_id, array( '_aioseo_robots_noindex', '_aioseop_noindex', '_yoast_wpseo_meta-robots-noindex' ) ) ); + } + if ( null === $seo['robots']['nofollow'] ) { + $seo['robots']['nofollow'] = codex_blog_bool_from_mixed( codex_blog_first_post_meta( $post_id, array( '_aioseo_robots_nofollow', '_aioseop_nofollow', '_yoast_wpseo_meta-robots-nofollow' ) ) ); + } + + return $seo; +} + +function codex_blog_get_aioseo_post( $post_id ) { + if ( ! function_exists( 'aioseo' ) ) { + return null; + } + + try { + $aioseo = aioseo(); + if ( is_object( $aioseo ) && isset( $aioseo->post ) && is_object( $aioseo->post ) && method_exists( $aioseo->post, 'getPost' ) ) { + return $aioseo->post->getPost( $post_id ); + } + } catch ( Throwable $e ) { + return null; + } + + return null; +} + +function codex_blog_object_property( $object, $property ) { + if ( ! is_object( $object ) ) { + return null; + } + + try { + if ( property_exists( $object, $property ) ) { + return $object->$property; + } + + if ( method_exists( $object, '__get' ) ) { + $value = $object->$property; + return null === $value ? null : $value; + } + + return null; + } catch ( Throwable $e ) { + return null; + } +} + +function codex_blog_value_or_meta( $value, $post_id, $meta_keys ) { + if ( null !== $value && '' !== $value && array() !== $value ) { + return $value; + } + + return codex_blog_first_post_meta( $post_id, $meta_keys ); +} + +function codex_blog_first_post_meta( $post_id, $meta_keys ) { + foreach ( $meta_keys as $meta_key ) { + $value = get_post_meta( $post_id, $meta_key, true ); + if ( null !== $value && '' !== $value && array() !== $value ) { + return $value; + } + } + + return null; +} + +function codex_blog_collect_seo_updates( $args ) { + $updates = array(); + $text_fields = array( + 'title', + 'focus_keyphrase', + 'og_title', + 'twitter_title', + ); + $textarea_fields = array( + 'description', + 'og_description', + 'twitter_description', + ); + $url_fields = array( + 'canonical_url', + 'og_image_url', + 'twitter_image_url', + ); + + foreach ( $text_fields as $field ) { + if ( array_key_exists( $field, $args ) ) { + $updates[ $field ] = sanitize_text_field( $args[ $field ] ); + } + } + + foreach ( $textarea_fields as $field ) { + if ( array_key_exists( $field, $args ) ) { + $updates[ $field ] = sanitize_textarea_field( $args[ $field ] ); + } + } + + foreach ( $url_fields as $field ) { + if ( array_key_exists( $field, $args ) ) { + $updates[ $field ] = esc_url_raw( $args[ $field ] ); + } + } + + if ( array_key_exists( 'keywords', $args ) ) { + $keywords = is_array( $args['keywords'] ) ? $args['keywords'] : array( $args['keywords'] ); + $updates['keywords'] = implode( ', ', array_filter( array_map( 'sanitize_text_field', $keywords ) ) ); + } + + if ( ! empty( $args['robots'] ) && is_array( $args['robots'] ) ) { + $updates['robots'] = array(); + if ( array_key_exists( 'noindex', $args['robots'] ) ) { + $updates['robots']['noindex'] = (bool) $args['robots']['noindex']; + } + if ( array_key_exists( 'nofollow', $args['robots'] ) ) { + $updates['robots']['nofollow'] = (bool) $args['robots']['nofollow']; + } + } + + return $updates; +} + +function codex_blog_save_aioseo_meta( $post_id, $updates ) { + $model = codex_blog_get_aioseo_post( $post_id ); + if ( ! $model ) { + return false; + } + + $property_map = array( + 'title' => 'title', + 'description' => 'description', + 'canonical_url' => 'canonical_url', + 'keywords' => 'keywords', + 'og_title' => 'og_title', + 'og_description' => 'og_description', + 'og_image_url' => 'og_image_url', + 'twitter_title' => 'twitter_title', + 'twitter_description' => 'twitter_description', + 'twitter_image_url' => 'twitter_image_url', + ); + + try { + foreach ( $property_map as $input_key => $property ) { + if ( array_key_exists( $input_key, $updates ) ) { + $model->$property = $updates[ $input_key ]; + } + } + + if ( ! empty( $updates['robots'] ) ) { + $model->robots_default = false; + if ( array_key_exists( 'noindex', $updates['robots'] ) ) { + $model->robots_noindex = (bool) $updates['robots']['noindex']; + } + if ( array_key_exists( 'nofollow', $updates['robots'] ) ) { + $model->robots_nofollow = (bool) $updates['robots']['nofollow']; + } + } + + if ( method_exists( $model, 'save' ) ) { + $model->save(); + return true; + } + } catch ( Throwable $e ) { + return new WP_Error( 'codex_blog_aioseo_save_failed', 'AIOSEO metadata could not be saved: ' . $e->getMessage() ); + } + + return false; +} + +function codex_blog_update_fallback_seo_meta( $post_id, $updates ) { + $meta_map = array( + 'title' => array( '_aioseo_title', '_aioseop_title', '_yoast_wpseo_title' ), + 'description' => array( '_aioseo_description', '_aioseop_description', '_yoast_wpseo_metadesc' ), + 'canonical_url' => array( '_aioseo_canonical_url', '_aioseop_custom_link', '_yoast_wpseo_canonical' ), + 'keywords' => array( '_aioseo_keywords', '_aioseop_keywords', '_yoast_wpseo_metakeywords' ), + 'focus_keyphrase' => array( '_aioseo_focus_keyphrase', '_yoast_wpseo_focuskw' ), + 'og_title' => array( '_aioseo_og_title', '_yoast_wpseo_opengraph-title' ), + 'og_description' => array( '_aioseo_og_description', '_yoast_wpseo_opengraph-description' ), + 'og_image_url' => array( '_aioseo_og_image_url', '_yoast_wpseo_opengraph-image' ), + 'twitter_title' => array( '_aioseo_twitter_title', '_yoast_wpseo_twitter-title' ), + 'twitter_description' => array( '_aioseo_twitter_description', '_yoast_wpseo_twitter-description' ), + 'twitter_image_url' => array( '_aioseo_twitter_image_url', '_yoast_wpseo_twitter-image' ), + ); + + foreach ( $meta_map as $input_key => $meta_keys ) { + if ( array_key_exists( $input_key, $updates ) ) { + codex_blog_update_meta_aliases( $post_id, $meta_keys, $updates[ $input_key ] ); + } + } + + if ( ! empty( $updates['robots'] ) ) { + if ( array_key_exists( 'noindex', $updates['robots'] ) ) { + codex_blog_update_meta_aliases( $post_id, array( '_aioseo_robots_noindex', '_aioseop_noindex', '_yoast_wpseo_meta-robots-noindex' ), $updates['robots']['noindex'] ? '1' : '0' ); + } + if ( array_key_exists( 'nofollow', $updates['robots'] ) ) { + codex_blog_update_meta_aliases( $post_id, array( '_aioseo_robots_nofollow', '_aioseop_nofollow', '_yoast_wpseo_meta-robots-nofollow' ), $updates['robots']['nofollow'] ? '1' : '0' ); + } + } +} + +function codex_blog_update_meta_aliases( $post_id, $meta_keys, $value ) { + update_post_meta( $post_id, $meta_keys[0], $value ); + + foreach ( array_slice( $meta_keys, 1 ) as $meta_key ) { + if ( '' !== get_post_meta( $post_id, $meta_key, true ) ) { + update_post_meta( $post_id, $meta_key, $value ); + } + } +} + +function codex_blog_bool_from_mixed( $value ) { + if ( null === $value || '' === $value ) { + return null; + } + if ( is_bool( $value ) ) { + return $value; + } + if ( is_numeric( $value ) ) { + return (bool) (int) $value; + } + + $value = strtolower( sanitize_text_field( (string) $value ) ); + if ( in_array( $value, array( '1', 'yes', 'true', 'on' ), true ) ) { + return true; + } + if ( in_array( $value, array( '0', 'no', 'false', 'off' ), true ) ) { + return false; + } + + return null; +} + +function codex_blog_count_links( $html ) { + $result = array( + 'internal' => 0, + 'external' => 0, + ); + + if ( ! preg_match_all( '/]*href=[\"\']([^\"\']+)[\"\']/i', $html, $matches ) ) { + return $result; + } + + $home_host = wp_parse_url( home_url(), PHP_URL_HOST ); + foreach ( $matches[1] as $href ) { + $href = trim( html_entity_decode( $href ) ); + if ( '' === $href || '#' === $href[0] || 0 === strpos( $href, 'mailto:' ) || 0 === strpos( $href, 'tel:' ) ) { + continue; + } + + $host = wp_parse_url( $href, PHP_URL_HOST ); + if ( ! $host || $host === $home_host ) { + $result['internal']++; + } else { + $result['external']++; + } + } + + return $result; +} + +function codex_blog_word_count( $text ) { + $text = trim( wp_strip_all_tags( $text ) ); + if ( '' === $text ) { + return 0; + } + + $words = preg_split( '/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY ); + return $words ? count( $words ) : 0; +} + function codex_blog_list_terms( $input ) { $args = codex_blog_input( $input ); $taxonomy = sanitize_key( $args['taxonomy'] ); @@ -1167,15 +2126,9 @@ function codex_blog_list_media( $input ) { 'total_pages' => (int) $query->max_num_pages, 'media' => array_map( static function( WP_Post $post ) { - return array( - 'id' => (int) $post->ID, - 'title' => get_the_title( $post ), - 'mime_type' => $post->post_mime_type, - 'url' => wp_get_attachment_url( $post->ID ), - 'date' => get_post_time( DATE_ATOM, false, $post ), - 'alt_text' => get_post_meta( $post->ID, '_wp_attachment_image_alt', true ), - 'parent_id' => (int) $post->post_parent, - ); + $attachment = codex_blog_attachment_to_array( $post->ID ); + $attachment['date'] = get_post_time( DATE_ATOM, false, $post ); + return $attachment; }, $query->posts ),