Skip to content

Add core/content-create, core/content-update, and core/content-delete abilities - #1025

Open
jorgefilipecosta wants to merge 177 commits into
developfrom
add/core-content-write-abilities
Open

jorgefilipecosta wants to merge 177 commits into
developfrom
add/core-content-write-abilities

Conversation

@jorgefilipecosta

@jorgefilipecosta jorgefilipecosta commented Sep 11, 2026 •

Copy link
Copy Markdown
Member

What?

Adds core/content-create, core/content-update, and core/content-delete next to core/content-query, and gives all four the author as author_slug.

Why?

Agents can read posts with core/content-query but 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 marked public.

  • Inputs use the field names core/content-query returns: title_raw, content_raw, and excerpt_raw as plain strings, plus status, slug, date, date_gmt, author_slug, and parent.

  • Breaking: core/content-query now filters by and returns author_slug, the user's nicename (the slug that core/users-query returns), instead of the author ID filter and the author: { id, name } field. The slug is looked up as given, as in core/users-query, so the database collation decides whether a variant in another case matches. A user the caller cannot see in core/users-query is 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 type instead of post_type, as in Abilities API: Add a core/content-query ability wordpress-develop#12195. Callers of 1.4.0 that send post_type, including through the deprecated core/read-content alias, must switch to type.

  • Input that cannot be applied is rejected with content_invalid_field before anything is written, as the query rejects filters with content_invalid_filter. This covers:

    • a field the post type does not support;
    • an unknown author;
    • a new date and date_gmt that refer to different times;
    • a parent that is not a readable post of the same type, or that would make a loop.

    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.

  • status lists the same statuses on update as on create, so clients can check it before sending. A post with an internal status, such as trash, keeps it when status is left out; sending that status back fails validation.

  • Sending back a draft's date_gmt as 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 fields projection, which always includes the id, 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 with force, 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 fields input schema, and the post output schema. Where the query's code differs from core's WP_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-query can 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 type rename.

Testing Instructions

  1. Enable the Custom Abilities experiment.
  2. Run core/content-create with { "type": "post", "title_raw": "Hello", "status": "draft", "fields": [ "id", "status", "title_raw", "author_slug" ] }.
  3. Run core/content-query with { "type": "post", "status": [ "draft" ], "author_slug": "<author_slug from step 2>", "fields": [ "id", "author_slug" ] }. The new draft is in the list.
  4. With the id from step 2, run core/content-update with { "id": <id>, "title_raw": "Hello again" }, then core/content-delete with { "id": <id> }.
  5. npm run test:php -- --filter 'ContentTest|ContentCreateTest|ContentUpdateTest|ContentDeleteTest'
  6. npm run test:e2e -- tests/e2e/specs/abilities

Changelog Entry

Added - New core/content-create, core/content-update, and core/content-delete abilities that write posts with the fields core/content-query returns.
Changed - core/content-query takes and returns the post type as type instead of post_type, and filters by and returns the post author as author_slug, the user's slug, instead of author.

Open WordPress Playground Preview

@github-actions

Copy link
Copy Markdown

✅ WordPress Plugin Check Report

✅ Status: Passed

📊 Report

All checks passed! No errors or warnings found.


🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check

@codecov

codecov Bot commented Sep 11, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 82.65%. Comparing base (e120e2c) to head (d8c21e5).
⚠️ Report is 3 commits behind head on develop.

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     
Flag Coverage Δ
unit 82.65% <100.00%> (+1.03%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jeffpaul jeffpaul added this to the 1.5.0 milestone Sep 17, 2026
@jeffpaul jeffpaul moved this from Triage to In progress in WordPress AI Roadmap Sep 17, 2026
@jeffpaul jeffpaul modified the milestones: 1.5.0, Future Release Sep 21, 2026
@whyisjake whyisjake mentioned this pull request Sep 26, 2026
6 of 7 tasks
@jorgefilipecosta
jorgefilipecosta force-pushed the add/core-content-write-abilities branch from 259fdf5 to 5e35c13 Compare September 29, 2026 18:32
@jeffpaul jeffpaul modified the milestones: Future Release, 1.5.0 Sep 29, 2026
@jeffpaul

Copy link
Copy Markdown
Member

@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.

@jorgefilipecosta

jorgefilipecosta commented Oct 1, 2026 •

Copy link
Copy Markdown
Member Author

Testing using the browser console

The 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

  1. Enable the Custom Abilities experiment, which registers the content abilities.
  2. Enable the Excerpt Generation experiment. Its editor script is what adds @wordpress/abilities and @wordpress/core-abilities to the page; WordPress does not load them on its own.
  3. Open a post in the block editor, for example /wp-admin/post.php?post=1&action=edit, and open the console. It has to be a post: Excerpt Generation does not load for pages, so the page editor does not have the modules.
  4. Paste each block below separately, in order.

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;
	}
};

