Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
51.49% covered (warning)
51.49%
431 / 837
17.65% covered (danger)
17.65%
6 / 34
CRAP
0.00% covered (danger)
0.00%
0 / 1
SkinTemplate
51.56% covered (warning)
51.56%
431 / 836
17.65% covered (danger)
17.65%
6 / 34
5671.99
0.00% covered (danger)
0.00%
0 / 1
 setupTemplate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setupTemplateForOutput
55.56% covered (warning)
55.56%
5 / 9
0.00% covered (danger)
0.00%
0 / 1
2.35
 setupTemplateContext
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
4.00
 generateHTML
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 outputPage
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getTemplateData
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 prepareQuickTemplate
100.00% covered (success)
100.00%
115 / 115
100.00% covered (success)
100.00%
1 / 1
10
 makePersonalToolsList
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getStructuredPersonalTools
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 buildPersonalUrls
35.06% covered (danger)
35.06%
27 / 77
0.00% covered (danger)
0.00%
0 / 1
167.84
 useCombinedLoginLink
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 buildLoginData
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 getCategoryPortletsData
30.00% covered (danger)
30.00%
3 / 10
0.00% covered (danger)
0.00%
0 / 1
6.09
 getCategoryLinks
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getPortletsTemplateData
94.87% covered (success)
94.87%
37 / 39
0.00% covered (danger)
0.00%
0 / 1
12.02
 buildLogoutLinkData
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 buildCreateAccountData
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 addPersonalPageItem
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
2.50
 buildPersonalPageItem
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 buildWatchlistData
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 tabAction
61.36% covered (warning)
61.36%
27 / 44
0.00% covered (danger)
0.00%
0 / 1
27.98
 getSkinNavOverrideableLabel
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
3.24
 makeTalkUrlDetails
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getWatchLinkAttrs
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
72
 runOnSkinTemplateNavigationHooks
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 buildContentNavigationUrlsInternal
51.79% covered (warning)
51.79%
130 / 251
0.00% covered (danger)
0.00%
0 / 1
778.18
 getSpecialPageAssociatedNavigationLinks
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
42
 buildContentActionUrls
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
9.86
 injectLegacyMenusIntoPersonalTools
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 makeSkinTemplatePersonalUrls
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 makeSearchInput
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 makeSearchButton
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
30
 isSpecialContributeShowable
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 makeContributionsLink
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2/**
3 * Copyright © Gabriel Wicke -- http://www.aulinx.de/
4 *
5 * @license GPL-2.0-or-later
6 * @file
7 */
8
9namespace MediaWiki\Skin;
10
11use InvalidArgumentException;
12use MediaWiki\Debug\MWDebug;
13use MediaWiki\Exception\MWException;
14use MediaWiki\Html\Html;
15use MediaWiki\Language\LanguageCode;
16use MediaWiki\Linker\Linker;
17use MediaWiki\MainConfigNames;
18use MediaWiki\MediaWikiServices;
19use MediaWiki\Message\Message;
20use MediaWiki\Parser\ParserOutputFlags;
21use MediaWiki\Permissions\Authority;
22use MediaWiki\ResourceLoader as RL;
23use MediaWiki\Skin\Components\SkinComponentUtils;
24use MediaWiki\SpecialPage\SpecialPage;
25use MediaWiki\Specials\Contribute\ContributeFactory;
26use MediaWiki\Title\Title;
27use Profiler;
28use RuntimeException;
29use Wikimedia\Message\MessageParam;
30use Wikimedia\Message\MessageSpecifier;
31use Wikimedia\Timestamp\TimestampFormat as TS;
32
33/**
34 * Base class for QuickTemplate-based skins.
35 *
36 * The template data is filled in SkinTemplate::prepareQuickTemplate.
37 *
38 * @stable to extend
39 * @ingroup Skins
40 */
41class SkinTemplate extends Skin {
42    /**
43     * @var string For QuickTemplate, the name of the subclass which will
44     *   actually fill the template.
45     */
46    public $template;
47
48    /** @var string */
49    public $thispage;
50    /** @var string */
51    public $titletxt;
52    /** @var string */
53    public $userpage;
54    /** @var bool TODO: Rename this to $isRegistered (but that's a breaking change) */
55    public $loggedin;
56    /** @var string */
57    public $username;
58    /** @var array */
59    public $userpageUrlDetails;
60
61    /** @var bool */
62    private $isTempUser;
63
64    /** @var bool */
65    private $isNamedUser;
66
67    /** @var bool */
68    private $isAnonUser;
69
70    /** @var bool */
71    private $templateContextSet = false;
72    /** @var array|null */
73    private $contentNavigationCached;
74    /** @var array|null */
75    private $portletsCached;
76
77    /**
78     * Create the template engine object; we feed it a bunch of data
79     * and eventually it spits out some HTML. Should have interface
80     * roughly equivalent to PHPTAL 0.7.
81     *
82     * @param class-string<QuickTemplate> $classname
83     * @return QuickTemplate
84     */
85    protected function setupTemplate( $classname ) {
86        return new $classname( $this->getConfig() );
87    }
88
89    /**
90     * @return QuickTemplate
91     */
92    protected function setupTemplateForOutput() {
93        $this->setupTemplateContext();
94        $template = $this->options['template'] ?? $this->template;
95        if ( !$template ) {
96            throw new RuntimeException(
97                'SkinTemplate skins must define a `template` either as a public'
98                    . ' property of by passing in a`template` option to the constructor.'
99            );
100        }
101        $tpl = $this->setupTemplate( $template );
102        return $tpl;
103    }
104
105    /**
106     * Setup class properties that are necessary prior to calling
107     * setupTemplateForOutput. It must be called inside
108     * prepareQuickTemplate.
109     * This function may set local class properties that will be used
110     * by other methods, but should not make assumptions about the
111     * implementation of setupTemplateForOutput
112     * @since 1.35
113     */
114    final protected function setupTemplateContext() {
115        if ( $this->templateContextSet ) {
116            return;
117        }
118
119        $request = $this->getRequest();
120        $user = $this->getUser();
121        $title = $this->getTitle();
122        $this->thispage = $title->getPrefixedDBkey();
123        $this->titletxt = $title->getPrefixedText();
124        $userpageTitle = $user->getUserPage();
125        $this->userpage = $userpageTitle->getPrefixedText();
126        $this->loggedin = $user->isRegistered();
127        $this->username = $user->getName();
128        $this->isTempUser = $user->isTemp();
129        $this->isNamedUser = $this->loggedin && !$this->isTempUser;
130        $this->isAnonUser = $user->isAnon();
131
132        if ( $this->isNamedUser ) {
133            $this->userpageUrlDetails = self::makeUrlDetails( $userpageTitle );
134        } else {
135            # This won't be used in the standard skins, but we define it to preserve the interface
136            # To save time, we check for existence
137            $this->userpageUrlDetails = self::makeKnownUrlDetails( $userpageTitle );
138        }
139
140        $this->templateContextSet = true;
141    }
142
143    /**
144     * Subclasses not wishing to use the QuickTemplate
145     * render method can rewrite this method, for example to use
146     * TemplateParser::processTemplate
147     * @since 1.35
148     * @return string HTML is the contents of the body tag e.g. <body>...</body>
149     */
150    public function generateHTML() {
151        $tpl = $this->prepareQuickTemplate();
152        $options = $this->getOptions();
153        $out = $this->getOutput();
154        // execute template
155        ob_start();
156        $tpl->execute();
157        $html = ob_get_contents();
158        ob_end_clean();
159
160        return $html;
161    }
162
163    /**
164     * Initialize various variables and generate the template
165     * @stable to override
166     */
167    public function outputPage() {
168        Profiler::instance()->setAllowOutput();
169        $out = $this->getOutput();
170
171        $this->initPage( $out );
172        $out->addJsConfigVars( $this->getJsConfigVars() );
173
174        // result may be an error
175        echo $this->generateHTML();
176    }
177
178    /**
179     * @inheritDoc
180     */
181    public function getTemplateData() {
182        return parent::getTemplateData() + $this->getPortletsTemplateData();
183    }
184
185    /**
186     * initialize various variables and generate the template
187     *
188     * @since 1.23
189     * @return QuickTemplate The template to be executed by outputPage
190     */
191    protected function prepareQuickTemplate() {
192        $title = $this->getTitle();
193        $request = $this->getRequest();
194        $out = $this->getOutput();
195        $config = $this->getConfig();
196        $tpl = $this->setupTemplateForOutput();
197
198        $tpl->set( 'title', $out->getPageTitle() );
199        $tpl->set( 'pagetitle', $out->getHTMLTitle() );
200
201        $tpl->set( 'thispage', $this->thispage );
202        $tpl->set( 'titleprefixeddbkey', $this->thispage );
203        $tpl->set( 'titletext', $title->getText() );
204        $tpl->set( 'articleid', $title->getArticleID() );
205
206        $tpl->set( 'isarticle', $out->isArticle() );
207
208        $tpl->set( 'subtitle', $this->prepareSubtitle() );
209        $tpl->set( 'undelete', $this->prepareUndeleteLink() );
210
211        $tpl->set( 'catlinks', $this->getCategories() );
212        $feeds = $this->buildFeedUrls();
213        $tpl->set( 'feeds', count( $feeds ) ? $feeds : false );
214
215        $tpl->set( 'mimetype', $config->get( MainConfigNames::MimeType ) );
216        $tpl->set( 'charset', 'UTF-8' );
217        $tpl->set( 'wgScript', $config->get( MainConfigNames::Script ) );
218        $tpl->set( 'skinname', $this->skinname );
219        $tpl->set( 'skinclass', static::class );
220        $tpl->set( 'skin', $this );
221        $tpl->set( 'printable', $out->isPrintable() );
222        $tpl->set( 'handheld', $request->getBool( 'handheld' ) );
223        $tpl->set( 'loggedin', $this->loggedin );
224        $tpl->set( 'notspecialpage', !$title->isSpecialPage() );
225
226        $searchTitle = SpecialPage::newSearchPage( $this->getUser() );
227        $searchLink = $searchTitle->getLocalURL();
228        $tpl->set( 'searchaction', $searchLink );
229        $tpl->deprecate( 'searchaction', '1.36' );
230
231        $tpl->set( 'searchtitle', $searchTitle->getPrefixedDBkey() );
232        $tpl->set( 'search', trim( $request->getVal( 'search', '' ) ) );
233        $tpl->set( 'stylepath', $config->get( MainConfigNames::StylePath ) );
234        $tpl->set( 'articlepath', $config->get( MainConfigNames::ArticlePath ) );
235        $tpl->set( 'scriptpath', $config->get( MainConfigNames::ScriptPath ) );
236        $tpl->set( 'serverurl', $config->get( MainConfigNames::Server ) );
237        $tpl->set( 'sitename', $config->get( MainConfigNames::Sitename ) );
238
239        $userLang = $this->getLanguage();
240        $userLangCode = $userLang->getHtmlCode();
241        $userLangDir = $userLang->getDir();
242
243        $tpl->set( 'lang', $userLangCode );
244        $tpl->set( 'dir', $userLangDir );
245        $tpl->set( 'rtl', $userLang->isRTL() );
246
247        $logos = RL\SkinModule::getAvailableLogos( $config, $userLangCode );
248        $tpl->set( 'logopath', $logos['1x'] );
249
250        $tpl->set( 'showjumplinks', true ); // showjumplinks preference has been removed
251        $tpl->set( 'username', $this->loggedin ? $this->username : null );
252        $tpl->set( 'userpage', $this->userpage );
253        $tpl->set( 'userpageurl', $this->userpageUrlDetails['href'] );
254        $tpl->set( 'userlang', $userLangCode );
255
256        // Users can have their language set differently than the
257        // content of the wiki. For these users, tell the web browser
258        // that interface elements are in a different language.
259        $tpl->set( 'userlangattributes', $this->prepareUserLanguageAttributes() );
260        $tpl->set( 'specialpageattributes', '' ); # obsolete
261        // Used by VectorBeta to insert HTML before content but after the
262        // heading for the page title. Defaults to empty string.
263        $tpl->set( 'prebodyhtml', '' );
264
265        $tpl->set( 'newtalk', $this->getNewtalks() );
266        $tpl->set( 'logo', $this->logoText() );
267
268        $footerData = $this->getComponent( 'footer' )->getTemplateData();
269        $tpl->set( 'copyright', $footerData['info']['copyright'] ?? false );
270        // No longer used
271        $tpl->set( 'viewcount', false );
272        $tpl->set( 'lastmod', $footerData['info']['lastmod'] ?? false );
273        $tpl->set( 'credits', $footerData['info']['credits'] ?? false );
274        $tpl->set( 'numberofwatchingusers', false );
275
276        $tpl->set( 'disclaimer', $footerData['places']['disclaimer'] ?? false );
277        $tpl->set( 'privacy', $footerData['places']['privacy'] ?? false );
278        $tpl->set( 'about', $footerData['places']['about'] ?? false );
279
280        // Flatten for compat with the 'footerlinks' key in QuickTemplate-based skins.
281        $flattenedfooterlinks = [];
282        foreach ( $footerData as $category => $data ) {
283            if ( $category !== 'data-icons' ) {
284                foreach ( $data['array-items'] as $item ) {
285                    $key = str_replace( 'data-', '', $category );
286                    $flattenedfooterlinks[$key][] = $item['name'];
287                    // For full support with BaseTemplate we also need to
288                    // copy over the keys.
289                    $tpl->set( $item['name'], $item['html'] );
290                }
291            }
292        }
293        $tpl->set( 'footerlinks', $flattenedfooterlinks );
294        $tpl->set( 'footericons', $this->getFooterIcons() );
295
296        $tpl->set( 'indicators', $out->getIndicators() );
297
298        $tpl->set( 'sitenotice', $this->getSiteNotice() );
299        $tpl->set( 'printfooter', $this->printSource() );
300        // Wrap the bodyText with #mw-content-text element
301        $tpl->set( 'bodytext', $this->wrapHTML( $title, $out->getHTML() ) );
302
303        $tpl->set( 'language_urls', $this->getLanguages() ?: false );
304
305        $content_navigation = $this->buildContentNavigationUrlsInternal();
306        $requestedMenus = $this->getOptions()['menus'];
307        # Personal toolbar
308        if ( !in_array( 'user-menu', $requestedMenus ) ) {
309            $tpl->set( 'personal_urls', $this->makeSkinTemplatePersonalUrls( $content_navigation ) );
310        }
311        // The user-menu, notifications, and user-interface-preferences are new content navigation entries which aren't
312        // expected to be part of content_navigation or content_actions. Adding them in there breaks skins that do not
313        // expect it. (See T316196)
314        $optInKeys = [
315            'user-menu',
316            'notifications',
317            'user-page',
318            'user-interface-preferences',
319            'category-normal',
320            'category-hidden',
321            'associated-pages',
322            // All historic menus are covered by requested menus so can be unset
323            // This should match the default for Skin::getOptions()['menus']
324            'namespaces',
325            'views',
326            'actions',
327            'variants'
328        ];
329        // We could iterate on keys of $requestedMenus but that might break skins making use of their own custom menus
330        // This is safer for backwards compatibility!
331        foreach ( $optInKeys as $key ) {
332            if ( !in_array( $key, $requestedMenus ) ) {
333                unset( $content_navigation[ $key ] );
334            }
335        }
336        $content_actions = $this->buildContentActionUrls( $content_navigation );
337        $tpl->set( 'content_navigation', $content_navigation );
338        $tpl->set( 'content_actions', $content_actions );
339
340        $tpl->set( 'sidebar', $this->buildSidebar() );
341        $tpl->set( 'nav_urls', $this->buildNavUrls() );
342
343        $tpl->set( 'debug', '' );
344        $tpl->set( 'debughtml', MWDebug::getHTMLDebugLog() );
345
346        // Set the bodytext to another key so that skins can just output it on its own
347        // and output printfooter and debughtml separately
348        $tpl->set( 'bodycontent', $tpl->data['bodytext'] );
349
350        // Append printfooter and debughtml onto bodytext so that skins that
351        // were already using bodytext before they were split out don't suddenly
352        // start not outputting information.
353        $tpl->data['bodytext'] .= Html::rawElement(
354            'div',
355            [ 'class' => 'printfooter' ],
356            "\n{$tpl->data['printfooter']}"
357        ) . "\n";
358        $tpl->data['bodytext'] .= $tpl->data['debughtml'];
359
360        // allow extensions adding stuff after the page content.
361        // See Skin::afterContentHook() for further documentation.
362        $tpl->set( 'dataAfterContent', $this->afterContentHook() );
363
364        return $tpl;
365    }
366
367    /**
368     * Get the HTML for the personal tools list
369     * @since 1.31
370     *
371     * @param array|null $personalTools
372     * @param array $options
373     * @return string
374     */
375    public function makePersonalToolsList( $personalTools = null, $options = [] ) {
376        $personalTools ??= $this->getPersonalToolsForMakeListItem(
377            $this->buildPersonalUrls()
378        );
379
380        $html = '';
381        foreach ( $personalTools as $key => $item ) {
382            $html .= $this->makeListItem( $key, $item, $options );
383        }
384        return $html;
385    }
386
387    /**
388     * Get personal tools for the user
389     *
390     * @since 1.31
391     *
392     * @return array[]
393     */
394    public function getStructuredPersonalTools() {
395        return $this->getPersonalToolsForMakeListItem(
396            $this->buildPersonalUrls()
397        );
398    }
399
400    /**
401     * Build array of urls for personal toolbar
402     *
403     * @param bool $includeNotifications Since 1.36, notifications are optional
404     * @return array
405     */
406    protected function buildPersonalUrls( bool $includeNotifications = true ) {
407        $this->setupTemplateContext();
408        $title = $this->getTitle();
409        $authority = $this->getAuthority();
410        $request = $this->getRequest();
411        $pageurl = $title->getLocalURL();
412        $services = MediaWikiServices::getInstance();
413        $authManager = $services->getAuthManager();
414        $groupPermissionsLookup = $services->getGroupPermissionsLookup();
415        $tempUserConfig = $services->getTempUserConfig();
416        $returnto = SkinComponentUtils::getReturnToParam( $title, $request, $authority );
417        $shouldHideUserLinks = $this->isAnonUser && $tempUserConfig->isKnown();
418
419        /* set up the default links for the personal toolbar */
420        $personal_urls = [];
421
422        if ( $this->loggedin ) {
423            $this->addPersonalPageItem( $personal_urls, '' );
424
425            // Merge notifications into the personal menu for older skins.
426            if ( $includeNotifications ) {
427                $contentNavigation = $this->buildContentNavigationUrlsInternal();
428
429                $personal_urls += $contentNavigation['notifications'];
430            }
431
432            $usertalkUrlDetails = $this->makeTalkUrlDetails( $this->userpage );
433            $personal_urls['mytalk'] = [
434                'text' => $this->msg( 'mytalk' )->text(),
435                'href' => &$usertalkUrlDetails['href'],
436                'class' => $usertalkUrlDetails['exists'] ? null : 'new',
437                'exists' => $usertalkUrlDetails['exists'],
438                'active' => ( $usertalkUrlDetails['href'] == $pageurl ),
439                'icon' => 'userTalk'
440            ];
441            if ( !$this->isTempUser ) {
442                $href = SkinComponentUtils::makeSpecialUrl( 'Preferences' );
443                $personal_urls['preferences'] = [
444                    'text' => $this->msg( 'mypreferences' )->text(),
445                    'href' => $href,
446                    'active' => ( $href == $pageurl ),
447                    'icon' => 'settings',
448                ];
449            }
450
451            if ( $authority->isAllowed( 'viewmywatchlist' ) ) {
452                $personal_urls['watchlist'] = self::buildWatchlistData();
453            }
454
455            # We need to do an explicit check for Special:Contributions, as we
456            # have to match both the title, and the target, which could come
457            # from request values (Special:Contributions?target=Jimbo_Wales)
458            # or be specified in "subpage" form
459            # (Special:Contributions/Jimbo_Wales). The plot
460            # thickens, because the Title object is altered for special pages,
461            # so it doesn't contain the original alias-with-subpage.
462            $origTitle = Title::newFromText( $request->getText( 'title' ) );
463            if ( $origTitle instanceof Title && $origTitle->isSpecialPage() ) {
464                [ $spName, $spPar ] =
465                    MediaWikiServices::getInstance()->getSpecialPageFactory()->
466                        resolveAlias( $origTitle->getText() );
467                $active = $spName == 'Contributions'
468                    && ( ( $spPar && $spPar == $this->username )
469                        || $request->getText( 'target' ) == $this->username );
470            } else {
471                $active = false;
472            }
473
474            $personal_urls = $this->makeContributionsLink( $personal_urls, 'mycontris', $this->username, $active );
475
476            // if we can't set the user, we can't unset it either
477            if ( $request->getSession()->canSetUser() ) {
478                $personal_urls['logout'] = $this->buildLogoutLinkData();
479            }
480        } elseif ( !$shouldHideUserLinks ) {
481            $canEdit = $authority->isAllowed( 'edit' );
482            $canEditWithTemp = $tempUserConfig->isAutoCreateAction( 'edit' );
483            // No need to show Talk and Contributions to anons if they can't contribute!
484            if ( $canEdit || $canEditWithTemp ) {
485                // Non interactive placeholder for anonymous users.
486                // It's unstyled by default (black color). Skin that
487                // needs it, can style it using the 'pt-anonuserpage' id.
488                // Skin that does not need it should unset it.
489                $personal_urls['anonuserpage'] = [
490                    'text' => $this->msg( 'notloggedin' )->text(),
491                ];
492            }
493            if ( $canEdit ) {
494                // Because of caching, we can't link directly to the IP talk and
495                // contributions pages. Instead we use the special page shortcuts
496                // (which work correctly regardless of caching). This means we can't
497                // determine whether these links are active or not, but since major
498                // skins (MonoBook, Vector) don't use this information, it's not a
499                // huge loss.
500                $personal_urls['anontalk'] = [
501                    'text' => $this->msg( 'anontalk' )->text(),
502                    'href' => SkinComponentUtils::makeSpecialUrlSubpage( 'Mytalk', false ),
503                    'active' => false,
504                    'icon' => 'userTalk',
505                ];
506                $personal_urls = $this->makeContributionsLink( $personal_urls, 'anoncontribs', null, false );
507            }
508        }
509
510        if ( !$this->loggedin ) {
511            $useCombinedLoginLink = $this->useCombinedLoginLink();
512            $login_url = $this->buildLoginData( $returnto, $useCombinedLoginLink );
513            $createaccount_url = $this->buildCreateAccountData( $returnto );
514
515            if (
516                $authManager->canCreateAccounts()
517                && $authority->isAllowed( 'createaccount' )
518                && !$useCombinedLoginLink
519            ) {
520                $personal_urls['createaccount'] = $createaccount_url;
521            }
522
523            if ( $authManager->canAuthenticateNow() ) {
524                // TODO: easy way to get anon authority
525                $key = $groupPermissionsLookup->groupHasPermission( '*', 'read' )
526                    ? 'login'
527                    : 'login-private';
528                $personal_urls[$key] = $login_url;
529            }
530        }
531
532        return $personal_urls;
533    }
534
535    /**
536     * Returns if a combined login/signup link will be used
537     * @unstable
538     *
539     * @return bool
540     */
541    protected function useCombinedLoginLink() {
542        $services = MediaWikiServices::getInstance();
543        $authManager = $services->getAuthManager();
544        $useCombinedLoginLink = $this->getConfig()->get( MainConfigNames::UseCombinedLoginLink );
545        if ( !$authManager->canCreateAccounts() || !$authManager->canAuthenticateNow() ) {
546            // don't show combined login/signup link if one of those is actually not available
547            $useCombinedLoginLink = false;
548        }
549
550        return $useCombinedLoginLink;
551    }
552
553    /**
554     * Build "Login" link
555     * @unstable
556     *
557     * @param string[] $returnto query params for the page to return to
558     * @param bool $useCombinedLoginLink when set a single link to login form will be created
559     *  with alternative label.
560     * @return array
561     */
562    protected function buildLoginData( $returnto, $useCombinedLoginLink ) {
563        $title = $this->getTitle();
564
565        $loginlink = $this->getAuthority()->isAllowed( 'createaccount' )
566            && $useCombinedLoginLink ? 'nav-login-createaccount' : 'pt-login';
567
568        $login_url = [
569            'single-id' => 'pt-login',
570            'text' => $this->msg( $loginlink )->text(),
571            'href' => SkinComponentUtils::makeSpecialUrl( 'Userlogin', $returnto ),
572            'active' => $title->isSpecial( 'Userlogin' )
573                || ( $title->isSpecial( 'CreateAccount' ) && $useCombinedLoginLink ),
574            'icon' => 'logIn'
575        ];
576
577        return $login_url;
578    }
579
580    /**
581     * @param array $links return value from OutputPage::getCategoryLinks
582     * @return array of data
583     */
584    private function getCategoryPortletsData( array $links ): array {
585        $categories = [];
586        foreach ( $links as $group => $groupLinks ) {
587            $allLinks = [];
588            $groupName = 'category-' . $group;
589            foreach ( $groupLinks as $i => $link ) {
590                $allLinks[$groupName . '-' . $i] = [
591                    'html' => $link,
592                ];
593            }
594            $categories[ $groupName ] = $allLinks;
595        }
596        return $categories;
597    }
598
599    /**
600     * Extends category links with Skin::getAfterPortlet functionality.
601     * @return string HTML
602     */
603    public function getCategoryLinks() {
604        $afterPortlet = $this->getPortletsTemplateData()['data-portlets']['data-category-normal']['html-after-portal']
605            ?? '';
606        return parent::getCategoryLinks() . $afterPortlet;
607    }
608
609    /**
610     * @return array of portlet data for all portlets
611     */
612    private function getPortletsTemplateData() {
613        if ( $this->portletsCached ) {
614            return $this->portletsCached;
615        }
616        $portlets = [];
617        $contentNavigation = $this->buildContentNavigationUrlsInternal();
618        $sidebar = [];
619        $sidebarData = $this->buildSidebar();
620        foreach ( $sidebarData as $name => $items ) {
621            if ( is_array( $items ) ) {
622                // Numeric strings gets an integer when set as key, cast back - T73639
623                $name = (string)$name;
624                switch ( $name ) {
625                    // ignore search
626                    case 'SEARCH':
627                        break;
628                    // Map toolbox to `tb` id.
629                    case 'TOOLBOX':
630                        $sidebar[] = $this->getPortletData( 'tb', $items );
631                        break;
632                    // Languages is no longer be tied to sidebar
633                    case 'LANGUAGES':
634                        // The language portal will be added provided either
635                        // languages exist or there is a value in html-after-portal
636                        // for example to show the add language wikidata link (T252800)
637                        $portal = $this->getPortletData( 'lang', $items );
638                        if ( count( $items ) || $portal['html-after-portal'] ) {
639                            $portlets['data-languages'] = $portal;
640                        }
641                        break;
642                    default:
643                        $sidebar[] = $this->getPortletData( $name, $items );
644                        break;
645                }
646            }
647        }
648
649        foreach ( $contentNavigation as $name => $items ) {
650            if ( $name === 'user-menu' ) {
651                $items = $this->getPersonalToolsForMakeListItem( $items, true );
652            }
653
654            $portlets['data-' . $name] = $this->getPortletData( $name, $items );
655        }
656
657        // A menu that includes the notifications. This will be deprecated in future versions
658        // of the skin API spec.
659        $portlets['data-personal'] = $this->getPortletData(
660            'personal',
661            $this->getPersonalToolsForMakeListItem(
662                $this->injectLegacyMenusIntoPersonalTools( $contentNavigation )
663            )
664        );
665
666        $this->portletsCached = [
667            'data-portlets' => $portlets,
668            'data-portlets-sidebar' => [
669                'data-portlets-first' => $sidebar[0] ?? null,
670                'array-portlets-rest' => array_slice( $sidebar, 1 ),
671            ],
672        ];
673        return $this->portletsCached;
674    }
675
676    /**
677     * Build data required for "Logout" link.
678     *
679     * @unstable
680     *
681     * @since 1.37
682     *
683     * @return array Array of data required to create a logout link.
684     */
685    final protected function buildLogoutLinkData() {
686        $title = $this->getTitle();
687        $request = $this->getRequest();
688        $authority = $this->getAuthority();
689        $returnto = SkinComponentUtils::getReturnToParam( $title, $request, $authority );
690        $isTemp = $this->isTempUser;
691        $msg = $isTemp ? 'templogout' : 'pt-userlogout';
692
693        return [
694            'single-id' => 'pt-logout',
695            'text' => $this->msg( $msg )->text(),
696            'data-mw-interface' => '1',
697            'href' => SkinComponentUtils::makeSpecialUrl( 'Userlogout', $returnto ),
698            'active' => false,
699            'icon' => 'logOut'
700        ];
701    }
702
703    /**
704     * Build "Create Account" link data.
705     * @unstable
706     *
707     * @param string[] $returnto query params for the page to return to
708     * @return array
709     */
710    protected function buildCreateAccountData( $returnto ) {
711        $title = $this->getTitle();
712
713        return [
714            'single-id' => 'pt-createaccount',
715            'text' => $this->msg( 'pt-createaccount' )->text(),
716            'href' => SkinComponentUtils::makeSpecialUrl( 'CreateAccount', $returnto ),
717            'active' => $title->isSpecial( 'CreateAccount' ),
718            'icon' => 'userAdd'
719        ];
720    }
721
722    /**
723     * Add the userpage link to the array
724     *
725     * @param array &$links Links array to append to
726     * @param string $idSuffix Something to add to the IDs to make them unique
727     */
728    private function addPersonalPageItem( &$links, $idSuffix ) {
729        if ( $this->isNamedUser ) { // T340152
730            $links['userpage'] = $this->buildPersonalPageItem( 'pt-userpage' . $idSuffix );
731        }
732    }
733
734    /**
735     * Build a user page link data.
736     *
737     * @param string $id of user page item to be output in HTML attribute (optional)
738     * @return array
739     */
740    protected function buildPersonalPageItem( $id = 'pt-userpage' ): array {
741        $linkClasses = $this->userpageUrlDetails['exists'] ? [] : [ 'new' ];
742        // T335440 Temp accounts dont show a user page link
743        // But we still need to update the user icon, as its used by other UI elements
744        $icon = $this->isTempUser ? 'userTemporary' : 'userAvatar';
745        $href = &$this->userpageUrlDetails['href'];
746        return [
747            'id' => $id,
748            'single-id' => 'pt-userpage',
749            'text' => $this->username,
750            'href' => $href,
751            'link-class' => $linkClasses,
752            'exists' => $this->userpageUrlDetails['exists'],
753            'active' => ( $this->userpageUrlDetails['href'] == $this->getTitle()->getLocalURL() ),
754            'icon' => $icon,
755        ];
756    }
757
758    /**
759     * Build a watchlist link data.
760     *
761     * @return array Array of data required to create a watchlist link.
762     */
763    private function buildWatchlistData() {
764        $href = SkinComponentUtils::makeSpecialUrl( 'Watchlist' );
765        $pageurl = $this->getTitle()->getLocalURL();
766
767        return [
768            'single-id' => 'pt-watchlist',
769            'text' => $this->msg( 'mywatchlist' )->text(),
770            'href' => $href,
771            'active' => ( $href == $pageurl ),
772            'icon' => 'watchlist'
773        ];
774    }
775
776    /**
777     * Builds an array with tab definition
778     *
779     * @param Title $title Page Where the tab links to
780     * @param string|string[]|MessageSpecifier $message Message or an array of message keys
781     *   (will fall back)
782     * @param bool $selected Display the tab as selected
783     * @param string $query Query string attached to tab URL
784     * @param bool $checkEdit Check if $title exists and mark with .new if one doesn't
785     *
786     * @return array
787     * @param-taint $message tainted
788     */
789    public function tabAction( $title, $message, $selected, $query = '', $checkEdit = false ) {
790        $classes = [];
791        if ( $selected ) {
792            $classes[] = 'selected';
793        }
794        $exists = true;
795        $services = MediaWikiServices::getInstance();
796        $linkClass = $services->getLinkRenderer()->getLinkClasses( $title );
797        if ( $checkEdit && !$title->isKnown() ) {
798            // Selected tabs should not show as red link. It doesn't make sense
799            // to show a red link on a page the user has already navigated to.
800            // https://phabricator.wikimedia.org/T294129#7451549
801            if ( !$selected ) {
802                // For historic reasons we add to the LI element
803                $classes[] = 'new';
804                // but adding the class to the A element is more appropriate.
805                $linkClass .= ' new';
806            }
807            $exists = false;
808            if ( $query !== '' ) {
809                $query = 'action=edit&redlink=1&' . $query;
810            } else {
811                $query = 'action=edit&redlink=1';
812            }
813        } elseif ( $title->isRedirect() ) {
814            // Do not redirect on redirect pages, see T5324
815            $origTitle = $this->getRelevantTitle();
816            // FIXME: If T385340 is resolved, this check can be removed
817            $action = $this->getContext()->getActionName();
818            $out = $this->getOutput();
819            $notCurrentActionView = $action !== 'view' || !$out->isRevisionCurrent();
820
821            if ( $origTitle instanceof Title && $title->isSamePageAs( $origTitle ) && $notCurrentActionView ) {
822                if ( $query !== '' ) {
823                    $query .= '&redirect=no';
824                } else {
825                    $query = 'redirect=no';
826                }
827            }
828        }
829
830        if ( $message instanceof MessageSpecifier ) {
831            $msg = new Message( $message );
832        } else {
833            // wfMessageFallback will nicely accept $message as an array of fallbacks
834            // or just a single key
835            $msg = wfMessageFallback( $message );
836        }
837        $msg->setContext( $this->getContext() );
838        if ( !$msg->isDisabled() ) {
839            $text = $msg->text();
840        } else {
841            $text = $services->getLanguageConverterFactory()
842                ->getLanguageConverter( $services->getContentLanguage() )
843                ->convertNamespace(
844                    $services->getNamespaceInfo()
845                        ->getSubject( $title->getNamespace() )
846                );
847        }
848
849        $result = [
850            // Use a string instead of a class list for hook compatibility (T393504)
851            'class' => implode( ' ', $classes ),
852            'text' => $text,
853            'href' => $title->getLocalURL( $query ),
854            'exists' => $exists,
855            'primary' => true ];
856        if ( $linkClass !== '' ) {
857            $result['link-class'] = trim( $linkClass );
858        }
859
860        return $result;
861    }
862
863    /**
864     * Get a message label that skins can override.
865     *
866     * @param string $labelMessageKey
867     * @param MessageParam|MessageSpecifier|string|int|float|null $param for the message
868     * @return string
869     */
870    private function getSkinNavOverrideableLabel( $labelMessageKey, $param = null ) {
871        $skname = $this->skinname;
872        // The following messages can be used here:
873        // * skin-action-addsection
874        // * skin-action-delete
875        // * skin-action-move
876        // * skin-action-protect
877        // * skin-action-undelete
878        // * skin-action-unprotect
879        // * skin-action-viewdeleted
880        // * skin-action-viewsource
881        // * skin-view-create
882        // * skin-view-create-local
883        // * skin-view-edit
884        // * skin-view-edit-local
885        // * skin-view-foreign
886        // * skin-view-history
887        // * skin-view-view
888        $msg = wfMessageFallback(
889                "$skname-$labelMessageKey",
890                "skin-$labelMessageKey"
891            )->setContext( $this->getContext() );
892
893        if ( $param ) {
894            if ( is_numeric( $param ) ) {
895                $msg->numParams( $param );
896            } else {
897                $msg->params( $param );
898            }
899        }
900        return $msg->text();
901    }
902
903    /**
904     * @param string $name
905     * @param string|array $urlaction
906     * @return array
907     */
908    private function makeTalkUrlDetails( $name, $urlaction = '' ) {
909        $title = Title::newFromTextThrow( $name )->getTalkPage();
910        return [
911            'href' => $title->getLocalURL( $urlaction ),
912            'exists' => $title->isKnown(),
913        ];
914    }
915
916    /**
917     * Get the attributes for the watch link.
918     * @param string $mode Either 'watch' or 'unwatch'
919     * @param Authority $performer
920     * @param Title $title
921     * @param string|null $action
922     * @param bool $onPage
923     * @return array
924     */
925    private function getWatchLinkAttrs(
926        string $mode, Authority $performer, Title $title, ?string $action, bool $onPage
927    ): array {
928        $isWatchMode = $action == 'watch';
929        $class = 'mw-watchlink ' . (
930            $onPage && ( $isWatchMode || $action == 'unwatch' ) ? 'selected' : ''
931            );
932
933        $services = MediaWikiServices::getInstance();
934        $watchlistManager = $services->getWatchlistManager();
935        $watchIcon = $watchlistManager->isWatched( $performer, $title ) ? 'unStar' : 'star';
936        $watchExpiry = null;
937        // Modify tooltip and add class identifying the page is temporarily watched, if applicable.
938        if ( $this->getConfig()->get( MainConfigNames::WatchlistExpiry ) &&
939            $watchlistManager->isTempWatched( $performer, $title )
940        ) {
941            $class .= ' mw-watchlink-temp';
942            $watchIcon = 'halfStar';
943
944            $watchStore = $services->getWatchedItemStore();
945            $watchedItem = $watchStore->getWatchedItem( $performer->getUser(), $title );
946            $diffInDays = $watchedItem->getExpiryInDays();
947            $watchExpiry = $watchedItem->getExpiry( TS::ISO_8601 );
948            if ( $diffInDays ) {
949                $msgParams = [ $diffInDays ];
950                // Resolves to tooltip-ca-unwatch-expiring message
951                $tooltip = 'ca-unwatch-expiring';
952            } else {
953                // Resolves to tooltip-ca-unwatch-expiring-hours message
954                $tooltip = 'ca-unwatch-expiring-hours';
955            }
956        }
957
958        return [
959            'class' => $class,
960            'icon' => $watchIcon,
961            // uses 'watch' or 'unwatch' message
962            'text' => $this->msg( $mode )->text(),
963            'single-id' => $tooltip ?? null,
964            'tooltip-params' => $msgParams ?? null,
965            'href' => $title->getLocalURL( [ 'action' => $mode ] ),
966            // Set a data-mw=interface attribute, which the mediawiki.page.ajax
967            // module will look for to make sure it's a trusted link
968            'data' => [
969                'mw-interface' => '1',
970                'mw-expiry' => $watchExpiry,
971            ],
972        ];
973    }
974
975    /**
976     * Run hooks relating to navigation menu data.
977     * Skins should extend this if they want to run opinionated transformations to the data after all
978     * hooks have been run. Note hooks are run in an arbitrary order.
979     *
980     * @param SkinTemplate $skin
981     * @param array &$content_navigation representing all menus.
982     * @since 1.37
983     */
984    protected function runOnSkinTemplateNavigationHooks( SkinTemplate $skin, &$content_navigation ) {
985        $beforeHookAssociatedPages = array_keys( $content_navigation['associated-pages'] );
986        $beforeHookNamespaces = array_keys( $content_navigation['namespaces'] );
987
988        // Equiv to SkinTemplateContentActions, run
989        $this->getHookRunner()->onSkinTemplateNavigation__Universal(
990            $skin, $content_navigation );
991        // The new `associatedPages` menu (added in 1.39)
992        // should be backwards compatibile with `namespaces`.
993        // To do this we look for hook modifications to both keys. If modifications are not
994        // made the new key, but are made to the old key, associatedPages reverts back to the
995        // links in the namespaces menu.
996        // It's expected in future that `namespaces` menu will become an alias for `associatedPages`
997        // at which point this code can be removed.
998        $afterHookNamespaces = array_keys( $content_navigation[ 'namespaces' ] );
999        $afterHookAssociatedPages = array_keys( $content_navigation[ 'associated-pages' ] );
1000        $associatedPagesChanged = count( array_diff( $afterHookAssociatedPages, $beforeHookAssociatedPages ) ) > 0;
1001        $namespacesChanged = count( array_diff( $afterHookNamespaces, $beforeHookNamespaces ) ) > 0;
1002        // If some change occurred to namespaces via the hook, revert back to namespaces.
1003        if ( !$associatedPagesChanged && $namespacesChanged ) {
1004            $content_navigation['associated-pages'] = $content_navigation['namespaces'];
1005        }
1006    }
1007
1008    /**
1009     * a structured array of links usually used for the tabs in a skin
1010     *
1011     * There are 4 standard sections
1012     * namespaces: Used for namespace tabs like special, page, and talk namespaces
1013     * views: Used for primary page views like read, edit, history
1014     * actions: Used for most extra page actions like deletion, protection, etc...
1015     * variants: Used to list the language variants for the page
1016     *
1017     * Each section's value is a key/value array of links for that section.
1018     * The links themselves have these common keys:
1019     * - class: The css classes to apply to the tab
1020     * - text: The text to display on the tab
1021     * - href: The href for the tab to point to
1022     * - rel: An optional rel= for the tab's link
1023     * - redundant: If true the tab will be dropped in skins using content_actions
1024     *   this is useful for tabs like "Read" which only have meaning in skins that
1025     *   take special meaning from the grouped structure of content_navigation
1026     *
1027     * Views also have an extra key which can be used:
1028     * - primary: If this is not true skins like vector may try to hide the tab
1029     *            when the user has limited space in their browser window
1030     *
1031     * content_navigation using code also expects these ids to be present on the
1032     * links, however these are usually automatically generated by SkinTemplate
1033     * itself and are not necessary when using a hook. The only things these may
1034     * matter to are people modifying content_navigation after it's initial creation:
1035     * - id: A "preferred" id, most skins are best off outputting this preferred
1036     *   id for best compatibility.
1037     * - tooltiponly: This is set to true for some tabs in cases where the system
1038     *   believes that the accesskey should not be added to the tab.
1039     *
1040     * @return array
1041     */
1042    private function buildContentNavigationUrlsInternal() {
1043        if ( $this->contentNavigationCached ) {
1044            return $this->contentNavigationCached;
1045        }
1046        // Display tabs for the relevant title rather than always the title itself
1047        $title = $this->getRelevantTitle();
1048        $onPage = $title->equals( $this->getTitle() );
1049
1050        $out = $this->getOutput();
1051        $request = $this->getRequest();
1052        $performer = $this->getAuthority();
1053        $action = $this->getContext()->getActionName();
1054        $services = MediaWikiServices::getInstance();
1055        $permissionManager = $services->getPermissionManager();
1056        $categoriesData = $this->getCategoryPortletsData( $this->getOutput()->getCategoryLinks() );
1057        $userPageLink = [];
1058        $this->addPersonalPageItem( $userPageLink, '-2' );
1059
1060        $content_navigation = $categoriesData + [
1061            // Modern keys: Please ensure these get unset inside Skin::prepareQuickTemplate
1062            'user-interface-preferences' => [],
1063            'user-page' => $userPageLink,
1064            'user-menu' => $this->buildPersonalUrls( false ),
1065            'notifications' => [],
1066            'associated-pages' => [],
1067            // Added in 1.44: a fixed position menu at bottom of page
1068            'dock-bottom' => [],
1069            // Legacy keys
1070            'namespaces' => [],
1071            'views' => [],
1072            'actions' => [],
1073            'variants' => []
1074        ];
1075
1076        $associatedPages = [];
1077        $namespaces = [];
1078        $userCanRead = $this->getAuthority()->probablyCan( 'read', $title );
1079
1080        // Checks if page is some kind of content
1081        if ( $title->canExist() ) {
1082            // Gets page objects for the associatedPages namespaces
1083            $subjectPage = $title->getSubjectPage();
1084            $talkPage = $title->getTalkPage();
1085
1086            // Determines if this is a talk page
1087            $isTalk = $title->isTalkPage();
1088
1089            // Generates XML IDs from namespace names
1090            $subjectId = $title->getNamespaceKey( '' );
1091
1092            if ( $subjectId == 'main' ) {
1093                $talkId = 'talk';
1094            } else {
1095                $talkId = "{$subjectId}_talk";
1096            }
1097
1098            // Adds namespace links
1099            if ( $subjectId === 'user' ) {
1100                $subjectMsg = $this->msg( 'nstab-user', $subjectPage->getRootText() );
1101            } else {
1102                // The following messages are used here:
1103                // * nstab-main
1104                // * nstab-media
1105                // * nstab-special
1106                // * nstab-project
1107                // * nstab-image
1108                // * nstab-mediawiki
1109                // * nstab-template
1110                // * nstab-help
1111                // * nstab-category
1112                // * nstab-<subject namespace key>
1113                $subjectMsg = [ "nstab-$subjectId" ];
1114
1115                if ( $subjectPage->isMainPage() ) {
1116                    array_unshift( $subjectMsg, 'nstab-mainpage' );
1117                }
1118            }
1119
1120            $associatedPages[$subjectId] = $this->tabAction(
1121                $subjectPage, $subjectMsg, !$isTalk, '', $userCanRead
1122            );
1123            $associatedPages[$subjectId]['context'] = 'subject';
1124            // Use the following messages if defined or talk if not:
1125            // * nstab-talk, nstab-user_talk, nstab-media_talk, nstab-project_talk
1126            // * nstab-image_talk, nstab-mediawiki_talk, nstab-template_talk
1127            // * nstab-help_talk, nstab-category_talk,
1128            // * nstab-<subject namespace key>_talk
1129            $associatedPages[$talkId] = $this->tabAction(
1130                $talkPage, [ "nstab-$talkId", "talk" ], $isTalk, '', $userCanRead
1131            );
1132            $associatedPages[$talkId]['context'] = 'talk';
1133
1134            if ( $userCanRead ) {
1135                // Adds "view" view link
1136                if ( $title->isKnown() ) {
1137                    $content_navigation['views']['view'] = $this->tabAction(
1138                        $isTalk ? $talkPage : $subjectPage,
1139                        'view-view',
1140                        ( $onPage && ( $action == 'view' || $action == 'purge' ) ), '', true
1141                    );
1142                    $content_navigation['views']['view']['text'] = $this->getSkinNavOverrideableLabel(
1143                        'view-view'
1144                    );
1145                    $content_navigation['views']['view']['icon'] = 'eye';
1146                    // signal to hide this from simple content_actions
1147                    $content_navigation['views']['view']['redundant'] = true;
1148                }
1149
1150                $page = $this->canUseWikiPage() ? $this->getWikiPage() : false;
1151                $isRemoteContent = $page && !$page->isLocal();
1152
1153                // If it is a non-local file, show a link to the file in its own repository
1154                // @todo abstract this for remote content that isn't a file
1155                if ( $isRemoteContent ) {
1156                    $content_navigation['views']['view-foreign'] = [
1157                        'text' => $this->getSkinNavOverrideableLabel(
1158                            'view-foreign', $page->getWikiDisplayName()
1159                        ),
1160                        'icon' => 'eye',
1161                        'href' => $page->getSourceURL(),
1162                        'primary' => false,
1163                    ];
1164                }
1165
1166                // Checks if user can edit the current page if it exists or create it otherwise
1167                if ( $this->getAuthority()->probablyCan( 'edit', $title ) ) {
1168                    // Builds CSS class for talk page links
1169                    $isTalkClass = $isTalk ? ' istalk' : '';
1170                    // Whether the user is editing the page
1171                    $isEditing = $onPage && ( $action == 'edit' || $action == 'submit' );
1172                    $isRedirect = $page && $page->isRedirect();
1173                    // Whether to show the "Add a new section" tab
1174                    // Checks if this is a current rev of talk page and is not forced to be hidden
1175                    $showNewSection = !$out->getOutputFlag( ParserOutputFlags::HIDE_NEW_SECTION ) && (
1176                        (
1177                            $isTalk && !$isRedirect && $out->isRevisionCurrent()
1178                        ) ||
1179                        $out->getOutputFlag( ParserOutputFlags::NEW_SECTION )
1180                    );
1181                    $section = $request->getVal( 'section' );
1182
1183                    if ( $title->exists()
1184                        || ( $title->inNamespace( NS_MEDIAWIKI )
1185                            && $title->getDefaultMessageText() !== false
1186                        )
1187                    ) {
1188                        $msgKey = $isRemoteContent ? 'edit-local' : 'edit';
1189                    } else {
1190                        $msgKey = $isRemoteContent ? 'create-local' : 'create';
1191                    }
1192                    $content_navigation['views']['edit'] = [
1193                        'class' => ( $isEditing && ( $section !== 'new' || !$showNewSection )
1194                            ? 'selected'
1195                            : null
1196                        ) . $isTalkClass,
1197                        'icon' => 'edit',
1198                        'text' => $this->getSkinNavOverrideableLabel(
1199                            "view-$msgKey"
1200                        ),
1201                        'single-id' => "ca-$msgKey",
1202                        'href' => $title->getLocalURL( $this->editUrlOptions() ),
1203                        'primary' => !$isRemoteContent, // don't collapse this in vector
1204                    ];
1205
1206                    // section link
1207                    if ( $showNewSection ) {
1208                        // Adds new section link
1209                        // $content_navigation['actions']['addsection']
1210                        $content_navigation['views']['addsection'] = [
1211                            'class' => ( $isEditing && $section == 'new' ) ? 'selected' : null,
1212                            'text' => $this->getSkinNavOverrideableLabel(
1213                                "action-addsection"
1214                            ),
1215                            'icon' => 'speechBubbleAdd',
1216                            'href' => $title->getLocalURL( 'action=edit&section=new' )
1217                        ];
1218                    }
1219                // Checks if the page has some kind of viewable source content
1220                } elseif ( $title->hasSourceText() ) {
1221                    // Adds view source view link
1222                    $content_navigation['views']['viewsource'] = [
1223                        'class' => ( $onPage && $action == 'edit' ) ? 'selected' : null,
1224                        'text' => $this->getSkinNavOverrideableLabel(
1225                            "action-viewsource"
1226                        ),
1227                        'icon' => 'editLock',
1228                        'href' => $title->getLocalURL( $this->editUrlOptions() ),
1229                        'primary' => true, // don't collapse this in vector
1230                    ];
1231                }
1232
1233                // Checks if the page exists
1234                if ( $title->exists() ) {
1235                    // Adds history view link
1236                    $content_navigation['views']['history'] = [
1237                        'class' => ( $onPage && $action == 'history' ) ? 'selected' : null,
1238                        'text' => $this->getSkinNavOverrideableLabel(
1239                            'view-history'
1240                        ),
1241                        'icon' => 'history',
1242                        'href' => $title->getLocalURL( 'action=history' ),
1243                    ];
1244
1245                    if ( $this->getAuthority()->probablyCan( 'delete', $title ) ) {
1246                        $content_navigation['actions']['delete'] = [
1247                            'icon' => 'trash',
1248                            'class' => ( $onPage && $action == 'delete' ) ? 'selected' : null,
1249                            'text' => $this->getSkinNavOverrideableLabel(
1250                                'action-delete'
1251                            ),
1252                            'href' => $title->getLocalURL( [
1253                                'action' => 'delete',
1254                                'oldid' => $title->getLatestRevID(),
1255                            ] )
1256                        ];
1257                    }
1258
1259                    if ( $this->getAuthority()->probablyCan( 'move', $title ) ) {
1260                        $moveTitle = SpecialPage::getTitleFor( 'Movepage', $title->getPrefixedDBkey() );
1261                        $content_navigation['actions']['move'] = [
1262                            'class' => $this->getTitle()->isSpecial( 'Movepage' ) ? 'selected' : null,
1263                            'text' => $this->getSkinNavOverrideableLabel(
1264                                'action-move'
1265                            ),
1266                            'icon' => 'move',
1267                            'href' => $moveTitle->getLocalURL()
1268                        ];
1269                    }
1270                } else {
1271                    // article doesn't exist or is deleted
1272                    if ( $this->getAuthority()->probablyCan( 'deletedhistory', $title ) ) {
1273                        $n = $title->getDeletedEditsCount();
1274                        if ( $n ) {
1275                            $undelTitle = SpecialPage::getTitleFor( 'Undelete', $title->getPrefixedDBkey() );
1276                            // If the user can't undelete but can view deleted
1277                            // history show them a "View .. deleted" tab instead.
1278                            $msgKey = $this->getAuthority()->probablyCan( 'undelete', $title ) ?
1279                                'undelete' : 'viewdeleted';
1280                            $content_navigation['actions']['undelete'] = [
1281                                'class' => $this->getTitle()->isSpecial( 'Undelete' ) ? 'selected' : null,
1282                                'text' => $this->getSkinNavOverrideableLabel(
1283                                    "action-$msgKey", $n
1284                                ),
1285                                'icon' => 'trash',
1286                                'href' => $undelTitle->getLocalURL()
1287                            ];
1288                        }
1289                    }
1290                }
1291
1292                $restrictionStore = $services->getRestrictionStore();
1293                if ( $this->getAuthority()->probablyCan( 'protect', $title ) &&
1294                    $restrictionStore->listApplicableRestrictionTypes( $title ) &&
1295                    $permissionManager->getNamespaceRestrictionLevels(
1296                        $title->getNamespace(),
1297                        $performer->getUser()
1298                    ) !== [ '' ]
1299                ) {
1300                    $isProtected = $restrictionStore->isProtected( $title );
1301                    $mode = $isProtected ? 'unprotect' : 'protect';
1302                    $content_navigation['actions'][$mode] = [
1303                        'class' => ( $onPage && $action == $mode ) ? 'selected' : null,
1304                        'text' => $this->getSkinNavOverrideableLabel(
1305                            "action-$mode"
1306                        ),
1307                        'icon' => $isProtected ? 'unLock' : 'lock',
1308                        'href' => $title->getLocalURL( "action=$mode" )
1309                    ];
1310                }
1311
1312                if ( $this->loggedin && $this->getAuthority()
1313                        ->isAllowedAll( 'viewmywatchlist', 'editmywatchlist' )
1314                ) {
1315                    /**
1316                     * The following actions use messages which, if made particular to
1317                     * the any specific skins, would break the Ajax code which makes this
1318                     * action happen entirely inline. OutputPage::getJSVars
1319                     * defines a set of messages in a javascript object - and these
1320                     * messages are assumed to be global for all skins. Without making
1321                     * a change to that procedure these messages will have to remain as
1322                     * the global versions.
1323                     */
1324                    $mode = MediaWikiServices::getInstance()->getWatchlistManager()
1325                        ->isWatched( $performer, $title ) ? 'unwatch' : 'watch';
1326
1327                    // Add the watch/unwatch link.
1328                    $content_navigation['actions'][$mode] = $this->getWatchLinkAttrs(
1329                        $mode,
1330                        $performer,
1331                        $title,
1332                        $action,
1333                        $onPage
1334                    );
1335                }
1336            }
1337
1338            // Add language variants
1339            $languageConverterFactory = MediaWikiServices::getInstance()->getLanguageConverterFactory();
1340
1341            if ( $userCanRead && !$languageConverterFactory->isConversionDisabled() ) {
1342                $pageLang = $title->getPageLanguage();
1343                $converter = $languageConverterFactory
1344                    ->getLanguageConverter( $pageLang );
1345                // Checks that language conversion is enabled and variants exist
1346                // And if it is not in the special namespace
1347                if ( $converter->hasVariants() ) {
1348                    // Gets list of language variants
1349                    $variants = $converter->getVariants();
1350                    // Gets preferred variant (note that user preference is
1351                    // only possible for wiki content language variant)
1352                    $preferred = $converter->getPreferredVariant();
1353                    if ( $action === 'view' ) {
1354                        $params = $request->getQueryValues();
1355                        unset( $params['title'] );
1356                    } else {
1357                        $params = [];
1358                    }
1359                    // Loops over each variant
1360                    foreach ( $variants as $code ) {
1361                        // Gets variant name from language code
1362                        $varname = $pageLang->getVariantname( $code );
1363                        // Appends variant link
1364                        $content_navigation['variants'][] = [
1365                            'class' => ( $code == $preferred ) ? 'selected' : null,
1366                            'text' => $varname,
1367                            'href' => $title->getLocalURL( [ 'variant' => $code ] + $params ),
1368                            'lang' => LanguageCode::bcp47( $code ),
1369                            'hreflang' => LanguageCode::bcp47( $code ),
1370                        ];
1371                    }
1372                }
1373            }
1374            $namespaces = $associatedPages;
1375        } else {
1376            // If it's not content, and a request URL is set it's got to be a special page
1377            try {
1378                $url = $request->getRequestURL();
1379            } catch ( MWException ) {
1380                $url = false;
1381            }
1382            $namespaces['special'] = [
1383                'class' => 'selected',
1384                'text' => $this->msg( 'nstab-special' )->text(),
1385                'href' => $url, // @see: T4457, T4510
1386                'context' => 'subject'
1387            ];
1388            $associatedPages += $this->getSpecialPageAssociatedNavigationLinks( $title );
1389        }
1390
1391        $content_navigation['namespaces'] = $namespaces;
1392        $content_navigation['associated-pages'] = $associatedPages;
1393        $this->runOnSkinTemplateNavigationHooks( $this, $content_navigation );
1394
1395        // Setup xml ids and tooltip info
1396        foreach ( $content_navigation as $section => &$links ) {
1397            foreach ( $links as $key => &$link ) {
1398                // Allow links to set their own id for backwards compatibility reasons.
1399                if ( isset( $link['id'] ) || isset( $link['html' ] ) ) {
1400                    continue;
1401                }
1402                $xmlID = $key;
1403                if ( isset( $link['context'] ) && $link['context'] == 'subject' ) {
1404                    $xmlID = 'ca-nstab-' . $xmlID;
1405                } elseif ( isset( $link['context'] ) && $link['context'] == 'talk' ) {
1406                    $xmlID = 'ca-talk';
1407                    $link['rel'] = 'discussion';
1408                } elseif ( $section == 'variants' ) {
1409                    $xmlID = 'ca-varlang-' . $xmlID;
1410                    // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive
1411                    $link['class'] .= ' ca-variants-' . $link['lang'];
1412                } else {
1413                    $xmlID = 'ca-' . $xmlID;
1414                }
1415                $link['id'] = $xmlID;
1416            }
1417        }
1418
1419        # We don't want to give the watch tab an accesskey if the
1420        # page is being edited, because that conflicts with the
1421        # accesskey on the watch checkbox.  We also don't want to
1422        # give the edit tab an accesskey, because that's fairly
1423        # superfluous and conflicts with an accesskey (Ctrl-E) often
1424        # used for editing in Safari.
1425        if ( in_array( $action, [ 'edit', 'submit' ] ) ) {
1426            if ( isset( $content_navigation['views']['edit'] ) ) {
1427                $content_navigation['views']['edit']['tooltiponly'] = true;
1428            }
1429            if ( isset( $content_navigation['actions']['watch'] ) ) {
1430                $content_navigation['actions']['watch']['tooltiponly'] = true;
1431            }
1432            if ( isset( $content_navigation['actions']['unwatch'] ) ) {
1433                $content_navigation['actions']['unwatch']['tooltiponly'] = true;
1434            }
1435        }
1436
1437        $this->contentNavigationCached = $content_navigation;
1438        return $content_navigation;
1439    }
1440
1441    /**
1442     * Return a list of pages that have been marked as related to/associated with
1443     * the special page for display.
1444     *
1445     * @param Title $title
1446     * @return array
1447     */
1448    private function getSpecialPageAssociatedNavigationLinks( Title $title ): array {
1449        $specialAssociatedNavigationLinks = [];
1450        $specialFactory = MediaWikiServices::getInstance()->getSpecialPageFactory();
1451        $special = $specialFactory->getPage( $title->getText() );
1452        if ( $special === null ) {
1453            // not a valid special page
1454            return [];
1455        }
1456        $special->setContext( $this );
1457        $associatedNavigationLinks = $special->getAssociatedNavigationLinks();
1458        // If there are no subpages, we should not render
1459        if ( count( $associatedNavigationLinks ) === 0 ) {
1460            return [];
1461        }
1462
1463        foreach ( $associatedNavigationLinks as $i => $relatedTitleText ) {
1464            $relatedTitle = Title::newFromText( $relatedTitleText );
1465            $special = $specialFactory->getPage( $relatedTitle->getText() );
1466            if ( $special === null ) {
1467                $text = $relatedTitle->getText();
1468            } else {
1469                $text = $special->getShortDescription( $relatedTitle->getSubpageText() );
1470            }
1471            $specialAssociatedNavigationLinks['special-specialAssociatedNavigationLinks-link-' . $i ] = [
1472                'text' => $text,
1473                'href' => $relatedTitle->getLocalURL(),
1474                'class' => $relatedTitle->fixSpecialName()->equals( $title->fixSpecialName() ) ? 'selected' : null,
1475            ];
1476        }
1477        return $specialAssociatedNavigationLinks;
1478    }
1479
1480    /**
1481     * an array of edit links by default used for the tabs
1482     * @param array $content_navigation
1483     * @return array
1484     */
1485    private function buildContentActionUrls( $content_navigation ) {
1486        // content_actions has been replaced with content_navigation for backwards
1487        // compatibility and also for skins that just want simple tabs content_actions
1488        // is now built by flattening the content_navigation arrays into one
1489
1490        $content_actions = [];
1491
1492        foreach ( $content_navigation as $links ) {
1493            foreach ( $links as $key => $value ) {
1494                if ( isset( $value['redundant'] ) && $value['redundant'] ) {
1495                    // Redundant tabs are dropped from content_actions
1496                    continue;
1497                }
1498
1499                // content_actions used to have ids built using the "ca-$key" pattern
1500                // so the xmlID based id is much closer to the actual $key that we want
1501                // for that reason we'll just strip out the ca- if present and use
1502                // the latter potion of the "id" as the $key
1503                if ( isset( $value['id'] ) && str_starts_with( $value['id'], 'ca-' ) ) {
1504                    $key = substr( $value['id'], 3 );
1505                }
1506
1507                if ( isset( $content_actions[$key] ) ) {
1508                    wfDebug( __METHOD__ . ": Found a duplicate key for $key while flattening " .
1509                        "content_navigation into content_actions." );
1510                    continue;
1511                }
1512
1513                $content_actions[$key] = $value;
1514            }
1515        }
1516
1517        return $content_actions;
1518    }
1519
1520    /**
1521     * Insert legacy menu items from content navigation into the personal toolbar.
1522     *
1523     * @internal
1524     *
1525     * @param array $contentNavigation
1526     * @return array
1527     */
1528    final protected function injectLegacyMenusIntoPersonalTools(
1529        array $contentNavigation
1530    ): array {
1531        $userMenu = $contentNavigation['user-menu'] ?? [];
1532        // userpage is only defined for logged-in users, and wfArrayInsertAfter requires the
1533        // $after parameter to be a known key in the array.
1534        if ( isset( $contentNavigation['user-menu']['userpage'] ) && isset( $contentNavigation['notifications'] ) ) {
1535            $userMenu = wfArrayInsertAfter(
1536                $userMenu,
1537                $contentNavigation['notifications'],
1538                'userpage'
1539            );
1540        }
1541        if ( isset( $contentNavigation['user-interface-preferences'] ) ) {
1542            return array_merge(
1543                $contentNavigation['user-interface-preferences'],
1544                $userMenu
1545            );
1546        }
1547        return $userMenu;
1548    }
1549
1550    /**
1551     * Build the personal urls array.
1552     *
1553     * @internal
1554     *
1555     * @param array $contentNavigation
1556     * @return array
1557     */
1558    private function makeSkinTemplatePersonalUrls(
1559        array $contentNavigation
1560    ): array {
1561        if ( isset( $contentNavigation['user-menu'] ) ) {
1562            return $this->injectLegacyMenusIntoPersonalTools( $contentNavigation );
1563        }
1564        return [];
1565    }
1566
1567    /**
1568     * @since 1.35
1569     * @param array $attrs (optional) will be passed to tooltipAndAccesskeyAttribs
1570     *  and decorate the resulting input
1571     * @return string of HTML input
1572     */
1573    public function makeSearchInput( $attrs = [] ) {
1574        // It's possible that getTemplateData might be calling
1575        // Skin::makeSearchInput. To avoid infinite recursion create a
1576        // new instance of the search component here.
1577        $searchBox = $this->getComponent( 'search-box' );
1578        $data = $searchBox->getTemplateData();
1579
1580        return Html::element( 'input',
1581            $data[ 'array-input-attributes' ] + $attrs
1582        );
1583    }
1584
1585    /**
1586     * @since 1.35
1587     * @param string $mode representing the type of button wanted
1588     *  either `go`, `fulltext` or `image`
1589     * @param array $attrs (optional)
1590     * @return string of HTML button
1591     */
1592    final public function makeSearchButton( $mode, $attrs = [] ) {
1593        // It's possible that getTemplateData might be calling
1594        // Skin::makeSearchInput. To avoid infinite recursion create a
1595        // new instance of the search component here.
1596        $searchBox = $this->getComponent( 'search-box' );
1597        $searchData = $searchBox->getTemplateData();
1598
1599        switch ( $mode ) {
1600            case 'go':
1601                $attrs['value'] ??= $this->msg( 'searcharticle' )->text();
1602                return Html::element(
1603                    'input',
1604                    array_merge(
1605                        $searchData[ 'array-button-go-attributes' ], $attrs
1606                    )
1607                );
1608            case 'fulltext':
1609                $attrs['value'] ??= $this->msg( 'searchbutton' )->text();
1610                return Html::element(
1611                    'input',
1612                    array_merge(
1613                        $searchData[ 'array-button-fulltext-attributes' ], $attrs
1614                    )
1615                );
1616            case 'image':
1617                $buttonAttrs = [
1618                    'type' => 'submit',
1619                    'name' => 'button',
1620                ];
1621                $buttonAttrs = array_merge(
1622                    $buttonAttrs,
1623                    Linker::tooltipAndAccesskeyAttribs( 'search-fulltext' ),
1624                    $attrs
1625                );
1626                unset( $buttonAttrs['src'] );
1627                unset( $buttonAttrs['alt'] );
1628                unset( $buttonAttrs['width'] );
1629                unset( $buttonAttrs['height'] );
1630                $imgAttrs = [
1631                    'src' => $attrs['src'],
1632                    'alt' => $attrs['alt'] ?? $this->msg( 'searchbutton' )->text(),
1633                    'width' => $attrs['width'] ?? null,
1634                    'height' => $attrs['height'] ?? null,
1635                ];
1636                return Html::rawElement( 'button', $buttonAttrs, Html::element( 'img', $imgAttrs ) );
1637            default:
1638                throw new InvalidArgumentException( 'Unknown mode passed to ' . __METHOD__ );
1639        }
1640    }
1641
1642    private function isSpecialContributeShowable(): bool {
1643        return ContributeFactory::isEnabledOnCurrentSkin(
1644            $this,
1645            $this->getConfig()->get( MainConfigNames::SpecialContributeSkinsEnabled )
1646        );
1647    }
1648
1649    /**
1650     * @param array &$personal_urls
1651     * @param string $key
1652     * @param string|null $userName
1653     * @param bool $active
1654     *
1655     * @return array
1656     */
1657    private function makeContributionsLink(
1658        array &$personal_urls,
1659        string $key,
1660        ?string $userName = null,
1661        bool $active = false
1662    ): array {
1663        $isSpecialContributeShowable = $this->isSpecialContributeShowable();
1664        $subpage = $userName ?? false;
1665        $user = $this->getUser();
1666        // If the "Contribute" page is showable and the user is anon. or has no edit count,
1667        // direct them to the "Contribute" page instead of the "Contributions" or "Mycontributions" pages.
1668        // Explanation:
1669        // a. For logged-in users: In wikis where the "Contribute" page is enabled, we only want
1670        // to navigate logged-in users to the "Contribute", when they have done no edits. Otherwise, we
1671        // want to navigate them to the "Mycontributions" page to easily access their edits/contributions.
1672        // Currently, the "Contribute" page is used as target for all logged-in users.
1673        // b. For anon. users: In wikis where the "Contribute" page is enabled, we still navigate the
1674        // anonymous users to the "Contribute" page.
1675        // Task: T369041
1676        if ( $isSpecialContributeShowable && (int)$user->getEditCount() === 0 ) {
1677            $href = SkinComponentUtils::makeSpecialUrlSubpage(
1678                'Contribute',
1679                false
1680            );
1681            $personal_urls['contribute'] = [
1682                'text' => $this->msg( 'contribute' )->text(),
1683                'href' => $href,
1684                'active' => $href == $this->getTitle()->getLocalURL(),
1685                'icon' => 'edit'
1686            ];
1687        } else {
1688            $href = SkinComponentUtils::makeSpecialUrlSubpage(
1689                $subpage !== false ? 'Contributions' : 'Mycontributions',
1690                $subpage
1691            );
1692            $personal_urls[$key] = [
1693                'text' => $this->msg( $key )->text(),
1694                'href' => $href,
1695                'active' => $active,
1696                'icon' => 'userContributions'
1697            ];
1698        }
1699        return $personal_urls;
1700    }
1701
1702}
1703
1704/** @deprecated class alias since 1.44 */
1705class_alias( SkinTemplate::class, 'SkinTemplate' );