wikimedia/mediawiki-extensions-MobileFrontend

View on GitHub
includes/Transforms/MakeSectionsTransform.php

Summary

Maintainability
B
4 hrs
Test Coverage
<?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 ) );
    }
}