Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 718
0.00% covered (danger)
0.00%
0 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
TimelessTemplate
0.00% covered (danger)
0.00%
0 / 718
0.00% covered (danger)
0.00%
0 / 21
18360
0.00% covered (danger)
0.00%
0 / 1
 execute
0.00% covered (danger)
0.00%
0 / 54
0.00% covered (danger)
0.00%
0 / 1
12
 getContentBlock
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
2
 getPortlet
0.00% covered (danger)
0.00%
0 / 79
0.00% covered (danger)
0.00%
0 / 1
110
 mergeClasses
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getFooterBlock
0.00% covered (danger)
0.00%
0 / 69
0.00% covered (danger)
0.00%
0 / 1
110
 getSidebarChunk
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
2
 getLogo
0.00% covered (danger)
0.00%
0 / 59
0.00% covered (danger)
0.00%
0 / 1
182
 getSearch
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
2
 getMainNavigation
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 getHeaderHack
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
2
 getPageToolSidebar
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
6
 getUserLinks
0.00% covered (danger)
0.00%
0 / 54
0.00% covered (danger)
0.00%
0 / 1
182
 getSiteNotices
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getContentSub
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getAfterContent
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 getPageTools
0.00% covered (danger)
0.00%
0 / 106
0.00% covered (danger)
0.00%
0 / 1
462
 getCategories
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
72
 getCatList
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 getVariants
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 getInterwikiLinks
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
56
 getLogoImage
