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