Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
2920973
Suggest mode: core subsystem (intent modes, overlay capture, structur…
adamsilverstein Jun 30, 2026
1406a17
Suggest mode: gate the feature behind a Suggestion Mode experiment
adamsilverstein Jun 30, 2026
bb166ee
Suggest mode: fix CI on the core branch
adamsilverstein Jun 30, 2026
0076b0b
Suggest mode: use progressive labels (Editing/Suggesting/Viewing) for…
adamsilverstein Jun 30, 2026
1210229
Suggest mode: move inline diff-rendering e2e tests to the inline layer
adamsilverstein Jun 30, 2026
04aa0f4
Suggest mode: sanitize suggestion payloads to match the writer's capa…
adamsilverstein Jul 1, 2026
e3f4b44
Suggest mode: lock saving while structural suggestions are pending
adamsilverstein Jul 1, 2026
1f2c6e9
Suggest mode: fix cross-parent block-move reject silently no-oping
adamsilverstein Jul 1, 2026
6156e1f
Suggest mode: enforce the lifecycle allowlist against merged request …
adamsilverstein Jul 1, 2026
53598fe
Suggest mode: consolidate feature gating behind one predicate
adamsilverstein Jul 1, 2026
29b4999
Suggest mode: move the editor intent API to private actions/selectors
adamsilverstein Jul 1, 2026
946583b
Suggest mode: keep interceptor dispatches off the undo stack
adamsilverstein Jul 1, 2026
efe0a5a
Suggest mode: fix intent-storage documentation contradictions
adamsilverstein Jul 1, 2026
fb08320
Address review feedback: minor fixes
adamsilverstein Jul 1, 2026
73e448b
Merge remote-tracking branch 'origin/trunk' into suggest/core
adamsilverstein Jul 2, 2026
d9798a2
Experiments: update Suggestion Mode description copy
adamsilverstein Jul 2, 2026
b0b32c5
Suggest mode: keep edits inside an inserted block part of the one ins…
adamsilverstein Jul 2, 2026
470d9e0
Suggest mode: avoid new react-hooks suppressions in the deferred-inse…
adamsilverstein Jul 2, 2026
6e9cdc6
Suggest mode: ship word-diff.js instead of the unused SuggestionDiff …
adamsilverstein Jul 2, 2026
7c3f924
Suggest mode: align the experiment description with the combined branch
adamsilverstein Jul 2, 2026
c7f050d
Suggest mode: add e2e coverage for the review cycle
adamsilverstein Jul 2, 2026
db10484
Suggest mode: switch an open sidebar to All notes when a suggestion i…
adamsilverstein Jul 6, 2026
33608c3
Suggest mode: persist pending structural suggestions instead of locki…
adamsilverstein Jul 6, 2026
1134f95
Suggest mode: e2e coverage for persistent structural suggestions
adamsilverstein Jul 6, 2026
59eb122
Suggest mode: attribute a move suggestion to the block the user moved
adamsilverstein Jul 7, 2026
dde3841
Merge branch 'trunk' into suggest/core
adamsilverstein Jul 7, 2026
543b4c8
Suggest mode: withdraw a pending suggestion with undo
adamsilverstein Jul 7, 2026
d4364bd
Suggest mode: e2e coverage for undo of structural and attribute sugge…
adamsilverstein Jul 7, 2026
39438d1
Suggest mode: e2e for text-alignment attribute suggestion
adamsilverstein Jul 7, 2026
80452aa
Merge remote-tracking branch 'origin/trunk' into suggest/core
adamsilverstein Jul 8, 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
239 changes: 239 additions & 0 deletions docs/explanations/architecture/suggestions.md

Large diffs are not rendered by default.

291 changes: 291 additions & 0 deletions docs/superpowers/plans/2026-06-16-suggest-mode-inline-markers.md

Large diffs are not rendered by default.

234 changes: 234 additions & 0 deletions lib/compat/wordpress-7.1/block-suggestions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
<?php
/**
* Suggestion 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`.
*
* Only `innerHTML` and `originalContent` are filtered: they are the fields a
* consumer turns back into markup when the block is re-inserted on accept.
*
* @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['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 the string 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;
}
foreach ( array( 'after', 'afterHTML', 'beforeHTML' ) as $key ) {
if ( isset( $operation[ $key ] ) && is_string( $operation[ $key ] ) ) {
$operation[ $key ] = wp_kses_post( $operation[ $key ] );
}
}
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' );
/**
* Hide un-accepted structural suggestions on the front end.
*
* Pending structural suggestion state (the `metadata.suggestion` marker, and
* for insertions the suggested block itself) saves into `post_content` so a
* suggestion survives a reload — the structural counterpart of inline
* suggestion markers living in content. At render time the strip is type-aware, mirroring the
* del/add split:
*
* - `pending-remove`: the block is real content until its removal is
* accepted, so it renders unchanged (`metadata` never reaches front-end
* markup).
* - `pending-insert`: the block is proposed new content, so it must not
* render until accepted — the whole subtree is dropped.
* - `pending-move`: the block renders (its content is real); it appears at
* the proposed position because block order is fixed before render. See
* Known Limitations in the suggestions architecture doc.
*
* @param string $block_content Rendered block HTML.
* @param array $block Parsed block, including attributes.
* @return string Block HTML, or an empty string for a pending insertion.
*/
function gutenberg_strip_pending_structural_suggestions( $block_content, $block ) {
if ( ! isset( $block['attrs']['metadata']['suggestion']['type'] ) ) {
return $block_content;
}
if ( 'pending-insert' === $block['attrs']['metadata']['suggestion']['type'] ) {
return '';
}
return $block_content;
}
add_filter( 'render_block', 'gutenberg_strip_pending_structural_suggestions', 10, 2 );
Loading
Loading