Skip to content

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

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

jorgefilipecosta wants to merge 182 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. Only init(), register_category(), and register() are public methods; as in core, the ability callbacks are closures over private methods.

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

    Both errors map the failed input to its message under params in the error data, as the REST API's rest_invalid_param does. 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 or publicly viewable 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.

  • core/content-query returns null instead of an empty string for a date it cannot resolve, as core does, and its date fields declare the date-time format. The write abilities take dates as strings only.

  • 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 (required in the shared post output schema), 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.
Changed - core/content-query returns null instead of an empty string for a date it cannot resolve.

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.78%. Comparing base (e120e2c) to head (d8b0eda).
⚠️ Report is 15 commits behind head on develop.

Additional details and impacted files
@@              Coverage Diff              @@
##             develop    #1025      +/-   ##
=============================================
+ Coverage      81.62%   82.78%   +1.15%     
- Complexity      3088     3528     +440     
=============================================
  Files            129      136       +7     
  Lines          12300    13603    +1303     
=============================================
+ Hits           10040    11261    +1221     
- Misses          2260     2342      +82     
Flag Coverage Δ
unit 82.78% <100.00%> (+1.15%) ⬆️

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

Comment thread includes/Abilities/Content/Content.php Outdated
Comment thread tests/Integration/Includes/Abilities/Content/ContentCreateTest.php Outdated
@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.
Comment thread includes/Abilities/Content/Content.php Outdated
…abilities

- Return null instead of an empty string for a date that cannot be
  resolved, and declare the `date-time` format on the date fields. A
  malformed stored date that the format rejects is reported as null
  too, instead of failing output validation for the whole call. The
  write abilities still take a date as a string only.
- Map the filter or field to the message under `params` in the
  `content_invalid_filter` and `content_invalid_field` error data, as
  the REST API's `rest_invalid_param` errors do, so callers can tell
  which input failed without parsing the translated message.
- Mark `id`, which is always returned, as required in the post output
  schema.
- Skip posts of other post types that query filters add, in query mode
  and in the slug lookup, as core does. The earlier port missed this,
  and left a stale `Plugin:` note in its place.
Only init(), register_category(), and register() stay public. The
permission and execute callbacks of `core/content-query`,
`core/content-create`, `core/content-update`, and `core/content-delete`,
and the `post_password_required` filter that format_post() adds, are now
private methods, reached through closures defined in the class, so
callers run the abilities through the Abilities API, such as
`wp_get_ability( 'core/content-query' )->execute()`, which validates the
input and checks permissions first.

Tests that call the callbacks directly now capture them from the
registration arguments, through the `wp_register_ability_args` filter.
The password filter test now reads `post_password_required()` while an
editable protected post renders, instead of calling the filter callback.
Ported from WordPress/wordpress-develop#12195.
A custom status can be publicly queryable without being public. Posts
with such a status are publicly viewable, so core/content-query lets
any logged-in user read them, but create and update only required the
publish capability for statuses registered as public. A contributor
could therefore show a post to everyone through that status.

Also check is_post_status_viewable(), and cover a publicly queryable
status in the create and update tests, which confirm that a refused
request writes nothing.
The kses rewrite in WordPress 7.2 also removes the text inside a
script element, which earlier versions keep, so the author round trip
failed on trunk. Build the expected values from what wp_kses_post()
leaves of the script, so the test passes on both.
jorgefilipecosta added a commit to jorgefilipecosta/wordpress-develop that referenced this pull request Oct 12, 2026
…ery ability

