Skip to content

Commit c3fefd9

Browse files
authored
Merge branch 'WordPress:trunk' into trunk
2 parents dbc3306 + b260b73 commit c3fefd9

3 files changed

Lines changed: 291 additions & 0 deletions

File tree

src/wp-includes/comment.php

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4271,3 +4271,93 @@ function wp_create_initial_comment_meta() {
42714271
)
42724272
);
42734273
}
4274+
4275+
/**
4276+
* Strips inline note markers from rendered block output.
4277+
*
4278+
* Inline notes - notes anchored to a text selection within a block rather than
4279+
* the whole block - are anchored in raw block content with
4280+
* `<mark class="wp-note" data-id="N">...</mark>` so the marker survives edits,
4281+
* but the public HTML should not expose note metadata. This filter unwraps the
4282+
* marker entirely - dropping the `<mark>` open tag and its matching closer while
4283+
* keeping the marked text - so nothing leaks to the front end. The raw
4284+
* `post_content` (and the REST `raw` view, revisions, exports) keeps the marker
4285+
* so the editor can re-attach it on reload.
4286+
*
4287+
* Only note markers are unwrapped: {@see WP_HTML_Tag_Processor::has_class()}
4288+
* matches the `wp-note` class by exact token, so a `<mark>` a user or plugin
4289+
* added (e.g. a `core/text-color` highlight, or an unrelated `wp-note-foo`
4290+
* class) is never flagged and survives byte-for-byte with all of its attributes
4291+
* intact. A naive regex would be wrong here: a `\bwp-note\b` word boundary also
4292+
* matches `wp-note-foo`, which is why the class check goes through the HTML API
4293+
* instead.
4294+
*
4295+
* The HTML API has no public token-removal method yet, so an anonymous
4296+
* {@see WP_HTML_Tag_Processor} subclass unwraps each note `<mark>` and its
4297+
* matching closer directly on the parsed token stream. Walking tokens - rather
4298+
* than matching `<mark>` with a regex - means a `</mark>`-looking sequence inside
4299+
* a comment or attribute value can never be mistaken for a real tag, and a
4300+
* nesting stack keeps each note opener paired with its own closer so overlapping
4301+
* notes and any user highlight `<mark>` left intact still resolve correctly.
4302+
*
4303+
* The low-level {@see WP_HTML_Tag_Processor} is used deliberately, rather than
4304+
* the tree-building {@see WP_HTML_Processor}. Note markers live in user-editable
4305+
* content, so the markup is not guaranteed to be well formed. On certain
4306+
* ill-formed nesting the tree builder aborts, which would leave note markers -
4307+
* and their metadata - in the rendered output. Scanning tokens instead removes
4308+
* every `wp-note` marker it encounters and degrades gracefully: an unbalanced or
4309+
* stray tag is left exactly as it was rather than corrupting surrounding markup.
4310+
*
4311+
* @since 7.1.0
4312+
*
4313+
* @param string $block_content Rendered block HTML.
4314+
* @return string Block HTML with `wp-note` markers unwrapped.
4315+
*/
4316+
function wp_strip_inline_note_markers( $block_content ) {
4317+
if ( ! str_contains( $block_content, 'wp-note' ) ) {
4318+
return $block_content;
4319+
}
4320+
4321+
/*
4322+
* Anonymous subclass exposing token removal, which WP_HTML_Tag_Processor
4323+
* does not provide publicly yet. Removing the current token via its bookmark
4324+
* span unwraps the `<mark>` (opener or closer) while keeping the text it
4325+
* wraps.
4326+
*/
4327+
$processor = new class( $block_content ) extends WP_HTML_Tag_Processor {
4328+
/**
4329+
* Removes the current token, keeping any text it wraps.
4330+
*/
4331+
public function remove_token(): void {
4332+
// Always called after next_tag() returned true, so the bookmark is set.
4333+
$this->set_bookmark( 'here' );
4334+
$span = $this->bookmarks['here'];
4335+
4336+
$this->lexical_updates[] = new WP_HTML_Text_Replacement( $span->start, $span->length, '' );
4337+
}
4338+
};
4339+
4340+
/*
4341+
* Walk every `<mark>`, tracking note nesting on a stack so each note opener
4342+
* pairs with its own closer, and unwrap only the note markers.
4343+
*/
4344+
$mark_stack = array();
4345+
$query = array(
4346+
'tag_name' => 'MARK',
4347+
'tag_closers' => 'visit',
4348+
);
4349+
while ( $processor->next_tag( $query ) ) {
4350+
if ( $processor->is_tag_closer() ) {
4351+
$is_note = array_pop( $mark_stack );
4352+
} else {
4353+
$is_note = $processor->has_class( 'wp-note' );
4354+
$mark_stack[] = $is_note;
4355+
}
4356+
4357+
if ( true === $is_note ) {
4358+
$processor->remove_token();
4359+
}
4360+
}
4361+
4362+
return $processor->get_updated_html();
4363+
}

