Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
76.78% covered (warning)
76.78%
248 / 323
25.00% covered (danger)
25.00%
4 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
CommentFormatter
76.78% covered (warning)
76.78%
248 / 323
25.00% covered (danger)
25.00%
4 / 16
154.17
0.00% covered (danger)
0.00%
0 / 1
 getParser
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 addDiscussionTools
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
42
 addTopicContainer
97.92% covered (success)
97.92%
47 / 48
0.00% covered (danger)
0.00%
0 / 1
8
 addDiscussionToolsInternal
94.59% covered (success)
94.59%
70 / 74
0.00% covered (danger)
0.00%
0 / 1
25.10
 removeInteractiveTools
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 postprocessTopicSubscription
89.55% covered (warning)
89.55%
60 / 67
0.00% covered (danger)
0.00%
0 / 1
14.22
 postprocessReplyTool
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
2
 metaLabel
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getJsonForCommentMarker
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 getSignatureRelativeTime
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 postprocessVisualEnhancements
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
5
 postprocessVisualEnhancementsSubtitle
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
42
 isEmptyTalkPage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 appendToEmptyTalkPage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 hasLedeContent
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 hasCommentsInLedeContent
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Extension\DiscussionTools;
4
5use Html;
6use Language;
7use MediaWiki\Extension\DiscussionTools\Hooks\HookUtils;
8use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentCommentItem;
9use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentHeadingItem;
10use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentThreadItem;
11use MediaWiki\MediaWikiServices;
12use MediaWiki\User\UserIdentity;
13use MWExceptionHandler;
14use MWTimestamp;
15use Parser;
16use ParserOutput;
17use Sanitizer;
18use Throwable;
19use Title;
20use WebRequest;
21use Wikimedia\Assert\Assert;
22use Wikimedia\Parsoid\DOM\Element;
23use Wikimedia\Parsoid\Utils\DOMCompat;
24use Wikimedia\Parsoid\Utils\DOMUtils;
25use Wikimedia\Parsoid\Wt2Html\XMLSerializer;
26
27class CommentFormatter {
28    // List of features which, when enabled, cause the comment formatter to run
29    public const USE_WITH_FEATURES = [
30        HookUtils::REPLYTOOL,
31        HookUtils::TOPICSUBSCRIPTION,
32        HookUtils::VISUALENHANCEMENTS
33    ];
34
35    /**
36     * Get a comment parser object for a DOM element
37     *
38     * This method exists so it can mocked in tests.
39     *
40     * @return CommentParser
41     */
42    protected static function getParser(): CommentParser {
43        return MediaWikiServices::getInstance()->getService( 'DiscussionTools.CommentParser' );
44    }
45
46    /**
47     * Add discussion tools to some HTML
48     *
49     * @param string &$text Parser text output (modified by reference)
50     * @param ParserOutput $pout ParserOutput object for metadata, e.g. parser limit report
51     * @param Parser $parser
52     */
53    public static function addDiscussionTools( string &$text, ParserOutput $pout, Parser $parser ): void {
54        $title = $parser->getTitle();
55        $start = microtime( true );
56        $requestId = null;
57
58        try {
59            [ 'html' => $text, 'tocInfo' => $tocInfo ] =
60                static::addDiscussionToolsInternal( $text, $pout, $title );
61
62            // Enhance the table of contents in supporting skins (vector-2022)
63
64            // Only do the work if the HTML would be shown. It looks like we can only check this
65            // by checking whether the HTML for the normal TOC has been generated. Code in
66            // OutputPage::addParserOutputMetadata does the same.
67            if ( $pout->getTOCHTML() ) {
68                // If the TOC HTML has been generated, then the parser cache is already split by user
69                // language (because of the "Contents" header in the TOC), so we can render text in user
70                // language as well. If that behavior in core changes, then we'll have to change this to
71                // happen in a post-processing step (like all other transformations) to avoid splitting it.
72                $lang = $parser->getOptions()->getUserLangObj();
73                $sections = $pout->getSections();
74                foreach ( $sections as &$item ) {
75                    $key = str_replace( '_', ' ', $item['anchor'] );
76                    // Unset if we did not format this section as a topic container
77                    if ( isset( $tocInfo[$key] ) ) {
78                        $count = $lang->formatNum( $tocInfo[$key]['commentCount'] );
79                        $commentCount = wfMessage(
80                            'discussiontools-topicheader-commentcount',
81                            $count
82                        )->inLanguage( $lang )->text();
83
84                        $summary = Html::element( 'span', [
85                            'class' => 'ext-discussiontools-init-sidebar-meta'
86                        ], $commentCount );
87
88                        // This also shows up in API action=parse&prop=sections output.
89                        $item['html-summary'] = $summary;
90                    } else {
91                        $item['html-summary'] = '';
92                    }
93                }
94                $pout->setSections( $sections );
95            }
96
97        } catch ( Throwable $e ) {
98            // Catch errors, so that they don't cause the entire page to not display.
99            // Log it and report the request ID to make it easier to find in the logs.
100            MWExceptionHandler::logException( $e );
101            $requestId = WebRequest::getRequestId();
102        }
103
104        $duration = microtime( true ) - $start;
105
106        $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
107        $stats->timing( 'discussiontools.addReplyLinks', $duration * 1000 );
108
109        // How long this method took, in seconds
110        $pout->setLimitReportData(
111            'discussiontools-limitreport-timeusage',
112            sprintf( '%.3f', $duration )
113        );
114        if ( $requestId ) {
115            // Request ID where errors were logged (only if an error occurred)
116            $pout->setLimitReportData(
117                'discussiontools-limitreport-errorreqid',
118                $requestId
119            );
120        }
121    }
122
123    /**
124     * Add a topic container around a heading element
125     *
126     * @param Element $headingElement Heading element
127     * @param ContentHeadingItem|null $headingItem Heading item
128     * @param array|null &$tocInfo TOC info
129     * @return Element Wrapper element (either found or newly added), or the heading element if not using wrappers
130     */
131    protected static function addTopicContainer(
132        Element $headingElement,
133        ?ContentHeadingItem $headingItem = null,
134        &$tocInfo = null
135    ): Element {
136        $legacyMarkup = MediaWikiServices::getInstance()->getMainConfig()->get( 'DiscussionToolsLegacyHeadingMarkup' );
137        $doc = $headingElement->ownerDocument;
138
139        if ( $legacyMarkup ) {
140            $wrapperNode = $headingElement;
141        } else {
142            $wrapperNode = $headingElement->parentNode;
143            if ( !(
144                $wrapperNode instanceof Element &&
145                DOMCompat::getClassList( $wrapperNode )->contains( 'mw-heading' )
146            ) ) {
147                $wrapperNode = $doc->createElement( 'div' );
148                $wrapperNode->setAttribute( 'class', 'mw-heading mw-heading2' );
149                $headingElement->parentNode->insertBefore( $wrapperNode, $headingElement );
150                $wrapperNode->appendChild( $headingElement );
151            }
152        }
153
154        DOMCompat::getClassList( $wrapperNode )->add( 'ext-discussiontools-init-section' );
155
156        if ( !$headingItem ) {
157            return $wrapperNode;
158        }
159
160        $headingNameEscaped = htmlspecialchars( $headingItem->getName(), ENT_NOQUOTES );
161
162        // Replaced in ::postprocessTopicSubscription() as the text depends on user state
163        if ( $headingItem->isSubscribable() ) {
164            $subscribeLink = $doc->createComment( '__DTSUBSCRIBELINK__' . $headingNameEscaped );
165            $headingElement->insertBefore( $subscribeLink, $headingElement->firstChild );
166
167            $subscribeButton = $doc->createComment( '__DTSUBSCRIBEBUTTONDESKTOP__' . $headingNameEscaped );
168            $wrapperNode->insertBefore( $subscribeButton, $wrapperNode->firstChild );
169        }
170
171        $ellipsisButton = $doc->createComment( '__DTELLIPSISBUTTON__' );
172        $wrapperNode->appendChild( $ellipsisButton );
173
174        // Visual enhancements: topic containers
175        $latestReplyItem = $headingItem->getLatestReply();
176        if ( $latestReplyItem ) {
177            $latestReplyJSON = static::getJsonForCommentMarker( $latestReplyItem );
178            $latestReply = $doc->createComment(
179                // Timestamp output varies by user timezone, so is formatted later
180                '__DTLATESTCOMMENTTHREAD__' . htmlspecialchars( $latestReplyJSON, ENT_NOQUOTES ) . '__'
181            );
182
183            $commentCount = $doc->createComment(
184                '__DTCOMMENTCOUNT__' . $headingItem->getCommentCount() . '__'
185            );
186
187            $authorCount = $doc->createComment(
188                '__DTAUTHORCOUNT__' . count( $headingItem->getAuthorsBelow() ) . '__'
189            );
190
191            // Topic subscriptions
192            $metadata = $doc->createElement( 'div' );
193            $metadata->setAttribute(
194                'class',
195                'ext-discussiontools-init-section-metadata'
196            );
197
198            $metadata->appendChild( $latestReply );
199            $metadata->appendChild( $commentCount );
200            $metadata->appendChild( $authorCount );
201
202            $actions = $doc->createElement( 'div' );
203            $actions->setAttribute(
204                'class',
205                'ext-discussiontools-init-section-actions'
206            );
207
208            if ( $headingItem->isSubscribable() ) {
209                $subscribeButton = $doc->createComment( '__DTSUBSCRIBEBUTTONMOBILE__' . $headingNameEscaped );
210                $actions->appendChild( $subscribeButton );
211            }
212
213            $bar = $doc->createElement( 'div' );
214            $bar->setAttribute(
215                'class',
216                'ext-discussiontools-init-section-bar'
217            );
218
219            $bar->appendChild( $metadata );
220            $bar->appendChild( $actions );
221
222            $wrapperNode->appendChild( $bar );
223
224            $tocInfo[ $headingItem->getLinkableTitle() ] = [
225                'commentCount' => $headingItem->getCommentCount(),
226            ];
227        }
228
229        return $wrapperNode;
230    }
231
232    /**
233     * Add discussion tools to some HTML
234     *
235     * @param string $html HTML
236     * @param ParserOutput $pout
237     * @param Title $title
238     * @return array HTML with discussion tools and TOC info
239     */
240    protected static function addDiscussionToolsInternal( string $html, ParserOutput $pout, Title $title ): array {
241        // The output of this method can end up in the HTTP cache (Varnish). Avoid changing it;
242        // and when doing so, ensure that frontend code can handle both the old and new outputs.
243        // See controller#init in JS.
244
245        $doc = DOMUtils::parseHTML( $html );
246        $container = DOMCompat::getBody( $doc );
247
248        $threadItemSet = static::getParser()->parse( $container, $title->getTitleValue() );
249        $threadItems = $threadItemSet->getThreadItems();
250
251        $tocInfo = [];
252
253        $newestComment = null;
254        $newestCommentJSON = null;
255
256        // Iterate in reverse order, because adding the range markers for a thread item
257        // can invalidate the ranges of subsequent thread items (T298096)
258        foreach ( array_reverse( $threadItems ) as $threadItem ) {
259            // Create a dummy node to attach data to.
260            if ( $threadItem instanceof ContentHeadingItem && $threadItem->isPlaceholderHeading() ) {
261                $node = $doc->createElement( 'span' );
262                $container->insertBefore( $node, $container->firstChild );
263                $threadItem->setRange( new ImmutableRange( $node, 0, $node, 0 ) );
264            }
265
266            // Add start and end markers to range
267            $id = $threadItem->getId();
268            $range = $threadItem->getRange();
269            $startMarker = $doc->createElement( 'span' );
270            $startMarker->setAttribute( 'data-mw-comment-start', '' );
271            $startMarker->setAttribute( 'id', $id );
272            $endMarker = $doc->createElement( 'span' );
273            $endMarker->setAttribute( 'data-mw-comment-end', $id );
274
275            // Extend the range if the start or end is inside an element which can't have element children.
276            // (There may be other problematic elements... but this seems like a good start.)
277            while ( CommentUtils::cantHaveElementChildren( $range->startContainer ) ) {
278                $range = $range->setStart(
279                    $range->startContainer->parentNode,
280                    CommentUtils::childIndexOf( $range->startContainer )
281                );
282            }
283            while ( CommentUtils::cantHaveElementChildren( $range->endContainer ) ) {
284                $range = $range->setEnd(
285                    $range->endContainer->parentNode,
286                    CommentUtils::childIndexOf( $range->endContainer ) + 1
287                );
288            }
289
290            $range->setStart( $range->endContainer, $range->endOffset )->insertNode( $endMarker );
291            $range->insertNode( $startMarker );
292
293            if ( $threadItem instanceof ContentHeadingItem ) {
294                // <span class="mw-headline" …>, or <hN …> in Parsoid HTML
295                $headline = $threadItem->getRange()->endContainer;
296                Assert::precondition( $headline instanceof Element, 'HeadingItem refers to an element node' );
297                $headline->setAttribute( 'data-mw-thread-id', $threadItem->getId() );
298                if ( $threadItem->getHeadingLevel() === 2 ) {
299                    $headingElement = CommentUtils::closestElement( $headline, [ 'h2' ] );
300
301                    if ( $headingElement ) {
302                        static::addTopicContainer( $headingElement, $threadItem, $tocInfo );
303                    }
304                }
305            } elseif ( $threadItem instanceof ContentCommentItem ) {
306                $replyButtons = $doc->createElement( 'span' );
307                $replyButtons->setAttribute( 'class', 'ext-discussiontools-init-replylink-buttons' );
308                $replyButtons->setAttribute( 'data-mw-thread-id', $threadItem->getId() );
309                $replyButtons->appendChild( $doc->createComment( '__DTREPLYBUTTONSCONTENT__' ) );
310
311                if ( !$newestComment || $threadItem->getTimestamp() > $newestComment->getTimestamp() ) {
312                    $newestComment = $threadItem;
313                    // Needs to calculated before DOM modifications change ranges
314                    $newestCommentJSON = static::getJsonForCommentMarker( $threadItem, true );
315                }
316
317                CommentModifier::addReplyLink( $threadItem, $replyButtons );
318            }
319        }
320
321        if ( $newestCommentJSON ) {
322            $newestCommentMarker = $doc->createComment(
323                '__DTLATESTCOMMENTPAGE__' . htmlspecialchars( $newestCommentJSON, ENT_NOQUOTES ) . '__'
324            );
325            $container->appendChild( $newestCommentMarker );
326        }
327
328        $startOfSections = DOMCompat::querySelector( $container, 'meta[property="mw:PageProp/toc"]' );
329
330        // Enhance other <h2>'s which aren't part of a thread
331        $headings = DOMCompat::querySelectorAll( $container, 'h2' );
332        foreach ( $headings as $headingElement ) {
333            $wrapper = $headingElement->parentNode;
334            if ( $wrapper instanceof Element && DOMCompat::getClassList( $wrapper )->contains( 'toctitle' ) ) {
335                continue;
336            }
337            $headingElement = static::addTopicContainer( $headingElement );
338            if ( !$startOfSections ) {
339                $startOfSections = $headingElement;
340            }
341        }
342
343        if (
344            // Page has no headings but some content
345            ( !$startOfSections && $container->childNodes->length ) ||
346            // Page has content before the first heading / TOC
347            ( $startOfSections && $startOfSections->previousSibling !== null )
348        ) {
349            $container->appendChild( $doc->createComment( '__DTHASLEDECONTENT__' ) );
350        }
351        if (
352            // Placeholder heading indicates that there are comments in the lede section (T324139).
353            // We can't really separate them from the lede content.
354            isset( $threadItems[0] ) &&
355            $threadItems[0] instanceof ContentHeadingItem &&
356            $threadItems[0]->isPlaceholderHeading()
357        ) {
358            $container->appendChild( $doc->createComment( '__DTHASCOMMENTSINLEDECONTENT__' ) );
359        }
360
361        if ( count( $threadItems ) === 0 ) {
362            $container->appendChild( $doc->createComment( '__DTEMPTYTALKPAGE__' ) );
363        }
364
365        $threadsJSON = array_map( static function ( ContentThreadItem $item ) {
366            return $item->jsonSerialize( true );
367        }, $threadItemSet->getThreadsStructured() );
368
369        $pout->setJsConfigVar( 'wgDiscussionToolsPageThreads', $threadsJSON );
370
371        // Like DOMCompat::getInnerHTML(), but disable 'smartQuote' for compatibility with
372        // ParserOutput::EDITSECTION_REGEX matching 'mw:editsection' tags (T274709)
373        $html = XMLSerializer::serialize( $container, [ 'innerXML' => true, 'smartQuote' => false ] )['html'];
374
375        return [ 'html' => $html, 'tocInfo' => $tocInfo ];
376    }
377
378    /**
379     * Replace placeholders for all interactive tools with nothing. This is intended for cases where
380     * interaction is unexpected, e.g. reply links while previewing an edit.
381     *
382     * @param string $text
383     * @return string
384     */
385    public static function removeInteractiveTools( string $text ) {
386        $text = strtr( $text, [
387            '<!--__DTREPLYBUTTONSCONTENT__-->' => '',
388            '<!--__DTELLIPSISBUTTON__-->' => '',
389            '<!--__DTEMPTYTALKPAGE__-->' => '',
390        ] );
391
392        $text = preg_replace( '/<!--__DTSUBSCRIBELINK__(.*?)-->/', '', $text );
393        $text = preg_replace( '/<!--__DTSUBSCRIBEBUTTON(DESKTOP|MOBILE)__(.*?)-->/', '', $text );
394
395        return $text;
396    }
397
398    /**
399     * Replace placeholders for topic subscription buttons with the real thing.
400     *
401     * @param string $text
402     * @param Language $lang
403     * @param SubscriptionStore $subscriptionStore
404     * @param UserIdentity $user
405     * @param bool $isMobile
406     * @return string
407     */
408    public static function postprocessTopicSubscription(
409        string $text, Language $lang, SubscriptionStore $subscriptionStore, UserIdentity $user, bool $isMobile
410    ): string {
411        $doc = DOMCompat::newDocument( true );
412
413        $matches = [];
414        preg_match_all( '/<!--__DTSUBSCRIBELINK__(.*?)-->/', $text, $matches );
415        $itemNames = array_map(
416            static function ( string $itemName ): string {
417                return htmlspecialchars_decode( $itemName );
418            },
419            $matches[1]
420        );
421
422        $items = $subscriptionStore->getSubscriptionItemsForUser(
423            $user,
424            $itemNames
425        );
426        $itemsByName = [];
427        foreach ( $items as $item ) {
428            $itemsByName[ $item->getItemName() ] = $item;
429        }
430
431        $text = preg_replace_callback(
432            '/<!--__DTSUBSCRIBELINK__(.*?)-->/',
433            static function ( $matches ) use ( $doc, $itemsByName, $lang ) {
434                $itemName = htmlspecialchars_decode( $matches[1] );
435                $isSubscribed = isset( $itemsByName[ $itemName ] ) && !$itemsByName[ $itemName ]->isMuted();
436                $subscribedState = isset( $itemsByName[ $itemName ] ) ? $itemsByName[ $itemName ]->getState() : null;
437
438                $subscribe = $doc->createElement( 'span' );
439                $subscribe->setAttribute(
440                    'class',
441                    'ext-discussiontools-init-section-subscribe mw-editsection-like'
442                );
443
444                $subscribeLink = $doc->createElement( 'a' );
445                // Set empty 'href' to avoid a:not([href]) selector in MobileFrontend
446                $subscribeLink->setAttribute( 'href', '' );
447                $subscribeLink->setAttribute( 'class', 'ext-discussiontools-init-section-subscribe-link' );
448                $subscribeLink->setAttribute( 'role', 'button' );
449                $subscribeLink->setAttribute( 'tabindex', '0' );
450                $subscribeLink->setAttribute( 'title', wfMessage(
451                    $isSubscribed ?
452                        'discussiontools-topicsubscription-button-unsubscribe-tooltip' :
453                        'discussiontools-topicsubscription-button-subscribe-tooltip'
454                )->inLanguage( $lang )->text() );
455                $subscribeLink->nodeValue = wfMessage(
456                    $isSubscribed ?
457                        'discussiontools-topicsubscription-button-unsubscribe' :
458                        'discussiontools-topicsubscription-button-subscribe'
459                )->inLanguage( $lang )->text();
460
461                if ( $subscribedState !== null ) {
462                    $subscribeLink->setAttribute( 'data-mw-subscribed', (string)$subscribedState );
463                }
464
465                $bracket = $doc->createElement( 'span' );
466                $bracket->setAttribute( 'class', 'ext-discussiontools-init-section-subscribe-bracket' );
467                $bracketOpen = $bracket->cloneNode( false );
468                $bracketOpen->nodeValue = '[';
469                $bracketClose = $bracket->cloneNode( false );
470                $bracketClose->nodeValue = ']';
471
472                $subscribe->appendChild( $bracketOpen );
473                $subscribe->appendChild( $subscribeLink );
474                $subscribe->appendChild( $bracketClose );
475
476                return DOMCompat::getOuterHTML( $subscribe );
477            },
478            $text
479        );
480
481        $text = preg_replace_callback(
482            '/<!--__DTSUBSCRIBEBUTTON(DESKTOP|MOBILE)__(.*?)-->/',
483            static function ( $matches ) use ( $doc, $itemsByName, $lang, $isMobile ) {
484                $buttonIsMobile = $matches[1] === 'MOBILE';
485                if ( $buttonIsMobile !== $isMobile ) {
486                    return '';
487                }
488                $itemName = htmlspecialchars_decode( $matches[2] );
489                $isSubscribed = isset( $itemsByName[ $itemName ] ) && !$itemsByName[ $itemName ]->isMuted();
490                $subscribedState = isset( $itemsByName[ $itemName ] ) ? $itemsByName[ $itemName ]->getState() : null;
491
492                $subscribe = new \OOUI\ButtonWidget( [
493                    'classes' => [ 'ext-discussiontools-init-section-subscribeButton' ],
494                    'framed' => false,
495                    'icon' => $isSubscribed ? 'bell' : 'bellOutline',
496                    'flags' => [ 'progressive' ],
497                    'label' => wfMessage( $isSubscribed ?
498                        'discussiontools-topicsubscription-button-unsubscribe-label' :
499                        'discussiontools-topicsubscription-button-subscribe-label'
500                    )->inLanguage( $lang )->text(),
501                    'title' => wfMessage( $isSubscribed ?
502                        'discussiontools-topicsubscription-button-unsubscribe-tooltip' :
503                        'discussiontools-topicsubscription-button-subscribe-tooltip'
504                    )->inLanguage( $lang )->text(),
505                    'infusable' => true,
506                ] );
507
508                if ( $subscribedState !== null ) {
509                    $subscribe->setAttributes( [ 'data-mw-subscribed' => (string)$subscribedState ] );
510                }
511
512                return $subscribe->toString();
513            },
514            $text
515        );
516
517        return $text;
518    }
519
520    /**
521     * Replace placeholders for reply links with the real thing.
522     *
523     * @param string $text
524     * @param Language $lang
525     * @param bool $isMobile
526     * @return string
527     */
528    public static function postprocessReplyTool(
529        string $text, Language $lang, bool $isMobile
530    ): string {
531        $doc = DOMCompat::newDocument( true );
532        $replyLinkText = wfMessage( 'discussiontools-replylink' )->inLanguage( $lang )->escaped();
533        $replyButtonText = wfMessage( 'discussiontools-replybutton' )->inLanguage( $lang )->escaped();
534
535        $text = preg_replace_callback(
536            '/<!--__DTREPLYBUTTONSCONTENT__-->/',
537            static function ( $matches ) use ( $doc, $replyLinkText, $replyButtonText, $isMobile ) {
538                $replyLinkButtons = $doc->createElement( 'span' );
539
540                // Reply