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_LIBRARY_DIR = 'resources/lib/codex/';
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 protected function processStyle( $style, $styleLang, $path, Context $context ) {
185 $pathAsString = $path instanceof FilePath ? $path->getPath() : $path;
186 if ( str_starts_with( $pathAsString, static::CODEX_LIBRARY_DIR ) ) {
187 // This is a Codex style file, don't do any processing.
188 // We need to avoid CSSJanus flipping in particular, because we're using RTL-specific
189 // files instead. Note that we're bypassing all of processStyle() when we really just
190 // care about bypassing flipping; that's fine for now, but could be a problem if
191 // processStyle() is ever expanded to do more than Less compilation, RTL flipping and
192 // image URL remapping.
193 return $style;
194 }
195
196 return parent::processStyle( $style, $styleLang, $path, $context );
197 }
198
199 public function getDefinitionSummary( Context $context ) {
200 $this->setupCodex( $context );
201 return parent::getDefinitionSummary( $context );
202 }
203
204 public function supportsURLLoading() {
205 // We need to override this explicitly. The parent method might return true if there are
206 // no 'packageFiles' set in the module definition and they're all generated by us.
207 // It's possible that this "should" return true in some circumstances (e.g. style-only use
208 // of CodexModule combined with non-packageFiles scripts), but those are edge cases that
209 // we're choosing not to support here.
210 return false;
211 }
212
219 private function getTheme( Context $context ): string {
220 if ( self::$themeMap === null ) {
221 // Initialize self::$themeMap
222 $skinCodexThemes = ExtensionRegistry::getInstance()->getAttribute( 'SkinCodexThemes' );
223 self::$themeMap = [ 'default' => 'wikimedia-ui' ] + $skinCodexThemes;
224 }
225 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
226 return self::$themeMap[ $context->getSkin() ] ?? self::$themeMap[ 'default' ];
227 }
228
235 private function getManifestFilePath( Context $context ): string {
236 $themeManifestNames = [
237 'wikimedia-ui' => [
238 'ltr' => 'manifest.json',
239 'rtl' => 'manifest-rtl.json',
240 ],
241 'wikimedia-ui-legacy' => [
242 'ltr' => 'manifest.json',
243 'rtl' => 'manifest-rtl.json',
244 ],
245 'experimental' => [
246 'ltr' => 'manifest.json',
247 'rtl' => 'manifest-rtl.json',
248 ]
249 ];
250
251 $theme = $this->getTheme( $context );
252 $direction = $context->getDirection();
253 if ( !isset( $themeManifestNames[ $theme ] ) ) {
254 throw new InvalidArgumentException( "Unknown Codex theme $theme" );
255 }
256 $manifestFile = $themeManifestNames[ $theme ][ $direction ];
257 $manifestFilePath = MW_INSTALL_PATH . '/' . static::CODEX_LIBRARY_DIR . 'modules/' . $manifestFile;
258 return $manifestFilePath;
259 }
260
288 private function getCodexFiles( Context $context ): array {
289 $manifestFilePath = $this->getManifestFilePath( $context );
290
291 if ( isset( self::$codexFilesCache[ $manifestFilePath ] ) ) {
292 return self::$codexFilesCache[ $manifestFilePath ];
293 }
294
295 $manifest = json_decode( file_get_contents( $manifestFilePath ), true );
296 $files = [];
297 $components = [];
298 foreach ( $manifest as $key => $val ) {
299 $files[ $val[ 'file' ] ] = [
300 'styles' => $val[ 'css' ] ?? [],
301 // $val['imports'] is expressed as manifest keys, transform those to file names
302 'dependencies' => array_map( static function ( $manifestKey ) use ( $manifest ) {
303 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
304 return $manifest[ $manifestKey ][ 'file' ];
305 }, $val[ 'imports' ] ?? [] )
306 ];
307
308 $isComponent = isset( $val[ 'isEntry' ] ) && $val[ 'isEntry' ];
309 if ( $isComponent ) {
310 $fileInfo = pathinfo( $val[ 'file' ] );
311 // $fileInfo[ 'filename' ] is the file name without the extension.
312 // This is the component name (e.g. CdxButton.cjs -> CdxButton).
313 $components[ $fileInfo[ 'filename' ] ] = $val[ 'file' ];
314 }
315 }
316
317 self::$codexFilesCache[ $manifestFilePath ] = [ 'files' => $files, 'components' => $components ];
318 return self::$codexFilesCache[ $manifestFilePath ];
319 }
320
342 private function setupCodex( Context $context ) {
343 if ( $this->setupComplete ) {
344 return;
345 }
346
347 // If we are tree-shaking, add component-specific JS/CSS files
348 if ( count( $this->codexComponents ) > 0 ) {
349 $this->addComponentFiles( $context );
350 }
351
352 // If we want to load the entire Codex library (no tree shaking)
353 if ( $this->codexFullLibrary ) {
354 $this->loadFullCodexLibrary( $context );
355
356 }
357
358 $this->setupComplete = true;
359 }
360
375 private function resolveDependencies( array $requestedFiles, array $codexFiles ) {
376 $scripts = [];
377 $styles = [];
378
379 $gatherDependencies = static function ( $file ) use ( &$scripts, &$styles, $codexFiles, &$gatherDependencies ) {
380 foreach ( $codexFiles[ 'files' ][ $file ][ 'dependencies' ] as $dep ) {
381 if ( !in_array( $dep, $scripts ) ) {
382 $gatherDependencies( $dep );
383 }
384 }
385 $scripts[] = $file;
386 $styles = array_merge( $styles, $codexFiles[ 'files' ][ $file ][ 'styles' ] );
387 };
388
389 foreach ( $requestedFiles as $requestedFile ) {
390 $gatherDependencies( $requestedFile );
391 }
392
393 return [ 'scripts' => $scripts, 'styles' => $styles ];
394 }
395
403 private function addComponentFiles( Context $context ) {
404 $remoteBasePath = $this->getConfig()->get( MainConfigNames::ResourceBasePath );
405 $codexFiles = $this->getCodexFiles( $context );
406
407 $requestedFiles = array_map( static function ( $component ) use ( $codexFiles ) {
408 if ( !isset( $codexFiles[ 'components' ][ $component ] ) ) {
409 throw new InvalidArgumentException(
410 "\"$component\" is not an export of Codex and cannot be included in the \"codexComponents\" array."
411 );
412 }
413 return $codexFiles[ 'components' ][ $component ];
414 }, $this->codexComponents );
415
416 [ 'scripts' => $scripts, 'styles' => $styles ] = $this->resolveDependencies( $requestedFiles, $codexFiles );
417
418 // Add the CSS files to the module's package file (unless this is a script-only module)
419 if ( !$this->isScriptOnly ) {
420 foreach ( $styles as $fileName ) {
421 $this->styles[] = new FilePath( static::CODEX_LIBRARY_DIR . 'modules/' .
422 $fileName, MW_INSTALL_PATH, $remoteBasePath );
423 }
424 }
425
426 // Add the JS files to the module's package file (unless this is a style-only module).
427 if ( !$this->isStyleOnly ) {
428 $exports = [];
429 foreach ( $this->codexComponents as $component ) {
430 $componentFile = $codexFiles[ 'components' ][ $component ];
431 $exports[ $component ] = new HtmlJsCode(
432 'require( ' . Html::encodeJsVar( "./_codex/$componentFile" ) . ' )'
433 );
434 }
435
436 // Add a synthetic top-level "exports" file
437 $syntheticExports = Html::encodeJsVar( HtmlJsCode::encodeObject( $exports ) );
438
439 // Proxy the synthetic exports object so that we can throw a useful error if a component
440 // is not defined in the module definition
441 $proxiedSyntheticExports = <<<JAVASCRIPT
442 module.exports = new Proxy( $syntheticExports, {
443 get( target, prop ) {
444 if ( !( prop in target ) ) {
445 throw new Error(
446 `Codex component "\${prop}" ` +
447 'is not listed in the "codexComponents" array ' +
448 'of the "{$this->getName()}" module in your module definition file'
449 );
450 }
451 return target[ prop ];
452 }
453 } );
454 JAVASCRIPT;
455
456 $this->packageFiles[] = [
457 'name' => 'codex.js',
458 'content' => $proxiedSyntheticExports
459 ];
460
461 // Add each of the referenced scripts to the package
462 foreach ( $scripts as $fileName ) {
463 $this->packageFiles[] = [
464 'name' => "_codex/$fileName",
465 'file' => new FilePath(
466 static::CODEX_LIBRARY_DIR . 'modules/' . $fileName,
467 MW_INSTALL_PATH, $remoteBasePath
468 )
469 ];
470 }
471 }
472 }
473
479 private function loadFullCodexLibrary( Context $context ) {
480 $remoteBasePath = $this->getConfig()->get( MainConfigNames::ResourceBasePath );
481
482 // Add all Codex JS files to the module's package
483 if ( !$this->isStyleOnly ) {
484 $this->packageFiles[] = [
485 'name' => 'codex.js',
486 'file' => new FilePath(
487 static::CODEX_LIBRARY_DIR . 'codex.umd.cjs',
488 MW_INSTALL_PATH, $remoteBasePath
489 )
490 ];
491 }
492
493 // Add all Codex CSS files to the module's package
494 if ( !$this->isScriptOnly ) {
495 // Theme-specific + direction style files
496 $themeStyles = [
497 'wikimedia-ui' => [
498 'ltr' => 'codex.style.css',
499 'rtl' => 'codex.style-rtl.css'
500 ],
501 'wikimedia-ui-legacy' => [
502 'ltr' => 'codex.style.css',
503 'rtl' => 'codex.style-rtl.css'
504 ],
505 'experimental' => [
506 'ltr' => 'codex.style.css',
507 'rtl' => 'codex.style-rtl.css'
508 ]
509 ];
510
511 $theme = $this->getTheme( $context );
512 $direction = $context->getDirection();
513 $styleFile = $themeStyles[ $theme ][ $direction ];
514 $this->styles[] = new FilePath(
515 static::CODEX_LIBRARY_DIR . $styleFile,
516 MW_INSTALL_PATH,
517 $remoteBasePath
518 );
519 }
520 }
521}
if(!defined( 'MEDIAWIKI')) if(ini_get('mbstring.func_overload')) if(!defined( 'MW_ENTRY_POINT')) global $IP
Environment checks.
Definition Setup.php:99
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...
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.
processStyle( $style, $styleLang, $path, Context $context)
Process a CSS/LESS string.
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()
A path to a bundled file (such as JavaScript or CSS), along with a remote and local base path.
Definition FilePath.php:34
Interface for configuration instances.
Definition Config.php:32