Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
76.52% covered (warning)
76.52%
202 / 264
47.37% covered (danger)
47.37%
9 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
CommentModifier
76.52% covered (warning)
76.52%
202 / 264
47.37% covered (danger)
47.37%
9 / 19
278.39
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
 whitespaceParsoidHack
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 sanitizeWikitextLinebreaks
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 addReplyLink
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 addListItem
94.74% covered (success)
94.74%
90 / 95
0.00% covered (danger)
0.00%
0 / 1
34.17
 allOfType
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 unwrapFragment
75.00% covered (warning)
75.00%
15 / 20
0.00% covered (danger)
0.00%
0 / 1
14.25
 unwrapList
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
13
 addSiblingListItem
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 createWikitextNode
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 isWikitextNodeListItem
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 appendSignature
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
8
 appendSignatureWikitext
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 addReply
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
56
 transferReply
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
72
 prepareWikitextReply
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 prepareHtmlReply
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
8
 addWikitextReply
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 addHtmlReply
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace MediaWiki\Extension\DiscussionTools;
4
5use InvalidArgumentException;
6use LogicException;
7use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentCommentItem;
8use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentThreadItem;
9use MediaWiki\MediaWikiServices;
10use UnexpectedValueException;
11use Wikimedia\Assert\Assert;
12use Wikimedia\Parsoid\DOM\Document;
13use Wikimedia\Parsoid\DOM\DocumentFragment;
14use Wikimedia\Parsoid\DOM\Element;
15use Wikimedia\Parsoid\DOM\Node;
16use Wikimedia\Parsoid\DOM\Text;
17use Wikimedia\Parsoid\Utils\DOMCompat;
18use Wikimedia\Parsoid\Utils\DOMUtils;
19
20class CommentModifier {
21
22    private function __construct() {
23    }
24
25    /**
26     * Add an attribute to a list item to remove pre-whitespace in Parsoid
27     */
28    private static function whitespaceParsoidHack( Element $listItem ): void {
29        // HACK: Setting data-parsoid removes the whitespace after the list item,
30        // which makes nested lists work.
31        // This is undocumented behaviour and probably very fragile.
32        $listItem->setAttribute( 'data-parsoid', '{}' );
33    }
34
35    /**
36     * Remove extra linebreaks from a wikitext string
37     */
38    public static function sanitizeWikitextLinebreaks( string $wikitext ): string {
39        $wikitext = CommentUtils::htmlTrim( $wikitext );
40        $wikitext = preg_replace( "/\r/", "\n", $wikitext );
41        $wikitext = preg_replace( "/\n+/", "\n", $wikitext );
42        return $wikitext;
43    }
44
45    /**
46     * Given a comment and a reply link, add the reply link to its document's DOM tree, at the end of
47     * the comment.
48     *
49     * @param ContentCommentItem $comment
50     * @param Node $linkNode Reply link
51     */
52    public static function addReplyLink( ContentCommentItem $comment, Node $linkNode ): void {
53        $target = $comment->getRange()->endContainer;
54
55        // Insert the link before trailing whitespace.
56        // In the MediaWiki parser output, <ul>/<dl> nodes are preceded by a newline. Normally it isn't
57        // visible on the page. But if we insert an inline element (the reply link) after it, it becomes
58        // meaningful and gets rendered, which results in additional spacing before some reply links.
59        // Split the text node, so that we can insert the link before the trailing whitespace.
60        if ( $target instanceof Text ) {
61            preg_match( '/\s*$/', $target->nodeValue ?? '', $matches, PREG_OFFSET_CAPTURE );
62            $byteOffset = $matches[0][1];
63            $charOffset = mb_strlen(
64                substr( $target->nodeValue ?? '', 0, $byteOffset )
65            );
66            $target->splitText( $charOffset );
67        }
68
69        $target->parentNode->insertBefore( $linkNode, $target->nextSibling );
70    }
71
72    /**
73     * Given a comment, add a list item to its document's DOM tree, inside of which a reply to said
74     * comment can be added.
75     *
76     * The DOM tree is suitably rearranged to ensure correct indentation level of the reply (wrapper
77     * nodes are added, and other nodes may be moved around).
78     *
79     * @param ContentThreadItem $comment
80     * @param string $replyIndentation Reply indentation syntax to use, one of:
81     *   - 'invisible' (use `<dl><dd>` tags to output `:` in wikitext)
82     *   - 'bullet' (use `<ul><li>` tags to output `*` in wikitext)
83     */
84    public static function addListItem( ContentThreadItem $comment, string $replyIndentation ): Element {
85        $listTypeMap = [
86            'li' => 'ul',
87            'dd' => 'dl'
88        ];
89
90        // 1. Start at given comment
91        // 2. Skip past all comments with level greater than the given
92        //    (or in other words, all replies, and replies to replies, and so on)
93        // 3. Add comment with level of the given comment plus 1
94
95        $curComment = $comment;
96        while ( count( $curComment->getReplies() ) ) {
97            $replies = $curComment->getReplies();
98            $curComment = end( $replies );
99        }
100
101        // Tag names for lists and items we're going to insert
102        if ( $replyIndentation === 'invisible' ) {
103            $itemType = 'dd';
104        } elseif ( $replyIndentation === 'bullet' ) {
105            $itemType = 'li';
106        } else {
107            throw new InvalidArgumentException( "Invalid reply indentation syntax '$replyIndentation'" );
108        }
109        $listType = $listTypeMap[ $itemType ];
110
111        $desiredLevel = $comment->getLevel() + 1;
112        $target = $curComment->getRange()->endContainer;
113
114        // target is a text node or an inline element at the end of a "paragraph"
115        // (not necessarily paragraph node).
116        // First, we need to find a block-level parent that we can mess with.
117        // If we can't find a surrounding list item or paragraph (e.g. maybe we're inside a table cell
118        // or something), take the parent node and hope for the best.
119        $parent = CommentUtils::closestElement( $target, [ 'li', 'dd', 'p' ] ) ??
120            $target->parentNode;
121        while ( $target->parentNode !== $parent ) {
122            $target = $target->parentNode;
123        }
124        // parent is a list item or paragraph (hopefully)
125        // target is an inline node within it
126
127        // If the comment is fully covered by some wrapper element, insert replies outside that wrapper.
128        // This will often just be a paragraph node (<p>), but it can be a <div> or <table> that serves
129        // as some kind of a fancy frame, which are often used for barnstars and announcements.
130        // @phan-suppress-next-line PhanTypeMismatchArgumentNullable
131        $excludedWrapper = CommentUtils::closestElement( $target, [ 'section' ] ) ?:
132            $curComment->getRootNode();
133        $covered = CommentUtils::getFullyCoveredSiblings( $curComment, $excludedWrapper );
134        if ( $curComment->getLevel() === 1 && $covered ) {
135            $target = end( $covered );
136            $parent = $target->parentNode;
137        }
138
139        // If the comment is in a transclusion, insert replies after the transclusion. (T313100)
140        // This method should never be called in cases where that would be a bad idea.
141        // @phan-suppress-next-line PhanTypeMismatchArgumentNullable
142        $transclusionNode = CommentUtils::getTranscludedFromElement( $target );
143        if ( $transclusionNode ) {
144            while (
145                ( $nextSibling = $transclusionNode->nextSibling ) &&
146                $nextSibling instanceof Element &&
147                $nextSibling->getAttribute( 'about' ) === $transclusionNode->getAttribute( 'about' )
148            ) {
149                $transclusionNode = $nextSibling;
150            }
151            $target = $transclusionNode;
152            $parent = $target->parentNode;
153        }
154
155        // If we can't insert a list directly inside this element, insert after it.
156        // The covered wrapper check above handles most cases, but we still need this sometimes, such as:
157        // * If the comment starts in the middle of a list, then ends with an unindented p/pre, the
158        //   wrapper check doesn't adjust the parent
159        // * If the comment consists of multiple list items (starting with a <dt>, so that the comment is
160        //   considered to be unindented, that is level === 1), but not all of them, the wrapper check
161        //   adjusts the parent to be the list, and the rest of the algorithm doesn't handle that well
162        if (
163            strtolower( $parent->tagName ) === 'p' ||
164            strtolower( $parent->tagName ) === 'pre' ||
165            strtolower( $parent->tagName ) === 'ul' ||
166            strtolower( $parent->tagName ) === 'dl'
167        ) {
168            $parent = $parent->parentNode;
169            $target = $target->parentNode;
170        }
171
172        Assert::precondition( $target !== null, 'We have not stepped outside the document' );
173        // Instead of just using $curComment->getLevel(), consider indentation of lists within the
174        // comment (T252702)
175        $curLevel = CommentUtils::getIndentLevel( $target, $curComment->getRootNode() ) + 1;
176
177        $item = null;
178        if ( $desiredLevel === 1 ) {
179            // Special handling for top-level comments
180            // We use section=new API for adding them in PHP, so this should never happen
181            throw new UnexpectedValueException( "Can't add a top-level comment" );
182
183        } elseif ( $curLevel < $desiredLevel ) {
184            // Insert more lists after the target to increase nesting.
185
186            // Parsoid puts HTML comments (and other "rendering-transparent nodes", e.g. category links)
187            // which appear at the end of the line in wikitext outside the paragraph,
188            // but we usually shouldn't insert replies between the paragraph and such comments. (T257651)
189            // Skip over comments and whitespace, but only update target when skipping past comments.
190            $pointer = $target;
191            while (
192                $pointer->nextSibling && (
193                    CommentUtils::isRenderingTransparentNode( $pointer->nextSibling ) ||
194                    (
195                        $pointer->nextSibling instanceof Text &&
196                        CommentUtils::htmlTrim( $pointer->nextSibling->nodeValue ?? '' ) === '' &&
197                        // If more that two lines of whitespace are detected, the following HTML
198                        // comments are not considered to be part of the reply (T264026)
199                        !preg_match( '/(\r?\n){2,}/', $pointer->nextSibling->nodeValue ?? '' )
200                    )
201                )
202            ) {
203                $pointer = $pointer->nextSibling;
204                if ( CommentUtils::isRenderingTransparentNode( $pointer ) ) {
205                    $target = $pointer;
206                }
207            }
208
209            // Insert required number of wrappers
210            while ( $curLevel < $desiredLevel ) {
211                $list = $target->ownerDocument->createElement( $listType );
212                // Setting modified would only be needed for removeAddedListItem,
213                // which isn't needed on the server
214                // $list->setAttribute( 'dt-modified', 'new' );
215                $item = $target->ownerDocument->createElement( $itemType );
216                // $item->setAttribute( 'dt-modified', 'new' );
217
218                $parent->insertBefore( $list, $target->nextSibling );
219                $list->appendChild( $item );
220
221                $target = $item;
222                $parent = $list;
223                $curLevel++;
224            }
225        } else {
226            // Split the ancestor nodes after the target to decrease nesting.
227
228            do {
229                if ( !$target || !$parent ) {
230                    throw new LogicException( 'Can not decrease nesting any more' );
231                }
232
233                // If target is the last child of its parent, no need to split it
234                if ( $target->nextSibling ) {
235                    // Create new identical node after the parent
236                    $newNode = $parent->cloneNode( false );
237                    // $parent->setAttribute( 'dt-modified', 'split' );
238                    $parent->parentNode->insertBefore( $newNode, $parent->nextSibling );
239
240                    // Move nodes following target to the new node
241                    while ( $target->nextSibling ) {
242                        $newNode->appendChild( $target->nextSibling );
243                    }
244                }
245
246                $target = $parent;
247                $parent = $parent->parentNode;
248
249                // Decrease nesting level if we escaped outside of a list
250                if ( isset( $listTypeMap[ strtolower( $target->tagName ) ] ) ) {
251                    $curLevel--;
252                }
253            } while ( $curLevel >= $desiredLevel );
254
255            // parent is now a list, target is a list item
256            if ( $itemType === strtolower( $target->tagName ) ) {
257                $item = $target->ownerDocument->createElement( $itemType );
258                // $item->setAttribute( 'dt-modified', 'new' );
259                $parent->insertBefore( $item, $target->nextSibling );
260
261            } else {
262                // This is the wrong type of list, split it one more time
263
264                // If target is the last child of its parent, no need to split it
265                if ( $target->nextSibling ) {
266                    // Create new identical node after the parent
267                    $newNode = $parent->cloneNode( false );
268                    // $parent->setAttribute( 'dt-modified', 'split' );
269                    $parent->parentNode->insertBefore( $newNode, $parent->nextSibling );
270
271                    // Move nodes following target to the new node
272                    while ( $target->nextSibling ) {
273                        $newNode->appendChild( $target->nextSibling );
274                    }
275                }
276
277                $target = $parent;
278                $parent = $parent->parentNode;
279
280                // Insert a list of the right type in the middle
281                $list = $target->ownerDocument->createElement( $listType );
282                // Setting modified would only be needed for removeAddedListItem,
283                // which isn't needed on the server
284                // $list->setAttribute( 'dt-modified', 'new' );
285                $item = $target->ownerDocument->createElement( $itemType );
286                // $item->setAttribute( 'dt-modified', 'new' );
287
288                $parent->insertBefore( $list, $target->nextSibling );
289                $list->appendChild( $item );
290            }
291        }
292
293        if ( $item === null ) {
294            throw new LogicException( 'No item found' );
295        }
296
297        return $item;
298    }
299
300    /**
301     * Check all elements in a node list are of a given type
302     *
303     * Also returns false if there are no elements in the list
304     *
305     * @param iterable<Node> $nodes Node list
306     * @param string $type Element type
307     */
308    private static function allOfType( iterable $nodes, string $type ): bool {
309        $hasElements = false;
310        foreach ( $nodes as $node ) {
311            if ( $node instanceof Element ) {
312                if ( strtolower( $node->tagName ) !== strtolower( $type ) ) {
313                    return false;
314                }
315                $hasElements = true;
316            }
317        }
318        return $hasElements;
319    }
320
321    /**
322     * Remove unnecessary list wrappers from a comment fragment
323     *
324     * TODO: Implement this in JS if required
325     *
326     * @param DocumentFragment $fragment Fragment
327     */
328    public static function unwrapFragment( DocumentFragment $fragment ): void {
329        // Wrap orphaned list items
330        $list = null;
331        if ( static::allOfType( $fragment->childNodes, 'dd' ) ) {
332            $list = $fragment->ownerDocument->createElement( 'dl' );
333        } elseif ( static::allOfType( $fragment->childNodes, 'li' ) ) {
334            $list = $fragment->ownerDocument->createElement( 'ul' );
335        }
336        if ( $list ) {
337            while ( $fragment->firstChild ) {
338                $list->appendChild( $fragment->firstChild );
339            }
340            $fragment->appendChild( $list );
341        }
342
343        // If all child nodes are lists of the same type, unwrap them
344        while (
345            static::allOfType( $fragment->childNodes, 'dl' ) ||
346            static::allOfType( $fragment->childNodes, 'ul' ) ||
347            static::allOfType( $fragment->childNodes, 'ol' ) ||
348            (
349                // Or if the comment starts with a bullet followed by indents
350                count( $fragment->childNodes ) > 1 &&
351                static::allOfType( [ $fragment->childNodes[0] ], 'ul' ) &&
352                static::allOfType( array_slice( iterator_to_array( $fragment->childNodes ), 1 ), 'dl' )
353            )
354        ) {
355            // Do not iterate over childNodes while we're modifying it
356            $childNodeList = iterator_to_array( $fragment->childNodes );
357            foreach ( $childNodeList as $node ) {
358                static::unwrapList( $node, $fragment );
359            }
360        }
361    }
362
363    // removeAddedListItem is only needed in the client
364
365    /**
366     * Unwrap a top level list, converting list item text to paragraphs
367     *
368     * Assumes that the list has a parent node, or is a root child in the provided
369     * document fragment.
370     *
371     * @param Node $list DOM node, will be wrapped if it is a list element (dl/ol/ul)
372     * @param DocumentFragment|null $fragment Containing document fragment if list has no parent
373     */
374    public static function unwrapList( Node $list, ?DocumentFragment $fragment = null ): void {
375        if ( !(
376            $list instanceof Element && (
377                strtolower( $list->tagName ) === 'dl' ||
378                strtolower( $list->tagName ) === 'ol' ||
379                strtolower( $list->tagName ) === 'ul'
380            )
381        ) ) {
382            // Not a list, leave alone (e.g. auto-generated ref block)
383            return;
384        }
385
386        // If the whole list is a template return it unmodified (T253150)
387        if ( CommentUtils::getTranscludedFromElement( $list ) ) {
388            return;
389        }
390
391        $doc = $list->ownerDocument;
392        $container = $fragment ?: $list->parentNode;
393        $referenceNode = $list;
394        while ( $list->firstChild ) {
395            if ( $list->firstChild instanceof Element ) {
396                // Move <dd> contents to <p>
397                $p = $doc->createElement( 'p' );
398                while ( $list->firstChild->firstChild ) {
399                    // If contents is a block element, place outside the paragraph
400                    // and start a new paragraph after
401                    if ( CommentUtils::isBlockElement( $list->firstChild->firstChild ) ) {
402                        if ( $p->firstChild ) {
403                            $insertBefore = $referenceNode->nextSibling;
404                            $referenceNode = $p;
405                            $container->insertBefore( $p, $insertBefore );
406                        }
407                        $insertBefore = $referenceNode->nextSibling;
408                        $referenceNode = $list->firstChild->firstChild;
409                        $container->insertBefore( $list->firstChild->firstChild, $insertBefore );
410                        $p = $doc->createElement( 'p' );
411                    } else {
412                        $p->appendChild( $list->firstChild->firstChild );
413                    }
414                }
415                if ( $p->firstChild ) {
416                    $insertBefore = $referenceNode->nextSibling;
417                    $referenceNode = $p;
418                    $container->insertBefore( $p, $insertBefore );
419                }
420                $list->removeChild( $list->firstChild );
421            } else {
422                // Text node / comment node, probably empty
423                $insertBefore = $referenceNode->nextSibling;
424                $referenceNode = $list->firstChild;
425                $container->insertBefore( $list->firstChild, $insertBefore );
426            }
427        }
428        $container->removeChild( $list );
429    }
430
431    /**
432     * Add another list item after the given one.
433     */
434    public static function addSiblingListItem( Element $previousItem ): Element {
435        $listItem = $previousItem->ownerDocument->createElement( $previousItem->tagName );
436        $previousItem->parentNode->insertBefore( $listItem, $previousItem->nextSibling );
437        return $listItem;
438    }
439
440    /**
441     * Create an element that will convert to the provided wikitext
442     */
443    public static function createWikitextNode( Document $doc, string $wikitext ): Element {
444        $span = $doc->createElement( 'span' );
445
446        $span->setAttribute( 'typeof', 'mw:Transclusion' );
447        $span->setAttribute( 'data-mw', json_encode( [ 'parts' => [ $wikitext ] ] ) );
448
449        return $span;
450    }
451
452    /**
453     * Check if an element created by ::createWikitextNode() starts with list item markup.
454     */
455    private static function isWikitextNodeListItem( Element $node ): bool {
456        $dataMw = json_decode( $node->getAttribute( 'data-mw' ) ?? '', true );
457        $wikitextLine = $dataMw['parts'][0] ?? null;
458        return $wikitextLine && is_string( $wikitextLine ) &&
459            in_array( $wikitextLine[0], [ '*', '#', ':', ';' ], true );
460    }
461
462    /**
463     * Append a user signature to the comment in the container.
464     */
465    public static function appendSignature( DocumentFragment $container, string $signature ): void {
466        $doc = $container->ownerDocument;
467
468        // If the last node isn't a paragraph (e.g. it's a list created in visual mode),
469        // or looks like a list item created in wikitext mode (T263217),
470        // then add another paragraph to contain the signature.
471        $wrapperNode = $container->lastChild;
472        if (
473            !( $wrapperNode instanceof Element ) ||
474            strtolower( $wrapperNode->tagName ) !== 'p' ||
475            // A signature placed inside a template rendering would get deleted (T390316)
476            $wrapperNode->getAttribute( 'typeof' ) === 'mw:Transclusion' ||
477            (
478                // This would be easier to check in prepareWikitextReply(), but that would result
479                // in an empty list item being added at the end if we don't need to add a signature.
480                ( $wtNode = $wrapperNode->lastChild ) &&
481                $wtNode instanceof Element &&
482                static::isWikitextNodeListItem( $wtNode )
483            )
484        ) {
485            $container->appendChild( $doc->createElement( 'p' ) );
486        }
487        // If the last node is empty, trim the signature to prevent leading whitespace triggering
488        // preformatted text (T269188, T276612)
489        if ( !$container->lastChild->firstChild ) {
490            $signature = ltrim( $signature, ' ' );
491        }
492        // Sign the last line
493        $container->lastChild->appendChild(
494            static::createWikitextNode(
495                $doc,
496                $signature
497            )
498        );
499    }
500
501    /**
502     * Append a user signature to the comment in the provided wikitext.
503     */
504    public static function appendSignatureWikitext( string $wikitext, string $signature ): string {
505        $wikitext = CommentUtils::htmlTrim( $wikitext );
506
507        $lines = explode( "\n", $wikitext );
508        $lastLine = end( $lines );
509
510        // If last line looks like a list item, add an empty line afterwards for the signature (T263217)
511        if ( $lastLine && in_array( $lastLine[0], [ '*', '#', ':', ';' ], true ) ) {
512            $wikitext .= "\n";
513            // Trim the signature to prevent leading whitespace triggering preformatted text (T269188, T276612)
514            $signature = ltrim( $signature, ' ' );
515        }
516
517        return $wikitext . $signature;
518    }
519
520    /**
521     * Add a reply to a specific comment
522     *
523     * @param ContentThreadItem $comment Comment being replied to
524     * @param DocumentFragment $container Container of comment DOM nodes
525     */
526    public static function addReply( ContentThreadItem $comment, DocumentFragment $container ): void {
527        $services = MediaWikiServices::getInstance();
528        $dtConfig = $services->getConfigFactory()->makeConfig( 'discussiontools' );
529        $replyIndentation = $dtConfig->get( 'DiscussionToolsReplyIndentation' );
530
531        $newParsoidItem = null;
532        // Transfer comment DOM to Parsoid DOM
533        // Wrap every root node of the document in a new list item (dd/li).
534        // In wikitext mode every root node is a paragraph.
535        // In visual mode the editor takes care of preventing problematic nodes
536        // like <table> or <h2> from ever occurring in the comment.
537        while ( $container->childNodes->length ) {
538            if ( !$newParsoidItem ) {
539                $newParsoidItem = static::addListItem( $comment, $replyIndentation );
540            } else {
541                $newParsoidItem = static::addSiblingListItem( $newParsoidItem );
542            }
543
544            // Suppress space after the indentation character to support nested lists (T238218).
545            // By request from the community, avoid this if possible after bullet indentation (T259864).
546            if ( !(
547                $replyIndentation === 'bullet' &&
548                ( $wtNode = $container->firstChild->lastChild ) &&
549                $wtNode instanceof Element &&
550                !static::isWikitextNodeListItem( $wtNode )
551            ) ) {
552                static::whitespaceParsoidHack( $newParsoidItem );
553            }
554
555            $newParsoidItem->appendChild( $container->firstChild );
556        }
557    }
558
559    /**
560     * Transfer comment DOM nodes into a list node, as if adding a reply, but without requiring a
561     * ThreadItem.
562     *
563     * @param DocumentFragment $container Container of comment DOM nodes
564     * @return Element $node List node
565     */
566    public static function transferReply( DocumentFragment $container ): Element {
567        $services = MediaWikiServices::getInstance();
568        $dtConfig = $services->getConfigFactory()->makeConfig( 'discussiontools' );
569        $replyIndentation = $dtConfig->get( 'DiscussionToolsReplyIndentation' );
570
571        $doc = $container->ownerDocument;
572
573        // Like addReply(), but we make our own list
574        $list = $doc->createElement( $replyIndentation === 'invisible' ? 'dl' : 'ul' );
575        while ( $container->childNodes->length ) {
576            $item = $doc->createElement( $replyIndentation === 'invisible' ? 'dd' : 'li' );
577            // Suppress space after the indentation character to support nested lists (T238218).
578            // By request from the community, avoid this if possible after bullet indentation (T259864).
579            if ( !(
580                $replyIndentation === 'bullet' &&
581                ( $wtNode = $container->firstChild->lastChild ) &&
582                $wtNode instanceof Element &&
583                !static::isWikitextNodeListItem( $wtNode )
584            ) ) {
585                static::whitespaceParsoidHack( $item );
586            }
587            $item->appendChild( $container->firstChild );
588            $list->appendChild( $item );
589        }
590        return $list;
591    }
592
593    /**
594     * Create a container of comment DOM nodes from wikitext
595     *
596     * @param Document $doc Document where the DOM nodes will be inserted
597     * @param string $wikitext
598     * @return DocumentFragment DOM nodes
599     */
600    public static function prepareWikitextReply( Document $doc, string $wikitext ): DocumentFragment {
601        $container = $doc->createDocumentFragment();
602
603        $wikitext = static::sanitizeWikitextLinebreaks( $wikitext );
604
605        $lines = explode( "\n", $wikitext );
606        foreach ( $lines as $line ) {
607            $p = $doc->createElement( 'p' );
608            $p->appendChild( static::createWikitextNode( $doc, $line ) );
609            $container->appendChild( $p );
610        }
611
612        return $container;
613    }
614
615    /**
616     * Create a container of comment DOM nodes from HTML
617     *
618     * @param Document $doc Document where the DOM nodes will be inserted
619     * @param string $html
620     * @return DocumentFragment DOM nodes
621     */
622    public static function prepareHtmlReply( Document $doc, string $html ): DocumentFragment {
623        $container = DOMUtils::parseHTMLToFragment( $doc, $html );
624
625        // Remove empty lines
626        // This should really be anything that serializes to empty string in wikitext,
627        // (e.g. <h2></h2>) but this will catch most cases
628        // Create a non-live child node list, so we don't have to worry about it changing
629        // as nodes are removed.
630        $childNodeList = iterator_to_array( $container->childNodes );
631        foreach ( $childNodeList as $node ) {
632            if ( (
633                $node instanceof Text &&
634                CommentUtils::htmlTrim( $node->nodeValue ?? '' ) === ''
635            ) || (
636                $node instanceof Element &&
637                strtolower( $node->tagName ) === 'p' &&
638                // A template-generated paragraph is not "empty" (T390316)
639                $node->getAttribute( 'typeof' ) !== 'mw:Transclusion' &&
640                CommentUtils::htmlTrim( DOMCompat::getInnerHTML( $node ) ) === ''
641            ) ) {
642                $container->removeChild( $node );
643            }
644        }
645
646        return $container;
647    }
648
649    /**
650     * Add a reply in the DOM to a comment using wikitext.
651     *
652     * @param ContentCommentItem $comment Comment being replied to
653     * @param string $wikitext
654     * @param string|null $signature
655     */
656    public static function addWikitextReply(
657        ContentCommentItem $comment, string $wikitext, ?string $signature = null
658    ): void {
659        $doc = $comment->getRange()->endContainer->ownerDocument;
660        $container = static::prepareWikitextReply( $doc, $wikitext );
661        if ( $signature !== null ) {
662            static::appendSignature( $container, $signature );
663        }
664        static::addReply( $comment, $container );
665    }
666
667    /**
668     * Add a reply in the DOM to a comment using HTML.
669     *
670     * @param ContentCommentItem $comment Comment being replied to
671     * @param string $html
672     * @param string|null $signature
673     */
674    public static function addHtmlReply(
675        ContentCommentItem $comment, string $html, ?string $signature = null
676    ): void {
677        $doc = $comment->getRange()->endContainer->ownerDocument;
678        $container = static::prepareHtmlReply( $doc, $html );
679        if ( $signature !== null ) {
680            static::appendSignature( $container, $signature );
681        }
682        static::addReply( $comment, $container );
683    }
684}