The content query now resolves the requested post with `get_content_by_id()` and
the post type with `get_exposed_post_type()`, and builds its `fields` input and
post output schemas with `get_fields_input_schema()` and
`get_content_output_schema()`, as the AI plugin does so its content write
abilities can reuse them (WordPress/ai#1025).

`get_content_by_id()` parses the ID the way the integer filters are parsed, so
only an integer or an unsigned integer string resolves a post.
jorgefilipecosta added a commit to jorgefilipecosta/wordpress-develop that referenced this pull request Oct 12, 2026
…ery ability

The query took an author user ID as its filter and returned the author as
`author`, an object with the user ID and display name. It now takes and returns
`author_slug`, the user's nicename, which `core/users-query` returns as `slug`,
as the AI plugin does (WordPress/ai#1025).

The slug must match exactly one user, or the filter fails as
`content_invalid_filter`. A user the caller may not see in `core/users-query` is
reported like a missing one, so the filter reveals no hidden user.
jorgefilipecosta added a commit to jorgefilipecosta/wordpress-develop that referenced this pull request Oct 12, 2026
…urns a non-string

`get_title()` returned whatever the `the_title` filters returned, so a filter
that returns a non-string failed its string return type and turned the query
into an `ability_callback_exception`. The rendered excerpt and content are
already guarded this way, as the AI plugin does
(WordPress/ai#1025).
jorgefilipecosta added a commit to jorgefilipecosta/wordpress-develop that referenced this pull request Oct 12, 2026
…uery ability

`check_read_permission()` calls `get_exposed_post_type()` for every post a
query returns, up to 100 per page, and each call rebuilt the map of every
exposed post type. It now reads the post type object and checks
`show_in_abilities`, the same truthy rule `get_post_types()` applies, as the AI
plugin does (WordPress/ai#1025).

`get_exposed_post_types()` was then only used for the post type names at
registration, which `get_post_types()` returns directly, so it is removed.
jorgefilipecosta added a commit to jorgefilipecosta/wordpress-develop that referenced this pull request Oct 12, 2026
The `get_author_by_slug()` docblock said the lookup reveals no hidden user, as
in `core/users-query`. A user who can edit others' posts of the post type, such
as an editor, resolves any slug, since they may make any user the author, while
`core/users-query` hides users like subscribers from them. The docblock now
says so, as the AI plugin does (WordPress/ai#1025).
jorgefilipecosta added a commit to jorgefilipecosta/wordpress-develop that referenced this pull request Oct 12, 2026
…docblock

The docblock said the parser keeps `author => 0` from dropping the author
filter, but the author filter is now `author_slug`, resolved by
`get_author_by_slug()`. `parse_filter_int()` only reads the `id` input and the
`parent` filter, so the docblock now describes those, as the AI plugin does
(WordPress/ai#1025).
jorgefilipecosta added a commit to jorgefilipecosta/wordpress-develop that referenced this pull request Oct 12, 2026
The class docblock and the `register()` comment named the future write ability
`core/manage-content`, and the comment sketched a `register_manage_content()`
call. The AI plugin is adding `core/content-create`, `core/content-update`, and
`core/content-delete` instead (WordPress/ai#1025), so
both now refer to future write-oriented content abilities in general.
jorgefilipecosta added a commit to jorgefilipecosta/wordpress-develop that referenced this pull request Oct 12, 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.
Ports the class docblock wording from WordPress/wordpress-develop#12195.
With `WP_Abilities_Settings` and `WP_Abilities_Content` both in Core,
the other core abilities are no longer all self-contained closures, so
the docblock contrasts the class with the core abilities that are
registered as closures.
jorgefilipecosta added a commit to jorgefilipecosta/wordpress-develop that referenced this pull request Oct 13, 2026
…ery ability

The content query now resolves the requested post with `get_content_by_id()` and
the post type with `get_exposed_post_type()`, and builds its `fields` input and
post output schemas with `get_fields_input_schema()` and
`get_content_output_schema()`, as the AI plugin does so its content write
abilities can reuse them (WordPress/ai#1025).

`get_content_by_id()` parses the ID the way the integer filters are parsed, so
only an integer or an unsigned integer string resolves a post.
jorgefilipecosta added a commit to jorgefilipecosta/wordpress-develop that referenced this pull request Oct 13, 2026
…ery ability

The query took an author user ID as its filter and returned the author as
`author`, an object with the user ID and display name. It now takes and returns
`author_slug`, the user's nicename, which `core/users-query` returns as `slug`,
as the AI plugin does (WordPress/ai#1025).

The slug must match exactly one user, or the filter fails as
`content_invalid_filter`. A user the caller may not see in `core/users-query` is
reported like a missing one, so the filter reveals no hidden user.
jorgefilipecosta added a commit to jorgefilipecosta/wordpress-develop that referenced this pull request Oct 13, 2026
…urns a non-string

`get_title()` returned whatever the `the_title` filters returned, so a filter
that returns a non-string failed its string return type and turned the query
into an `ability_callback_exception`. The rendered excerpt and content are
already guarded this way, as the AI plugin does
(WordPress/ai#1025).
jorgefilipecosta added a commit to jorgefilipecosta/wordpress-develop that referenced this pull request Oct 13, 2026
…uery ability

`check_read_permission()` calls `get_exposed_post_type()` for every post a
query returns, up to 100 per page, and each call rebuilt the map of every
exposed post type. It now reads the post type object and checks
`show_in_abilities`, the same truthy rule `get_post_types()` applies, as the AI
plugin does (WordPress/ai#1025).

`get_exposed_post_types()` was then only used for the post type names at
registration, which `get_post_types()` returns directly, so it is removed.
jorgefilipecosta added a commit to jorgefilipecosta/wordpress-develop that referenced this pull request Oct 13, 2026
The `get_author_by_slug()` docblock said the lookup reveals no hidden user, as
in `core/users-query`. A user who can edit others' posts of the post type, such
as an editor, resolves any slug, since they may make any user the author, while
`core/users-query` hides users like subscribers from them. The docblock now
says so, as the AI plugin does (WordPress/ai#1025).
jorgefilipecosta added a commit to jorgefilipecosta/wordpress-develop that referenced this pull request Oct 13, 2026
…docblock

The docblock said the parser keeps `author => 0` from dropping the author
filter, but the author filter is now `author_slug`, resolved by
`get_author_by_slug()`. `parse_filter_int()` only reads the `id` input and the
`parent` filter, so the docblock now describes those, as the AI plugin does
(WordPress/ai#1025).
jorgefilipecosta added a commit to jorgefilipecosta/wordpress-develop that referenced this pull request Oct 13, 2026
The class docblock and the `register()` comment named the future write ability
`core/manage-content`, and the comment sketched a `register_manage_content()`
call. The AI plugin is adding `core/content-create`, `core/content-update`, and
`core/content-delete` instead (WordPress/ai#1025), so
both now refer to future write-oriented content abilities in general.
jorgefilipecosta added a commit to jorgefilipecosta/wordpress-develop that referenced this pull request Oct 13, 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.
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