Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
f9baca5
Suggest mode 2/7: suggestion storage, REST controller, and provider
adamsilverstein Jul 17, 2026
5d67f02
Merge branch 'suggest/intent' into suggest/data
adamsilverstein Aug 5, 2026
0c3c9bc
Merge branch 'suggest/intent' into suggest/data
adamsilverstein Aug 11, 2026
548a812
Merge branch 'suggest/intent' into suggest/data
adamsilverstein Aug 11, 2026
60d20a1
Merge branch 'suggest/intent' into suggest/data
adamsilverstein Aug 12, 2026
93e5cad
Drop dependency-group comment blocks in suggestion mode data files
adamsilverstein Aug 12, 2026
568e8d6
Suggest mode: cancel pending auto-saves when leaving Suggest intent
adamsilverstein Aug 22, 2026
ff96c0f
Suggest mode: persist a structural decision before mutating the block…
adamsilverstein Aug 22, 2026
31d5e28
Merge branch 'suggest/intent' into suggest/data
adamsilverstein Aug 22, 2026
b50655b
Merge branch 'suggest/intent' into suggest/data
adamsilverstein Aug 23, 2026
7b51646
Convert suggestion-mode data layer to TypeScript
adamsilverstein Aug 23, 2026
24e1a2e
Merge remote-tracking branch 'origin/trunk' into suggest/data
adamsilverstein Aug 24, 2026
788481e
Merge branch 'suggest/intent' into suggest/data
adamsilverstein Aug 24, 2026
ef43f1c
Merge remote-tracking branch 'origin/trunk' into suggest/data
adamsilverstein Aug 24, 2026
8aec65e
Merge branch 'suggest/intent' into suggest/data
adamsilverstein Aug 24, 2026
72c3bb9
Merge branch 'suggest/intent' into suggest/data
adamsilverstein Aug 24, 2026
3304c95
Repoint the suggestion-mode imports at the store constants
adamsilverstein Aug 24, 2026
eabf601
Merge branch 'relocate/intent' into relocate/data
adamsilverstein Aug 25, 2026
c778241
Move the storage-layer review fixes into the storage layer
adamsilverstein Aug 25, 2026
bf4e03e
Merge suggest/intent into suggest/data
adamsilverstein Aug 25, 2026
c05f47a
Merge remote-tracking branch 'origin/suggest/intent' into suggest/data
adamsilverstein Aug 27, 2026
6b52cd1
Merge branch 'suggest/intent' into suggest/data
adamsilverstein Aug 31, 2026
e09d869
Merge branch 'suggest/intent' into suggest/data
adamsilverstein Aug 31, 2026
ec0f118
Name DOM unit tests for the jsdom Jest project
adamsilverstein Aug 31, 2026
79cb562
Merge branch 'suggest/intent' into suggest/data
adamsilverstein Aug 31, 2026
1545656
Merge branch 'suggest/intent' into suggest/data
adamsilverstein Sep 4, 2026
15093ba
Type the interface store import and the plugin global without directives
adamsilverstein Sep 4, 2026
fb2285f
Run the data layer's unit tests under Vitest
adamsilverstein Sep 4, 2026
6f1b6fc
Merge branch 'suggest/intent' into suggest/data
adamsilverstein Sep 4, 2026
e9d82f9
Merge branch 'suggest/intent' into suggest/data
adamsilverstein Sep 5, 2026
a8ea6eb
Merge branch 'suggest/intent' into suggest/data
adamsilverstein Sep 6, 2026
18a38c8
Merge branch 'suggest/intent' into suggest/data
adamsilverstein Sep 10, 2026
bb2db06
Merge branch 'suggest/intent' into suggest/data
adamsilverstein Sep 11, 2026
70d3146
Merge branch 'suggest/intent' into suggest/data
adamsilverstein Sep 11, 2026
5487e49
Suggest mode: drop the `@wordpress/interface` typings shim
adamsilverstein Sep 11, 2026
2807883
Merge branch 'suggest/intent' into suggest/data
adamsilverstein Sep 15, 2026
445c519
Merge remote-tracking branch 'origin/suggest/intent' into suggest/data
adamsilverstein Sep 16, 2026
d8b1597
Suggest mode: reformat for Prettier 3.9.6
adamsilverstein Sep 16, 2026
a32a57d
Merge remote-tracking branch 'origin/suggest/intent' into suggest/data
adamsilverstein Sep 18, 2026
b0c7248
Merge remote-tracking branch 'origin/suggest/intent' into suggest/data
adamsilverstein Sep 18, 2026
e1998ee
Suggest mode: clear the overlay when rejecting an attribute suggestion
adamsilverstein Sep 29, 2026
723f771
Merge remote-tracking branch 'origin/suggest/intent' into suggest/data
adamsilverstein Sep 29, 2026
ceb3997
Suggest mode: resolve the live parent when rejecting a move
adamsilverstein Sep 29, 2026
e4fdd2f
Suggest mode: flush pending suggestions when leaving Suggesting
adamsilverstein Sep 29, 2026
429da7a
Merge remote-tracking branch 'origin/suggest/intent' into suggest/data
adamsilverstein Sep 29, 2026
34431b4
Merge remote-tracking branch 'origin/suggest/intent' into suggest/data
adamsilverstein Sep 29, 2026
ce42365
Suggestions: KSES every string leaf of applied payload values
adamsilverstein Sep 30, 2026
92707f2
Merge remote-tracking branch 'origin/suggest/intent' into suggest/data
adamsilverstein Sep 30, 2026
9e649a9
Suggestion mode: Let blocks subscribe to their own overlay entry
adamsilverstein Sep 30, 2026
325717e
Suggestion mode: Debounce auto-save per changed block, flush on unmount
adamsilverstein Sep 30, 2026
9108e5a
Suggestion mode: Expose whether an interceptor bypass is pending
adamsilverstein Sep 30, 2026
1d70d0f
Merge remote-tracking branch 'origin/suggest/intent' into suggest/data
adamsilverstein Sep 30, 2026
3219749
Suggestion mode: Pass a valid status to createNotice
adamsilverstein Sep 30, 2026
872f58d
Merge remote-tracking branch 'origin/suggest/intent' into suggest/data
adamsilverstein Sep 30, 2026
4402200
Merge branch 'suggest/intent' into suggest/data
adamsilverstein Sep 30, 2026
9fdfbbd
Merge branch 'suggest/intent' into suggest/data
adamsilverstein Oct 1, 2026
84f62d3
Suggest mode: Unlink a withdrawn suggestion from its block
adamsilverstein Oct 1, 2026
489b8ea
Suggest mode: Keep the overlay entry when an apply fails to save
adamsilverstein Oct 1, 2026
b33ba84
Suggest mode: Drop the redundant note update permission shortcut
adamsilverstein Oct 1, 2026
4cb645c
Merge branch 'suggest/intent' into suggest/data
adamsilverstein Oct 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 209 additions & 0 deletions lib/compat/wordpress-7.1/block-suggestions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
<?php
/**
* Suggestion data support for suggest mode.
*
* Registers the comment meta that backs a suggested edit. A note-type comment
* becomes a suggestion when it carries a `_wp_suggestion` payload;
* `_wp_suggestion_status` tracks its apply/reject lifecycle. The base note
* infrastructure graduated to WordPress 6.9 core, so only the
* suggestion-specific additions live here in the 7.1 compat layer.
*
* @package gutenberg
*/

