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