Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
50.89% covered (warning)
50.89%
545 / 1071
42.65% covered (danger)
42.65%
29 / 68
CRAP
0.00% covered (danger)
0.00%
0 / 1
Skin
50.89% covered (warning)
50.89%
545 / 1071
42.65% covered (danger)
42.65%
29 / 68
9433.42
0.00% covered (danger)
0.00%
0 / 1
 getVersion
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getComponent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTemplateData
65.96% covered (warning)
65.96%
31 / 47
0.00% covered (danger)
0.00%
0 / 1
13.95
 normalizeKey
50.00% covered (danger)
50.00%
12 / 24
0.00% covered (danger)
0.00%
0 / 1
13.12
 __construct
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
4.01
 getSkinName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isResponsive
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 initPage
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
20
 getDefaultModules
81.25% covered (warning)
81.25%
39 / 48
0.00% covered (danger)
0.00%
0 / 1
16.48
 preloadExistence
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
90
 setRelevantTitle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRelevantTitle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setRelevantUser
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRelevantUser
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
11
 outputPage
n/a
0 / 0
n/a
0 / 0
0
 getPageClasses
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
6
 getHtmlElementAttributes
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getCategoryLinks
16.28% covered (danger)
16.28%
7 / 43
0.00% covered (danger)
0.00%
0 / 1
35.75
 getCategories
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 afterContentHook
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 getCanonicalUrl
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 printSource
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getUndeleteLink
18.18% covered (danger)
18.18%
6 / 33
0.00% covered (danger)
0.00%
0 / 1
43.05
 subPageSubtitleInternal
27.27% covered (danger)
27.27%
9 / 33
0.00% covered (danger)
0.00%
0 / 1
40.16
 getFooterTemplateDataItem
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getCopyright
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 logoText
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 getFooterIcons
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 makeFooterIcon
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 editUrlOptions
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 showEmailUser
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 makeMainPageUrl
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 makeSpecialUrl
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 makeSpecialUrlSubpage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 makeInternalOrExternalUrl
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 makeUrlDetails
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 makeKnownUrlDetails
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 mapInterwikiToLanguage
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getLanguages
15.52% covered (danger)
15.52%
9 / 58
0.00% covered (danger)
0.00%
0 / 1
83.96
 buildNavUrls
37.84% covered (danger)
37.84%
42 / 111
0.00% covered (danger)
0.00%
0 / 1
126.93
 buildFeedUrls
44.44% covered (danger)
44.44%
4 / 9
0.00% covered (danger)
0.00%
0 / 1
4.54
 buildSidebar
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
1 / 1
4
 addToSidebar
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addToSidebarPlain
100.00% covered (success)
100.00%
55 / 55
100.00% covered (success)
100.00%
1 / 1
16
 getSidebarIcon
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
7
 hideNewTalkMessagesForCurrentSession
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getNewtalks
2.50% covered (danger)
2.50%
2 / 80
0.00% covered (danger)
0.00%
0 / 1
223.54
 getCachedNotice
32.26% covered (danger)
32.26%
10 / 31
0.00% covered (danger)
0.00%
0 / 1
12.77
 getSiteNotice