/**
* Maximum byte length of a `_wp_suggestion` payload. Mirrored on the client
* (`PAYLOAD_MAX_BYTES` in suggestion-mode/provider.js) so the editor refuses
* to submit anything the server will reject.
*/
if ( ! defined( 'GUTENBERG_SUGGESTION_PAYLOAD_MAX_BYTES' ) ) {
define( 'GUTENBERG_SUGGESTION_PAYLOAD_MAX_BYTES', 65536 );
}

/**
* Applies `wp_kses_post()` to the HTML-bearing string fields of a serialized
* block snapshot carried inside a suggestion operation (`op.block` on
* `block-remove` / `block-insert-after` ops), recursing into `innerBlocks`.
*
* `innerHTML` and `originalContent` are the fields a consumer turns back
* into markup when the block is re-inserted. `attributes` get the same
* string-leaf walk core applies to parsed blocks, so no nested value
* escapes the filter.
*
* @param array $block Serialized block snapshot (decoded from JSON).
* @return array Snapshot with HTML-bearing fields filtered.
*/
function gutenberg_kses_suggestion_block_snapshot( $block ) {
foreach ( array( 'innerHTML', 'originalContent' ) as $key ) {
if ( isset( $block[ $key ] ) && is_string( $block[ $key ] ) ) {
$block[ $key ] = wp_kses_post( $block[ $key ] );
}
}
if ( isset( $block['attributes'] ) && is_array( $block['attributes'] ) ) {
$block['attributes'] = filter_block_kses_value( $block['attributes'], 'post' );
}
if ( isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
foreach ( $block['innerBlocks'] as $index => $inner_block ) {
if ( is_array( $inner_block ) ) {
$block['innerBlocks'][ $index ] = gutenberg_kses_suggestion_block_snapshot( $inner_block );
}
}
}
return $block;
}

