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 
36 
135  private const FEATURE_FILES = [
136  'accessibility' => [
137  'all' => [ 'resources/src/mediawiki.skinning/accessibility.less' ],
138  ],
139  'normalize' => [
140  'all' => [ 'resources/src/mediawiki.skinning/normalize.less' ],
141  ],
142  'logo' => [
143  // Applies the logo and ensures it downloads prior to printing.
144  'all' => [ 'resources/src/mediawiki.skinning/logo.less' ],
145  // Reserves whitespace for the logo in a pseudo element.
146  'print' => [ 'resources/src/mediawiki.skinning/logo-print.less' ],
147  ],
148  'content-media' => [
149  'all' => [ 'resources/src/mediawiki.skinning/content.thumbnails-common.less' ],
150  'screen' => [ 'resources/src/mediawiki.skinning/content.thumbnails-screen.less' ],
151  'print' => [ 'resources/src/mediawiki.skinning/content.thumbnails-print.less' ],
152  ],
153  'content-links' => [
154  'screen' => [ 'resources/src/mediawiki.skinning/content.links.less' ]
155  ],
156  'content-links-external' => [
157  'screen' => [ 'resources/src/mediawiki.skinning/content.externallinks.less' ]
158  ],
159  'content-body' => [
160  'screen' => [ 'resources/src/mediawiki.skinning/content.body.less' ],
161  'print' => [ 'resources/src/mediawiki.skinning/content.body-print.less' ],
162  ],
163  'content-tables' => [
164  'screen' => [ 'resources/src/mediawiki.skinning/content.tables.less' ],
165  'print' => [ 'resources/src/mediawiki.skinning/content.tables-print.less' ]
166  ],
167  // Legacy shorthand for 6 features: interface-core, interface-edit-section-links,
168  // interface-indicators, interface-subtitle, interface-site-notice, interface-user-message
169  'interface' => [],
170  'interface-category' => [
171  'screen' => [ 'resources/src/mediawiki.skinning/interface.category.less' ],
172  'print' => [ 'resources/src/mediawiki.skinning/interface.category-print.less' ],
173  ],
174  'interface-core' => [
175  'screen' => [ 'resources/src/mediawiki.skinning/interface.less' ],
176  'print' => [ 'resources/src/mediawiki.skinning/interface-print.less' ],
177  ],
178  'interface-edit-section-links' => [
179  'screen' => [ 'resources/src/mediawiki.skinning/interface-edit-section-links.less' ],
180  ],
181  'interface-indicators' => [
182  'screen' => [ 'resources/src/mediawiki.skinning/interface-indicators.less' ],
183  ],
184  'interface-site-notice' => [
185  'screen' => [ 'resources/src/mediawiki.skinning/interface-site-notice.less' ],
186  ],
187  'interface-subtitle' => [
188  'screen' => [ 'resources/src/mediawiki.skinning/interface-subtitle.less' ],
189  ],
190  'interface-message-box' => [
191  'all' => [ 'resources/src/mediawiki.skinning/messageBoxes.less' ],
192  ],
193  'interface-user-message' => [
194  'screen' => [ 'resources/src/mediawiki.skinning/interface-user-message.less' ],
195  ],
196  'elements' => [
197  'screen' => [ 'resources/src/mediawiki.skinning/elements.less' ],
198  'print' => [ 'resources/src/mediawiki.skinning/elements-print.less' ],
199  ],
200  // The styles of the legacy feature was removed in 1.39. This can be removed when no skins are referencing it
201  // (Dropping this line will trigger InvalidArgumentException: Feature 'legacy' is not recognised)
202  'legacy' => [],
203  'i18n-ordered-lists' => [
204  'screen' => [ 'resources/src/mediawiki.skinning/i18n-ordered-lists.less' ],
205  ],
206  'i18n-all-lists-margins' => [
207  'screen' => [ 'resources/src/mediawiki.skinning/i18n-all-lists-margins.less' ],
208  ],
209  'i18n-headings' => [
210  'screen' => [ 'resources/src/mediawiki.skinning/i18n-headings.less' ],
211  ],
212  'toc' => [
213  'all' => [ 'resources/src/mediawiki.skinning/toc/common.css' ],
214  'screen' => [ 'resources/src/mediawiki.skinning/toc/screen.less' ],
215  'print' => [ 'resources/src/mediawiki.skinning/toc/print.css' ],
216  ],
217  ];
218 
220  private $features;
221 
229  private const DEFAULT_FEATURES_SPECIFIED = [
230  'accessibility' => true,
231  'content-body' => true,
232  'interface-core' => true,
233  'toc' => true,
234  ];
235 
244  private const DEFAULT_FEATURES_ABSENT = [
245  'logo',
246  ];
247 
248  private const LESS_MESSAGES = [
249  // `toc` feature, used in screen.less
250  'hidetoc',
251  'showtoc',
252  ];
253 
274  public function __construct(
275  array $options = [],
276  $localBasePath = null,
277  $remoteBasePath = null
278  ) {
279  $features = $options['features'] ?? self::DEFAULT_FEATURES_ABSENT;
280  $listMode = array_keys( $features ) === range( 0, count( $features ) - 1 );
281 
282  $messages = '';
283  // NOTE: Compatibility is only applied when features are provided
284  // in map-form. The list-form does not currently get these.
285  $features = $listMode ? self::applyFeaturesCompatibility(
286  array_fill_keys( $features, true ), false, $messages
287  ) : self::applyFeaturesCompatibility( $features, true, $messages );
288 
289  foreach ( $features as $key => $enabled ) {
290  if ( !isset( self::FEATURE_FILES[$key] ) ) {
291  throw new InvalidArgumentException( "Feature '$key' is not recognised" );
292  }
293  }
294 
295  $this->features = $listMode
296  ? array_keys( array_filter( $features ) )
297  : array_keys( array_filter( $features + self::DEFAULT_FEATURES_SPECIFIED ) );
298 
299  // Only the `toc` feature makes use of interface messages.
300  // For skins not using the `toc` feature, make sure LocalisationCache
301  // remains untouched (T270027).
302  if ( in_array( 'toc', $this->features ) ) {
303  $options['lessMessages'] = array_merge(
304  $options['lessMessages'] ?? [],
305  self::LESS_MESSAGES
306  );
307  }
308 
309  if ( $messages !== '' ) {
310  $messages .= 'More information can be found at [[mw:Manual:ResourceLoaderSkinModule]]. ';
311  $options['deprecated'] = $messages;
312  }
313  parent::__construct( $options, $localBasePath, $remoteBasePath );
314  }
315 
323  protected static function applyFeaturesCompatibility(
324  array $features, bool $addUnspecifiedFeatures = true, &$messages = ''
325  ): array {
326  // The `content` feature is mapped to `content-media`.
327  if ( isset( $features[ 'content' ] ) ) {
328  $features[ 'content-media' ] = $features[ 'content' ];
329  unset( $features[ 'content' ] );
330  $messages .= '[1.37] The use of the `content` feature with SkinModule'
331  . ' is deprecated. Use `content-media` instead. ';
332  }
333 
334  // The `content-thumbnails` feature is mapped to `content-media`.
335  if ( isset( $features[ 'content-thumbnails' ] ) ) {
336  $features[ 'content-media' ] = $features[ 'content-thumbnails' ];
337  $messages .= '[1.37] The use of the `content-thumbnails` feature with SkinModule'
338  . ' is deprecated. Use `content-media` instead. ';
339  unset( $features[ 'content-thumbnails' ] );
340  }
341 
342  // If `content-links` feature is set but no preference for `content-links-external` is set
343  if ( $addUnspecifiedFeatures && isset( $features[ 'content-links' ] )
344  && !isset( $features[ 'content-links-external' ] )
345  ) {
346  // Assume the same true/false preference for both.
347  $features[ 'content-links-external' ] = $features[ 'content-links' ];
348  }
349 
350  // The legacy feature no longer exists (T89981) but to avoid fatals in skins is retained.
351  if ( isset( $features['legacy'] ) && $features['legacy'] ) {
352  $messages .= '[1.37] The use of the `legacy` feature with SkinModule is deprecated'
353  . '(T89981) and is a NOOP since 1.39 (T304325). This should be urgently omited to retain compatibility '
354  . 'with future MediaWiki versions';
355  }
356 
357  // The `content-links` feature was split out from `elements`.
358  // Make sure skins asking for `elements` also get these by default.
359  if ( $addUnspecifiedFeatures && isset( $features[ 'element' ] ) && !isset( $features[ 'content-links' ] ) ) {
360  $features[ 'content-links' ] = $features[ 'element' ];
361  }
362 
363  // `content-parser-output` was renamed to `content-body`.
364  // No need to go through deprecation process here since content-parser-output added and removed in 1.36.
365  // Remove this check when no matches for
366  // https://codesearch.wmcloud.org/search/?q=content-parser-output&i=nope&files=&excludeFiles=&repos=
367  if ( isset( $features[ 'content-parser-output' ] ) ) {
368  $features[ 'content-body' ] = $features[ 'content-parser-output' ];
369  unset( $features[ 'content-parser-output' ] );
370  }
371 
372  // The interface module is a short hand for several modules. Enable them now.
373  if ( isset( $features[ 'interface' ] ) && $features[ 'interface' ] ) {
374  unset( $features[ 'interface' ] );
375  $features[ 'interface-core' ] = true;
376  $features[ 'interface-indicators' ] = true;
377  $features[ 'interface-subtitle' ] = true;
378  $features[ 'interface-user-message' ] = true;
379  $features[ 'interface-site-notice' ] = true;
380  $features[ 'interface-edit-section-links' ] = true;
381  }
382  return $features;
383  }
384 
391  public function getStyleFiles( Context $context ) {
392  $styles = parent::getStyleFiles( $context );
393 
394  // Bypass the current module paths so that these files are served from core,
395  // instead of the individual skin's module directory.
396  [ $defaultLocalBasePath, $defaultRemoteBasePath ] =
398  [],
399  null,
401  );
402 
403  $featureFilePaths = [];
404 
405  foreach ( self::FEATURE_FILES as $feature => $featureFiles ) {
406  if ( in_array( $feature, $this->features ) ) {
407  foreach ( $featureFiles as $mediaType => $files ) {
408  foreach ( $files as $filepath ) {
409  $featureFilePaths[$mediaType][] = new FilePath(
410  $filepath,
411  $defaultLocalBasePath,
412  $defaultRemoteBasePath
413  );
414  }
415  }
416  if ( $feature === 'content-media' && (
419  ) ) {
420  $featureFilePaths['all'][] = new FilePath(
421  'resources/src/mediawiki.skinning/content.media-common.less',
422  $defaultLocalBasePath,
423  $defaultRemoteBasePath
424  );
425  $featureFilePaths['screen'][] = new FilePath(
426  'resources/src/mediawiki.skinning/content.media-screen.less',
427  $defaultLocalBasePath,
428  $defaultRemoteBasePath
429  );
430  $featureFilePaths['print'][] = new FilePath(
431  'resources/src/mediawiki.skinning/content.media-print.less',
432  $defaultLocalBasePath,
433  $defaultRemoteBasePath
434  );
435  }
436  }
437  }
438 
439  // Styles defines in options are added to the $featureFilePaths to ensure
440  // that $featureFilePaths styles precede module defined ones.
441  // This is particularly important given the `normalize` styles need to be the first
442  // outputted (see T269618).
443  foreach ( $styles as $mediaType => $paths ) {
444  $featureFilePaths[$mediaType] = array_merge( $featureFilePaths[$mediaType] ?? [], $paths );
445  }
446 
447  return $featureFilePaths;
448  }
449 
454  public function getStyles( Context $context ) {
455  $logo = $this->getLogoData( $this->getConfig(), $context->getLanguage() );
456  $styles = parent::getStyles( $context );
457  $this->normalizeStyles( $styles );
458 
459  $isLogoFeatureEnabled = in_array( 'logo', $this->features );
460  if ( $isLogoFeatureEnabled ) {
461  $default = !is_array( $logo ) ? $logo : ( $logo['svg'] ?? $logo['1x'] ?? null );
462  // Can't add logo CSS if no logo defined.
463  if ( !$default ) {
464  return $styles;
465  }
466  $styles['all'][] = '.mw-wiki-logo { background-image: ' .
467  CSSMin::buildUrlValue( $default ) .
468  '; }';
469 
470  if ( is_array( $logo ) ) {
471  if ( isset( $logo['svg'] ) ) {
472  $styles['all'][] = '.mw-wiki-logo { ' .
473  'background-size: 135px auto; }';
474  } else {
475  if ( isset( $logo['1.5x'] ) ) {
476  $styles[
477  '(-webkit-min-device-pixel-ratio: 1.5), ' .
478  '(min-resolution: 1.5dppx), ' .
479  '(min-resolution: 144dpi)'
480  ][] = '.mw-wiki-logo { background-image: ' .
481  CSSMin::buildUrlValue( $logo['1.5x'] ) . ';' .
482  'background-size: 135px auto; }';
483  }
484  if ( isset( $logo['2x'] ) ) {
485  $styles[
486  '(-webkit-min-device-pixel-ratio: 2), ' .
487  '(min-resolution: 2dppx), ' .
488  '(min-resolution: 192dpi)'
489  ][] = '.mw-wiki-logo { background-image: ' .
490  CSSMin::buildUrlValue( $logo['2x'] ) . ';' .
491  'background-size: 135px auto; }';
492  }
493  }
494  }
495  }
496 
497  return $styles;
498  }
499 
504  public function getPreloadLinks( Context $context ): array {
505  if ( !in_array( 'logo', $this->features ) ) {
506  return [];
507  }
508 
509  $logo = $this->getLogoData( $this->getConfig(), $context->getLanguage() );
510 
511  if ( !is_array( $logo ) ) {
512  // No media queries required if we only have one variant
513  return [ $logo => [ 'as' => 'image' ] ];
514  }
515 
516  if ( isset( $logo['svg'] ) ) {
517  // No media queries required if we only have a 1x and svg variant
518  // because all preload-capable browsers support SVGs
519  return [ $logo['svg'] => [ 'as' => 'image' ] ];
520  }
521 
522  $logosPerDppx = [];
523  foreach ( $logo as $dppx => $src ) {
524  // Keys are in this format: "1.5x"
525  $dppx = substr( $dppx, 0, -1 );
526  $logosPerDppx[$dppx] = $src;
527  }
528 
529  // Because PHP can't have floats as array keys
530  uksort( $logosPerDppx, static function ( $a, $b ) {
531  $a = floatval( $a );
532  $b = floatval( $b );
533  // Sort from smallest to largest (e.g. 1x, 1.5x, 2x)
534  return $a <=> $b;
535  } );
536 
537  $logos = [];
538  foreach ( $logosPerDppx as $dppx => $src ) {
539  $logos[] = [
540  'dppx' => $dppx,
541  'src' => $src
542  ];
543  }
544 
545  $logosCount = count( $logos );
546  $preloadLinks = [];
547  // Logic must match SkinModule:
548  // - 1x applies to resolution < 1.5dppx
549  // - 1.5x applies to resolution >= 1.5dppx && < 2dppx
550  // - 2x applies to resolution >= 2dppx
551  // Note that min-resolution and max-resolution are both inclusive.
552  for ( $i = 0; $i < $logosCount; $i++ ) {
553  if ( $i === 0 ) {
554  // Smallest dppx
555  // min-resolution is ">=" (larger than or equal to)
556  // "not min-resolution" is essentially "<"
557  $media_query = 'not all and (min-resolution: ' . $logos[1]['dppx'] . 'dppx)';
558  } elseif ( $i !== $logosCount - 1 ) {
559  // In between
560  // Media query expressions can only apply "not" to the entire expression
561  // (e.g. can't express ">= 1.5 and not >= 2).
562  // Workaround: Use <= 1.9999 in place of < 2.
563  $upper_bound = floatval( $logos[$i + 1]['dppx'] ) - 0.000001;
564  $media_query = '(min-resolution: ' . $logos[$i]['dppx'] .
565  'dppx) and (max-resolution: ' . $upper_bound . 'dppx)';
566  } else {
567  // Largest dppx
568  $media_query = '(min-resolution: ' . $logos[$i]['dppx'] . 'dppx)';
569  }
570 
571  $preloadLinks[$logos[$i]['src']] = [
572  'as' => 'image',
573  'media' => $media_query
574  ];
575  }
576 
577  return $preloadLinks;
578  }
579 
588  private function normalizeStyles( array &$styles ): void {
589  foreach ( $styles as $key => $val ) {
590  if ( !is_array( $val ) ) {
591  $styles[$key] = [ $val ];
592  }
593  }
594  }
595 
602  private static function getRelativeSizedLogo( array $logoElement ) {
603  $width = $logoElement['width'];
604  $height = $logoElement['height'];
605  $widthRelative = $width / 16;
606  $heightRelative = $height / 16;
607  // Allow skins to scale the wordmark with browser font size (T207789)
608  $logoElement['style'] = 'width: ' . $widthRelative . 'em; height: ' . $heightRelative . 'em;';
609  return $logoElement;
610  }
611 
627  public static function getAvailableLogos( Config $conf, string $lang = null ): array {
628  $logos = $conf->get( MainConfigNames::Logos );
629  if ( $logos === false ) {
630  // no logos were defined... this will either
631  // 1. Load from wgLogo and wgLogoHD
632  // 2. Trigger runtime exception if those are not defined.
633  $logos = [];
634  }
635  if ( $lang && isset( $logos['variants'][$lang] ) ) {
636  foreach ( $logos['variants'][$lang] as $type => $value ) {
637  $logos[$type] = $value;
638  }
639  }
640 
641  // If logos['1x'] is not defined, see if we can use wgLogo
642  if ( !isset( $logos[ '1x' ] ) ) {
643  $logo = $conf->get( MainConfigNames::Logo );
644  if ( $logo ) {
645  $logos['1x'] = $logo;
646  }
647  }
648 
649  try {
650  $logoHD = $conf->get( MainConfigNames::LogoHD );
651  // make sure not false
652  if ( $logoHD ) {
653  // wfDeprecated( __METHOD__ . ' with $wgLogoHD set instead of $wgLogos', '1.35', false, 1 );
654  $logos += $logoHD;
655  }
656  } catch ( ConfigException $e ) {
657  // no backwards compatibility changes needed.
658  }
659 
660  if ( isset( $logos['wordmark'] ) ) {
661  // Allow skins to scale the wordmark with browser font size (T207789)
662  $logos['wordmark'] = self::getRelativeSizedLogo( $logos['wordmark'] );
663  }
664  if ( isset( $logos['tagline'] ) ) {
665  $logos['tagline'] = self::getRelativeSizedLogo( $logos['tagline'] );
666  }
667 
668  return $logos;
669  }
670 
680  protected function getLogoData( Config $conf, string $lang = null ) {
681  $logoHD = self::getAvailableLogos( $conf, $lang );
682  $logo = $logoHD['1x'];
683 
684  $logo1Url = OutputPage::transformResourcePath( $conf, $logo );
685 
686  $logoUrls = [
687  '1x' => $logo1Url,
688  ];
689 
690  if ( isset( $logoHD['svg'] ) ) {
691  $logoUrls['svg'] = OutputPage::transformResourcePath(
692  $conf,
693  $logoHD['svg']
694  );
695  } elseif ( isset( $logoHD['1.5x'] ) || isset( $logoHD['2x'] ) ) {
696  // Only 1.5x and 2x are supported
697  if ( isset( $logoHD['1.5x'] ) ) {
698  $logoUrls['1.5x'] = OutputPage::transformResourcePath(
699  $conf,
700  $logoHD['1.5x']
701  );
702  }
703  if ( isset( $logoHD['2x'] ) ) {
704  $logoUrls['2x'] = OutputPage::transformResourcePath(
705  $conf,
706  $logoHD['2x']
707  );
708  }
709  } else {
710  // Return a string rather than a one-element array, getLogoPreloadlinks depends on this
711  return $logo1Url;
712  }
713 
714  return $logoUrls;
715  }
716 
721  public function isKnownEmpty( Context $context ) {
722  // Regardless of whether the files are specified, we always
723  // provide mw-wiki-logo styles.
724  return false;
725  }
726 
733  protected function getLessVars( Context $context ) {
734  $lessVars = parent::getLessVars( $context );
735  $logos = self::getAvailableLogos( $this->getConfig() );
736 
737  if ( isset( $logos['wordmark'] ) ) {
738  $logo = $logos['wordmark'];
739  $lessVars[ 'logo-enabled' ] = true;
740  $lessVars[ 'logo-wordmark-url' ] = CSSMin::buildUrlValue( $logo['src'] );
741  $lessVars[ 'logo-wordmark-width' ] = intval( $logo['width'] );
742  $lessVars[ 'logo-wordmark-height' ] = intval( $logo['height'] );
743  } else {
744  $lessVars[ 'logo-enabled' ] = false;
745  }
746  return $lessVars;
747  }
748 
749  public function getDefinitionSummary( Context $context ) {
750  $summary = parent::getDefinitionSummary( $context );
751  $summary[] = [
752  'logos' => self::getAvailableLogos( $this->getConfig() ),
753  ];
754  return $summary;
755  }
756 }
757 
759 class_alias( SkinModule::class, 'ResourceLoaderSkinModule' );
if(!defined('MW_SETUP_CALLBACK'))
Definition: WebStart.php:88
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:274
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:323
getLessVars(Context $context)
Get language-specific LESS variables for this module.
Definition: SkinModule.php:733
static getAvailableLogos(Config $conf, string $lang=null)
Return an array of all available logos that a skin may use.
Definition: SkinModule.php:627
getLogoData(Config $conf, string $lang=null)
Definition: SkinModule.php:680
getStyleFiles(Context $context)
Get styles defined in the module definition, plus any enabled feature styles.
Definition: SkinModule.php:391
getDefinitionSummary(Context $context)
Get the definition summary for this module.
Definition: SkinModule.php:749
__construct(array $options=[], $localBasePath=null, $remoteBasePath=null)
Definition: SkinModule.php:274
This is one of the Core classes and should be read at least once by any new developers.
Definition: OutputPage.php:60
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