Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}/<route>`, `Authorization: Bearer <connector key>`. `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.
Expand Down
7 changes: 5 additions & 2 deletions monoranks.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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' ) ) {
Expand All @@ -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' ) );
Expand Down
10 changes: 8 additions & 2 deletions readme.txt
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
=== MonoRanks: AI SEO, AEO and GEO Audit with Fixes You Approve ===
Contributors: veronalabs, mostafas1990, kashani
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

Expand Down Expand Up @@ -58,8 +58,9 @@
**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.

Expand Down Expand Up @@ -133,6 +134,11 @@

== 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.
Expand Down
4 changes: 4 additions & 0 deletions src/Actions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down
13 changes: 13 additions & 0 deletions src/CachePurger.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,19 @@
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 ) {
/**
Expand Down Expand Up @@ -189,7 +202,7 @@
return null;
}
foreach ( $urls as $url ) {
do_action( 'litespeed_purge_url', $url );

Check warning on line 205 in src/CachePurger.php

View workflow job for this annotation

GitHub Actions / WordPress.org plugin check

WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound

Hook names invoked by a theme/plugin should start with the theme/plugin prefix. Found: "litespeed_purge_url".
}
return true;
},
Expand Down Expand Up @@ -263,7 +276,7 @@
if ( ! $post_id || ! class_exists( 'Breeze_PurgeCache' ) || ! has_action( 'purge_post_cache' ) ) {
return null;
}
do_action( 'purge_post_cache', $post_id );

Check warning on line 279 in src/CachePurger.php

View workflow job for this annotation

GitHub Actions / WordPress.org plugin check

WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound

Hook names invoked by a theme/plugin should start with the theme/plugin prefix. Found: "purge_post_cache".
return true;
},
// Kinsta (host cache, its must-use plugin): $kinsta_cache->kinsta_cache_purge->initiate_purge( $post_id, 'post' ),
Expand Down Expand Up @@ -319,10 +332,10 @@
/** Headers for a file the plugin serves itself: never kept by browsers, proxies or page caches. */
public static function no_cache_response() {
if ( ! defined( 'DONOTCACHEPAGE' ) ) {
define( 'DONOTCACHEPAGE', true ); // WP Super Cache, W3 Total Cache, WP Rocket and others skip the page.

Check warning on line 335 in src/CachePurger.php

View workflow job for this annotation

GitHub Actions / WordPress.org plugin check

WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound

Global constants defined by a theme/plugin should start with the theme/plugin prefix. Found: "DONOTCACHEPAGE".
}
if ( has_action( 'litespeed_control_set_nocache' ) ) {
do_action( 'litespeed_control_set_nocache', 'MonoRanks serves this file' );

Check warning on line 338 in src/CachePurger.php

View workflow job for this annotation

GitHub Actions / WordPress.org plugin check

WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound

Hook names invoked by a theme/plugin should start with the theme/plugin prefix. Found: "litespeed_control_set_nocache".
}
nocache_headers();
if ( ! headers_sent() ) {
Expand Down
105 changes: 100 additions & 5 deletions src/Content.php
Original file line number Diff line number Diff line change
Expand Up @@ -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:<taxonomy>:<term_id>". */
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();
Expand Down Expand Up @@ -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,
Expand All @@ -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 ),
);
}
}
2 changes: 2 additions & 0 deletions src/Insights.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'] : '' ),
Expand Down
39 changes: 39 additions & 0 deletions src/Notifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Expand Down
10 changes: 9 additions & 1 deletion src/Rest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' ),
Expand All @@ -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,
);
}

Expand Down
Loading
Loading