/**
* Sanitizes a `_wp_suggestion` payload to match what the writing user could
* publish directly in post content.
*
* The suggestion payload is applied verbatim to block attributes when a
* reviewer accepts it. Without write-time filtering, a low-capability
* suggester could smuggle markup (script tags, event handlers) that a
* reviewer with `unfiltered_html` would then persist under their own KSES
* exemption. To close that hole while keeping parity with regular editing:
*
* - Users with `unfiltered_html` store the payload as-is — the same
* freedom they already have in post content.
* - Everyone else has `wp_kses_post()` applied to every string leaf of
* the values that get APPLIED to content on accept/reject: `after`,
* `afterHTML`, `beforeHTML`, and the serialized block snapshot in `block`.
*
* `before` is intentionally NOT filtered: it is only compared against live
* content for conflict detection, never applied. Filtering it would produce
* false staleness warnings whenever the real content contains markup that
* KSES would strip.
*
* Note: apply-time sanitization scope is still under discussion; this
* write-time capability-matched filter is the baseline.
*
* @param string $value Raw JSON payload.
* @return string Sanitized JSON payload, or '' when the payload is invalid.
*/
function gutenberg_sanitize_suggestion_payload( $value ) {
if ( current_user_can( 'unfiltered_html' ) ) {
return $value;
}

$decoded = json_decode( $value, true );
// The REST controller rejects invalid-JSON payloads with a 400 before
// this callback runs; treat any non-REST garbage the same way the size
// cap does — reject rather than store something the client can't parse.
if ( ! is_array( $decoded ) ) {
return '';
}

if ( isset( $decoded['operations'] ) && is_array( $decoded['operations'] ) ) {
foreach ( $decoded['operations'] as $index => $operation ) {
if ( ! is_array( $operation ) ) {
continue;
}
// `after` can be structured (a table's `body` rows, a gallery's
// `images`), so every string leaf is filtered, not only strings.
foreach ( array( 'after', 'afterHTML', 'beforeHTML' ) as $key ) {
if ( isset( $operation[ $key ] ) ) {
$operation[ $key ] = filter_block_kses_value( $operation[ $key ], 'post' );
}
}
if ( isset( $operation['block'] ) && is_array( $operation['block'] ) ) {
$operation['block'] = gutenberg_kses_suggestion_block_snapshot( $operation['block'] );
}
$decoded['operations'][ $index ] = $operation;
}
}

$encoded = wp_json_encode( $decoded );
return false === $encoded ? '' : $encoded;
}

/**
* Registers the comment meta used by suggested edits.
*
* Notes ship in WordPress 6.9 core, which registers the base note meta
* (`_wp_note_status`). Suggestions are a Gutenberg 7.1 feature layered on top,
* so the suggestion-specific meta is registered here:
*
* - `_wp_suggestion` — proposed edit, JSON payload. Presence of this
* meta is what makes a note a suggestion.
* - `_wp_suggestion_status` — suggestion lifecycle (`pending` / `applied`
* / `rejected`). Set on apply or reject so the
* comment thread persists as evidence even after
* the suggestion is resolved.
*
* The suggestion is stored as comment meta rather than `comment_content` so a
* note can carry both a discussion (content) and a proposed edit (meta), and so
* per-meta `auth_callback`/`sanitize_callback` give strict per-field control
* independent of comment-text moderation. Size validation is strict: oversized
* payloads are rejected rather than truncated, since truncating JSON corrupts
* the payload.
*/
function gutenberg_register_suggestion_meta() {
$max_suggestion_payload_bytes = GUTENBERG_SUGGESTION_PAYLOAD_MAX_BYTES;

register_meta(
'comment',
'_wp_suggestion',
array(
'type' => 'string',
'description' => __( 'Suggested edit payload (JSON).', 'gutenberg' ),
'single' => true,
'show_in_rest' => array(
'schema' => array(
'type' => 'string',
'maxLength' => $max_suggestion_payload_bytes,
),
),
'sanitize_callback' => function ( $value ) use ( $max_suggestion_payload_bytes ) {
if ( ! is_string( $value ) ) {
return '';
}
// Reject rather than truncate. Truncating mid-string produces
// invalid JSON; `parseSuggestionPayload` would then return
// null and the suggestion would silently disappear.
if ( strlen( $value ) > $max_suggestion_payload_bytes ) {
return '';
}
// Capability-matched KSES filtering; runs as the writing user
// (the suggester) on create/update.
return gutenberg_sanitize_suggestion_payload( $value );
},
'auth_callback' => function ( $allowed, $meta_key, $object_id ) {
// During comment creation the comment does not yet exist, so
// `object_id` is 0. Defer to the comment controller's own
// create permission — if the request can create the
// comment at all, it can set the suggestion meta on it.
if ( ! $object_id ) {
return current_user_can( 'edit_posts' );
}
$comment = get_comment( $object_id );
if ( $comment && 'note' === $comment->comment_type ) {
return current_user_can( 'edit_post', $comment->comment_post_ID );
}
return current_user_can( 'edit_comment', $object_id );
},
)
);

register_meta(
'comment',
'_wp_suggestion_status',
array(
'type' => 'string',
'description' => __( 'Suggestion lifecycle status.', 'gutenberg' ),
'single' => true,
'show_in_rest' => array(
'schema' => array(
'type' => 'string',
'enum' => array( 'pending', 'applied', 'rejected' ),
),
),
'auth_callback' => function ( $allowed, $meta_key, $object_id ) {
$comment = get_comment( $object_id );
if ( $comment && 'note' === $comment->comment_type ) {
return current_user_can( 'edit_post', $comment->comment_post_ID );
}
return current_user_can( 'edit_comment', $object_id );
},
)
);
}
add_action( 'init', 'gutenberg_register_suggestion_meta' );
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
<?php
/**
* REST controller overrides for suggestion edits on `note`-type comments.
*
* Block notes (and the base note REST controller) graduated to WordPress 6.9
* core. Suggestions - a note that carries a `_wp_suggestion` payload describing
* a proposed edit - are a Gutenberg 7.1 feature layered on top, so only the
* suggestion-specific behavior lives here as a thin subclass of the core
* comments controller.
*
* Permissions are core's: `update_item` requires `edit_comment`, which
* `map_meta_cap()` resolves to `edit_post` on the note's parent post, so a post
* editor can already apply or reject a suggestion left on their post. This
* subclass only adds the suggestion-specific storage rules:
*
* - `prepare_item_for_database`: enforces server-side payload validation
* (a `_wp_suggestion` larger than `GUTENBERG_SUGGESTION_PAYLOAD_MAX_BYTES`
* is rejected with HTTP 413, and a payload that isn't a valid JSON object
* is rejected with HTTP 400, before any storage happens) and surfaces the
* suggestion payload to the content-allowed check below.
* - `check_is_comment_content_allowed`: a note may have empty
* `comment_content` when it carries only a proposed edit.
*
* @package gutenberg
* @since 7.1.0
*/

