diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index b93908adc0519..cd38a1c927264 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -4271,3 +4271,81 @@ function wp_create_initial_comment_meta() { ) ); } + +/** + * Strips inline note markers from rendered block output. + * + * Inline notes - notes anchored to a text selection within a block rather than + * the whole block - are anchored in raw block content with + * `...` so the marker survives edits, + * but the public HTML should not expose note metadata. This filter unwraps the + * marker entirely - dropping the `` open tag and its matching closer while + * keeping the marked text - so nothing leaks to the front end. The raw + * `post_content` (and the REST `raw` view, revisions, exports) keeps the marker + * so the editor can re-attach it on reload. + * + * Only note markers are unwrapped: {@see WP_HTML_Tag_Processor::has_class()} + * matches the `wp-note` class by exact token, so a `` a user or plugin + * added (e.g. a `core/text-color` highlight, or an unrelated `wp-note-foo` + * class) is never flagged and survives byte-for-byte with all of its attributes + * intact. A naive regex would be wrong here: a `\bwp-note\b` word boundary also + * matches `wp-note-foo`, which is why the class check goes through the HTML API + * instead. + * + * The HTML API has no public token-removal method yet, so an anonymous + * {@see WP_HTML_Tag_Processor} subclass unwraps each note `` and its + * matching closer directly on the parsed token stream. Walking tokens - rather + * than matching `` with a regex - means a ``-looking sequence inside + * a comment or attribute value can never be mistaken for a real tag, and a + * nesting stack keeps each note opener paired with its own closer so overlapping + * notes and any user highlight `` left intact still resolve correctly. + * + * @since 7.1.0 + * + * @param string $block_content Rendered block HTML. + * @return string Block HTML with `wp-note` markers unwrapped. + */ +function wp_strip_inline_note_markers( $block_content ) { + if ( ! str_contains( $block_content, 'wp-note' ) ) { + return $block_content; + } + + // Anonymous subclass exposing token removal, which WP_HTML_Tag_Processor + // does not provide publicly yet. Removing the current token via its bookmark + // span unwraps the `` (opener or closer) while keeping the text it + // wraps. + $processor = new class( $block_content ) extends WP_HTML_Tag_Processor { + /** + * Removes the current token, keeping any text it wraps. + */ + public function remove_token() { + // Always called after next_tag() returned true, so the bookmark is set. + $this->set_bookmark( 'here' ); + $span = $this->bookmarks['here']; + + $this->lexical_updates[] = new WP_HTML_Text_Replacement( $span->start, $span->length, '' ); + } + }; + + // Walk every ``, tracking note nesting on a stack so each note opener + // pairs with its own closer, and unwrap only the note markers. + $mark_stack = array(); + $query = array( + 'tag_name' => 'MARK', + 'tag_closers' => 'visit', + ); + while ( $processor->next_tag( $query ) ) { + if ( $processor->is_tag_closer() ) { + $is_note = array_pop( $mark_stack ); + } else { + $is_note = $processor->has_class( 'wp-note' ); + $mark_stack[] = $is_note; + } + + if ( true === $is_note ) { + $processor->remove_token(); + } + } + + return $processor->get_updated_html(); +} diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 5581828a10b61..13fb922bc82de 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -789,6 +789,9 @@ // Fluid typography. add_filter( 'render_block', 'wp_render_typography_support', 10, 2 ); +// Inline note markers. +add_filter( 'render_block', 'wp_strip_inline_note_markers' ); + // User preferences. add_action( 'init', 'wp_register_persisted_preferences_meta' ); diff --git a/tests/phpunit/tests/comment/stripInlineNoteMarkers.php b/tests/phpunit/tests/comment/stripInlineNoteMarkers.php new file mode 100644 index 0000000000000..ada2c8a999dea --- /dev/null +++ b/tests/phpunit/tests/comment/stripInlineNoteMarkers.php @@ -0,0 +1,121 @@ +` wrapper is removed entirely - both the open tag + * and its matching closer - so no note marker or metadata reaches the public + * HTML, while the marked text (and any nested formatting) is preserved. + * + * @group comment + * @group notes + * + * @covers ::wp_strip_inline_note_markers + */ +class Tests_Comment_StripInlineNoteMarkers extends WP_UnitTestCase { + + public function test_strip_unwraps_marker_from_mark() { + $html = '

Hello marked world

'; + $stripped = wp_strip_inline_note_markers( $html ); + + $this->assertSame( '

Hello marked world

', $stripped ); + } + + public function test_strip_handles_multiple_markers_in_one_block() { + $html = '

a and b

'; + $stripped = wp_strip_inline_note_markers( $html ); + + $this->assertSame( '

a and b

', $stripped ); + } + + public function test_strip_passes_through_block_content_without_markers() { + $html = '

Plain text with no notes here.

'; + $stripped = wp_strip_inline_note_markers( $html ); + + $this->assertSame( $html, $stripped ); + } + + public function test_strip_keeps_other_classes_when_removing_wp_note() { + // The whole wrapper is removed, so any companion classes go with it. + $html = '

x

'; + $stripped = wp_strip_inline_note_markers( $html ); + + $this->assertSame( '

x

', $stripped ); + } + + public function test_strip_leaves_unrelated_marks_untouched() { + // A user highlight (`core/text-color`) serializes as a plain `` and + // must survive untouched. + $html = '

keep me

'; + $stripped = wp_strip_inline_note_markers( $html ); + + $this->assertSame( $html, $stripped ); + } + + public function test_strip_does_not_match_partial_class_names() { + // `wp-note-foo` is a different class and must not be treated as a marker; + // a regex word boundary would incorrectly match it. + $html = '

keep me

'; + $stripped = wp_strip_inline_note_markers( $html ); + + $this->assertSame( $html, $stripped ); + } + + public function test_strip_preserves_user_mark_attributes_next_to_note() { + // A user/plugin `` with several attributes sitting beside a note + // marker must be returned byte-for-byte; only the `wp-note` wrapper goes. + $html = '

user and noted

'; + $stripped = wp_strip_inline_note_markers( $html ); + + $this->assertSame( '

user and noted

', $stripped ); + } + + public function test_strip_preserves_nested_formatting() { + // A note wrapping already-formatted text (e.g. coloured text) serializes + // with nested inline elements. The wrapper is removed while the inner + // markup is preserved intact. + $html = '

a red b

'; + $stripped = wp_strip_inline_note_markers( $html ); + + $this->assertSame( '

a red b

', $stripped ); + } + + public function test_strip_unwraps_note_but_keeps_inner_highlight_mark() { + // A note wrapping a user highlight nests `` inside ``. Only the + // note wrapper is removed; the inner highlight `` is preserved, and + // the closer pairing must not unbalance. + $html = '

a hi b

'; + $stripped = wp_strip_inline_note_markers( $html ); + + $this->assertSame( '

a hi b

', $stripped ); + } + + public function test_strip_handles_overlapping_nested_note_markers() { + // Two notes anchored on overlapping text serialize as nested ``s. + // Both wrappers are removed and the text survives. + $html = '

abc

'; + $stripped = wp_strip_inline_note_markers( $html ); + + $this->assertSame( '

abc

', $stripped ); + } + + public function test_strip_ignores_mark_like_text_inside_a_comment() { + // A `
` sequence inside an HTML comment is text, not a tag. Walking + // the parsed token stream ignores it; a raw regex over the string would + // mistake it for the note's closer, unbalance the pairing, and corrupt + // both the comment and the real wrapper. + $html = '

abtail

'; + $stripped = wp_strip_inline_note_markers( $html ); + + $this->assertSame( '

abtail

', $stripped ); + } + + public function test_strip_filter_is_registered_on_render_block() { + // Guards against future hook rewiring that would silently leave + // inline-note markers in rendered output. + $this->assertNotFalse( + has_filter( 'render_block', 'wp_strip_inline_note_markers' ) + ); + } +}