| 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 |
|