Repository navigation
Add core/content-create, core/content-update, and core/content-delete abilities - #1025
jorgefilipecosta wants to merge 177 commits into
Conversation
✅ WordPress Plugin Check Report
📊 ReportAll checks passed! No errors or warnings found. 🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## develop #1025 +/- ##
=============================================
+ Coverage 81.62% 82.65% +1.03%
- Complexity 3088 3232 +144
=============================================
Files 129 129
Lines 12300 12721 +421
=============================================
+ Hits 10040 10515 +475
+ Misses 2260 2206 -54
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
259fdf5 to
5e35c13
Compare
|
@jorgefilipecosta moving this to the 1.5.0 milestone that's currently targeted for the end of October, but happy to cut that release sooner once this PR is ready so that we can get it into the AI plugin and provide an outlet to ease approval of getting this into core in time for the WP 7.2 beta 1 timeline on October 20th. |
Testing using the browser consoleThe new abilities can also be tested from the browser console with the client-side Abilities API. I ran these steps on wp-env (WordPress 7.1.2) at 7eefd2a, and the outputs below are from that run. Post IDs, timestamps, and links will differ on your site. Setup
0. Load the API and a logging helper await ( await import( '@wordpress/core-abilities' ) ).ready;
const { executeAbility, getAbility } = await import( '@wordpress/abilities' );
const run = async ( name, input ) => {
console.log( `▶ ${ name }`, input );
try {
const output = await executeAbility( name, input );
console.log( '✔ output', output );
return output;
} catch ( error ) {
console.log( '✖ error', error.code, error.message, error.data ?? '' );
return error;
}
};
1. Create a draft const created = await run( 'core/content-create', {
post_type: 'post',
title_raw: 'Hello from the Abilities API',
content_raw: '<!-- wp:paragraph -->\n<p>Written by core/content-create.</p>\n<!-- /wp:paragraph -->',
excerpt_raw: 'Created from the browser console.',
status: 'draft',
fields: [ 'id', 'post_type', 'status', 'date', 'slug', 'link', 'title_raw', 'excerpt_raw', 'content_raw', 'author_slug' ],
} );Output: {
id: 7,
post_type: 'post',
status: 'draft',
date: '2026-10-06T17:11:15+00:00',
slug: '', // a draft gets no slug until it is published
link: 'http://localhost:8889/?p=7',
title_raw: 'Hello from the Abilities API',
excerpt_raw: 'Created from the browser console.',
content_raw: '<!-- wp:paragraph -->\n<p>Written by core/content-create.</p>\n<!-- /wp:paragraph -->',
author_slug: 'admin', // the user's slug, as core/users-query returns it
}2. Read it back by ID const read = await run( 'core/content-query', {
id: created.id,
fields: [ 'id', 'status', 'title_rendered', 'excerpt_rendered', 'content_rendered', 'author_slug' ],
} );
const readDefault = await run( 'core/content-query', { id: created.id } );Output: // read
{
id: 7,
status: 'draft',
title_rendered: 'Hello from the Abilities API',
excerpt_rendered: '<p>Created from the browser console.</p>\n',
content_rendered: '\n<p class="wp-block-paragraph">Written by core/content-create.</p>\n',
author_slug: 'admin',
}
// readDefault: without `fields`, the default field set
{ id: 7, post_type: 'post', status: 'draft', date: '2026-10-06T17:11:15+00:00', slug: '', title_rendered: 'Hello from the Abilities API' }3. Update: rename, set the slug, publish const updated = await run( 'core/content-update', {
id: created.id,
title_raw: 'Hello again from the Abilities API',
slug: 'hello-abilities-api',
status: 'publish',
fields: [ 'id', 'status', 'date', 'modified', 'slug', 'link', 'title_raw', 'excerpt_raw', 'content_raw' ],
} );Output: {
id: 7,
status: 'publish',
date: '2026-10-06T17:12:49+00:00', // the draft's date was floating, so publishing sets it to now
modified: '2026-10-06T17:12:49+00:00',
slug: 'hello-abilities-api',
link: 'http://localhost:8889/hello-abilities-api/',
title_raw: 'Hello again from the Abilities API',
excerpt_raw: 'Created from the browser console.', // fields left out keep their values
content_raw: '<!-- wp:paragraph -->\n<p>Written by core/content-create.</p>\n<!-- /wp:paragraph -->',
}4. Query: by slug, by author, a filtered list, and specific IDs const bySlug = await run( 'core/content-query', {
post_type: 'post',
slug: 'hello-abilities-api',
fields: [ 'id', 'status', 'slug', 'link', 'title_rendered' ],
} );
const byAuthor = await run( 'core/content-query', {
post_type: 'post',
author_slug: created.author_slug,
fields: [ 'id', 'title_rendered', 'author_slug' ],
} );
const list = await run( 'core/content-query', {
post_type: 'post',
status: [ 'publish', 'draft' ],
per_page: 5,
fields: [ 'id', 'status', 'title_rendered' ],
} );
const byIds = await run( 'core/content-query', {
post_type: 'post',
include: [ created.id, 1 ],
fields: [ 'id', 'title_rendered' ],
} );Output: // bySlug: a single post is returned directly
{ id: 7, status: 'publish', slug: 'hello-abilities-api', link: 'http://localhost:8889/hello-abilities-api/', title_rendered: 'Hello again from the Abilities API' }
// byAuthor: the posts of the user with that slug (on a fresh site)
{
posts: [
{ id: 7, title_rendered: 'Hello again from the Abilities API', author_slug: 'admin' },
{ id: 1, title_rendered: 'Hello world!', author_slug: 'admin' },
],
total: 2,
total_pages: 1,
}
// list: newest first, with page counts (on a fresh site; other posts on the site are listed too)
{
posts: [
{ id: 7, status: 'publish', title_rendered: 'Hello again from the Abilities API' },
{ id: 1, status: 'publish', title_rendered: 'Hello world!' },
],
total: 2,
total_pages: 1,
}
// byIds
{ posts: [ { id: 7, title_rendered: 'Hello again from the Abilities API' }, { id: 1, title_rendered: 'Hello world!' } ], total: 2, total_pages: 1 }5. Delete: trash, trash again, delete permanently, read again const trashed = await run( 'core/content-delete', {
id: created.id,
fields: [ 'id', 'status', 'slug', 'title_raw' ],
} );
const trashedAgain = await run( 'core/content-delete', { id: created.id } );
const deleted = await run( 'core/content-delete', {
id: created.id,
force: true,
fields: [ 'id', 'status', 'title_raw' ],
} );
const gone = await run( 'core/content-query', { id: created.id } );Output: // trashed: without `force`, the post is trashed and returned
{ id: 7, status: 'trash', slug: 'hello-abilities-api__trashed', title_raw: 'Hello again from the Abilities API' }
// trashedAgain: error
{ code: 'content_already_trashed', message: 'The post has already been deleted.', data: { status: 410 } }
// deleted: `force: true` deletes the post and returns it as it was before the deletion
{ id: 7, status: 'trash', title_raw: 'Hello again from the Abilities API' }
// gone: error, the permission check refuses a post that does not exist
{ code: 'rest_ability_cannot_execute', message: 'Sorry, you are not allowed to execute this ability.', data: { status: 403 } }6. Input validation and scheduling const noType = await run( 'core/content-create', { title_raw: 'No post type' } );
const noOffset = await run( 'core/content-create', { post_type: 'post', title_raw: 'No timezone offset', date: '2027-01-01T09:00:00' } );
const unknownAuthor = await run( 'core/content-create', { post_type: 'post', title_raw: 'Unknown author', author_slug: 'no-such-user' } );
const unknownAuthorFilter = await run( 'core/content-query', { post_type: 'post', author_slug: 'no-such-user' } );
const scheduled = await run( 'core/content-create', {
post_type: 'post',
title_raw: 'Scheduled by an ability',
status: 'publish',
date: '2027-01-01T09:00:00+00:00',
fields: [ 'id', 'status', 'date', 'date_gmt' ],
} );
const cleanup = await run( 'core/content-delete', { id: scheduled.id, force: true, fields: [ 'id', 'status' ] } );Output: // noType and noOffset: thrown by the client's input validation, before any request is sent
{ code: 'ability_invalid_input', message: 'Ability "core/content-create" has invalid input. Reason: post_type is a required property of input.' }
{ code: 'ability_invalid_input', message: 'Ability "core/content-create" has invalid input. Reason: Invalid date.' }
// unknownAuthor and unknownAuthorFilter: errors from the server; nothing is written
{ code: 'content_invalid_field', message: 'The author_slug field must be the slug of an existing user.', data: { status: 400 } }
{ code: 'content_invalid_filter', message: 'The author_slug filter must be the slug of an existing user.', data: { status: 400 } }
// scheduled: a future date with `publish` schedules the post
{ id: 9, status: 'future', date: '2027-01-01T09:00:00+00:00', date_gmt: '2027-01-01T09:00:00+00:00' }
// cleanup
{ id: 9, status: 'future' }The client's validation requires a timezone offset or a trailing 7. Page parents const parentPage = await run( 'core/content-create', {
post_type: 'page',
title_raw: 'Parent page',
status: 'publish',
fields: [ 'id', 'link', 'parent' ],
} );
const childPage = await run( 'core/content-create', {
post_type: 'page',
title_raw: 'Child page',
status: 'publish',
parent: parentPage.id,
fields: [ 'id', 'link', 'parent' ],
} );
const loop = await run( 'core/content-update', { id: parentPage.id, parent: childPage.id } );
const pagesCleanup = [
await run( 'core/content-delete', { id: childPage.id, force: true, fields: [ 'id' ] } ),
await run( 'core/content-delete', { id: parentPage.id, force: true, fields: [ 'id' ] } ),
];Output: // parentPage and childPage
{ id: 10, link: 'http://localhost:8889/parent-page/', parent: 0 }
{ id: 11, link: 'http://localhost:8889/parent-page/child-page/', parent: 10 }
// loop: error, a page cannot move under one of its own descendants
{ code: 'content_invalid_field', message: 'The parent field must be 0 or the ID of a readable post of the same type, other than the post itself or one of its descendants.', data: { status: 400 } }
// pagesCleanup
[ { id: 11 }, { id: 10 } ] |
|
The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message. To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
|
One thing I want to flag is This is problematic for agents and risks corrupting large amounts of data. It's also extremely token inefficient. There is a PR from the contributor day at WCUS that addresses this, and at least could be looked at as a base for ideas #945 |
I missed the PR/ping thank you for raising awareness @apeatling. And thank you for the work at #945 @seedprod. I think overwriting the whole content like this PR does is going to be needed as it is basic functionality but I totally agree it should not be the only way as it may be token inefficient and can cause problems on large posts. I guess we could get this PR merged, and then as follow explore options using additional fields for partial updates either as string replace on old_content #945, or with positions offsets where the new content is added or both? Would be happy to collaborate with @seedprod on that. Would that work @apeatling ? |
Agreed, no need to be a blocker, although I think it is a semi-dealbreaker for agent usage. Makes sense to follow up. |
Yes, agreed there, but this PR is already huge I guess we can try to merge this one as soon as possible and then iterate fast on this option as a follow up. I will try to propose a draft follow up based on this PR (which will need updates based on what we change here). This partial updates option will need some thinking, it may even be an object so in the future we can do some nice things like expand to be block aware and/or html API aware. |
|
Thanks for all the work on this PR, Jorge! I really appreciate the time you've spent implementing, testing, and iterating on these abilities. The direction is sound and follows the principles we agreed on: each ability has distinct behavior expressed through its input/output schemas and annotations. Let's follow the input/output shape established in
Happy to go into details on any of these. |
b798181 to
e439910
Compare
|
Could we use By The proposal would use
Resolution should remain straightforward: look up the user by slug, then use their ID internally for querying, assignment, and capability checks. Unknown or ambiguous slugs should fail before querying or writing, never silently drop the filter. Omitting the field would leave queries unrestricted by author, use the current user on create, and preserve the existing author on update. We should explicitly decide how to handle compatibility with the query’s existing |
get_author_by_slug() searches every user of the network (`'blog_id' => 0`), and a user who can list users or edit others' posts could name any of them. On multisite, an editor of one site could therefore tell whether a nicename exists anywhere on the network: an existing user returned an empty list, while a missing one returned an error. The REST API users endpoint only shows users of the current site, and so does core/users-query. Those two capabilities now only reveal members of the current site, as in WordPress/wordpress-develop#12195. The lookup still searches the whole network, so posts by authors who are not members, such as super admins, can be filtered when they have posts in a publicly viewable post type. The docblock now uses core's wording.
The slug lookup already documented that slugs repeat across statuses. In a hierarchical post type, posts under different parents can share a slug too, and a lookup by post type and slug cannot tell them apart. The get_post_by_slug() docblock and the `slug` input description now say so, and point callers that need a specific post to its ID, as in WordPress/wordpress-develop#12195.
Core has no core/users-query yet, so WordPress/wordpress-develop#12195 now describes the author slug as the user's nicename, which the REST API users endpoint returns as `slug`. The plugin ships core/users-query, so its query keeps pointing agents there. The `author_slug` field and filter descriptions now carry Plugin: markers for that difference.
The `list<string>` type of `$fields` is wider than the other types, so the `@param` tags of format_post() and build_post_fields() were misaligned. This matches WordPress/wordpress-develop#12195.
WordPress/wordpress-develop#12195 dropped the `open_world` annotation from core/content-query, because WP_Ability only documents the `readonly`, `destructive`, and `idempotent` annotations. The plugin keeps it for MCP clients, which assume open-world when the hint is absent, so the comment now marks it as a plugin difference.
The AI plugin's coding standards ask for early exits in loops, so its copy of `set_up_post_context()` skips unset globals with `continue`. Core now does the same, so the two classes stay identical (WordPress/ai#1025). The test helper that reads the loop globals follows the same shape.
The curated post types map documented its values as `true` or an array "reserved for enabling specific operations in the future". No such operations exist, so any non-empty array exposes the post type in full: core/content-query checks the flag with empty(). Giving arrays a per-operation meaning later would change the behavior of any plugin passing one today. The map now documents its values as booleans, as `show_in_rest` is for post types, matching WordPress/wordpress-develop#12195.
Requesting `link` for a hierarchical post type loaded the parent of each returned page with its own query, because page permalinks walk the ancestors through get_page_uri(). Inherited read permissions read the parent too. The REST posts controller primes both the parent and the author caches, but query mode, whose comment says it mirrors that controller, only primed the authors. Query mode now calls update_post_parent_caches() as well, which runs no query when no returned post has a parent, as in WordPress/wordpress-develop#12195. Core passes it `$query->posts`; the WordPress stubs type that as post objects or IDs, so the plugin passes the post objects it already filtered for update_post_author_caches(), and a Plugin: comment marks the difference. A new test requests `link` for three child pages and expects three queries against the posts table.
format_post() unlocks a password-protected post for a user who can edit it. The excerpt test set an explicit `post_excerpt`, which wp_trim_excerpt() returns as is, so it passed even without the bypass. The post now has no excerpt, so it is generated from the content, which get_the_content() replaces with the password form unless the post is unlocked. This matches WordPress/wordpress-develop#12195.
The `array<string, mixed>` type of `$columns` is wider than `int`, so the tags were misaligned. This matches WordPress/wordpress-develop#12195. The rest of that commit moves core's tests to core's conventions, which the plugin's namespaced tests do not share: they use PHPUnit's setUp() and tearDown() like every other plugin test, keep the `wpai` prefix and the leading backslashes, and their tearDown() removes the flag the polyfill sets on `post` and `page`.
The edit fields, the cache priming fields, the default fields, and the loop globals never change, so they are now class constants, like CATEGORY and MAX_PER_PAGE, as in WordPress/wordpress-develop#12195. Like the plugin's other array constants, they carry a phpcs:ignore for Slevomat's multi-constant sniff, which reads the array items as separate constants. Core's commit also drops the leading backslashes from class names, which the plugin's namespaced class needs.
Ports the simplifications of WordPress/wordpress-develop#12195: - Resolve ID and slug requests with one get_requested_post() helper. A request with an `id` or a `slug` now always takes the single-post path, so one that does not resolve fails closed, instead of falling through to query mode, when the callbacks run on input that skipped schema validation. - Parse `page` and `per_page` with parse_filter_int(), like `id` and `parent`, and drop input_int(). Values that skipped schema validation and are not positive integers now fall back to the defaults. - Read GMT dates with get_post_datetime() and convert the result back to UTC, which replaces is_usable_date() and the `$field` normalization. - Pick the post a slug resolves to in one loop. - Look up author slugs with get_user_by(). Core keeps nicenames unique, so the check for users sharing one, and its test case, are gone. The write abilities' `author_slug` uses the same lookup. - Copy the loop globals with array_intersect_key(). - Drop the self-parent check from the inherited read permission, since the cycle guard already rejects a post that is its own parent. The write abilities keep get_content_by_id(), now a wrapper that only resolves requests with an `id`: their `slug` is the slug to write, not one to look the post up by. The update's date check, the other user of is_usable_date(), now compares the stored GMT date unless it is the zero date, as the REST posts controller does. Where the plugin's tools need more than core's code, the plugin differs, with Plugin: comments: the query posts are filtered to post objects before they prime the caches and in the slug loop, for PHPStan, and the slug loop ends with an early exit, as the plugin's coding standards ask. A @phpstan-param tag types the date formatters' `$field` as 'date' or 'modified', which the removed normalization guaranteed.
An update leaves out a date the post already has, so both dates core/content-query returns can be sent back after a timezone change (59151f9). A draft without a fixed date has no stored GMT date, and saving it moves it to the current time. So scheduling such a draft at the date it has, by sending `status: future` with its `date`, `date_gmt`, or both, dropped the date and published the draft at once. The REST API only schedules it when it gets `date_gmt` alone. The update now keeps the date it is sent back in that case: the post has no stored GMT date, the status becomes `future`, every date sent was the post's own, and that date is still ahead. A date that has passed still publishes the draft now, as publishing it does. Posts with a stored GMT date never get here, so a post saved before a timezone change still keeps its dates. New tests schedule such a draft with each date field, check that a past date still publishes it now, and check that a draft with a fixed date keeps both stored dates when it is scheduled after a timezone change.
The label now starts with a verb, like "Get Site Information", as in WordPress/wordpress-develop#12195. The deprecated core/read-content alias takes its label from it, so it becomes "Query Content (deprecated)".
"Content Create", "Content Update", and "Content Delete" are now "Create Content", "Update Content", and "Delete Content", like "Query Content" and core's "Get Site Information".
get_title() returns an empty string when a `the_title` filter returns a non-string, instead of failing its string return type and turning the query into an `ability_callback_exception`. The guard came from this plugin, but only WordPress/wordpress-develop#12195 tested it. This adds that test.
Ports the latest changes of WordPress/wordpress-develop#12195: - format_post() now sets the post up as the global post while it builds all of the post's fields, as the REST posts controller does, and restores the previous context in the same finally block that removes the password filter. Title and permalink filters now see the requested post too, and requesting both rendered fields no longer sets the context up twice. - Every post now goes through setup_postdata(), which reads the author, so the author caches are primed for every page. Before, rendered fields ran one user query per author. - set_up_post_context() saves the loop globals by value. array_intersect_key() kept a global as a reference when a calling function binds it with `global`, as load_template() and WP_Block::render() do, so the restore left the requested post in `$post` and `$id`. - The include filter accepts a single ID, as schema validation does. Before, the query failed with `content_invalid_filter`. - One helper builds the `content_invalid_filter` errors, the `author_slug` parameter name stays out of the translatable strings, and the total page count drops a check that ceil() already covers. The tests drop three that other tests already cover, merge the post fixtures that only differed in their text, assert that the post type objects exist, since get_post_type_object() returns null rather than false, and use REST_TESTS_IMPOSSIBLY_HIGH_NUMBER for posts that do not exist. New tests cover a single include ID, title and permalink filters, globals bound by the caller, and author priming. The plugin wraps a single include ID in an array before passing it to wp_parse_id_list(), with a Plugin: comment: that function only supports an integer since WordPress 7.2, and the WordPress stubs type its input as an array or a string.
write_content() fetched the stored author on every update, even when the input had no author_slug to compare it with.
The date conflict and unknown author errors now pass `date`, `date_gmt`, and `author_slug` to sprintf(), as the query's filter errors and check_unsupported_fields() already do, so translators leave them as is.
It only checks whether the current user may assign the requested author.
It still listed post type abilities as future work and said post types must be marked before core/content-query is registered. The flag now feeds every core/content-* ability.
…tests The content query tests already use it.
`read.ok` is false for any failure, a module that fails to load included. The run endpoint denies the read of the deleted post, so the test now also expects `rest_ability_cannot_execute`.
The plugin renamed core's test_does_not_register_core_content_query_ability_without_exposed_post_types, widened it to every content ability, and replaced core's assertWPError() and assertSame() with assertAbilityError() in two author slug tests. The tests match core again, and the checks for the deprecated alias and the write abilities follow a `// Plugin:` comment.
- Register the abilities in setUp(). Tests that register a post status or a post type first register them again. - Share the query read and the floating draft set-up in ContentUpdateTest, and the create and update round trip in ContentCreateTest. The editor round trip reuses the author case with a script. - Remove tests that others already cover: an update with only text fields, an author updating their own draft, an invalid status and an empty author slug on update, which uses the create schema, and a non-string title filter on create, which ContentTest covers. - Check dates out of step with the timezone after a change to Asia/Tokyo instead of Europe/Lisbon, which is on GMT in December. With no offset, a date's local and GMT values are the same, so the test could not tell which one the update compared. The imported post case adds nothing then. - Inline the helpers that only one test used, move assertNoPostTitled() to ContentCreateTest, and let assertAbilityError() check the status. - Drop the checks that nothing was written after an error from validation or the permission check, which run before the ability does.
gziolo
left a comment
There was a problem hiding this comment.
Two remaining issues noted inline.
| default: | ||
| // A status registered as public shows the post to everyone, as publishing does. | ||
| $status_object = get_post_status_object( $post_status ); | ||
| if ( $status_object instanceof \stdClass && $status_object->public && ! current_user_can( $post_type_object->cap->publish_posts ) ) { // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Capability is resolved from the post type's capability object. |
There was a problem hiding this comment.
The publish permission check still misses custom statuses with public: false, publicly_queryable: true, and internal: false.
I verified that a contributor without publish_posts can use this status through both create and update REST endpoints. Other logged-in users can then read the content through core/content-query.
Could we check $status_object->public || is_post_status_viewable( $status_object ) here? Both checks are needed. This remains a blocker. Please add tests for create and update that also confirm a rejected request writes nothing.
| ), | ||
| array( | ||
| 'title' => array( | ||
| 'raw' => 'div <strong>strong</strong> oh noes', |
There was a problem hiding this comment.
This test is failing on WordPress trunk because sanitization now removes the script contents too. It looks like a test compatibility regression. Could we address this before merging?
core/content-create, core/content-update, and core/content-delete abilities
- Keep only a `show_in_abilities` value of `true` on registered post types until core's WP_Post_Type::set_props() does, so other values, such as arrays, can be given a meaning later. - Count the pages with the page size the query ran with, since `pre_get_posts` callbacks can change it. - Read `page` and `per_page` with absint(), so whole numbers such as 2.0 are honored. - Keep the loop globals of a global post that was never set up, so get_the_content() outside the loop does not throw. - Fold to_output_post() into format_post() and the date helpers into format_date(), inline the raw title format callback, and stop caching the post field definitions. - Keep the future date an update sends back when it publishes a draft without a fixed date, as when it schedules one, so the post is scheduled for that date.
- Rename the `post_type` input and output field to `type` in every content ability, so a returned post's `type` can be sent back as it is. Callers of 1.4.0 that send `post_type`, including through the `core/read-content` alias, must switch to `type`. - Accept any whole number the integer schema accepts for IDs, `parent`, `page`, and `per_page`, such as 2.0, "2.0", or "+2", up to 2 ** 53. - Report a page past the last one as not found (404). - Always include the `id`, so a post is never an empty object. - Look up an author slug as given, so the database collation decides whether a variant in another case matches. The write abilities compare slugs with the same lookup, so an author can name themselves in another case, and sending back the stored author in another case keeps it. - Use `! empty()` for the field checks. - Update the tests and the e2e specs to match.
- Explain in the class docblock why the content ability lives in a dedicated class instead of a closure in wp_register_core_abilities(), as the users and settings classes do, and refer to the Core class by its current name, `WP_Abilities_Content`. - Say that an empty `fields` list, like an omitted one, returns the lean set of common read fields.
What?
Adds
core/content-create,core/content-update, andcore/content-deletenext tocore/content-query, and gives all four the author asauthor_slug.Why?
Agents can read posts with
core/content-querybut cannot write them. These abilities let an agent read a post, change its fields, and write it back under the same names, shapes, and rules.How?
The abilities live in
includes/Abilities/Content/Content.php, behind the Custom Abilities experiment, and register when a post type is exposed to abilities. Like the read abilities, they are markedpublic.Inputs use the field names
core/content-queryreturns:title_raw,content_raw, andexcerpt_rawas plain strings, plusstatus,slug,date,date_gmt,author_slug, andparent.Breaking:
core/content-querynow filters by and returnsauthor_slug, the user's nicename (theslugthatcore/users-queryreturns), instead of theauthorID filter and theauthor: { id, name }field. The slug is looked up as given, as incore/users-query, so the database collation decides whether a variant in another case matches. A user the caller cannot see incore/users-queryis reported as missing, but anyone who can edit others' posts of the post type can name any author.Breaking: all four abilities take and return the post type as
typeinstead ofpost_type, as in Abilities API: Add acore/content-queryability wordpress-develop#12195. Callers of 1.4.0 that sendpost_type, including through the deprecatedcore/read-contentalias, must switch totype.Input that cannot be applied is rejected with
content_invalid_fieldbefore anything is written, as the query rejects filters withcontent_invalid_filter. This covers:dateanddate_gmtthat refer to different times;An update can always send back the post's current parent and dates and, if that user still exists, its current author.
Giving a post any public status needs the publish capability, not only
publish, so a contributor cannot publish through a status another plugin registers. Sending back the status a post already has skips this check.statuslists the same statuses on update as on create, so clients can check it before sending. A post with an internal status, such astrash, keeps it whenstatusis left out; sending that status back fails validation.Sending back a draft's
date_gmtas the query returned it keeps the draft's floating date. A draft's slug is made unique under the parent it will have. A post created without a status counts as a draft for this.Outputs follow the query's rules: the written post goes through the same
fieldsprojection, which always includes theid, and raw fields need edit access. Create and update leave out raw fields the user cannot edit instead of refusing them, because the post is already written. Delete returns the trashed post, or withforce, the post as it was before the deletion.The query and the write abilities share the ID lookup, the exposed post type check, the author lookup, the
fieldsinput schema, and the post output schema. Where the query's code differs from core'sWP_Abilities_Content, a// Plugin:comment says what core does. The shared ID lookup accepts an ID in any whole-number form the integer schema accepts, such as"5.0"or"+5", but rejects fractions, and an ID beyond the integer range no longer wraps around onto another post.Update is destructive but not idempotent, so it stays on POST. Delete is destructive and idempotent.
The password, sticky flag, format, featured media, template, menu order, comment and ping status, and terms are left out until
core/content-querycan return them.Related: #1026 (an alternative implementation).
Use of AI Tools
AI assistance: Yes. Claude Code was used to port the review changes from the core PR, including the
typerename.Testing Instructions
core/content-createwith{ "type": "post", "title_raw": "Hello", "status": "draft", "fields": [ "id", "status", "title_raw", "author_slug" ] }.core/content-querywith{ "type": "post", "status": [ "draft" ], "author_slug": "<author_slug from step 2>", "fields": [ "id", "author_slug" ] }. The new draft is in the list.idfrom step 2, runcore/content-updatewith{ "id": <id>, "title_raw": "Hello again" }, thencore/content-deletewith{ "id": <id> }.npm run test:php -- --filter 'ContentTest|ContentCreateTest|ContentUpdateTest|ContentDeleteTest'npm run test:e2e -- tests/e2e/specs/abilitiesChangelog Entry