76.19% covered (warning)
76.19%
16 / 21
0.00% covered (danger)
0.00%
0 / 1
11.35
 doEditSectionLink
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
6
 doEditSectionLinksHTML
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 makeToolbox
61.11% covered (warning)
61.11%
22 / 36
0.00% covered (danger)
0.00%
0 / 1
15.88
 getIndicatorsData
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 getPersonalToolsForMakeListItem
96.67% covered (success)
96.67%
29 / 30
0.00% covered (danger)
0.00%
0 / 1
9
 makeLink
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 makeListItem
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 makeSearchInput
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 makeSearchButton
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 getAfterPortlet
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 prepareSubtitle
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 getJsConfigVars
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getUserLanguageAttributes
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
3.43
 prepareUserLanguageAttributes
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 prepareUndeleteLink
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 wrapHTML
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getOptions
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
1
 supportsMenu
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getPortletLinkOptions
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 getPortletData
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20
21use MediaWiki\Context\ContextSource;
22use MediaWiki\Debug\MWDebug;
23use MediaWiki\HookContainer\ProtectedHookAccessorTrait;
24use MediaWiki\Html\Html;
25use MediaWiki\MainConfigNames;
26use MediaWiki\MediaWikiServices;
27use MediaWiki\Output\OutputPage;
28use MediaWiki\Parser\Sanitizer;
29use MediaWiki\ResourceLoader as RL;
30use MediaWiki\Revision\RevisionStore;
31use MediaWiki\Skin\SkinComponent;
32use MediaWiki\Skin\SkinComponentFooter;
33use MediaWiki\Skin\SkinComponentLink;
34use MediaWiki\Skin\SkinComponentListItem;
35use MediaWiki\Skin\SkinComponentMenu;
36use MediaWiki\Skin\SkinComponentRegistry;
37use MediaWiki\Skin\SkinComponentRegistryContext;
38use MediaWiki\Skin\SkinComponentUtils;
39use MediaWiki\SpecialPage\SpecialPage;
40use MediaWiki\Specials\SpecialUserRights;
41use MediaWiki\Title\Title;
42use MediaWiki\User\User;
43use MediaWiki\User\UserIdentity;
44use MediaWiki\User\UserIdentityValue;
45use MediaWiki\WikiMap\WikiMap;
46
47/**
48 * @defgroup Skins Skins
49 */
50
51/**
52 * The base class for all skins.
53 *
54 * See docs/Skin.md for more information.
55 *
56 * @stable to extend
57 * @ingroup Skins
58 */
59abstract class Skin extends ContextSource {
60    use ProtectedHookAccessorTrait;
61
62    /**
63     * @var array link options used in Skin::makeLink. Can be set by skin option `link`.
64     */
65    private $defaultLinkOptions;
66
67    /**
68     * @var string|null
69     */
70    protected $skinname = null;
71
72    /**
73     * @var array Skin options passed into constructor
74     */
75    protected $options = [];
76    protected $mRelevantTitle = null;
77
78    /**
79     * @var UserIdentity|null|false
80     */
81    private $mRelevantUser = false;
82
83    /** The current major version of the skin specification. */
84    protected const VERSION_MAJOR = 1;
85
86    /** @var array|null Cache for getLanguages() */
87    private $languageLinks;
88
89    /** @var array|null Cache for buildSidebar() */
90    private $sidebar;
91
92    /**
93     * @var SkinComponentRegistry Initialised in constructor.
94     */
95    private $componentRegistry = null;
96
97    /**
98     * Get the current major version of Skin. This is used to manage changes
99     * to underlying data and for providing support for older and new versions of code.
100     *
101     * @since 1.36
102     * @return int
103     */
104    public static function getVersion() {
105        return self::VERSION_MAJOR;
106    }
107
108    /**
109     * @internal use in Skin.php, SkinTemplate.php or SkinMustache.php
110     * @param string $name
111     * @return SkinComponent
112     */
113    final protected function getComponent( string $name ): SkinComponent {
114        return $this->componentRegistry->getComponent( $name );
115    }
116
117    /**
118     * @stable to extend. Subclasses may extend this method to add additional
119     * template data.
120     * @internal this method should never be called outside Skin and its subclasses
121     * as it can be computationally expensive and typically has side effects on the Skin
122     * instance, through execution of hooks.
123     *
124     * The data keys should be valid English words. Compound words should
125     * be hyphenated except if they are normally written as one word. Each
126     * key should be prefixed with a type hint, this may be enforced by the
127     * class PHPUnit test.
128     *
129     * Plain strings are prefixed with 'html-', plain arrays with 'array-'
130     * and complex array data with 'data-'. 'is-' and 'has-' prefixes can
131     * be used for boolean variables.
132     * Messages are prefixed with 'msg-', followed by their message key.
133     * All messages specified in the skin option 'messages' will be available
134     * under 'msg-MESSAGE-NAME'.
135     *
136     * @return array Data for a mustache template
137     */
138    public function getTemplateData() {
139        $title = $this->getTitle();
140        $out = $this->getOutput();
141        $user = $this->getUser();
142        $isMainPage = $title->isMainPage();
143        $blankedHeading = false;
144        // Heading can only be blanked on "views". It should
145        // still show on action=edit, diff pages and action=history
146        $isHeadingOverridable = $this->getContext()->getActionName() === 'view' &&
147            !$this->getRequest()->getRawVal( 'diff' );
148
149        if ( $isMainPage && $isHeadingOverridable ) {
150            // Special casing for the main page to allow more freedom to editors, to
151            // design their home page differently. This came up in T290480.
152            // The parameter for logged in users is optional and may
153            // or may not be used.
154            $titleMsg = $user->isAnon() ?
155                $this->msg( 'mainpage-title' ) :
156                $this->msg( 'mainpage-title-loggedin', $user->getName() );
157
158            // T298715: Use content language rather than user language so that
159            // the custom page heading is shown to all users, not just those that have
160            // their interface set to the site content language.
161            //
162            // T331095: Avoid Message::inContentLanuguage and, just like Parser,
163            // pick the language variant based on the current URL and/or user
164            // preference if their variant relates to the content language.
165            $forceUIMsgAsContentMsg = $this->getConfig()
166                ->get( MainConfigNames::ForceUIMsgAsContentMsg );
167            if ( !in_array( $titleMsg->getKey(), (array)$forceUIMsgAsContentMsg ) ) {
168                $services = MediaWikiServices::getInstance();
169                $contLangVariant = $services->getLanguageConverterFactory()
170                    ->getLanguageConverter( $services->getContentLanguage() )
171                    ->getPreferredVariant();
172                $titleMsg->inLanguage( $contLangVariant );
173            }
174            $titleMsg->setInterfaceMessageFlag( true );
175            $blankedHeading = $titleMsg->isBlank();
176            if ( !$titleMsg->isDisabled() ) {
177                $htmlTitle = $titleMsg->parse();
178            } else {
179                $htmlTitle = $out->getPageTitle();
180            }
181        } else {
182            $htmlTitle = $out->getPageTitle();
183        }
184
185        $data = [
186            // raw HTML
187            'html-title-heading' => Html::rawElement(
188                'h1',
189                [
190                    'id' => 'firstHeading',
191                    'class' => 'firstHeading mw-first-heading',
192                    'style' => $blankedHeading ? 'display: none' : null
193                ] + $this->getUserLanguageAttributes(),
194                $htmlTitle
195            ),
196            'html-title' => $htmlTitle ?: null,
197            // Boolean values
198            'is-title-blank' => $blankedHeading, // @since 1.38
199            'is-anon' => $user->isAnon(),
200            'is-article' => $out->isArticle(),
201            'is-mainpage' => $isMainPage,
202            'is-specialpage' => $title->isSpecialPage(),
203            'canonical-url' => $this->getCanonicalUrl(),
204        ];
205
206        $components = $this->componentRegistry->getComponents();
207        foreach ( $components as $componentName => $component ) {
208            $data['data-' . $componentName] = $component->getTemplateData();
209        }
210        return $data;
211    }
212
213    /**
214     * Normalize a skin preference value to a form that can be loaded.
215     *
216     * If a skin can't be found, it will fall back to the configured default ($wgDefaultSkin), or the
217     * hardcoded default ($wgFallbackSkin) if the default skin is unavailable too.
218     *
219     * @param string $key 'monobook', 'vector', etc.
220     * @return string
221     */
222    public static function normalizeKey( string $key ) {
223        $config = MediaWikiServices::getInstance()->getMainConfig();
224        $defaultSkin = $config->get( MainConfigNames::DefaultSkin );
225        $fallbackSkin = $config->get( MainConfigNames::FallbackSkin );
226        $skinFactory = MediaWikiServices::getInstance()->getSkinFactory();
227        $skinNames = $skinFactory->getInstalledSkins();
228
229        // Make keys lowercase for case-insensitive matching.
230        $skinNames = array_change_key_case( $skinNames, CASE_LOWER );
231        $key = strtolower( $key );
232        $defaultSkin = strtolower( $defaultSkin );
233        $fallbackSkin = strtolower( $fallbackSkin );
234
235        if ( $key == '' || $key == 'default' ) {
236            // Don't return the default immediately;
237            // in a misconfiguration we need to fall back.
238            $key = $defaultSkin;
239        }
240
241        if ( isset( $skinNames[$key] ) ) {
242            return $key;
243        }
244
245        // Older versions of the software used a numeric setting
246        // in the user preferences.
247        $fallback = [
248            0 => $defaultSkin,
249            2 => 'cologneblue'
250        ];
251
252        if ( isset( $fallback[$key] ) ) {
253            // @phan-suppress-next-line PhanTypeMismatchDimFetch False positive
254            $key = $fallback[$key];
255        }
256
257        if ( isset( $skinNames[$key] ) ) {
258            return $key;
259        } elseif ( isset( $skinNames[$defaultSkin] ) ) {
260            return $defaultSkin;
261        } else {
262            return $fallbackSkin;
263        }
264    }
265
266    /**
267     * @since 1.31
268     * @param string|array|null $options Options for the skin can be an array (since 1.35).
269     *  When a string is passed, it's the skinname.
270     *  When an array is passed:
271     *
272     *  - `name`: Required. Internal skin name, generally in lowercase to comply with conventions
273     *     for interface message keys and CSS class names which embed this value.
274     *
275     *  - `format`: Enable rendering of skin as json or html.
276     *
277     *     Since: MW 1.43
278     *     Default: `html`
279     *
280     *  - `styles`: ResourceLoader style modules to load on all pages. Default: `[]`
281     *
282     *  - `scripts`: ResourceLoader script modules to load on all pages. Default: `[]`
283     *
284     *  - `toc`: Whether a table of contents is included in the main article content
285     *     area. If your skin has place a table of contents elsewhere (for example, the sidebar),
286     *     set this to `false`.
287     *
288     *     See ParserOutput::getText() for the implementation logic.
289     *
290     *     Default: `true`
291     *
292     *  - `bodyClasses`: An array of extra class names to add to the HTML `<body>` element.
293     *     Default: `[]`
294     *
295     *  - `bodyOnly`: Whether the skin is takes control of generating the `<html>`, `<head>` and
296     *    `<body>` elements. This is for SkinTemplate subclasses only. For SkinMustache, this is
297     *     always true and ignored.
298     *
299     *     Default: `false`
300     *
301     *  - `clientPrefEnabled`: Enable support for mw.user.clientPrefs.
302     *     This instructs OutputPage and ResourceLoader\ClientHtml to include an inline script
303     *     in web responses for unregistered users to switch HTML classes as needed.
304     *
305     *     Since: MW 1.41
306     *     Default: `false`
307     *
308     *  - `wrapSiteNotice`: Enable support for standard site notice wrapper.
309     *     This instructs the Skin to wrap banners in div#siteNotice.
310     *
311     *     Since: MW 1.42
312     *     Default: `false`
313     *
314     *  - `responsive`: Whether the skin supports responsive behaviour and wants a viewport meta
315     *     tag to be added to the HTML head. Note, users can disable this feature via a user
316     *     preference.
317     *
318     *     Default: `false`
319     *
320     *  - `supportsMwHeading`: Whether the skin supports new HTML markup for headings, which uses
321     *     `<div class="mw-heading">` tags (https://www.mediawiki.org/wiki/Heading_HTML_changes).
322     *     If false, MediaWiki will output the legacy markup instead.
323     *
324     *     Since: MW 1.43
325     *     Default: `false` (will become `true` in and then will be removed in the future)
326     *
327     *  - `link`: An array of link option overriddes. See Skin::makeLink for the available options.
328     *
329     *     Default: `[]`
330     *
331     *  - `tempUserBanner`: Whether to display a banner on page views by in temporary user sessions.
332     *     This will prepend SkinComponentTempUserBanner to the `<body>` above everything else.
333     *     See also MediaWiki\MainConfigSchema::AutoCreateTempUser and User::isTemp.
334     *
335     *     Default: `false`
336     *
337     *  - `menus`: Which menus the skin supports, to allow features like SpecialWatchlist
338     *     to render their own navigation in the skins that don't support certain menus.
339     *     For any key in the array, the skin is promising to render an element e.g. the
340     *     presence of `associated-pages` means the skin will render a menu
341     *     compatible with mw.util.addPortletLink which has the ID p-associated-pages.
342     *
343     *     Default: `['namespaces', 'views', 'actions', 'variants']`
344     *
345     *     Opt-in menus:
346     *     - `associated-pages`
347     *     - `notification`
348     *     - `user-interface-preferences`
349     *     - `user-page`
350     *     - `user-menu`
351     */
352    public function __construct( $options = null ) {
353        if ( is_string( $options ) ) {
354            $this->skinname = $options;
355        } elseif ( $options ) {
356            $name = $options['name'] ?? null;
357
358            if ( !$name ) {
359                throw new SkinException( 'Skin name must be specified' );
360            }
361
362            // Defaults are set in Skin::getOptions()
363            $this->options = $options;
364            $this->skinname = $name;
365        }
366        $this->defaultLinkOptions = $this->getOptions()['link'];
367        $this->componentRegistry = new SkinComponentRegistry(
368            new SkinComponentRegistryContext( $this )
369        );
370    }
371
372    /**
373     * @return string|null Skin name
374     */
375    public function getSkinName() {
376        return $this->skinname;
377    }
378
379    /**
380     * Indicates if this skin is responsive.
381     * Responsive skins have skin--responsive added to <body> by OutputPage,
382     * and a viewport <meta> tag set by Skin::initPage.
383     *
384     * @since 1.36
385     * @stable to override
386     * @return bool
387     */
388    public function isResponsive() {
389        $isSkinResponsiveCapable = $this->getOptions()['responsive'];
390        $userOptionsLookup = MediaWikiServices::getInstance()->getUserOptionsLookup();
391
392        return $isSkinResponsiveCapable &&
393            $userOptionsLookup->getBoolOption( $this->getUser(), 'skin-responsive' );
394    }
395
396    /**
397     * @stable to override
398     * @param OutputPage $out
399     */
400    public function initPage( OutputPage $out ) {
401        $skinMetaTags = $this->getConfig()->get( MainConfigNames::SkinMetaTags );
402        $siteName = $this->getConfig()->get( MainConfigNames::Sitename );
403        $this->preloadExistence();
404
405        if ( $this->isResponsive() ) {
406            $out->addMeta(
407                'viewport',
408                'width=device-width, initial-scale=1.0, ' .
409                'user-scalable=yes, minimum-scale=0.25, maximum-scale=5.0'
410            );
411        } else {
412            // Force the desktop experience on an iPad by resizing the mobile viewport to
413            // the value of @min-width-breakpoint-desktop (1120px).
414            // This is as @min-width-breakpoint-desktop-wide usually tends to optimize
415            // for larger screens with max-widths and margins.
416            // The initial-scale SHOULD NOT be set here as defining it will impact zoom
417            // on mobile devices. To allow font-size adjustment in iOS devices (see T311795)
418            // we will define a zoom in JavaScript on certain devices (see resources/src/mediawiki.page.ready/ready.js)
419            $out->addMeta(
420                'viewport',
421                'width=1120'
422            );
423        }
424
425        $tags = [
426            'og:site_name' => $siteName,
427            'og:title' => $out->getHTMLTitle(),
428            'twitter:card' => 'summary_large_image',
429            'og:type' => 'website',
430        ];
431
432        // Support sharing on platforms such as Facebook and Twitter
433        foreach ( $tags as $key => $value ) {
434            if ( in_array( $key, $skinMetaTags ) ) {
435                $out->addMeta( $key, $value );
436            }
437        }
438    }
439
440    /**
441     * Defines the ResourceLoader modules that should be added to the skin
442     * It is recommended that skins wishing to override call parent::getDefaultModules()
443     * and substitute out any modules they wish to change by using a key to look them up
444     *
445     * Any modules defined with the 'styles' key will be added as render blocking CSS via
446     * Output::addModuleStyles. Similarly, each key should refer to a list of modules
447     *
448     * @stable to override
449     * @return array Array of modules with helper keys for easy overriding
450     */
451    public function getDefaultModules() {
452        $out = $this->getOutput();
453        $user = $this->getUser();
454
455        // Modules declared in the $modules literal are loaded
456        // for ALL users, on ALL pages, in ALL skins.
457        // Keep this list as small as possible!
458        $modules = [
459            // The 'styles' key sets render-blocking style modules
460            // Unlike other keys in $modules, this is an associative array
461            // where each key is its own group pointing to a list of modules
462            'styles' => [
463                'skin' => $this->getOptions()['styles'],
464                'core' => [],
465                'content' => [],
466                'syndicate' => [],
467                'user' => []
468            ],
469            'core' => [
470                'site',
471                'mediawiki.page.ready',
472            ],
473            // modules that enhance the content in some way
474            'content' => [],
475            // modules relating to search functionality
476            'search' => [],
477            // Skins can register their own scripts
478            'skin' => $this->getOptions()['scripts'],
479            // modules relating to functionality relating to watching an article
480            'watch' => [],
481            // modules which relate to the current users preferences
482            'user' => [],
483            // modules relating to RSS/Atom Feeds
484            'syndicate' => [],
485        ];
486
487        // Preload jquery.tablesorter for mediawiki.page.ready
488        if ( strpos( $out->getHTML(), 'sortable' ) !== false ) {
489            $modules['content'][] = 'jquery.tablesorter';
490            $modules['styles']['content'][] = 'jquery.tablesorter.styles';
491        }
492
493        // Preload jquery.makeCollapsible for mediawiki.page.ready
494        if ( strpos( $out->getHTML(), 'mw-collapsible' ) !== false ) {
495            $modules['content'][] = 'jquery.makeCollapsible';
496            $modules['styles']['content'][] = 'jquery.makeCollapsible.styles';
497        }
498
499        // Load relevant styles on wiki pages that use mw-ui-button.
500        // Since 1.26, this no longer loads unconditionally. Special pages
501        // and extensions should load this via addModuleStyles() instead.
502        if ( strpos( $out->getHTML(), 'mw-ui-button' ) !== false ) {
503            $modules['styles']['content'][] = 'mediawiki.ui.button';
504        }
505
506        if ( $out->isTOCEnabled() ) {
507            $modules['content'][] = 'mediawiki.toc';
508        }
509
510        $authority = $this->getAuthority();
511        if ( $authority->getUser()->isRegistered()
512            && $authority->isAllowedAll( 'writeapi', 'viewmywatchlist', 'editmywatchlist' )
513            && $this->getRelevantTitle()->canExist()
514        ) {
515            $modules['watch'][] = 'mediawiki.page.watch.ajax';
516        }
517
518        $userOptionsLookup = MediaWikiServices::getInstance()->getUserOptionsLookup();
519        if ( $userOptionsLookup->getBoolOption( $user, 'editsectiononrightclick' )
520            || ( $out->isArticle() && $userOptionsLookup->getOption( $user, 'editondblclick' ) )
521        ) {
522            $modules['user'][] = 'mediawiki.misc-authed-pref';
523        }
524
525        if ( $out->isSyndicated() ) {
526            $modules['styles']['syndicate'][] = 'mediawiki.feedlink';
527        }
528
529        if ( $user->isTemp() ) {
530            $modules['user'][] = 'mediawiki.tempUserBanner';
531            $modules['styles']['user'][] = 'mediawiki.tempUserBanner.styles';
532        }
533
534        if ( $this->getTitle() && $this->getTitle()->getNamespace() === NS_FILE ) {
535            $modules['styles']['core'][] = 'filepage'; // local Filepage.css, T31277, T356505
536        }
537
538        return $modules;
539    }
540
541    /**
542     * Preload the existence of three commonly-requested pages in a single query
543     */
544    private function preloadExistence() {
545        $titles = [];
546
547        // User/talk link
548        $user = $this->getUser();
549        if ( $user->isRegistered() ) {
550            $titles[] = $user->getUserPage();
551            $titles[] = $user->getTalkPage();
552        }
553
554        // Check, if the page can hold some kind of content, otherwise do nothing
555        $title = $this->getRelevantTitle();
556        if ( $title->canExist() && $title->canHaveTalkPage() ) {
557            $namespaceInfo = MediaWikiServices::getInstance()->getNamespaceInfo();
558            if ( $title->isTalkPage() ) {
559                $titles[] = $namespaceInfo->getSubjectPage( $title );
560            } else {
561                $titles[] = $namespaceInfo->getTalkPage( $title );
562            }
563        }
564
565        // Preload for self::getCategoryLinks
566        $allCats = $this->getOutput()->getCategoryLinks();
567        if ( isset( $allCats['normal'] ) && $allCats['normal'] !== [] ) {
568            $catLink = Title::newFromText( $this->msg( 'pagecategorieslink' )->inContentLanguage()->text() );
569            if ( $catLink ) {
570                // If this is a special page, the LinkBatch would skip it
571                $titles[] = $catLink;
572            }
573        }
574
575        $this->getHookRunner()->onSkinPreloadExistence( $titles, $this );
576
577        if ( $titles ) {
578            $linkBatchFactory = MediaWikiServices::getInstance()->getLinkBatchFactory();
579            $lb = $linkBatchFactory->newLinkBatch( $titles );
580            $lb->setCaller( __METHOD__ );
581            $lb->execute();
582        }
583    }
584
585    /**
586     * @see self::getRelevantTitle()
587     * @param Title $t
588     */
589    public function setRelevantTitle( $t ) {
590        $this->mRelevantTitle = $t;
591    }
592
593    /**
594     * Return the "relevant" title.
595     * A "relevant" title is not necessarily the actual title of the page.
596     * Special pages like Special:MovePage use set the page they are acting on
597     * as their "relevant" title, this allows the skin system to display things
598     * such as content tabs which belong to that page instead of displaying
599     * a basic special page tab which has almost no meaning.
600     *
601     * @return Title|null the title is null when no relevant title was set, as this
602     *   falls back to ContextSource::getTitle
603     */
604    public function getRelevantTitle() {
605        return $this->mRelevantTitle ?? $this->getTitle();
606    }
607
608    /**
609     * @see self::getRelevantUser()
610     * @param UserIdentity|null $u
611     */
612    public function setRelevantUser( ?UserIdentity $u ) {
613        $this->mRelevantUser = $u;
614    }
615
616    /**
617     * Return the "relevant" user.
618     * A "relevant" user is similar to a relevant title. Special pages like
619     * Special:Contributions mark the user which they are relevant to so that
620     * things like the toolbox can display the information they usually are only
621     * able to display on a user's userpage and talkpage.
622     *
623     * @return UserIdentity|null Null if there's no relevant user or the viewer cannot view it.
624     */
625    public function getRelevantUser(): ?UserIdentity {
626        if ( $this->mRelevantUser === false ) {
627            $this->mRelevantUser = null; // false indicates we never attempted to load it.
628            $title = $this->getRelevantTitle();
629            if ( $title->hasSubjectNamespace( NS_USER ) ) {
630                $services = MediaWikiServices::getInstance();
631                $rootUser = $title->getRootText();
632                $userNameUtils = $services->getUserNameUtils();
633                if ( $userNameUtils->isIP( $rootUser ) ) {
634                    $this->mRelevantUser = UserIdentityValue::newAnonymous( $rootUser );
635                } else {
636                    $user = $services->getUserIdentityLookup()->getUserIdentityByName( $rootUser );
637                    $this->mRelevantUser = $user && $user->isRegistered() ? $user : null;
638                }
639            }
640        }
641
642        // The relevant user should only be set if it exists. However, if it exists but is hidden,
643        // and the viewer cannot see hidden users, this exposes the fact that the user exists;
644        // pretend like the user does not exist in such cases, by setting it to null. T120883
645        if ( $this->mRelevantUser && $this->mRelevantUser->isRegistered() ) {
646            $userBlock = MediaWikiServices::getInstance()
647                ->getBlockManager()
648                ->getBlock( $this->mRelevantUser, null );
649            if ( $userBlock && $userBlock->getHideName() &&
650                !$this->getAuthority()->isAllowed( 'hideuser' )
651            ) {
652                $this->mRelevantUser = null;
653            }
654        }
655
656        return $this->mRelevantUser;
657    }
658
659    /**
660     * Outputs the HTML generated by other functions.
661     */
662    abstract public function outputPage();
663
664    /**
665     * TODO: document
666     * @param Title $title
667     * @return string
668     */
669    public function getPageClasses( $title ) {
670        $services = MediaWikiServices::getInstance();
671        $ns = $title->getNamespace();
672        $numeric = 'ns-' . $ns;
673
674        if ( $title->isSpecialPage() ) {
675            $type = 'ns-special';
676            // T25315: provide a class based on the canonical special page name without subpages
677            [ $canonicalName ] = $services->getSpecialPageFactory()->resolveAlias( $title->getDBkey() );
678            if ( $canonicalName ) {
679                $type .= ' ' . Sanitizer::escapeClass( "mw-special-$canonicalName" );
680            } else {
681                $type .= ' mw-invalidspecialpage';
682            }
683        } else {
684            if ( $title->isTalkPage() ) {
685                $type = 'ns-talk';
686            } else {
687                $type = 'ns-subject';
688            }
689            // T208315: add HTML class when the user can edit the page
690            if ( $this->getAuthority()->probablyCan( 'edit', $title ) ) {
691                $type .= ' mw-editable';
692            }
693        }
694
695        $titleFormatter = $services->getTitleFormatter();
696        $name = Sanitizer::escapeClass( 'page-' . $titleFormatter->getPrefixedText( $title ) );
697        $root = Sanitizer::escapeClass( 'rootpage-' . $titleFormatter->formatTitle( $ns, $title->getRootText() ) );
698        // Add a static class that is not subject to translation to allow extensions/skins/global code to target main
699        // pages reliably (T363281)
700        if ( $title->isMainPage() ) {
701            $name .= ' page-Main_Page';
702        }
703
704        return "$numeric $type $name $root";
705    }
706
707    /**
708     * Return values for <html> element
709     * @return array Array of associative name-to-value elements for <html> element
710     */
711    public function getHtmlElementAttributes() {
712        $lang = $this->getLanguage();
713        return [
714            'lang' => $lang->getHtmlCode(),
715            'dir' => $lang->getDir(),
716            'class' => 'client-nojs',
717        ];
718    }
719
720    /**
721     * @return string HTML
722     */
723    public function getCategoryLinks() {
724        $out = $this->getOutput();
725        $allCats = $out->getCategoryLinks();
726        $title = $this->getTitle();
727        $services = MediaWikiServices::getInstance();
728        $linkRenderer = $services->getLinkRenderer();
729
730        if ( $allCats === [] ) {
731            return '';
732        }
733
734        $embed = "<li>";
735        $pop = "</li>";
736
737        $s = '';
738        $colon = $this->msg( 'colon-separator' )->escaped();
739
740        if ( !empty( $allCats['normal'] ) ) {
741            $t = $embed . implode( $pop . $embed, $allCats['normal'] ) . $pop;
742
743            $msg = $this->msg( 'pagecategories' )->numParams( count( $allCats['normal'] ) );
744            $linkPage = $this->msg( 'pagecategorieslink' )->inContentLanguage()->text();
745            $pageCategoriesLinkTitle = Title::newFromText( $linkPage );
746            if ( $pageCategoriesLinkTitle ) {
747                $link = $linkRenderer->makeLink( $pageCategoriesLinkTitle, $msg->text() );
748            } else {
749                $link = $msg->escaped();
750            }
751            $s .= Html::rawElement(
752                'div',
753                [ 'id' => 'mw-normal-catlinks', 'class' => 'mw-normal-catlinks' ],
754                $link . $colon . Html::rawElement( 'ul', [], $t )
755            );
756        }
757
758        # Hidden categories
759        if ( isset( $allCats['hidden'] ) ) {
760            $userOptionsLookup = $services->getUserOptionsLookup();
761
762            if ( $userOptionsLookup->getBoolOption( $this->getUser(), 'showhiddencats' ) ) {
763                $class = ' mw-hidden-cats-user-shown';
764            } elseif ( $title->inNamespace( NS_CATEGORY ) ) {
765                $class = ' mw-hidden-cats-ns-shown';
766            } else {
767                $class = ' mw-hidden-cats-hidden';
768            }
769
770            $s .= Html::rawElement(
771                'div',
772                [ 'id' => 'mw-hidden-catlinks', 'class' => "mw-hidden-catlinks$class" ],
773                $this->msg( 'hidden-categories' )->numParams( count( $allCats['hidden'] ) )->escaped() .
774                    $colon .
775                    Html::rawElement(
776                        'ul',
777                        [],
778                        $embed . implode( $pop . $embed, $allCats['hidden'] ) . $pop
779                    )
780            );
781        }
782
783        return $s;
784    }
785
786    /**
787     * @return string HTML
788     */
789    public function getCategories() {
790        $userOptionsLookup = MediaWikiServices::getInstance()->getUserOptionsLookup();
791        $showHiddenCats = $userOptionsLookup->getBoolOption( $this->getUser(), 'showhiddencats' );
792
793        $catlinks = $this->getCategoryLinks();
794        // Check what we're showing
795        $allCats = $this->getOutput()->getCategoryLinks();
796        $showHidden = $showHiddenCats || $this->getTitle()->inNamespace( NS_CATEGORY );
797
798        $classes = [ 'catlinks' ];
799        if ( empty( $allCats['normal'] ) && !( !empty( $allCats['hidden'] ) && $showHidden ) ) {
800            $classes[] = 'catlinks-allhidden';
801        }
802
803        return Html::rawElement(
804            'div',
805            [ 'id' => 'catlinks', 'class' => $classes, 'data-mw' => 'interface' ],
806            $catlinks
807        );
808    }
809
810    /**
811     * This runs a hook to allow extensions placing their stuff after content
812     * and article metadata (e.g. categories).
813     * Note: This function has nothing to do with afterContent().
814     *
815     * This hook is placed here in order to allow using the same hook for all
816     * skins, both the SkinTemplate based ones and the older ones, which directly
817     * use this class to get their data.
818     *
819     * The output of this function gets processed in SkinTemplate::outputPage() for
820     * the SkinTemplate based skins, all other skins should directly echo it.
821     *
822     * @return string Empty by default, if not changed by any hook function.
823     */
824    protected function afterContentHook() {
825        $data = '';
826
827        if ( $this->getHookRunner()->onSkinAfterContent( $data, $this ) ) {
828            // adding just some spaces shouldn't toggle the output
829            // of the whole <div/>, so we use trim() here
830            if ( trim( $data ) != '' ) {
831                // Doing this here instead of in the skins to
832                // ensure that the div has the same ID in all
833                // skins
834                $data = "<div id='mw-data-after-content'>\n" .
835                    "\t$data\n" .
836                    "</div>\n";
837            }
838        } else {
839            wfDebug( "Hook SkinAfterContent changed output processing." );
840        }
841
842        return $data;
843    }
844
845    /**
846     * Get the canonical URL (permalink) for the page including oldid if present.
847     *
848     * @return string
849     */
850    private function getCanonicalUrl() {
851        $title = $this->getTitle();
852        $oldid = $this->getOutput()->getRevisionId();
853        if ( $oldid ) {
854            return $title->getCanonicalURL( 'oldid=' . $oldid );
855        } else {
856            // oldid not available for non existing pages
857            return $title->getCanonicalURL();
858        }
859    }
860
861    /**
862     * Text with the permalink to the source page,
863     * usually shown on the footer of a printed page
864     *
865     * @stable to override
866     * @return string HTML text with an URL
867     */
868    public function printSource() {
869        $urlUtils = MediaWikiServices::getInstance()->getUrlUtils();
870        $url = htmlspecialchars( $urlUtils->expandIRI( $this->getCanonicalUrl() ) ?? '' );
871
872        return $this->msg( 'retrievedfrom' )
873            ->rawParams( '<a dir="ltr" href="' . $url . '">' . $url . '</a>' )
874            ->parse();
875    }
876
877    /**
878     * @return string HTML
879     */
880    public function getUndeleteLink() {
881        $action = $this->getRequest()->getRawVal( 'action', 'view' );
882        $title = $this->getTitle();
883        $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
884
885        if ( ( !$title->exists() || $action == 'history' ) &&
886            $this->getAuthority()->probablyCan( 'deletedhistory', $title )
887        ) {
888            $n = $title->getDeletedEditsCount();
889
890            if ( $n ) {
891                if ( $this->getAuthority()->probablyCan( 'undelete', $title ) ) {
892                    $msg = 'thisisdeleted';
893                } else {
894                    $msg = 'viewdeleted';
895                }
896
897                $subtitle = $this->msg( $msg )->rawParams(
898                    $linkRenderer->makeKnownLink(
899                        SpecialPage::getTitleFor( 'Undelete', $title->getPrefixedDBkey() ),
900                        $this->msg( 'restorelink' )->numParams( $n )->text() )
901                    )->escaped();
902
903                $links = [];
904                // Add link to page logs, unless we're on the history page (which
905                // already has one)
906                if ( $action !== 'history' ) {
907                    $links[] = $linkRenderer->makeKnownLink(
908                        SpecialPage::getTitleFor( 'Log' ),
909                        $this->msg( 'viewpagelogs-lowercase' )->text(),
910                        [],
911                        [ 'page' => $title->getPrefixedText() ]
912                    );
913                }
914
915                // Allow extensions to add more links
916                $this->getHookRunner()->onUndeletePageToolLinks(
917                    $this->getContext(), $linkRenderer, $links );
918
919                if ( $links ) {
920                    $subtitle .= ''
921                        . $this->msg( 'word-separator' )->escaped()
922                        . $this->msg( 'parentheses' )
923                            ->rawParams( $this->getLanguage()->pipeList( $links ) )
924                            ->escaped();
925                }
926
927                return Html::rawElement( 'div', [ 'class' => 'mw-undelete-subtitle' ], $subtitle );
928            }
929        }
930
931        return '';
932    }
933
934    /**
935     * @return string
936     */
937    private function subPageSubtitleInternal() {
938        $services = MediaWikiServices::getInstance();
939        $linkRenderer = $services->getLinkRenderer();
940        $out = $this->getOutput();
941        $title = $out->getTitle();
942        $subpages = '';
943
944        if ( !$this->getHookRunner()->onSkinSubPageSubtitle( $subpages, $this, $out ) ) {
945            return $subpages;
946        }
947
948        $hasSubpages = $services->getNamespaceInfo()->hasSubpages( $title->getNamespace() );
949        if ( !$out->isArticle() || !$hasSubpages ) {
950            return $subpages;
951        }
952
953        $ptext = $title->getPrefixedText();
954        if ( strpos( $ptext, '/' ) !== false ) {
955            $links = explode( '/', $ptext );
956            array_pop( $links );
957            $count = 0;
958            $growingLink = '';
959            $display = '';
960            $lang = $this->getLanguage();
961
962            foreach ( $links as $link ) {
963                $growingLink .= $link;
964                $display .= $link;
965                $linkObj = Title::newFromText( $growingLink );
966
967                if ( $linkObj && $linkObj->isKnown() ) {
968                    $getlink = $linkRenderer->makeKnownLink( $linkObj, $display );
969
970                    $count++;
971
972                    if ( $count > 1 ) {
973                        $subpages .= $lang->getDirMarkEntity() . $this->msg( 'pipe-separator' )->escaped();
974                    } else {
975                        $subpages .= '&lt; ';
976                    }
977
978                    $subpages .= $getlink;
979                    $display = '';
980                } else {
981                    $display .= '/';
982                }
983                $growingLink .= '/';
984            }
985        }
986
987        return $subpages;
988    }
989
990    /**
991     * Helper function for mapping template data for use in legacy function
992     *
993     * @param string $dataKey
994     * @param string $name
995     * @return string
996     */
997    private function getFooterTemplateDataItem( string $dataKey, string $name ) {
998        $footerData = $this->getComponent( 'footer' )->getTemplateData();
999        $items = $footerData[ $dataKey ]['array-items'] ?? [];
1000        foreach ( $items as $item ) {
1001            if ( $item['name'] === $name ) {
1002                return $item['html'];
1003            }
1004        }
1005        return '';
1006    }
1007
1008    /**
1009     * @return string
1010     */
1011    final public function getCopyright(): string {
1012        return $this->getFooterTemplateDataItem( 'data-info', 'copyright' );
1013    }
1014
1015    /**
1016     * @param string $align
1017     * @return string
1018     */
1019    public function logoText( $align = '' ) {
1020        if ( $align != '' ) {
1021            $a = " style='float: {$align};'";
1022        } else {
1023            $a = '';
1024        }
1025
1026        $mp = $this->msg( 'mainpage' )->escaped();
1027        $url = htmlspecialchars( Title::newMainPage()->getLocalURL() );
1028
1029        $logourl = RL\SkinModule::getAvailableLogos(
1030            $this->getConfig(),
1031            $this->getLanguage()->getCode()
1032        )[ '1x' ];
1033        return "<a href='{$url}'><img{$a} src='{$logourl}' alt='[{$mp}]' /></a>";
1034    }
1035
1036    /**
1037     * Get template representation of the footer.
1038     *
1039     * Stable to use since 1.40 but should not be overridden.
1040     *
1041     * @since 1.35
1042     * @internal for use inside SkinComponentRegistryContext
1043     * @return array
1044     */
1045    public function getFooterIcons() {
1046        MWDebug::detectDeprecatedOverride( $this, __CLASS__, 'getFooterIcons', '1.40' );
1047        return SkinComponentFooter::getFooterIconsData(
1048            $this->getConfig()
1049        );
1050    }
1051
1052    /**
1053     * Renders a $wgFooterIcons icon according to the method's arguments
1054     *
1055     * Stable to use since 1.40 but should not be overridden.
1056     *
1057     * @param array $icon The icon to build the html for, see $wgFooterIcons
1058     *   for the format of this array.
1059     * @param bool|string $withImage Whether to use the icon's image or output
1060     *   a text-only footericon.
1061     * @return string HTML
1062     */
1063    public function makeFooterIcon( $icon, $withImage = 'withImage' ) {
1064        MWDebug::detectDeprecatedOverride( $this, __CLASS__, 'makeFooterIcon', '1.40' );
1065        return SkinComponentFooter::makeFooterIconHTML(
1066            $this->getConfig(), $icon, $withImage
1067        );
1068    }
1069
1070    /**
1071     * Return URL options for the 'edit page' link.
1072     * This may include an 'oldid' specifier, if the current page view is such.
1073     *
1074     * @return array
1075     * @internal
1076     */
1077    public function editUrlOptions() {
1078        $options = [ 'action' => 'edit' ];
1079        $out = $this->getOutput();
1080
1081        if ( !$out->isRevisionCurrent() ) {
1082            $options['oldid'] = intval( $out->getRevisionId() );
1083        }
1084
1085        return $options;
1086    }
1087
1088    /**
1089     * @param UserIdentity|int $id
1090     * @return bool
1091     */
1092    public function showEmailUser( $id ) {
1093        if ( $id instanceof UserIdentity ) {
1094            $targetUser = User::newFromIdentity( $id );
1095        } else {
1096            $targetUser = User::newFromId( $id );
1097        }
1098
1099        # The sending user must have a confirmed email address and the receiving
1100        # user must accept emails from the sender.
1101        $emailUser = MediaWikiServices::getInstance()->getEmailUserFactory()
1102            ->newEmailUser( $this->getUser() );
1103
1104        return $emailUser->canSend()->isOK()
1105            && $emailUser->validateTarget( $targetUser )->isOK();
1106    }
1107
1108    /* these are used extensively in SkinTemplate, but also some other places */
1109
1110    /**
1111     * @param string|array $urlaction
1112     * @return string
1113     */
1114    public static function makeMainPageUrl( $urlaction = '' ) {
1115        $title = Title::newMainPage();
1116
1117        return $title->getLinkURL( $urlaction );
1118    }
1119
1120    /**
1121     * Make a URL for a Special Page using the given query and protocol.
1122     *
1123     * If $proto is set to null, make a local URL. Otherwise, make a full
1124     * URL with the protocol specified.
1125     *
1126     * @deprecated since 1.39 - Moved to SkinComponentUtils::makeSpecialUrl
1127     * @param string $name Name of the Special page
1128     * @param string|array $urlaction Query to append
1129     * @param string|null $proto Protocol to use or null for a local URL
1130     * @return string
1131     */
1132    public static function makeSpecialUrl( $name, $urlaction = '', $proto = null ) {
1133        return SkinComponentUtils::makeSpecialUrl( $name, $urlaction, $proto );
1134    }
1135
1136    /**
1137     * @deprecated since 1.39 - Moved to SkinComponentUtils::makeSpecialUrlSubpage
1138     * @param string $name
1139     * @param string|bool $subpage false for no subpage
1140     * @param string|array $urlaction
1141     * @return string
1142     */
1143    public static function makeSpecialUrlSubpage( $name, $subpage, $urlaction = '' ) {
1144        return SkinComponentUtils::makeSpecialUrlSubpage( $name, $subpage, $urlaction );
1145    }
1146
1147    /**
1148     * If url string starts with http, consider as external URL, else
1149     * internal
1150     * @param string $name
1151     * @return string URL
1152     */
1153    public static function makeInternalOrExternalUrl( $name ) {
1154        $protocols = MediaWikiServices::getInstance()->getUrlUtils()->validProtocols();
1155
1156        if ( preg_match( '/^(?i:' . $protocols . ')/', $name ) ) {
1157            return $name;
1158        } else {
1159            $title = $name instanceof Title ? $name : Title::newFromText( $name );
1160            return $title ? $title->getLocalURL() : '';
1161        }
1162    }
1163
1164    /**
1165     * these return an array with the 'href' and boolean 'exists'
1166     * @param string|Title $name
1167     * @param string|array $urlaction
1168     * @return array
1169     */
1170    protected static function makeUrlDetails( $name, $urlaction = '' ) {
1171        $title = $name instanceof Title ? $name : Title::newFromText( $name );
1172        return [
1173            'href' => $title ? $title->getLocalURL( $urlaction ) : '',
1174            'exists' => $title && $title->isKnown(),
1175        ];
1176    }
1177
1178    /**
1179     * Make URL details where the article exists (or at least it's convenient to think so)
1180     * @param string|Title $name Article name
1181     * @param string|array $urlaction
1182     * @return array
1183     */
1184    protected static function makeKnownUrlDetails( $name, $urlaction = '' ) {
1185        $title = $name instanceof Title ? $name : Title::newFromText( $name );
1186        return [
1187            'href' => $title ? $title->getLocalURL( $urlaction ) : '',
1188            'exists' => (bool)$title,
1189        ];
1190    }
1191
1192    /**
1193     * Allows correcting the language of interlanguage links which, mostly due to
1194     * legacy reasons, do not always match the standards compliant language tag.
1195     *
1196     * @param string $code
1197     * @return string
1198     * @since 1.35
1199     */
1200    public function mapInterwikiToLanguage( $code ) {
1201        $map = $this->getConfig()->get( MainConfigNames::InterlanguageLinkCodeMap );
1202        return $map[ $code ] ?? $code;
1203    }
1204
1205    /**
1206     * Generates array of language links for the current page.
1207     * This may includes items added to this section by the SidebarBeforeOutput hook
1208     * (which may not necessarily be language links)
1209     *
1210     * @since 1.35
1211     * @return array
1212     */
1213    public function getLanguages() {
1214        if ( $this->getConfig()->get( MainConfigNames::HideInterlanguageLinks ) ) {
1215            return [];
1216        }
1217        if ( $this->languageLinks === null ) {
1218            $hookRunner = $this->getHookRunner();
1219
1220            $userLang = $this->getLanguage();
1221            $languageLinks = [];
1222            $langNameUtils = MediaWikiServices::getInstance()->getLanguageNameUtils();
1223
1224            foreach ( $this->getOutput()->getLanguageLinks() as $languageLinkText ) {
1225                $class = 'interlanguage-link interwiki-' . explode( ':', $languageLinkText, 2 )[0];
1226
1227                $languageLinkTitle = Title::newFromText( $languageLinkText );
1228                if ( !$languageLinkTitle ) {
1229                    continue;
1230                }
1231
1232                $ilInterwikiCode =
1233                    $this->mapInterwikiToLanguage( $languageLinkTitle->getInterwiki() );
1234
1235                $ilLangName = $langNameUtils->getLanguageName( $ilInterwikiCode );
1236
1237                if ( strval( $ilLangName ) === '' ) {
1238                    $ilDisplayTextMsg = $this->msg( "interlanguage-link-$ilInterwikiCode" );
1239                    if ( !$ilDisplayTextMsg->isDisabled() ) {
1240                        // Use custom MW message for the display text
1241                        $ilLangName = $ilDisplayTextMsg->text();
1242                    } else {
1243                        // Last resort: fallback to the language link target
1244                        $ilLangName = $languageLinkText;
1245                    }
1246                } else {
1247                    // Use the language autonym as display text
1248                    $ilLangName = $this->getLanguage()->ucfirst( $ilLangName );
1249                }
1250
1251                // CLDR extension or similar is required to localize the language name;
1252                // otherwise we'll end up with the autonym again.
1253                $ilLangLocalName =
1254                    $langNameUtils->getLanguageName( $ilInterwikiCode, $userLang->getCode() );
1255
1256                $languageLinkTitleText = $languageLinkTitle->getText();
1257                if ( $ilLangLocalName === '' ) {
1258                    $ilFriendlySiteName =
1259                        $this->msg( "interlanguage-link-sitename-$ilInterwikiCode" );
1260                    if ( !$ilFriendlySiteName->isDisabled() ) {
1261                        if ( $languageLinkTitleText === '' ) {
1262                            $ilTitle =
1263                                $this->msg( 'interlanguage-link-title-nonlangonly',
1264                                    $ilFriendlySiteName->text() )->text();
1265                        } else {
1266                            $ilTitle =
1267                                $this->msg( 'interlanguage-link-title-nonlang',
1268                                    $languageLinkTitleText, $ilFriendlySiteName->text() )->text();
1269                        }
1270                    } else {
1271                        // we have nothing friendly to put in the title, so fall back to
1272                        // displaying the interlanguage link itself in the title text
1273                        // (similar to what is done in page content)
1274                        $ilTitle = $languageLinkTitle->getInterwiki() . ":$languageLinkTitleText";
1275                    }
1276                } elseif ( $languageLinkTitleText === '' ) {
1277                    $ilTitle =
1278                        $this->msg( 'interlanguage-link-title-langonly', $ilLangLocalName )->text();
1279                } else {
1280                    $ilTitle =
1281                        $this->msg( 'interlanguage-link-title', $languageLinkTitleText,
1282                            $ilLangLocalName )->text();
1283                }
1284
1285                $ilInterwikiCodeBCP47 = LanguageCode::bcp47( $ilInterwikiCode );
1286                $languageLink = [
1287                    'href' => $languageLinkTitle->getFullURL(),
1288                    'text' => $ilLangName,
1289                    'title' => $ilTitle,
1290                    'class' => $class,
1291                    'link-class' => 'interlanguage-link-target',
1292                    'lang' => $ilInterwikiCodeBCP47,
1293                    'hreflang' => $ilInterwikiCodeBCP47,
1294                ];
1295                $hookRunner->onSkinTemplateGetLanguageLink(
1296                    $languageLink, $languageLinkTitle, $this->getTitle(), $this->getOutput()
1297                );
1298                $languageLinks[] = $languageLink;
1299            }
1300            $this->languageLinks = $languageLinks;
1301        }
1302
1303        return $this->languageLinks;
1304    }
1305
1306    /**
1307     * Build array of common navigation links.
1308     * Assumes thispage property has been set before execution.
1309     * @since 1.35
1310     * @return array
1311     */
1312    protected function buildNavUrls() {
1313        $out = $this->getOutput();
1314        $title = $this->getTitle();
1315        $thispage = $title->getPrefixedDBkey();
1316        $uploadNavigationUrl = $this->getConfig()->get( MainConfigNames::UploadNavigationUrl );
1317
1318        $nav_urls = [];
1319        $nav_urls['mainpage'] = [ 'href' => self::makeMainPageUrl() ];
1320        if ( $uploadNavigationUrl ) {
1321            $nav_urls['upload'] = [ 'href' => $uploadNavigationUrl ];
1322        } elseif ( UploadBase::isEnabled() && UploadBase::isAllowed( $this->getAuthority() ) === true ) {
1323            $nav_urls['upload'] = [ 'href' => SkinComponentUtils::makeSpecialUrl( 'Upload' ) ];
1324        } else {
1325            $nav_urls['upload'] = false;
1326        }
1327        $nav_urls['specialpages'] = [ 'href' => SkinComponentUtils::makeSpecialUrl( 'Specialpages' ) ];
1328
1329        $nav_urls['print'] = false;
1330        $nav_urls['permalink'] = false;
1331        $nav_urls['info'] = false;
1332        $nav_urls['whatlinkshere'] = false;
1333        $nav_urls['recentchangeslinked'] = false;
1334        $nav_urls['contributions'] = false;
1335        $nav_urls['log'] = false;
1336        $nav_urls['blockip'] = false;
1337        $nav_urls['changeblockip'] = false;
1338        $nav_urls['unblockip'] = false;
1339        $nav_urls['mute'] = false;
1340        $nav_urls['emailuser'] = false;
1341        $nav_urls['userrights'] = false;
1342
1343        // A print stylesheet is attached to all pages, but nobody ever
1344        // figures that out. :)  Add a link...
1345        if ( !$out->isPrintable() && ( $out->isArticle() || $title->isSpecialPage() ) ) {
1346            $nav_urls['print'] = [
1347                'text' => $this->msg( 'printableversion' )->text(),
1348                'href' => 'javascript:print();'
1349            ];
1350        }
1351
1352        if ( $out->isArticle() ) {
1353            // Also add a "permalink" while we're at it
1354            $revid = $out->getRevisionId();
1355            if ( $revid ) {
1356                $nav_urls['permalink'] = [
1357                    'icon' => 'link',
1358                    'text' => $this->msg( 'permalink' )->text(),
1359                    'href' => $title->getLocalURL( "oldid=$revid" )
1360                ];
1361            }
1362        }
1363
1364        if ( $out->isArticleRelated() ) {
1365            $nav_urls['whatlinkshere'] = [
1366                'href' => SpecialPage::getTitleFor( 'Whatlinkshere', $thispage )->getLocalURL()
1367            ];
1368
1369            $nav_urls['info'] = [
1370                'icon' => 'infoFilled',
1371                'text' => $this->msg( 'pageinfo-toolboxlink' )->text(),
1372                'href' => $title->getLocalURL( "action=info" )
1373            ];
1374
1375            if ( $title->exists() || $title->inNamespace( NS_CATEGORY ) ) {
1376                $nav_urls['recentchangeslinked'] = [
1377                    'href' => SpecialPage::getTitleFor( 'Recentchangeslinked', $thispage )->getLocalURL()
1378                ];
1379            }
1380        }
1381
1382        $user = $this->getRelevantUser();
1383
1384        if ( $user ) {
1385            $rootUser = $user->getName();
1386
1387            $nav_urls['contributions'] = [
1388                'text' => $this->msg( 'tool-link-contributions', $rootUser )->text(),
1389                'href' => SkinComponentUtils::makeSpecialUrlSubpage( 'Contributions', $rootUser ),
1390                'tooltip-params' => [ $rootUser ],
1391            ];
1392
1393            $nav_urls['log'] = [
1394                'icon' => 'listBullet',
1395                'href' => SkinComponentUtils::makeSpecialUrlSubpage( 'Log', $rootUser )
1396            ];
1397
1398            if ( $this->getAuthority()->isAllowed( 'block' ) ) {
1399                // Check if the user is already blocked
1400                $userBlock = MediaWikiServices::getInstance()
1401                ->getBlockManager()
1402                ->getBlock( $user, null );
1403                if ( $userBlock ) {
1404                    $nav_urls['changeblockip'] = [
1405                        'icon' => 'block',
1406                        'href' => SkinComponentUtils::makeSpecialUrlSubpage( 'Block', $rootUser )
1407                    ];
1408                    $nav_urls['unblockip'] = [
1409                        'icon' => 'unBlock',
1410                        'href' => SkinComponentUtils::makeSpecialUrlSubpage( 'Unblock', $rootUser )
1411                    ];
1412                } else {
1413                    $nav_urls['blockip'] = [
1414                        'icon' => 'block',
1415                        'text' => $this->msg( 'blockip', $rootUser )->text(),
1416                        'href' => SkinComponentUtils::makeSpecialUrlSubpage( 'Block', $rootUser )
1417                    ];
1418                }
1419            }
1420
1421            if ( $this->showEmailUser( $user ) ) {
1422                $nav_urls['emailuser'] = [
1423                    'text' => $this->msg( 'tool-link-emailuser', $rootUser )->text(),
1424                    'href' => SkinComponentUtils::makeSpecialUrlSubpage( 'Emailuser', $rootUser ),
1425                    'tooltip-params' => [ $rootUser ],
1426                ];
1427            }
1428
1429            if ( $user->isRegistered() ) {
1430                if ( $this->getConfig()->get( MainConfigNames::EnableSpecialMute ) &&
1431                    $this->getUser()->isNamed()
1432                ) {
1433                    $nav_urls['mute'] = [
1434                        'text' => $this->msg( 'mute-preferences' )->text(),
1435                        'href' => SkinComponentUtils::makeSpecialUrlSubpage( 'Mute', $rootUser )
1436                    ];
1437                }
1438
1439                $sur = new SpecialUserRights;
1440                $sur->setContext( $this->getContext() );
1441                $canChange = $sur->userCanChangeRights( $user );
1442                $delimiter = $this->getConfig()->get(
1443                    MainConfigNames::UserrightsInterwikiDelimiter );
1444                if ( str_contains( $rootUser, $delimiter ) ) {
1445                    // Username contains interwiki delimiter, link it via the
1446                    // #{userid} syntax. (T260222)
1447                    $linkArgs = [ false, [ 'user' => '#' . $user->getId() ] ];
1448                } else {
1449                    $linkArgs = [ $rootUser ];
1450                }
1451                $nav_urls['userrights'] = [
1452                    'icon' => 'userGroup',
1453                    'text' => $this->msg(
1454                        $canChange ? 'tool-link-userrights' : 'tool-link-userrights-readonly',
1455                        $rootUser
1456                    )->text(),
1457                    'href' => SkinComponentUtils::makeSpecialUrlSubpage( 'Userrights', ...$linkArgs )
1458                ];
1459            }
1460        }
1461
1462        return $nav_urls;
1463    }
1464
1465    /**
1466     * Build data structure representing syndication links.
1467     * @since 1.35
1468     * @return array
1469     */
1470    final protected function buildFeedUrls() {
1471        $feeds = [];
1472        $out = $this->getOutput();
1473        if ( $out->isSyndicated() ) {
1474            foreach ( $out->getSyndicationLinks() as $format => $link ) {
1475                $feeds[$format] = [
1476                    // Messages: feed-atom, feed-rss
1477                    'text' => $this->msg( "feed-$format" )->text(),
1478                    'href' => $link
1479                ];
1480            }
1481        }
1482        return $feeds;
1483    }
1484
1485    /**
1486     * Build an array that represents the sidebar(s), the navigation bar among them.
1487     *
1488     * BaseTemplate::getSidebar can be used to simplify the format and id generation in new skins.
1489     *
1490     * The format of the returned array is [ heading => content, ... ], where:
1491     * - heading is the heading of a navigation portlet. It is either:
1492     *   - magic string to be handled by the skins ('SEARCH' / 'LANGUAGES' / 'TOOLBOX' / ...)
1493     *     - Note that 'SEARCH' unlike others is not supported out-of-the-box by the skins.
1494     *     - For it to work, a skin must add custom support for it.
1495     *   - a message name (e.g. 'navigation'), the message should be HTML-escaped by the skin
1496     *   - plain text, which should be HTML-escaped by the skin
1497     * - content is the contents of the portlet. This must be array of link data in a format
1498     *         accepted by self::makeListItem().
1499     *   - (for a magic string as a key, any value)
1500     *
1501     * Note that extensions can control the sidebar contents using the SkinBuildSidebar hook
1502     * and can technically insert anything in here; skin creators are expected to handle
1503     * values described above.
1504     *
1505     * @return array
1506     */
1507    public function buildSidebar() {
1508        if ( $this->sidebar === null ) {
1509            $services = MediaWikiServices::getInstance();
1510            $callback = function ( $old = null, &$ttl = null ) {
1511                $bar = [];
1512                $this->addToSidebar( $bar, 'sidebar' );
1513
1514                // This hook may vary its behaviour by skin.
1515                $this->getHookRunner()->onSkinBuildSidebar( $this, $bar );
1516                $msgCache = MediaWikiServices::getInstance()->getMessageCache();
1517                if ( $msgCache->isDisabled() ) {
1518                    // Don't cache the fallback if DB query failed. T133069
1519                    $ttl = WANObjectCache::TTL_UNCACHEABLE;
1520                }
1521
1522                return $bar;
1523            };
1524
1525            $msgCache = $services->getMessageCache();
1526            $wanCache = $services->getMainWANObjectCache();
1527            $config = $this->getConfig();
1528            $languageCode = $this->getLanguage()->getCode();
1529
1530            $sidebar = $config->get( MainConfigNames::EnableSidebarCache )
1531                ? $wanCache->getWithSetCallback(
1532                    $wanCache->makeKey( 'sidebar', $languageCode, $this->getSkinName() ?? '' ),
1533                    $config->get( MainConfigNames::SidebarCacheExpiry ),
1534                    $callback,
1535                    [
1536                        'checkKeys' => [
1537                            // Unless there is both no exact $code override nor an i18n definition
1538                            // in the software, the only MediaWiki page to check is for $code.
1539                            $msgCache->getCheckKey( $languageCode )
1540                        ],
1541                        'lockTSE' => 30
1542                    ]
1543                )
1544                : $callback();
1545
1546            $sidebar['TOOLBOX'] = array_merge(
1547                $this->makeToolbox(
1548                    $this->buildNavUrls(),
1549                    $this->buildFeedUrls()
1550                ), $sidebar['TOOLBOX'] ?? []
1551            );
1552
1553            $sidebar['LANGUAGES'] = $this->getLanguages();
1554            // Apply post-processing to the cached value
1555            $this->getHookRunner()->onSidebarBeforeOutput( $this, $sidebar );
1556            $this->sidebar = $sidebar;
1557        }
1558
1559        return $this->sidebar;
1560    }
1561
1562    /**
1563     * Add content from a sidebar system message
1564     * Currently only used for MediaWiki:Sidebar (but may be used by Extensions)
1565     *
1566     * This is just a wrapper around addToSidebarPlain() for backwards compatibility
1567     *
1568     * @param array &$bar
1569     * @param string $message
1570     */
1571    public function addToSidebar( &$bar, $message ) {
1572        $this->addToSidebarPlain( $bar, $this->msg( $message )->inContentLanguage()->plain() );
1573    }
1574
1575    /**
1576     * Add content from plain text
1577     * @since 1.17
1578     * @param array &$bar
1579     * @param string $text
1580     * @return array
1581     */
1582    public function addToSidebarPlain( &$bar, $text ) {
1583        $lines = explode( "\n", $text );
1584
1585        $heading = '';
1586        $config = $this->getConfig();
1587        $messageTitle = $config->get( MainConfigNames::EnableSidebarCache )
1588            ? Title::newMainPage() : $this->getTitle();
1589        $services = MediaWikiServices::getInstance();
1590        $messageCache = $services->getMessageCache();
1591        $urlUtils = $services->getUrlUtils();
1592
1593        foreach ( $lines as $line ) {
1594            if ( strpos( $line, '*' ) !== 0 ) {
1595                continue;
1596            }
1597            $line = rtrim( $line, "\r" ); // for Windows compat
1598
1599            if ( strpos( $line, '**' ) !== 0 ) {
1600                $heading = trim( $line, '* ' );
1601                if ( !array_key_exists( $heading, $bar ) ) {
1602                    $bar[$heading] = [];
1603                }
1604            } else {
1605                $line = trim( $line, '* ' );
1606
1607                if ( strpos( $line, '|' ) !== false ) {
1608                    $line = $messageCache->transform( $line, false, null, $messageTitle );
1609                    $line = array_map( 'trim', explode( '|', $line, 2 ) );
1610                    if ( count( $line ) !== 2 ) {
1611                        // Second check, could be hit by people doing
1612                        // funky stuff with parserfuncs... (T35321)
1613                        continue;
1614                    }
1615
1616                    $extraAttribs = [];
1617
1618                    $msgLink = $this->msg( $line[0] )->page( $messageTitle )->inContentLanguage();
1619                    if ( $msgLink->exists() ) {
1620                        $link = $msgLink->text();
1621                        if ( $link == '-' ) {
1622                            continue;
1623                        }
1624                    } else {