Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.72% covered (success)
96.72%
59 / 61
60.00% covered (warning)
60.00%
3 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
HandleParsoidSectionLinks
96.72% covered (success)
96.72%
59 / 61
60.00% covered (warning)
60.00%
3 / 5
23
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%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isHtmlHeading
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 transformDOM
100.00% covered (success)
100.00%
47 / 47
100.00% covered (success)
100.00%
1 / 1
14
 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\ParserOptions;
14use MediaWiki\Parser\ParserOutput;
15use MediaWiki\Parser\ParserOutputFlags;
16use MediaWiki\Parser\Sanitizer;
17use MediaWiki\Skin\Skin;
18use MediaWiki\Title\TitleFactory;
19use Psr\Log\LoggerInterface;
20use Wikimedia\Parsoid\DOM\Document;
21use Wikimedia\Parsoid\DOM\Element;
22use Wikimedia\Parsoid\Utils\DOMCompat;
23use Wikimedia\Parsoid\Utils\DOMDataUtils;
24
25/**
26 * Add anchors and other heading formatting, and replace the section link placeholders.
27 * @internal
28 */
29class HandleParsoidSectionLinks extends ContentDOMTransformStage {
30
31    private TitleFactory $titleFactory;
32
33    public function __construct(
34        ServiceOptions $options, LoggerInterface $logger, TitleFactory $titleFactory
35    ) {
36        parent::__construct( $options, $logger );
37        $this->titleFactory = $titleFactory;
38    }
39
40    public function shouldRun( ParserOutput $po, ?ParserOptions $popts, array $options = [] ): bool {
41        // Only run this stage if it is parsoid content
42        return ( $options['isParsoidContent'] ?? false );
43    }
44
45    /**
46     * Check if the heading has attributes that can only be added using HTML syntax.
47     *
48     * In the Parsoid default future, we might prefer checking for stx=html.
49     */
50    private function isHtmlHeading( Element $h ): bool {
51        foreach ( $h->attributes as $attr ) {
52            // Condition matches DiscussionTool's CommentFormatter::handleHeading
53            if (
54                !in_array( $attr->name, [ 'id', 'data-object-id', 'about', 'typeof' ], true ) &&
55                !Sanitizer::isReservedDataAttribute( $attr->name )
56            ) {
57                return true;
58            }
59        }
60        // FIXME(T100856): stx info probably shouldn't be in data-parsoid
61        // Id is ignored above since it's a special case, make use of metadata
62        // to determine if it came from wikitext
63        if ( DOMDataUtils::getDataParsoid( $h )->reusedId ?? false ) {
64            return true;
65        }
66        return false;
67    }
68
69    public function transformDOM(
70        Document $dom, ParserOutput $po, ?ParserOptions $popts, array &$options
71    ): Document {
72        $skin = $this->resolveSkin( $options );
73        $titleText = $po->getTitleText();
74        // Transform:
75        //  <section data-mw-section-id=...>
76        //   <h2 id=...><span id=... typeof="mw:FallbackId"></span> ... </h2>
77        //   ...section contents..
78        // To:
79        //  <section data-mw-section-id=...>
80        //   <div class="mw-heading mw-heading2">
81        //    <h2 id=...><span id=... typeof="mw:FallbackId"></span> ... </h2>
82        //    <span class="mw-editsection">...section edit link...</span>
83        //   </div>
84        // That is, we're wrapping a <div> around the <h2> generated by
85        // Parsoid, and then (assuming section edit links are enabled)
86        // adding a <span> with the section edit link
87        // inside that <div>
88        //
89        // If COLLAPSIBLE_SECTIONS is set, then we also wrap a <div>
90        // around the section *contents*.
91        $toc = $po->getTOCData();
92        $sections = ( $toc !== null ) ? $toc->getSections() : [];
93        // use the TOC data to extract the headings:
94        foreach ( $sections as $section ) {
95            if ( $section->anchor === '' ) {
96                // T375002 / T368722: The empty string isn't a valid id so
97                // Parsoid will have reassigned it and we'll never be able
98                // to select by it below.  There's no sense in logging an
99                // error since it's a common enough occurrence at present.
100                continue;
101            }
102            $h = $dom->getElementById( $section->anchor );
103            if ( $h === null ) {
104                $this->logger->error(
105                    __METHOD__ . ': Heading missing for anchor',
106                    $section->toLegacy()
107                );
108                continue;
109            }
110
111            if ( $this->isHtmlHeading( $h ) ) {
112                // This is a <h#> tag with attributes added using HTML syntax.
113                // Mark it with a class to make them easier to distinguish (T68637).
114                DOMCompat::getClassList( $h )->add( 'mw-html-heading' );
115
116                // Do not add the wrapper if the heading has attributes added using HTML syntax (T353489).
117                continue;
118            }
119
120            $fromTitle = $section->fromTitle;
121            $div = $dom->createElement( 'div' );
122            if (
123                $fromTitle !== null &&
124                ( $options['enableSectionEditLinks'] ?? true ) &&
125                !$po->getOutputFlag( ParserOutputFlags::NO_SECTION_EDIT_LINKS )
126            ) {
127                $editPage = $this->titleFactory->newFromTextThrow( $fromTitle );
128                $html = $skin->doEditSectionLink(
129                    $editPage, $section->index, $h->textContent,
130                    $skin->getLanguage()
131                );
132                DOMCompat::setInnerHTML( $div, $html );
133            }
134
135            // Reuse existing wrapper if present.
136            $maybeWrapper = $h->parentNode;
137            '@phan-var \Wikimedia\Parsoid\DOM\Element $maybeWrapper';
138            if (
139                DOMCompat::nodeName( $maybeWrapper ) === 'div' &&
140                DOMCompat::getClassList( $maybeWrapper )->contains( 'mw-heading' )
141            ) {
142                // Transfer section edit link children to existing wrapper
143                // All contents of the div (the section edit link) will be
144                // inserted immediately following the <h> tag
145                $ref = $h->nextSibling;
146                while ( $div->firstChild !== null ) {
147                    // @phan-suppress-next-line PhanTypeMismatchArgumentNullableInternal firstChild is non-null (PHP81)
148                    $maybeWrapper->insertBefore( $div->firstChild, $ref );
149                }
150                $div = $maybeWrapper; // for use below
151            } else {
152                // Move <hX> to new wrapper: the div contents are currently
153                // the section edit link. We first replace the h with the
154                // div, then insert the <h> as the first child of the div
155                // so the section edit link is immediately following the <h>.
156                $div->setAttribute(
157                    'class', 'mw-heading mw-heading' . $section->hLevel
158                );
159                $h->parentNode->replaceChild( $div, $h );
160                // Work around bug in phan (https://github.com/phan/phan/pull/4837)
161                // by asserting that $div->firstChild is non-null here.  Actually,
162                // ::insertBefore will work fine if $div->firstChild is null (if
163                // "doEditSectionLink" returned nothing, for instance), but
164                // phan incorrectly thinks the second argument must be non-null.
165                $divFirstChild = $div->firstChild;
166                '@phan-var \DOMNode $divFirstChild'; // asserting non-null (PHP81)
167                $div->insertBefore( $h, $divFirstChild );
168            }
169            // Create collapsible section wrapper if requested.
170            if ( $po->getOutputFlag( ParserOutputFlags::COLLAPSIBLE_SECTIONS ) ) {
171                $contentsDiv = $dom->createElement( 'div' );
172                while ( $div->nextSibling !== null ) {
173                    // @phan-suppress-next-line PhanTypeMismatchArgumentNullableInternal
174                    $contentsDiv->appendChild( $div->nextSibling );
175                }
176                $div->parentNode->appendChild( $contentsDiv );
177            }
178        }
179        return $dom;
180    }
181
182    /**
183     * Extracts the skin from the $options array, with a fallback on request context skin
184     * @param array $options
185     * @return Skin
186     */
187    private function resolveSkin( array $options ): Skin {
188        $skin = $options[ 'skin' ] ?? null;
189        if ( !$skin ) {
190            // T348853 passing $skin will be mandatory in the future
191            $skin = RequestContext::getMain()->getSkin();
192        }
193        return $skin;
194    }
195}