Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
63.18% covered (warning)
63.18%
187 / 296
47.83% covered (danger)
47.83%
11 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
WikiModule
63.18% covered (warning)
63.18%
187 / 296
47.83% covered (danger)
47.83%
11 / 23
702.28
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
7
 getPages
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
6.03
 getGroup
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDB
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getContent
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
7.07
 getContentObj
34.62% covered (danger)
34.62%
9 / 26
0.00% covered (danger)
0.00%
0 / 1
25.89
 shouldEmbedModule
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
 getScript
56.25% covered (warning)
56.25%
9 / 16
0.00% covered (danger)
0.00%
0 / 1
11.10
 isPackaged
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 supportsURLLoading
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRequireKey
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPackageFiles
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
156
 getStyles
51.52% covered (warning)
51.52%
17 / 33
0.00% covered (danger)
0.00%
0 / 1
21.40
 enableModuleContentVersion
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDefinitionSummary
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 isKnownEmpty
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 setTitleInfo
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 makeTitleKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTitleInfo
44.44% covered (danger)
44.44%
8 / 18
0.00% covered (danger)
0.00%
0 / 1
12.17
 fetchTitleInfo
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
5
 preloadTitleInfo
100.00% covered (success)
100.00%
50 / 50
100.00% covered (success)
100.00%
1 / 1
9
 invalidateModuleCache
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
9.13
 getType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 * @author Trevor Parscal
