Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.67% covered (warning)
72.67%
125 / 172
42.86% covered (danger)
42.86%
6 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
CodexModule
72.67% covered (warning)
72.67%
125 / 172
42.86% covered (danger)
42.86%
6 / 14
86.32
0.00% covered (danger)
0.00%
0 / 1
 __construct
75.76% covered (warning)
75.76%
25 / 33
0.00% covered (danger)
0.00%
0 / 1
12.72
 getIcons
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getPackageFiles
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getStyleFiles
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getDefinitionSummary
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getFlip
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 supportsURLLoading
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTheme
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getManifestFilePath
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
2
 getCodexFiles
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
5
 setupCodex
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 resolveDependencies
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 addComponentFiles
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
7
 loadFullCodexLibrary
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
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
21namespace MediaWiki\ResourceLoader;
22
23use ExtensionRegistry;
24use InvalidArgumentException;
25use MediaWiki\Config\Config;
26use MediaWiki\Html\Html;
27use MediaWiki\Html\HtmlJsCode;
28use 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 */
39class 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}