Merge pull request #4680 from open-sausages/features/dbfile-generated-files

API Generated files API
This commit is contained in:
Ingo Schommer 2015-10-23 13:19:56 +13:00
commit f252cfad20
11 changed files with 1072 additions and 631 deletions

View File

@ -25,3 +25,7 @@ Injector:
type: prototype type: prototype
# Image mechanism # Image mechanism
Image_Backend: GDBackend Image_Backend: GDBackend
GeneratedAssetHandler:
class: SilverStripe\Filesystem\Storage\CacheGeneratedAssetHandler
properties:
AssetStore: '%$AssetStore'

View File

@ -272,8 +272,9 @@ EOT
// a more specific error description. // a more specific error description.
if(Director::isLive() && $this->isError() && !$this->body) { if(Director::isLive() && $this->isError() && !$this->body) {
$formatter = Injector::get('FriendlyErrorFormatter'); $formatter = Injector::get('FriendlyErrorFormatter');
$formatter->setStatusCode($this->statusCode); echo $formatter->format(array(
echo $formatter->format(array()); 'code' => $this->statusCode
));
} else { } else {
echo $this->body; echo $this->body;

View File

@ -20,6 +20,8 @@
the `DataObject::ID` in a `data-fileid` property, or via shortcodes. This is necessary because file the `DataObject::ID` in a `data-fileid` property, or via shortcodes. This is necessary because file
urls are no longer able to identify assets. urls are no longer able to identify assets.
* Extension point `HtmlEditorField::processImage` has been removed, and moved to `Image::regenerateImageHTML` * Extension point `HtmlEditorField::processImage` has been removed, and moved to `Image::regenerateImageHTML`
* `Requirements::combined_files` no longer does JS minification. Please ensure that JS files are optimised
before combining.
## New API ## New API
@ -27,6 +29,8 @@
* `ShortcodeHandler` interface to help generate standard handlers for HTML shortcodes in the editor. * `ShortcodeHandler` interface to help generate standard handlers for HTML shortcodes in the editor.
* `AssetNameGenerator` interface, including a `DefaultAssetNameGenerator` implementation, which is used to generate * `AssetNameGenerator` interface, including a `DefaultAssetNameGenerator` implementation, which is used to generate
renaming suggestions based on an original given filename in order to resolve file duplication issues. renaming suggestions based on an original given filename in order to resolve file duplication issues.
* `GeneratedAssetHandler` API now used to store and manage generated files (such as those used for error page
cache or combined files).
## Deprecated classes/methods ## Deprecated classes/methods

View File

@ -0,0 +1,175 @@
<?php
namespace SilverStripe\Filesystem\Storage;
use Config;
use Exception;
use Flushable;
use SS_Cache;
use Zend_Cache_Core;
/**
* Handle references to generated files via cached tuples
*
* Important: If you are using the default FlysystemStore with legacy_filenames, you will need to ?flush
* in order to refresh combined files.
*
* @package framework
* @subpackage filesystem
*/
class CacheGeneratedAssetHandler implements GeneratedAssetHandler, Flushable {
/**
* Lifetime of cache
*
* @config
* @var int
*/
private static $lifetime = 0;
/**
* Backend for generated files
*
* @var AssetStore
*/
protected $assetStore = null;
/**
* Assign the asset backend
*
* @param AssetStore $store
* @return $this
*/
public function setAssetStore(AssetStore $store) {
$this->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');
$cache->setLifetime(Config::inst()->get(__CLASS__, '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']);
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace SilverStripe\Filesystem\Storage;
/**
* Interface to define a handler for persistent generated files
*
* @package framework
* @subpackage filesystem
*/
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
*
* @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);
/**
* 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.
*
* @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);
/**
* 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);
}

View File

@ -2,89 +2,124 @@
namespace SilverStripe\Framework\Logging; namespace SilverStripe\Framework\Logging;
use Monolog\Logger;
use Monolog\Handler\AbstractProcessingHandler;
use Monolog\Formatter\FormatterInterface; use Monolog\Formatter\FormatterInterface;
/** /**
* Produce a friendly error message * Produce a friendly error message
*/ */
class DebugViewFriendlyErrorFormatter implements FormatterInterface class DebugViewFriendlyErrorFormatter implements FormatterInterface {
{
/**
* Default status code
*
* @var int
*/
protected $statusCode = 500; protected $statusCode = 500;
/**
* Default friendly error
*
* @var string
*/
protected $friendlyErrorMessage = 'Error'; protected $friendlyErrorMessage = 'Error';
/**
* Default error body
*
* @var string
*/
protected $friendlyErrorDetail; protected $friendlyErrorDetail;
/**
* Get default status code
*
* @return int
*/
public function getStatusCode() { public function getStatusCode() {
return $this->statusCode; return $this->statusCode;
} }
/**
* Set default status code
*
* @param int $statusCode
*/
public function setStatusCode($statusCode) { public function setStatusCode($statusCode) {
$this->statusCode = $statusCode; $this->statusCode = $statusCode;
} }
public function getTitle($title) { /**
* Get friendly title
*
* @return string
*/
public function getTitle() {
return $this->friendlyErrorMessage; return $this->friendlyErrorMessage;
} }
/**
* Set friendly title
*
* @param string $title
*/
public function setTitle($title) { public function setTitle($title) {
$this->friendlyErrorMessage = $title; $this->friendlyErrorMessage = $title;
} }
public function getBody($title) { /**
* Get default error body
*
* @return string
*/
public function getBody() {
return $this->friendlyErrorDetail; return $this->friendlyErrorDetail;
} }
/**
* Set default error body
*
* @param string $body
*/
public function setBody($body) { public function setBody($body) {
$this->friendlyErrorDetail = $body; $this->friendlyErrorDetail = $body;
} }
public function format(array $record) public function format(array $record) {
{ // Get error code
$code = empty($record['code']) ? $this->statusCode : $record['code'];
return $this->output(); return $this->output($code);
} }
public function formatBatch(array $records) { public function formatBatch(array $records) {
return $this->output(); $message = '';
} foreach ($records as $record) {
$message .= $this->format($record);
}
return $message;
}
public function output() { /**
* Return the appropriate error content for the given status code
*
* @param int $statusCode
* @return string Content in an appropriate format for the current request
*/
public function output($statusCode) {
// TODO: Refactor into a content-type option // TODO: Refactor into a content-type option
if(\Director::is_ajax()) { if(\Director::is_ajax()) {
return $this->friendlyErrorMessage; return $this->getTitle();
} else {
// TODO: Refactor this into CMS
if(class_exists('ErrorPage')){
$errorFilePath = \ErrorPage::get_filepath_for_errorcode(
$this->statusCode,
class_exists('Translatable') ? \Translatable::get_current_locale() : null
);
if(file_exists($errorFilePath)) {
$content = file_get_contents($errorFilePath);
if(!headers_sent()) {
header('Content-Type: text/html');
}
// $BaseURL is left dynamic in error-###.html, so that multi-domain sites don't get broken
return str_replace('$BaseURL', \Director::absoluteBaseURL(), $content);
}
}
$renderer = \Debug::create_debug_view();
$output = $renderer->renderHeader();
$output .= $renderer->renderInfo("Website Error", $this->friendlyErrorMessage, $this->friendlyErrorDetail);
if(\Email::config()->admin_email) {
$mailto = \Email::obfuscate(\Email::config()->admin_email);
$output .= $renderer->renderParagraph('Contact an administrator: ' . $mailto . '');
}
$output .= $renderer->renderFooter();
return $output;
} }
$renderer = \Debug::create_debug_view();
$output = $renderer->renderHeader();
$output .= $renderer->renderInfo("Website Error", $this->getTitle(), $this->getBody());
if(\Email::config()->admin_email) {
$mailto = \Email::obfuscate(\Email::config()->admin_email);
$output .= $renderer->renderParagraph('Contact an administrator: ' . $mailto . '');
}
$output .= $renderer->renderFooter();
return $output;
} }
} }

View File

@ -7,6 +7,7 @@ use SilverStripe\Filesystem\Flysystem\FlysystemAssetStore;
use SilverStripe\Filesystem\Flysystem\FlysystemUrlPlugin; use SilverStripe\Filesystem\Flysystem\FlysystemUrlPlugin;
use SilverStripe\Filesystem\Storage\AssetContainer; use SilverStripe\Filesystem\Storage\AssetContainer;
use SilverStripe\Filesystem\Storage\AssetStore; use SilverStripe\Filesystem\Storage\AssetStore;
use SilverStripe\Filesystem\Storage\CacheGeneratedAssetHandler;
class AssetStoreTest extends SapphireTest { class AssetStoreTest extends SapphireTest {
@ -450,6 +451,10 @@ class AssetStoreTest_SpyStore extends FlysystemAssetStore {
* Reset defaults for this store * Reset defaults for this store
*/ */
public static function reset() { 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) { if(self::$basedir) {
$path = self::base_path(); $path = self::base_path();
if(file_exists($path)) { if(file_exists($path)) {
@ -469,6 +474,12 @@ class AssetStoreTest_SpyStore extends FlysystemAssetStore {
if($asset instanceof Folder) { if($asset instanceof Folder) {
return self::base_path() . '/' . $asset->getFilename(); return self::base_path() . '/' . $asset->getFilename();
} }
if($asset instanceof File) {
$asset = $asset->File;
}
if($asset instanceof DBFile) {
return BASE_PATH . $asset->getSourceURL();
}
return BASE_PATH . $asset->getUrl(); return BASE_PATH . $asset->getUrl();
} }

View File

@ -75,8 +75,8 @@ class HtmlEditorConfigTest extends SapphireTest {
HtmlEditorConfig::require_js(); HtmlEditorConfig::require_js();
$js = Requirements::get_custom_scripts(); $js = Requirements::get_custom_scripts();
$this->assertContains('tinymce.PluginManager.load("plugin1", "/mypath/plugin1");', $js); $this->assertContains('tinymce.PluginManager.load("plugin1", "/mypath/plugin1");', $js['htmlEditorConfig']);
$this->assertContains('tinymce.PluginManager.load("plugin2", "/mypath/plugin2");', $js); $this->assertContains('tinymce.PluginManager.load("plugin2", "/mypath/plugin2");', $js['htmlEditorConfig']);
} }
public function testRequireJSIncludesAllConfigs() { public function testRequireJSIncludesAllConfigs() {
@ -86,7 +86,7 @@ class HtmlEditorConfigTest extends SapphireTest {
HtmlEditorConfig::require_js(); HtmlEditorConfig::require_js();
$js = Requirements::get_custom_scripts(); $js = Requirements::get_custom_scripts();
$this->assertContains('"configA":{', $js); $this->assertContains('"configA":{', $js['htmlEditorConfig']);
$this->assertContains('"configB":{', $js); $this->assertContains('"configB":{', $js['htmlEditorConfig']);
} }
} }

View File

@ -1,4 +1,6 @@
<?php <?php
use SilverStripe\Filesystem\Storage\CacheGeneratedAssetHandler;
/** /**
* @package framework * @package framework
* @subpackage tests * @subpackage tests
@ -10,11 +12,19 @@ class RequirementsTest extends SapphireTest {
static $html_template = '<html><head></head><body></body></html>'; static $html_template = '<html><head></head><body></body></html>';
static $old_requirements = null; public function setUp() {
parent::setUp();
AssetStoreTest_SpyStore::activate('RequirementsTest'); // Set backend root to /RequirementsTest
}
public function tearDown() {
AssetStoreTest_SpyStore::reset();
parent::tearDown();
}
public function testExternalUrls() { public function testExternalUrls() {
$backend = new Requirements_Backend; $backend = new Requirements_Backend;
$backend->set_combined_files_enabled(true); $backend->setCombinedFilesEnabled(true);
$backend->javascript('http://www.mydomain.com/test.js'); $backend->javascript('http://www.mydomain.com/test.js');
$backend->javascript('https://www.mysecuredomain.com/test.js'); $backend->javascript('https://www.mysecuredomain.com/test.js');
@ -51,23 +61,35 @@ class RequirementsTest extends SapphireTest {
); );
} }
/**
* Setup new backend
*
* @param Requirements_Backend $backend
*/
protected function setupRequirements($backend) {
// Flush requirements
$backend->clear();
$backend->clearCombinedFiles();
$backend->setCombinedFilesFolder('_combinedfiles');
CacheGeneratedAssetHandler::flush();
}
/**
* Setup combined and non-combined js with the backend
*
* @param Requirements_Backend $backend
*/
protected function setupCombinedRequirements($backend) { protected function setupCombinedRequirements($backend) {
$basePath = $this->getCurrentRelativePath(); $basePath = $this->getCurrentRelativePath();
$this->setupRequirements($backend);
$backend->clear();
$backend->setCombinedFilesFolder('assets');
// clearing all previously generated requirements (just in case)
$backend->clear_combined_files();
$backend->delete_combined_files('RequirementsTest_bc.js');
// require files normally (e.g. called from a FormField instance) // require files normally (e.g. called from a FormField instance)
$backend->javascript($basePath . '/RequirementsTest_a.js'); $backend->javascript($basePath . '/RequirementsTest_a.js');
$backend->javascript($basePath . '/RequirementsTest_b.js'); $backend->javascript($basePath . '/RequirementsTest_b.js');
$backend->javascript($basePath . '/RequirementsTest_c.js'); $backend->javascript($basePath . '/RequirementsTest_c.js');
// require two of those files as combined includes // require two of those files as combined includes
$backend->combine_files( $backend->combineFiles(
'RequirementsTest_bc.js', 'RequirementsTest_bc.js',
array( array(
$basePath . '/RequirementsTest_b.js', $basePath . '/RequirementsTest_b.js',
@ -76,44 +98,46 @@ class RequirementsTest extends SapphireTest {
); );
} }
/**
* Setup combined files with the backend
*
* @param Requirements_Backend $backend
*/
protected function setupCombinedNonrequiredRequirements($backend) { protected function setupCombinedNonrequiredRequirements($backend) {
$basePath = $this->getCurrentRelativePath(); $basePath = $this->getCurrentRelativePath();
$this->setupRequirements($backend);
$backend->clear(); // require files as combined includes
$backend->setCombinedFilesFolder('assets'); $backend->combineFiles(
'RequirementsTest_bc.js',
// clearing all previously generated requirements (just in case) array(
$backend->clear_combined_files(); $basePath . '/RequirementsTest_b.js',
$backend->delete_combined_files('RequirementsTest_bc.js'); $basePath . '/RequirementsTest_c.js'
)
// require files as combined includes );
$backend->combine_files( }
'RequirementsTest_bc.js',
array(
$basePath . '/RequirementsTest_b.js',
$basePath . '/RequirementsTest_c.js'
)
);
}
public function testCombinedJavascript() { public function testCombinedJavascript() {
$backend = new Requirements_Backend; $backend = new Requirements_Backend();
$backend->set_combined_files_enabled(true);
$backend->setCombinedFilesFolder('assets');
$this->setupCombinedRequirements($backend); $this->setupCombinedRequirements($backend);
$combinedFilePath = Director::baseFolder() . '/assets/' . 'RequirementsTest_bc.js'; $combinedFileName = '/_combinedfiles/b2a28d2463/RequirementsTest_bc.js';
$combinedFilePath = AssetStoreTest_SpyStore::base_path() . $combinedFileName;
$html = $backend->includeInHTML(false, self::$html_template); $html = $backend->includeInHTML(false, self::$html_template);
/* COMBINED JAVASCRIPT FILE IS INCLUDED IN HTML HEADER */ /* COMBINED JAVASCRIPT FILE IS INCLUDED IN HTML HEADER */
$this->assertTrue((bool)preg_match('/src=".*\/RequirementsTest_bc\.js/', $html), $this->assertRegExp(
'combined javascript file is included in html header'); '/src=".*' . preg_quote($combinedFileName, '/') . '/',
$html,
'combined javascript file is included in html header'
);
/* COMBINED JAVASCRIPT FILE EXISTS */ /* COMBINED JAVASCRIPT FILE EXISTS */
$this->assertTrue(file_exists($combinedFilePath), $this->assertTrue(
'combined javascript file exists'); file_exists($combinedFilePath),
'combined javascript file exists'
);
/* COMBINED JAVASCRIPT HAS CORRECT CONTENT */ /* COMBINED JAVASCRIPT HAS CORRECT CONTENT */
$this->assertTrue((strpos(file_get_contents($combinedFilePath), "alert('b')") !== false), $this->assertTrue((strpos(file_get_contents($combinedFilePath), "alert('b')") !== false),
@ -122,35 +146,42 @@ class RequirementsTest extends SapphireTest {
'combined javascript has correct content'); 'combined javascript has correct content');
/* COMBINED FILES ARE NOT INCLUDED TWICE */ /* COMBINED FILES ARE NOT INCLUDED TWICE */
$this->assertFalse((bool)preg_match('/src=".*\/RequirementsTest_b\.js/', $html), $this->assertNotRegExp(
'combined files are not included twice'); '/src=".*\/RequirementsTest_b\.js/',
$this->assertFalse((bool)preg_match('/src=".*\/RequirementsTest_c\.js/', $html), $html,
'combined files are not included twice'); 'combined files are not included twice'
);
$this->assertNotRegExp(
'/src=".*\/RequirementsTest_c\.js/',
$html,
'combined files are not included twice'
);
/* NORMAL REQUIREMENTS ARE STILL INCLUDED */ /* NORMAL REQUIREMENTS ARE STILL INCLUDED */
$this->assertTrue((bool)preg_match('/src=".*\/RequirementsTest_a\.js/', $html), $this->assertRegExp(
'normal requirements are still included'); '/src=".*\/RequirementsTest_a\.js/',
$html,
$backend->delete_combined_files('RequirementsTest_bc.js'); 'normal requirements are still included'
);
// Then do it again, this time not requiring the files beforehand // Then do it again, this time not requiring the files beforehand
$backend = new Requirements_Backend; unlink($combinedFilePath);
$backend->set_combined_files_enabled(true); $backend = new Requirements_Backend();
$backend->setCombinedFilesFolder('assets');
$this->setupCombinedNonrequiredRequirements($backend); $this->setupCombinedNonrequiredRequirements($backend);
$combinedFilePath = Director::baseFolder() . '/assets/' . 'RequirementsTest_bc.js';
$html = $backend->includeInHTML(false, self::$html_template); $html = $backend->includeInHTML(false, self::$html_template);
/* COMBINED JAVASCRIPT FILE IS INCLUDED IN HTML HEADER */ /* COMBINED JAVASCRIPT FILE IS INCLUDED IN HTML HEADER */
$this->assertTrue((bool)preg_match('/src=".*\/RequirementsTest_bc\.js/', $html), $this->assertRegExp(
'combined javascript file is included in html header'); '/src=".*' . preg_quote($combinedFileName, '/') . '/',
$html,
'combined javascript file is included in html header'
);
/* COMBINED JAVASCRIPT FILE EXISTS */ /* COMBINED JAVASCRIPT FILE EXISTS */
$this->assertTrue(file_exists($combinedFilePath), $this->assertTrue(
'combined javascript file exists'); file_exists($combinedFilePath),
'combined javascript file exists'
);
/* COMBINED JAVASCRIPT HAS CORRECT CONTENT */ /* COMBINED JAVASCRIPT HAS CORRECT CONTENT */
$this->assertTrue((strpos(file_get_contents($combinedFilePath), "alert('b')") !== false), $this->assertTrue((strpos(file_get_contents($combinedFilePath), "alert('b')") !== false),
@ -159,20 +190,24 @@ class RequirementsTest extends SapphireTest {
'combined javascript has correct content'); 'combined javascript has correct content');
/* COMBINED FILES ARE NOT INCLUDED TWICE */ /* COMBINED FILES ARE NOT INCLUDED TWICE */
$this->assertFalse((bool)preg_match('/src=".*\/RequirementsTest_b\.js/', $html), $this->assertNotRegExp(
'combined files are not included twice'); '/src=".*\/RequirementsTest_b\.js/',
$this->assertFalse((bool)preg_match('/src=".*\/RequirementsTest_c\.js/', $html), $html,
'combined files are not included twice'); 'combined files are not included twice'
);
$backend->delete_combined_files('RequirementsTest_bc.js'); $this->assertNotRegExp(
'/src=".*\/RequirementsTest_c\.js/',
$html,
'combined files are not included twice'
);
} }
public function testCombinedCss() { public function testCombinedCss() {
$basePath = $this->getCurrentRelativePath(); $basePath = $this->getCurrentRelativePath();
$backend = new Requirements_Backend; $backend = new Requirements_Backend();
$backend->set_combined_files_enabled(true); $this->setupRequirements($backend);
$backend->combine_files( $backend->combineFiles(
'print.css', 'print.css',
array( array(
$basePath . '/RequirementsTest_print_a.css', $basePath . '/RequirementsTest_print_a.css',
@ -183,120 +218,156 @@ class RequirementsTest extends SapphireTest {
$html = $backend->includeInHTML(false, self::$html_template); $html = $backend->includeInHTML(false, self::$html_template);
$this->assertTrue((bool)preg_match('/href=".*\/print\.css/', $html), 'Print stylesheets have been combined.'); $this->assertRegExp(
$this->assertTrue((bool)preg_match( '/href=".*\/print\.css/',
'/media="print/', $html), $html,
'Print stylesheets have been combined.'
);
$this->assertRegExp(
'/media="print/',
$html,
'Combined print stylesheet retains the media parameter' 'Combined print stylesheet retains the media parameter'
); );
// Test that combining a file multiple times doesn't trigger an error
$backend = new Requirements_Backend();
$this->setupRequirements($backend);
$backend->combineFiles(
'style.css',
array(
$basePath . '/RequirementsTest_b.css',
$basePath . '/RequirementsTest_c.css'
)
);
$backend->combineFiles(
'style.css',
array(
$basePath . '/RequirementsTest_b.css',
$basePath . '/RequirementsTest_c.css'
)
);
$html = $backend->includeInHTML(false, self::$html_template);
$this->assertRegExp(
'/href=".*\/style\.css/',
$html,
'Stylesheets have been combined.'
);
} }
public function testBlockedCombinedJavascript() { public function testBlockedCombinedJavascript() {
$basePath = $this->getCurrentRelativePath(); $basePath = $this->getCurrentRelativePath();
$backend = new Requirements_Backend();
$backend = new Requirements_Backend; $this->setupCombinedRequirements($backend);
$backend->set_combined_files_enabled(true); $combinedFileName = '/_combinedfiles/b2a28d2463/RequirementsTest_bc.js';
$backend->setCombinedFilesFolder('assets'); $combinedFilePath = AssetStoreTest_SpyStore::base_path() . $combinedFileName;
$combinedFilePath = Director::baseFolder() . '/assets/' . 'RequirementsTest_bc.js';
/* BLOCKED COMBINED FILES ARE NOT INCLUDED */ /* BLOCKED COMBINED FILES ARE NOT INCLUDED */
$this->setupCombinedRequirements($backend);
$backend->block('RequirementsTest_bc.js'); $backend->block('RequirementsTest_bc.js');
$backend->delete_combined_files('RequirementsTest_bc.js');
clearstatcache(); // needed to get accurate file_exists() results clearstatcache(); // needed to get accurate file_exists() results
$html = $backend->includeInHTML(false, self::$html_template); $html = $backend->includeInHTML(false, self::$html_template);
$this->assertFileNotExists($combinedFilePath);
$this->assertFalse((bool)preg_match('/src=".*\/RequirementsTest_bc\.js/', $html), $this->assertNotRegExp(
'blocked combined files are not included '); '/src=".*\/RequirementsTest_bc\.js/',
$html,
'blocked combined files are not included'
);
$backend->unblock('RequirementsTest_bc.js'); $backend->unblock('RequirementsTest_bc.js');
/* BLOCKED UNCOMBINED FILES ARE NOT INCLUDED */ /* BLOCKED UNCOMBINED FILES ARE NOT INCLUDED */
$this->setupCombinedRequirements($backend); $this->setupCombinedRequirements($backend);
$backend->block($basePath .'/RequirementsTest_b.js'); $backend->block($basePath .'/RequirementsTest_b.js');
$backend->delete_combined_files('RequirementsTest_bc.js'); $combinedFileName2 = '/_combinedfiles/37bd2d9dcb/RequirementsTest_bc.js'; // SHA1 without file c included
$combinedFilePath2 = AssetStoreTest_SpyStore::base_path() . $combinedFileName2;
clearstatcache(); // needed to get accurate file_exists() results clearstatcache(); // needed to get accurate file_exists() results
$html = $backend->includeInHTML(false, self::$html_template); $html = $backend->includeInHTML(false, self::$html_template);
$this->assertFalse((strpos(file_get_contents($combinedFilePath), "alert('b')") !== false), $this->assertFileExists($combinedFilePath2);
'blocked uncombined files are not included'); $this->assertTrue(
$backend->unblock('RequirementsTest_b.js'); strpos(file_get_contents($combinedFilePath2), "alert('b')") === false,
'blocked uncombined files are not included'
);
$backend->unblock($basePath . '/RequirementsTest_b.js');
/* A SINGLE FILE CAN'T BE INCLUDED IN TWO COMBINED FILES */ /* A SINGLE FILE CAN'T BE INCLUDED IN TWO COMBINED FILES */
$this->setupCombinedRequirements($backend); $this->setupCombinedRequirements($backend);
clearstatcache(); // needed to get accurate file_exists() results clearstatcache(); // needed to get accurate file_exists() results
// This throws a notice-level error, so we prefix with @ // Exception generated from including invalid file
@$backend->combine_files( $this->setExpectedException(
'InvalidArgumentException',
sprintf(
"Requirements_Backend::combine_files(): Already included file(s) %s in combined file '%s'",
$basePath . '/RequirementsTest_c.js',
'RequirementsTest_bc.js'
)
);
$backend->combineFiles(
'RequirementsTest_ac.js', 'RequirementsTest_ac.js',
array( array(
$basePath . '/RequirementsTest_a.js', $basePath . '/RequirementsTest_a.js',
$basePath . '/RequirementsTest_c.js' $basePath . '/RequirementsTest_c.js'
) )
); );
$combinedFiles = $backend->get_combine_files();
$this->assertEquals(
array_keys($combinedFiles),
array('RequirementsTest_bc.js'),
"A single file can't be included in two combined files"
);
$backend->delete_combined_files('RequirementsTest_bc.js');
} }
public function testArgsInUrls() { public function testArgsInUrls() {
$basePath = $this->getCurrentRelativePath(); $basePath = $this->getCurrentRelativePath();
$backend = new Requirements_Backend; $backend = new Requirements_Backend;
$backend->set_combined_files_enabled(true); $this->setupRequirements($backend);
$backend->javascript($basePath . '/RequirementsTest_a.js?test=1&test=2&test=3'); $backend->javascript($basePath . '/RequirementsTest_a.js?test=1&test=2&test=3');
$backend->css($basePath . '/RequirementsTest_a.css?test=1&test=2&test=3'); $backend->css($basePath . '/RequirementsTest_a.css?test=1&test=2&test=3');
$backend->delete_combined_files('RequirementsTest_bc.js');
$html = $backend->includeInHTML(false, self::$html_template); $html = $backend->includeInHTML(false, self::$html_template);
/* Javascript has correct path */ /* Javascript has correct path */
$this->assertTrue( $this->assertRegExp(
(bool)preg_match('/src=".*\/RequirementsTest_a\.js\?m=\d\d+&amp;test=1&amp;test=2&amp;test=3/',$html), '/src=".*\/RequirementsTest_a\.js\?m=\d\d+&amp;test=1&amp;test=2&amp;test=3/',
'javascript has correct path'); $html,
'javascript has correct path'
);
/* CSS has correct path */ /* CSS has correct path */
$this->assertTrue( $this->assertRegExp(
(bool)preg_match('/href=".*\/RequirementsTest_a\.css\?m=\d\d+&amp;test=1&amp;test=2&amp;test=3/',$html), '/href=".*\/RequirementsTest_a\.css\?m=\d\d+&amp;test=1&amp;test=2&amp;test=3/',
'css has correct path'); $html,
'css has correct path'
);
} }
public function testRequirementsBackend() { public function testRequirementsBackend() {
$basePath = $this->getCurrentRelativePath(); $basePath = $this->getCurrentRelativePath();
$backend = new Requirements_Backend(); $backend = new Requirements_Backend();
$this->setupRequirements($backend);
$backend->javascript($basePath . '/a.js'); $backend->javascript($basePath . '/a.js');
$this->assertTrue(count($backend->get_javascript()) == 1, $this->assertTrue(count($backend->getJavascript()) == 1,
"There should be only 1 file included in required javascript."); "There should be only 1 file included in required javascript.");
$this->assertTrue(in_array($basePath . '/a.js', $backend->get_javascript()), $this->assertTrue(in_array($basePath . '/a.js', $backend->getJavascript()),
"a.js should be included in required javascript."); "a.js should be included in required javascript.");
$backend->javascript($basePath . '/b.js'); $backend->javascript($basePath . '/b.js');
$this->assertTrue(count($backend->get_javascript()) == 2, $this->assertTrue(count($backend->getJavascript()) == 2,
"There should be 2 files included in required javascript."); "There should be 2 files included in required javascript.");
$backend->block($basePath . '/a.js'); $backend->block($basePath . '/a.js');
$this->assertTrue(count($backend->get_javascript()) == 1, $this->assertTrue(count($backend->getJavascript()) == 1,
"There should be only 1 file included in required javascript."); "There should be only 1 file included in required javascript.");
$this->assertFalse(in_array($basePath . '/a.js', $backend->get_javascript()), $this->assertFalse(in_array($basePath . '/a.js', $backend->getJavascript()),
"a.js should not be included in required javascript after it has been blocked."); "a.js should not be included in required javascript after it has been blocked.");
$this->assertTrue(in_array($basePath . '/b.js', $backend->get_javascript()), $this->assertTrue(in_array($basePath . '/b.js', $backend->getJavascript()),
"b.js should be included in required javascript."); "b.js should be included in required javascript.");
$backend->css($basePath . '/a.css'); $backend->css($basePath . '/a.css');
$this->assertTrue(count($backend->get_css()) == 1, $this->assertTrue(count($backend->getCSS()) == 1,
"There should be only 1 file included in required css."); "There should be only 1 file included in required css.");
$this->assertArrayHasKey($basePath . '/a.css', $backend->get_css(), $this->assertArrayHasKey($basePath . '/a.css', $backend->getCSS(),
"a.css should be in required css."); "a.css should be in required css.");
$backend->block($basePath . '/a.css'); $backend->block($basePath . '/a.css');
$this->assertTrue(count($backend->get_css()) == 0, $this->assertTrue(count($backend->getCSS()) == 0,
"There should be nothing in required css after file has been blocked."); "There should be nothing in required css after file has been blocked.");
} }
@ -307,6 +378,7 @@ class RequirementsTest extends SapphireTest {
$basePath = 'framework' . substr($basePath, strlen(FRAMEWORK_DIR)); $basePath = 'framework' . substr($basePath, strlen(FRAMEWORK_DIR));
$backend = new Requirements_Backend(); $backend = new Requirements_Backend();
$this->setupRequirements($backend);
$holder = Requirements::backend(); $holder = Requirements::backend();
Requirements::set_backend($backend); Requirements::set_backend($backend);
$data = new ArrayData(array( $data = new ArrayData(array(
@ -335,16 +407,17 @@ class RequirementsTest extends SapphireTest {
public function testJsWriteToBody() { public function testJsWriteToBody() {
$backend = new Requirements_Backend(); $backend = new Requirements_Backend();
$this->setupRequirements($backend);
$backend->javascript('http://www.mydomain.com/test.js'); $backend->javascript('http://www.mydomain.com/test.js');
// Test matching with HTML5 <header> tags as well // Test matching with HTML5 <header> tags as well
$template = '<html><head></head><body><header>My header</header><p>Body</p></body></html>'; $template = '<html><head></head><body><header>My header</header><p>Body</p></body></html>';
$backend->set_write_js_to_body(false); $backend->setWriteJavascriptToBody(false);
$html = $backend->includeInHTML(false, $template); $html = $backend->includeInHTML(false, $template);
$this->assertContains('<head><script', $html); $this->assertContains('<head><script', $html);
$backend->set_write_js_to_body(true); $backend->setWriteJavascriptToBody(true);
$html = $backend->includeInHTML(false, $template); $html = $backend->includeInHTML(false, $template);
$this->assertNotContains('<head><script', $html); $this->assertNotContains('<head><script', $html);
$this->assertContains('</script></body>', $html); $this->assertContains('</script></body>', $html);
@ -353,6 +426,7 @@ class RequirementsTest extends SapphireTest {
public function testIncludedJsIsNotCommentedOut() { public function testIncludedJsIsNotCommentedOut() {
$template = '<html><head></head><body><!--<script>alert("commented out");</script>--></body></html>'; $template = '<html><head></head><body><!--<script>alert("commented out");</script>--></body></html>';
$backend = new Requirements_Backend(); $backend = new Requirements_Backend();
$this->setupRequirements($backend);
$backend->javascript($this->getCurrentRelativePath() . '/RequirementsTest_a.js'); $backend->javascript($this->getCurrentRelativePath() . '/RequirementsTest_a.js');
$html = $backend->includeInHTML(false, $template); $html = $backend->includeInHTML(false, $template);
//wiping out commented-out html //wiping out commented-out html
@ -364,9 +438,10 @@ class RequirementsTest extends SapphireTest {
$template = '<html><head></head><body><!--<script>alert("commented out");</script>-->' $template = '<html><head></head><body><!--<script>alert("commented out");</script>-->'
. '<h1>more content</h1></body></html>'; . '<h1>more content</h1></body></html>';
$backend = new Requirements_Backend(); $backend = new Requirements_Backend();
$backend->set_suffix_requirements(false); $this->setupRequirements($backend);
$backend->setSuffixRequirements(false);
$src = $this->getCurrentRelativePath() . '/RequirementsTest_a.js'; $src = $this->getCurrentRelativePath() . '/RequirementsTest_a.js';
$urlSrc = Controller::join_links(Director::baseURL(), $src); $urlSrc = ControllerTest_ContainerController::join_links(Director::baseURL(), $src);
$backend->javascript($src); $backend->javascript($src);
$html = $backend->includeInHTML(false, $template); $html = $backend->includeInHTML(false, $template);
$this->assertEquals('<html><head></head><body><!--<script>alert("commented out");</script>-->' $this->assertEquals('<html><head></head><body><!--<script>alert("commented out");</script>-->'
@ -375,6 +450,7 @@ class RequirementsTest extends SapphireTest {
public function testForceJsToBottom() { public function testForceJsToBottom() {
$backend = new Requirements_Backend(); $backend = new Requirements_Backend();
$this->setupRequirements($backend);
$backend->javascript('http://www.mydomain.com/test.js'); $backend->javascript('http://www.mydomain.com/test.js');
// Test matching with HTML5 <header> tags as well // Test matching with HTML5 <header> tags as well
@ -391,8 +467,8 @@ class RequirementsTest extends SapphireTest {
// Test if the script is before the head tag, not before the body. // Test if the script is before the head tag, not before the body.
// Expected: $JsInHead // Expected: $JsInHead
$backend->set_write_js_to_body(false); $backend->setWriteJavascriptToBody(false);
$backend->set_force_js_to_bottom(false); $backend->setForceJSToBottom(false);
$html = $backend->includeInHTML(false, $template); $html = $backend->includeInHTML(false, $template);
$this->assertNotEquals($JsInBody, $html); $this->assertNotEquals($JsInBody, $html);
$this->assertNotEquals($JsAtEnd, $html); $this->assertNotEquals($JsAtEnd, $html);
@ -400,16 +476,16 @@ class RequirementsTest extends SapphireTest {
// Test if the script is before the first <script> tag, not before the body. // Test if the script is before the first <script> tag, not before the body.
// Expected: $JsInBody // Expected: $JsInBody
$backend->set_write_js_to_body(true); $backend->setWriteJavascriptToBody(true);
$backend->set_force_js_to_bottom(false); $backend->setForceJSToBottom(false);
$html = $backend->includeInHTML(false, $template); $html = $backend->includeInHTML(false, $template);
$this->assertNotEquals($JsAtEnd, $html); $this->assertNotEquals($JsAtEnd, $html);
$this->assertEquals($JsInBody, $html); $this->assertEquals($JsInBody, $html);
// Test if the script is placed just before the closing bodytag, with write-to-body false. // Test if the script is placed just before the closing bodytag, with write-to-body false.
// Expected: $JsAtEnd // Expected: $JsAtEnd
$backend->set_write_js_to_body(false); $backend->setWriteJavascriptToBody(false);
$backend->set_force_js_to_bottom(true); $backend->setForceJSToBottom(true);
$html = $backend->includeInHTML(false, $template); $html = $backend->includeInHTML(false, $template);
$this->assertNotEquals($JsInHead, $html); $this->assertNotEquals($JsInHead, $html);
$this->assertNotEquals($JsInBody, $html); $this->assertNotEquals($JsInBody, $html);
@ -417,8 +493,8 @@ class RequirementsTest extends SapphireTest {
// Test if the script is placed just before the closing bodytag, with write-to-body true. // Test if the script is placed just before the closing bodytag, with write-to-body true.
// Expected: $JsAtEnd // Expected: $JsAtEnd
$backend->set_write_js_to_body(true); $backend->setWriteJavascriptToBody(true);
$backend->set_force_js_to_bottom(true); $backend->setForceJSToBottom(true);
$html = $backend->includeInHTML(false, $template); $html = $backend->includeInHTML(false, $template);
$this->assertNotEquals($JsInHead, $html); $this->assertNotEquals($JsInHead, $html);
$this->assertNotEquals($JsInBody, $html); $this->assertNotEquals($JsInBody, $html);
@ -429,21 +505,22 @@ class RequirementsTest extends SapphireTest {
$template = '<html><head></head><body><header>My header</header><p>Body</p></body></html>'; $template = '<html><head></head><body><header>My header</header><p>Body</p></body></html>';
$basePath = $this->getCurrentRelativePath(); $basePath = $this->getCurrentRelativePath();
$backend = new Requirements_Backend; $backend = new Requirements_Backend();
$this->setupRequirements($backend);
$backend->javascript($basePath .'/RequirementsTest_a.js'); $backend->javascript($basePath .'/RequirementsTest_a.js');
$backend->javascript($basePath .'/RequirementsTest_b.js?foo=bar&bla=blubb'); $backend->javascript($basePath .'/RequirementsTest_b.js?foo=bar&bla=blubb');
$backend->css($basePath .'/RequirementsTest_a.css'); $backend->css($basePath .'/RequirementsTest_a.css');
$backend->css($basePath .'/RequirementsTest_b.css?foo=bar&bla=blubb'); $backend->css($basePath .'/RequirementsTest_b.css?foo=bar&bla=blubb');
$backend->set_suffix_requirements(true); $backend->setSuffixRequirements(true);
$html = $backend->includeInHTML(false, $template); $html = $backend->includeInHTML(false, $template);
$this->assertRegexp('/RequirementsTest_a\.js\?m=[\d]*"/', $html); $this->assertRegexp('/RequirementsTest_a\.js\?m=[\d]*"/', $html);
$this->assertRegexp('/RequirementsTest_b\.js\?m=[\d]*&amp;foo=bar&amp;bla=blubb"/', $html); $this->assertRegexp('/RequirementsTest_b\.js\?m=[\d]*&amp;foo=bar&amp;bla=blubb"/', $html);
$this->assertRegexp('/RequirementsTest_a\.css\?m=[\d]*"/', $html); $this->assertRegexp('/RequirementsTest_a\.css\?m=[\d]*"/', $html);
$this->assertRegexp('/RequirementsTest_b\.css\?m=[\d]*&amp;foo=bar&amp;bla=blubb"/', $html); $this->assertRegexp('/RequirementsTest_b\.css\?m=[\d]*&amp;foo=bar&amp;bla=blubb"/', $html);
$backend->set_suffix_requirements(false); $backend->setSuffixRequirements(false);
$html = $backend->includeInHTML(false, $template); $html = $backend->includeInHTML(false, $template);
$this->assertNotContains('RequirementsTest_a.js=', $html); $this->assertNotContains('RequirementsTest_a.js=', $html);
$this->assertNotRegexp('/RequirementsTest_a\.js\?m=[\d]*"/', $html); $this->assertNotRegexp('/RequirementsTest_a\.js\?m=[\d]*"/', $html);
@ -452,26 +529,15 @@ class RequirementsTest extends SapphireTest {
$this->assertNotRegexp('/RequirementsTest_b\.css\?m=[\d]*&amp;foo=bar&amp;bla=blubb"/', $html); $this->assertNotRegexp('/RequirementsTest_b\.css\?m=[\d]*&amp;foo=bar&amp;bla=blubb"/', $html);
} }
/**
* Verify that the given backend includes the given files
*
* @param Requirements_Backend $backend
* @param string $type js or css
* @param array|string $files Files or list of files to check
*/
public function assertFileIncluded($backend, $type, $files) { public function assertFileIncluded($backend, $type, $files) {
$type = strtolower($type); $includedFiles = $this->getBackendFiles($backend, $type);
switch (strtolower($type)) {
case 'css':
$method = 'get_css';
$type = 'CSS';
break;
case 'js':
case 'javascript':
case 'script':
$method = 'get_javascript';
$type = 'JavaScript';
break;
}
$includedFiles = $backend->$method();
// Workaround for inconsistent return formats
if($method == 'get_javascript') {
$includedFiles = array_combine(array_values($includedFiles), array_values($includedFiles));
}
if(is_array($files)) { if(is_array($files)) {
$failedMatches = array(); $failedMatches = array();
@ -497,26 +563,7 @@ class RequirementsTest extends SapphireTest {
} }
public function assertFileNotIncluded($backend, $type, $files) { public function assertFileNotIncluded($backend, $type, $files) {
$type = strtolower($type); $includedFiles = $this->getBackendFiles($backend, $type);
switch ($type) {
case 'css':
$method = 'get_css';
$type = 'CSS';
break;
case 'js':
case 'get_javascript':
case 'script':
$method = 'get_javascript';
$type = 'JavaScript';
break;
}
$includedFiles = $backend->$method();
// Workaround for inconsistent return formats
if($method == 'get_javascript') {
$includedFiles = array_combine(array_values($includedFiles), array_values($includedFiles));
}
if(is_array($files)) { if(is_array($files)) {
$failedMatches = array(); $failedMatches = array();
foreach ($files as $file) { foreach ($files as $file) {
@ -539,4 +586,26 @@ class RequirementsTest extends SapphireTest {
); );
} }
} }
/**
* Get files of the given type from the backend
*
* @param Requirements_Backend $backend
* @param string $type js or css
* @return array
*/
protected function getBackendFiles($backend, $type) {
$type = strtolower($type);
switch (strtolower($type)) {
case 'css':
return $backend->getCSS();
case 'js':
case 'javascript':
case 'script':
$scripts = $backend->getJavascript();
return array_combine(array_values($scripts), array_values($scripts));
}
return array();
}
} }

View File

@ -10,6 +10,12 @@ class SSViewerTest extends SapphireTest {
parent::setUp(); parent::setUp();
Config::inst()->update('SSViewer', 'source_file_comments', false); Config::inst()->update('SSViewer', 'source_file_comments', false);
Config::inst()->update('SSViewer_FromString', 'cache_template', false); Config::inst()->update('SSViewer_FromString', 'cache_template', false);
AssetStoreTest_SpyStore::activate('SSViewerTest');
}
public function tearDown() {
AssetStoreTest_SpyStore::reset();
parent::tearDown();
} }
/** /**
@ -138,54 +144,6 @@ class SSViewerTest extends SapphireTest {
<% require css($cssFile) %>"); <% require css($cssFile) %>");
$this->assertFalse((bool)trim($template), "Should be no content in this return."); $this->assertFalse((bool)trim($template), "Should be no content in this return.");
} }
public function testRequirementsCombine(){
$oldBackend = Requirements::backend();
$testBackend = new Requirements_Backend();
Requirements::set_backend($testBackend);
$combinedTestFilePath = BASE_PATH . '/' . $testBackend->getCombinedFilesFolder() . '/testRequirementsCombine.js';
$jsFile = FRAMEWORK_DIR . '/tests/view/themes/javascript/bad.js';
$jsFileContents = file_get_contents(BASE_PATH . '/' . $jsFile);
Requirements::combine_files('testRequirementsCombine.js', array($jsFile));
require_once('thirdparty/jsmin/jsmin.php');
// first make sure that our test js file causes an exception to be thrown
try{
$content = JSMin::minify($content);
Requirements::set_backend($oldBackend);
$this->fail('JSMin did not throw exception on minify bad file: ');
}catch(Exception $e){
// exception thrown... good
}
// secondly, make sure that requirements combine throws the correct warning, and only that warning
@unlink($combinedTestFilePath);
try{
Requirements::process_combined_files();
}catch(PHPUnit_Framework_Error_Warning $e){
if(strstr($e->getMessage(), 'Failed to minify') === false){
Requirements::set_backend($oldBackend);
$this->fail('Requirements::process_combined_files raised a warning, which is good, but this is not the expected warning ("Failed to minify..."): '.$e);
}
}catch(Exception $e){
Requirements::set_backend($oldBackend);
$this->fail('Requirements::process_combined_files did not catch exception caused by minifying bad js file: '.$e);
}
// and make sure the combined content matches the input content, i.e. no loss of functionality
if(!file_exists($combinedTestFilePath)){
Requirements::set_backend($oldBackend);
$this->fail('No combined file was created at expected path: '.$combinedTestFilePath);
}
$combinedTestFileContents = file_get_contents($combinedTestFilePath);
$this->assertContains($jsFileContents, $combinedTestFileContents);
// reset
Requirements::set_backend($oldBackend);
}
public function testComments() { public function testComments() {
$output = $this->render(<<<SS $output = $this->render(<<<SS
@ -1357,8 +1315,8 @@ after')
$basePath = dirname($this->getCurrentRelativePath()) . '/forms'; $basePath = dirname($this->getCurrentRelativePath()) . '/forms';
$backend = new Requirements_Backend; $backend = new Requirements_Backend;
$backend->set_combined_files_enabled(false); $backend->setCombinedFilesEnabled(false);
$backend->combine_files( $backend->combineFiles(
'RequirementsTest_ab.css', 'RequirementsTest_ab.css',
array( array(
$basePath . '/RequirementsTest_a.css', $basePath . '/RequirementsTest_a.css',

View File

@ -1,19 +1,14 @@
<?php <?php
use SilverStripe\Filesystem\Storage\GeneratedAssetHandler;
/** /**
* Requirements tracker for JavaScript and CSS. * Requirements tracker for JavaScript and CSS.
* *
* @package framework * @package framework
* @subpackage view * @subpackage view
*/ */
class Requirements implements Flushable { class Requirements {
/**
* Triggered early in the request when a flush is requested
*/
public static function flush() {
self::delete_all_combined_files();
}
/** /**
* Enable combining of css/javascript files. * Enable combining of css/javascript files.
@ -21,7 +16,7 @@ class Requirements implements Flushable {
* @param bool $enable * @param bool $enable
*/ */
public static function set_combined_files_enabled($enable) { public static function set_combined_files_enabled($enable) {
self::backend()->set_combined_files_enabled($enable); self::backend()->setCombinedFilesEnabled($enable);
} }
/** /**
@ -30,7 +25,7 @@ class Requirements implements Flushable {
* @return bool * @return bool
*/ */
public static function get_combined_files_enabled() { public static function get_combined_files_enabled() {
return self::backend()->get_combined_files_enabled(); return self::backend()->getCombinedFilesEnabled();
} }
/** /**
@ -51,7 +46,7 @@ class Requirements implements Flushable {
* @param bool * @param bool
*/ */
public static function set_suffix_requirements($var) { public static function set_suffix_requirements($var) {
self::backend()->set_suffix_requirements($var); self::backend()->setSuffixRequirements($var);
} }
/** /**
@ -60,7 +55,7 @@ class Requirements implements Flushable {
* @return bool * @return bool
*/ */
public static function get_suffix_requirements() { public static function get_suffix_requirements() {
return self::backend()->get_suffix_requirements(); return self::backend()->getSuffixRequirements();
} }
/** /**
@ -73,7 +68,7 @@ class Requirements implements Flushable {
public static function backend() { public static function backend() {
if(!self::$backend) { if(!self::$backend) {
self::$backend = new Requirements_Backend(); self::$backend = Injector::inst()->create('Requirements_Backend');
} }
return self::$backend; return self::$backend;
} }
@ -112,7 +107,7 @@ class Requirements implements Flushable {
* @return array * @return array
*/ */
public static function get_custom_scripts() { public static function get_custom_scripts() {
return self::backend()->get_custom_scripts(); return self::backend()->getCustomScripts();
} }
/** /**
@ -224,7 +219,7 @@ class Requirements implements Flushable {
* Removes all items from the block list * Removes all items from the block list
*/ */
public static function unblock_all() { public static function unblock_all() {
self::backend()->unblock_all(); self::backend()->unblockAll();
} }
/** /**
@ -248,7 +243,7 @@ class Requirements implements Flushable {
* @param SS_HTTPResponse $response * @param SS_HTTPResponse $response
*/ */
public static function include_in_response(SS_HTTPResponse $response) { public static function include_in_response(SS_HTTPResponse $response) {
return self::backend()->include_in_response($response); return self::backend()->includeInResponse($response);
} }
/** /**
@ -273,12 +268,10 @@ class Requirements implements Flushable {
* increases performance by fewer HTTP requests. * increases performance by fewer HTTP requests.
* *
* The combined file is regenerated based on every file modification time. Optionally a * The combined file is regenerated based on every file modification time. Optionally a
* rebuild can be triggered by appending ?flush=1 to the URL. If all files to be combined are * rebuild can be triggered by appending ?flush=1 to the URL.
* JavaScript, we use the external JSMin library to minify the JavaScript.
* *
* All combined files will have a comment on the start of each concatenated file denoting their * All combined files will have a comment on the start of each concatenated file denoting their
* original position. For easier debugging, we only minify JavaScript if not in development * original position.
* mode ({@link Director::isDev()}).
* *
* CAUTION: You're responsible for ensuring that the load order for combined files is * CAUTION: You're responsible for ensuring that the load order for combined files is
* retained - otherwise combining JavaScript files can lead to functional errors in the * retained - otherwise combining JavaScript files can lead to functional errors in the
@ -316,49 +309,32 @@ class Requirements implements Flushable {
* @return bool|void * @return bool|void
*/ */
public static function combine_files($combinedFileName, $files, $media = null) { public static function combine_files($combinedFileName, $files, $media = null) {
self::backend()->combine_files($combinedFileName, $files, $media); self::backend()->combineFiles($combinedFileName, $files, $media);
} }
/** /**
* Return all combined files; keys are the combined file names, values are lists of * Return all combined files; keys are the combined file names, values are lists of
* files being combined. * associative arrays with 'files', 'type', and 'media' keys for details about this
* combined file.
* *
* @return array * @return array
*/ */
public static function get_combine_files() { public static function get_combine_files() {
return self::backend()->get_combine_files(); return self::backend()->getCombinedFiles();
}
/**
* Delete all dynamically generated combined files from the filesystem
*
* @param string $combinedFileName If left blank, all combined files are deleted.
*/
public static function delete_combined_files($combinedFileName = null) {
return self::backend()->delete_combined_files($combinedFileName);
}
/**
* 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()->delete_all_combined_files();
} }
/** /**
* Re-sets the combined files definition. See {@link Requirements_Backend::clear_combined_files()} * Re-sets the combined files definition. See {@link Requirements_Backend::clear_combined_files()}
*/ */
public static function clear_combined_files() { public static function clear_combined_files() {
self::backend()->clear_combined_files(); self::backend()->clearCombinedFiles();
} }
/** /**
* Do the heavy lifting involved in combining (and, in the case of JavaScript minifying) the * Do the heavy lifting involved in combining the combined files.
* combined files.
*/ */
public static function process_combined_files() { public static function process_combined_files() {
return self::backend()->process_combined_files(); return self::backend()->processCombinedFiles();
} }
/** /**
@ -368,7 +344,7 @@ class Requirements implements Flushable {
* @param bool * @param bool
*/ */
public static function set_write_js_to_body($var) { public static function set_write_js_to_body($var) {
self::backend()->set_write_js_to_body($var); self::backend()->setWriteJavascriptToBody($var);
} }
/** /**
@ -378,9 +354,28 @@ class Requirements implements Flushable {
* @param boolean $var If true, force the JavaScript to be included at the bottom of the page * @param boolean $var If true, force the JavaScript to be included at the bottom of the page
*/ */
public static function set_force_js_to_bottom($var) { public static function set_force_js_to_bottom($var) {
self::backend()->set_force_js_to_bottom($var); self::backend()->setForceJSToBottom($var);
} }
/**
* Check if header comments are written
*
* @return bool
*/
public static function get_write_header_comments() {
return self::backend()->getWriteHeaderComment();
}
/**
* Flag whether header comments should be written for each combined file
*
* @param bool $write
*/
public function set_write_header_comments($write) {
self::backend()->setWriteHeaderComment($write);
}
/** /**
* Output debugging information * Output debugging information
*/ */
@ -404,46 +399,57 @@ class Requirements_Backend {
* *
* @var bool * @var bool
*/ */
protected $suffix_requirements = true; protected $suffixRequirements = true;
/** /**
* Whether to combine CSS and JavaScript files * Whether to combine CSS and JavaScript files
* *
* @var bool * @var bool
*/ */
protected $combined_files_enabled = true; protected $combinedFilesEnabled = true;
/**
* Determine if files should be combined automatically on dev mode
* By default, files will be left uncombined when developing.
*
* @config
* @var bool
*/
private static $combine_in_dev = false;
/** /**
* Paths to all required JavaScript files relative to docroot * Paths to all required JavaScript files relative to docroot
* *
* @var array $javascript * @var array
*/ */
protected $javascript = array(); protected $javascript = array();
/** /**
* Paths to all required CSS files relative to the docroot. * Paths to all required CSS files relative to the docroot.
* *
* @var array $css * @var array
*/ */
protected $css = array(); protected $css = array();
/** /**
* All custom javascript code that is inserted into the page's HTML * All custom javascript code that is inserted into the page's HTML
* *
* @var array $customScript * @var array
*/ */
protected $customScript = array(); protected $customScript = array();
/** /**
* All custom CSS rules which are inserted directly at the bottom of the HTML <head> tag * All custom CSS rules which are inserted directly at the bottom of the HTML <head> tag
* *
* @var array $customCSS * @var array
*/ */
protected $customCSS = array(); protected $customCSS = array();
/** /**
* All custom HTML markup which is added before the closing <head> tag, e.g. additional * All custom HTML markup which is added before the closing <head> tag, e.g. additional
* metatags. * metatags.
*
* @var array
*/ */
protected $customHeadTags = array(); protected $customHeadTags = array();
@ -451,7 +457,7 @@ class Requirements_Backend {
* Remembers the file paths or uniquenessIDs of all Requirements cleared through * Remembers the file paths or uniquenessIDs of all Requirements cleared through
* {@link clear()}, so that they can be restored later. * {@link clear()}, so that they can be restored later.
* *
* @var array $disabled * @var array
*/ */
protected $disabled = array(); protected $disabled = array();
@ -463,7 +469,7 @@ class Requirements_Backend {
* *
* Use {@link unblock()} or {@link unblock_all()} to revert changes. * Use {@link unblock()} or {@link unblock_all()} to revert changes.
* *
* @var array $blocked * @var array
*/ */
protected $blocked = array(); protected $blocked = array();
@ -471,23 +477,16 @@ class Requirements_Backend {
* A list of combined files registered via {@link combine_files()}. Keys are the output file * A list of combined files registered via {@link combine_files()}. Keys are the output file
* names, values are lists of input files. * names, values are lists of input files.
* *
* @var array $combine_files * @var array
*/ */
public $combine_files = array(); protected $combinedFiles = array();
/**
* Use the JSMin library to minify any javascript file passed to {@link combine_files()}.
*
* @var bool
*/
public $combine_js_with_jsmin = true;
/** /**
* Whether or not file headers should be written when combining files * Whether or not file headers should be written when combining files
* *
* @var boolean * @var boolean
*/ */
public $write_header_comment = true; public $writeHeaderComment = true;
/** /**
* Where to save combined files. By default they're placed in assets/_combinedfiles, however * Where to save combined files. By default they're placed in assets/_combinedfiles, however
@ -505,22 +504,39 @@ class Requirements_Backend {
* *
* @var bool * @var bool
*/ */
public $write_js_to_body = true; public $writeJavascriptToBody = true;
/** /**
* Force the JavaScript to the bottom of the page, even if there's a script tag in the body already * Force the JavaScript to the bottom of the page, even if there's a script tag in the body already
* *
* @var boolean * @var boolean
*/ */
protected $force_js_to_bottom = false; protected $forceJSToBottom = false;
/**
* Configures the default prefix for comined files
*
* @config
* @var string
*/
private static $default_combined_files_folder = '_combinedfiles';
/**
* Gets the backend storage for generated files
*
* @return GeneratedAssetHandler
*/
protected function getAssetHandler() {
return Injector::inst()->get('GeneratedAssetHandler');
}
/** /**
* Enable or disable the combination of CSS and JavaScript files * Enable or disable the combination of CSS and JavaScript files
* *
* @param $enable * @param bool $enable
*/ */
public function set_combined_files_enabled($enable) { public function setCombinedFilesEnabled($enable) {
$this->combined_files_enabled = (bool) $enable; $this->combinedFilesEnabled = (bool) $enable;
} }
/** /**
@ -528,15 +544,37 @@ class Requirements_Backend {
* *
* @return bool * @return bool
*/ */
public function get_combined_files_enabled() { public function getCombinedFilesEnabled() {
return $this->combined_files_enabled; return $this->combinedFilesEnabled;
} }
/** /**
* Set the folder to save combined files in. By default they're placed in assets/_combinedfiles, * Check if header comments are written
*
* @return bool
*/
public function getWriteHeaderComment() {
return $this->writeHeaderComment;
}
/**
* Flag whether header comments should be written for each combined file
*
* @param bool $write
* @return $this
*/
public function setWriteHeaderComment($write) {
$this->writeHeaderComment = $write;
return $this;
}
/**
* Set the folder to save combined files in. By default they're placed in _combinedfiles,
* however this may be an issue depending on your setup, especially for CSS files which often * however this may be an issue depending on your setup, especially for CSS files which often
* contain relative paths. * contain relative paths.
* *
* This must not include any 'assets' prefix
*
* @param string $folder * @param string $folder
*/ */
public function setCombinedFilesFolder($folder) { public function setCombinedFilesFolder($folder) {
@ -544,10 +582,15 @@ class Requirements_Backend {
} }
/** /**
* @return string Folder relative to the webroot * Retrieve the combined files folder prefix
*
* @return string
*/ */
public function getCombinedFilesFolder() { public function getCombinedFilesFolder() {
return ($this->combinedFilesFolder) ? $this->combinedFilesFolder : ASSETS_DIR . '/_combinedfiles'; if($this->combinedFilesFolder) {
return $this->combinedFilesFolder;
}
return Config::inst()->get(__CLASS__, 'default_combined_files_folder');
} }
/** /**
@ -558,8 +601,8 @@ class Requirements_Backend {
* *
* @param bool * @param bool
*/ */
public function set_suffix_requirements($var) { public function setSuffixRequirements($var) {
$this->suffix_requirements = $var; $this->suffixRequirements = $var;
} }
/** /**
@ -567,8 +610,8 @@ class Requirements_Backend {
* *
* @return bool * @return bool
*/ */
public function get_suffix_requirements() { public function getSuffixRequirements() {
return $this->suffix_requirements; return $this->suffixRequirements;
} }
/** /**
@ -576,18 +619,41 @@ class Requirements_Backend {
* head tag. * head tag.
* *
* @param bool * @param bool
* @return $this
*/ */
public function set_write_js_to_body($var) { public function setWriteJavascriptToBody($var) {
$this->write_js_to_body = $var; $this->writeJavascriptToBody = $var;
return $this;
}
/**
* Check whether you want to write the JS to the body of the page rather than at the end of the
* head tag.
*
* @return bool
*/
public function getWriteJavascriptToBody() {
return $this->writeJavascriptToBody;
} }
/** /**
* Forces the JavaScript requirements to the end of the body, right before the closing tag * Forces the JavaScript requirements to the end of the body, right before the closing tag
* *
* @param bool * @param bool
* @return $this
*/ */
public function set_force_js_to_bottom($var) { public function setForceJSToBottom($var) {
$this->force_js_to_bottom = $var; $this->forceJSToBottom = $var;
return $this;
}
/**
* Check if the JavaScript requirements are written to the end of the body, right before the closing tag
*
* @return bool
*/
public function getForceJSToBottom() {
return $this->forceJSToBottom;
} }
/** /**
@ -600,23 +666,44 @@ class Requirements_Backend {
} }
/** /**
* Returns an array of all required JavaScript * Remove a javascript requirement
*
* @param string $file
*/
protected function unsetJavascript($file) {
unset($this->javascript[$file]);
}
/**
* Returns an array of required JavaScript, excluding blocked
* *
* @return array * @return array
*/ */
public function get_javascript() { public function getJavascript() {
return array_keys(array_diff_key($this->javascript, $this->blocked)); return array_keys(array_diff_key($this->javascript, $this->blocked));
} }
/**
* Gets all javascript, including blocked files. Unwraps the array into a non-associative list
*
* @return array Indexed array of javascript files
*/
protected function getAllJavascript() {
return array_keys($this->javascript);
}
/** /**
* Register the given JavaScript code into the list of requirements * Register the given JavaScript code into the list of requirements
* *
* @param string $script The script content as a string (without enclosing <script> tag) * @param string $script The script content as a string (without enclosing <script> tag)
* @param string|int $uniquenessID A unique ID that ensures a piece of code is only added once * @param string $uniquenessID A unique ID that ensures a piece of code is only added once
*/ */
public function customScript($script, $uniquenessID = null) { public function customScript($script, $uniquenessID = null) {
if($uniquenessID) $this->customScript[$uniquenessID] = $script; if($uniquenessID) {
else $this->customScript[] = $script; $this->customScript[$uniquenessID] = $script;
} else {
$this->customScript[] = $script;
}
$script .= "\n"; $script .= "\n";
} }
@ -626,47 +713,63 @@ class Requirements_Backend {
* *
* @return array * @return array
*/ */
public function get_custom_scripts() { public function getCustomScripts() {
$requirements = ""; return array_diff_key($this->customScript, $this->blocked);
if($this->customScript) {
foreach($this->customScript as $script) {
$requirements .= "$script\n";
}
}
return $requirements;
} }
/** /**
* Register the given CSS styles into the list of requirements * Register the given CSS styles into the list of requirements
* *
* @param string $script CSS selectors as a string (without enclosing <style> tag) * @param string $script CSS selectors as a string (without enclosing <style> tag)
* @param string|int $uniquenessID A unique ID that ensures a piece of code is only added once * @param string $uniquenessID A unique ID that ensures a piece of code is only added once
*/ */
public function customCSS($script, $uniquenessID = null) { public function customCSS($script, $uniquenessID = null) {
if($uniquenessID) $this->customCSS[$uniquenessID] = $script; if($uniquenessID) {
else $this->customCSS[] = $script; $this->customCSS[$uniquenessID] = $script;
} else {
$this->customCSS[] = $script;
}
}
/**
* Return all registered custom CSS
*
* @return array
*/
public function getCustomCSS() {
return array_diff_key($this->customCSS, $this->blocked);
} }
/** /**
* Add the following custom HTML code to the <head> section of the page * Add the following custom HTML code to the <head> section of the page
* *
* @param string $html Custom HTML code * @param string $html Custom HTML code
* @param string|int $uniquenessID A unique ID that ensures a piece of code is only added once * @param string $uniquenessID A unique ID that ensures a piece of code is only added once
*/ */
public function insertHeadTags($html, $uniquenessID = null) { public function insertHeadTags($html, $uniquenessID = null) {
if($uniquenessID) $this->customHeadTags[$uniquenessID] = $html; if($uniquenessID) {
else $this->customHeadTags[] = $html; $this->customHeadTags[$uniquenessID] = $html;
} else {
$this->customHeadTags[] = $html;
}
}
/**
* Return all custom head tags
*
* @return array
*/
public function getCustomHeadTags() {
return array_diff_key($this->customHeadTags, $this->blocked);
} }
/** /**
* Include the content of the given JavaScript file in the list of requirements. Dollar-sign * Include the content of the given JavaScript file in the list of requirements. Dollar-sign
* variables will be interpolated with values from $vars similar to a .ss template. * variables will be interpolated with values from $vars similar to a .ss template.
* *
* @param string $file The template file to load, relative to docroot * @param string $file The template file to load, relative to docroot
* @param string[]|int[] $vars The array of variables to interpolate. * @param string[] $vars The array of variables to interpolate.
* @param string|int $uniquenessID A unique ID that ensures a piece of code is only added once * @param string $uniquenessID A unique ID that ensures a piece of code is only added once
*/ */
public function javascriptTemplate($file, $vars, $uniquenessID = null) { public function javascriptTemplate($file, $vars, $uniquenessID = null) {
$script = file_get_contents(Director::getAbsFile($file)); $script = file_get_contents(Director::getAbsFile($file));
@ -685,7 +788,7 @@ class Requirements_Backend {
/** /**
* Register the given stylesheet into the list of requirements. * Register the given stylesheet into the list of requirements.
* *
* @param string $file The CSS file to load, relative to site root * @param string $file The CSS file to load, relative to site root
* @param string $media Comma-separated list of media types to use in the link tag * @param string $media Comma-separated list of media types to use in the link tag
* (e.g. 'screen,projector') * (e.g. 'screen,projector')
*/ */
@ -695,13 +798,40 @@ class Requirements_Backend {
); );
} }
/**
* Remove a css requirement
*
* @param string $file
*/
protected function unsetCSS($file) {
unset($this->css[$file]);
}
/** /**
* Get the list of registered CSS file requirements, excluding blocked files * Get the list of registered CSS file requirements, excluding blocked files
* *
* @return array Associative array of file to spec
*/
public function getCSS() {
return array_diff_key($this->css, $this->blocked);
}
/**
* Gets all CSS files requirements, including blocked
*
* @return array Associative array of file to spec
*/
protected function getAllCSS() {
return $this->css;
}
/**
* Gets the list of all blocked files
*
* @return array * @return array
*/ */
public function get_css() { public function getBlocked() {
return array_diff_key($this->css, $this->blocked); return $this->blocked;
} }
/** /**
@ -745,6 +875,7 @@ class Requirements_Backend {
$this->customCSS = $this->disabled['customCSS']; $this->customCSS = $this->disabled['customCSS'];
$this->customHeadTags = $this->disabled['customHeadTags']; $this->customHeadTags = $this->disabled['customHeadTags'];
} }
/** /**
* Block inclusion of a specific file * Block inclusion of a specific file
* *
@ -768,13 +899,13 @@ class Requirements_Backend {
* @param string|int $fileOrID * @param string|int $fileOrID
*/ */
public function unblock($fileOrID) { public function unblock($fileOrID) {
if(isset($this->blocked[$fileOrID])) unset($this->blocked[$fileOrID]); unset($this->blocked[$fileOrID]);
} }
/** /**
* Removes all items from the block list * Removes all items from the block list
*/ */
public function unblock_all() { public function unblockAll() {
$this->blocked = array(); $this->blocked = array();
} }
@ -797,26 +928,24 @@ class Requirements_Backend {
$jsRequirements = ''; $jsRequirements = '';
// Combine files - updates $this->javascript and $this->css // Combine files - updates $this->javascript and $this->css
$this->process_combined_files(); $this->processCombinedFiles();
foreach(array_diff_key($this->javascript,$this->blocked) as $file => $dummy) { foreach($this->getJavascript() as $file) {
$path = Convert::raw2xml($this->path_for_file($file)); $path = Convert::raw2xml($this->pathForFile($file));
if($path) { if($path) {
$jsRequirements .= "<script type=\"text/javascript\" src=\"$path\"></script>\n"; $jsRequirements .= "<script type=\"text/javascript\" src=\"$path\"></script>\n";
} }
} }
// Add all inline JavaScript *after* including external files they might rely on // Add all inline JavaScript *after* including external files they might rely on
if($this->customScript) { foreach($this->getCustomScripts() as $script) {
foreach(array_diff_key($this->customScript,$this->blocked) as $script) { $jsRequirements .= "<script type=\"text/javascript\">\n//<![CDATA[\n";
$jsRequirements .= "<script type=\"text/javascript\">\n//<![CDATA[\n"; $jsRequirements .= "$script\n";
$jsRequirements .= "$script\n"; $jsRequirements .= "\n//]]>\n</script>\n";
$jsRequirements .= "\n//]]>\n</script>\n";
}
} }
foreach(array_diff_key($this->css,$this->blocked) as $file => $params) { foreach($this->getCSS() as $file => $params) {
$path = Convert::raw2xml($this->path_for_file($file)); $path = Convert::raw2xml($this->pathForFile($file));
if($path) { if($path) {
$media = (isset($params['media']) && !empty($params['media'])) $media = (isset($params['media']) && !empty($params['media']))
? " media=\"{$params['media']}\"" : ""; ? " media=\"{$params['media']}\"" : "";
@ -824,15 +953,15 @@ class Requirements_Backend {
} }
} }
foreach(array_diff_key($this->customCSS, $this->blocked) as $css) { foreach($this->getCustomCSS() as $css) {
$requirements .= "<style type=\"text/css\">\n$css\n</style>\n"; $requirements .= "<style type=\"text/css\">\n$css\n</style>\n";
} }
foreach(array_diff_key($this->customHeadTags,$this->blocked) as $customHeadTag) { foreach($this->getCustomHeadTags() as $customHeadTag) {
$requirements .= "$customHeadTag\n"; $requirements .= "$customHeadTag\n";
} }
if ($this->force_js_to_bottom) { if ($this->getForceJSToBottom()) {
// Remove all newlines from code to preserve layout // Remove all newlines from code to preserve layout
$jsRequirements = preg_replace('/>\n*/', '>', $jsRequirements); $jsRequirements = preg_replace('/>\n*/', '>', $jsRequirements);
@ -842,7 +971,7 @@ class Requirements_Backend {
// Put CSS at the bottom of the head // Put CSS at the bottom of the head
$content = preg_replace("/(<\/head>)/i", $requirements . "\\1", $content); $content = preg_replace("/(<\/head>)/i", $requirements . "\\1", $content);
} elseif($this->write_js_to_body) { } elseif($this->getWriteJavascriptToBody()) {
// Remove all newlines from code to preserve layout // Remove all newlines from code to preserve layout
$jsRequirements = preg_replace('/>\n*/', '>', $jsRequirements); $jsRequirements = preg_replace('/>\n*/', '>', $jsRequirements);
@ -884,13 +1013,13 @@ class Requirements_Backend {
* *
* @param SS_HTTPResponse $response * @param SS_HTTPResponse $response
*/ */
public function include_in_response(SS_HTTPResponse $response) { public function includeInResponse(SS_HTTPResponse $response) {
$this->process_combined_files(); $this->processCombinedFiles();
$jsRequirements = array(); $jsRequirements = array();
$cssRequirements = array(); $cssRequirements = array();
foreach(array_diff_key($this->javascript, $this->blocked) as $file => $dummy) { foreach($this->getJavascript() as $file) {
$path = $this->path_for_file($file); $path = $this->pathForFile($file);
if($path) { if($path) {
$jsRequirements[] = str_replace(',', '%2C', $path); $jsRequirements[] = str_replace(',', '%2C', $path);
} }
@ -898,8 +1027,8 @@ class Requirements_Backend {
$response->addHeader('X-Include-JS', implode(',', $jsRequirements)); $response->addHeader('X-Include-JS', implode(',', $jsRequirements));
foreach(array_diff_key($this->css,$this->blocked) as $file => $params) { foreach($this->getCSS() as $file => $params) {
$path = $this->path_for_file($file); $path = $this->pathForFile($file);
if($path) { if($path) {
$path = str_replace(',', '%2C', $path); $path = str_replace(',', '%2C', $path);
$cssRequirements[] = isset($params['media']) ? "$path:##:$params[media]" : $path; $cssRequirements[] = isset($params['media']) ? "$path:##:$params[media]" : $path;
@ -948,13 +1077,17 @@ class Requirements_Backend {
} }
} else { } else {
// Stub i18n implementation for when i18n is disabled. // Stub i18n implementation for when i18n is disabled.
if(!$langOnly) $files[] = FRAMEWORK_DIR . '/javascript/i18nx.js'; if(!$langOnly) {
$files[] = FRAMEWORK_DIR . '/javascript/i18nx.js';
}
} }
if($return) { if($return) {
return $files; return $files;
} else { } else {
foreach($files as $file) $this->javascript($file); foreach($files as $file) {
$this->javascript($file);
}
} }
} }
@ -964,15 +1097,16 @@ class Requirements_Backend {
* @param string $fileOrUrl * @param string $fileOrUrl
* @return string|bool * @return string|bool
*/ */
protected function path_for_file($fileOrUrl) { protected function pathForFile($fileOrUrl) {
if(preg_match('{^//|http[s]?}', $fileOrUrl)) { // Since combined urls could be root relative, treat them as urls here.
if(preg_match('{^(//)|(http[s]?:)}', $fileOrUrl) || Director::is_root_relative_url($fileOrUrl)) {
return $fileOrUrl; return $fileOrUrl;
} elseif(Director::fileExists($fileOrUrl)) { } elseif(Director::fileExists($fileOrUrl)) {
$filePath = preg_replace('/\?.*/', '', Director::baseFolder() . '/' . $fileOrUrl); $filePath = preg_replace('/\?.*/', '', Director::baseFolder() . '/' . $fileOrUrl);
$prefix = Director::baseURL(); $prefix = Director::baseURL();
$mtimesuffix = ""; $mtimesuffix = "";
$suffix = ''; $suffix = '';
if($this->suffix_requirements) { if($this->getSuffixRequirements()) {
$mtimesuffix = "?m=" . filemtime($filePath); $mtimesuffix = "?m=" . filemtime($filePath);
$suffix = '&'; $suffix = '&';
} }
@ -996,13 +1130,10 @@ class Requirements_Backend {
* increases performance by fewer HTTP requests. * increases performance by fewer HTTP requests.
* *
* The combined file is regenerated based on every file modification time. Optionally a * The combined file is regenerated based on every file modification time. Optionally a
* rebuild can be triggered by appending ?flush=1 to the URL. If all files to be combined are * rebuild can be triggered by appending ?flush=1 to the URL.
* JavaScript, we use the external JSMin library to minify the JavaScript. This can be
* controlled using {@link $combine_js_with_jsmin}.
* *
* All combined files will have a comment on the start of each concatenated file denoting their * All combined files will have a comment on the start of each concatenated file denoting their
* original position. For easier debugging, we only minify JavaScript if not in development * original position.
* mode ({@link Director::isDev()}).
* *
* CAUTION: You're responsible for ensuring that the load order for combined files is * CAUTION: You're responsible for ensuring that the load order for combined files is
* retained - otherwise combining JavaScript files can lead to functional errors in the * retained - otherwise combining JavaScript files can lead to functional errors in the
@ -1034,269 +1165,275 @@ class Requirements_Backend {
* </code> * </code>
* *
* @param string $combinedFileName Filename of the combined file relative to docroot * @param string $combinedFileName Filename of the combined file relative to docroot
* @param array $files Array of filenames relative to docroot * @param array $files Array of filenames relative to docroot
* @param string $media * @param string $media If including CSS Files, you can specify a media type
*
* @return bool|void
*/ */
public function combine_files($combinedFileName, $files, $media = null) { public function combineFiles($combinedFileName, $files, $media = null) {
// duplicate check // Skip this combined files if already included
foreach($this->combine_files as $_combinedFileName => $_files) { if(isset($this->combinedFiles[$combinedFileName])) {
$duplicates = array_intersect($_files, $files); return;
if($duplicates && $combinedFileName != $_combinedFileName) { }
user_error("Requirements_Backend::combine_files(): Already included files " . implode(',', $duplicates)
. " in combined file '{$_combinedFileName}'", E_USER_NOTICE); // Add all files to necessary type list
return false; $paths = array();
$combinedType = null;
foreach($files as $file) {
// Get file details
list($path, $type) = $this->parseCombinedFile($file);
if($type === 'javascript') {
$type = 'js';
}
if($combinedType && $type && $combinedType !== $type) {
throw new InvalidArgumentException(
"Cannot mix js and css files in same combined file {$combinedFileName}"
);
}
switch($type) {
case 'css':
$this->css($path, $media);
break;
case 'js':
$this->javascript($path);
break;
default:
throw new InvalidArgumentException("Invalid combined file type: {$type}");
}
$combinedType = $type;
$paths[] = $path;
}
// Duplicate check
foreach($this->combinedFiles as $existingCombinedFilename => $combinedItem) {
$existingFiles = $combinedItem['files'];
$duplicates = array_intersect($existingFiles, $paths);
if($duplicates) {
throw new InvalidArgumentException(sprintf(
"Requirements_Backend::combine_files(): Already included file(s) %s in combined file '%s'",
implode(',', $duplicates),
$existingCombinedFilename
));
} }
} }
foreach($files as $index=>$file) {
if(is_array($file)) { $this->combinedFiles[$combinedFileName] = array(
// Either associative array path=>path type=>type or numeric 0=>path 1=>type 'files' => $paths,
// Otherwise, assume path is the first item 'type' => $combinedType,
if (isset($file['type']) && in_array($file['type'], array('css', 'javascript', 'js'))) { 'media' => $media
switch ($file['type']) { );
case 'css': }
$this->css($file['path'], $media);
break; /**
default: * Return path and type of given combined file
$this->javascript($file['path']); *
break; * @param string|array $file Either a file path, or an array spec
} * @return array array with two elements, path and type of file
$files[$index] = $file['path']; */
} elseif (isset($file[1]) && in_array($file[1], array('css', 'javascript', 'js'))) { protected function parseCombinedFile($file) {
switch ($file[1]) { // Array with path and type keys
case 'css': if(is_array($file) && isset($file['path']) && isset($file['type'])) {
$this->css($file[0], $media); return array($file['path'], $file['type']);
break;
default:
$this->javascript($file[0]);
break;
}
$files[$index] = $file[0];
} else {
$file = array_shift($file);
}
}
if (!is_array($file)) {
if(substr($file, -2) == 'js') {
$this->javascript($file);
} elseif(substr($file, -3) == 'css') {
$this->css($file, $media);
} else {
user_error("Requirements_Backend::combine_files(): Couldn't guess file type for file '$file', "
. "please specify by passing using an array instead.", E_USER_NOTICE);
}
}
} }
$this->combine_files[$combinedFileName] = $files;
// Extract value from indexed array
if(is_array($file)) {
$path = array_shift($file);
// See if there's a type specifier
if($file) {
$type = array_shift($file);
return array($path, $type);
}
// Otherwise convent to string
$file = $path;
}
$type = File::get_file_extension($file);
return array($file, $type);
} }
/** /**
* Return all combined files; keys are the combined file names, values are lists of * Return all combined files; keys are the combined file names, values are lists of
* files being combined. * associative arrays with 'files', 'type', and 'media' keys for details about this
* combined file.
* *
* @return array * @return array
*/ */
public function get_combine_files() { public function getCombinedFiles() {
return $this->combine_files; return array_diff_key($this->combinedFiles, $this->blocked);
} }
/** /**
* Delete all dynamically generated combined files from the filesystem * Includes all combined files, including blocked ones
* *
* @param string $combinedFileName If left blank, all combined files are deleted. * @return type
*/ */
public function delete_combined_files($combinedFileName = null) { protected function getAllCombinedFiles() {
$combinedFiles = ($combinedFileName) ? array($combinedFileName => null) : $this->combine_files; return $this->combinedFiles;
$combinedFolder = ($this->getCombinedFilesFolder()) ?
(Director::baseFolder() . '/' . $this->combinedFilesFolder) : Director::baseFolder();
foreach($combinedFiles as $combinedFile => $sourceItems) {
$filePath = $combinedFolder . '/' . $combinedFile;
if(file_exists($filePath)) {
unlink($filePath);
}
}
}
/**
* Deletes all generated combined files in the configured combined files directory,
* but doesn't delete the directory itself.
*/
public function delete_all_combined_files() {
$combinedFolder = $this->getCombinedFilesFolder();
if(!$combinedFolder) return false;
$path = Director::baseFolder() . '/' . $combinedFolder;
if(file_exists($path)) {
Filesystem::removeFolder($path, true);
}
} }
/** /**
* Clear all registered CSS and JavaScript file combinations * Clear all registered CSS and JavaScript file combinations
*/ */
public function clear_combined_files() { public function clearCombinedFiles() {
$this->combine_files = array(); $this->combinedFiles = array();
} }
/** /**
* Do the heavy lifting involved in combining (and, in the case of JavaScript minifying) the * Do the heavy lifting involved in combining the combined files.
* combined files.
*/ */
public function process_combined_files() { public function processCombinedFiles() {
// The class_exists call prevents us loading SapphireTest.php (slow) just to know that // Check if combining is enabled
// SapphireTest isn't running :-) if(!$this->enabledCombinedFiles()) {
if(class_exists('SapphireTest', false)) $runningTest = SapphireTest::is_running_test();
else $runningTest = false;
if((Director::isDev() && !$runningTest && !isset($_REQUEST['combine'])) || !$this->combined_files_enabled) {
return; return;
} }
// Make a map of files that could be potentially combined // Process each combined files
$combinerCheck = array(); foreach($this->getAllCombinedFiles() as $combinedFile => $combinedItem) {
foreach($this->combine_files as $combinedFile => $sourceItems) { $fileList = $combinedItem['files'];
foreach($sourceItems as $sourceItem) { $type = $combinedItem['type'];
if(isset($combinerCheck[$sourceItem]) && $combinerCheck[$sourceItem] != $combinedFile){ $media = $combinedItem['media'];
user_error("Requirements_Backend::process_combined_files - file '$sourceItem' appears in two " .
"combined files:" . " '{$combinerCheck[$sourceItem]}' and '$combinedFile'", E_USER_WARNING);
}
$combinerCheck[$sourceItem] = $combinedFile;
} // Generate this file, unless blocked
} $combinedURL = null;
if(!isset($this->blocked[$combinedFile])) {
// Work out the relative URL for the combined files from the base folder $combinedURL = $this->getCombinedFileURL($combinedFile, $fileList);
$combinedFilesFolder = ($this->getCombinedFilesFolder()) ? ($this->getCombinedFilesFolder() . '/') : '';
// Figure out which ones apply to this request
$combinedFiles = array();
$newJSRequirements = array();
$newCSSRequirements = array();
foreach($this->javascript as $file => $dummy) {
if(isset($combinerCheck[$file])) {
$newJSRequirements[$combinedFilesFolder . $combinerCheck[$file]] = true;
$combinedFiles[$combinerCheck[$file]] = true;
} else {
$newJSRequirements[$file] = true;
}
}
foreach($this->css as $file => $params) {
if(isset($combinerCheck[$file])) {
// Inherit the parameters from the last file in the combine set.
$newCSSRequirements[$combinedFilesFolder . $combinerCheck[$file]] = $params;
$combinedFiles[$combinerCheck[$file]] = true;
} else {
$newCSSRequirements[$file] = $params;
}
}
// Process the combined files
$base = Director::baseFolder() . '/';
foreach(array_diff_key($combinedFiles, $this->blocked) as $combinedFile => $dummy) {
$fileList = $this->combine_files[$combinedFile];
$combinedFilePath = $base . $combinedFilesFolder . '/' . $combinedFile;
// Make the folder if necessary
if(!file_exists(dirname($combinedFilePath))) {
Filesystem::makeFolder(dirname($combinedFilePath));
} }
// If the file isn't writeable, don't even bother trying to make the combined file and return. The // Replace all existing files, injecting the combined file at the position of the first item
// files will be included individually instead. This is a complex test because is_writable fails // in order to preserve inclusion order.
// if the file doesn't exist yet. // Note that we iterate across blocked files in order to get the correct order, and validate
if((file_exists($combinedFilePath) && !is_writable($combinedFilePath)) // that the file is included in the correct location (regardless of which files are blocked).
|| (!file_exists($combinedFilePath) && !is_writable(dirname($combinedFilePath))) $included = false;
) { switch($type) {
user_error("Requirements_Backend::process_combined_files(): Couldn't create '$combinedFilePath'", case 'css': {
E_USER_WARNING); $newCSS = array(); // Assoc array of css file => spec
return false; foreach($this->getAllCSS() as $css => $spec) {
} if(!in_array($css, $fileList)) {
$newCSS[$css] = $spec;
// Determine if we need to build the combined include } elseif(!$included && $combinedURL) {
if(file_exists($combinedFilePath)) { $newCSS[$combinedURL] = array('media' => $media);
// file exists, check modification date of every contained file $included = true;
$srcLastMod = 0; }
foreach($fileList as $file) { // If already included, or otherwise blocked, then don't add into CSS
if(file_exists($base . $file)) {
$srcLastMod = max(filemtime($base . $file), $srcLastMod);
} }
$this->css = $newCSS;
break;
} }
$refresh = $srcLastMod > filemtime($combinedFilePath); case 'js': {
} else { // Assoc array of file => true
// File doesn't exist, or refresh was explicitly required $newJS = array();
$refresh = true; foreach($this->getAllJavascript() as $script) {
} if(!in_array($script, $fileList)) {
$newJS[$script] = true;
if(!$refresh) continue; } elseif(!$included && $combinedURL) {
$newJS[$combinedURL] = true;
$failedToMinify = false; $included = true;
$combinedData = ""; }
foreach(array_diff($fileList, $this->blocked) as $file) { // If already included, or otherwise blocked, then don't add into scripts
$fileContent = file_get_contents($base . $file); }
$this->javascript = $newJS;
try{ break;
$fileContent = $this->minifyFile($file, $fileContent);
}catch(Exception $e){
$failedToMinify = true;
} }
if ($this->write_header_comment) {
// Write a header comment for each file for easier identification and debugging. The semicolon between each file is required for jQuery to be combined properly and protects against unterminated statements.
$combinedData .= "/****** FILE: $file *****/\n";
}
$combinedData .= $fileContent . "\n";
} }
if($combinedURL && !$included) {
$successfulWrite = false; throw new Exception("Failed to merge combined file {$combinedFile} with existing requirements");
$fh = fopen($combinedFilePath, 'wb');
if($fh) {
if(fwrite($fh, $combinedData) == strlen($combinedData)) $successfulWrite = true;
fclose($fh);
unset($fh);
}
if($failedToMinify){
// Failed to minify, use unminified files instead. This warning is raised at the end to allow code execution
// to complete in case this warning is caught inside a try-catch block.
user_error('Failed to minify '.$file.', exception: '.$e->getMessage(), E_USER_WARNING);
}
// Unsuccessful write - just include the regular JS files, rather than the combined one
if(!$successfulWrite) {
user_error("Requirements_Backend::process_combined_files(): Couldn't create '$combinedFilePath'",
E_USER_WARNING);
continue;
} }
} }
// Note: Alters the original information, which means you can't call this method repeatedly - it will behave
// differently on the subsequent calls
$this->javascript = $newJSRequirements;
$this->css = $newCSSRequirements;
} }
/** /**
* Minify the given $content according to the file type indicated in $filename * Given a set of files, combine them (as necessary) and return the url
* *
* @param string $filename * @param string $combinedFile Filename for this combined file
* @param string $content * @param array $fileList List of files to combine
* @return string * @return string URL to this resource
*/ */
protected function minifyFile($filename, $content) { protected function getCombinedFileURL($combinedFile, $fileList) {
// if we have a javascript file and jsmin is enabled, minify the content // Generate path (Filename)
$isJS = stripos($filename, '.js'); $combinedFileID = File::join_paths($this->getCombinedFilesFolder(), $combinedFile);
if($isJS && $this->combine_js_with_jsmin) {
require_once('thirdparty/jsmin/jsmin.php');
increase_time_limit_to(); // Get entropy for this combined file (last modified date of most recent file)
$content = JSMin::minify($content); $entropy = $this->getEntropyOfFiles($fileList);
// Send file combination request to the backend, with an optional callback to perform regeneration
$combinedURL = $this
->getAssetHandler()
->getGeneratedURL(
$combinedFileID,
$entropy,
function() use ($fileList) {
$combinedData = '';
$base = Director::baseFolder() . '/';
foreach(array_diff($fileList, $this->getBlocked()) as $file) {
$fileContent = file_get_contents($base . $file);
if ($this->writeHeaderComment) {
// Write a header comment for each file for easier identification and debugging.
$combinedData .= "/****** FILE: $file *****/\n";
}
$combinedData .= $fileContent . "\n";
}
return $combinedData;
}
);
// Since url won't be automatically suffixed, add it in here
if($this->getSuffixRequirements()) {
$q = stripos($combinedURL, '?') === false ? '?' : '&';
$combinedURL .= "{$q}m={$entropy}";
} }
$content .= ($isJS ? ';' : '') . "\n";
return $content; return $combinedURL;
}
/**
* Check if combined files are enabled
*
* @return bool
*/
protected function enabledCombinedFiles() {
if(!$this->combinedFilesEnabled) {
return false;
}
// Tests should be combined
if(class_exists('SapphireTest', false) && SapphireTest::is_running_test()) {
return true;
}
// Check if specified via querystring
if(isset($_REQUEST['combine'])) {
return true;
}
// Non-dev sites are always combined
if(!Director::isDev()) {
return true;
}
// Fallback to default
return Config::inst()->get(__CLASS__, 'combine_in_dev');
}
/**
* For a given filelist, determine some discriminating value to determine if
* any of these files have changed.
*
* @param array $fileList List of files
* @return int Last modified timestamp of these files
*/
protected function getEntropyOfFiles($fileList) {
// file exists, check modification date of every contained file
$base = Director::baseFolder() . '/';
$srcLastMod = 0;
foreach($fileList as $file) {
if(file_exists($base . $file)) {
$srcLastMod = max(filemtime($base . $file), $srcLastMod);
} else {
throw new InvalidArgumentException("Combined file {$file} does not exist");
}
}
return $srcLastMod;
} }
/** /**
@ -1340,7 +1477,7 @@ class Requirements_Backend {
Debug::show($this->customCSS); Debug::show($this->customCSS);
Debug::show($this->customScript); Debug::show($this->customScript);
Debug::show($this->customHeadTags); Debug::show($this->customHeadTags);
Debug::show($this->combine_files); Debug::show($this->combinedFiles);
} }
} }