getAbility( 'core/content-create' ).input_schema shows an ability's input schema.

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 Z in dates. A request that skips it, such as a direct call to the REST run endpoint, has a date without an offset read in the site's timezone: with the site set to Europe/Madrid, date: '2027-01-01T09:00:00' was saved with date_gmt 2027-01-01T08:00:00+00:00.

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 } ]

@jorgefilipecosta
jorgefilipecosta marked this pull request as ready for review October 1, 2026 11:20
@jorgefilipecosta
jorgefilipecosta requested a review from a team October 1, 2026 11:20
@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

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 props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: jorgefilipecosta <jorgefilipecosta@git.wordpress.org>
Co-authored-by: gziolo <gziolo@git.wordpress.org>
Co-authored-by: jeffpaul <jeffpaul@git.wordpress.org>
Co-authored-by: apeatling <apeatling@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@apeatling

Copy link
Copy Markdown

One thing I want to flag is core/content-update and the only option being to rewrite the entire content field with updates.

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

@jorgefilipecosta

Copy link
Copy Markdown
Member Author

One thing I want to flag is core/content-update and the only option being to rewrite the entire content field with updates.

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 ?

@apeatling

apeatling commented Oct 1, 2026 •

Copy link
Copy Markdown

I think overwriting the whole content like this PR does is going to be needed as it is basic functionality

Agreed, no need to be a blocker, although I think it is a semi-dealbreaker for agent usage. Makes sense to follow up.

@jorgefilipecosta

Copy link
Copy Markdown
Member Author

I think overwriting the whole content like this PR does is going to be needed as it is basic functionality

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.

@jeffpaul
jeffpaul requested a review from gziolo October 4, 2026 15:02
@gziolo

gziolo commented Oct 5, 2026

Copy link
Copy Markdown
Member

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 core/content-query more closely. Right now the write abilities copy the REST posts endpoint in many places. An agent should be able to read a post with core/content-query and update its writable fields using consistent names, shapes, and rules. In practice:

  • Inputs mirror the query output. Align writable field names and values with the query output where practical. Use plain strings, not REST's { raw } objects. Reject input that cannot be applied, as the query does, instead of ignoring it.
  • Outputs follow the query rules. Raw fields need edit access, so avoid bypassing edit-access checks with $edit_context. Use one shape for delete instead of { deleted, previous }. Leave the query's read fields as they are, so drop supports_feature() and keep post_type_supports().
  • Share code and naming with the query. Use one ID lookup, one exposed post type check, and one fields schema. Use "content" in method names, not "post" or REST names like prepare_item_for_database().

Happy to go into details on any of these.

@jorgefilipecosta
jorgefilipecosta force-pushed the add/core-content-write-abilities branch from b798181 to e439910 Compare October 5, 2026 22:01
@gziolo

gziolo commented Oct 6, 2026

Copy link
Copy Markdown
Member

Could we use author_slug (a string) consistently across the content abilities? Currently, core/content-query accepts an author ID for filtering and returns author as { id, name }, while create/update accept an integer ID.

By author_slug, I mean the public user slug (user_nicename), which appears in author URLs and is exposed as slug by core/users-query—not the login or display name. This gives agents a human-readable reference they can pass directly between abilities.

The proposal would use author_slug for:

  • Filtering posts in core/content-query.
  • Representing the author in content output.
  • Assigning the author in core/content-create and core/content-update.

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 author field, but this would give us one concise, consistent representation across reads and writes.

Comment thread includes/Abilities/Content/Content.php Outdated
Comment thread includes/Abilities/Content/Content.php Outdated
Comment thread includes/Abilities/Content/Content.php Outdated
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.
jorgefilipecosta added a commit to jorgefilipecosta/wordpress-develop that referenced this pull request Oct 7, 2026
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 gziolo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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?

@gziolo gziolo changed the title Add core/content-create, core/content-update, and core/content-delete abilities Add core/content-create, core/content-update, and core/content-delete abilities Oct 8, 2026
- 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Needs review

Development

Successfully merging this pull request may close these issues.

4 participants