MediaWiki master
SkinModule.php
Go to the documentation of this file.
1<?php
7
8use InvalidArgumentException;
12use Wikimedia\Minify\CSSMin;
13
21
116 private const FEATURE_FILES = [
117 'accessibility' => [
118 'all' => [ 'resources/src/mediawiki.skinning/accessibility.less' ],
119 ],
120 'normalize' => [
121 'all' => [ 'resources/src/mediawiki.skinning/normalize.less' ],
122 ],
123 'logo' => [
124 // Applies the logo and ensures it downloads prior to printing.
125 'all' => [ 'resources/src/mediawiki.skinning/logo.less' ],
126 // Reserves whitespace for the logo in a pseudo element.
127 'print' => [ 'resources/src/mediawiki.skinning/logo-print.less' ],
128 ],
129 // Placeholder for dynamic definition in getFeatureFilePaths()
130 'content-media' => [],
131 'content-media-dark' => [
132 'screen' => [ 'resources/src/mediawiki.skinning/content.media-dark.less' ],
133 ],
134 'content-links' => [
135 'screen' => [ 'resources/src/mediawiki.skinning/content.links.less' ]
136 ],
137 'content-links-external' => [
138 'screen' => [ 'resources/src/mediawiki.skinning/content.externallinks.less' ]
139 ],
140 'content-body' => [
141 'screen' => [ 'resources/src/mediawiki.skinning/content.body.less' ],
142 'print' => [ 'resources/src/mediawiki.skinning/content.body-print.less' ],
143 ],
144 'content-tables' => [
145 'screen' => [ 'resources/src/mediawiki.skinning/content.tables.less' ],
146 'print' => [ 'resources/src/mediawiki.skinning/content.tables-print.less' ]
147 ],
148 'interface-category' => [
149 'screen' => [ 'resources/src/mediawiki.skinning/interface.category.less' ],
150 'print' => [ 'resources/src/mediawiki.skinning/interface.category-print.less' ],
151 ],
152 'interface-core' => [
153 'screen' => [ 'resources/src/mediawiki.skinning/interface.less' ],
154 'print' => [ 'resources/src/mediawiki.skinning/interface-print.less' ],
155 ],
156 'interface-edit-section-links' => [
157 'screen' => [ 'resources/src/mediawiki.skinning/interface-edit-section-links.less' ],
158 ],
159 'interface-indicators' => [
160 'screen' => [ 'resources/src/mediawiki.skinning/interface-indicators.less' ],
161 ],
162 'interface-site-notice' => [
163 'screen' => [ 'resources/src/mediawiki.skinning/interface-site-notice.less' ],
164 ],
165 'interface-subtitle' => [
166 'screen' => [ 'resources/src/mediawiki.skinning/interface-subtitle.less' ],
167 ],
168 'interface-message-box' => [
169 'all' => [ 'resources/src/mediawiki.skinning/messageBoxes.less' ],
170 ],
171 'interface-user-message' => [
172 'screen' => [ 'resources/src/mediawiki.skinning/interface-user-message.less' ],
173 ],
174 'elements' => [
175 'screen' => [ 'resources/src/mediawiki.skinning/elements.less' ],
176 'print' => [ 'resources/src/mediawiki.skinning/elements-print.less' ],
177 ],
178 'i18n-ordered-lists' => [
179 'screen' => [ 'resources/src/mediawiki.skinning/i18n-ordered-lists.less' ],
180 ],
181 'i18n-all-lists-margins' => [
182 'screen' => [ 'resources/src/mediawiki.skinning/i18n-all-lists-margins.less' ],
183 ],
184 'i18n-headings' => [
185 'screen' => [ 'resources/src/mediawiki.skinning/i18n-headings.less' ],
186 ],
187 'toc' => [
188 'all' => [ 'resources/src/mediawiki.skinning/toc/common.less' ],
189 'screen' => [ 'resources/src/mediawiki.skinning/toc/screen.less' ],
190 'print' => [ 'resources/src/mediawiki.skinning/toc/print.less' ],
191 ],
192 ];
193
194 private const COMPAT_ALIASES = [
195 // MediaWiki 1.36
196 'content-parser-output' => 'content-body',
197 // MediaWiki 1.37
198 'content' => 'content-media',
199 'content-thumbnails' => 'content-media',
200 // MediaWiki 1.39
201 // The 'legacy' feature has been folded into other features that relevant skins
202 // are expected to have already enabled separately. It is now a no-op that can
203 // be safely removed from any skin.json files (T89981, T304325).
204 'legacy' => null,
205 ];
206
208 private $features;
209
215 private const DEFAULT_FEATURES_SPECIFIED = [
216 'accessibility' => true,
217 'content-body' => true,
218 'interface-core' => true,
219 'toc' => true
220 ];
221
228 private const DEFAULT_FEATURES_ABSENT = [
229 'logo',
230 ];
231
232 private const LESS_MESSAGES = [
233 // `toc` feature, used in screen.less
234 'hidetoc',
235 'showtoc',
236 ];
237
258 public function __construct(
259 array $options = [],
260 $localBasePath = null,
261 $remoteBasePath = null
262 ) {
263 $features = $options['features'] ?? self::DEFAULT_FEATURES_ABSENT;
264 $listMode = array_keys( $features ) === range( 0, count( $features ) - 1 );
265
266 $messages = '';
267 // NOTE: Compatibility is only applied when features are provided
268 // in map-form. The list-form takes full control instead.
269 $features = $listMode ?
271 array_fill_keys( $features, true ),
272 false,
274 )
275 : self::applyFeaturesCompatibility( $features, true, $messages );
276
277 foreach ( $features as $key => $enabled ) {
278 if ( !isset( self::FEATURE_FILES[$key] ) ) {
279 throw new InvalidArgumentException( "Feature '$key' is not recognised" );
280 }
281 }
282
283 $this->features = $listMode
284 ? array_keys( array_filter( $features ) )
285 : array_keys( array_filter( $features + self::DEFAULT_FEATURES_SPECIFIED ) );
286
287 // Only the `toc` feature makes use of interface messages.
288 // For skins not using the `toc` feature, make sure LocalisationCache
289 // remains untouched (T270027).
290 if ( in_array( 'toc', $this->features ) ) {
291 $options['lessMessages'] = array_merge(
292 $options['lessMessages'] ?? [],
293 self::LESS_MESSAGES
294 );
295 }
296
297 if ( $messages !== '' ) {
298 $messages .= 'More information can be found at [[mw:Manual:ResourceLoaderSkinModule]]. ';
299 $options['deprecated'] = $messages;
300 }
301 parent::__construct( $options, $localBasePath, $remoteBasePath );
302 }
303
311 protected static function applyFeaturesCompatibility(
312 array $features, bool $addUnspecifiedFeatures = true, &$messages = ''
313 ): array {
314 if ( isset( $features[ 'i18n-all-lists-margins' ] ) ) {
315 // Emit warning only. Key is supported as-is.
316 // Replacement requires maintainer intervention as it has non-trivial side-effects.
317 $messages .= '[1.43] The use of the `i18n-all-lists-margins` feature with SkinModule'
318 . ' is deprecated as it is now provided by `elements`. Please remove and '
319 . ' add `elements`, drop support for RTL languages, or incorporate the '
320 . ' styles provided by this module into your skin.';
321 }
322 if ( isset( $features[ 'interface-message-box' ] ) && $features[ 'interface-message-box' ] ) {
323 // Emit warning only. Key is supported as-is (For now)
324 // Replacement requires maintainer loading a suitable Codex module instead.
325 // Note: When removing this deprecation notice and associated code, please
326 // make sure mediawiki.legacy.messageBox is not broken.
327 $messages .= '[1.43] The use of the `interface-message-box` feature with SkinModule'
328 . ' is deprecated in favor of CodexModule. Please remove this feature.';
329 }
330
331 foreach ( self::COMPAT_ALIASES as $from => $to ) {
332 if ( isset( $features[ $from ] ) && $to !== null ) {
333 if ( isset( $features[ $to ] ) ) {
334 $messages .= "SkinModule feature `$from` conflicts with `$to` and was ignored. ";
335 } else {
336 $features[ $to ] = $features[ $from ];
337 }
338 }
339 unset( $features[ $from ] );
340 }
341
342 // If `content-links` feature is set but no preference for `content-links-external` is set
343 if ( $addUnspecifiedFeatures
344 && isset( $features[ 'content-links' ] )
345 && !isset( $features[ 'content-links-external' ] )
346 ) {
347 // Assume the same true/false preference for both.
348 $features[ 'content-links-external' ] = $features[ 'content-links' ];
349 }
350
351 // The `content-links` feature was split out from `elements`.
352 // Make sure skins asking for `elements` also get these by default.
353 if ( $addUnspecifiedFeatures && isset( $features[ 'elements' ] ) && !isset( $features[ 'content-links' ] ) ) {
354 $features[ 'content-links' ] = $features[ 'elements' ];
355 }
356
357 // The interface module is a short hand for several modules. Enable them now.
358 if ( isset( $features[ 'interface' ] ) && $features[ 'interface' ] ) {
359 $features[ 'interface-core' ] = true;
360 $features[ 'interface-indicators' ] = true;
361 $features[ 'interface-subtitle' ] = true;
362 $features[ 'interface-user-message' ] = true;
363 $features[ 'interface-site-notice' ] = true;
364 $features[ 'interface-edit-section-links' ] = true;
365 }
366 unset( $features[ 'interface' ] );
367
368 return $features;
369 }
370
376 public function getFeatureFilePaths() {
377 // Bypass the current module paths so that these files are served from core,
378 // instead of the individual skin's module directory.
379 [ $defaultLocalBasePath, $defaultRemoteBasePath ] =
381 [],
382 null,
383 $this->getConfig()->get( MainConfigNames::ResourceBasePath )
384 );
385
386 $featureFilePaths = [];
387
388 foreach ( self::FEATURE_FILES as $feature => $featureFiles ) {
389 if ( in_array( $feature, $this->features ) ) {
390 foreach ( $featureFiles as $mediaType => $files ) {
391 foreach ( $files as $filepath ) {
392 $featureFilePaths[$mediaType][] = new FilePath(
393 $filepath,
394 $defaultLocalBasePath,
395 $defaultRemoteBasePath
396 );
397 }
398 }
399
400 if ( $feature === 'content-media' ) {
401 if ( $this->getConfig()->get( MainConfigNames::UseLegacyMediaStyles ) ) {
402 $featureFilePaths['all'][] = new FilePath(
403 'resources/src/mediawiki.skinning/content.thumbnails-common.less',
404 $defaultLocalBasePath,
405 $defaultRemoteBasePath
406 );
407 $featureFilePaths['screen'][] = new FilePath(
408 'resources/src/mediawiki.skinning/content.thumbnails-screen.less',
409 $defaultLocalBasePath,
410 $defaultRemoteBasePath
411 );
412 $featureFilePaths['print'][] = new FilePath(
413 'resources/src/mediawiki.skinning/content.thumbnails-print.less',
414 $defaultLocalBasePath,
415 $defaultRemoteBasePath
416 );
417 }
418 $featureFilePaths['all'][] = new FilePath(
419 'resources/src/mediawiki.skinning/content.media-common.less',
420 $defaultLocalBasePath,
421 $defaultRemoteBasePath
422 );
423 $featureFilePaths['screen'][] = new FilePath(
424 'resources/src/mediawiki.skinning/content.media-screen.less',
425 $defaultLocalBasePath,
426 $defaultRemoteBasePath
427 );
428 $featureFilePaths['print'][] = new FilePath(
429 'resources/src/mediawiki.skinning/content.media-print.less',
430 $defaultLocalBasePath,
431 $defaultRemoteBasePath
432 );
433 }
434 }
435 }
436 return $featureFilePaths;
437 }
438
448 private function combineFeatureAndParentStyles( $featureStyles, $parentStyles ) {
449 $combinedFeatureStyles = ResourceLoader::makeCombinedStyles( $featureStyles );
450 $combinedParentStyles = ResourceLoader::makeCombinedStyles( $parentStyles );
451 $combinedStyles = array_merge( $combinedFeatureStyles, $combinedParentStyles );
452 return [ '' => $combinedStyles ];
453 }
454
462 public function generateAndAppendLogoStyles( $featureStyles, $context ) {
463 $logo = $this->getLogoData( $this->getConfig(), $context->getLanguage() );
464 $default = !is_array( $logo ) ? $logo : ( $logo['svg'] ?? $logo['1x'] ?? null );
465
466 // Can't add logo CSS if no logo defined.
467 if ( !$default ) {
468 return $featureStyles;
469 }
470
471 $featureStyles['all'][] = '.mw-wiki-logo { background-image: ' .
472 CSSMin::buildUrlValue( $default ) .
473 '; }';
474
475 if ( is_array( $logo ) ) {
476 if ( isset( $logo['svg'] ) ) {
477 $featureStyles['all'][] = '.mw-wiki-logo { ' .
478 'background-size: 135px auto; }';
479 } else {
480 if ( isset( $logo['1.5x'] ) ) {
481 $featureStyles[
482 '(-webkit-min-device-pixel-ratio: 1.5), ' .
483 '(min-resolution: 1.5dppx), ' .
484 '(min-resolution: 144dpi)'
485 ][] = '.mw-wiki-logo { background-image: ' .
486 CSSMin::buildUrlValue( $logo['1.5x'] ) . ';' .
487 'background-size: 135px auto; }';
488 }
489 if ( isset( $logo['2x'] ) ) {
490 $featureStyles[
491 '(-webkit-min-device-pixel-ratio: 2), ' .
492 '(min-resolution: 2dppx), ' .
493 '(min-resolution: 192dpi)'
494 ][] = '.mw-wiki-logo { background-image: ' .
495 CSSMin::buildUrlValue( $logo['2x'] ) . ';' .
496 'background-size: 135px auto; }';
497 }
498 }
499 }
500 return $featureStyles;
501 }
502
507 public function getStyles( Context $context ) {
508 $parentStyles = parent::getStyles( $context );
509 $featureFilePaths = $this->getFeatureFilePaths();
510 $featureStyles = $this->readStyleFiles( $featureFilePaths, $context );
511
512 $this->normalizeStyles( $featureStyles );
513 $this->normalizeStyles( $parentStyles );
514
515 $isLogoFeatureEnabled = in_array( 'logo', $this->features );
516 if ( $isLogoFeatureEnabled ) {
517 $featureStyles = $this->generateAndAppendLogoStyles( $featureStyles, $context );
518 }
519
520 return $this->combineFeatureAndParentStyles( $featureStyles, $parentStyles );
521 }
522
523 public function getPreloadLinks( Context $context ): array {
524 if ( !in_array( 'logo', $this->features ) ) {
525 return [];
526 }
527
528 $logo = $this->getLogoData( $this->getConfig(), $context->getLanguage() );
529
530 if ( !is_array( $logo ) ) {
531 // No media queries required if we only have one variant
532 return [ $logo => [ 'as' => 'image' ] ];
533 }
534
535 if ( isset( $logo['svg'] ) ) {
536 // No media queries required if we only have a 1x and svg variant
537 // because all preload-capable browsers support SVGs
538 return [ $logo['svg'] => [ 'as' => 'image' ] ];
539 }
540
541 $logosPerDppx = [];
542 foreach ( $logo as $dppx => $src ) {
543 // Keys are in this format: "1.5x"
544 $dppx = substr( $dppx, 0, -1 );
545 $logosPerDppx[$dppx] = $src;
546 }
547
548 // Because PHP can't have floats as array keys
549 uksort( $logosPerDppx, static function ( $a, $b ) {
550 $a = floatval( $a );
551 $b = floatval( $b );
552 // Sort from smallest to largest (e.g. 1x, 1.5x, 2x)
553 return $a <=> $b;
554 } );
555
556 $logos = [];
557 foreach ( $logosPerDppx as $dppx => $src ) {
558 $logos[] = [
559 'dppx' => $dppx,
560 'src' => $src
561 ];
562 }
563
564 $logosCount = count( $logos );
565 $preloadLinks = [];
566 // Logic must match SkinModule:
567 // - 1x applies to resolution < 1.5dppx
568 // - 1.5x applies to resolution >= 1.5dppx && < 2dppx
569 // - 2x applies to resolution >= 2dppx
570 // Note that min-resolution and max-resolution are both inclusive.
571 for ( $i = 0; $i < $logosCount; $i++ ) {
572 if ( $i === 0 ) {
573 // Smallest dppx
574 // min-resolution is ">=" (larger than or equal to)
575 // "not min-resolution" is essentially "<"
576 $media_query = 'not all and (min-resolution: ' . $logos[1]['dppx'] . 'dppx)';
577 } elseif ( $i !== $logosCount - 1 ) {
578 // In between
579 // Media query expressions can only apply "not" to the entire expression
580 // (e.g. can't express ">= 1.5 and not >= 2).
581 // Workaround: Use <= 1.9999 in place of < 2.
582 $upper_bound = floatval( $logos[$i + 1]['dppx'] ) - 0.000001;
583 $media_query = '(min-resolution: ' . $logos[$i]['dppx'] .
584 'dppx) and (max-resolution: ' . $upper_bound . 'dppx)';
585 } else {
586 // Largest dppx
587 $media_query = '(min-resolution: ' . $logos[$i]['dppx'] . 'dppx)';
588 }
589
590 $preloadLinks[$logos[$i]['src']] = [
591 'as' => 'image',
592 'media' => $media_query
593 ];
594 }
595
596 return $preloadLinks;
597 }
598
607 private function normalizeStyles( array &$styles ): void {
608 foreach ( $styles as $key => $val ) {
609 if ( !is_array( $val ) ) {
610 $styles[$key] = [ $val ];
611 }
612 }
613 }
614
621 private static function getRelativeSizedLogo( array $logoElement ) {
622 $width = $logoElement['width'];
623 $height = $logoElement['height'];
624 $widthRelative = $width / 16;
625 $heightRelative = $height / 16;
626 // Allow skins to scale the wordmark with browser font size (T207789)
627 $logoElement['style'] = 'width: ' . $widthRelative . 'em; height: ' . $heightRelative . 'em;';
628 return $logoElement;
629 }
630
646 public static function getAvailableLogos( Config $conf, ?string $lang = null ): array {
647 $logos = $conf->get( MainConfigNames::Logos );
648 if ( $logos === false ) {
649 // no logos were defined... this will either
650 // 1. Load from wgLogo
651 // 2. Trigger runtime exception if those are not defined.
652 $logos = [];
653 }
654 if ( $lang && isset( $logos['variants'][$lang] ) ) {
655 foreach ( $logos['variants'][$lang] as $type => $value ) {
656 $logos[$type] = $value;
657 }
658 }
659
660 // If logos['1x'] is not defined, see if we can use wgLogo
661 if ( !isset( $logos[ '1x' ] ) ) {
662 $logo = $conf->get( MainConfigNames::Logo );
663 if ( $logo ) {
664 $logos['1x'] = $logo;
665 }
666 }
667
668 if ( isset( $logos['wordmark'] ) ) {
669 // Allow skins to scale the wordmark with browser font size (T207789)
670 $logos['wordmark'] = self::getRelativeSizedLogo( $logos['wordmark'] );
671 }
672 if ( isset( $logos['tagline'] ) ) {
673 $logos['tagline'] = self::getRelativeSizedLogo( $logos['tagline'] );
674 }
675
676 return $logos;
677 }
678
688 protected function getLogoData( Config $conf, ?string $lang = null ) {
689 $logoHD = self::getAvailableLogos( $conf, $lang );
690 $logo = $logoHD['1x'];
691
692 $logo1Url = OutputPage::transformResourcePath( $conf, $logo );
693
694 $logoUrls = [
695 '1x' => $logo1Url,
696 ];
697
698 if ( isset( $logoHD['svg'] ) ) {
699 $logoUrls['svg'] = OutputPage::transformResourcePath(
700 $conf,
701 $logoHD['svg']
702 );
703 } elseif ( isset( $logoHD['1.5x'] ) || isset( $logoHD['2x'] ) ) {
704 // Only 1.5x and 2x are supported
705 if ( isset( $logoHD['1.5x'] ) ) {
706 $logoUrls['1.5x'] = OutputPage::transformResourcePath(
707 $conf,
708 $logoHD['1.5x']
709 );
710 }
711 if ( isset( $logoHD['2x'] ) ) {
712 $logoUrls['2x'] = OutputPage::transformResourcePath(
713 $conf,
714 $logoHD['2x']
715 );
716 }
717 } else {
718 // Return a string rather than a one-element array, getLogoPreloadlinks depends on this
719 return $logo1Url;
720 }
721
722 return $logoUrls;
723 }
724
729 public function isKnownEmpty( Context $context ) {
730 // Regardless of whether the files are specified, we always
731 // provide mw-wiki-logo styles.
732 return false;
733 }
734
741 protected function getLessVars( Context $context ) {
742 $lessVars = parent::getLessVars( $context );
743 $logos = self::getAvailableLogos( $this->getConfig(), $context->getLanguage() );
744
745 if ( isset( $logos['wordmark'] ) ) {
746 $logo = $logos['wordmark'];
747 $lessVars[ 'logo-enabled' ] = true;
748 $lessVars[ 'logo-wordmark-url' ] = CSSMin::buildUrlValue( $logo['src'] );
749 $lessVars[ 'logo-wordmark-width' ] = intval( $logo['width'] );
750 $lessVars[ 'logo-wordmark-height' ] = intval( $logo['height'] );
751 } else {
752 $lessVars[ 'logo-enabled' ] = false;
753 }
754 return $lessVars;
755 }
756
758 public function getDefinitionSummary( Context $context ) {
759 $summary = parent::getDefinitionSummary( $context );
760 $summary[] = [
761 'logos' => self::getAvailableLogos( $this->getConfig(), $context->getLanguage() ),
762 ];
763 return $summary;
764 }
765}
if(!defined('MW_SETUP_CALLBACK'))
Definition WebStart.php:68
A class containing constants representing the names of configuration variables.
const ResourceBasePath
Name constant for the ResourceBasePath 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:32
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:20
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.array
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:18
get( $name)
Get a configuration variable such as "Sitename" or "UploadMaintenance.".