Issue #3031415 by markcarver: Overhaul CDN Providers API

parent 2b13823e
<?php
/**
* @file
* Drupal Bootstrap Drush commands.
*/
use Drupal\bootstrap\Bootstrap;
use Drupal\bootstrap\Theme;
use Drupal\Component\Serialization\Yaml;
/**
* Implements hook_drush_command().
*/
function bootstrap_drush_command() {
$items['bootstrap-generate-docs'] = [
'description' => dt('Generates markdown documentation for the Drupal based code.'),
'arguments' => [
'type' => 'The specific type of documentation to generate, defaults to "all". Can be: "all", "settings".',
],
'aliases' => ['bs-docs'],
];
return $items;
}
/**
* Generates markdown documentation.
*
* @param string $type
* The type of documentation.
*/
function drush_bootstrap_generate_docs($type = 'all') {
$types = $type === 'all' ? ['settings'] : [$type];
foreach ($types as $type) {
$function = "_drush_bootstrap_generate_docs_$type";
if (function_exists($function)) {
$ret = $function(Bootstrap::getTheme('bootstrap'));
if ($ret) {
drush_log('Successfully generated documentation for: ' . $type, 'success');
}
else {
drush_log('Unable to generate documentation for: ' . $type, 'error');
}
}
else {
drush_log('Invalid documentation type: ' . $type, 'error');
}
}
}
/**
* Generates settings documentation.
*
* @param \Drupal\bootstrap\Theme $bootstrap
* The theme instance of the Drupal Bootstrap base theme.
*/
function _drush_bootstrap_generate_docs_settings(Theme $bootstrap) {
$filename = realpath($bootstrap->getPath() . '/docs/Theme-Settings.md');
$marker_start = "<!-- THEME SETTINGS GENERATION START -->";
$marker_end = "<!-- THEME SETTINGS GENERATION END -->\n";
$contents = @file_get_contents($filename) ?: '';
$parts = @preg_split('/' . preg_quote($marker_start, '/') . '|' . preg_quote($marker_end, '/') . '/', $contents) ?: [];
$start = isset($parts[0]) ? [trim($parts[0])] : [];
$end = isset($parts[2]) ? [trim($parts[2])] : [];
// Determine the groups.
$groups = [
'general' => [],
'components' => [],
'javascript' => [],
'cdn' => [],
'advanced' => [],
];
foreach ($bootstrap->getSettingPlugin() as $setting) {
// Only get the first two groups (we don't need 3rd, or more, levels).
$_groups = array_slice(array_filter($setting->getGroups()), 0, 2, FALSE);
if (!$_groups) {
continue;
}
$groups[array_keys($_groups)[0]][implode(' > ', $_groups)][] = $setting->getPluginDefinition();
}
// Generate a table of each group's settings.
$lines = [$marker_start];
foreach ($groups as $subgroups) {
foreach ($subgroups as $group => $settings) {
$lines[] = '';
$lines[] = '---';
$lines[] = '';
$lines[] = "### $group";
$lines[] = '';
$lines[] = '<table class="table table-striped table-responsive">';
$lines[] = ' <thead>';
$lines[] = ' <tr>';
$lines[] = ' <th class="col-xs-3">Setting name</th>';
$lines[] = ' <th>Description and default value</th>';
$lines[] = ' </tr>';
$lines[] = ' </thead>';
$lines[] = ' <tbody>';
foreach ($settings as $definition) {
$lines[] = ' <tr>';
$lines[] = ' <td class="col-xs-3">';
$lines[] = $definition['id'];
$lines[] = ' </td>';
$lines[] = ' <td>';
if ($description = trim(str_replace('&quot;', '"', $definition['description']))) {
$lines[] = ' <div class="help-block">' . $description . '</div>';
}
if ($example = trim(Yaml::encode([$definition['id'] => $definition['defaultValue']]))) {
$lines[] = ' <pre class="language-yaml"><code>' . $example . '</code></pre>';
}
$lines[] = ' </td>';
$lines[] = ' </tr>';
}
$lines[] = ' </tbody>';
$lines[] = '</table>';
}
}
$lines[] = $marker_end;
// Ensure we have link references at the bottom.
$output = implode("\n", array_merge($start, $lines, $end)) . "\n";
// Save the generated output to the appropriate file.
return file_put_contents($filename, $output) !== FALSE;
}
......@@ -771,19 +771,14 @@ function bootstrap_element_smart_description(array &$element, array &$target = N
function bootstrap_get_cdn_assets($type = NULL, $provider = NULL, $theme = NULL) {
Bootstrap::deprecated();
$original_type = $type;
$assets = [];
$return = [];
$config = \Drupal::config('system.performance');
$cdnAssets = ProviderManager::load($theme, $provider)->getCdnAssets();
$assets = ProviderManager::load($theme, $provider)->getCdnAssets();
$types = !isset($type) ? ['css', 'js'] : (array) $type;
foreach ($types as $type) {
if ($config->get("$type.preprocess") && !empty($cdnAssets['min'][$type])) {
$assets[$type] = $cdnAssets['min'][$type];
$return[$type] = $assets->get($type, $config->get("$type.preprocess"));
}
elseif (!empty($data[$type])) {
$assets[$type] = $cdnAssets[$type];
}
}
return is_string($original_type) ? $assets[$original_type] : $assets;
return is_string($original_type) ? $return[$original_type] : $return;
}
/**
......
......@@ -28,15 +28,6 @@ After NodeJS has finished installing its own modules, it will automatically
invoke `grunt install` for you. This is a grunt task that is specifically
designed to keep the project in sync amongst maintainers.
## Drush
There are several commands available to run, please execute `drush` to view the
full list. This topic only covers the commands this project created.
### `drush bootstrap-generate-docs` or `drush bs-docs`
Generates markdown documentation for the Drupal based code. Possible arguments:
- **type:** The specific type of documentation to generate, defaults to `all`.
Possible values: `all|settings`
## Grunt
There are several tasks available to run, please execute `grunt --help` to view
the full list of tasks currently available. This topic only covers the most
......@@ -105,6 +96,15 @@ this limits the rapid development of the `overrides.less` file to the default
Bootstrap theme. If you have switched themes, you must manually compile all
the version and theme override files.
## Custom Scripts
This project also uses custom/standalone PHP scripts opposed to vendor specific
CLI programs (e.g. Drush or Drupal Console). This is primarily to ensure these
scripts can be executed regardless of which vendor specific CLI program or
version a maintainer may have installed.
### `./gen-theme-setting-docs.php`
Generates the markdown documentation for all available theme settings.
## Releases
This project attempts to provide more structured release notes. This allows the
project to communicate more effectively to the users what exactly has changed
......@@ -128,6 +128,11 @@ However, if it is long, it should really be a change record.
<p>&nbsp;</p>
<p>Changes since <!-- previous release --> (<!-- commit count -->):</p>
<h3 id="security">Security Announcements</h3>
<ul>
<li><!-- Issue/Commit Message --></li>
</ul>
<h3 id="features">New Features</h3>
<ul>
<li><!-- Issue/Commit Message --></li>
......
This diff is collapsed.
......@@ -3,7 +3,88 @@
<!-- @ingroup -->
# @BootstrapProvider
This plugin is a little too complex to explain (for now). If you would like to
help expand this documentation, please [create an issue](https://www.drupal.org/node/add/project-issue/bootstrap).
- [Create a plugin](#create)
- [Rebuild the cache](#rebuild)
See the existing classes below on examples of how to implement your own.
---
## Create a plugin {#create}
We'll use the `\Drupal\bootstrap\Plugin\Provider\JsDelivr` CDN Provider as an
example of how to create a quick custom CDN provider using its API URLs.
Replace all following instances of `THEMENAME` with the actual machine name of
your sub-theme.
You may also feel free to replace the provided URLs with your own. Most of the
popular CDN API output can be easily parsed, however you may need to provide
addition parsing in your custom CDN Provider if you're not getting the desired
results.
If you're truly interested in implementing a CDN Provider, it is highly
recommended that you read the accompanying PHP based documentation on the
classes and methods responsible for actually retrieving, parsing and caching
the data from the CDN's API.
Create a file at `./THEMENAME/src/Plugin/Provider/MyCdn.php` with the
following contents:
```php
<?php
namespace Drupal\THEMENAME\Plugin\Provider;
use Drupal\bootstrap\Plugin\Provider\ApiProviderBase;
/**
* The "mycdn" CDN Provider plugin.
*
* @ingroup plugins_provider
*
* @BootstrapProvider(
* id = "mycdn",
* label = @Translation("My CDN"),
* description = @Translation("My CDN (jsDelivr)"),
* weight = -1
* )
*/
class JsDelivr extends ApiProviderBase {
/**
* {@inheritdoc}
*/
protected function getApiAssetsUrlTemplate() {
return 'https://data.jsdelivr.com/v1/package/npm/@library@@version/flat';
}
/**
* {@inheritdoc}
*/
protected function getApiVersionsUrlTemplate() {
return 'https://data.jsdelivr.com/v1/package/npm/@library';
}
/**
* {@inheritdoc}
*/
protected function getCdnUrlTemplate() {
return 'https://cdn.jsdelivr.net/npm/@library@@version/@file';
}
}
?>
```
## Rebuild the cache {#rebuild}
Once you have saved, you must rebuild your cache for this new plugin to be
discovered. This must happen anytime you make a change to the actual file name
or the information inside the `@BootstrapProvider` annotation.
To rebuild your cache, navigate to `admin/config/development/performance` and
click the `Clear all caches` button. Or if you prefer, run `drush cr` from the
command line.
Voilà! After this, you should have a fully functional `@BootstrapProvider`
plugin!
<!-- THEME SETTINGS GENERATION START -->
{% for heading, settings in groups %}
---
### {{ heading|raw }}
<table class="table table-striped table-responsive">
<thead>
<tr>
<th class="col-xs-3">{{ 'Setting name'|t }}</th>
<th>{{ 'Description and default value'|t }}</th>
</tr>
</thead>
<tbody>
{% for id, setting in settings %}<tr>
<td class="col-xs-3">
<span id="{{- id|clean_class -}}" data-anchor="true">{{- id -}}</span>
</td>
<td>
<div class="help-block">{{- setting.description -}}</div>
<pre class="language-yaml"><code>{{- setting.defaultValue -}}</code></pre>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endfor %}
{% if deprecated %}
---
### {{ 'Deprecated'|t }}
<table class="table table-responsive">
<thead>
<tr>
<th class="col-xs-3">{{ 'Setting name'|t }}</th>
<th>{{ 'Description and default value'|t }}</th>
</tr>
</thead>
<tbody>
{% for id, setting in deprecated %}<tr class="bg-warning">
<td class="col-xs-3">
<span id="{{- id -}}" data-anchor="true">{{- id -}}</span>
</td>
<td>
<div class="help-block">{{- setting.description -}}</div>
<pre class="language-yaml"><code>{{- setting.defaultValue -}}</code></pre>
<div class="alert alert-danger alert-sm">
<strong>{{ 'Deprecated since @version'|t({'@version': setting.deprecated.version }) }}</strong> - {{ setting.deprecated.reason }} ({{ 'see: @replacement'|t({'@replacement': setting.deprecated.replacement}) }})
</div>
</td>
</tr>
{% endfor -%}
</tbody>
</table>
{% endif %}
<!-- THEME SETTINGS GENERATION END -->
......@@ -135,18 +135,22 @@
$context.find('[data-drupal-selector="edit-cdn"]').drupalSetSummary(function () {
var summary = [];
var $cdnProvider = $context.find('select[name="cdn_provider"] :selected');
var cdnProvider = $cdnProvider.val();
if ($cdnProvider.length) {
var provider = $cdnProvider.text();
var $version = $context.find('select[name="cdn_' + cdnProvider + '_version"] :selected');
var $version = $context.find('select[name="cdn_version"] :selected');
if ($version.length && $version.val().length) {
provider += ' - ' + $version.text();
var $theme = $context.find('select[name="cdn_' + cdnProvider + '_theme"] :selected');
var $theme = $context.find('select[name="cdn_theme"] :selected');
if ($theme.length) {
provider += ' (' + $theme.text() + ')';
}
}
else if ($cdnProvider.val() === 'custom') {
var $urls = $context.find('textarea[name="cdn_custom"]');
var urls = ($urls.val() + '').split(/\r\n|\n/).filter(Boolean);
provider += ' (' + Drupal.formatPlural(urls.length, '1 URL', '@count URLs') + ')';
}
summary.push(provider);
}
......@@ -197,10 +201,11 @@
}
},
complete: function () {
$preview.parent().find('select[name="cdn_jsdelivr_theme"]').bind('change', function () {
$preview.parent().find('select[name="cdn_theme"]').bind('change', function () {
$preview.find('.bootswatch-preview').addClass('visually-hidden');
if ($(this).val().length) {
$preview.find('#bootstrap-theme-preview-' + $(this).val()).removeClass('visually-hidden');
var theme = $(this).val();
if (theme && theme.length) {
$preview.find('#bootstrap-theme-preview-' + theme).removeClass('visually-hidden');
}
}).change();
}
......
<?php
/**
* @file
* Locates the Drupal root directory and bootstraps the kernel.
*/
use Drupal\Core\DrupalKernel;
use Symfony\Component\HttpFoundation\Request;
// Immediately return if classes are discoverable (already booted).
if (class_exists('\Drupal\Core\DrupalKernel') && class_exists('\Drupal')) {
return \Drupal::service('kernel');
}
function _find_autoloader($dir) {
if (file_exists($autoloadFile = $dir . '/autoload.php') || file_exists($autoloadFile = $dir . '/vendor/autoload.php')) {
return include_once($autoloadFile);
}
else if (empty($dir) || $dir === DIRECTORY_SEPARATOR) {
return FALSE;
}
return _find_autoloader(realpath("$dir/.."));
}
$autoloader = _find_autoloader(empty($_SERVER['PWD']) ? getcwd() : $_SERVER['PWD']);
if (!$autoloader || !class_exists('\Drupal\Core\DrupalKernel')) {
throw new \Exception("This script must be invoked inside a Drupal 8 environment. Unable to continue.");
}
// Create a DrupalKernel instance.
DrupalKernel::bootEnvironment();
$kernel = new DrupalKernel('prod', $autoloader);
// Need to change the current working directory to the actual root path.
// This is needed in case the script is initiated inside a sub-directory.
chdir($kernel->getAppRoot());
// Initialize settings, this requires reflection since its a protected method.
$request = Request::createFromGlobals();
$initializeSettings = new \ReflectionMethod($kernel, 'initializeSettings');
$initializeSettings->setAccessible(TRUE);
$initializeSettings->invokeArgs($kernel, [$request]);
// Boot the kernel.
$kernel->boot();
$kernel->preHandle($request);
// Due to a core bug, the theme handler has to be invoked to register theme
// namespaces with the autoloader.
// @todo Remove once installed_extensions makes its way into core.
// @see https://www.drupal.org/project/drupal/issues/2941757
$container = $kernel->getContainer();
if (!$container->has('installed_extensions')) {
$container->get('theme_handler')->listInfo();
}
return $kernel;
#!/usr/bin/env php
<?php
/**
* @file
* Generates the markdown documentation for all available theme settings.
*/
/**
* Note: this script is intended to be executed independently via PHP, e.g.:
* $ ./scripts/gen-theme-setting-docs.php
*/
use Drupal\bootstrap\Bootstrap;
use Drupal\bootstrap\Plugin\Setting\DeprecatedSettingInterface;
use Drupal\bootstrap\Plugin\Setting\SettingInterface;
use Drupal\Component\Render\FormattableMarkup;
use Drupal\Component\Utility\Html;
use Drupal\Core\Serialization\Yaml;
$kernel = require_once __DIR__ . '/bootstrap.php';
$bootstrap = Bootstrap::getTheme('bootstrap');
/** @var \Drupal\bootstrap\Plugin\Setting\SettingInterface[] $settings */
$settings = array_filter($bootstrap->getSettingPlugin(NULL, TRUE), function (SettingInterface $setting) {
return !!$setting->getGroups();
});
// Populate the variables with settings.
$variables = ['groups' => []];
$deprecatedSettings = [];
$replacementPairs = [
'&quot;' => '"',
'\n' => "\n",
];
foreach ($settings as $id => $setting) {
$defaultValue = $setting->getDefaultValue();
$deprecated = FALSE;
if ($setting instanceof DeprecatedSettingInterface) {
$newSetting = $setting->getDeprecatedReplacementSetting()->getPluginId();
$deprecated = [
'reason' => new FormattableMarkup($setting->getDeprecatedReason(), []),
'replacement' => new FormattableMarkup('<a href="#@anchor">@setting</a>', [
'@anchor' => Html::cleanCssIdentifier($newSetting),
'@setting' => $newSetting,
]),
'version' => new FormattableMarkup($setting->getDeprecatedVersion(), []),
];
}
$data = [
'id' => $id,
'description' => new FormattableMarkup(strtr($setting->getDescription(), $replacementPairs), []),
'defaultValue' => $defaultValue !== NULL ? new FormattableMarkup(strtr(trim(Yaml::encode([$id => $defaultValue])), $replacementPairs), []) : NULL,
'deprecated' => $deprecated,
];
// Defer adding deprecated settings.
if ($deprecated) {
$deprecatedSettings[$id] = $data;
}
else {
// Only get the first two groups (we don't need 3rd, or more, levels).
$header = implode(' > ', array_slice(array_filter($setting->getGroups()), 0, 2, FALSE));
$variables['groups'][$header][$id] = $data;
}
}
// Add Deprecated settings last (special table).
if ($deprecatedSettings) {
$variables['deprecated'] = $deprecatedSettings;
}
$docsPath = "{$bootstrap->getPath()}/docs";
// Render the settings.
$output = Bootstrap::renderCustomTemplate("{$docsPath}/theme-settings.twig", $variables);
// Save the generated output to the appropriate file.
$result = Bootstrap::putContents("{$docsPath}/Theme-Settings.md", $output, '<!-- THEME SETTINGS GENERATION START -->', '<!-- THEME SETTINGS GENERATION END -->');
if ($result) {
echo 'Successfully generated theme documentation!';
exit(0);
}
echo 'Unable to generate theme documentation!';
exit(1);
This diff is collapsed.
<?php
namespace Drupal\bootstrap;
/**
* Interface DeprecatedInterface.
*/
interface DeprecatedInterface {
/**
* The reason for deprecation.
*
* @return \Drupal\Core\StringTranslation\TranslatableMarkup
* A TranslatableMarkup object.
*/
public function getDeprecatedReason();
/**
* The code that replaces the deprecated functionality.
*
* @return string|false
* The replacement code location or FALSE if there is no replacement.
*/
public function getDeprecatedReplacement();
/**
* The version this was deprecated in.
*
* @return string
* A version string.
*/
public function getDeprecatedVersion();
}
<?php
namespace Drupal\bootstrap;
use Drupal\Component\Serialization\Json;
use Symfony\Component\HttpFoundation\Response;
/**
* Class JsonResponse.
*/
class JsonResponse extends Response {
/**
* The decoded JSON array.
*
* @var array
*/
protected $json;
/**
* {@inheritdoc}
*/
public function __construct($content = '', $status = 200, array $headers = []) {
parent::__construct($content, $status, $headers);
$this->json = Json::decode($content ?: '[]') ?: [];
}
/**
* Creates a new JsonResponse object from a Symfony Response object.
*
* @param \Symfony\Component\HttpFoundation\Response $response
* A Symfony Response object.
*
* @return \Drupal\bootstrap\JsonResponse
* A JsonResponse object.
*/
public static function createFromResponse(Response $response) {
return new static($response->getContent(), $response->getStatusCode(), $response->headers->all());
}
/**
* Retrieves the JSON array.
*
* @return array
* The JSON array.
*/
public function getJson(): array {
return $this->json;
}
}
......@@ -36,9 +36,7 @@ class LibraryInfo extends PluginBase implements AlterInterface {
}
// Alter the framework library based on currently set CDN Provider.
if ($cdnProvider = $this->theme->getCdnProvider()) {
$cdnProvider->alterFrameworkLibrary($libraries['framework']);
}
$this->theme->getCdnProvider()->alterFrameworkLibrary($libraries['framework']);
}
// Core replacements.
elseif ($extension === 'core') {
......
......@@ -31,6 +31,10 @@ class SystemThemeSettings extends FormBase implements FormInterface {
// Iterate over all setting plugins and add them to the form.
foreach ($theme->getSettingPlugin() as $setting) {
// Skip settings that shouldn't be created automatically.
if (!$setting->autoCreateFormElement()) {
continue;
}
$setting->alterForm($form->getArray(), $form_state);
}
}
......@@ -198,6 +202,12 @@ class SystemThemeSettings extends FormBase implements FormInterface {
// Retrieve the submitted value.
$value = $form_state->getValue($name);
// Trim any new lines and convert to simple new line breaks.
$definition = $setting->getPluginDefinition();
if (isset($definition['type']) && $definition['type'] === 'textarea' && is_string($value)) {
$value = implode("\n", array_filter(array_map('trim', preg_split("/\r\n|\n/", $value))));
}
// Determine if the setting has a new value that overrides the original.
// Ignore the schemas "setting" because it's handled by UpdateManager.
if ($name !== 'schemas' && $settings->overridesValue($name, $value)) {
......
<?php
namespace Drupal\bootstrap\Plugin\Preprocess;
use Drupal\bootstrap\Utility\Variables;
/**
* Pre-processes variables for the "container__help_block" theme hook.
*
* @ingroup plugins_preprocess
*
* @BootstrapPreprocess("container__help_block")
*/
class ContainerHelpBlock extends PreprocessBase implements PreprocessInterface {
/**
* {@inheritdoc}
*/
public function preprocessVariables(Variables $variables) {
$variables->addClass('help-block');
}
}
This diff is collapsed.
<?php
namespace Drupal\bootstrap\Plugin\Provider;
/**
* The "bootstrapcdn" CDN Provider plugin.
*
* @ingroup plugins_provider
*
* @BootstrapProvider(
* id = "bootstrapcdn",
* label = @Translation("BootstrapCDN"),
* description = @Translation("BootstrapCDN was founded in 2012 by <a href=:DavidHenzel rel=noopener target=_blank>David Henzel</a> and Justin Dorfman at MaxCDN. Today, BootstrapCDN is used by over <a href=:built_with rel=noopener target=_blank>7.9 million sites</a> delivering over 70 billion requests a month.", arguments = {
* ":DavidHenzel" = "https://twitter.com/DavidHenzel",
* ":built_with" = "https://trends.builtwith.com/cdn/BootstrapCDN",
* }),
* )
*/
class BootstrapCdn extends ApiProviderBase {
/**
* {@inheritdoc}
*/
protected function getApiAssetsUrlTemplate() {
return 'https://www.bootstrapcdn.com/api/v1/@library/@version';
}