Skip to content

Introduce two new abilities for getting and setting SEO data for given posts#23369

Draft
leonidasmi wants to merge 23 commits into
trunkfrom
add/read-write-post-abilities
Draft

Introduce two new abilities for getting and setting SEO data for given posts#23369
leonidasmi wants to merge 23 commits into
trunkfrom
add/read-write-post-abilities

Conversation

@leonidasmi

@leonidasmi leonidasmi commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Context

  • Adds two new abilities, one to get the SEO data for a given post and one to set SEO data of a given post
  • Both those abilities are enabled, only if indexables are enabled
  • We actually went back and added the if indexables are enabled condition for the pre-existing abilities as well, because they are also based on indexables.

Summary

This PR can be summarized in the following changelog entry:

  • Introduces two new Yoast SEO abilities for getting and setting SEO data for given posts.

Relevant technical choices:

Title-based post lookup:

  • The read ability resolves a post by post_id, permalink, or title. The title value is a comma-separated list; each value is matched as a whole contiguous substring of the post's breadcrumb title, and a post is returned if it matches any value (OR/union). Example — hiking boots, trail compiles to:
WHERE object_type = 'post' AND object_sub_type = 'post'
  AND ( is_public IS NULL OR is_public = 1 )
  AND ( breadcrumb_title LIKE '%hiking boots%' OR breadcrumb_title LIKE '%trail%' )
ORDER BY object_last_modified DESC, id DESC
LIMIT 10 OFFSET <(page-1)*10>
  • So "Hiking boots for sale" matches; "hiking shoes for sale" does not.
  • Why we did it that way:
    • Whole-phrase, not per-word. Each comma value matches as a contiguous substring, so hiking boots means the phrase — not "hiking" and "boots" scattered across a title, which is noisy and surprising.
    • Comma = OR. Lets one call find several distinct posts; any match is returned.
    • Paginate, don't batch (the main perf call). Each returned post is rendered through the Meta surface), which is costly per post. So we cap pages at 10 and let the client page, rather than rendering a large set at once.
    • Write path stays strict. update-post-seo-data takes only post_id/permalink, never title, so an edit always hits one unambiguous post.

Not super-DRY :

  • There's some stuff that are duplicated between Abilities_Integration (where we register the SEO data a user can edit) and Post_SEO_Field_Map (where we register how the SEO data coming from the user are applied to indexable data)
  • We have decided not to couple those two, because the possibility of these two sets of data diverging is low and because it would have introduced quite some logic in the Abilities_Integration side, where now the SEO data that we expect are glanceable

Test instructions

Test instructions for the acceptance test before the PR gets merged

This PR can be acceptance tested by following these steps:

Declaration of the abilities

