Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.24% covered (success)
95.24%
80 / 84
72.73% covered (warning)
72.73%
8 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
HandleTOCMarkers
95.24% covered (success)
95.24%
80 / 84
72.73% covered (warning)
72.73%
8 / 11
32
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
2
 transformText
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 injectTOC
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 resolveUserLanguage
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
4.37
 tocIndent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 tocUnindent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 tocLine
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 tocLineEnd
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 tocList
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
1
 generateTOC
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
10
1<?php
2
3namespace MediaWiki\OutputTransform\Stages;
4
5use Language;
6use MediaWiki\Context\RequestContext;
7use MediaWiki\Html\Html;
8use MediaWiki\MainConfigNames;
9use MediaWiki\MediaWikiServices;
10use MediaWiki\OutputTransform\ContentTextTransformStage;
11use MediaWiki\Parser\Parser;
12use MediaWiki\Parser\ParserOutput;
13use MediaWiki\Parser\Sanitizer;
14use MediaWiki\Tidy\TidyDriverBase;
15use ParserOptions;
16use Wikimedia\Parsoid\Core\TOCData;
17
18/**
19 * Inject table of contents (or empty string if there's no sections)
20 * @internal
21 */
22class HandleTOCMarkers extends ContentTextTransformStage {
23
24    private TidyDriverBase $tidy;
25
26    public function __construct( TidyDriverBase $tidy ) {
27        $this->tidy = $tidy;
28    }
29
30    public function shouldRun( ParserOutput $po, ?ParserOptions $popts, array $options = [] ): bool {
31        return !( $options['allowTOC'] ?? true ) || ( $options['injectTOC'] ?? true );
32    }
33
34    protected function transformText( string $text, ParserOutput $po, ?ParserOptions $popts, array &$options ): string {
35        if ( ( $options['allowTOC'] ?? true ) && ( $options['injectTOC'] ?? true ) ) {
36            return $this->injectTOC( $text, $po, $options );
37        }
38        if ( !( $options['allowTOC'] ?? true ) ) {
39            return Parser::replaceTableOfContentsMarker( $text, '' );
40        }
41        return $text;
42    }
43
44    private function injectTOC( string $text, ParserOutput $po, array $options ): string {
45        $lang = $this->resolveUserLanguage( $options );
46        $numSections = count( $po->getSections() );
47        $tocData = $po->getTOCData();
48        if ( $numSections === 0 ) {
49            $toc = '';
50        } else {
51            $toc = self::generateTOC( $tocData, $lang );
52            // TODO: This may no longer be needed since Ic0a805f29c928d0c2edf266ea045b0d29bb45a28
53            $toc = $this->tidy->tidy( $toc, [ Sanitizer::class, 'armorFrenchSpaces' ] );
54        }
55
56        return Parser::replaceTableOfContentsMarker( $text, $toc );
57    }
58
59    /**
60     * Extracts the userLanguage from the $options array, with a fallback on skin language and request
61     * context language
62     * @param array $options
63     * @return Language
64     */
65    private function resolveUserLanguage( array $options ): Language {
66        $userLang = $options['userLang'] ?? null;
67        $skin = $options['skin'] ?? null;
68        if ( ( !$userLang ) && $skin ) {
69            // TODO: We probably don't need a full Skin option here
70            $userLang = $skin->getLanguage();
71        }
72        if ( !$userLang ) {
73            // T348853 passing either userLang or skin will be mandatory in the future
74            $userLang = RequestContext::getMain()->getLanguage();
75        }
76        return $userLang;
77    }
78
79    /**
80     * Add another level to the Table of Contents
81     *
82     * @return string
83     */
84    private static function tocIndent() {
85        return "\n<ul>\n";
86    }
87
88    /**
89     * Finish one or more sublevels on the Table of Contents
90     *
91     * @param int $level
92     * @return string
93     */
94    private static function tocUnindent( $level ) {
95        return "</li>\n" . str_repeat( "</ul>\n</li>\n", $level > 0 ? $level : 0 );
96    }
97
98    /**
99     * parameter level defines if we are on an indentation level
100     *
101     * @param string $linkAnchor Identifier
102     * @param string $tocline Properly escaped HTML
103     * @param string $tocnumber Unescaped text
104     * @param int $level
105     * @param string|false $sectionIndex
106     * @return string
107     */
108    private static function tocLine( $linkAnchor, $tocline, $tocnumber, $level, $sectionIndex = false ) {
109        $classes = "toclevel-$level";
110
111        // Parser.php used to suppress tocLine by setting $sectionindex to false.
112        // In those circumstances, we can now encounter '' or a "T-" prefixed index
113        // for when the section comes from templates.
114        if ( $sectionIndex !== false && $sectionIndex !== '' && !str_starts_with( $sectionIndex, "T-" ) ) {
115            $classes .= " tocsection-$sectionIndex";
116        }
117
118        // <li class="$classes"><a href="#$linkAnchor"><span class="tocnumber">
119        // $tocnumber</span> <span class="toctext">$tocline</span></a>
120        return Html::openElement( 'li', [ 'class' => $classes ] )
121            . Html::rawElement( 'a',
122                [ 'href' => "#$linkAnchor" ],
123                Html::element( 'span', [ 'class' => 'tocnumber' ], $tocnumber )
124                    . ' '
125                    . Html::rawElement( 'span', [ 'class' => 'toctext' ], $tocline )
126            );
127    }
128
129    /**
130     * End a Table Of Contents line.
131     * tocUnindent() will be used instead if we're ending a line below
132     * the new level.
133     * @return string
134     */
135    private static function tocLineEnd() {
136        return "</li>\n";
137    }
138
139    /**
140     * Wraps the TOC in a div with ARIA navigation role and provides the hide/collapse JavaScript.
141     *
142     * @param string $toc Html of the Table Of Contents
143     * @param Language|null $lang Language for the toc title, defaults to user language
144     * @return string Full html of the TOC
145     */
146    private static function tocList( $toc, Language $lang = null ) {
147        $lang ??= RequestContext::getMain()->getLanguage();
148
149        $title = wfMessage( 'toc' )->inLanguage( $lang )->escaped();
150
151        return '<div id="toc" class="toc" role="navigation" aria-labelledby="mw-toc-heading">'
152            . Html::element( 'input', [
153                'type' => 'checkbox',
154                'role' => 'button',
155                'id' => 'toctogglecheckbox',
156                'class' => 'toctogglecheckbox',
157                'style' => 'display:none',
158            ] )
159            . Html::openElement( 'div', [
160                'class' => 'toctitle',
161                'lang' => $lang->getHtmlCode(),
162                'dir' => $lang->getDir(),
163            ] )
164            . '<h2 id="mw-toc-heading">' . $title . '</h2>'
165            . '<span class="toctogglespan">'
166            . Html::label( '', 'toctogglecheckbox', [
167                'class' => 'toctogglelabel',
168            ] )
169            . '</span>'
170            . '</div>'
171            . $toc
172            . "</ul>\n</div>\n";
173    }
174
175    /**
176     * Generate a table of contents from a section tree.
177     *
178     * @param ?TOCData $tocData Return value of ParserOutput::getSections()
179     * @param Language|null $lang Language for the toc title, defaults to user language
180     * @param array $options
181     *   - 'maxtoclevel' Max TOC level to generate
182     * @return string HTML fragment
183     */
184    private static function generateTOC( ?TOCData $tocData, Language $lang = null, array $options = [] ): string {
185        $toc = '';
186        $lastLevel = 0;
187        $maxTocLevel = $options['maxtoclevel'] ?? null;
188        if ( $maxTocLevel === null ) {
189            // Use wiki-configured default
190            $services = MediaWikiServices::getInstance();
191            $config = $services->getMainConfig();
192            $maxTocLevel = $config->get( MainConfigNames::MaxTocLevel );
193        }
194        foreach ( ( $tocData ? $tocData->getSections() : [] ) as $section ) {
195            $tocLevel = $section->tocLevel;
196            if ( $tocLevel < $maxTocLevel ) {
197                if ( $tocLevel > $lastLevel ) {
198                    $toc .= self::tocIndent();
199                } elseif ( $tocLevel < $lastLevel ) {
200                    if ( $lastLevel < $maxTocLevel ) {
201                        $toc .= self::tocUnindent(
202                            $lastLevel - $tocLevel );
203                    } else {
204                        $toc .= self::tocLineEnd();
205                    }
206                } else {
207                    $toc .= self::tocLineEnd();
208                }
209
210                $toc .= self::tocLine( $section->linkAnchor,
211                    $section->line, $section->number,
212                    $tocLevel, $section->index );
213                $lastLevel = $tocLevel;
214            }
215        }
216        if ( $lastLevel < $maxTocLevel && $lastLevel > 0 ) {
217            $toc .= self::tocUnindent( $lastLevel - 1 );
218        }
219        return self::tocList( $toc, $lang );
220    }
221}