3 * Copyright © 2005 Ævar Arnfjörð Bjarmason
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.
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.
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
23 namespace MediaWiki\Specials
;
27 use MediaWiki\Config\Config
;
28 use MediaWiki\HookContainer\HookRunner
;
29 use MediaWiki\Html\Html
;
30 use MediaWiki\Language\Language
;
31 use MediaWiki\Language\RawMessage
;
32 use MediaWiki\MainConfigNames
;
33 use MediaWiki\MediaWikiServices
;
34 use MediaWiki\Message\Message
;
35 use MediaWiki\Parser\Parser
;
36 use MediaWiki\Parser\ParserFactory
;
37 use MediaWiki\Parser\ParserOutput
;
38 use MediaWiki\Parser\ParserOutputFlags
;
39 use MediaWiki\Parser\Sanitizer
;
40 use MediaWiki\Registration\ExtensionRegistry
;
41 use MediaWiki\SpecialPage\SpecialPage
;
42 use MediaWiki\Utils\ExtensionInfo
;
43 use MediaWiki\Utils\GitInfo
;
44 use MediaWiki\Utils\MWTimestamp
;
45 use MediaWiki\Utils\UrlUtils
;
46 use Symfony\Component\Yaml\Yaml
;
47 use Wikimedia\Composer\ComposerInstalled
;
48 use Wikimedia\Parsoid\Core\SectionMetadata
;
49 use Wikimedia\Parsoid\Core\TOCData
;
50 use Wikimedia\Rdbms\IConnectionProvider
;
53 * Version information about MediaWiki (core, extensions, libs), PHP, and the database.
55 * @ingroup SpecialPage
57 class SpecialVersion
extends SpecialPage
{
60 * @var string The current rev id/SHA hash of MediaWiki core
62 protected $coreId = '';
65 * @var string[]|false Lazy initialized key/value with message content
67 protected static $extensionTypes = false;
76 protected $tocSection;
79 protected $tocSubSection;
81 private ParserFactory
$parserFactory;
82 private UrlUtils
$urlUtils;
83 private IConnectionProvider
$dbProvider;
86 * @param ParserFactory $parserFactory
87 * @param UrlUtils $urlUtils
88 * @param IConnectionProvider $dbProvider
90 public function __construct(
91 ParserFactory
$parserFactory,
93 IConnectionProvider
$dbProvider
95 parent
::__construct( 'Version' );
96 $this->parserFactory
= $parserFactory;
97 $this->urlUtils
= $urlUtils;
98 $this->dbProvider
= $dbProvider;
103 * @param ExtensionRegistry $reg
104 * @param Config $conf For additional entries from $wgExtensionCredits.
106 * @see $wgExtensionCredits
108 public static function getCredits( ExtensionRegistry
$reg, Config
$conf ): array {
109 $credits = $conf->get( MainConfigNames
::ExtensionCredits
);
110 foreach ( $reg->getAllThings() as $credit ) {
111 $credits[$credit['type']][] = $credit;
117 * @param string|null $par
119 public function execute( $par ) {
120 $config = $this->getConfig();
121 $credits = self
::getCredits( ExtensionRegistry
::getInstance(), $config );
124 $this->outputHeader();
125 $out = $this->getOutput();
126 $out->getMetadata()->setPreventClickjacking( false );
128 // Explode the subpage information into useful bits
129 $parts = explode( '/', (string)$par );
131 if ( isset( $parts[1] ) ) {
132 $extName = str_replace( '_', ' ', $parts[1] );
134 foreach ( $credits as $extensions ) {
135 foreach ( $extensions as $ext ) {
136 if ( isset( $ext['name'] ) && ( $ext['name'] === $extName ) ) {
143 $out->setStatusCode( 404 );
146 $extName = 'MediaWiki';
149 // Now figure out what to do
150 switch ( strtolower( $parts[0] ) ) {
152 $out->addModuleStyles( 'mediawiki.special' );
154 $wikiText = '{{int:version-credits-not-found}}';
155 if ( $extName === 'MediaWiki' ) {
156 $wikiText = file_get_contents( MW_INSTALL_PATH
. '/CREDITS' );
157 // Put the contributor list into columns
158 $wikiText = str_replace(
159 [ '<!-- BEGIN CONTRIBUTOR LIST -->', '<!-- END CONTRIBUTOR LIST -->' ],
160 [ '<div class="mw-version-credits">', '</div>' ],
163 } elseif ( ( $extNode !== null ) && isset( $extNode['path'] ) ) {
164 $file = ExtensionInfo
::getAuthorsFileName( dirname( $extNode['path'] ) );
166 $wikiText = file_get_contents( $file );
167 if ( str_ends_with( $file, '.txt' ) ) {
168 $wikiText = Html
::element(
180 $out->setPageTitleMsg( $this->msg( 'version-credits-title' )->plaintextParams( $extName ) );
181 $out->addWikiTextAsInterface( $wikiText );
185 $out->setPageTitleMsg( $this->msg( 'version-license-title' )->plaintextParams( $extName ) );
187 $licenseFound = false;
189 if ( $extName === 'MediaWiki' ) {
190 $out->addWikiTextAsInterface(
191 file_get_contents( MW_INSTALL_PATH
. '/COPYING' )
193 $licenseFound = true;
194 } elseif ( ( $extNode !== null ) && isset( $extNode['path'] ) ) {
195 $files = ExtensionInfo
::getLicenseFileNames( dirname( $extNode['path'] ) );
197 $licenseFound = true;
198 foreach ( $files as $file ) {
199 $out->addWikiTextAsInterface(
206 file_get_contents( $file )
212 if ( !$licenseFound ) {
213 $out->addWikiTextAsInterface( '{{int:version-license-not-found}}' );
218 $out->addModuleStyles( 'mediawiki.special' );
220 $out->addHTML( $this->getMediaWikiCredits() );
222 $this->tocData
= new TOCData();
224 $this->tocSection
= 0;
225 $this->tocSubSection
= 0;
227 // Build the page contents (this also fills in TOCData)
229 $this->softwareInformation(),
230 $this->getEntryPointInfo(),
231 $this->getSkinCredits( $credits ),
232 $this->getExtensionCredits( $credits ),
233 $this->getLibraries( $credits ),
234 $this->getParserTags(),
235 $this->getParserFunctionHooks(),
241 $pout = new ParserOutput
;
242 $pout->setTOCData( $this->tocData
);
243 $pout->setOutputFlag( ParserOutputFlags
::SHOW_TOC
);
244 $pout->setRawText( Parser
::TOC_PLACEHOLDER
);
245 $out->addParserOutput( $pout );
248 foreach ( $sections as $content ) {
249 $out->addHTML( $content );
257 * Add a section to the table of contents. This doesn't add the heading to the actual page.
258 * Assumes the IDs don't use non-ASCII characters.
260 * @param string $labelMsg Message key to use for the label
263 private function addTocSection( $labelMsg, $id ) {
266 $this->tocSubSection
= 0;
267 $this->tocData
->addSection( new SectionMetadata(
270 $this->msg( $labelMsg )->escaped(),
271 $this->getLanguage()->formatNum( $this->tocSection
),
272 (string)$this->tocIndex
,
281 * Add a sub-section to the table of contents. This doesn't add the heading to the actual page.
282 * Assumes the IDs don't use non-ASCII characters.
284 * @param string $label Text of the label
287 private function addTocSubSection( $label, $id ) {
289 $this->tocSubSection++
;
290 $this->tocData
->addSection( new SectionMetadata(
293 htmlspecialchars( $label ),
294 // See Parser::localizeTOC
295 $this->getLanguage()->formatNum( $this->tocSection
) . '.' .
296 $this->getLanguage()->formatNum( $this->tocSubSection
),
297 (string)$this->tocIndex
,
306 * Returns HTML showing the license information.
308 * @return string HTML
310 private function getMediaWikiCredits() {
311 // No TOC entry for this heading, we treat it like the lede section
313 $ret = Html
::element(
315 [ 'id' => 'mw-version-license' ],
316 $this->msg( 'version-license' )->text()
319 $ret .= Html
::rawElement( 'div', [ 'class' => 'plainlinks' ],
320 $this->msg( new RawMessage( self
::getCopyrightAndAuthorList() ) )->parseAsBlock() .
321 Html
::rawElement( 'div', [ 'class' => 'mw-version-license-info' ],
322 $this->msg( 'version-license-info' )->parseAsBlock()
330 * Get the "MediaWiki is copyright 2001-20xx by lots of cool folks" text
332 * @internal For use by WebInstallerWelcome
333 * @return string Wikitext
335 public static function getCopyrightAndAuthorList() {
336 if ( defined( 'MEDIAWIKI_INSTALL' ) ) {
337 $othersLink = '[https://www.mediawiki.org/wiki/Special:Version/Credits ' .
338 wfMessage( 'version-poweredby-others' )->plain() . ']';
340 $othersLink = '[[Special:Version/Credits|' .
341 wfMessage( 'version-poweredby-others' )->plain() . ']]';
344 $translatorsLink = '[https://translatewiki.net/wiki/Translating:MediaWiki/Credits ' .
345 wfMessage( 'version-poweredby-translators' )->plain() . ']';
348 'Magnus Manske', 'Brooke Vibber', 'Lee Daniel Crocker',
349 'Tim Starling', 'Erik Möller', 'Gabriel Wicke', 'Ævar Arnfjörð Bjarmason',
350 'Niklas Laxström', 'Domas Mituzas', 'Rob Church', 'Yuri Astrakhan',
351 'Aryeh Gregor', 'Aaron Schulz', 'Andrew Garrett', 'Raimond Spekking',
352 'Alexandre Emsenhuber', 'Siebrand Mazeland', 'Chad Horohoe',
353 'Roan Kattouw', 'Trevor Parscal', 'Bryan Tong Minh', 'Sam Reed',
354 'Victor Vasiliev', 'Rotem Liss', 'Platonides', 'Antoine Musso',
355 'Timo Tijhof', 'Daniel Kinzler', 'Jeroen De Dauw', 'Brad Jorsch',
356 'Bartosz Dziewoński', 'Ed Sanders', 'Moriel Schottlender',
357 'Kunal Mehta', 'James D. Forrester', 'Brian Wolff', 'Adam Shorland',
358 'DannyS712', 'Ori Livneh', 'Max Semenik', 'Amir Sarabadani',
359 'Derk-Jan Hartman', 'Petr Pchelko',
360 $othersLink, $translatorsLink
363 return wfMessage( 'version-poweredby-credits', MWTimestamp
::getLocalInstance()->format( 'Y' ),
364 Message
::listParam( $authorList ) )->plain();
368 * Helper for self::softwareInformation().
370 * @return string[] Array of wikitext strings keyed by wikitext strings
372 private function getSoftwareInformation() {
373 $dbr = $this->dbProvider
->getReplicaDatabase();
375 // Put the software in an array of form 'name' => 'version'. All messages should
376 // be loaded here, so feel free to use wfMessage in the 'name'. Wikitext
377 // can be used both in the name and value.
379 '[https://www.mediawiki.org/ MediaWiki]' => self
::getVersionLinked(),
380 '[https://php.net/ PHP]' => PHP_VERSION
. " (" . PHP_SAPI
. ")",
381 '[https://icu.unicode.org/ ICU]' => INTL_ICU_VERSION
,
382 $dbr->getSoftwareLink() => $dbr->getServerInfo(),
385 // T339915: If wikidiff2 is installed, show version
386 if ( phpversion( "wikidiff2" ) ) {
387 $software[ '[https://www.mediawiki.org/wiki/Wikidiff2 wikidiff2]' ] = phpversion( "wikidiff2" );
390 // Allow a hook to add/remove items.
391 $this->getHookRunner()->onSoftwareInfo( $software );
397 * Returns HTML showing the third party software versions (apache, php, mysql).
399 * @return string HTML
401 private function softwareInformation() {
402 $this->addTocSection( 'version-software', 'mw-version-software' );
404 $out = Html
::element(
406 [ 'id' => 'mw-version-software' ],
407 $this->msg( 'version-software' )->text()
410 $out .= Html
::openElement( 'table', [ 'class' => 'wikitable plainlinks', 'id' => 'sv-software' ] );
412 $out .= $this->getTableHeaderHtml( [
413 $this->msg( 'version-software-product' )->text(),
414 $this->msg( 'version-software-version' )->text()
417 foreach ( $this->getSoftwareInformation() as $name => $version ) {
418 $out .= Html
::rawElement(
421 Html
::rawElement( 'td', [], $this->msg( new RawMessage( $name ) )->parse() ) .
422 Html
::rawElement( 'td', [ 'dir' => 'ltr' ], $this->msg( new RawMessage( $version ) )->parse() )
426 $out .= Html
::closeElement( 'table' );
432 * Return a string of the MediaWiki version with Git revision if available.
434 * @param string $flags If set to 'nodb', the language-specific parantheses are not used.
435 * @param Language|string|null $lang Language in which to render the version; ignored if
436 * $flags is set to 'nodb'.
437 * @return string A version string, as wikitext. This should be parsed
438 * (unless `nodb` is set) and escaped before being inserted as HTML.
440 public static function getVersion( $flags = '', $lang = null ) {
441 $gitInfo = GitInfo
::repo()->getHeadSHA1();
443 $version = MW_VERSION
;
444 } elseif ( $flags === 'nodb' ) {
445 $shortSha1 = substr( $gitInfo, 0, 7 );
446 $version = MW_VERSION
. " ($shortSha1)";
448 $shortSha1 = substr( $gitInfo, 0, 7 );
449 $msg = wfMessage( 'parentheses' );
450 if ( $lang !== null ) {
451 $msg->inLanguage( $lang );
453 $shortSha1 = $msg->params( $shortSha1 )->text();
454 $version = MW_VERSION
. ' ' . $shortSha1;
461 * Return a wikitext-formatted string of the MediaWiki version with a link to
462 * the Git SHA1 of head if available.
463 * The fallback is just MW_VERSION.
467 public static function getVersionLinked() {
468 return self
::getVersionLinkedGit() ?
: MW_VERSION
;
474 private static function getMWVersionLinked() {
476 $hookRunner = new HookRunner( MediaWikiServices
::getInstance()->getHookContainer() );
477 if ( $hookRunner->onSpecialVersionVersionUrl( MW_VERSION
, $versionUrl ) ) {
479 preg_match( "/^(\d+\.\d+)/", MW_VERSION
, $versionParts );
480 $versionUrl = "https://www.mediawiki.org/wiki/MediaWiki_{$versionParts[1]}";
483 return '[' . $versionUrl . ' ' . MW_VERSION
. ']';
487 * @since 1.22 Includes the date of the Git HEAD commit
488 * @return bool|string MW version and Git HEAD (SHA1 stripped to the first 7 chars)
489 * with link and date, or false on failure
491 private static function getVersionLinkedGit() {
494 $gitInfo = new GitInfo( MW_INSTALL_PATH
);
495 $headSHA1 = $gitInfo->getHeadSHA1();
500 $shortSHA1 = '(' . substr( $headSHA1, 0, 7 ) . ')';
502 $gitHeadUrl = $gitInfo->getHeadViewUrl();
503 if ( $gitHeadUrl !== false ) {
504 $shortSHA1 = "[$gitHeadUrl $shortSHA1]";
507 $gitHeadCommitDate = $gitInfo->getHeadCommitDate();
508 if ( $gitHeadCommitDate ) {
509 $shortSHA1 .= Html
::element( 'br' ) . $wgLang->timeanddate( (string)$gitHeadCommitDate, true );
512 return self
::getMWVersionLinked() . " $shortSHA1";
516 * Returns an array with the base extension types.
517 * Type is stored as array key, the message as array value.
519 * TODO: ideally this would return all extension types.
524 public static function getExtensionTypes(): array {
525 if ( self
::$extensionTypes === false ) {
526 self
::$extensionTypes = [
527 'specialpage' => wfMessage( 'version-specialpages' )->text(),
528 'editor' => wfMessage( 'version-editors' )->text(),
529 'parserhook' => wfMessage( 'version-parserhooks' )->text(),
530 'variable' => wfMessage( 'version-variables' )->text(),
531 'media' => wfMessage( 'version-mediahandlers' )->text(),
532 'antispam' => wfMessage( 'version-antispam' )->text(),
533 'skin' => wfMessage( 'version-skins' )->text(),
534 'api' => wfMessage( 'version-api' )->text(),
535 'other' => wfMessage( 'version-other' )->text(),
538 ( new HookRunner( MediaWikiServices
::getInstance()->getHookContainer() ) )
539 ->onExtensionTypes( self
::$extensionTypes );
542 return self
::$extensionTypes;
546 * Returns the internationalized name for an extension type.
550 * @param string $type
554 public static function getExtensionTypeName( $type ) {
555 $types = self
::getExtensionTypes();
557 return $types[$type] ??
$types['other'];
561 * Generate HTML showing the name, URL, author and description of each extension.
563 * @param array $credits
564 * @return string HTML
566 private function getExtensionCredits( array $credits ) {
567 $extensionTypes = self
::getExtensionTypes();
569 $this->addTocSection( 'version-extensions', 'mw-version-ext' );
571 $out = Html
::element(
573 [ 'id' => 'mw-version-ext' ],
574 $this->msg( 'version-extensions' )->text()
579 // Skins are displayed separately, see getSkinCredits()
580 ( count( $credits ) === 1 && isset( $credits['skin'] ) )
582 $out .= Html
::element(
585 $this->msg( 'version-extensions-no-ext' )->text()
591 // Find all extensions that do not have a valid type and give them the type 'other'.
592 $credits['other'] ??
= [];
593 foreach ( $credits as $type => $extensions ) {
594 if ( !array_key_exists( $type, $extensionTypes ) ) {
595 $credits['other'] = array_merge( $credits['other'], $extensions );
599 // Loop through the extension categories to display their extensions in the list.
600 foreach ( $extensionTypes as $type => $text ) {
601 // Skins have a separate section
602 if ( $type !== 'other' && $type !== 'skin' ) {
603 $out .= $this->getExtensionCategory( $type, $text, $credits[$type] ??
[] );
607 // We want the 'other' type to be last in the list.
608 $out .= $this->getExtensionCategory( 'other', $extensionTypes['other'], $credits['other'] );
614 * Generate HTML showing the name, URL, author and description of each skin.
616 * @param array $credits
617 * @return string HTML
619 private function getSkinCredits( array $credits ) {
620 $this->addTocSection( 'version-skins', 'mw-version-skin' );
622 $out = Html
::element(
624 [ 'id' => 'mw-version-skin' ],
625 $this->msg( 'version-skins' )->text()
628 if ( !isset( $credits['skin'] ) ||
!$credits['skin'] ) {
629 $out .= Html
::element(
632 $this->msg( 'version-skins-no-skin' )->text()
637 $out .= $this->getExtensionCategory( 'skin', null, $credits['skin'] );
643 * Generate the section for installed external libraries
645 * @param array $credits
648 protected function getLibraries( array $credits ) {
649 $this->addTocSection( 'version-libraries', 'mw-version-libraries' );
651 $out = Html
::element(
653 [ 'id' => 'mw-version-libraries' ],
654 $this->msg( 'version-libraries' )->text()
658 . $this->getExternalLibraries( $credits )
659 . $this->getClientSideLibraries();
663 * Generate an HTML table for external server-side libraries that are installed
665 * @param array $credits
668 protected function getExternalLibraries( array $credits ) {
670 MW_INSTALL_PATH
. '/vendor/composer/installed.json'
673 $extensionTypes = self
::getExtensionTypes();
674 foreach ( $extensionTypes as $type => $message ) {
675 if ( !isset( $credits[$type] ) ||
$credits[$type] === [] ) {
678 foreach ( $credits[$type] as $extension ) {
679 if ( !isset( $extension['path'] ) ) {
682 $paths[] = dirname( $extension['path'] ) . '/vendor/composer/installed.json';
688 foreach ( $paths as $path ) {
689 if ( !file_exists( $path ) ) {
693 $installed = new ComposerInstalled( $path );
695 $dependencies +
= $installed->getInstalledDependencies();
698 if ( $dependencies === [] ) {
702 ksort( $dependencies );
704 $this->addTocSubSection( $this->msg( 'version-libraries-server' )->text(), 'mw-version-libraries-server' );
706 $out = Html
::element(
708 [ 'id' => 'mw-version-libraries-server' ],
709 $this->msg( 'version-libraries-server' )->text()
711 $out .= Html
::openElement(
713 [ 'class' => 'wikitable plainlinks mw-installed-software', 'id' => 'sv-libraries' ]
716 $out .= $this->getTableHeaderHtml( [
717 $this->msg( 'version-libraries-library' )->text(),
718 $this->msg( 'version-libraries-version' )->text(),
719 $this->msg( 'version-libraries-license' )->text(),
720 $this->msg( 'version-libraries-description' )->text(),
721 $this->msg( 'version-libraries-authors' )->text(),
724 foreach ( $dependencies as $name => $info ) {
725 if ( !is_array( $info ) ||
str_starts_with( $info['type'], 'mediawiki-' ) ) {
726 // Skip any extensions or skins since they'll be listed
727 // in their proper section
730 $authors = array_map( static function ( $arr ) {
731 return new HtmlArmor( isset( $arr['homepage'] ) ?
732 Html
::element( 'a', [ 'href' => $arr['homepage'] ], $arr['name'] ) :
733 htmlspecialchars( $arr['name'] )
735 }, $info['authors'] );
736 $authors = $this->listAuthors( $authors, false, MW_INSTALL_PATH
. "/vendor/$name" );
738 // We can safely assume that the libraries' names and descriptions
739 // are written in English and aren't going to be translated,
740 // so set appropriate lang and dir attributes
741 $out .= Html
::openElement( 'tr', [
742 // Add an anchor so docs can link easily to the version of
743 // this specific library
744 'id' => Sanitizer
::escapeIdForAttribute(
745 "mw-version-library-$name"
750 $this->getLinkRenderer()->makeExternalLink(
751 "https://packagist.org/packages/$name",
753 $this->getFullTitle(),
755 [ 'class' => 'mw-version-library-name' ]
758 . Html
::element( 'td', [ 'dir' => 'auto' ], $info['version'] )
759 // @phan-suppress-next-line SecurityCheck-DoubleEscaped See FIXME in listToText
760 . Html
::element( 'td', [ 'dir' => 'auto' ], $this->listToText( $info['licenses'] ) )
761 . Html
::element( 'td', [ 'lang' => 'en', 'dir' => 'ltr' ], $info['description'] )
762 . Html
::rawElement( 'td', [], $authors )
763 . Html
::closeElement( 'tr' );
765 $out .= Html
::closeElement( 'table' );
775 public static function parseForeignResources() {
776 $registryDirs = [ 'MediaWiki' => MW_INSTALL_PATH
. '/resources/lib' ]
777 + ExtensionRegistry
::getInstance()->getAttribute( 'ForeignResourcesDir' );
780 foreach ( $registryDirs as $source => $registryDir ) {
781 $foreignResources = Yaml
::parseFile( "$registryDir/foreign-resources.yaml" );
782 foreach ( $foreignResources as $name => $module ) {
783 $key = $name . $module['version'];
784 if ( isset( $modules[$key] ) ) {
785 $modules[$key]['source'][] = $source;
788 $modules[$key] = $module +
[ 'name' => $name, 'source' => [ $source ] ];
796 * Generate an HTML table for client-side libraries that are installed
798 * @return string HTML output
800 private function getClientSideLibraries() {
801 $this->addTocSubSection( $this->msg( 'version-libraries-client' )->text(), 'mw-version-libraries-client' );
803 $out = Html
::element(
805 [ 'id' => 'mw-version-libraries-client' ],
806 $this->msg( 'version-libraries-client' )->text()
808 $out .= Html
::openElement(
810 [ 'class' => 'wikitable plainlinks mw-installed-software', 'id' => 'sv-libraries-client' ]
813 $out .= $this->getTableHeaderHtml( [
814 $this->msg( 'version-libraries-library' )->text(),
815 $this->msg( 'version-libraries-version' )->text(),
816 $this->msg( 'version-libraries-license' )->text(),
817 $this->msg( 'version-libraries-authors' )->text(),
818 $this->msg( 'version-libraries-source' )->text()
821 foreach ( self
::parseForeignResources() as $name => $info ) {
822 // We can safely assume that the libraries' names and descriptions
823 // are written in English and aren't going to be translated,
824 // so set appropriate lang and dir attributes
825 $out .= Html
::openElement( 'tr', [
826 // Add an anchor so docs can link easily to the version of
827 // this specific library
828 'id' => Sanitizer
::escapeIdForAttribute(
829 "mw-version-library-$name"
834 $this->getLinkRenderer()->makeExternalLink(
837 $this->getFullTitle(),
839 [ 'class' => 'mw-version-library-name' ]
842 . Html
::element( 'td', [ 'dir' => 'auto' ], $info['version'] )
843 . Html
::element( 'td', [ 'dir' => 'auto' ], $info['license'] )
844 . Html
::element( 'td', [ 'dir' => 'auto' ], $info['authors'] ??
'—' )
845 // @phan-suppress-next-line SecurityCheck-DoubleEscaped See FIXME in listToText
846 . Html
::element( 'td', [ 'dir' => 'auto' ], $this->listToText( $info['source'] ) )
847 . Html
::closeElement( 'tr' );
849 $out .= Html
::closeElement( 'table' );
855 * Obtains a list of installed parser tags and the associated H2 header
857 * @return string HTML output
859 protected function getParserTags() {
860 $tags = $this->parserFactory
->getMainInstance()->getTags();
865 $this->addTocSection( 'version-parser-extensiontags', 'mw-version-parser-extensiontags' );
867 $out = Html
::rawElement(
869 [ 'id' => 'mw-version-parser-extensiontags' ],
872 [ 'class' => 'plainlinks' ],
873 $this->getLinkRenderer()->makeExternalLink(
874 'https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Tag_extensions',
875 $this->msg( 'version-parser-extensiontags' ),
876 $this->getFullTitle()
881 array_walk( $tags, static function ( &$value ) {
882 // Bidirectional isolation improves readability in RTL wikis
883 $value = Html
::rawElement(
885 // Prevent < and > from slipping to another line
887 'style' => 'white-space: nowrap;',
889 Html
::element( 'code', [], "<$value>" )
893 $out .= $this->listToText( $tags );
899 * Obtains a list of installed parser function hooks and the associated H2 header
901 * @return string HTML output
903 protected function getParserFunctionHooks() {
904 $funcHooks = $this->parserFactory
->getMainInstance()->getFunctionHooks();
909 $this->addTocSection( 'version-parser-function-hooks', 'mw-version-parser-function-hooks' );
911 $out = Html
::rawElement(
913 [ 'id' => 'mw-version-parser-function-hooks' ],
916 [ 'class' => 'plainlinks' ],
917 $this->getLinkRenderer()->makeExternalLink(
918 'https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Parser_functions',
919 $this->msg( 'version-parser-function-hooks' ),
920 $this->getFullTitle()
925 $funcSynonyms = $this->parserFactory
->getMainInstance()->getFunctionSynonyms();
926 // This will give us the preferred synonyms in the content language, as if
927 // we used MagicWord::getSynonym( 0 ), because they appear first in the arrays.
928 // We can't use MagicWord directly, because only Parser knows whether a function
929 // uses the leading "#" or not. Case-sensitive functions ("1") win over
930 // case-insensitive ones ("0"), like in Parser::callParserFunction().
931 // There should probably be a better API for this.
932 $preferredSynonyms = array_flip( array_reverse( $funcSynonyms[1] +
$funcSynonyms[0] ) );
933 array_walk( $funcHooks, static function ( &$value ) use ( $preferredSynonyms ) {
934 $value = $preferredSynonyms[$value];
937 // Sort case-insensitively, ignoring the leading '#' if present
938 usort( $funcHooks, static function ( $a, $b ) {
939 return strcasecmp( ltrim( $a, '#' ), ltrim( $b, '#' ) );
942 array_walk( $funcHooks, static function ( &$value ) {
943 // Bidirectional isolation ensures it displays as {{#ns}} and not {{ns#}} in RTL wikis
944 $value = Html
::rawElement(
947 Html
::element( 'code', [], '{{' . $value . '}}' )
951 $out .= $this->getLanguage()->listToText( $funcHooks );
957 * Creates and returns the HTML for a single extension category.
960 * @param string $type
961 * @param string|null $text
962 * @param array $creditsGroup
965 protected function getExtensionCategory( $type, ?
string $text, array $creditsGroup ) {
968 if ( $creditsGroup ) {
969 $out .= $this->openExtType( $text, 'credits-' . $type );
971 usort( $creditsGroup, [ $this, 'compare' ] );
973 foreach ( $creditsGroup as $extension ) {
974 $out .= $this->getCreditsForExtension( $type, $extension );
977 $out .= Html
::closeElement( 'table' );
984 * Callback to sort extensions by type.
989 public function compare( $a, $b ) {
990 return $this->getLanguage()->lc( $a['name'] ) <=> $this->getLanguage()->lc( $b['name'] );
994 * Creates and formats a version line for a single extension.
996 * Information for five columns will be created. Parameters required in the
997 * $extension array for part rendering are indicated in ()
998 * - The name of (name), and URL link to (url), the extension
999 * - Official version number (version) and if available version control system
1000 * revision (path), link, and date
1001 * - If available the short name of the license (license-name) and a link
1002 * to ((LICENSE)|(COPYING))(\.txt)? if it exists.
1003 * - Description of extension (descriptionmsg or description)
1004 * - List of authors (author) and link to a ((AUTHORS)|(CREDITS))(\.txt)? file if it exists
1006 * @param string $type Category name of the extension
1007 * @param array $extension
1009 * @return string Raw HTML
1011 public function getCreditsForExtension( $type, array $extension ) {
1012 $out = $this->getOutput();
1014 // We must obtain the information for all the bits and pieces!
1015 // ... such as extension names and links
1016 if ( isset( $extension['namemsg'] ) ) {
1017 // Localized name of extension
1018 $extensionName = $this->msg( $extension['namemsg'] );
1019 } elseif ( isset( $extension['name'] ) ) {
1020 // Non localized version
1021 $extensionName = $extension['name'];
1023 $extensionName = $this->msg( 'version-no-ext-name' );
1026 if ( isset( $extension['url'] ) ) {
1027 $extensionNameLink = $this->getLinkRenderer()->makeExternalLink(
1030 $this->getFullTitle(),
1032 [ 'class' => 'mw-version-ext-name' ]
1035 $extensionNameLink = htmlspecialchars( $extensionName );
1038 // ... and the version information
1039 // If the extension path is set we will check that directory for GIT
1040 // metadata in an attempt to extract date and vcs commit metadata.
1041 $canonicalVersion = '–';
1042 $extensionPath = null;
1047 if ( isset( $extension['version'] ) ) {
1048 $canonicalVersion = $out->parseInlineAsInterface( $extension['version'] );
1051 if ( isset( $extension['path'] ) ) {
1052 $extensionPath = dirname( $extension['path'] );
1053 if ( $this->coreId
== '' ) {
1054 wfDebug( 'Looking up core head id' );
1055 $coreHeadSHA1 = GitInfo
::repo()->getHeadSHA1();
1056 if ( $coreHeadSHA1 ) {
1057 $this->coreId
= $coreHeadSHA1;
1060 $cache = MediaWikiServices
::getInstance()->getObjectCacheFactory()->getInstance( CACHE_ANYTHING
);
1061 $memcKey = $cache->makeKey(
1062 'specialversion-ext-version-text', $extension['path'], $this->coreId
1064 [ $vcsVersion, $vcsLink, $vcsDate ] = $cache->get( $memcKey );
1066 if ( !$vcsVersion ) {
1067 wfDebug( "Getting VCS info for extension {$extension['name']}" );
1068 $gitInfo = new GitInfo( $extensionPath );
1069 $vcsVersion = $gitInfo->getHeadSHA1();
1070 if ( $vcsVersion !== false ) {
1071 $vcsVersion = substr( $vcsVersion, 0, 7 );
1072 $vcsLink = $gitInfo->getHeadViewUrl();
1073 $vcsDate = $gitInfo->getHeadCommitDate();
1075 $cache->set( $memcKey, [ $vcsVersion, $vcsLink, $vcsDate ], 60 * 60 * 24 );
1077 wfDebug( "Pulled VCS info for extension {$extension['name']} from cache" );
1081 $versionString = Html
::rawElement(
1083 [ 'class' => 'mw-version-ext-version' ],
1087 if ( $vcsVersion ) {
1089 $vcsVerString = $this->getLinkRenderer()->makeExternalLink(
1091 $this->msg( 'version-version', $vcsVersion ),
1092 $this->getFullTitle(),
1094 [ 'class' => 'mw-version-ext-vcs-version' ]
1097 $vcsVerString = Html
::element( 'span',
1098 [ 'class' => 'mw-version-ext-vcs-version' ],
1102 $versionString .= " {$vcsVerString}";
1105 $versionString .= ' ' . Html
::element( 'span', [
1106 'class' => 'mw-version-ext-vcs-timestamp',
1107 'dir' => $this->getLanguage()->getDir(),
1108 ], $this->getLanguage()->timeanddate( $vcsDate, true ) );
1110 $versionString = Html
::rawElement( 'span',
1111 [ 'class' => 'mw-version-ext-meta-version' ],
1116 // ... and license information; if a license file exists we
1119 if ( isset( $extension['name'] ) ) {
1120 $licenseName = null;
1121 if ( isset( $extension['license-name'] ) ) {
1122 $licenseName = new HtmlArmor( $out->parseInlineAsInterface( $extension['license-name'] ) );
1123 } elseif ( $extensionPath !== null && ExtensionInfo
::getLicenseFileNames( $extensionPath ) ) {
1124 $licenseName = $this->msg( 'version-ext-license' )->text();
1126 if ( $licenseName !== null ) {
1127 $licenseLink = $this->getLinkRenderer()->makeLink(
1128 $this->getPageTitle( 'License/' . $extension['name'] ),
1131 'class' => 'mw-version-ext-license',
1138 // ... and generate the description; which can be a parameterized l10n message
1139 // in the form [ <msgname>, <parameter>, <parameter>... ] or just a straight
1141 if ( isset( $extension['descriptionmsg'] ) ) {
1142 // Localized description of extension
1143 $descriptionMsg = $extension['descriptionmsg'];
1145 if ( is_array( $descriptionMsg ) ) {
1146 $descriptionMsgKey = array_shift( $descriptionMsg );
1147 $descriptionMsg = array_map( 'htmlspecialchars', $descriptionMsg );
1148 $description = $this->msg( $descriptionMsgKey, ...$descriptionMsg )->text();
1150 $description = $this->msg( $descriptionMsg )->text();
1152 } elseif ( isset( $extension['description'] ) ) {
1153 // Non localized version
1154 $description = $extension['description'];
1158 $description = $out->parseInlineAsInterface( $description );
1160 // ... now get the authors for this extension
1161 $authors = $extension['author'] ??
[];
1162 // @phan-suppress-next-line PhanTypeMismatchArgumentNullable path is set when there is a name
1163 $authors = $this->listAuthors( $authors, $extension['name'], $extensionPath );
1165 // Finally! Create the table
1166 $html = Html
::openElement( 'tr', [
1167 'class' => 'mw-version-ext',
1168 'id' => Sanitizer
::escapeIdForAttribute( 'mw-version-ext-' . $type . '-' . $extension['name'] )
1172 $html .= Html
::rawElement( 'td', [], $extensionNameLink );
1173 $html .= Html
::rawElement( 'td', [], $versionString );
1174 $html .= Html
::rawElement( 'td', [], $licenseLink );
1175 $html .= Html
::rawElement( 'td', [ 'class' => 'mw-version-ext-description' ], $description );
1176 $html .= Html
::rawElement( 'td', [ 'class' => 'mw-version-ext-authors' ], $authors );
1178 $html .= Html
::closeElement( 'tr' );
1184 * Generate HTML showing hooks in $wgHooks.
1186 * @return string HTML
1188 private function getHooks() {
1189 if ( !$this->getConfig()->get( MainConfigNames
::SpecialVersionShowHooks
) ) {
1193 $hookContainer = MediaWikiServices
::getInstance()->getHookContainer();
1194 $hookNames = $hookContainer->getHookNames();
1196 if ( !$hookNames ) {
1203 $this->addTocSection( 'version-hooks', 'mw-version-hooks' );
1204 $ret[] = Html
::element(
1206 [ 'id' => 'mw-version-hooks' ],
1207 $this->msg( 'version-hooks' )->text()
1209 $ret[] = Html
::openElement( 'table', [ 'class' => 'wikitable', 'id' => 'sv-hooks' ] );
1210 $ret[] = Html
::openElement( 'tr' );
1211 $ret[] = Html
::element( 'th', [], $this->msg( 'version-hook-name' )->text() );
1212 $ret[] = Html
::element( 'th', [], $this->msg( 'version-hook-subscribedby' )->text() );
1213 $ret[] = Html
::closeElement( 'tr' );
1215 foreach ( $hookNames as $name ) {
1216 $handlers = $hookContainer->getHandlerDescriptions( $name );
1218 $ret[] = Html
::openElement( 'tr' );
1219 $ret[] = Html
::element( 'td', [], $name );
1220 // @phan-suppress-next-line SecurityCheck-DoubleEscaped See FIXME in listToText
1221 $ret[] = Html
::element( 'td', [], $this->listToText( $handlers ) );
1222 $ret[] = Html
::closeElement( 'tr' );
1225 $ret[] = Html
::closeElement( 'table' );
1227 return implode( "\n", $ret );
1230 private function openExtType( ?
string $text = null, ?
string $name = null ) {
1233 $opt = [ 'class' => 'wikitable plainlinks mw-installed-software' ];
1236 $opt['id'] = "sv-$name";
1239 $out .= Html
::openElement( 'table', $opt );
1241 if ( $text !== null ) {
1242 $out .= Html
::element( 'caption', [], $text );
1245 if ( $name && $text !== null ) {
1246 $this->addTocSubSection( $text, "sv-$name" );
1249 $firstHeadingMsg = ( $name === 'credits-skin' )
1250 ?
'version-skin-colheader-name'
1251 : 'version-ext-colheader-name';
1253 $out .= $this->getTableHeaderHtml( [
1254 $this->msg( $firstHeadingMsg )->text(),
1255 $this->msg( 'version-ext-colheader-version' )->text(),
1256 $this->msg( 'version-ext-colheader-license' )->text(),
1257 $this->msg( 'version-ext-colheader-description' )->text(),
1258 $this->msg( 'version-ext-colheader-credits' )->text()
1265 * Return HTML for a table header with given texts in header cells
1267 * Includes thead element and scope="col" attribute for improved accessibility
1269 * @param string|array $headers
1270 * @return string HTML
1272 private function getTableHeaderHtml( $headers ): string {
1274 $out .= Html
::openElement( 'thead' );
1275 $out .= Html
::openElement( 'tr' );
1276 foreach ( $headers as $header ) {
1277 $out .= Html
::element( 'th', [ 'scope' => 'col' ], $header );
1279 $out .= Html
::closeElement( 'tr' );
1280 $out .= Html
::closeElement( 'thead' );
1285 * Get information about client's IP address.
1287 * @return string HTML fragment
1289 private function IPInfo() {
1290 $ip = str_replace( '--', ' - ', htmlspecialchars( $this->getRequest()->getIP() ) );
1292 return "<!-- visited from $ip -->\n<span style='display:none'>visited from $ip</span>";
1296 * Return a formatted unsorted list of authors
1299 * If an item in the $authors array is '...' it is assumed to indicate an
1300 * 'and others' string which will then be linked to an ((AUTHORS)|(CREDITS))(\.txt)?
1301 * file if it exists in $dir.
1303 * Similarly an entry ending with ' ...]' is assumed to be a link to an
1304 * 'and others' page.
1306 * If no '...' string variant is found, but an authors file is found an
1307 * 'and others' will be added to the end of the credits.
1309 * @param string|array $authors
1310 * @param string|bool $extName Name of the extension for link creation,
1311 * false if no links should be created
1312 * @param string $extDir Path to the extension root directory
1313 * @return string HTML fragment
1315 public function listAuthors( $authors, $extName, $extDir ): string {
1317 $linkRenderer = $this->getLinkRenderer();
1320 $authors = (array)$authors;
1322 // Special case: if the authors array has only one item and it is "...",
1323 // it should not be rendered as the "version-poweredby-others" i18n msg,
1324 // but rather as "version-poweredby-various" i18n msg instead.
1325 if ( count( $authors ) === 1 && $authors[0] === '...' ) {
1326 // Link to the extension's or skin's AUTHORS or CREDITS file, if there is
1327 // such a file; otherwise just return the i18n msg as-is
1328 if ( $extName && ExtensionInfo
::getAuthorsFileName( $extDir ) ) {
1329 return $linkRenderer->makeLink(
1330 $this->getPageTitle( "Credits/$extName" ),
1331 $this->msg( 'version-poweredby-various' )->text()
1334 return $this->msg( 'version-poweredby-various' )->escaped();
1338 // Otherwise, if we have an actual array that has more than one item,
1339 // process each array item as usual
1340 foreach ( $authors as $item ) {
1341 if ( $item instanceof HtmlArmor
) {
1342 $list[] = HtmlArmor
::getHtml( $item );
1343 } elseif ( $item === '...' ) {
1346 if ( $extName && ExtensionInfo
::getAuthorsFileName( $extDir ) ) {
1347 $text = $linkRenderer->makeLink(
1348 $this->getPageTitle( "Credits/$extName" ),
1349 $this->msg( 'version-poweredby-others' )->text()
1352 $text = $this->msg( 'version-poweredby-others' )->escaped();
1355 } elseif ( str_ends_with( $item, ' ...]' ) ) {
1357 $list[] = $this->getOutput()->parseInlineAsInterface(
1358 substr( $item, 0, -4 ) . $this->msg( 'version-poweredby-others' )->text() . "]"
1361 $list[] = $this->getOutput()->parseInlineAsInterface( $item );
1365 if ( $extName && !$hasOthers && ExtensionInfo
::getAuthorsFileName( $extDir ) ) {
1366 $list[] = $linkRenderer->makeLink(
1367 $this->getPageTitle( "Credits/$extName" ),
1368 $this->msg( 'version-poweredby-others' )->text()
1372 return $this->listToText( $list, false );
1376 * Convert an array of items into a list for display.
1378 * @param array $list List of elements to display
1379 * @param bool $sort Whether to sort the items in $list
1381 * @fixme This method does not handle escaping consistently. Language::listToText expects all list elements to be
1382 * already escaped. However, self::arrayToString escapes some elements, but not others.
1384 private function listToText( array $list, bool $sort = true ): string {
1392 return $this->getLanguage()
1393 ->listToText( array_map( [ __CLASS__
, 'arrayToString' ], $list ) );
1397 * Convert an array or object to a string for display.
1399 * @internal For use by ApiQuerySiteinfo (TODO: Turn into more stable method)
1400 * @param mixed $list Will convert an array to string if given and return
1401 * the parameter unaltered otherwise
1403 * @fixme This should handle escaping more consistently, see FIXME in listToText
1405 public static function arrayToString( $list ) {
1406 if ( is_array( $list ) && count( $list ) == 1 ) {
1409 if ( $list instanceof Closure
) {
1410 // Don't output stuff like "Closure$;1028376090#8$48499d94fe0147f7c633b365be39952b$"
1412 } elseif ( is_object( $list ) ) {
1413 return wfMessage( 'parentheses' )->params( get_class( $list ) )->escaped();
1414 } elseif ( !is_array( $list ) ) {
1417 if ( is_object( $list[0] ) ) {
1418 $class = get_class( $list[0] );
1423 return wfMessage( 'parentheses' )->params( "$class, {$list[1]}" )->escaped();
1428 * @deprecated since 1.41 Use GitInfo::repo() for MW_INSTALL_PATH, or new GitInfo otherwise.
1429 * @param string $dir Directory of the git checkout
1430 * @return string|false Sha1 of commit HEAD points to
1432 public static function getGitHeadSha1( $dir ) {
1433 wfDeprecated( __METHOD__
, '1.41' );
1434 return ( new GitInfo( $dir ) )->getHeadSHA1();
1438 * Get the list of entry points and their URLs
1439 * @return string HTML
1441 public function getEntryPointInfo() {
1442 $config = $this->getConfig();
1443 $scriptPath = $config->get( MainConfigNames
::ScriptPath
) ?
: '/';
1446 'version-entrypoints-articlepath' => $config->get( MainConfigNames
::ArticlePath
),
1447 'version-entrypoints-scriptpath' => $scriptPath,
1448 'version-entrypoints-index-php' => wfScript( 'index' ),
1449 'version-entrypoints-api-php' => wfScript( 'api' ),
1450 'version-entrypoints-rest-php' => wfScript( 'rest' ),
1453 $language = $this->getLanguage();
1455 'dir' => $language->getDir(),
1456 'lang' => $language->getHtmlCode(),
1460 $this->addTocSection( 'version-entrypoints', 'mw-version-entrypoints' );
1462 $out = Html
::element(
1464 [ 'id' => 'mw-version-entrypoints' ],
1465 $this->msg( 'version-entrypoints' )->text()
1467 Html
::openElement( 'table',
1469 'class' => 'wikitable plainlinks',
1470 'id' => 'mw-version-entrypoints-table',
1475 Html
::openElement( 'thead' ) .
1476 Html
::openElement( 'tr' ) .
1480 $this->msg( 'version-entrypoints-header-entrypoint' )->text()
1485 $this->msg( 'version-entrypoints-header-url' )->text()
1487 Html
::closeElement( 'tr' ) .
1488 Html
::closeElement( 'thead' );
1490 foreach ( $entryPoints as $message => $value ) {
1491 $url = $this->urlUtils
->expand( $value, PROTO_RELATIVE
);
1492 $out .= Html
::openElement( 'tr' ) .
1493 Html
::rawElement( 'td', [], $this->msg( $message )->parse() ) .
1494 Html
::rawElement( 'td', [],
1498 $this->msg( new RawMessage( "[$url $value]" ) )->parse()
1501 Html
::closeElement( 'tr' );
1504 $out .= Html
::closeElement( 'table' );
1509 protected function getGroupName() {
1515 * Retain the old class name for backwards compatibility.
1516 * @deprecated since 1.41
1518 class_alias( SpecialVersion
::class, 'SpecialVersion' );