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