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