Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.83% covered (success)
93.83%
213 / 227
80.77% covered (warning)
80.77%
21 / 26
CRAP
0.00% covered (danger)
0.00%
0 / 1
CommentUtils
93.83% covered (success)
93.83%
213 / 227
80.77% covered (warning)
80.77%
21 / 26
161.71
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isBlockElement
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isRenderingTransparentNode
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
11
 isOurGeneratedNode
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 cantHaveElementChildren
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
7
 isCommentSeparator
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
9
 isCommentContent
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 childIndexOf
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 contains
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 closestElement
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 closestElementWithSibling
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
72
 getTranscludedFromElement
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
10
 getHeadlineNodeAndOffset
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
4.43
 htmlTrim
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getIndentLevel
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 getCoveredSiblings
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 getFullyCoveredSiblings
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
 unwrapParsoidSections
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
8.03
 getTitleFromUrl
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 linearWalk
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 linearWalkBackwards
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 getRangeFirstNode
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getRangeLastNode
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 compareRanges
88.00% covered (warning)
88.00%
22 / 25
0.00% covered (danger)
0.00%
0 / 1
23.91
 compareRangesAlmostEqualBoundaries
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
15
 isSingleCommentSignedBy
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3namespace MediaWiki\Extension\DiscussionTools;
4
5use Config;
6use LogicException;
7use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentCommentItem;
8use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentThreadItem;
9use Wikimedia\Assert\Assert;
10use Wikimedia\Parsoid\DOM\Comment;
11use Wikimedia\Parsoid\DOM\Element;
12use Wikimedia\Parsoid\DOM\Node;
13use Wikimedia\Parsoid\DOM\Text;
14use Wikimedia\Parsoid\Utils\DOMCompat;
15
16class CommentUtils {
17
18    private function __construct() {
19    }
20
21    private static $blockElementTypes = [
22        'div', 'p',
23        // Tables
24        'table', 'tbody', 'thead', 'tfoot', 'caption', 'th', 'tr', 'td',
25        // Lists
26        'ul', 'ol', 'li', 'dl', 'dt', 'dd',
27        // HTML5 heading content
28        'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hgroup',
29        // HTML5 sectioning content
30        'article', 'aside', 'body', 'nav', 'section', 'footer', 'header', 'figure',
31        'figcaption', 'fieldset', 'details', 'blockquote',
32        // Other
33        'hr', 'button', 'canvas', 'center', 'col', 'colgroup', 'embed',
34        'map', 'object', 'pre', 'progress', 'video'
35    ];
36
37    /**
38     * @param Node $node
39     * @return bool Node is a block element
40     */
41    public static function isBlockElement( Node $node ): bool {
42        return $node instanceof Element &&
43            in_array( strtolower( $node->tagName ), static::$blockElementTypes );
44    }
45
46    private const SOL_TRANSPARENT_LINK_REGEX =
47        '/(?:^|\s)mw:PageProp\/(?:Category|redirect|Language)(?=$|\s)/D';
48
49    /**
50     * @param Node $node
51     * @return bool Node is considered a rendering-transparent node in Parsoid
52     */
53    public static function isRenderingTransparentNode( Node $node ): bool {
54        $nextSibling = $node->nextSibling;
55        return (
56            $node instanceof Comment ||
57            $node instanceof Element && (
58                strtolower( $node->tagName ) === 'meta' ||
59                (
60                    strtolower( $node->tagName ) === 'link' &&
61                    preg_match( static::SOL_TRANSPARENT_LINK_REGEX, $node->getAttribute( 'rel' ) ?? '' )
62                ) ||
63                // Empty inline templates, e.g. tracking templates. (T269036)
64                // But not empty nodes that are just the start of a non-empty template about-group. (T290940)
65                (
66                    strtolower( $node->tagName ) === 'span' &&
67                    in_array( 'mw:Transclusion', explode( ' ', $node->getAttribute( 'typeof' ) ?? '' ) ) &&
68                    !static::htmlTrim( DOMCompat::getInnerHTML( $node ) ) &&
69                    (
70                        !$nextSibling || !( $nextSibling instanceof Element ) ||
71                        // Maybe we should be checking all of the about-grouped nodes to see if they're empty,
72                        // but that's prooobably not needed in practice, and it leads to a quadratic worst case.
73                        $nextSibling->getAttribute( 'about' ) !== $node->getAttribute( 'about' )
74                    )
75                )
76            )
77        );
78    }
79
80    /**
81     * @param Node $node
82     * @return bool Node was added to the page by DiscussionTools
83     */
84    public static function isOurGeneratedNode( Node $node ): bool {
85        return $node instanceof Element && (
86            DOMCompat::getClassList( $node )->contains( 'ext-discussiontools-init-replylink-buttons' ) ||
87            $node->hasAttribute( 'data-mw-comment-start' ) ||
88            $node->hasAttribute( 'data-mw-comment-end' )
89        );
90    }
91
92    /**
93     * Elements which can't have element children (but some may have text content).
94     *
95     * @var string[]
96     */
97    private static array $noElementChildrenElementTypes = [
98        // https://html.spec.whatwg.org/multipage/syntax.html#elements-2
99        // Void elements
100        'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
101        'link', 'meta', 'param', 'source', 'track', 'wbr',
102        // Raw text elements
103        'script', 'style',
104        // Escapable raw text elements
105        'textarea', 'title',
106        // Foreign elements
107        'math', 'svg',
108        // Treated like text when scripting is enabled in the parser
109        // https://html.spec.whatwg.org/#the-noscript-element
110        'noscript',
111        // Replaced elements (that aren't already included above)
112        // https://html.spec.whatwg.org/multipage/rendering.html#replaced-elements
113        // They might allow element children, but they aren't rendered on the page.
114        'audio', 'canvas', 'iframe', 'object', 'video',
115    ];
116
117    /**
118     * @param Node $node
119     * @return bool If true, node can't have element children. If false, it's complicated.
120     */
121    public static function cantHaveElementChildren( Node $node ): bool {
122        return (
123            $node instanceof Comment ||
124            $node instanceof Element && (
125                in_array( strtolower( $node->tagName ), static::$noElementChildrenElementTypes ) ||
126                // Thumbnail wrappers generated by MediaTransformOutput::linkWrap (T301427),
127                // for compatibility with TimedMediaHandler.
128                // There is no better way to detect them, and we can't insert markers here,
129                // because the media DOM CSS depends on specific tag names and their order :(
130                // TODO See if we can remove this condition when wgParserEnableLegacyMediaDOM=false
131                // is enabled everywhere.
132                (
133                    in_array( strtolower( $node->tagName ), [ 'a', 'span' ] ) &&
134                    $node->firstChild &&
135                    // We always step inside a child node so this can't be infinite, silly Phan
136                    // @phan-suppress-next-line PhanInfiniteRecursion
137                    static::cantHaveElementChildren( $node->firstChild )
138                ) ||
139                // Do not insert anything inside figures when using wgParserEnableLegacyMediaDOM=false,
140                // because their CSS can't handle it (T320285).
141                strtolower( $node->tagName ) === 'figure'
142            )
143        );
144    }
145
146    /**
147     * Check whether the node is a comment separator (instead of a part of the comment).
148     *
149     * @param Node $node
150     * @return bool
151     */
152    public static function isCommentSeparator( Node $node ): bool {
153        return $node instanceof Element && (
154            // Empty paragraphs (`<p><br></p>`) between indented comments mess up indentation detection
155            strtolower( $node->tagName ) === 'br' ||
156            // Horizontal line
157            strtolower( $node->tagName ) === 'hr' ||
158            // {{outdent}} templates
159            DOMCompat::getClassList( $node )->contains( 'outdent-template' ) ||
160            // {{tracked}} templates (T313097)
161            DOMCompat::getClassList( $node )->contains( 'mw-trackedTemplate' ) ||
162            // Wikitext definition list term markup (`;`) when used as a fake heading (T265964)
163            (
164                strtolower( $node->nodeName ) === 'dl' &&
165                count( $node->childNodes ) === 1 &&
166                $node->firstChild instanceof Element &&
167                strtolower( $node->firstChild->nodeName ) === 'dt'
168            )
169        );
170    }
171
172    /**
173     * Check whether the node is a comment content. It's a little vague what this means…
174     *
175     * @param Node $node Node, should be a leaf node (a node with no children)
176     * @return bool
177     */
178    public static function isCommentContent( Node $node ): bool {
179        return (
180            $node instanceof Text &&
181            static::htmlTrim( $node->nodeValue ?? '' ) !== ''
182        ) ||
183        (
184            static::cantHaveElementChildren( $node )
185        );
186    }
187
188    /**
189     * Get the index of $child in its parent
190     *
191     * @param Node $child
192     * @return int
193     */
194    public static function childIndexOf( Node $child ): int {
195        $i = 0;
196        while ( ( $child = $child->previousSibling ) ) {
197            $i++;
198        }
199        return $i;
200    }
201
202    /**
203     * Check whether a Node contains (is an ancestor of) another Node (or is the same node)
204     *
205     * @param Node $ancestor
206     * @param Node $descendant
207     * @return bool
208     */
209    public static function contains( Node $ancestor, Node $descendant ): bool {
210        // TODO can we use Node->compareDocumentPosition() here maybe?
211        $node = $descendant;
212        while ( $node && $node !== $ancestor ) {
213            $node = $node->parentNode;
214        }
215        return $node === $ancestor;
216    }
217
218    /**
219     * Find closest ancestor element using one of the given tag names.
220     *
221     * @param Node $node
222     * @param string[] $tagNames
223     * @return Element|null
224     */
225    public static function closestElement( Node $node, array $tagNames ): ?Element {
226        do {
227            if (
228                $node instanceof Element &&
229                in_array( strtolower( $node->tagName ), $tagNames )
230            ) {
231                return $node;
232            }
233            $node = $node->parentNode;
234        } while ( $node );
235        return null;
236    }
237
238    /**
239     * Find closest ancestor element that has sibling nodes
240     *
241     * @param Node $node
242     * @param string $direction Can be 'next', 'previous', or 'either'
243     * @return Element|null
244     */
245    public static function closestElementWithSibling( Node $node, string $direction ): ?Element {
246        do {
247            if (
248                $node instanceof Element && (
249                    ( $node->nextSibling && ( $direction === 'next' || $direction == 'either' ) ) ||
250                    ( $node->previousSibling && ( $direction === 'previous' || $direction == 'either' ) )
251                )
252            ) {
253                return $node;
254            }
255            $node = $node->parentNode;
256        } while ( $node );
257        return null;
258    }
259
260    /**
261     * Find the transclusion node which rendered the current node, if it exists.
262     *
263     * 1. Find the closest ancestor with an 'about' attribute
264     * 2. Find the main node of the about-group (first sibling with the same 'about' attribute)
265     * 3. If this is an mw:Transclusion node, return it; otherwise, go to step 1
266     *
267     * @param Node $node
268     * @return Element|null Transclusion node, null if not found
269     */
270    public static function getTranscludedFromElement( Node $node ): ?Element {
271        while ( $node ) {
272            // 1.
273            if (
274                $node instanceof Element &&
275                $node->getAttribute( 'about' ) &&
276                preg_match( '/^#mwt\d+$/', $node->getAttribute( 'about' ) ?? '' )
277            ) {
278                $about = $node->getAttribute( 'about' );
279
280                // 2.
281                while (
282                    ( $previousSibling = $node->previousSibling ) &&
283                    $previousSibling instanceof Element &&
284                    $previousSibling->getAttribute( 'about' ) === $about
285                ) {
286                    $node = $previousSibling;
287                }
288
289                // 3.
290                if (
291                    $node->getAttribute( 'typeof' ) &&
292                    in_array( 'mw:Transclusion', explode( ' ', $node->getAttribute( 'typeof' ) ?? '' ) )
293                ) {
294                    break;
295                }
296            }
297
298            $node = $node->parentNode;
299        }
300        return $node;
301    }
302
303    /**
304     * Given a heading node, return the node on which the ID attribute is set.
305     *
306     * Also returns the offset within that node where the heading text starts.
307     *
308     * @param Element $heading Heading node (`<h1>`-`<h6>`)
309     * @return array Array containing a 'node' (Element) and offset (int)
310     */
311    public static function getHeadlineNodeAndOffset( Element $heading ): array {
312        // This code assumes that $wgFragmentMode is [ 'html5', 'legacy' ] or [ 'html5' ]
313        $headline = $heading;
314        $offset = 0;
315
316        if ( $headline->hasAttribute( 'data-mw-comment-start' ) ) {
317            $headline = $headline->parentNode;
318            Assert::precondition( $headline !== null, 'data-mw-comment-start was attached to a heading' );
319        }
320
321        if ( !$headline->getAttribute( 'id' ) ) {
322            // PHP HTML: Find the child with .mw-headline
323            $headline = DOMCompat::querySelector( $headline, '.mw-headline' );