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