PluginProbe
Gutenberg / 23.7.2
Gutenberg v23.7.2
23.9.1 23.9.0 23.8.0 23.7.2 23.7.1 23.7.0 23.6.1 23.6.2 23.6.0 23.5.3 23.5.2 23.5.1 23.5.0 23.4.0 23.3.2 23.3.1 23.3.0 23.2.0 23.2.1 23.2.2 23.1.1 23.1.0 23.0.1 12.6.0 7.4.0 All 402 releases
gutenberg / lib / compat / wordpress-7.1 / block-comments.php

block-comments.php in Gutenberg 23.7.2, at lib/compat/wordpress-7.1/block-comments.php

183 lines 7.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Inline (partial-text) note support for block comments.
4 *
5 * Block comments (notes) shipped in WordPress 6.9; see
6 * `lib/compat/wordpress-6.9/block-comments.php`. Inline notes - notes anchored
7 * to a text selection within a block rather than the whole block - are a 7.1
8 * addition and live here.
9 *
10 * An inline note's anchor is the in-content `<mark class="wp-note" data-id="N">`
11 * marker alone: the `data-id` identifies the note and the marker's position
12 * follows edits, so no separate selection metadata is stored. The marker is
13 * kept in the raw `post_content` (and REST `raw` view) and only stripped from
14 * rendered front-end output by the filter below.
15 */
16
17 /**
18 * Strip inline note markers from rendered block output.
19 *
20 * Inline notes are anchored in raw block content with
21 * `<mark class="wp-note" data-id="N">…</mark>` so the marker survives edits,
22 * but the public HTML should not expose note metadata. `render_block` unwraps
23 * the marker entirely - dropping the `<mark>` open tag and its matching closer
24 * while keeping the marked text - so nothing leaks to the front end. The raw
25 * `post_content` (and the REST `raw` view, revisions, exports) keeps the marker
26 * so the editor can re-attach on reload.
27 *
28 * Only note markers are unwrapped: `WP_HTML_Tag_Processor::has_class()` matches
29 * the `wp-note` class by exact token, so a `<mark>` a user or plugin added
30 * (e.g. a `core/text-color` highlight, or an unrelated `wp-note-foo` class) is
31 * never flagged and survives byte-for-byte with all of its attributes intact.
32 * A naive regex would be wrong here: a `\bwp-note\b` word boundary also matches
33 * `wp-note-foo`, which is why the class check goes through the HTML API instead.
34 *
35 * The HTML API has no public token-removal method yet (it is on the roadmap:
36 * https://github.com/WordPress/gutenberg/discussions/54583), so an anonymous
37 * `WP_HTML_Tag_Processor` subclass unwraps each note `<mark>` and its matching
38 * closer directly on the parsed token stream. Walking tokens - rather than
39 * matching `<mark>` with a regex - means `</mark>`-looking text inside a comment
40 * or attribute value can never be mistaken for a real tag, and a nesting stack
41 * keeps each note opener paired with its own closer so overlapping notes and any
42 * user highlight `<mark>` left intact still resolve correctly.
43 *
44 * @param string $block_content Rendered block HTML.
45 * @return string Block HTML with wp-note markers unwrapped.
46 */
47 function gutenberg_strip_inline_note_markers( $block_content ) {
48 if ( ! str_contains( $block_content, 'wp-note' ) ) {
49 return $block_content;
50 }
51
52 // Anonymous subclass exposing token removal, which WP_HTML_Tag_Processor
53 // does not provide publicly yet. Removing the current token via its bookmark
54 // span unwraps the `<mark>` (opener or closer) while keeping the text it
55 // wraps. The redeclaration-guard sniff cannot tell these class methods from
56 // global functions, so it is disabled for the class body.
57 // phpcs:disable Gutenberg.CodeAnalysis.GuardedFunctionAndClassNames.FunctionNotGuardedAgainstRedeclaration
58 $processor = new class( $block_content ) extends WP_HTML_Tag_Processor {
59 /**
60 * Removes the current token, keeping any text it wraps.
61 */
62 public function remove_token(): void {
63 // Always called after next_tag() returned true, so the bookmark is set.
64 $this->set_bookmark( 'here' );
65 $span = $this->bookmarks['here'];
66
67 $this->lexical_updates[] = new WP_HTML_Text_Replacement( $span->start, $span->length, '' );
68 }
69 };
70 // phpcs:enable Gutenberg.CodeAnalysis.GuardedFunctionAndClassNames.FunctionNotGuardedAgainstRedeclaration
71
72 // Walk every `<mark>`, tracking note nesting on a stack so each note opener
73 // pairs with its own closer, and unwrap only the note markers.
74 $mark_stack = array();
75 $query = array(
76 'tag_name' => 'MARK',
77 'tag_closers' => 'visit',
78 );
79 while ( $processor->next_tag( $query ) ) {
80 if ( $processor->is_tag_closer() ) {
81 $is_note = array_pop( $mark_stack );
82 } else {
83 $is_note = $processor->has_class( 'wp-note' );
84 $mark_stack[] = $is_note;
85 }
86
87 if ( true === $is_note ) {
88 $processor->remove_token();
89 }
90 }
91
92 return $processor->get_updated_html();
93 }
94 add_filter( 'render_block', 'gutenberg_strip_inline_note_markers' );
95
96 /**
97 * Allows the note mention chip markup through comment kses.
98 *
99 * The notes `@` mention completer stores a mention as a chip carrying the
100 * mentioned user's ID in a class token:
101 * `<span class="wp-note-mention user-N">@Name</span>`. The default comment
102 * kses allowlist (the minimal `$allowedtags` used in the
103 * `pre_comment_content` context) does not allow `span` at all, so for users
104 * without `unfiltered_html` the mention would be stripped on save.
105 *
106 * This allowance is deliberately narrow and always on: `span` is a
107 * semantics-free element and gutenberg_notes_sanitize_mention_classes()
108 * reduces its `class` to the two mention tokens right after kses runs, so
109 * regular (including anonymous) commenters gain nothing beyond the inert
110 * mention markup itself. Keeping it unconditional avoids per-comment-type
111 * arming and disarming of kses state across the direct and REST write paths.
112 *
113 * @param array<string, array<string, bool>> $allowed The allowed tags structure for the context.
114 * @param string $context The kses context.
115 * @return array<string, array<string, bool>> Modified allowed tags structure.
116 */
117 function gutenberg_notes_allow_mention_span( $allowed, $context ): array {
118 if ( ! is_array( $allowed ) ) {
119 $allowed = array();
120 }
121 if ( 'pre_comment_content' !== $context ) {
122 return $allowed;
123 }
124
125 if ( ! isset( $allowed['span'] ) || ! is_array( $allowed['span'] ) ) {
126 $allowed['span'] = array();
127 }
128
129 $allowed['span']['class'] = true;
130
131 return $allowed;
132 }
133
134 /**
135 * Reduces `span` classes in comment content to the note mention tokens.
136 *
137 * gutenberg_notes_allow_mention_span() lets `class` through kses on `span`
138 * so the mention chip survives, but `class` is an open-ended styling and
139 * scripting hook, so this companion pass - running right after
140 * `wp_filter_kses` at priority 10 - strips every class token except the two
141 * the mention markup uses: `wp-note-mention` and `user-N`. `span` is the only
142 * comment tag allowed to carry `class` at all, so walking `span` tags covers
143 * the entire allowance.
144 *
145 * The pass only applies while the restrictive comment allowlist is active:
146 * users with `unfiltered_html` are filtered through `wp_filter_post_kses`
147 * (or not at all), where arbitrary classes are already permitted, and
148 * narrowing their markup here would restrict what core allows them to post.
149 *
150 * @param string $content Slashed comment content, already filtered by kses.
151 * @return string Slashed comment content with span classes reduced.
152 */
153 function gutenberg_notes_sanitize_mention_classes( $content ): string {
154 if ( ! is_string( $content ) ) {
155 $content = '';
156 }
157 if ( false === has_filter( 'pre_comment_content', 'wp_filter_kses' ) ) {
158 return $content;
159 }
160
161 $processor = new WP_HTML_Tag_Processor( wp_unslash( $content ) );
162
163 while ( $processor->next_tag( 'SPAN' ) ) {
164 foreach ( $processor->class_list() as $token ) {
165 if ( 'wp-note-mention' !== $token && ! preg_match( '/^user-[1-9][0-9]*$/', $token ) ) {
166 // Removing the last class also removes the attribute itself.
167 $processor->remove_class( $token );
168 }
169 }
170 }
171
172 return wp_slash( $processor->get_updated_html() );
173 }
174
175 /*
176 * When WordPress itself carries the mention allowance (WordPress 7.1+),
177 * defer to it.
178 */
179 if ( ! function_exists( '_wp_kses_allow_note_mention_span' ) ) {
180 add_filter( 'wp_kses_allowed_html', 'gutenberg_notes_allow_mention_span', 10, 2 );
181 add_filter( 'pre_comment_content', 'gutenberg_notes_sanitize_mention_classes', 11 );
182 }
183