Localisation updates from https://translatewiki.net.
[mediawiki.git] / includes / specials / SpecialVersion.php
blobe095f7dbc95b72213af2e1289dbcd660bc5995a5
1 <?php
2 /**
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
20 * @file
23 namespace MediaWiki\Specials;
25 use Closure;
26 use HtmlArmor;
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;
52 /**
53 * Version information about MediaWiki (core, extensions, libs), PHP, and the database.
55 * @ingroup SpecialPage
57 class SpecialVersion extends SpecialPage {
59 /**
60 * @var string The current rev id/SHA hash of MediaWiki core
62 protected $coreId = '';
64 /**
65 * @var string[]|false Lazy initialized key/value with message content
67 protected static $extensionTypes = false;
69 /** @var TOCData */
70 protected $tocData;
72 /** @var int */
73 protected $tocIndex;
75 /** @var int */
76 protected $tocSection;
78 /** @var int */
79 protected $tocSubSection;
81 private ParserFactory $parserFactory;
82 private UrlUtils $urlUtils;
83 private IConnectionProvider $dbProvider;
85 /**
86 * @param ParserFactory $parserFactory
87 * @param UrlUtils $urlUtils
88 * @param IConnectionProvider $dbProvider
90 public function __construct(
91 ParserFactory $parserFactory,
92 UrlUtils $urlUtils,
93 IConnectionProvider $dbProvider
94 ) {
95 parent::__construct( 'Version' );
96 $this->parserFactory = $parserFactory;
97 $this->urlUtils = $urlUtils;
98 $this->dbProvider = $dbProvider;
102 * @since 1.35
103 * @param ExtensionRegistry $reg
104 * @param Config $conf For additional entries from $wgExtensionCredits.
105 * @return array[]
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;
113 return $credits;
117 * @param string|null $par
119 public function execute( $par ) {
120 $config = $this->getConfig();
121 $credits = self::getCredits( ExtensionRegistry::getInstance(), $config );
123 $this->setHeaders();
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 );
130 $extNode = null;
131 if ( isset( $parts[1] ) ) {
132 $extName = str_replace( '_', ' ', $parts[1] );
133 // Find it!
134 foreach ( $credits as $extensions ) {
135 foreach ( $extensions as $ext ) {
136 if ( isset( $ext['name'] ) && ( $ext['name'] === $extName ) ) {
137 $extNode = &$ext;
138 break 2;
142 if ( !$extNode ) {
143 $out->setStatusCode( 404 );
145 } else {
146 $extName = 'MediaWiki';
149 // Now figure out what to do
150 switch ( strtolower( $parts[0] ) ) {
151 case 'credits':
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>' ],
161 $wikiText
163 } elseif ( ( $extNode !== null ) && isset( $extNode['path'] ) ) {
164 $file = ExtensionInfo::getAuthorsFileName( dirname( $extNode['path'] ) );
165 if ( $file ) {
166 $wikiText = file_get_contents( $file );
167 if ( str_ends_with( $file, '.txt' ) ) {
168 $wikiText = Html::element(
169 'pre',
171 'lang' => 'en',
172 'dir' => 'ltr',
174 $wikiText
180 $out->setPageTitleMsg( $this->msg( 'version-credits-title' )->plaintextParams( $extName ) );
181 $out->addWikiTextAsInterface( $wikiText );
182 break;
184 case 'license':
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'] ) );
196 if ( $files ) {
197 $licenseFound = true;
198 foreach ( $files as $file ) {
199 $out->addWikiTextAsInterface(
200 Html::element(
201 'pre',
203 'lang' => 'en',
204 'dir' => 'ltr',
206 file_get_contents( $file )
212 if ( !$licenseFound ) {
213 $out->addWikiTextAsInterface( '{{int:version-license-not-found}}' );
215 break;
217 default:
218 $out->addModuleStyles( 'mediawiki.special' );
220 $out->addHTML( $this->getMediaWikiCredits() );
222 $this->tocData = new TOCData();
223 $this->tocIndex = 0;
224 $this->tocSection = 0;
225 $this->tocSubSection = 0;
227 // Build the page contents (this also fills in TOCData)
228 $sections = [
229 $this->softwareInformation(),
230 $this->getEntryPointInfo(),
231 $this->getSkinCredits( $credits ),
232 $this->getExtensionCredits( $credits ),
233 $this->getLibraries( $credits ),
234 $this->getParserTags(),
235 $this->getParserFunctionHooks(),
236 $this->getHooks(),
237 $this->IPInfo(),
240 // Insert TOC first
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 );
247 // Insert contents
248 foreach ( $sections as $content ) {
249 $out->addHTML( $content );
252 break;
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
261 * @param string $id
263 private function addTocSection( $labelMsg, $id ) {
264 $this->tocIndex++;
265 $this->tocSection++;
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,
273 null,
274 null,
275 $id,
277 ) );
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
285 * @param string $id
287 private function addTocSubSection( $label, $id ) {
288 $this->tocIndex++;
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,
298 null,
299 null,
300 $id,
302 ) );
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(
314 'h2',
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()
326 return $ret;
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() . ']';
339 } else {
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() . ']';
347 $authorList = [
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().
369 * @since 1.34
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.
378 $software = [
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 );
393 return $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(
405 'h2',
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()
415 ] );
417 foreach ( $this->getSoftwareInformation() as $name => $version ) {
418 $out .= Html::rawElement(
419 'tr',
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' );
428 return $out;
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();
442 if ( !$gitInfo ) {
443 $version = MW_VERSION;
444 } elseif ( $flags === 'nodb' ) {
445 $shortSha1 = substr( $gitInfo, 0, 7 );
446 $version = MW_VERSION . " ($shortSha1)";
447 } else {
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;
457 return $version;
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.
465 * @return string
467 public static function getVersionLinked() {
468 return self::getVersionLinkedGit() ?: MW_VERSION;
472 * @return string
474 private static function getMWVersionLinked() {
475 $versionUrl = "";
476 $hookRunner = new HookRunner( MediaWikiServices::getInstance()->getHookContainer() );
477 if ( $hookRunner->onSpecialVersionVersionUrl( MW_VERSION, $versionUrl ) ) {
478 $versionParts = [];
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() {
492 global $wgLang;
494 $gitInfo = new GitInfo( MW_INSTALL_PATH );
495 $headSHA1 = $gitInfo->getHeadSHA1();
496 if ( !$headSHA1 ) {
497 return false;
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.
521 * @since 1.17
522 * @return string[]
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.
548 * @since 1.17
550 * @param string $type
552 * @return string
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(
572 'h2',
573 [ 'id' => 'mw-version-ext' ],
574 $this->msg( 'version-extensions' )->text()
577 if (
578 !$credits ||
579 // Skins are displayed separately, see getSkinCredits()
580 ( count( $credits ) === 1 && isset( $credits['skin'] ) )
582 $out .= Html::element(
583 'p',
585 $this->msg( 'version-extensions-no-ext' )->text()
588 return $out;
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'] );
610 return $out;
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(
623 'h2',
624 [ 'id' => 'mw-version-skin' ],
625 $this->msg( 'version-skins' )->text()
628 if ( !isset( $credits['skin'] ) || !$credits['skin'] ) {
629 $out .= Html::element(
630 'p',
632 $this->msg( 'version-skins-no-skin' )->text()
635 return $out;
637 $out .= $this->getExtensionCategory( 'skin', null, $credits['skin'] );
639 return $out;
643 * Generate the section for installed external libraries
645 * @param array $credits
646 * @return string
648 protected function getLibraries( array $credits ) {
649 $this->addTocSection( 'version-libraries', 'mw-version-libraries' );
651 $out = Html::element(
652 'h2',
653 [ 'id' => 'mw-version-libraries' ],
654 $this->msg( 'version-libraries' )->text()
657 return $out
658 . $this->getExternalLibraries( $credits )
659 . $this->getClientSideLibraries();
663 * Generate an HTML table for external server-side libraries that are installed
665 * @param array $credits
666 * @return string
668 protected function getExternalLibraries( array $credits ) {
669 $paths = [
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] === [] ) {
676 continue;
678 foreach ( $credits[$type] as $extension ) {
679 if ( !isset( $extension['path'] ) ) {
680 continue;
682 $paths[] = dirname( $extension['path'] ) . '/vendor/composer/installed.json';
686 $dependencies = [];
688 foreach ( $paths as $path ) {
689 if ( !file_exists( $path ) ) {
690 continue;
693 $installed = new ComposerInstalled( $path );
695 $dependencies += $installed->getInstalledDependencies();
698 if ( $dependencies === [] ) {
699 return '';
702 ksort( $dependencies );
704 $this->addTocSubSection( $this->msg( 'version-libraries-server' )->text(), 'mw-version-libraries-server' );
706 $out = Html::element(
707 'h3',
708 [ 'id' => 'mw-version-libraries-server' ],
709 $this->msg( 'version-libraries-server' )->text()
711 $out .= Html::openElement(
712 'table',
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(),
722 ] );
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
728 continue;
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"
746 ) ] )
747 . Html::rawElement(
748 'td',
750 $this->getLinkRenderer()->makeExternalLink(
751 "https://packagist.org/packages/$name",
752 $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' );
767 return $out;
771 * @internal
772 * @since 1.42
773 * @return array
775 public static function parseForeignResources() {
776 $registryDirs = [ 'MediaWiki' => MW_INSTALL_PATH . '/resources/lib' ]
777 + ExtensionRegistry::getInstance()->getAttribute( 'ForeignResourcesDir' );
779 $modules = [];
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;
786 continue;
788 $modules[$key] = $module + [ 'name' => $name, 'source' => [ $source ] ];
791 ksort( $modules );
792 return $modules;
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(
804 'h3',
805 [ 'id' => 'mw-version-libraries-client' ],
806 $this->msg( 'version-libraries-client' )->text()
808 $out .= Html::openElement(
809 'table',
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()
819 ] );
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"
830 ) ] )
831 . Html::rawElement(
832 'td',
834 $this->getLinkRenderer()->makeExternalLink(
835 $info['homepage'],
836 $info['name'],
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' );
851 return $out;
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();
861 if ( !$tags ) {
862 return '';
865 $this->addTocSection( 'version-parser-extensiontags', 'mw-version-parser-extensiontags' );
867 $out = Html::rawElement(
868 'h2',
869 [ 'id' => 'mw-version-parser-extensiontags' ],
870 Html::rawElement(
871 'span',
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(
884 'bdi',
885 // Prevent < and > from slipping to another line
887 'style' => 'white-space: nowrap;',
889 Html::element( 'code', [], "<$value>" )
891 } );
893 $out .= $this->listToText( $tags );
895 return $out;
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();
905 if ( !$funcHooks ) {
906 return '';
909 $this->addTocSection( 'version-parser-function-hooks', 'mw-version-parser-function-hooks' );
911 $out = Html::rawElement(
912 'h2',
913 [ 'id' => 'mw-version-parser-function-hooks' ],
914 Html::rawElement(
915 'span',
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];
935 } );
937 // Sort case-insensitively, ignoring the leading '#' if present
938 usort( $funcHooks, static function ( $a, $b ) {
939 return strcasecmp( ltrim( $a, '#' ), ltrim( $b, '#' ) );
940 } );
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(
945 'bdi',
947 Html::element( 'code', [], '{{' . $value . '}}' )
949 } );
951 $out .= $this->getLanguage()->listToText( $funcHooks );
953 return $out;
957 * Creates and returns the HTML for a single extension category.
959 * @since 1.17
960 * @param string $type
961 * @param string|null $text
962 * @param array $creditsGroup
963 * @return string
965 protected function getExtensionCategory( $type, ?string $text, array $creditsGroup ) {
966 $out = '';
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' );
980 return $out;
984 * Callback to sort extensions by type.
985 * @param array $a
986 * @param array $b
987 * @return int
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'];
1022 } else {
1023 $extensionName = $this->msg( 'version-no-ext-name' );
1026 if ( isset( $extension['url'] ) ) {
1027 $extensionNameLink = $this->getLinkRenderer()->makeExternalLink(
1028 $extension['url'],
1029 $extensionName,
1030 $this->getFullTitle(),
1032 [ 'class' => 'mw-version-ext-name' ]
1034 } else {
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 = '&ndash;';
1042 $extensionPath = null;
1043 $vcsVersion = null;
1044 $vcsLink = null;
1045 $vcsDate = 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 );
1076 } else {
1077 wfDebug( "Pulled VCS info for extension {$extension['name']} from cache" );
1081 $versionString = Html::rawElement(
1082 'span',
1083 [ 'class' => 'mw-version-ext-version' ],
1084 $canonicalVersion
1087 if ( $vcsVersion ) {
1088 if ( $vcsLink ) {
1089 $vcsVerString = $this->getLinkRenderer()->makeExternalLink(
1090 $vcsLink,
1091 $this->msg( 'version-version', $vcsVersion ),
1092 $this->getFullTitle(),
1094 [ 'class' => 'mw-version-ext-vcs-version' ]
1096 } else {
1097 $vcsVerString = Html::element( 'span',
1098 [ 'class' => 'mw-version-ext-vcs-version' ],
1099 "({$vcsVersion})"
1102 $versionString .= " {$vcsVerString}";
1104 if ( $vcsDate ) {
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' ],
1112 $versionString
1116 // ... and license information; if a license file exists we
1117 // will link to it
1118 $licenseLink = '';
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'] ),
1129 $licenseName,
1131 'class' => 'mw-version-ext-license',
1132 'dir' => 'auto',
1138 // ... and generate the description; which can be a parameterized l10n message
1139 // in the form [ <msgname>, <parameter>, <parameter>... ] or just a straight
1140 // up string
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();
1149 } else {
1150 $description = $this->msg( $descriptionMsg )->text();
1152 } elseif ( isset( $extension['description'] ) ) {
1153 // Non localized version
1154 $description = $extension['description'];
1155 } else {
1156 $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' );
1180 return $html;
1184 * Generate HTML showing hooks in $wgHooks.
1186 * @return string HTML
1188 private function getHooks() {
1189 if ( !$this->getConfig()->get( MainConfigNames::SpecialVersionShowHooks ) ) {
1190 return '';
1193 $hookContainer = MediaWikiServices::getInstance()->getHookContainer();
1194 $hookNames = $hookContainer->getHookNames();
1196 if ( !$hookNames ) {
1197 return '';
1200 sort( $hookNames );
1202 $ret = [];
1203 $this->addTocSection( 'version-hooks', 'mw-version-hooks' );
1204 $ret[] = Html::element(
1205 'h2',
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 ) {
1231 $out = '';
1233 $opt = [ 'class' => 'wikitable plainlinks mw-installed-software' ];
1235 if ( $name ) {
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()
1259 ] );
1261 return $out;
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 {
1273 $out = '';
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' );
1281 return $out;
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
1298 * 'And Others'
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 {
1316 $hasOthers = false;
1317 $linkRenderer = $this->getLinkRenderer();
1319 $list = [];
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()
1333 } else {
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 === '...' ) {
1344 $hasOthers = true;
1346 if ( $extName && ExtensionInfo::getAuthorsFileName( $extDir ) ) {
1347 $text = $linkRenderer->makeLink(
1348 $this->getPageTitle( "Credits/$extName" ),
1349 $this->msg( 'version-poweredby-others' )->text()
1351 } else {
1352 $text = $this->msg( 'version-poweredby-others' )->escaped();
1354 $list[] = $text;
1355 } elseif ( str_ends_with( $item, ' ...]' ) ) {
1356 $hasOthers = true;
1357 $list[] = $this->getOutput()->parseInlineAsInterface(
1358 substr( $item, 0, -4 ) . $this->msg( 'version-poweredby-others' )->text() . "]"
1360 } else {
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
1380 * @return string
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 {
1385 if ( !$list ) {
1386 return '';
1388 if ( $sort ) {
1389 sort( $list );
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
1402 * @return mixed
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 ) {
1407 $list = $list[0];
1409 if ( $list instanceof Closure ) {
1410 // Don't output stuff like "Closure$;1028376090#8$48499d94fe0147f7c633b365be39952b$"
1411 return 'Closure';
1412 } elseif ( is_object( $list ) ) {
1413 return wfMessage( 'parentheses' )->params( get_class( $list ) )->escaped();
1414 } elseif ( !is_array( $list ) ) {
1415 return $list;
1416 } else {
1417 if ( is_object( $list[0] ) ) {
1418 $class = get_class( $list[0] );
1419 } else {
1420 $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 ) ?: '/';
1445 $entryPoints = [
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();
1454 $thAttributes = [
1455 'dir' => $language->getDir(),
1456 'lang' => $language->getHtmlCode(),
1457 'scope' => 'col'
1460 $this->addTocSection( 'version-entrypoints', 'mw-version-entrypoints' );
1462 $out = Html::element(
1463 'h2',
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',
1471 'dir' => 'ltr',
1472 'lang' => 'en'
1475 Html::openElement( 'thead' ) .
1476 Html::openElement( 'tr' ) .
1477 Html::element(
1478 'th',
1479 $thAttributes,
1480 $this->msg( 'version-entrypoints-header-entrypoint' )->text()
1482 Html::element(
1483 'th',
1484 $thAttributes,
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', [],
1495 Html::rawElement(
1496 'code',
1498 $this->msg( new RawMessage( "[$url $value]" ) )->parse()
1501 Html::closeElement( 'tr' );
1504 $out .= Html::closeElement( 'table' );
1506 return $out;
1509 protected function getGroupName() {
1510 return 'wiki';
1515 * Retain the old class name for backwards compatibility.
1516 * @deprecated since 1.41
1518 class_alias( SpecialVersion::class, 'SpecialVersion' );