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