Issue #2852156 by markcarver: Move "overrides" source files and generated CSS to separate project

Signed-off-by: markcarver's avatarMark Carver <mark.carver@me.com>
parent e732cb42
......@@ -351,6 +351,19 @@ class Bootstrap {
return static::getTheme('bootstrap')->getPath() . '/autoload-fix.php';
}
/**
* Checks whether a specific URL is reachable.
*
* @param string $url
* The URL to check.
* @param array $options
* Additional options to pass to the HTTP client.
* @param \Exception|null $exception
* Any Exceptions throw, passed by reference.
*
* @return \Drupal\bootstrap\SerializedResponse
* A SerializedResponse object.
*/
public static function checkUrlIsReachable($url, array $options = [], &$exception = NULL) {
$options['method'] = 'HEAD';
$options['ttl'] = 0;
......@@ -383,7 +396,10 @@ class Bootstrap {
unset($options['ttl']);
$cache = \Drupal::keyValueExpirable('theme:' . static::getTheme()->getName() . ':http');
$hash = Crypt::generateBase64HashIdentifier($options, ['request', $url]);
// The URL cannot be part of the prefix as the "name" field of
// "key_value_expire" has a max length of 128.
$hash = Crypt::generateBase64HashIdentifier(['url' => $url] + $options, 'request');
$response = $cache->get($hash);
if (!isset($response)) {
......@@ -404,9 +420,24 @@ class Bootstrap {
}
// Only cache if a maximum age has been detected.
if ($response->getStatusCode() == 200 && ($maxAge = isset($ttl) ? $ttl : $response->getMaxAge())) {
$maxAge = (int) isset($ttl) ? $ttl : $response->getMaxAge();
if ($response->getStatusCode() == 200 && $maxAge > 0) {
// Due to key_value_expire setting the "expire" field to "INT(11)", it
// is technically limited to a 32bit max value (Y2K38 bug).
// @todo Remove this once this is no longer an issue.
// @see https://www.drupal.org/project/drupal/issues/65474
// @see https://www.drupal.org/project/drupal/issues/1003692
$requestTime = \Drupal::time()->getRequestTime();
if (($requestTime + $maxAge) > 2147483647) {
$maxAge = 2147483647 - $requestTime;
}
try {
$cache->setWithExpire($hash, $response, $maxAge);
}
catch (\Exception $e) {
// Intentionally do nothing, tried to cache response... it failed.
}
}
}
return $response;
......
......@@ -20,9 +20,13 @@ abstract class ApiProviderBase extends ProviderBase {
protected function discoverCdnAssets($version, $theme = NULL) {
if ($this->supportsThemes()) {
$themes = $this->getCdnThemes($version);
return isset($themes[$theme]) ? $themes[$theme] : new CdnAssets();
if (isset($themes[$theme])) {
return $themes[$theme];
}
return $this->requestApiAssets('bootstrap', $version, $this->getCacheTtl(static::CACHE_ASSETS));
// Fall back to the first available theme if possible (likely Bootstrap).
return reset($themes) ?: new CdnAssets();
}
return $this->requestApiAssets('bootstrap', $version)->getTheme('bootstrap');
}
/**
......@@ -31,7 +35,7 @@ abstract class ApiProviderBase extends ProviderBase {
protected function discoverCdnThemes($version) {
$assets = new CdnAssets();
foreach (['bootstrap', 'bootswatch'] as $library) {
$assets = $this->requestApiAssets($library, $version, $this->getCacheTtl(static::CACHE_THEMES), $assets);
$assets = $this->requestApiAssets($library, $version, $assets);
}
return $assets->getThemes();
}
......@@ -40,7 +44,7 @@ abstract class ApiProviderBase extends ProviderBase {
* {@inheritdoc}
*/
protected function discoverCdnVersions() {
return $this->requestApiVersions('bootstrap', $this->getCacheTtl(static::CACHE_VERSIONS));
return $this->requestApiVersions('bootstrap');
}
/**
......@@ -112,7 +116,7 @@ abstract class ApiProviderBase extends ProviderBase {
* Additional information about the file, if any.
*
* @return \Drupal\bootstrap\Plugin\Provider\CdnAsset
* A CDN URL.
* A CDN Asset object, for a given URL.
*/
protected function getCdnUrl($library, $version, $file, array $info = []) {
$library = $this->mapLibrary($library);
......@@ -302,18 +306,16 @@ abstract class ApiProviderBase extends ProviderBase {
* The library to request.
* @param string $version
* The version to request.
* @param int $ttl
* Optional. A specific TTL value to use for caching the HTTP request. If
* not set, it will default to whatever is returned by the HTTP request.
* @param \Drupal\bootstrap\Plugin\Provider\CdnAssets $assets
* An existing CdnAssets object, if chaining multiple requests together.
*
* @return \Drupal\bootstrap\Plugin\Provider\CdnAssets
* The CdnAssets provided by the API.
*/
protected function requestApiAssets($library, $version, $ttl = NULL, CdnAssets $assets = NULL) {
protected function requestApiAssets($library, $version, CdnAssets $assets = NULL) {
$url = $this->getApiAssetsUrl($library, $version);
$data = $this->request($url, ['ttl' => $ttl])->getData();
$options = ['ttl' => $this->getCacheTtl(static::CACHE_ASSETS)];
$data = $this->request($url, $options)->getData();
// If bootstrap data could not be returned, provide defaults.
if (!$data && $this->cdnExceptions && $library === 'bootstrap') {
......@@ -336,16 +338,14 @@ abstract class ApiProviderBase extends ProviderBase {
*
* @param string $library
* The library to request versions for.
* @param int $ttl
* Optional. A specific TTL value to use for caching the HTTP request. If
* not set, it will default to whatever is returned by the HTTP request.
*
* @return array
* An associative array of versions, keyed by version.
*/
public function requestApiVersions($library, $ttl = NULL) {
public function requestApiVersions($library) {
$url = $this->getApiVersionsUrl($library);
$data = $this->request($url, ['ttl' => $ttl])->getData();
$options = ['ttl' => $this->getCacheTtl(static::CACHE_VERSIONS)];
$data = $this->request($url, $options)->getData();
// If bootstrap data could not be returned, provide defaults.
if (!$data && $this->cdnExceptions && $library === 'bootstrap') {
......
......@@ -11,6 +11,7 @@ namespace Drupal\bootstrap\Plugin\Provider;
* id = "_broken",
* label = @Translation("Broken"),
* description = @Translation("Broken CDN Provider instance."),
* hidden = true,
* )
*/
class Broken extends ProviderBase {
......
......@@ -33,7 +33,7 @@ class CdnAsset {
*
* @var string
*/
const VALID_FILE_REGEXP = '`([^/]*)/bootstrap(-theme)?(\.min)?\.(js|css)$`';
const VALID_FILE_REGEXP = '`([^/]*)/(?:[\w-]+)?bootstrap(?:-([\w]+))?(\.min)?\.(js|css)$`';
/**
* A list of available Bootswatch themes, keyed by major Bootstrap version.
......@@ -161,27 +161,33 @@ class CdnAsset {
*/
public function __construct($url, $library = NULL, $version = NULL, array $info = []) {
// Extract the necessary data from the file.
list($path, $example, $minified, $type) = static::extractParts($url);
list($path, $theme, $minified, $type) = static::extractParts($url);
// Bootstrap's example theme.
if ($example) {
if ($theme === 'theme') {
$theme = 'bootstrap_theme';
$label = $this->t('Example Theme');
if (!isset($library)) {
$library = 'bootstrap';
}
}
// Core bootstrap library.
elseif ($path === 'css' || $path === 'js') {
elseif (!$theme && ($path === 'css' || $path === 'js' || $path === Bootstrap::PROJECT_BRANCH)) {
$theme = 'bootstrap';
$label = $this->t('Default');
if (!isset($library)) {
$library = 'bootstrap';
}
}
// Other (e.g. bootswatch theme).
else {
$bootswatchThemes = isset(static::$bootswatchThemes[Bootstrap::FRAMEWORK_VERSION[0]]) ? static::$bootswatchThemes[Bootstrap::FRAMEWORK_VERSION[0]] : [];
if (!$theme || ($theme && !in_array($theme, $bootswatchThemes))) {
$theme = in_array($path, $bootswatchThemes) ? $path : 'bootstrap';
}
$label = new HtmlEscapedText(ucfirst($theme));
if (!isset($library)) {
$library = in_array($path, $bootswatchThemes) ? 'bootswatch' : 'unknown';
$library = in_array($theme, $bootswatchThemes) ? 'bootswatch' : 'unknown';
}
}
......@@ -217,10 +223,10 @@ class CdnAsset {
protected static function extractParts($url) {
preg_match(static::VALID_FILE_REGEXP, $url, $matches);
$path = isset($matches[1]) ? mb_strtolower(Html::escape($matches[1])) : NULL;
$example = isset($matches[2]) ? !!$matches[2] : FALSE;
$theme = isset($matches[2]) ? mb_strtolower(Html::escape($matches[2])) : NULL;
$minified = isset($matches[3]) ? !!$matches[3] : FALSE;
$type = isset($matches[4]) ? mb_strtolower(Html::escape($matches[4])) : NULL;
return [$path, $example, $minified, $type];
return [$path, $theme, $minified, $type];
}
/**
......
......@@ -157,6 +157,26 @@ class CdnAssets {
return $this->library;
}
/**
* Retrieves a specific theme.
*
* @param string $theme
* The theme to return. If not specified, the first available theme will
* be returned.
*
* @return static
*/
public function getTheme($theme = NULL) {
$themes = $this->getThemes();
if (!$theme) {
return reset($themes) ?: new static();
}
if (isset($themes[$theme])) {
return $themes[$theme];
}
return new static();
}
/**
* Groups available assets by theme.
*
......@@ -207,6 +227,19 @@ class CdnAssets {
return $themes;
}
/**
* Merges another CdnAssets object onto this one.
*
* @param \Drupal\bootstrap\Plugin\Provider\CdnAssets $assets
* A CdnAssets object.
*
* @return static
*/
public function merge(CdnAssets $assets) {
$this->appendAssets($assets->toArray());
return $this;
}
/**
* Prepends a CdnAsset object to the list.
*
......
<?php
namespace Drupal\bootstrap\Plugin\Provider;
use Drupal\bootstrap\Bootstrap;
/**
* The "drupal_bootstrap_styles" CDN Provider plugin.
*
* @ingroup plugins_provider
*
* @BootstrapProvider(
* id = "drupal_bootstrap_styles",
* label = @Translation("Drupal Bootstrap Styles"),
* description = @Translation("Provides styles that bridge the gap between Drupal and Bootstrap."),
* hidden = true,
* )
*/
class DrupalBootstrapStyles extends JsDelivr {
const KNOWN_FALL_BACK_VERSION = '0.0.1';
/**
* Retrieves the latest version of the published NPM package.
*
* While this isn't technically necessary, jsDelivr have been known to not
* favor "version-less" requests. This ensures that a specific and published
* NPM version is always used.
*
* @return string
* The latest version.
*/
protected function getLatestVersion() {
$json = $this->request('https://data.jsdelivr.com/v1/package/npm/@unicorn-fail/drupal-bootstrap-styles', ['ttl' => static::TTL_ONE_WEEK])->getData();
return isset($json['tags']['latest']) ? $json['tags']['latest'] : static::KNOWN_FALL_BACK_VERSION;
}
/**
* {@inheritdoc}
*/
protected function getApiAssetsUrlTemplate() {
$latest = $this->getLatestVersion();
return "https://cdn.jsdelivr.net/npm/@unicorn-fail/drupal-bootstrap-styles@$latest/dist/api.json";
}
/**
* {@inheritdoc}
*/
protected function getApiVersionsUrlTemplate() {
$latest = $this->getLatestVersion();
return "https://cdn.jsdelivr.net/npm/@unicorn-fail/drupal-bootstrap-styles@$latest/dist/api.json";
}
/**
* {@inheritdoc}
*/
protected function getCdnUrlTemplate() {
$latest = $this->getLatestVersion();
return "https://cdn.jsdelivr.net/npm/@unicorn-fail/drupal-bootstrap-styles@$latest/@file";
}
/**
* {@inheritdoc}
*/
protected function parseAssets(array $data, $library, $version, CdnAssets $assets = NULL) {
if (!isset($assets)) {
$assets = new CdnAssets();
}
$files = array_filter(isset($data['files']) ? $data['files'] : [], function ($file) use ($library, $version) {
if (strpos($file['name'], '/dist/' . $version . '/' . Bootstrap::PROJECT_BRANCH . '/') !== 0) {
return FALSE;
}
$theme = !!preg_match("`drupal-bootstrap-([\w]+)(\.min)?\.css$`", $file['name']);
return ($library === 'bootstrap' && !$theme) || ($library === 'bootswatch' && $theme);
});
foreach ($files as $file) {
$assets->append($this->getCdnUrl('drupal-bootstrap-styles', $version, !empty($file['symlink']) ? $file['symlink'] : $file['name'], $file));
}
return $assets;
}
/**
* {@inheritdoc}
*/
protected function parseVersions(array $data = []) {
$versions = [];
$files = isset($data['files']) ? $data['files'] : [];
foreach ($files as $file) {
if (preg_match("`^/?dist/(\d+\.\d+\.\d+)/(\d\.x-\d\.x)/drupal-bootstrap-?([\w]+)?(\.min)?\.css$`", $file['name'], $matches)) {
$version = $matches[1];
$branch = $matches[2];
if ($branch === Bootstrap::PROJECT_BRANCH && $this->isValidVersion($version)) {
$versions[$version] = $version;
}
}
}
return $versions;
}
}
......@@ -104,15 +104,14 @@ class ProviderBase extends PluginBase implements ProviderInterface {
$hash = Crypt::generateBase64HashIdentifier($data);
// Retrieve the cached value or build it if necessary.
$assets = $this->cacheGet('library', $hash, [], function () use ($data) {
$framework = $this->cacheGet('library', $hash, [], function () use ($framework, $data) {
$version = isset($data['version']) ? $data['version'] : NULL;
$theme = isset($data['theme']) ? $data['theme'] : NULL;
return $this->getCdnAssets($version, $theme)->toLibraryArray($data['min']);
});
$assets = $this->getCdnAssets($version, $theme)->toLibraryArray($data['min']);
// Immediately return if there are no theme CDN assets to use.
if (empty($assets)) {
return;
return $framework;
}
// Override the framework version with the CDN version that is being used.
......@@ -120,19 +119,16 @@ class ProviderBase extends PluginBase implements ProviderInterface {
$framework['version'] = $data['version'];
}
// Merge the assets into the library info.
$framework = NestedArray::mergeDeepArray([$assets, $framework], TRUE);
// The overrides file must also be stored in the "base" category so
// it isn't added after any potential sub-theme's "theme" category.
// There's no weight, so it will be added after the provider's assets.
// Since this uses a relative path to the ancestor from DRUPAL_ROOT,
// the entire path must be prepended with a forward slash (/) so it
// doesn't prepend the active theme's path.
// @see https://www.drupal.org/node/2770613
if ($overrides = $this->getOverrides()) {
$framework['css']['base']["/$overrides"] = [];
// @todo Provide a UI setting for this?
$styles = [];
if ($this->theme->getSetting('cdn_styles', TRUE)) {
$stylesProvider = ProviderManager::load($this->theme, 'drupal_bootstrap_styles');
$styles = $stylesProvider->getCdnAssets($version, $theme)->toLibraryArray($data['min']);
}
// Merge the assets with the existing library info and return it.
return NestedArray::mergeDeepArray([$assets, $styles, $framework], TRUE);
});
}
/**
......@@ -267,6 +263,10 @@ class ProviderBase extends PluginBase implements ProviderInterface {
public function getCacheTtl($type) {
if (!isset($this->cacheTtl[$type])) {
$this->cacheTtl[$type] = (int) $this->theme->getSetting("cdn_cache_ttl_$type", static::TTL_NEVER);
// If TTL is -1, the set a far reaching date from now.
if ($this->cacheTtl[$type] === static::TTL_FOREVER) {
$this->cacheTtl[$type] = static::TTL_ONE_YEAR * 10;
}
}
return $this->cacheTtl[$type];
}
......@@ -474,30 +474,6 @@ class ProviderBase extends PluginBase implements ProviderInterface {
return $this->pluginDefinition['label'] ?: $this->getPluginId();
}
/**
* Retrieves the Drupal overrides CSS file.
*
* @return string|null
* THe Drupal overrides CSS file.
*
* @todo This should really be a part of the CDN asset discovery phase.
*
* @see https://www.drupal.org/project/bootstrap/issues/2852156
*/
protected function getOverrides() {
$overrides = NULL;
$version = $this->getCdnVersion() ?: Bootstrap::FRAMEWORK_VERSION;
$theme = $this->getCdnTheme();
$theme = !$theme || $theme === '_default' || $theme === 'bootstrap' || $theme === 'bootstrap_theme' ? '' : "-$theme";
foreach ($this->theme->getAncestry(TRUE) as $ancestor) {
$file = $ancestor->getPath() . "/css/{$version}/overrides{$theme}.min.css";
if (file_exists($file)) {
$overrides = $file;
}
}
return $overrides;
}
/**
* {@inheritdoc}
*/
......
......@@ -438,8 +438,9 @@ class Theme {
// Only continue if the theme is Bootstrap based.
if ($this->isBootstrap()) {
$provider_manager = new ProviderManager($this);
foreach (array_keys($provider_manager->getDefinitions()) as $provider) {
if ($provider === 'none' || $provider === '_broken') {
foreach ($provider_manager->getDefinitions() as $provider => $definition) {
// Ignore hidden providers.
if (!empty($definition['hidden'])) {
continue;
}
$this->cdnProviders[$provider] = $provider_manager->get($provider, ['theme' => $this]);
......
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