Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
65.37% covered (warning)
65.37%
168 / 257
36.84% covered (danger)
36.84%
7 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
CommentModifier
65.37% covered (warning)
65.37%
168 / 257
36.84% covered (danger)
36.84%
7 / 19
592.42
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
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 unwrapFragment
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
90
 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            // Do not iterate over childNodes while we're modifying it
352            $childNodeList = iterator_to_array( $fragment->childNodes );
353            foreach ( $childNodeList as $node ) {
354                static::unwrapList( $node, $fragment );
355            }
356        }
357    }
358
359    // removeAddedListItem is only needed in the client
360
361    /**
362     * Unwrap a top level list, converting list item text to paragraphs
363     *
364     * Assumes that the list has a parent node, or is a root child in the provided
365     * document fragment.
366     *
367     * @param Node $list DOM node, will be wrapped if it is a list element (dl/ol/ul)
368     * @param DocumentFragment|null $fragment Containing document fragment if list has no parent
369     */
370    public static function unwrapList( Node $list, ?DocumentFragment $fragment = null ): void {
371        $doc = $list->ownerDocument;
372        $container = $fragment ?: $list->parentNode;
373        $referenceNode = $list;
374
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        while ( $list->firstChild ) {
392            if ( $list->firstChild instanceof Element ) {
393                // Move <dd> contents to <p>
394                $p = $doc->createElement( 'p' );
395                while ( $list->firstChild->firstChild ) {
396                    // If contents is a block element, place outside the paragraph
397                    // and start a new paragraph after
398                    if ( CommentUtils::isBlockElement( $list->firstChild->firstChild ) ) {
399                        if ( $p->firstChild ) {
400                            $insertBefore = $referenceNode->nextSibling;
401                            $referenceNode = $p;
402                            $container->insertBefore( $p, $insertBefore );
403                        }
404                        $insertBefore = $referenceNode->nextSibling;
405                        $referenceNode = $list->firstChild->firstChild;
406                        $container->insertBefore( $list->firstChild->firstChild, $insertBefore );
407                        $p = $doc->createElement( 'p' );
408                    } else {
409                        $p->appendChild( $list->firstChild->firstChild );
410                    }
411                }
412                if ( $p->firstChild ) {
413                    $insertBefore = $referenceNode->nextSibling;
414                    $referenceNode = $p;
415                    $container->insertBefore( $p, $insertBefore );
416                }
417                $list->removeChild( $list->firstChild );
418            } else {
419                // Text node / comment node, probably empty
420                $insertBefore = $referenceNode->nextSibling;
421                $referenceNode = $list->firstChild;
422                $container->insertBefore( $list->firstChild, $insertBefore );
423            }
424        }
425        $container->removeChild( $list );
426    }
427
428    /**
429     * Add another list item after the given one.
430     */
431    public static function addSiblingListItem( Element $previousItem ): Element {
432        $listItem = $previousItem->ownerDocument->createElement( $previousItem->tagName );
433        $previousItem->parentNode->insertBefore( $listItem, $previousItem->nextSibling );
434        return $listItem;
435    }
436
437    /**
438     * Create an element that will convert to the provided wikitext
439     */
440    public static function createWikitextNode( Document $doc, string $wikitext ): Element {
441        $span = $doc->createElement( 'span' );
442
443        $span->setAttribute( 'typeof', 'mw:Transclusion' );
444        $span->setAttribute( 'data-mw', json_encode( [ 'parts' => [ $wikitext ] ] ) );
445
446        return $span;
447    }
448
449    /**
450     * Check if an element created by ::createWikitextNode() starts with list item markup.
451     */
452    private static function isWikitextNodeListItem( Element $node ): bool {
453        $dataMw = json_decode( $node->getAttribute( 'data-mw' ) ?? '', true );
454        $wikitextLine = $dataMw['parts'][0] ?? null;
455        return $wikitextLine && is_string( $wikitextLine ) &&
456            in_array( $wikitextLine[0], [ '*', '#', ':', ';' ], true );
457    }
458
459    /**
460     * Append a user signature to the comment in the container.
461     */
462    public static function appendSignature( DocumentFragment $container, string $signature ): void {
463        $doc = $container->ownerDocument;
464
465        // If the last node isn't a paragraph (e.g. it's a list created in visual mode),
466        // or looks like a list item created in wikitext mode (T263217),
467        // then add another paragraph to contain the signature.
468        $wrapperNode = $container->lastChild;
469        if (
470            !( $wrapperNode instanceof Element ) ||
471            strtolower( $wrapperNode->tagName ) !== 'p' ||
472            (
473                // This would be easier to check in prepareWikitextReply(), but that would result
474                // in an empty list item being added at the end if we don't need to add a signature.
475                ( $wtNode = $wrapperNode->lastChild ) &&
476                $wtNode instanceof Element &&
477                static::isWikitextNodeListItem( $wtNode )
478            )
479        ) {
480            $container->appendChild( $doc->createElement( 'p' ) );
481        }
482        // If the last node is empty, trim the signature to prevent leading whitespace triggering
483        // preformatted text (T269188, T276612)
484        if ( !$container->lastChild->firstChild ) {
485            $signature = ltrim( $signature, ' ' );
486        }
487        // Sign the last line
488        $container->lastChild->appendChild(
489            static::createWikitextNode(
490                $doc,
491                $signature
492            )
493        );
494    }
495
496    /**
497     * Append a user signature to the comment in the provided wikitext.
498     */
499    public static function appendSignatureWikitext( string $wikitext, string $signature ): string {
500        $wikitext = CommentUtils::htmlTrim( $wikitext );
501
502        $lines = explode( "\n", $wikitext );
503        $lastLine = end( $lines );
504
505        // If last line looks like a list item, add an empty line afterwards for the signature (T263217)
506        if ( $lastLine && in_array( $lastLine[0], [ '*', '#', ':', ';' ], true ) ) {
507            $wikitext .= "\n";
508            // Trim the signature to prevent leading whitespace triggering preformatted text (T269188, T276612)
509            $signature = ltrim( $signature, ' ' );
510        }
511
512        return $wikitext . $signature;
513    }
514
515    /**
516     * Add a reply to a specific comment
517     *
518     * @param ContentThreadItem $comment Comment being replied to
519     * @param DocumentFragment $container Container of comment DOM nodes
520     */
521    public static function addReply( ContentThreadItem $comment, DocumentFragment $container ): void {
522        $services = MediaWikiServices::getInstance();
523        $dtConfig = $services->getConfigFactory()->makeConfig( 'discussiontools' );
524        $replyIndentation = $dtConfig->get( 'DiscussionToolsReplyIndentation' );
525
526        $newParsoidItem = null;
527        // Transfer comment DOM to Parsoid DOM
528        // Wrap every root node of the document in a new list item (dd/li).
529        // In wikitext mode every root node is a paragraph.
530        // In visual mode the editor takes care of preventing problematic nodes
531        // like <table> or <h2> from ever occurring in the comment.
532        while ( $container->childNodes->length ) {
533            if ( !$newParsoidItem ) {
534                $newParsoidItem = static::addListItem( $comment, $replyIndentation );
535            } else {
536                $newParsoidItem = static::addSiblingListItem( $newParsoidItem );
537            }
538
539            // Suppress space after the indentation character to support nested lists (T238218).
540            // By request from the community, avoid this if possible after bullet indentation (T259864).
541            if ( !(
542                $replyIndentation === 'bullet' &&
543                ( $wtNode = $container->firstChild->lastChild ) &&
544                $wtNode instanceof Element &&
545                !static::isWikitextNodeListItem( $wtNode )
546            ) ) {
547                static::whitespaceParsoidHack( $newParsoidItem );
548            }
549
550            $newParsoidItem->appendChild( $container->firstChild );
551        }
552    }
553
554    /**
555     * Transfer comment DOM nodes into a list node, as if adding a reply, but without requiring a
556     * ThreadItem.
557     *
558     * @param DocumentFragment $container Container of comment DOM nodes
559     * @return Element $node List node
560     */
561    public static function transferReply( DocumentFragment $container ): Element {
562        $services = MediaWikiServices::getInstance();
563        $dtConfig = $services->getConfigFactory()->makeConfig( 'discussiontools' );
564        $replyIndentation = $dtConfig->get( 'DiscussionToolsReplyIndentation' );
565
566        $doc = $container->ownerDocument;
567
568        // Like addReply(), but we make our own list
569        $list = $doc->createElement( $replyIndentation === 'invisible' ? 'dl' : 'ul' );
570        while ( $container->childNodes->length ) {
571            $item = $doc->createElement( $replyIndentation === 'invisible' ? 'dd' : 'li' );
572            // Suppress space after the indentation character to support nested lists (T238218).
573            // By request from the community, avoid this if possible after bullet indentation (T259864).
574            if ( !(
575                $replyIndentation === 'bullet' &&
576                ( $wtNode = $container->firstChild->lastChild ) &&
577                $wtNode instanceof Element &&
578                !static::isWikitextNodeListItem( $wtNode )
579            ) ) {
580                static::whitespaceParsoidHack( $item );
581            }
582            $item->appendChild( $container->firstChild );
583            $list->appendChild( $item );
584        }
585        return $list;
586    }
587
588    /**
589     * Create a container of comment DOM nodes from wikitext
590     *
591     * @param Document $doc Document where the DOM nodes will be inserted
592     * @param string $wikitext
593     * @return DocumentFragment DOM nodes
594     */
595    public static function prepareWikitextReply( Document $doc, string $wikitext ): DocumentFragment {
596        $container = $doc->createDocumentFragment();
597
598        $wikitext = static::sanitizeWikitextLinebreaks( $wikitext );
599
600        $lines = explode( "\n", $wikitext );
601        foreach ( $lines as $line ) {
602            $p = $doc->createElement( 'p' );
603            $p->appendChild( static::createWikitextNode( $doc, $line ) );
604            $container->appendChild( $p );
605        }
606
607        return $container;
608    }
609
610    /**
611     * Create a container of comment DOM nodes from HTML
612     *
613     * @param Document $doc Document where the DOM nodes will be inserted
614     * @param string $html
615     * @return DocumentFragment DOM nodes
616     */
617    public static function prepareHtmlReply( Document $doc, string $html ): DocumentFragment {
618        $container = DOMUtils::parseHTMLToFragment( $doc, $html );
619
620        // Remove empty lines
621        // This should really be anything that serializes to empty string in wikitext,
622        // (e.g. <h2></h2>) but this will catch most cases
623        // Create a non-live child node list, so we don't have to worry about it changing
624        // as nodes are removed.
625        $childNodeList = iterator_to_array( $container->childNodes );
626        foreach ( $childNodeList as $node ) {
627            if ( (
628                $node instanceof Text &&
629                CommentUtils::htmlTrim( $node->nodeValue ?? '' ) === ''
630            ) || (
631                $node instanceof Element &&
632                strtolower( $node->tagName ) === 'p' &&
633                CommentUtils::htmlTrim( DOMCompat::getInnerHTML( $node ) ) === ''
634            ) ) {
635                $container->removeChild( $node );
636            }
637        }
638
639        return $container;
640    }
641
642    /**
643     * Add a reply in the DOM to a comment using wikitext.
644     *
645     * @param ContentCommentItem $comment Comment being replied to
646     * @param string $wikitext
647     * @param string|null $signature
648     */
649    public static function addWikitextReply(
650        ContentCommentItem $comment, string $wikitext, string $signature = null
651    ): void {
652        $doc = $comment->getRange()->endContainer->ownerDocument;
653        $container = static::prepareWikitextReply( $doc, $wikitext );
654        if ( $signature !== null ) {
655            static::appendSignature( $container, $signature );
656        }
657        static::addReply( $comment, $container );
658    }
659
660    /**
661     * Add a reply in the DOM to a comment using HTML.
662     *
663     * @param ContentCommentItem $comment Comment being replied to
664     * @param string $html
665     * @param string|null $signature
666     */
667    public static function addHtmlReply(
668        ContentCommentItem $comment, string $html, string $signature = null
669    ): void {
670        $doc = $comment->getRange()->endContainer->ownerDocument;
671        $container = static::prepareHtmlReply( $doc, $html );
672        if ( $signature !== null ) {
673            static::appendSignature( $container, $signature );
674        }
675        static::addReply( $comment, $container );
676    }
677}