src/wp-includes/default-filters.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -789,6 +789,9 @@
789789
// Fluid typography.
790790
add_filter( 'render_block', 'wp_render_typography_support', 10, 2 );
791791

792+
// Inline note markers.
793+
add_filter( 'render_block', 'wp_strip_inline_note_markers' );
794+
792795
// User preferences.
793796
add_action( 'init', 'wp_register_persisted_preferences_meta' );
794797

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
<?php
2+
3+
/**
4+
* Tests that inline note markers are unwrapped in rendered block output via the
5+
* render_block filter, while raw post content is left untouched.
6+
*
7+
* The `<mark class="wp-note">` wrapper is removed entirely - both the open tag
8+
* and its matching closer - so no note marker or metadata reaches the public
9+
* HTML, while the marked text (and any nested formatting) is preserved.
10+
*
11+
* @group comment
12+
* @group notes
13+
*
14+
* @covers ::wp_strip_inline_note_markers
15+
*/
16+
class Tests_Comment_StripInlineNoteMarkers extends WP_UnitTestCase {
17+
18+
/**
19+
* @ticket 65482
20+
*/
21+
public function test_strip_unwraps_marker_from_mark(): void {
22+
$html = '<p>Hello <mark class="wp-note" data-id="7">marked</mark> world</p>';
23+
$stripped = wp_strip_inline_note_markers( $html );
24+
25+
$this->assertSame( '<p>Hello marked world</p>', $stripped );
26+
}
27+
28+
/**
29+
* @ticket 65482
30+
*/
31+
public function test_strip_handles_multiple_markers_in_one_block(): void {
32+
$html = '<p><mark class="wp-note" data-id="1">a</mark> and <mark class="wp-note" data-id="2">b</mark></p>';
33+
$stripped = wp_strip_inline_note_markers( $html );
34+
35+
$this->assertSame( '<p>a and b</p>', $stripped );
36+
}
37+
38+
/**
39+
* @ticket 65482
40+
*/
41+
public function test_strip_passes_through_block_content_without_markers(): void {
42+
$html = '<p>Plain text with no notes here.</p>';
43+
$stripped = wp_strip_inline_note_markers( $html );
44+
45+
$this->assertSame( $html, $stripped );
46+
}
47+
48+
/**
49+
* @ticket 65482
50+
*/
51+
public function test_strip_keeps_other_classes_when_removing_wp_note(): void {
52+
// The whole wrapper is removed, so any companion classes go with it.
53+
$html = '<p><mark class="custom wp-note other" data-id="3">x</mark></p>';
54+
$stripped = wp_strip_inline_note_markers( $html );
55+
56+
$this->assertSame( '<p>x</p>', $stripped );
57+
}
58+
59+
/**
60+
* @ticket 65482
61+
*/
62+
public function test_strip_leaves_unrelated_marks_untouched(): void {
63+
// A user highlight (`core/text-color`) serializes as a plain `<mark>` and
64+
// must survive untouched.
65+
$html = '<p><mark style="background-color:#ff0">keep me</mark></p>';
66+
$stripped = wp_strip_inline_note_markers( $html );
67+
68+
$this->assertSame( $html, $stripped );
69+
}
70+
71+
/**
72+
* @ticket 65482
73+
*/
74+
public function test_strip_does_not_match_partial_class_names(): void {
75+
// `wp-note-foo` is a different class and must not be treated as a marker;
76+
// a regex word boundary would incorrectly match it.
77+
$html = '<p><mark class="wp-note-foo">keep me</mark></p>';
78+
$stripped = wp_strip_inline_note_markers( $html );
79+
80+
$this->assertSame( $html, $stripped );
81+
}
82+
83+
/**
84+
* @ticket 65482
85+
*/
86+
public function test_strip_preserves_user_mark_attributes_next_to_note(): void {
87+
// A user/plugin `<mark>` with several attributes sitting beside a note
88+
// marker must be returned byte-for-byte; only the `wp-note` wrapper goes.
89+
$html = '<p><mark class="highlight" style="background-color:#ff0" data-id="99" title="kept">user</mark> and <mark class="wp-note" data-id="4">noted</mark></p>';
90+
$stripped = wp_strip_inline_note_markers( $html );
91+
92+
$this->assertSame( '<p><mark class="highlight" style="background-color:#ff0" data-id="99" title="kept">user</mark> and noted</p>', $stripped );
93+
}
94+
95+
/**
96+
* @ticket 65482
97+
*/
98+
public function test_strip_preserves_nested_formatting(): void {
99+
// A note wrapping already-formatted text (e.g. coloured text) serializes
100+
// with nested inline elements. The wrapper is removed while the inner
101+
// markup is preserved intact.
102+
$html = '<p><mark class="wp-note" data-id="1">a <span style="color:red">red</span> b</mark></p>';
103+
$stripped = wp_strip_inline_note_markers( $html );
104+
105+
$this->assertSame( '<p>a <span style="color:red">red</span> b</p>', $stripped );
106+
}
107+
108+
/**
109+
* @ticket 65482
110+
*/
111+
public function test_strip_unwraps_note_but_keeps_inner_highlight_mark(): void {
112+
// A note wrapping a user highlight nests `<mark>` inside `<mark>`. Only the
113+
// note wrapper is removed; the inner highlight `<mark>` is preserved, and
114+
// the closer pairing must not unbalance.
115+
$html = '<p><mark class="wp-note" data-id="1">a <mark style="background-color:#ff0">hi</mark> b</mark></p>';
116+
$stripped = wp_strip_inline_note_markers( $html );
117+
118+
$this->assertSame( '<p>a <mark style="background-color:#ff0">hi</mark> b</p>', $stripped );
119+
}
120+
121+
/**
122+
* @ticket 65482
123+
*/
124+
public function test_strip_handles_overlapping_nested_note_markers(): void {
125+
// Two notes anchored on overlapping text serialize as nested `<mark>`s.
126+
// Both wrappers are removed and the text survives.
127+
$html = '<p><mark class="wp-note" data-id="1">a<mark class="wp-note" data-id="2">b</mark>c</mark></p>';
128+
$stripped = wp_strip_inline_note_markers( $html );
129+
130+
$this->assertSame( '<p>abc</p>', $stripped );
131+
}
132+
133+
/**
134+
* @ticket 65482
135+
*/
136+
public function test_strip_ignores_mark_like_text_inside_a_comment(): void {
137+
// A `</mark>` sequence inside an HTML comment is text, not a tag. Walking
138+
// the parsed token stream ignores it; a raw regex over the string would
139+
// mistake it for the note's closer, unbalance the pairing, and corrupt
140+
// both the comment and the real wrapper.
141+
$html = '<p><mark class="wp-note" data-id="1">a<!-- </mark> -->b</mark>tail</p>';
142+
$stripped = wp_strip_inline_note_markers( $html );
143+
144+
$this->assertSame( '<p>a<!-- </mark> -->btail</p>', $stripped );
145+
}
146+
147+
/**
148+
* A note marker left unclosed (e.g. by a hand edit in the code editor) still
149+
* has its opener stripped, so no `wp-note` metadata leaks to the front end.
150+
*
151+
* @ticket 65482
152+
*/
153+
public function test_strip_unwraps_unclosed_note_marker(): void {
154+
$html = '<p><mark class="wp-note" data-id="1">a';
155+
$stripped = wp_strip_inline_note_markers( $html );
156+
157+
$this->assertSame( '<p>a', $stripped );
158+
}
159+
160+
/**
161+
* A stray `</mark>` closer with no matching opener is not a note marker, so it
162+
* is left exactly as it was rather than corrupting the surrounding markup.
163+
*
164+
* @ticket 65482
165+
*/
166+
public function test_strip_leaves_stray_mark_closer_untouched(): void {
167+
$html = '<p>a</mark>b</p>';
168+
$stripped = wp_strip_inline_note_markers( $html );
169+
170+
$this->assertSame( $html, $stripped );
171+
}
172+
173+
/**
174+
* Note markers can be crossed with other inline formatting in hand-edited or
175+
* otherwise ill-formed content. The full HTML tree builder would abort on this
176+
* nesting and leave the marker (and its metadata) in place; scanning tokens
177+
* strips the `wp-note` marker regardless and keeps the rest of the markup.
178+
*
179+
* @ticket 65482
180+
*/
181+
public function test_strip_unwraps_note_marker_with_improper_nesting(): void {
182+
$html = '<p><mark class="wp-note" data-id="1">a<i>b</mark>c</i></p>';
183+
$stripped = wp_strip_inline_note_markers( $html );
184+
185+
$this->assertSame( '<p>a<i>bc</i></p>', $stripped );
186+
}
187+
188+
/**
189+
* @ticket 65482
190+
*/
191+
public function test_strip_filter_is_registered_on_render_block(): void {
192+
// Guards against future hook rewiring that would silently leave
193+
// inline-note markers in rendered output.
194+
$this->assertNotFalse(
195+
has_filter( 'render_block', 'wp_strip_inline_note_markers' )
196+
);
197+
}
198+
}

0 commit comments

Comments
 (0)