0.00% covered (danger)
0.00%
0 / 58
0.00% covered (danger)
0.00%
0 / 1
600
1<?php
2/**
3 * BaseTemplate class for the Timeless skin
4 *
5 * @ingroup Skins
6 */
7
8namespace MediaWiki\Skin\Timeless;
9
10use MediaWiki\FileRepo\File\File;
11use MediaWiki\Html\Html;
12use MediaWiki\Linker\Linker;
13use MediaWiki\MediaWikiServices;
14use MediaWiki\Parser\Sanitizer;
15use MediaWiki\ResourceLoader\SkinModule;
16use MediaWiki\Skin\BaseTemplate;
17use MediaWiki\SpecialPage\SpecialPage;
18
19class TimelessTemplate extends BaseTemplate {
20
21    /** @var array */
22    protected $pileOfTools;
23
24    /** @var (array|false)[] */
25    protected $sidebar;
26
27    /** @var array|null */
28    protected $otherProjects;
29
30    /** @var array|null */
31    protected $collectionPortlet;
32
33    /** @var array[] */
34    protected $languages;
35
36    /** @var string */
37    protected $afterLangPortlet;
38
39    /**
40     * Outputs the entire contents of the page
41     */
42    public function execute() {
43        $this->sidebar = $this->data['sidebar'];
44        $this->languages = $this->sidebar['LANGUAGES'];
45
46        // WikiBase sidebar thing
47        // The hook will set it even if it's empty, but we don't care about
48        // other projects if it's empty.
49        if ( !empty( $this->sidebar['wikibase-otherprojects'] ) ) {
50            $this->otherProjects = $this->sidebar['wikibase-otherprojects'];
51            unset( $this->sidebar['wikibase-otherprojects'] );
52        }
53        // Collection sidebar thing
54        if ( isset( $this->sidebar['coll-print_export'] ) ) {
55            $this->collectionPortlet = $this->sidebar['coll-print_export'];
56            unset( $this->sidebar['coll-print_export'] );
57        }
58
59        $this->pileOfTools = $this->getPageTools();
60        $userLinks = $this->getUserLinks();
61
62        $html = Html::openElement( 'div', [ 'id' => 'mw-wrapper', 'class' => $userLinks['class'] ] );
63
64        $html .= Html::rawElement( 'div', [ 'id' => 'mw-header-container', 'class' => 'ts-container' ],
65            Html::rawElement( 'div', [ 'id' => 'mw-header', 'class' => 'ts-inner' ],
66                $userLinks['html'] .
67                $this->getLogo( 'p-logo-text', 'text' ) .
68                $this->getSearch()
69            ) .
70            $this->getClear()
71        );
72        $html .= $this->getHeaderHack();
73
74        // For mobile
75        $html .= Html::element( 'div', [ 'id' => 'menus-cover' ] );
76
77        $html .= Html::rawElement( 'div', [ 'id' => 'mw-content-container', 'class' => 'ts-container' ],
78            Html::rawElement( 'div', [ 'id' => 'mw-content-block', 'class' => 'ts-inner' ],
79                Html::rawElement( 'div', [ 'id' => 'mw-content-wrapper' ],
80                    $this->getContentBlock() .
81                    $this->getAfterContent()
82                ) .
83                Html::rawElement( 'div', [ 'id' => 'mw-site-navigation' ],
84                    $this->getLogo( 'p-logo', 'image' ) .
85                    $this->getMainNavigation() .
86                    $this->getSidebarChunk(
87                        'site-tools',
88                        'timeless-sitetools',
89                        $this->getPortlet(
90                            'tb',
91                            $this->pileOfTools['general'],
92                            'timeless-sitetools'
93                        )
94                    )
95                ) .
96                Html::rawElement( 'div', [ 'id' => 'mw-related-navigation' ],
97                    $this->getPageToolSidebar() .
98                    $this->getInterwikiLinks() .
99                    $this->getCategories()
100                ) .
101                $this->getClear()
102            )
103        );
104
105        $html .= Html::rawElement( 'div',
106            [ 'id' => 'mw-footer-container', 'class' => 'mw-footer-container ts-container' ],
107            $this->getFooterBlock( [ 'class' => [ 'mw-footer', 'ts-inner' ], 'id' => 'mw-footer' ] )
108        );
109
110        $html .= Html::closeElement( 'div' );
111
112        // The unholy echo
113        echo $html;
114    }
115
116    /**
117     * Generate the page content block
118     * Broken out here due to the excessive indenting, or stuff.
119     *
120     * @return string html
121     */
122    protected function getContentBlock() {
123        $templateData = $this->getSkin()->getTemplateData();
124        $html = Html::rawElement(
125            'div',
126            [ 'id' => 'content', 'class' => 'mw-body', 'role' => 'main' ],
127            $this->getSiteNotices() .
128            $this->getIndicators() .
129            $templateData[ 'html-title-heading' ] .
130            Html::rawElement( 'div', [ 'id' => 'bodyContentOuter' ],
131                Html::rawElement( 'div', [ 'id' => 'siteSub' ], $this->getMsg( 'tagline' )->parse() ) .
132                Html::rawElement( 'div', [ 'id' => 'mw-page-header-links' ],
133                    $this->getPortlet(
134                        'namespaces',
135                        $this->pileOfTools['namespaces'],
136                        'timeless-namespaces',
137                        [ 'extra-classes' => 'tools-inline' ]
138                    ) .
139                    $this->getPortlet(
140                        'more',
141                        $this->pileOfTools['more'],
142                        'timeless-more',
143                        [ 'extra-classes' => 'tools-inline' ]
144                    ) .
145                    $this->getVariants() .
146                    $this->getPortlet(
147                        'views',
148                        $this->pileOfTools['page-primary'],
149                        'timeless-pagetools',
150                        [ 'extra-classes' => 'tools-inline' ]
151                    )
152                ) .
153                $this->getClear() .
154                Html::rawElement( 'div', [ 'id' => 'bodyContent' ],
155                    $this->getContentSub() .
156                    $this->get( 'bodytext' ) .
157                    $this->getClear()
158                )
159            )
160        );
161
162        return Html::rawElement( 'div', [ 'id' => 'mw-content' ], $html );
163    }
164
165    /**
166     * Generates a block of navigation links with a header
167     * This is some random fork of some random fork of what was supposed to be in core. Latest
168     * version copied out of MonoBook, probably. (20190719)
169     *
170     * @param string $name
171     * @param array|string $content array of links for use with makeListItem, or a block of text
172     *        Expected array format:
173     *     [
174     *         $name => [
175     *             'links' => [ '0' =>
176     *                 [
177     *                     'href' => ...,
178     *                     'single-id' => ...,
179     *                     'text' => ...
180     *                 ]
181     *             ],
182     *             'id' => ...,
183     *             'active' => ...
184     *         ],
185     *         ...
186     *     ]
187     * @param null|string|array|bool $msg
188     * @param array $setOptions miscellaneous overrides, see below
189     *
190     * @return string html
191     * @suppress PhanTypeMismatchArgumentNullable
192     */
193    protected function getPortlet( $name, $content, $msg = null, $setOptions = [] ) {
194        $skin = $this->getSkin();
195        // random stuff to override with any provided options
196        $options = array_merge( [
197            'role' => 'navigation',
198            // extra classes/ids
199            'id' => 'p-' . $name,
200            'class' => [ 'mw-portlet', 'emptyPortlet' => !$content ],
201            'extra-classes' => '',
202            'body-id' => null,
203            'body-class' => 'mw-portlet-body',
204            'body-extra-classes' => '',
205            // wrapper for individual list items
206            'text-wrapper' => [ 'tag' => 'span' ],
207            // option to stick arbitrary stuff at the beginning of the ul
208            'list-prepend' => ''
209        ], $setOptions );
210
211        // Handle the different $msg possibilities
212        if ( $msg === null ) {
213            $msg = $name;
214            $msgParams = [];
215        } elseif ( is_array( $msg ) ) {
216            $msgString = array_shift( $msg );
217            $msgParams = $msg;
218            $msg = $msgString;
219        } else {
220            $msgParams = [];
221        }
222        $msgObj = $this->getMsg( $msg, $msgParams );
223        if ( $msgObj->exists() ) {
224            $msgString = $msgObj->parse();
225        } else {
226            $msgString = htmlspecialchars( $msg );
227        }
228
229        $labelId = Sanitizer::escapeIdForAttribute( "p-$name-label" );
230
231        if ( is_array( $content ) ) {
232            $contentText = Html::openElement( 'ul',
233                [ 'lang' => $this->get( 'userlang' ), 'dir' => $this->get( 'dir' ) ]
234            );
235            $contentText .= $options['list-prepend'];
236            foreach ( $content as $key => $item ) {
237                if ( is_array( $options['text-wrapper'] ) ) {
238                    $contentText .= $skin->makeListItem(
239                        $key,
240                        $item,
241                        [ 'text-wrapper' => $options['text-wrapper'] ]
242                    );
243                } else {
244                    $contentText .= $skin->makeListItem(
245                        $key,
246                        $item
247                    );
248                }
249            }
250            $contentText .= Html::closeElement( 'ul' );
251        } else {
252            $contentText = $content;
253        }
254
255        $divOptions = [
256            'role' => $options['role'],
257            'class' => $this->mergeClasses( $options['class'], $options['extra-classes'] ),
258            'id' => Sanitizer::escapeIdForAttribute( $options['id'] ),
259            'title' => Linker::titleAttrib( $options['id'] ),
260            'aria-labelledby' => $labelId
261        ];
262        $labelOptions = [
263            'id' => $labelId,
264            'lang' => $this->get( 'userlang' ),
265            'dir' => $this->get( 'dir' )
266        ];
267
268        $bodyDivOptions = [
269            'class' => $this->mergeClasses( $options['body-class'], $options['body-extra-classes'] )
270        ];
271        if ( is_string( $options['body-id'] ) ) {
272            $bodyDivOptions['id'] = $options['body-id'];
273        }
274
275        $afterPortlet = '';
276        $content = $this->getSkin()->getAfterPortlet( $name );
277        if ( $content !== '' ) {
278            $afterPortlet = Html::rawElement(
279                'div',
280                [ 'class' => [ 'after-portlet', 'after-portlet-' . $name ] ],
281                $content
282            );
283        }
284
285        if ( $name === 'lang' ) {
286            $this->afterLangPortlet = $afterPortlet;
287        }
288
289        $html = Html::rawElement( 'div', $divOptions,
290            Html::rawElement( 'h3', $labelOptions, $msgString ) .
291            Html::rawElement( 'div', $bodyDivOptions,
292                $contentText .
293                $afterPortlet
294            )
295        );
296
297        return $html;
298    }
299
300    /**
301     * Helper function for getPortlet
302     *
303     * Merge all provided css classes into a single array
304     * Account for possible different input methods matching what Html::element stuff takes
305     *
306     * @param string|array $class base portlet/body class
307     * @param string|array $extraClasses any extra classes to also include
308     *
309     * @return array all classes to apply
310     */
311    protected function mergeClasses( $class, $extraClasses ) {
312        if ( !is_array( $class ) ) {
313            $class = [ $class ];
314        }
315        if ( !is_array( $extraClasses ) ) {
316            $extraClasses = [ $extraClasses ];
317        }
318
319        return array_merge( $class, $extraClasses );
320    }
321
322    /**
323     * Better renderer for getFooterIcons and getFooterLinks, based on Vector's HTML output
324     * (as of 2016)
325     *
326     * @param array $setOptions Miscellaneous other options
327     * * 'id' for footer id
328     * * 'class' for footer class
329     * * 'order' to determine whether icons or links appear first: 'iconsfirst' or links, though in
330     *   practice we currently only check if it is or isn't 'iconsfirst'
331     * * 'link-prefix' to set the prefix for all link and block ids; most skins use 'f' or 'footer',
332     *   as in id='f-whatever' vs id='footer-whatever'
333     * * 'link-style' to pass to getFooterLinks: "flat" to disable categorisation of links in a
334     *   nested array
335     *
336     * @return string html
337     */
338    protected function getFooterBlock( $setOptions = [] ) {
339        // Set options and fill in defaults
340        $options = $setOptions + [
341            'id' => 'footer',
342            'class' => 'mw-footer',
343            'order' => 'iconsfirst',
344            'link-prefix' => 'footer',
345            'link-style' => null
346        ];
347
348        // phpcs:ignore Generic.Files.LineLength.TooLong
349        '@phan-var array{id:string,class:string,order:string,link-prefix:string,link-style:?string} $options';
350        $validFooterIcons = $this->get( 'footericons' );
351        $validFooterLinks = $this->getFooterLinks( $options['link-style'] );
352
353        $html = '';
354
355        $html .= Html::openElement( 'div', [
356            'id' => $options['id'],
357            'class' => $options['class'],
358            'role' => 'contentinfo',
359            'lang' => $this->get( 'userlang' ),
360            'dir' => $this->get( 'dir' )
361        ] );
362
363        $iconsHTML = '';
364        if ( count( $validFooterIcons ) > 0 ) {
365            $iconsHTML .= Html::openElement( 'ul', [ 'id' => "{$options['link-prefix']}-icons" ] );
366            foreach ( $validFooterIcons as $blockName => $footerIcons ) {
367                $iconsHTML .= Html::openElement( 'li', [
368                    'id' => Sanitizer::escapeIdForAttribute(
369                        "{$options['link-prefix']}-{$blockName}ico"
370                    ),
371                    'class' => 'footer-icons'
372                ] );
373                foreach ( $footerIcons as $icon ) {
374                    $iconsHTML .= $this->getSkin()->makeFooterIcon( $icon );
375                }
376                $iconsHTML .= Html::closeElement( 'li' );
377            }
378            $iconsHTML .= Html::closeElement( 'ul' );
379        }
380
381        $linksHTML = '';
382        if ( count( $validFooterLinks ) > 0 ) {
383            if ( $options['link-style'] === 'flat' ) {
384                $linksHTML .= Html::openElement( 'ul', [
385                    'id' => "{$options['link-prefix']}-list",
386                    'class' => 'footer-places'
387                ] );
388                foreach ( $validFooterLinks as $link ) {
389                    $linksHTML .= Html::rawElement(
390                        'li',
391                        [ 'id' => Sanitizer::escapeIdForAttribute( $link ) ],
392                        $this->get( $link )
393                    );
394                }
395                $linksHTML .= Html::closeElement( 'ul' );
396            } else {
397                $linksHTML .= Html::openElement( 'div', [ 'id' => "{$options['link-prefix']}-list" ] );
398                foreach ( $validFooterLinks as $category => $links ) {
399                    $linksHTML .= Html::openElement( 'ul',
400                        [ 'id' => Sanitizer::escapeIdForAttribute(
401                            "{$options['link-prefix']}-{$category}"
402                        ) ]
403                    );
404                    foreach ( $links as $link ) {
405                        $linksHTML .= Html::rawElement(
406                            'li',
407                            [ 'id' => Sanitizer::escapeIdForAttribute(
408                                "{$options['link-prefix']}-{$category}-{$link}"
409                            ) ],
410                            $this->get( $link )
411                        );
412                    }
413                    $linksHTML .= Html::closeElement( 'ul' );
414                }
415                $linksHTML .= Html::closeElement( 'div' );
416            }
417        }
418
419        if ( $options['order'] === 'iconsfirst' ) {
420            $html .= $iconsHTML . $linksHTML;
421        } else {
422            $html .= $linksHTML . $iconsHTML;
423        }
424
425        $html .= $this->getClear() . Html::closeElement( 'div' );
426
427        return $html;
428    }
429
430    /**
431     * Sidebar chunk containing one or more portlets
432     *
433     * @param string $id
434     * @param string $headerMessage
435     * @param string $content
436     * @param array $classes
437     *
438     * @return string html
439     */
440    protected function getSidebarChunk( $id, $headerMessage, $content, $classes = [] ) {
441        $html = '';
442
443        $html .= Html::rawElement(
444            'div',
445            [
446                'id' => Sanitizer::escapeIdForAttribute( $id ),
447                'class' => array_merge( [ 'sidebar-chunk' ], $classes )
448            ],
449            Html::rawElement( 'h2', [],
450                Html::element( 'span', [],
451                    $this->getMsg( $headerMessage )->text()
452                )
453            ) .
454            Html::rawElement( 'div', [ 'class' => 'sidebar-inner' ], $content )
455        );
456
457        return $html;
458    }
459
460    /**
461     * The logo and (optionally) site title
462     *
463     * @param string $id
464     * @param string $part whether it's only image, only text, or both
465     *
466     * @return string html
467     */
468    protected function getLogo( $id = 'p-logo', $part = 'both' ) {
469        $html = '';
470        $config = $this->getSkin()->getContext()->getConfig();
471
472        $html .= Html::openElement(
473            'div',
474            [
475                'id' => Sanitizer::escapeIdForAttribute( $id ),
476                'class' => 'mw-portlet',
477                'role' => 'banner'
478            ]
479        );
480        $logos = SkinModule::getAvailableLogos( $config, $this->getSkin()->getLanguage()->getCode() );
481        if ( $part !== 'image' ) {
482            $wordmarkImage = $this->getLogoImage( $config->get( 'TimelessWordmark' ), true );
483            if ( !$wordmarkImage && isset( $logos['wordmark'] ) ) {
484                $wordmarkData = $logos['wordmark'];
485                $wordmarkImage = Html::element( 'img', [
486                    'src' => $wordmarkData['src'],
487                    'height' => $wordmarkData['height'] ?? null,
488                    'width' => $wordmarkData['width'] ?? null,
489                ] );
490            }
491
492            $titleClass = '';
493            $siteTitle = '';
494            if ( !$wordmarkImage ) {
495                $langConv = MediaWikiServices::getInstance()->getLanguageConverterFactory()
496                    ->getLanguageConverter( $this->getSkin()->getLanguage() );
497                if ( $langConv->hasVariants() ) {
498                    $siteTitle = $langConv->convert( $this->getMsg( 'timeless-sitetitle' )->escaped() );
499                } else {
500                    $siteTitle = $this->getMsg( 'timeless-sitetitle' )->escaped();
501                }
502                // width is 11em; 13 characters will probably fit?
503                if ( mb_strlen( $siteTitle ) > 13 ) {
504                    $titleClass = 'long';
505                }
506            } else {
507                $titleClass = 'wordmark';
508            }
509            $html .= Html::rawElement( 'a', [
510                    'id' => 'p-banner',
511                    'class' => [ 'mw-wiki-title', $titleClass ],
512                    'href' => $this->data['nav_urls']['mainpage']['href']
513                ],
514                $wordmarkImage ?: $siteTitle
515            );
516
517        }
518        if ( $part !== 'text' ) {
519            $logoImage = $this->getLogoImage( $config->get( 'TimelessLogo' ) );
520            if ( $logoImage === false ) {
521                $logoSrc = $logos['svg'] ?? $logos['icon'] ?? $logos['1x'] ?? '';
522                if ( $logoSrc !== '' ) {
523                    $logoImage = Html::element( 'img', [
524                        'src' => $logoSrc,
525                    ] );
526                }
527            }
528
529            $html .= Html::rawElement(
530                'a',
531                array_merge(
532                    [
533                        'class' => [ 'mw-wiki-logo', !$logoImage ? 'fallback' : 'timeless-logo' ],
534                        'href' => $this->data['nav_urls']['mainpage']['href']
535                    ],
536                    Linker::tooltipAndAccesskeyAttribs( 'p-logo' )
537                ),
538                $logoImage ?: ''
539            );
540        }
541        $html .= Html::closeElement( 'div' );
542
543        return $html;
544    }
545
546    /**
547     * The search box at the top
548     *
549     * @return string html
550     */
551    protected function getSearch() {
552        $skin = $this->getSkin();
553        $html = Html::openElement( 'div', [ 'class' => 'mw-portlet', 'id' => 'p-search' ] );
554
555        $html .= Html::rawElement(
556            'h3',
557            [ 'lang' => $this->get( 'userlang' ), 'dir' => $this->get( 'dir' ) ],
558            Html::rawElement( 'label', [ 'for' => 'searchInput' ], $this->getMsg( 'search' )->escaped() )
559        );
560
561        $html .= Html::rawElement( 'form', [ 'action' => $this->get( 'wgScript' ), 'id' => 'searchform' ],
562            Html::rawElement( 'div', [ 'id' => 'simpleSearch' ],
563                Html::rawElement( 'div', [ 'id' => 'searchInput-container' ],
564                    $skin->makeSearchInput( [
565                        'id' => 'searchInput'
566                    ] )
567                ) .
568                Html::hidden( 'title', $this->get( 'searchtitle' ) ) .
569                $skin->makeSearchButton(
570                    'fulltext',
571                    [ 'id' => 'mw-searchButton', 'class' => 'searchButton mw-fallbackSearchButton' ]
572                ) .
573                $skin->makeSearchButton(
574                    'go',
575                    [ 'id' => 'searchButton', 'class' => 'searchButton' ]
576                )
577            )
578        );
579
580        return $html . Html::closeElement( 'div' );
581    }
582
583    /**
584     * Left sidebar navigation, usually
585     *
586     * @return string html
587     */
588    protected function getMainNavigation() {
589        $html = '';
590
591        // Already hardcoded into header
592        $this->sidebar['SEARCH'] = false;
593        // Parsed as part of pageTools
594        $this->sidebar['TOOLBOX'] = false;
595        // Forcibly removed to separate chunk
596        $this->sidebar['LANGUAGES'] = false;
597        foreach ( $this->sidebar as $name => $content ) {
598            if ( $content === false ) {
599                continue;
600            }
601            // Numeric strings gets an integer when set as key, cast back - T73639
602            $name = (string)$name;
603            $html .= $this->getPortlet( $name, $content );
604        }
605
606        return $this->getSidebarChunk( 'site-navigation', 'navigation', $html );
607    }
608
609    /**
610     * The colour bars
611     * Split this out so we don't have to look at it/can easily kill it later
612     *
613     * @return string html
614     */
615    protected function getHeaderHack() {
616        $html = '';
617
618        // These are almost exactly the same and this is stupid.
619        $html .= Html::rawElement( 'div', [ 'id' => 'mw-header-hack', 'class' => 'color-bar' ],
620            Html::rawElement( 'div', [ 'class' => 'color-middle-container' ],
621                Html::element( 'div', [ 'class' => 'color-middle' ] )
622            ) .
623            Html::element( 'div', [ 'class' => 'color-left' ] ) .
624            Html::element( 'div', [ 'class' => 'color-right' ] )
625        );
626        $html .= Html::rawElement( 'div', [ 'id' => 'mw-header-nav-hack' ],
627            Html::rawElement( 'div', [ 'class' => 'color-bar' ],
628                Html::rawElement( 'div', [ 'class' => 'color-middle-container' ],
629                    Html::element( 'div', [ 'class' => 'color-middle' ] )
630                ) .
631                Html::element( 'div', [ 'class' => 'color-left' ] ) .
632                Html::element( 'div', [ 'class' => 'color-right' ] )
633            )
634        );
635
636        return $html;
637    }
638
639    /**
640     * Page tools in sidebar
641     *
642     * @return string html
643     */
644    protected function getPageToolSidebar() {
645        $pageTools = $this->getPortlet(
646            'cactions',
647            $this->pileOfTools['page-secondary'],
648            'timeless-pageactions'
649        );
650        $pageTools .= $this->getPortlet(
651            'userpagetools',
652            $this->pileOfTools['user'],
653            'timeless-userpagetools'
654        );
655        $pageTools .= $this->getPortlet(
656            'pagemisc',
657            $this->pileOfTools['page-tertiary'],
658            'timeless-pagemisc'
659        );
660        if ( $this->collectionPortlet !== null ) {
661            $pageTools .= $this->getPortlet(
662                'coll-print_export',
663                $this->collectionPortlet
664            );
665        }
666
667        return $this->getSidebarChunk( 'page-tools', 'timeless-pageactions', $pageTools );
668    }
669
670    /**
671     * Personal/user links portlet for header
672     *
673     * @return array [ html, class ], where class is an extra class to apply to surrounding objects
674     * (for width adjustments)
675     */
676    protected function getUserLinks() {
677        $skin = $this->getSkin();
678        $user = $skin->getUser();
679        $personalTools = $skin->getPersonalToolsForMakeListItem( $this->get( 'personal_urls' ) );
680        // Preserve standard username label to allow customisation (T215822)
681        $userName = $personalTools['userpage']['links'][0]['text'] ?? $user->getName();
682
683        $extraTools = [];
684
685        // Remove anon placeholder
686        if ( isset( $personalTools['anonuserpage'] ) ) {
687            unset( $personalTools['anonuserpage'] );
688        }
689        // Remove temp user placeholder, as we display the user name in the dropdown header instead.
690        // Removing the use of .mw-userpage-tmp class also prevents the anchored popup from appearing,
691        // which is good, because there's no reasonable place to put it.
692        if (
693            isset( $personalTools['userpage'] ) &&
694            in_array( 'mw-userpage-tmp', $personalTools['userpage']['links'][0]['class'] ?? [] )
695        ) {
696            unset( $personalTools['userpage'] );
697        }
698
699        // Remove Echo badges
700        if ( isset( $personalTools['notifications-alert'] ) ) {
701            $extraTools['notifications-alert'] = $personalTools['notifications-alert'];
702            unset( $personalTools['notifications-alert'] );
703        }
704        if ( isset( $personalTools['notifications-notice'] ) ) {
705            $extraTools['notifications-notice'] = $personalTools['notifications-notice'];
706            unset( $personalTools['notifications-notice'] );
707        }
708        $class = $extraTools === [] ? '' : 'extension-icons';
709
710        // Re-label some messages
711        if ( isset( $personalTools['userpage'] ) ) {
712            $personalTools['userpage']['links'][0]['text'] =
713                $this->getMsg( 'timeless-userpage', $userName )->text();
714        }
715        if ( isset( $personalTools['mytalk'] ) ) {
716            $personalTools['mytalk']['links'][0]['text'] =
717                $this->getMsg( 'timeless-talkpage', $userName )->text();
718        }
719
720        // Labels
721        if ( $user->isNamed() ) {
722            $dropdownHeader = $userName;
723            $headerMsg = [ 'timeless-loggedinas', $userName ];
724        } elseif ( $user->isTemp() ) {
725            $dropdownHeader = $user->getName();
726            $headerMsg = 'timeless-notloggedin';
727        } else {
728            $dropdownHeader = $this->getMsg( 'timeless-anonymous' )->text();
729            $headerMsg = 'timeless-notloggedin';
730        }
731        $html = Html::openElement( 'div', [ 'id' => 'user-tools' ] );
732
733        $html .= Html::rawElement( 'div', [ 'id' => 'personal' ],
734            Html::rawElement( 'h2', [],
735                Html::element( 'span', [], $dropdownHeader )
736            ) .
737            Html::rawElement( 'div', [ 'id' => 'personal-inner', 'class' => 'dropdown' ],
738                $this->getPortlet( 'personal', $personalTools, $headerMsg )
739            )
740        );
741
742        // Extra icon stuff (echo etc)
743        if ( $extraTools !== [] ) {
744            $iconList = '';
745            foreach ( $extraTools as $key => $item ) {
746                $iconList .= $skin->makeListItem( $key, $item );
747            }
748
749            $html .= Html::rawElement(
750                'div',
751                [ 'id' => 'personal-extra', 'class' => 'p-body' ],
752                Html::rawElement( 'ul', [], $iconList )
753            );
754        }
755
756        $html .= Html::closeElement( 'div' );
757
758        return [
759            'html' => $html,
760            'class' => $class
761        ];
762    }
763
764    /**
765     * Notices that may appear above the firstHeading
766     *
767     * @return string html
768     */
769    protected function getSiteNotices() {
770        $html = '';
771
772        if ( $this->data['sitenotice'] ) {
773            $html .= Html::rawElement( 'div', [ 'id' => 'siteNotice' ], $this->get( 'sitenotice' ) );
774        }
775        if ( $this->data['newtalk'] ) {
776            $html .= Html::rawElement( 'div', [ 'class' => 'usermessage' ], $this->get( 'newtalk' ) );
777        }
778
779        return $html;
780    }
781
782    /**
783     * Links and information that may appear below the firstHeading
784     *
785     * @return string html
786     */
787    protected function getContentSub() {
788        $html = Html::openElement( 'div', [ 'id' => 'contentSub' ] );
789        if ( $this->data['subtitle'] ) {
790            $html .= $this->get( 'subtitle' );
791        }
792        if ( $this->data['undelete'] ) {
793            $html .= $this->get( 'undelete' );
794        }
795        return $html . Html::closeElement( 'div' );
796    }
797
798    /**
799     * The data after content, catlinks, and potential other stuff that may appear within
800     * the content block but after the main content
801     *
802     * @return string html
803     */
804    protected function getAfterContent() {
805        $html = '';
806
807        if ( $this->data['catlinks'] || $this->data['dataAfterContent'] ) {
808            $html .= Html::openElement( 'div', [ 'id' => 'content-bottom-stuff' ] );
809            if ( $this->data['catlinks'] ) {
810                $html .= $this->get( 'catlinks' );
811            }
812            if ( $this->data['dataAfterContent'] ) {
813                $html .= $this->get( 'dataAfterContent' );
814            }
815            $html .= Html::closeElement( 'div' );
816        }
817
818        return $html;
819    }
820
821    /**
822     * Generate pile of all the tools
823     *
824     * We can make a few assumptions based on where a tool started out:
825     *     If it's in the cactions region, it's a page tool, probably primary or secondary
826     *     ...that's all I can think of
827     *
828     * @return array of array of tools information (portlet formatting)
829     */
830    protected function getPageTools() {
831        $title = $this->getSkin()->getTitle();
832        $namespace = $title->getNamespace();
833
834        $sortedPileOfTools = [
835            'namespaces' => [],
836            'page-primary' => [],
837            'page-secondary' => [],
838            'user' => [],
839            'page-tertiary' => [],
840            'more' => [],
841            'general' => []
842        ];
843
844        // Tools specific to the page
845        $pileOfEditTools = [];
846        $contentNavigation = $this->data['content_navigation'];
847
848        foreach ( $contentNavigation as $navKey => $navBlock ) {
849            // Just use namespaces items as they are
850            if ( $navKey == 'namespaces' ) {
851                if ( $namespace < 0 && count( $navBlock ) < 2 ) {
852                    // Put special page ns_pages in the more pile so they're not so lonely
853                    $sortedPileOfTools['page-tertiary'] = $navBlock;
854                } else {
855                    $sortedPileOfTools['namespaces'] = $navBlock;
856                }
857            } elseif ( $navKey == 'variants' ) {
858                // wat
859                $sortedPileOfTools['variants'] = $navBlock;
860            } else {
861                $pileOfEditTools = array_merge( $pileOfEditTools, $navBlock );
862            }
863        }
864
865        // Tools that may be general or page-related (typically the toolbox)
866        $pileOfTools = $this->sidebar['TOOLBOX'];
867        if ( $namespace >= 0 ) {
868            $pileOfTools['pagelog'] = [
869                'text' => $this->getMsg( 'timeless-pagelog' )->text(),
870                'href' => SpecialPage::getTitleFor( 'Log' )->getLocalURL(
871                    [ 'page' => $title->getPrefixedText() ]
872                ),
873                'id' => 't-pagelog'
874            ];
875        }
876
877        // Mobile toggles
878        $pileOfTools['more'] = [
879            'text' => $this->getMsg( 'timeless-more' )->text(),
880            'id' => 'ca-more',
881            'class' => 'dropdown-toggle'
882        ];
883        // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset
884        if ( !empty( $this->sidebar['LANGUAGES'] ) || $sortedPileOfTools['variants']
885            || $this->otherProjects !== null ) {
886            $pileOfTools['languages'] = [
887                'text' => $this->getMsg( 'timeless-languages' )->escaped(),
888                'id' => 'ca-languages',
889                'class' => 'dropdown-toggle'
890            ];
891        }
892
893        // This is really dumb, and you're an idiot for doing it this way.
894        // Obviously if you're not the idiot who did this, I don't mean you.
895        foreach ( $pileOfEditTools as $navKey => $navBlock ) {
896            if ( in_array( $navKey, [
897                'watch',
898                'unwatch'
899            ] ) ) {
900                $currentSet = 'namespaces';
901            } elseif ( in_array( $navKey, [
902                'edit',
903                'view',
904                'history',
905                'addsection',
906                'viewsource'
907            ] ) ) {
908                $currentSet = 'page-primary';
909            } elseif ( in_array( $navKey, [
910                'delete',
911                'rename',
912                'protect',
913                'unprotect',
914                'move'
915            ] ) ) {
916                $currentSet = 'page-secondary';
917            } else {
918                // Catch random extension ones?
919                $currentSet = 'page-primary';
920            }
921            $sortedPileOfTools[$currentSet][$navKey] = $navBlock;
922        }
923        foreach ( $pileOfTools as $navKey => $navBlock ) {
924            $currentSet = null;
925
926            if ( $navKey === 'contributions' ) {
927                $currentSet = 'page-primary';
928            } elseif ( in_array( $navKey, [
929                'blockip',
930                'userrights',
931                'log',
932                'emailuser'
933
934            ] ) ) {
935                $currentSet = 'user';
936            } elseif ( in_array( $navKey, [
937                'whatlinkshere',
938                'print',
939                'info',
940                'pagelog',
941                'recentchangeslinked',
942                'permalink',
943                'wikibase',
944                'cite'
945            ] ) ) {
946                $currentSet = 'page-tertiary';
947            } elseif ( in_array( $navKey, [
948                'more',
949                'languages'
950            ] ) ) {
951                $currentSet = 'more';
952            } else {
953                $currentSet = 'general';
954            }
955            $sortedPileOfTools[$currentSet][$navKey] = $navBlock;
956        }
957
958        // Extra sorting for Extension:ProofreadPage namespace items
959        $tabs = [
960            // This is the order we want them in...
961            'proofreadPageScanLink',
962            'proofreadPageIndexLink',
963            'proofreadPageNextLink',
964        ];
965        foreach ( $tabs as $tab ) {
966            if ( isset( $sortedPileOfTools['namespaces'][$tab] ) ) {
967                $toMove = $sortedPileOfTools['namespaces'][$tab];
968                unset( $sortedPileOfTools['namespaces'][$tab] );
969
970                // move to end!
971                $sortedPileOfTools['namespaces'][$tab] = $toMove;
972            }
973        }
974
975        return $sortedPileOfTools;
976    }
977
978    /**
979     * Categories for the sidebar
980     *
981     * Assemble an array of categories. This doesn't show any categories for the
982     * action=history view, but that behaviour is consistent with other skins.
983     *
984     * @return string html
985     */
986    protected function getCategories() {
987        $skin = $this->getSkin();
988        $catHeader = 'categories';
989        $catList = '';
990
991        $allCats = $skin->getOutput()->getCategoryLinks();
992        if ( $allCats !== [] ) {
993            if ( isset( $allCats['normal'] ) && $allCats['normal'] !== [] ) {
994                $catList .= $this->getCatList(
995                    $allCats['normal'],
996                    'normal-catlinks',
997                    'mw-normal-catlinks',
998                    'categories'
999                );
1000            } else {
1001                $catHeader = 'hidden-categories';
1002            }
1003
1004            if ( isset( $allCats['hidden'] ) ) {
1005                $hiddenCatClass = [ 'mw-hidden-catlinks' ];
1006                if ( MediaWikiServices::getInstance()
1007                    ->getUserOptionsLookup()
1008                    ->getBoolOption( $skin->getUser(), 'showhiddencats' )
1009                ) {
1010                    $hiddenCatClass[] = 'mw-hidden-cats-user-shown';
1011                } elseif ( $skin->getTitle()->getNamespace() === NS_CATEGORY ) {
1012                    $hiddenCatClass[] = 'mw-hidden-cats-ns-shown';
1013                } else {
1014                    $hiddenCatClass[] = 'mw-hidden-cats-hidden';
1015                }
1016                $catList .= $this->getCatList(
1017                    $allCats['hidden'],
1018                    'hidden-catlinks',
1019                    $hiddenCatClass,
1020                    [ 'hidden-categories', count( $allCats['hidden'] ) ]
1021                );
1022            }
1023        }
1024
1025        if ( $catList !== '' ) {
1026            return $this->getSidebarChunk( 'catlinks-sidebar', $catHeader, $catList );
1027        }
1028
1029        return '';
1030    }
1031
1032    /**
1033     * List of categories
1034     *
1035     * @param array $list
1036     * @param string $id
1037     * @param string|array $class
1038     * @param string|array $message i18n message name or an array of [ message name, params ]
1039     *
1040     * @return string html
1041     */
1042    protected function getCatList( $list, $id, $class, $message ) {
1043        $html = Html::openElement( 'div', [ 'id' => "sidebar-{$id}", 'class' => $class ] );
1044
1045        $makeLinkItem = static function ( $linkHtml ) {
1046            return Html::rawElement( 'li', [], $linkHtml );
1047        };
1048
1049        $categoryItems = array_map( $makeLinkItem, $list );
1050
1051        $categoriesHtml = Html::rawElement( 'ul',
1052            [],
1053            implode( '', $categoryItems )
1054        );
1055
1056        $html .= $this->getPortlet( $id, $categoriesHtml, $message );
1057
1058        return $html . Html::closeElement( 'div' );
1059    }
1060
1061    /**
1062     * Interlanguage links block, with variants if applicable
1063     * Layout sort of assumes we're using ULS compact language handling
1064     * if there's a lot of languages.
1065     *
1066     * @return string html
1067     */
1068    protected function getVariants() {
1069        $html = '';
1070
1071        if ( $this->pileOfTools['variants'] ) {
1072            $html .= $this->getPortlet(
1073                'variants-desktop',
1074                $this->pileOfTools['variants'],
1075                'variants',
1076                [ 'body-extra-classes' => 'dropdown' ]
1077            );
1078        }
1079
1080        return $html;
1081    }
1082
1083    /**
1084     * Interwiki links block
1085     *
1086     * @return string html
1087     */
1088    protected function getInterwikiLinks() {
1089        $html = '';
1090        $variants = '';
1091        $otherprojects = '';
1092        $show = false;
1093        $variantsOnly = false;
1094
1095        if ( $this->pileOfTools['variants'] ) {
1096            $variants = $this->getPortlet(
1097                'variants',
1098                $this->pileOfTools['variants']
1099            );
1100            $show = true;
1101            $variantsOnly = true;
1102        }
1103
1104        $languages = $this->getPortlet( 'lang', $this->languages, 'otherlanguages' );
1105
1106        // Force rendering of this section if there are languages or when the 'lang'
1107        // portlet has been modified by hook even if there are no language items.
1108        if ( count( $this->languages ) || $this->afterLangPortlet !== '' ) {
1109            $show = true;
1110            $variantsOnly = false;
1111        } else {
1112            $languages = '';
1113        }
1114
1115        // if using wikibase for 'in other projects'
1116        if ( $this->otherProjects !== null ) {
1117            $otherprojects = $this->getPortlet(
1118                'wikibase-otherprojects',
1119                $this->otherProjects
1120            );
1121            $show = true;
1122            $variantsOnly = false;
1123        }
1124
1125        if ( $show ) {
1126            $html .= $this->getSidebarChunk(
1127                'other-languages',
1128                'timeless-projects',
1129                $variants . $languages . $otherprojects,
1130                $variantsOnly ? [ 'variants-only' ] : []
1131            );
1132        }
1133
1134        return $html;
1135    }
1136
1137    /**
1138     * Generate img-based logos for proper HiDPI support
1139     *
1140     * @param string|array|null $logo
1141     * @param bool $doLarge Render extra-large HiDPI logos for mobile devices?
1142     *
1143     * @return string|false html|we're not doing this
1144     */
1145    protected function getLogoImage( $logo, $doLarge = false ) {
1146        if ( $logo === null ) {
1147            // not set, fall back to generic methods
1148            return false;
1149        }
1150
1151        // Generate $logoData from a file upload
1152        if ( is_string( $logo ) ) {
1153            $file = MediaWikiServices::getInstance()->getRepoGroup()->findFile( $logo );
1154
1155            if ( !$file || !$file->canRender() ) {
1156                // eeeeeh bail, scary
1157                return false;
1158            }
1159            $logoData = [];
1160
1161            // Calculate intended sizes
1162            $width = $file->getWidth();
1163            $height = $file->getHeight();
1164            $bound = $width > $height ? $width : $height;
1165            $svg = File::normalizeExtension( $file->getExtension() ) === 'svg';
1166
1167            // Mobile stuff is generally a lot more than just 2ppp. Let's go with 4x?
1168            // Currently we're just doing this for wordmarks, which shouldn't get that
1169            // big in practice, so this is probably safe enough. And no need to use
1170            // this for desktop logos, so fall back to 2x for 2x as default...
1171            $large = $doLarge ? 4 : 2;
1172
1173            if ( $bound <= 165 ) {
1174                // It's a 1x image
1175                $logoData['width'] = $width;
1176                $logoData['height'] = $height;
1177
1178                if ( $svg ) {
1179                    $logoData['1x'] = $file->createThumb( $logoData['width'] );
1180                    $logoData['1.5x'] = $file->createThumb( (int)( $logoData['width'] * 1.5 ) );
1181                    $logoData['2x'] = $file->createThumb( $logoData['width'] * $large );
1182                } elseif ( $file->mustRender() ) {
1183                    $logoData['1x'] = $file->createThumb( $logoData['width'] );
1184                } else {
1185                    $logoData['1x'] = $file->getUrl();
1186                }
1187
1188            } elseif ( $bound >= 230 && $bound <= 330 ) {
1189                // It's a 2x image
1190                $logoData['width'] = (int)( $width / 2 );
1191                $logoData['height'] = (int)( $height / 2 );
1192
1193                $logoData['1x'] = $file->createThumb( $logoData['width'] );
1194                $logoData['1.5x'] = $file->createThumb( (int)( $logoData['width'] * 1.5 ) );
1195
1196                if ( $svg || $file->mustRender() ) {
1197                    $logoData['2x'] = $file->createThumb( $logoData['width'] * 2 );
1198                } else {
1199                    $logoData['2x'] = $file->getUrl();
1200                }
1201            } else {
1202                // Okay, whatever, we get to pick something random
1203                // Yes I am aware this means they might have arbitrarily tall logos,
1204                // and you know what, let 'em, I don't care
1205                $logoData['width'] = 155;
1206                $logoData['height'] = File::scaleHeight( $width, $height, $logoData['width'] );
1207
1208                $logoData['1x'] = $file->createThumb( $logoData['width'] );
1209                if ( $svg || $logoData['width'] * 1.5 <= $width ) {
1210                    $logoData['1.5x'] = $file->createThumb( (int)( $logoData['width'] * 1.5 ) );
1211                }
1212                if ( $svg || $logoData['width'] * 2 <= $width ) {
1213                    $logoData['2x'] = $file->createThumb( $logoData['width'] * $large );
1214                }
1215            }
1216        } elseif ( is_array( $logo ) ) {
1217            // manually set logo data for non-file-uploads
1218            $logoData = $logo;
1219        } else {
1220            // nope
1221            return false;
1222        }
1223
1224        // Render the html output!
1225        $attribs = [
1226            'alt' => $this->getMsg( 'sitetitle' )->text(),
1227            // Should we care? It's just a logo...
1228            'decoding' => 'auto',
1229            'width' => $logoData['width'],
1230            'height' => $logoData['height'],
1231        ];
1232
1233        if ( !isset( $logoData['1x'] ) && isset( $logoData['2x'] ) ) {
1234            // We'll allow it...
1235            $attribs['src'] = $logoData['2x'];
1236        } else {
1237            // Okay, we really do want a 1x otherwise. If this throws an error or
1238            // something because there's nothing here, GOOD.
1239            $attribs['src'] = $logoData['1x'];
1240
1241            // Throw the rest in a srcset
1242            unset( $logoData['1x'], $logoData['width'], $logoData['height'] );
1243            $srcset = '';
1244            foreach ( $logoData as $res => $path ) {
1245                if ( $srcset != '' ) {
1246                    $srcset .= ', ';
1247                }
1248                $srcset .= $path . ' ' . $res;
1249            }
1250
1251            if ( $srcset !== '' ) {
1252                $attribs['srcset'] = $srcset;
1253            }
1254        }
1255
1256        return Html::element( 'img', $attribs );
1257    }
1258}