Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.00% covered (success)
95.00%
57 / 60
71.43% covered (warning)
71.43%
5 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
HandleSectionLinks
95.00% covered (success)
95.00%
57 / 60
71.43% covered (warning)
71.43%
5 / 7
13
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%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 transformText
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 replaceHeadings
96.67% covered (success)
96.67%
29 / 30
0.00% covered (danger)
0.00%
0 / 1
2
 makeHeading
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
3.10
 addSectionLinks
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 resolveSkin
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace MediaWiki\OutputTransform\Stages;
4
5use MediaWiki\Context\RequestContext;
6use MediaWiki\Html\Html;
7use MediaWiki\Html\HtmlHelper;
8use MediaWiki\OutputTransform\ContentTextTransformStage;
9use MediaWiki\Parser\ParserOutput;
10use MediaWiki\Parser\ParserOutputFlags;
11use MediaWiki\Parser\Sanitizer;
12use MediaWiki\Title\TitleFactory;
13use ParserOptions;
14use Skin;
15
16/**
17 * Add anchors and other heading formatting, and replace the section link placeholders.
18 * @internal
19 */
20class HandleSectionLinks extends ContentTextTransformStage {
21    private const EDITSECTION_REGEX = '#<mw:editsection page="(.*?)" section="(.*?)">(.*?)</mw:editsection>#s';
22    private const HEADING_REGEX =
23        '/<H(?P<level>[1-6])(?P<attrib>(?:[^\'">]*|"([^"]*)"|\'([^\']*)\')*>)(?P<header>[\s\S]*?)<\/H[1-6] *>/i';
24
25    private TitleFactory $titleFactory;
26
27    public function __construct( TitleFactory $titleFactory ) {
28        $this->titleFactory = $titleFactory;
29    }
30
31    public function shouldRun( ParserOutput $po, ?ParserOptions $popts, array $options = [] ): bool {
32        $isParsoid = $options['isParsoidContent'] ?? false;
33        return !$isParsoid;
34    }
35
36    protected function transformText( string $text, ParserOutput $po, ?ParserOptions $popts, array &$options ): string {
37        $text = $this->replaceHeadings( $text );
38
39        if (
40            ( $options['enableSectionEditLinks'] ?? true ) &&
41            !$po->getOutputFlag( ParserOutputFlags::NO_SECTION_EDIT_LINKS )
42        ) {
43            return $this->addSectionLinks( $text, $po, $options );
44        } else {
45            return preg_replace( self::EDITSECTION_REGEX, '', $text );
46        }
47    }
48
49    private function replaceHeadings( string $text ): string {
50        return preg_replace_callback( self::HEADING_REGEX, function ( $m ) {
51            // Parse attributes out of the <h#> tag. Do not actually use HtmlHelper's output,
52            // because EDITSECTION_REGEX is sensitive to quotes in HTML serialization.
53            $attrs = [];
54            HtmlHelper::modifyElements(
55                $m[0],
56                static fn ( $node ) => in_array( $node->name, [ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' ] ),
57                static function ( $node ) use ( &$attrs ) {
58                    $attrs = $node->attrs->getValues();
59                    return $node;
60                }
61            );
62
63            if ( !isset( $attrs['data-mw-anchor'] ) ) {
64                return $m[0];
65            }
66
67            $anchor = $attrs['data-mw-anchor'];
68            $fallbackAnchor = $attrs['data-mw-fallback-anchor'] ?? false;
69            unset( $attrs['data-mw-anchor'] );
70            unset( $attrs['data-mw-fallback-anchor'] );
71
72            // Split the heading content from the section edit link placeholder
73            $editlink = '';
74            $contents = preg_replace_callback( self::EDITSECTION_REGEX, static function ( $mm ) use ( &$editlink ) {
75                $editlink = $mm[0];
76                return '';
77            }, $m['header'] );
78
79            return $this->makeHeading(
80                (int)$m['level'],
81                $attrs,
82                $anchor,
83                $contents,
84                $editlink,
85                $fallbackAnchor
86            );
87        }, $text );
88    }
89
90    /**
91     * @param int $level The level of the headline (1-6)
92     * @param array $attrs HTML attributes
93     * @param string $anchor The anchor to give the headline (the bit after the #)
94     * @param string $html HTML for the text of the header
95     * @param string $link HTML to add for the section edit link
96     * @param string|false $fallbackAnchor A second, optional anchor to give for
97     *   backward compatibility (false to omit)
98     * @return string HTML headline
99     */
100    private function makeHeading( $level, $attrs, $anchor, $html,
101        $link, $fallbackAnchor = false
102    ) {
103        $anchorEscaped = htmlspecialchars( $anchor, ENT_COMPAT );
104        $fallback = '';
105        if ( $fallbackAnchor !== false && $fallbackAnchor !== $anchor ) {
106            $fallbackAnchor = htmlspecialchars( $fallbackAnchor, ENT_COMPAT );
107            $fallback = "<span id=\"$fallbackAnchor\"></span>";
108        }
109        return "<h$level" . Html::expandAttributes( $attrs ) . ">"
110            . "$fallback<span class=\"mw-headline\" id=\"$anchorEscaped\">$html</span>"
111            . $link
112            . "</h$level>";
113    }
114
115    private function addSectionLinks( string $text, ParserOutput $po, array $options ): string {
116        $skin = $this->resolveSkin( $options );
117        $titleText = $po->getTitleText();
118        return preg_replace_callback( self::EDITSECTION_REGEX, function ( $m ) use ( $skin, $titleText ) {
119            $editsectionPage = $this->titleFactory->newFromTextThrow( htmlspecialchars_decode( $m[1] ) );
120            $editsectionSection = htmlspecialchars_decode( $m[2] );
121            $editsectionContent = Sanitizer::decodeCharReferences( $m[3] );
122            return $skin->doEditSectionLink( $editsectionPage, $editsectionSection, $editsectionContent,
123                $skin->getLanguage() );
124        }, $text );
125    }
126
127    /**
128     * Extracts the skin from the $options array, with a fallback on request context skin
129     * @param array $options
130     * @return Skin
131     */
132    private function resolveSkin( array $options ): Skin {
133        $skin = $options[ 'skin' ] ?? null;
134        if ( !$skin ) {
135            // T348853 passing $skin will be mandatory in the future
136            $skin = RequestContext::getMain()->getSkin();
137        }
138        return $skin;
139    }
140}