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