MediaWiki master
SkinModule.php
Go to the documentation of this file.
1<?php
7
8use InvalidArgumentException;
14use Wikimedia\Minify\CSSMin;
15
22class SkinModule extends FileModule {
23
119 private const FEATURE_FILES = [
120 'accessibility' => [
121 'all' => [ 'resources/src/mediawiki.skinning/accessibility.less' ],
122 ],
123 'normalize' => [
124 'all' => [ 'resources/src/mediawiki.skinning/normalize.less' ],
125 ],
126 'logo' => [
127 // Applies the logo and ensures it downloads prior to printing.
128 'all' => [ 'resources/src/mediawiki.skinning/logo.less' ],
129 // Reserves whitespace for the logo in a pseudo element.
130 'print' => [ 'resources/src/mediawiki.skinning/logo-print.less' ],
131 ],
132 'content-media' => [
133 'all' => [
134 'resources/src/mediawiki.skinning/content.media-common.less',
135 ],
136 'screen' => [
137 'resources/src/mediawiki.skinning/content.media-screen.less',
138 ],
139 'print' => [
140 'resources/src/mediawiki.skinning/content.media-print.less',
141 ],
142 ],
143 'content-media-legacy' => [
144 'all' => [
145 'resources/src/mediawiki.skinning/content.thumbnails-common.less',
146 ],
147 'screen' => [
148 'resources/src/mediawiki.skinning/content.thumbnails-screen.less',
149 ],
150 'print' => [
151 'resources/src/mediawiki.skinning/content.thumbnails-print.less',
152 ],
153 ],
154 'content-media-dark' => [
155 'screen' => [ 'resources/src/mediawiki.skinning/content.media-dark.less' ],
156 ],
157 'content-links' => [
158 'screen' => [ 'resources/src/mediawiki.skinning/content.links.less' ]
159 ],
160 'content-links-external' => [
161 'screen' => [ 'resources/src/mediawiki.skinning/content.externallinks.less' ]
162 ],
163 'content-body' => [
164 'screen' => [ 'resources/src/mediawiki.skinning/content.body.less' ],
165 'print' => [ 'resources/src/mediawiki.skinning/content.body-print.less' ],
166 ],
167 'content-tables' => [
168 'screen' => [ 'resources/src/mediawiki.skinning/content.tables.less' ],
169 'print' => [ 'resources/src/mediawiki.skinning/content.tables-print.less' ]
170 ],
171 'interface-category' => [
172 'screen' => [ 'resources/src/mediawiki.skinning/interface.category.less' ],
173 'print' => [ 'resources/src/mediawiki.skinning/interface.category-print.less' ],
174 ],
175 'interface-core' => [
176 'screen' => [ 'resources/src/mediawiki.skinning/interface.less' ],
177 'print' => [ 'resources/src/mediawiki.skinning/interface-print.less' ],
178 ],
179 'interface-edit-section-links' => [
180 'screen' => [ 'resources/src/mediawiki.skinning/interface-edit-section-links.less' ],
181 ],
182 'interface-indicators' => [
183 'screen' => [ 'resources/src/mediawiki.skinning/interface-indicators.less' ],
184 ],
185 'interface-site-notice' => [
186 'screen' => [ 'resources/src/mediawiki.skinning/interface-site-notice.less' ],
187 ],
188 'interface-subtitle' => [
189 'screen' => [ 'resources/src/mediawiki.skinning/interface-subtitle.less' ],
190 ],
191 'interface-message-box' => [
192 'all' => [ 'resources/src/mediawiki.skinning/messageBoxes.less' ],
193 ],
194 'interface-user-message' => [
195 'screen' => [ 'resources/src/mediawiki.skinning/interface-user-message.less' ],
196 ],
197 'elements' => [
198 'screen' => [ 'resources/src/mediawiki.skinning/elements.less' ],
199 'print' => [ 'resources/src/mediawiki.skinning/elements-print.less' ],
200 ],
201 'i18n-ordered-lists' => [
202 'screen' => [ 'resources/src/mediawiki.skinning/i18n-ordered-lists.less' ],
203 ],
204 'i18n-all-lists-margins' => [
205 'screen' => [ 'resources/src/mediawiki.skinning/i18n-all-lists-margins.less' ],
206 ],
207 'i18n-headings' => [
208 'screen' => [ 'resources/src/mediawiki.skinning/i18n-headings.less' ],
209 ],
210 'toc' => [
211 'all' => [ 'resources/src/mediawiki.skinning/toc/common.less' ],
212 'screen' => [ 'resources/src/mediawiki.skinning/toc/screen.less' ],
213 'print' => [ 'resources/src/mediawiki.skinning/toc/print.less' ],
214 ],
215 ];
216
217 private const COMPAT_ALIASES = [
218 // MediaWiki 1.36
219 'content-parser-output' => 'content-body',
220 // MediaWiki 1.37
221 'content' => 'content-media',
222 'content-thumbnails' => 'content-media',
223 // MediaWiki 1.39
224 // The 'legacy' feature has been folded into other features that relevant skins
225 // are expected to have already enabled separately. It is now a no-op that can
226 // be safely removed from any skin.json files (T89981, T304325).
227 'legacy' => null,
228 ];
229
231 private $features;
232
238 private const DEFAULT_FEATURES_SPECIFIED = [
239 'accessibility' => true,
240 'content-body' => true,
241 'interface-core' => true,
242 'toc' => true
243 ];
244
251 private const DEFAULT_FEATURES_ABSENT = [
252 'logo',
253 ];
254
255 private const TOC_LESS_MESSAGES = [
256 // `toc` feature, used in screen.less
257 'hidetoc',
258 'showtoc',
259 ];
260
261 private const SECTION_LESS_MESSAGES = [
262 // `interface-edit-section-links` feature
263 'pipe-separator',
264 ];
265
286 public function __construct(
287 array $options = [],
288 $localBasePath = null,
289 $remoteBasePath = null
290 ) {
291 $features = $options['features'] ?? self::DEFAULT_FEATURES_ABSENT;
292 $listMode = array_keys( $features ) === range( 0, count( $features ) - 1 );
293
294 $messages = '';
295 // NOTE: Compatibility is only applied when features are provided
296 // in map-form. The list-form takes full control instead.
297 $features = $listMode ?
299 array_fill_keys( $features, true ),
300 false,
302 )
303 : self::applyFeaturesCompatibility( $features, true, $messages );
304
305 foreach ( $features as $key => $enabled ) {
306 if ( !isset( self::FEATURE_FILES[$key] ) ) {
307 throw new InvalidArgumentException( "Feature '$key' is not recognised" );
308 }
309 }
310
311 $this->features = $listMode
312 ? array_keys( array_filter( $features ) )
313 : array_keys( array_filter( $features + self::DEFAULT_FEATURES_SPECIFIED ) );
314
315 // Only the `toc` feature makes use of interface messages.
316 // For skins not using the `toc` feature, make sure LocalisationCache
317 // remains untouched (T270027).
318 if ( in_array( 'toc', $this->features ) ) {
319 $options['lessMessages'] = array_merge(
320 $options['lessMessages'] ?? [],
321 self::TOC_LESS_MESSAGES
322 );
323 }
324 if ( in_array( 'interface-edit-section-links', $this->features ) ) {
325 $options['lessMessages'] = array_merge(
326 $options['lessMessages'] ?? [],
327 self::SECTION_LESS_MESSAGES
328 );
329 }
330
331 if ( $messages !== '' ) {
332 $messages .= 'More information can be found at [[mw:Manual:ResourceLoaderSkinModule]]. ';
333 $options['deprecated'] = $messages;
334 }
335 parent::__construct( $options, $localBasePath, $remoteBasePath );
336 }
337
345 protected static function applyFeaturesCompatibility(
346 array $features, bool $addUnspecifiedFeatures = true, &$messages = ''
347 ): array {
348 if ( isset( $features[ 'i18n-all-lists-margins' ] ) ) {
349 // Emit warning only. Key is supported as-is.
350 // Replacement requires maintainer intervention as it has non-trivial side-effects.
351 $messages .= '[1.43] The use of the `i18n-all-lists-margins` feature with SkinModule'
352 . ' is deprecated as it is now provided by `elements`. Please remove and '
353 . ' add `elements`, drop support for RTL languages, or incorporate the '
354 . ' styles provided by this module into your skin.';
355 }
356 if ( isset( $features[ 'interface-message-box' ] ) && $features[ 'interface-message-box' ] ) {
357 // Emit warning only. Key is supported as-is (For now)
358 // Replacement requires maintainer loading a suitable Codex module instead.
359 // Note: When removing this deprecation notice and associated code, please
360 // make sure mediawiki.legacy.messageBox is not broken.
361 $messages .= '[1.43] The use of the `interface-message-box` feature with SkinModule'
362 . ' is deprecated in favor of CodexModule. Please remove this feature.';
363 }
364
365 foreach ( self::COMPAT_ALIASES as $from => $to ) {
366 if ( isset( $features[ $from ] ) && $to !== null ) {
367 if ( isset( $features[ $to ] ) ) {
368 $messages .= "SkinModule feature `$from` conflicts with `$to` and was ignored. ";
369 } else {
370 $features[ $to ] = $features[ $from ];
371 }
372 }
373 unset( $features[ $from ] );
374 }
375
376 // If `content-links` feature is set but no preference for `content-links-external` is set
377 if ( $addUnspecifiedFeatures
378 && isset( $features[ 'content-links' ] )
379 && !isset( $features[ 'content-links-external' ] )
380 ) {
381 // Assume the same true/false preference for both.
382 $features[ 'content-links-external' ] = $features[ 'content-links' ];
383 }
384
385 // The `content-links` feature was split out from `elements`.
386 // Make sure skins asking for `elements` also get these by default.
387 if ( $addUnspecifiedFeatures && isset( $features[ 'elements' ] ) && !isset( $features[ 'content-links' ] ) ) {
388 $features[ 'content-links' ] = $features[ 'elements' ];
389 }
390
391 // The interface module is a short hand for several modules. Enable them now.
392 if ( isset( $features[ 'interface' ] ) && $features[ 'interface' ] ) {
393 $features[ 'interface-core' ] = true;
394 $features[ 'interface-indicators' ] = true;
395 $features[ 'interface-subtitle' ] = true;
396 $features[ 'interface-user-message' ] = true;
397 $features[ 'interface-site-notice' ] = true;
398 $features[ 'interface-edit-section-links' ] = true;
399 }
400 unset( $features[ 'interface' ] );
401
402 return $features;
403 }
404
410 public function getFeatureFilePaths() {
411 // Bypass the current module paths so that these files are served from core,
412 // instead of the individual skin's module directory.
413 [ $defaultLocalBasePath, $defaultRemoteBasePath ] =
415 [],
416 null,
417 $this->getConfig()->get( MainConfigNames::ResourceBasePath )
418 );
419
420 $featureFilePaths = [];
421
422 foreach ( self::FEATURE_FILES as $feature => $featureFiles ) {
423 if ( in_array( $feature, $this->features ) ) {
424 foreach ( $featureFiles as $mediaType => $files ) {
425 foreach ( $files as $filepath ) {
426 $featureFilePaths[$mediaType][] = new FilePath(
427 $filepath,
428 $defaultLocalBasePath,
429 $defaultRemoteBasePath
430 );
431 }
432 }
433 }
434 }
435 return $featureFilePaths;
436 }
437
448 private function combineFeatureAndParentStyles( $featureStyles, $parentStyles, $request ) {
449 $combinedFeatureStyles = ResourceLoader::makeCombinedStyles( $featureStyles, $request );
450 $combinedParentStyles = ResourceLoader::makeCombinedStyles( $parentStyles, $request );
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['2x'] ) ) {
481 $featureStyles[
482 '(-webkit-min-device-pixel-ratio: 2), ' .
483 '(min-resolution: 2dppx), ' .
484 '(min-resolution: 192dpi)'
485 ][] = '.mw-wiki-logo { background-image: ' .
486 CSSMin::buildUrlValue( $logo['2x'] ) . ';' .
487 'background-size: 135px auto; }';
488 }
489 }
490 }
491 return $featureStyles;
492 }
493
498 public function getStyles( Context $context ) {
499 $parentStyles = parent::getStyles( $context );
500 $featureFilePaths = $this->getFeatureFilePaths();
501 $featureStyles = $this->readStyleFiles( $featureFilePaths, $context );
502
503 $this->normalizeStyles( $featureStyles );
504 $this->normalizeStyles( $parentStyles );
505
506 $isLogoFeatureEnabled = in_array( 'logo', $this->features );
507 if ( $isLogoFeatureEnabled ) {
508 $featureStyles = $this->generateAndAppendLogoStyles( $featureStyles, $context );
509 }
510 $isAccessibilityEnabled = in_array( 'accessibility', $this->features );
511
512 $config = $this->getConfig();
513 $limits = $config->get( 'ThumbLimits' );
514
515 // @todo: these may be converted to em units at later point in project (pending feedback)
516 // @todo: This may be moved to a dedicated module later on to group user customizations
517 // (for example the underline user preference currently residing in `content-links` feature.
518 if ( $isAccessibilityEnabled ) {
519 [ $smallSize, $defaultSize, $largeSize ] = DefaultPreferencesFactory::getNormalizedThumbSizes(
520 $config->get( MainConfigNames::ThumbLimits ),
521 $config->get( 'DefaultUserOptions' )
522 );
523 $featureStyles['all'][] = <<<CSS
524:root {
525 --image-size-small: {$smallSize}px;
526 --image-size-standard: {$defaultSize}px;
527 --image-size-large: {$largeSize}px;
528}
529CSS;
530 }
531
532 return $this->combineFeatureAndParentStyles( $featureStyles, $parentStyles, $context->getRequest() );
533 }
534
535 public function getPreloadLinks( Context $context ): array {
536 if ( !in_array( 'logo', $this->features ) ) {
537 return [];
538 }
539
540 $logo = $this->getLogoData( $this->getConfig(), $context->getLanguage() );
541
542 if ( !is_array( $logo ) ) {
543 // No media queries required if we only have one variant
544 return [ $logo => [ 'as' => 'image' ] ];
545 }
546
547 if ( isset( $logo['svg'] ) ) {
548 // No media queries required if we only have a 1x and svg variant
549 // because all preload-capable browsers support SVGs
550 return [ $logo['svg'] => [ 'as' => 'image' ] ];
551 }
552
553 $logos = [];
554 foreach ( $logo as $dppx => $src ) {
555 // Keys are in this format: "2x"
556 $logos[] = [ 'dppx' => (float)$dppx, 'src' => $src ];
557 }
558 // Sort from smallest to largest (e.g. 1x, 2x)
559 usort( $logos, static fn ( $a, $b ) => $a['dppx'] <=> $b['dppx'] );
560
561 $logosCount = count( $logos );
562 $preloadLinks = [];
563 // Logic must match SkinModule:
564 // - 1x applies to resolution < 2dppx
565 // - 2x applies to resolution >= 2dppx
566 // Note that min-resolution and max-resolution are both inclusive.
567 for ( $i = 0; $i < $logosCount; $i++ ) {
568 if ( $i === 0 ) {
569 // Smallest dppx
570 // min-resolution is ">=" (larger than or equal to)
571 // "not min-resolution" is essentially "<"
572 $media_query = 'not all and (min-resolution: ' . $logos[1]['dppx'] . 'dppx)';
573 } elseif ( $i !== $logosCount - 1 ) {
574 // In between
575 // Media query expressions can only apply "not" to the entire expression
576 // (e.g. can't express ">= 1.5 and not >= 2).
577 // Workaround: Use <= 1.9999 in place of < 2.
578 $upper_bound = floatval( $logos[$i + 1]['dppx'] ) - 0.000001;
579 $media_query = '(min-resolution: ' . $logos[$i]['dppx'] .
580 'dppx) and (max-resolution: ' . $upper_bound . 'dppx)';
581 } else {
582 // Largest dppx
583 $media_query = '(min-resolution: ' . $logos[$i]['dppx'] . 'dppx)';
584 }
585
586 $preloadLinks[$logos[$i]['src']] = [
587 'as' => 'image',
588 'media' => $media_query
589 ];
590 }
591
592 return $preloadLinks;
593 }
594
603 private function normalizeStyles( array &$styles ): void {
604 foreach ( $styles as $key => $val ) {
605 if ( !is_array( $val ) ) {
606 $styles[$key] = [ $val ];
607 }
608 }
609 }
610
617 private static function getRelativeSizedLogo( array $logoElement ) {
618 $width = $logoElement['width'];
619 $height = $logoElement['height'];
620 $widthRelative = $width / 16;
621 $heightRelative = $height / 16;
622 // Allow skins to scale the wordmark with browser font size (T207789)
623 $logoElement['style'] = 'width: ' . $widthRelative . 'em; height: ' . $heightRelative . 'em;';
624 return $logoElement;
625 }
626
642 public static function getAvailableLogos( Config $conf, ?string $lang = null ): array {
643 $logos = $conf->get( MainConfigNames::Logos );
644 if ( $logos === false ) {
645 // no logos were defined... this will either
646 // 1. Load from wgLogo
647 // 2. Trigger runtime exception if those are not defined.
648 $logos = [];
649 }
650 if ( $lang && isset( $logos['variants'][$lang] ) ) {
651 foreach ( $logos['variants'][$lang] as $type => $value ) {
652 $logos[$type] = $value;
653 }
654 }
655
656 // If logos['1x'] is not defined, see if we can use wgLogo
657 if ( !isset( $logos[ '1x' ] ) ) {
658 $logo = $conf->get( MainConfigNames::Logo );
659 if ( $logo ) {
660 $logos['1x'] = $logo;
661 }
662 }
663
664 if ( isset( $logos['wordmark'] ) ) {
665 // Allow skins to scale the wordmark with browser font size (T207789)
666 $logos['wordmark'] = self::getRelativeSizedLogo( $logos['wordmark'] );
667 }
668 if ( isset( $logos['tagline'] ) ) {
669 $logos['tagline'] = self::getRelativeSizedLogo( $logos['tagline'] );
670 }
671
672 return $logos;
673 }
674
684 protected function getLogoData( Config $conf, ?string $lang = null ) {
685 $logoHD = self::getAvailableLogos( $conf, $lang );
686 $logo = $logoHD['1x'];
687
688 $logo1Url = OutputPage::transformResourcePath( $conf, $logo );
689
690 $logoUrls = [
691 '1x' => $logo1Url,
692 ];
693
694 if ( isset( $logoHD['svg'] ) ) {
695 $logoUrls['svg'] = OutputPage::transformResourcePath(
696 $conf,
697 $logoHD['svg']
698 );
699 } elseif ( isset( $logoHD['2x'] ) ) {
700 $logoUrls['2x'] = OutputPage::transformResourcePath(
701 $conf,
702 $logoHD['2x']
703 );
704 } else {
705 // Return a string rather than a one-element array, getLogoPreloadlinks depends on this
706 return $logo1Url;
707 }
708
709 return $logoUrls;
710 }
711
716 public function isKnownEmpty( Context $context ) {
717 // Regardless of whether the files are specified, we always
718 // provide mw-wiki-logo styles.
719 return false;
720 }
721
728 protected function getLessVars( Context $context ) {
729 $lessVars = parent::getLessVars( $context );
730 $config = $this->getConfig();
731 $logos = self::getAvailableLogos( $config, $context->getLanguage() );
732 [ $smallSize, $defaultSize, $largeSize ] = DefaultPreferencesFactory::getNormalizedThumbSizes(
733 $config->get( MainConfigNames::ThumbLimits ),
734 $config->get( 'DefaultUserOptions' )
735 );
736
737 $lessVars[ 'image-size-standard' ] = $defaultSize;
738 if ( isset( $logos['wordmark'] ) ) {
739 $logo = $logos['wordmark'];
740 $lessVars[ 'logo-enabled' ] = true;
741 $lessVars[ 'logo-wordmark-url' ] = CSSMin::buildUrlValue( $logo['src'] );
742 $lessVars[ 'logo-wordmark-width' ] = intval( $logo['width'] );
743 $lessVars[ 'logo-wordmark-height' ] = intval( $logo['height'] );
744 } else {
745 $lessVars[ 'logo-enabled' ] = false;
746 }
747 return $lessVars;
748 }
749
751 public function getDefinitionSummary( Context $context ) {
752 $summary = parent::getDefinitionSummary( $context );
753 $config = $this->getConfig();
754 $summary[] = [
755 'thumblimits' => $config->get( 'ThumbLimits' ),
756 'logos' => self::getAvailableLogos( $config, $context->getLanguage() ),
757 ];
758 return $summary;
759 }
760}
if(!defined('MW_SETUP_CALLBACK'))
Definition WebStart.php:71
A class containing constants representing the names of configuration variables.
const ResourceBasePath
Name constant for the ResourceBasePath setting, for use with Config::get()
const ThumbLimits
Name constant for the ThumbLimits setting, for use with Config::get()
This is one of the Core classes and should be read at least once by any new developers.
This is the default implementation of PreferencesFactory.
The WebRequest class encapsulates getting at data passed in the URL or via a POSTed form,...
Context object that contains information about the state of a specific ResourceLoader web request.
Definition Context.php:35
Module based on local JavaScript/CSS files.
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
static makeCombinedStyles(array $stylePairs, WebRequest $request)
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.".