89 private $expandedPackageFiles = [];
95 private $fullyExpandedPackageFiles = [];
162 $hasTemplates =
false;
169 foreach ( $options as $member => $option ) {
176 $this->{$member} = is_array( $option ) ? $option : [ $option ];
179 $hasTemplates =
true;
180 $this->{$member} = is_array( $option ) ? $option : [ $option ];
183 case 'languageScripts':
186 if ( !is_array( $option ) ) {
187 throw new InvalidArgumentException(
188 "Invalid collated file path list error. " .
189 "'$option' given, array expected."
192 foreach ( $option as $key => $value ) {
193 if ( !is_string( $key ) ) {
194 throw new InvalidArgumentException(
195 "Invalid collated file path list key error. " .
196 "'$key' given, string expected."
199 $this->{$member}[$key] = is_array( $value ) ? $value : [ $value ];
203 $this->deprecated = $option;
210 $option = array_values( array_unique( (array)$option ) );
213 $this->{$member} = $option;
218 $this->{$member} = (string)$option;
223 case 'skipStructureTest':
224 $this->{$member} = (bool)$option;
228 if ( isset( $options[
'scripts'] ) && isset( $options[
'packageFiles'] ) ) {
229 throw new InvalidArgumentException(
"A module may not set both 'scripts' and 'packageFiles'" );
231 if ( isset( $options[
'packageFiles'] ) && isset( $options[
'skinScripts'] ) ) {
232 throw new InvalidArgumentException(
"Options 'skinScripts' and 'packageFiles' cannot be used together." );
234 if ( $hasTemplates ) {
235 $this->dependencies[] =
'mediawiki.template';
237 foreach ( $this->templates as $alias => $templatePath ) {
238 if ( is_int( $alias ) ) {
239 $alias = $this->
getPath( $templatePath );
241 $suffix = explode(
'.', $alias );
242 $suffix = end( $suffix );
243 $compilerModule =
'mediawiki.template.' . $suffix;
244 if ( $suffix !==
'html' && !in_array( $compilerModule, $this->dependencies ) ) {
245 $this->dependencies[] = $compilerModule;
273 if ( isset( $options[
'remoteExtPath'] ) ) {
276 $remoteBasePath = $extensionAssetsPath .
'/' . $options[
'remoteExtPath'];
279 if ( isset( $options[
'remoteSkinPath'] ) ) {
285 if ( array_key_exists(
'localBasePath', $options ) ) {
289 if ( array_key_exists(
'remoteBasePath', $options ) ) {
318 static function ( array $file ): array {
319 if ( $file[
'type'] ===
'script+style' ) {
320 $file[
'content'] = $file[
'content'][
'script'];
321 $file[
'type'] =
'script';
330 $files = $this->getScriptFiles( $context );
333 fn ( $file ) => $this->readFileInfo( $context, $file ),
336 return [
'plainScripts' => $files ];
349 && !$this->packageFiles
351 && !$this->hasGeneratedScripts();
356 return $this->skipStructureTest || parent::shouldSkipStructureTest();
364 private function hasGeneratedScripts() {
366 [ $this->scripts, $this->languageScripts, $this->skinScripts, $this->debugScripts ]
370 if ( is_array( $script ) ) {
371 if ( isset( $script[
'callback'] ) || isset( $script[
'versionCallback'] ) ) {
395 if ( $file[
'type'] ===
'script+style' ) {
397 $file[
'content'][
'style'],
398 $file[
'content'][
'styleLang'],
419 if ( $this->hasGeneratedStyles ) {
422 return parent::getStyleURLsForDebug( $context );
427 foreach ( $this->
getStyleFiles( $context ) as $mediaType => $list ) {
428 $urls[$mediaType] = [];
429 foreach ( $list as $file ) {
430 $urls[$mediaType][] = OutputPage::transformResourcePath(
445 return array_merge( $this->messages, $this->lessMessages );
455 private function pluckFromMessageBlob( $blob, array $allowed ): array {
456 $data = $blob ? json_decode( $blob, true ) : [];
459 return array_intersect_key( $data, array_fill_keys( $allowed,
true ) );
466 $blob = parent::getMessageBlob( $context );
477 $reducedMessages = $this->getMessages();
478 foreach ( $this->lessMessages as $messageKey ) {
479 $i = array_search( $messageKey, $reducedMessages );
480 if ( $i !==
false ) {
481 unset( $reducedMessages[$i] );
484 return json_encode( (
object)$this->pluckFromMessageBlob( $blob, $reducedMessages ) );
508 private static function wrapAndEscapeMessage( $msg ) {
509 return str_replace(
"'",
"\'", CSSMin::serializeStringValue( $msg ) );
521 $vars = parent::getLessVars( $context );
523 if ( $this->lessMessages ) {
524 $blob = parent::getMessageBlob( $context );
525 $messages = $this->pluckFromMessageBlob( $blob, $this->lessMessages );
532 foreach ( $this->lessMessages as $msgKey ) {
533 $vars[
'msg-' . $msgKey] = self::wrapAndEscapeMessage( $messages[$msgKey] ??
"â§¼{$msgKey}â§½" );
556 return $this->dependencies;
566 private function getFileContents( $localPath, $type ) {
567 if ( !is_file( $localPath ) ) {
568 throw new RuntimeException(
"$type file not found or not a file: \"$localPath\"" );
570 return $this->stripBom( file_get_contents( $localPath ) );
577 if ( !$this->skipFunction ) {
580 $localPath = $this->getLocalPath( $this->skipFunction );
581 return $this->getFileContents( $localPath,
'skip function' );
607 private function getFileHashes(
Context $context ) {
610 foreach ( $this->getStyleFiles( $context ) as $filePaths ) {
611 foreach ( $filePaths as $filePath ) {
612 $files[] = $this->getLocalPath( $filePath );
619 $expandedPackageFiles = $this->expandPackageFiles( $context );
620 if ( $expandedPackageFiles ) {
621 foreach ( $expandedPackageFiles[
'files'] as $fileInfo ) {
622 $filePath = $fileInfo[
'filePath'] ?? $fileInfo[
'versionFilePath'] ??
null;
623 if ( $filePath instanceof FilePath ) {
624 $files[] = $filePath->getLocalPath();
630 $scriptFileInfos = $this->getScriptFiles( $context );
631 foreach ( $scriptFileInfos as $fileInfo ) {
632 $filePath = $fileInfo[
'filePath'] ?? $fileInfo[
'versionFilePath'] ??
null;
633 if ( $filePath instanceof FilePath ) {
634 $files[] = $filePath->getLocalPath();
638 foreach ( $this->templates as $filePath ) {
639 $files[] = $this->getLocalPath( $filePath );
642 if ( $this->skipFunction ) {
643 $files[] = $this->getLocalPath( $this->skipFunction );
654 $files = array_unique( $files );
660 return FileContentsHasher::getFileContentsHash( $files );
670 $summary = parent::getDefinitionSummary( $context );
687 $options[$member] = $this->{$member};
690 $packageFiles = $this->expandPackageFiles( $context );
691 $packageSummaries = [];
693 if ( $packageFiles ) {
702 $packageMain = $packageFiles[
'main'];
703 foreach ( $packageFiles[
'files'] as $fileName => $fileInfo ) {
704 $packageSummaries[$fileName] =
705 $fileInfo[
'definitionSummary'] ?? $fileInfo[
'content'] ??
null;
709 $scriptFiles = $this->getScriptFiles( $context );
710 $scriptSummaries = [];
711 foreach ( $scriptFiles as $fileName => $fileInfo ) {
712 $scriptSummaries[$fileName] =
713 $fileInfo[
'definitionSummary'] ?? $fileInfo[
'content'] ??
null;
717 'options' => $options,
718 'packageFiles' => $packageSummaries,
719 'packageMain' => $packageMain,
720 'scripts' => $scriptSummaries,
721 'fileHashes' => $this->getFileHashes( $context ),
722 'messageBlob' => $this->getMessageBlob( $context ),
725 $lessVars = $this->getLessVars( $context );
727 $summary[] = [
'lessVars' => $lessVars ];
739 return $path->getPath();
751 if (
$path->getLocalBasePath() !==
null ) {
752 return $path->getLocalPath();
757 return "{$this->localBasePath}/$path";
766 if (
$path->getRemoteBasePath() !==
null ) {
767 return $path->getRemotePath();
772 if ( $this->remoteBasePath ===
'/' ) {
775 return "{$this->remoteBasePath}/$path";
787 return preg_match(
'/\.less$/i',
$path ) ?
'less' :
'css';
797 if ( preg_match(
'/\.json$/i',
$path ) ) {
800 if ( preg_match(
'/\.vue$/i',
$path ) ) {
813 private static function collateStyleFilesByMedia( array $list ) {
815 foreach ( $list as $key => $value ) {
816 if ( is_int( $key ) ) {
818 $collatedFiles[
'all'][] = $value;
819 } elseif ( is_array( $value ) ) {
821 $optionValue = $value[
'media'] ??
'all';
822 $collatedFiles[$optionValue][] = $key;
825 return $collatedFiles;
838 if ( isset( $list[$key] ) && is_array( $list[$key] ) ) {
855 private function getScriptFiles(
Context $context ): array {
859 'scripts' => $this->scripts,
860 'languageScripts' => $this->getLanguageScripts( $context->getLanguage() ),
861 'skinScripts' => self::tryForKey( $this->skinScripts, $context->getSkin(),
'default' ),
864 $filesByCategory[
'debugScripts'] = $this->debugScripts;
868 foreach ( $filesByCategory as $category => $files ) {
869 foreach ( $files as $key => $fileInfo ) {
870 $expandedFileInfo = $this->expandFileInfo( $context, $fileInfo,
"$category\[$key]" );
871 $expandedFiles[$expandedFileInfo[
'name']] = $expandedFileInfo;
875 return $expandedFiles;
885 private function getLanguageScripts(
string $lang ): array {
886 $scripts = self::tryForKey( $this->languageScripts, $lang );
893 if ( $this->languageScripts ) {
894 $fallbacks = MediaWikiServices::getInstance()
895 ->getLanguageFallback()
896 ->getAll( $lang, LanguageFallbackMode::MESSAGES );
897 foreach ( $fallbacks as $lang ) {
898 $scripts = self::tryForKey( $this->languageScripts, $lang );
909 $moduleName = $this->getName();
910 foreach ( $moduleSkinStyles as $skinName => $overrides ) {
913 if ( isset( $this->skinStyles[$skinName] ) ) {
919 if ( isset( $overrides[$moduleName] ) ) {
920 $paths = (array)$overrides[$moduleName];
922 } elseif ( isset( $overrides[
'+' . $moduleName] ) ) {
923 $paths = (array)$overrides[
'+' . $moduleName];
924 $styleFiles = isset( $this->skinStyles[
'default'] ) ?
925 (array)$this->skinStyles[
'default'] :
933 [ $localBasePath, $remoteBasePath ] = self::extractBasePaths( $overrides );
935 foreach ( $paths as
$path ) {
936 $styleFiles[] =
new FilePath(
$path, $localBasePath, $remoteBasePath );
939 $this->skinStyles[$skinName] = $styleFiles;
951 return array_merge_recursive(
952 self::collateStyleFilesByMedia( $this->styles ),
953 self::collateStyleFilesByMedia(
954 self::tryForKey( $this->skinStyles, $context->
getSkin(),
'default' )
967 return self::collateStyleFilesByMedia(
968 self::tryForKey( $this->skinStyles, $skinName )
979 $skinFactory = MediaWikiServices::getInstance()->getSkinFactory();
982 $internalSkinNames = array_keys( $skinFactory->getInstalledSkins() );
983 $internalSkinNames[] =
'default';
985 foreach ( $internalSkinNames as $internalSkinName ) {
986 $styleFiles = array_merge_recursive(
988 $this->getSkinStyleFiles( $internalSkinName )
1001 $collatedStyleFiles = array_merge_recursive(
1002 self::collateStyleFilesByMedia( $this->styles ),
1003 $this->getAllSkinStyleFiles()
1008 foreach ( $collatedStyleFiles as $styleFiles ) {
1009 foreach ( $styleFiles as $styleFile ) {
1010 $result[] = $this->getLocalPath( $styleFile );
1029 foreach ( $styles as $media => $files ) {
1030 $uniqueFiles = array_unique( $files, SORT_REGULAR );
1032 foreach ( $uniqueFiles as $file ) {
1033 $styleFiles[] = $this->readStyleFile( $file, $context );
1035 $styles[$media] = implode(
"\n", $styleFiles );
1051 $localPath = $this->getLocalPath(
$path );
1052 $style = $this->getFileContents( $localPath,
'style' );
1053 $styleLang = $this->getStyleSheetLang( $localPath );
1055 return $this->processStyle( $style, $styleLang,
$path, $context );
1075 $localPath = $this->getLocalPath(
$path );
1076 $remotePath = $this->getRemotePath(
$path );
1078 if ( $styleLang ===
'less' ) {
1079 $style = $this->compileLessString( $style, $localPath, $context );
1080 $this->hasGeneratedStyles =
true;
1083 if ( $this->getFlip( $context ) ) {
1084 $style = CSSJanus::transform(
1089 $this->hasGeneratedStyles =
true;
1092 $localDir = dirname( $localPath );
1093 $remoteDir = dirname( $remotePath );
1095 $localFileRefs = CSSMin::getLocalFileReferences( $style, $localDir );
1096 foreach ( $localFileRefs as $file ) {
1097 if ( is_file( $file ) ) {
1098 $this->localFileRefs[] = $file;
1100 $this->missingLocalFileRefs[] = $file;
1105 return CSSMin::remap( $style, $localDir, $remoteDir,
true );
1114 return $context->
getDirection() ===
'rtl' && !$this->noflip;
1124 $canBeStylesOnly = !(
1127 || $this->debugScripts
1129 || $this->languageScripts
1130 || $this->skinScripts
1131 || $this->dependencies
1133 || $this->skipFunction
1134 || $this->packageFiles
1136 return $canBeStylesOnly ? self::LOAD_STYLES : self::LOAD_GENERAL;
1154 $cache = MediaWikiServices::getInstance()->getObjectCacheFactory()
1158 $skinName = $context->
getSkin();
1159 $skinImportPaths = ExtensionRegistry::getInstance()->getAttribute(
'SkinLessImportPaths' );
1161 if ( isset( $skinImportPaths[ $skinName ] ) ) {
1162 $importDirs[] = $skinImportPaths[ $skinName ];
1165 $vars = $this->getLessVars( $context );
1171 'importDirs' => $importDirs,
1174 'codexDevDir' => $this->getConfig()->get( MainConfigNames::CodexDevelopmentDir )
1176 $key = $cache->makeGlobalKey(
1177 'resourceloader-less',
1179 hash(
'md4', $style ),
1180 hash(
'md4', serialize( $compilerParams ) )
1185 $data = $cache->get( $key );
1187 $files = $data ? Module::expandRelativePaths( $data[
'files'] ) :
false;
1191 $data[
'hash'] !== FileContentsHasher::getFileContentsHash( $files )
1193 $compiler = $context->
getResourceLoader()->getLessCompiler( $vars, $importDirs );
1195 $css = $compiler->parse( $style, $stylePath )->getCss();
1196 $files = $compiler->getParsedFiles();
1199 'files' => Module::getRelativePaths( $files ),
1203 'hash' => FileContentsHasher::getFileContentsHash( $files )
1205 $cache->set( $key, $data, $cache::TTL_DAY );
1208 foreach ( $files as
$path ) {
1209 $this->localFileRefs[] =
$path;
1212 return $data[
'css'];
1223 foreach ( $this->templates as $alias => $templatePath ) {
1225 if ( is_int( $alias ) ) {
1226 $alias = $this->getPath( $templatePath );
1228 $localPath = $this->getLocalPath( $templatePath );
1229 $content = $this->getFileContents( $localPath,
'template' );
1231 $templates[$alias] = $this->stripBom( $content );
1255 private function expandPackageFiles(
Context $context ) {
1257 if ( isset( $this->expandedPackageFiles[$hash] ) ) {
1258 return $this->expandedPackageFiles[$hash];
1260 if ( $this->packageFiles ===
null ) {
1263 $expandedFiles = [];
1266 foreach ( $this->packageFiles as $key => $fileInfo ) {
1267 $expanded = $this->expandFileInfo( $context, $fileInfo,
"packageFiles[$key]" );
1268 $fileName = $expanded[
'name'];
1269 if ( !empty( $expanded[
'main'] ) ) {
1270 unset( $expanded[
'main'] );
1271 $type = $expanded[
'type'];
1272 $mainFile = $fileName;
1273 if ( $type !==
'script' && $type !==
'script-vue' ) {
1274 $msg =
"Main file in package must be of type 'script', module " .
1275 "'{$this->getName()}', main file '{$mainFile}' is '{$type}'.";
1276 $this->getLogger()->error( $msg );
1277 throw new LogicException( $msg );
1280 $expandedFiles[$fileName] = $expanded;
1283 if ( $expandedFiles && $mainFile ===
null ) {
1285 foreach ( $expandedFiles as
$path => $file ) {
1286 if ( $file[
'type'] ===
'script' || $file[
'type'] ===
'script-vue' ) {
1294 'main' => $mainFile,
1295 'files' => $expandedFiles
1298 $this->expandedPackageFiles[$hash] = $result;
1331 private function expandFileInfo( Context $context, $fileInfo, $debugKey ) {
1332 if ( is_string( $fileInfo ) ) {
1335 'name' => $fileInfo,
1336 'type' => self::getPackageFileType( $fileInfo ),
1337 'filePath' =>
new FilePath( $fileInfo, $this->localBasePath, $this->remoteBasePath )
1339 } elseif ( $fileInfo instanceof FilePath ) {
1341 'name' => $fileInfo->getPath(),
1344 } elseif ( !is_array( $fileInfo ) ) {
1345 $msg =
"Invalid type in $debugKey for module '{$this->getName()}', " .
1346 "must be array, string or FilePath";
1347 $this->getLogger()->error( $msg );
1348 throw new LogicException( $msg );
1350 if ( !isset( $fileInfo[
'name'] ) ) {
1351 $msg =
"Missing 'name' key in $debugKey for module '{$this->getName()}'";
1352 $this->getLogger()->error( $msg );
1353 throw new LogicException( $msg );
1355 $fileName = $this->getPath( $fileInfo[
'name'] );
1358 $type = $fileInfo[
'type'] ?? self::getPackageFileType( $fileName );
1360 'name' => $fileName,
1363 if ( !empty( $fileInfo[
'main'] ) ) {
1364 $expanded[
'main'] =
true;
1371 if ( isset( $fileInfo[
'content'] ) ) {
1372 $expanded[
'content'] = $fileInfo[
'content'];
1373 } elseif ( isset( $fileInfo[
'file'] ) ) {
1374 $expanded[
'filePath'] = $this->makeFilePath( $fileInfo[
'file'] );
1375 } elseif ( isset( $fileInfo[
'callback'] ) ) {
1377 $expanded[
'callbackParam'] = $fileInfo[
'callbackParam'] ??
null;
1379 if ( !is_callable( $fileInfo[
'callback'] ) ) {
1380 $msg =
"Invalid 'callback' for module '{$this->getName()}', file '{$fileName}'.";
1381 $this->getLogger()->error( $msg );
1382 throw new LogicException( $msg );
1384 if ( isset( $fileInfo[
'versionCallback'] ) ) {
1385 if ( !is_callable( $fileInfo[
'versionCallback'] ) ) {
1386 throw new LogicException(
"Invalid 'versionCallback' for "
1387 .
"module '{$this->getName()}', file '{$fileName}'."
1393 $callbackResult = ( $fileInfo[
'versionCallback'] )(
1396 $expanded[
'callbackParam']
1398 if ( $callbackResult instanceof FilePath ) {
1399 $callbackResult->initBasePaths( $this->localBasePath, $this->remoteBasePath );
1400 $expanded[
'versionFilePath'] = $callbackResult;
1402 $expanded[
'definitionSummary'] = $callbackResult;
1405 $expanded[
'callback'] = $fileInfo[
'callback'];
1408 $callbackResult = ( $fileInfo[
'callback'] )(
1411 $expanded[
'callbackParam']
1413 if ( $callbackResult instanceof FilePath ) {
1414 $callbackResult->initBasePaths( $this->localBasePath, $this->remoteBasePath );
1415 $expanded[
'filePath'] = $callbackResult;
1417 $expanded[
'content'] = $callbackResult;
1420 } elseif ( isset( $fileInfo[
'config'] ) ) {
1421 if ( $type !==
'data' ) {
1422 $msg =
"Key 'config' only valid for data files. "
1423 .
" Module '{$this->getName()}', file '{$fileName}' is '{$type}'.";
1424 $this->getLogger()->error( $msg );
1425 throw new LogicException( $msg );
1427 $expandedConfig = [];
1428 foreach ( $fileInfo[
'config'] as $configKey => $var ) {
1429 $expandedConfig[ is_numeric( $configKey ) ? $var : $configKey ] = $this->
getConfig()->get( $var );
1431 $expanded[
'content'] = $expandedConfig;
1432 } elseif ( !empty( $fileInfo[
'main'] ) ) {
1434 $expanded[
'filePath'] = $this->makeFilePath( $fileName );
1436 $msg =
"Incomplete definition for module '{$this->getName()}', file '{$fileName}'. "
1437 .
"One of 'file', 'content', 'callback', or 'config' must be set.";
1438 $this->getLogger()->error( $msg );
1439 throw new LogicException( $msg );
1441 if ( !isset( $expanded[
'filePath'] ) ) {
1442 $expanded[
'virtualFilePath'] = $this->makeFilePath( $fileName );
1453 private function makeFilePath(
$path ): FilePath {
1454 if (
$path instanceof FilePath ) {
1456 } elseif ( is_string(
$path ) ) {
1457 return new FilePath(
$path, $this->localBasePath, $this->remoteBasePath );
1459 throw new InvalidArgumentException(
'$path must be either FilePath or string' );
1470 if ( $this->packageFiles ===
null ) {
1474 if ( isset( $this->fullyExpandedPackageFiles[ $hash ] ) ) {
1475 return $this->fullyExpandedPackageFiles[ $hash ];
1477 $expandedPackageFiles = $this->expandPackageFiles( $context ) ?? [];
1480 $expandedPackageFiles[
'files'] = array_map(
function ( array $fileInfo ) use ( $context ): array {
1481 return $this->readFileInfo( $context, $fileInfo );
1482 }, $expandedPackageFiles[
'files'] );
1484 $this->fullyExpandedPackageFiles[ $hash ] = $expandedPackageFiles;
1485 return $expandedPackageFiles;
1497 private function readFileInfo(
Context $context, array $fileInfo ): array {
1502 if ( !isset( $fileInfo[
'content'] ) && isset( $fileInfo[
'callback'] ) ) {
1503 $callbackResult = ( $fileInfo[
'callback'] )(
1506 $fileInfo[
'callbackParam']
1508 if ( $callbackResult instanceof
FilePath ) {
1510 $fileInfo[
'filePath'] = $callbackResult;
1512 $fileInfo[
'content'] = $callbackResult;
1514 unset( $fileInfo[
'callback'] );
1521 if ( !isset( $fileInfo[
'content'] ) && isset( $fileInfo[
'filePath'] ) ) {
1522 $localPath = $this->getLocalPath( $fileInfo[
'filePath'] );
1523 $content = $this->getFileContents( $localPath,
'package' );
1524 if ( $fileInfo[
'type'] ===
'data' ) {
1525 $content = json_decode( $content,
false, 512, JSON_THROW_ON_ERROR );
1527 $fileInfo[
'content'] = $content;
1529 if ( $fileInfo[
'type'] ===
'script-vue' ) {
1531 $fileInfo[
'content' ] = $this->parseVueContent( $context, $fileInfo[
'content' ] );
1532 }
catch ( InvalidArgumentException $e ) {
1533 $msg =
"Error parsing file '{$fileInfo['name']}' in module '{$this->getName()}': " .
1534 "{$e->getMessage()}";
1535 $this->getLogger()->error( $msg );
1536 throw new RuntimeException( $msg );
1538 $fileInfo[
'type'] =
'script+style';
1540 if ( !isset( $fileInfo[
'content'] ) ) {
1542 $msg =
"Unable to resolve contents for file {$fileInfo['name']}";
1543 $this->getLogger()->error( $msg );
1544 throw new RuntimeException( $msg );
1548 unset( $fileInfo[
'definitionSummary'] );
1550 unset( $fileInfo[
'callbackParam'] );
1566 if ( str_starts_with( $input,
"\xef\xbb\xbf" ) ) {
1567 return substr( $input, 3 );