Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
90.21% |
212 / 235 |
|
57.14% |
12 / 21 |
CRAP | |
0.00% |
0 / 1 |
| CodexModule | |
90.21% |
212 / 235 |
|
57.14% |
12 / 21 |
71.21 | |
0.00% |
0 / 1 |
| __construct | |
81.82% |
27 / 33 |
|
0.00% |
0 / 1 |
11.73 | |||
| getIcons | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
4.02 | |||
| getIconFilePath | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| getMessages | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
4 | |||
| getPackageFiles | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| getStyleFiles | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| processStyle | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
12 | |||
| getDefinitionSummary | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| supportsURLLoading | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getTheme | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| makeFilePath | |
78.57% |
22 / 28 |
|
0.00% |
0 / 1 |
5.25 | |||
| getCodexDirectory | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| isDevelopmentMode | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getDevelopmentWarning | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
| getManifestFilePath | |
95.24% |
20 / 21 |
|
0.00% |
0 / 1 |
2 | |||
| getCodexFiles | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
5 | |||
| setupCodex | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
4 | |||
| resolveDependencies | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
4 | |||
| addComponentFiles | |
100.00% |
35 / 35 |
|
100.00% |
1 / 1 |
7 | |||
| loadFullCodexLibrary | |
100.00% |
36 / 36 |
|
100.00% |
1 / 1 |
4 | |||
| getType | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * @license GPL-2.0-or-later |
| 4 | * @file |
| 5 | */ |
| 6 | |
| 7 | namespace MediaWiki\ResourceLoader; |
| 8 | |
| 9 | use InvalidArgumentException; |
| 10 | use MediaWiki\Config\Config; |
| 11 | use MediaWiki\Html\Html; |
| 12 | use MediaWiki\Html\HtmlJsCode; |
| 13 | use MediaWiki\MainConfigNames; |
| 14 | use MediaWiki\Registration\ExtensionRegistry; |
| 15 | use RuntimeException; |
| 16 | |
| 17 | /** |
| 18 | * Module for codex that has direction-specific style files and a static helper |
| 19 | * function for embedding icons in package modules. This module also contains |
| 20 | * logic to support code-splitting (aka tree-shaking) of the Codex library to |
| 21 | * return only a subset of component JS and/or CSS files. |
| 22 | * |
| 23 | * @ingroup ResourceLoader |
| 24 | * @internal |
| 25 | */ |
| 26 | class CodexModule extends FileModule { |
| 27 | protected const CODEX_DEFAULT_LIBRARY_DIR = 'resources/lib/codex'; |
| 28 | private const CODEX_MODULE_DEPENDENCIES = [ 'vue' ]; |
| 29 | |
| 30 | /** @var array<string,string>|null */ |
| 31 | private static ?array $themeMap = null; |
| 32 | |
| 33 | /** |
| 34 | * Cache for getCodexFiles(). Maps manifest file paths to outputs. For more information about |
| 35 | * the structure of the outputs, see the documentation comment for getCodexFiles(). |
| 36 | * |
| 37 | * This array looks like: |
| 38 | * [ |
| 39 | * '/path/to/manifest.json' => [ 'files' => [ ... ], 'components' => [ ... ] ], |
| 40 | * '/path/to/manifest-rtl.json' => [ 'files' => [ ... ], 'components' => [ ... ] ], |
| 41 | * ... |
| 42 | * ] |
| 43 | * |
| 44 | * @var array<string,array{files:array<string,array{styles:string[],dependencies:string[]}>,components:array<string,string>}> |
| 45 | */ |
| 46 | private static array $codexFilesCache = []; |
| 47 | |
| 48 | /** |
| 49 | * Names of the requested components. Comes directly from the 'codexComponents' property in the |
| 50 | * module definition. |
| 51 | * |
| 52 | * @var string[] |
| 53 | */ |
| 54 | private array $codexComponents = []; |
| 55 | |
| 56 | private bool $isStyleOnly = false; |
| 57 | private bool $isScriptOnly = false; |
| 58 | private bool $codexFullLibrary = false; |
| 59 | private bool $setupComplete = false; |
| 60 | |
| 61 | /** |
| 62 | * @param array $options [optional] |
| 63 | * - codexComponents: array of Codex components to include |
| 64 | * - codexFullLibrary: whether to load the entire Codex library |
| 65 | * - codexStyleOnly: whether to include only style files |
| 66 | * - codexScriptOnly: whether to include only script files |
| 67 | * @param string|null $localBasePath [optional] |
| 68 | * @param string|null $remoteBasePath [optional] |
| 69 | */ |
| 70 | public function __construct( array $options = [], $localBasePath = null, $remoteBasePath = null ) { |
| 71 | if ( isset( $options[ 'codexComponents' ] ) ) { |
| 72 | if ( !is_array( $options[ 'codexComponents' ] ) || count( $options[ 'codexComponents' ] ) === 0 ) { |
| 73 | throw new InvalidArgumentException( |
| 74 | "All 'codexComponents' properties in your module definition file " . |
| 75 | 'must either be omitted or be an array with at least one component name' |
| 76 | ); |
| 77 | } |
| 78 | |
| 79 | $this->codexComponents = $options[ 'codexComponents' ]; |
| 80 | } |
| 81 | |
| 82 | if ( isset( $options['codexFullLibrary'] ) ) { |
| 83 | if ( isset( $options[ 'codexComponents' ] ) ) { |
| 84 | throw new InvalidArgumentException( |
| 85 | 'ResourceLoader modules using the CodexModule class cannot ' . |
| 86 | "use both 'codexFullLibrary' and 'codexComponents' options. " . |
| 87 | "Instead, use 'codexFullLibrary' to load the entire library" . |
| 88 | "or 'codexComponents' to load a subset of components." |
| 89 | ); |
| 90 | } |
| 91 | |
| 92 | $this->codexFullLibrary = $options[ 'codexFullLibrary' ]; |
| 93 | } |
| 94 | |
| 95 | if ( isset( $options[ 'codexStyleOnly' ] ) ) { |
| 96 | $this->isStyleOnly = $options[ 'codexStyleOnly' ]; |
| 97 | } |
| 98 | |
| 99 | if ( isset( $options[ 'codexScriptOnly' ] ) ) { |
| 100 | $this->isScriptOnly = $options[ 'codexScriptOnly' ]; |
| 101 | } |
| 102 | |
| 103 | // Normalize $options[ 'dependencies' ] to always make it an array. It could be unset, or |
| 104 | // it could be a string. |
| 105 | $options[ 'dependencies' ] = (array)( $options[ 'dependencies' ] ?? [] ); |
| 106 | |
| 107 | // Unlike when the entire @wikimedia/codex module is depended on, when the codexComponents |
| 108 | // option is used, Vue is not automatically included, though it is required. Add it and |
| 109 | // other dependencies here. |
| 110 | if ( !$this->isStyleOnly && count( $this->codexComponents ) > 0 ) { |
| 111 | $options[ 'dependencies' ] = array_unique( array_merge( |
| 112 | $options[ 'dependencies' ], |
| 113 | self::CODEX_MODULE_DEPENDENCIES |
| 114 | ) ); |
| 115 | } |
| 116 | |
| 117 | if ( in_array( '@wikimedia/codex', $options[ 'dependencies' ] ) ) { |
| 118 | throw new InvalidArgumentException( |
| 119 | 'ResourceLoader modules using the CodexModule class cannot ' . |
| 120 | "list the '@wikimedia/codex' module as a dependency. " . |
| 121 | "Instead, use 'codexComponents' to require a subset of components." |
| 122 | ); |
| 123 | } |
| 124 | |
| 125 | parent::__construct( $options, $localBasePath, $remoteBasePath ); |
| 126 | } |
| 127 | |
| 128 | /** |
| 129 | * Retrieve the specified icon definitions from codex-icons.json. Intended as a convenience |
| 130 | * function to be used in packageFiles definitions. |
| 131 | * |
| 132 | * Example: |
| 133 | * "packageFiles": [ |
| 134 | * { |
| 135 | * "name": "icons.json", |
| 136 | * "callback": "MediaWiki\\ResourceLoader\\CodexModule::getIcons", |
| 137 | * "callbackParam": [ |
| 138 | * "cdxIconClear", |
| 139 | * "cdxIconTrash" |
| 140 | * ] |
| 141 | * } |
| 142 | * ] |
| 143 | * |
| 144 | * @param Context|null $context |
| 145 | * @param Config $config |
| 146 | * @param string[]|null $iconNames Names of icons to fetch, or null to fetch all icons |
| 147 | * @return array |
| 148 | */ |
| 149 | public static function getIcons( ?Context $context, Config $config, ?array $iconNames = null ): array { |
| 150 | static $cachedIcons = null; |
| 151 | static $cachedIconFilePath = null; |
| 152 | |
| 153 | $iconFile = self::getIconFilePath( $config ); |
| 154 | if ( $cachedIcons === null || $cachedIconFilePath !== $iconFile ) { |
| 155 | $cachedIcons = json_decode( file_get_contents( $iconFile ), true ); |
| 156 | $cachedIconFilePath = $iconFile; |
| 157 | } |
| 158 | |
| 159 | if ( $iconNames === null ) { |
| 160 | return $cachedIcons; |
| 161 | } |
| 162 | return array_intersect_key( $cachedIcons, array_flip( $iconNames ) ); |
| 163 | } |
| 164 | |
| 165 | private static function getIconFilePath( Config $config ): string { |
| 166 | $devDir = $config->get( MainConfigNames::CodexDevelopmentDir ); |
| 167 | $iconsDir = $devDir !== null ? |
| 168 | "$devDir/packages/codex-icons/dist" : |
| 169 | MW_INSTALL_PATH . '/resources/lib/codex-icons'; |
| 170 | return "$iconsDir/codex-icons.json"; |
| 171 | } |
| 172 | |
| 173 | /** @inheritDoc */ |
| 174 | public function getMessages() { |
| 175 | $messages = parent::getMessages(); |
| 176 | |
| 177 | // Add messages used inside Codex Vue components. The message keys are defined in the |
| 178 | // "messageKeys.json" file from the Codex package |
| 179 | if ( $this->codexFullLibrary || ( !$this->isStyleOnly && count( $this->codexComponents ) > 0 ) ) { |
| 180 | $messageKeyFilePath = $this->makeFilePath( 'messageKeys.json' )->getLocalPath(); |
| 181 | $messageKeys = json_decode( file_get_contents( $messageKeyFilePath ), true ); |
| 182 | $messages = array_merge( $messages, $messageKeys ); |
| 183 | } |
| 184 | |
| 185 | return $messages; |
| 186 | } |
| 187 | |
| 188 | // These 3 public methods are the entry points to this class; depending on the |
| 189 | // circumstances any one of these might be called first. |
| 190 | |
| 191 | /** @inheritDoc */ |
| 192 | public function getPackageFiles( Context $context ) { |
| 193 | $this->setupCodex( $context ); |
| 194 | return parent::getPackageFiles( $context ); |
| 195 | } |
| 196 | |
| 197 | /** @inheritDoc */ |
| 198 | public function getStyleFiles( Context $context ) { |
| 199 | $this->setupCodex( $context ); |
| 200 | return parent::getStyleFiles( $context ); |
| 201 | } |
| 202 | |
| 203 | /** @inheritDoc */ |
| 204 | protected function processStyle( $style, $styleLang, $path, Context $context ) { |
| 205 | $pathAsString = $path instanceof FilePath ? $path->getLocalPath() : $path; |
| 206 | if ( str_starts_with( $pathAsString, $this->getCodexDirectory() ) ) { |
| 207 | // This is a Codex style file, don't do any processing. |
| 208 | // We need to avoid CSSJanus flipping in particular, because we're using RTL-specific |
| 209 | // files instead. Note that we're bypassing all of processStyle() when we really just |
| 210 | // care about bypassing flipping; that's fine for now, but could be a problem if |
| 211 | // processStyle() is ever expanded to do more than Less compilation, RTL flipping and |
| 212 | // image URL remapping. |
| 213 | return $style; |
| 214 | } |
| 215 | |
| 216 | return parent::processStyle( $style, $styleLang, $path, $context ); |
| 217 | } |
| 218 | |
| 219 | /** @inheritDoc */ |
| 220 | public function getDefinitionSummary( Context $context ) { |
| 221 | $this->setupCodex( $context ); |
| 222 | return parent::getDefinitionSummary( $context ); |
| 223 | } |
| 224 | |
| 225 | /** @inheritDoc */ |
| 226 | public function supportsURLLoading() { |
| 227 | // We need to override this explicitly. The parent method might return true if there are |
| 228 | // no 'packageFiles' set in the module definition and they're all generated by us. |
| 229 | // It's possible that this "should" return true in some circumstances (e.g. style-only use |
| 230 | // of CodexModule combined with non-packageFiles scripts), but those are edge cases that |
| 231 | // we're choosing not to support here. |
| 232 | return false; |
| 233 | } |
| 234 | |
| 235 | /** |
| 236 | * Get the theme to use based on the current skin. |
| 237 | * |
| 238 | * @param Context $context |
| 239 | * @return string Name of the current theme |
| 240 | */ |
| 241 | private function getTheme( Context $context ): string { |
| 242 | if ( self::$themeMap === null ) { |
| 243 | // Initialize self::$themeMap |
| 244 | $skinCodexThemes = ExtensionRegistry::getInstance()->getAttribute( 'SkinCodexThemes' ); |
| 245 | self::$themeMap = [ 'default' => 'wikimedia-ui' ] + $skinCodexThemes; |
| 246 | } |
| 247 | return self::$themeMap[ $context->getSkin() ] ?? self::$themeMap[ 'default' ]; |
| 248 | } |
| 249 | |
| 250 | /** |
| 251 | * Build a FilePath object representing the path to a Codex file. |
| 252 | * |
| 253 | * If $wgCodexDevelopmentDir is set, the returned FilePath object points to |
| 254 | * $wgCodexDevelopmentDir/$file. Otherwise, it points to resources/lib/codex/$file. |
| 255 | * |
| 256 | * @param string $file File name |
| 257 | */ |
| 258 | private function makeFilePath( string $file ): FilePath { |
| 259 | $remoteBasePath = $this->getConfig()->get( MainConfigNames::ResourceBasePath ); |
| 260 | $devDir = $this->getConfig()->get( MainConfigNames::CodexDevelopmentDir ); |
| 261 | if ( $devDir === null ) { |
| 262 | $filePath = new FilePath( |
| 263 | static::CODEX_DEFAULT_LIBRARY_DIR . '/' . $file, |
| 264 | MW_INSTALL_PATH, |
| 265 | $remoteBasePath |
| 266 | ); |
| 267 | if ( !file_exists( $filePath->getLocalPath() ) ) { |
| 268 | throw new RuntimeException( "Could not find Codex file `{$filePath->getLocalPath()}`" ); |
| 269 | } |
| 270 | return $filePath; |
| 271 | } |
| 272 | |
| 273 | $modulesDir = $devDir . '/packages/codex/dist/modules'; |
| 274 | if ( !file_exists( $modulesDir ) ) { |
| 275 | throw new RuntimeException( |
| 276 | "Could not find Codex development build, `$modulesDir` does not exist. " . |
| 277 | "You may need to run `npm run build-all` in the `$devDir` directory, " . |
| 278 | "or disable Codex development mode by setting \$wgCodexDevelopmentDir = null;" |
| 279 | ); |
| 280 | } |
| 281 | $path = $devDir . '/packages/codex/dist/' . $file; |
| 282 | if ( !file_exists( $path ) ) { |
| 283 | throw new RuntimeException( |
| 284 | "Could not find Codex file `$path`. " . |
| 285 | "You may need to run `npm run build-all` in the `$devDir` directory, " . |
| 286 | "or you may be using a version of Codex that is too old for this version of MediaWiki." |
| 287 | ); |
| 288 | } |
| 289 | |
| 290 | // Return a modified FilePath object that bypasses LocalBasePath |
| 291 | // HACK: We do have to set LocalBasePath to a non-null value, otherwise |
| 292 | // FileModule::getLocalPath() will still prepend its own base path |
| 293 | return new class ( $path, '' ) extends FilePath { |
| 294 | public function getLocalPath(): string { |
| 295 | return $this->getPath(); |
| 296 | } |
| 297 | }; |
| 298 | } |
| 299 | |
| 300 | /** |
| 301 | * Get the path to the directory that contains the Codex files. |
| 302 | * |
| 303 | * In production mode this is resources/lib/codex, but in development mode it can be |
| 304 | * somewhere else. |
| 305 | * |
| 306 | * @return string |
| 307 | */ |
| 308 | private function getCodexDirectory() { |
| 309 | // Reuse the logic in makeFilePath by passing in an empty path |
| 310 | return $this->makeFilePath( '' )->getLocalPath(); |
| 311 | } |
| 312 | |
| 313 | private function isDevelopmentMode(): bool { |
| 314 | return $this->getConfig()->get( MainConfigNames::CodexDevelopmentDir ) !== null; |
| 315 | } |
| 316 | |
| 317 | private function getDevelopmentWarning(): string { |
| 318 | return $this->isDevelopmentMode() ? |
| 319 | Html::encodeJsCall( 'mw.log.warn', [ |
| 320 | "You are using a local development version of Codex, which may not match the latest version. " . |
| 321 | "To disable this, set \$wgCodexDevelopmentDir = null;" |
| 322 | ] ) : |
| 323 | ''; |
| 324 | } |
| 325 | |
| 326 | /** |
| 327 | * Decide which manifest file to use, based on the theme and the direction (LTR or RTL). |
| 328 | * |
| 329 | * @param Context $context |
| 330 | * @return string Name of the manifest file to use |
| 331 | */ |
| 332 | private function getManifestFilePath( Context $context ): string { |
| 333 | $themeManifestNames = [ |
| 334 | 'wikimedia-ui' => [ |
| 335 | 'ltr' => 'manifest.json', |
| 336 | 'rtl' => 'manifest-rtl.json', |
| 337 | ], |
| 338 | 'wikimedia-ui-legacy' => [ |
| 339 | 'ltr' => 'manifest.json', |
| 340 | 'rtl' => 'manifest-rtl.json', |
| 341 | ], |
| 342 | 'experimental' => [ |
| 343 | 'ltr' => 'manifest.json', |
| 344 | 'rtl' => 'manifest-rtl.json', |
| 345 | ] |
| 346 | ]; |
| 347 | |
| 348 | $theme = $this->getTheme( $context ); |
| 349 | $direction = $context->getDirection(); |
| 350 | if ( !isset( $themeManifestNames[ $theme ] ) ) { |
| 351 | throw new InvalidArgumentException( "Unknown Codex theme $theme" ); |
| 352 | } |
| 353 | $manifestFile = $themeManifestNames[ $theme ][ $direction ]; |
| 354 | $manifestFilePath = $this->makeFilePath( "modules/$manifestFile" ); |
| 355 | return $manifestFilePath->getLocalPath(); |
| 356 | } |
| 357 | |
| 358 | /** |
| 359 | * Get information about all available Codex files. |
| 360 | * |
| 361 | * This transforms the Codex manifest to a more useful format, and caches it so that different |
| 362 | * instances of this class don't each parse the manifest separately. |
| 363 | * |
| 364 | * The returned data structure contains a 'files' key with dependency information about every |
| 365 | * file (both entry point and non-entry point files), and a 'component' key that is an array |
| 366 | * mapping component names to file names. The full data structure looks like this: |
| 367 | * |
| 368 | * [ |
| 369 | * 'files' => [ |
| 370 | * 'CdxButtonGroup.js' => [ |
| 371 | * 'styles' => [ 'CdxButtonGroup.css' ], |
| 372 | * 'dependencies' => [ 'CdxButton.js', 'buttonHelpers.js' ] |
| 373 | * ], |
| 374 | * // all files are listed here, both entry point and non-entry point files |
| 375 | * ], |
| 376 | * 'components' => [ |
| 377 | * 'CdxButtonGroup' => 'CdxButtonGroup.js', |
| 378 | * // only entry point files are listed here |
| 379 | * ] |
| 380 | * ] |
| 381 | * |
| 382 | * @param Context $context |
| 383 | * @return array{files:array<string,array{styles:string[],dependencies:string[]}>,components:array<string,string>} |
| 384 | */ |
| 385 | private function getCodexFiles( Context $context ): array { |
| 386 | $manifestFilePath = $this->getManifestFilePath( $context ); |
| 387 | |
| 388 | if ( isset( self::$codexFilesCache[ $manifestFilePath ] ) ) { |
| 389 | return self::$codexFilesCache[ $manifestFilePath ]; |
| 390 | } |
| 391 | |
| 392 | $manifest = json_decode( file_get_contents( $manifestFilePath ), true ); |
| 393 | $files = []; |
| 394 | $components = []; |
| 395 | foreach ( $manifest as $key => $val ) { |
| 396 | $files[ $val[ 'file' ] ] = [ |
| 397 | 'styles' => $val[ 'css' ] ?? [], |
| 398 | // $val['imports'] is expressed as manifest keys, transform those to file names |
| 399 | 'dependencies' => array_map( static function ( $manifestKey ) use ( $manifest ) { |
| 400 | // @phan-suppress-next-line PhanTypeArraySuspiciousNullable |
| 401 | return $manifest[ $manifestKey ][ 'file' ]; |
| 402 | }, $val[ 'imports' ] ?? [] ) |
| 403 | ]; |
| 404 | |
| 405 | $isComponent = isset( $val[ 'isEntry' ] ) && $val[ 'isEntry' ]; |
| 406 | if ( $isComponent ) { |
| 407 | $fileInfo = pathinfo( $val[ 'file' ] ); |
| 408 | // $fileInfo[ 'filename' ] is the file name without the extension. |
| 409 | // This is the component name (e.g. CdxButton.cjs -> CdxButton). |
| 410 | $components[ $fileInfo[ 'filename' ] ] = $val[ 'file' ]; |
| 411 | } |
| 412 | } |
| 413 | |
| 414 | self::$codexFilesCache[ $manifestFilePath ] = [ 'files' => $files, 'components' => $components ]; |
| 415 | return self::$codexFilesCache[ $manifestFilePath ]; |
| 416 | } |
| 417 | |
| 418 | /** |
| 419 | * There are several different use-cases for CodexModule. We may be dealing |
| 420 | * with: |
| 421 | * |
| 422 | * - A CSS-only or JS & CSS module for the entire component library |
| 423 | * - An otherwise standard module that needs one or more Codex icons |
| 424 | * - A CSS-only or CSS-and-JS module that has opted-in to Codex's |
| 425 | * tree-shaking feature by specifying the "codexComponents" option |
| 426 | * |
| 427 | * Regardless of the kind of CodexModule we are dealing with, some kind of |
| 428 | * one-time setup operation may need to be performed. |
| 429 | * |
| 430 | * In the case of a full-library module, we need to ensure that the correct |
| 431 | * theme- and direction-specific CSS file is used. |
| 432 | * |
| 433 | * In the case of a tree-shaking module, we need to ensure that the CSS |
| 434 | * and/or JS files for the specified components (as well as all |
| 435 | * dependencies) are added to the module's packageFiles. |
| 436 | * |
| 437 | * @param Context $context |
| 438 | */ |
| 439 | private function setupCodex( Context $context ) { |
| 440 | if ( $this->setupComplete ) { |
| 441 | return; |
| 442 | } |
| 443 | |
| 444 | // If we are tree-shaking, add component-specific JS/CSS files |
| 445 | if ( count( $this->codexComponents ) > 0 ) { |
| 446 | $this->addComponentFiles( $context ); |
| 447 | } |
| 448 | |
| 449 | // If we want to load the entire Codex library (no tree shaking) |
| 450 | if ( $this->codexFullLibrary ) { |
| 451 | $this->loadFullCodexLibrary( $context ); |
| 452 | |
| 453 | } |
| 454 | |
| 455 | $this->setupComplete = true; |
| 456 | } |
| 457 | |
| 458 | /** |
| 459 | * Resolve the dependencies for a list of files, return flat arrays of both scripts and styles. |
| 460 | * The returned arrays include the files in $requestedFiles, plus their dependencies, their |
| 461 | * dependencies' dependencies, etc. The returned arrays are ordered in the order that the files |
| 462 | * should be loaded in; in other words, the files are ordered such that each file appears after |
| 463 | * all of its dependencies. |
| 464 | * |
| 465 | * @param string[] $requestedFiles File names whose dependencies to resolve |
| 466 | * @phpcs:ignore Generic.Files.LineLength, MediaWiki.Commenting.FunctionComment.MissingParamName |
| 467 | * @param array{files:array<string,array{styles:string[],dependencies:string[]}>,components:array<string,string>} $codexFiles |
| 468 | * Data structure returned by getCodexFiles() |
| 469 | * @return array{scripts:string[],styles:string[]} Resolved dependencies, with script and style |
| 470 | * files listed separately. |
| 471 | */ |
| 472 | private function resolveDependencies( array $requestedFiles, array $codexFiles ) { |
| 473 | $scripts = []; |
| 474 | $styles = []; |
| 475 | |
| 476 | $gatherDependencies = static function ( $file ) use ( &$scripts, &$styles, $codexFiles, &$gatherDependencies ) { |
| 477 | foreach ( $codexFiles[ 'files' ][ $file ][ 'dependencies' ] as $dep ) { |
| 478 | if ( !in_array( $dep, $scripts ) ) { |
| 479 | $gatherDependencies( $dep ); |
| 480 | } |
| 481 | } |
| 482 | $scripts[] = $file; |
| 483 | $styles = array_merge( $styles, $codexFiles[ 'files' ][ $file ][ 'styles' ] ); |
| 484 | }; |
| 485 | |
| 486 | foreach ( $requestedFiles as $requestedFile ) { |
| 487 | $gatherDependencies( $requestedFile ); |
| 488 | } |
| 489 | |
| 490 | return [ 'scripts' => $scripts, 'styles' => $styles ]; |
| 491 | } |
| 492 | |
| 493 | /** |
| 494 | * For Codex modules that rely on tree-shaking, this method determines |
| 495 | * which CSS and/or JS files need to be included by consulting the |
| 496 | * appropriate manifest file. |
| 497 | * |
| 498 | * @param Context $context |
| 499 | */ |
| 500 | private function addComponentFiles( Context $context ) { |
| 501 | $codexFiles = $this->getCodexFiles( $context ); |
| 502 | |
| 503 | $requestedFiles = array_map( static function ( $component ) use ( $codexFiles ) { |
| 504 | if ( !isset( $codexFiles[ 'components' ][ $component ] ) ) { |
| 505 | throw new InvalidArgumentException( |
| 506 | "\"$component\" is not an export of Codex and cannot be included in the \"codexComponents\" array." |
| 507 | ); |
| 508 | } |
| 509 | return $codexFiles[ 'components' ][ $component ]; |
| 510 | }, $this->codexComponents ); |
| 511 | |
| 512 | [ 'scripts' => $scripts, 'styles' => $styles ] = $this->resolveDependencies( $requestedFiles, $codexFiles ); |
| 513 | |
| 514 | // Add the CSS files to the module's package file (unless this is a script-only module) |
| 515 | if ( !$this->isScriptOnly ) { |
| 516 | foreach ( $styles as $fileName ) { |
| 517 | $this->styles[] = $this->makeFilePath( "modules/$fileName" ); |
| 518 | } |
| 519 | } |
| 520 | |
| 521 | // Add the JS files to the module's package file (unless this is a style-only module). |
| 522 | if ( !$this->isStyleOnly ) { |
| 523 | // Add a synthetic top-level "exports" file |
| 524 | $exports = []; |
| 525 | foreach ( $this->codexComponents as $component ) { |
| 526 | $componentFile = $codexFiles[ 'components' ][ $component ]; |
| 527 | $exports[ $component ] = new HtmlJsCode( |
| 528 | 'require( ' . Html::encodeJsVar( "./_codex/$componentFile" ) . ' )' |
| 529 | ); |
| 530 | } |
| 531 | |
| 532 | $syntheticExports = Html::encodeJsVar( HtmlJsCode::encodeObject( $exports ) ); |
| 533 | |
| 534 | // Add a console warning in development mode |
| 535 | $devWarning = $this->getDevelopmentWarning(); |
| 536 | |
| 537 | // Proxy the synthetic exports object so that we can throw a useful error if a component |
| 538 | // is not defined in the module definition |
| 539 | $proxiedSyntheticExports = <<<JAVASCRIPT |
| 540 | module.exports = new Proxy( $syntheticExports, { |
| 541 | get( target, prop ) { |
| 542 | if ( !( prop in target ) ) { |
| 543 | throw new Error( |
| 544 | `Codex component "\${prop}" ` + |
| 545 | 'is not listed in the "codexComponents" array ' + |
| 546 | 'of the "{$this->getName()}" module in your module definition file' |
| 547 | ); |
| 548 | } |
| 549 | return target[ prop ]; |
| 550 | } |
| 551 | } ); |
| 552 | $devWarning |
| 553 | JAVASCRIPT; |
| 554 | |
| 555 | $this->packageFiles[] = [ |
| 556 | 'name' => 'codex.js', |
| 557 | 'content' => $proxiedSyntheticExports |
| 558 | ]; |
| 559 | |
| 560 | // Add each of the referenced scripts to the package |
| 561 | foreach ( $scripts as $fileName ) { |
| 562 | $this->packageFiles[] = [ |
| 563 | 'name' => "_codex/$fileName", |
| 564 | 'file' => $this->makeFilePath( "modules/$fileName" ) |
| 565 | ]; |
| 566 | } |
| 567 | } |
| 568 | } |
| 569 | |
| 570 | /** |
| 571 | * For loading the entire Codex library, rather than a subset module of it. |
| 572 | */ |
| 573 | private function loadFullCodexLibrary( Context $context ) { |
| 574 | // Add all Codex JS files to the module's package |
| 575 | if ( !$this->isStyleOnly ) { |
| 576 | $jsFilePath = $this->makeFilePath( 'codex.umd.cjs' ); |
| 577 | |
| 578 | // Add a console warning in development mode |
| 579 | $devWarning = $this->getDevelopmentWarning(); |
| 580 | if ( $devWarning ) { |
| 581 | $this->packageFiles[] = [ |
| 582 | 'name' => 'codex.js', |
| 583 | 'callback' => static function () use ( $jsFilePath, $devWarning ) { |
| 584 | return $devWarning . ';' . file_get_contents( $jsFilePath->getLocalPath() ); |
| 585 | }, |
| 586 | 'versionCallback' => static function () use ( $jsFilePath ) { |
| 587 | return $jsFilePath; |
| 588 | } |
| 589 | ]; |
| 590 | } else { |
| 591 | $this->packageFiles[] = [ |
| 592 | 'name' => 'codex.js', |
| 593 | 'file' => $jsFilePath |
| 594 | ]; |
| 595 | } |
| 596 | } |
| 597 | |
| 598 | // Add all Codex CSS files to the module's package |
| 599 | if ( !$this->isScriptOnly ) { |
| 600 | // Theme-specific + direction style files |
| 601 | $themeStyles = [ |
| 602 | 'wikimedia-ui' => [ |
| 603 | 'ltr' => 'codex.style.css', |
| 604 | 'rtl' => 'codex.style-rtl.css' |
| 605 | ], |
| 606 | 'wikimedia-ui-legacy' => [ |
| 607 | 'ltr' => 'codex.style.css', |
| 608 | 'rtl' => 'codex.style-rtl.css' |
| 609 | ], |
| 610 | 'experimental' => [ |
| 611 | 'ltr' => 'codex.style.css', |
| 612 | 'rtl' => 'codex.style-rtl.css' |
| 613 | ] |
| 614 | ]; |
| 615 | |
| 616 | $theme = $this->getTheme( $context ); |
| 617 | $direction = $context->getDirection(); |
| 618 | $styleFile = $themeStyles[ $theme ][ $direction ]; |
| 619 | $this->styles[] = $this->makeFilePath( $styleFile ); |
| 620 | } |
| 621 | } |
| 622 | |
| 623 | /** |
| 624 | * @inheritDoc |
| 625 | */ |
| 626 | public function getType(): string { |
| 627 | return $this->isStyleOnly ? self::LOAD_STYLES : self::LOAD_GENERAL; |
| 628 | } |
| 629 | } |