Merge "rest: Return a 400 for invalid render IDs"
[mediawiki.git] / includes / specials / SpecialVersion.php
blobcb460cd59fb4a64abdae4d0efc8dbf1d683527dc
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', 'Umherirrender', 'C. Scott Ananian',
360 'fomafix', 'Thiemo Kreuz', 'Gergő Tisza', 'Volker E.',
361 $othersLink, $translatorsLink
364 return wfMessage( 'version-poweredby-credits', MWTimestamp::getLocalInstance()->format( 'Y' ),
365 Message::listParam( $authorList ) )->plain();
369 * Helper for self::softwareInformation().
370 * @since 1.34
371 * @return string[] Array of wikitext strings keyed by wikitext strings
373 private function getSoftwareInformation() {
374 $dbr = $this->dbProvider->getReplicaDatabase();
376 // Put the software in an array of form 'name' => 'version'. All messages should
377 // be loaded here, so feel free to use wfMessage in the 'name'. Wikitext
378 // can be used both in the name and value.
379 $software = [
380 '[https://www.mediawiki.org/ MediaWiki]' => self::getVersionLinked(),
381 '[https://php.net/ PHP]' => PHP_VERSION . " (" . PHP_SAPI . ")",
382 '[https://icu.unicode.org/ ICU]' => INTL_ICU_VERSION,
383 $dbr->getSoftwareLink() => $dbr->getServerInfo(),
386 // T339915: If wikidiff2 is installed, show version
387 if ( phpversion( "wikidiff2" ) ) {
388 $software[ '[https://www.mediawiki.org/wiki/Wikidiff2 wikidiff2]' ] = phpversion( "wikidiff2" );
391 // Allow a hook to add/remove items.
392 $this->getHookRunner()->onSoftwareInfo( $software );
394 return $software;
398 * Returns HTML showing the third party software versions (apache, php, mysql).
400 * @return string HTML
402 private function softwareInformation() {
403 $this->addTocSection( 'version-software', 'mw-version-software' );
405 $out = Html::element(
406 'h2',
407 [ 'id' => 'mw-version-software' ],
408 $this->msg( 'version-software' )->text()
411 $out .= Html::openElement( 'table', [ 'class' => 'wikitable plainlinks', 'id' => 'sv-software' ] );
413 $out .= $this->getTableHeaderHtml( [
414 $this->msg( 'version-software-product' )->text(),
415 $this->msg( 'version-software-version' )->text()
416 ] );
418 foreach ( $this->getSoftwareInformation() as $name => $version ) {
419 $out .= Html::rawElement(
420 'tr',
422 Html::rawElement( 'td', [], $this->msg( new RawMessage( $name ) )->parse() ) .
423 Html::rawElement( 'td', [ 'dir' => 'ltr' ], $this->msg( new RawMessage( $version ) )->parse() )
427 $out .= Html::closeElement( 'table' );
429 return $out;
433 * Return a string of the MediaWiki version with Git revision if available.
435 * @param string $flags If set to 'nodb', the language-specific parantheses are not used.
436 * @param Language|string|null $lang Language in which to render the version; ignored if
437 * $flags is set to 'nodb'.
438 * @return string A version string, as wikitext. This should be parsed
439 * (unless `nodb` is set) and escaped before being inserted as HTML.
441 public static function getVersion( $flags = '', $lang = null ) {
442 $gitInfo = GitInfo::repo()->getHeadSHA1();
443 if ( !$gitInfo ) {
444 $version = MW_VERSION;
445 } elseif ( $flags === 'nodb' ) {
446 $shortSha1 = substr( $gitInfo, 0, 7 );
447 $version = MW_VERSION . " ($shortSha1)";
448 } else {
449 $shortSha1 = substr( $gitInfo, 0, 7 );
450 $msg = wfMessage( 'parentheses' );
451 if ( $lang !== null ) {
452 $msg->inLanguage( $lang );
454 $shortSha1 = $msg->params( $shortSha1 )->text();
455 $version = MW_VERSION . ' ' . $shortSha1;
458 return $version;
462 * Return a wikitext-formatted string of the MediaWiki version with a link to
463 * the Git SHA1 of head if available.
464 * The fallback is just MW_VERSION.
466 * @return string
468 public static function getVersionLinked() {
469 return self::getVersionLinkedGit() ?: MW_VERSION;
473 * @return string
475 private static function getMWVersionLinked() {
476 $versionUrl = "";
477 $hookRunner = new HookRunner( MediaWikiServices::getInstance()->getHookContainer() );
478 if ( $hookRunner->onSpecialVersionVersionUrl( MW_VERSION, $versionUrl ) ) {
479 $versionParts = [];
480 preg_match( "/^(\d+\.\d+)/", MW_VERSION, $versionParts );
481 $versionUrl = "https://www.mediawiki.org/wiki/MediaWiki_{$versionParts[1]}";
484 return '[' . $versionUrl . ' ' . MW_VERSION . ']';
488 * @since 1.22 Includes the date of the Git HEAD commit
489 * @return bool|string MW version and Git HEAD (SHA1 stripped to the first 7 chars)
490 * with link and date, or false on failure
492 private static function getVersionLinkedGit() {
493 global $wgLang;
495 $gitInfo = new GitInfo( MW_INSTALL_PATH );
496 $headSHA1 = $gitInfo->getHeadSHA1();
497 if ( !$headSHA1 ) {
498 return false;
501 $shortSHA1 = '(' . substr( $headSHA1, 0, 7 ) . ')';
503 $gitHeadUrl = $gitInfo->getHeadViewUrl();
504 if ( $gitHeadUrl !== false ) {
505 $shortSHA1 = "[$gitHeadUrl $shortSHA1]";
508 $gitHeadCommitDate = $gitInfo->getHeadCommitDate();
509 if ( $gitHeadCommitDate ) {
510 $shortSHA1 .= Html::element( 'br' ) . $wgLang->timeanddate( (string)$gitHeadCommitDate, true );
513 return self::getMWVersionLinked() . " $shortSHA1";
517 * Returns an array with the base extension types.
518 * Type is stored as array key, the message as array value.
520 * TODO: ideally this would return all extension types.
522 * @since 1.17
523 * @return string[]
525 public static function getExtensionTypes(): array {
526 if ( self::$extensionTypes === false ) {
527 self::$extensionTypes = [
528 'specialpage' => wfMessage( 'version-specialpages' )->text(),
529 'editor' => wfMessage( 'version-editors' )->text(),
530 'parserhook' => wfMessage( 'version-parserhooks' )->text(),
531 'variable' => wfMessage( 'version-variables' )->text(),
532 'media' => wfMessage( 'version-mediahandlers' )->text(),
533 'antispam' => wfMessage( 'version-antispam' )->text(),
534 'skin' => wfMessage( 'version-skins' )->text(),
535 'api' => wfMessage( 'version-api' )->text(),
536 'other' => wfMessage( 'version-other' )->text(),
539 ( new HookRunner( MediaWikiServices::getInstance()->getHookContainer() ) )
540 ->onExtensionTypes( self::$extensionTypes );
543 return self::$extensionTypes;
547 * Returns the internationalized name for an extension type.
549 * @since 1.17
551 * @param string $type
553 * @return string
555 public static function getExtensionTypeName( $type ) {
556 $types = self::getExtensionTypes();
558 return $types[$type] ?? $types['other'];
562 * Generate HTML showing the name, URL, author and description of each extension.
564 * @param array $credits
565 * @return string HTML
567 private function getExtensionCredits( array $credits ) {
568 $extensionTypes = self::getExtensionTypes();
570 $this->addTocSection( 'version-extensions', 'mw-version-ext' );
572 $out = Html::element(
573 'h2',
574 [ 'id' => 'mw-version-ext' ],
575 $this->msg( 'version-extensions' )->text()
578 if (
579 !$credits ||
580 // Skins are displayed separately, see getSkinCredits()
581 ( count( $credits ) === 1 && isset( $credits['skin'] ) )
583 $out .= Html::element(
584 'p',
586 $this->msg( 'version-extensions-no-ext' )->text()
589 return $out;
592 // Find all extensions that do not have a valid type and give them the type 'other'.
593 $credits['other'] ??= [];
594 foreach ( $credits as $type => $extensions ) {
595 if ( !array_key_exists( $type, $extensionTypes ) ) {
596 $credits['other'] = array_merge( $credits['other'], $extensions );
600 // Loop through the extension categories to display their extensions in the list.
601 foreach ( $extensionTypes as $type => $text ) {
602 // Skins have a separate section
603 if ( $type !== 'other' && $type !== 'skin' ) {
604 $out .= $this->getExtensionCategory( $type, $text, $credits[$type] ?? [] );
608 // We want the 'other' type to be last in the list.
609 $out .= $this->getExtensionCategory( 'other', $extensionTypes['other'], $credits['other'] );
611 return $out;
615 * Generate HTML showing the name, URL, author and description of each skin.
617 * @param array $credits
618 * @return string HTML
620 private function getSkinCredits( array $credits ) {
621 $this->addTocSection( 'version-skins', 'mw-version-skin' );
623 $out = Html::element(
624 'h2',
625 [ 'id' => 'mw-version-skin' ],
626 $this->msg( 'version-skins' )->text()
629 if ( !isset( $credits['skin'] ) || !$credits['skin'] ) {
630 $out .= Html::element(
631 'p',
633 $this->msg( 'version-skins-no-skin' )->text()
636 return $out;
638 $out .= $this->getExtensionCategory( 'skin', null, $credits['skin'] );
640 return $out;
644 * Generate the section for installed external libraries
646 * @param array $credits
647 * @return string
649 protected function getLibraries( array $credits ) {
650 $this->addTocSection( 'version-libraries', 'mw-version-libraries' );
652 $out = Html::element(
653 'h2',
654 [ 'id' => 'mw-version-libraries' ],
655 $this->msg( 'version-libraries' )->text()
658 return $out
659 . $this->getExternalLibraries( $credits )
660 . $this->getClientSideLibraries();
664 * Generate an HTML table for external server-side libraries that are installed
666 * @param array $credits
667 * @return string
669 protected function getExternalLibraries( array $credits ) {
670 $paths = [
671 MW_INSTALL_PATH . '/vendor/composer/installed.json'
674 $extensionTypes = self::getExtensionTypes();
675 foreach ( $extensionTypes as $type => $message ) {
676 if ( !isset( $credits[$type] ) || $credits[$type] === [] ) {
677 continue;
679 foreach ( $credits[$type] as $extension ) {
680 if ( !isset( $extension['path'] ) ) {
681 continue;
683 $paths[] = dirname( $extension['path'] ) . '/vendor/composer/installed.json';
687 $dependencies = [];
689 foreach ( $paths as $path ) {
690 if ( !file_exists( $path ) ) {
691 continue;
694 $installed = new ComposerInstalled( $path );
696 $dependencies += $installed->getInstalledDependencies();
699 if ( $dependencies === [] ) {
700 return '';
703 ksort( $dependencies );
705 $this->addTocSubSection( $this->msg( 'version-libraries-server' )->text(), 'mw-version-libraries-server' );
707 $out = Html::element(
708 'h3',
709 [ 'id' => 'mw-version-libraries-server' ],
710 $this->msg( 'version-libraries-server' )->text()
712 $out .= Html::openElement(
713 'table',
714 [ 'class' => 'wikitable plainlinks mw-installed-software', 'id' => 'sv-libraries' ]
717 $out .= $this->getTableHeaderHtml( [
718 $this->msg( 'version-libraries-library' )->text(),
719 $this->msg( 'version-libraries-version' )->text(),
720 $this->msg( 'version-libraries-license' )->text(),
721 $this->msg( 'version-libraries-description' )->text(),
722 $this->msg( 'version-libraries-authors' )->text(),
723 ] );
725 foreach ( $dependencies as $name => $info ) {
726 if ( !is_array( $info ) || str_starts_with( $info['type'], 'mediawiki-' ) ) {
727 // Skip any extensions or skins since they'll be listed
728 // in their proper section
729 continue;
731 $authors = array_map( static function ( $arr ) {
732 return new HtmlArmor( isset( $arr['homepage'] ) ?
733 Html::element( 'a', [ 'href' => $arr['homepage'] ], $arr['name'] ) :
734 htmlspecialchars( $arr['name'] )
736 }, $info['authors'] );
737 $authors = $this->listAuthors( $authors, false, MW_INSTALL_PATH . "/vendor/$name" );
739 // We can safely assume that the libraries' names and descriptions
740 // are written in English and aren't going to be translated,
741 // so set appropriate lang and dir attributes
742 $out .= Html::openElement( 'tr', [
743 // Add an anchor so docs can link easily to the version of
744 // this specific library
745 'id' => Sanitizer::escapeIdForAttribute(
746 "mw-version-library-$name"
747 ) ] )
748 . Html::rawElement(
749 'td',
751 $this->getLinkRenderer()->makeExternalLink(
752 "https://packagist.org/packages/$name",
753 $name,
754 $this->getFullTitle(),
756 [ 'class' => 'mw-version-library-name' ]
759 . Html::element( 'td', [ 'dir' => 'auto' ], $info['version'] )
760 // @phan-suppress-next-line SecurityCheck-DoubleEscaped See FIXME in listToText
761 . Html::element( 'td', [ 'dir' => 'auto' ], $this->listToText( $info['licenses'] ) )
762 . Html::element( 'td', [ 'lang' => 'en', 'dir' => 'ltr' ], $info['description'] )
763 . Html::rawElement( 'td', [], $authors )
764 . Html::closeElement( 'tr' );
766 $out .= Html::closeElement( 'table' );
768 return $out;
772 * @internal
773 * @since 1.42
774 * @return array
776 public static function parseForeignResources() {
777 $registryDirs = [ 'MediaWiki' => MW_INSTALL_PATH . '/resources/lib' ]
778 + ExtensionRegistry::getInstance()->getAttribute( 'ForeignResourcesDir' );
780 $modules = [];
781 foreach ( $registryDirs as $source => $registryDir ) {
782 $foreignResources = Yaml::parseFile( "$registryDir/foreign-resources.yaml" );
783 foreach ( $foreignResources as $name => $module ) {
784 $key = $name . $module['version'];
785 if ( isset( $modules[$key] ) ) {
786 $modules[$key]['source'][] = $source;
787 continue;
789 $modules[$key] = $module + [ 'name' => $name, 'source' => [ $source ] ];
792 ksort( $modules );
793 return $modules;
797 * Generate an HTML table for client-side libraries that are installed
799 * @return string HTML output
801 private function getClientSideLibraries() {
802 $this->addTocSubSection( $this->msg( 'version-libraries-client' )->text(), 'mw-version-libraries-client' );
804 $out = Html::element(
805 'h3',
806 [ 'id' => 'mw-version-libraries-client' ],
807 $this->msg( 'version-libraries-client' )->text()
809 $out .= Html::openElement(
810 'table',
811 [ 'class' => 'wikitable plainlinks mw-installed-software', 'id' => 'sv-libraries-client' ]
814 $out .= $this->getTableHeaderHtml( [
815 $this->msg( 'version-libraries-library' )->text(),
816 $this->msg( 'version-libraries-version' )->text(),
817 $this->msg( 'version-libraries-license' )->text(),
818 $this->msg( 'version-libraries-authors' )->text(),
819 $this->msg( 'version-libraries-source' )->text()
820 ] );
822 foreach ( self::parseForeignResources() as $name => $info ) {
823 // We can safely assume that the libraries' names and descriptions
824 // are written in English and aren't going to be translated,
825 // so set appropriate lang and dir attributes
826 $out .= Html::openElement( 'tr', [
827 // Add an anchor so docs can link easily to the version of
828 // this specific library
829 'id' => Sanitizer::escapeIdForAttribute(
830 "mw-version-library-$name"
831 ) ] )
832 . Html::rawElement(
833 'td',
835 $this->getLinkRenderer()->makeExternalLink(
836 $info['homepage'],
837 $info['name'],
838 $this->getFullTitle(),
840 [ 'class' => 'mw-version-library-name' ]
843 . Html::element( 'td', [ 'dir' => 'auto' ], $info['version'] )
844 . Html::element( 'td', [ 'dir' => 'auto' ], $info['license'] )
845 . Html::element( 'td', [ 'dir' => 'auto' ], $info['authors'] ?? '—' )
846 // @phan-suppress-next-line SecurityCheck-DoubleEscaped See FIXME in listToText
847 . Html::element( 'td', [ 'dir' => 'auto' ], $this->listToText( $info['source'] ) )
848 . Html::closeElement( 'tr' );
850 $out .= Html::closeElement( 'table' );
852 return $out;
856 * Obtains a list of installed parser tags and the associated H2 header
858 * @return string HTML output
860 protected function getParserTags() {
861 $tags = $this->parserFactory->getMainInstance()->getTags();
862 if ( !$tags ) {
863 return '';
866 $this->addTocSection( 'version-parser-extensiontags', 'mw-version-parser-extensiontags' );
868 $out = Html::rawElement(
869 'h2',
870 [ 'id' => 'mw-version-parser-extensiontags' ],
871 Html::rawElement(
872 'span',
873 [ 'class' => 'plainlinks' ],
874 $this->getLinkRenderer()->makeExternalLink(
875 'https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Tag_extensions',
876 $this->msg( 'version-parser-extensiontags' ),
877 $this->getFullTitle()
882 array_walk( $tags, static function ( &$value ) {
883 // Bidirectional isolation improves readability in RTL wikis
884 $value = Html::rawElement(
885 'bdi',
886 // Prevent < and > from slipping to another line
888 'style' => 'white-space: nowrap;',
890 Html::element( 'code', [], "<$value>" )
892 } );
894 $out .= $this->listToText( $tags );
896 return $out;
900 * Obtains a list of installed parser function hooks and the associated H2 header
902 * @return string HTML output
904 protected function getParserFunctionHooks() {
905 $funcHooks = $this->parserFactory->getMainInstance()->getFunctionHooks();
906 if ( !$funcHooks ) {
907 return '';
910 $this->addTocSection( 'version-parser-function-hooks', 'mw-version-parser-function-hooks' );
912 $out = Html::rawElement(
913 'h2',
914 [ 'id' => 'mw-version-parser-function-hooks' ],
915 Html::rawElement(
916 'span',
917 [ 'class' => 'plainlinks' ],
918 $this->getLinkRenderer()->makeExternalLink(
919 'https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Parser_functions',
920 $this->msg( 'version-parser-function-hooks' ),
921 $this->getFullTitle()
926 $funcSynonyms = $this->parserFactory->getMainInstance()->getFunctionSynonyms();
927 // This will give us the preferred synonyms in the content language, as if
928 // we used MagicWord::getSynonym( 0 ), because they appear first in the arrays.
929 // We can't use MagicWord directly, because only Parser knows whether a function
930 // uses the leading "#" or not. Case-sensitive functions ("1") win over
931 // case-insensitive ones ("0"), like in Parser::callParserFunction().
932 // There should probably be a better API for this.
933 $preferredSynonyms = array_flip( array_reverse( $funcSynonyms[1] + $funcSynonyms[0] ) );
934 array_walk( $funcHooks, static function ( &$value ) use ( $preferredSynonyms ) {
935 $value = $preferredSynonyms[$value];
936 } );
938 // Sort case-insensitively, ignoring the leading '#' if present
939 usort( $funcHooks, static function ( $a, $b ) {
940 return strcasecmp( ltrim( $a, '#' ), ltrim( $b, '#' ) );
941 } );
943 array_walk( $funcHooks, static function ( &$value ) {
944 // Bidirectional isolation ensures it displays as {{#ns}} and not {{ns#}} in RTL wikis
945 $value = Html::rawElement(
946 'bdi',
948 Html::element( 'code', [], '{{' . $value . '}}' )
950 } );
952 $out .= $this->getLanguage()->listToText( $funcHooks );
954 return $out;
958 * Creates and returns the HTML for a single extension category.
960 * @since 1.17
961 * @param string $type
962 * @param string|null $text
963 * @param array $creditsGroup
964 * @return string
966 protected function getExtensionCategory( $type, ?string $text, array $creditsGroup ) {
967 $out = '';
969 if ( $creditsGroup ) {
970 $out .= $this->openExtType( $text, 'credits-' . $type );
972 usort( $creditsGroup, [ $this, 'compare' ] );
974 foreach ( $creditsGroup as $extension ) {
975 $out .= $this->getCreditsForExtension( $type, $extension );
978 $out .= Html::closeElement( 'table' );
981 return $out;
985 * Callback to sort extensions by type.
986 * @param array $a
987 * @param array $b
988 * @return int
990 public function compare( $a, $b ) {
991 return $this->getLanguage()->lc( $a['name'] ) <=> $this->getLanguage()->lc( $b['name'] );
995 * Creates and formats a version line for a single extension.
997 * Information for five columns will be created. Parameters required in the
998 * $extension array for part rendering are indicated in ()
999 * - The name of (name), and URL link to (url), the extension
1000 * - Official version number (version) and if available version control system
1001 * revision (path), link, and date
1002 * - If available the short name of the license (license-name) and a link
1003 * to ((LICENSE)|(COPYING))(\.txt)? if it exists.
1004 * - Description of extension (descriptionmsg or description)
1005 * - List of authors (author) and link to a ((AUTHORS)|(CREDITS))(\.txt)? file if it exists
1007 * @param string $type Category name of the extension
1008 * @param array $extension
1010 * @return string Raw HTML
1012 public function getCreditsForExtension( $type, array $extension ) {
1013 $out = $this->getOutput();
1015 // We must obtain the information for all the bits and pieces!
1016 // ... such as extension names and links
1017 if ( isset( $extension['namemsg'] ) ) {
1018 // Localized name of extension
1019 $extensionName = $this->msg( $extension['namemsg'] );
1020 } elseif ( isset( $extension['name'] ) ) {
1021 // Non localized version
1022 $extensionName = $extension['name'];
1023 } else {
1024 $extensionName = $this->msg( 'version-no-ext-name' );
1027 if ( isset( $extension['url'] ) ) {
1028 $extensionNameLink = $this->getLinkRenderer()->makeExternalLink(
1029 $extension['url'],
1030 $extensionName,
1031 $this->getFullTitle(),
1033 [ 'class' => 'mw-version-ext-name' ]
1035 } else {
1036 $extensionNameLink = htmlspecialchars( $extensionName );
1039 // ... and the version information
1040 // If the extension path is set we will check that directory for GIT
1041 // metadata in an attempt to extract date and vcs commit metadata.
1042 $canonicalVersion = '&ndash;';
1043 $extensionPath = null;
1044 $vcsVersion = null;
1045 $vcsLink = null;
1046 $vcsDate = null;
1048 if ( isset( $extension['version'] ) ) {
1049 $canonicalVersion = $out->parseInlineAsInterface( $extension['version'] );
1052 if ( isset( $extension['path'] ) ) {
1053 $extensionPath = dirname( $extension['path'] );
1054 if ( $this->coreId == '' ) {
1055 wfDebug( 'Looking up core head id' );
1056 $coreHeadSHA1 = GitInfo::repo()->getHeadSHA1();
1057 if ( $coreHeadSHA1 ) {
1058 $this->coreId = $coreHeadSHA1;
1061 $cache = MediaWikiServices::getInstance()->getObjectCacheFactory()->getInstance( CACHE_ANYTHING );
1062 $memcKey = $cache->makeKey(
1063 'specialversion-ext-version-text', $extension['path'], $this->coreId
1065 [ $vcsVersion, $vcsLink, $vcsDate ] = $cache->get( $memcKey );
1067 if ( !$vcsVersion ) {
1068 wfDebug( "Getting VCS info for extension {$extension['name']}" );
1069 $gitInfo = new GitInfo( $extensionPath );
1070 $vcsVersion = $gitInfo->getHeadSHA1();
1071 if ( $vcsVersion !== false ) {
1072 $vcsVersion = substr( $vcsVersion, 0, 7 );
1073 $vcsLink = $gitInfo->getHeadViewUrl();
1074 $vcsDate = $gitInfo->getHeadCommitDate();
1076 $cache->set( $memcKey, [ $vcsVersion, $vcsLink, $vcsDate ], 60 * 60 * 24 );
1077 } else {
1078 wfDebug( "Pulled VCS info for extension {$extension['name']} from cache" );
1082 $versionString = Html::rawElement(
1083 'span',
1084 [ 'class' => 'mw-version-ext-version' ],
1085 $canonicalVersion
1088 if ( $vcsVersion ) {
1089 if ( $vcsLink ) {
1090 $vcsVerString = $this->getLinkRenderer()->makeExternalLink(
1091 $vcsLink,
1092 $this->msg( 'version-version', $vcsVersion ),
1093 $this->getFullTitle(),
1095 [ 'class' => 'mw-version-ext-vcs-version' ]
1097 } else {
1098 $vcsVerString = Html::element( 'span',
1099 [ 'class' => 'mw-version-ext-vcs-version' ],
1100 "({$vcsVersion})"
1103 $versionString .= " {$vcsVerString}";
1105 if ( $vcsDate ) {
1106 $versionString .= ' ' . Html::element( 'span', [
1107 'class' => 'mw-version-ext-vcs-timestamp',
1108 'dir' => $this->getLanguage()->getDir(),
1109 ], $this->getLanguage()->timeanddate( $vcsDate, true ) );
1111 $versionString = Html::rawElement( 'span',
1112 [ 'class' => 'mw-version-ext-meta-version' ],
1113 $versionString
1117 // ... and license information; if a license file exists we
1118 // will link to it
1119 $licenseLink = '';
1120 if ( isset( $extension['name'] ) ) {
1121 $licenseName = null;
1122 if ( isset( $extension['license-name'] ) ) {
1123 $licenseName = new HtmlArmor( $out->parseInlineAsInterface( $extension['license-name'] ) );
1124 } elseif ( $extensionPath !== null && ExtensionInfo::getLicenseFileNames( $extensionPath ) ) {
1125 $licenseName = $this->msg( 'version-ext-license' )->text();
1127 if ( $licenseName !== null ) {
1128 $licenseLink = $this->getLinkRenderer()->makeLink(
1129 $this->getPageTitle( 'License/' . $extension['name'] ),
1130 $licenseName,
1132 'class' => 'mw-version-ext-license',
1133 'dir' => 'auto',
1139 // ... and generate the description; which can be a parameterized l10n message
1140 // in the form [ <msgname>, <parameter>, <parameter>... ] or just a straight
1141 // up string
1142 if ( isset( $extension['descriptionmsg'] ) ) {
1143 // Localized description of extension
1144 $descriptionMsg = $extension['descriptionmsg'];
1146 if ( is_array( $descriptionMsg ) ) {
1147 $descriptionMsgKey = array_shift( $descriptionMsg );
1148 $descriptionMsg = array_map( 'htmlspecialchars', $descriptionMsg );
1149 $description = $this->msg( $descriptionMsgKey, ...$descriptionMsg )->text();
1150 } else {
1151 $description = $this->msg( $descriptionMsg )->text();
1153 } elseif ( isset( $extension['description'] ) ) {
1154 // Non localized version
1155 $description = $extension['description'];
1156 } else {
1157 $description = '';
1159 $description = $out->parseInlineAsInterface( $description );
1161 // ... now get the authors for this extension
1162 $authors = $extension['author'] ?? [];
1163 // @phan-suppress-next-line PhanTypeMismatchArgumentNullable path is set when there is a name
1164 $authors = $this->listAuthors( $authors, $extension['name'], $extensionPath );
1166 // Finally! Create the table
1167 $html = Html::openElement( 'tr', [
1168 'class' => 'mw-version-ext',
1169 'id' => Sanitizer::escapeIdForAttribute( 'mw-version-ext-' . $type . '-' . $extension['name'] )
1173 $html .= Html::rawElement( 'td', [], $extensionNameLink );
1174 $html .= Html::rawElement( 'td', [], $versionString );
1175 $html .= Html::rawElement( 'td', [], $licenseLink );
1176 $html .= Html::rawElement( 'td', [ 'class' => 'mw-version-ext-description' ], $description );
1177 $html .= Html::rawElement( 'td', [ 'class' => 'mw-version-ext-authors' ], $authors );
1179 $html .= Html::closeElement( 'tr' );
1181 return $html;
1185 * Generate HTML showing hooks in $wgHooks.
1187 * @return string HTML
1189 private function getHooks() {
1190 if ( !$this->getConfig()->get( MainConfigNames::SpecialVersionShowHooks ) ) {
1191 return '';
1194 $hookContainer = MediaWikiServices::getInstance()->getHookContainer();
1195 $hookNames = $hookContainer->getHookNames();
1197 if ( !$hookNames ) {
1198 return '';
1201 sort( $hookNames );
1203 $ret = [];
1204 $this->addTocSection( 'version-hooks', 'mw-version-hooks' );
1205 $ret[] = Html::element(
1206 'h2',
1207 [ 'id' => 'mw-version-hooks' ],
1208 $this->msg( 'version-hooks' )->text()
1210 $ret[] = Html::openElement( 'table', [ 'class' => 'wikitable', 'id' => 'sv-hooks' ] );
1211 $ret[] = Html::openElement( 'tr' );
1212 $ret[] = Html::element( 'th', [], $this->msg( 'version-hook-name' )->text() );
1213 $ret[] = Html::element( 'th', [], $this->msg( 'version-hook-subscribedby' )->text() );
1214 $ret[] = Html::closeElement( 'tr' );
1216 foreach ( $hookNames as $name ) {
1217 $handlers = $hookContainer->getHandlerDescriptions( $name );
1219 $ret[] = Html::openElement( 'tr' );
1220 $ret[] = Html::element( 'td', [], $name );
1221 // @phan-suppress-next-line SecurityCheck-DoubleEscaped See FIXME in listToText
1222 $ret[] = Html::element( 'td', [], $this->listToText( $handlers ) );
1223 $ret[] = Html::closeElement( 'tr' );
1226 $ret[] = Html::closeElement( 'table' );
1228 return implode( "\n", $ret );
1231 private function openExtType( ?string $text = null, ?string $name = null ) {
1232 $out = '';
1234 $opt = [ 'class' => 'wikitable plainlinks mw-installed-software' ];
1236 if ( $name ) {
1237 $opt['id'] = "sv-$name";
1240 $out .= Html::openElement( 'table', $opt );
1242 if ( $text !== null ) {
1243 $out .= Html::element( 'caption', [], $text );
1246 if ( $name && $text !== null ) {
1247 $this->addTocSubSection( $text, "sv-$name" );
1250 $firstHeadingMsg = ( $name === 'credits-skin' )
1251 ? 'version-skin-colheader-name'
1252 : 'version-ext-colheader-name';
1254 $out .= $this->getTableHeaderHtml( [
1255 $this->msg( $firstHeadingMsg )->text(),
1256 $this->msg( 'version-ext-colheader-version' )->text(),
1257 $this->msg( 'version-ext-colheader-license' )->text(),
1258 $this->msg( 'version-ext-colheader-description' )->text(),
1259 $this->msg( 'version-ext-colheader-credits' )->text()
1260 ] );
1262 return $out;
1266 * Return HTML for a table header with given texts in header cells
1268 * Includes thead element and scope="col" attribute for improved accessibility
1270 * @param string|array $headers
1271 * @return string HTML
1273 private function getTableHeaderHtml( $headers ): string {
1274 $out = '';
1275 $out .= Html::openElement( 'thead' );
1276 $out .= Html::openElement( 'tr' );
1277 foreach ( $headers as $header ) {
1278 $out .= Html::element( 'th', [ 'scope' => 'col' ], $header );
1280 $out .= Html::closeElement( 'tr' );
1281 $out .= Html::closeElement( 'thead' );
1282 return $out;
1286 * Get information about client's IP address.
1288 * @return string HTML fragment
1290 private function IPInfo() {
1291 $ip = str_replace( '--', ' - ', htmlspecialchars( $this->getRequest()->getIP() ) );
1293 return "<!-- visited from $ip -->\n<span style='display:none'>visited from $ip</span>";
1297 * Return a formatted unsorted list of authors
1299 * 'And Others'
1300 * If an item in the $authors array is '...' it is assumed to indicate an
1301 * 'and others' string which will then be linked to an ((AUTHORS)|(CREDITS))(\.txt)?
1302 * file if it exists in $dir.
1304 * Similarly an entry ending with ' ...]' is assumed to be a link to an
1305 * 'and others' page.
1307 * If no '...' string variant is found, but an authors file is found an
1308 * 'and others' will be added to the end of the credits.
1310 * @param string|array $authors
1311 * @param string|bool $extName Name of the extension for link creation,
1312 * false if no links should be created
1313 * @param string $extDir Path to the extension root directory
1314 * @return string HTML fragment
1316 public function listAuthors( $authors, $extName, $extDir ): string {
1317 $hasOthers = false;
1318 $linkRenderer = $this->getLinkRenderer();
1320 $list = [];
1321 $authors = (array)$authors;
1323 // Special case: if the authors array has only one item and it is "...",
1324 // it should not be rendered as the "version-poweredby-others" i18n msg,
1325 // but rather as "version-poweredby-various" i18n msg instead.
1326 if ( count( $authors ) === 1 && $authors[0] === '...' ) {
1327 // Link to the extension's or skin's AUTHORS or CREDITS file, if there is
1328 // such a file; otherwise just return the i18n msg as-is
1329 if ( $extName && ExtensionInfo::getAuthorsFileName( $extDir ) ) {
1330 return $linkRenderer->makeLink(
1331 $this->getPageTitle( "Credits/$extName" ),
1332 $this->msg( 'version-poweredby-various' )->text()
1334 } else {
1335 return $this->msg( 'version-poweredby-various' )->escaped();
1339 // Otherwise, if we have an actual array that has more than one item,
1340 // process each array item as usual
1341 foreach ( $authors as $item ) {
1342 if ( $item instanceof HtmlArmor ) {
1343 $list[] = HtmlArmor::getHtml( $item );
1344 } elseif ( $item === '...' ) {
1345 $hasOthers = true;
1347 if ( $extName && ExtensionInfo::getAuthorsFileName( $extDir ) ) {
1348 $text = $linkRenderer->makeLink(
1349 $this->getPageTitle( "Credits/$extName" ),
1350 $this->msg( 'version-poweredby-others' )->text()
1352 } else {
1353 $text = $this->msg( 'version-poweredby-others' )->escaped();
1355 $list[] = $text;
1356 } elseif ( str_ends_with( $item, ' ...]' ) ) {
1357 $hasOthers = true;
1358 $list[] = $this->getOutput()->parseInlineAsInterface(
1359 substr( $item, 0, -4 ) . $this->msg( 'version-poweredby-others' )->text() . "]"
1361 } else {
1362 $list[] = $this->getOutput()->parseInlineAsInterface( $item );
1366 if ( $extName && !$hasOthers && ExtensionInfo::getAuthorsFileName( $extDir ) ) {
1367 $list[] = $linkRenderer->makeLink(
1368 $this->getPageTitle( "Credits/$extName" ),
1369 $this->msg( 'version-poweredby-others' )->text()
1373 return $this->listToText( $list, false );
1377 * Convert an array of items into a list for display.
1379 * @param array $list List of elements to display
1380 * @param bool $sort Whether to sort the items in $list
1381 * @return string
1382 * @fixme This method does not handle escaping consistently. Language::listToText expects all list elements to be
1383 * already escaped. However, self::arrayToString escapes some elements, but not others.
1385 private function listToText( array $list, bool $sort = true ): string {
1386 if ( !$list ) {
1387 return '';
1389 if ( $sort ) {
1390 sort( $list );
1393 return $this->getLanguage()
1394 ->listToText( array_map( [ __CLASS__, 'arrayToString' ], $list ) );
1398 * Convert an array or object to a string for display.
1400 * @internal For use by ApiQuerySiteinfo (TODO: Turn into more stable method)
1401 * @param mixed $list Will convert an array to string if given and return
1402 * the parameter unaltered otherwise
1403 * @return mixed
1404 * @fixme This should handle escaping more consistently, see FIXME in listToText
1406 public static function arrayToString( $list ) {
1407 if ( is_array( $list ) && count( $list ) == 1 ) {
1408 $list = $list[0];
1410 if ( $list instanceof Closure ) {
1411 // Don't output stuff like "Closure$;1028376090#8$48499d94fe0147f7c633b365be39952b$"
1412 return 'Closure';
1413 } elseif ( is_object( $list ) ) {
1414 return wfMessage( 'parentheses' )->params( get_class( $list ) )->escaped();
1415 } elseif ( !is_array( $list ) ) {
1416 return $list;
1417 } else {
1418 if ( is_object( $list[0] ) ) {
1419 $class = get_class( $list[0] );
1420 } else {
1421 $class = $list[0];
1424 return wfMessage( 'parentheses' )->params( "$class, {$list[1]}" )->escaped();
1429 * @deprecated since 1.41 Use GitInfo::repo() for MW_INSTALL_PATH, or new GitInfo otherwise.
1430 * @param string $dir Directory of the git checkout
1431 * @return string|false Sha1 of commit HEAD points to
1433 public static function getGitHeadSha1( $dir ) {
1434 wfDeprecated( __METHOD__, '1.41' );
1435 return ( new GitInfo( $dir ) )->getHeadSHA1();
1439 * Get the list of entry points and their URLs
1440 * @return string HTML
1442 public function getEntryPointInfo() {
1443 $config = $this->getConfig();
1444 $scriptPath = $config->get( MainConfigNames::ScriptPath ) ?: '/';
1446 $entryPoints = [
1447 'version-entrypoints-articlepath' => $config->get( MainConfigNames::ArticlePath ),
1448 'version-entrypoints-scriptpath' => $scriptPath,
1449 'version-entrypoints-index-php' => wfScript( 'index' ),
1450 'version-entrypoints-api-php' => wfScript( 'api' ),
1451 'version-entrypoints-rest-php' => wfScript( 'rest' ),
1454 $language = $this->getLanguage();
1455 $thAttributes = [
1456 'dir' => $language->getDir(),
1457 'lang' => $language->getHtmlCode(),
1458 'scope' => 'col'
1461 $this->addTocSection( 'version-entrypoints', 'mw-version-entrypoints' );
1463 $out = Html::element(
1464 'h2',
1465 [ 'id' => 'mw-version-entrypoints' ],
1466 $this->msg( 'version-entrypoints' )->text()
1468 Html::openElement( 'table',
1470 'class' => 'wikitable plainlinks',
1471 'id' => 'mw-version-entrypoints-table',
1472 'dir' => 'ltr',
1473 'lang' => 'en'
1476 Html::openElement( 'thead' ) .
1477 Html::openElement( 'tr' ) .
1478 Html::element(
1479 'th',
1480 $thAttributes,
1481 $this->msg( 'version-entrypoints-header-entrypoint' )->text()
1483 Html::element(
1484 'th',
1485 $thAttributes,
1486 $this->msg( 'version-entrypoints-header-url' )->text()
1488 Html::closeElement( 'tr' ) .
1489 Html::closeElement( 'thead' );
1491 foreach ( $entryPoints as $message => $value ) {
1492 $url = $this->urlUtils->expand( $value, PROTO_RELATIVE );
1493 $out .= Html::openElement( 'tr' ) .
1494 Html::rawElement( 'td', [], $this->msg( $message )->parse() ) .
1495 Html::rawElement( 'td', [],
1496 Html::rawElement(
1497 'code',
1499 $this->msg( new RawMessage( "[$url $value]" ) )->parse()
1502 Html::closeElement( 'tr' );
1505 $out .= Html::closeElement( 'table' );
1507 return $out;
1510 protected function getGroupName() {
1511 return 'wiki';
1516 * Retain the old class name for backwards compatibility.
1517 * @deprecated since 1.41
1519 class_alias( SpecialVersion::class, 'SpecialVersion' );