13use InvalidArgumentException;
21use Wikimedia\Minify\CSSMin;
86 private $expandedPackageFiles = [];
92 private $fullyExpandedPackageFiles = [];
156 $hasTemplates =
false;
163 foreach ( $options as $member => $option ) {
170 $this->{$member} = is_array( $option ) ? $option : [ $option ];
173 $hasTemplates =
true;
174 $this->{$member} = is_array( $option ) ? $option : [ $option ];
177 case 'languageScripts':
180 if ( !is_array( $option ) ) {
181 throw new InvalidArgumentException(
182 "Invalid collated file path list error. " .
183 "'$option' given, array expected."
186 foreach ( $option as $key => $value ) {
187 if ( !is_string( $key ) ) {
188 throw new InvalidArgumentException(
189 "Invalid collated file path list key error. " .
190 "'$key' given, string expected."
193 $this->{$member}[$key] = is_array( $value ) ? $value : [ $value ];
197 $this->deprecated = $option;
203 $option = array_values( array_unique( (array)$option ) );
206 $this->{$member} = $option;
211 $this->{$member} = (string)$option;
216 case 'skipStructureTest':
217 $this->{$member} = (bool)$option;
221 if ( isset( $options[
'scripts'] ) && isset( $options[
'packageFiles'] ) ) {
222 throw new InvalidArgumentException(
"A module may not set both 'scripts' and 'packageFiles'" );
224 if ( isset( $options[
'packageFiles'] ) && isset( $options[
'skinScripts'] ) ) {
225 throw new InvalidArgumentException(
"Options 'skinScripts' and 'packageFiles' cannot be used together." );
227 if ( $hasTemplates ) {
228 $this->dependencies[] =
'mediawiki.template';
230 foreach ( $this->templates as $alias => $templatePath ) {
231 if ( is_int( $alias ) ) {
232 $alias = $this->
getPath( $templatePath );
234 $suffix = explode(
'.', $alias );
235 $suffix = end( $suffix );
236 $compilerModule =
'mediawiki.template.' . $suffix;
237 if ( $suffix !==
'html' && !in_array( $compilerModule, $this->dependencies ) ) {
238 $this->dependencies[] = $compilerModule;
266 if ( isset( $options[
'remoteExtPath'] ) ) {
269 $remoteBasePath = $extensionAssetsPath .
'/' . $options[
'remoteExtPath'];
272 if ( isset( $options[
'remoteSkinPath'] ) ) {
278 if ( array_key_exists(
'localBasePath', $options ) ) {
282 if ( array_key_exists(
'remoteBasePath', $options ) ) {
311 static function ( array $file ): array {
312 if ( $file[
'type'] ===
'script+style' ) {
313 $file[
'content'] = $file[
'content'][
'script'];
314 $file[
'type'] =
'script';
323 $files = $this->getScriptFiles( $context );
326 fn ( $file ) => $this->readFileInfo( $context, $file ),
329 return [
'plainScripts' => $files ];
342 && !$this->packageFiles
344 && !$this->hasGeneratedScripts();
349 return $this->skipStructureTest || parent::shouldSkipStructureTest();
357 private function hasGeneratedScripts() {
359 [ $this->scripts, $this->languageScripts, $this->skinScripts, $this->debugScripts ]
363 if ( is_array( $script ) ) {
364 if ( isset( $script[
'callback'] ) || isset( $script[
'versionCallback'] ) ) {
388 if ( $file[
'type'] ===
'script+style' ) {
390 $file[
'content'][
'style'],
391 $file[
'content'][
'styleLang'],
412 if ( $this->hasGeneratedStyles ) {
415 return parent::getStyleURLsForDebug( $context );
420 foreach ( $this->
getStyleFiles( $context ) as $mediaType => $list ) {
421 $urls[$mediaType] = [];
422 foreach ( $list as $file ) {
423 $urls[$mediaType][] = OutputPage::transformResourcePath(
467 private function getFileContents( $localPath, $type ) {
468 if ( !is_file( $localPath ) ) {
469 throw new RuntimeException(
"$type file not found or not a file: \"$localPath\"" );
471 return $this->
stripBom( file_get_contents( $localPath ) );
478 if ( !$this->skipFunction ) {
481 $localPath = $this->
getLocalPath( $this->skipFunction );
482 return $this->getFileContents( $localPath,
'skip function' );
508 private function getFileHashes(
Context $context ) {
512 foreach ( $filePaths as $filePath ) {
520 $expandedPackageFiles = $this->expandPackageFiles( $context );
521 if ( $expandedPackageFiles ) {
522 foreach ( $expandedPackageFiles[
'files'] as $fileInfo ) {
523 $filePath = $fileInfo[
'filePath'] ?? $fileInfo[
'versionFilePath'] ??
null;
524 if ( $filePath instanceof FilePath ) {
525 $files[] = $filePath->getLocalPath();
531 $scriptFileInfos = $this->getScriptFiles( $context );
532 foreach ( $scriptFileInfos as $fileInfo ) {
533 $filePath = $fileInfo[
'filePath'] ?? $fileInfo[
'versionFilePath'] ??
null;
534 if ( $filePath instanceof FilePath ) {
535 $files[] = $filePath->getLocalPath();
539 foreach ( $this->templates as $filePath ) {
543 if ( $this->skipFunction ) {
555 $files = array_unique( $files );
571 $summary = parent::getDefinitionSummary( $context );
588 $options[$member] = $this->{$member};
592 $packageSummaries = [];
602 foreach (
$packageFiles[
'files'] as $fileName => $fileInfo ) {
603 $packageSummaries[$fileName] =
604 $fileInfo[
'definitionSummary'] ?? $fileInfo[
'content'] ??
null;
608 $scriptFiles = $this->getScriptFiles( $context );
609 $scriptSummaries = [];
610 foreach ( $scriptFiles as $fileName => $fileInfo ) {
611 $scriptSummaries[$fileName] =
612 $fileInfo[
'definitionSummary'] ?? $fileInfo[
'content'] ??
null;
616 'options' => $options,
617 'packageFiles' => $packageSummaries,
618 'scripts' => $scriptSummaries,
619 'fileHashes' => $this->getFileHashes( $context ),
625 $summary[] = [
'lessVars' => $lessVars ];
637 return $path->getPath();
649 if (
$path->getLocalBasePath() !==
null ) {
650 return $path->getLocalPath();
655 return "{$this->localBasePath}/$path";
664 if (
$path->getRemoteBasePath() !==
null ) {
665 return $path->getRemotePath();
670 if ( $this->remoteBasePath ===
'/' ) {
673 return "{$this->remoteBasePath}/$path";
685 return preg_match(
'/\.less$/i',
$path ) ?
'less' :
'css';
695 if ( preg_match(
'/\.json$/i',
$path ) ) {
698 if ( preg_match(
'/\.vue$/i',
$path ) ) {
711 private static function collateStyleFilesByMedia( array $list ) {
713 foreach ( $list as $key => $value ) {
714 if ( is_int( $key ) ) {
716 $collatedFiles[
'all'][] = $value;
717 } elseif ( is_array( $value ) ) {
719 $optionValue = $value[
'media'] ??
'all';
720 $collatedFiles[$optionValue][] = $key;
723 return $collatedFiles;
736 if ( isset( $list[$key] ) && is_array( $list[$key] ) ) {
753 private function getScriptFiles(
Context $context ): array {
757 'scripts' => $this->scripts,
758 'languageScripts' => $this->getLanguageScripts( $context->getLanguage() ),
759 'skinScripts' => self::
tryForKey( $this->skinScripts, $context->getSkin(),
'default' ),
766 foreach ( $filesByCategory as $category => $files ) {
767 foreach ( $files as $key => $fileInfo ) {
768 $expandedFileInfo = $this->expandFileInfo( $context, $fileInfo,
"$category\[$key]" );
769 $expandedFiles[$expandedFileInfo[
'name']] = $expandedFileInfo;
773 return $expandedFiles;
783 private function getLanguageScripts(
string $lang ): array {
784 $scripts = self::tryForKey( $this->languageScripts, $lang );
791 if ( $this->languageScripts ) {
793 ->getLanguageFallback()
794 ->getAll( $lang, LanguageFallbackMode::MESSAGES );
795 foreach ( $fallbacks as $lang ) {
796 $scripts = self::tryForKey( $this->languageScripts, $lang );
807 $moduleName = $this->getName();
808 foreach ( $moduleSkinStyles as $skinName => $overrides ) {
811 if ( isset( $this->skinStyles[$skinName] ) ) {
817 if ( isset( $overrides[$moduleName] ) ) {
818 $paths = (array)$overrides[$moduleName];
820 } elseif ( isset( $overrides[
'+' . $moduleName] ) ) {
821 $paths = (array)$overrides[
'+' . $moduleName];
822 $styleFiles = isset( $this->skinStyles[
'default'] ) ?
823 (array)$this->skinStyles[
'default'] :
831 [ $localBasePath, $remoteBasePath ] = self::extractBasePaths( $overrides );
833 foreach ( $paths as
$path ) {
834 $styleFiles[] =
new FilePath(
$path, $localBasePath, $remoteBasePath );
837 $this->skinStyles[$skinName] = $styleFiles;
849 return array_merge_recursive(
850 self::collateStyleFilesByMedia( $this->styles ),
851 self::collateStyleFilesByMedia(
852 self::tryForKey( $this->skinStyles, $context->
getSkin(),
'default' )
865 return self::collateStyleFilesByMedia(
866 self::tryForKey( $this->skinStyles, $skinName )
877 $skinFactory = MediaWikiServices::getInstance()->getSkinFactory();
880 $internalSkinNames = array_keys( $skinFactory->getInstalledSkins() );
881 $internalSkinNames[] =
'default';
883 foreach ( $internalSkinNames as $internalSkinName ) {
884 $styleFiles = array_merge_recursive(
886 $this->getSkinStyleFiles( $internalSkinName )
899 $collatedStyleFiles = array_merge_recursive(
900 self::collateStyleFilesByMedia( $this->styles ),
901 $this->getAllSkinStyleFiles()
906 foreach ( $collatedStyleFiles as $styleFiles ) {
907 foreach ( $styleFiles as $styleFile ) {
908 $result[] = $this->getLocalPath( $styleFile );
927 foreach ( $styles as $media => $files ) {
928 $uniqueFiles = array_unique( $files, SORT_REGULAR );
930 foreach ( $uniqueFiles as $file ) {
931 $styleFiles[] = $this->readStyleFile( $file, $context );
933 $styles[$media] = implode(
"\n", $styleFiles );
949 $localPath = $this->getLocalPath(
$path );
950 $style = $this->getFileContents( $localPath,
'style' );
951 $styleLang = $this->getStyleSheetLang( $localPath );
953 return $this->processStyle( $style, $styleLang,
$path, $context );
973 $localPath = $this->getLocalPath(
$path );
974 $remotePath = $this->getRemotePath(
$path );
976 if ( $styleLang ===
'less' ) {
977 $style = $this->compileLessString( $style, $localPath, $context );
978 $this->hasGeneratedStyles =
true;
981 if ( $this->getFlip( $context ) ) {
982 $style = CSSJanus::transform(
987 $this->hasGeneratedStyles =
true;
990 $localDir = dirname( $localPath );
991 $remoteDir = dirname( $remotePath );
993 $localFileRefs = CSSMin::getLocalFileReferences( $style, $localDir );
994 foreach ( $localFileRefs as $file ) {
995 if ( is_file( $file ) ) {
996 $this->localFileRefs[] = $file;
998 $this->missingLocalFileRefs[] = $file;
1003 return CSSMin::remap( $style, $localDir, $remoteDir,
true );
1012 return $context->
getDirection() ===
'rtl' && !$this->noflip;
1022 $canBeStylesOnly = !(
1025 || $this->debugScripts
1027 || $this->languageScripts
1028 || $this->skinScripts
1029 || $this->dependencies
1031 || $this->skipFunction
1032 || $this->packageFiles
1034 return $canBeStylesOnly ? self::LOAD_STYLES : self::LOAD_GENERAL;
1052 $cache = MediaWikiServices::getInstance()->getObjectCacheFactory()
1056 $skinName = $context->
getSkin();
1057 $skinImportPaths = ExtensionRegistry::getInstance()->getAttribute(
'SkinLessImportPaths' );
1059 if ( isset( $skinImportPaths[ $skinName ] ) ) {
1060 $importDirs[] = $skinImportPaths[ $skinName ];
1063 $vars = $this->getLessVars( $context );
1069 'importDirs' => $importDirs,
1072 'codexDevDir' => $this->getConfig()->get( MainConfigNames::CodexDevelopmentDir )
1074 $key = $cache->makeGlobalKey(
1075 'resourceloader-less',
1077 hash(
'md4', $style ),
1078 hash(
'md4', serialize( $compilerParams ) )
1083 $data = $cache->get( $key );
1086 $data[
'hash'] !== FileContentsHasher::getFileContentsHash( $data[
'files'] )
1088 $compiler = $context->
getResourceLoader()->getLessCompiler( $vars, $importDirs );
1090 $css = $compiler->parse( $style, $stylePath )->getCss();
1094 $files = $compiler->getParsedFiles();
1097 'files' => Module::getRelativePaths( $files ),
1098 'hash' => FileContentsHasher::getFileContentsHash( $files )
1100 $cache->set( $key, $data, $cache::TTL_DAY );
1103 foreach ( Module::expandRelativePaths( $data[
'files'] ) as
$path ) {
1104 $this->localFileRefs[] =
$path;
1107 return $data[
'css'];
1118 foreach ( $this->templates as $alias => $templatePath ) {
1120 if ( is_int( $alias ) ) {
1121 $alias = $this->getPath( $templatePath );
1123 $localPath = $this->getLocalPath( $templatePath );
1124 $content = $this->getFileContents( $localPath,
'template' );
1126 $templates[$alias] = $this->stripBom( $content );
1150 private function expandPackageFiles(
Context $context ) {
1152 if ( isset( $this->expandedPackageFiles[$hash] ) ) {
1153 return $this->expandedPackageFiles[$hash];
1155 if ( $this->packageFiles ===
null ) {
1158 $expandedFiles = [];
1161 foreach ( $this->packageFiles as $key => $fileInfo ) {
1162 $expanded = $this->expandFileInfo( $context, $fileInfo,
"packageFiles[$key]" );
1163 $fileName = $expanded[
'name'];
1164 if ( !empty( $expanded[
'main'] ) ) {
1165 unset( $expanded[
'main'] );
1166 $type = $expanded[
'type'];
1167 $mainFile = $fileName;
1168 if ( $type !==
'script' && $type !==
'script-vue' ) {
1169 $msg =
"Main file in package must be of type 'script', module " .
1170 "'{$this->getName()}', main file '{$mainFile}' is '{$type}'.";
1171 $this->getLogger()->error( $msg );
1172 throw new LogicException( $msg );
1175 $expandedFiles[$fileName] = $expanded;
1178 if ( $expandedFiles && $mainFile ===
null ) {
1180 foreach ( $expandedFiles as
$path => $file ) {
1181 if ( $file[
'type'] ===
'script' || $file[
'type'] ===
'script-vue' ) {
1189 'main' => $mainFile,
1190 'files' => $expandedFiles
1193 $this->expandedPackageFiles[$hash] = $result;
1226 private function expandFileInfo( Context $context, $fileInfo, $debugKey ) {
1227 if ( is_string( $fileInfo ) ) {
1230 'name' => $fileInfo,
1231 'type' => self::getPackageFileType( $fileInfo ),
1232 'filePath' =>
new FilePath( $fileInfo, $this->localBasePath, $this->remoteBasePath )
1234 } elseif ( $fileInfo instanceof FilePath ) {
1236 'name' => $fileInfo->getPath(),
1239 } elseif ( !is_array( $fileInfo ) ) {
1240 $msg =
"Invalid type in $debugKey for module '{$this->getName()}', " .
1241 "must be array, string or FilePath";
1242 $this->getLogger()->error( $msg );
1243 throw new LogicException( $msg );
1245 if ( !isset( $fileInfo[
'name'] ) ) {
1246 $msg =
"Missing 'name' key in $debugKey for module '{$this->getName()}'";
1247 $this->getLogger()->error( $msg );
1248 throw new LogicException( $msg );
1250 $fileName = $this->getPath( $fileInfo[
'name'] );
1253 $type = $fileInfo[
'type'] ?? self::getPackageFileType( $fileName );
1255 'name' => $fileName,
1258 if ( !empty( $fileInfo[
'main'] ) ) {
1259 $expanded[
'main'] =
true;
1266 if ( isset( $fileInfo[
'content'] ) ) {
1267 $expanded[
'content'] = $fileInfo[
'content'];
1268 } elseif ( isset( $fileInfo[
'file'] ) ) {
1269 $expanded[
'filePath'] = $this->makeFilePath( $fileInfo[
'file'] );
1270 } elseif ( isset( $fileInfo[
'callback'] ) ) {
1272 $expanded[
'callbackParam'] = $fileInfo[
'callbackParam'] ??
null;
1274 if ( !is_callable( $fileInfo[
'callback'] ) ) {
1275 $msg =
"Invalid 'callback' for module '{$this->getName()}', file '{$fileName}'.";
1276 $this->getLogger()->error( $msg );
1277 throw new LogicException( $msg );
1279 if ( isset( $fileInfo[
'versionCallback'] ) ) {
1280 if ( !is_callable( $fileInfo[
'versionCallback'] ) ) {
1281 throw new LogicException(
"Invalid 'versionCallback' for "
1282 .
"module '{$this->getName()}', file '{$fileName}'."
1288 $callbackResult = ( $fileInfo[
'versionCallback'] )(
1291 $expanded[
'callbackParam']
1293 if ( $callbackResult instanceof FilePath ) {
1294 $callbackResult->initBasePaths( $this->localBasePath, $this->remoteBasePath );
1295 $expanded[
'versionFilePath'] = $callbackResult;
1297 $expanded[
'definitionSummary'] = $callbackResult;
1300 $expanded[
'callback'] = $fileInfo[
'callback'];
1303 $callbackResult = ( $fileInfo[
'callback'] )(
1306 $expanded[
'callbackParam']
1308 if ( $callbackResult instanceof FilePath ) {
1309 $callbackResult->initBasePaths( $this->localBasePath, $this->remoteBasePath );
1310 $expanded[
'filePath'] = $callbackResult;
1312 $expanded[
'content'] = $callbackResult;
1315 } elseif ( isset( $fileInfo[
'config'] ) ) {
1316 if ( $type !==
'data' ) {
1317 $msg =
"Key 'config' only valid for data files. "
1318 .
" Module '{$this->getName()}', file '{$fileName}' is '{$type}'.";
1319 $this->getLogger()->error( $msg );
1320 throw new LogicException( $msg );
1322 $expandedConfig = [];
1323 foreach ( $fileInfo[
'config'] as $configKey => $var ) {
1324 $expandedConfig[ is_numeric( $configKey ) ? $var : $configKey ] = $this->getConfig()->get( $var );
1326 $expanded[
'content'] = $expandedConfig;
1327 } elseif ( !empty( $fileInfo[
'main'] ) ) {
1329 $expanded[
'filePath'] = $this->makeFilePath( $fileName );
1331 $msg =
"Incomplete definition for module '{$this->getName()}', file '{$fileName}'. "
1332 .
"One of 'file', 'content', 'callback', or 'config' must be set.";
1333 $this->getLogger()->error( $msg );
1334 throw new LogicException( $msg );
1336 if ( !isset( $expanded[
'filePath'] ) ) {
1337 $expanded[
'virtualFilePath'] = $this->makeFilePath( $fileName );
1348 private function makeFilePath(
$path ): FilePath {
1349 if (
$path instanceof FilePath ) {
1351 } elseif ( is_string(
$path ) ) {
1352 return new FilePath(
$path, $this->localBasePath, $this->remoteBasePath );
1354 throw new InvalidArgumentException(
'$path must be either FilePath or string' );
1365 if ( $this->packageFiles ===
null ) {
1369 if ( isset( $this->fullyExpandedPackageFiles[ $hash ] ) ) {
1370 return $this->fullyExpandedPackageFiles[ $hash ];
1372 $expandedPackageFiles = $this->expandPackageFiles( $context ) ?? [];
1375 $expandedPackageFiles[
'files'] = array_map(
function ( array $fileInfo ) use ( $context ): array {
1376 return $this->readFileInfo( $context, $fileInfo );
1377 }, $expandedPackageFiles[
'files'] );
1379 $this->fullyExpandedPackageFiles[ $hash ] = $expandedPackageFiles;
1380 return $expandedPackageFiles;
1392 private function readFileInfo(
Context $context, array $fileInfo ): array {
1397 if ( !isset( $fileInfo[
'content'] ) && isset( $fileInfo[
'callback'] ) ) {
1398 $callbackResult = ( $fileInfo[
'callback'] )(
1401 $fileInfo[
'callbackParam']
1403 if ( $callbackResult instanceof
FilePath ) {
1405 $fileInfo[
'filePath'] = $callbackResult;
1407 $fileInfo[
'content'] = $callbackResult;
1409 unset( $fileInfo[
'callback'] );
1416 if ( !isset( $fileInfo[
'content'] ) && isset( $fileInfo[
'filePath'] ) ) {
1417 $localPath = $this->getLocalPath( $fileInfo[
'filePath'] );
1418 $content = $this->getFileContents( $localPath,
'package' );
1419 if ( $fileInfo[
'type'] ===
'data' ) {
1420 $content = json_decode( $content,
false, 512, JSON_THROW_ON_ERROR );
1422 $fileInfo[
'content'] = $content;
1424 if ( $fileInfo[
'type'] ===
'script-vue' ) {
1427 $fileInfo[
'content' ] = $this->parseVueContent( $context, $fileInfo[
'content' ] );
1428 }
catch ( InvalidArgumentException $e ) {
1429 $msg =
"Error parsing file '{$fileInfo['name']}' in module '{$this->getName()}': " .
1430 "{$e->getMessage()}";
1431 $this->getLogger()->error( $msg );
1432 throw new RuntimeException( $msg );
1434 $fileInfo[
'type'] =
'script+style';
1436 if ( !isset( $fileInfo[
'content'] ) ) {
1438 $msg =
"Unable to resolve contents for file {$fileInfo['name']}";
1439 $this->getLogger()->error( $msg );
1440 throw new RuntimeException( $msg );
1444 unset( $fileInfo[
'definitionSummary'] );
1446 unset( $fileInfo[
'callbackParam'] );
1462 if ( str_starts_with( $input,
"\xef\xbb\xbf" ) ) {
1463 return substr( $input, 3 );
if(!defined('MW_SETUP_CALLBACK'))
Generate hash digests of file contents to help with cache invalidation.
static getFileContentsHash( $filePaths)
Get a hash of the combined contents of one or more files, either by retrieving a previously-computed ...
A class containing constants representing the names of configuration variables.
const StylePath
Name constant for the StylePath setting, for use with Config::get()
const ExtensionAssetsPath
Name constant for the ExtensionAssetsPath setting, for use with Config::get()
const ResourceBasePath
Name constant for the ResourceBasePath setting, for use with Config::get()
This is one of the Core classes and should be read at least once by any new developers.
Context object that contains information about the state of a specific ResourceLoader web request.
getHash()
All factors that uniquely identify this request, except 'modules'.