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 // Placeholder for dynamic definition in getFeatureFilePaths()
148 'content-media' => [],
149 'content-media-dark' => [
150 'screen' => [ 'resources/src/mediawiki.skinning/content.media-dark.less' ],
151 ],
152 'content-links' => [
153 'screen' => [ 'resources/src/mediawiki.skinning/content.links.less' ]
154 ],
155 'content-links-external' => [
156 'screen' => [ 'resources/src/mediawiki.skinning/content.externallinks.less' ]
157 ],
158 'content-body' => [
159 'screen' => [ 'resources/src/mediawiki.skinning/content.body.less' ],
160 'print' => [ 'resources/src/mediawiki.skinning/content.body-print.less' ],
161 ],
162 'content-tables' => [
163 'screen' => [ 'resources/src/mediawiki.skinning/content.tables.less' ],
164 'print' => [ 'resources/src/mediawiki.skinning/content.tables-print.less' ]
165 ],
166 'interface-category' => [
167 'screen' => [ 'resources/src/mediawiki.skinning/interface.category.less' ],
168 'print' => [ 'resources/src/mediawiki.skinning/interface.category-print.less' ],
169 ],
170 'interface-core' => [
171 'screen' => [ 'resources/src/mediawiki.skinning/interface.less' ],
172 'print' => [ 'resources/src/mediawiki.skinning/interface-print.less' ],
173 ],
174 'interface-edit-section-links' => [
175 'screen' => [ 'resources/src/mediawiki.skinning/interface-edit-section-links.less' ],
176 ],
177 'interface-indicators' => [
178 'screen' => [ 'resources/src/mediawiki.skinning/interface-indicators.less' ],
179 ],
180 'interface-site-notice' => [
181 'screen' => [ 'resources/src/mediawiki.skinning/interface-site-notice.less' ],
182 ],
183 'interface-subtitle' => [
184 'screen' => [ 'resources/src/mediawiki.skinning/interface-subtitle.less' ],
185 ],
186 'interface-message-box' => [
187 'all' => [ 'resources/src/mediawiki.skinning/messageBoxes.less' ],
188 ],
189 'interface-user-message' => [
190 'screen' => [ 'resources/src/mediawiki.skinning/interface-user-message.less' ],
191 ],
192 'elements' => [
193 'screen' => [ 'resources/src/mediawiki.skinning/elements.less' ],
194 'print' => [ 'resources/src/mediawiki.skinning/elements-print.less' ],
195 ],
196 'i18n-ordered-lists' => [
197 'screen' => [ 'resources/src/mediawiki.skinning/i18n-ordered-lists.less' ],
198 ],
199 'i18n-all-lists-margins' => [
200 'screen' => [ 'resources/src/mediawiki.skinning/i18n-all-lists-margins.less' ],
201 ],
202 'i18n-headings' => [
203 'screen' => [ 'resources/src/mediawiki.skinning/i18n-headings.less' ],
204 ],
205 'toc' => [
206 'all' => [ 'resources/src/mediawiki.skinning/toc/common.less' ],
207 'screen' => [ 'resources/src/mediawiki.skinning/toc/screen.less' ],
208 'print' => [ 'resources/src/mediawiki.skinning/toc/print.less' ],
209 ],
210 ];
211
212 private const COMPAT_ALIASES = [
213 // MediaWiki 1.36
214 'content-parser-output' => 'content-body',
215 // MediaWiki 1.37
216 'content' => 'content-media',
217 'content-thumbnails' => 'content-media',
218 // MediaWiki 1.39
219 // The 'legacy' feature has been folded into other features that relevant skins
220 // are expected to have already enabled separately. It is now a no-op that can
221 // be safely removed from any skin.json files (T89981, T304325).
222 'legacy' => null,
223 ];
224
226 private $features;
227
233 private const DEFAULT_FEATURES_SPECIFIED = [
234 'accessibility' => true,
235 'content-body' => true,
236 'interface-core' => true,
237 'toc' => true
238 ];
239
246 private const DEFAULT_FEATURES_ABSENT = [
247 'logo',
248 ];
249
250 private const LESS_MESSAGES = [
251 // `toc` feature, used in screen.less
252 'hidetoc',
253 'showtoc',
254 ];
255
276 public function __construct(
277 array $options = [],
278 $localBasePath = null,
279 $remoteBasePath = null
280 ) {
281 $features = $options['features'] ?? self::DEFAULT_FEATURES_ABSENT;
282 $listMode = array_keys( $features ) === range( 0, count( $features ) - 1 );
283
284 $messages = '';
285 // NOTE: Compatibility is only applied when features are provided
286 // in map-form. The list-form takes full control instead.
287 $features = $listMode ?
289 array_fill_keys( $features, true ),
290 false,
292 )
293 : self::applyFeaturesCompatibility( $features, true, $messages );
294
295 foreach ( $features as $key => $enabled ) {
296 if ( !isset( self::FEATURE_FILES[$key] ) ) {
297 throw new InvalidArgumentException( "Feature '$key' is not recognised" );
298 }
299 }
300
301 $this->features = $listMode
302 ? array_keys( array_filter( $features ) )
303 : array_keys( array_filter( $features + self::DEFAULT_FEATURES_SPECIFIED ) );
304
305 // Only the `toc` feature makes use of interface messages.
306 // For skins not using the `toc` feature, make sure LocalisationCache
307 // remains untouched (T270027).
308 if ( in_array( 'toc', $this->features ) ) {
309 $options['lessMessages'] = array_merge(
310 $options['lessMessages'] ?? [],
311 self::LESS_MESSAGES
312 );
313 }
314
315 if ( $messages !== '' ) {
316 $messages .= 'More information can be found at [[mw:Manual:ResourceLoaderSkinModule]]. ';
317 $options['deprecated'] = $messages;
318 }
319 parent::__construct( $options, $localBasePath, $remoteBasePath );
320 }
321
329 protected static function applyFeaturesCompatibility(
330 array $features, bool $addUnspecifiedFeatures = true, &$messages = ''
331 ): array {
332 if ( isset( $features[ 'i18n-all-lists-margins' ] ) ) {
333 // Emit warning only. Key is supported as-is.
334 // Replacement requires maintainer intervention as it has non-trivial side-effects.
335 $messages .= '[1.43] The use of the `i18n-all-lists-margins` feature with SkinModule'
336 . ' is deprecated as it is now provided by `elements`. Please remove and '
337 . ' add `elements`, drop support for RTL languages, or incorporate the '
338 . ' styles provided by this module into your skin.';
339 }
340 if ( isset( $features[ 'interface-message-box' ] ) && $features[ 'interface-message-box' ] ) {
341 // Emit warning only. Key is supported as-is (For now)
342 // Replacement requires maintainer loading a suitable Codex module instead.
343 // Note: When removing this deprecation notice and associated code, please
344 // make sure mediawiki.legacy.messageBox is not broken.
345 $messages .= '[1.43] The use of the `interface-message-box` feature with SkinModule'
346 . ' is deprecated in favor of CodexModule. Please remove this feature.';
347 }
348
349 foreach ( self::COMPAT_ALIASES as $from => $to ) {
350 if ( isset( $features[ $from ] ) && $to !== null ) {
351 if ( isset( $features[ $to ] ) ) {
352 $messages .= "SkinModule feature `$from` conflicts with `$to` and was ignored. ";
353 } else {
354 $features[ $to ] = $features[ $from ];
355 }
356 }
357 unset( $features[ $from ] );
358 }
359
360 // If `content-links` feature is set but no preference for `content-links-external` is set
361 if ( $addUnspecifiedFeatures
362 && isset( $features[ 'content-links' ] )
363 && !isset( $features[ 'content-links-external' ] )
364 ) {
365 // Assume the same true/false preference for both.
366 $features[ 'content-links-external' ] = $features[ 'content-links' ];
367 }
368
369 // The `content-links` feature was split out from `elements`.
370 // Make sure skins asking for `elements` also get these by default.
371 if ( $addUnspecifiedFeatures && isset( $features[ 'elements' ] ) && !isset( $features[ 'content-links' ] ) ) {
372 $features[ 'content-links' ] = $features[ 'elements' ];
373 }
374
375 // The interface module is a short hand for several modules. Enable them now.
376 if ( isset( $features[ 'interface' ] ) && $features[ 'interface' ] ) {
377 $features[ 'interface-core' ] = true;
378 $features[ 'interface-indicators' ] = true;
379 $features[ 'interface-subtitle' ] = true;
380 $features[ 'interface-user-message' ] = true;
381 $features[ 'interface-site-notice' ] = true;
382 $features[ 'interface-edit-section-links' ] = true;
383 }
384 unset( $features[ 'interface' ] );
385
386 return $features;
387 }
388
394 public function getFeatureFilePaths() {
395 // Bypass the current module paths so that these files are served from core,
396 // instead of the individual skin's module directory.
397 [ $defaultLocalBasePath, $defaultRemoteBasePath ] =
399 [],
400 null,
401 $this->getConfig()->get( MainConfigNames::ResourceBasePath )
402 );
403
404 $featureFilePaths = [];
405
406 foreach ( self::FEATURE_FILES as $feature => $featureFiles ) {
407 if ( in_array( $feature, $this->features ) ) {
408 foreach ( $featureFiles as $mediaType => $files ) {
409 foreach ( $files as $filepath ) {
410 $featureFilePaths[$mediaType][] = new FilePath(
411 $filepath,
412 $defaultLocalBasePath,
413 $defaultRemoteBasePath
414 );
415 }
416 }
417
418 if ( $feature === 'content-media' ) {
419 if ( $this->getConfig()->get( MainConfigNames::UseLegacyMediaStyles ) ) {
420 $featureFilePaths['all'][] = new FilePath(
421 'resources/src/mediawiki.skinning/content.thumbnails-common.less',
422 $defaultLocalBasePath,
423 $defaultRemoteBasePath
424 );
425 $featureFilePaths['screen'][] = new FilePath(
426 'resources/src/mediawiki.skinning/content.thumbnails-screen.less',
427 $defaultLocalBasePath,
428 $defaultRemoteBasePath
429 );
430 $featureFilePaths['print'][] = new FilePath(
431 'resources/src/mediawiki.skinning/content.thumbnails-print.less',
432 $defaultLocalBasePath,
433 $defaultRemoteBasePath
434 );
435 }
436 if (
437 !$this->getConfig()->get( MainConfigNames::ParserEnableLegacyMediaDOM ) ||
438 $this->getConfig()->get( MainConfigNames::UseContentMediaStyles )
439 ) {
440 $featureFilePaths['all'][] = new FilePath(
441 'resources/src/mediawiki.skinning/content.media-common.less',
442 $defaultLocalBasePath,
443 $defaultRemoteBasePath
444 );
445 $featureFilePaths['screen'][] = new FilePath(
446 'resources/src/mediawiki.skinning/content.media-screen.less',
447 $defaultLocalBasePath,
448 $defaultRemoteBasePath
449 );
450 $featureFilePaths['print'][] = new FilePath(
451 'resources/src/mediawiki.skinning/content.media-print.less',
452 $defaultLocalBasePath,
453 $defaultRemoteBasePath
454 );
455 }
456 }
457 }
458 }
459 return $featureFilePaths;
460 }
461
471 private function combineFeatureAndParentStyles( $featureStyles, $parentStyles ) {
472 $combinedFeatureStyles = ResourceLoader::makeCombinedStyles( $featureStyles );
473 $combinedParentStyles = ResourceLoader::makeCombinedStyles( $parentStyles );
474 $combinedStyles = array_merge( $combinedFeatureStyles, $combinedParentStyles );
475 return [ '' => $combinedStyles ];
476 }
477
485 public function generateAndAppendLogoStyles( $featureStyles, $context ) {
486 $logo = $this->getLogoData( $this->getConfig(), $context->getLanguage() );
487 $default = !is_array( $logo ) ? $logo : ( $logo['svg'] ?? $logo['1x'] ?? null );
488
489 // Can't add logo CSS if no logo defined.
490 if ( !$default ) {
491 return $featureStyles;
492 }
493
494 $featureStyles['all'][] = '.mw-wiki-logo { background-image: ' .
495 CSSMin::buildUrlValue( $default ) .
496 '; }';
497
498 if ( is_array( $logo ) ) {
499 if ( isset( $logo['svg'] ) ) {
500 $featureStyles['all'][] = '.mw-wiki-logo { ' .
501 'background-size: 135px auto; }';
502 } else {
503 if ( isset( $logo['1.5x'] ) ) {
504 $featureStyles[
505 '(-webkit-min-device-pixel-ratio: 1.5), ' .
506 '(min-resolution: 1.5dppx), ' .
507 '(min-resolution: 144dpi)'
508 ][] = '.mw-wiki-logo { background-image: ' .
509 CSSMin::buildUrlValue( $logo['1.5x'] ) . ';' .
510 'background-size: 135px auto; }';
511 }
512 if ( isset( $logo['2x'] ) ) {
513 $featureStyles[
514 '(-webkit-min-device-pixel-ratio: 2), ' .
515 '(min-resolution: 2dppx), ' .
516 '(min-resolution: 192dpi)'
517 ][] = '.mw-wiki-logo { background-image: ' .
518 CSSMin::buildUrlValue( $logo['2x'] ) . ';' .
519 'background-size: 135px auto; }';
520 }
521 }
522 }
523 return $featureStyles;
524 }
525
530 public function getStyles( Context $context ) {
531 $parentStyles = parent::getStyles( $context );
532 $featureFilePaths = $this->getFeatureFilePaths();
533 $featureStyles = $this->readStyleFiles( $featureFilePaths, $context );
534
535 $this->normalizeStyles( $featureStyles );
536 $this->normalizeStyles( $parentStyles );
537
538 $isLogoFeatureEnabled = in_array( 'logo', $this->features );
539 if ( $isLogoFeatureEnabled ) {
540 $featureStyles = $this->generateAndAppendLogoStyles( $featureStyles, $context );
541 }
542
543 return $this->combineFeatureAndParentStyles( $featureStyles, $parentStyles );
544 }
545
546 public function getPreloadLinks( Context $context ): array {
547 if ( !in_array( 'logo', $this->features ) ) {
548 return [];
549 }
550
551 $logo = $this->getLogoData( $this->getConfig(), $context->getLanguage() );
552
553 if ( !is_array( $logo ) ) {
554 // No media queries required if we only have one variant
555 return [ $logo => [ 'as' => 'image' ] ];
556 }
557
558 if ( isset( $logo['svg'] ) ) {
559 // No media queries required if we only have a 1x and svg variant
560 // because all preload-capable browsers support SVGs
561 return [ $logo['svg'] => [ 'as' => 'image' ] ];
562 }
563
564 $logosPerDppx = [];
565 foreach ( $logo as $dppx => $src ) {
566 // Keys are in this format: "1.5x"
567 $dppx = substr( $dppx, 0, -1 );
568 $logosPerDppx[$dppx] = $src;
569 }
570
571 // Because PHP can't have floats as array keys
572 uksort( $logosPerDppx, static function ( $a, $b ) {
573 $a = floatval( $a );
574 $b = floatval( $b );
575 // Sort from smallest to largest (e.g. 1x, 1.5x, 2x)
576 return $a <=> $b;
577 } );
578
579 $logos = [];
580 foreach ( $logosPerDppx as $dppx => $src ) {
581 $logos[] = [
582 'dppx' => $dppx,
583 'src' => $src
584 ];
585 }
586
587 $logosCount = count( $logos );
588 $preloadLinks = [];
589 // Logic must match SkinModule:
590 // - 1x applies to resolution < 1.5dppx
591 // - 1.5x applies to resolution >= 1.5dppx && < 2dppx
592 // - 2x applies to resolution >= 2dppx
593 // Note that min-resolution and max-resolution are both inclusive.
594 for ( $i = 0; $i < $logosCount; $i++ ) {
595 if ( $i === 0 ) {
596 // Smallest dppx
597 // min-resolution is ">=" (larger than or equal to)
598 // "not min-resolution" is essentially "<"
599 $media_query = 'not all and (min-resolution: ' . $logos[1]['dppx'] . 'dppx)';
600 } elseif ( $i !== $logosCount - 1 ) {
601 // In between
602 // Media query expressions can only apply "not" to the entire expression
603 // (e.g. can't express ">= 1.5 and not >= 2).
604 // Workaround: Use <= 1.9999 in place of < 2.
605 $upper_bound = floatval( $logos[$i + 1]['dppx'] ) - 0.000001;
606 $media_query = '(min-resolution: ' . $logos[$i]['dppx'] .
607 'dppx) and (max-resolution: ' . $upper_bound . 'dppx)';
608 } else {
609 // Largest dppx
610 $media_query = '(min-resolution: ' . $logos[$i]['dppx'] . 'dppx)';
611 }
612
613 $preloadLinks[$logos[$i]['src']] = [
614 'as' => 'image',
615 'media' => $media_query
616 ];
617 }
618
619 return $preloadLinks;
620 }
621
630 private function normalizeStyles( array &$styles ): void {
631 foreach ( $styles as $key => $val ) {
632 if ( !is_array( $val ) ) {
633 $styles[$key] = [ $val ];
634 }
635 }
636 }
637
644 private static function getRelativeSizedLogo( array $logoElement ) {
645 $width = $logoElement['width'];
646 $height = $logoElement['height'];
647 $widthRelative = $width / 16;
648 $heightRelative = $height / 16;
649 // Allow skins to scale the wordmark with browser font size (T207789)
650 $logoElement['style'] = 'width: ' . $widthRelative . 'em; height: ' . $heightRelative . 'em;';
651 return $logoElement;
652 }
653
669 public static function getAvailableLogos( Config $conf, ?string $lang = null ): array {
670 $logos = $conf->get( MainConfigNames::Logos );
671 if ( $logos === false ) {
672 // no logos were defined... this will either
673 // 1. Load from wgLogo
674 // 2. Trigger runtime exception if those are not defined.
675 $logos = [];
676 }
677 if ( $lang && isset( $logos['variants'][$lang] ) ) {
678 foreach ( $logos['variants'][$lang] as $type => $value ) {
679 $logos[$type] = $value;
680 }
681 }
682
683 // If logos['1x'] is not defined, see if we can use wgLogo
684 if ( !isset( $logos[ '1x' ] ) ) {
685 $logo = $conf->get( MainConfigNames::Logo );
686 if ( $logo ) {
687 $logos['1x'] = $logo;
688 }
689 }
690
691 if ( isset( $logos['wordmark'] ) ) {
692 // Allow skins to scale the wordmark with browser font size (T207789)
693 $logos['wordmark'] = self::getRelativeSizedLogo( $logos['wordmark'] );
694 }
695 if ( isset( $logos['tagline'] ) ) {
696 $logos['tagline'] = self::getRelativeSizedLogo( $logos['tagline'] );
697 }
698
699 return $logos;
700 }
701
711 protected function getLogoData( Config $conf, ?string $lang = null ) {
712 $logoHD = self::getAvailableLogos( $conf, $lang );
713 $logo = $logoHD['1x'];
714
715 $logo1Url = OutputPage::transformResourcePath( $conf, $logo );
716
717 $logoUrls = [
718 '1x' => $logo1Url,
719 ];
720
721 if ( isset( $logoHD['svg'] ) ) {
722 $logoUrls['svg'] = OutputPage::transformResourcePath(
723 $conf,
724 $logoHD['svg']
725 );
726 } elseif ( isset( $logoHD['1.5x'] ) || isset( $logoHD['2x'] ) ) {
727 // Only 1.5x and 2x are supported
728 if ( isset( $logoHD['1.5x'] ) ) {
729 $logoUrls['1.5x'] = OutputPage::transformResourcePath(
730 $conf,
731 $logoHD['1.5x']
732 );
733 }
734 if ( isset( $logoHD['2x'] ) ) {
735 $logoUrls['2x'] = OutputPage::transformResourcePath(
736 $conf,
737 $logoHD['2x']
738 );
739 }
740 } else {
741 // Return a string rather than a one-element array, getLogoPreloadlinks depends on this
742 return $logo1Url;
743 }
744
745 return $logoUrls;
746 }
747
752 public function isKnownEmpty( Context $context ) {
753 // Regardless of whether the files are specified, we always
754 // provide mw-wiki-logo styles.
755 return false;
756 }
757
764 protected function getLessVars( Context $context ) {
765 $lessVars = parent::getLessVars( $context );
766 $logos = self::getAvailableLogos( $this->getConfig(), $context->getLanguage() );
767
768 if ( isset( $logos['wordmark'] ) ) {
769 $logo = $logos['wordmark'];
770 $lessVars[ 'logo-enabled' ] = true;
771 $lessVars[ 'logo-wordmark-url' ] = CSSMin::buildUrlValue( $logo['src'] );
772 $lessVars[ 'logo-wordmark-width' ] = intval( $logo['width'] );
773 $lessVars[ 'logo-wordmark-height' ] = intval( $logo['height'] );
774 } else {
775 $lessVars[ 'logo-enabled' ] = false;
776 }
777 return $lessVars;
778 }
779
780 public function getDefinitionSummary( Context $context ) {
781 $summary = parent::getDefinitionSummary( $context );
782 $summary[] = [
783 'logos' => self::getAvailableLogos( $this->getConfig(), $context->getLanguage() ),
784 ];
785 return $summary;
786 }
787}
if(!defined('MW_SETUP_CALLBACK'))
Definition WebStart.php:82
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:46
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.
getLogoData(Config $conf, ?string $lang=null)
getDefinitionSummary(Context $context)
Get the definition summary for this module.
getPreloadLinks(Context $context)
Get a list of resources that web browsers may preload.
generateAndAppendLogoStyles( $featureStyles, $context)
Generates CSS for .mw-logo-logo styles and appends them to the skin feature styles array.
static getAvailableLogos(Config $conf, ?string $lang=null)
Return an array of all available logos that a skin may use.
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.".