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