array(provided filepaths) */ protected $providedJavascript = array(); /** * Paths to all required CSS files relative to the docroot. * * @var array */ protected $css = array(); /** * All custom javascript code that is inserted into the page's HTML * * @var array */ protected $customScript = array(); /** * All custom CSS rules which are inserted directly at the bottom of the HTML
tag * * @var array */ protected $customCSS = array(); /** * All custom HTML markup which is added before the closing tag, e.g. additional * metatags. * * @var array */ protected $customHeadTags = array(); /** * Remembers the file paths or uniquenessIDs of all Requirements cleared through * {@link clear()}, so that they can be restored later. * * @var array */ protected $disabled = array(); /** * The file paths (relative to docroot) or uniquenessIDs of any included requirements which * should be blocked when executing {@link inlcudeInHTML()}. This is useful, for example, * to block scripts included by a superclass without having to override entire functions and * duplicate a lot of code. * * Use {@link unblock()} or {@link unblock_all()} to revert changes. * * @var array */ protected $blocked = array(); /** * A list of combined files registered via {@link combine_files()}. Keys are the output file * names, values are lists of input files. * * @var array */ protected $combinedFiles = array(); /** * Use the injected minification service to minify any javascript file passed to {@link combine_files()}. * * @var bool */ protected $minifyCombinedFiles = false; /** * Whether or not file headers should be written when combining files * * @var boolean */ protected $writeHeaderComment = true; /** * Where to save combined files. By default they're placed in assets/_combinedfiles, however * this may be an issue depending on your setup, especially for CSS files which often contain * relative paths. * * @var string */ protected $combinedFilesFolder = null; /** * Put all JavaScript includes at the bottom of the template before the closing tag, * rather than the default behaviour of placing them at the end of the tag. This means * script downloads won't block other HTTP requests, which can be a performance improvement. * * @var bool */ public $writeJavascriptToBody = true; /** * Force the JavaScript to the bottom of the page, even if there's a script tag in the body already * * @var boolean */ protected $forceJSToBottom = false; /** * Configures the default prefix for combined files. * * This defaults to `_combinedfiles`, and is the folder within the configured asset backend that * combined files will be stored in. If using a backend shared with other systems, it is usually * necessary to distinguish combined files from other assets. * * @config * @var string */ private static $default_combined_files_folder = '_combinedfiles'; /** * Flag to include the hash in the querystring instead of the filename for combined files. * * By default the `
* Requirements::combine_files(
* 'foobar.js',
* array(
* 'mysite/javascript/foo.js',
* 'mysite/javascript/bar.js',
* ),
* array(
* 'async' => true,
* 'defer' => true,
* )
* );
*
*
* Example for combined CSS:
*
* Requirements::combine_files(
* 'foobar.css',
* array(
* 'mysite/javascript/foo.css',
* 'mysite/javascript/bar.css',
* ),
* array(
* 'media' => 'print',
* )
* );
*
*
* @param string $combinedFileName Filename of the combined file relative to docroot
* @param array $files Array of filenames relative to docroot
* @param array $options Array of options for combining files. Available options are:
* - 'media' : If including CSS Files, you can specify a media type
* - 'async' : If including JavaScript Files, boolean value to set async attribute to script tag
* - 'defer' : If including JavaScript Files, boolean value to set defer attribute to script tag
*/
public function combineFiles($combinedFileName, $files, $options = array())
{
if (is_string($options)) {
Deprecation::notice('4.0', 'Parameter media is deprecated. Use options array instead.');
$options = array('media' => $options);
}
// Skip this combined files if already included
if (isset($this->combinedFiles[$combinedFileName])) {
return;
}
// Add all files to necessary type list
$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, (isset($options['media']) ? $options['media'] : null));
break;
case 'js':
$this->javascript($path, $options);
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
));
}
}
$this->combinedFiles[$combinedFileName] = array(
'files' => $paths,
'type' => $combinedType,
'options' => $options,
);
}
/**
* Return path and type of given combined file
*
* @param string|array $file Either a file path, or an array spec
* @return array array with two elements, path and type of file
*/
protected function parseCombinedFile($file)
{
// Array with path and type keys
if (is_array($file) && isset($file['path']) && isset($file['type'])) {
return array($file['path'], $file['type']);
}
// 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
* associative arrays with 'files', 'type', and 'media' keys for details about this
* combined file.
*
* @return array
*/
public function getCombinedFiles()
{
return array_diff_key($this->combinedFiles, $this->blocked);
}
/**
* Includes all combined files, including blocked ones
*
* @return array
*/
protected function getAllCombinedFiles()
{
return $this->combinedFiles;
}
/**
* Clears all combined files
*/
public function deleteAllCombinedFiles()
{
$combinedFolder = $this->getCombinedFilesFolder();
if ($combinedFolder) {
$this->getAssetHandler()->removeContent($combinedFolder);
}
}
/**
* Clear all registered CSS and JavaScript file combinations
*/
public function clearCombinedFiles()
{
$this->combinedFiles = array();
}
/**
* Do the heavy lifting involved in combining the combined files.
*/
public function processCombinedFiles()
{
// Check if combining is enabled
if (!$this->getCombinedFilesEnabled()) {
return;
}
// Before scripts are modified, detect files that are provided by preceding ones
$providedScripts = $this->getProvidedScripts();
// Process each combined files
foreach ($this->getAllCombinedFiles() as $combinedFile => $combinedItem) {
$fileList = $combinedItem['files'];
$type = $combinedItem['type'];
$options = $combinedItem['options'];
// Generate this file, unless blocked
$combinedURL = null;
if (!isset($this->blocked[$combinedFile])) {
// Filter files for blocked / provided
$filteredFileList = array_diff(
$fileList,
$this->getBlocked(),
$providedScripts
);
$combinedURL = $this->getCombinedFileURL($combinedFile, $filteredFileList, $type);
}
// Replace all existing files, injecting the combined file at the position of the first item
// in order to preserve inclusion order.
// Note that we iterate across blocked files in order to get the correct order, and validate
// that the file is included in the correct location (regardless of which files are blocked).
$included = false;
switch ($type) {
case 'css': {
$newCSS = array(); // Assoc array of css file => spec
foreach ($this->getAllCSS() as $css => $spec) {
if (!in_array($css, $fileList)) {
$newCSS[$css] = $spec;
} elseif (!$included && $combinedURL) {
$newCSS[$combinedURL] = array('media' => (isset($options['media']) ? $options['media'] : null));
$included = true;
}
// If already included, or otherwise blocked, then don't add into CSS
}
$this->css = $newCSS;
break;
}
case 'js': {
// Assoc array of file => attributes
$newJS = array();
foreach ($this->getAllJavascript() as $script => $attributes) {
if (!in_array($script, $fileList)) {
$newJS[$script] = $attributes;
} elseif (!$included && $combinedURL) {
$newJS[$combinedURL] = $options;
$included = true;
}
// If already included, or otherwise blocked, then don't add into scripts
}
$this->javascript = $newJS;
break;
}
}
}
}
/**
* Given a set of files, combine them (as necessary) and return the url
*
* @param string $combinedFile Filename for this combined file
* @param array $fileList List of files to combine
* @param string $type Either 'js' or 'css'
* @return string|null URL to this resource, if there are files to combine
*/
protected function getCombinedFileURL($combinedFile, $fileList, $type)
{
// Skip empty lists
if (empty($fileList)) {
return null;
}
// Generate path (Filename)
$hashQuerystring = Config::inst()->get(static::class, 'combine_hash_querystring');
if (!$hashQuerystring) {
$combinedFile = $this->hashedCombinedFilename($combinedFile, $fileList);
}
$combinedFileID = File::join_paths($this->getCombinedFilesFolder(), $combinedFile);
// Send file combination request to the backend, with an optional callback to perform regeneration
$minify = $this->getMinifyCombinedFiles();
if ($minify && !$this->minifier) {
throw new Exception(
sprintf(
'Cannot minify files without a minification service defined.
Set %s::minifyCombinedFiles to false, or inject a %s service on
%s.properties.minifier',
__CLASS__,
Requirements_Minifier::class,
__CLASS__
)
);
}
$combinedURL = $this
->getAssetHandler()
->getContentURL(
$combinedFileID,
function () use ($fileList, $minify, $type) {
// Physically combine all file content
$combinedData = '';
$base = Director::baseFolder() . '/';
foreach ($fileList as $file) {
$fileContent = file_get_contents($base . $file);
// Use configured minifier
if ($minify) {
$fileContent = $this->minifier->minify($fileContent, $type, $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;
}
);
// If the name isn't hashed, we will need to append the querystring m= parameter instead
// Since url won't be automatically suffixed, add it in here
if ($hashQuerystring && $this->getSuffixRequirements()) {
$hash = $this->hashOfFiles($fileList);
$q = stripos($combinedURL, '?') === false ? '?' : '&';
$combinedURL .= "{$q}m={$hash}";
}
return $combinedURL;
}
/**
* Given a filename and list of files, generate a new filename unique to these files
*
* @param string $combinedFile
* @param array $fileList
* @return string
*/
protected function hashedCombinedFilename($combinedFile, $fileList)
{
$name = pathinfo($combinedFile, PATHINFO_FILENAME);
$hash = $this->hashOfFiles($fileList);
$extension = File::get_file_extension($combinedFile);
return $name . '-' . substr($hash, 0, 7) . '.' . $extension;
}
/**
* Check if combined files are enabled
*
* @return bool
*/
public function getCombinedFilesEnabled()
{
if (!$this->combinedFilesEnabled) {
return false;
}
// Tests should be combined
if (class_exists('SilverStripe\\Dev\\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 string SHA1 bashed file hash
*/
protected function hashOfFiles($fileList)
{
// Get hash based on hash of each file
$base = Director::baseFolder() . '/';
$hash = '';
foreach ($fileList as $file) {
if (file_exists($base . $file)) {
$hash .= sha1_file($base . $file);
} else {
throw new InvalidArgumentException("Combined file {$file} does not exist");
}
}
return sha1($hash);
}
/**
* Registers the given themeable stylesheet as required.
*
* A CSS file in the current theme path name 'themename/css/$name.css' is first searched for,
* and it that doesn't exist and the module parameter is set then a CSS file with that name in
* the module is used.
*
* @param string $name The name of the file - eg '/css/File.css' would have the name 'File'
* @param string $media Comma-separated list of media types to use in the link tag
* (e.g. 'screen,projector')
*/
public function themedCSS($name, $media = null)
{
$path = ThemeResourceLoader::instance()->findThemedCSS($name, SSViewer::get_themes());
if ($path) {
$this->css($path, $media);
} else {
throw new \InvalidArgumentException(
"The css file doesn't exist. Please check if the file $name.css exists in any context or search for "
. "themedCSS references calling this file in your templates."
);
}
}
/**
* Registers the given themeable javascript as required.
*
* A javascript file in the current theme path name 'themename/javascript/$name.js' is first searched for,
* and it that doesn't exist and the module parameter is set then a javascript file with that name in
* the module is used.
*
* @param string $name The name of the file - eg '/js/File.js' would have the name 'File'
* @param string $type Comma-separated list of types to use in the script tag
* (e.g. 'text/javascript,text/ecmascript')
*/
public function themedJavascript($name, $type = null)
{
$path = ThemeResourceLoader::instance()->findThemedJavascript($name, SSViewer::get_themes());
if ($path) {
$opts = [];
if ($type) {
$opts['type'] = $type;
}
$this->javascript($path, $opts);
} else {
throw new \InvalidArgumentException(
"The javascript file doesn't exist. Please check if the file $name.js exists in any "
. "context or search for themedJavascript references calling this file in your templates."
);
}
}
/**
* Output debugging information.
*/
public function debug()
{
Debug::show($this->javascript);
Debug::show($this->css);
Debug::show($this->customCSS);
Debug::show($this->customScript);
Debug::show($this->customHeadTags);
Debug::show($this->combinedFiles);
}
}