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