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