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