get(static::class, 'disable_flush_combined'); if(!$disabled) { self::delete_all_combined_files(); } } /** * Enable combining of css/javascript files. * * @param bool $enable */ public static function set_combined_files_enabled($enable) { self::backend()->setCombinedFilesEnabled($enable); } /** * Checks whether combining of css/javascript files is enabled. * * @return bool */ public static function get_combined_files_enabled() { return self::backend()->getCombinedFilesEnabled(); } /** * Set the relative folder e.g. 'assets' for where to store combined files * * @param string $folder Path to folder */ public static function set_combined_files_folder($folder) { self::backend()->setCombinedFilesFolder($folder); } /** * Set whether to add caching query params to the requests for file-based requirements. * Eg: themes/myTheme/js/main.js?m=123456789. The parameter is a timestamp generated by * filemtime. This has the benefit of allowing the browser to cache the URL infinitely, * while automatically busting this cache every time the file is changed. * * @param bool */ public static function set_suffix_requirements($var) { self::backend()->setSuffixRequirements($var); } /** * Check whether we want to suffix requirements * * @return bool */ public static function get_suffix_requirements() { return self::backend()->getSuffixRequirements(); } /** * Instance of the requirements for storage. You can create your own backend to change the * default JS and CSS inclusion behaviour. * * @var Requirements_Backend */ private static $backend = null; /** * @return Requirements_Backend */ public static function backend() { if(!self::$backend) { self::$backend = Injector::inst()->create('Requirements_Backend'); } return self::$backend; } /** * Setter method for changing the Requirements backend * * @param Requirements_Backend $backend */ public static function set_backend(Requirements_Backend $backend) { self::$backend = $backend; } /** * Register the given JavaScript file as required. * * @param string $file Relative to docroot * @param array $options List of options. Available options include: * - 'provides' : List of scripts files included in this file */ public static function javascript($file, $options = array()) { self::backend()->javascript($file, $options); } /** * Register the given JavaScript code into the list of requirements * * @param string $script The script content as a string (without enclosing "; } } // Add all inline JavaScript *after* including external files they might rely on foreach($this->getCustomScripts() as $script) { $jsRequirements .= ""; } foreach($this->getCSS() as $file => $params) { $path = Convert::raw2att($this->pathForFile($file)); if($path) { $media = (isset($params['media']) && !empty($params['media'])) ? " media=\"{$params['media']}\"" : ""; $requirements .= "\n"; } } foreach($this->getCustomCSS() as $css) { $requirements .= "\n"; } foreach($this->getCustomHeadTags() as $customHeadTag) { $requirements .= "$customHeadTag\n"; } // Inject CSS into body $content = $this->insertTagsIntoHead($requirements, $content); // Inject scripts if ($this->getForceJSToBottom()) { $content = $this->insertScriptsAtBottom($jsRequirements, $content); } elseif($this->getWriteJavascriptToBody()) { $content = $this->insertScriptsIntoBody($jsRequirements, $content); } else { $content = $this->insertTagsIntoHead($jsRequirements, $content); } return $content; } /** * Given a block of HTML, insert the given scripts at the bottom before * the closing
))/U', $content, $commentTags, 0, $scriptTagPosition) && $commentTags[1] == '-->' ); if($canWriteToBody) { // Insert content before existing script tags $content = substr($content, 0, $scriptTagPosition) . $jsRequirements . substr($content, $scriptTagPosition); } else { // Insert content at bottom of page otherwise $content = $this->insertScriptsAtBottom($jsRequirements, $content); } return $content; } /** * Given a block of HTML, insert the given code inside the block * * @param string $jsRequirements String containing one or more html tags * @param string $content HTML body * @return string Merged HTML */ protected function insertTagsIntoHead($jsRequirements, $content) { $content = preg_replace( '/(<\/head>)/i', $this->escapeReplacement($jsRequirements) . '\\1', $content ); return $content; } /** * Safely escape a literal string for use in preg_replace replacement * * @param string $replacement * @return string */ protected function escapeReplacement($replacement) { return addcslashes($replacement, '\\$'); } /** * Attach requirements inclusion to X-Include-JS and X-Include-CSS headers on the given * HTTP Response * * @param SS_HTTPResponse $response */ public function includeInResponse(SS_HTTPResponse $response) { $this->processCombinedFiles(); $jsRequirements = array(); $cssRequirements = array(); foreach($this->getJavascript() as $file) { $path = $this->pathForFile($file); if($path) { $jsRequirements[] = str_replace(',', '%2C', $path); } } if(count($jsRequirements)) { $response->addHeader('X-Include-JS', implode(',', $jsRequirements)); } foreach($this->getCSS() as $file => $params) { $path = $this->pathForFile($file); if($path) { $path = str_replace(',', '%2C', $path); $cssRequirements[] = isset($params['media']) ? "$path:##:$params[media]" : $path; } } if(count($cssRequirements)) { $response->addHeader('X-Include-CSS', implode(',', $cssRequirements)); } } /** * Add i18n files from the given javascript directory. SilverStripe expects that the given * directory will contain a number of JavaScript files named by language: en_US.js, de_DE.js, * etc. * * @param string $langDir The JavaScript lang directory, relative to the site root, e.g., * 'framework/javascript/lang' * @param bool $return Return all relative file paths rather than including them in * requirements * @param bool $langOnly Only include language files, not the base libraries * * @return array|null All relative files if $return is true, or null otherwise */ public function add_i18n_javascript($langDir, $return = false, $langOnly = false) { $files = array(); $base = Director::baseFolder() . '/'; if(i18n::config()->js_i18n) { // Include i18n.js even if no languages are found. The fact that // add_i18n_javascript() was called indicates that the methods in // here are needed. if(!$langOnly) $files[] = FRAMEWORK_DIR . '/client/dist/js/i18n.js'; if(substr($langDir,-1) != '/') $langDir .= '/'; $candidates = array( 'en.js', 'en_US.js', i18n::get_lang_from_locale(i18n::config()->default_locale) . '.js', i18n::config()->default_locale . '.js', i18n::get_lang_from_locale(i18n::get_locale()) . '.js', i18n::get_locale() . '.js', ); foreach($candidates as $candidate) { if(file_exists($base . DIRECTORY_SEPARATOR . $langDir . $candidate)) { $files[] = $langDir . $candidate; } } } else { // Stub i18n implementation for when i18n is disabled. if(!$langOnly) { $files[] = FRAMEWORK_DIR . '/client/dist/js/i18nx.js'; } } if($return) { return $files; } else { foreach($files as $file) { $this->javascript($file); } return null; } } /** * Finds the path for specified file * * @param string $fileOrUrl * @return string|bool */ protected function pathForFile($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; } elseif(Director::fileExists($fileOrUrl)) { $filePath = preg_replace('/\?.*/', '', Director::baseFolder() . '/' . $fileOrUrl); $prefix = Director::baseURL(); $mtimesuffix = ""; $suffix = ''; if($this->getSuffixRequirements()) { $mtimesuffix = "?m=" . filemtime($filePath); $suffix = '&'; } if(strpos($fileOrUrl, '?') !== false) { if (strlen($suffix) == 0) { $suffix = '?'; } $suffix .= substr($fileOrUrl, strpos($fileOrUrl, '?')+1); $fileOrUrl = substr($fileOrUrl, 0, strpos($fileOrUrl, '?')); } else { $suffix = ''; } return "{$prefix}{$fileOrUrl}{$mtimesuffix}{$suffix}"; } else { throw new InvalidArgumentException("File {$fileOrUrl} does not exist"); } } /** * Concatenate several css or javascript files into a single dynamically generated file. This * increases performance by fewer HTTP requests. * * The combined file is regenerated based on every file modification time. Optionally a * rebuild can be triggered by appending ?flush=1 to the URL. * * All combined files will have a comment on the start of each concatenated file denoting their * original position. * * 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 * JavaScript logic, and combining CSS can lead to incorrect inheritance. You can also * only include each file once across all includes and combinations in a single page load. * * CAUTION: Combining CSS Files discards any "media" information. * * Example for combined JavaScript: *
* Requirements::combine_files(
* 'foobar.js',
* array(
* 'mysite/javascript/foo.js',
* 'mysite/javascript/bar.js',
* )
* );
*
*
* Example for combined CSS:
*
* Requirements::combine_files(
* 'foobar.css',
* array(
* 'mysite/javascript/foo.css',
* 'mysite/javascript/bar.css',
* )
* );
*
*
* @param string $combinedFileName Filename of the combined file relative to docroot
* @param array $files Array of filenames relative to docroot
* @param string $media If including CSS Files, you can specify a media type
*/
public function combineFiles($combinedFileName, $files, $media = null) {
// 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, $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
));
}
}
$this->combinedFiles[$combinedFileName] = array(
'files' => $paths,
'type' => $combinedType,
'media' => $media
);
}
/**
* 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'];
$media = $combinedItem['media'];
// 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' => $media);
$included = true;
}
// If already included, or otherwise blocked, then don't add into CSS
}
$this->css = $newCSS;
break;
}
case 'js': {
// Assoc array of file => true
$newJS = array();
foreach($this->getAllJavascript() as $script) {
if(!in_array($script, $fileList)) {
$newJS[$script] = true;
} elseif(!$included && $combinedURL) {
$newJS[$combinedURL] = true;
$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->getMinifyCombinedJSFiles();
$combinedURL = $this
->getAssetHandler()
->getContentURL(
$combinedFileID,
function() use ($fileList, $minify, $type) {
// Physically combine all file content
$combinedData = '';
$base = Director::baseFolder() . '/';
$minifier = Injector::inst()->get('Requirements_Minifier');
foreach($fileList as $file) {
$fileContent = file_get_contents($base . $file);
// Use configured minifier
if($minify) {
$fileContent = $minifier->minify($fileContent, $type, $file);
}
if ($this->writeHeaderComment) {
// Write a header comment for each file for easier identification and debugging.
$combinedData .= "/****** FILE: $file *****/\n";
}
$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('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 $module The module to fall back to if the css file does not exist in the
* current theme.
* @param string $media Comma-separated list of media types to use in the link tag
* (e.g. 'screen,projector')
*/
public function themedCSS($name, $module = null, $media = null) {
$theme = SSViewer::get_theme_folder();
$project = project();
$absbase = BASE_PATH . DIRECTORY_SEPARATOR;
$abstheme = $absbase . $theme;
$absproject = $absbase . $project;
$css = "/css/$name.css";
if(file_exists($absproject . $css)) {
$this->css($project . $css, $media);
} elseif($module && file_exists($abstheme . '_' . $module.$css)) {
$this->css($theme . '_' . $module . $css, $media);
} elseif(file_exists($abstheme . $css)) {
$this->css($theme . $css, $media);
} elseif($module) {
$this->css($module . $css, $media);
}
}
/**
* 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);
}
}
/**
* Provides an abstract interface for minifying content
*/
interface Requirements_Minifier {
/**
* Minify the given content
*
* @param string $content
* @param string $type Either js or css
* @param string $filename Name of file to display in case of error
* @return string minified content
*/
public function minify($content, $type, $filename);
}