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