6 * @author Roan Kattouw
7 */
8
9namespace MediaWiki\ResourceLoader;
10
11use CSSJanus;
12use InvalidArgumentException;
13use MediaWiki\Content\Content;
14use MediaWiki\Json\FormatJson;
15use MediaWiki\Linker\LinkTarget;
16use MediaWiki\MainConfigNames;
17use MediaWiki\MediaWikiServices;
18use MediaWiki\Page\PageIdentity;
19use MediaWiki\Revision\RevisionRecord;
20use MediaWiki\Title\Title;
21use MediaWiki\Title\TitleValue;
22use MemoizedCallable;
23use Wikimedia\Minify\CSSMin;
24use Wikimedia\Rdbms\Database;
25use Wikimedia\Rdbms\IReadableDatabase;
26use Wikimedia\Timestamp\ConvertibleTimestamp;
27use Wikimedia\Timestamp\TimestampFormat as TS;
28
29/**
30 * Abstraction for ResourceLoader modules which pull from wiki pages
31 *
32 * This can only be used for wiki pages in the MediaWiki and User namespaces,
33 * because of its dependence on the functionality of Title::isUserConfigPage()
34 * and Title::isSiteConfigPage().
35 *
36 * This module supports being used as a placeholder for a module on a remote wiki.
37 * To do so, getDB() must be overloaded to return a foreign database object that
38 * allows local wikis to query page metadata.
39 *
40 * Safe for calls on local wikis are:
41 * - Option getters:
42 *   - getGroup()
43 *   - getPages()
44 * - Basic methods that strictly involve the foreign database
45 *   - getDB()
46 *   - isKnownEmpty()
47 *   - getTitleInfo()
48 *
49 * @ingroup ResourceLoader
50 * @since 1.17
51 */
52class WikiModule extends Module {
53    /** @var string Origin defaults to users with sitewide authority */
54    protected $origin = self::ORIGIN_USER_SITEWIDE;
55
56    /**
57     * In-process cache for title info, structured as an array
58     * [
59     *  <batchKey> // Pipe-separated list of sorted keys from getPages
60     *   => [
61     *     <titleKey> => [ // Normalised title key
62     *       'page_len' => ..,
63     *       'page_latest' => ..,
64     *       'page_touched' => ..,
65     *     ]
66     *   ]
67     * ]
68     * @see self::fetchTitleInfo()
69     * @see self::makeTitleKey()
70     * @var array
71     */
72    protected $titleInfo = [];
73
74    /** @var array List of page names that contain CSS */
75    protected $styles = [];
76
77    /** @var array List of page names that contain JavaScript */
78    protected $scripts = [];
79
80    /** @var array List of page names that contain JSON */
81    protected $datas = [];
82
83    /** @var string|null Group of module */
84    protected $group;
85
86    /**
87     * @param array|null $options For back-compat, this can be omitted in favour of overwriting
88     *  getPages.
89     */
90    public function __construct( ?array $options = null ) {
91        if ( $options === null ) {
92            return;
93        }
94
95        foreach ( $options as $member => $option ) {
96            switch ( $member ) {
97                case 'styles':
98                case 'scripts':
99                case 'datas':
100                case 'group':
101                    $this->{$member} = $option;
102                    break;
103            }
104        }
105    }
106
107    /**
108     * Subclasses should return an associative array of resources in the module.
109     * Keys should be the title of a page in the MediaWiki or User namespace.
110     *
111     * Values should be a nested array of options.
112     * The supported keys are 'type' and (CSS only) 'media'.
113     *
114     * For scripts, 'type' should be 'script'.
115     * For JSON files, 'type' should be 'data'.
116     * For stylesheets, 'type' should be 'style'.
117     *
118     * There is an optional 'media' key, the value of which can be the
119     * medium ('screen', 'print', etc.) of the stylesheet.
120     *
121     * @param Context $context
122     * @return array[]
123     * @phan-return array<string,array{type:string,media?:string}>
124     */
125    protected function getPages( Context $context ) {
126        $config = $this->getConfig();
127        $pages = [];
128
129        // Filter out pages from origins not allowed by the current wiki configuration.
130        if ( $config->get( MainConfigNames::UseSiteJs ) ) {
131            foreach ( $this->scripts as $script ) {
132                $pages[$script] = [ 'type' => 'script' ];
133            }
134            foreach ( $this->datas as $data ) {
135                $pages[$data] = [ 'type' => 'data' ];
136            }
137        }
138
139        if ( $config->get( MainConfigNames::UseSiteCss ) ) {
140            foreach ( $this->styles as $style ) {
141                $pages[$style] = [ 'type' => 'style' ];
142            }
143        }
144
145        return $pages;
146    }
147
148    /**
149     * Get group name
150     *
151     * @return string|null
152     */
153    public function getGroup() {
154        return $this->group;
155    }
156
157    /**
158     * Get the Database handle used for computing the module version.
159     *
160     * Subclasses may override this to return a foreign database, which would
161     * allow them to register a module on wiki A that fetches wiki pages from
162     * wiki B.
163     *
164     * The way this works is that the local module is a placeholder that can
165     * only computer a module version hash. The 'source' of the module must
166     * be set to the foreign wiki directly. Methods getScript() and getContent()
167     * will not use this handle and are not valid on the local wiki.
168     *
169     * @return IReadableDatabase
170     */
171    protected function getDB() {
172        return MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
173    }
174
175    /**
176     * @param string $titleText
177     * @param Context $context
178     * @return null|string
179     * @since 1.32 added the $context parameter
180     */
181    protected function getContent( $titleText, Context $context ) {
182        $pageStore = MediaWikiServices::getInstance()->getPageStore();
183        $title = $pageStore->getPageByText( $titleText );
184        if ( !$title ) {
185            return null; // Bad title
186        }
187
188        $content = $this->getContentObj( $title, $context );
189        if ( !$content ) {
190            return null; // No content found
191        }
192
193        $handler = $content->getContentHandler();
194        if ( $handler->isSupportedFormat( CONTENT_FORMAT_CSS ) ) {
195            $format = CONTENT_FORMAT_CSS;
196        } elseif ( $handler->isSupportedFormat( CONTENT_FORMAT_JAVASCRIPT ) ) {
197            $format = CONTENT_FORMAT_JAVASCRIPT;
198        } elseif ( $handler->isSupportedFormat( CONTENT_FORMAT_JSON ) ) {
199            $format = CONTENT_FORMAT_JSON;
200        } elseif ( $handler->isSupportedFormat( CONTENT_FORMAT_VUE ) ) {
201            $format = CONTENT_FORMAT_VUE;
202        } else {
203            return null; // Bad content model
204        }
205
206        return $content->serialize( $format );
207    }
208
209    /**
210     * @param PageIdentity $page
211     * @param Context $context
212     * @param int $maxRedirects Maximum number of redirects to follow.
213     *        Either 0 or 1.
214     * @return Content|null
215     * @since 1.32 added the $context and $maxRedirects parameters
216     * @internal for testing
217     */
218    protected function getContentObj(
219        PageIdentity $page, Context $context, $maxRedirects = 1
220    ) {
221        $overrideCallback = $context->getContentOverrideCallback();
222        $content = $overrideCallback ? $overrideCallback( $page ) : null;
223        if ( $content ) {
224            if ( !$content instanceof Content ) {
225                $this->getLogger()->error(
226                    'Bad content override for "{title}" in ' . __METHOD__,
227                    [ 'title' => (string)$page ]
228                );
229                return null;
230            }
231        } else {
232            $revision = MediaWikiServices::getInstance()
233                ->getRevisionLookup()
234                ->getKnownCurrentRevision( $page );
235            if ( !$revision ) {
236                return null;
237            }
238            $content = $revision->getMainContentRaw();
239
240            if ( !$content ) {
241                $this->getLogger()->error(
242                    'Failed to load content of CSS/JS/JSON page "{title}" in ' . __METHOD__,
243                    [ 'title' => (string)$page ]
244                );
245                return null;
246            }
247        }
248
249        if ( $maxRedirects > 0 ) {
250            $newTitle = $content->getRedirectTarget();
251            if ( $newTitle ) {
252                return $this->getContentObj( $newTitle, $context, 0 );
253            }
254        }
255
256        return $content;
257    }
258
259    /**
260     * @param Context $context
261     * @return bool
262     */
263    public function shouldEmbedModule( Context $context ) {
264        $overrideCallback = $context->getContentOverrideCallback();
265        if ( $overrideCallback && $this->getSource() === 'local' ) {
266            foreach ( $this->getPages( $context ) as $page => $info ) {
267                $title = Title::newFromText( $page );
268                if ( $title && $overrideCallback( $title ) !== null ) {
269                    return true;
270                }
271            }
272        }
273
274        return parent::shouldEmbedModule( $context );
275    }
276
277    /**
278     * @param Context $context
279     * @return string|array JavaScript code, or a package files array
280     */
281    public function getScript( Context $context ) {
282        if ( $this->isPackaged() ) {
283            $packageFiles = $this->getPackageFiles( $context );
284            // TODO deduplicate this from FileModule, move up to Module?
285            foreach ( $packageFiles['files'] as &$file ) {
286                if ( $file['type'] === 'script+style' ) {
287                    $file['content'] = $file['content']['script'];
288                    $file['type'] = 'script';
289                }
290            }
291            return $packageFiles;
292        } else {
293            $scripts = '';
294            foreach ( $this->getPages( $context ) as $titleText => $options ) {
295                if ( $options['type'] !== 'script' ) {
296                    continue;
297                }
298                $script = $this->getContent( $titleText, $context );
299                if ( strval( $script ) !== '' ) {
300                    $script = $this->validateScriptFile( $titleText, $script );
301                    $scripts .= ResourceLoader::makeComment( $titleText ) . $script . "\n";
302                }
303            }
304            return $scripts;
305        }
306    }
307
308    /**
309     * Get whether this module is a packaged module.
310     *
311     * If false (the default), JavaScript pages are concatenated and executed as a single
312     * script. JSON pages are not supported.
313     *
314     * If true, the pages are bundled such that each page gets a virtual file name, where only
315     * the "main" script will be executed at first, and other JS or JSON pages may be be imported
316     * in client-side code through the `require()` function.
317     *
318     * @stable to override
319     * @since 1.38
320     * @return bool
321     */
322    protected function isPackaged(): bool {
323        // Packaged mode is disabled by default for backwards compatibility.
324        // Subclasses may opt-in to this feature.
325        return false;
326    }
327
328    /**
329     * @return bool
330     */
331    public function supportsURLLoading() {
332        // If package files are involved, don't support URL loading
333        return !$this->isPackaged();
334    }
335
336    /**
337     * Convert a namespace-formatted page title to a virtual package file name.
338     *
339     * This determines how the page may be imported in client-side code via `require()`.
340     *
341     * @stable to override
342     * @since 1.38
343     * @param string $titleText
344     * @return string
345     */
346    protected function getRequireKey( string $titleText ): string {
347        return $titleText;
348    }
349
350    /**
351     * @param Context $context
352     * @return array{main:?string,files:array<string,array>}
353     */
354    private function getPackageFiles( Context $context ): array {
355        $main = null;
356
357        $files = [];
358        foreach ( $this->getPages( $context ) as $titleText => $options ) {
359
360            if (
361                $options['type'] !== 'script' &&
362                $options['type'] !== 'script-vue' &&
363                $options['type'] !== 'data'
364            ) {
365                continue;
366            }
367            $content = $this->getContent( $titleText, $context );
368            if ( strval( $content ) !== '' ) {
369                $fileKey = $this->getRequireKey( $titleText );
370                if ( $options['type'] === 'script' ) {
371                    $script = $this->validateScriptFile( $titleText, $content );
372                    $files[$fileKey] = [
373                        'type' => 'script',
374                        'content' => $script,
375                    ];
376                    // First script becomes the "main" script
377                    $main ??= $fileKey;
378
379                } elseif ( $options['type'] === 'script-vue' ) {
380                    try {
381                        $files[$fileKey]['content'] = $this->parseVueContent( $context, $content );
382                    } catch ( InvalidArgumentException $e ) {
383                        $message = "Failed to parse vue component in $titleText{$e->getMessage()}";
384                        $files[$fileKey]['content'] = [
385                            'script' => 'mw.log.error( ' . $context->encodeJson( $message ) . ' )',
386                            'style' => ''
387                        ];
388                    }
389                    if ( $files[$fileKey]['content']['styleLang'] === 'less' ) {
390                        $message = "Failed to parse Vue component in $titleText: Use of LESS styles is not supported.";
391                        $files[$fileKey]['content'] = [
392                            'script' => 'mw.log.error( ' . $context->encodeJson( $message ) . ' )',
393                            'style' => ''
394                        ];
395                    }
396                    $files[$fileKey]['content']['titleText'] = $titleText;
397                    $files[$fileKey]['type'] = 'script+style';
398
399                } elseif ( $options['type'] === 'data' ) {
400                    $data = FormatJson::decode( $content );
401                    if ( $data == null ) {
402                        // This is unlikely to happen since we only load JSON from
403                        // wiki pages with a JSON content model, which are validated
404                        // during edit save.
405                        $data = [ 'error' => 'Invalid JSON' ];
406                    }
407                    $files[$fileKey] = [
408                        'type' => 'data',
409                        'content' => $data,
410                    ];
411                }
412            }
413        }
414
415        return [
416            'main' => $main,
417            'files' => $files,
418        ];
419    }
420
421    /**
422     * @param Context $context
423     * @return array
424     */
425    public function getStyles( Context $context ) {
426        $remoteDir = $this->getConfig()->get( MainConfigNames::ScriptPath );
427        if ( $remoteDir === '' ) {
428            // When the site is configured with the script path at the
429            // document root, MediaWiki uses an empty string but that is
430            // not a valid URI path. Expand to a slash to avoid fatals
431            // later in CSSMin::resolveUrl().
432            // See also FilePath::extractBasePaths, T282280.
433            $remoteDir = '/';
434        }
435
436        $styles = [];
437        foreach ( $this->getPages( $context ) as $titleText => $options ) {
438            if ( $options['type'] !== 'style' ) {
439                continue;
440            }
441            $style = $this->getContent( $titleText, $context );
442            if ( strval( $style ) === '' ) {
443                continue;
444            }
445            if ( $this->getFlip( $context ) ) {
446                $style = CSSJanus::transform( $style, true, false );
447            }
448
449            $style = MemoizedCallable::call(
450                [ CSSMin::class, 'remap' ],
451                [ $style, false, $remoteDir, true ]
452            );
453            $media = $options['media'] ?? 'all';
454            $style = ResourceLoader::makeComment( $titleText ) . $style;
455            $styles[$media][] = $style;
456        }
457
458        if ( $this->isPackaged() ) {
459            $packageFiles = $this->getPackageFiles( $context );
460            foreach ( $packageFiles['files'] as $fileName => $file ) {
461                if ( $file['type'] === 'script+style' ) {
462                    $style = $file['content']['style'];
463                    if ( $this->getFlip( $context ) ) {
464                        $style = CSSJanus::transform( $style, true, false );
465                    }
466
467                    $style = MemoizedCallable::call(
468                        [ CSSMin::class, 'remap' ],
469                        [ $style, false, $remoteDir, true ]
470                    );
471
472                    $style = ResourceLoader::makeComment( $file['content']['titleText'] ) . $style;
473                    $styles['all'][] = $style;
474                }
475            }
476        }
477        return $styles;
478    }
479
480    /**
481     * Disable module content versioning.
482     *
483     * This class does not support generating content outside of a module
484     * request due to foreign database support.
485     *
486     * See getDefinitionSummary() for meta-data versioning.
487     *
488     * @return bool
489     */
490    public function enableModuleContentVersion() {
491        return false;
492    }
493
494    /**
495     * @param Context $context
496     * @return array
497     */
498    public function getDefinitionSummary( Context $context ) {
499        $summary = parent::getDefinitionSummary( $context );
500        $summary[] = [
501            'pages' => $this->getPages( $context ),
502            // Includes meta data of current revisions
503            'titleInfo' => $this->getTitleInfo( $context ),
504        ];
505        return $summary;
506    }
507
508    /**
509     * @param Context $context
510     * @return bool
511     */
512    public function isKnownEmpty( Context $context ) {
513        // If a module has dependencies it cannot be empty. An empty array will be cast to false
514        if ( $this->getDependencies() ) {
515            return false;
516        }
517
518        // Optimisation: For user modules, don't needlessly load if there are no non-empty pages
519        // This is worthwhile because unlike most modules, user modules require their own
520        // separate embedded request (managed by ClientHtml).
521        $revisions = $this->getTitleInfo( $context );
522        if ( $this->getGroup() === self::GROUP_USER ) {
523            foreach ( $revisions as $revision ) {
524                if ( $revision['page_len'] > 0 ) {
525                    // At least one non-empty page, module should be loaded
526                    return false;
527                }
528            }
529            return true;
530        }
531
532        // T70488: For non-user modules (i.e. ones that are called in cached HTML output) only check
533        // page existence. This ensures that, if some pages in a module are temporarily blanked,
534        // we don't stop embedding the module's script or link tag on newly cached pages.
535        return count( $revisions ) === 0;
536    }
537
538    private function setTitleInfo( string $batchKey, array $titleInfo ) {
539        $this->titleInfo[$batchKey] = $titleInfo;
540    }
541
542    private static function makeTitleKey( LinkTarget $title ): string {
543        // Used for keys in titleInfo.
544        return "{$title->getNamespace()}:{$title->getDBkey()}";
545    }
546
547    /**
548     * Get the information about the wiki pages for a given context.
549     * @param Context $context
550     * @return array[] Keyed by page name
551     */
552    protected function getTitleInfo( Context $context ) {
553        $pageNames = array_keys( $this->getPages( $context ) );
554        sort( $pageNames );
555        $batchKey = implode( '|', $pageNames );
556        if ( !isset( $this->titleInfo[$batchKey] ) ) {
557            $this->titleInfo[$batchKey] = static::fetchTitleInfo( $this->getDB(), $pageNames, __METHOD__ );
558        }
559
560        $titleInfo = $this->titleInfo[$batchKey];
561
562        // Override the title info from the overrides, if any
563        $overrideCallback = $context->getContentOverrideCallback();
564        if ( $overrideCallback ) {
565            foreach ( $pageNames as $page ) {
566                $title = Title::newFromText( $page );
567                $content = $title ? $overrideCallback( $title ) : null;
568                if ( $content !== null ) {
569                    $titleInfo[$title->getPrefixedText()] = [
570                        'page_len' => $content->getSize(),
571                        'page_latest' => 'TBD', // None available
572                        'page_touched' => ConvertibleTimestamp::now( TS::MW ),
573                    ];
574                }
575            }
576        }
577
578        return $titleInfo;
579    }
580
581    /**
582     * @param IReadableDatabase $db
583     * @param string[] $pages
584     * @param string $fname @phan-mandatory-param
585     * @return array
586     */
587    protected static function fetchTitleInfo( IReadableDatabase $db, array $pages, $fname = __METHOD__ ) {
588        $titleInfo = [];
589        $linkBatchFactory = MediaWikiServices::getInstance()->getLinkBatchFactory();
590        $batch = $linkBatchFactory->newLinkBatch();
591        foreach ( $pages as $titleText ) {
592            $title = Title::newFromText( $titleText );
593            if ( $title ) {
594                // Page name may be invalid if user-provided (e.g. gadgets)
595                $batch->addObj( $title );
596            }
597        }
598        if ( !$batch->isEmpty() ) {
599            $res = $db->newSelectQueryBuilder()
600                // Include page_touched to allow purging if cache is poisoned (T117587, T113916)
601                ->select( [ 'page_namespace', 'page_title', 'page_touched', 'page_len', 'page_latest' ] )
602                ->from( 'page' )
603                ->where( $batch->constructSet( 'page', $db ) )
604                ->caller( $fname )->fetchResultSet();
605            foreach ( $res as $row ) {
606                // Avoid including ids or timestamps of revision/page tables so
607                // that versions are not wasted
608                $title = new TitleValue( (int)$row->page_namespace, $row->page_title );
609                $titleInfo[self::makeTitleKey( $title )] = [
610                    'page_len' => $row->page_len,
611                    'page_latest' => $row->page_latest,
612                    'page_touched' => ConvertibleTimestamp::convert( TS::MW, $row->page_touched ),
613                ];
614            }
615        }
616        return $titleInfo;
617    }
618
619    /**
620     * Batched version of WikiModule::getTitleInfo
621     *
622     * Title info for the passed modules is cached together. On index.php, OutputPage improves
623     * cache use by having one batch shared between all users (site-wide modules) and a batch
624     * for current-user modules.
625     *
626     * @since 1.28
627     * @internal For use by ResourceLoader and OutputPage only
628     * @param Context $context
629     * @param string[] $moduleNames
630     */
631    public static function preloadTitleInfo(
632        Context $context, array $moduleNames
633    ) {
634        $rl = $context->getResourceLoader();
635        // getDB() can be overridden to point to a foreign database.
636        // Group pages by database to ensure we fetch titles from the correct database.
637        // By preloading both local and foreign titles, this method doesn't depend
638        // on knowing the local database.
639
640        /** @var array<string,array{db:IReadableDatabase,pages:string[],modules:WikiModule[]}> $byDomain */
641        $byDomain = [];
642        foreach ( $moduleNames as $name ) {
643            $module = $rl->getModule( $name );
644            if ( $module instanceof self ) {
645                // Subclasses may implement getDB differently
646                $db = $module->getDB();
647                $domain = $db->getDomainID();
648
649                $byDomain[ $domain ] ??= [ 'db' => $db, 'pages' => [], 'modules' => [] ];
650                $byDomain[ $domain ]['pages'] = array_merge(
651                    $byDomain[ $domain ]['pages'],
652                    array_keys( $module->getPages( $context ) )
653                );
654                $byDomain[ $domain ]['modules'][] = $module;
655            }
656        }
657
658        if ( !$byDomain ) {
659            // Nothing to preload
660            return;
661        }
662
663        $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
664        $fname = __METHOD__;
665
666        foreach ( $byDomain as $domainId => $batch ) {
667            // Fetch title info
668            sort( $batch['pages'] );
669            $pagesHash = sha1( implode( '|', $batch['pages'] ) );
670            $allInfo = $cache->getWithSetCallback(
671                $cache->makeGlobalKey( 'resourceloader-titleinfo', $domainId, $pagesHash ),
672                $cache::TTL_HOUR,
673                static function ( $curVal, &$ttl, array &$setOpts ) use ( $batch, $fname ) {
674                    $setOpts += Database::getCacheSetOptions( $batch['db'] );
675                    return static::fetchTitleInfo( $batch['db'], $batch['pages'], $fname );
676                },
677                [
678                    'checkKeys' => [
679                        $cache->makeGlobalKey( 'resourceloader-titleinfo', $domainId ) ]
680                ]
681            );
682
683            // Inject to WikiModule objects
684            foreach ( $batch['modules'] as $wikiModule ) {
685                $pages = $wikiModule->getPages( $context );
686                $info = [];
687                foreach ( $pages as $pageName => $unused ) {
688                    // Map page name to canonical form (T145673).
689                    $title = Title::newFromText( $pageName );
690                    if ( !$title ) {
691                        // Page name may be invalid if user-provided (e.g. gadgets)
692                        $rl->getLogger()->info(
693                            'Invalid wiki page title "{title}" in ' . __METHOD__,
694                            [ 'title' => $pageName ]
695                        );
696                        continue;
697                    }
698                    $infoKey = self::makeTitleKey( $title );
699                    if ( isset( $allInfo[$infoKey] ) ) {
700                        $info[$infoKey] = $allInfo[$infoKey];
701                    }
702                }
703                $pageNames = array_keys( $pages );
704                sort( $pageNames );
705                $batchKey = implode( '|', $pageNames );
706                $wikiModule->setTitleInfo( $batchKey, $info );
707            }
708        }
709    }
710
711    /**
712     * Clear the preloadTitleInfo() cache for all wiki modules on this wiki on
713     * page change if it was a JS or CSS page
714     *
715     * @internal
716     * @param PageIdentity $page
717     * @param RevisionRecord|null $old Prior page revision
718     * @param RevisionRecord|null $new New page revision
719     * @param string $domain Database domain ID
720     */
721    public static function invalidateModuleCache(
722        PageIdentity $page,
723        ?RevisionRecord $old,
724        ?RevisionRecord $new,
725        string $domain
726    ) {
727        static $models = [ CONTENT_MODEL_CSS, CONTENT_MODEL_JAVASCRIPT, CONTENT_MODEL_VUE ];
728
729        $purge = false;
730        // TODO: MCR: differentiate between page functionality and content model!
731        //       Not all pages containing CSS or JS have to be modules! [PageType]
732        if ( $old ) {
733            $oldModel = $old->getMainContentModel();
734            if ( in_array( $oldModel, $models ) ) {
735                $purge = true;
736            }
737        }
738
739        if ( !$purge && $new ) {
740            $newModel = $new->getMainContentModel();
741            if ( in_array( $newModel, $models ) ) {
742                $purge = true;
743            }
744        }
745
746        if ( !$purge ) {
747            $title = Title::newFromPageIdentity( $page );
748            $purge = ( $title->isSiteConfigPage() || $title->isUserConfigPage() );
749        }
750
751        if ( $purge ) {
752            $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
753            $key = $cache->makeGlobalKey( 'resourceloader-titleinfo', $domain );
754            $cache->touchCheckKey( $key );
755        }
756    }
757
758    /**
759     * @since 1.28
760     * @return string
761     */
762    public function getType() {
763        // Check both because subclasses don't always pass pages via the constructor,
764        // they may also override getPages() instead, in which case we should keep
765        // defaulting to LOAD_GENERAL and allow them to override getType() separately.
766        return ( $this->styles && !$this->scripts ) ? self::LOAD_STYLES : self::LOAD_GENERAL;
767    }
768}