Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.41% covered (success)
90.41%
66 / 73
62.50% covered (warning)
62.50%
5 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
MakeSectionsTransform
90.41% covered (success)
90.41%
66 / 73
62.50% covered (warning)
62.50%
5 / 8
26.60
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getHeadingName
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
5.26
 makeSections
93.33% covered (success)
93.33%
28 / 30
0.00% covered (danger)
0.00%
0 / 1
7.01
 prepareHeading
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 createSectionBodyElement
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getTopHeadings
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
7.14
 interimTogglingSupport
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 apply
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace MobileFrontend\Transforms;
4
5use DomException;
6use LogicException;
7use MediaWiki\Html\Html;
8use MediaWiki\ResourceLoader\ResourceLoader;
9use Wikimedia\Parsoid\DOM\Document;
10use Wikimedia\Parsoid\DOM\Element;
11use Wikimedia\Parsoid\DOM\Node;
12use Wikimedia\Parsoid\Utils\DOMCompat;
13
14/**
15 * Implements IMobileTransform, that splits the body of the document into
16 * sections demarcated by the $headings elements. Also moves the first paragraph
17 * in the lead section above the infobox.
18 *
19 * All member elements of the sections are added to a <code><div></code> so
20 * that the section bodies are clearly defined (to be "expandable" for
21 * example).
22 *
23 * @see IMobileTransform
24 */
25class MakeSectionsTransform implements IMobileTransform {
26
27    /**
28     * Class name for collapsible section wrappers
29     */
30    public const STYLE_COLLAPSIBLE_SECTION_CLASS = 'collapsible-block';
31
32    /**
33     * Whether scripts can be added in the output.
34     */
35    private bool $scriptsEnabled;
36
37    /**
38     * List of tags that could be considered as section headers.
39     * @var string[]
40     */
41    private array $topHeadingTags;
42
43    /**
44     * @param string[] $topHeadingTags list of tags could be considered as sections
45     * @param bool $scriptsEnabled whether scripts are enabled
46     */
47    public function __construct(
48        array $topHeadingTags,
49        bool $scriptsEnabled
50    ) {
51        $this->topHeadingTags = $topHeadingTags;
52        $this->scriptsEnabled = $scriptsEnabled;
53    }
54
55    /**
56     * @param Node|null $node
57     * @return string|false Heading tag name if the node is a heading
58     */
59    private function getHeadingName( $node ): bool|string {
60        if ( !( $node instanceof Element ) ) {
61            return false;
62        }
63        // We accept both kinds of nodes that can be returned by getTopHeadings():
64        // a `<h1>` to `<h6>` node, or a `<div class="mw-heading">` node wrapping it.
65        // In the future `<div class="mw-heading">` will be required (T13555).
66        if ( DOMCompat::getClassList( $node )->contains( 'mw-heading' ) ) {
67            $node = DOMCompat::querySelector( $node, implode( ',', $this->topHeadingTags ) );
68            if ( !( $node instanceof Element ) ) {
69                return false;
70            }
71        }
72        return $node->tagName;
73    }
74
75    /**
76     * Actually splits the body of the document into sections
77     *
78     * @param Element $body representing the HTML of the current article. In the HTML the sections
79     *  should not be wrapped.
80     * @param Element[] $headingWrappers The headings (or wrappers) returned by getTopHeadings():
81     *  `<h1>` to `<h6>` nodes, or `<div class="mw-heading">` nodes wrapping them.
82     *  In the future `<div class="mw-heading">` will be required (T13555).
83     * @throws DomException
84     */
85    private function makeSections( Element $body, array $headingWrappers ): void {
86        $ownerDocument = $body->ownerDocument;
87        if ( $ownerDocument === null ) {
88            return;
89        }
90        // Find the parser output wrapper div
91        $container = DOMCompat::querySelector(
92            $ownerDocument, 'body > div.mw-parser-output'
93        );
94        if ( !$container ) {
95            // No wrapper? This could be an old parser cache entry, or perhaps the
96            // OutputPage contained something that was not generated by the parser.
97            // Try using the <body> as the container.
98            $container = DOMCompat::getBody( $ownerDocument );
99            if ( !$container ) {
100                throw new LogicException( "HTML lacked body element even though we put it there ourselves" );
101            }
102        }
103
104        $containerChild = $container->firstChild;
105        $firstHeading = reset( $headingWrappers );
106        $firstHeadingName = $this->getHeadingName( $firstHeading );
107        $sectionNumber = 0;
108        $sectionBody = $this->createSectionBodyElement( $ownerDocument, $sectionNumber, false );
109
110        while ( $containerChild ) {
111            $node = $containerChild;
112            $containerChild = $containerChild->nextSibling;
113            // If we've found a top level heading, insert the previous section if
114            // necessary and clear the container div.
115            if ( $firstHeadingName && $this->getHeadingName( $node ) === $firstHeadingName ) {
116                // The heading we are transforming is always 1 section ahead of the
117                // section we are currently processing
118                /** @phan-suppress-next-line PhanTypeMismatchArgumentSuperType Node vs. Element */
119                $this->prepareHeading( $ownerDocument, $node, $sectionNumber + 1, $this->scriptsEnabled );
120                // Insert the previous section body and reset it for the new section
121                $container->insertBefore( $sectionBody, $node );
122
123                $sectionNumber += 1;
124                $sectionBody = $this->createSectionBodyElement(
125                    $ownerDocument,
126                    $sectionNumber,
127                    $this->scriptsEnabled
128                );
129                continue;
130            }
131
132            // If it is not a top level heading, keep appending the nodes to the
133            // section body container.
134            $sectionBody->appendChild( $node );
135        }
136
137        // Append the last section body.
138        $container->appendChild( $sectionBody );
139    }
140
141    /**
142     * Prepare section headings, add required classes and onclick actions
143     *
144     * @param Document $doc
145     * @param Element $heading
146     * @param int $sectionNumber
147     * @param bool $isCollapsible
148     * @throws DOMException
149     */
150    private function prepareHeading(
151        Document $doc, Element $heading, $sectionNumber, $isCollapsible
152    ): void {
153        $className = $heading->hasAttribute( 'class' ) ? $heading->getAttribute( 'class' ) . ' ' : '';
154        $heading->setAttribute( 'class', $className . 'section-heading' );
155        if ( $isCollapsible ) {
156            $heading->setAttribute( 'onclick', 'mfTempOpenSection(' . $sectionNumber . ')' );
157        }
158
159        // prepend indicator - this avoids a reflow by creating a placeholder for a toggling indicator
160        $indicator = $doc->createElement( 'span' );
161        $indicator->setAttribute( 'class', 'indicator mf-icon mf-icon-expand mf-icon--small' );
162        $heading->insertBefore( $indicator, $heading->firstChild );
163    }
164
165    /**
166     * Creates a Section body element
167     *
168     * @param Document $doc
169     * @param int $sectionNumber
170     * @param bool $isCollapsible
171     *
172     * @return Element
173     * @throws DOMException
174     */
175    private function createSectionBodyElement( Document $doc, $sectionNumber, $isCollapsible ): Element {
176        $sectionClass = 'mf-section-' . $sectionNumber;
177        if ( $isCollapsible ) {
178            // TODO: Probably good to rename this to the more generic 'section'.
179            // We have no idea how the skin will use this.
180            $sectionClass .= ' ' . self::STYLE_COLLAPSIBLE_SECTION_CLASS;
181        }
182
183        // FIXME: The class `/mf\-section\-[0-9]+/` is kept for caching reasons
184        // but given class is unique usage is discouraged. [T126825]
185        $sectionBody = $doc->createElement( 'section' );
186        $sectionBody->setAttribute( 'class', $sectionClass );
187        $sectionBody->setAttribute( 'id', 'mf-section-' . $sectionNumber );
188        return $sectionBody;
189    }
190
191    /**
192     * Gets top headings in the document.
193     *
194     * Note well that the rank order is defined by the
195     * <code>MobileFormatter#topHeadingTags</code> property.
196     *
197     * @param Element $doc
198     * @return array An array first is the highest rank headings
199     */
200    private function getTopHeadings( Element $doc ): array {
201        $headings = [];
202
203        foreach ( $this->topHeadingTags as $tagName ) {
204            $allTags = DOMCompat::querySelectorAll( $doc, $tagName );
205
206            foreach ( $allTags as $el ) {
207                $parent = $el->parentNode;
208                if ( !( $parent instanceof Element ) ) {
209                    continue;
210                }
211                // Use the `<div class="mw-heading">` wrapper if it is present. When they are required
212                // (T13555), the querySelectorAll() above can use the class and this can be removed.
213                if ( DOMCompat::getClassList( $parent )->contains( 'mw-heading' ) ) {
214                    $el = $parent;
215                }
216                // This check can be removed too when we require the wrappers.
217                if ( $parent->getAttribute( 'class' ) !== 'toctitle' ) {
218                    $headings[] = $el;
219                }
220            }
221            if ( $headings ) {
222                return $headings;
223            }
224
225        }
226
227        return $headings;
228    }
229
230    /**
231     * Make it possible to open sections while JavaScript is still loading.
232     *
233     * @return string The JavaScript code to add event handlers to the skin
234     */
235    public static function interimTogglingSupport(): string {
236        $js = <<<JAVASCRIPT
237function mfTempOpenSection( id ) {
238    var block = document.getElementById( "mf-section-" + id );
239    block.className += " open-block";
240    // The previous sibling to the content block is guaranteed to be the
241    // associated heading due to mobileformatter. We need to add the same
242    // class to flip the collapse arrow icon.
243    // <h[1-6]>heading</h[1-6]><div id="mf-section-[1-9]+"></div>
244    block.previousSibling.className += " open-block";
245}
246JAVASCRIPT;
247        return Html::inlineScript(
248            ResourceLoader::filter( 'minify-js', $js )
249        );
250    }
251
252    /**
253     * Performs html transformation that splits the body of the document into
254     * sections demarcated by the $headings elements. Also moves the first paragraph
255     * in the lead section above the infobox.
256     * @param Element $node to be transformed
257     * @throws DomException
258     */
259    public function apply( Element $node ): void {
260        $this->makeSections( $node, $this->getTopHeadings( $node ) );
261    }
262}