The GET SEO data ability
  • Do a GET request to WP's http://example.com/wp-json/wp-abilities/v1/abilities endpoint and confirm that you see the yoast-seo/get-post-seo-data ability we added and that it looks like this:
    {
        "name": "yoast-seo/get-post-seo-data",
        "label": "Get Post SEO Data",
        "description": "Get the SEO data for a post. Identify the post by post_id, by permalink (URL), or by title keywords; a title keyword search returns the SEO data for every matching post. With no identifier, the latest public post is returned.",
        "category": "yoast-seo",
        "input_schema": {
            "type": "object",
            "properties": {
                "post_id": {
                    "type": "integer",
                    "description": "The ID of the post to retrieve.",
                    "minimum": 1
                },
                "permalink": {
                    "type": "string",
                    "description": "The permalink (URL) of the post to retrieve."
                },
                "title": {
                    "type": "string",
                    "description": "Keywords to search for in post titles. The search string is split on whitespace and each token must be present in the breadcrumb title. Returns the SEO data for every matching post."
                }
            }
        },
        "output_schema": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "post_id": {
                        "type": "integer"
                    },
                    "post_title": {
                        "type": [
                            "string",
                            "null"
                        ]
                    },
                    "permalink": {
                        "type": [
                            "string",
                            "null"
                        ]
                    },
                    "post_type": {
                        "type": "string"
                    },
                    "post_status": {
                        "type": [
                            "string",
                            "null"
                        ]
                    },
                    "seo_title": {
                        "type": [
                            "string",
                            "null"
                        ]
                    },
                    "seo_title_rendered": {
                        "type": [
                            "string",
                            "null"
                        ],
                        "description": "The SEO title as output on the front end: the global default template applied when no custom value is set, with replacement variables expanded. Null when nothing is output."
                    },
                    "meta_description": {
                        "type": [
                            "string",
                            "null"
                        ]
                    },
                    "meta_description_rendered": {
                        "type": [
                            "string",
                            "null"
                        ],
                        "description": "The meta description as output on the front end: the global default template applied when no custom value is set, with replacement variables expanded. Null when nothing is output."
                    },
                    "focus_keyphrase": {
                        "type": [
                            "string",
                            "null"
                        ]
                    },
                    "canonical": {
                        "type": [
                            "string",
                            "null"
                        ]
                    },
                    "canonical_rendered": {
                        "type": [
                            "string",
                            "null"
                        ],
                        "description": "The canonical URL as output on the front end: the global default template applied when no custom value is set, with replacement variables expanded. Null when nothing is output."
                    },
                    "is_cornerstone": {
                        "type": "boolean"
                    },
                    "noindex": {
                        "type": [
                            "boolean",
                            "null"
                        ]
                    },
                    "nofollow": {
                        "type": "boolean"
                    },
                    "noimageindex": {
                        "type": "boolean"
                    },
                    "noarchive": {
                        "type": "boolean"
                    },
                    "nosnippet": {
                        "type": "boolean"
                    },
                    "open_graph_title": {
                        "type": [
                            "string",
                            "null"
                        ]
                    },
                    "open_graph_title_rendered": {
                        "type": [
                            "string",
                            "null"
                        ],
                        "description": "The Open Graph title as output on the front end: the global default template applied when no custom value is set, with replacement variables expanded. Null when nothing is output."
                    },
                    "open_graph_description": {
                        "type": [
                            "string",
                            "null"
                        ]
                    },
                    "open_graph_description_rendered": {
                        "type": [
                            "string",
                            "null"
                        ],
                        "description": "The Open Graph description as output on the front end: the global default template applied when no custom value is set, with replacement variables expanded. Null when nothing is output."
                    },
                    "twitter_title": {
                        "type": [
                            "string",
                            "null"
                        ]
                    },
                    "twitter_title_rendered": {
                        "type": [
                            "string",
                            "null"
                        ],
                        "description": "The Twitter title as output on the front end: the global default template applied when no custom value is set, with replacement variables expanded. Null when nothing is output."
                    },
                    "twitter_description": {
                        "type": [
                            "string",
                            "null"
                        ]
                    },
                    "twitter_description_rendered": {
                        "type": [
                            "string",
                            "null"
                        ],
                        "description": "The Twitter description as output on the front end: the global default template applied when no custom value is set, with replacement variables expanded. Null when nothing is output."
                    },
                    "schema_page_type": {
                        "type": [
                            "string",
                            "null"
                        ]
                    },
                    "schema_article_type": {
                        "type": [
                            "string",
                            "null"
                        ]
                    },
                    "seo_score": {
                        "type": "string",
                        "enum": [
                            "na",
                            "bad",
                            "ok",
                            "good"
                        ]
                    },
                    "readability_score": {
                        "type": "string",
                        "enum": [
                            "na",
                            "bad",
                            "ok",
                            "good"
                        ]
                    },
                    "inclusive_language_score": {
                        "type": "string",
                        "enum": [
                            "na",
                            "bad",
                            "ok",
                            "good"
                        ]
                    }
                }
            }
        },
        "meta": {
            "annotations": {
                "readonly": true,
                "destructive": false,
                "idempotent": true
            },
            "show_in_rest": true,
            "mcp": {
                "public": true
            }
        },
        "_links": {
            "self": [
                {
                    "href": "https://basic.wordpress.test/wp-json/wp-abilities/v1/abilities/yoast-seo/get-post-seo-data",
                    "targetHints": {
                        "allow": [
                            "GET"
                        ]
                    }
                }
            ],
            "collection": [
                {
                    "href": "https://basic.wordpress.test/wp-json/wp-abilities/v1/abilities"
                }
            ],
            "wp:action-run": [
                {
                    "href": "https://basic.wordpress.test/wp-json/wp-abilities/v1/abilities/yoast-seo/get-post-seo-data/run"
                }
            ]
        }
    }
  • Confirm that the above declarations and their descriptions make sense and help agents understand what's possible.
