Commit 451bebc4 authored by effulgentsia's avatar effulgentsia

Issue #2451411 by almaudoh, Shamsher_Alam, lauriii, borisson_, cilefen,...

Issue #2451411 by almaudoh, Shamsher_Alam, lauriii, borisson_, cilefen, davidhernandez, Cottser, Wim Leers, joelpittet: Add libraries-override to themes' *.info.yml
parent 6a175c39
......@@ -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])) {
......
<?php
/**
* @file
* Contains \Drupal\Core\Asset\Exception\InvalidLibrariesOverrideSpecificationException.
*/
namespace Drupal\Core\Asset\Exception;
/**
* Defines a custom exception if a definition refers to a non-existent library.
*/
class InvalidLibrariesOverrideSpecificationException extends \RuntimeException {
}
......@@ -9,8 +9,6 @@
use Drupal\Core\Cache\CacheCollectorInterface;
use Drupal\Core\Cache\CacheTagsInvalidatorInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Theme\ThemeManagerInterface;
/**
* Discovers available asset libraries in Drupal.
......@@ -87,6 +85,8 @@ public function getLibraryByName($extension, $name) {
*/
public function clearCachedDefinitions() {
$this->cacheTagInvalidator->invalidateTags(['library_info']);
$this->libraryDefinitions = [];
$this->collector->clear();
}
}
......@@ -7,6 +7,7 @@
namespace Drupal\Core\Asset;
use Drupal\Core\Asset\Exception\InvalidLibrariesOverrideSpecificationException;
use Drupal\Core\Cache\CacheCollector;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Lock\LockBackendInterface;
......@@ -79,9 +80,52 @@ 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']));
}
}
}
}
return $libraries;
}
}
......@@ -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;
......@@ -313,6 +323,70 @@ protected function parseLibraryInfo($extension, $path) {
return $libraries;
}
/**
* 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().
*/
......@@ -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;
}
}
......@@ -80,6 +80,13 @@ class ActiveTheme {
*/
protected $regions;
/**
* The libraries or library assets overridden by the theme.
*
* @var array
*/
protected $librariesOverride;
/**
* Constructs an ActiveTheme object.
*
......@@ -96,6 +103,7 @@ public function __construct(array $values) {
'extension' => 'html.twig',
'base_themes' => [],
'regions' => [],
'libraries_override' => [],
];
$this->name = $values['name'];
......@@ -107,6 +115,7 @@ public function __construct(array $values) {
$this->extension = $values['extension'];
$this->baseThemes = $values['base_themes'];
$this->regions = $values['regions'];
$this->librariesOverride = $values['libraries_override'];
}
/**
......@@ -169,6 +178,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 +209,14 @@ 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;
}
}
......@@ -161,27 +161,27 @@ 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;
}
}
......@@ -241,6 +241,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 +258,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;
}
}
......@@ -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
......
......@@ -7,3 +7,11 @@ 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
/**
* @file
* Test CSS asset file for test_theme.theme.
*/
/**
* @file
* Test JS asset file for test_theme.theme.
*/
......@@ -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
......
......@@ -3,3 +3,11 @@ global-styling:
css:
base:
kitten.css: {}
collapse:
version: VERSION
js:
js/collapse.js: { }
css:
base:
css/collapse.css: { }
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: { }
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
......@@ -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);
}
......
......@@ -93,7 +93,7 @@ public function testGetRegistryForModule() {
'engine' => 'twig',
'owner' => 'twig',
'stylesheets_remove' => [],
'stylesheets_override' => [],
'libraries_override' => [],
'libraries' => [],
'extension' => '.twig',
'base_themes' => [],
......
......@@ -31,4 +31,3 @@ regions:
footer_third: 'Footer third'
footer_fourth: 'Footer fourth'
footer_fifth: 'Footer fifth'
......@@ -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:
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment