diff --git a/_config/asset.yml b/_config/asset.yml
index 3ff54fd29..1b4249277 100644
--- a/_config/asset.yml
+++ b/_config/asset.yml
@@ -25,9 +25,13 @@ Injector:
type: prototype
# Image mechanism
Image_Backend: GDBackend
+ # Requirements config
GeneratedAssetHandler:
- class: SilverStripe\Filesystem\Storage\CacheGeneratedAssetHandler
+ class: SilverStripe\Filesystem\Storage\FlysystemGeneratedAssetHandler
properties:
- AssetStore: '%$AssetStore'
+ Filesystem: '%$FlysystemBackend'
Requirements_Minifier:
class: SilverStripe\View\JSMinifier
+ Requirements_Backend:
+ properties:
+ AssetHandler: '%$GeneratedAssetHandler'
diff --git a/control/Director.php b/control/Director.php
index f50e423cf..48fce1515 100644
--- a/control/Director.php
+++ b/control/Director.php
@@ -289,7 +289,7 @@ class Director implements TemplateGlobalProvider {
$existingRequirementsBackend = Requirements::backend();
Config::inst()->update('Cookie', 'report_errors', false);
- Requirements::set_backend(new Requirements_Backend());
+ Requirements::set_backend(Injector::inst()->create('Requirements_Backend'));
// Set callback to invoke prior to return
$onCleanup = function() use(
diff --git a/docs/en/02_Developer_Guides/01_Templates/03_Requirements.md b/docs/en/02_Developer_Guides/01_Templates/03_Requirements.md
index 51bea33ab..4f1226433 100644
--- a/docs/en/02_Developer_Guides/01_Templates/03_Requirements.md
+++ b/docs/en/02_Developer_Guides/01_Templates/03_Requirements.md
@@ -102,22 +102,83 @@ by reducing HTTP requests.
To make debugging easier in your local environment, combined files is disabled when running your application in `dev`
-mode.
+mode. You can re-enable dev combination by setting `Requirements_Backend.combine_in_dev` to true.
-By default it stores the generated file in the assets/ folder, but you can configure this by pointing the
-`Requirements.combined_files_folder` configuration setting to a specific folder.
+### Configuring combined file storage
-**mysite/_config/app.yml**
-
- :::yml
- Requirements:
- combined_files_folder: '_combined'
+In some situations or server configurations, it may be necessary to customise the behaviour of generated javascript
+files in order to ensure that current files are served in requests.
-
-If SilverStripe doesn't have permissions on your server to write these files it will default back to including them
-individually. SilverStripe **will not** rewrite your paths within the file.
-
+By default, files will be generated on demand in the format `assets/_combinedfiles/name-.js`,
+where `` represents the hash of the source files used to generate that content. The default flysystem backend,
+as used by the `[api:AssetStore]` backend, is used for this storage, but it can be substituted for any
+other backend.
+
+You can also use any of the below options in order to tweak this behaviour:
+
+ * `Requirements.disable_flush_combined` - By default all combined files are deleted on flush.
+ If combined files are stored in source control, and thus updated manually, you might want to
+ turn this on to disable this behaviour.
+ * `Requirements_Backend.combine_hash_querystring` - By default the `` of the source files is appended to
+ the end of the combined file (prior to the file extension). If combined files are versioned in source control,
+ or running in a distributed environment (such as one where the newest version of a file may not always be
+ immediately available) then it may sometimes be necessary to disable this. When this is set to true, the hash
+ will instead be appended via a querystring parameter to enable cache busting, but not in the
+ filename itself. I.e. `assets/_combinedfiles/name.js?m=`
+ * `Requirements_Backend.default_combined_files_folder` - This defaults to `_combinedfiles`, and is the folder
+ within the configured asset backend that combined files will be stored in. If using a backend shared with
+ other systems, it is usually necessary to distinguish combined files from other assets.
+ * `Requirements_Backend.combine_in_dev` - By default combined files will not be combined except in test
+ or live environments. Turning this on will allow for pre-combining of files in development mode.
+
+In some cases it may be necessary to create a new storage backend for combined files, if the default location
+is not appropriate. Normally a single backend is used for all site assets, so a number of objects must be
+replaced. For instance, the below will set a new set of dependencies to write to `mysite/javascript/combined`
+
+
+ :::yaml
+ ---
+ Name: myrequirements
+ ---
+ Requirements:
+ disable_flush_combined: true
+ Requirements_Backend:
+ combine_in_dev: true
+ combine_hash_querystring: true
+ default_combined_files_folder: 'combined'
+ Injector:
+ MySiteAdapter:
+ class: 'SilverStripe\Filesystem\Flysystem\AssetAdapter'
+ constructor:
+ Root: ./mysite/javascript
+ # Define the default filesystem
+ MySiteBackend:
+ class: 'League\Flysystem\Filesystem'
+ constructor:
+ Adapter: '%$MySiteAdapter'
+ calls:
+ PublicURLPlugin: [ addPlugin, [ %$FlysystemUrlPlugin ] ]
+ # Requirements config
+ MySiteAssetHandler:
+ class: SilverStripe\Filesystem\Storage\FlysystemGeneratedAssetHandler
+ properties:
+ Filesystem: '%$MySiteBackend'
+ Requirements_Backend:
+ properties:
+ AssetHandler: '%$MySiteAssetHandler'
+
+In the above configuration, automatic expiry of generated files has been disabled, and it is necessary for
+the developer to maintain these files manually. This may be useful in environments where assets must
+be pre-cached, where scripts must be served alongside static files, or where no framework php request is
+guaranteed. Alternatively, files may be served from instances other than the one which generated the
+page response, and file synchronisation might not occur fast enough to propagate combined files to
+mirrored filesystems.
+
+In any case, care should be taken to determine the mechanism appropriate for your development
+and production environments.
+
+### Combined CSS Files
You can also combine CSS files into a media-specific stylesheets as you would with the `Requirements::css` call - use
the third paramter of the `combine_files` function:
@@ -130,6 +191,11 @@ the third paramter of the `combine_files` function:
Requirements::combine_files('print.css', $printStylesheets, 'print');
+
+When combining CSS files, take care of relative urls, as these will not be re-written to match
+the destination location of the resulting combined CSS.
+
+
## Clearing assets
:::php
diff --git a/docs/en/04_Changelogs/4.0.0.md b/docs/en/04_Changelogs/4.0.0.md
index 178b5e77a..a0e051664 100644
--- a/docs/en/04_Changelogs/4.0.0.md
+++ b/docs/en/04_Changelogs/4.0.0.md
@@ -38,6 +38,7 @@
### ErrorPage
+ * `ErrorPage.static_filepath` config has been removed.
* `ErrorPage::get_filepath_for_errorcode` has been removed
* `ErrorPage::alternateFilepathForErrorcode` extension point has been removed
@@ -123,7 +124,6 @@ New methods on `Requirements` are added to access these:
And some methods on `Requirements` and `Requirements_Backend` have been removed as they are obsolete.
* `delete_combined_files` (both classes)
- * `delete_all_combined_files` (both classes)
A new config `Requirements_Backend.combine_in_dev` has been added in order to allow combined files to be
forced on during development. If this is off, combined files is only enabled in live environments.
@@ -418,27 +418,13 @@ After:
### Update code that modifies the behaviour of ErrorPage
-Since ErrorPage writes statically cached files for each dataobject, in order to integrate it with both the
-new asset backend, and ensure that .htaccess static references still works, these files are now cached
-potentially in two places:
+ErrorPage has been updated to use a configurable asset backend, similar to the `AssetStore` described above.
+This replaces the `ErrorPage.static_filepath` config that was used to write local files.
- * When an error is generated within the live environment, by default the error handler will query the
-`GeneratedAssetHandler` for cached content, which is then served up in the same PHP request. This is the
-primary cache for error content, which does not involve database access, but is processed within the
-PHP process. Although, the file path for this cache is not predictable, as it uses the configured asset backend,
-and is not necessarily present on the same filesystem as the site code.
+As a result, error pages may be cached either to a local filesystem, or an external Flysystem store
+(which is configured via setting a new Flysystem backend with YAML).
- * In order to ensure that the webserver has direct access an available cached error page, it can be necessary
-to ensure a physical file is present locally. By setting the `ErrorPage.enable_static_file` config,
-the generation of this file can be controlled. When this disabled, the static file will only be cached
-via the configured backend. When this is enabled (as it is by default) then the error page will be generated
-in the same location as it were in framework version 3.x. E.g. `/assets/error-404.html`
-
-If your webserver relies on static paths encoded in `.htaccess` to these files, then it's preferable to leave
-this option on. If using a non-local filesystem, and another mechanism for intercepting webserver errors,
-then it may be preferable to leave this off, meaning that the local assets folder is unnecessary.
-
-`ErrorPage::get_filepath_for_errorcode` has been removed, because the local path for a specific code is
+`ErrorPage::get_filepath_for_errorcode()` has been removed, because the local path for a specific code is
no longer assumed. Instead you should use `ErrorPage::get_content_for_errorcode` which retrieves the
appropriate content for that error using one of the methods above.
diff --git a/filesystem/flysystem/AssetAdapter.php b/filesystem/flysystem/AssetAdapter.php
index 98cc00642..ac9fadd31 100644
--- a/filesystem/flysystem/AssetAdapter.php
+++ b/filesystem/flysystem/AssetAdapter.php
@@ -32,9 +32,22 @@ class AssetAdapter extends Local {
);
public function __construct($root = null, $writeFlags = LOCK_EX, $linkHandling = self::DISALLOW_LINKS) {
+ // Get root path
+ if (!$root) {
+ // Empty root will set the path to assets
+ $root = ASSETS_PATH;
+ } elseif(strpos($root, './') === 0) {
+ // Substitute leading ./ with BASE_PATH
+ $root = BASE_PATH . substr($root, 1);
+ } elseif(strpos($root, '../') === 0) {
+ // Substitute leading ./ with parent of BASE_PATH, in case storage is outside of the webroot.
+ $root = dirname(BASE_PATH) . substr($root, 2);
+ }
+
// Override permissions with config
$permissions = \Config::inst()->get(get_class($this), 'file_permissions');
- parent::__construct($root ?: ASSETS_PATH, $writeFlags, $linkHandling, $permissions);
+
+ parent::__construct($root, $writeFlags, $linkHandling, $permissions);
}
/**
diff --git a/filesystem/flysystem/FlysystemGeneratedAssetHandler.php b/filesystem/flysystem/FlysystemGeneratedAssetHandler.php
new file mode 100644
index 000000000..5606e072d
--- /dev/null
+++ b/filesystem/flysystem/FlysystemGeneratedAssetHandler.php
@@ -0,0 +1,106 @@
+assetStore = $store;
+ return $this;
+ }
+
+ /**
+ * Get the asset backend
+ *
+ * @return Filesystem
+ */
+ public function getFilesystem() {
+ return $this->assetStore;
+ }
+
+ public function getContentURL($filename, $callback = null) {
+ $result = $this->checkOrCreate($filename, $callback);
+ if($result) {
+ return $this
+ ->getFilesystem()
+ ->getPublicUrl($filename);
+ }
+ }
+
+ public function getContent($filename, $callback = null) {
+ $result = $this->checkOrCreate($filename, $callback);
+ if($result) {
+ return $this
+ ->getFilesystem()
+ ->read($filename);
+ }
+ }
+
+ /**
+ * Check if the file exists or that the $callback provided was able to regenerate it.
+ *
+ * @param string $filename
+ * @param callable $callback
+ * @return bool Whether or not the file exists
+ * @throws Exception If an error has occurred during save
+ */
+ protected function checkOrCreate($filename, $callback = null) {
+ // Check if there is an existing asset
+ if ($this->getFilesystem()->has($filename)) {
+ return true;
+ }
+
+ if (!$callback) {
+ return false;
+ }
+
+ // Invoke regeneration and save
+ $content = call_user_func($callback);
+ $this->setContent($filename, $content);
+ return true;
+ }
+
+ public function setContent($filename, $content) {
+ // Store content
+ $result = $this
+ ->getFilesystem()
+ ->put($filename, $content);
+
+ if(!$result) {
+ throw new Exception("Error regenerating file \"{$filename}\"");
+ }
+ }
+
+ public function removeContent($filename) {
+ if($this->getFilesystem()->has($filename)) {
+ $handler = $this->getFilesystem()->get($filename);
+ $handler->delete();
+ }
+
+ }
+
+
+}
diff --git a/filesystem/storage/CacheGeneratedAssetHandler.php b/filesystem/storage/CacheGeneratedAssetHandler.php
deleted file mode 100644
index f97718699..000000000
--- a/filesystem/storage/CacheGeneratedAssetHandler.php
+++ /dev/null
@@ -1,177 +0,0 @@
-assetStore = $store;
- return $this;
- }
-
- /**
- * Get the asset backend
- *
- * @return AssetStore
- */
- public function getAssetStore() {
- return $this->assetStore;
- }
-
- /**
- * @return Zend_Cache_Core
- */
- protected static function get_cache() {
- $cache = SS_Cache::factory('CacheGeneratedAssetHandler');
- $lifetime = Config::inst()->get(__CLASS__, 'lifetime') ?: null; // map falsey to null (indefinite)
- $cache->setLifetime($lifetime);
- return $cache;
- }
-
- /**
- * Flush the cache
- */
- public static function flush() {
- self::get_cache()->clean();
- }
-
- public function getGeneratedURL($filename, $entropy = 0, $callback = null) {
- $result = $this->getGeneratedFile($filename, $entropy, $callback);
- if($result) {
- return $this
- ->getAssetStore()
- ->getAsURL($result['Filename'], $result['Hash'], $result['Variant']);
- }
- }
-
- public function getGeneratedContent($filename, $entropy = 0, $callback = null) {
- $result = $this->getGeneratedFile($filename, $entropy, $callback);
- if($result) {
- return $this
- ->getAssetStore()
- ->getAsString($result['Filename'], $result['Hash'], $result['Variant']);
- }
- }
-
- /**
- * Generate or return the tuple for the given file, optionally regenerating it if it
- * doesn't exist
- *
- * @param string $filename
- * @param mixed $entropy
- * @param callable $callback
- * @return array tuple array if available
- * @throws Exception If the file isn't available and $callback fails to regenerate content
- */
- protected function getGeneratedFile($filename, $entropy = 0, $callback = null) {
- // Check if there is an existing asset
- $cache = self::get_cache();
- $cacheID = $this->getCacheKey($filename, $entropy);
- $data = $cache->load($cacheID);
- if($data) {
- $result = unserialize($data);
- $valid = $this->validateResult($result, $filename);
- if($valid) {
- return $result;
- }
- }
-
- // Regenerate
- if($callback) {
- // Invoke regeneration and save
- $content = call_user_func($callback);
- return $this->updateContent($filename, $entropy, $content);
- }
- }
-
- public function updateContent($filename, $entropy, $content) {
- $cache = self::get_cache();
- $cacheID = $this->getCacheKey($filename, $entropy);
-
- // Store content
- $result = $this
- ->getAssetStore()
- ->setFromString($content, $filename);
- if($result) {
- $cache->save(serialize($result), $cacheID);
- }
-
- // Ensure this result is successfully saved
- $valid = $this->validateResult($result, $filename);
- if($valid) {
- return $result;
- }
-
- throw new Exception("Error regenerating file \"{$filename}\"");
- }
-
-
- /**
- * Get cache key for the given generated asset
- *
- * @param string $filename
- * @param mixed $entropy
- * @return string
- */
- protected function getCacheKey($filename, $entropy = 0) {
- $cacheID = sha1($filename);
- if($entropy) {
- $cacheID .= '_' . sha1($entropy);
- }
- return $cacheID;
- }
-
- /**
- * Validate that the given result is valid
- *
- * @param mixed $result
- * @param string $filename
- * @return bool True if this $result is valid
- */
- protected function validateResult($result, $filename) {
- if(!$result) {
- return false;
- }
-
- // Retrieve URL from tuple
- $store = $this->getAssetStore();
- return $store->exists($result['Filename'], $result['Hash'], $result['Variant']);
- }
-
-}
diff --git a/filesystem/storage/GeneratedAssetHandler.php b/filesystem/storage/GeneratedAssetHandler.php
index 3cbc64192..0087b0d35 100644
--- a/filesystem/storage/GeneratedAssetHandler.php
+++ b/filesystem/storage/GeneratedAssetHandler.php
@@ -11,37 +11,45 @@ namespace SilverStripe\Filesystem\Storage;
interface GeneratedAssetHandler {
/**
- * Given a filename and entropy, determine if a pre-generated file is valid. If this file is invalid
- * or expired, invoke $callback to regenerate the content.
- *
- * Returns a URL to the generated file
+ * Returns a URL to a generated asset, if one is available.
+ *
+ * Given a filename, determine if a file is available. If the file is unavailable,
+ * and a callback is supplied, invoke it to regenerate the content.
*
* @param string $filename
- * @param mixed $entropy
* @param callable $callback To generate content. If none provided, url will only be returned
* if there is valid content.
* @return string URL to generated file
*/
- public function getGeneratedURL($filename, $entropy = 0, $callback = null);
+ public function getContentURL($filename, $callback = null);
/**
- * Given a filename and entropy, determine if a pre-generated file is valid. If this file is invalid
- * or expired, invoke $callback to regenerate the content.
+ * Returns the content for a generated asset, if one is available.
+ *
+ * Given a filename, determine if a file is available. If the file is unavailable,
+ * and a callback is supplied, invoke it to regenerate the content.
*
* @param string $filename
- * @param mixed $entropy
* @param callable $callback To generate content. If none provided, content will only be returned
* if there is valid content.
* @return string Content for this generated file
*/
- public function getGeneratedContent($filename, $entropy = 0, $callback = null);
+ public function getContent($filename, $callback = null);
/**
* Update content with new value
*
* @param string $filename
- * @param mixed $entropy
* @param string $content Content to write to the backend
*/
- public function updateContent($filename, $entropy, $content);
+ public function setContent($filename, $content);
+
+ /**
+ * Remove any content under the given file.
+ *
+ * If $filename is a folder, it should delete all files underneath it also.
+ *
+ * @param string $filename
+ */
+ public function removeContent($filename);
}
diff --git a/tests/filesystem/AssetStoreTest.php b/tests/filesystem/AssetStoreTest.php
index 1d11606cf..c000cf175 100644
--- a/tests/filesystem/AssetStoreTest.php
+++ b/tests/filesystem/AssetStoreTest.php
@@ -7,7 +7,7 @@ use SilverStripe\Filesystem\Flysystem\FlysystemAssetStore;
use SilverStripe\Filesystem\Flysystem\FlysystemUrlPlugin;
use SilverStripe\Filesystem\Storage\AssetContainer;
use SilverStripe\Filesystem\Storage\AssetStore;
-use SilverStripe\Filesystem\Storage\CacheGeneratedAssetHandler;
+use SilverStripe\Filesystem\Storage\FlysystemGeneratedAssetHandler;
class AssetStoreTest extends SapphireTest {
@@ -424,6 +424,12 @@ class AssetStoreTest_SpyStore extends FlysystemAssetStore {
$backend->setFilesystem($filesystem);
Injector::inst()->registerService($backend, 'AssetStore');
+ // Assign flysystem backend to generated asset handler at the same time
+ $generated = new FlysystemGeneratedAssetHandler();
+ $generated->setFilesystem($filesystem);
+ Injector::inst()->registerService($generated, 'GeneratedAssetHandler');
+ Requirements::backend()->setAssetHandler($generated);
+
// Disable legacy and set defaults
Config::inst()->remove(get_class(new FlysystemAssetStore()), 'legacy_filenames');
Config::inst()->update('Director', 'alternate_base_url', '/');
@@ -451,9 +457,6 @@ class AssetStoreTest_SpyStore extends FlysystemAssetStore {
* Reset defaults for this store
*/
public static function reset() {
- // Need flushing since it won't have any files left
- CacheGeneratedAssetHandler::flush();
-
// Remove all files in this store
if(self::$basedir) {
$path = self::base_path();
diff --git a/tests/forms/RequirementsTest.php b/tests/forms/RequirementsTest.php
index a07654276..29b1d4270 100644
--- a/tests/forms/RequirementsTest.php
+++ b/tests/forms/RequirementsTest.php
@@ -1,6 +1,5 @@
create('Requirements_Backend');
$backend->setCombinedFilesEnabled(true);
$backend->javascript('http://www.mydomain.com/test.js');
@@ -72,7 +71,7 @@ class RequirementsTest extends SapphireTest {
$backend->clearCombinedFiles();
$backend->setCombinedFilesFolder('_combinedfiles');
$backend->setMinifyCombinedJSFiles(false);
- CacheGeneratedAssetHandler::flush();
+ Requirements::flush();
}
/**
@@ -119,10 +118,10 @@ class RequirementsTest extends SapphireTest {
}
public function testCombinedJavascript() {
- $backend = new Requirements_Backend();
+ $backend = Injector::inst()->create('Requirements_Backend');
$this->setupCombinedRequirements($backend);
- $combinedFileName = '/_combinedfiles/b2a28d2463/RequirementsTest_bc.js';
+ $combinedFileName = '/_combinedfiles/RequirementsTest_bc-51622b5.js';
$combinedFilePath = AssetStoreTest_SpyStore::base_path() . $combinedFileName;
$html = $backend->includeInHTML(false, self::$html_template);
@@ -167,7 +166,7 @@ class RequirementsTest extends SapphireTest {
// Then do it again, this time not requiring the files beforehand
unlink($combinedFilePath);
- $backend = new Requirements_Backend();
+ $backend = Injector::inst()->create('Requirements_Backend');
$this->setupCombinedNonrequiredRequirements($backend);
$html = $backend->includeInHTML(false, self::$html_template);
@@ -205,7 +204,7 @@ class RequirementsTest extends SapphireTest {
public function testCombinedCss() {
$basePath = $this->getCurrentRelativePath();
- $backend = new Requirements_Backend();
+ $backend = Injector::inst()->create('Requirements_Backend');
$this->setupRequirements($backend);
$backend->combineFiles(
@@ -220,7 +219,7 @@ class RequirementsTest extends SapphireTest {
$html = $backend->includeInHTML(false, self::$html_template);
$this->assertRegExp(
- '/href=".*\/print\.css/',
+ '/href=".*\/print\-94e723d\.css/',
$html,
'Print stylesheets have been combined.'
);
@@ -231,7 +230,7 @@ class RequirementsTest extends SapphireTest {
);
// Test that combining a file multiple times doesn't trigger an error
- $backend = new Requirements_Backend();
+ $backend = Injector::inst()->create('Requirements_Backend');
$this->setupRequirements($backend);
$backend->combineFiles(
'style.css',
@@ -250,7 +249,7 @@ class RequirementsTest extends SapphireTest {
$html = $backend->includeInHTML(false, self::$html_template);
$this->assertRegExp(
- '/href=".*\/style\.css/',
+ '/href=".*\/style\-2b3e4c9\.css/',
$html,
'Stylesheets have been combined.'
);
@@ -258,9 +257,9 @@ class RequirementsTest extends SapphireTest {
public function testBlockedCombinedJavascript() {
$basePath = $this->getCurrentRelativePath();
- $backend = new Requirements_Backend();
+ $backend = Injector::inst()->create('Requirements_Backend');
$this->setupCombinedRequirements($backend);
- $combinedFileName = '/_combinedfiles/b2a28d2463/RequirementsTest_bc.js';
+ $combinedFileName = '/_combinedfiles/RequirementsTest_bc-51622b5.js';
$combinedFilePath = AssetStoreTest_SpyStore::base_path() . $combinedFileName;
/* BLOCKED COMBINED FILES ARE NOT INCLUDED */
@@ -279,7 +278,7 @@ class RequirementsTest extends SapphireTest {
/* BLOCKED UNCOMBINED FILES ARE NOT INCLUDED */
$this->setupCombinedRequirements($backend);
$backend->block($basePath .'/RequirementsTest_b.js');
- $combinedFileName2 = '/_combinedfiles/37bd2d9dcb/RequirementsTest_bc.js'; // SHA1 without file c included
+ $combinedFileName2 = '/_combinedfiles/RequirementsTest_bc-fc7468e.js'; // SHA1 without file c included
$combinedFilePath2 = AssetStoreTest_SpyStore::base_path() . $combinedFileName2;
clearstatcache(); // needed to get accurate file_exists() results
$html = $backend->includeInHTML(false, self::$html_template);
@@ -315,7 +314,7 @@ class RequirementsTest extends SapphireTest {
public function testArgsInUrls() {
$basePath = $this->getCurrentRelativePath();
- $backend = new Requirements_Backend;
+ $backend = Injector::inst()->create('Requirements_Backend');
$this->setupRequirements($backend);
$backend->javascript($basePath . '/RequirementsTest_a.js?test=1&test=2&test=3');
@@ -340,7 +339,7 @@ class RequirementsTest extends SapphireTest {
public function testRequirementsBackend() {
$basePath = $this->getCurrentRelativePath();
- $backend = new Requirements_Backend();
+ $backend = Injector::inst()->create('Requirements_Backend');
$this->setupRequirements($backend);
$backend->javascript($basePath . '/a.js');
@@ -378,7 +377,7 @@ class RequirementsTest extends SapphireTest {
// to something else
$basePath = 'framework' . substr($basePath, strlen(FRAMEWORK_DIR));
- $backend = new Requirements_Backend();
+ $backend = Injector::inst()->create('Requirements_Backend');
$this->setupRequirements($backend);
$holder = Requirements::backend();
Requirements::set_backend($backend);
@@ -407,7 +406,7 @@ class RequirementsTest extends SapphireTest {
}
public function testJsWriteToBody() {
- $backend = new Requirements_Backend();
+ $backend = Injector::inst()->create('Requirements_Backend');
$this->setupRequirements($backend);
$backend->javascript('http://www.mydomain.com/test.js');
@@ -426,7 +425,7 @@ class RequirementsTest extends SapphireTest {
public function testIncludedJsIsNotCommentedOut() {
$template = '';
- $backend = new Requirements_Backend();
+ $backend = Injector::inst()->create('Requirements_Backend');
$this->setupRequirements($backend);
$backend->javascript($this->getCurrentRelativePath() . '/RequirementsTest_a.js');
$html = $backend->includeInHTML(false, $template);
@@ -438,7 +437,7 @@ class RequirementsTest extends SapphireTest {
public function testCommentedOutScriptTagIsIgnored() {
$template = ''
. 'more content
';
- $backend = new Requirements_Backend();
+ $backend = Injector::inst()->create('Requirements_Backend');
$this->setupRequirements($backend);
$backend->setSuffixRequirements(false);
$src = $this->getCurrentRelativePath() . '/RequirementsTest_a.js';
@@ -450,7 +449,7 @@ class RequirementsTest extends SapphireTest {
}
public function testForceJsToBottom() {
- $backend = new Requirements_Backend();
+ $backend = Injector::inst()->create('Requirements_Backend');
$this->setupRequirements($backend);
$backend->javascript('http://www.mydomain.com/test.js');
@@ -506,7 +505,7 @@ class RequirementsTest extends SapphireTest {
$template = 'Body
';
$basePath = $this->getCurrentRelativePath();
- $backend = new Requirements_Backend();
+ $backend = Injector::inst()->create('Requirements_Backend');
$this->setupRequirements($backend);
$backend->javascript($basePath .'/RequirementsTest_a.js');
diff --git a/tests/view/SSViewerTest.php b/tests/view/SSViewerTest.php
index 5a7b7e1db..74a8c135c 100644
--- a/tests/view/SSViewerTest.php
+++ b/tests/view/SSViewerTest.php
@@ -146,7 +146,7 @@ class SSViewerTest extends SapphireTest {
}
public function testRequirementsCombine(){
- $testBackend = new Requirements_Backend();
+ $testBackend = Injector::inst()->create('Requirements_Backend');
$testBackend->setSuffixRequirements(false);
//$combinedTestFilePath = BASE_PATH . '/' . $testBackend->getCombinedFilesFolder() . '/testRequirementsCombine.js';
@@ -167,7 +167,7 @@ class SSViewerTest extends SapphireTest {
$testBackend->processCombinedFiles();
$js = $testBackend->getJavascript();
$combinedTestFilePath = BASE_PATH . reset($js);
- $this->assertContains('testRequirementsCombine.js', $combinedTestFilePath);
+ $this->assertContains('_combinedfiles/testRequirementsCombine-7c20750.js', $combinedTestFilePath);
// and make sure the combined content matches the input content, i.e. no loss of functionality
if(!file_exists($combinedTestFilePath)) {
@@ -1348,7 +1348,7 @@ after')
$template = new SSViewer(array('SSViewerTestProcess'));
$basePath = dirname($this->getCurrentRelativePath()) . '/forms';
- $backend = new Requirements_Backend;
+ $backend = Injector::inst()->create('Requirements_Backend');
$backend->setCombinedFilesEnabled(false);
$backend->combineFiles(
'RequirementsTest_ab.css',
diff --git a/view/Requirements.php b/view/Requirements.php
index e1239c281..af9db8aa2 100644
--- a/view/Requirements.php
+++ b/view/Requirements.php
@@ -8,7 +8,28 @@ use SilverStripe\Filesystem\Storage\GeneratedAssetHandler;
* @package framework
* @subpackage view
*/
-class Requirements {
+class Requirements implements Flushable {
+
+ /**
+ * Flag whether combined files should be deleted on flush.
+ *
+ * By default all combined files are deleted on flush. If combined files are stored in source control,
+ * and thus updated manually, you might want to turn this on to disable this behaviour.
+ *
+ * @config
+ * @var bool
+ */
+ private static $disable_flush_combined = false;
+
+ /**
+ * Triggered early in the request when a flush is requested
+ */
+ public static function flush() {
+ $disabled = Config::inst()->get(static::class, 'disable_flush_combined');
+ if(!$disabled) {
+ self::delete_all_combined_files();
+ }
+ }
/**
* Enable combining of css/javascript files.
@@ -66,6 +87,9 @@ class Requirements {
*/
private static $backend = null;
+ /**
+ * @return Requirements_Backend
+ */
public static function backend() {
if(!self::$backend) {
self::$backend = Injector::inst()->create('Requirements_Backend');
@@ -323,6 +347,14 @@ class Requirements {
return self::backend()->getCombinedFiles();
}
+ /**
+ * Deletes all generated combined files in the configured combined files directory,
+ * but doesn't delete the directory itself
+ */
+ public static function delete_all_combined_files() {
+ return self::backend()->deleteAllCombinedFiles();
+ }
+
/**
* Re-sets the combined files definition. See {@link Requirements_Backend::clear_combined_files()}
*/
@@ -427,7 +459,8 @@ class Requirements {
* @package framework
* @subpackage view
*/
-class Requirements_Backend {
+class Requirements_Backend
+{
/**
* Whether to add caching query params to the requests for file-based requirements.
@@ -447,8 +480,10 @@ class Requirements_Backend {
protected $combinedFilesEnabled = true;
/**
- * Determine if files should be combined automatically on dev mode
- * By default, files will be left uncombined when developing.
+ * Determine if files should be combined automatically on dev mode.
+ *
+ * By default combined files will not be combined except in test or
+ * live environments. Turning this on will allow for pre-combining of files in development mode.
*
* @config
* @var bool
@@ -559,20 +594,53 @@ class Requirements_Backend {
protected $forceJSToBottom = false;
/**
- * Configures the default prefix for comined files
+ * Configures the default prefix for combined files.
+ *
+ * This defaults to `_combinedfiles`, and is the folder within the configured asset backend that
+ * combined files will be stored in. If using a backend shared with other systems, it is usually
+ * necessary to distinguish combined files from other assets.
*
* @config
* @var string
*/
private static $default_combined_files_folder = '_combinedfiles';
+ /**
+ * Flag to include the hash in the querystring instead of the filename for combined files.
+ *
+ * By default the `` of the source files is appended to the end of the combined file
+ * (prior to the file extension). If combined files are versioned in source control or running
+ * in a distributed environment (such as one where the newest version of a file may not always be
+ * immediately available) then it may sometimes be necessary to disable this. When this is set to true,
+ * the hash will instead be appended via a querystring parameter to enable cache busting, but not in
+ * the filename itself. I.e. `assets/_combinedfiles/name.js?m=`
+ *
+ * @config
+ * @var bool
+ */
+ private static $combine_hash_querystring = false;
+
+ /**
+ * @var GeneratedAssetHandler
+ */
+ protected $assetHandler = null;
+
/**
* Gets the backend storage for generated files
*
* @return GeneratedAssetHandler
*/
- protected function getAssetHandler() {
- return Injector::inst()->get('GeneratedAssetHandler');
+ public function getAssetHandler() {
+ return $this->assetHandler;
+ }
+
+ /**
+ * Set a new asset handler for this backend
+ *
+ * @param GeneratedAssetHandler $handler
+ */
+ public function setAssetHandler(GeneratedAssetHandler $handler) {
+ $this->assetHandler = $handler;
}
/**
@@ -619,8 +687,8 @@ class Requirements_Backend {
/**
* Retrieve the combined files folder prefix
- *
- * @return string
+ *
+ * @return string
*/
public function getCombinedFilesFolder() {
if($this->combinedFilesFolder) {
@@ -931,7 +999,7 @@ class Requirements_Backend {
$this->customCSS = $this->disabled['customCSS'];
$this->customHeadTags = $this->disabled['customHeadTags'];
}
-
+
/**
* Block inclusion of a specific file
*
@@ -1229,7 +1297,7 @@ class Requirements_Backend {
if(isset($this->combinedFiles[$combinedFileName])) {
return;
}
-
+
// Add all files to necessary type list
$paths = array();
$combinedType = null;
@@ -1270,7 +1338,7 @@ class Requirements_Backend {
));
}
}
-
+
$this->combinedFiles[$combinedFileName] = array(
'files' => $paths,
'type' => $combinedType,
@@ -1328,6 +1396,16 @@ class Requirements_Backend {
return $this->combinedFiles;
}
+ /**
+ * Clears all combined files
+ */
+ public function deleteAllCombinedFiles() {
+ $combinedFolder = $this->getCombinedFilesFolder();
+ if($combinedFolder) {
+ $this->getAssetHandler()->removeContent($combinedFolder);
+ }
+ }
+
/**
* Clear all registered CSS and JavaScript file combinations
*/
@@ -1404,31 +1482,34 @@ class Requirements_Backend {
* @return string URL to this resource
*/
protected function getCombinedFileURL($combinedFile, $fileList, $type) {
- // Generate path (Filename)
- $combinedFileID = File::join_paths($this->getCombinedFilesFolder(), $combinedFile);
+ // Filter blocked files
+ $fileList = array_diff($fileList, $this->getBlocked());
- // Get entropy for this combined file (last modified date of most recent file)
- $entropy = $this->getEntropyOfFiles($fileList);
+ // Generate path (Filename)
+ $hashQuerystring = Config::inst()->get(static::class, 'combine_hash_querystring');
+ if(!$hashQuerystring) {
+ $combinedFile = $this->hashedCombinedFilename($combinedFile, $fileList);
+ }
+ $combinedFileID = File::join_paths($this->getCombinedFilesFolder(), $combinedFile);
// Send file combination request to the backend, with an optional callback to perform regeneration
$minify = $this->getMinifyCombinedJSFiles();
$combinedURL = $this
->getAssetHandler()
- ->getGeneratedURL(
+ ->getContentURL(
$combinedFileID,
- $entropy,
function() use ($fileList, $minify, $type) {
// Physically combine all file content
$combinedData = '';
$base = Director::baseFolder() . '/';
$minifier = Injector::inst()->get('Requirements_Minifier');
- foreach(array_diff($fileList, $this->getBlocked()) as $file) {
+ foreach($fileList as $file) {
$fileContent = file_get_contents($base . $file);
// Use configured minifier
if($minify) {
$fileContent = $minifier->minify($fileContent, $type, $file);
}
-
+
if ($this->writeHeaderComment) {
// Write a header comment for each file for easier identification and debugging.
$combinedData .= "/****** FILE: $file *****/\n";
@@ -1439,15 +1520,31 @@ class Requirements_Backend {
}
);
+ // If the name isn't hashed, we will need to append the querystring m= parameter instead
// Since url won't be automatically suffixed, add it in here
- if($this->getSuffixRequirements()) {
+ if($hashQuerystring && $this->getSuffixRequirements()) {
+ $hash = $this->hashOfFiles($fileList);
$q = stripos($combinedURL, '?') === false ? '?' : '&';
- $combinedURL .= "{$q}m={$entropy}";
+ $combinedURL .= "{$q}m={$hash}";
}
return $combinedURL;
}
+ /**
+ * Given a filename and list of files, generate a new filename unique to these files
+ *
+ * @param string $name
+ * @param array $files
+ * @return string
+ */
+ protected function hashedCombinedFilename($combinedFile, $fileList) {
+ $name = pathinfo($combinedFile, PATHINFO_FILENAME);
+ $hash = $this->hashOfFiles($fileList);
+ $extension = File::get_file_extension($combinedFile);
+ return $name . '-' . substr($hash, 0, 7) . '.' . $extension;
+ }
+
/**
* Check if combined files are enabled
*
@@ -1482,20 +1579,20 @@ class Requirements_Backend {
* any of these files have changed.
*
* @param array $fileList List of files
- * @return int Last modified timestamp of these files
+ * @return string SHA1 bashed file hash
*/
- protected function getEntropyOfFiles($fileList) {
- // file exists, check modification date of every contained file
+ protected function hashOfFiles($fileList) {
+ // Get hash based on hash of each file
$base = Director::baseFolder() . '/';
- $srcLastMod = 0;
+ $hash = '';
foreach($fileList as $file) {
if(file_exists($base . $file)) {
- $srcLastMod = max(filemtime($base . $file), $srcLastMod);
+ $hash .= sha1_file($base . $file);
} else {
throw new InvalidArgumentException("Combined file {$file} does not exist");
}
}
- return $srcLastMod;
+ return sha1($hash);
}
/**