Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.52% covered (warning)
72.52%
190 / 262
42.11% covered (danger)
42.11%
8 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
CommentModifier
72.52% covered (warning)
72.52%
190 / 262
42.11% covered (danger)
42.11%
8 / 19
366.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
 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%
16 / 16
100.00% covered (success)
100.00%
1 / 1
7
 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
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
56
 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     * @return Element
84     */
85    public static function addListItem( ContentThreadItem $comment, string $replyIndentation ): Element {
86        $listTypeMap = [
87            'li' => 'ul',
88            'dd' => 'dl'
89        ];
90
91        // 1. Start at given comment
92        // 2. Skip past all comments with level greater than the given
93        //    (or in other words, all replies, and replies to replies, and so on)
94        // 3. Add comment with level of the given comment plus 1
95
96        $curComment = $comment;
97        while ( count( $curComment->getReplies() ) ) {
98            $replies = $curComment->getReplies();
99            $curComment = end( $replies );
100        }
101
102        // Tag names for lists and items we're going to insert
103        if ( $replyIndentation === 'invisible' ) {
104            $itemType = 'dd';
105        } elseif ( $replyIndentation === 'bullet' ) {
106            $itemType = 'li';
107        } else {
108            throw new InvalidArgumentException( "Invalid reply indentation syntax '$replyIndentation'" );
109        }
110        $listType = $listTypeMap[ $itemType ];
111
112        $desiredLevel = $comment->getLevel() + 1;
113        $target = $curComment->getRange()->endContainer;
114
115        // target is a text node or an inline element at the end of a "paragraph"
116        // (not necessarily paragraph node).
117        // First, we need to find a block-level parent that we can mess with.
118        // If we can't find a surrounding list item or paragraph (e.g. maybe we're inside a table cell
119        // or something), take the parent node and hope for the best.
120        $parent = CommentUtils::closestElement( $target, [ 'li', 'dd', 'p' ] ) ??
121            $target->parentNode;
122        while ( $target->parentNode !== $parent ) {
123            $target = $target->parentNode;
124        }
125        // parent is a list item or paragraph (hopefully)
126        // target is an inline node within it
127
128        // If the comment is fully covered by some wrapper element, insert replies outside that wrapper.
129        // This will often just be a paragraph node (<p>), but it can be a <div> or <table> that serves
130        // as some kind of a fancy frame, which are often used for barnstars and announcements.
131        // @phan-suppress-next-line PhanTypeMismatchArgumentNullable
132        $excludedWrapper = CommentUtils::closestElement( $target, [ 'section' ] ) ?:
133            $curComment->getRootNode();
134        $covered = CommentUtils::getFullyCoveredSiblings( $curComment, $excludedWrapper );
135        if ( $curComment->getLevel() === 1 && $covered ) {
136            $target = end( $covered );
137            $parent = $target->parentNode;
138        }
139
140        // If the comment is in a transclusion, insert replies after the transclusion. (T313100)
141        // This method should never be called in cases where that would be a bad idea.
142        // @phan-suppress-next-line PhanTypeMismatchArgumentNullable
143        $transclusionNode = CommentUtils::getTranscludedFromElement( $target );
144        if ( $transclusionNode ) {
145            while (
146                ( $nextSibling = $transclusionNode->nextSibling ) &&
147                $nextSibling instanceof Element &&
148                $nextSibling->getAttribute( 'about' ) === $transclusionNode->getAttribute( 'about' )
149            ) {
150                $transclusionNode = $nextSibling;
151            }
152            $target = $transclusionNode;
153            $parent = $target->parentNode;
154        }
155
156        // If we can't insert a list directly inside this element, insert after it.
157        // The covered wrapper check above handles most cases, but we still need this sometimes, such as:
158        // * If the comment starts in the middle of a list, then ends with an unindented p/pre, the
159        //   wrapper check doesn't adjust the parent
160        // * If the comment consists of multiple list items (starting with a <dt>, so that the comment is
161        //   considered to be unindented, that is level === 1), but not all of them, the wrapper check
162        //   adjusts the parent to be the list, and the rest of the algorithm doesn't handle that well
163        if (
164            strtolower( $parent->tagName ) === 'p' ||
165            strtolower( $parent->tagName ) === 'pre' ||
166            strtolower( $parent->tagName ) === 'ul' ||
167            strtolower( $parent->tagName ) === 'dl'
168        ) {
169            $parent = $parent->parentNode;
170            $target = $target->parentNode;
171        }
172
173        Assert::precondition( $target !== null, 'We have not stepped outside the document' );
174        // Instead of just using $curComment->getLevel(), consider indentation of lists within the
175        // comment (T252702)
176        $curLevel = CommentUtils::getIndentLevel( $target, $curComment->getRootNode() ) + 1;
177
178        $item = null;
179        if ( $desiredLevel === 1 ) {
180            // Special handling for top-level comments
181            // We use section=new API for adding them in PHP, so this should never happen
182            throw new UnexpectedValueException( "Can't add a top-level comment" );
183
184        } elseif ( $curLevel < $desiredLevel ) {
185            // Insert more lists after the target to increase nesting.
186
187            // Parsoid puts HTML comments (and other "rendering-transparent nodes", e.g. category links)
188            // which appear at the end of the line in wikitext outside the paragraph,
189            // but we usually shouldn't insert replies between the paragraph and such comments. (T257651)
190            // Skip over comments and whitespace, but only update target when skipping past comments.
191            $pointer = $target;
192            while (
193                $pointer->nextSibling && (
194                    CommentUtils::isRenderingTransparentNode( $pointer->nextSibling ) ||
195                    (
196                        $pointer->nextSibling instanceof Text &&
197                        CommentUtils::htmlTrim( $pointer->nextSibling->nodeValue ?? '' ) === '' &&
198                        // If more that two lines of whitespace are detected, the following HTML
199                        // comments are not considered to be part of the reply (T264026)
200                        !preg_match( '/(\r?\n){2,}/', $pointer->nextSibling->nodeValue ?? '' )
201                    )
202                )
203            ) {
204                $pointer = $pointer->nextSibling;
205                if ( CommentUtils::isRenderingTransparentNode( $pointer ) ) {
206                    $target = $pointer;
207                }
208            }
209
210            // Insert required number of wrappers
211            while ( $curLevel < $desiredLevel ) {
212                $list = $target->ownerDocument->createElement( $listType );
213                // Setting modified would only be needed for removeAddedListItem,
214                // which isn't needed on the server
215                // $list->setAttribute( 'dt-modified', 'new' );
216                $item = $target->ownerDocument->createElement( $itemType );
217                // $item->setAttribute( 'dt-modified', 'new' );
218
219                $parent->insertBefore( $list, $target->nextSibling );
220                $list->appendChild( $item );
221
222                $target = $item;
223                $parent = $list;
224                $curLevel++;
225            }
226        } else {
227            // Split the ancestor nodes after the target to decrease nesting.
228
229            do {
230                if ( !$target || !$parent ) {
231                    throw new LogicException( 'Can not decrease nesting any more' );
232                }
233
234                // If target is the last child of its parent, no need to split it
235                if ( $target->nextSibling ) {
236                    // Create new identical node after the parent
237                    $newNode = $parent->cloneNode( false );
238                    // $parent->setAttribute( 'dt-modified', 'split' );
239                    $parent->parentNode->insertBefore( $newNode, $parent->nextSibling );
240
241                    // Move nodes following target to the new node
242                    while ( $target->nextSibling ) {
243                        $newNode->appendChild( $target->nextSibling );
244                    }
245                }
246
247                $target = $parent;
248                $parent = $parent->parentNode;
249
250                // Decrease nesting level if we escaped outside of a list
251                if ( isset( $listTypeMap[ strtolower( $target->tagName ) ] ) ) {
252                    $curLevel--;
253                }
254            } while ( $curLevel >= $desiredLevel );
255
256            // parent is now a list, target is a list item
257            if ( $itemType === strtolower( $target->tagName ) ) {
258                $item = $target->ownerDocument->createElement( $itemType );
259                // $item->setAttribute( 'dt-modified', 'new' );
260                $parent->insertBefore( $item, $target->nextSibling );
261
262            } else {
263                // This is the wrong type of list, split it one more time
264
265                // If target is the last child of its parent, no need to split it
266                if ( $target->nextSibling ) {
267                    // Create new identical node after the parent
268                    $newNode = $parent->cloneNode( false );
269                    // $parent->setAttribute( 'dt-modified', 'split' );
270                    $parent->parentNode->insertBefore( $newNode, $parent->nextSibling );
271
272                    // Move nodes following target to the new node
273                    while ( $target->nextSibling ) {
274                        $newNode->appendChild( $target->nextSibling );
275                    }
276                }
277
278                $target = $parent;
279                $parent = $parent->parentNode;
280
281                // Insert a list of the right type in the middle
282                $list = $target->ownerDocument->createElement( $listType );
283                // Setting modified would only be needed for removeAddedListItem,
284                // which isn't needed on the server
285                // $list->setAttribute( 'dt-modified', 'new' );
286                $item = $target->ownerDocument->createElement( $itemType );
287                // $item->setAttribute( 'dt-modified', 'new' );
288
289                $parent->insertBefore( $list, $target->nextSibling );
290                $list->appendChild( $item );
291            }
292        }
293
294        if ( $item === null ) {
295            throw new LogicException( 'No item found' );
296        }
297
298        return $item;
299    }
300
301    /**
302     * Check all elements in a node list are of a given type
303     *
304     * Also returns false if there are no elements in the list
305     *
306     * @param iterable<Node> $nodes Node list
307     * @param string $type Element type
308     * @return bool
309     */
310    private static function allOfType( iterable $nodes, string $type ): bool {
311        $hasElements = false;
312        foreach ( $nodes as $node ) {
313            if ( $node instanceof Element ) {
314                if ( strtolower( $node->tagName ) !== strtolower( $type ) ) {
315                    return false;
316                }
317                $hasElements = true;
318            }
319        }
320        return $hasElements;
321    }
322
323    /**
324     * Remove unnecessary list wrappers from a comment fragment
325     *
326     * TODO: Implement this in JS if required
327     *
328     * @param DocumentFragment $fragment Fragment
329     */
330    public static function unwrapFragment( DocumentFragment $fragment ): void {
331        // Wrap orphaned list items
332        $list = null;
333        if ( static::allOfType( $fragment->childNodes, 'dd' ) ) {
334            $list = $fragment->ownerDocument->createElement( 'dl' );
335        } elseif ( static::allOfType( $fragment->childNodes, 'li' ) ) {
336            $list = $fragment->ownerDocument->createElement( 'ul' );
337        }
338        if ( $list ) {
339            while ( $fragment->firstChild ) {
340                $list->appendChild( $fragment->firstChild );
341            }
342            $fragment->appendChild( $list );
343        }
344
345        // If all child nodes are lists of the same type, unwrap them
346        while (
347            static::allOfType( $fragment->childNodes, 'dl' ) ||
348            static::allOfType( $fragment->childNodes, 'ul' ) ||
349            static::allOfType( $fragment->childNodes, 'ol' ) ||
350            (
351                // Or if the comment starts with a bullet followed by indents
352                count( $fragment->childNodes ) > 1 &&
353                static::allOfType( [ $fragment->childNodes[0] ], 'ul' ) &&
354                static::allOfType( array_slice( iterator_to_array( $fragment->childNodes ), 1 ), 'dl' )
355            )
356        ) {
357            // Do not iterate over childNodes while we're modifying it
358            $childNodeList = iterator_to_array( $fragment->childNodes );
359            foreach ( $childNodeList as $node ) {
360                static::unwrapList( $node, $fragment );
361            }
362        }
363    }
364
365    // removeAddedListItem is only needed in the client
366
367    /**
368     * Unwrap a top level list, converting list item text to paragraphs
369     *
370     * Assumes that the list has a parent node, or is a root child in the provided
371     * document fragment.
372     *
373     * @param Node $list DOM node, will be wrapped if it is a list element (dl/ol/ul)
374     * @param DocumentFragment|null $fragment Containing document fragment if list has no parent
375     */
376    public static function unwrapList( Node $list, ?DocumentFragment $fragment = null ): void {
377        if ( !(
378            $list instanceof Element && (
379                strtolower( $list->tagName ) === 'dl' ||
380                strtolower( $list->tagName ) === 'ol' ||
381                strtolower( $list->tagName ) === 'ul'
382            )
383        ) ) {
384            // Not a list, leave alone (e.g. auto-generated ref block)
385            return;
386        }
387
388        // If the whole list is a template return it unmodified (T253150)
389        if ( CommentUtils::getTranscludedFromElement( $list ) ) {
390            return;
391        }
392
393        $doc = $list->ownerDocument;
394        $container = $fragment ?: $list->parentNode;
395        $referenceNode = $list;
396        while ( $list->firstChild ) {
397            if ( $list->firstChild instanceof Element ) {
398                // Move <dd> contents to <p>
399                $p = $doc->createElement( 'p' );
400                while ( $list->firstChild->firstChild ) {
401                    // If contents is a block element, place outside the paragraph
402                    // and start a new paragraph after
403                    if ( CommentUtils::isBlockElement( $list->firstChild->firstChild ) ) {
404                        if ( $p->firstChild ) {
405                            $insertBefore = $referenceNode->nextSibling;
406                            $referenceNode = $p;
407                            $container->insertBefore( $p, $insertBefore );
408                        }
409                        $insertBefore = $referenceNode->nextSibling;
410                        $referenceNode = $list->firstChild->firstChild;
411                        $container->insertBefore( $list->firstChild->firstChild, $insertBefore );
412                        $p = $doc->createElement( 'p' );
413                    } else {
414                        $p->appendChild( $list->firstChild->firstChild );
415                    }
416                }
417                if ( $p->firstChild ) {
418                    $insertBefore = $referenceNode->nextSibling;
419                    $referenceNode = $p;
420                    $container->insertBefore( $p, $insertBefore );
421                }
422                $list->removeChild( $list->firstChild );
423            } else {
424                // Text node / comment node, probably empty
425                $insertBefore = $referenceNode->nextSibling;
426                $referenceNode = $list->firstChild;
427                $container->insertBefore( $list->firstChild, $insertBefore );
428            }
429        }
430        $container->removeChild( $list );
431    }
432
433    /**
434     * Add another list item after the given one.
435     */
436    public static function addSiblingListItem( Element $previousItem ): Element {
437        $listItem = $previousItem->ownerDocument->createElement( $previousItem->tagName );
438        $previousItem->parentNode->insertBefore( $listItem, $previousItem->nextSibling );
439        return $listItem;
440    }
441
442    /**
443     * Create an element that will convert to the provided wikitext
444     */
445    public static function createWikitextNode( Document $doc, string $wikitext ): Element {
446        $span = $doc->createElement( 'span' );
447
448        $span->setAttribute( 'typeof', 'mw:Transclusion' );
449        $span->setAttribute( 'data-mw', json_encode( [ 'parts' => [ $wikitext ] ] ) );
450
451        return $span;
452    }
453
454    /**
455     * Check if an element created by ::createWikitextNode() starts with list item markup.
456     */
457    private static function isWikitextNodeListItem( Element $node ): bool {
458        $dataMw = json_decode( $node->getAttribute( 'data-mw' ) ?? '', true );
459        $wikitextLine = $dataMw['parts'][0] ?? null;
460        return $wikitextLine && is_string( $wikitextLine ) &&
461            in_array( $wikitextLine[0], [ '*', '#', ':', ';' ], true );
462    }
463
464    /**
465     * Append a user signature to the comment in the container.
466     */
467    public static function appendSignature( DocumentFragment $container, string $signature ): void {
468        $doc = $container->ownerDocument;
469
470        // If the last node isn't a paragraph (e.g. it's a list created in visual mode),
471        // or looks like a list item created in wikitext mode (T263217),
472        // then add another paragraph to contain the signature.
473        $wrapperNode = $container->lastChild;
474        if (
475            !( $wrapperNode instanceof Element ) ||
476            strtolower( $wrapperNode->tagName ) !== 'p' ||
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                CommentUtils::htmlTrim( DOMCompat::getInnerHTML( $node ) ) === ''
639            ) ) {
640                $container->removeChild( $node );
641            }
642        }
643
644        return $container;
645    }
646
647    /**
648     * Add a reply in the DOM to a comment using wikitext.
649     *
650     * @param ContentCommentItem $comment Comment being replied to
651     * @param string $wikitext
652     * @param string|null $signature
653     */
654    public static function addWikitextReply(
655        ContentCommentItem $comment, string $wikitext, string $signature = null
656    ): void {
657        $doc = $comment->getRange()->endContainer->ownerDocument;
658        $container = static::prepareWikitextReply( $doc, $wikitext );
659        if ( $signature !== null ) {
660            static::appendSignature( $container, $signature );
661        }
662        static::addReply( $comment, $container );
663    }
664
665    /**
666     * Add a reply in the DOM to a comment using HTML.
667     *
668     * @param ContentCommentItem $comment Comment being replied to
669     * @param string $html
670     * @param string|null $signature
671     */
672    public static function addHtmlReply(
673        ContentCommentItem $comment, string $html, string $signature = null
674    ): void {
675        $doc = $comment->getRange()->endContainer->ownerDocument;
676        $container = static::prepareHtmlReply( $doc, $html );
677        if ( $signature !== null ) {
678            static::appendSignature( $container, $signature );
679        }
680        static::addReply( $comment, $container );
681    }
682}