1<?php 2 3namespace Drupal\Core\Asset; 4 5use Drupal\Core\Asset\Exception\IncompleteLibraryDefinitionException; 6use Drupal\Core\Asset\Exception\InvalidLibrariesOverrideSpecificationException; 7use Drupal\Core\Asset\Exception\InvalidLibraryFileException; 8use Drupal\Core\Asset\Exception\LibraryDefinitionMissingLicenseException; 9use Drupal\Core\Extension\ModuleHandlerInterface; 10use Drupal\Core\Serialization\Yaml; 11use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface; 12use Drupal\Core\Theme\ThemeManagerInterface; 13use Drupal\Component\Serialization\Exception\InvalidDataTypeException; 14use Drupal\Component\Utility\NestedArray; 15 16/** 17 * Parses library files to get extension data. 18 */ 19class LibraryDiscoveryParser { 20 21 /** 22 * The module handler. 23 * 24 * @var \Drupal\Core\Extension\ModuleHandlerInterface 25 */ 26 protected $moduleHandler; 27 28 /** 29 * The theme manager. 30 * 31 * @var \Drupal\Core\Theme\ThemeManagerInterface 32 */ 33 protected $themeManager; 34 35 /** 36 * The app root. 37 * 38 * @var string 39 */ 40 protected $root; 41 42 /** 43 * The stream wrapper manager. 44 * 45 * @var \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface 46 */ 47 protected $streamWrapperManager; 48 49 /** 50 * The libraries directory file finder. 51 * 52 * @var \Drupal\Core\Asset\LibrariesDirectoryFileFinder 53 */ 54 protected $librariesDirectoryFileFinder; 55 56 /** 57 * Constructs a new LibraryDiscoveryParser instance. 58 * 59 * @param string $root 60 * The app root. 61 * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler 62 * The module handler. 63 * @param \Drupal\Core\Theme\ThemeManagerInterface $theme_manager 64 * The theme manager. 65 * @param \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface $stream_wrapper_manager 66 * The stream wrapper manager. 67 * @param \Drupal\Core\Asset\LibrariesDirectoryFileFinder $libraries_directory_file_finder 68 * The libraries directory file finder. 69 */ 70 public function __construct($root, ModuleHandlerInterface $module_handler, ThemeManagerInterface $theme_manager, StreamWrapperManagerInterface $stream_wrapper_manager, LibrariesDirectoryFileFinder $libraries_directory_file_finder = NULL) { 71 $this->root = $root; 72 $this->moduleHandler = $module_handler; 73 $this->themeManager = $theme_manager; 74 $this->streamWrapperManager = $stream_wrapper_manager; 75 if (!$libraries_directory_file_finder) { 76 @trigger_error('Calling LibraryDiscoveryParser::__construct() without the $libraries_directory_file_finder argument is deprecated in drupal:8.9.0. The $libraries_directory_file_finder argument will be required in drupal:10.0.0. See https://www.drupal.org/node/3099614', E_USER_DEPRECATED); 77 $libraries_directory_file_finder = \Drupal::service('library.libraries_directory_file_finder'); 78 } 79 $this->librariesDirectoryFileFinder = $libraries_directory_file_finder; 80 } 81 82 /** 83 * Parses and builds up all the libraries information of an extension. 84 * 85 * @param string $extension 86 * The name of the extension that registered a library. 87 * 88 * @return array 89 * All library definitions of the passed extension. 90 * 91 * @throws \Drupal\Core\Asset\Exception\IncompleteLibraryDefinitionException 92 * Thrown when a library has no js/css/setting. 93 * @throws \UnexpectedValueException 94 * Thrown when a js file defines a positive weight. 95 */ 96 public function buildByExtension($extension) { 97 if ($extension === 'core') { 98 $path = 'core'; 99 $extension_type = 'core'; 100 } 101 else { 102 if ($this->moduleHandler->moduleExists($extension)) { 103 $extension_type = 'module'; 104 } 105 else { 106 $extension_type = 'theme'; 107 } 108 $path = $this->drupalGetPath($extension_type, $extension); 109 } 110 111 $libraries = $this->parseLibraryInfo($extension, $path); 112 $libraries = $this->applyLibrariesOverride($libraries, $extension); 113 114 foreach ($libraries as $id => &$library) { 115 if (!isset($library['js']) && !isset($library['css']) && !isset($library['drupalSettings']) && !isset($library['dependencies'])) { 116 throw new IncompleteLibraryDefinitionException(sprintf("Incomplete library definition for definition '%s' in extension '%s'", $id, $extension)); 117 } 118 $library += ['dependencies' => [], 'js' => [], 'css' => []]; 119 120 if (isset($library['header']) && !is_bool($library['header'])) { 121 throw new \LogicException(sprintf("The 'header' key in the library definition '%s' in extension '%s' is invalid: it must be a boolean.", $id, $extension)); 122 } 123 124 if (isset($library['version'])) { 125 // @todo Retrieve version of a non-core extension. 126 if ($library['version'] === 'VERSION') { 127 $library['version'] = \Drupal::VERSION; 128 } 129 // Remove 'v' prefix from external library versions. 130 elseif (is_string($library['version']) && $library['version'][0] === 'v') { 131 $library['version'] = substr($library['version'], 1); 132 } 133 } 134 135 // If this is a 3rd party library, the license info is required. 136 if (isset($library['remote']) && !isset($library['license'])) { 137 throw new LibraryDefinitionMissingLicenseException(sprintf("Missing license information in library definition for definition '%s' extension '%s': it has a remote, but no license.", $id, $extension)); 138 } 139 140 // Assign Drupal's license to libraries that don't have license info. 141 if (!isset($library['license'])) { 142 $library['license'] = [ 143 'name' => 'GNU-GPL-2.0-or-later', 144 'url' => 'https://www.drupal.org/licensing/faq', 145 'gpl-compatible' => TRUE, 146 ]; 147 } 148 149 foreach (['js', 'css'] as $type) { 150 // Prepare (flatten) the SMACSS-categorized definitions. 151 // @todo After Asset(ic) changes, retain the definitions as-is and 152 // properly resolve dependencies for all (css) libraries per category, 153 // and only once prior to rendering out an HTML page. 154 if ($type == 'css' && !empty($library[$type])) { 155 assert(static::validateCssLibrary($library[$type]) < 2, 'CSS files should be specified as key/value pairs, where the values are configuration options. See https://www.drupal.org/node/2274843.'); 156 assert(static::validateCssLibrary($library[$type]) === 0, 'CSS must be nested under a category. See https://www.drupal.org/node/2274843.'); 157 foreach ($library[$type] as $category => $files) { 158 $category_weight = 'CSS_' . strtoupper($category); 159 assert(defined($category_weight), 'Invalid CSS category: ' . $category . '. See https://www.drupal.org/node/2274843.'); 160 foreach ($files as $source => $options) { 161 if (!isset($options['weight'])) { 162 $options['weight'] = 0; 163 } 164 // Apply the corresponding weight defined by CSS_* constants. 165 $options['weight'] += constant($category_weight); 166 $library[$type][$source] = $options; 167 } 168 unset($library[$type][$category]); 169 } 170 } 171 foreach ($library[$type] as $source => $options) { 172 unset($library[$type][$source]); 173 // Allow to omit the options hashmap in YAML declarations. 174 if (!is_array($options)) { 175 $options = []; 176 } 177 if ($type == 'js' && isset($options['weight']) && $options['weight'] > 0) { 178 throw new \UnexpectedValueException("The $extension/$id library defines a positive weight for '$source'. Only negative weights are allowed (but should be avoided). Instead of a positive weight, specify accurate dependencies for this library."); 179 } 180 // Unconditionally apply default groups for the defined asset files. 181 // The library system is a dependency management system. Each library 182 // properly specifies its dependencies instead of relying on a custom 183 // processing order. 184 if ($type == 'js') { 185 $options['group'] = JS_LIBRARY; 186 } 187 elseif ($type == 'css') { 188 $options['group'] = $extension_type == 'theme' ? CSS_AGGREGATE_THEME : CSS_AGGREGATE_DEFAULT; 189 } 190 // By default, all library assets are files. 191 if (!isset($options['type'])) { 192 $options['type'] = 'file'; 193 } 194 if ($options['type'] == 'external') { 195 $options['data'] = $source; 196 } 197 // Determine the file asset URI. 198 else { 199 if ($source[0] === '/') { 200 // An absolute path maps to DRUPAL_ROOT / base_path(). 201 if ($source[1] !== '/') { 202 $source = substr($source, 1); 203 // Non core provided libraries can be in multiple locations. 204 if (strpos($source, 'libraries/') === 0) { 205 $path_to_source = $this->librariesDirectoryFileFinder->find(substr($source, 10)); 206 if ($path_to_source) { 207 $source = $path_to_source; 208 } 209 } 210 $options['data'] = $source; 211 } 212 // A protocol-free URI (e.g., //cdn.com/example.js) is external. 213 else { 214 $options['type'] = 'external'; 215 $options['data'] = $source; 216 } 217 } 218 // A stream wrapper URI (e.g., public://generated_js/example.js). 219 elseif ($this->streamWrapperManager->isValidUri($source)) { 220 $options['data'] = $source; 221 } 222 // A regular URI (e.g., http://example.com/example.js) without 223 // 'external' explicitly specified, which may happen if, e.g. 224 // libraries-override is used. 225 elseif ($this->isValidUri($source)) { 226 $options['type'] = 'external'; 227 $options['data'] = $source; 228 } 229 // By default, file paths are relative to the registering extension. 230 else { 231 $options['data'] = $path . '/' . $source; 232 } 233 } 234 235 if (!isset($library['version'])) { 236 // @todo Get the information from the extension. 237 $options['version'] = -1; 238 } 239 else { 240 $options['version'] = $library['version']; 241 } 242 243 // Set the 'minified' flag on JS file assets, default to FALSE. 244 if ($type == 'js' && $options['type'] == 'file') { 245 $options['minified'] = isset($options['minified']) ? $options['minified'] : FALSE; 246 } 247 248 $library[$type][] = $options; 249 } 250 } 251 } 252 253 return $libraries; 254 } 255 256 /** 257 * Parses a given library file and allows modules and themes to alter it. 258 * 259 * This method sets the parsed information onto the library property. 260 * 261 * Library information is parsed from *.libraries.yml files; see 262 * editor.libraries.yml for an example. Every library must have at least one 263 * js or css entry. Each entry starts with a machine name and defines the 264 * following elements: 265 * - js: A list of JavaScript files to include. Each file is keyed by the file 266 * path. An item can have several attributes (like HTML 267 * attributes). For example: 268 * @code 269 * js: 270 * path/js/file.js: { attributes: { defer: true } } 271 * @endcode 272 * If the file has no special attributes, just use an empty object: 273 * @code 274 * js: 275 * path/js/file.js: {} 276 * @endcode 277 * The path of the file is relative to the module or theme directory, unless 278 * it starts with a /, in which case it is relative to the Drupal root. If 279 * the file path starts with //, it will be treated as a protocol-free, 280 * external resource (e.g., //cdn.com/library.js). Full URLs 281 * (e.g., http://cdn.com/library.js) as well as URLs that use a valid 282 * stream wrapper (e.g., public://path/to/file.js) are also supported. 283 * - css: A list of categories for which the library provides CSS files. The 284 * available categories are: 285 * - base 286 * - layout 287 * - component 288 * - state 289 * - theme 290 * Each category is itself a key for a sub-list of CSS files to include: 291 * @code 292 * css: 293 * component: 294 * css/file.css: {} 295 * @endcode 296 * Just like with JavaScript files, each CSS file is the key of an object 297 * that can define specific attributes. The format of the file path is the 298 * same as for the JavaScript files. 299 * If the JavaScript or CSS file starts with /libraries/ the 300 * library.libraries_directory_file_finder service is used to find the files 301 * in the following locations: 302 * - A libraries directory in the current site directory, for example: 303 * sites/default/libraries. 304 * - The root libraries directory. 305 * - A libraries directory in the selected installation profile, for 306 * example: profiles/my_install_profile/libraries. 307 * - dependencies: A list of libraries this library depends on. 308 * - version: The library version. The string "VERSION" can be used to mean 309 * the current Drupal core version. 310 * - header: By default, JavaScript files are included in the footer. If the 311 * script must be included in the header (along with all its dependencies), 312 * set this to true. Defaults to false. 313 * - minified: If the file is already minified, set this to true to avoid 314 * minifying it again. Defaults to false. 315 * - remote: If the library is a third-party script, this provides the 316 * repository URL for reference. 317 * - license: If the remote property is set, the license information is 318 * required. It has 3 properties: 319 * - name: The human-readable name of the license. 320 * - url: The URL of the license file/information for the version of the 321 * library used. 322 * - gpl-compatible: A Boolean for whether this library is GPL compatible. 323 * 324 * See https://www.drupal.org/node/2274843#define-library for more 325 * information. 326 * 327 * @param string $extension 328 * The name of the extension that registered a library. 329 * @param string $path 330 * The relative path to the extension. 331 * 332 * @return array 333 * An array of parsed library data. 334 * 335 * @throws \Drupal\Core\Asset\Exception\InvalidLibraryFileException 336 * Thrown when a parser exception got thrown. 337 */ 338 protected function parseLibraryInfo($extension, $path) { 339 $libraries = []; 340 341 $library_file = $path . '/' . $extension . '.libraries.yml'; 342 if (file_exists($this->root . '/' . $library_file)) { 343 try { 344 $libraries = Yaml::decode(file_get_contents($this->root . '/' . $library_file)) ?? []; 345 } 346 catch (InvalidDataTypeException $e) { 347 // Rethrow a more helpful exception to provide context. 348 throw new InvalidLibraryFileException(sprintf('Invalid library definition in %s: %s', $library_file, $e->getMessage()), 0, $e); 349 } 350 } 351 352 // Allow modules to add dynamic library definitions. 353 $hook = 'library_info_build'; 354 if ($this->moduleHandler->implementsHook($extension, $hook)) { 355 $libraries = NestedArray::mergeDeep($libraries, $this->moduleHandler->invoke($extension, $hook)); 356 } 357 358 // Allow modules to alter the module's registered libraries. 359 $this->moduleHandler->alter('library_info', $libraries, $extension); 360 $this->themeManager->alter('library_info', $libraries, $extension); 361 362 return $libraries; 363 } 364 365 /** 366 * Apply libraries overrides specified for the current active theme. 367 * 368 * @param array $libraries 369 * The libraries definitions. 370 * @param string $extension 371 * The extension in which these libraries are defined. 372 * 373 * @return array 374 * The modified libraries definitions. 375 */ 376 protected function applyLibrariesOverride($libraries, $extension) { 377 $active_theme = $this->themeManager->getActiveTheme(); 378 // ActiveTheme::getLibrariesOverride() returns libraries-overrides for the 379 // current theme as well as all its base themes. 380 $all_libraries_overrides = $active_theme->getLibrariesOverride(); 381 foreach ($all_libraries_overrides as $theme_path => $libraries_overrides) { 382 foreach ($libraries as $library_name => $library) { 383 // Process libraries overrides. 384 if (isset($libraries_overrides["$extension/$library_name"])) { 385 if (isset($library['deprecated'])) { 386 $override_message = sprintf('Theme "%s" is overriding a deprecated library.', $extension); 387 $library_deprecation = str_replace('%library_id%', "$extension/$library_name", $library['deprecated']); 388 @trigger_error("$override_message $library_deprecation", E_USER_DEPRECATED); 389 } 390 // Active theme defines an override for this library. 391 $override_definition = $libraries_overrides["$extension/$library_name"]; 392 if (is_string($override_definition) || $override_definition === FALSE) { 393 // A string or boolean definition implies an override (or removal) 394 // for the whole library. Use the override key to specify that this 395 // library will be overridden when it is called. 396 // @see \Drupal\Core\Asset\LibraryDiscovery::getLibraryByName() 397 if ($override_definition) { 398 $libraries[$library_name]['override'] = $override_definition; 399 } 400 else { 401 $libraries[$library_name]['override'] = FALSE; 402 } 403 } 404 elseif (is_array($override_definition)) { 405 // An array definition implies an override for an asset within this 406 // library. 407 foreach ($override_definition as $sub_key => $value) { 408 // Throw an exception if the asset is not properly specified. 409 if (!is_array($value)) { 410 throw new InvalidLibrariesOverrideSpecificationException(sprintf('Library asset %s is not correctly specified. It should be in the form "extension/library_name/sub_key/path/to/asset.js".', "$extension/$library_name/$sub_key")); 411 } 412 if ($sub_key === 'drupalSettings') { 413 // drupalSettings may not be overridden. 414 throw new InvalidLibrariesOverrideSpecificationException(sprintf('drupalSettings may not be overridden in libraries-override. Trying to override %s. Use hook_library_info_alter() instead.', "$extension/$library_name/$sub_key")); 415 } 416 elseif ($sub_key === 'css') { 417 // SMACSS category should be incorporated into the asset name. 418 foreach ($value as $category => $overrides) { 419 $this->setOverrideValue($libraries[$library_name], [$sub_key, $category], $overrides, $theme_path); 420 } 421 } 422 else { 423 $this->setOverrideValue($libraries[$library_name], [$sub_key], $value, $theme_path); 424 } 425 } 426 } 427 } 428 } 429 } 430 431 return $libraries; 432 } 433 434 /** 435 * Wraps drupal_get_path(). 436 */ 437 protected function drupalGetPath($type, $name) { 438 return drupal_get_path($type, $name); 439 } 440 441 /** 442 * Determines if the supplied string is a valid URI. 443 */ 444 protected function isValidUri($string) { 445 return count(explode('://', $string)) === 2; 446 } 447 448 /** 449 * Overrides the specified library asset. 450 * 451 * @param array $library 452 * The containing library definition. 453 * @param array $sub_key 454 * An array containing the sub-keys specifying the library asset, e.g. 455 * ['js'] or ['css', 'component']. 456 * @param array $overrides 457 * Specifies the overrides, this is an array where the key is the asset to 458 * be overridden while the value is overriding asset. 459 * @param string $theme_path 460 * The theme or base theme. 461 */ 462 protected function setOverrideValue(array &$library, array $sub_key, array $overrides, $theme_path) { 463 foreach ($overrides as $original => $replacement) { 464 // Get the attributes of the asset to be overridden. If the key does 465 // not exist, then throw an exception. 466 $key_exists = NULL; 467 $parents = array_merge($sub_key, [$original]); 468 // Save the attributes of the library asset to be overridden. 469 $attributes = NestedArray::getValue($library, $parents, $key_exists); 470 if ($key_exists) { 471 // Remove asset to be overridden. 472 NestedArray::unsetValue($library, $parents); 473 // No need to replace if FALSE is specified, since that is a removal. 474 if ($replacement) { 475 // Ensure the replacement path is relative to drupal root. 476 $replacement = $this->resolveThemeAssetPath($theme_path, $replacement); 477 $new_parents = array_merge($sub_key, [$replacement]); 478 // Replace with an override if specified. 479 NestedArray::setValue($library, $new_parents, $attributes); 480 } 481 } 482 } 483 } 484 485 /** 486 * Ensures that a full path is returned for an overriding theme asset. 487 * 488 * @param string $theme_path 489 * The theme or base theme. 490 * @param string $overriding_asset 491 * The overriding library asset. 492 * 493 * @return string 494 * A fully resolved theme asset path relative to the Drupal directory. 495 */ 496 protected function resolveThemeAssetPath($theme_path, $overriding_asset) { 497 if ($overriding_asset[0] !== '/' && !$this->isValidUri($overriding_asset)) { 498 // The destination is not an absolute path and it's not a URI (e.g. 499 // public://generated_js/example.js or http://example.com/js/my_js.js), so 500 // it's relative to the theme. 501 return '/' . $theme_path . '/' . $overriding_asset; 502 } 503 return $overriding_asset; 504 } 505 506 /** 507 * Validates CSS library structure. 508 * 509 * @param array $library 510 * The library definition array. 511 * 512 * @return int 513 * Returns based on validity: 514 * - 0 if the library definition is valid 515 * - 1 if the library definition has improper nesting 516 * - 2 if the library definition specifies files as an array 517 */ 518 public static function validateCssLibrary($library) { 519 $categories = []; 520 // Verify options first and return early if invalid. 521 foreach ($library as $category => $files) { 522 if (!is_array($files)) { 523 return 2; 524 } 525 $categories[] = $category; 526 foreach ($files as $source => $options) { 527 if (!is_array($options)) { 528 return 1; 529 } 530 } 531 } 532 533 return 0; 534 } 535 536} 537