Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 828
0.00% covered (danger)
0.00%
0 / 38
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialVersion
0.00% covered (danger)
0.00%
0 / 827
0.00% covered (danger)
0.00%
0 / 38
26082
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getCredits
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 execute
0.00% covered (danger)
0.00%
0 / 93
0.00% covered (danger)
0.00%
0 / 1
506
 addTocSection
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 addTocSubSection
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 getMediaWikiCredits
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 getCopyrightAndAuthorList
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
6
 getSoftwareInformation
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 softwareInformation
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
6
 getVersion
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 getVersionLinked
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 getMWVersionLinked
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getVersionLinkedGit
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 getExtensionTypes
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
6
 getExtensionTypeName
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getExtensionCredits
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
90
 getSkinCredits
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 getLibraries
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 parseComposerInstalled
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
72
 getExternalLibraries
0.00% covered (danger)
0.00%
0 / 53
0.00% covered (danger)
0.00%
0 / 1
42
 parseForeignResources
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 getClientSideLibraries
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
6
 getParserTags
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
6
 getParserFunctionHooks
0.00% covered (danger)
0.00%
0 / 61
0.00% covered (danger)
0.00%
0 / 1
72
 getParsoidModules
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
6
 getExtensionCategory
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 compare
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCreditsForExtension
0.00% covered (danger)
0.00%
0 / 113
0.00% covered (danger)
0.00%
0 / 1
462
 getHooks
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
20
 openExtType
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
42
 getTableHeaderHtml
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 IPInfo
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 listAuthors
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
210
 listToText
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 arrayToString
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
56
 getGitHeadSha1
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getEntryPointInfo
0.00% covered (danger)
0.00%
0 / 57
0.00% covered (danger)
0.00%
0 / 1
12
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Copyright © 2005 Ævar Arnfjörð Bjarmason
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 */
22
23namespace MediaWiki\Specials;
24
25use Closure;
26use MediaWiki\Config\Config;
27use MediaWiki\HookContainer\HookRunner;
28use MediaWiki\Html\Html;
29use MediaWiki\Language\Language;
30use MediaWiki\Language\RawMessage;
31use MediaWiki\MainConfigNames;
32use MediaWiki\MediaWikiServices;
33use MediaWiki\Message\Message;
34use MediaWiki\Parser\ParserFactory;
35use MediaWiki\Parser\Sanitizer;
36use MediaWiki\Registration\ExtensionRegistry;
37use MediaWiki\SpecialPage\SpecialPage;
38use MediaWiki\Utils\ExtensionInfo;
39use MediaWiki\Utils\GitInfo;
40use MediaWiki\Utils\MWTimestamp;
41use MediaWiki\Utils\UrlUtils;
42use Symfony\Component\Yaml\Yaml;
43use Wikimedia\Composer\ComposerInstalled;
44use Wikimedia\HtmlArmor\HtmlArmor;
45use Wikimedia\Parsoid\Core\SectionMetadata;
46use Wikimedia\Parsoid\Core\TOCData;
47use Wikimedia\Rdbms\IConnectionProvider;
48
49/**
50 * Version information about MediaWiki (core, extensions, libs), PHP, and the database.
51 *
52 * @ingroup SpecialPage
53 */
54class SpecialVersion extends SpecialPage {
55
56    /**
57     * @var string The current rev id/SHA hash of MediaWiki core
58     */
59    protected $coreId = '';
60
61    /**
62     * @var string[]|false Lazy initialized key/value with message content
63     */
64    protected static $extensionTypes = false;
65
66    /** @var TOCData */
67    protected $tocData;
68
69    /** @var int */
70    protected $tocIndex;
71
72    /** @var int */
73    protected $tocSection;
74
75    /** @var int */
76    protected $tocSubSection;
77
78    private ParserFactory $parserFactory;
79    private UrlUtils $urlUtils;
80    private IConnectionProvider $dbProvider;
81
82    public function __construct(
83        ParserFactory $parserFactory,
84        UrlUtils $urlUtils,
85        IConnectionProvider $dbProvider
86    ) {
87        parent::__construct( 'Version' );
88        $this->parserFactory = $parserFactory;
89        $this->urlUtils = $urlUtils;
90        $this->dbProvider = $dbProvider;
91    }
92
93    /**
94     * @since 1.35
95     * @param ExtensionRegistry $reg
96     * @param Config $conf For additional entries from $wgExtensionCredits.
97     * @return array[]
98     * @see $wgExtensionCredits
99     */
100    public static function getCredits( ExtensionRegistry $reg, Config $conf ): array {
101        $credits = $conf->get( MainConfigNames::ExtensionCredits );
102        foreach ( $reg->getAllThings() as $credit ) {
103            $credits[$credit['type']][] = $credit;
104        }
105        return $credits;
106    }
107
108    /**
109     * @param string|null $par
110     */
111    public function execute( $par ) {
112        $config = $this->getConfig();
113        $credits = self::getCredits( ExtensionRegistry::getInstance(), $config );
114
115        $this->setHeaders();
116        $this->outputHeader();
117        $out = $this->getOutput();
118        $out->getMetadata()->setPreventClickjacking( false );
119
120        // Explode the subpage information into useful bits
121        $parts = explode( '/', (string)$par );
122        $extNode = null;
123        if ( isset( $parts[1] ) ) {
124            $extName = str_replace( '_', ' ', $parts[1] );
125            // Find it!
126            foreach ( $credits as $extensions ) {
127                foreach ( $extensions as $ext ) {
128                    if ( isset( $ext['name'] ) && ( $ext['name'] === $extName ) ) {
129                        $extNode = &$ext;
130                        break 2;
131                    }
132                }
133            }
134            if ( !$extNode ) {
135                $out->setStatusCode( 404 );
136            }
137        } else {
138            $extName = 'MediaWiki';
139        }
140
141        // Now figure out what to do
142        switch ( strtolower( $parts[0] ) ) {
143            case 'credits':
144                $out->addModuleStyles( 'mediawiki.special' );
145
146                $wikiText = '{{int:version-credits-not-found}}';
147                if ( $extName === 'MediaWiki' ) {
148                    $wikiText = file_get_contents( MW_INSTALL_PATH . '/CREDITS' );
149                    // Put the contributor list into columns
150                    $wikiText = str_replace(
151                        [ '<!-- BEGIN CONTRIBUTOR LIST -->', '<!-- END CONTRIBUTOR LIST -->' ],
152                        [ '<div class="mw-version-credits">', '</div>' ],
153                        $wikiText
154                    );
155                } elseif ( ( $extNode !== null ) && isset( $extNode['path'] ) ) {
156                    $file = ExtensionInfo::getAuthorsFileName( dirname( $extNode['path'] ) );
157                    if ( $file ) {
158                        $wikiText = file_get_contents( $file );
159                        if ( str_ends_with( $file, '.txt' ) ) {
160                            $wikiText = Html::element(
161                                'pre',
162                                [
163                                    'lang' => 'en',
164                                    'dir' => 'ltr',
165                                ],
166                                $wikiText
167                            );
168                        }
169                    }
170                }
171
172                $out->setPageTitleMsg( $this->msg( 'version-credits-title' )->plaintextParams( $extName ) );
173                $out->addWikiTextAsInterface( $wikiText );
174                break;
175
176            case 'license':
177                $out->setPageTitleMsg( $this->msg( 'version-license-title' )->plaintextParams( $extName ) );
178
179                $licenseFound = false;
180
181                if ( $extName === 'MediaWiki' ) {
182                    $out->addWikiTextAsInterface(
183                        file_get_contents( MW_INSTALL_PATH . '/COPYING' )
184                    );
185                    $licenseFound = true;
186                } elseif ( ( $extNode !== null ) && isset( $extNode['path'] ) ) {
187                    $files = ExtensionInfo::getLicenseFileNames( dirname( $extNode['path'] ) );
188                    if ( $files ) {
189                        $licenseFound = true;
190                        foreach ( $files as $file ) {
191                            $out->addWikiTextAsInterface(
192                                Html::element(
193                                    'pre',
194                                    [
195                                        'lang' => 'en',
196                                        'dir' => 'ltr',
197                                    ],
198                                    file_get_contents( $file )
199                                )
200                            );
201                        }
202                    }
203                }
204                if ( !$licenseFound ) {
205                    $out->addWikiTextAsInterface( '{{int:version-license-not-found}}' );
206                }
207                break;
208
209            default:
210                $out->addModuleStyles( 'mediawiki.special' );
211
212                $out->addHTML( $this->getMediaWikiCredits() );
213
214                $this->tocData = new TOCData();
215                $this->tocIndex = 0;
216                $this->tocSection = 0;
217                $this->tocSubSection = 0;
218
219                // Build the page contents (this also fills in TOCData)
220                $sections = [
221                    $this->softwareInformation(),
222                    $this->getEntryPointInfo(),
223                    $this->getSkinCredits( $credits ),
224                    $this->getExtensionCredits( $credits ),
225                    $this->getLibraries( $credits ),
226                    $this->getParserTags(),
227                    $this->getParserFunctionHooks(),
228                    $this->getParsoidModules(),
229                    $this->getHooks(),
230                    $this->IPInfo(),
231                ];
232
233                // Insert TOC first
234                $out->addTOCPlaceholder( $this->tocData );
235
236                // Insert contents
237                foreach ( $sections as $content ) {
238                    $out->addHTML( $content );
239                }
240
241                break;
242        }
243    }
244
245    /**
246     * Add a section to the table of contents. This doesn't add the heading to the actual page.
247     * Assumes the IDs don't use non-ASCII characters.
248     *
249     * @param string $labelMsg Message key to use for the label
250     * @param string $id
251     */
252    private function addTocSection( $labelMsg, $id ) {
253        $this->tocIndex++;
254        $this->tocSection++;
255        $this->tocSubSection = 0;
256        $this->tocData->addSection( new SectionMetadata(
257            1,
258            2,
259            $this->msg( $labelMsg )->escaped(),
260            $this->getLanguage()->formatNum( $this->tocSection ),
261            (string)$this->tocIndex,
262            null,
263            null,
264            $id,
265            $id
266        ) );
267    }
268
269    /**
270     * Add a sub-section to the table of contents. This doesn't add the heading to the actual page.
271     * Assumes the IDs don't use non-ASCII characters.
272     *
273     * @param string $label Text of the label
274     * @param string $id
275     */
276    private function addTocSubSection( $label, $id ) {
277        $this->tocIndex++;
278        $this->tocSubSection++;
279        $this->tocData->addSection( new SectionMetadata(
280            2,
281            3,
282            htmlspecialchars( $label ),
283            // See Parser::localizeTOC
284            $this->getLanguage()->formatNum( $this->tocSection ) . '.' .
285                $this->getLanguage()->formatNum( $this->tocSubSection ),
286            (string)$this->tocIndex,
287            null,
288            null,
289            $id,
290            $id
291        ) );
292    }
293
294    /**
295     * Returns HTML showing the license information.
296     *
297     * @return string HTML
298     */
299    private function getMediaWikiCredits() {
300        // No TOC entry for this heading, we treat it like the lede section
301
302        $ret = Html::element(
303            'h2',
304            [ 'id' => 'mw-version-license' ],
305            $this->msg( 'version-license' )->text()
306        );
307
308        $ret .= Html::rawElement( 'div', [ 'class' => 'plainlinks' ],
309            $this->msg( new RawMessage( self::getCopyrightAndAuthorList() ) )->parseAsBlock() .
310            Html::rawElement( 'div', [ 'class' => 'mw-version-license-info' ],
311                $this->msg( 'version-license-info' )->parseAsBlock()
312            )
313        );
314
315        return $ret;
316    }
317
318    /**
319     * Get the "MediaWiki is copyright 2001-20xx by lots of cool folks" text
320     *
321     * @internal For use by WebInstallerWelcome
322     * @return string Wikitext
323     */
324    public static function getCopyrightAndAuthorList() {
325        if ( defined( 'MEDIAWIKI_INSTALL' ) ) {
326            $othersLink = '[https://www.mediawiki.org/wiki/Special:Version/Credits ' .
327                wfMessage( 'version-poweredby-others' )->plain() . ']';
328        } else {
329            $othersLink = '[[Special:Version/Credits|' .
330                wfMessage( 'version-poweredby-others' )->plain() . ']]';
331        }
332
333        $translatorsLink = '[https://translatewiki.net/wiki/Translating:MediaWiki/Credits ' .
334            wfMessage( 'version-poweredby-translators' )->plain() . ']';
335
336        $authorList = [
337            'Magnus Manske', 'Brooke Vibber', 'Lee Daniel Crocker',
338            'Tim Starling', 'Erik Möller', 'Gabriel Wicke', 'Ævar Arnfjörð Bjarmason',
339            'Niklas Laxström', 'Domas Mituzas', 'Rob Church', 'Yuri Astrakhan',
340            'Aryeh Gregor', 'Aaron Schulz', 'Andrew Garrett', 'Raimond Spekking',
341            'Alexandre Emsenhuber', 'Siebrand Mazeland', 'Chad Horohoe',
342            'Roan Kattouw', 'Trevor Parscal', 'Bryan Tong Minh', 'Sam Reed',
343            'Victor Vasiliev', 'Rotem Liss', 'Platonides', 'Antoine Musso',
344            'Timo Tijhof', 'Daniel Kinzler', 'Jeroen De Dauw', 'Brad Jorsch',
345            'Bartosz Dziewoński', 'Ed Sanders', 'Moriel Schottlender',
346            'Kunal Mehta', 'James D. Forrester', 'Brian Wolff', 'Adam Shorland',
347            'DannyS712', 'Ori Livneh', 'Max Semenik', 'Amir Sarabadani',
348            'Derk-Jan Hartman', 'Petr Pchelko', 'Umherirrender', 'C. Scott Ananian',
349            'fomafix', 'Thiemo Kreuz', 'Gergő Tisza', 'Volker E.',
350            'Jack Phoenix', 'Isarra Yos',
351            $othersLink, $translatorsLink
352        ];
353
354        return wfMessage( 'version-poweredby-credits', MWTimestamp::getLocalInstance()->format( 'Y' ),
355            Message::listParam( $authorList ) )->plain();
356    }
357
358    /**
359     * Helper for self::softwareInformation().
360     * @since 1.34
361     * @return string[] Array of wikitext strings keyed by wikitext strings
362     */
363    private function getSoftwareInformation() {
364        $dbr = $this->dbProvider->getReplicaDatabase();
365
366        // Put the software in an array of form 'name' => 'version'. All messages should
367        // be loaded here, so feel free to use wfMessage in the 'name'. Wikitext
368        // can be used both in the name and value.
369        $software = [
370            '[https://www.mediawiki.org/ MediaWiki]' => self::getVersionLinked(),
371            '[https://php.net/ PHP]' => PHP_VERSION . " (" . PHP_SAPI . ")",
372            '[https://icu.unicode.org/ ICU]' => INTL_ICU_VERSION,
373            $dbr->getSoftwareLink() => $dbr->getServerInfo(),
374        ];
375
376        // T339915: If wikidiff2 is installed, show version
377        if ( phpversion( "wikidiff2" ) ) {
378            $software[ '[https://www.mediawiki.org/wiki/Wikidiff2 wikidiff2]' ] = phpversion( "wikidiff2" );
379        }
380
381        // Allow a hook to add/remove items.
382        $this->getHookRunner()->onSoftwareInfo( $software );
383
384        return $software;
385    }
386
387    /**
388     * Returns HTML showing the third party software versions (apache, php, mysql).
389     *
390     * @return string HTML
391     */
392    private function softwareInformation() {
393        $this->addTocSection( 'version-software', 'mw-version-software' );
394
395        $out = Html::element(
396            'h2',
397            [ 'id' => 'mw-version-software' ],
398            $this->msg( 'version-software' )->text()
399        );
400
401        $out .= Html::openElement( 'table', [ 'class' => 'wikitable plainlinks', 'id' => 'sv-software' ] );
402
403        $out .= $this->getTableHeaderHtml( [
404            $this->msg( 'version-software-product' )->text(),
405            $this->msg( 'version-software-version' )->text()
406        ] );
407
408        foreach ( $this->getSoftwareInformation() as $name => $version ) {
409            $out .= Html::rawElement(
410                'tr',
411                [],
412                Html::rawElement( 'td', [], $this->msg( new RawMessage( $name ) )->parse() ) .
413                    Html::rawElement( 'td', [ 'dir' => 'ltr' ], $this->msg( new RawMessage( $version ) )->parse() )
414            );
415        }
416
417        $out .= Html::closeElement( 'table' );
418
419        return $out;
420    }
421
422    /**
423     * Return a string of the MediaWiki version with Git revision if available.
424     *
425     * @param string $flags If set to 'nodb', the language-specific parantheses are not used.
426     * @param Language|string|null $lang Language in which to render the version; ignored if
427     *   $flags is set to 'nodb'.
428     * @return string A version string, as wikitext. This should be parsed
429     *   (unless `nodb` is set) and escaped before being inserted as HTML.
430     */
431    public static function getVersion( $flags = '', $lang = null ) {
432        $gitInfo = GitInfo::repo()->getHeadSHA1();
433        if ( !$gitInfo ) {
434            $version = MW_VERSION;
435        } elseif ( $flags === 'nodb' ) {
436            $shortSha1 = substr( $gitInfo, 0, 7 );
437            $version = MW_VERSION . " ($shortSha1)";
438        } else {
439            $shortSha1 = substr( $gitInfo, 0, 7 );
440            $msg = wfMessage( 'parentheses' );
441            if ( $lang !== null ) {
442                $msg->inLanguage( $lang );
443            }
444            $shortSha1 = $msg->params( $shortSha1 )->text();
445            $version = MW_VERSION . ' ' . $shortSha1;
446        }
447
448        return $version;
449    }
450
451    /**
452     * Return a wikitext-formatted string of the MediaWiki version with a link to
453     * the Git SHA1 of head if available.
454     * The fallback is just MW_VERSION.
455     *
456     * @return string
457     */
458    public static function getVersionLinked() {
459        return self::getVersionLinkedGit() ?: MW_VERSION;
460    }
461
462    /**
463     * @return string
464     */
465    private static function getMWVersionLinked() {
466        $versionUrl = "";
467        $hookRunner = new HookRunner( MediaWikiServices::getInstance()->getHookContainer() );
468        if ( $hookRunner->onSpecialVersionVersionUrl( MW_VERSION, $versionUrl ) ) {
469            $versionParts = [];
470            preg_match( "/^(\d+\.\d+)/", MW_VERSION, $versionParts );
471            $versionUrl = "https://www.mediawiki.org/wiki/MediaWiki_{$versionParts[1]}";
472        }
473
474        return '[' . $versionUrl . ' ' . MW_VERSION . ']';
475    }
476
477    /**
478     * @since 1.22 Includes the date of the Git HEAD commit
479     * @return bool|string MW version and Git HEAD (SHA1 stripped to the first 7 chars)
480     *   with link and date, or false on failure
481     */
482    private static function getVersionLinkedGit() {
483        global $wgLang;
484
485        $gitInfo = new GitInfo( MW_INSTALL_PATH );
486        $headSHA1 = $gitInfo->getHeadSHA1();
487        if ( !$headSHA1 ) {
488            return false;
489        }
490
491        $shortSHA1 = '(' . substr( $headSHA1, 0, 7 ) . ')';
492
493        $gitHeadUrl = $gitInfo->getHeadViewUrl();
494        if ( $gitHeadUrl !== false ) {
495            $shortSHA1 = "[$gitHeadUrl $shortSHA1]";
496        }
497
498        $gitHeadCommitDate = $gitInfo->getHeadCommitDate();
499        if ( $gitHeadCommitDate ) {
500            $shortSHA1 .= Html::element( 'br' ) . $wgLang->timeanddate( (string)$gitHeadCommitDate, true );
501        }
502
503        return self::getMWVersionLinked() . " $shortSHA1";
504    }
505
506    /**
507     * Returns an array with the base extension types.
508     * Type is stored as array key, the message as array value.
509     *
510     * TODO: ideally this would return all extension types.
511     *
512     * @since 1.17
513     * @return string[]
514     */
515    public static function getExtensionTypes(): array {
516        if ( self::$extensionTypes === false ) {
517            self::$extensionTypes = [
518                'specialpage' => wfMessage( 'version-specialpages' )->text(),
519                'editor' => wfMessage( 'version-editors' )->text(),
520                'parserhook' => wfMessage( 'version-parserhooks' )->text(),
521                'variable' => wfMessage( 'version-variables' )->text(),
522                'media' => wfMessage( 'version-mediahandlers' )->text(),
523                'antispam' => wfMessage( 'version-antispam' )->text(),
524                'skin' => wfMessage( 'version-skins' )->text(),
525                'api' => wfMessage( 'version-api' )->text(),
526                'other' => wfMessage( 'version-other' )->text(),
527            ];
528
529            ( new HookRunner( MediaWikiServices::getInstance()->getHookContainer() ) )
530                ->onExtensionTypes( self::$extensionTypes );
531        }
532
533        return self::$extensionTypes;
534    }
535
536    /**
537     * Returns the internationalized name for an extension type.
538     *
539     * @since 1.17
540     *
541     * @param string $type
542     *
543     * @return string
544     */
545    public static function getExtensionTypeName( $type ) {
546        $types = self::getExtensionTypes();
547
548        return $types[$type] ?? $types['other'];
549    }
550
551    /**
552     * Generate HTML showing the name, URL, author and description of each extension.
553     *
554     * @param array $credits
555     * @return string HTML
556     */
557    private function getExtensionCredits( array $credits ) {
558        $extensionTypes = self::getExtensionTypes();
559
560        $this->addTocSection( 'version-extensions', 'mw-version-ext' );
561
562        $out = Html::element(
563            'h2',
564            [ 'id' => 'mw-version-ext' ],
565            $this->msg( 'version-extensions' )->text()
566        );
567
568        if (
569            !$credits ||
570            // Skins are displayed separately, see getSkinCredits()
571            ( count( $credits ) === 1 && isset( $credits['skin'] ) )
572        ) {
573            $out .= Html::element(
574                'p',
575                [],
576                $this->msg( 'version-extensions-no-ext' )->text()
577            );
578
579            return $out;
580        }
581
582        // Find all extensions that do not have a valid type and give them the type 'other'.
583        $credits['other'] ??= [];
584        foreach ( $credits as $type => $extensions ) {
585            if ( !array_key_exists( $type, $extensionTypes ) ) {
586                $credits['other'] = array_merge( $credits['other'], $extensions );
587            }
588        }
589
590        // Loop through the extension categories to display their extensions in the list.
591        foreach ( $extensionTypes as $type => $text ) {
592            // Skins have a separate section
593            if ( $type !== 'other' && $type !== 'skin' ) {
594                $out .= $this->getExtensionCategory( $type, $text, $credits[$type] ?? [] );
595            }
596        }
597
598        // We want the 'other' type to be last in the list.
599        $out .= $this->getExtensionCategory( 'other', $extensionTypes['other'], $credits['other'] );
600
601        return $out;
602    }
603
604    /**
605     * Generate HTML showing the name, URL, author and description of each skin.
606     *
607     * @param array $credits
608     * @return string HTML
609     */
610    private function getSkinCredits( array $credits ) {
611        $this->addTocSection( 'version-skins', 'mw-version-skin' );
612
613        $out = Html::element(
614            'h2',
615            [ 'id' => 'mw-version-skin' ],
616            $this->msg( 'version-skins' )->text()
617        );
618
619        if ( !isset( $credits['skin'] ) || !$credits['skin'] ) {
620            $out .= Html::element(
621                'p',
622                [],
623                $this->msg( 'version-skins-no-skin' )->text()
624            );
625
626            return $out;
627        }
628        $out .= $this->getExtensionCategory( 'skin', null, $credits['skin'] );
629
630        return $out;
631    }
632
633    /**
634     * Generate the section for installed external libraries
635     *
636     * @param array $credits
637     * @return string
638     */
639    protected function getLibraries( array $credits ) {
640        $this->addTocSection( 'version-libraries', 'mw-version-libraries' );
641
642        $out = Html::element(
643            'h2',
644            [ 'id' => 'mw-version-libraries' ],
645            $this->msg( 'version-libraries' )->text()
646        );
647
648        return $out
649            . $this->getExternalLibraries( $credits )
650            . $this->getClientSideLibraries();
651    }
652
653    /**
654     * @internal
655     * @since 1.44
656     * @param array $credits
657     * @return array
658     */
659    public static function parseComposerInstalled( array $credits ) {
660        $paths = [
661            MW_INSTALL_PATH . '/vendor/composer/installed.json'
662        ];
663
664        $extensionTypes = self::getExtensionTypes();
665        foreach ( $extensionTypes as $type => $message ) {
666            if ( !isset( $credits[$type] ) || $credits[$type] === [] ) {
667                continue;
668            }
669            foreach ( $credits[$type] as $extension ) {
670                if ( !isset( $extension['path'] ) ) {
671                    continue;
672                }
673                $paths[] = dirname( $extension['path'] ) . '/vendor/composer/installed.json';
674            }
675        }
676
677        $dependencies = [];
678
679        foreach ( $paths as $path ) {
680            if ( !file_exists( $path ) ) {
681                continue;
682            }
683
684            $installed = new ComposerInstalled( $path );
685
686            $dependencies += $installed->getInstalledDependencies();
687        }
688
689        ksort( $dependencies );
690        return $dependencies;
691    }
692
693    /**
694     * Generate an HTML table for external libraries that are installed
695     *
696     * @param array $credits
697     * @return string
698     */
699    protected function getExternalLibraries( array $credits ) {
700        $dependencies = self::parseComposerInstalled( $credits );
701        if ( $dependencies === [] ) {
702            return '';
703        }
704
705        $this->addTocSubSection( $this->msg( 'version-libraries-server' )->text(), 'mw-version-libraries-server' );
706
707        $out = Html::element(
708            'h3',
709            [ 'id' => 'mw-version-libraries-server' ],
710            $this->msg( 'version-libraries-server' )->text()
711        );
712        $out .= Html::openElement(
713            'table',
714            [ 'class' => 'wikitable plainlinks mw-installed-software', 'id' => 'sv-libraries' ]
715        );
716
717        $out .= $this->getTableHeaderHtml( [
718            $this->msg( 'version-libraries-library' )->text(),
719            $this->msg( 'version-libraries-version' )->text(),
720            $this->msg( 'version-libraries-license' )->text(),
721            $this->msg( 'version-libraries-description' )->text(),
722            $this->msg( 'version-libraries-authors' )->text(),
723        ] );
724
725        foreach ( $dependencies as $name => $info ) {
726            if ( !is_array( $info ) || str_starts_with( $info['type'], 'mediawiki-' ) ) {
727                // Skip any extensions or skins since they'll be listed
728                // in their proper section
729                continue;
730            }
731            $authors = array_map( static function ( $arr ) {
732                return new HtmlArmor( isset( $arr['homepage'] ) ?
733                    Html::element( 'a', [ 'href' => $arr['homepage'] ], $arr['name'] ) :
734                    htmlspecialchars( $arr['name'] )
735                );
736            }, $info['authors'] );
737            $authors = $this->listAuthors( $authors, false, MW_INSTALL_PATH . "/vendor/$name" );
738
739            // We can safely assume that the libraries' names and descriptions
740            // are written in English and aren't going to be translated,
741            // so set appropriate lang and dir attributes
742            $out .= Html::openElement( 'tr', [
743                // Add an anchor so docs can link easily to the version of
744                // this specific library
745                'id' => Sanitizer::escapeIdForAttribute(
746                    "mw-version-library-$name"
747                ) ] )
748                . Html::rawElement(
749                    'td',
750                    [],
751                    $this->getLinkRenderer()->makeExternalLink(
752                        "https://packagist.org/packages/$name",
753                        $name,
754                        $this->getFullTitle(),
755                        '',
756                        [ 'class' => 'mw-version-library-name' ]
757                    )
758                )
759                . Html::element( 'td', [ 'dir' => 'auto' ], $info['version'] )
760                // @phan-suppress-next-line SecurityCheck-DoubleEscaped See FIXME in listToText
761                . Html::element( 'td', [ 'dir' => 'auto' ], $this->listToText( $info['licenses'] ) )
762                . Html::element( 'td', [ 'lang' => 'en', 'dir' => 'ltr' ], $info['description'] )
763                . Html::rawElement( 'td', [], $authors )
764                . Html::closeElement( 'tr' );
765        }
766        $out .= Html::closeElement( 'table' );
767
768        return $out;
769    }
770
771    /**
772     * @internal
773     * @since 1.42
774     * @return array
775     */
776    public static function parseForeignResources() {
777        $registryDirs = [ 'MediaWiki' => MW_INSTALL_PATH . '/resources/lib' ]
778            + ExtensionRegistry::getInstance()->getAttribute( 'ForeignResourcesDir' );
779
780        $modules = [];
781        foreach ( $registryDirs as $source => $registryDir ) {
782            $foreignResources = Yaml::parseFile( "$registryDir/foreign-resources.yaml" );
783            foreach ( $foreignResources as $name => $module ) {
784                $key = $name . $module['version'];
785                if ( isset( $modules[$key] ) ) {
786                    $modules[$key]['source'][] = $source;
787                    continue;
788                }
789                $modules[$key] = $module + [ 'name' => $name, 'source' => [ $source ] ];
790            }
791        }
792        ksort( $modules );
793        return $modules;
794    }
795
796    /**
797     * Generate an HTML table for client-side libraries that are installed
798     *
799     * @return string HTML output
800     */
801    private function getClientSideLibraries() {
802        $this->addTocSubSection( $this->msg( 'version-libraries-client' )->text(), 'mw-version-libraries-client' );
803
804        $out = Html::element(
805            'h3',
806            [ 'id' => 'mw-version-libraries-client' ],
807            $this->msg( 'version-libraries-client' )->text()
808        );
809        $out .= Html::openElement(
810            'table',
811            [ 'class' => 'wikitable plainlinks mw-installed-software', 'id' => 'sv-libraries-client' ]
812        );
813
814        $out .= $this->getTableHeaderHtml( [
815            $this->msg( 'version-libraries-library' )->text(),
816            $this->msg( 'version-libraries-version' )->text(),
817            $this->msg( 'version-libraries-license' )->text(),
818            $this->msg( 'version-libraries-authors' )->text(),
819            $this->msg( 'version-libraries-source' )->text()
820        ] );
821
822        foreach ( self::parseForeignResources() as $name => $info ) {
823            // We can safely assume that the libraries' names and descriptions
824            // are written in English and aren't going to be translated,
825            // so set appropriate lang and dir attributes
826            $out .= Html::openElement( 'tr', [
827                // Add an anchor so docs can link easily to the version of
828                // this specific library
829                'id' => Sanitizer::escapeIdForAttribute(
830                    "mw-version-library-$name"
831                ) ] )
832                . Html::rawElement(
833                    'td',
834                    [],
835                    $this->getLinkRenderer()->makeExternalLink(
836                        $info['homepage'],
837                        $info['name'],
838                        $this->getFullTitle(),
839                        '',
840                        [ 'class' => 'mw-version-library-name' ]
841                    )
842                )
843                . Html::element( 'td', [ 'dir' => 'auto' ], $info['version'] )
844                . Html::element( 'td', [ 'dir' => 'auto' ], $info['license'] )
845                . Html::element( 'td', [ 'dir' => 'auto' ], $info['authors'] ?? '—' )
846                // @phan-suppress-next-line SecurityCheck-DoubleEscaped See FIXME in listToText
847                . Html::element( 'td', [ 'dir' => 'auto' ], $this->listToText( $info['source'] ) )
848                . Html::closeElement( 'tr' );
849        }
850        $out .= Html::closeElement( 'table' );
851
852        return $out;
853    }
854
855    /**
856     * Obtains a list of installed parser tags and the associated H2 header
857     *
858     * @return string HTML output
859     */
860    protected function getParserTags() {
861        $tags = $this->parserFactory->getMainInstance()->getTags();
862        if ( !$tags ) {
863            return '';
864        }
865
866        $this->addTocSection( 'version-parser-extensiontags', 'mw-version-parser-extensiontags' );
867
868        $out = Html::rawElement(
869            'h2',
870            [ 'id' => 'mw-version-parser-extensiontags' ],
871            Html::rawElement(
872                'span',
873                [ 'class' => 'plainlinks' ],
874                $this->getLinkRenderer()->makeExternalLink(
875                    'https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Tag_extensions',
876                    $this->msg( 'version-parser-extensiontags' ),
877                    $this->getFullTitle()
878                )
879            )
880        );
881
882        array_walk( $tags, static function ( &$value ) {
883            // Bidirectional isolation improves readability in RTL wikis
884            $value = Html::rawElement(
885                'bdi',
886                // Prevent < and > from slipping to another line
887                [
888                    'style' => 'white-space: nowrap;',
889                ],
890                Html::element( 'code', [], "<$value>" )
891            );
892        } );
893
894        $out .= $this->listToText( $tags );
895
896        return $out;
897    }
898
899    /**
900     * Obtains a list of installed parser function hooks and the associated H2 header
901     *
902     * @return string HTML output
903     */
904    protected function getParserFunctionHooks() {
905        $funcHooks = $this->parserFactory->getMainInstance()->getFunctionHooks();
906        if ( !$funcHooks ) {
907            return '';
908        }
909
910        $this->addTocSection( 'version-parser-function-hooks', 'mw-version-parser-function-hooks' );
911
912        $out = Html::rawElement(
913            'h2',
914            [ 'id' => 'mw-version-parser-function-hooks' ],
915            Html::rawElement(
916                'span',
917                [ 'class' => 'plainlinks' ],
918                $this->getLinkRenderer()->makeExternalLink(
919                    'https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Parser_functions',
920                    $this->msg( 'version-parser-function-hooks' ),
921                    $this->getFullTitle()
922                )
923            )
924        );
925
926        $funcSynonyms = $this->parserFactory->getMainInstance()->getFunctionSynonyms();
927        // This will give us the preferred synonyms in the content language, as if
928        // we used MagicWord::getSynonym( 0 ), because they appear first in the arrays.
929        // We can't use MagicWord directly, because only Parser knows whether a function
930        // uses the leading "#" or not. Case-sensitive functions ("1") win over
931        // case-insensitive ones ("0"), like in Parser::callParserFunction().
932        // There should probably be a better API for this.
933        $preferredSynonyms = array_flip( array_reverse( $funcSynonyms[1] + $funcSynonyms[0] ) );
934        array_walk( $funcHooks, static function ( &$value ) use ( $preferredSynonyms ) {
935            $value = $preferredSynonyms[$value];
936        } );
937        $legacyHooks = array_flip( $funcHooks );
938
939        // Sort case-insensitively, ignoring the leading '#' if present
940        $cmpHooks = static function ( $a, $b ) {
941            return strcasecmp( ltrim( $a, '#' ), ltrim( $b, '#' ) );
942        };
943        usort( $funcHooks, $cmpHooks );
944
945        $formatHooks = static function ( &$value ) {
946            // Bidirectional isolation ensures it displays as {{#ns}} and not {{ns#}} in RTL wikis
947            $value = Html::rawElement(
948                'bdi',
949                [],
950                Html::element( 'code', [], '{{' . $value . '}}' )
951            );
952        };
953        array_walk( $funcHooks, $formatHooks );
954
955        $out .= $this->getLanguage()->listToText( $funcHooks );
956
957        # Get a list of parser functions from Parsoid as well.
958        $parsoidHooks = [];
959        $services = MediaWikiServices::getInstance();
960        $siteConfig = $services->getParsoidSiteConfig();
961        $magicWordFactory = $services->getMagicWordFactory();
962        foreach ( $siteConfig->getPFragmentHandlerKeys() as $key ) {
963            $config = $siteConfig->getPFragmentHandlerConfig( $key );
964            if ( !( $config['options']['parserFunction'] ?? false ) ) {
965                continue;
966            }
967            $mw = $magicWordFactory->get( $key );
968            foreach ( $mw->getSynonyms() as $local ) {
969                if ( !( $config['options']['nohash'] ?? false ) ) {
970                    $local = '#' . $local;
971                }
972                // Skip hooks already present in legacy hooks (they will
973                // also work in parsoid)
974                if ( isset( $legacyHooks[$local] ) ) {
975                    continue;
976                }
977                $parsoidHooks[] = $local;
978            }
979        }
980        if ( $parsoidHooks ) {
981            $out .= Html::element(
982                'h3',
983                [ 'id' => 'mw-version-parser-function-hooks-parsoid' ],
984                $this->msg( 'version-parser-function-hooks-parsoid' )->text()
985            );
986            usort( $parsoidHooks, $cmpHooks );
987            array_walk( $parsoidHooks, $formatHooks );
988            $out .= $this->getLanguage()->listToText( $parsoidHooks );
989        }
990
991        return $out;
992    }
993
994    /**
995     * Obtains a list of installed Parsoid Modules and the associated H2 header
996     *
997     * @return string HTML output
998     */
999    protected function getParsoidModules() {
1000        $siteConfig = MediaWikiServices::getInstance()->getParsoidSiteConfig();
1001        $modules = $siteConfig->getExtensionModules();
1002
1003        if ( !$modules ) {
1004            return '';
1005        }
1006
1007        $this->addTocSection( 'version-parsoid-modules', 'mw-version-parsoid-modules' );
1008
1009        $out = Html::rawElement(
1010            'h2',
1011            [ 'id' => 'mw-version-parsoid-modules' ],
1012            Html::rawElement(
1013                'span',
1014                [ 'class' => 'plainlinks' ],
1015                $this->getLinkRenderer()->makeExternalLink(
1016                    'https://www.mediawiki.org/wiki/Special:MyLanguage/Parsoid',
1017                    $this->msg( 'version-parsoid-modules' ),
1018                    $this->getFullTitle()
1019                )
1020            )
1021        );
1022
1023        $moduleNames = array_map(
1024            static fn ( $m )=>Html::element( 'code', [], $m->getConfig()['name'] ),
1025            $modules
1026        );
1027
1028        $out .= $this->getLanguage()->listToText( $moduleNames );
1029
1030        return $out;
1031    }
1032
1033    /**
1034     * Creates and returns the HTML for a single extension category.
1035     *
1036     * @since 1.17
1037     * @param string $type
1038     * @param string|null $text
1039     * @param array $creditsGroup
1040     * @return string
1041     */
1042    protected function getExtensionCategory( $type, ?string $text, array $creditsGroup ) {
1043        $out = '';
1044
1045        if ( $creditsGroup ) {
1046            $out .= $this->openExtType( $text, 'credits-' . $type );
1047
1048            usort( $creditsGroup, [ $this, 'compare' ] );
1049
1050            foreach ( $creditsGroup as $extension ) {
1051                $out .= $this->getCreditsForExtension( $type, $extension );
1052            }
1053
1054            $out .= Html::closeElement( 'table' );
1055        }
1056
1057        return $out;
1058    }
1059
1060    /**
1061     * Callback to sort extensions by type.
1062     * @param array $a
1063     * @param array $b
1064     * @return int
1065     */
1066    public function compare( $a, $b ) {
1067        return $this->getLanguage()->lc( $a['name'] ) <=> $this->getLanguage()->lc( $b['name'] );
1068    }
1069
1070    /**
1071     * Creates and formats a version line for a single extension.
1072     *
1073     * Information for five columns will be created. Parameters required in the
1074     * $extension array for part rendering are indicated in ()
1075     *  - The name of (name), and URL link to (url), the extension
1076     *  - Official version number (version) and if available version control system
1077     *    revision (path), link, and date
1078     *  - If available the short name of the license (license-name) and a link
1079     *    to ((LICENSE)|(COPYING))(\.txt)? if it exists.
1080     *  - Description of extension (descriptionmsg or description)
1081     *  - List of authors (author) and link to a ((AUTHORS)|(CREDITS))(\.txt)? file if it exists
1082     *
1083     * @param string $type Category name of the extension
1084     * @param array $extension
1085     *
1086     * @return string Raw HTML
1087     */
1088    public function getCreditsForExtension( $type, array $extension ) {
1089        $out = $this->getOutput();
1090
1091        // We must obtain the information for all the bits and pieces!
1092        // ... such as extension names and links
1093        if ( isset( $extension['namemsg'] ) ) {
1094            // Localized name of extension
1095            $extensionName = $this->msg( $extension['namemsg'] );
1096        } elseif ( isset( $extension['name'] ) ) {
1097            // Non localized version
1098            $extensionName = $extension['name'];
1099        } else {
1100            $extensionName = $this->msg( 'version-no-ext-name' );
1101        }
1102
1103        if ( isset( $extension['url'] ) ) {
1104            $extensionNameLink = $this->getLinkRenderer()->makeExternalLink(
1105                $extension['url'],
1106                $extensionName,
1107                $this->getFullTitle(),
1108                '',
1109                [ 'class' => 'mw-version-ext-name' ]
1110            );
1111        } else {
1112            $extensionNameLink = htmlspecialchars( $extensionName );
1113        }
1114
1115        // ... and the version information
1116        // If the extension path is set we will check that directory for GIT
1117        // metadata in an attempt to extract date and vcs commit metadata.
1118        $canonicalVersion = '&ndash;';
1119        $extensionPath = null;
1120        $vcsVersion = null;
1121        $vcsLink = null;
1122        $vcsDate = null;
1123
1124        if ( isset( $extension['version'] ) ) {
1125            $canonicalVersion = $out->parseInlineAsInterface( $extension['version'] );
1126        }
1127
1128        if ( isset( $extension['path'] ) ) {
1129            $extensionPath = dirname( $extension['path'] );
1130            if ( $this->coreId == '' ) {
1131                wfDebug( 'Looking up core head id' );
1132                $coreHeadSHA1 = GitInfo::repo()->getHeadSHA1();
1133                if ( $coreHeadSHA1 ) {
1134                    $this->coreId = $coreHeadSHA1;
1135                }
1136            }
1137            $cache = MediaWikiServices::getInstance()->getObjectCacheFactory()->getInstance( CACHE_ANYTHING );
1138            $memcKey = $cache->makeKey(
1139                'specialversion-ext-version-text', $extension['path'], $this->coreId
1140            );
1141            [ $vcsVersion, $vcsLink, $vcsDate ] = $cache->get( $memcKey );
1142
1143            if ( !$vcsVersion ) {
1144                wfDebug( "Getting VCS info for extension {$extension['name']}" );
1145                $gitInfo = new GitInfo( $extensionPath );
1146                $vcsVersion = $gitInfo->getHeadSHA1();
1147                if ( $vcsVersion !== false ) {
1148                    $vcsVersion = substr( $vcsVersion, 0, 7 );
1149                    $vcsLink = $gitInfo->getHeadViewUrl();
1150                    $vcsDate = $gitInfo->getHeadCommitDate();
1151                }
1152                $cache->set( $memcKey, [ $vcsVersion, $vcsLink, $vcsDate ], 60 * 60 * 24 );
1153            } else {
1154                wfDebug( "Pulled VCS info for extension {$extension['name']} from cache" );
1155            }
1156        }
1157
1158        $versionString = Html::rawElement(
1159            'span',
1160            [ 'class' => 'mw-version-ext-version' ],
1161            $canonicalVersion
1162        );
1163
1164        if ( $vcsVersion ) {
1165            if ( $vcsLink ) {
1166                $vcsVerString = $this->getLinkRenderer()->makeExternalLink(
1167                    $vcsLink,
1168                    $this->msg( 'version-version', $vcsVersion ),
1169                    $this->getFullTitle(),
1170                    '',
1171                    [ 'class' => 'mw-version-ext-vcs-version' ]
1172                );
1173            } else {
1174                $vcsVerString = Html::element( 'span',
1175                    [ 'class' => 'mw-version-ext-vcs-version' ],
1176                    "({$vcsVersion})"
1177                );
1178            }
1179            $versionString .= " {$vcsVerString}";
1180
1181            if ( $vcsDate ) {
1182                $versionString .= ' ' . Html::element( 'span', [
1183                    'class' => 'mw-version-ext-vcs-timestamp',
1184                    'dir' => $this->getLanguage()->getDir(),
1185                ], $this->getLanguage()->timeanddate( $vcsDate, true ) );
1186            }
1187            $versionString = Html::rawElement( 'span',
1188                [ 'class' => 'mw-version-ext-meta-version' ],
1189                $versionString
1190            );
1191        }
1192
1193        // ... and license information; if a license file exists we
1194        // will link to it
1195        $licenseLink = '';
1196        if ( isset( $extension['name'] ) ) {
1197            $licenseName = null;
1198            if ( isset( $extension['license-name'] ) ) {
1199                $licenseName = new HtmlArmor( $out->parseInlineAsInterface( $extension['license-name'] ) );
1200            } elseif ( $extensionPath !== null && ExtensionInfo::getLicenseFileNames( $extensionPath ) ) {
1201                $licenseName = $this->msg( 'version-ext-license' )->text();
1202            }
1203            if ( $licenseName !== null ) {
1204                $licenseLink = $this->getLinkRenderer()->makeLink(
1205                    $this->getPageTitle( 'License/' . $extension['name'] ),
1206                    $licenseName,
1207                    [
1208                        'class' => 'mw-version-ext-license',
1209                        'dir' => 'auto',
1210                    ]
1211                );
1212            }
1213        }
1214
1215        // ... and generate the description; which can be a parameterized l10n message
1216        // in the form [ <msgname>, <parameter>, <parameter>... ] or just a straight
1217        // up string
1218        if ( isset( $extension['descriptionmsg'] ) ) {
1219            // Localized description of extension
1220            $descriptionMsg = $extension['descriptionmsg'];
1221
1222            if ( is_array( $descriptionMsg ) ) {
1223                $descriptionMsgKey = array_shift( $descriptionMsg );
1224                $descriptionMsg = array_map( 'htmlspecialchars', $descriptionMsg );
1225                $description = $this->msg( $descriptionMsgKey, ...$descriptionMsg )->text();
1226            } else {
1227                $description = $this->msg( $descriptionMsg )->text();
1228            }
1229        } elseif ( isset( $extension['description'] ) ) {
1230            // Non localized version
1231            $description = $extension['description'];
1232        } else {
1233            $description = '';
1234        }
1235        $description = $out->parseInlineAsInterface( $description );
1236
1237        // ... now get the authors for this extension
1238        $authors = $extension['author'] ?? [];
1239        // @phan-suppress-next-line PhanTypeMismatchArgumentNullable path is set when there is a name
1240        $authors = $this->listAuthors( $authors, $extension['name'], $extensionPath );
1241
1242        // Finally! Create the table
1243        $html = Html::openElement( 'tr', [
1244                'class' => 'mw-version-ext',
1245                'id' => Sanitizer::escapeIdForAttribute( 'mw-version-ext-' . $type . '-' . $extension['name'] )
1246            ]
1247        );
1248
1249        $html .= Html::rawElement( 'td', [], $extensionNameLink );
1250        $html .= Html::rawElement( 'td', [], $versionString );
1251        $html .= Html::rawElement( 'td', [], $licenseLink );
1252        $html .= Html::rawElement( 'td', [ 'class' => 'mw-version-ext-description' ], $description );
1253        $html .= Html::rawElement( 'td', [ 'class' => 'mw-version-ext-authors' ], $authors );
1254
1255        $html .= Html::closeElement( 'tr' );
1256
1257        return $html;
1258    }
1259
1260    /**
1261     * Generate HTML showing hooks in $wgHooks.
1262     *
1263     * @return string HTML
1264     */
1265    private function getHooks() {
1266        if ( !$this->getConfig()->get( MainConfigNames::SpecialVersionShowHooks ) ) {
1267            return '';
1268        }
1269
1270        $hookContainer = MediaWikiServices::getInstance()->getHookContainer();
1271        $hookNames = $hookContainer->getHookNames();
1272
1273        if ( !$hookNames ) {
1274            return '';
1275        }
1276
1277        sort( $hookNames );
1278
1279        $ret = [];
1280        $this->addTocSection( 'version-hooks', 'mw-version-hooks' );
1281        $ret[] = Html::element(
1282            'h2',
1283            [ 'id' => 'mw-version-hooks' ],
1284            $this->msg( 'version-hooks' )->text()
1285        );
1286        $ret[] = Html::openElement( 'table', [ 'class' => 'wikitable', 'id' => 'sv-hooks' ] );
1287        $ret[] = Html::openElement( 'tr' );
1288        $ret[] = Html::element( 'th', [], $this->msg( 'version-hook-name' )->text() );
1289        $ret[] = Html::element( 'th', [], $this->msg( 'version-hook-subscribedby' )->text() );
1290        $ret[] = Html::closeElement( 'tr' );
1291
1292        foreach ( $hookNames as $name ) {
1293            $handlers = $hookContainer->getHandlerDescriptions( $name );
1294
1295            $ret[] = Html::openElement( 'tr' );
1296            $ret[] = Html::element( 'td', [], $name );
1297            // @phan-suppress-next-line SecurityCheck-DoubleEscaped See FIXME in listToText
1298            $ret[] = Html::element( 'td', [], $this->listToText( $handlers ) );
1299            $ret[] = Html::closeElement( 'tr' );
1300        }
1301
1302        $ret[] = Html::closeElement( 'table' );
1303
1304        return implode( "\n", $ret );
1305    }
1306
1307    private function openExtType( ?string $text = null, ?string $name = null ): string {
1308        $out = '';
1309
1310        $opt = [ 'class' => 'wikitable plainlinks mw-installed-software' ];
1311
1312        if ( $name ) {
1313            $opt['id'] = "sv-$name";
1314        }
1315
1316        $out .= Html::openElement( 'table', $opt );
1317
1318        if ( $text !== null ) {
1319            $out .= Html::element( 'caption', [], $text );
1320        }
1321
1322        if ( $name && $text !== null ) {
1323            $this->addTocSubSection( $text, "sv-$name" );
1324        }
1325
1326        $firstHeadingMsg = ( $name === 'credits-skin' )
1327            ? 'version-skin-colheader-name'
1328            : 'version-ext-colheader-name';
1329
1330        $out .= $this->getTableHeaderHtml( [
1331            $this->msg( $firstHeadingMsg )->text(),
1332            $this->msg( 'version-ext-colheader-version' )->text(),
1333            $this->msg( 'version-ext-colheader-license' )->text(),
1334            $this->msg( 'version-ext-colheader-description' )->text(),
1335            $this->msg( 'version-ext-colheader-credits' )->text()
1336        ] );
1337
1338        return $out;
1339    }
1340
1341    /**
1342     * Return HTML for a table header with given texts in header cells
1343     *
1344     * Includes thead element and scope="col" attribute for improved accessibility
1345     *
1346     * @param string|array $headers
1347     * @return string HTML
1348     */
1349    private function getTableHeaderHtml( $headers ): string {
1350        $out = '';
1351        $out .= Html::openElement( 'thead' );
1352        $out .= Html::openElement( 'tr' );
1353        foreach ( $headers as $header ) {
1354            $out .= Html::element( 'th', [ 'scope' => 'col' ], $header );
1355        }
1356        $out .= Html::closeElement( 'tr' );
1357        $out .= Html::closeElement( 'thead' );
1358        return $out;
1359    }
1360
1361    /**
1362     * Get information about client's IP address.
1363     *
1364     * @return string HTML fragment
1365     */
1366    private function IPInfo() {
1367        $ip = str_replace( '--', ' - ', htmlspecialchars( $this->getRequest()->getIP() ) );
1368
1369        return "<!-- visited from $ip -->\n<span style='display:none'>visited from $ip</span>";
1370    }
1371
1372    /**
1373     * Return a formatted unsorted list of authors
1374     *
1375     * 'And Others'
1376     *   If an item in the $authors array is '...' it is assumed to indicate an
1377     *   'and others' string which will then be linked to an ((AUTHORS)|(CREDITS))(\.txt)?
1378     *   file if it exists in $dir.
1379     *
1380     *   Similarly an entry ending with ' ...]' is assumed to be a link to an
1381     *   'and others' page.
1382     *
1383     *   If no '...' string variant is found, but an authors file is found an
1384     *   'and others' will be added to the end of the credits.
1385     *
1386     * @param string|array $authors
1387     * @param string|bool $extName Name of the extension for link creation,
1388     *   false if no links should be created
1389     * @param string $extDir Path to the extension root directory
1390     * @return string HTML fragment
1391     */
1392    public function listAuthors( $authors, $extName, $extDir ): string {
1393        $hasOthers = false;
1394        $linkRenderer = $this->getLinkRenderer();
1395
1396        $list = [];
1397        $authors = (array)$authors;
1398
1399        // Special case: if the authors array has only one item and it is "...",
1400        // it should not be rendered as the "version-poweredby-others" i18n msg,
1401        // but rather as "version-poweredby-various" i18n msg instead.
1402        if ( count( $authors ) === 1 && $authors[0] === '...' ) {
1403            // Link to the extension's or skin's AUTHORS or CREDITS file, if there is
1404            // such a file; otherwise just return the i18n msg as-is
1405            if ( $extName && ExtensionInfo::getAuthorsFileName( $extDir ) ) {
1406                return $linkRenderer->makeLink(
1407                    $this->getPageTitle( "Credits/$extName" ),
1408                    $this->msg( 'version-poweredby-various' )->text()
1409                );
1410            } else {
1411                return $this->msg( 'version-poweredby-various' )->escaped();
1412            }
1413        }
1414
1415        // Otherwise, if we have an actual array that has more than one item,
1416        // process each array item as usual
1417        foreach ( $authors as $item ) {
1418            if ( $item instanceof HtmlArmor ) {
1419                $list[] = HtmlArmor::getHtml( $item );
1420            } elseif ( $item === '...' ) {
1421                $hasOthers = true;
1422
1423                if ( $extName && ExtensionInfo::getAuthorsFileName( $extDir ) ) {
1424                    $text = $linkRenderer->makeLink(
1425                        $this->getPageTitle( "Credits/$extName" ),
1426                        $this->msg( 'version-poweredby-others' )->text()
1427                    );
1428                } else {
1429                    $text = $this->msg( 'version-poweredby-others' )->escaped();
1430                }
1431                $list[] = $text;
1432            } elseif ( str_ends_with( $item, ' ...]' ) ) {
1433                $hasOthers = true;
1434                $list[] = $this->getOutput()->parseInlineAsInterface(
1435                    substr( $item, 0, -4 ) . $this->msg( 'version-poweredby-others' )->text() . "]"
1436                );
1437            } else {
1438                $list[] = $this->getOutput()->parseInlineAsInterface( $item );
1439            }
1440        }
1441
1442        if ( $extName && !$hasOthers && ExtensionInfo::getAuthorsFileName( $extDir ) ) {
1443            $list[] = $linkRenderer->makeLink(
1444                $this->getPageTitle( "Credits/$extName" ),
1445                $this->msg( 'version-poweredby-others' )->text()
1446            );
1447        }
1448
1449        return $this->listToText( $list, false );
1450    }
1451
1452    /**
1453     * Convert an array of items into a list for display.
1454     *
1455     * @param array $list List of elements to display
1456     * @param bool $sort Whether to sort the items in $list
1457     * @return string
1458     * @fixme This method does not handle escaping consistently. Language::listToText expects all list elements to be
1459     * already escaped. However, self::arrayToString escapes some elements, but not others.
1460     */
1461    private function listToText( array $list, bool $sort = true ): string {
1462        if ( !$list ) {
1463            return '';
1464        }
1465        if ( $sort ) {
1466            sort( $list );
1467        }
1468
1469        return $this->getLanguage()
1470            ->listToText( array_map( [ self::class, 'arrayToString' ], $list ) );
1471    }
1472
1473    /**
1474     * Convert an array or object to a string for display.
1475     *
1476     * @internal For use by ApiQuerySiteinfo (TODO: Turn into more stable method)
1477     * @param mixed $list Will convert an array to string if given and return
1478     *   the parameter unaltered otherwise
1479     * @return mixed
1480     * @fixme This should handle escaping more consistently, see FIXME in listToText
1481     */
1482    public static function arrayToString( $list ) {
1483        if ( is_array( $list ) && count( $list ) == 1 ) {
1484            $list = $list[0];
1485        }
1486        if ( $list instanceof Closure ) {
1487            // Don't output stuff like "Closure$;1028376090#8$48499d94fe0147f7c633b365be39952b$"
1488            return 'Closure';
1489        } elseif ( is_object( $list ) ) {
1490            return wfMessage( 'parentheses' )->params( get_class( $list ) )->escaped();
1491        } elseif ( !is_array( $list ) ) {
1492            return $list;
1493        } else {
1494            if ( is_object( $list[0] ) ) {
1495                $class = get_class( $list[0] );
1496            } else {
1497                $class = $list[0];
1498            }
1499
1500            return wfMessage( 'parentheses' )->params( "$class{$list[1]}" )->escaped();
1501        }
1502    }
1503
1504    /**
1505     * @deprecated since 1.41 Use GitInfo::repo() for MW_INSTALL_PATH, or new GitInfo otherwise.
1506     * @param string $dir Directory of the git checkout
1507     * @return string|false Sha1 of commit HEAD points to
1508     */
1509    public static function getGitHeadSha1( $dir ) {
1510        wfDeprecated( __METHOD__, '1.41' );
1511        return ( new GitInfo( $dir ) )->getHeadSHA1();
1512    }
1513
1514    /**
1515     * Get the list of entry points and their URLs
1516     * @return string HTML
1517     */
1518    public function getEntryPointInfo() {
1519        $config = $this->getConfig();
1520        $scriptPath = $config->get( MainConfigNames::ScriptPath ) ?: '/';
1521
1522        $entryPoints = [
1523            'version-entrypoints-articlepath' => $config->get( MainConfigNames::ArticlePath ),
1524            'version-entrypoints-scriptpath' => $scriptPath,
1525            'version-entrypoints-index-php' => wfScript( 'index' ),
1526            'version-entrypoints-api-php' => wfScript( 'api' ),
1527            'version-entrypoints-rest-php' => wfScript( 'rest' ),
1528        ];
1529
1530        $language = $this->getLanguage();
1531        $thAttributes = [
1532            'dir' => $language->getDir(),
1533            'lang' => $language->getHtmlCode(),
1534            'scope' => 'col'
1535        ];
1536
1537        $this->addTocSection( 'version-entrypoints', 'mw-version-entrypoints' );
1538
1539        $out = Html::element(
1540                'h2',
1541                [ 'id' => 'mw-version-entrypoints' ],
1542                $this->msg( 'version-entrypoints' )->text()
1543            ) .
1544            Html::openElement( 'table',
1545                [
1546                    'class' => 'wikitable plainlinks',
1547                    'id' => 'mw-version-entrypoints-table',
1548                    'dir' => 'ltr',
1549                    'lang' => 'en'
1550                ]
1551            ) .
1552            Html::openElement( 'thead' ) .
1553            Html::openElement( 'tr' ) .
1554            Html::element(
1555                'th',
1556                $thAttributes,
1557                $this->msg( 'version-entrypoints-header-entrypoint' )->text()
1558            ) .
1559            Html::element(
1560                'th',
1561                $thAttributes,
1562                $this->msg( 'version-entrypoints-header-url' )->text()
1563            ) .
1564            Html::closeElement( 'tr' ) .
1565            Html::closeElement( 'thead' );
1566
1567        foreach ( $entryPoints as $message => $value ) {
1568            $url = $this->urlUtils->expand( $value, PROTO_RELATIVE );
1569            $out .= Html::openElement( 'tr' ) .
1570                Html::rawElement( 'td', [], $this->msg( $message )->parse() ) .
1571                Html::rawElement( 'td', [],
1572                    Html::rawElement(
1573                        'code',
1574                        [],
1575                        $this->msg( new RawMessage( "[$url $value]" ) )->parse()
1576                    )
1577                ) .
1578                Html::closeElement( 'tr' );
1579        }
1580
1581        $out .= Html::closeElement( 'table' );
1582
1583        return $out;
1584    }
1585
1586    protected function getGroupName() {
1587        return 'wiki';
1588    }
1589}
1590
1591/**
1592 * Retain the old class name for backwards compatibility.
1593 * @deprecated since 1.41
1594 */
1595class_alias( SpecialVersion::class, 'SpecialVersion' );