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