Skip to content

Commit

Permalink
Cache external JS/CSS files
Browse files Browse the repository at this point in the history
Resolves #9987
  • Loading branch information
brandonkelly committed Apr 27, 2022
1 parent bec317c commit 1cb2edf
Show file tree
Hide file tree
Showing 6 changed files with 276 additions and 36 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG-WIP.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,8 @@
- Added `craft\helpers\FileHelper::deleteQueuedFiles()`.
- Added `craft\helpers\Gql::getSchemaContainedEntryTypes)()`.
- Added `craft\helpers\Html::hiddenLabel()`.
- Added `craft\helpers\Html::unwrapCondition()`.
- Added `craft\helpers\Html::unwrapNoscript()`.
- Added `craft\helpers\ImageTransforms`.
- Added `craft\helpers\Money`.
- Added `craft\helpers\Number::isInt()`.
Expand Down Expand Up @@ -388,6 +390,10 @@
- Added `craft\web\twig\variables\Cp::fieldLayoutDesigner()`.
- Added `craft\web\twig\variables\Cp::getFsOptions()`.
- Added `craft\web\twig\variables\Cp::getVolumeOptions()`.
- Added `craft\web\View::clearCssFileBuffer()`.
- Added `craft\web\View::clearJsFileBuffer()`.
- Added `craft\web\View::startCssFileBuffer()`.
- Added `craft\web\View::startJsFileBuffer()`.
- Added the `Craft.appendBodyHtml()` JavaScript method, which replaces the now-deprecated `appendFootHtml()` method.
- Added the `Craft.CpScreenSlideout` JavaScript class, which can be used to create slideouts from actions that return `$this->asCpScreen()`.
- Added the `Craft.ElementEditor` JavaScript class.
Expand Down Expand Up @@ -450,6 +456,7 @@
- Template autosuggestions now include their filename. ([#9744](https://github.com/craftcms/cms/pull/9744))
- Improved the look of loading spinners in the control panel. ([#9109](https://github.com/craftcms/cms/discussions/9109))
- The default `subLeft` and `subRight` search query term options are now only applied to terms that don’t include an asterisk at the beginning/end, e.g. `hello*`. ([#10613](https://github.com/craftcms/cms/discussions/10613))
- `{% cache %}` tags now store any external JavaScript or CSS files registered with `{% js %}` and `{% css %}` tags. ([#9987](https://github.com/craftcms/cms/discussions/9987))
- All control panel templates end in `.twig` now. ([#9743](https://github.com/craftcms/cms/pull/9743))
- 404 requests are no longer logged by default. ([#10659](https://github.com/craftcms/cms/pull/10659))
- Log entries are now single-line by default when Dev Mode is disabled. ([#10659](https://github.com/craftcms/cms/pull/10659))
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Release Notes for Craft CMS 4

## Unreleased

### Added
- Added `craft\helpers\Html::unwrapCondition()`.
- Added `craft\helpers\Html::unwrapNoscript()`.
- Added `craft\web\View::clearCssFileBuffer()`.
- Added `craft\web\View::clearJsFileBuffer()`.
- Added `craft\web\View::startCssFileBuffer()`.
- Added `craft\web\View::startJsFileBuffer()`.

### Changed
- `{% cache %}` tags now store any external JavaScript or CSS files registered with `{% js %}` and `{% css %}` tags. ([#9987](https://github.com/craftcms/cms/discussions/9987))

## 4.0.0-RC1 - 2022-04-26

### Added
Expand Down
41 changes: 41 additions & 0 deletions src/helpers/Html.php
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,47 @@ private static function _sortedDataAttributes(): array
return self::$_sortedDataAttributes;
}

/**
* Unwraps an IE conditional comment from the given HTML.
*
* @param string $content
* @return array[] An array containing the HTML content, and the condition (if there is one).
* @phpstan-return array{string,string|null}
* @since 4.0.0
* @see wrapIntoCondition()
*/
public static function unwrapCondition(string $content): array
{
if (preg_match('/^<!--\[if (.*?)]>(?:<!-->)?\\n(.*)\\n<!(?:--<!)?\[endif]-->$/s', $content, $match)) {
$condition = $match[1];
$content = $match[2];
} else {
$condition = null;
}

return [$content, $condition];
}

/**
* Unwraps a `<noscript>` tag from the given HTML.
*
* @param string $content
* @return array[] An array containing the HTML content, and whether a `<noscript>` tag was found.
* @phpstan-return array{string,bool}
* @since 4.0.0
*/
public static function unwrapNoscript(string $content): array
{
if (preg_match('/^<noscript>(.*)<\/noscript>$/s', $content, $match)) {
$noscript = true;
$content = $match[1];
} else {
$noscript = false;
}

return [$content, $noscript];
}

/**
* Normalizes an element ID into only alphanumeric characters, underscores, and dashes, or generates one at random.
*
Expand Down
119 changes: 83 additions & 36 deletions src/services/TemplateCaches.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
namespace craft\services;

use Craft;
use craft\helpers\ArrayHelper;
use craft\helpers\DateTimeHelper;
use craft\helpers\Html;
use craft\helpers\StringHelper;
Expand Down Expand Up @@ -43,11 +44,11 @@ class TemplateCaches extends Component
*
* @param string $key The template cache key
* @param bool $global Whether the cache would have been stored globally.
* @param bool $registerScripts Whether JS and CSS code coptured with the cache should be registered
* @param bool $registerResources Whether JS and CSS resources captured by the cache should be registered
* @return string|null
* @throws Exception if this is a console request and `false` is passed to `$global`
*/
public function getTemplateCache(string $key, bool $global, bool $registerScripts = false): ?string
public function getTemplateCache(string $key, bool $global, bool $registerResources = false): ?string
{
// Make sure template caching is enabled
if ($this->_isTemplateCachingEnabled() === false) {
Expand All @@ -61,14 +62,20 @@ public function getTemplateCache(string $key, bool $global, bool $registerScript
return null;
}

[$body, $tags, $bufferedJs, $bufferedScripts, $bufferedCss] = array_pad($data, 5, null);
[$body, $tags, $bufferedJs, $bufferedScripts, $bufferedCss, $bufferedJsFiles, $bufferedCssFiles] = array_pad($data, 7, null);

// If we're actively collecting element cache tags, add this cache's tags to the collection
Craft::$app->getElements()->collectCacheTags($tags);

// Register JS and CSS tags
if ($registerScripts) {
$this->_registerScripts($bufferedJs ?? [], $bufferedScripts ?? [], $bufferedCss ?? []);
if ($registerResources) {
$this->_registerResources(
$bufferedJs ?? [],
$bufferedScripts ?? [],
$bufferedCss ?? [],
$bufferedJsFiles ?? [],
$bufferedCssFiles ?? [],
);
}

return $body;
Expand All @@ -77,12 +84,13 @@ public function getTemplateCache(string $key, bool $global, bool $registerScript
/**
* Starts a new template cache.
*
* @param bool $withScripts Whether JS and CSS code registered with [[\craft\web\View::registerJs()]],
* [[\craft\web\View::registerScript()]], and [[\craft\web\View::registerCss()]] should be captured and
* included in the cache. If this is `true`, be sure to pass `$withScripts = true` to [[endTemplateCache()]]
* @param bool $withResources Whether JS and CSS code registered with [[\craft\web\View::registerJs()]],
* [[\craft\web\View::registerScript()]], [[\craft\web\View::registerCss()]],
* [[\craft\web\View::registerJsFile()]], and [[\craft\web\View::registerCssFile()]] should be captured and
* included in the cache. If this is `true`, be sure to pass `$withResources = true` to [[endTemplateCache()]]
* as well.
*/
public function startTemplateCache(bool $withScripts = false): void
public function startTemplateCache(bool $withResources = false): void
{
// Make sure template caching is enabled
if ($this->_isTemplateCachingEnabled() === false) {
Expand All @@ -91,11 +99,13 @@ public function startTemplateCache(bool $withScripts = false): void

Craft::$app->getElements()->startCollectingCacheTags();

if ($withScripts) {
if ($withResources) {
$view = Craft::$app->getView();
$view->startJsBuffer();
$view->startScriptBuffer();
$view->startCssBuffer();
$view->startJsFileBuffer();
$view->startCssFileBuffer();
}
}

Expand All @@ -107,13 +117,14 @@ public function startTemplateCache(bool $withScripts = false): void
* @param string|null $duration How long the cache should be stored for. Should be a [relative time format](https://php.net/manual/en/datetime.formats.relative.php).
* @param mixed $expiration When the cache should expire.
* @param string $body The contents of the cache.
* @param bool $withScripts Whether JS and CSS code registered with [[\craft\web\View::registerJs()]],
* [[\craft\web\View::registerScript()]], and [[\craft\web\View::registerCss()]] should be captured and
* included in the cache.
* @param bool $withResources Whether JS and CSS code registered with [[\craft\web\View::registerJs()]],
* [[\craft\web\View::registerScript()]], [[\craft\web\View::registerCss()]],
* [[\craft\web\View::registerJsFile()]], and [[\craft\web\View::registerCssFile()]] should be captured
* and included in the cache.
* @throws Exception if this is a console request and `false` is passed to `$global`
* @throws Throwable
*/
public function endTemplateCache(string $key, bool $global, ?string $duration, mixed $expiration, string $body, bool $withScripts = false): void
public function endTemplateCache(string $key, bool $global, ?string $duration, mixed $expiration, string $body, bool $withResources = false): void
{
// Make sure template caching is enabled
if ($this->_isTemplateCachingEnabled() === false) {
Expand All @@ -122,11 +133,13 @@ public function endTemplateCache(string $key, bool $global, ?string $duration, m

$dep = Craft::$app->getElements()->stopCollectingCacheTags();

if ($withScripts) {
if ($withResources) {
$view = Craft::$app->getView();
$bufferedJs = $view->clearJsBuffer(false, false);
$bufferedScripts = $view->clearScriptBuffer();
$bufferedCss = $view->clearCssBuffer();
$bufferedJsFiles = $view->clearJsFileBuffer();
$bufferedCssFiles = $view->clearCssFileBuffer();
}

// If there are any transform generation URLs in the body, don't cache it.
Expand All @@ -140,23 +153,17 @@ public function endTemplateCache(string $key, bool $global, ?string $duration, m

$cacheValue = [$body, $dep->tags];

if ($withScripts) {
if ($withResources) {
// Parse the JS/CSS code and tag attributes out of the <script> and <style> tags
$bufferedScripts = array_map(function($tags) {
return array_map(function($tag) {
$tag = Html::parseTag($tag);
return [$tag['children'][0]['value'], $tag['attributes']];
}, $tags);
}, $bufferedScripts);
$bufferedCss = array_map(function($tag) {
$tag = Html::parseTag($tag);
return [$tag['children'][0]['value'], $tag['attributes']];
}, $bufferedCss);

array_push($cacheValue, $bufferedJs, $bufferedScripts, $bufferedCss);
$bufferedScripts = array_map(fn(array $tags) => $this->_parseInlineResourceTags($tags), $bufferedScripts);
$bufferedCss = $this->_parseInlineResourceTags($bufferedCss);
$bufferedJsFiles = array_map(fn(array $tags) => $this->_parseExternalResourceTags($tags, 'src'), $bufferedJsFiles);
$bufferedCssFiles = $this->_parseExternalResourceTags($bufferedCssFiles, 'href');

array_push($cacheValue, $bufferedJs, $bufferedScripts, $bufferedCss, $bufferedJsFiles, $bufferedCssFiles);

// Re-register the JS and CSS
$this->_registerScripts($bufferedJs, $bufferedScripts, $bufferedCss);
$this->_registerResources($bufferedJs, $bufferedScripts, $bufferedCss, $bufferedJsFiles, $bufferedCssFiles);
}

$cacheKey = $this->_cacheKey($key, $global);
Expand All @@ -172,27 +179,67 @@ public function endTemplateCache(string $key, bool $global, ?string $duration, m
Craft::$app->getCache()->set($cacheKey, $cacheValue, $duration, $dep);
}

private function _registerScripts(array $bufferedJs, array $bufferedScripts, array $bufferedCss): void
private function _parseInlineResourceTags(array $tags): array
{
return array_map(function($tag) {
$tag = Html::parseTag($tag);
return [$tag['children'][0]['value'], $tag['attributes']];
}, $tags);
}

private function _parseExternalResourceTags(array $tags, string $urlAttribute): array
{
return array_map(function($tag) use ($urlAttribute) {
[$tag, $condition] = Html::unwrapCondition($tag);
[$tag, $noscript] = Html::unwrapNoscript($tag);
$tag = Html::parseTag($tag);
$url = ArrayHelper::remove($tag['attributes'], $urlAttribute);
$options = $tag['attributes'];
if ($condition) {
$options['condition'] = $condition;
}
if ($noscript) {
$options['noscript'] = true;
}
return [$url, $options];
}, $tags);
}

private function _registerResources(
array $bufferedJs,
array $bufferedScripts,
array $bufferedCss,
array $bufferedJsFiles,
array $bufferedCssFiles,
): void {
$view = Craft::$app->getView();

foreach ($bufferedJs as $pos => $scripts) {
foreach ($scripts as $key => $script) {
$view->registerJs($script, $pos, $key);
foreach ($scripts as $key => $js) {
$view->registerJs($js, $pos, $key);
}
}

foreach ($bufferedScripts as $pos => $tags) {
foreach ($tags as $key => $tag) {
[$script, $options] = $tag;
foreach ($tags as $key => [$script, $options]) {
$view->registerScript($script, $pos, $options, $key);
}
}

foreach ($bufferedCss as $key => $tag) {
[$css, $options] = $tag;
foreach ($bufferedCss as $key => [$css, $options]) {
$view->registerCss($css, $options, $key);
}

foreach ($bufferedJsFiles as $pos => $tags) {
foreach ($tags as $key => [$url, $options]) {
$options['position'] = $pos;
$view->registerJsFile($url, $options, $key);
}
}

foreach ($bufferedCssFiles as $key => [$url, $options]) {
$view->registerCssFile($url, $options, $key);
}
}

/**
Expand Down
Loading

0 comments on commit 1cb2edf

Please sign in to comment.