MediaWiki  master
SkinModule.php
Go to the documentation of this file.
1 <?php
20 namespace MediaWiki\ResourceLoader;
21 
22 use Config;
23 use ConfigException;
24 use InvalidArgumentException;
26 use OutputPage;
27 use Wikimedia\Minify\CSSMin;
28 
39  public $targets = [ 'desktop', 'mobile' ];
40 
139  private const FEATURE_FILES = [
140  'accessibility' => [
141  'all' => [ 'resources/src/mediawiki.skinning/accessibility.less' ],
142  ],
143  'normalize' => [
144  'all' => [ 'resources/src/mediawiki.skinning/normalize.less' ],
145  ],
146  'logo' => [
147  // Applies the logo and ensures it downloads prior to printing.
148  'all' => [ 'resources/src/mediawiki.skinning/logo.less' ],
149  // Reserves whitespace for the logo in a pseudo element.
150  'print' => [ 'resources/src/mediawiki.skinning/logo-print.less' ],
151  ],
152  'content-media' => [
153  'all' => [ 'resources/src/mediawiki.skinning/content.thumbnails-common.less' ],
154  'screen' => [ 'resources/src/mediawiki.skinning/content.thumbnails-screen.less' ],
155  'print' => [ 'resources/src/mediawiki.skinning/content.thumbnails-print.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  // Legacy shorthand for 6 features: interface-core, interface-edit-section-links,
172  // interface-indicators, interface-subtitle, interface-site-notice, interface-user-message
173  'interface' => [],
174  'interface-category' => [
175  'screen' => [ 'resources/src/mediawiki.skinning/interface.category.less' ],
176  'print' => [ 'resources/src/mediawiki.skinning/interface.category-print.less' ],
177  ],
178  'interface-core' => [
179  'screen' => [ 'resources/src/mediawiki.skinning/interface.less' ],
180  'print' => [ 'resources/src/mediawiki.skinning/interface-print.less' ],
181  ],
182  'interface-edit-section-links' => [
183  'screen' => [ 'resources/src/mediawiki.skinning/interface-edit-section-links.less' ],
184  ],
185  'interface-indicators' => [
186  'screen' => [ 'resources/src/mediawiki.skinning/interface-indicators.less' ],
187  ],
188  'interface-site-notice' => [
189  'screen' => [ 'resources/src/mediawiki.skinning/interface-site-notice.less' ],
190  ],
191  'interface-subtitle' => [
192  'screen' => [ 'resources/src/mediawiki.skinning/interface-subtitle.less' ],
193  ],
194  'interface-message-box' => [
195  'all' => [ 'resources/src/mediawiki.skinning/messageBoxes.less' ],
196  ],
197  'interface-user-message' => [
198  'screen' => [ 'resources/src/mediawiki.skinning/interface-user-message.less' ],
199  ],
200  'elements' => [
201  'screen' => [ 'resources/src/mediawiki.skinning/elements.less' ],
202  'print' => [ 'resources/src/mediawiki.skinning/elements-print.less' ],
203  ],
204  // The styles of the legacy feature was removed in 1.39. This can be removed when no skins are referencing it
205  // (Dropping this line will trigger InvalidArgumentException: Feature 'legacy' is not recognised)
206  'legacy' => [],
207  'i18n-ordered-lists' => [
208  'screen' => [ 'resources/src/mediawiki.skinning/i18n-ordered-lists.less' ],
209  ],
210  'i18n-all-lists-margins' => [
211  'screen' => [ 'resources/src/mediawiki.skinning/i18n-all-lists-margins.less' ],
212  ],
213  'i18n-headings' => [
214  'screen' => [ 'resources/src/mediawiki.skinning/i18n-headings.less' ],
215  ],
216  'toc' => [
217  'all' => [ 'resources/src/mediawiki.skinning/toc/common.css' ],
218  'screen' => [ 'resources/src/mediawiki.skinning/toc/screen.less' ],
219  'print' => [ 'resources/src/mediawiki.skinning/toc/print.css' ],
220  ],
221  ];
222 
224  private $features;
225 
233  private const DEFAULT_FEATURES_SPECIFIED = [
234  'accessibility' => true,
235  'content-body' => true,
236  'interface-core' => true,
237  'toc' => true,
238  ];
239 
248  private const DEFAULT_FEATURES_ABSENT = [
249  'logo',
250  ];
251 
252  private const LESS_MESSAGES = [
253  // `toc` feature, used in screen.less
254  'hidetoc',
255  'showtoc',
256  ];
257 
278  public function __construct(
279  array $options = [],
280  $localBasePath = null,
281  $remoteBasePath = null
282  ) {
283  $features = $options['features'] ?? self::DEFAULT_FEATURES_ABSENT;
284  $listMode = array_keys( $features ) === range( 0, count( $features ) - 1 );
285 
286  $messages = '';
287  // NOTE: Compatibility is only applied when features are provided
288  // in map-form. The list-form does not currently get these.
289  $features = $listMode ? self::applyFeaturesCompatibility(
290  array_fill_keys( $features, true ), false, $messages
291  ) : self::applyFeaturesCompatibility( $features, true, $messages );
292 
293  foreach ( $features as $key => $enabled ) {
294  if ( !isset( self::FEATURE_FILES[$key] ) ) {
295  throw new InvalidArgumentException( "Feature '$key' is not recognised" );
296  }
297  }
298 
299  $this->features = $listMode
300  ? array_keys( array_filter( $features ) )
301  : array_keys( array_filter( $features + self::DEFAULT_FEATURES_SPECIFIED ) );
302 
303  // Only the `toc` feature makes use of interface messages.
304  // For skins not using the `toc` feature, make sure LocalisationCache
305  // remains untouched (T270027).
306  if ( in_array( 'toc', $this->features ) ) {
307  $options['lessMessages'] = array_merge(
308  $options['lessMessages'] ?? [],
309  self::LESS_MESSAGES
310  );
311  }
312 
313  if ( $messages !== '' ) {
314  $messages .= 'More information can be found at [[mw:Manual:ResourceLoaderSkinModule]]. ';
315  $options['deprecated'] = $messages;
316  }
317  parent::__construct( $options, $localBasePath, $remoteBasePath );
318  }
319 
327  protected static function applyFeaturesCompatibility(
328  array $features, bool $addUnspecifiedFeatures = true, &$messages = ''
329  ): array {
330  // The `content` feature is mapped to `content-media`.
331  if ( isset( $features[ 'content' ] ) ) {
332  $features[ 'content-media' ] = $features[ 'content' ];
333  unset( $features[ 'content' ] );
334  $messages .= '[1.37] The use of the `content` feature with ResourceLoaderSkinModule'
335  . ' is deprecated. Use `content-media` instead. ';
336  }
337 
338  // The `content-thumbnails` feature is mapped to `content-media`.
339  if ( isset( $features[ 'content-thumbnails' ] ) ) {
340  $features[ 'content-media' ] = $features[ 'content-thumbnails' ];
341  $messages .= '[1.37] The use of the `content-thumbnails` feature with ResourceLoaderSkinModule'
342  . ' is deprecated. Use `content-media` instead. ';
343  unset( $features[ 'content-thumbnails' ] );
344  }
345 
346  // If `content-links` feature is set but no preference for `content-links-external` is set
347  if ( $addUnspecifiedFeatures && isset( $features[ 'content-links' ] )
348  && !isset( $features[ 'content-links-external' ] )
349  ) {
350  // Assume the same true/false preference for both.
351  $features[ 'content-links-external' ] = $features[ 'content-links' ];
352  }
353 
354  // The legacy feature no longer exists (T89981) but to avoid fatals in skins is retained.
355  if ( isset( $features['legacy'] ) && $features['legacy'] ) {
356  $messages .= '[1.37] The use of the `legacy` feature with ResourceLoaderSkinModule is deprecated'
357  . '(T89981) and is a NOOP since 1.39 (T304325). This should be urgently omited to retain compatibility '
358  . 'with future MediaWiki versions';
359  }
360 
361  // The `content-links` feature was split out from `elements`.
362  // Make sure skins asking for `elements` also get these by default.
363  if ( $addUnspecifiedFeatures && isset( $features[ 'element' ] ) && !isset( $features[ 'content-links' ] ) ) {
364  $features[ 'content-links' ] = $features[ 'element' ];
365  }
366 
367  // `content-parser-output` was renamed to `content-body`.
368  // No need to go through deprecation process here since content-parser-output added and removed in 1.36.
369  // Remove this check when no matches for
370  // https://codesearch.wmcloud.org/search/?q=content-parser-output&i=nope&files=&excludeFiles=&repos=
371  if ( isset( $features[ 'content-parser-output' ] ) ) {
372  $features[ 'content-body' ] = $features[ 'content-parser-output' ];
373  unset( $features[ 'content-parser-output' ] );
374  }
375 
376  // The interface module is a short hand for several modules. Enable them now.
377  if ( isset( $features[ 'interface' ] ) && $features[ 'interface' ] ) {
378  unset( $features[ 'interface' ] );
379  $features[ 'interface-core' ] = true;
380  $features[ 'interface-indicators' ] = true;
381  $features[ 'interface-subtitle' ] = true;
382  $features[ 'interface-user-message' ] = true;
383  $features[ 'interface-site-notice' ] = true;
384  $features[ 'interface-edit-section-links' ] = true;
385  }
386  return $features;
387  }
388 
395  public function getStyleFiles( Context $context ) {
396  $styles = parent::getStyleFiles( $context );
397 
398  // Bypass the current module paths so that these files are served from core,
399  // instead of the individual skin's module directory.
400  list( $defaultLocalBasePath, $defaultRemoteBasePath ) =
402  [],
403  null,
405  );
406 
407  $featureFilePaths = [];
408 
409  foreach ( self::FEATURE_FILES as $feature => $featureFiles ) {
410  if ( in_array( $feature, $this->features ) ) {
411  foreach ( $featureFiles as $mediaType => $files ) {
412  foreach ( $files as $filepath ) {
413  $featureFilePaths[$mediaType][] = new FilePath(
414  $filepath,
415  $defaultLocalBasePath,
416  $defaultRemoteBasePath
417  );
418  }
419  }
420  if ( $feature === 'content-media' && (
423  ) ) {
424  $featureFilePaths['all'][] = new FilePath(
425  'resources/src/mediawiki.skinning/content.media-common.less',
426  $defaultLocalBasePath,
427  $defaultRemoteBasePath
428  );
429  $featureFilePaths['screen'][] = new FilePath(
430  'resources/src/mediawiki.skinning/content.media-screen.less',
431  $defaultLocalBasePath,
432  $defaultRemoteBasePath
433  );
434  $featureFilePaths['print'][] = new FilePath(
435  'resources/src/mediawiki.skinning/content.media-print.less',
436  $defaultLocalBasePath,
437  $defaultRemoteBasePath
438  );
439  }
440  }
441  }
442 
443  // Styles defines in options are added to the $featureFilePaths to ensure
444  // that $featureFilePaths styles precede module defined ones.
445  // This is particularly important given the `normalize` styles need to be the first
446  // outputted (see T269618).
447  foreach ( $styles as $mediaType => $paths ) {
448  $featureFilePaths[$mediaType] = array_merge( $featureFilePaths[$mediaType] ?? [], $paths );
449  }
450 
451  return $featureFilePaths;
452  }
453 
458  public function getStyles( Context $context ) {
459  $logo = $this->getLogoData( $this->getConfig(), $context->getLanguage() );
460  $styles = parent::getStyles( $context );
461  $this->normalizeStyles( $styles );
462 
463  $isLogoFeatureEnabled = in_array( 'logo', $this->features );
464  if ( $isLogoFeatureEnabled ) {
465  $default = !is_array( $logo ) ? $logo : ( $logo['1x'] ?? null );
466  // Can't add logo CSS if no logo defined.
467  if ( !$default ) {
468  return $styles;
469  }
470  $styles['all'][] = '.mw-wiki-logo { background-image: ' .
471  CSSMin::buildUrlValue( $default ) .
472  '; }';
473 
474  if ( is_array( $logo ) ) {
475  if ( isset( $logo['svg'] ) ) {
476  $styles['all'][] = '.mw-wiki-logo { ' .
477  'background-image: linear-gradient(transparent, transparent), ' .
478  CSSMin::buildUrlValue( $logo['svg'] ) . ';' .
479  'background-size: 135px auto; }';
480  } else {
481  if ( isset( $logo['1.5x'] ) ) {
482  $styles[
483  '(-webkit-min-device-pixel-ratio: 1.5), ' .
484  '(min-resolution: 1.5dppx), ' .
485  '(min-resolution: 144dpi)'
486  ][] = '.mw-wiki-logo { background-image: ' .
487  CSSMin::buildUrlValue( $logo['1.5x'] ) . ';' .
488  'background-size: 135px auto; }';
489  }
490  if ( isset( $logo['2x'] ) ) {
491  $styles[
492  '(-webkit-min-device-pixel-ratio: 2), ' .
493  '(min-resolution: 2dppx), ' .
494  '(min-resolution: 192dpi)'
495  ][] = '.mw-wiki-logo { background-image: ' .
496  CSSMin::buildUrlValue( $logo['2x'] ) . ';' .
497  'background-size: 135px auto; }';
498  }
499  }
500  }
501  }
502 
503  return $styles;
504  }
505 
510  public function getPreloadLinks( Context $context ): array {
511  if ( !in_array( 'logo', $this->features ) ) {
512  return [];
513  }
514 
515  $logo = $this->getLogoData( $this->getConfig(), $context->getLanguage() );
516 
517  if ( !is_array( $logo ) ) {
518  // No media queries required if we only have one variant
519  return [ $logo => [ 'as' => 'image' ] ];
520  }
521 
522  if ( isset( $logo['svg'] ) ) {
523  // No media queries required if we only have a 1x and svg variant
524  // because all preload-capable browsers support SVGs
525  return [ $logo['svg'] => [ 'as' => 'image' ] ];
526  }
527 
528  $logosPerDppx = [];
529  foreach ( $logo as $dppx => $src ) {
530  // Keys are in this format: "1.5x"
531  $dppx = substr( $dppx, 0, -1 );
532  $logosPerDppx[$dppx] = $src;
533  }
534 
535  // Because PHP can't have floats as array keys
536  uksort( $logosPerDppx, static function ( $a, $b ) {
537  $a = floatval( $a );
538  $b = floatval( $b );
539  // Sort from smallest to largest (e.g. 1x, 1.5x, 2x)
540  return $a <=> $b;
541  } );
542 
543  $logos = [];
544  foreach ( $logosPerDppx as $dppx => $src ) {
545  $logos[] = [
546  'dppx' => $dppx,
547  'src' => $src
548  ];
549  }
550 
551  $logosCount = count( $logos );
552  $preloadLinks = [];
553  // Logic must match SkinModule:
554  // - 1x applies to resolution < 1.5dppx
555  // - 1.5x applies to resolution >= 1.5dppx && < 2dppx
556  // - 2x applies to resolution >= 2dppx
557  // Note that min-resolution and max-resolution are both inclusive.
558  for ( $i = 0; $i < $logosCount; $i++ ) {
559  if ( $i === 0 ) {
560  // Smallest dppx
561  // min-resolution is ">=" (larger than or equal to)
562  // "not min-resolution" is essentially "<"
563  $media_query = 'not all and (min-resolution: ' . $logos[1]['dppx'] . 'dppx)';
564  } elseif ( $i !== $logosCount - 1 ) {
565  // In between
566  // Media query expressions can only apply "not" to the entire expression
567  // (e.g. can't express ">= 1.5 and not >= 2).
568  // Workaround: Use <= 1.9999 in place of < 2.
569  $upper_bound = floatval( $logos[$i + 1]['dppx'] ) - 0.000001;
570  $media_query = '(min-resolution: ' . $logos[$i]['dppx'] .
571  'dppx) and (max-resolution: ' . $upper_bound . 'dppx)';
572  } else {
573  // Largest dppx
574  $media_query = '(min-resolution: ' . $logos[$i]['dppx'] . 'dppx)';
575  }
576 
577  $preloadLinks[$logos[$i]['src']] = [
578  'as' => 'image',
579  'media' => $media_query
580  ];
581  }
582 
583  return $preloadLinks;
584  }
585 
594  private function normalizeStyles( array &$styles ): void {
595  foreach ( $styles as $key => $val ) {
596  if ( !is_array( $val ) ) {
597  $styles[$key] = [ $val ];
598  }
599  }
600  }
601 
608  private static function getRelativeSizedLogo( array $logoElement ) {
609  $width = $logoElement['width'];
610  $height = $logoElement['height'];
611  $widthRelative = $width / 16;
612  $heightRelative = $height / 16;
613  // Allow skins to scale the wordmark with browser font size (T207789)
614  $logoElement['style'] = 'width: ' . $widthRelative . 'em; height: ' . $heightRelative . 'em;';
615  return $logoElement;
616  }
617 
633  public static function getAvailableLogos( Config $conf, string $lang = null ): array {
634  $logos = $conf->get( MainConfigNames::Logos );
635  if ( $logos === false ) {
636  // no logos were defined... this will either
637  // 1. Load from wgLogo and wgLogoHD
638  // 2. Trigger runtime exception if those are not defined.
639  $logos = [];
640  }
641  if ( $lang && isset( $logos['variants'][$lang] ) ) {
642  foreach ( $logos['variants'][$lang] as $type => $value ) {
643  $logos[$type] = $value;
644  }
645  }
646 
647  // If logos['1x'] is not defined, see if we can use wgLogo
648  if ( !isset( $logos[ '1x' ] ) ) {
649  $logo = $conf->get( MainConfigNames::Logo );
650  if ( $logo ) {
651  $logos['1x'] = $logo;
652  }
653  }
654 
655  try {
656  $logoHD = $conf->get( MainConfigNames::LogoHD );
657  // make sure not false
658  if ( $logoHD ) {
659  // wfDeprecated( __METHOD__ . ' with $wgLogoHD set instead of $wgLogos', '1.35', false, 1 );
660  $logos += $logoHD;
661  }
662  } catch ( ConfigException $e ) {
663  // no backwards compatibility changes needed.
664  }
665 
666  if ( isset( $logos['wordmark'] ) ) {
667  // Allow skins to scale the wordmark with browser font size (T207789)
668  $logos['wordmark'] = self::getRelativeSizedLogo( $logos['wordmark'] );
669  }
670  if ( isset( $logos['tagline'] ) ) {
671  $logos['tagline'] = self::getRelativeSizedLogo( $logos['tagline'] );
672  }
673 
674  return $logos;
675  }
676 
686  protected function getLogoData( Config $conf, string $lang = null ) {
687  $logoHD = self::getAvailableLogos( $conf, $lang );
688  $logo = $logoHD['1x'];
689 
690  $logo1Url = OutputPage::transformResourcePath( $conf, $logo );
691 
692  $logoUrls = [
693  '1x' => $logo1Url,
694  ];
695 
696  if ( isset( $logoHD['svg'] ) ) {
697  $logoUrls['svg'] = OutputPage::transformResourcePath(
698  $conf,
699  $logoHD['svg']
700  );
701  } elseif ( isset( $logoHD['1.5x'] ) || isset( $logoHD['2x'] ) ) {
702  // Only 1.5x and 2x are supported
703  if ( isset( $logoHD['1.5x'] ) ) {
704  $logoUrls['1.5x'] = OutputPage::transformResourcePath(
705  $conf,
706  $logoHD['1.5x']
707  );
708  }
709  if ( isset( $logoHD['2x'] ) ) {
710  $logoUrls['2x'] = OutputPage::transformResourcePath(
711  $conf,
712  $logoHD['2x']
713  );
714  }
715  } else {
716  // Return a string rather than a one-element array, getLogoPreloadlinks depends on this
717  return $logo1Url;
718  }
719 
720  return $logoUrls;
721  }
722 
727  public function isKnownEmpty( Context $context ) {
728  // Regardless of whether the files are specified, we always
729  // provide mw-wiki-logo styles.
730  return false;
731  }
732 
739  protected function getLessVars( Context $context ) {
740  $lessVars = parent::getLessVars( $context );
741  $logos = self::getAvailableLogos( $this->getConfig() );
742 
743  if ( isset( $logos['wordmark'] ) ) {
744  $logo = $logos['wordmark'];
745  $lessVars[ 'logo-enabled' ] = true;
746  $lessVars[ 'logo-wordmark-url' ] = CSSMin::buildUrlValue( $logo['src'] );
747  $lessVars[ 'logo-wordmark-width' ] = intval( $logo['width'] );
748  $lessVars[ 'logo-wordmark-height' ] = intval( $logo['height'] );
749  } else {
750  $lessVars[ 'logo-enabled' ] = false;
751  }
752  return $lessVars;
753  }
754 
755  public function getDefinitionSummary( Context $context ) {
756  $summary = parent::getDefinitionSummary( $context );
757  $summary[] = [
758  'logos' => self::getAvailableLogos( $this->getConfig() ),
759  ];
760  return $summary;
761  }
762 }
763 
765 class_alias( SkinModule::class, 'ResourceLoaderSkinModule' );
if(!defined('MW_SETUP_CALLBACK'))
The persistent session ID (if any) loaded at startup.
Definition: WebStart.php:82
Exceptions for config failures.
A class containing constants representing the names of configuration variables.
const UseContentMediaStyles
Name constant for the UseContentMediaStyles setting, for use with Config::get()
const ResourceBasePath
Name constant for the ResourceBasePath setting, for use with Config::get()
const ParserEnableLegacyMediaDOM
Name constant for the ParserEnableLegacyMediaDOM setting, for use with Config::get()
Context object that contains information about the state of a specific ResourceLoader web request.
Definition: Context.php:46
static extractBasePaths(array $options=[], $localBasePath=null, $remoteBasePath=null)
Extract a pair of local and remote base paths from module definition information.
Definition: FileModule.php:281
string $remoteBasePath
Remote base path, see __construct()
Definition: FileModule.php:58
string $localBasePath
Local base path, see __construct()
Definition: FileModule.php:55
string[] $messages
List of message keys used by this module.
Definition: FileModule.php:124
A path to a bundled file (such as JavaScript or CSS), along with a remote and local base path.
Definition: FilePath.php:34
Module augmented with context-specific LESS variables.
Module for skin stylesheets.
Definition: SkinModule.php:35
static applyFeaturesCompatibility(array $features, bool $addUnspecifiedFeatures=true, &$messages='')
Definition: SkinModule.php:327
getLessVars(Context $context)
Get language-specific LESS variables for this module.
Definition: SkinModule.php:739
static getAvailableLogos(Config $conf, string $lang=null)
Return an array of all available logos that a skin may use.
Definition: SkinModule.php:633
$targets
All skins are assumed to be compatible with mobile.
Definition: SkinModule.php:39
getLogoData(Config $conf, string $lang=null)
Definition: SkinModule.php:686
getStyleFiles(Context $context)
Get styles defined in the module definition, plus any enabled feature styles.
Definition: SkinModule.php:395
getDefinitionSummary(Context $context)
Get the definition summary for this module.
Definition: SkinModule.php:755
__construct(array $options=[], $localBasePath=null, $remoteBasePath=null)
Definition: SkinModule.php:278
This is one of the Core classes and should be read at least once by any new developers.
Definition: OutputPage.php:55
static transformResourcePath(Config $config, $path)
Transform path to web-accessible static resource.
Interface for configuration instances.
Definition: Config.php:30
get( $name)
Get a configuration variable such as "Sitename" or "UploadMaintenance.".
if(!isset( $args[0])) $lang