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