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