mediawiki-extensions-Discus.../includes/CommentFormatter.php
Bartosz Dziewoński a3f665e816 Remove <header> tags around headings for compat with MobileFrontend
We added it because the initial designs for the subscribe action were
much easier to implement like this, and topic "containers" (T269950)
would have required it.

However, the latest design of the subscribe action will not need it
(T279149), and topic containers are still very far away, so let's
remove it for now.

Bug: T280433
Change-Id: I21a23e9bea43f24d265750926fbd62b99038d3f1
2021-04-19 17:47:43 +02:00

192 lines
6.8 KiB
PHP

<?php
namespace MediaWiki\Extension\DiscussionTools;
use DOMElement;
use Language;
use MediaWiki\Extension\DiscussionTools\Hooks\HookUtils;
use MediaWiki\MediaWikiServices;
use MWExceptionHandler;
use Throwable;
use WebRequest;
use Wikimedia\Parsoid\Utils\DOMUtils;
use Wikimedia\Parsoid\Wt2Html\XMLSerializer;
class CommentFormatter {
// List of features which, when enabled, cause the comment formatter to run
public const USE_WITH_FEATURES = [
HookUtils::REPLYTOOL,
HookUtils::TOPICSUBSCRIPTION,
];
protected const REPLY_LINKS_COMMENT = '<!-- DiscussionTools addReplyLinks called -->';
/**
* Get a comment parser object for a DOM element
*
* This method exists so it can mocked in tests.
*
* @param DOMElement $container
* @return CommentParser
*/
protected static function getParser( DOMElement $container ) : CommentParser {
return CommentParser::newFromGlobalState( $container );
}
/**
* Add reply links to some HTML
*
* @param string &$text Parser text output
* @param Language $lang Interface language
*/
public static function addReplyLinks( string &$text, Language $lang ) : void {
$start = microtime( true );
// Never add links twice.
// This is required because we try again to add links to cached content
// to support query string or cookie enabling
if ( strpos( $text, static::REPLY_LINKS_COMMENT ) !== false ) {
return;
}
$text = $text . "\n" . static::REPLY_LINKS_COMMENT;
try {
// Add reply links and hidden data about comment ranges.
$newText = static::addReplyLinksInternal( $text, $lang );
} catch ( Throwable $e ) {
// Catch errors, so that they don't cause the entire page to not display.
// Log it and add the request ID in a comment to make it easier to find in the logs.
MWExceptionHandler::logException( $e );
$requestId = htmlspecialchars( WebRequest::getRequestId() );
$info = "<!-- [$requestId] DiscussionTools could not add reply links on this page -->";
$text .= "\n" . $info;
return;
}
$text = $newText;
$duration = microtime( true ) - $start;
$stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
$stats->timing( 'discussiontools.addReplyLinks', $duration * 1000 );
}
/**
* Add reply links to some HTML
*
* @param string $html HTML
* @param Language $lang Interface language
* @return string HTML with reply links
*/
protected static function addReplyLinksInternal( string $html, Language $lang ) : string {
// The output of this method can end up in the HTTP cache (Varnish). Avoid changing it;
// and when doing so, ensure that frontend code can handle both the old and new outputs.
// See controller#init in JS.
$doc = DOMUtils::parseHTML( $html );
$doc->preserveWhiteSpace = false;
$container = $doc->getElementsByTagName( 'body' )->item( 0 );
if ( !( $container instanceof DOMElement ) ) {
return $html;
}
$parser = static::getParser( $container );
$threadItems = $parser->getThreadItems();
foreach ( $threadItems as $threadItem ) {
// TODO: Consider not attaching JSON data to the DOM.
// Create a dummy node to attach data to.
if ( $threadItem instanceof HeadingItem && $threadItem->isPlaceholderHeading() ) {
$node = $doc->createElement( 'span' );
$container->insertBefore( $node, $container->firstChild );
$threadItem->setRange( new ImmutableRange( $node, 0, $node, 0 ) );
}
// And start and end markers to range
$id = $threadItem->getId();
$range = $threadItem->getRange();
$startMarker = $doc->createElement( 'span' );
$startMarker->setAttribute( 'data-mw-comment-start', '' );
$startMarker->setAttribute( 'id', $id );
$endMarker = $doc->createElement( 'span' );
$endMarker->setAttribute( 'data-mw-comment-end', $id );
// Extend the range if the start or end is inside an element which can't have element children.
// (There may be other problematic elements... but this seems like a good start.)
if ( CommentUtils::cantHaveElementChildren( $range->startContainer ) ) {
$range = $range->setStart(
$range->startContainer->parentNode,
CommentUtils::childIndexOf( $range->startContainer )
);
}
if ( CommentUtils::cantHaveElementChildren( $range->endContainer ) ) {
$range = $range->setEnd(
$range->endContainer->parentNode,
CommentUtils::childIndexOf( $range->endContainer ) + 1
);
}
$range->setStart( $range->endContainer, $range->endOffset )->insertNode( $endMarker );
$range->insertNode( $startMarker );
$itemData = $threadItem->jsonSerialize();
$itemJSON = json_encode( $itemData );
if ( $threadItem instanceof HeadingItem ) {
$threadItem->getRange()->endContainer->setAttribute( 'data-mw-comment', $itemJSON );
if ( !$threadItem->isPlaceholderHeading() && $threadItem->getHeadingLevel() == 2 ) {
$headingNode = CommentUtils::closestElement( $threadItem->getRange()->endContainer, [ 'h2' ] );
if ( $headingNode ) {
$headingNode->setAttribute( 'class', 'ext-discussiontools-section' );
// Replaced in PageHooks as the icon depends on user state
$subscribe = $doc->createComment( '__DTSUBSCRIBE__' . $threadItem->getName() );
$headingNode->appendChild( $subscribe );
}
}
} elseif ( $threadItem instanceof CommentItem ) {
$replyLinkButtons = $doc->createElement( 'span' );
$replyLinkButtons->setAttribute( 'class', 'ext-discussiontools-init-replylink-buttons' );
// Reply
$replyLink = $doc->createElement( 'a' );
$replyLink->setAttribute( 'class', 'ext-discussiontools-init-replylink-reply' );
$replyLink->setAttribute( 'role', 'button' );
$replyLink->setAttribute( 'tabindex', '0' );
$replyLink->setAttribute( 'data-mw-comment', $itemJSON );
// Set empty 'href' to avoid a:not([href]) selector in MobileFrontend
$replyLink->setAttribute( 'href', '' );
$replyLink->nodeValue = wfMessage( 'discussiontools-replylink' )->inLanguage( $lang )->text();
$bracket = $doc->createElement( 'span' );
$bracket->setAttribute( 'class', 'ext-discussiontools-init-replylink-bracket' );
$bracketLeft = $bracket->cloneNode( false );
$bracketLeft->nodeValue = '[';
$bracketRight = $bracket->cloneNode( false );
$bracketRight->nodeValue = ']';
$replyLinkButtons->appendChild( $bracketLeft );
$replyLinkButtons->appendChild( $replyLink );
$replyLinkButtons->appendChild( $bracketRight );
CommentModifier::addReplyLink( $threadItem, $replyLinkButtons );
}
}
$docElement = $doc->getElementsByTagName( 'body' )->item( 0 );
if ( !( $docElement instanceof DOMElement ) ) {
return $html;
}
// Like DOMCompat::getInnerHTML(), but disable 'smartQuote' for compatibility with
// ParserOutput::EDITSECTION_REGEX matching 'mw:editsection' tags (T274709)
return XMLSerializer::serialize( $docElement, [ 'innerXML' => true, 'smartQuote' => false ] )['html'];
}
}