Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
69.23% covered (warning)
69.23%
171 / 247
35.71% covered (danger)
35.71%
5 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
SkinModule
69.23% covered (warning)
69.23%
171 / 247
35.71% covered (danger)
35.71%
5 / 14
233.52
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
7
 applyFeaturesCompatibility
65.52% covered (warning)
65.52%
19 / 29
0.00% covered (danger)
0.00%
0 / 1
26.50
 getFeatureFilePaths
35.29% covered (danger)
35.29%
18 / 51
0.00% covered (danger)
0.00%
0 / 1
30.94
 combineFeatureAndParentStyles
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 generateAndAppendLogoStyles
96.43% covered (success)
96.43%
27 / 28
0.00% covered (danger)
0.00%
0 / 1
7
 getStyles
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 getPreloadLinks
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
9
 normalizeStyles
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 getRelativeSizedLogo
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getAvailableLogos
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
9.02
 getLogoData
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
6
 isKnownEmpty
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLessVars
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 getDefinitionSummary
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20namespace MediaWiki\ResourceLoader;
21
22use InvalidArgumentException;
23use MediaWiki\Config\Config;
24use MediaWiki\MainConfigNames;
25use MediaWiki\Output\OutputPage;
26use Wikimedia\Minify\CSSMin;
27
28/**
29 * Module for skin stylesheets.
30 *
31 * @ingroup ResourceLoader
32 * @internal
33 */
34class SkinModule extends LessVarFileModule {
35
36    /**
37     * Every skin should define which features it would like to reuse for core inside a
38     * ResourceLoader module that has set the class to SkinModule.
39     * For a feature to be valid it must be listed here along with the associated resources
40     *
41     * The following features are available:
42     *
43     * "accessibility":
44     *     Adds universal accessibility rules.
45     *
46     * "logo":
47     *     Adds CSS to style an element with class `mw-wiki-logo` using the value of wgLogos['1x'].
48     *     This is enabled by default if no features are added.
49     *
50     * "normalize":
51     *     Styles needed to normalize rendering across different browser rendering engines.
52     *     All to address bugs and common browser inconsistencies for skins and extensions.
53     *     Inspired by necolas' normalize.css. This is meant to be kept lean,
54     *     basic styling beyond normalization should live in one of the following modules.
55     *
56     * "elements":
57     *     The base level that only contains the most basic of common skin styles.
58     *     Only styles for single elements are included, no styling for complex structures like the
59     *     TOC is present. This level is for skins that want to implement the entire style of even
60     *     content area structures like the TOC themselves.
61     *
62     * "content-media":
63     *     Styles for thumbnails and floated elements.
64     *     Will add styles for the new media structure on wikis where $wgParserEnableLegacyMediaDOM is disabled,
65     *     or $wgUseContentMediaStyles is enabled.
66     *     See https://www.mediawiki.org/wiki/Parsing/Media_structure
67     *
68     *     Compatibility aliases: "content", "content-thumbnails".
69     *
70     * "content-links":
71     *     The skin will apply optional styling rules for links that should be styled differently
72     *     to the rules in `elements` and `normalize`. It provides support for .mw-selflink,
73     *     a.new (red links), a.stub (stub links) and some basic styles for external links.
74     *     It also provides rules supporting the underline user preference.
75     *
76     * "content-links-external":
77     *     The skin will apply optional styling rules to links to provide icons for different file types.
78     *
79     * "content-body":
80     *     Styles for the mw-parser-output class.
81     *
82     * "content-tables":
83     *     Styles .wikitable style tables.
84     *
85     * "interface":
86     *     Shorthand for a set of styles that are common
87     *     to skins like MonoBook, Vector, etc... Essentially this level is for styles that are
88     *     common to MonoBook clones.
89     *     This enables interface-core, interface-indicators, interface-subtitle,
90     *      interface-user-message, interface-site-notice and interface-edit-section-links.
91     *
92     * "interface-category":
93     *     Styles used for styling the categories in a horizontal bar at the bottom of the content.
94     *
95     * "interface-core":
96     *     Required interface core styles. Disabling these is not recommended.
97     *
98     * "interface-edit-section-links":
99     *     Default interface styling for edit section links.
100     *
101     * "interface-indicators":
102     *     Default interface styling for indicators.
103     *
104     * "interface-message-box":
105     *     Styles for message boxes. Can be used by skins that do not load Codex styles on page load.
106     *     Deprecated since MediaWiki 1.43. Skins should now use CodexModule::class to style messages.
107     *
108     * "interface-site-notice":
109     *     Default interface styling for site notices.
110     *
111     * "interface-subtitle":
112     *     Default interface styling for subtitle area.
113     *
114     * "interface-user-message":
115     *     Default interface styling for html-user-message (you have new talk page messages box)
116     *
117     * "i18n-ordered-lists":
118     *     Styles for ordered lists elements that support mixed language content.
119     *
120     * "i18n-all-lists-margins":
121     *     Deprecated since MediaWiki 1.43. It's merged into the `elements` module.
122     *
123     * "i18n-headings":
124     *     Styles for line-heights of headings across different languages.
125     *
126     * "toc"
127     *     Styling rules for the table of contents.
128     *
129     * NOTE: The order of the keys defines the order in which the styles are output.
130     */
131    private const FEATURE_FILES = [
132        'accessibility' => [
133            'all' => [ 'resources/src/mediawiki.skinning/accessibility.less' ],
134        ],
135        'normalize' => [
136            'all' => [ 'resources/src/mediawiki.skinning/normalize.less' ],
137        ],
138        'logo' => [
139            // Applies the logo and ensures it downloads prior to printing.
140            'all' => [ 'resources/src/mediawiki.skinning/logo.less' ],
141            // Reserves whitespace for the logo in a pseudo element.
142            'print' => [ 'resources/src/mediawiki.skinning/logo-print.less' ],
143        ],
144        // Placeholder for dynamic definition in getFeatureFilePaths()
145        'content-media' => [],
146        'content-links' => [
147            'screen' => [ 'resources/src/mediawiki.skinning/content.links.less' ]
148        ],
149        'content-links-external' => [
150            'screen' => [ 'resources/src/mediawiki.skinning/content.externallinks.less' ]
151        ],
152        'content-body' => [
153            'screen' => [ 'resources/src/mediawiki.skinning/content.body.less' ],
154            'print' => [ 'resources/src/mediawiki.skinning/content.body-print.less' ],
155        ],
156        'content-tables' => [
157            'screen' => [ 'resources/src/mediawiki.skinning/content.tables.less' ],
158            'print' => [ 'resources/src/mediawiki.skinning/content.tables-print.less' ]
159        ],
160        'interface-category' => [
161            'screen' => [ 'resources/src/mediawiki.skinning/interface.category.less' ],
162            'print' => [ 'resources/src/mediawiki.skinning/interface.category-print.less' ],
163        ],
164        'interface-core' => [
165            'screen' => [ 'resources/src/mediawiki.skinning/interface.less' ],
166            'print' => [ 'resources/src/mediawiki.skinning/interface-print.less' ],
167        ],
168        'interface-edit-section-links' => [
169            'screen' => [ 'resources/src/mediawiki.skinning/interface-edit-section-links.less' ],
170        ],
171        'interface-indicators' => [
172            'screen' => [ 'resources/src/mediawiki.skinning/interface-indicators.less' ],
173        ],
174        'interface-site-notice' => [
175            'screen' => [ 'resources/src/mediawiki.skinning/interface-site-notice.less' ],
176        ],
177        'interface-subtitle' => [
178            'screen' => [ 'resources/src/mediawiki.skinning/interface-subtitle.less' ],
179        ],
180        'interface-message-box' => [
181            'all' => [ 'resources/src/mediawiki.skinning/messageBoxes.less' ],
182        ],
183        'interface-user-message' => [
184            'screen' => [ 'resources/src/mediawiki.skinning/interface-user-message.less' ],
185        ],
186        'elements' => [
187            'screen' => [ 'resources/src/mediawiki.skinning/elements.less' ],
188            'print' => [ 'resources/src/mediawiki.skinning/elements-print.less' ],
189        ],
190        'i18n-ordered-lists' => [
191            'screen' => [ 'resources/src/mediawiki.skinning/i18n-ordered-lists.less' ],
192        ],
193        'i18n-all-lists-margins' => [
194            'screen' => [ 'resources/src/mediawiki.skinning/i18n-all-lists-margins.less' ],
195        ],
196        'i18n-headings' => [
197            'screen' => [ 'resources/src/mediawiki.skinning/i18n-headings.less' ],
198        ],
199        'toc' => [
200            'all' => [ 'resources/src/mediawiki.skinning/toc/common.less' ],
201            'screen' => [ 'resources/src/mediawiki.skinning/toc/screen.less' ],
202            'print' => [ 'resources/src/mediawiki.skinning/toc/print.less' ],
203        ],
204    ];
205
206    private const COMPAT_ALIASES = [
207        // MediaWiki 1.36
208        'content-parser-output' => 'content-body',
209        // MediaWiki 1.37
210        'content' => 'content-media',
211        'content-thumbnails' => 'content-media',
212        // MediaWiki 1.39
213        // The 'legacy' feature has been folded into other features that relevant skins
214        // are expected to have already enabled separately. It is now a no-op that can
215        // be safely removed from any skin.json files (T89981, T304325).
216        'legacy' => null,
217    ];
218
219    /** @var string[] */
220    private $features;
221
222    /**
223     * Defaults for when a 'features' parameter is specified.
224     *
225     * When these apply, they are the merged into the specified options.
226     */
227    private const DEFAULT_FEATURES_SPECIFIED = [
228        'accessibility' => true,
229        'content-body' => true,
230        'interface-core' => true,
231        'toc' => true
232    ];
233
234    /**
235     * Default for when the 'features' parameter is absent.
236     *
237     * For backward-compatibility, when the parameter is not declared
238     * only 'logo' styles are loaded.
239     */
240    private const DEFAULT_FEATURES_ABSENT = [
241        'logo',
242    ];
243
244    private const LESS_MESSAGES = [
245        // `toc` feature, used in screen.less
246        'hidetoc',
247        'showtoc',
248    ];
249
250    /**
251     * @param array $options
252     * - features: Map from feature keys to boolean indicating whether to load
253     *   or not include the associated styles.
254     *   Keys not specified get their default from self::DEFAULT_FEATURES_SPECIFIED.
255     *
256     *   If this is set to a list of strings, then the defaults do not apply.
257     *   Use this at your own risk as it means you opt-out from backwards compatibility
258     *   provided through these defaults. For example, when features are migrated
259     *   to the SkinModule system from other parts of MediaWiki, those new feature keys
260     *   may be enabled by default, and opting out means you may be missing some styles
261     *   after an upgrade until you enable them or implement them by other means.
262     *
263     * - lessMessages: Interface message keys to export as LESS variables.
264     *   See also LessVarFileModule.
265     *
266     * @param string|null $localBasePath
267     * @param string|null $remoteBasePath
268     * @see Additonal options at $wgResourceModules
269     */
270    public function __construct(
271        array $options = [],
272        $localBasePath = null,
273        $remoteBasePath = null
274    ) {
275        $features = $options['features'] ?? self::DEFAULT_FEATURES_ABSENT;
276        $listMode = array_keys( $features ) === range( 0, count( $features ) - 1 );
277
278        $messages = '';
279        // NOTE: Compatibility is only applied when features are provided
280        // in map-form. The list-form takes full control instead.
281        $features = $listMode ?
282            self::applyFeaturesCompatibility(
283                array_fill_keys( $features, true ),
284                false,
285                $messages
286            )
287            : self::applyFeaturesCompatibility( $features, true, $messages );
288
289        foreach ( $features as $key => $enabled ) {
290            if ( !isset( self::FEATURE_FILES[$key] ) ) {
291                throw new InvalidArgumentException( "Feature '$key' is not recognised" );
292            }
293        }
294
295        $this->features = $listMode
296            ? array_keys( array_filter( $features ) )
297            : array_keys( array_filter( $features + self::DEFAULT_FEATURES_SPECIFIED ) );
298
299        // Only the `toc` feature makes use of interface messages.
300        // For skins not using the `toc` feature, make sure LocalisationCache
301        // remains untouched (T270027).
302        if ( in_array( 'toc', $this->features ) ) {
303            $options['lessMessages'] = array_merge(
304                $options['lessMessages'] ?? [],
305                self::LESS_MESSAGES
306            );
307        }
308
309        if ( $messages !== '' ) {
310            $messages .= 'More information can be found at [[mw:Manual:ResourceLoaderSkinModule]]. ';
311            $options['deprecated'] = $messages;
312        }
313        parent::__construct( $options, $localBasePath, $remoteBasePath );
314    }
315
316    /**
317     * @internal
318     * @param array $features
319     * @param bool $addUnspecifiedFeatures Whether to add new features if missing
320     * @param string &$messages Messages to report deprecations
321     * @return array
322     */
323    protected static function applyFeaturesCompatibility(
324        array $features, bool $addUnspecifiedFeatures = true, &$messages = ''
325    ): array {
326        if ( isset( $features[ 'i18n-all-lists-margins' ] ) ) {
327            // Emit warning only. Key is supported as-is.
328            // Replacement requires maintainer intervention as it has non-trivial side-effects.
329            $messages .= '[1.43] The use of the `i18n-all-lists-margins` feature with SkinModule'
330                . ' is deprecated as it is now provided by `elements`. Please remove and '
331                . ' add `elements`, drop support for RTL languages, or incorporate the '
332                . ' styles provided by this module into your skin.';
333        }
334        if ( isset( $features[ 'interface-message-box' ] ) && $features[ 'interface-message-box' ] ) {
335            // Emit warning only. Key is supported as-is (For now)
336            // Replacement requires maintainer loading a suitable Codex module instead.
337            // Note: When removing this deprecation notice and associated code, please
338            // make sure mediawiki.legacy.messageBox is not broken.
339            $messages .= '[1.43] The use of the `interface-message-box` feature with SkinModule'
340                . ' is deprecated in favor of CodexModule. Please remove this feature.';
341        }
342
343        foreach ( self::COMPAT_ALIASES as $from => $to ) {
344            if ( isset( $features[ $from ] ) && $to !== null ) {
345                if ( isset( $features[ $to ] ) ) {
346                    $messages .= "SkinModule feature `$from` conflicts with `$to` and was ignored. ";
347                } else {
348                    $features[ $to ] = $features[ $from ];
349                }
350            }
351            unset( $features[ $from ] );
352        }
353
354        // If `content-links` feature is set but no preference for `content-links-external` is set
355        if ( $addUnspecifiedFeatures
356            && isset( $features[ 'content-links' ] )
357            && !isset( $features[ 'content-links-external' ] )
358        ) {
359            // Assume the same true/false preference for both.
360            $features[ 'content-links-external' ] = $features[ 'content-links' ];
361        }
362
363        // The `content-links` feature was split out from `elements`.
364        // Make sure skins asking for `elements` also get these by default.
365        if ( $addUnspecifiedFeatures && isset( $features[ 'elements' ] ) && !isset( $features[ 'content-links' ] ) ) {
366            $features[ 'content-links' ] = $features[ 'elements' ];
367        }
368
369        // The interface module is a short hand for several modules. Enable them now.
370        if ( isset( $features[ 'interface' ] ) && $features[ 'interface' ] ) {
371            $features[ 'interface-core' ] = true;
372            $features[ 'interface-indicators' ] = true;
373            $features[ 'interface-subtitle' ] = true;
374            $features[ 'interface-user-message' ] = true;
375            $features[ 'interface-site-notice' ] = true;
376            $features[ 'interface-edit-section-links' ] = true;
377        }
378        unset( $features[ 'interface' ] );
379
380        return $features;
381    }
382
383    /**
384     * Get styles defined in the module definition.
385     *
386     * @return array
387     */
388    public function getFeatureFilePaths() {
389        // Bypass the current module paths so that these files are served from core,
390        // instead of the individual skin's module directory.
391        [ $defaultLocalBasePath, $defaultRemoteBasePath ] =
392            FileModule::extractBasePaths(
393                [],
394                null,
395                $this->getConfig()->get( MainConfigNames::ResourceBasePath )
396            );
397
398        $featureFilePaths = [];
399
400        foreach ( self::FEATURE_FILES as $feature => $featureFiles ) {
401            if ( in_array( $feature, $this->features ) ) {
402                foreach ( $featureFiles as $mediaType => $files ) {
403                    foreach ( $files as $filepath ) {
404                        $featureFilePaths[$mediaType][] = new FilePath(
405                            $filepath,
406                            $defaultLocalBasePath,
407                            $defaultRemoteBasePath
408                        );
409                    }
410                }
411
412                if ( $feature === 'content-media' ) {
413                    if ( $this->getConfig()->get( MainConfigNames::UseLegacyMediaStyles ) ) {
414                        $featureFilePaths['all'][] = new FilePath(
415                            'resources/src/mediawiki.skinning/content.thumbnails-common.less',
416                            $defaultLocalBasePath,
417                            $defaultRemoteBasePath
418                        );
419                        $featureFilePaths['screen'][] = new FilePath(
420                            'resources/src/mediawiki.skinning/content.thumbnails-screen.less',
421                            $defaultLocalBasePath,
422                            $defaultRemoteBasePath
423                        );
424                        $featureFilePaths['print'][] = new FilePath(
425                            'resources/src/mediawiki.skinning/content.thumbnails-print.less',
426                            $defaultLocalBasePath,
427                            $defaultRemoteBasePath
428                        );
429                    }
430                    if (
431                        !$this->getConfig()->get( MainConfigNames::ParserEnableLegacyMediaDOM ) ||
432                        $this->getConfig()->get( MainConfigNames::UseContentMediaStyles )
433                    ) {
434                        $featureFilePaths['all'][] = new FilePath(
435                            'resources/src/mediawiki.skinning/content.media-common.less',
436                            $defaultLocalBasePath,
437                            $defaultRemoteBasePath
438                        );
439                        $featureFilePaths['screen'][] = new FilePath(
440                            'resources/src/mediawiki.skinning/content.media-screen.less',
441                            $defaultLocalBasePath,
442                            $defaultRemoteBasePath
443                        );
444                        $featureFilePaths['print'][] = new FilePath(
445                            'resources/src/mediawiki.skinning/content.media-print.less',
446                            $defaultLocalBasePath,
447                            $defaultRemoteBasePath
448                        );
449                    }
450                }
451            }
452        }
453        return $featureFilePaths;
454    }
455
456    /**
457     * Combines feature styles and parent skin styles, ensuring that all
458     * feature styles are output *first*, followed by skin related styles.
459     *
460     * @param array $featureStyles
461     * @param array $parentStyles
462     *
463     * @return array
464     */
465    private function combineFeatureAndParentStyles( $featureStyles, $parentStyles ) {
466        $combinedFeatureStyles = ResourceLoader::makeCombinedStyles( $featureStyles );
467        $combinedParentStyles = ResourceLoader::makeCombinedStyles( $parentStyles );
468        $combinedStyles = array_merge( $combinedFeatureStyles, $combinedParentStyles );
469        return [ '' => $combinedStyles ];
470    }
471
472    /**
473     * Generates CSS for .mw-logo-logo styles and appends them
474     * to the skin feature styles array.
475     * @param array $featureStyles
476     * @param Context $context
477     * @return array
478     */
479    public function generateAndAppendLogoStyles( $featureStyles, $context ) {
480        $logo = $this->getLogoData( $this->getConfig(), $context->getLanguage() );
481        $default = !is_array( $logo ) ? $logo : ( $logo['svg'] ?? $logo['1x'] ?? null );
482
483        // Can't add logo CSS if no logo defined.
484        if ( !$default ) {
485            return $featureStyles;
486        }
487
488        $featureStyles['all'][] = '.mw-wiki-logo { background-image: ' .
489            CSSMin::buildUrlValue( $default ) .
490            '; }';
491
492        if ( is_array( $logo ) ) {
493            if ( isset( $logo['svg'] ) ) {
494                $featureStyles['all'][] = '.mw-wiki-logo { ' .
495                    'background-size: 135px auto; }';
496            } else {
497                if ( isset( $logo['1.5x'] ) ) {
498                    $featureStyles[
499                        '(-webkit-min-device-pixel-ratio: 1.5), ' .
500                        '(min-resolution: 1.5dppx), ' .
501                        '(min-resolution: 144dpi)'
502                    ][] = '.mw-wiki-logo { background-image: ' .
503                        CSSMin::buildUrlValue( $logo['1.5x'] ) . ';' .
504                        'background-size: 135px auto; }';
505                }
506                if ( isset( $logo['2x'] ) ) {
507                    $featureStyles[
508                        '(-webkit-min-device-pixel-ratio: 2), ' .
509                        '(min-resolution: 2dppx), ' .
510                        '(min-resolution: 192dpi)'
511                    ][] = '.mw-wiki-logo { background-image: ' .
512                        CSSMin::buildUrlValue( $logo['2x'] ) . ';' .
513                        'background-size: 135px auto; }';
514                }
515            }
516        }
517        return $featureStyles;
518    }
519
520    /**
521     * @param Context $context
522     * @return array
523     */
524    public function getStyles( Context $context ) {
525        $parentStyles = parent::getStyles( $context );
526        $featureFilePaths = $this->getFeatureFilePaths();
527        $featureStyles = $this->readStyleFiles( $featureFilePaths, $context );
528
529        $this->normalizeStyles( $featureStyles );
530        $this->normalizeStyles( $parentStyles );
531
532        $isLogoFeatureEnabled = in_array( 'logo', $this->features );
533        if ( $isLogoFeatureEnabled ) {
534            $featureStyles = $this->generateAndAppendLogoStyles( $featureStyles, $context );
535        }
536
537        return $this->combineFeatureAndParentStyles( $featureStyles, $parentStyles );
538    }
539
540    /**
541     * @param Context $context
542     * @return array
543     */
544    public function getPreloadLinks( Context $context ): array {
545        if ( !in_array( 'logo', $this->features ) ) {
546            return [];
547        }
548
549        $logo = $this->getLogoData( $this->getConfig(), $context->getLanguage() );
550
551        if ( !is_array( $logo ) ) {
552            // No media queries required if we only have one variant
553            return [ $logo => [ 'as' => 'image' ] ];
554        }
555
556        if ( isset( $logo['svg'] ) ) {
557            // No media queries required if we only have a 1x and svg variant
558            // because all preload-capable browsers support SVGs
559            return [ $logo['svg'] => [ 'as' => 'image' ] ];
560        }
561
562        $logosPerDppx = [];
563        foreach ( $logo as $dppx => $src ) {
564            // Keys are in this format: "1.5x"
565            $dppx = substr( $dppx, 0, -1 );
566            $logosPerDppx[$dppx] = $src;
567        }
568
569        // Because PHP can't have floats as array keys
570        uksort( $logosPerDppx, static function ( $a, $b ) {
571            $a = floatval( $a );
572            $b = floatval( $b );
573            // Sort from smallest to largest (e.g. 1x, 1.5x, 2x)
574            return $a <=> $b;
575        } );
576
577        $logos = [];
578        foreach ( $logosPerDppx as $dppx => $src ) {
579            $logos[] = [
580                'dppx' => $dppx,
581                'src' => $src
582            ];
583        }
584
585        $logosCount = count( $logos );
586        $preloadLinks = [];
587        // Logic must match SkinModule:
588        // - 1x applies to resolution < 1.5dppx
589        // - 1.5x applies to resolution >= 1.5dppx && < 2dppx
590        // - 2x applies to resolution >= 2dppx
591        // Note that min-resolution and max-resolution are both inclusive.
592        for ( $i = 0; $i < $logosCount; $i++ ) {
593            if ( $i === 0 ) {
594                // Smallest dppx
595                // min-resolution is ">=" (larger than or equal to)
596                // "not min-resolution" is essentially "<"
597                $media_query = 'not all and (min-resolution: ' . $logos[1]['dppx'] . 'dppx)';
598            } elseif ( $i !== $logosCount - 1 ) {
599                // In between
600                // Media query expressions can only apply "not" to the entire expression
601                // (e.g. can't express ">= 1.5 and not >= 2).
602                // Workaround: Use <= 1.9999 in place of < 2.
603                $upper_bound = floatval( $logos[$i + 1]['dppx'] ) - 0.000001;
604                $media_query = '(min-resolution: ' . $logos[$i]['dppx'] .
605                    'dppx) and (max-resolution: ' . $upper_bound . 'dppx)';
606            } else {
607                // Largest dppx
608                $media_query = '(min-resolution: ' . $logos[$i]['dppx'] . 'dppx)';
609            }
610
611            $preloadLinks[$logos[$i]['src']] = [
612                'as' => 'image',
613                'media' => $media_query
614            ];
615        }
616
617        return $preloadLinks;
618    }
619
620    /**
621     * Ensure all media keys use array values.
622     *
623     * Normalises arrays returned by the FileModule::getStyles() method.
624     *
625     * @param array &$styles Associative array, keys are strings (media queries),
626     *   values are strings or arrays
627     */
628    private function normalizeStyles( array &$styles ): void {
629        foreach ( $styles as $key => $val ) {
630            if ( !is_array( $val ) ) {
631                $styles[$key] = [ $val ];
632            }
633        }
634    }
635
636    /**
637     * Modifies configured logo width/height to ensure they are present and scalable
638     * with different font-sizes.
639     * @param array $logoElement with width, height and src keys.
640     * @return array modified version of $logoElement
641     */
642    private static function getRelativeSizedLogo( array $logoElement ) {
643        $width = $logoElement['width'];
644        $height = $logoElement['height'];
645        $widthRelative = $width / 16;
646        $heightRelative = $height / 16;
647        // Allow skins to scale the wordmark with browser font size (T207789)
648        $logoElement['style'] = 'width: ' . $widthRelative . 'em; height: ' . $heightRelative . 'em;';
649        return $logoElement;
650    }
651
652    /**
653     * Return an array of all available logos that a skin may use.
654     * @since 1.35
655     * @param Config $conf
656     * @param string|null $lang Language code for logo variant, since 1.39
657     * @return array with the following keys:
658     *  - 1x(string): a square logo composing the `icon` and `wordmark` (required)
659     *  - 2x (string): a square logo for HD displays (optional)
660     *  - wordmark (object): a rectangle logo (wordmark) for print media and skins which desire
661     *      horizontal logo (optional). Must declare width and height fields,  defined in pixels
662     *      which will be converted to ems based on 16px font-size.
663     *  - tagline (object): replaces `tagline` message in certain skins. Must declare width and
664     *      height fields defined in pixels, which are converted to ems based on 16px font-size.
665     *  - icon (string): a square logo similar to 1x, but without the wordmark. SVG recommended.
666     */
667    public static function getAvailableLogos( Config $conf, ?string $lang = null ): array {
668        $logos = $conf->get( MainConfigNames::Logos );
669        if ( $logos === false ) {
670            // no logos were defined... this will either
671            // 1. Load from wgLogo
672            // 2. Trigger runtime exception if those are not defined.
673            $logos = [];
674        }
675        if ( $lang && isset( $logos['variants'][$lang] ) ) {
676            foreach ( $logos['variants'][$lang] as $type => $value ) {
677                $logos[$type] = $value;
678            }
679        }
680
681        // If logos['1x'] is not defined, see if we can use wgLogo
682        if ( !isset( $logos[ '1x' ] ) ) {
683            $logo = $conf->get( MainConfigNames::Logo );
684            if ( $logo ) {
685                $logos['1x'] = $logo;
686            }
687        }
688
689        if ( isset( $logos['wordmark'] ) ) {
690            // Allow skins to scale the wordmark with browser font size (T207789)
691            $logos['wordmark'] = self::getRelativeSizedLogo( $logos['wordmark'] );
692        }
693        if ( isset( $logos['tagline'] ) ) {
694            $logos['tagline'] = self::getRelativeSizedLogo( $logos['tagline'] );
695        }
696
697        return $logos;
698    }
699
700    /**
701     * @since 1.31
702     * @param Config $conf
703     * @param string|null $lang Language code for logo variant, since 1.39
704     * @return string|array Single url if no variants are defined,
705     *  or an array of logo urls keyed by dppx in form "<float>x".
706     *  Key "1x" is always defined. Key "svg" may also be defined,
707     *  in which case variants other than "1x" are omitted.
708     */
709    protected function getLogoData( Config $conf, ?string $lang = null ) {
710        $logoHD = self::getAvailableLogos( $conf, $lang );
711        $logo = $logoHD['1x'];
712
713        $logo1Url = OutputPage::transformResourcePath( $conf, $logo );
714
715        $logoUrls = [
716            '1x' => $logo1Url,
717        ];
718
719        if ( isset( $logoHD['svg'] ) ) {
720            $logoUrls['svg'] = OutputPage::transformResourcePath(
721                $conf,
722                $logoHD['svg']
723            );
724        } elseif ( isset( $logoHD['1.5x'] ) || isset( $logoHD['2x'] ) ) {
725            // Only 1.5x and 2x are supported
726            if ( isset( $logoHD['1.5x'] ) ) {
727                $logoUrls['1.5x'] = OutputPage::transformResourcePath(
728                    $conf,
729                    $logoHD['1.5x']
730                );
731            }
732            if ( isset( $logoHD['2x'] ) ) {
733                $logoUrls['2x'] = OutputPage::transformResourcePath(
734                    $conf,
735                    $logoHD['2x']
736                );
737            }
738        } else {
739            // Return a string rather than a one-element array, getLogoPreloadlinks depends on this
740            return $logo1Url;
741        }
742
743        return $logoUrls;
744    }
745
746    /**
747     * @param Context $context
748     * @return bool
749     */
750    public function isKnownEmpty( Context $context ) {
751        // Regardless of whether the files are specified, we always
752        // provide mw-wiki-logo styles.
753        return false;
754    }
755
756    /**
757     * Get language-specific LESS variables for this module.
758     *
759     * @param Context $context
760     * @return array
761     */
762    protected function getLessVars( Context $context ) {
763        $lessVars = parent::getLessVars( $context );
764        $logos = self::getAvailableLogos( $this->getConfig(), $context->getLanguage() );
765
766        if ( isset( $logos['wordmark'] ) ) {
767            $logo = $logos['wordmark'];
768            $lessVars[ 'logo-enabled' ] = true;
769            $lessVars[ 'logo-wordmark-url' ] = CSSMin::buildUrlValue( $logo['src'] );
770            $lessVars[ 'logo-wordmark-width' ] = intval( $logo['width'] );
771            $lessVars[ 'logo-wordmark-height' ] = intval( $logo['height'] );
772        } else {
773            $lessVars[ 'logo-enabled' ] = false;
774        }
775        return $lessVars;
776    }
777
778    public function getDefinitionSummary( Context $context ) {
779        $summary = parent::getDefinitionSummary( $context );
780        $summary[] = [
781            'logos' => self::getAvailableLogos( $this->getConfig(), $context->getLanguage() ),
782        ];
783        return $summary;
784    }
785}