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