The SET SEO data ability
  • Do a GET request to WP's http://example.com/wp-json/wp-abilities/v1/abilities endpoint and confirm that you see the yoast-seo/update-post-seo-data ability we added and that it looks like this:
    {
        "name": "yoast-seo/update-post-seo-data",
        "label": "Update Post SEO Data",
        "description": "Update the SEO data for a single post. Identify the post by post_id, by permalink (URL), or by unambiguous title keywords. Only the fields you provide are changed; a provided empty value clears that field.",
        "category": "yoast-seo",
        "input_schema": {
            "type": "object",
            "properties": {
                "post_id": {
                    "type": "integer",
                    "description": "The ID of the post to update.",
                    "minimum": 1
                },
                "permalink": {
                    "type": "string",
                    "description": "The permalink (URL) of the post to update."
                },
                "title": {
                    "type": "string",
                    "description": "Title keywords identifying the post to update. Must match exactly one post."
                },
                "seo_title": {
                    "type": [
                        "string",
                        "null"
                    ]
                },
                "meta_description": {
                    "type": [
                        "string",
                        "null"
                    ]
                },
                "focus_keyphrase": {
                    "type": [
                        "string",
                        "null"
                    ],
                    "maxLength": 191
                },
                "canonical": {
                    "type": [
                        "string",
                        "null"
                    ]
                },
                "is_cornerstone": {
                    "type": "boolean"
                },
                "noindex": {
                    "type": [
                        "boolean",
                        "null"
                    ]
                },
                "nofollow": {
                    "type": "boolean"
                },
                "noimageindex": {
                    "type": "boolean"
                },
                "noarchive": {
                    "type": "boolean"
                },
                "nosnippet": {
                    "type": "boolean"
                },
                "open_graph_title": {
                    "type": [
                        "string",
                        "null"
                    ]
                },
                "open_graph_description": {
                    "type": [
                        "string",
                        "null"
                    ]
                },
                "twitter_title": {
                    "type": [
                        "string",
                        "null"
                    ]
                },
                "twitter_description": {
                    "type": [
                        "string",
                        "null"
                    ]
                },
                "schema_page_type": {
                    "type": [
                        "string",
                        "null"
                    ],
                    "maxLength": 64
                },
                "schema_article_type": {
                    "type": [
                        "string",
                        "null"
                    ],
                    "maxLength": 64
                }
            }
        },
        "output_schema": {
            "type": "object",
            "properties": {
                "post_id": {
                    "type": "integer"
                },
                "post_title": {
                    "type": [
                        "string",
                        "null"
                    ]
                },
                "permalink": {
                    "type": [
                        "string",
                        "null"
                    ]
                },
                "post_type": {
                    "type": "string"
                },
                "post_status": {
                    "type": [
                        "string",
                        "null"
                    ]
                },
                "seo_title": {
                    "type": [
                        "string",
                        "null"
                    ]
                },
                "seo_title_rendered": {
                    "type": [
                        "string",
                        "null"
                    ],
                    "description": "The SEO title as output on the front end: the global default template applied when no custom value is set, with replacement variables expanded. Null when nothing is output."
                },
                "meta_description": {
                    "type": [
                        "string",
                        "null"
                    ]
                },
                "meta_description_rendered": {
                    "type": [
                        "string",
                        "null"
                    ],
                    "description": "The meta description as output on the front end: the global default template applied when no custom value is set, with replacement variables expanded. Null when nothing is output."
                },
                "focus_keyphrase": {
                    "type": [
                        "string",
                        "null"
                    ]
                },
                "canonical": {
                    "type": [
                        "string",
                        "null"
                    ]
                },
                "canonical_rendered": {
                    "type": [
                        "string",
                        "null"
                    ],
                    "description": "The canonical URL as output on the front end: the global default template applied when no custom value is set, with replacement variables expanded. Null when nothing is output."
                },
                "is_cornerstone": {
                    "type": "boolean"
                },
                "noindex": {
                    "type": [
                        "boolean",
                        "null"
                    ]
                },
                "nofollow": {
                    "type": "boolean"
                },
                "noimageindex": {
                    "type": "boolean"
                },
                "noarchive": {
                    "type": "boolean"
                },
                "nosnippet": {
                    "type": "boolean"
                },
                "open_graph_title": {
                    "type": [
                        "string",
                        "null"
                    ]
                },
                "open_graph_title_rendered": {
                    "type": [
                        "string",
                        "null"
                    ],
                    "description": "The Open Graph title as output on the front end: the global default template applied when no custom value is set, with replacement variables expanded. Null when nothing is output."
                },
                "open_graph_description": {
                    "type": [
                        "string",
                        "null"
                    ]
                },
                "open_graph_description_rendered": {
                    "type": [
                        "string",
                        "null"
                    ],
                    "description": "The Open Graph description as output on the front end: the global default template applied when no custom value is set, with replacement variables expanded. Null when nothing is output."
                },
                "twitter_title": {
                    "type": [
                        "string",
                        "null"
                    ]
                },
                "twitter_title_rendered": {
                    "type": [
                        "string",
                        "null"
                    ],
                    "description": "The Twitter title as output on the front end: the global default template applied when no custom value is set, with replacement variables expanded. Null when nothing is output."
                },
                "twitter_description": {
                    "type": [
                        "string",
                        "null"
                    ]
                },
                "twitter_description_rendered": {
                    "type": [
                        "string",
                        "null"
                    ],
                    "description": "The Twitter description as output on the front end: the global default template applied when no custom value is set, with replacement variables expanded. Null when nothing is output."
                },
                "schema_page_type": {
                    "type": [
                        "string",
                        "null"
                    ]
                },
                "schema_article_type": {
                    "type": [
                        "string",
                        "null"
                    ]
                },
                "seo_score": {
                    "type": "string",
                    "enum": [
                        "na",
                        "bad",
                        "ok",
                        "good"
                    ]
                },
                "readability_score": {
                    "type": "string",
                    "enum": [
                        "na",
                        "bad",
                        "ok",
                        "good"
                    ]
                },
                "inclusive_language_score": {
                    "type": "string",
                    "enum": [
                        "na",
                        "bad",
                        "ok",
                        "good"
                    ]
                }
            }
        },
        "meta": {
            "annotations": {
                "readonly": false,
                "destructive": false,
                "idempotent": true
            },
            "show_in_rest": true,
            "mcp": {
                "public": true
            }
        },
        "_links": {
            "self": [
                {
                    "href": "https://basic.wordpress.test/wp-json/wp-abilities/v1/abilities/yoast-seo/update-post-seo-data",
                    "targetHints": {
                        "allow": [
                            "GET"
                        ]
                    }
                }
            ],
            "collection": [
                {
                    "href": "https://basic.wordpress.test/wp-json/wp-abilities/v1/abilities"
                }
            ],
            "wp:action-run": [
                {
                    "href": "https://basic.wordpress.test/wp-json/wp-abilities/v1/abilities/yoast-seo/update-post-seo-data/run"
                }
            ]
        }
    }
  • Confirm that the above declarations and their descriptions make sense and help agents understand what's possible.
  • Now disable the indexables (using the add_filter( 'Yoast\WP\SEO\should_index_indexables', '__return_false' ); filter) and confirm that you can't find the above abilities
    • In fact, if you disable indexables you can find NO Yoast ability.

