diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c4e5d74..c9ba076 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -86,7 +86,7 @@ jobs: WP_VERSION: ${{ matrix.wp }} # --fail-on-skipped: without a WordPress the integration tests skip themselves, which must not pass here. - name: Integration tests against WordPress ${{ matrix.wp }} - run: vendor/bin/phpunit -c phpunit-integration.xml.dist --exclude-group tsf --fail-on-skipped + run: vendor/bin/phpunit -c phpunit-integration.xml.dist --exclude-group tsf,yoast --fail-on-skipped env: MONORANKS_WP_PATH: /tmp/wordpress # The SEO Framework is installed but not activated; MONORANKS_WP_PLUGINS loads it for this run only. diff --git a/CHANGELOG.md b/CHANGELOG.md index ff6a193..ce1675a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to the MonoRanks WordPress plugin. The format follows [Keep a Changelog](https://keepachangelog.com/); versions follow [Semantic Versioning](https://semver.org/). +## v0.1.24 - 2026-10-23 + +- **New:** Category, tag and product category pages are sent to MonoRanks with their SEO title and meta description. +- **New:** MonoRanks can write and undo the SEO title and meta description of these pages in Rank Math and Yoast SEO. Without an SEO plugin, it writes the category description. +- **Enhancement:** Edited or deleted categories and tags are rechecked in MonoRanks within minutes. + ## v0.1.23 - 2026-10-17 - **Enhancement:** Sites MonoRanks cannot reach, such as local or firewalled sites, now connect with one button too: the key comes back by itself, nothing to copy. diff --git a/docs/api.md b/docs/api.md index d5c088f..d4d67a3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -104,6 +104,25 @@ Since 0.1.15 an SEO field (`seo_title`, `seo_description`, `canonical`, `noindex The plugin's status, sent after connecting, on "Send content now" and daily (the same object as `GET /wp-json/monoranks/v1/status` without the user fields). `seo_plugin` is `yoast`, `rankmath`, `aioseo`, `tsf` (The SEO Framework, since 0.1.15), `other` (an SEO plugin MonoRanks cannot write into, since 0.1.15) or `none`; `seo_plugin_name` (since 0.1.15) is its name, or `""`. If MonoRanks answers 400 to a status whose `seo_plugin` is `tsf` or `other`, the plugin sends it once more with `seo_plugin: "none"`. +## Category, tag and other archive pages (since 0.1.24) + +Terms of every archive taxonomy (public, publicly queryable, with a rewrite: `category`, `post_tag`, `product_cat`, `product_tag` and the like; not `post_format`) are sent in `POST /api/connector/content` after the posts, in the same item shape. Terms with no posts are left out, like unpublished posts. The term pages follow the last post page, so `pages` and `total` count both. + +```json +{ "id": "term:category:12", "type": "term:category", "type_label": "Category", "url": "https://example.com/category/privacy/", "title": "Privacy", "excerpt": "Plain-text description, 40 words", "seo_title": "", "seo_description": "Privacy guides…", "canonical": "", "noindex": false, "author": null, "author_avatar": null, "categories": [], "tags": [], "published_at": null, "modified_at": null, "images": [], "term_id": 12, "taxonomy": "category" } +``` + +- `seo_title` / `seo_description`: the stored values (not rendered templates), `""` when unset. Rank Math: term meta `rank_math_title` / `rank_math_description`. Yoast SEO: option `wpseo_taxonomy_meta[taxonomy][term_id]` keys `wpseo_title` / `wpseo_desc`. No SEO plugin: no title, and the term's own description as `seo_description`. +- `noindex`: Rank Math's `rank_math_robots` or Yoast's `wpseo_noindex`; `false` otherwise. +- Status adds `"term_seo"` to `can_write` when the SEO setup is Rank Math, Yoast or none, and `taxonomies: { "category": 6, "post_tag": 2 }` (terms with posts). +- `POST /api/connector/changes`: a created or edited term is sent as `{ "post_id": "term:category:12", "url": "…", "event": "updated", "item": { … } }`; a deleted term, or one with no posts left, as `event: "unpublished"`. + +Writes go through `POST /wp-json/monoranks/v1/apply` with `term_id` and `taxonomy` instead of `post_id`: `{ "id": "chg_9", "field": "seo_description", "term_id": 12, "taxonomy": "category", "value": "…", "expected": "" }`. Only `seo_title` and `seo_description` (else `field_not_allowed`). The result has the usual shape plus `term_id` and `taxonomy`, and `cache` for the archive URL. Undo is a normal change back to `previous`, expecting the written value; `""` clears the field. + +- Rank Math: term meta. Yoast SEO: through `WPSEO_Taxonomy_Meta::set_values()` with the term's other values kept, then Yoast's indexable for the term is rebuilt. No SEO plugin: the description is the term description (HTML allowed as in core); a title is refused with `not_supported`, `"reason": "Titles of category pages need an SEO plugin."`. +- Any other SEO plugin (All in One SEO, The SEO Framework, others): `not_supported` with a short reason. +- `changed_since_preview` with `current` when `expected` differs from the stored value; `term_not_found` for an unknown term or a taxonomy without archive pages; `forbidden` when the user lacks `manage_options` or the taxonomy's `edit_terms` capability. + ## What to do next and per-post data: API v1 reads The Overview's **What to do next** cards and the **MonoRanks** box in the post editor read MonoRanks API v1 with the same key: `GET {api_base}/api/v1/sites/{site_id}/`, `Authorization: Bearer `. `site_id` is the one stored at pairing (or the overview's `site_id` for a key pasted by hand). The full shapes are in MonoRanks' `/api/v1/openapi.json`; the plugin keeps only the fields below and ignores the rest. diff --git a/monoranks.php b/monoranks.php index decd2d0..52ead5a 100644 --- a/monoranks.php +++ b/monoranks.php @@ -4,7 +4,7 @@ * Description: Connects this site to MonoRanks (SEO, AEO and GEO audits): sends published content metadata and applies the fixes you approve there, with undo. * Plugin URI: https://monoranks.com/product/wordpress/ * Author URI: https://veronalabs.com/ - * Version: 0.1.23 + * Version: 0.1.24 * Requires at least: 6.0 * Requires PHP: 7.4 * Author: VeronaLabs @@ -26,7 +26,7 @@ return; } -define( 'MONORANKS_CONNECTOR_VERSION', '0.1.23' ); +define( 'MONORANKS_CONNECTOR_VERSION', '0.1.24' ); define( 'MONORANKS_CONNECTOR_FILE', __FILE__ ); // MonoRanks address used when a key is pasted by hand. The download build sets it; wp-config.php may override it. if ( ! defined( 'MONORANKS_API_BASE' ) ) { @@ -40,6 +40,9 @@ add_action( 'init', array( 'MonoRanks\\AiAccess', 'maybe_serve_llms' ), 1 ); add_action( 'transition_post_status', array( 'MonoRanks\\Notifier', 'on_transition' ), 20, 3 ); add_action( 'post_updated', array( 'MonoRanks\\Notifier', 'on_update' ), 20, 3 ); +add_action( 'created_term', array( 'MonoRanks\\Notifier', 'on_term' ), 20, 3 ); +add_action( 'edited_term', array( 'MonoRanks\\Notifier', 'on_term' ), 20, 3 ); +add_action( 'delete_term', array( 'MonoRanks\\Notifier', 'on_term_delete' ), 20, 4 ); add_action( 'shutdown', array( 'MonoRanks\\Notifier', 'flush' ) ); add_action( \MonoRanks\Sync::STEP_HOOK, array( 'MonoRanks\\Sync', 'run' ) ); add_action( \MonoRanks\Sync::DAILY_HOOK, array( 'MonoRanks\\Sync', 'daily' ) ); diff --git a/readme.txt b/readme.txt index bf43a1b..02a760d 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: seo, aeo, geo, llms-txt, ai-seo Requires at least: 6.0 Tested up to: 7.1 Requires PHP: 7.4 -Stable tag: 0.1.23 +Stable tag: 0.1.24 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -58,8 +58,9 @@ The plugin talks to [MonoRanks](https://monoranks.com). **Nothing is sent until **What is sent, over HTTPS** * **Published content details:** post type, URL, title, a 40-word excerpt, author display name and avatar URL, categories, tags, publish and modified dates, SEO title, meta description, canonical URL, noindex flag, and image URLs with their alt text. Sent when you connect, when MonoRanks asks, and once a day. +* **Category, tag and other archive pages:** name, URL, a 40-word description, SEO title, meta description and noindex flag. * **When you publish, update or unpublish a post:** its ID, URL and the details above, so MonoRanks can recheck that page. -* **Plugin status:** site name and address, plugin and WordPress version, active SEO plugin, and the number of published items per type. +* **Plugin status:** site name and address, plugin and WordPress version, active SEO plugin, and the number of published items per type and of categories, tags and other terms. **Never sent:** drafts, private or password-protected posts, comments, user emails or passwords, plugin or theme settings. @@ -133,6 +134,11 @@ Please report security bugs found in the source code of the MonoRanks plugin thr == Changelog == += 0.1.24 - 2026-10-23 = +* **New:** Category, tag and product category pages are sent to MonoRanks with their SEO title and meta description. +* **New:** MonoRanks can write and undo the SEO title and meta description of these pages in Rank Math and Yoast SEO. Without an SEO plugin, it writes the category description. +* **Enhancement:** Edited or deleted categories and tags are rechecked in MonoRanks within minutes. + = 0.1.23 - 2026-10-17 = * **Enhancement:** Sites MonoRanks cannot reach, such as local or firewalled sites, now connect with one button too: the key comes back by itself, nothing to copy. * **Enhancement:** Simpler Settings and Overview screens: short rows, with details in (i) tips. diff --git a/src/Actions.php b/src/Actions.php index 5456410..cd7ca31 100644 --- a/src/Actions.php +++ b/src/Actions.php @@ -122,6 +122,10 @@ public static function change_from_ready( array $fix ) { $c = array( 'id' => $fix['id'], 'field' => $fix['field'], 'value' => (string) $fix['after'], 'expected' => $fix['before'] ); if ( $fix['post_id'] ) { $c['post_id'] = (int) $fix['post_id']; + } elseif ( ! empty( $fix['term_id'] ) && ! empty( $fix['taxonomy'] ) ) { + // A category, tag or other archive page. + $c['term_id'] = (int) $fix['term_id']; + $c['taxonomy'] = (string) $fix['taxonomy']; } if ( 'alt' === $fix['field'] ) { $c['attachment_id'] = (int) $fix['attachment_id']; diff --git a/src/CachePurger.php b/src/CachePurger.php index 505a6c3..1e2ff8f 100644 --- a/src/CachePurger.php +++ b/src/CachePurger.php @@ -91,6 +91,19 @@ public static function url( $url ) { return self::urls( array( $url ), $post_id ); } + /** + * Purge a term's archive page (a category, tag or product category page). Only its first page is purged; later + * pages of the archive show no SEO title or description of their own in most themes. + * + * @param int $term_id Term ID. + * @param string $taxonomy Taxonomy name. + * @return array{purged: string[], failed: string[], none: bool} + */ + public static function term( $term_id, $taxonomy ) { + $link = get_term_link( (int) $term_id, (string) $taxonomy ); + return self::urls( is_string( $link ) && '' !== $link ? array( $link ) : array(), 0 ); + } + /** Shared by post() and url(): the filter, the purge and the action. */ private static function urls( array $urls, $post_id ) { /** diff --git a/src/Content.php b/src/Content.php index 4b9bff9..afb54fc 100644 --- a/src/Content.php +++ b/src/Content.php @@ -12,6 +12,66 @@ public static function post_types() { return array_values( $types ); } + /** + * Taxonomies whose terms have archive pages: public, publicly queryable and with a rewrite (category, post_tag, + * product_cat, product_tag and the like). Post formats are left out: their archives are not pages anyone writes for. + */ + public static function taxonomies() { + $out = array(); + foreach ( get_taxonomies( array( 'public' => true ), 'objects' ) as $name => $tax ) { + if ( 'post_format' === $name || empty( $tax->publicly_queryable ) || empty( $tax->rewrite ) ) { + continue; + } + $out[] = $name; + } + return $out; + } + + /** The content item id of a term: "term::". */ + public static function term_key( $taxonomy, $term_id ) { + return 'term:' . $taxonomy . ':' . (int) $term_id; + } + + /** One term (an archive page) as a content item, in the same shape as a post, or null when it has no link. */ + public static function term_item( \WP_Term $term ) { + $link = get_term_link( $term ); + if ( is_wp_error( $link ) || ! $link ) { + return null; + } + $tax = get_taxonomy( $term->taxonomy ); + return array( + 'id' => self::term_key( $term->taxonomy, $term->term_id ), + 'type' => 'term:' . $term->taxonomy, + 'type_label' => $tax ? $tax->labels->singular_name : $term->taxonomy, + 'url' => $link, + 'title' => $term->name, + 'excerpt' => wp_trim_words( wp_strip_all_tags( (string) $term->description ), 40, '' ), + 'author' => null, + 'author_avatar' => null, + 'categories' => array(), + 'tags' => array(), + 'published_at' => null, + 'modified_at' => null, + 'seo_title' => SeoFields::term_get( $term->term_id, $term->taxonomy, 'seo_title' ), + 'seo_description' => SeoFields::term_get( $term->term_id, $term->taxonomy, 'seo_description' ), + 'canonical' => '', + 'noindex' => SeoFields::term_noindex( $term->term_id, $term->taxonomy ), + 'images' => array(), + 'term_id' => (int) $term->term_id, + 'taxonomy' => $term->taxonomy, + ); + } + + /** Terms with at least one post (an empty archive is not a page, like an unpublished post). */ + public static function term_count( $taxonomies = null ) { + $taxonomies = null === $taxonomies ? self::taxonomies() : $taxonomies; + if ( ! $taxonomies ) { + return 0; + } + $n = wp_count_terms( array( 'taxonomy' => $taxonomies, 'hide_empty' => true ) ); + return is_wp_error( $n ) ? 0 : (int) $n; + } + public static function item( \WP_Post $post ) { $author = get_userdata( (int) $post->post_author ); $cats = array(); @@ -88,8 +148,15 @@ public static function item( \WP_Post $post ) { ); } + /** + * One page of the content sync: published posts first, then the terms of archive taxonomies, in pages of $per_page. + * Post pages are numbered as before; term pages follow the last post page. Terms have no modified date, so a sync + * limited to recent changes ($modified_after) sends posts only. + */ public static function page( $page, $per_page, $modified_after = null ) { - $args = array( + $page = max( 1, (int) $page ); + $per_page = max( 1, (int) $per_page ); + $args = array( 'post_type' => self::post_types(), 'post_status' => 'publish', 'has_password' => false, @@ -102,11 +169,39 @@ public static function page( $page, $per_page, $modified_after = null ) { if ( $modified_after ) { $args['date_query'] = array( array( 'column' => 'post_modified_gmt', 'after' => $modified_after ) ); } - $query = new \WP_Query( $args ); + $query = new \WP_Query( $args ); + $items = array_map( array( __CLASS__, 'item' ), $query->posts ); + $post_total = (int) $query->found_posts; + if ( ! $query->posts && $page > 1 ) { + // Past the last post page WP_Query reports no total, so count the posts on their own. + $count = new \WP_Query( array_merge( $args, array( 'posts_per_page' => 1, 'paged' => 1, 'fields' => 'ids' ) ) ); + $post_total = (int) $count->found_posts; + } + $post_pages = (int) ceil( $post_total / $per_page ); + $taxonomies = $modified_after ? array() : self::taxonomies(); + $term_total = self::term_count( $taxonomies ); + if ( $term_total && $page > $post_pages ) { + $terms = get_terms( + array( + 'taxonomy' => $taxonomies, + 'hide_empty' => true, + 'orderby' => 'term_id', + 'order' => 'ASC', + 'number' => $per_page, + 'offset' => ( $page - $post_pages - 1 ) * $per_page, + ) + ); + foreach ( is_array( $terms ) ? $terms : array() as $term ) { + $item = $term instanceof \WP_Term ? self::term_item( $term ) : null; + if ( $item ) { + $items[] = $item; + } + } + } return array( - 'items' => array_map( array( __CLASS__, 'item' ), $query->posts ), - 'total' => (int) $query->found_posts, - 'pages' => (int) $query->max_num_pages, + 'items' => $items, + 'total' => $post_total + $term_total, + 'pages' => $post_pages + (int) ceil( $term_total / $per_page ), ); } } diff --git a/src/Insights.php b/src/Insights.php index 4a21a9e..d098fe3 100644 --- a/src/Insights.php +++ b/src/Insights.php @@ -343,6 +343,8 @@ public static function normalise_overview( array $o ) { 'field' => (string) $row['field'], 'post_id' => isset( $row['post_id'] ) ? (int) $row['post_id'] : 0, 'attachment_id' => isset( $row['attachment_id'] ) ? (int) $row['attachment_id'] : 0, + 'term_id' => isset( $row['term_id'] ) ? (int) $row['term_id'] : 0, + 'taxonomy' => isset( $row['taxonomy'] ) && is_string( $row['taxonomy'] ) ? sanitize_key( $row['taxonomy'] ) : '', 'from' => $str( isset( $row['from'] ) ? $row['from'] : '', 2000 ), 'op' => isset( $row['op'] ) && 'remove' === $row['op'] ? 'remove' : 'insert_top', 'title' => $str( isset( $row['title'] ) ? $row['title'] : '' ), diff --git a/src/Notifier.php b/src/Notifier.php index 73f31b3..780acd7 100644 --- a/src/Notifier.php +++ b/src/Notifier.php @@ -37,12 +37,51 @@ private static function queue( $post, $event ) { self::$queue[ $post->ID ] = array( 'post_id' => $post->ID, 'url' => get_permalink( $post ), 'event' => $event ); } + /** created_term and edited_term: the term's archive page is sent again (or as gone when it has no posts left). */ + public static function on_term( $term_id, $tt_id = 0, $taxonomy = '' ) { + if ( Writer::$applying || ! in_array( (string) $taxonomy, Content::taxonomies(), true ) ) { + return; + } + $key = Content::term_key( $taxonomy, $term_id ); + if ( isset( self::$queue[ $key ] ) && 'unpublished' === self::$queue[ $key ]['event'] ) { + return; + } + $link = get_term_link( (int) $term_id, (string) $taxonomy ); + self::$queue[ $key ] = array( 'post_id' => $key, 'url' => is_string( $link ) ? $link : '', 'event' => 'updated', 'term' => array( (int) $term_id, (string) $taxonomy ) ); + } + + /** delete_term: the archive page is gone. The deleted term still knows its link. */ + public static function on_term_delete( $term_id, $tt_id = 0, $taxonomy = '', $deleted = null ) { + if ( ! in_array( (string) $taxonomy, Content::taxonomies(), true ) ) { + return; + } + $key = Content::term_key( $taxonomy, $term_id ); + $link = $deleted instanceof \WP_Term ? get_term_link( $deleted ) : ''; + self::$queue[ $key ] = array( 'post_id' => $key, 'url' => is_string( $link ) ? $link : '', 'event' => 'unpublished' ); + } + public static function flush() { if ( ! self::$queue || ! Connection::has_key() ) { return; } $changes = array(); foreach ( self::$queue as $change ) { + if ( isset( $change['term'] ) ) { + $term = get_term( $change['term'][0], $change['term'][1] ); + $item = $term instanceof \WP_Term && (int) $term->count > 0 ? Content::term_item( $term ) : null; + unset( $change['term'] ); + if ( $item ) { + $change['item'] = $item; + } else { + $change['event'] = 'unpublished'; + } + $changes[] = $change; + continue; + } + if ( is_string( $change['post_id'] ) ) { + $changes[] = $change; // A deleted term. + continue; + } $post = get_post( $change['post_id'] ); if ( 'unpublished' !== $change['event'] && $post && 'publish' === $post->post_status && ! post_password_required( $post ) ) { $change['item'] = Content::item( $post ); diff --git a/src/Rest.php b/src/Rest.php index dff2120..160923c 100644 --- a/src/Rest.php +++ b/src/Rest.php @@ -100,6 +100,13 @@ public static function status_payload() { $c = wp_count_posts( $type ); $counts[ $type ] = isset( $c->publish ) ? (int) $c->publish : 0; } + // Archive taxonomies and their terms with posts, the ones sent as content items. + $taxonomies = array(); + foreach ( Content::taxonomies() as $taxonomy ) { + $taxonomies[ $taxonomy ] = Content::term_count( array( $taxonomy ) ); + } + // "term_seo": SEO title and meta description of category, tag and other archive pages can be written. + $can_write = SeoFields::term_supported() ? array_merge( Writer::FIELDS, array( 'term_seo' ) ) : Writer::FIELDS; return array( 'site_url' => home_url(), 'name' => get_bloginfo( 'name' ), @@ -108,7 +115,8 @@ public static function status_payload() { 'seo_plugin' => SeoFields::plugin(), 'seo_plugin_name' => SeoFields::plugin_name(), 'post_types' => $counts, - 'can_write' => Writer::FIELDS, + 'taxonomies' => $taxonomies, + 'can_write' => $can_write, ); } diff --git a/src/SeoFields.php b/src/SeoFields.php index 0d92a61..7380aaa 100644 --- a/src/SeoFields.php +++ b/src/SeoFields.php @@ -207,6 +207,164 @@ public static function set( $post_id, $field, $value ) { return true; } + /** Rank Math's term meta for the two term fields. */ + const RANKMATH_TERM = array( 'seo_title' => 'rank_math_title', 'seo_description' => 'rank_math_description' ); + /** Yoast's taxonomy meta option and its keys for the two term fields. */ + const YOAST_TAX_OPTION = 'wpseo_taxonomy_meta'; + const YOAST_TERM = array( 'seo_title' => 'wpseo_title', 'seo_description' => 'wpseo_desc' ); + /** The fields MonoRanks writes on category, tag and other archive pages. */ + const TERM_FIELDS = array( 'seo_title', 'seo_description' ); + + /** True when term SEO fields can be written with the active SEO setup: Rank Math, Yoast or no SEO plugin. */ + public static function term_supported() { + return in_array( self::plugin(), array( 'rankmath', 'yoast', 'none' ), true ); + } + + /** + * Why a write of this field to a term would not show, as array( error, reason ), or null when it can go ahead. + * Without an SEO plugin only the description can be written: it is the term's own description. + */ + public static function term_refusal( $field ) { + $plugin = self::plugin(); + if ( ! self::term_supported() ) { + /* The reason is shown in the MonoRanks app, which is in English. */ + return array( 'error' => 'not_supported', 'reason' => sprintf( 'MonoRanks cannot write category pages in %s yet.', self::plugin_name() ) ); + } + if ( 'none' === $plugin && 'seo_title' === $field ) { + return array( 'error' => 'not_supported', 'reason' => 'Titles of category pages need an SEO plugin.' ); + } + return null; + } + + /** Yoast's stored values for one term, or an empty array. */ + private static function yoast_term_meta( $term_id, $taxonomy ) { + $all = get_option( self::YOAST_TAX_OPTION ); + return is_array( $all ) && isset( $all[ $taxonomy ][ $term_id ] ) && is_array( $all[ $taxonomy ][ $term_id ] ) ? $all[ $taxonomy ][ $term_id ] : array(); + } + + /** + * The stored (raw, not rendered) value of a term field in the active SEO setup, '' when unset. Without an SEO plugin + * the description is the term's own description and there is no title. + */ + public static function term_get( $term_id, $taxonomy, $field ) { + $term_id = (int) $term_id; + switch ( self::plugin() ) { + case 'rankmath': + if ( ! isset( self::RANKMATH_TERM[ $field ] ) ) { + return ''; + } + $value = get_term_meta( $term_id, self::RANKMATH_TERM[ $field ], true ); + return is_string( $value ) ? $value : ''; + case 'yoast': + if ( ! isset( self::YOAST_TERM[ $field ] ) ) { + return ''; + } + $meta = self::yoast_term_meta( $term_id, $taxonomy ); + return isset( $meta[ self::YOAST_TERM[ $field ] ] ) && is_string( $meta[ self::YOAST_TERM[ $field ] ] ) ? $meta[ self::YOAST_TERM[ $field ] ] : ''; + case 'none': + if ( 'seo_description' !== $field ) { + return ''; + } + $value = get_term_field( 'description', $term_id, $taxonomy, 'raw' ); + return is_string( $value ) ? $value : ''; + default: + return ''; + } + } + + /** Noindex of a term in the active SEO plugin, false when unknown. */ + public static function term_noindex( $term_id, $taxonomy ) { + $plugin = self::plugin(); + if ( 'rankmath' === $plugin ) { + $robots = get_term_meta( (int) $term_id, 'rank_math_robots', true ); + return is_array( $robots ) && in_array( 'noindex', $robots, true ); + } + if ( 'yoast' === $plugin ) { + $meta = self::yoast_term_meta( (int) $term_id, $taxonomy ); + return isset( $meta['wpseo_noindex'] ) && 'noindex' === $meta['wpseo_noindex']; + } + return false; + } + + /** + * Writes a term field into the active SEO setup ('' clears it). Values arrive sanitised by Writer. Returns true on + * success. Callers check term_refusal() first. + */ + public static function term_set( $term_id, $taxonomy, $field, $value ) { + $term_id = (int) $term_id; + $value = (string) $value; + switch ( self::plugin() ) { + case 'rankmath': + if ( ! isset( self::RANKMATH_TERM[ $field ] ) ) { + return false; + } + if ( '' === $value ) { + delete_term_meta( $term_id, self::RANKMATH_TERM[ $field ] ); + } else { + update_term_meta( $term_id, self::RANKMATH_TERM[ $field ], wp_slash( $value ) ); + } + return true; + case 'yoast': + return isset( self::YOAST_TERM[ $field ] ) ? self::yoast_term_set( $term_id, $taxonomy, self::YOAST_TERM[ $field ], $value ) : false; + case 'none': + if ( 'seo_description' !== $field ) { + return false; + } + $r = wp_update_term( $term_id, $taxonomy, array( 'description' => wp_slash( $value ) ) ); + return ! is_wp_error( $r ); + default: + return false; + } + } + + /** + * Yoast keeps term values in one option, wpseo_taxonomy_meta[ taxonomy ][ term_id ]. Its own API + * (WPSEO_Taxonomy_Meta::set_values) is used when present, with every stored value of the term passed back in, because + * it resets the keys it is not given. Then Yoast's indexable for the term is rebuilt, since its pages read the + * indexable, not the option. + */ + private static function yoast_term_set( $term_id, $taxonomy, $key, $value ) { + $meta = self::yoast_term_meta( $term_id, $taxonomy ); + $meta[ $key ] = $value; + if ( class_exists( 'WPSEO_Taxonomy_Meta' ) && method_exists( 'WPSEO_Taxonomy_Meta', 'set_values' ) && class_exists( 'WPSEO_Utils' ) ) { + \WPSEO_Taxonomy_Meta::set_values( $term_id, $taxonomy, $meta ); + } else { + $all = get_option( self::YOAST_TAX_OPTION ); + $all = is_array( $all ) ? $all : array(); + $meta = array_filter( $meta, static function ( $v ) { return '' !== $v; } ); + if ( $meta ) { + $all[ $taxonomy ][ $term_id ] = $meta; + } else { + unset( $all[ $taxonomy ][ $term_id ] ); + if ( isset( $all[ $taxonomy ] ) && array() === $all[ $taxonomy ] ) { + unset( $all[ $taxonomy ] ); + } + } + // Yoast's marker that the option is already clean, so it does not validate the whole option again. + $all['wpseo_already_validated'] = true; + update_option( self::YOAST_TAX_OPTION, $all ); + } + self::yoast_rebuild_term( $term_id ); + return true; + } + + /** Rebuild Yoast's indexable for a term, through its own term watcher, when Yoast offers it. */ + private static function yoast_rebuild_term( $term_id ) { + $watcher = 'Yoast\\WP\\SEO\\Integrations\\Watchers\\Indexable_Term_Watcher'; + if ( ! function_exists( 'YoastSEO' ) || ! class_exists( $watcher ) ) { + return; + } + try { + $instance = YoastSEO()->classes->get( $watcher ); + if ( $instance && method_exists( $instance, 'build_indexable' ) ) { + $instance->build_indexable( (int) $term_id ); + } + } catch ( \Throwable $e ) { + // The value is saved; Yoast rebuilds the indexable itself the next time the term is saved. + unset( $e ); + } + } + /** When no SEO plugin handles output, print the connector's own values. */ public static function register_output() { add_filter( 'pre_get_document_title', array( __CLASS__, 'filter_title' ), 20 ); diff --git a/src/Writer.php b/src/Writer.php index 35120ba..69e91ef 100644 --- a/src/Writer.php +++ b/src/Writer.php @@ -98,6 +98,10 @@ private static function one( array $c, $actor ) { } return $result; } + // A category, tag or other archive page: the change names the term instead of a post. + if ( empty( $c['post_id'] ) && ! empty( $c['term_id'] ) && ! empty( $c['taxonomy'] ) ) { + return self::term( $c, $id, $field, $value, $actor ); + } $post_id = isset( $c['post_id'] ) ? (int) $c['post_id'] : 0; if ( ! $post_id && ! empty( $c['url'] ) ) { $post_id = url_to_postid( (string) $c['url'] ); @@ -129,6 +133,43 @@ private static function one( array $c, $actor ) { return array( 'id' => $id, 'ok' => true, 'previous' => $previous, 'post_id' => $post_id, 'cache' => CachePurger::post( $post_id ) ); } + /** + * SEO title or meta description of a term's archive page (monoranks/wordpress-plugin#25): Rank Math term meta, Yoast's + * taxonomy meta, or without an SEO plugin the term's own description. Undo is a normal change back to `previous`. + */ + private static function term( array $c, $id, $field, $value, $actor ) { + if ( ! in_array( $field, SeoFields::TERM_FIELDS, true ) ) { + return array( 'id' => $id, 'ok' => false, 'error' => 'field_not_allowed' ); + } + $taxonomy = sanitize_key( (string) $c['taxonomy'] ); + $term_id = (int) $c['term_id']; + $term = in_array( $taxonomy, Content::taxonomies(), true ) ? get_term( $term_id, $taxonomy ) : null; + if ( ! $term instanceof \WP_Term ) { + return array( 'id' => $id, 'ok' => false, 'error' => 'term_not_found' ); + } + $tax = get_taxonomy( $taxonomy ); + $cap = $tax && isset( $tax->cap->edit_terms ) ? $tax->cap->edit_terms : 'manage_categories'; + if ( ! current_user_can( 'manage_options' ) || ! current_user_can( $cap ) ) { + return array( 'id' => $id, 'ok' => false, 'error' => 'forbidden', 'reason' => 'This user cannot edit these terms.' ); + } + $refused = SeoFields::term_refusal( $field ); + if ( $refused ) { + return array( 'id' => $id, 'ok' => false, 'error' => $refused['error'], 'reason' => $refused['reason'] ); + } + $previous = (string) SeoFields::term_get( $term_id, $taxonomy, $field ); + if ( isset( $c['expected'] ) && null !== $c['expected'] && (string) $c['expected'] !== $previous ) { + return array( 'id' => $id, 'ok' => false, 'error' => 'changed_since_preview', 'current' => $previous ); + } + // Without an SEO plugin the description is the term's own, which may hold HTML; SEO plugin fields are plain text. + $clean = 'none' === SeoFields::plugin() ? trim( wp_kses_post( $value ) ) : sanitize_text_field( $value ); + if ( ! SeoFields::term_set( $term_id, $taxonomy, $field, $clean ) ) { + return array( 'id' => $id, 'ok' => false, 'error' => 'update_failed', 'reason' => 'WordPress did not save the term.' ); + } + clean_term_cache( $term_id, $taxonomy ); + self::log( $actor, $field, Content::term_key( $taxonomy, $term_id ), $previous, $clean ); + return array( 'id' => $id, 'ok' => true, 'previous' => $previous, 'term_id' => $term_id, 'taxonomy' => $taxonomy, 'cache' => CachePurger::term( $term_id, $taxonomy ) ); + } + /** * Body edit: one paragraph at the top of post_content. Only

, , ,
survive sanitising; the value * MonoRanks approved must be at most 2,000 characters (checked above). @@ -339,6 +380,9 @@ public static function reverse( array $row ) { } } elseif ( 0 === strpos( $target, 'post:' ) ) { $c['post_id'] = (int) substr( $target, 5 ); + } elseif ( preg_match( '/^term:([a-z0-9_-]+):(\d+)$/', $target, $m ) ) { + $c['taxonomy'] = $m[1]; + $c['term_id'] = (int) $m[2]; } elseif ( 'site' !== $target ) { return null; } diff --git a/tests/integration/TermSeoTest.php b/tests/integration/TermSeoTest.php new file mode 100644 index 0000000..abeeb5e --- /dev/null +++ b/tests/integration/TermSeoTest.php @@ -0,0 +1,122 @@ +markTestSkipped( 'Set MONORANKS_WP_PATH to a local WordPress install to run the integration tests.' ); + } + foreach ( array( 'monoranks_change_log', SeoFields::YOAST_TAX_OPTION ) as $name ) { + $this->options[ $name ] = get_option( $name, null ); + } + $this->user = get_current_user_id(); + $admins = get_users( array( 'role' => 'administrator', 'number' => 1, 'fields' => 'ID' ) ); + wp_set_current_user( $admins ? (int) $admins[0] : 1 ); + } + + protected function tear_down() { + if ( defined( 'MONORANKS_TESTS_WP' ) ) { + foreach ( $this->terms as $id ) { + wp_delete_term( $id, 'category' ); + } + foreach ( $this->options as $name => $value ) { + null === $value ? delete_option( $name ) : update_option( $name, $value ); + } + wp_set_current_user( $this->user ); + } + parent::tear_down(); + } + + private function category( $description = '' ) { + $t = wp_insert_term( 'MonoRanks term test ' . wp_generate_password( 6, false ), 'category', array( 'description' => $description ) ); + $this->terms[] = (int) $t['term_id']; + return get_term( (int) $t['term_id'], 'category' ); + } + + private function write( $term, $field, $value, $expected ) { + return Writer::apply( array( array( 'id' => 'c', 'field' => $field, 'term_id' => $term->term_id, 'taxonomy' => 'category', 'value' => $value, 'expected' => $expected ) ), 'test' )[0]; + } + + public function test_a_category_is_a_content_item() { + $term = $this->category( 'Old words.' ); + $item = Content::term_item( $term ); + $this->assertSame( 'term:category:' . $term->term_id, $item['id'] ); + $this->assertSame( 'term:category', $item['type'] ); + $this->assertSame( get_term_link( $term ), $item['url'] ); + $this->assertSame( 'Old words.', $item['excerpt'] ); + $this->assertSame( $term->term_id, $item['term_id'] ); + $this->assertContains( 'category', Content::taxonomies() ); + } + + public function test_the_meta_description_is_written_and_undone() { + $plugin = SeoFields::plugin(); + if ( ! in_array( $plugin, array( 'none', 'yoast' ), true ) ) { + $this->markTestSkipped( 'Runs without an SEO plugin or with Yoast SEO.' ); + } + $term = $this->category( 'Old description.' ); + $old = 'none' === $plugin ? 'Old description.' : ''; + $this->assertSame( $old, Content::term_item( $term )['seo_description'] ); + + $this->assertSame( 'changed_since_preview', $this->write( $term, 'seo_description', self::DESCRIPTION, 'not this' )['error'] ); + $r = $this->write( $term, 'seo_description', self::DESCRIPTION, $old ); + $this->assertTrue( $r['ok'], wp_json_encode( $r ) ); + $this->assertSame( $old, $r['previous'] ); + $this->assertSame( self::DESCRIPTION, Content::term_item( get_term( $term->term_id, 'category' ) )['seo_description'] ); + if ( 'yoast' === $plugin ) { + $meta = get_option( SeoFields::YOAST_TAX_OPTION ); + $this->assertSame( self::DESCRIPTION, $meta['category'][ $term->term_id ]['wpseo_desc'] ); + // The page reads Yoast's indexable, which was rebuilt with the new description. + $indexable = YoastSEO()->meta->for_term( $term->term_id ); + $this->assertSame( self::DESCRIPTION, $indexable ? $indexable->description : null ); + } else { + $this->assertSame( self::DESCRIPTION, get_term( $term->term_id, 'category' )->description ); + $this->assertSame( 'not_supported', $this->write( $term, 'seo_title', 'A title', '' )['error'] ); + } + + $undo = Writer::apply( array( Writer::reverse( array_slice( get_option( 'monoranks_change_log' ), -1 )[0] ) ), 'test' )[0]; + $this->assertTrue( $undo['ok'], wp_json_encode( $undo ) ); + $this->assertSame( $old, Content::term_item( get_term( $term->term_id, 'category' ) )['seo_description'] ); + if ( 'yoast' === $plugin ) { + $meta = get_option( SeoFields::YOAST_TAX_OPTION ); + $this->assertTrue( empty( $meta['category'][ $term->term_id ]['wpseo_desc'] ) ); + } + } + + /** + * Needs Yoast SEO loaded (MONORANKS_WP_PLUGINS=wordpress-seo/wp-seo.php), so CI leaves it out with the yoast group. + * + * @group yoast + */ + public function test_with_yoast_a_title_is_written_keeping_the_other_values() { + if ( 'yoast' !== SeoFields::plugin() ) { + $this->markTestSkipped( 'Needs Yoast SEO: MONORANKS_WP_PLUGINS=wordpress-seo/wp-seo.php.' ); + } + $term = $this->category(); + \WPSEO_Taxonomy_Meta::set_values( $term->term_id, 'category', array( 'wpseo_focuskw' => 'privacy', 'wpseo_desc' => 'Kept.' ) ); + $this->assertTrue( $this->write( $term, 'seo_title', 'Privacy | VeronaLabs', '' )['ok'] ); + $meta = get_option( SeoFields::YOAST_TAX_OPTION )['category'][ $term->term_id ]; + $this->assertSame( 'Privacy | VeronaLabs', $meta['wpseo_title'] ); + $this->assertSame( 'Kept.', $meta['wpseo_desc'] ); + $this->assertSame( 'privacy', $meta['wpseo_focuskw'] ); + } +} diff --git a/tests/unit/TermSeoTest.php b/tests/unit/TermSeoTest.php new file mode 100644 index 0000000..d560b73 --- /dev/null +++ b/tests/unit/TermSeoTest.php @@ -0,0 +1,329 @@ +> term id => meta key => value */ + private $term_meta = array(); + /** @var array */ + private $options = array(); + /** @var array */ + private $terms = array(); + /** @var bool */ + private $can = true; + + protected function set_up() { + parent::set_up(); + Monkey\setUp(); + if ( ! class_exists( 'WP_Term' ) ) { + eval( 'class WP_Term { public $term_id; public $taxonomy; public $name = ""; public $description = ""; public $count = 1; }' ); // phpcs:ignore Squiz.PHP.Eval.Discouraged -- a stand-in for core's class in unit tests. + } + $this->terms[12] = $this->term( 12, 'category', 'News', 'All our news in one place.' ); + $meta = &$this->term_meta; + $options = &$this->options; + $terms = &$this->terms; + $can = &$this->can; + $tax = static function ( $name, $public, $queryable, $rewrite ) { + return (object) array( + 'name' => $name, + 'public' => $public, + 'publicly_queryable' => $queryable, + 'rewrite' => $rewrite, + 'labels' => (object) array( 'singular_name' => 'post_tag' === $name ? 'Tag' : 'Category' ), + 'cap' => (object) array( 'edit_terms' => 'manage_categories' ), + ); + }; + $taxes = array( + 'category' => $tax( 'category', true, true, array( 'slug' => 'category' ) ), + 'post_tag' => $tax( 'post_tag', true, true, array( 'slug' => 'tag' ) ), + 'post_format' => $tax( 'post_format', true, true, array( 'slug' => 'type' ) ), + 'hidden' => $tax( 'hidden', true, false, false ), + ); + Functions\when( 'get_taxonomies' )->justReturn( $taxes ); + Functions\when( 'get_taxonomy' )->alias( static function ( $name ) use ( $taxes ) { return isset( $taxes[ $name ] ) ? $taxes[ $name ] : false; } ); + Functions\when( 'get_term' )->alias( static function ( $id ) use ( &$terms ) { return isset( $terms[ (int) $id ] ) ? $terms[ (int) $id ] : null; } ); + Functions\when( 'get_term_link' )->alias( static function ( $term ) use ( &$terms ) { + $term = is_object( $term ) ? $term : $terms[ (int) $term ]; + return 'https://example.com/category/' . strtolower( $term->name ) . '/'; + } ); + Functions\when( 'get_term_field' )->alias( static function ( $field, $id ) use ( &$terms ) { return $terms[ (int) $id ]->$field; } ); + Functions\when( 'wp_update_term' )->alias( static function ( $id, $taxonomy, $args ) use ( &$terms ) { $terms[ (int) $id ]->description = $args['description']; return array( 'term_id' => $id ); } ); + Functions\when( 'get_term_meta' )->alias( static function ( $id, $key ) use ( &$meta ) { return isset( $meta[ $id ][ $key ] ) ? $meta[ $id ][ $key ] : ''; } ); + Functions\when( 'update_term_meta' )->alias( static function ( $id, $key, $value ) use ( &$meta ) { $meta[ $id ][ $key ] = $value; return true; } ); + Functions\when( 'delete_term_meta' )->alias( static function ( $id, $key ) use ( &$meta ) { unset( $meta[ $id ][ $key ] ); return true; } ); + Functions\when( 'get_option' )->alias( static function ( $k, $d = false ) use ( &$options ) { return array_key_exists( $k, $options ) ? $options[ $k ] : $d; } ); + Functions\when( 'update_option' )->alias( static function ( $k, $v ) use ( &$options ) { $options[ $k ] = $v; return true; } ); + Functions\when( 'current_user_can' )->alias( static function () use ( &$can ) { return $can; } ); + Functions\when( 'is_wp_error' )->justReturn( false ); + Functions\when( 'wp_slash' )->returnArg(); + Functions\when( 'sanitize_key' )->alias( static function ( $s ) { return strtolower( preg_replace( '/[^a-z0-9_\-]/i', '', (string) $s ) ); } ); + Functions\when( 'sanitize_text_field' )->alias( static function ( $s ) { return trim( wp_strip_all_tags( (string) $s ) ); } ); + Functions\when( 'wp_strip_all_tags' )->alias( static function ( $s ) { return strip_tags( (string) $s ); } ); + Functions\when( 'wp_kses_post' )->alias( static function ( $s ) { return strip_tags( (string) $s, '

' ); } ); + Functions\when( 'wp_trim_words' )->alias( static function ( $s, $n ) { return implode( ' ', array_slice( preg_split( '/\s+/', trim( (string) $s ) ), 0, $n ) ); } ); + Functions\when( 'clean_term_cache' )->justReturn( null ); + Functions\when( 'home_url' )->alias( static function ( $path = '' ) { return 'https://example.com' . $path; } ); + Functions\when( 'untrailingslashit' )->alias( static function ( $s ) { return rtrim( $s, '/\\' ); } ); + Functions\when( 'has_action' )->justReturn( false ); + foreach ( array( 'rocket_clean_files', 'rocket_clean_home', 'w3tc_flush_url', 'wpsc_delete_url_cache', 'sg_cachepress_purge_cache', 'pantheon_wp_clear_edge_paths' ) as $fn ) { + if ( function_exists( $fn ) ) { + Functions\when( $fn )->justReturn( null ); + } + } + } + + protected function tear_down() { + Monkey\tearDown(); + parent::tear_down(); + } + + private function term( $id, $taxonomy, $name, $description ) { + $t = new \WP_Term(); + $t->term_id = $id; + $t->taxonomy = $taxonomy; + $t->name = $name; + $t->description = $description; + return $t; + } + + private function write( $field, $value, $expected = null, $extra = array() ) { + $c = array_merge( array( 'id' => 'c1', 'field' => $field, 'term_id' => 12, 'taxonomy' => 'category', 'value' => $value ), $extra ); + if ( null !== $expected ) { + $c['expected'] = $expected; + } + return Writer::apply( array( $c ), 'test' )[0]; + } + + public function test_archive_taxonomies_have_a_public_archive_and_skip_post_formats() { + $this->assertSame( array( 'category', 'post_tag' ), Content::taxonomies() ); + } + + public function test_without_an_seo_plugin_a_term_is_sent_with_its_description_as_meta_description() { + $item = Content::term_item( $this->terms[12] ); + $this->assertSame( + array( + 'id' => 'term:category:12', + 'type' => 'term:category', + 'type_label' => 'Category', + 'url' => 'https://example.com/category/news/', + 'title' => 'News', + 'excerpt' => 'All our news in one place.', + 'author' => null, + 'author_avatar' => null, + 'categories' => array(), + 'tags' => array(), + 'published_at' => null, + 'modified_at' => null, + 'seo_title' => '', + 'seo_description' => 'All our news in one place.', + 'canonical' => '', + 'noindex' => false, + 'images' => array(), + 'term_id' => 12, + 'taxonomy' => 'category', + ), + $item + ); + } + + public function test_without_an_seo_plugin_the_description_is_written_and_undone_but_a_title_is_refused() { + $r = $this->write( 'seo_title', 'News from VeronaLabs' ); + $this->assertSame( array( 'id' => 'c1', 'ok' => false, 'error' => 'not_supported', 'reason' => 'Titles of category pages need an SEO plugin.' ), $r ); + + $old = 'All our news in one place.'; + $r = $this->write( 'seo_description', 'Product news and releases.', 'something else' ); + $this->assertSame( array( 'id' => 'c1', 'ok' => false, 'error' => 'changed_since_preview', 'current' => $old ), $r ); + $this->assertSame( $old, $this->terms[12]->description ); + + $r = $this->write( 'seo_description', 'Product news and releases.', $old ); + $this->assertTrue( $r['ok'] ); + $this->assertSame( $old, $r['previous'] ); + $this->assertSame( 12, $r['term_id'] ); + $this->assertSame( 'category', $r['taxonomy'] ); + $this->assertSame( 'Product news and releases.', $this->terms[12]->description ); + + // Undo: a normal change back to the previous value, expecting what was written. The HTML of the old description stays. + $r = $this->write( 'seo_description', $old, 'Product news and releases.' ); + $this->assertTrue( $r['ok'] ); + $this->assertSame( $old, $this->terms[12]->description ); + + // The change log row reverses into the same kind of change. + $log = $this->options['monoranks_change_log']; + $this->assertSame( 'term:category:12', $log[0]['target'] ); + $this->assertSame( array( 'id' => 'undo', 'field' => 'seo_description', 'value' => $old, 'expected' => 'Product news and releases.', 'taxonomy' => 'category', 'term_id' => 12 ), Writer::reverse( $log[0] ) ); + } + + public function test_a_term_write_purges_the_archive_page_from_page_caches() { + Filters\expectApplied( 'monoranks_purge_urls' )->once()->with( array( 'https://example.com/category/news/' ), 0 )->andReturnFirstArg(); + $r = $this->write( 'seo_description', 'Product news.' ); + $this->assertTrue( $r['ok'] ); + $this->assertSame( array( 'purged', 'failed', 'none' ), array_keys( $r['cache'] ) ); + } + + public function test_unknown_terms_other_fields_and_users_without_the_capability_are_refused() { + $this->assertSame( 'term_not_found', $this->write( 'seo_description', 'x', null, array( 'term_id' => 99 ) )['error'] ); + $this->assertSame( 'term_not_found', $this->write( 'seo_description', 'x', null, array( 'taxonomy' => 'hidden' ) )['error'] ); + $this->assertSame( 'field_not_allowed', $this->write( 'canonical', 'https://example.com/' )['error'] ); + $this->can = false; + $this->assertSame( 'forbidden', $this->write( 'seo_description', 'x' )['error'] ); + $this->assertSame( 'All our news in one place.', $this->terms[12]->description ); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_rank_math_term_meta_is_sent_written_and_cleared_by_undo() { + define( 'RANK_MATH_VERSION', '1.0.250' ); + $this->term_meta[12] = array( 'rank_math_description' => 'Old description', 'rank_math_robots' => array( 'noindex' ) ); + $item = Content::term_item( $this->terms[12] ); + $this->assertSame( '', $item['seo_title'] ); + $this->assertSame( 'Old description', $item['seo_description'] ); + $this->assertTrue( $item['noindex'] ); + + $r = $this->write( 'seo_title', 'News | VeronaLabs', 'Stale title' ); + $this->assertSame( array( 'id' => 'c1', 'ok' => false, 'error' => 'changed_since_preview', 'current' => '' ), $r ); + + $r = $this->write( 'seo_title', 'News | VeronaLabs', '' ); + $this->assertTrue( $r['ok'] ); + $this->assertSame( '', $r['previous'] ); + $this->assertSame( 'News | VeronaLabs', $this->term_meta[12]['rank_math_title'] ); + + // Undo writes '' back, which clears the field. + $this->assertTrue( $this->write( 'seo_title', '', 'News | VeronaLabs' )['ok'] ); + $this->assertArrayNotHasKey( 'rank_math_title', $this->term_meta[12] ); + $this->assertSame( 'Old description', $this->term_meta[12]['rank_math_description'] ); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_yoast_taxonomy_meta_is_sent_and_written_keeping_the_other_keys() { + define( 'WPSEO_VERSION', '28.5' ); + $this->options['wpseo_taxonomy_meta'] = array( + 'category' => array( 12 => array( 'wpseo_desc' => 'Old description', 'wpseo_focuskw' => 'news', 'wpseo_noindex' => 'noindex' ) ), + 'post_tag' => array( 3 => array( 'wpseo_title' => 'A tag' ) ), + ); + $item = Content::term_item( $this->terms[12] ); + $this->assertSame( '', $item['seo_title'] ); + $this->assertSame( 'Old description', $item['seo_description'] ); + $this->assertTrue( $item['noindex'] ); + + $this->assertSame( 'changed_since_preview', $this->write( 'seo_description', 'New', 'Not this' )['error'] ); + $r = $this->write( 'seo_description', 'Product news and releases.', 'Old description' ); + $this->assertTrue( $r['ok'] ); + $this->assertSame( 'Old description', $r['previous'] ); + $saved = $this->options['wpseo_taxonomy_meta']; + $this->assertSame( array( 'wpseo_desc' => 'Product news and releases.', 'wpseo_focuskw' => 'news', 'wpseo_noindex' => 'noindex' ), $saved['category'][12] ); + $this->assertSame( array( 3 => array( 'wpseo_title' => 'A tag' ) ), $saved['post_tag'] ); + + // Undo of a title written on a term with no other values removes the term's entry again. + $this->terms[5] = $this->term( 5, 'category', 'Empty', '' ); + $this->assertTrue( $this->write( 'seo_title', 'Title', '', array( 'term_id' => 5 ) )['ok'] ); + $this->assertSame( array( 'wpseo_title' => 'Title' ), $this->options['wpseo_taxonomy_meta']['category'][5] ); + $this->assertTrue( $this->write( 'seo_title', '', 'Title', array( 'term_id' => 5 ) )['ok'] ); + $this->assertArrayNotHasKey( 5, $this->options['wpseo_taxonomy_meta']['category'] ); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_yoast_own_api_gets_every_stored_value_of_the_term() { + define( 'WPSEO_VERSION', '28.5' ); + eval( 'class WPSEO_Utils {} class WPSEO_Taxonomy_Meta { public static $calls = array(); public static function set_values( $id, $tax, array $values ) { self::$calls[] = array( $id, $tax, $values ); } }' ); // phpcs:ignore Squiz.PHP.Eval.Discouraged -- a stand-in for Yoast's class. + $this->options['wpseo_taxonomy_meta'] = array( 'category' => array( 12 => array( 'wpseo_desc' => 'Old', 'wpseo_focuskw' => 'news' ) ) ); + $this->assertTrue( $this->write( 'seo_title', 'News', '' )['ok'] ); + $this->assertSame( array( array( 12, 'category', array( 'wpseo_desc' => 'Old', 'wpseo_focuskw' => 'news', 'wpseo_title' => 'News' ) ) ), \WPSEO_Taxonomy_Meta::$calls ); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_other_seo_plugins_refuse_term_writes_and_do_not_offer_them() { + define( 'AIOSEO_VERSION', '4.7.0' ); + $r = $this->write( 'seo_description', 'x' ); + $this->assertFalse( $r['ok'] ); + $this->assertSame( 'not_supported', $r['error'] ); + $this->assertSame( 'MonoRanks cannot write category pages in All in One SEO yet.', $r['reason'] ); + $this->assertFalse( SeoFields::term_supported() ); + $this->assertSame( '', Content::term_item( $this->terms[12] )['seo_description'] ); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_the_sync_sends_term_pages_after_the_post_pages() { + // 3 published posts in pages of 2, then 3 terms: pages 1-2 are posts (not under test here), pages 3-4 terms. + eval( 'class WP_Query { public $posts = array(); public $found_posts = 0; public $max_num_pages = 0; public function __construct( $a ) { $all = array( 1, 2, 3 ); $this->found_posts = 3; $this->posts = array_slice( $all, ( $a["paged"] - 1 ) * $a["posts_per_page"], $a["posts_per_page"] ); if ( ! $this->posts ) { $this->found_posts = 0; } } }' ); // phpcs:ignore Squiz.PHP.Eval.Discouraged -- a stand-in for core's class. + $this->terms[13] = $this->term( 13, 'category', 'Releases', '' ); + $this->terms[14] = $this->term( 14, 'post_tag', 'Tips', '' ); + $terms = $this->terms; + Functions\when( 'get_post_types' )->justReturn( array( 'post' => 'post' ) ); + Functions\when( 'wp_count_terms' )->justReturn( 3 ); + Functions\when( 'get_terms' )->alias( static function ( $a ) use ( $terms ) { + return $a['hide_empty'] ? array_slice( array_values( $terms ), $a['offset'], $a['number'] ) : array(); + } ); + + $r = Content::page( 3, 2 ); + $this->assertSame( 6, $r['total'] ); + $this->assertSame( 4, $r['pages'] ); + $this->assertSame( array( 'term:category:12', 'term:category:13' ), array_column( $r['items'], 'id' ) ); + $r = Content::page( 4, 2 ); + $this->assertSame( array( 'term:post_tag:14' ), array_column( $r['items'], 'id' ) ); + $this->assertSame( 4, $r['pages'] ); + } + + public function test_edited_and_deleted_terms_are_pushed_like_post_changes() { + $this->options['monoranks_connection'] = array( 'key' => 'k', 'api_base' => 'https://app.monoranks.com' ); + $this->terms[13] = $this->term( 13, 'category', 'Releases', '' ); + $sent = array(); + Functions\when( 'wp_json_encode' )->alias( 'json_encode' ); + Functions\when( 'wp_get_environment_type' )->justReturn( 'production' ); + Functions\when( 'wp_remote_post' )->alias( static function ( $url, $args ) use ( &$sent ) { $sent[] = json_decode( $args['body'], true ); return array(); } ); + Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 ); + Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{}' ); + + \MonoRanks\Notifier::on_term( 12, 0, 'category' ); + \MonoRanks\Notifier::on_term( 99, 0, 'post_format' ); + \MonoRanks\Notifier::on_term_delete( 13, 0, 'category', $this->terms[13] ); + \MonoRanks\Notifier::flush(); + + $this->assertCount( 1, $sent ); + $changes = $sent[0]['changes']; + $this->assertCount( 2, $changes ); + $this->assertSame( 'term:category:12', $changes[0]['post_id'] ); + $this->assertSame( 'updated', $changes[0]['event'] ); + $this->assertSame( 'https://example.com/category/news/', $changes[0]['url'] ); + $this->assertSame( 12, $changes[0]['item']['term_id'] ); + $this->assertArrayNotHasKey( 'term', $changes[0] ); + $this->assertSame( array( 'post_id' => 'term:category:13', 'url' => 'https://example.com/category/releases/', 'event' => 'unpublished' ), $changes[1] ); + } + + public function test_status_offers_term_writes_and_counts_terms_per_taxonomy() { + Functions\when( 'get_post_types' )->justReturn( array( 'post' => 'post' ) ); + Functions\when( 'wp_count_posts' )->justReturn( (object) array( 'publish' => 4 ) ); + Functions\when( 'wp_count_terms' )->alias( static function ( $args ) { return 'category' === $args['taxonomy'][0] ? 6 : 2; } ); + Functions\when( 'get_bloginfo' )->justReturn( 'x' ); + $status = Rest::status_payload(); + $this->assertSame( array( 'category' => 6, 'post_tag' => 2 ), $status['taxonomies'] ); + $this->assertContains( 'term_seo', $status['can_write'] ); + $this->assertContains( 'seo_title', $status['can_write'] ); + } +}