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