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 */
20
21/**
22 * Module for skin stylesheets.
23 *
24 * @ingroup ResourceLoader
25 * @internal
26 */
27class ResourceLoaderSkinModule extends ResourceLoaderFileModule {
28	/**
29	 * All skins are assumed to be compatible with mobile
30	 */
31	public $targets = [ 'desktop', 'mobile' ];
32
33	/**
34	 * Every skin should define which features it would like to reuse for core inside a
35	 * ResourceLoader module that has set the class to ResourceLoaderSkinModule.
36	 * For a feature to be valid it must be listed here along with the associated resources
37	 *
38	 * The following features are available:
39	 *
40	 * "logo":
41	 *     Adds CSS to style an element with class `mw-wiki-logo` using the value of wgLogos['1x'].
42	 *     This is enabled by default if no features are added.
43	 *
44	 * "normalize":
45	 *     Styles needed to normalize rendering across different browser rendering engines.
46	 *     All to address bugs and common browser inconsistencies for skins and extensions.
47	 *     Inspired by necolas' normalize.css. This is meant to be kept lean,
48	 *     basic styling beyond normalization should live in one of the following modules.
49	 *
50	 * "elements":
51	 *     The base level that only contains the most basic of common skin styles.
52	 *     Only styles for single elements are included, no styling for complex structures like the
53	 *     TOC is present. This level is for skins that want to implement the entire style of even
54	 *     content area structures like the TOC themselves.
55	 *
56	 * "content":
57	 *     The most commonly used level for skins implemented from scratch. This level includes all
58	 *     the single element styles from "elements" as well as styles for complex structures such
59	 *     as the TOC that are output in the content area by MediaWiki rather than the skin.
60	 *     Essentially this is the common level that lets skins leave the style of the content area
61	 *     as it is normally styled, while leaving the rest of the skin up to the skin
62	 *     implementation.
63	 *
64	 * "interface":
65	 *     The highest level, this stylesheet contains extra common styles for classes like
66	 *     .firstHeading, #contentSub, et cetera which are not outputted by MediaWiki but are common
67	 *     to skins like MonoBook, Vector, etc... Essentially this level is for styles that are
68	 *     common to MonoBook clones.
69	 *
70	 * "i18n-ordered-lists":
71	 *     Styles for ordered lists elements that support mixed language content.
72	 *
73	 * "i18n-all-lists-margins":
74	 *     Styles for margins of list elements where LTR and RTL are mixed.
75	 *
76	 * "i18n-headings":
77	 *     Styles for line-heights of headings across different languages.
78	 *
79	 * "legacy":
80	 *     For backwards compatibility a legacy feature is provided.
81	 *     New skins should not use this if they can avoid doing so.
82	 *     This feature also contains all `i18n-` prefixed features.
83	 */
84	private const FEATURE_FILES = [
85		'logo' => [
86			// Applies the logo and ensures it downloads prior to printing.
87			'all' => [ 'resources/src/mediawiki.skinning/logo.less' ],
88			// Reserves whitespace for the logo in a pseudo element.
89			'print' => [ 'resources/src/mediawiki.skinning/logo-print.less' ],
90		],
91		'content' => [
92			'screen' => [ 'resources/src/mediawiki.skinning/content.css' ],
93		],
94		'interface' => [
95			'screen' => [ 'resources/src/mediawiki.skinning/interface.css' ],
96		],
97		'normalize' => [
98			'screen' => [ 'resources/src/mediawiki.skinning/normalize.less' ],
99		],
100		'elements' => [
101			'screen' => [ 'resources/src/mediawiki.skinning/elements.css' ],
102		],
103		'legacy' => [
104			'all' => [ 'resources/src/mediawiki.skinning/messageBoxes.less' ],
105			'print' => [ 'resources/src/mediawiki.skinning/commonPrint.css' ],
106			'screen' => [ 'resources/src/mediawiki.skinning/legacy.less' ],
107		],
108		'i18n-ordered-lists' => [
109			'screen' => [ 'resources/src/mediawiki.skinning/i18n-ordered-lists.less' ],
110		],
111		'i18n-all-lists-margins' => [
112			'screen' => [ 'resources/src/mediawiki.skinning/i18n-all-lists-margins.less' ],
113		],
114		'i18n-headings' => [
115			'screen' => [ 'resources/src/mediawiki.skinning/i18n-headings.less' ],
116		],
117	];
118
119	/** @var string[] */
120	private $features;
121
122	/** @var array */
123	private const DEFAULT_FEATURES = [
124		'logo' => false,
125		'content' => false,
126		'interface' => false,
127		'elements' => false,
128		'legacy' => false,
129		'i18n-ordered-lists' => false,
130		'i18n-all-lists-margins' => false,
131		'i18n-headings' => false,
132	];
133
134	public function __construct(
135		array $options = [],
136		$localBasePath = null,
137		$remoteBasePath = null
138	) {
139		parent::__construct( $options, $localBasePath, $remoteBasePath );
140		$features = $options['features'] ??
141			// For historic reasons if nothing is declared logo and legacy features are enabled.
142			[
143				'logo' => true,
144				'legacy' => true
145			];
146		$enabledFeatures = [];
147		$compatibilityMode = false;
148		foreach ( $features as $key => $enabled ) {
149			if ( is_bool( $enabled ) ) {
150				$enabledFeatures[$key] = $enabled;
151			} else {
152				// operating in array mode.
153				$enabledFeatures[$enabled] = true;
154				$compatibilityMode = true;
155			}
156		}
157		// If the module didn't specify an option use the default features values.
158		// This allows new features to be turned on automatically.
159		if ( !$compatibilityMode ) {
160			foreach ( self::DEFAULT_FEATURES as $key => $enabled ) {
161				if ( !isset( $enabledFeatures[$key] ) ) {
162					$enabledFeatures[$key] = $enabled;
163				}
164			}
165		}
166		$this->features = array_filter(
167			array_keys( $enabledFeatures ),
168			function ( $key ) use ( $enabledFeatures ) {
169				return $enabledFeatures[ $key ];
170			}
171		);
172	}
173
174	/**
175	 * Get styles defined in the module definition, plus any enabled feature styles.
176	 *
177	 * @param ResourceLoaderContext $context
178	 * @return array
179	 */
180	public function getStyleFiles( ResourceLoaderContext $context ) {
181		$styles = parent::getStyleFiles( $context );
182
183		list( $defaultLocalBasePath, $defaultRemoteBasePath ) =
184			ResourceLoaderFileModule::extractBasePaths();
185
186		foreach ( $this->features as $feature ) {
187			if ( !isset( self::FEATURE_FILES[$feature] ) ) {
188				// We could be an old version of MediaWiki and a new feature is being requested (T271441).
189				continue;
190			}
191			foreach ( self::FEATURE_FILES[$feature] as $mediaType => $files ) {
192				if ( !isset( $styles[$mediaType] ) ) {
193					$styles[$mediaType] = [];
194				}
195				foreach ( $files as $filepath ) {
196					$styles[$mediaType][] = new ResourceLoaderFilePath(
197						$filepath,
198						$defaultLocalBasePath,
199						$defaultRemoteBasePath
200					);
201				}
202			}
203		}
204
205		return $styles;
206	}
207
208	/**
209	 * @param ResourceLoaderContext $context
210	 * @return array
211	 */
212	public function getStyles( ResourceLoaderContext $context ) {
213		$logo = $this->getLogoData( $this->getConfig() );
214		$styles = parent::getStyles( $context );
215		$this->normalizeStyles( $styles );
216
217		$isLogoFeatureEnabled = in_array( 'logo', $this->features );
218		if ( $isLogoFeatureEnabled ) {
219			$default = !is_array( $logo ) ? $logo : $logo['1x'];
220			$styles['all'][] = '.mw-wiki-logo { background-image: ' .
221				CSSMin::buildUrlValue( $default ) .
222				'; }';
223			if ( is_array( $logo ) ) {
224				if ( isset( $logo['svg'] ) ) {
225					$styles['all'][] = '.mw-wiki-logo { ' .
226						'background-image: -webkit-linear-gradient(transparent, transparent), ' .
227							CSSMin::buildUrlValue( $logo['svg'] ) . '; ' .
228						'background-image: linear-gradient(transparent, transparent), ' .
229							CSSMin::buildUrlValue( $logo['svg'] ) . ';' .
230						'background-size: 135px auto; }';
231				} else {
232					if ( isset( $logo['1.5x'] ) ) {
233						$styles[
234							'(-webkit-min-device-pixel-ratio: 1.5), ' .
235							'(min--moz-device-pixel-ratio: 1.5), ' .
236						'(min-resolution: 1.5dppx), ' .
237							'(min-resolution: 144dpi)'
238						][] = '.mw-wiki-logo { background-image: ' .
239						CSSMin::buildUrlValue( $logo['1.5x'] ) . ';' .
240						'background-size: 135px auto; }';
241					}
242					if ( isset( $logo['2x'] ) ) {
243						$styles[
244							'(-webkit-min-device-pixel-ratio: 2), ' .
245							'(min--moz-device-pixel-ratio: 2), ' .
246							'(min-resolution: 2dppx), ' .
247							'(min-resolution: 192dpi)'
248						][] = '.mw-wiki-logo { background-image: ' .
249						CSSMin::buildUrlValue( $logo['2x'] ) . ';' .
250						'background-size: 135px auto; }';
251					}
252				}
253			}
254		}
255
256		return $styles;
257	}
258
259	/**
260	 * @param ResourceLoaderContext $context
261	 * @return array
262	 */
263	public function getPreloadLinks( ResourceLoaderContext $context ) {
264		return $this->getLogoPreloadlinks();
265	}
266
267	/**
268	 * Helper method for getPreloadLinks()
269	 * @return array
270	 */
271	private function getLogoPreloadlinks() : array {
272		$logo = $this->getLogoData( $this->getConfig() );
273
274		$logosPerDppx = [];
275		$logos = [];
276
277		$preloadLinks = [];
278
279		if ( !is_array( $logo ) ) {
280			// No media queries required if we only have one variant
281			$preloadLinks[$logo] = [ 'as' => 'image' ];
282			return $preloadLinks;
283		}
284
285		if ( isset( $logo['svg'] ) ) {
286			// No media queries required if we only have a 1x and svg variant
287			// because all preload-capable browsers support SVGs
288			$preloadLinks[$logo['svg']] = [ 'as' => 'image' ];
289			return $preloadLinks;
290		}
291
292		foreach ( $logo as $dppx => $src ) {
293			// Keys are in this format: "1.5x"
294			$dppx = substr( $dppx, 0, -1 );
295			$logosPerDppx[$dppx] = $src;
296		}
297
298		// Because PHP can't have floats as array keys
299		uksort( $logosPerDppx, function ( $a, $b ) {
300			$a = floatval( $a );
301			$b = floatval( $b );
302			// Sort from smallest to largest (e.g. 1x, 1.5x, 2x)
303			return $a <=> $b;
304		} );
305
306		foreach ( $logosPerDppx as $dppx => $src ) {
307			$logos[] = [
308				'dppx' => $dppx,
309				'src' => $src
310			];
311		}
312
313		$logosCount = count( $logos );
314		// Logic must match ResourceLoaderSkinModule:
315		// - 1x applies to resolution < 1.5dppx
316		// - 1.5x applies to resolution >= 1.5dppx && < 2dppx
317		// - 2x applies to resolution >= 2dppx
318		// Note that min-resolution and max-resolution are both inclusive.
319		for ( $i = 0; $i < $logosCount; $i++ ) {
320			if ( $i === 0 ) {
321				// Smallest dppx
322				// min-resolution is ">=" (larger than or equal to)
323				// "not min-resolution" is essentially "<"
324				$media_query = 'not all and (min-resolution: ' . $logos[1]['dppx'] . 'dppx)';
325			} elseif ( $i !== $logosCount - 1 ) {
326				// In between
327				// Media query expressions can only apply "not" to the entire expression
328				// (e.g. can't express ">= 1.5 and not >= 2).
329				// Workaround: Use <= 1.9999 in place of < 2.
330				$upper_bound = floatval( $logos[$i + 1]['dppx'] ) - 0.000001;
331				$media_query = '(min-resolution: ' . $logos[$i]['dppx'] .
332					'dppx) and (max-resolution: ' . $upper_bound . 'dppx)';
333			} else {
334				// Largest dppx
335				$media_query = '(min-resolution: ' . $logos[$i]['dppx'] . 'dppx)';
336			}
337
338			$preloadLinks[$logos[$i]['src']] = [
339				'as' => 'image',
340				'media' => $media_query
341			];
342		}
343
344		return $preloadLinks;
345	}
346
347	/**
348	 * Ensure all media keys use array values.
349	 *
350	 * Normalises arrays returned by the ResourceLoaderFileModule::getStyles() method.
351	 *
352	 * @param array &$styles Associative array, keys are strings (media queries),
353	 *   values are strings or arrays
354	 */
355	private function normalizeStyles( array &$styles ) : void {
356		foreach ( $styles as $key => $val ) {
357			if ( !is_array( $val ) ) {
358				$styles[$key] = [ $val ];
359			}
360		}
361	}
362
363	/**
364	 * Return an array of all available logos that a skin may use.
365	 * @since 1.35
366	 * @param Config $conf
367	 * @return array with the following keys:
368	 *  - 1x: a square logo (required)
369	 *  - 2x: a square logo for HD displays (optional)
370	 *  - wordmark: a rectangle logo (wordmark) for print media and skins which desire
371	 *      horizontal logo (optional)
372	 */
373	public static function getAvailableLogos( $conf ) : array {
374		$logos = $conf->get( 'Logos' );
375		if ( $logos === false ) {
376			// no logos were defined... this will either
377			// 1. Load from wgLogo and wgLogoHD
378			// 2. Trigger runtime exception if those are not defined.
379			$logos = [];
380		}
381
382		// If logos['1x'] is not defined, see if we can use wgLogo
383		if ( !isset( $logos[ '1x' ] ) ) {
384			$logo = $conf->get( 'Logo' );
385			if ( $logo ) {
386				$logos['1x'] = $logo;
387			}
388		}
389
390		try {
391			$logoHD = $conf->get( 'LogoHD' );
392			// make sure not false
393			if ( $logoHD ) {
394				// wfDeprecated( __METHOD__ . ' with $wgLogoHD set instead of $wgLogos', '1.35', false, 1 );
395				$logos += $logoHD;
396			}
397		} catch ( ConfigException $e ) {
398			// no backwards compatibility changes needed.
399		}
400
401		// check the configuration is valid
402		if ( !isset( $logos['1x'] ) ) {
403			throw new \RuntimeException( "The key `1x` is required for wgLogos or wgLogo must be defined." );
404		}
405		// return the modified logos!
406		return $logos;
407	}
408
409	/**
410	 * @since 1.31
411	 * @param Config $conf
412	 * @return string|array Single url if no variants are defined,
413	 *  or an array of logo urls keyed by dppx in form "<float>x".
414	 *  Key "1x" is always defined. Key "svg" may also be defined,
415	 *  in which case variants other than "1x" are omitted.
416	 */
417	protected function getLogoData( Config $conf ) {
418		$logoHD = self::getAvailableLogos( $conf );
419		$logo = $logoHD['1x'];
420
421		$logo1Url = OutputPage::transformResourcePath( $conf, $logo );
422
423		$logoUrls = [
424			'1x' => $logo1Url,
425		];
426
427		if ( isset( $logoHD['svg'] ) ) {
428			$logoUrls['svg'] = OutputPage::transformResourcePath(
429				$conf,
430				$logoHD['svg']
431			);
432		} elseif ( isset( $logoHD['1.5x'] ) || isset( $logoHD['2x'] ) ) {
433			// Only 1.5x and 2x are supported
434			if ( isset( $logoHD['1.5x'] ) ) {
435				$logoUrls['1.5x'] = OutputPage::transformResourcePath(
436					$conf,
437					$logoHD['1.5x']
438				);
439			}
440			if ( isset( $logoHD['2x'] ) ) {
441				$logoUrls['2x'] = OutputPage::transformResourcePath(
442					$conf,
443					$logoHD['2x']
444				);
445			}
446		} else {
447			// Return a string rather than a one-element array, getLogoPreloadlinks depends on this
448			return $logo1Url;
449		}
450
451		return $logoUrls;
452	}
453
454	/**
455	 * @param ResourceLoaderContext $context
456	 * @return bool
457	 */
458	public function isKnownEmpty( ResourceLoaderContext $context ) {
459		// Regardless of whether the files are specified, we always
460		// provide mw-wiki-logo styles.
461		return false;
462	}
463
464	/**
465	 * Get language-specific LESS variables for this module.
466	 *
467	 * @param ResourceLoaderContext $context
468	 * @return array
469	 */
470	protected function getLessVars( ResourceLoaderContext $context ) {
471		$lessVars = parent::getLessVars( $context );
472		$logos = self::getAvailableLogos( $this->getConfig() );
473
474		if ( isset( $logos['wordmark'] ) ) {
475			$logo = $logos['wordmark'];
476			$lessVars[ 'logo-enabled' ] = true;
477			$lessVars[ 'logo-wordmark-url' ] = CSSMin::buildUrlValue( $logo['src'] );
478			$lessVars[ 'logo-wordmark-width' ] = intval( $logo['width'] );
479			$lessVars[ 'logo-wordmark-height' ] = intval( $logo['height'] );
480		} else {
481			$lessVars[ 'logo-enabled' ] = false;
482		}
483		return $lessVars;
484	}
485
486	public function getDefinitionSummary( ResourceLoaderContext $context ) {
487		$summary = parent::getDefinitionSummary( $context );
488		$summary[] = [
489			'logos' => self::getAvailableLogos( $this->getConfig() ),
490		];
491		return $summary;
492	}
493}
494