Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
72.67% |
125 / 172 |
|
42.86% |
6 / 14 |
CRAP | |
0.00% |
0 / 1 |
CodexModule | |
72.67% |
125 / 172 |
|
42.86% |
6 / 14 |
86.32 | |
0.00% |
0 / 1 |
__construct | |
75.76% |
25 / 33 |
|
0.00% |
0 / 1 |
12.72 | |||
getIcons | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
getPackageFiles | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getStyleFiles | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getDefinitionSummary | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
getFlip | |
0.00% |
0 / 1 |
|
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 | |||
getManifestFilePath | |
95.24% |
20 / 21 |
|
0.00% |
0 / 1 |
2 | |||
getCodexFiles | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
5 | |||
setupCodex | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
4.05 | |||
resolveDependencies | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
4 | |||
addComponentFiles | |
100.00% |
35 / 35 |
|
100.00% |
1 / 1 |
7 | |||
loadFullCodexLibrary | |
0.00% |
0 / 29 |
|
0.00% |
0 / 1 |
12 |
1 | <?php |
2 | /** |
3 | * This program is free software; you can redistribute it and/or modify |
4 | * it under the terms of the GNU General Public License as published by |
5 | * the Free Software Foundation; either version 2 of the License, or |
6 | * (at your option) any later version. |
7 | * |
8 | * This program is distributed in the hope that it will be useful, |
9 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
10 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
11 | * GNU General Public License for more details. |
12 | * |
13 | * You should have received a copy of the GNU General Public License along |
14 | * with this program; if not, write to the Free Software Foundation, Inc., |
15 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
16 | * http://www.gnu.org/copyleft/gpl.html |
17 | * |
18 | * @file |
19 | */ |
20 | |
21 | namespace MediaWiki\ResourceLoader; |
22 | |
23 | use ExtensionRegistry; |
24 | use InvalidArgumentException; |
25 | use MediaWiki\Config\Config; |
26 | use MediaWiki\Html\Html; |
27 | use MediaWiki\Html\HtmlJsCode; |
28 | use MediaWiki\MainConfigNames; |
29 | |
30 | /** |
31 | * Module for codex that has direction-specific style files and a static helper |
32 | * function for embedding icons in package modules. This module also contains |
33 | * logic to support code-splitting (aka tree-shaking) of the Codex library to |
34 | * return only a subset of component JS and/or CSS files. |
35 | * |
36 | * @ingroup ResourceLoader |
37 | * @internal |
38 | */ |
39 | class CodexModule extends FileModule { |
40 | protected const CODEX_MODULE_DIR = 'resources/lib/codex/modules/'; |
41 | private const CODEX_MODULE_DEPENDENCIES = [ 'vue' ]; |
42 | |
43 | /** @var array<string,string>|null */ |
44 | private static ?array $themeMap = null; |
45 | |
46 | /** |
47 | * Cache for getCodexFiles(). Maps manifest file paths to outputs. For more information about |
48 | * the structure of the outputs, see the documentation comment for getCodexFiles(). |
49 | * |
50 | * This array looks like: |
51 | * [ |
52 | * '/path/to/manifest.json' => [ 'files' => [ ... ], 'components' => [ ... ] ], |
53 | * '/path/to/manifest-rtl.json' => [ 'files' => [ ... ], 'components' => [ ... ] ], |
54 | * ... |
55 | * ] |
56 | * |
57 | * @var array<string,array{files:array<string,array{styles:string[],dependencies:string[]}>,components:array<string,string>}> |
58 | */ |
59 | private static array $codexFilesCache = []; |
60 | |
61 | /** |
62 | * Names of the requested components. Comes directly from the 'codexComponents' property in the |
63 | * module definition. |
64 | * |
65 | * @var string[] |
66 | */ |
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 | |
74 | /** |
75 | * @param array $options [optional] |
76 | * - codexComponents: array of Codex components to include |
77 | * - codexFullLibrary: whether to load the entire Codex library |
78 | * - codexStyleOnly: whether to include only style files |
79 | * - codexScriptOnly: whether to include only script files |
80 | * @param string|null $localBasePath [optional] |
81 | * @param string|null $remoteBasePath [optional] |
82 | */ |
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 | |
141 | /** |
142 | * Retrieve the specified icon definitions from codex-icons.json. Intended as a convenience |
143 | * function to be used in packageFiles definitions. |
144 | * |
145 | * Example: |
146 | * "packageFiles": [ |
147 | * { |
148 | * "name": "icons.json", |
149 | * "callback": "MediaWiki\\ResourceLoader\\CodexModule::getIcons", |
150 | * "callbackParam": [ |
151 | * "cdxIconClear", |
152 | * "cdxIconTrash" |
153 | * ] |
154 | * } |
155 | * ] |
156 | * |
157 | * @param Context $context |
158 | * @param Config $config |
159 | * @param string[] $iconNames Names of icons to fetch |
160 | * @return array |
161 | */ |
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 | |
204 | /** |
205 | * Get the theme to use based on the current skin. |
206 | * |
207 | * @param Context $context |
208 | * @return string Name of the current theme |
209 | */ |
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 | |
220 | /** |
221 | * Decide which manifest file to use, based on the theme and the direction (LTR or RTL). |
222 | * |
223 | * @param Context $context |
224 | * @return string Name of the manifest file to use |
225 | */ |
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.json', |
238 | 'rtl' => 'manifest-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 | |
252 | /** |
253 | * Get information about all available Codex files. |
254 | * |
255 | * This transforms the Codex manifest to a more useful format, and caches it so that different |
256 | * instances of this class don't each parse the manifest separately. |
257 | * |
258 | * The returned data structure contains a 'files' key with dependency information about every |
259 | * file (both entry point and non-entry point files), and a 'component' key that is an array |
260 | * mapping component names to file names. The full data structure looks like this: |
261 | * |
262 | * [ |
263 | * 'files' => [ |
264 | * 'CdxButtonGroup.js' => [ |
265 | * 'styles' => [ 'CdxButtonGroup.css' ], |
266 | * 'dependencies' => [ 'CdxButton.js', 'buttonHelpers.js' ] |
267 | * ], |
268 | * // all files are listed here, both entry point and non-entry point files |
269 | * ], |
270 | * 'components' => [ |
271 | * 'CdxButtonGroup' => 'CdxButtonGroup.js', |
272 | * // only entry point files are listed here |
273 | * ] |
274 | * ] |
275 | * |
276 | * @param Context $context |
277 | * @return array{files:array<string,array{styles:string[],dependencies:string[]}>,components:array<string,string>} |
278 | */ |
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 | |
312 | /** |
313 | * There are several different use-cases for CodexModule. We may be dealing |
314 | * with: |
315 | * |
316 | * - A CSS-only or JS & CSS module for the entire component library |
317 | * - An otherwise standard module that needs one or more Codex icons |
318 | * - A CSS-only or CSS-and-JS module that has opted-in to Codex's |
319 | * tree-shaking feature by specifying the "codexComponents" option |
320 | * |
321 | * Regardless of the kind of CodexModule we are dealing with, some kind of |
322 | * one-time setup operation may need to be performed. |
323 | * |
324 | * In the case of a full-library module, we need to ensure that the correct |
325 | * theme- and direction-specific CSS file is used. |
326 | * |
327 | * In the case of a tree-shaking module, we need to ensure that the CSS |
328 | * and/or JS files for the specified components (as well as all |
329 | * dependencies) are added to the module's packageFiles. |
330 | * |
331 | * @param Context $context |
332 | */ |
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 | |
352 | /** |
353 | * Resolve the dependencies for a list of files, return flat arrays of both scripts and styles. |
354 | * The returned arrays include the files in $requestedFiles, plus their dependencies, their |
355 | * dependencies' dependencies, etc. The returned arrays are ordered in the order that the files |
356 | * should be loaded in; in other words, the files are ordered such that each file appears after |
357 | * all of its dependencies. |
358 | * |
359 | * @param string[] $requestedFiles File names whose dependencies to resolve |
360 | * @phpcs:ignore Generic.Files.LineLength, MediaWiki.Commenting.FunctionComment.MissingParamName |
361 | * @param array{files:array<string,array{styles:string[],dependencies:string[]}>,components:array<string,string>} $codexFiles |
362 | * Data structure returned by getCodexFiles() |
363 | * @return array{scripts:string[],styles:string[]} Resolved dependencies, with script and style |
364 | * files listed separately. |
365 | */ |
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 | |
387 | /** |
388 | * For Codex modules that rely on tree-shaking, this method determines |
389 | * which CSS and/or JS files need to be included by consulting the |
390 | * appropriate manifest file. |
391 | * |
392 | * @param Context $context |
393 | */ |
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 | |
462 | /** |
463 | * For loading the entire Codex library, rather than a subset module of it. |
464 | * |
465 | * @param Context $context |
466 | */ |
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.css', |
492 | 'rtl' => 'resources/lib/codex/codex.style-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 | } |