Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.70% covered (success)
96.70%
88 / 91
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
HandleParsoidSectionLinks
96.70% covered (success)
96.70%
88 / 91
50.00% covered (danger)
50.00%
3 / 6
30
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
 shouldRun
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isHtmlHeading
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 transformDOM
97.14% covered (success)
97.14%
34 / 35
0.00% covered (danger)
0.00%
0 / 1
8
 transformHeading
100.00% covered (success)
100.00%
47 / 47
100.00% covered (success)
100.00%
1 / 1
16
 resolveSkin
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\OutputTransform\Stages;
5
6use MediaWiki\Config\ServiceOptions;
7use MediaWiki\Context\RequestContext;
8use MediaWiki\OutputTransform\ContentDOMTransformStage;
9use MediaWiki\Parser\ParserOptions;
10use MediaWiki\Parser\ParserOutput;
11use MediaWiki\Parser\ParserOutputFlags;
12use MediaWiki\Skin\Skin;
13use MediaWiki\Title\TitleFactory;
14use Psr\Log\LoggerInterface;
15use Wikimedia\Parsoid\Core\SectionMetadata;
16use Wikimedia\Parsoid\DOM\DocumentFragment;
17use Wikimedia\Parsoid\DOM\Element;
18use Wikimedia\Parsoid\DOM\Node;
19use Wikimedia\Parsoid\Utils\DOMCompat;
20use Wikimedia\Parsoid\Utils\DOMTraverser;
21use Wikimedia\Parsoid\Utils\DOMUtils;
22use Wikimedia\Parsoid\Utils\WTUtils;
23
24/**
25 * Add anchors and other heading formatting, and replace the section link placeholders.
26 * @internal
27 */
28class HandleParsoidSectionLinks extends ContentDOMTransformStage {
29
30    public function __construct(
31        ServiceOptions $options,
32        LoggerInterface $logger,
33        private TitleFactory $titleFactory
34    ) {
35        parent::__construct( $options, $logger, transformBodyOnly: true );
36    }
37
38    public function shouldRun( ParserOutput $po, ParserOptions $popts, array $options = [] ): bool {
39        // Only run this stage if it is parsoid content
40        return $po->getContentHolder()->isParsoidContent();
41    }
42
43    /**
44     * Check if the heading has attributes that can only be added using HTML syntax.
45     *
46     * In the Parsoid default future, we might prefer only checking for stx=html.
47     */
48    private static function isHtmlHeading( Element $h ): bool {
49        if ( $h->hasAttribute( 'data-mw-wikitext' ) ) {
50            return false;
51        }
52        // FIXME(T100856): stx info probably shouldn't be in data-parsoid
53        // but keep these here until the parser cache turns over.
54        return WTUtils::isLiteralHTMLNode( $h );
55    }
56
57    public function transformDOM(
58        DocumentFragment $df, ParserOutput $po, ParserOptions $popts, array &$options
59    ): DocumentFragment {
60        $skin = $this->resolveSkin( $options );
61        // Transform:
62        //  <section data-mw-section-id=...>
63        //   <h2 id=...><span id=... typeof="mw:FallbackId"></span> ... </h2>
64        //   ...section contents..
65        // To:
66        //  <section data-mw-section-id=...>
67        //   <div class="mw-heading mw-heading2">
68        //    <h2 id=...><span id=... typeof="mw:FallbackId"></span> ... </h2>
69        //    <span class="mw-editsection">...section edit link...</span>
70        //   </div>
71        // That is, we're wrapping a <div> around the <h2> generated by
72        // Parsoid, and then (assuming section edit links are enabled)
73        // adding a <span> with the section edit link
74        // inside that <div>
75        //
76        // If COLLAPSIBLE_SECTIONS is set, then we also wrap a <div>
77        // around the section *contents*.
78        $toc = $po->getTOCData();
79        $sections = ( $toc !== null ) ? $toc->getSections() : [];
80        $sectionMap = [];
81        foreach ( $sections as $section ) {
82            if ( $section->anchor === '' ) {
83                // T375002 / T368722: The empty string isn't a valid id so
84                // Parsoid will have reassigned it and we'll never be able
85                // to select by it below.  There's no sense in logging an
86                // error since it's a common enough occurrence at present.
87                continue;
88            }
89            $sectionMap[$section->anchor] = [
90                'processed' => false,
91                'section' => $section
92            ];
93        }
94
95        $traverser = new DOMTraverser( false, false );
96        $headings = array_fill_keys(
97            [ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' ], true
98        );
99        $traverser->addHandler( null, function ( Node $node ) use (
100            $df, $po, $popts, $options, $skin, &$sectionMap, $headings
101        ) {
102            if ( !( $headings[DOMUtils::nodeName( $node )] ?? false ) ) {
103                return true;
104            }
105            '@phan-var Element $node';
106            $id = DOMCompat::getAttribute( $node, 'id' );
107            if ( $id === null ) {
108                return true;
109            }
110            if ( self::isHtmlHeading( $node ) ) {
111                // This is a <h#> tag with attributes added using HTML syntax.
112                // Mark it with a class to make them easier to distinguish (T68637).
113                DOMCompat::getClassList( $node )->add( 'mw-html-heading' );
114                // Do not add the wrapper if the heading has attributes added using HTML syntax (T353489).
115                return true;
116            }
117            // Ensure the data-mw-wikitext marker doesn't leak into the output
118            $node->removeAttribute( 'data-mw-wikitext' );
119            if ( !isset( $sectionMap[$id] ) ) {
120                return true;
121            }
122            return $this->transformHeading(
123                $df, $node, $po, $popts, $options, $skin, $sectionMap[$id]
124            );
125        } );
126        $traverser->traverse( null, $df );
127
128        /* TEMPORARILY DISABLE: T428849
129        foreach ( $sectionMap as $id => $sectionInfo ) {
130            if ( !$sectionInfo['processed'] ) {
131                $this->logger->error(
132                    __METHOD__ . ': Heading missing for anchor',
133                    $sectionInfo['section']->toLegacy()
134                );
135            }
136        }
137        */
138
139        return $df;
140    }
141
142    /**
143     * @param DocumentFragment $df
144     * @param Element $h
145     * @param ParserOutput $po
146     * @param ParserOptions $popts
147     * @param array $options
148     * @param Skin $skin
149     * @param array{section:SectionMetadata,processed:bool} &$sectionInfo
150     * @return Node|null|bool
151     */
152    private function transformHeading(
153        DocumentFragment $df, Element $h,
154        ParserOutput $po, ParserOptions $popts, array $options,
155        Skin $skin, array &$sectionInfo
156    ) {
157        $sectionInfo['processed'] = true;
158        $section = $sectionInfo['section'];
159
160        // T406897: Transfer ID from heading to aria-labelledby attribute
161        // on the <section> tag.
162        $s = $h->parentNode;
163        if (
164            $s instanceof Element &&
165            DOMUtils::nodeName( $s ) === 'div' &&
166            DOMCompat::getClassList( $s )->contains( 'mw-heading' )
167        ) {
168            // Handle existing wrapper (T357826)
169            $s = $s->parentNode;
170        }
171        if (
172            $s instanceof Element &&
173            DOMUtils::nodeName( $s ) === 'section'
174        ) {
175            $id = DOMCompat::getAttribute( $h, 'id' );
176            if ( $id !== null ) {
177                $s->setAttribute( 'aria-labelledby', $id );
178            }
179        }
180
181        $next = $h->nextSibling;
182
183        $fromTitle = $section->fromTitle;
184        $div = $df->ownerDocument->createElement( 'div' );
185        if (
186            $fromTitle !== null &&
187            // this should be kept in sync with the legacy implementation in HandleSectionLinks
188            !$po->getOutputFlag( ParserOutputFlags::NO_SECTION_EDIT_LINKS ) &&
189            !$popts->getSuppressSectionEditLinks() &&
190            ( $options['enableSectionEditLinks'] ?? true )
191        ) {
192            $editPage = $this->titleFactory->newFromTextThrow( $fromTitle );
193            $html = $skin->doEditSectionLink(
194                $editPage, $section->index, $h->textContent,
195                // T413227: skin doesn't mark user interface language as used,
196                // but it is used here.
197                $popts->getUserLangObj()
198            );
199            DOMCompat::setInnerHTML( $div, $html );
200        }
201
202        // Reuse existing wrapper if present.
203        $maybeWrapper = $h->parentNode;
204        '@phan-var \Wikimedia\Parsoid\DOM\Element $maybeWrapper';
205        if (
206            DOMUtils::nodeName( $maybeWrapper ) === 'div' &&
207            DOMCompat::getClassList( $maybeWrapper )->contains( 'mw-heading' )
208        ) {
209            // Transfer section edit link children to existing wrapper
210            // All contents of the div (the section edit link) will be
211            // inserted immediately following the <h> tag
212            $ref = $h->nextSibling;
213            while ( $div->firstChild !== null ) {
214                $maybeWrapper->insertBefore( $div->firstChild, $ref );
215            }
216            $div = $maybeWrapper; // for use below
217        } else {
218            // Move <hX> to new wrapper: the div contents are currently
219            // the section edit link. We first replace the h with the
220            // div, then insert the <h> as the first child of the div
221            // so the section edit link is immediately following the <h>.
222            $div->setAttribute(
223                'class', 'mw-heading mw-heading' . $section->hLevel
224            );
225            $h->parentNode->replaceChild( $div, $h );
226            // Work around bug in phan (https://github.com/phan/phan/pull/4837)
227            // by asserting that $div->firstChild is non-null here.  Actually,
228            // ::insertBefore will work fine if $div->firstChild is null (if
229            // "doEditSectionLink" returned nothing, for instance), but
230            // phan incorrectly thinks the second argument must be non-null.
231            $divFirstChild = $div->firstChild;
232            '@phan-var \DOMNode $divFirstChild'; // asserting non-null (PHP81)
233            $div->insertBefore( $h, $divFirstChild );
234        }
235        // Create collapsible section wrapper if requested.
236        if ( $po->getOutputFlag( ParserOutputFlags::COLLAPSIBLE_SECTIONS ) ) {
237            $contentsDiv = $df->ownerDocument->createElement( 'div' );
238            DOMCompat::getClassList( $contentsDiv )->add(
239                'mw-collapsible-content'
240            );
241            while ( $div->nextSibling !== null ) {
242                $contentsDiv->appendChild( $div->nextSibling );
243            }
244            $div->parentNode->appendChild( $contentsDiv );
245        }
246
247        return $next;
248    }
249
250    /**
251     * Extracts the skin from the $options array, with a fallback on request context skin
252     * @param array $options
253     * @return Skin
254     */
255    private function resolveSkin( array $options ): Skin {
256        $skin = $options[ 'skin' ] ?? null;
257        if ( !$skin ) {
258            // T348853 passing $skin will be mandatory in the future
259            $skin = RequestContext::getMain()->getSkin();
260        }
261        return $skin;
262    }
263}