includes/Transforms/MakeSectionsTransform.php
<?php
namespace MobileFrontend\Transforms;
use DOMDocument;
use DOMElement;
use DOMNode;
use DOMXPath;
use LogicException;
use MediaWiki\Html\Html;
use MediaWiki\ResourceLoader\ResourceLoader;
use Wikimedia\Parsoid\Utils\DOMCompat;
/**
* Implements IMobileTransform, that splits the body of the document into
* sections demarcated by the $headings elements. Also moves the first paragraph
* in the lead section above the infobox.
*
* All member elements of the sections are added to a <code><div></code> so
* that the section bodies are clearly defined (to be "expandable" for
* example).
*
* @see IMobileTransform
*/
class MakeSectionsTransform implements IMobileTransform {
/**
* Class name for collapsible section wrappers
*/
public const STYLE_COLLAPSIBLE_SECTION_CLASS = 'collapsible-block';
/**
* Whether scripts can be added in the output.
* @var bool
*/
private $scriptsEnabled;
/**
* List of tags that could be considered as section headers.
* @var array
*/
private $topHeadingTags;
/**
*
* @param array $topHeadingTags list of tags could ne cosidered as sections
* @param bool $scriptsEnabled wheather scripts are enabled
*/
public function __construct(
array $topHeadingTags,
bool $scriptsEnabled
) {
$this->topHeadingTags = $topHeadingTags;
$this->scriptsEnabled = $scriptsEnabled;
}
/**
* @param DOMNode|null $node
* @return string|false Heading tag name if the node is a heading
*/
private function getHeadingName( $node ) {
if ( !( $node instanceof DOMElement ) ) {
return false;
}
// We accept both kinds of nodes that can be returned by getTopHeadings():
// a `<h1>` to `<h6>` node, or a `<div class="mw-heading">` node wrapping it.
// In the future `<div class="mw-heading">` will be required (T13555).
if ( DOMCompat::getClassList( $node )->contains( 'mw-heading' ) ) {
$node = DOMCompat::querySelector( $node, implode( ',', $this->topHeadingTags ) );
if ( !( $node instanceof DOMElement ) ) {
return false;
}
}
return $node->tagName;
}
/**
* Actually splits the body of the document into sections
*
* @param DOMElement $body representing the HTML of the current article. In the HTML the sections
* should not be wrapped.
* @param DOMElement[] $headingWrappers The headings (or wrappers) returned by getTopHeadings():
* `<h1>` to `<h6>` nodes, or `<div class="mw-heading">` nodes wrapping them.
* In the future `<div class="mw-heading">` will be required (T13555).
*/
private function makeSections( DOMElement $body, array $headingWrappers ) {
$ownerDocument = $body->ownerDocument;
if ( $ownerDocument === null ) {
return;
}
// Find the parser output wrapper div
$xpath = new DOMXPath( $ownerDocument );
$containers = $xpath->query(
// Equivalent of CSS attribute `~=` to support multiple classes
'body/div[contains(concat(" ",normalize-space(@class)," ")," mw-parser-output ")][1]'
);
if ( !$containers->length ) {
// No wrapper? This could be an old parser cache entry, or perhaps the
// OutputPage contained something that was not generated by the parser.
// Try using the <body> as the container.
$containers = $xpath->query( 'body' );
if ( !$containers->length ) {
throw new LogicException( "HTML lacked body element even though we put it there ourselves" );
}
}
$container = $containers->item( 0 );
$containerChild = $container->firstChild;
$firstHeading = reset( $headingWrappers );
$firstHeadingName = $this->getHeadingName( $firstHeading );
$sectionNumber = 0;
$sectionBody = $this->createSectionBodyElement( $ownerDocument, $sectionNumber, false );
while ( $containerChild ) {
$node = $containerChild;
$containerChild = $containerChild->nextSibling;
// If we've found a top level heading, insert the previous section if
// necessary and clear the container div.
if ( $firstHeadingName && $this->getHeadingName( $node ) === $firstHeadingName ) {
// The heading we are transforming is always 1 section ahead of the
// section we are currently processing
/** @phan-suppress-next-line PhanTypeMismatchArgumentSuperType DOMNode vs. DOMElement */
$this->prepareHeading( $ownerDocument, $node, $sectionNumber + 1, $this->scriptsEnabled );
// Insert the previous section body and reset it for the new section
$container->insertBefore( $sectionBody, $node );
$sectionNumber += 1;
$sectionBody = $this->createSectionBodyElement(
$ownerDocument,
$sectionNumber,
$this->scriptsEnabled
);
continue;
}
// If it is not a top level heading, keep appending the nodes to the
// section body container.
$sectionBody->appendChild( $node );
}
// Append the last section body.
$container->appendChild( $sectionBody );
}
/**
* Prepare section headings, add required classes and onclick actions
*
* @param DOMDocument $doc
* @param DOMElement $heading
* @param int $sectionNumber
* @param bool $isCollapsible
*/
private function prepareHeading(
DOMDocument $doc, DOMElement $heading, $sectionNumber, $isCollapsible
) {
$className = $heading->hasAttribute( 'class' ) ? $heading->getAttribute( 'class' ) . ' ' : '';
$heading->setAttribute( 'class', $className . 'section-heading' );
if ( $isCollapsible ) {
$heading->setAttribute( 'onclick', 'mfTempOpenSection(' . $sectionNumber . ')' );
}
// prepend indicator - this avoids a reflow by creating a placeholder for a toggling indicator
$indicator = $doc->createElement( 'span' );
$indicator->setAttribute( 'class', 'indicator mf-icon mf-icon-expand mf-icon--small' );
$heading->insertBefore( $indicator, $heading->firstChild ?? $heading );
}
/**
* Creates a Section body element
*
* @param DOMDocument $doc
* @param int $sectionNumber
* @param bool $isCollapsible
*
* @return DOMElement
*/
private function createSectionBodyElement( DOMDocument $doc, $sectionNumber, $isCollapsible ) {
$sectionClass = 'mf-section-' . $sectionNumber;
if ( $isCollapsible ) {
// TODO: Probably good to rename this to the more generic 'section'.
// We have no idea how the skin will use this.
$sectionClass .= ' ' . self::STYLE_COLLAPSIBLE_SECTION_CLASS;
}
// FIXME: The class `/mf\-section\-[0-9]+/` is kept for caching reasons
// but given class is unique usage is discouraged. [T126825]
$sectionBody = $doc->createElement( 'section' );
$sectionBody->setAttribute( 'class', $sectionClass );
$sectionBody->setAttribute( 'id', 'mf-section-' . $sectionNumber );
return $sectionBody;
}
/**
* Gets top headings in the document.
*
* Note well that the rank order is defined by the
* <code>MobileFormatter#topHeadingTags</code> property.
*
* @param DOMElement $doc
* @return array An array first is the highest rank headings
*/
private function getTopHeadings( DOMElement $doc ): array {
$headings = [];
foreach ( $this->topHeadingTags as $tagName ) {
$allTags = DOMCompat::querySelectorAll( $doc, $tagName );
foreach ( $allTags as $el ) {
$parent = $el->parentNode;
if ( !( $parent instanceof DOMElement ) ) {
continue;
}
// Use the `<div class="mw-heading">` wrapper if it is present. When they are required
// (T13555), the querySelectorAll() above can use the class and this can be removed.
if ( DOMCompat::getClassList( $parent )->contains( 'mw-heading' ) ) {
$el = $parent;
}
// This check can be removed too when we require the wrappers.
if ( $parent->getAttribute( 'class' ) !== 'toctitle' ) {
$headings[] = $el;
}
}
if ( $headings ) {
return $headings;
}
}
return $headings;
}
/**
* Make it possible to open sections while JavaScript is still loading.
*
* @return string The JavaScript code to add event handlers to the skin
*/
public static function interimTogglingSupport() {
$js = <<<JAVASCRIPT
function mfTempOpenSection( id ) {
var block = document.getElementById( "mf-section-" + id );
block.className += " open-block";
// The previous sibling to the content block is guaranteed to be the
// associated heading due to mobileformatter. We need to add the same
// class to flip the collapse arrow icon.
// <h[1-6]>heading</h[1-6]><div id="mf-section-[1-9]+"></div>
block.previousSibling.className += " open-block";
}
JAVASCRIPT;
return Html::inlineScript(
ResourceLoader::filter( 'minify-js', $js )
);
}
/**
* Performs html transformation that splits the body of the document into
* sections demarcated by the $headings elements. Also moves the first paragraph
* in the lead section above the infobox.
* @param DOMElement $doc html document
*/
public function apply( DOMElement $doc ) {
$this->makeSections( $doc, $this->getTopHeadings( $doc ) );
}
}