Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.54% covered (warning)
86.54%
45 / 52
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
HandleParsoidSectionLinks
86.54% covered (warning)
86.54%
45 / 52
50.00% covered (danger)
50.00%
2 / 4
16.62
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
 shouldRun
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 transformDOM
85.37% covered (warning)
85.37%
35 / 41
0.00% covered (danger)
0.00%
0 / 1
10.31
 resolveSkin
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
1<?php
2// Suppress UnusedPluginSuppression because Phan on PHP 8.1 needs more
3// suppressions than PHP 7.x due to tighter types on Element::insertBefore()
4// and Element::appendChild(): see comments marked PHP81 below.  The
5// Unused*Suppression can be removed once MW moves to >= PHP 8.1.
6// @phan-file-suppress UnusedPluginSuppression,UnusedPluginFileSuppression
7
8namespace MediaWiki\OutputTransform\Stages;
9
10use MediaWiki\Config\ServiceOptions;
11use MediaWiki\Context\RequestContext;
12use MediaWiki\OutputTransform\ContentDOMTransformStage;
13use MediaWiki\Parser\ParserOutput;
14use MediaWiki\Parser\ParserOutputFlags;
15use MediaWiki\Title\TitleFactory;
16use ParserOptions;
17use Psr\Log\LoggerInterface;
18use Skin;
19use Wikimedia\Parsoid\DOM\Document;
20use Wikimedia\Parsoid\Utils\DOMCompat;
21
22/**
23 * Add anchors and other heading formatting, and replace the section link placeholders.
24 * @internal
25 */
26class HandleParsoidSectionLinks extends ContentDOMTransformStage {
27
28    private TitleFactory $titleFactory;
29
30    public function __construct(
31        ServiceOptions $options, LoggerInterface $logger, TitleFactory $titleFactory
32    ) {
33        parent::__construct( $options, $logger );
34        $this->titleFactory = $titleFactory;
35    }
36
37    public function shouldRun( ParserOutput $po, ?ParserOptions $popts, array $options = [] ): bool {
38        // Only run this stage if it is parsoid content *and* section edit
39        // links are enabled.
40        return (
41            ( $options['isParsoidContent'] ?? false ) &&
42            ( $options['enableSectionEditLinks'] ?? true ) &&
43            !$po->getOutputFlag( ParserOutputFlags::NO_SECTION_EDIT_LINKS )
44        );
45    }
46
47    public function transformDOM(
48        Document $dom, ParserOutput $po, ?ParserOptions $popts, array &$options
49    ): Document {
50        $skin = $this->resolveSkin( $options );
51        $titleText = $po->getTitleText();
52        // Transform:
53        //  <section data-mw-section-id=...>
54        //   <h2 id=...><span id=... typeof="mw:FallbackId"></span> ... </h2>
55        //   ...section contents..
56        // To:
57        //  <section data-mw-section-id=...>
58        //   <div class="mw-heading mw-heading2">
59        //    <h2 id=...><span id=... typeof="mw:FallbackId"></span> ... </h2>
60        //    <span class="mw-editsection">...section edit link...</span>
61        //   </div>
62        // That is, we're wrapping a <div> around the <h2> generated by
63        // Parsoid, and then adding a <span> with the section edit link
64        // inside that <div>
65        //
66        // If COLLAPSIBLE_SECTIONS is set, then we also wrap a <div>
67        // around the section contents.
68        $toc = $po->getTOCData();
69        $sections = ( $toc !== null ) ? $toc->getSections() : [];
70        // use the TOC data to extract the headings:
71        foreach ( $sections as $section ) {
72            $fromTitle = $section->fromTitle;
73            if ( $fromTitle === null ) {
74                // T353489: don't wrap bare <h> tags
75                continue;
76            }
77            $h = $dom->getElementById( $section->anchor );
78            if ( $h === null ) {
79                $this->logger->error(
80                    __METHOD__ . ': Heading missing for anchor',
81                    $section->toLegacy()
82                );
83                continue;
84            }
85            $div = $dom->createElement( 'div' );
86            $editPage = $this->titleFactory->newFromTextThrow( $fromTitle );
87            $html = $skin->doEditSectionLink(
88                $editPage, $section->index, $h->textContent,
89                $skin->getLanguage()
90            );
91            DOMCompat::setInnerHTML( $div, $html );
92
93            // Reuse existing wrapper if present.
94            $maybeWrapper = $h->parentNode;
95            '@phan-var \Wikimedia\Parsoid\DOM\Element $maybeWrapper';
96            if (
97                DOMCompat::nodeName( $maybeWrapper ) === 'div' &&
98                DOMCompat::getClassList( $maybeWrapper )->contains( 'mw-heading' )
99            ) {
100                // Transfer section edit link children to existing wrapper
101                // All contents of the div (the section edit link) will be
102                // inserted immediately following the <h> tag
103                $ref = $h->nextSibling;
104                while ( $div->firstChild !== null ) {
105                    // @phan-suppress-next-line PhanTypeMismatchArgumentNullableInternal firstChild is non-null (PHP81)
106                    $maybeWrapper->insertBefore( $div->firstChild, $ref );
107                }
108                $div = $maybeWrapper; // for use below
109            } else {
110                // Move <hX> to new wrapper: the div contents are currently
111                // the section edit link. We first replace the h with the
112                // div, then insert the <h> as the first child of the div
113                // so the section edit link is immediately following the <h>.
114                $div->setAttribute(
115                    'class', 'mw-heading mw-heading' . $section->hLevel
116                );
117                $h->parentNode->replaceChild( $div, $h );
118                // Work around bug in phan (https://github.com/phan/phan/pull/4837)
119                // by asserting that $div->firstChild is non-null here.  Actually,
120                // ::insertBefore will work fine if $div->firstChild is null (if
121                // "doEditSectionLink" returned nothing, for instance), but
122                // phan incorrectly thinks the second argument must be non-null.
123                $divFirstChild = $div->firstChild;
124                '@phan-var \DOMNode $divFirstChild'; // asserting non-null (PHP81)
125                $div->insertBefore( $h, $divFirstChild );
126            }
127            // Create collapsible section wrapper if requested.
128            if ( $po->getOutputFlag( ParserOutputFlags::COLLAPSIBLE_SECTIONS ) ) {
129                $contentsDiv = $dom->createElement( 'div' );
130                while ( $div->nextSibling !== null ) {
131                    // @phan-suppress-next-line PhanTypeMismatchArgumentNullableInternal
132                    $contentsDiv->appendChild( $div->nextSibling );
133                }
134                $div->parentNode->appendChild( $contentsDiv );
135            }
136        }
137        return $dom;
138    }
139
140    /**
141     * Extracts the skin from the $options array, with a fallback on request context skin
142     * @param array $options
143     * @return Skin
144     */
145    private function resolveSkin( array $options ): Skin {
146        $skin = $options[ 'skin' ] ?? null;
147        if ( !$skin ) {
148            // T348853 passing $skin will be mandatory in the future
149            $skin = RequestContext::getMain()->getSkin();
150        }
151        return $skin;
152    }
153}