Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.46% covered (success)
91.46%
75 / 82
66.67% covered (warning)
66.67%
6 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
MakeSectionsTransform
91.46% covered (success)
91.46%
75 / 82
66.67% covered (warning)
66.67%
6 / 9
30.56
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.75% covered (success)
93.75%
30 / 32
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
 getFirstHeading
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 interimTogglingSupport
100.00% covered (success)
100.00%
3 / 3
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 DOMDocument;
6use DOMElement;
7use DOMNode;
8use DOMXPath;
9use LogicException;
10use MediaWiki\Html\Html;
11use MediaWiki\ResourceLoader\ResourceLoader;
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     * @var bool
35     */
36    private $scriptsEnabled;
37
38    /**
39     * List of tags that could be considered as section headers.
40     * @var array
41     */
42    private $topHeadingTags;
43
44    /**
45     *
46     * @param array $topHeadingTags list of tags could ne cosidered as sections
47     * @param bool $scriptsEnabled wheather scripts are enabled
48     */
49    public function __construct(
50        array $topHeadingTags,
51        bool $scriptsEnabled
52    ) {
53        $this->topHeadingTags = $topHeadingTags;
54        $this->scriptsEnabled = $scriptsEnabled;
55    }
56
57    /**
58     * @param DOMNode|null $node
59     * @return string|false Heading tag name if the node is a heading
60     */
61    private function getHeadingName( $node ) {
62        if ( !( $node instanceof DOMElement ) ) {
63            return false;
64        }
65        // We accept both kinds of nodes that can be returned by getTopHeadings():
66        // a `<h1>` to `<h6>` node, or a `<div class="mw-heading">` node wrapping it.
67        // In the future `<div class="mw-heading">` will be required (T13555).
68        if ( DOMCompat::getClassList( $node )->contains( 'mw-heading' ) ) {
69            $node = DOMCompat::querySelector( $node, implode( ',', $this->topHeadingTags ) );
70            if ( !( $node instanceof DOMElement ) ) {
71                return false;
72            }
73        }
74        return $node->tagName;
75    }
76
77    /**
78     * Actually splits the body of the document into sections
79     *
80     * @param DOMElement $body representing the HTML of the current article. In the HTML the sections
81     *  should not be wrapped.
82     * @param DOMElement[] $headingWrappers The headings (or wrappers) returned by getTopHeadings():
83     *  `<h1>` to `<h6>` nodes, or `<div class="mw-heading">` nodes wrapping them.
84     *  In the future `<div class="mw-heading">` will be required (T13555).
85     */
86    private function makeSections( DOMElement $body, array $headingWrappers ) {
87        $ownerDocument = $body->ownerDocument;
88        if ( $ownerDocument === null ) {
89            return;
90        }
91        // Find the parser output wrapper div
92        $xpath = new DOMXPath( $ownerDocument );
93        $containers = $xpath->query(
94            // Equivalent of CSS attribute `~=` to support multiple classes
95            'body/div[contains(concat(" ",normalize-space(@class)," ")," mw-parser-output ")][1]'
96        );
97        if ( !$containers->length ) {
98            // No wrapper? This could be an old parser cache entry, or perhaps the
99            // OutputPage contained something that was not generated by the parser.
100            // Try using the <body> as the container.
101            $containers = $xpath->query( 'body' );
102            if ( !$containers->length ) {
103                throw new LogicException( "HTML lacked body element even though we put it there ourselves" );
104            }
105        }
106
107        $container = $containers->item( 0 );
108        $containerChild = $container->firstChild;
109        $firstHeading = reset( $headingWrappers );
110        $firstHeadingName = $this->getHeadingName( $firstHeading );
111        $sectionNumber = 0;
112        $sectionBody = $this->createSectionBodyElement( $ownerDocument, $sectionNumber, false );
113
114        while ( $containerChild ) {
115            $node = $containerChild;
116            $containerChild = $containerChild->nextSibling;
117            // If we've found a top level heading, insert the previous section if
118            // necessary and clear the container div.
119            if ( $firstHeadingName && $this->getHeadingName( $node ) === $firstHeadingName ) {
120                // The heading we are transforming is always 1 section ahead of the
121                // section we are currently processing
122                /** @phan-suppress-next-line PhanTypeMismatchArgumentSuperType DOMNode vs. DOMElement */
123                $this->prepareHeading( $ownerDocument, $node, $sectionNumber + 1, $this->scriptsEnabled );
124                // Insert the previous section body and reset it for the new section
125                $container->insertBefore( $sectionBody, $node );
126
127                $sectionNumber += 1;
128                $sectionBody = $this->createSectionBodyElement(
129                    $ownerDocument,
130                    $sectionNumber,
131                    $this->scriptsEnabled
132                );
133                continue;
134            }
135
136            // If it is not a top level heading, keep appending the nodes to the
137            // section body container.
138            $sectionBody->appendChild( $node );
139        }
140
141        // Append the last section body.
142        $container->appendChild( $sectionBody );
143    }
144
145    /**
146     * Prepare section headings, add required classes and onclick actions
147     *
148     * @param DOMDocument $doc
149     * @param DOMElement $heading
150     * @param int $sectionNumber
151     * @param bool $isCollapsible
152     */
153    private function prepareHeading(
154        DOMDocument $doc, DOMElement $heading, $sectionNumber, $isCollapsible
155    ) {
156        $className = $heading->hasAttribute( 'class' ) ? $heading->getAttribute( 'class' ) . ' ' : '';
157        $heading->setAttribute( 'class', $className . 'section-heading' );
158        if ( $isCollapsible ) {
159            $heading->setAttribute( 'onclick', 'mfTempOpenSection(' . $sectionNumber . ')' );
160        }
161
162        // prepend indicator - this avoids a reflow by creating a placeholder for a toggling indicator
163        $indicator = $doc->createElement( 'span' );
164        $indicator->setAttribute( 'class', 'indicator mf-icon mf-icon-expand mf-icon--small' );
165        $heading->insertBefore( $indicator, $heading->firstChild ?? $heading );
166    }
167
168    /**
169     * Creates a Section body element
170     *
171     * @param DOMDocument $doc
172     * @param int $sectionNumber
173     * @param bool $isCollapsible
174     *
175     * @return DOMElement
176     */
177    private function createSectionBodyElement( DOMDocument $doc, $sectionNumber, $isCollapsible ) {
178        $sectionClass = 'mf-section-' . $sectionNumber;
179        if ( $isCollapsible ) {
180            // TODO: Probably good to rename this to the more generic 'section'.
181            // We have no idea how the skin will use this.
182            $sectionClass .= ' ' . self::STYLE_COLLAPSIBLE_SECTION_CLASS;
183        }
184
185        // FIXME: The class `/mf\-section\-[0-9]+/` is kept for caching reasons
186        // but given class is unique usage is discouraged. [T126825]
187        $sectionBody = $doc->createElement( 'section' );
188        $sectionBody->setAttribute( 'class', $sectionClass );
189        $sectionBody->setAttribute( 'id', 'mf-section-' . $sectionNumber );
190        return $sectionBody;
191    }
192
193    /**
194     * Gets top headings in the document.
195     *
196     * Note well that the rank order is defined by the
197     * <code>MobileFormatter#topHeadingTags</code> property.
198     *
199     * @param DOMElement $doc
200     * @return array An array first is the highest rank headings
201     */
202    private function getTopHeadings( DOMElement $doc ): array {
203        $headings = [];
204
205        foreach ( $this->topHeadingTags as $tagName ) {
206            $allTags = DOMCompat::querySelectorAll( $doc, $tagName );
207
208            foreach ( $allTags as $el ) {
209                $parent = $el->parentNode;
210                if ( !( $parent instanceof DOMElement ) ) {
211                    continue;
212                }
213                // Use the `<div class="mw-heading">` wrapper if it is present. When they are required
214                // (T13555), the querySelectorAll() above can use the class and this can be removed.
215                if ( DOMCompat::getClassList( $parent )->contains( 'mw-heading' ) ) {
216                    $el = $parent;
217                }
218                // This check can be removed too when we require the wrappers.
219                if ( $parent->getAttribute( 'class' ) !== 'toctitle' ) {
220                    $headings[] = $el;
221                }
222            }
223            if ( $headings ) {
224                return $headings;
225            }
226
227        }
228
229        return $headings;
230    }
231
232        /**
233         * Retrieves the tag name of the first heading element (e.g., `h1`, `h2`, etc.)
234         * inside the `.mw-parser-output` container. If no headings are found, returns an
235         * empty string.
236         *
237         * Note: The function loops over all top-level sibling elements inside
238         * .mw-parser-output until it finds the first heading element. This approach is
239         * considered acceptable because:
240         *  1. It breaks immediately upon finding the first heading, so performance is not
241         *     significantly impacted.
242         *  2. Practical HTML article structures are unlikely to contain a large number
243         *     of elements without headings.
244         *
245         * @param DOMElement $doc
246         * @return string
247         */
248    public function getFirstHeading( DOMElement $doc ): string {
249        $parserContent = DOMCompat::querySelector( $doc, '.mw-parser-output' );
250
251        if ( !$parserContent ) {
252            return '';
253        }
254
255        // Start with the first child element of '.mw-parser-output'
256        $currentElement = DOMCompat::getFirstElementChild( $parserContent );
257
258        // Traverse siblings to find the first heading element
259        while ( $currentElement ) {
260            // Check if the current element's tag name matches any heading tag
261            if ( in_array( $currentElement->tagName, $this->topHeadingTags ) ) {
262
263                return $currentElement->tagName;
264            }
265
266            // Move to the next sibling
267            $currentElement = DOMCompat::getNextElementSibling( $currentElement );
268        }
269
270        // Return an empty string if no heading is found
271        return '';
272    }
273
274    /**
275     * Make it possible to open sections while JavaScript is still loading.
276     *
277     * @return string The JavaScript code to add event handlers to the skin
278     */
279    public static function interimTogglingSupport() {
280        $js = <<<JAVASCRIPT
281function mfTempOpenSection( id ) {
282    var block = document.getElementById( "mf-section-" + id );
283    block.className += " open-block";
284    // The previous sibling to the content block is guaranteed to be the
285    // associated heading due to mobileformatter. We need to add the same
286    // class to flip the collapse arrow icon.
287    // <h[1-6]>heading</h[1-6]><div id="mf-section-[1-9]+"></div>
288    block.previousSibling.className += " open-block";
289}
290JAVASCRIPT;
291        return Html::inlineScript(
292            ResourceLoader::filter( 'minify-js', $js )
293        );
294    }
295
296    /**
297     * Performs html transformation that splits the body of the document into
298     * sections demarcated by the $headings elements. Also moves the first paragraph
299     * in the lead section above the infobox.
300     * @param DOMElement $doc html document
301     */
302    public function apply( DOMElement $doc ) {
303        $this->makeSections( $doc, $this->getTopHeadings( $doc ) );
304    }
305}