silverstripe-framework/View/Requirements_Backend.php
Ingo Schommer 2316b0da9f API Remove i18n::js_i18n option
The JavaScript i18n functionality in SilverStripe is used in the CMS as well as form field implementations.
Form fields used to include their own JavaScript for usage outside of CMS. This now requires custom build tooling in a project.
Hence there's no need for an i18n shim (i18nx.js), since the CMS always uses i18n support.
2016-09-16 13:46:09 +12:00

1453 lines
40 KiB
PHP

<?php
namespace SilverStripe\View;
use InvalidArgumentException;
use SilverStripe\Assets\File;
use SilverStripe\Assets\Storage\GeneratedAssetHandler;
use SilverStripe\Control\Director;
use SilverStripe\Control\HTTPResponse;
use SilverStripe\Core\Config\Config;
use SilverStripe\Core\Convert;
use SilverStripe\Core\Injector\Injectable;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Dev\Debug;
use SilverStripe\Dev\Deprecation;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\i18n\i18n;
class Requirements_Backend
{
use Injectable;
/**
* 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.
*
* @var bool
*/
protected $suffixRequirements = true;
/**
* Whether to combine CSS and JavaScript files
*
* @var bool
*/
protected $combinedFilesEnabled = true;
/**
* Determine if files should be combined automatically on dev mode.
*
* By default combined files will not be combined except in test or
* live environments. Turning this on will allow for pre-combining of files in development mode.
*
* @config
* @var bool
*/
private static $combine_in_dev = false;
/**
* Paths to all required JavaScript files relative to docroot
*
* @var array
*/
protected $javascript = array();
/**
* Map of included scripts to array of contained files.
* To be used alongside front-end combination mechanisms.
*
* @var array Map of providing filepath => 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 <head> tag
*
* @var array
*/
protected $customCSS = array();
/**
* All custom HTML markup which is added before the closing <head> 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 JSMin library to minify any javascript file passed to {@link combine_files()}.
*
* @var bool
*/
protected $minifyCombinedJSFiles = true;
/**
* 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 <body> tag,
* rather than the default behaviour of placing them at the end of the <head> 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 `<hash>` of the source files is appended to the end of the combined file
* (prior to the file extension). If combined files are versioned in source control or running
* in a distributed environment (such as one where the newest version of a file may not always be
* immediately available) then it may sometimes be necessary to disable this. When this is set to true,
* the hash will instead be appended via a querystring parameter to enable cache busting, but not in
* the filename itself. I.e. `assets/_combinedfiles/name.js?m=<hash>`
*
* @config
* @var bool
*/
private static $combine_hash_querystring = false;
/**
* @var GeneratedAssetHandler
*/
protected $assetHandler = null;
/**
* Gets the backend storage for generated files
*
* @return GeneratedAssetHandler
*/
public function getAssetHandler()
{
return $this->assetHandler;
}
/**
* Set a new asset handler for this backend
*
* @param GeneratedAssetHandler $handler
*/
public function setAssetHandler(GeneratedAssetHandler $handler)
{
$this->assetHandler = $handler;
}
/**
* Enable or disable the combination of CSS and JavaScript files
*
* @param bool $enable
*/
public function setCombinedFilesEnabled($enable)
{
$this->combinedFilesEnabled = (bool)$enable;
}
/**
* 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
* contain relative paths.
*
* This must not include any 'assets' prefix
*
* @param string $folder
*/
public function setCombinedFilesFolder($folder)
{
$this->combinedFilesFolder = $folder;
}
/**
* Retrieve the combined files folder prefix
*
* @return string
*/
public function getCombinedFilesFolder()
{
if ($this->combinedFilesFolder) {
return $this->combinedFilesFolder;
}
return Config::inst()->get(__CLASS__, 'default_combined_files_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 function setSuffixRequirements($var)
{
$this->suffixRequirements = $var;
}
/**
* Check whether we want to suffix requirements
*
* @return bool
*/
public function getSuffixRequirements()
{
return $this->suffixRequirements;
}
/**
* Set whether you want to write the JS to the body of the page rather than at the end of the
* head tag.
*
* @param bool
* @return $this
*/
public function setWriteJavascriptToBody($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
*
* @param bool
* @return $this
*/
public function setForceJSToBottom($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;
}
/**
* Check if minify js files should be combined
*
* @return bool
*/
public function getMinifyCombinedJSFiles()
{
return $this->minifyCombinedJSFiles;
}
/**
* Set if combined js files should be minified
*
* @param bool $minify
* @return $this
*/
public function setMinifyCombinedJSFiles($minify)
{
$this->minifyCombinedJSFiles = $minify;
return $this;
}
/**
* 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
* - 'async' : Boolean value to set async attribute to script tag
* - 'defer' : Boolean value to set defer attribute to script tag
* - 'type' : Override script type= value.
*/
public function javascript($file, $options = array())
{
// Get type
$type = null;
if (isset($this->javascript[$file]['type'])) {
$type = $this->javascript[$file]['type'];
}
if (isset($options['type'])) {
$type = $options['type'];
}
// make sure that async/defer is set if it is set once even if file is included multiple times
$async = (
isset($options['async']) && isset($options['async']) == true
|| (
isset($this->javascript[$file])
&& isset($this->javascript[$file]['async'])
&& $this->javascript[$file]['async'] == true
)
);
$defer = (
isset($options['defer']) && isset($options['defer']) == true
|| (
isset($this->javascript[$file])
&& isset($this->javascript[$file]['defer'])
&& $this->javascript[$file]['defer'] == true
)
);
$this->javascript[$file] = array(
'async' => $async,
'defer' => $defer,
'type' => $type,
);
// Record scripts included in this file
if (isset($options['provides'])) {
$this->providedJavascript[$file] = array_values($options['provides']);
}
}
/**
* Remove a javascript requirement
*
* @param string $file
*/
protected function unsetJavascript($file)
{
unset($this->javascript[$file]);
}
/**
* Gets all scripts that are already provided by prior scripts.
* This follows these rules:
* - Files will not be considered provided if they are separately
* included prior to the providing file.
* - Providing files can be blocked, and don't provide anything
* - Provided files can't be blocked (you need to block the provider)
* - If a combined file includes files that are provided by prior
* scripts, then these should be excluded from the combined file.
* - If a combined file includes files that are provided by later
* scripts, then these files should be included in the combined
* file, but we can't block the later script either (possible double
* up of file).
*
* @return array Array of provided files (map of $path => $path)
*/
public function getProvidedScripts()
{
$providedScripts = array();
$includedScripts = array();
foreach ($this->javascript as $script => $options) {
// Ignore scripts that are explicitly blocked
if (isset($this->blocked[$script])) {
continue;
}
// At this point, the file is included.
// This might also be combined at this point, potentially.
$includedScripts[$script] = true;
// Record any files this provides, EXCEPT those already included by now
if (isset($this->providedJavascript[$script])) {
foreach ($this->providedJavascript[$script] as $provided) {
if (!isset($includedScripts[$provided])) {
$providedScripts[$provided] = $provided;
}
}
}
}
return $providedScripts;
}
/**
* Returns an array of required JavaScript, excluding blocked
* and duplicates of provided files.
*
* @return array
*/
public function getJavascript()
{
return array_diff_key(
$this->javascript,
$this->getBlocked(),
$this->getProvidedScripts()
);
}
/**
* 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 $this->javascript;
}
/**
* 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 $uniquenessID A unique ID that ensures a piece of code is only added once
*/
public function customScript($script, $uniquenessID = null)
{
if ($uniquenessID) {
$this->customScript[$uniquenessID] = $script;
} else {
$this->customScript[] = $script;
}
}
/**
* Return all registered custom scripts
*
* @return array
*/
public function getCustomScripts()
{
return array_diff_key($this->customScript, $this->blocked);
}
/**
* Register the given CSS styles into the list of requirements
*
* @param string $script CSS selectors as a string (without enclosing <style> tag)
* @param string $uniquenessID A unique ID that ensures a piece of code is only added once
*/
public function customCSS($script, $uniquenessID = null)
{
if ($uniquenessID) {
$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
*
* @param string $html Custom HTML code
* @param string $uniquenessID A unique ID that ensures a piece of code is only added once
*/
public function insertHeadTags($html, $uniquenessID = null)
{
if ($uniquenessID) {
$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
* 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[] $vars The array of variables to interpolate.
* @param string $uniquenessID A unique ID that ensures a piece of code is only added once
*/
public function javascriptTemplate($file, $vars, $uniquenessID = null)
{
$script = file_get_contents(Director::getAbsFile($file));
$search = array();
$replace = array();
if ($vars) {
foreach ($vars as $k => $v) {
$search[] = '$' . $k;
$replace[] = str_replace("\\'", "'", Convert::raw2js($v));
}
}
$script = str_replace($search, $replace, $script);
$this->customScript($script, $uniquenessID);
}
/**
* Register the given stylesheet into the list of requirements.
*
* @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
* (e.g. 'screen,projector')
*/
public function css($file, $media = null)
{
$this->css[$file] = array(
"media" => $media
);
}
/**
* 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
*
* @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
*/
public function getBlocked()
{
return $this->blocked;
}
/**
* Clear either a single or all requirements
*
* Caution: Clearing single rules added via customCSS and customScript only works if you
* originally specified a $uniquenessID.
*
* @param string|int $fileOrID
*/
public function clear($fileOrID = null)
{
if ($fileOrID) {
foreach (array('javascript', 'css', 'customScript', 'customCSS', 'customHeadTags') as $type) {
if (isset($this->{$type}[$fileOrID])) {
$this->disabled[$type][$fileOrID] = $this->{$type}[$fileOrID];
unset($this->{$type}[$fileOrID]);
}
}
} else {
$this->disabled['javascript'] = $this->javascript;
$this->disabled['css'] = $this->css;
$this->disabled['customScript'] = $this->customScript;
$this->disabled['customCSS'] = $this->customCSS;
$this->disabled['customHeadTags'] = $this->customHeadTags;
$this->javascript = array();
$this->css = array();
$this->customScript = array();
$this->customCSS = array();
$this->customHeadTags = array();
}
}
/**
* Restore requirements cleared by call to Requirements::clear
*/
public function restore()
{
$this->javascript = $this->disabled['javascript'];
$this->css = $this->disabled['css'];
$this->customScript = $this->disabled['customScript'];
$this->customCSS = $this->disabled['customCSS'];
$this->customHeadTags = $this->disabled['customHeadTags'];
}
/**
* Block inclusion of a specific file
*
* The difference between this and {@link clear} is that the calling order does not matter;
* {@link clear} must be called after the initial registration, whereas {@link block} can be
* used in advance. This is useful, for example, to block scripts included by a superclass
* without having to override entire functions and duplicate a lot of code.
*
* Note that blocking should be used sparingly because it's hard to trace where an file is
* being blocked from.
*
* @param string|int $fileOrID
*/
public function block($fileOrID)
{
$this->blocked[$fileOrID] = $fileOrID;
}
/**
* Remove an item from the block list
*
* @param string|int $fileOrID
*/
public function unblock($fileOrID)
{
unset($this->blocked[$fileOrID]);
}
/**
* Removes all items from the block list
*/
public function unblockAll()
{
$this->blocked = array();
}
/**
* Update the given HTML content with the appropriate include tags for the registered
* requirements. Needs to receive a valid HTML/XHTML template in the $content parameter,
* including a head and body tag.
*
* @param string $content HTML content that has already been parsed from the $templateFile
* through {@link SSViewer}
* @return string HTML content augmented with the requirements tags
*/
public function includeInHTML($content)
{
if (func_num_args() > 1) {
Deprecation::notice(
'5.0',
'$templateFile argument is deprecated. includeInHTML takes a sole $content parameter now.'
);
$content = func_get_arg(1);
}
// Skip if content isn't injectable, or there is nothing to inject
$tagsAvailable = preg_match('#</head\b#', $content);
$hasFiles = $this->css || $this->javascript || $this->customCSS || $this->customScript || $this->customHeadTags;
if (!$tagsAvailable || !$hasFiles) {
return $content;
}
$requirements = '';
$jsRequirements = '';
// Combine files - updates $this->javascript and $this->css
$this->processCombinedFiles();
foreach ($this->getJavascript() as $file => $attributes) {
$async = (isset($attributes['async']) && $attributes['async'] == true) ? " async" : "";
$defer = (isset($attributes['defer']) && $attributes['defer'] == true) ? " defer" : "";
$type = Convert::raw2att(isset($attributes['type']) ? $attributes['type'] : "application/javascript");
$path = Convert::raw2att($this->pathForFile($file));
if ($path) {
$jsRequirements .= "<script type=\"{$type}\" src=\"{$path}\"{$async}{$defer}></script>";
}
}
// Add all inline JavaScript *after* including external files they might rely on
foreach ($this->getCustomScripts() as $script) {
$jsRequirements .= "<script type=\"application/javascript\">//<![CDATA[\n";
$jsRequirements .= "$script\n";
$jsRequirements .= "//]]></script>";
}
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 .= "<link rel=\"stylesheet\" type=\"text/css\" {$media} href=\"$path\" />\n";
}
}
foreach ($this->getCustomCSS() as $css) {
$requirements .= "<style type=\"text/css\">\n$css\n</style>\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 </body> tag
*
* @param string $jsRequirements String containing one or more javascript <script /> tags
* @param string $content HTML body
* @return string Merged HTML
*/
protected function insertScriptsAtBottom($jsRequirements, $content)
{
// Forcefully put the scripts at the bottom of the body instead of before the first
// script tag.
$content = preg_replace(
'/(<\/body[^>]*>)/i',
$this->escapeReplacement($jsRequirements) . '\\1',
$content
);
return $content;
}
/**
* Given a block of HTML, insert the given scripts inside the <body></body>
*
* @param string $jsRequirements String containing one or more javascript <script /> tags
* @param string $content HTML body
* @return string Merged HTML
*/
protected function insertScriptsIntoBody($jsRequirements, $content)
{
// If your template already has script tags in the body, then we try to put our script
// tags just before those. Otherwise, we put it at the bottom.
$bodyTagPosition = stripos($content, '<body');
$scriptTagPosition = stripos($content, '<script', $bodyTagPosition);
$commentTags = array();
$canWriteToBody = ($scriptTagPosition !== false)
&&
// Check that the script tag is not inside a html comment tag
!(
preg_match('/.*(?|(<!--)|(-->))/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 <head></head> 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 HTTPResponse $response
*/
public function includeInResponse(HTTPResponse $response)
{
$this->processCombinedFiles();
$jsRequirements = array();
$cssRequirements = array();
foreach ($this->getJavascript() as $file => $attributes) {
$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
*
* @return array|null All relative files if $return is true, or null otherwise
*/
public function add_i18n_javascript($langDir, $return = false)
{
$files = array();
$base = Director::baseFolder() . '/';
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;
}
}
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:
* <code>
* Requirements::combine_files(
* 'foobar.js',
* array(
* 'mysite/javascript/foo.js',
* 'mysite/javascript/bar.js',
* ),
* array(
* 'async' => true,
* 'defer' => true,
* )
* );
* </code>
*
* Example for combined CSS:
* <code>
* Requirements::combine_files(
* 'foobar.css',
* array(
* 'mysite/javascript/foo.css',
* 'mysite/javascript/bar.css',
* ),
* array(
* 'media' => 'print',
* )
* );
* </code>
*
* @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->getMinifyCombinedJSFiles();
$combinedURL = $this
->getAssetHandler()
->getContentURL(
$combinedFileID,
function () use ($fileList, $minify, $type) {
// Physically combine all file content
$combinedData = '';
$base = Director::baseFolder() . '/';
$minifier = Injector::inst()->get('SilverStripe\\View\\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('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 exists. 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 exists. 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);
}
}