Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
76.60% covered (warning)
76.60%
203 / 265
47.37% covered (danger)
47.37%
9 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
CommentModifier
76.60% covered (warning)
76.60%
203 / 265
47.37% covered (danger)
47.37%
9 / 19
280.44
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%
35 / 35
100.00% covered (success)
100.00%
1 / 1
14
 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\Core\DOMCompat;
13use Wikimedia\Parsoid\DOM\Document;
14use Wikimedia\Parsoid\DOM\DocumentFragment;
15use Wikimedia\Parsoid\DOM\Element;
16use Wikimedia\Parsoid\DOM\Node;
17use Wikimedia\Parsoid\DOM\Text;
18use Wikimedia\Parsoid\Ext\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                if ( $referenceNode ) {
426                    $container->insertBefore( $referenceNode, $insertBefore );
427                }
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            // A signature placed inside a template rendering would get deleted (T390316)
478            $wrapperNode->getAttribute( 'typeof' ) === 'mw:Transclusion' ||
479            (
480                // This would be easier to check in prepareWikitextReply(), but that would result
481                // in an empty list item being added at the end if we don't need to add a signature.
482                ( $wtNode = $wrapperNode->lastChild ) &&
483                $wtNode instanceof Element &&
484                static::isWikitextNodeListItem( $wtNode )
485            )
486        ) {
487            $container->appendChild( $doc->createElement( 'p' ) );
488        }
489        // If the last node is empty, trim the signature to prevent leading whitespace triggering
490        // preformatted text (T269188, T276612)
491        if ( !$container->lastChild->firstChild ) {
492            $signature = ltrim( $signature, ' ' );
493        }
494        // Sign the last line
495        $container->lastChild->appendChild(
496            static::createWikitextNode(
497                $doc,
498                $signature
499            )
500        );
501    }
502
503    /**
504     * Append a user signature to the comment in the provided wikitext.
505     */
506    public static function appendSignatureWikitext( string $wikitext, string $signature ): string {
507        $wikitext = CommentUtils::htmlTrim( $wikitext );
508
509        $lines = explode( "\n", $wikitext );
510        $lastLine = end( $lines );
511
512        // If last line looks like a list item, add an empty line afterwards for the signature (T263217)
513        if ( $lastLine && in_array( $lastLine[0], [ '*', '#', ':', ';' ], true ) ) {
514            $wikitext .= "\n";
515            // Trim the signature to prevent leading whitespace triggering preformatted text (T269188, T276612)
516            $signature = ltrim( $signature, ' ' );
517        }
518
519        return $wikitext . $signature;
520    }
521
522    /**
523     * Add a reply to a specific comment
524     *
525     * @param ContentThreadItem $comment Comment being replied to
526     * @param DocumentFragment $container Container of comment DOM nodes
527     */
528    public static function addReply( ContentThreadItem $comment, DocumentFragment $container ): void {
529        $services = MediaWikiServices::getInstance();
530        $dtConfig = $services->getConfigFactory()->makeConfig( 'discussiontools' );
531        $replyIndentation = $dtConfig->get( 'DiscussionToolsReplyIndentation' );
532
533        $newParsoidItem = null;
534        // Transfer comment DOM to Parsoid DOM
535        // Wrap every root node of the document in a new list item (dd/li).
536        // In wikitext mode every root node is a paragraph.
537        // In visual mode the editor takes care of preventing problematic nodes
538        // like <table> or <h2> from ever occurring in the comment.
539        while ( $container->childNodes->length ) {
540            if ( !$newParsoidItem ) {
541                $newParsoidItem = static::addListItem( $comment, $replyIndentation );
542            } else {
543                $newParsoidItem = static::addSiblingListItem( $newParsoidItem );
544            }
545
546            // Suppress space after the indentation character to support nested lists (T238218).
547            // By request from the community, avoid this if possible after bullet indentation (T259864).
548            if ( !(
549                $replyIndentation === 'bullet' &&
550                ( $wtNode = $container->firstChild->lastChild ) &&
551                $wtNode instanceof Element &&
552                !static::isWikitextNodeListItem( $wtNode )
553            ) ) {
554                static::whitespaceParsoidHack( $newParsoidItem );
555            }
556
557            $newParsoidItem->appendChild( $container->firstChild );
558        }
559    }
560
561    /**
562     * Transfer comment DOM nodes into a list node, as if adding a reply, but without requiring a
563     * ThreadItem.
564     *
565     * @param DocumentFragment $container Container of comment DOM nodes
566     * @return Element $node List node
567     */
568    public static function transferReply( DocumentFragment $container ): Element {
569        $services = MediaWikiServices::getInstance();
570        $dtConfig = $services->getConfigFactory()->makeConfig( 'discussiontools' );
571        $replyIndentation = $dtConfig->get( 'DiscussionToolsReplyIndentation' );
572
573        $doc = $container->ownerDocument;
574
575        // Like addReply(), but we make our own list
576        $list = $doc->createElement( $replyIndentation === 'invisible' ? 'dl' : 'ul' );
577        while ( $container->childNodes->length ) {
578            $item = $doc->createElement( $replyIndentation === 'invisible' ? 'dd' : 'li' );
579            // Suppress space after the indentation character to support nested lists (T238218).
580            // By request from the community, avoid this if possible after bullet indentation (T259864).
581            if ( !(
582                $replyIndentation === 'bullet' &&
583                ( $wtNode = $container->firstChild->lastChild ) &&
584                $wtNode instanceof Element &&
585                !static::isWikitextNodeListItem( $wtNode )
586            ) ) {
587                static::whitespaceParsoidHack( $item );
588            }
589            $item->appendChild( $container->firstChild );
590            $list->appendChild( $item );
591        }
592        return $list;
593    }
594
595    /**
596     * Create a container of comment DOM nodes from wikitext
597     *
598     * @param Document $doc Document where the DOM nodes will be inserted
599     * @param string $wikitext
600     * @return DocumentFragment DOM nodes
601     */
602    public static function prepareWikitextReply( Document $doc, string $wikitext ): DocumentFragment {
603        $container = $doc->createDocumentFragment();
604
605        $wikitext = static::sanitizeWikitextLinebreaks( $wikitext );
606
607        $lines = explode( "\n", $wikitext );
608        foreach ( $lines as $line ) {
609            $p = $doc->createElement( 'p' );
610            $p->appendChild( static::createWikitextNode( $doc, $line ) );
611            $container->appendChild( $p );
612        }
613
614        return $container;
615    }
616
617    /**
618     * Create a container of comment DOM nodes from HTML
619     *
620     * @param Document $doc Document where the DOM nodes will be inserted
621     * @param string $html
622     * @return DocumentFragment DOM nodes
623     */
624    public static function prepareHtmlReply( Document $doc, string $html ): DocumentFragment {
625        $container = DOMUtils::parseHTMLToFragment( $doc, $html );
626
627        // Remove empty lines
628        // This should really be anything that serializes to empty string in wikitext,
629        // (e.g. <h2></h2>) but this will catch most cases
630        // Create a non-live child node list, so we don't have to worry about it changing
631        // as nodes are removed.
632        $childNodeList = iterator_to_array( $container->childNodes );
633        foreach ( $childNodeList as $node ) {
634            if ( (
635                $node instanceof Text &&
636                CommentUtils::htmlTrim( $node->nodeValue ?? '' ) === ''
637            ) || (
638                $node instanceof Element &&
639                strtolower( $node->tagName ) === 'p' &&
640                // A template-generated paragraph is not "empty" (T390316)
641                $node->getAttribute( 'typeof' ) !== 'mw:Transclusion' &&
642                CommentUtils::htmlTrim( DOMCompat::getInnerHTML( $node ) ) === ''
643            ) ) {
644                $container->removeChild( $node );
645            }
646        }
647
648        return $container;
649    }
650
651    /**
652     * Add a reply in the DOM to a comment using wikitext.
653     *
654     * @param ContentCommentItem $comment Comment being replied to
655     * @param string $wikitext
656     * @param string|null $signature
657     */
658    public static function addWikitextReply(
659        ContentCommentItem $comment, string $wikitext, ?string $signature = null
660    ): void {
661        $doc = $comment->getRange()->endContainer->ownerDocument;
662        $container = static::prepareWikitextReply( $doc, $wikitext );
663        if ( $signature !== null ) {
664            static::appendSignature( $container, $signature );
665        }
666        static::addReply( $comment, $container );
667    }
668
669    /**
670     * Add a reply in the DOM to a comment using HTML.
671     *
672     * @param ContentCommentItem $comment Comment being replied to
673     * @param string $html
674     * @param string|null $signature
675     */
676    public static function addHtmlReply(
677        ContentCommentItem $comment, string $html, ?string $signature = null
678    ): void {
679        $doc = $comment->getRange()->endContainer->ownerDocument;
680        $container = static::prepareHtmlReply( $doc, $html );
681        if ( $signature !== null ) {
682            static::appendSignature( $container, $signature );
683        }
684        static::addReply( $comment, $container );
685    }
686}