diff --git a/core/lib/Drupal/Core/Asset/AssetResolver.php b/core/lib/Drupal/Core/Asset/AssetResolver.php index 6186d2a..9927049 100644 --- a/core/lib/Drupal/Core/Asset/AssetResolver.php +++ b/core/lib/Drupal/Core/Asset/AssetResolver.php @@ -166,6 +166,7 @@ public function getCssAssets(AttachedAssetsInterface $assets, $optimize) { uasort($css, 'static::sort'); // Allow themes to remove CSS files by CSS files full path and file name. + // @todo Remove in Drupal 9.0.x. if ($stylesheet_remove = $theme_info->getStyleSheetsRemove()) { foreach ($css as $key => $options) { if (isset($stylesheet_remove[$key])) { diff --git a/core/lib/Drupal/Core/Asset/Exception/InvalidLibrariesExtendSpecificationException.php b/core/lib/Drupal/Core/Asset/Exception/InvalidLibrariesExtendSpecificationException.php new file mode 100644 index 0000000..f1b9df0 --- /dev/null +++ b/core/lib/Drupal/Core/Asset/Exception/InvalidLibrariesExtendSpecificationException.php @@ -0,0 +1,15 @@ +cacheTagInvalidator->invalidateTags(['library_info']); + $this->libraryDefinitions = []; + $this->collector->clear(); } } diff --git a/core/lib/Drupal/Core/Asset/LibraryDiscoveryCollector.php b/core/lib/Drupal/Core/Asset/LibraryDiscoveryCollector.php index 7ab83d5..f0a590a 100644 --- a/core/lib/Drupal/Core/Asset/LibraryDiscoveryCollector.php +++ b/core/lib/Drupal/Core/Asset/LibraryDiscoveryCollector.php @@ -7,6 +7,9 @@ namespace Drupal\Core\Asset; +use Drupal\Component\Utility\NestedArray; +use Drupal\Core\Asset\Exception\InvalidLibrariesExtendSpecificationException; +use Drupal\Core\Asset\Exception\InvalidLibrariesOverrideSpecificationException; use Drupal\Core\Cache\CacheCollector; use Drupal\Core\Cache\CacheBackendInterface; use Drupal\Core\Lock\LockBackendInterface; @@ -79,9 +82,94 @@ protected function getCid() { * {@inheritdoc} */ protected function resolveCacheMiss($key) { - $this->storage[$key] = $this->discoveryParser->buildByExtension($key); + $this->storage[$key] = $this->getLibraryDefinitions($key); $this->persist($key); return $this->storage[$key]; } + + + /** + * Returns the library definitions for a given extension. + * + * This also implements libraries-overrides for entire libraries that have + * been specified by the LibraryDiscoveryParser. + * + * @param string $extension + * The name of the extension for which library definitions will be returned. + * + * @return array + * The library definitions for $extension with overrides applied. + * + * @throws \Drupal\Core\Asset\Exception\InvalidLibrariesOverrideSpecificationException + */ + protected function getLibraryDefinitions($extension) { + $libraries = $this->discoveryParser->buildByExtension($extension); + foreach ($libraries as $name => $definition) { + // Handle libraries that are marked for override or removal. + // @see \Drupal\Core\Asset\LibraryDiscoveryParser::applyLibrariesOverride() + if (isset($definition['override'])) { + if ($definition['override'] === FALSE) { + // Remove the library definition if FALSE is given. + unset($libraries[$name]); + } + else { + // Otherwise replace with existing library definition if it exists. + // Throw an exception if it doesn't. + list($replacement_extension, $replacement_name) = explode('/', $definition['override']); + $replacement_definition = $this->get($replacement_extension); + if (isset($replacement_definition[$replacement_name])) { + $libraries[$name] = $replacement_definition[$replacement_name]; + } + else { + throw new InvalidLibrariesOverrideSpecificationException(sprintf('The specified library %s does not exist.', $definition['override'])); + } + } + } + else { + // If libraries are not overridden, then apply libraries extend. + $libraries[$name] = $this->applyLibrariesExtend($extension, $name, $definition); + } + } + return $libraries; + } + + /** + * Applies the libraries-extend specified by the active theme. + * + * This extends the library definitions with the those specified by the + * libraries-extend specifications for the active theme. + * + * @param string $extension + * The name of the extension for which library definitions will be extended. + * @param string $library_name + * The name of the library whose definitions is to be extended. + * @param $library_definition + * The library definition to be extended. + * + * @return array + * The library definition extended as specified by libraries-extend. + * + * @throws \Drupal\Core\Asset\Exception\InvalidLibrariesExtendSpecificationException + */ + protected function applyLibrariesExtend($extension, $library_name, $library_definition) { + $libraries_extend = $this->themeManager->getActiveTheme()->getLibrariesExtend(); + if (!empty($libraries_extend["$extension/$library_name"])) { + foreach ($libraries_extend["$extension/$library_name"] as $library_extend_name) { + if (!is_string($library_extend_name)) { + // Only string library names are allowed. + throw new InvalidLibrariesExtendSpecificationException('The libraries-extend specification for each library must be a list of strings.'); + } + list($new_extension, $new_library_name) = explode('/', $library_extend_name, 2); + $new_libraries = $this->get($new_extension); + if (isset($new_libraries[$new_library_name])) { + $library_definition = NestedArray::mergeDeep($library_definition, $new_libraries[$new_library_name]); + } + else { + throw new InvalidLibrariesExtendSpecificationException(sprintf('The specified library "%s" does not exist.', $library_extend_name)); + } + } + } + return $library_definition; + } } diff --git a/core/lib/Drupal/Core/Asset/LibraryDiscoveryParser.php b/core/lib/Drupal/Core/Asset/LibraryDiscoveryParser.php index e6a4376..427640f 100644 --- a/core/lib/Drupal/Core/Asset/LibraryDiscoveryParser.php +++ b/core/lib/Drupal/Core/Asset/LibraryDiscoveryParser.php @@ -8,8 +8,10 @@ namespace Drupal\Core\Asset; use Drupal\Core\Asset\Exception\IncompleteLibraryDefinitionException; +use Drupal\Core\Asset\Exception\InvalidLibrariesOverrideSpecificationException; use Drupal\Core\Asset\Exception\InvalidLibraryFileException; use Drupal\Core\Asset\Exception\LibraryDefinitionMissingLicenseException; +use Drupal\Core\Extension\Extension; use Drupal\Core\Extension\ModuleHandlerInterface; use Drupal\Core\Theme\ThemeManagerInterface; use Drupal\Component\Serialization\Exception\InvalidDataTypeException; @@ -88,6 +90,7 @@ public function buildByExtension($extension) { } $libraries = $this->parseLibraryInfo($extension, $path); + $libraries = $this->applyLibrariesOverride($libraries, $extension); foreach ($libraries as $id => &$library) { if (!isset($library['js']) && !isset($library['css']) && !isset($library['drupalSettings'])) { @@ -185,6 +188,13 @@ public function buildByExtension($extension) { elseif ($this->fileValidUri($source)) { $options['data'] = $source; } + // A regular URI (e.g., http://example.com/example.js) without + // 'external' explicitly specified, which may happen if, e.g. + // libraries-override is used. + elseif ($this->isValidUri($source)) { + $options['type'] = 'external'; + $options['data'] = $source; + } // By default, file paths are relative to the registering extension. else { $options['data'] = $path . '/' . $source; @@ -314,6 +324,70 @@ protected function parseLibraryInfo($extension, $path) { } /** + * Apply libraries overrides specified for the current active theme. + * + * @param array $libraries + * The libraries definitions. + * @param string $extension + * The extension in which these libraries are defined. + * + * @return array + * The modified libraries definitions. + */ + protected function applyLibrariesOverride($libraries, $extension) { + $active_theme = $this->themeManager->getActiveTheme(); + // ActiveTheme::getLibrariesOverride() returns libraries-overrides for the + // current theme as well as all its base themes. + $all_libraries_overrides = $active_theme->getLibrariesOverride(); + foreach ($all_libraries_overrides as $theme_path => $libraries_overrides) { + foreach ($libraries as $library_name => $library) { + // Process libraries overrides. + if (isset($libraries_overrides["$extension/$library_name"])) { + // Active theme defines an override for this library. + $override_definition = $libraries_overrides["$extension/$library_name"]; + if (is_string($override_definition) || $override_definition === FALSE) { + // A string or boolean definition implies an override (or removal) + // for the whole library. Use the override key to specify that this + // library will be overridden when it is called. + // @see \Drupal\Core\Asset\LibraryDiscovery::getLibraryByName() + if ($override_definition) { + $libraries[$library_name]['override'] = $override_definition; + } + else { + $libraries[$library_name]['override'] = FALSE; + } + } + elseif (is_array($override_definition)) { + // An array definition implies an override for an asset within this + // library. + foreach ($override_definition as $sub_key => $value) { + // Throw an exception if the asset is not properly specified. + if (!is_array($value)) { + 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")); + } + if ($sub_key === 'drupalSettings') { + // drupalSettings may not be overridden. + 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")); + } + elseif ($sub_key === 'css') { + // SMACSS category should be incorporated into the asset name. + foreach ($value as $category => $overrides) { + $this->setOverrideValue($libraries[$library_name], [$sub_key, $category], $overrides, $theme_path); + } + } + else { + $this->setOverrideValue($libraries[$library_name], [$sub_key], $value, $theme_path); + } + } + } + } + } + } + + return $libraries; + } + + /** * Wraps drupal_get_path(). */ protected function drupalGetPath($type, $name) { @@ -327,4 +401,67 @@ protected function fileValidUri($source) { return file_valid_uri($source); } + /** + * Determines if the supplied string is a valid URI. + */ + protected function isValidUri($string) { + return count(explode('://', $string)) === 2; + } + + /** + * Overrides the specified library asset. + * + * @param array $library + * The containing library definition. + * @param array $sub_key + * An array containing the sub-keys specifying the library asset, e.g. + * @code['js']@endcode or @code['css', 'component']@endcode + * @param array $overrides + * Specifies the overrides, this is an array where the key is the asset to + * be overridden while the value is overriding asset. + */ + protected function setOverrideValue(array &$library, array $sub_key, array $overrides, $theme_path) { + foreach ($overrides as $original => $replacement) { + // Get the attributes of the asset to be overridden. If the key does + // not exist, then throw an exception. + $key_exists = NULL; + $parents = array_merge($sub_key, [$original]); + // Save the attributes of the library asset to be overridden. + $attributes = NestedArray::getValue($library, $parents, $key_exists); + if ($key_exists) { + // Remove asset to be overridden. + NestedArray::unsetValue($library, $parents); + // No need to replace if FALSE is specified, since that is a removal. + if ($replacement) { + // Ensure the replacement path is relative to drupal root. + $replacement = $this->resolveThemeAssetPath($theme_path, $replacement); + $new_parents = array_merge($sub_key, [$replacement]); + // Replace with an override if specified. + NestedArray::setValue($library, $new_parents, $attributes); + } + } + } + } + + /** + * Ensures that a full path is returned for an overriding theme asset. + * + * @param string $theme_path + * The theme or base theme. + * @param string $overriding_asset + * The overriding library asset. + * + * @return string + * A fully resolved theme asset path relative to the Drupal directory. + */ + protected function resolveThemeAssetPath($theme_path, $overriding_asset) { + if ($overriding_asset[0] !== '/' && !$this->isValidUri($overriding_asset)) { + // The destination is not an absolute path and it's not a URI (e.g. + // public://generated_js/example.js or http://example.com/js/my_js.js), so + // it's relative to the theme. + return '/' . $theme_path . '/' . $overriding_asset; + } + return $overriding_asset; + } + } diff --git a/core/lib/Drupal/Core/Theme/ActiveTheme.php b/core/lib/Drupal/Core/Theme/ActiveTheme.php index c35afad..3b43cc0 100644 --- a/core/lib/Drupal/Core/Theme/ActiveTheme.php +++ b/core/lib/Drupal/Core/Theme/ActiveTheme.php @@ -81,6 +81,13 @@ class ActiveTheme { protected $regions; /** + * The libraries or library assets overridden by the theme. + * + * @var array + */ + protected $librariesOverride; + + /** * Constructs an ActiveTheme object. * * @param array $values @@ -96,6 +103,8 @@ public function __construct(array $values) { 'extension' => 'html.twig', 'base_themes' => [], 'regions' => [], + 'libraries_override' => [], + 'libraries_extend' => [], ]; $this->name = $values['name']; @@ -107,6 +116,8 @@ public function __construct(array $values) { $this->extension = $values['extension']; $this->baseThemes = $values['base_themes']; $this->regions = $values['regions']; + $this->librariesOverride = $values['libraries_override']; + $this->librariesExtend = $values['libraries_extend']; } /** @@ -169,6 +180,8 @@ public function getLibraries() { * Returns the removed stylesheets by the theme. * * @return mixed + * + * @deprecated in Drupal 8.0.0, will be removed before Drupal 9.0.0. */ public function getStyleSheetsRemove() { return $this->styleSheetsRemove; @@ -198,4 +211,24 @@ public function getRegions() { return array_keys($this->regions); } + /** + * Returns the libraries or library assets overridden by the active theme. + * + * @return array + * The list of libraries overrides. + */ + public function getLibrariesOverride() { + return $this->librariesOverride; + } + + /** + * Returns the libraries extended by the active theme. + * + * @return array + * The list of libraries extend. + */ + public function getLibrariesExtend() { + return $this->librariesExtend; + } + } diff --git a/core/lib/Drupal/Core/Theme/ThemeInitialization.php b/core/lib/Drupal/Core/Theme/ThemeInitialization.php index 8b476a2..e310777 100644 --- a/core/lib/Drupal/Core/Theme/ThemeInitialization.php +++ b/core/lib/Drupal/Core/Theme/ThemeInitialization.php @@ -161,27 +161,56 @@ public function getActiveTheme(Extension $theme, array $base_themes = []) { $values['path'] = $theme_path; $values['name'] = $theme->getName(); - // Prepare stylesheets from this theme as well as all ancestor themes. - // We work it this way so that we can have child themes remove CSS files - // easily from parent. - $values['stylesheets_remove'] = array(); + // @todo Remove in Drupal 9.0.x. + $values['stylesheets_remove'] = $this->prepareStylesheetsRemove($theme, $base_themes); - // Grab stylesheets from base theme. + // Prepare libraries overrides from this theme and ancestor themes. This + // allows child themes to easily remove CSS files from base themes and + // modules. + $values['libraries_override'] = []; + + // Get libraries overrides declared by base themes. foreach ($base_themes as $base) { - $base_theme_path = $base->getPath(); - if (!empty($base->info['stylesheets-remove'])) { - foreach ($base->info['stylesheets-remove'] as $css_file) { - $css_file = $this->resolveStyleSheetPlaceholders($css_file); - $values['stylesheets_remove'][$css_file] = $css_file; + if (!empty($base->info['libraries-override'])) { + foreach ($base->info['libraries-override'] as $library => $override) { + $values['libraries_override'][$base->getPath()][$library] = $override; } } } - // Add stylesheets used by this theme. - if (!empty($theme->info['stylesheets-remove'])) { - foreach ($theme->info['stylesheets-remove'] as $css_file) { - $css_file = $this->resolveStyleSheetPlaceholders($css_file); - $values['stylesheets_remove'][$css_file] = $css_file; + // Add libraries overrides declared by this theme. + if (!empty($theme->info['libraries-override'])) { + foreach ($theme->info['libraries-override'] as $library => $override) { + $values['libraries_override'][$theme->getPath()][$library] = $override; + } + } + + // Get libraries extend declared by base themes. + foreach ($base_themes as $base) { + if (!empty($base->info['libraries-extend'])) { + foreach ($base->info['libraries-extend'] as $library => $extend) { + if (isset($values['libraries_extend'][$library])) { + // Merge if libraries-extend has already been defined for this + // library. + $values['libraries_extend'][$library] = array_merge($values['libraries_extend'][$library], $extend); + } + else { + $values['libraries_extend'][$library] = $extend; + } + } + } + } + // Add libraries extend declared by this theme. + if (!empty($theme->info['libraries-extend'])) { + foreach ($theme->info['libraries-extend'] as $library => $extend) { + if (isset($values['libraries_extend'][$library])) { + // Merge if libraries-extend has already been defined for this + // library. + $values['libraries_extend'][$library] = array_merge($values['libraries_extend'][$library], $extend); + } + else { + $values['libraries_extend'][$library] = $extend; + } } } @@ -241,6 +270,8 @@ protected function getExtensions() { * * @return string * CSS file where placeholders are replaced. + * + * @todo Remove in Drupal 9.0.x. */ protected function resolveStyleSheetPlaceholders($css_file) { $token_candidate = explode('/', $css_file)[0]; @@ -256,4 +287,44 @@ protected function resolveStyleSheetPlaceholders($css_file) { return str_replace($token_candidate, $extensions[$token]->getPath(), $css_file); } } + + /** + * Prepares stylesheets-remove specified in the *.info.yml file. + * + * @param \Drupal\Core\Extension\Extension $theme + * The theme extension object. + * @param \Drupal\Core\Extension\Extension[] $base_themes + * An array of base themes. + * + * @return string[] + * The list of stylesheets-remove specified in the *.info.yml file. + * + * @todo Remove in Drupal 9.0.x. + */ + protected function prepareStylesheetsRemove(Extension $theme, $base_themes) { + // Prepare stylesheets from this theme as well as all ancestor themes. + // We work it this way so that we can have child themes remove CSS files + // easily from parent. + $stylesheets_remove = array(); + // Grab stylesheets from base theme. + foreach ($base_themes as $base) { + $base_theme_path = $base->getPath(); + if (!empty($base->info['stylesheets-remove'])) { + foreach ($base->info['stylesheets-remove'] as $css_file) { + $css_file = $this->resolveStyleSheetPlaceholders($css_file); + $stylesheets_remove[$css_file] = $css_file; + } + } + } + + // Add stylesheets used by this theme. + if (!empty($theme->info['stylesheets-remove'])) { + foreach ($theme->info['stylesheets-remove'] as $css_file) { + $css_file = $this->resolveStyleSheetPlaceholders($css_file); + $stylesheets_remove[$css_file] = $css_file; + } + } + return $stylesheets_remove; + } + } diff --git a/core/modules/system/src/Tests/Asset/LibraryDiscoveryIntegrationTest.php b/core/modules/system/src/Tests/Asset/LibraryDiscoveryIntegrationTest.php index 2ddccea..2de5680 100644 --- a/core/modules/system/src/Tests/Asset/LibraryDiscoveryIntegrationTest.php +++ b/core/modules/system/src/Tests/Asset/LibraryDiscoveryIntegrationTest.php @@ -7,39 +7,289 @@ namespace Drupal\system\Tests\Asset; +use Drupal\Core\Asset\Exception\InvalidLibrariesExtendSpecificationException; +use Drupal\Core\Asset\Exception\InvalidLibrariesOverrideSpecificationException; use Drupal\simpletest\KernelTestBase; /** - * Tests the element info. + * Tests the library discovery and library discovery parser. * * @group Render */ class LibraryDiscoveryIntegrationTest extends KernelTestBase { /** + * The library discovery service. + * + * @var \Drupal\Core\Asset\LibraryDiscoveryInterface + */ + protected $libraryDiscovery; + + /** * {@inheritdoc} */ protected function setUp() { parent::setUp(); - $this->container->get('theme_handler')->install(['test_theme', 'classy']); + $this->container->get('theme_installer')->install(['test_theme', 'classy']); + $this->libraryDiscovery = $this->container->get('library.discovery'); } /** * Ensures that the element info can be altered by themes. */ public function testElementInfoByTheme() { + $this->activateTheme('test_theme'); + $this->assertTrue($this->libraryDiscovery->getLibraryByName('test_theme', 'kitten')); + } + + /** + * Tests that libraries-override are applied to library definitions. + */ + public function testLibrariesOverride() { + // Assert some classy libraries that will be overridden or removed. + $this->activateTheme('classy'); + $this->assertAssetInLibrary('core/themes/classy/css/components/button.css', 'classy', 'base', 'css'); + $this->assertAssetInLibrary('core/themes/classy/css/components/collapse-processed.css', 'classy', 'base', 'css'); + $this->assertAssetInLibrary('core/themes/classy/css/components/container-inline.css', 'classy', 'base', 'css'); + $this->assertAssetInLibrary('core/themes/classy/css/components/details.css', 'classy', 'base', 'css'); + $this->assertAssetInLibrary('core/themes/classy/css/components/dialog.css', 'classy', 'dialog', 'css'); + + // Confirmatory assert on core library to be removed. + $this->assertTrue($this->libraryDiscovery->getLibraryByName('core', 'drupal.progress'), 'Confirmatory test on "core/drupal.progress"'); + + // Activate test theme that defines libraries overrides. + $this->activateTheme('test_theme'); + + // Assert that entire library was correctly overridden. + $this->assertEqual($this->libraryDiscovery->getLibraryByName('core', 'drupal.collapse'), $this->libraryDiscovery->getLibraryByName('test_theme', 'collapse'), 'Entire library correctly overridden.'); + + // Assert that classy library assets were correctly overridden or removed. + $this->assertNoAssetInLibrary('core/themes/classy/css/components/button.css', 'classy', 'base', 'css'); + $this->assertNoAssetInLibrary('core/themes/classy/css/components/collapse-processed.css', 'classy', 'base', 'css'); + $this->assertNoAssetInLibrary('core/themes/classy/css/components/container-inline.css', 'classy', 'base', 'css'); + $this->assertNoAssetInLibrary('core/themes/classy/css/components/details.css', 'classy', 'base', 'css'); + $this->assertNoAssetInLibrary('core/themes/classy/css/components/dialog.css', 'classy', 'dialog', 'css'); + + $this->assertAssetInLibrary('core/modules/system/tests/themes/test_theme/css/my-button.css', 'classy', 'base', 'css'); + $this->assertAssetInLibrary('core/modules/system/tests/themes/test_theme/css/my-collapse-processed.css', 'classy', 'base', 'css'); + $this->assertAssetInLibrary('themes/my_theme/css/my-container-inline.css', 'classy', 'base', 'css'); + $this->assertAssetInLibrary('themes/my_theme/css/my-details.css', 'classy', 'base', 'css'); + + // Assert that entire library was correctly removed. + $this->assertFalse($this->libraryDiscovery->getLibraryByName('core', 'drupal.progress'), 'Entire library correctly removed.'); + + // Assert that overridden library asset still retains attributes. + $library = $this->libraryDiscovery->getLibraryByName('core', 'jquery'); + foreach ($library['js'] as $definition) { + if ($definition['data'] == 'core/modules/system/tests/themes/test_theme/js/collapse.js') { + $this->assertTrue($definition['minified'] && $definition['weight'] == -20, 'Previous attributes retained'); + break; + } + } + } + + /** + * Tests libraries-override on drupalSettings. + */ + public function testLibrariesOverrideDrupalSettings() { + // Activate test theme that attempts to override drupalSettings. + $this->activateTheme('test_theme_libraries_override_with_drupal_settings'); + + // Assert that drupalSettings cannot be overridden and throws an exception. + try { + $this->libraryDiscovery->getLibraryByName('core', 'drupal.ajax'); + $this->fail('Throw Exception when trying to override drupalSettings'); + } + catch (InvalidLibrariesOverrideSpecificationException $e) { + $expected_message = 'drupalSettings may not be overridden in libraries-override. Trying to override core/drupal.ajax/drupalSettings. Use hook_library_info_alter() instead.'; + $this->assertEqual($e->getMessage(), $expected_message, 'Throw Exception when trying to override drupalSettings'); + } + } + + /** + * Tests libraries-override on malformed assets. + */ + public function testLibrariesOverrideMalformedAsset() { + // Activate test theme that overrides with a malformed asset. + $this->activateTheme('test_theme_libraries_override_with_invalid_asset'); + + // Assert that improperly formed asset "specs" throw an exception. + try { + $this->libraryDiscovery->getLibraryByName('core', 'drupal.dialog'); + $this->fail('Throw Exception when specifying invalid override'); + } + catch (InvalidLibrariesOverrideSpecificationException $e) { + $expected_message = 'Library asset core/drupal.dialog/css is not correctly specified. It should be in the form "extension/library_name/sub_key/path/to/asset.js".'; + $this->assertEqual($e->getMessage(), $expected_message, 'Throw Exception when specifying invalid override'); + } + } + + /** + * Tests library assets with other ways for specifying paths. + */ + public function testLibrariesOverrideOtherAssetLibraryNames() { + // Activate a test theme that defines libraries overrides on other types of + // assets. + $this->activateTheme('test_theme'); + + // Assert Drupal-relative paths. + $this->assertAssetInLibrary('themes/my_theme/css/dropbutton.css', 'core', 'drupal.dropbutton', 'css'); + + // Assert stream wrapper paths. + $this->assertAssetInLibrary('public://my_css/vertical-tabs.css', 'core', 'drupal.vertical-tabs', 'css'); + + // Assert a protocol-relative URI. + $this->assertAssetInLibrary('//my-server/my_theme/css/jquery_ui.css', 'core', 'jquery.ui', 'css'); + + // Assert an absolute URI. + $this->assertAssetInLibrary('http://example.com/my_theme/css/farbtastic.css', 'core', 'jquery.farbtastic', 'css'); + } + + /** + * Tests that base theme libraries-override still apply in sub themes. + */ + public function testBaseThemeLibrariesOverrideInSubTheme() { + // Activate a test theme that has subthemes. + $this->activateTheme('test_subtheme'); + + // Assert that libraries-override specified in the base theme still applies + // in the sub theme. + $this->assertNoAssetInLibrary('core/misc/dialog/dialog.js', 'core', 'drupal.dialog', 'js'); + $this->assertAssetInLibrary('core/modules/system/tests/themes/test_basetheme/css/farbtastic.css', 'core', 'jquery.farbtastic', 'css'); + } + + /** + * Tests libraries-extend. + */ + public function testLibrariesExtend() { + // Activate classy themes and verify the libraries are not extended. + $this->activateTheme('classy'); + $this->assertNoAssetInLibrary('core/modules/system/tests/themes/test_theme_libraries_extend/css/extend_1.css', 'classy', 'book-navigation', 'css'); + $this->assertNoAssetInLibrary('core/modules/system/tests/themes/test_theme_libraries_extend/js/extend_1.js', 'classy', 'book-navigation', 'js'); + $this->assertNoAssetInLibrary('core/modules/system/tests/themes/test_theme_libraries_extend/css/extend_2.css', 'classy', 'book-navigation', 'css'); + + // Activate the theme that extends the book-navigation library in classy. + $this->activateTheme('test_theme_libraries_extend'); + $this->assertAssetInLibrary('core/modules/system/tests/themes/test_theme_libraries_extend/css/extend_1.css', 'classy', 'book-navigation', 'css'); + $this->assertAssetInLibrary('core/modules/system/tests/themes/test_theme_libraries_extend/js/extend_1.js', 'classy', 'book-navigation', 'js'); + $this->assertAssetInLibrary('core/modules/system/tests/themes/test_theme_libraries_extend/css/extend_2.css', 'classy', 'book-navigation', 'css'); + + // Activate a sub theme and confirm that it inherits the library assets + // extended in the base theme as well as its own. + $this->assertNoAssetInLibrary('core/modules/system/tests/themes/test_basetheme/css/base-libraries-extend.css', 'classy', 'base', 'css'); + $this->assertNoAssetInLibrary('core/modules/system/tests/themes/test_subtheme/css/sub-libraries-extend.css', 'classy', 'base', 'css'); + $this->activateTheme('test_subtheme'); + $this->assertAssetInLibrary('core/modules/system/tests/themes/test_basetheme/css/base-libraries-extend.css', 'classy', 'base', 'css'); + $this->assertAssetInLibrary('core/modules/system/tests/themes/test_subtheme/css/sub-libraries-extend.css', 'classy', 'base', 'css'); + + // Activate test theme that extends with a non-existent library. An + // exception should be thrown. + $this->activateTheme('test_theme_libraries_extend'); + try { + $this->libraryDiscovery->getLibraryByName('core', 'drupal.dialog'); + $this->fail('Throw Exception when specifying non-existent libraries-extend.'); + } + catch (InvalidLibrariesExtendSpecificationException $e) { + $expected_message = 'The specified library "test_theme_libraries_extend/non_existent_library" does not exist.'; + $this->assertEqual($e->getMessage(), $expected_message, 'Throw Exception when specifying non-existent libraries-extend.'); + } + + // Also, test non-string libraries-extend. An exception should be thrown. + $this->container->get('theme_installer')->install(['test_theme']); + try { + $this->libraryDiscovery->getLibraryByName('test_theme', 'collapse'); + $this->fail('Throw Exception when specifying non-string libraries-extend.'); + } + catch (InvalidLibrariesExtendSpecificationException $e) { + $expected_message = 'The libraries-extend specification for each library must be a list of strings.'; + $this->assertEqual($e->getMessage(), $expected_message, 'Throw Exception when specifying non-string libraries-extend.'); + } + } + + /** + * Activates a specified theme. + * + * Installs the theme if not already installed and makes it the active theme. + * + * @param string $theme_name + * The name of the theme to be activated. + */ + protected function activateTheme($theme_name) { + $this->container->get('theme_installer')->install([$theme_name]); + /** @var \Drupal\Core\Theme\ThemeInitializationInterface $theme_initializer */ $theme_initializer = $this->container->get('theme.initialization'); /** @var \Drupal\Core\Theme\ThemeManagerInterface $theme_manager */ $theme_manager = $this->container->get('theme.manager'); - /** @var \Drupal\Core\Render\ElementInfoManagerInterface $element_info */ - $library_discovery = $this->container->get('library.discovery'); + $theme_manager->setActiveTheme($theme_initializer->getActiveThemeByName($theme_name)); + + $this->libraryDiscovery->clearCachedDefinitions(); + + // Assert message. + $this->pass(sprintf('Activated theme "%s"', $theme_name)); + } - $theme_manager->setActiveTheme($theme_initializer->getActiveThemeByName('test_theme')); - $this->assertTrue($library_discovery->getLibraryByName('test_theme', 'kitten')); + /** + * Asserts that the specified asset is in the given library. + * + * @param string $asset + * The asset file with the path for the file. + * @param string $extension + * The extension in which the $library is defined. + * @param string $library_name + * Name of the library. + * @param mixed $sub_key + * The library sub key where the given asset is defined. + * @param string $message + * (optional) A message to display with the assertion. + * + * @return bool + * TRUE if the specified asset is found in the library. + */ + protected function assertAssetInLibrary($asset, $extension, $library_name, $sub_key, $message = NULL) { + if (!isset($message)) { + $message = sprintf('Asset %s found in library "%s/%s"', $asset, $extension, $library_name); + } + $library = $this->libraryDiscovery->getLibraryByName($extension, $library_name); + foreach ($library[$sub_key] as $definition) { + if ($asset == $definition['data']) { + return $this->pass($message); + } + } + return $this->fail($message); + } + + /** + * Asserts that the specified asset is not in the given library. + * + * @param string $asset + * The asset file with the path for the file. + * @param string $extension + * The extension in which the $library_name is defined. + * @param string $library_name + * Name of the library. + * @param mixed $sub_key + * The library sub key where the given asset is defined. + * @param string $message + * (optional) A message to display with the assertion. + * + * @return bool + * TRUE if the specified asset is not found in the library. + */ + protected function assertNoAssetInLibrary($asset, $extension, $library_name, $sub_key, $message = NULL) { + if (!isset($message)) { + $message = sprintf('Asset %s not found in library "%s/%s"', $asset, $extension, $library_name); + } + $library = $this->libraryDiscovery->getLibraryByName($extension, $library_name); + foreach ($library[$sub_key] as $definition) { + if ($asset == $definition['data']) { + return $this->fail($message); + } + } + return $this->pass($message); } } diff --git a/core/modules/system/src/Tests/Theme/ThemeTest.php b/core/modules/system/src/Tests/Theme/ThemeTest.php index c47e022..28d47386 100644 --- a/core/modules/system/src/Tests/Theme/ThemeTest.php +++ b/core/modules/system/src/Tests/Theme/ThemeTest.php @@ -175,7 +175,7 @@ function testCSSOverride() { $config->set('css.preprocess', 0); $config->save(); $this->drupalGet('theme-test/suggestion'); - $this->assertNoText('system.module.css', 'The theme\'s .info.yml file is able to override a module CSS file from being added to the page.'); + $this->assertNoText('system.module.css', "The theme's .info.yml file is able to remove a module CSS file from being added to the page."); // Also test with aggregation enabled, simply ensuring no PHP errors are // triggered during drupal_build_css_cache() when a source file doesn't diff --git a/core/modules/system/tests/themes/test_basetheme/test_basetheme.info.yml b/core/modules/system/tests/themes/test_basetheme/test_basetheme.info.yml index dcb1a2f..7b76ded 100644 --- a/core/modules/system/tests/themes/test_basetheme/test_basetheme.info.yml +++ b/core/modules/system/tests/themes/test_basetheme/test_basetheme.info.yml @@ -7,3 +7,15 @@ libraries: - test_basetheme/global-styling stylesheets-remove: - '@theme_test/css/base-remove.css' +libraries-override: + core/drupal.dialog: + js: + misc/dialog/dialog.js: false + core/jquery.farbtastic: + css: + component: + assets/vendor/farbtastic/farbtastic.css: css/farbtastic.css + +libraries-extend: + classy/base: + - test_basetheme/global-styling diff --git a/core/modules/system/tests/themes/test_basetheme/test_basetheme.libraries.yml b/core/modules/system/tests/themes/test_basetheme/test_basetheme.libraries.yml index f7529ea..b3d3406 100644 --- a/core/modules/system/tests/themes/test_basetheme/test_basetheme.libraries.yml +++ b/core/modules/system/tests/themes/test_basetheme/test_basetheme.libraries.yml @@ -5,3 +5,4 @@ global-styling: base-add.css: {} base-add.sub-remove.css: {} samename.css: {} + css/base-libraries-extend.css: {} diff --git a/core/modules/system/tests/themes/test_subtheme/test_subtheme.info.yml b/core/modules/system/tests/themes/test_subtheme/test_subtheme.info.yml index 6883e5a..b217374 100644 --- a/core/modules/system/tests/themes/test_subtheme/test_subtheme.info.yml +++ b/core/modules/system/tests/themes/test_subtheme/test_subtheme.info.yml @@ -9,3 +9,7 @@ libraries: stylesheets-remove: - '@theme_test/css/sub-remove.css' - '@test_basetheme/base-add.sub-remove.css' + +libraries-extend: + classy/base: + - test_subtheme/global-styling diff --git a/core/modules/system/tests/themes/test_subtheme/test_subtheme.libraries.yml b/core/modules/system/tests/themes/test_subtheme/test_subtheme.libraries.yml index 931dffe..1fff390 100644 --- a/core/modules/system/tests/themes/test_subtheme/test_subtheme.libraries.yml +++ b/core/modules/system/tests/themes/test_subtheme/test_subtheme.libraries.yml @@ -4,3 +4,4 @@ global-styling: base: css/sub-add.css: {} css/samename.css: {} + css/sub-libraries-extend.css: {} diff --git a/core/modules/system/tests/themes/test_theme/css/collapse.css b/core/modules/system/tests/themes/test_theme/css/collapse.css new file mode 100644 index 0000000..23f38b3 --- /dev/null +++ b/core/modules/system/tests/themes/test_theme/css/collapse.css @@ -0,0 +1,4 @@ +/** + * @file + * Test CSS asset file for test_theme.theme. + */ diff --git a/core/modules/system/tests/themes/test_theme/js/collapse.js b/core/modules/system/tests/themes/test_theme/js/collapse.js new file mode 100644 index 0000000..4d66841 --- /dev/null +++ b/core/modules/system/tests/themes/test_theme/js/collapse.js @@ -0,0 +1,4 @@ +/** + * @file + * Test JS asset file for test_theme.theme. + */ diff --git a/core/modules/system/tests/themes/test_theme/test_theme.info.yml b/core/modules/system/tests/themes/test_theme/test_theme.info.yml index 8f613bf..4c1568c 100644 --- a/core/modules/system/tests/themes/test_theme/test_theme.info.yml +++ b/core/modules/system/tests/themes/test_theme/test_theme.info.yml @@ -18,6 +18,49 @@ stylesheets-remove: - '@system/css/system.module.css' libraries: - test_theme/global-styling +libraries-override: + # Replace an entire library. + core/drupal.collapse: test_theme/collapse + # Remove an entire library. + core/drupal.progress: false + # Replace particular library assets. + classy/base: + css: + component: + css/components/button.css: css/my-button.css + css/components/collapse-processed.css: css/my-collapse-processed.css + css/components/container-inline.css: /themes/my_theme/css/my-container-inline.css + css/components/details.css: /themes/my_theme/css/my-details.css + # Remove particular library assets. + classy/dialog: + css: + component: + css/components/dialog.css: false + # It works for JS as well. + core/jquery: + js: + assets/vendor/jquery/jquery.min.js: js/collapse.js + # Use Drupal-relative paths. + core/drupal.dropbutton: + css: + component: + misc/dropbutton/dropbutton.css: /themes/my_theme/css/dropbutton.css + # Use stream wrappers. + core/drupal.vertical-tabs: + css: + component: + misc/vertical-tabs.css: public://my_css/vertical-tabs.css + # Use a protocol-relative URI. + core/jquery.ui: + css: + component: + assets/vendor/jquery.ui/themes/base/core.css: //my-server/my_theme/css/jquery_ui.css + # Use an absolute URI. + core/jquery.farbtastic: + css: + component: + assets/vendor/farbtastic/farbtastic.css: http://example.com/my_theme/css/farbtastic.css + regions: content: Content left: Left diff --git a/core/modules/system/tests/themes/test_theme/test_theme.libraries.yml b/core/modules/system/tests/themes/test_theme/test_theme.libraries.yml index c1fe4a5..52bb147 100644 --- a/core/modules/system/tests/themes/test_theme/test_theme.libraries.yml +++ b/core/modules/system/tests/themes/test_theme/test_theme.libraries.yml @@ -3,3 +3,11 @@ global-styling: css: base: kitten.css: {} + +collapse: + version: VERSION + js: + js/collapse.js: { } + css: + base: + css/collapse.css: { } diff --git a/core/modules/system/tests/themes/test_theme_libraries_extend/test_theme_libraries_extend.info.yml b/core/modules/system/tests/themes/test_theme_libraries_extend/test_theme_libraries_extend.info.yml new file mode 100644 index 0000000..597a5b0 --- /dev/null +++ b/core/modules/system/tests/themes/test_theme_libraries_extend/test_theme_libraries_extend.info.yml @@ -0,0 +1,15 @@ +name: 'Test theme libraries-extend' +type: theme +description: 'Test Theme with libraries-extend' +version: VERSION +base theme: classy +core: 8.x +libraries-extend: + classy/book-navigation: + - test_theme_libraries_extend/extend_one + - test_theme_libraries_extend/extend_two + core/drupal.dialog: + - test_theme_libraries_extend/non_existent_library + test_theme/collapse: + - not_a_string: + expected: 'an exception' diff --git a/core/modules/system/tests/themes/test_theme_libraries_extend/test_theme_libraries_extend.libraries.yml b/core/modules/system/tests/themes/test_theme_libraries_extend/test_theme_libraries_extend.libraries.yml new file mode 100644 index 0000000..a276151 --- /dev/null +++ b/core/modules/system/tests/themes/test_theme_libraries_extend/test_theme_libraries_extend.libraries.yml @@ -0,0 +1,11 @@ +extend_one: + css: + theme: + css/extend_1.css: { } + js: + js/extend_1.js: { } + +extend_two: + css: + theme: + css/extend_2.css: { } diff --git a/core/modules/system/tests/themes/test_theme_libraries_override_with_drupal_settings/test_theme_libraries_override_with_drupal_settings.info.yml b/core/modules/system/tests/themes/test_theme_libraries_override_with_drupal_settings/test_theme_libraries_override_with_drupal_settings.info.yml new file mode 100644 index 0000000..f55f6d0 --- /dev/null +++ b/core/modules/system/tests/themes/test_theme_libraries_override_with_drupal_settings/test_theme_libraries_override_with_drupal_settings.info.yml @@ -0,0 +1,12 @@ +name: 'Test theme libraries-override' +type: theme +description: 'Theme with drupalSettings libraries-override' +version: VERSION +base theme: classy +core: 8.x +libraries-override: + # drupalSettings libraries override. Should throw a + # \Drupal\Core\Asset\Exception\InvalidLibrariesOverrideSpecificationException. + core/drupal.ajax: + drupalSettings: + ajaxPageState: { } diff --git a/core/modules/system/tests/themes/test_theme_libraries_override_with_invalid_asset/test_theme_libraries_override_with_invalid_asset.info.yml b/core/modules/system/tests/themes/test_theme_libraries_override_with_invalid_asset/test_theme_libraries_override_with_invalid_asset.info.yml new file mode 100644 index 0000000..f31ade0 --- /dev/null +++ b/core/modules/system/tests/themes/test_theme_libraries_override_with_invalid_asset/test_theme_libraries_override_with_invalid_asset.info.yml @@ -0,0 +1,11 @@ +name: 'Test theme libraries-override' +type: theme +description: 'Theme with invalid libraries-override asset spec.' +version: VERSION +base theme: classy +core: 8.x +libraries-override: + # A malformed library asset name. Should throw a + # \Drupal\Core\Asset\Exception\InvalidLibrariesOverrideSpecificationException. + core/drupal.dialog: + css: false diff --git a/core/tests/Drupal/Tests/Core/Asset/LibraryDiscoveryCollectorTest.php b/core/tests/Drupal/Tests/Core/Asset/LibraryDiscoveryCollectorTest.php index 1c3733d..6199438 100644 --- a/core/tests/Drupal/Tests/Core/Asset/LibraryDiscoveryCollectorTest.php +++ b/core/tests/Drupal/Tests/Core/Asset/LibraryDiscoveryCollectorTest.php @@ -94,7 +94,7 @@ public function testResolveCacheMiss() { $this->activeTheme = $this->getMockBuilder('Drupal\Core\Theme\ActiveTheme') ->disableOriginalConstructor() ->getMock(); - $this->themeManager->expects($this->once()) + $this->themeManager->expects($this->exactly(3)) ->method('getActiveTheme') ->willReturn($this->activeTheme); $this->activeTheme->expects($this->once()) @@ -120,7 +120,7 @@ public function testDestruct() { $this->activeTheme = $this->getMockBuilder('Drupal\Core\Theme\ActiveTheme') ->disableOriginalConstructor() ->getMock(); - $this->themeManager->expects($this->once()) + $this->themeManager->expects($this->exactly(3)) ->method('getActiveTheme') ->willReturn($this->activeTheme); $this->activeTheme->expects($this->once()) diff --git a/core/tests/Drupal/Tests/Core/Asset/LibraryDiscoveryParserTest.php b/core/tests/Drupal/Tests/Core/Asset/LibraryDiscoveryParserTest.php index 9865cb2..2f5ed21 100644 --- a/core/tests/Drupal/Tests/Core/Asset/LibraryDiscoveryParserTest.php +++ b/core/tests/Drupal/Tests/Core/Asset/LibraryDiscoveryParserTest.php @@ -73,6 +73,15 @@ protected function setUp() { $this->moduleHandler = $this->getMock('Drupal\Core\Extension\ModuleHandlerInterface'); $this->themeManager = $this->getMock('Drupal\Core\Theme\ThemeManagerInterface'); + $mock_active_theme = $this->getMockBuilder('Drupal\Core\Theme\ActiveTheme') + ->disableOriginalConstructor() + ->getMock(); + $mock_active_theme->expects($this->any()) + ->method('getLibrariesOverride') + ->willReturn([]); + $this->themeManager->expects($this->any()) + ->method('getActiveTheme') + ->willReturn($mock_active_theme); $this->libraryDiscoveryParser = new TestLibraryDiscoveryParser($this->root, $this->moduleHandler, $this->themeManager); } diff --git a/core/tests/Drupal/Tests/Core/Theme/RegistryTest.php b/core/tests/Drupal/Tests/Core/Theme/RegistryTest.php index 0cb5a25..e0c20cd 100644 --- a/core/tests/Drupal/Tests/Core/Theme/RegistryTest.php +++ b/core/tests/Drupal/Tests/Core/Theme/RegistryTest.php @@ -93,7 +93,8 @@ public function testGetRegistryForModule() { 'engine' => 'twig', 'owner' => 'twig', 'stylesheets_remove' => [], - 'stylesheets_override' => [], + 'libraries_override' => [], + 'libraries_extend' => [], 'libraries' => [], 'extension' => '.twig', 'base_themes' => [], diff --git a/core/themes/bartik/bartik.info.yml b/core/themes/bartik/bartik.info.yml index 4ee4487..a74f90c 100644 --- a/core/themes/bartik/bartik.info.yml +++ b/core/themes/bartik/bartik.info.yml @@ -31,4 +31,3 @@ regions: footer_third: 'Footer third' footer_fourth: 'Footer fourth' footer_fifth: 'Footer fifth' - diff --git a/core/themes/seven/seven.info.yml b/core/themes/seven/seven.info.yml index b0911be..b983303 100644 --- a/core/themes/seven/seven.info.yml +++ b/core/themes/seven/seven.info.yml @@ -8,8 +8,11 @@ version: VERSION core: 8.x libraries: - seven/global-styling -stylesheets-remove: - - core/assets/vendor/jquery.ui/themes/base/dialog.css +libraries-override: + core/jquery.ui.dialog: + css: + component: + assets/vendor/jquery.ui/themes/base/dialog.css: false quickedit_stylesheets: - css/components/quickedit.css regions: