MediaWiki master
CodexModule.php
Go to the documentation of this file.
1<?php
22
24use InvalidArgumentException;
29
39class CodexModule extends FileModule {
40 protected const CODEX_MODULE_DIR = 'resources/lib/codex/modules/';
41 private const CODEX_MODULE_DEPENDENCIES = [ 'vue' ];
42
44 private static ?array $themeMap = null;
45
59 private static array $codexFilesCache = [];
60
67 private array $codexComponents = [];
68
69 private bool $isStyleOnly = false;
70 private bool $isScriptOnly = false;
71 private bool $codexFullLibrary = false;
72 private bool $setupComplete = false;
73
83 public function __construct( array $options = [], $localBasePath = null, $remoteBasePath = null ) {
84 if ( isset( $options[ 'codexComponents' ] ) ) {
85 if ( !is_array( $options[ 'codexComponents' ] ) || count( $options[ 'codexComponents' ] ) === 0 ) {
86 throw new InvalidArgumentException(
87 "All 'codexComponents' properties in your module definition file " .
88 'must either be omitted or be an array with at least one component name'
89 );
90 }
91
92 $this->codexComponents = $options[ 'codexComponents' ];
93 }
94
95 if ( isset( $options['codexFullLibrary'] ) ) {
96 if ( isset( $options[ 'codexComponents' ] ) ) {
97 throw new InvalidArgumentException(
98 'ResourceLoader modules using the CodexModule class cannot ' .
99 "use both 'codexFullLibrary' and 'codexComponents' options. " .
100 "Instead, use 'codexFullLibrary' to load the entire library" .
101 "or 'codexComponents' to load a subset of components."
102 );
103 }
104
105 $this->codexFullLibrary = $options[ 'codexFullLibrary' ];
106 }
107
108 if ( isset( $options[ 'codexStyleOnly' ] ) ) {
109 $this->isStyleOnly = $options[ 'codexStyleOnly' ];
110 }
111
112 if ( isset( $options[ 'codexScriptOnly' ] ) ) {
113 $this->isScriptOnly = $options[ 'codexScriptOnly' ];
114 }
115
116 // Normalize $options[ 'dependencies' ] to always make it an array. It could be unset, or
117 // it could be a string.
118 $options[ 'dependencies' ] = (array)( $options[ 'dependencies' ] ?? [] );
119
120 // Unlike when the entire @wikimedia/codex module is depended on, when the codexComponents
121 // option is used, Vue is not automatically included, though it is required. Add it and
122 // other dependencies here.
123 if ( !$this->isStyleOnly && count( $this->codexComponents ) > 0 ) {
124 $options[ 'dependencies' ] = array_unique( array_merge(
125 $options[ 'dependencies' ],
126 self::CODEX_MODULE_DEPENDENCIES
127 ) );
128 }
129
130 if ( in_array( '@wikimedia/codex', $options[ 'dependencies' ] ) ) {
131 throw new InvalidArgumentException(
132 'ResourceLoader modules using the CodexModule class cannot ' .
133 "list the '@wikimedia/codex' module as a dependency. " .
134 "Instead, use 'codexComponents' to require a subset of components."
135 );
136 }
137
138 parent::__construct( $options, $localBasePath, $remoteBasePath );
139 }
140
162 public static function getIcons( Context $context, Config $config, array $iconNames = [] ): array {
163 global $IP;
164 static $allIcons = null;
165 if ( $allIcons === null ) {
166 $allIcons = json_decode( file_get_contents( "$IP/resources/lib/codex-icons/codex-icons.json" ), true );
167 }
168 return array_intersect_key( $allIcons, array_flip( $iconNames ) );
169 }
170
171 // These 3 public methods are the entry points to this class; depending on the
172 // circumstances any one of these might be called first.
173
174 public function getPackageFiles( Context $context ) {
175 $this->setupCodex( $context );
176 return parent::getPackageFiles( $context );
177 }
178
179 public function getStyleFiles( Context $context ) {
180 $this->setupCodex( $context );
181 return parent::getStyleFiles( $context );
182 }
183
184 public function getDefinitionSummary( Context $context ) {
185 $this->setupCodex( $context );
186 return parent::getDefinitionSummary( $context );
187 }
188
189 public function getFlip( Context $context ) {
190 // Never flip styles for Codex modules, because we already provide separate style files
191 // for LTR vs RTL
192 return false;
193 }
194
195 public function supportsURLLoading() {
196 // We need to override this explicitly. The parent method might return true if there are
197 // no 'packageFiles' set in the module definition and they're all generated by us.
198 // It's possible that this "should" return true in some circumstances (e.g. style-only use
199 // of CodexModule combined with non-packageFiles scripts), but those are edge cases that
200 // we're choosing not to support here.
201 return false;
202 }
203
210 private function getTheme( Context $context ): string {
211 if ( self::$themeMap === null ) {
212 // Initialize self::$themeMap
213 $skinCodexThemes = ExtensionRegistry::getInstance()->getAttribute( 'SkinCodexThemes' );
214 self::$themeMap = [ 'default' => 'wikimedia-ui' ] + $skinCodexThemes;
215 }
216 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
217 return self::$themeMap[ $context->getSkin() ] ?? self::$themeMap[ 'default' ];
218 }
219
226 private function getManifestFilePath( Context $context ): string {
227 $themeManifestNames = [
228 'wikimedia-ui' => [
229 'ltr' => 'manifest.json',
230 'rtl' => 'manifest-rtl.json',
231 ],
232 'wikimedia-ui-legacy' => [
233 'ltr' => 'manifest-legacy.json',
234 'rtl' => 'manifest-legacy-rtl.json',
235 ],
236 'experimental' => [
237 'ltr' => 'manifest-experimental.json',
238 'rtl' => 'manifest-experimental-rtl.json',
239 ]
240 ];
241
242 $theme = $this->getTheme( $context );
243 $direction = $context->getDirection();
244 if ( !isset( $themeManifestNames[ $theme ] ) ) {
245 throw new InvalidArgumentException( "Unknown Codex theme $theme" );
246 }
247 $manifestFile = $themeManifestNames[ $theme ][ $direction ];
248 $manifestFilePath = MW_INSTALL_PATH . '/' . static::CODEX_MODULE_DIR . $manifestFile;
249 return $manifestFilePath;
250 }
251
279 private function getCodexFiles( Context $context ): array {
280 $manifestFilePath = $this->getManifestFilePath( $context );
281
282 if ( isset( self::$codexFilesCache[ $manifestFilePath ] ) ) {
283 return self::$codexFilesCache[ $manifestFilePath ];
284 }
285
286 $manifest = json_decode( file_get_contents( $manifestFilePath ), true );
287 $files = [];
288 $components = [];
289 foreach ( $manifest as $key => $val ) {
290 $files[ $val[ 'file' ] ] = [
291 'styles' => $val[ 'css' ] ?? [],
292 // $val['imports'] is expressed as manifest keys, transform those to file names
293 'dependencies' => array_map( static function ( $manifestKey ) use ( $manifest ) {
294 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
295 return $manifest[ $manifestKey ][ 'file' ];
296 }, $val[ 'imports' ] ?? [] )
297 ];
298
299 $isComponent = isset( $val[ 'isEntry' ] ) && $val[ 'isEntry' ];
300 if ( $isComponent ) {
301 $fileInfo = pathinfo( $val[ 'file' ] );
302 // $fileInfo[ 'filename' ] is the file name without the extension.
303 // This is the component name (e.g. CdxButton.cjs -> CdxButton).
304 $components[ $fileInfo[ 'filename' ] ] = $val[ 'file' ];
305 }
306 }
307
308 self::$codexFilesCache[ $manifestFilePath ] = [ 'files' => $files, 'components' => $components ];
309 return self::$codexFilesCache[ $manifestFilePath ];
310 }
311
333 private function setupCodex( Context $context ) {
334 if ( $this->setupComplete ) {
335 return;
336 }
337
338 // If we are tree-shaking, add component-specific JS/CSS files
339 if ( count( $this->codexComponents ) > 0 ) {
340 $this->addComponentFiles( $context );
341 }
342
343 // If we want to load the entire Codex library (no tree shaking)
344 if ( $this->codexFullLibrary ) {
345 $this->loadFullCodexLibrary( $context );
346
347 }
348
349 $this->setupComplete = true;
350 }
351
366 private function resolveDependencies( array $requestedFiles, array $codexFiles ) {
367 $scripts = [];
368 $styles = [];
369
370 $gatherDependencies = static function ( $file ) use ( &$scripts, &$styles, $codexFiles, &$gatherDependencies ) {
371 foreach ( $codexFiles[ 'files' ][ $file ][ 'dependencies' ] as $dep ) {
372 if ( !in_array( $dep, $scripts ) ) {
373 $gatherDependencies( $dep );
374 }
375 }
376 $scripts[] = $file;
377 $styles = array_merge( $styles, $codexFiles[ 'files' ][ $file ][ 'styles' ] );
378 };
379
380 foreach ( $requestedFiles as $requestedFile ) {
381 $gatherDependencies( $requestedFile );
382 }
383
384 return [ 'scripts' => $scripts, 'styles' => $styles ];
385 }
386
394 private function addComponentFiles( Context $context ) {
395 $remoteBasePath = $this->getConfig()->get( MainConfigNames::ResourceBasePath );
396 $codexFiles = $this->getCodexFiles( $context );
397
398 $requestedFiles = array_map( static function ( $component ) use ( $codexFiles ) {
399 if ( !isset( $codexFiles[ 'components' ][ $component ] ) ) {
400 throw new InvalidArgumentException(
401 "\"$component\" is not an export of Codex and cannot be included in the \"codexComponents\" array."
402 );
403 }
404 return $codexFiles[ 'components' ][ $component ];
405 }, $this->codexComponents );
406
407 [ 'scripts' => $scripts, 'styles' => $styles ] = $this->resolveDependencies( $requestedFiles, $codexFiles );
408
409 // Add the CSS files to the module's package file (unless this is a script-only module)
410 if ( !$this->isScriptOnly ) {
411 foreach ( $styles as $fileName ) {
412 $this->styles[] = new FilePath( static::CODEX_MODULE_DIR .
413 $fileName, MW_INSTALL_PATH, $remoteBasePath );
414 }
415 }
416
417 // Add the JS files to the module's package file (unless this is a style-only module).
418 if ( !$this->isStyleOnly ) {
419 $exports = [];
420 foreach ( $this->codexComponents as $component ) {
421 $componentFile = $codexFiles[ 'components' ][ $component ];
422 $exports[ $component ] = new HtmlJsCode(
423 'require( ' . Html::encodeJsVar( "./_codex/$componentFile" ) . ' )'
424 );
425 }
426
427 // Add a synthetic top-level "exports" file
428 $syntheticExports = Html::encodeJsVar( HtmlJsCode::encodeObject( $exports ) );
429
430 // Proxy the synthetic exports object so that we can throw a useful error if a component
431 // is not defined in the module definition
432 $proxiedSyntheticExports = <<<JAVASCRIPT
433 module.exports = new Proxy( $syntheticExports, {
434 get( target, prop ) {
435 if ( !( prop in target ) ) {
436 throw new Error(
437 `Codex component "\${prop}" ` +
438 'is not listed in the "codexComponents" array ' +
439 'of the "{$this->getName()}" module in your module definition file'
440 );
441 }
442 return target[ prop ];
443 }
444 } );
445 JAVASCRIPT;
446
447 $this->packageFiles[] = [
448 'name' => 'codex.js',
449 'content' => $proxiedSyntheticExports
450 ];
451
452 // Add each of the referenced scripts to the package
453 foreach ( $scripts as $fileName ) {
454 $this->packageFiles[] = [
455 'name' => "_codex/$fileName",
456 'file' => new FilePath( static::CODEX_MODULE_DIR . $fileName, MW_INSTALL_PATH, $remoteBasePath )
457 ];
458 }
459 }
460 }
461
467 private function loadFullCodexLibrary( Context $context ) {
468 $remoteBasePath = $this->getConfig()->get( MainConfigNames::ResourceBasePath );
469
470 // Add all Codex JS files to the module's package
471 if ( !$this->isStyleOnly ) {
472 $this->packageFiles[] = [
473 'name' => 'codex.js',
474 'file' => new FilePath( 'resources/lib/codex/codex.umd.cjs', MW_INSTALL_PATH, $remoteBasePath )
475 ];
476 }
477
478 // Add all Codex CSS files to the module's package
479 if ( !$this->isScriptOnly ) {
480 // Theme-specific + direction style files
481 $themeStyles = [
482 'wikimedia-ui' => [
483 'ltr' => 'resources/lib/codex/codex.style.css',
484 'rtl' => 'resources/lib/codex/codex.style-rtl.css'
485 ],
486 'wikimedia-ui-legacy' => [
487 'ltr' => 'resources/lib/codex/codex.style-legacy.css',
488 'rtl' => 'resources/lib/codex/codex.style-legacy-rtl.css'
489 ],
490 'experimental' => [
491 'ltr' => 'resources/lib/codex/codex.style-experimental.css',
492 'rtl' => 'resources/lib/codex/codex.style-experimental-rtl.css'
493 ]
494 ];
495
496 $theme = $this->getTheme( $context );
497 $direction = $context->getDirection();
498 $styleFile = $themeStyles[ $theme ][ $direction ];
499 $this->styles[] = new FilePath(
500 $styleFile,
501 MW_INSTALL_PATH,
502 $remoteBasePath
503 );
504 }
505 }
506}
if(!defined( 'MEDIAWIKI')) if(ini_get('mbstring.func_overload')) if(!defined( 'MW_ENTRY_POINT')) global $IP
Environment checks.
Definition Setup.php:98
if(!defined('MW_SETUP_CALLBACK'))
Definition WebStart.php:81
Load JSON files, and uses a Processor to extract information.
A wrapper class which causes Html::encodeJsVar() and Html::encodeJsCall() (as well as their Xml::* co...
This class is a collection of static functions that serve two purposes:
Definition Html.php:56
A class containing constants representing the names of configuration variables.
Module for codex that has direction-specific style files and a static helper function for embedding i...
getFlip(Context $context)
Get whether CSS for this module should be flipped.
getStyleFiles(Context $context)
Get a list of file paths for all styles in this module, in order of proper inclusion.
getPackageFiles(Context $context)
Resolve the package files definition and generate the content of each package file.
__construct(array $options=[], $localBasePath=null, $remoteBasePath=null)
static getIcons(Context $context, Config $config, array $iconNames=[])
Retrieve the specified icon definitions from codex-icons.json.
getDefinitionSummary(Context $context)
Get the definition summary for this module.
Context object that contains information about the state of a specific ResourceLoader web request.
Definition Context.php:45
Module based on local JavaScript/CSS files.
string $remoteBasePath
Remote base path, see __construct()
string $localBasePath
Local base path, see __construct()
Interface for configuration instances.
Definition Config.php:32