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