if ( class_exists( 'WP_REST_Comments_Controller' ) && ! class_exists( 'Gutenberg_REST_Comment_Controller_7_1' ) ) {
/**
* Core class to manage suggestion edits on note comments via the REST API.
*/
class Gutenberg_REST_Comment_Controller_7_1 extends WP_REST_Comments_Controller {

/**
* Validates an incoming request's `_wp_suggestion` meta before any
* storage happens:
*
* - The payload must be within the allowed byte budget. Truncating
* arbitrary JSON corrupts the payload, so we reject with a 413.
* - The payload must decode to a JSON object. Storing garbage would
* make `parseSuggestionPayload` return null on the client and the
* suggestion would silently disappear, so we reject with a 400.
*
* @param WP_REST_Request $request Full details about the request.
* @return true|WP_Error True if no payload or valid, WP_Error otherwise.
*/
protected static function validate_suggestion_payload( $request ) {
$meta = $request['meta'] ?? null;
if ( ! is_array( $meta ) || ! isset( $meta['_wp_suggestion'] ) ) {
return true;
}
$value = $meta['_wp_suggestion'];
if ( ! is_string( $value ) ) {
return true;
}
if ( strlen( $value ) > GUTENBERG_SUGGESTION_PAYLOAD_MAX_BYTES ) {
return new WP_Error(
'rest_suggestion_too_large',
sprintf(
/* translators: %d: maximum allowed byte length. */
__( 'Suggestion payload exceeds the %d-byte limit.', 'gutenberg' ),
GUTENBERG_SUGGESTION_PAYLOAD_MAX_BYTES
),
array( 'status' => 413 )
);
}
// An empty string is the documented "no suggestion" value; anything
// else must decode to a JSON object carrying the payload fields.
if ( '' !== $value ) {
$decoded = json_decode( $value, true );
if ( ! is_array( $decoded ) ) {
return new WP_Error(
'rest_suggestion_invalid_json',
__( 'Suggestion payload must be a valid JSON object.', 'gutenberg' ),
array( 'status' => 400 )
);
}
}
return true;
}

/**
* Prepares a single comment for create or update.
*
* Wraps core's preparation with two suggestion-specific concerns:
*
* - Rejects oversized `_wp_suggestion` payloads with a clean 413, and
* payloads that aren't valid JSON objects with a 400, before any
* storage happens (both create and update call this and return
* its WP_Error).
* - Surfaces the `_wp_suggestion` payload in the prepared `meta` so the
* content-allowed check can recognize a payload-only note, mirroring
* how core copies `_wp_note_status` for the same check.
*
* @param WP_REST_Request $request Request object.
* @return array|WP_Error Prepared comment, or WP_Error.
*/
protected function prepare_item_for_database( $request ) {
$payload_check = self::validate_suggestion_payload( $request );
if ( is_wp_error( $payload_check ) ) {
return $payload_check;
}

$prepared_comment = parent::prepare_item_for_database( $request );
if ( is_wp_error( $prepared_comment ) ) {
return $prepared_comment;
}

if ( isset( $request['meta']['_wp_suggestion'] ) ) {
if ( ! isset( $prepared_comment['meta'] ) || ! is_array( $prepared_comment['meta'] ) ) {
$prepared_comment['meta'] = array();
}
$prepared_comment['meta']['_wp_suggestion'] = $request['meta']['_wp_suggestion'];
}

return $prepared_comment;
}

/**
* Allows a note comment to have empty content when it carries a
* suggestion payload.
*
* A pure suggestion (a proposed edit with no discussion text) has empty
* `comment_content`; core would otherwise reject it. Everything else
* defers to core's check.
*
* @param array $prepared_comment Prepared comment data.
* @return bool
*/
protected function check_is_comment_content_allowed( $prepared_comment ) {
if (
isset( $prepared_comment['comment_type'] ) &&
'note' === $prepared_comment['comment_type'] &&
! empty( $prepared_comment['meta']['_wp_suggestion'] )
) {
return true;
}

return parent::check_is_comment_content_allowed( $prepared_comment );
}
}
}

add_action(
'rest_api_init',
function () {
// Core registers its routes on `rest_api_init` at priority 99, so this
// subclass registers first and its handlers are matched before core's.
$controller = new Gutenberg_REST_Comment_Controller_7_1();
$controller->register_routes();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
11
);
5 changes: 5 additions & 0 deletions lib/load.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ function gutenberg_is_experiment_enabled( $name ) {
require __DIR__ . '/compat/wordpress-7.1/block-bindings.php';
require __DIR__ . '/compat/wordpress-7.1/query-block.php';
require __DIR__ . '/compat/wordpress-7.1/block-comments.php';
// The suggestion meta registration and REST controller are always loaded:
// both are inert without suggestion data, which only the experiment-gated
// editor UI creates. The user-facing feature is gated in JS.
require __DIR__ . '/compat/wordpress-7.1/block-suggestions.php';
require __DIR__ . '/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php';

// WordPress 7.2 compat.
require __DIR__ . '/compat/wordpress-7.2/class-gutenberg-rest-templates-controller-7-2.php';
Expand Down
Loading
Loading