MediaWiki master
SkinModule.php
Go to the documentation of this file.
1<?php
21
22use InvalidArgumentException;
26use Wikimedia\Minify\CSSMin;
27
35
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
215 private $features;
216
224 private const DEFAULT_FEATURES_SPECIFIED = [
225 'accessibility' => true,
226 'content-body' => true,
227 'interface-core' => true,
228 'toc' => true
229 ];
230
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
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
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
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 ] =
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
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
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
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
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
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
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
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
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
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
759 protected function getLessVars( Context $context ) {
760 $lessVars = parent::getLessVars( $context );
761 $logos = self::getAvailableLogos( $this->getConfig() );
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() ),
779 ];
780 return $summary;
781 }
782}
if(!defined('MW_SETUP_CALLBACK'))
Definition WebStart.php:81
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()
const UseLegacyMediaStyles
Name constant for the UseLegacyMediaStyles setting, for use with Config::get()
This is one of the Core classes and should be read at least once by any new developers.
Context object that contains information about the state of a specific ResourceLoader web request.
Definition Context.php:45
static extractBasePaths(array $options=[], $localBasePath=null, $remoteBasePath=null)
Extract a pair of local and remote base paths from module definition information.
string $remoteBasePath
Remote base path, see __construct()
string $localBasePath
Local base path, see __construct()
string[] $messages
List of message keys used by this module.
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.
static makeCombinedStyles(array $stylePairs)
Combines an associative array mapping media type to CSS into a single stylesheet with "@media" blocks...
Module for skin stylesheets.
static applyFeaturesCompatibility(array $features, bool $addUnspecifiedFeatures=true, &$messages='')
getLessVars(Context $context)
Get language-specific LESS variables for this module.
static getAvailableLogos(Config $conf, string $lang=null)
Return an array of all available logos that a skin may use.
getLogoData(Config $conf, string $lang=null)
getDefinitionSummary(Context $context)
Get the definition summary for this module.
generateAndAppendLogoStyles( $featureStyles, $context)
Generates CSS for .mw-logo-logo styles and appends them to the skin feature styles array.
getFeatureFilePaths()
Get styles defined in the module definition.
__construct(array $options=[], $localBasePath=null, $remoteBasePath=null)
Interface for configuration instances.
Definition Config.php:32
get( $name)
Get a configuration variable such as "Sitename" or "UploadMaintenance.".