Usage of the new abilities

  • Create a post with meaningful content and set its SEO data (title, meta description, social title, robots, SEO/readability score, etc.)
Using the REST API
Using our `getting SEO data` ability
  • Run a GET request to /wp-json/wp-abilities/v1/abilities/yoast-seo/get-post-seo-data/run?input[post_id]=<POST_ID>
    • where <POST_ID> is the post ID
  • or run a GET request to /wp-json/wp-abilities/v1/abilities/yoast-seo/get-post-seo-data/run?input[permalink]=https://basic.wordpress.test/<POST_SLUG>
    • where <POST_SLUG> is the post's slu
  • or run a GET request to `/wp-json/wp-abilities/v1/abilities/yoast-seo/get-post-seo-data/run?input[title]=<POST_TITLE_KEYWORDS>
    • where <POST_TITLE_KEYWORDS> is keywords that exist in the post's title, separated by commas (we're gonna test this more extensively below).
    • So, if you want to find the post that has a title like 10 ways to backpack across northern Europe, you can use 10 ways, backpack, northern Europe or any combination of that, to find your post
  • Confirm that you get a result of an array with the post's SEO data, like:
[
    {
        "post_id": 712,
        "post_title": "NBA Draft 2026 winners and losers include Dybantsa, Peterson, Boozer and … fans?",
        "permalink": "https://basic.wordpress.test/uncategorized/nba-draft-2026-winners-and-losers-include-dybantsa-peterson-boozer-and-fans/",
        "post_type": "post",
        "post_status": "publish",
        "seo_title": null,
        "seo_title_rendered": "NBA Draft 2026 winners and losers include Dybantsa, Peterson, Boozer and … fans? - Basic",
        "meta_description": "The first round of the draft was a remarkably on-the-rails affair. %%sep%% %%primary_category%%",
        "meta_description_rendered": "The first round of the draft was a remarkably on-the-rails affair. -",
        "focus_keyphrase": "NBA Draft 2026",
        "canonical": null,
        "canonical_rendered": "https://basic.wordpress.test/uncategorized/nba-draft-2026-winners-and-losers-include-dybantsa-peterson-boozer-and-fans/",
        "is_cornerstone": false,
        "noindex": null,
        "nofollow": false,
        "noimageindex": false,
        "noarchive": false,
        "nosnippet": false,
        "open_graph_title": null,
        "open_graph_title_rendered": "NBA Draft 2026 winners and losers include Dybantsa, Peterson, Boozer and … fans?",
        "open_graph_description": null,
        "open_graph_description_rendered": "The first round of the draft was a remarkably on-the-rails affair. -",
        "twitter_title": null,
        "twitter_title_rendered": null,
        "twitter_description": null,
        "twitter_description_rendered": null,
        "schema_page_type": null,
        "schema_article_type": null,
        "seo_score": "ok",
        "readability_score": "ok",
        "inclusive_language_score": "na"
    }
]
  • Notice that the _rendered attributes are the ones that are output in the frontend, with default settings and replacement variables expanded
  • Repeat the test with multiple combinations of settings and confirm that the result is what you expect each time (empty SEO titles or custom, good SEO scores or no, cornerstone content or not, twitter titles same with OG ones or not, etc.)
  • Repeat the test with a post ID, title or permalink that should yield no posts and confirm that you get a yoast_seo_invalid_<indentifier> - No post could be found for the given identifier error
  • Repeat the test with a user with no capabilities of editing that post and confirm that you get a rest_ability_cannot_execute error
  • Repeat the test without post_id, or permalink or title in the parameters and confirm you get a yoast_seo_missing_identifier error
Using our `setting SEO data` ability
  • Run the following queries, to see the state of the postmeta and indexable data in the database for the post you're gonna edit (use the <POST_ID> accordingly):

  • Post meta:

    SELECT meta_key, meta_value
    FROM wp_postmeta
    WHERE post_id = <POST_ID> 
    ORDER BY meta_key;
  • Indexable:

    SELECT *
    FROM wp_yoast_indexable
    WHERE object_type = 'post' AND object_id = <POST_ID>;
  • Run a POST request to /wp-json/wp-abilities/v1/abilities/yoast-seo/update-post-seo-data/run

    • you need to add the following body: {"input":{"post_id":<POST_ID>,"seo_title":"new SEO title"}}
    • or (similar to the above) use the post's permalink to find it (but not the title), by adding this body: {"input":{"permalink":<POST_PERMALINK>,"seo_title":"new SEO title"}}
  • and instead of seo_title, you can set any of the attributes that were listed in the ability declaration you saw in the above section. Some example that we like to test are below

  • For all examples below, once you do the requests:

    • Check the response that includes the new SEO data for that post and confirm you see the updated data.
    • Also check the frontend of the post and confirm that the data you changed have the value you expect
    • Also, delete the indexable from the database, re-load the post in the frontend and find the indexable that got auto-created again. It should have the same data with before
  • Canonical

    • Set a custom canonical: body {"input":{"post_id":<POST_ID>,"canonical":"https://basic.wordpress.test/my-canonical/"}}.
    • Confirm the response shows "canonical": "https://basic.wordpress.test/my-canonical/" and "canonical_rendered" matching it, and that the <link rel="canonical"> on the front end points there.
    • Verify in the DB: post meta has a _yoast_wpseo_canonical row with value https://basic.wordpress.test/my-canonical/, and the indexable canonical column holds the same value.
      • No other meta or indexable data have changed
    • Clear it again: body {"input":{"post_id":<POST_ID>,"canonical":""}}.
    • Confirm "canonical" is back to null and "canonical_rendered" reverted to the post's own permalink.
    • Verify in the DB: the _yoast_wpseo_canonical meta row is gone, and the indexable canonical column is NULL (it did not get the computed permalink baked in).
      • No other meta or indexable data have changed
  • Cornerstone

    • Mark as cornerstone: body {"input":{"post_id":<POST_ID>,"is_cornerstone":true}}. Confirm the response shows "is_cornerstone": true and the metabox toggle is on.
    • Verify in the DB: post meta has _yoast_wpseo_is_cornerstone = 1, and the indexable is_cornerstone column = 1.
      • No other meta or indexable data have changed
    • Unmark it: body {"input":{"post_id":<POST_ID>,"is_cornerstone":false}}. Confirm "is_cornerstone": false and the toggle is off.
    • Verify in the DB: the _yoast_wpseo_is_cornerstone meta row is gone, and the indexable is_cornerstone column = 0.
      • No other meta or indexable data have changed
  • Schema

    • Set both schema types: body {"input":{"post_id":<POST_ID>,"schema_page_type":"ItemPage","schema_article_type":"NewsArticle"}}.
    • Confirm the response echoes those values and the Schema tab in the metabox reflects them.
    • Verify in the DB: post meta has _yoast_wpseo_schema_page_type = ItemPage and _yoast_wpseo_schema_article_type = NewsArticle; the indexable schema_page_type / schema_article_type columns hold the same.
    • Clear them: body {"input":{"post_id":<POST_ID>,"schema_page_type":"","schema_article_type":""}} and confirm both are null in the response.
    • Verify in the DB: both _yoast_wpseo_schema_* meta rows are gone, and both indexable schema columns are NULL.
      • No other meta or indexable data have changed
    • Send an invalid value: body {"input":{"post_id":<POST_ID>,"schema_page_type":"FooPage"}} and confirm you get an error.
    • Verify in the DB: nothing changed — the _yoast_wpseo_schema_page_type meta row and the indexable column still hold their previous values (the rejected request wrote nothing).
      • No other meta or indexable data have changed
  • Advanced robots (noimageindex / noarchive / nosnippet)

    • These three flags are stored together in a single _yoast_wpseo_meta-robots-adv meta key (as a comma-separated list), but each maps to its own indexable column — so this also tests that they merge correctly.
    • Enable two of them: body {"input":{"post_id":<POST_ID>,"noimageindex":true,"noarchive":true}}.
    • Confirm the response shows "noimageindex": true, "noarchive": true, "nosnippet": false, and that the front-end robots meta tag includes noimageindex and noarchive.
    • Verify in the DB: post meta has _yoast_wpseo_meta-robots-adv = noimageindex,noarchive; the indexable is_robots_noimageindex = 1, is_robots_noarchive = 1, is_robots_nosnippet = 0.
      • No other meta or indexable data have changed
    • Add the third flag without repeating the first two: body {"input":{"post_id":<POST_ID>,"nosnippet":true}}.
    • Confirm the response now shows all three as true (the previously-set flags were preserved, not wiped).
    • Verify in the DB: _yoast_wpseo_meta-robots-adv = noimageindex,noarchive,nosnippet; all three is_robots_* indexable columns = 1.
      • No other meta or indexable data have changed
    • Clear them all: body {"input":{"post_id":<POST_ID>,"noimageindex":false,"noarchive":false,"nosnippet":false}}.
    • Confirm all three are false in the response and the front-end robots meta tag no longer lists any of them.
    • Verify in the DB: the _yoast_wpseo_meta-robots-adv meta row is gone, and all three is_robots_* indexable columns = 0.
      • No other meta or indexable data have changed
  • At least one of the rest (robots + title)

    • Set a custom SEO title and noindex the post: body {"input":{"post_id":<POST_ID>,"seo_title":"My custom title","noindex":true}}.
    • Confirm the response shows the new "seo_title" and "noindex": true, and the front-end title/robots meta reflect it.
    • Verify in the DB: post meta has _yoast_wpseo_title = My custom title and _yoast_wpseo_meta-robots-noindex = 1; the indexable title column = My custom title and is_robots_noindex column = 1.
      • No other meta or indexable data have changed
    • Reset noindex to the post-type default: body {"input":{"post_id":<POST_ID>,"noindex":null}}.
    • Verify in the DB: the _yoast_wpseo_meta-robots-noindex meta row is gone, and the indexable is_robots_noindex column is NULL.
      • No other meta or indexable data have changed
    • Set noindex to false: body {"input":{"post_id":<POST_ID>,"noindex":false}}.
    • Verify in the DB: the _yoast_wpseo_meta-robots-noindex meta row is equal to 2, and the indexable is_robots_noindex column is 0.
      • No other meta or indexable data have changed
  • Repeat the test using invalid SEO data, for example use a schema page type that doesn't exist. eg. {"permalink":<POST_PERMALINK>,"schema_page_type":"FooPage"}}. Confirm that you get an error.

  • Repeat the test with a post ID or permalink that should yield no posts and confirm that you get a yoast_seo_invalid_<indentifier> - No post could be found for the given identifier error

  • Repeat the test with a user with no capabilities of editing that post and confirm that you get a rest_ability_cannot_execute error

Using an AI agent
  • Follow the documentation on how to test WP Abilities
  • To use the GET SEO data ability,
  • In Claude incognito window, Ask the agent for any of the above SEO data. We will try using ID, permalink and title
    • Ask something like: "I want to know the twitter description of the post with ID 5"
    • Or "Give me the schema types of the https://basic.wordpress.test/test-post post"
    • Or "Is the latest post that talks about backpacking across Europe a cornerstone post and what's its readability score?"
    • Repeat the test with a post ID or permalink or title that should yield no posts and confirm that you the agent wont find one to retrieve.
  • To use the SET SEO data ability,
  • In Claude incognito window, Ask the agent to set any of the above SEO data. We will try using ID and permalink
    • Ask the agent something like "For the post with ID 5, set its meta description to something in accordance with its keyphrase"
    • or "Noindex the https://basic.wordpress.test/test-post post"
    • Check the frontend of the post and confirm that the attribute has changed
    • Repeat the test using invalid SEO data, for example use a schema page type that doesn't exist. eg. "for the post with ID 5, set its schema page type to FooPage. Confirm that the agent wont do that.
    • Repeat the test with a post ID or permalink that should yield no posts and confirm that you the agent wont find one to update
Test the post lookup via titles

Using REST API

  • We want to test looking up posts via titles, so I'll shar esome examples below:
  • Run the following WP CLI commands to create the posts we want:
wp post create --post_status=publish --post_title="Hiking Boots Review"
wp post create --post_status=publish --post_title="Best Hiking Boots 2026"
wp post create --post_status=publish --post_title="Trail Running Guide"
  • Use POSTMAN to GET //wp-json/wp-abilities/v1/abilities/yoast-seo/get-post-seo-data/run?input[title]=<KEYWORDS>, each time with a different title as :
    • input[title]=hiking boots should yield the 2 first posts ("Hiking Boots Review" and "Best Hiking Boots 2026")
    • input[title]=hiking boots, trail should yield all posts ("Hiking Boots Review" and "Best Hiking Boots 2026" and "Trail Running Guide")
    • input[title]=boots hiking should yield no posts, so it will return a yoast_seo_invalid_<indentifier> - No post could be found for the given identifier error
  • Now create 9 more posts that have Hiking Boots in their title to test the pagination
wp post create --post_status=publish --post_title="Hiking Boots 1"
wp post create --post_status=publish --post_title="Hiking Boots 2"
wp post create --post_status=publish --post_title="Hiking Boots 3"
...
...
wp post create --post_status=publish --post_title="Hiking Boots 9"
  • Use POSTMAN to GET //wp-json/wp-abilities/v1/abilities/yoast-seo/get-post-seo-data/run?input[title]=<KEYWORDS>&input[page]=<NUMBER_OF_PAGINATION>, each time with a different title as :
    • input[title]=hiking boots and input[page]=1 should yield the 10 most recently modified posts
    • input[title]=hiking boots and input[page]=2 should yield the next 10 most recently modified posts (if you followed the above instructions closely, this will be a single post with the "Hiking Boots Review" title

Using AI Agent

  • In an incognito window always, so that each prompt doesn't affect the other
  • After creating the above posts, let's ask the agent: "In the site, want the SEO data of posts that are about hiking boots"
  • Check in the details of the Agent's thinking what it does
    • In my test, it first "found 10 results on the first page" and then it "check if there's more content on page 2"
    • Eventually, it yielded SEO data for all 11 posts
  • In a new incognito window, ask it, "I want the SEO data of the oldest post that's about Hiking boots
    • In my tests, it said "I've got the first page of results showing 10 hiking boots posts ordered by most recent modification, and I'm checking whether there's a second page to make sure I haven't missed any entries"
    • It then asked the second pagination so it was able to find the oldest post (the Hiking Boots Review) as it should.
Test permissions for agents

Relevant test scenarios

  • Changes should be tested with the browser console open
  • Changes should be tested on different posts/pages/taxonomies/custom post types/custom taxonomies
  • Changes should be tested on different editors (Default Block/Gutenberg/Classic/Elementor/other)
  • Changes should be tested on different browsers
  • Changes should be tested on multisite

Test instructions for QA when the code is in the RC

  • QA should use the same steps as above.

Impact check

This PR affects the following parts of the plugin, which may require extra testing:

  • Smoke test the AIOSEO import

Other environments

  • This PR also affects Shopify. I have added a changelog entry starting with [shopify-seo], added test instructions for Shopify and attached the Shopify label to this PR.
  • This PR also affects Yoast SEO for Google Docs. I have added a changelog entry starting with [yoast-doc-extension], added test instructions for Yoast SEO for Google Docs and attached the Google Docs Add-on label to this PR.

Documentation

  • I have written documentation for this change. For example, comments in the Relevant technical choices, comments in the code, documentation on Confluence / shared Google Drive / Yoast developer portal, or other.

Quality assurance

  • I have tested this code to the best of my abilities.
  • During testing, I had activated all plugins that Yoast SEO provides integrations for.
  • I have added unit tests to verify the code works as intended.
  • If any part of the code is behind a feature flag, my test instructions also cover cases where the feature flag is switched off.
  • I have written this PR in accordance with my team's definition of done.
  • I have checked that the base branch is correctly set.
  • I have run grunt build:images and committed the results, if my PR introduces or edits images or SVGs.

Innovation

  • No innovation project is applicable for this PR.
  • This PR falls under an innovation project. I have attached the innovation label.
  • I have added my hours to the WBSO document.

Fixes #

@leonidasmi leonidasmi added the changelog: enhancement Needs to be included in the 'Enhancements' category in the changelog label Jun 18, 2026
@coveralls

coveralls commented Jun 26, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 4845

Warning

No base build found for commit db4e5d4 on trunk.
Coverage changes can't be calculated without a base build.
If a base build is processing, this comment will update automatically when it completes.

Coverage: 50.021%

Details

  • Patch coverage: 138 uncovered changes across 2 files (240 of 378 lines covered, 63.49%).

Uncovered Changes

File Changed Covered %
src/abilities/user-interface/abilities-integration.php 180 44 24.44%
src/abilities/application/post-identifier-resolver.php 53 51 96.23%
Total (8 files) 378 240 63.49%

Coverage Regressions

Requires a base build to compare against. How to fix this →


Coverage Stats

Coverage Status
Relevant Lines: 42308
Covered Lines: 21163
Line Coverage: 50.02%
Coverage Strength: 4.13 hits per line

💛 - Coveralls

@leonidasmi leonidasmi force-pushed the add/read-write-post-abilities branch from 57d78fa to 824cc9a Compare June 26, 2026 13:13
@leonidasmi leonidasmi force-pushed the add/read-write-post-abilities branch from af3c479 to 0fbb3ad Compare June 29, 2026 08:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds two new Yoast SEO WP Abilities to read and update a post’s SEO metadata, including support for resolving posts by ID/permalink and (for reads) by title keyword search with pagination. It introduces new application services for resolving post identifiers and mapping SEO fields between ability input/output and indexables, plus repository/query support and expanded unit/integration test coverage.

Changes:

  • Add yoast-seo/get-post-seo-data and yoast-seo/update-post-seo-data abilities with schemas and permission checks.
  • Implement title-keyword post lookup in Indexable_Repository, and add services to resolve identifiers and map/apply SEO field patches.
  • Extend indexable→postmeta mapping to support per-column updates and “delete empty” semantics, with accompanying tests.

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/WP/Repositories/Find_Posts_By_Title_Keywords_Test.php WP-level integration tests for title-keyword post lookup behavior (OR phrases, pagination, caps, empty search).
tests/Unit/Repositories/Indexable_Repository_Test.php Unit tests validating query construction for find_posts_by_title_keywords, including paging and caps.
tests/Unit/Helpers/Indexable_To_Postmeta_Helper_Test.php Unit tests for new per-column postmeta mapping behavior and delete-empty semantics for added mappings.
tests/Unit/Abilities/User_Interface/Abilities_Integration_Test.php Unit tests ensuring the two new abilities register correctly and permission callbacks behave as expected.
tests/Unit/Abilities/Application/Post_SEO_Field_Map_Test.php Unit tests for SEO field mapping to output shape and patch-apply behavior onto indexables.
tests/Unit/Abilities/Application/Post_SEO_Data_Updater_Test.php Unit tests for updating SEO data, cascading to postmeta, and rebuilding the indexable.
tests/Unit/Abilities/Application/Post_SEO_Data_Collector_Test.php Unit tests for collecting SEO data for resolved posts and error propagation.
tests/Unit/Abilities/Application/Post_Identifier_Resolver_Test.php Unit tests for resolving posts by ID/permalink/title keywords and error cases.
src/repositories/indexable-repository.php Adds find_posts_by_title_keywords with phrase caps and pagination controls.
src/helpers/indexable-to-postmeta-helper.php Adds per-column mapping and delete-empty support; extends mapped fields (cornerstone, schema types).
src/abilities/user-interface/abilities-integration.php Registers the new abilities, schemas, and permissions; adds supporting dependencies.
src/abilities/application/post-seo-field-map.php Implements the shared SEO field contract: ability input/output mapping and patch application.
src/abilities/application/post-seo-data-updater.php Implements the write path: resolve → apply patch → map to postmeta → rebuild → return.
src/abilities/application/post-seo-data-collector.php Implements the read path: resolve one/many → map to output array(s).
src/abilities/application/post-identifier-resolver.php Adds shared resolver for post identification by ID/permalink/title keywords with standard errors.
composer.json Updates YoastCS thresholds to reflect reduced CS errors.

Comment on lines +133 to +145
public function map_column_to_postmeta( $indexable, $indexable_column, $delete_empty = false ) {
// The advanced-robots flags are persisted together under one meta key, so they cascade through it.
if ( \in_array( $indexable_column, [ 'is_robots_noimageindex', 'is_robots_noarchive', 'is_robots_nosnippet' ], true ) ) {
$indexable_column = 'meta_robots_adv';
}

if ( ! isset( $this->yoast_to_postmeta[ $indexable_column ] ) ) {
return;
}

$map_info = $this->yoast_to_postmeta[ $indexable_column ];
\call_user_func( [ $this, $map_info['map_method'] ], $indexable, $map_info['post_meta_key'], $indexable_column, $delete_empty );
}
Comment on lines +116 to +118
'is_cornerstone' => (bool) $indexable->is_cornerstone,
'noindex' => $indexable->is_robots_noindex,
'nofollow' => (bool) $indexable->is_robots_nofollow,
leonidasmi and others added 5 commits June 30, 2026 16:28
The additionalProperties => false addition to the post-identifier and
update input schemas made the strict registration test's expected
input_schema diverge from the actual wp_register_ability arguments,
erroring the unit suite. Mirror additionalProperties => false into the
two expected-schema helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown

A merge conflict has been detected for the proposed code changes in this PR. Please resolve the conflict by either rebasing the PR or merging in changes from the base branch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changelog: enhancement Needs to be included in the 'Enhancements' category in the changelog merge conflict

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants