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 // Deprecated since 1.43, it's merged into the `elements` module.
202 'i18n-all-lists-margins' => [],
203 'i18n-headings' => [
204 'screen' => [ 'resources/src/mediawiki.skinning/i18n-headings.less' ],
205 ],
206 'toc' => [
207 'all' => [ 'resources/src/mediawiki.skinning/toc/common.css' ],
208 'screen' => [ 'resources/src/mediawiki.skinning/toc/screen.less' ],
209 'print' => [ 'resources/src/mediawiki.skinning/toc/print.css' ],
210 ],
211 ];
212
214 private $features;
215
223 private const DEFAULT_FEATURES_SPECIFIED = [
224 'accessibility' => true,
225 'content-body' => true,
226 'interface-core' => true,
227 'toc' => true
228 ];
229
238 private const DEFAULT_FEATURES_ABSENT = [
239 'logo',
240 ];
241
242 private const LESS_MESSAGES = [
243 // `toc` feature, used in screen.less
244 'hidetoc',
245 'showtoc',
246 ];
247
268 public function __construct(
269 array $options = [],
270 $localBasePath = null,
271 $remoteBasePath = null
272 ) {
273 $features = $options['features'] ?? self::DEFAULT_FEATURES_ABSENT;
274 $listMode = array_keys( $features ) === range( 0, count( $features ) - 1 );
275
276 $messages = '';
277 // NOTE: Compatibility is only applied when features are provided
278 // in map-form. The list-form does not currently get these.
279 $features = $listMode ? self::applyFeaturesCompatibility(
280 array_fill_keys( $features, true ), false, $messages
281 ) : self::applyFeaturesCompatibility( $features, true, $messages );
282
283 foreach ( $features as $key => $enabled ) {
284 if ( !isset( self::FEATURE_FILES[$key] ) ) {
285 throw new InvalidArgumentException( "Feature '$key' is not recognised" );
286 }
287 }
288
289 $this->features = $listMode
290 ? array_keys( array_filter( $features ) )
291 : array_keys( array_filter( $features + self::DEFAULT_FEATURES_SPECIFIED ) );
292
293 // Only the `toc` feature makes use of interface messages.
294 // For skins not using the `toc` feature, make sure LocalisationCache
295 // remains untouched (T270027).
296 if ( in_array( 'toc', $this->features ) ) {
297 $options['lessMessages'] = array_merge(
298 $options['lessMessages'] ?? [],
299 self::LESS_MESSAGES
300 );
301 }
302
303 if ( $messages !== '' ) {
304 $messages .= 'More information can be found at [[mw:Manual:ResourceLoaderSkinModule]]. ';
305 $options['deprecated'] = $messages;
306 }
307 parent::__construct( $options, $localBasePath, $remoteBasePath );
308 }
309
317 protected static function applyFeaturesCompatibility(
318 array $features, bool $addUnspecifiedFeatures = true, &$messages = ''
319 ): array {
320 if ( isset( $features[ 'i18n-all-lists-margins' ] ) ) {
321 unset( $features[ 'i18n-all-lists-margins' ] );
322 $messages .= '[1.43] The use of the `i18n-all-lists-margins` feature with SkinModule'
323 . ' is deprecated. Please remove. ';
324 }
325 // The `content` feature is mapped to `content-media`.
326 if ( isset( $features[ 'content' ] ) ) {
327 $features[ 'content-media' ] = $features[ 'content' ];
328 unset( $features[ 'content' ] );
329 $messages .= '[1.37] The use of the `content` feature with SkinModule'
330 . ' is deprecated. Use `content-media` instead. ';
331 }
332
333 // The `content-thumbnails` feature is mapped to `content-media`.
334 if ( isset( $features[ 'content-thumbnails' ] ) ) {
335 $features[ 'content-media' ] = $features[ 'content-thumbnails' ];
336 $messages .= '[1.37] The use of the `content-thumbnails` feature with SkinModule'
337 . ' is deprecated. Use `content-media` instead. ';
338 unset( $features[ 'content-thumbnails' ] );
339 }
340
341 // If `content-links` feature is set but no preference for `content-links-external` is set
342 if ( $addUnspecifiedFeatures && isset( $features[ 'content-links' ] )
343 && !isset( $features[ 'content-links-external' ] )
344 ) {
345 // Assume the same true/false preference for both.
346 $features[ 'content-links-external' ] = $features[ 'content-links' ];
347 }
348
349 // The legacy feature no longer exists (T89981) but to avoid fatals in skins is retained.
350 if ( isset( $features['legacy'] ) && $features['legacy'] ) {
351 $messages .= '[1.37] The use of the `legacy` feature with SkinModule is deprecated'
352 . '(T89981) and is a NOOP since 1.39 (T304325). This should be urgently omited to retain compatibility '
353 . 'with future MediaWiki versions';
354 }
355
356 // The `content-links` feature was split out from `elements`.
357 // Make sure skins asking for `elements` also get these by default.
358 if ( $addUnspecifiedFeatures && isset( $features[ 'element' ] ) && !isset( $features[ 'content-links' ] ) ) {
359 $features[ 'content-links' ] = $features[ 'element' ];
360 }
361
362 // `content-parser-output` was renamed to `content-body`.
363 // No need to go through deprecation process here since content-parser-output added and removed in 1.36.
364 // Remove this check when no matches for
365 // https://codesearch.wmcloud.org/search/?q=content-parser-output&i=nope&files=&excludeFiles=&repos=
366 if ( isset( $features[ 'content-parser-output' ] ) ) {
367 $features[ 'content-body' ] = $features[ 'content-parser-output' ];
368 unset( $features[ 'content-parser-output' ] );
369 }
370
371 // The interface module is a short hand for several modules. Enable them now.
372 if ( isset( $features[ 'interface' ] ) && $features[ 'interface' ] ) {
373 unset( $features[ 'interface' ] );
374 $features[ 'interface-core' ] = true;
375 $features[ 'interface-indicators' ] = true;
376 $features[ 'interface-subtitle' ] = true;
377 $features[ 'interface-user-message' ] = true;
378 $features[ 'interface-site-notice' ] = true;
379 $features[ 'interface-edit-section-links' ] = true;
380 }
381 return $features;
382 }
383
389 public function getFeatureFilePaths() {
390 // Bypass the current module paths so that these files are served from core,
391 // instead of the individual skin's module directory.
392 [ $defaultLocalBasePath, $defaultRemoteBasePath ] =
394 [],
395 null,
396 $this->getConfig()->get( MainConfigNames::ResourceBasePath )
397 );
398
399 $featureFilePaths = [];
400
401 foreach ( self::FEATURE_FILES as $feature => $featureFiles ) {
402 if ( in_array( $feature, $this->features ) ) {
403 foreach ( $featureFiles as $mediaType => $files ) {
404 foreach ( $files as $filepath ) {
405 $featureFilePaths[$mediaType][] = new FilePath(
406 $filepath,
407 $defaultLocalBasePath,
408 $defaultRemoteBasePath
409 );
410 }
411 }
412
413 if ( $feature === 'content-media' ) {
414 if ( $this->getConfig()->get( MainConfigNames::UseLegacyMediaStyles ) ) {
415 $featureFilePaths['all'][] = new FilePath(
416 'resources/src/mediawiki.skinning/content.thumbnails-common.less',
417 $defaultLocalBasePath,
418 $defaultRemoteBasePath
419 );
420 $featureFilePaths['screen'][] = new FilePath(
421 'resources/src/mediawiki.skinning/content.thumbnails-screen.less',
422 $defaultLocalBasePath,
423 $defaultRemoteBasePath
424 );
425 $featureFilePaths['print'][] = new FilePath(
426 'resources/src/mediawiki.skinning/content.thumbnails-print.less',
427 $defaultLocalBasePath,
428 $defaultRemoteBasePath
429 );
430 }
431 if (
432 !$this->getConfig()->get( MainConfigNames::ParserEnableLegacyMediaDOM ) ||
433 $this->getConfig()->get( MainConfigNames::UseContentMediaStyles )
434 ) {
435 $featureFilePaths['all'][] = new FilePath(
436 'resources/src/mediawiki.skinning/content.media-common.less',
437 $defaultLocalBasePath,
438 $defaultRemoteBasePath
439 );
440 $featureFilePaths['screen'][] = new FilePath(
441 'resources/src/mediawiki.skinning/content.media-screen.less',
442 $defaultLocalBasePath,
443 $defaultRemoteBasePath
444 );
445 $featureFilePaths['print'][] = new FilePath(
446 'resources/src/mediawiki.skinning/content.media-print.less',
447 $defaultLocalBasePath,
448 $defaultRemoteBasePath
449 );
450 }
451 }
452 }
453 }
454 return $featureFilePaths;
455 }
456
466 private function combineFeatureAndParentStyles( $featureStyles, $parentStyles ) {
467 $combinedFeatureStyles = ResourceLoader::makeCombinedStyles( $featureStyles );
468 $combinedParentStyles = ResourceLoader::makeCombinedStyles( $parentStyles );
469 $combinedStyles = array_merge( $combinedFeatureStyles, $combinedParentStyles );
470 return [ '' => $combinedStyles ];
471 }
472
480 public function generateAndAppendLogoStyles( $featureStyles, $context ) {
481 $logo = $this->getLogoData( $this->getConfig(), $context->getLanguage() );
482 $default = !is_array( $logo ) ? $logo : ( $logo['svg'] ?? $logo['1x'] ?? null );
483
484 // Can't add logo CSS if no logo defined.
485 if ( !$default ) {
486 return $featureStyles;
487 }
488
489 $featureStyles['all'][] = '.mw-wiki-logo { background-image: ' .
490 CSSMin::buildUrlValue( $default ) .
491 '; }';
492
493 if ( is_array( $logo ) ) {
494 if ( isset( $logo['svg'] ) ) {
495 $featureStyles['all'][] = '.mw-wiki-logo { ' .
496 'background-size: 135px auto; }';
497 } else {
498 if ( isset( $logo['1.5x'] ) ) {
499 $featureStyles[
500 '(-webkit-min-device-pixel-ratio: 1.5), ' .
501 '(min-resolution: 1.5dppx), ' .
502 '(min-resolution: 144dpi)'
503 ][] = '.mw-wiki-logo { background-image: ' .
504 CSSMin::buildUrlValue( $logo['1.5x'] ) . ';' .
505 'background-size: 135px auto; }';
506 }
507 if ( isset( $logo['2x'] ) ) {
508 $featureStyles[
509 '(-webkit-min-device-pixel-ratio: 2), ' .
510 '(min-resolution: 2dppx), ' .
511 '(min-resolution: 192dpi)'
512 ][] = '.mw-wiki-logo { background-image: ' .
513 CSSMin::buildUrlValue( $logo['2x'] ) . ';' .
514 'background-size: 135px auto; }';
515 }
516 }
517 }
518 return $featureStyles;
519 }
520
525 public function getStyles( Context $context ) {
526 $parentStyles = parent::getStyles( $context );
527 $featureFilePaths = $this->getFeatureFilePaths();
528 $featureStyles = $this->readStyleFiles( $featureFilePaths, $context );
529
530 $this->normalizeStyles( $featureStyles );
531 $this->normalizeStyles( $parentStyles );
532
533 $isLogoFeatureEnabled = in_array( 'logo', $this->features );
534 if ( $isLogoFeatureEnabled ) {
535 $featureStyles = $this->generateAndAppendLogoStyles( $featureStyles, $context );
536 }
537
538 return $this->combineFeatureAndParentStyles( $featureStyles, $parentStyles );
539 }
540
545 public function getPreloadLinks( Context $context ): array {
546 if ( !in_array( 'logo', $this->features ) ) {
547 return [];
548 }
549
550 $logo = $this->getLogoData( $this->getConfig(), $context->getLanguage() );
551
552 if ( !is_array( $logo ) ) {
553 // No media queries required if we only have one variant
554 return [ $logo => [ 'as' => 'image' ] ];
555 }
556
557 if ( isset( $logo['svg'] ) ) {
558 // No media queries required if we only have a 1x and svg variant
559 // because all preload-capable browsers support SVGs
560 return [ $logo['svg'] => [ 'as' => 'image' ] ];
561 }
562
563 $logosPerDppx = [];
564 foreach ( $logo as $dppx => $src ) {
565 // Keys are in this format: "1.5x"
566 $dppx = substr( $dppx, 0, -1 );
567 $logosPerDppx[$dppx] = $src;
568 }
569
570 // Because PHP can't have floats as array keys
571 uksort( $logosPerDppx, static function ( $a, $b ) {
572 $a = floatval( $a );
573 $b = floatval( $b );
574 // Sort from smallest to largest (e.g. 1x, 1.5x, 2x)
575 return $a <=> $b;
576 } );
577
578 $logos = [];
579 foreach ( $logosPerDppx as $dppx => $src ) {
580 $logos[] = [
581 'dppx' => $dppx,
582 'src' => $src
583 ];
584 }
585
586 $logosCount = count( $logos );
587 $preloadLinks = [];
588 // Logic must match SkinModule:
589 // - 1x applies to resolution < 1.5dppx
590 // - 1.5x applies to resolution >= 1.5dppx && < 2dppx
591 // - 2x applies to resolution >= 2dppx
592 // Note that min-resolution and max-resolution are both inclusive.
593 for ( $i = 0; $i < $logosCount; $i++ ) {
594 if ( $i === 0 ) {
595 // Smallest dppx
596 // min-resolution is ">=" (larger than or equal to)
597 // "not min-resolution" is essentially "<"
598 $media_query = 'not all and (min-resolution: ' . $logos[1]['dppx'] . 'dppx)';
599 } elseif ( $i !== $logosCount - 1 ) {
600 // In between
601 // Media query expressions can only apply "not" to the entire expression
602 // (e.g. can't express ">= 1.5 and not >= 2).
603 // Workaround: Use <= 1.9999 in place of < 2.
604 $upper_bound = floatval( $logos[$i + 1]['dppx'] ) - 0.000001;
605 $media_query = '(min-resolution: ' . $logos[$i]['dppx'] .
606 'dppx) and (max-resolution: ' . $upper_bound . 'dppx)';
607 } else {
608 // Largest dppx
609 $media_query = '(min-resolution: ' . $logos[$i]['dppx'] . 'dppx)';
610 }
611
612 $preloadLinks[$logos[$i]['src']] = [
613 'as' => 'image',
614 'media' => $media_query
615 ];
616 }
617
618 return $preloadLinks;
619 }
620
629 private function normalizeStyles( array &$styles ): void {
630 foreach ( $styles as $key => $val ) {
631 if ( !is_array( $val ) ) {
632 $styles[$key] = [ $val ];
633 }
634 }
635 }
636
643 private static function getRelativeSizedLogo( array $logoElement ) {
644 $width = $logoElement['width'];
645 $height = $logoElement['height'];
646 $widthRelative = $width / 16;
647 $heightRelative = $height / 16;
648 // Allow skins to scale the wordmark with browser font size (T207789)
649 $logoElement['style'] = 'width: ' . $widthRelative . 'em; height: ' . $heightRelative . 'em;';
650 return $logoElement;
651 }
652
668 public static function getAvailableLogos( Config $conf, string $lang = null ): array {
669 $logos = $conf->get( MainConfigNames::Logos );
670 if ( $logos === false ) {
671 // no logos were defined... this will either
672 // 1. Load from wgLogo
673 // 2. Trigger runtime exception if those are not defined.
674 $logos = [];
675 }
676 if ( $lang && isset( $logos['variants'][$lang] ) ) {
677 foreach ( $logos['variants'][$lang] as $type => $value ) {
678 $logos[$type] = $value;
679 }
680 }
681
682 // If logos['1x'] is not defined, see if we can use wgLogo
683 if ( !isset( $logos[ '1x' ] ) ) {
684 $logo = $conf->get( MainConfigNames::Logo );
685 if ( $logo ) {
686 $logos['1x'] = $logo;
687 }
688 }
689
690 if ( isset( $logos['wordmark'] ) ) {
691 // Allow skins to scale the wordmark with browser font size (T207789)
692 $logos['wordmark'] = self::getRelativeSizedLogo( $logos['wordmark'] );
693 }
694 if ( isset( $logos['tagline'] ) ) {
695 $logos['tagline'] = self::getRelativeSizedLogo( $logos['tagline'] );
696 }
697
698 return $logos;
699 }
700
710 protected function getLogoData( Config $conf, string $lang = null ) {
711 $logoHD = self::getAvailableLogos( $conf, $lang );
712 $logo = $logoHD['1x'];
713
714 $logo1Url = OutputPage::transformResourcePath( $conf, $logo );
715
716 $logoUrls = [
717 '1x' => $logo1Url,
718 ];
719
720 if ( isset( $logoHD['svg'] ) ) {
721 $logoUrls['svg'] = OutputPage::transformResourcePath(
722 $conf,
723 $logoHD['svg']
724 );
725 } elseif ( isset( $logoHD['1.5x'] ) || isset( $logoHD['2x'] ) ) {
726 // Only 1.5x and 2x are supported
727 if ( isset( $logoHD['1.5x'] ) ) {
728 $logoUrls['1.5x'] = OutputPage::transformResourcePath(
729 $conf,
730 $logoHD['1.5x']
731 );
732 }
733 if ( isset( $logoHD['2x'] ) ) {
734 $logoUrls['2x'] = OutputPage::transformResourcePath(
735 $conf,
736 $logoHD['2x']
737 );
738 }
739 } else {
740 // Return a string rather than a one-element array, getLogoPreloadlinks depends on this
741 return $logo1Url;
742 }
743
744 return $logoUrls;
745 }
746
751 public function isKnownEmpty( Context $context ) {
752 // Regardless of whether the files are specified, we always
753 // provide mw-wiki-logo styles.
754 return false;
755 }
756
763 protected function getLessVars( Context $context ) {
764 $lessVars = parent::getLessVars( $context );
765 $logos = self::getAvailableLogos( $this->getConfig(), $context->getLanguage() );
766
767 if ( isset( $logos['wordmark'] ) ) {
768 $logo = $logos['wordmark'];
769 $lessVars[ 'logo-enabled' ] = true;
770 $lessVars[ 'logo-wordmark-url' ] = CSSMin::buildUrlValue( $logo['src'] );
771 $lessVars[ 'logo-wordmark-width' ] = intval( $logo['width'] );
772 $lessVars[ 'logo-wordmark-height' ] = intval( $logo['height'] );
773 } else {
774 $lessVars[ 'logo-enabled' ] = false;
775 }
776 return $lessVars;
777 }
778
779 public function getDefinitionSummary( Context $context ) {
780 $summary = parent::getDefinitionSummary( $context );
781 $summary[] = [
782 'logos' => self::getAvailableLogos( $this->getConfig(), $context->getLanguage() ),
783 ];
784 return $summary;
785 }
786}
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.".