API Enable single javascript files to declare that they include other files

This commit is contained in:
Damian Mooyman 2016-01-14 17:23:21 +13:00
parent 01ef4c2d05
commit 8e1ae55ff6
4 changed files with 255 additions and 87 deletions

View File

@ -66,6 +66,21 @@ JavaScript in a separate file and instead load, via search and replace, several
In this example, `editor.template.js` is expected to contain a replaceable variable expressed as `$EditorCSS`.
If you are using front-end script combination mechanisms, you can optionally declare
that your included files provide these scripts. This will ensure that subsequent
Requirement calls that rely on those included scripts will not double include those
files.
:::php
Requirements::javascript('mysite/js/dist/bundle.js', ['provides' => [
'mysite/js/jquery.js'
'mysite/js/src/main.js',
'mysite/js/src/functions.js'
]]);
Requirements::javascript('mysite/js/jquery.js'); // Will will skip this file
### Custom Inline CSS or Javascript
You can also quote custom script directly. This may seem a bit ugly, but is useful when you need to transfer some kind

View File

@ -137,6 +137,22 @@ And some methods on `Requirements` and `Requirements_Backend` have been removed
A new config `Requirements_Backend.combine_in_dev` has been added in order to allow combined files to be
forced on during development. If this is off, combined files is only enabled in live environments.
In addition, a new API for javascript files has been added to support front-end tools
such as [browserify](http://browserify.org/) that pre-combine scripts. You can specify
a 'provides' option to the second parameter of `Requirements::javascript` to declare
included files.
E.g.
:::php
Requirements::javascript('mysite/js/dist/bundle.js', ['provides' => [
'mysite/js/jquery.js'
'mysite/js/src/main.js',
'mysite/js/src/functions.js'
]]);
## Upgrading
### Update code that uses SQLQuery

View File

@ -22,6 +22,7 @@ class RequirementsTest extends SapphireTest {
}
public function testExternalUrls() {
/** @var Requirements_Backend $backend */
$backend = Injector::inst()->create('Requirements_Backend');
$backend->setCombinedFilesEnabled(true);
@ -118,6 +119,7 @@ class RequirementsTest extends SapphireTest {
}
public function testCombinedJavascript() {
/** @var Requirements_Backend $backend */
$backend = Injector::inst()->create('Requirements_Backend');
$this->setupCombinedRequirements($backend);
@ -166,6 +168,7 @@ class RequirementsTest extends SapphireTest {
// Then do it again, this time not requiring the files beforehand
unlink($combinedFilePath);
/** @var Requirements_Backend $backend */
$backend = Injector::inst()->create('Requirements_Backend');
$this->setupCombinedNonrequiredRequirements($backend);
$html = $backend->includeInHTML(false, self::$html_template);
@ -204,6 +207,7 @@ class RequirementsTest extends SapphireTest {
public function testCombinedCss() {
$basePath = $this->getCurrentRelativePath();
/** @var Requirements_Backend $backend */
$backend = Injector::inst()->create('Requirements_Backend');
$this->setupRequirements($backend);
@ -230,6 +234,7 @@ class RequirementsTest extends SapphireTest {
);
// Test that combining a file multiple times doesn't trigger an error
/** @var Requirements_Backend $backend */
$backend = Injector::inst()->create('Requirements_Backend');
$this->setupRequirements($backend);
$backend->combineFiles(
@ -257,6 +262,7 @@ class RequirementsTest extends SapphireTest {
public function testBlockedCombinedJavascript() {
$basePath = $this->getCurrentRelativePath();
/** @var Requirements_Backend $backend */
$backend = Injector::inst()->create('Requirements_Backend');
$this->setupCombinedRequirements($backend);
$combinedFileName = '/_combinedfiles/RequirementsTest_bc-51622b5.js';
@ -314,6 +320,7 @@ class RequirementsTest extends SapphireTest {
public function testArgsInUrls() {
$basePath = $this->getCurrentRelativePath();
/** @var Requirements_Backend $backend */
$backend = Injector::inst()->create('Requirements_Backend');
$this->setupRequirements($backend);
@ -339,6 +346,7 @@ class RequirementsTest extends SapphireTest {
public function testRequirementsBackend() {
$basePath = $this->getCurrentRelativePath();
/** @var Requirements_Backend $backend */
$backend = Injector::inst()->create('Requirements_Backend');
$this->setupRequirements($backend);
$backend->javascript($basePath . '/a.js');
@ -377,6 +385,7 @@ class RequirementsTest extends SapphireTest {
// to something else
$basePath = 'framework' . substr($basePath, strlen(FRAMEWORK_DIR));
/** @var Requirements_Backend $backend */
$backend = Injector::inst()->create('Requirements_Backend');
$this->setupRequirements($backend);
$holder = Requirements::backend();
@ -406,6 +415,7 @@ class RequirementsTest extends SapphireTest {
}
public function testJsWriteToBody() {
/** @var Requirements_Backend $backend */
$backend = Injector::inst()->create('Requirements_Backend');
$this->setupRequirements($backend);
$backend->javascript('http://www.mydomain.com/test.js');
@ -425,6 +435,7 @@ class RequirementsTest extends SapphireTest {
public function testIncludedJsIsNotCommentedOut() {
$template = '<html><head></head><body><!--<script>alert("commented out");</script>--></body></html>';
/** @var Requirements_Backend $backend */
$backend = Injector::inst()->create('Requirements_Backend');
$this->setupRequirements($backend);
$backend->javascript($this->getCurrentRelativePath() . '/RequirementsTest_a.js');
@ -437,6 +448,7 @@ class RequirementsTest extends SapphireTest {
public function testCommentedOutScriptTagIsIgnored() {
$template = '<html><head></head><body><!--<script>alert("commented out");</script>-->'
. '<h1>more content</h1></body></html>';
/** @var Requirements_Backend $backend */
$backend = Injector::inst()->create('Requirements_Backend');
$this->setupRequirements($backend);
$backend->setSuffixRequirements(false);
@ -449,6 +461,7 @@ class RequirementsTest extends SapphireTest {
}
public function testForceJsToBottom() {
/** @var Requirements_Backend $backend */
$backend = Injector::inst()->create('Requirements_Backend');
$this->setupRequirements($backend);
$backend->javascript('http://www.mydomain.com/test.js');
@ -505,6 +518,7 @@ class RequirementsTest extends SapphireTest {
$template = '<html><head></head><body><header>My header</header><p>Body</p></body></html>';
$basePath = $this->getCurrentRelativePath();
/** @var Requirements_Backend $backend */
$backend = Injector::inst()->create('Requirements_Backend');
$this->setupRequirements($backend);
@ -529,6 +543,56 @@ class RequirementsTest extends SapphireTest {
$this->assertNotRegexp('/RequirementsTest_b\.css\?m=[\d]*&amp;foo=bar&amp;bla=blubb"/', $html);
}
/**
* Tests that provided files work
*/
public function testProvidedFiles() {
/** @var Requirements_Backend $backend */
$template = '<html><head></head><body><header>My header</header><p>Body</p></body></html>';
$basePath = $this->getCurrentRelativePath();
// Test that provided files block subsequent files
$backend = Injector::inst()->create('Requirements_Backend');
$this->setupRequirements($backend);
$backend->javascript($basePath . '/RequirementsTest_a.js');
$backend->javascript($basePath . '/RequirementsTest_b.js', [
'provides' => [
$basePath . '/RequirementsTest_a.js',
$basePath . '/RequirementsTest_c.js'
]
]);
$backend->javascript($basePath . '/RequirementsTest_c.js');
// Note that _a.js isn't considered provided because it was included
// before it was marked as provided
$this->assertEquals([
$basePath . '/RequirementsTest_c.js' => $basePath . '/RequirementsTest_c.js'
], $backend->getProvidedScripts());
$html = $backend->includeInHTML(false, $template);
$this->assertRegExp('/src=".*\/RequirementsTest_a\.js/', $html);
$this->assertRegExp('/src=".*\/RequirementsTest_b\.js/', $html);
$this->assertNotRegExp('/src=".*\/RequirementsTest_c\.js/', $html);
// Test that provided files block subsequent combined files
$backend = Injector::inst()->create('Requirements_Backend');
$this->setupRequirements($backend);
$backend->combineFiles('combined_a.js', [$basePath . '/RequirementsTest_a.js']);
$backend->javascript($basePath . '/RequirementsTest_b.js', [
'provides' => [
$basePath . '/RequirementsTest_a.js',
$basePath . '/RequirementsTest_c.js'
]
]);
$backend->combineFiles('combined_c.js', [$basePath . '/RequirementsTest_c.js']);
$this->assertEquals([
$basePath . '/RequirementsTest_c.js' => $basePath . '/RequirementsTest_c.js'
], $backend->getProvidedScripts());
$html = $backend->includeInHTML(false, $template);
$this->assertRegExp('/src=".*\/combined_a/', $html);
$this->assertRegExp('/src=".*\/RequirementsTest_b\.js/', $html);
$this->assertNotRegExp('/src=".*\/combined_c/', $html);
$this->assertNotRegExp('/src=".*\/RequirementsTest_c\.js/', $html);
}
/**
* Verify that the given backend includes the given files
*

View File

@ -110,9 +110,11 @@ class Requirements implements Flushable {
* 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) {
self::backend()->javascript($file);
public static function javascript($file, $options = array()) {
self::backend()->javascript($file, $options);
}
/**
@ -497,6 +499,14 @@ class Requirements_Backend
*/
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.
*
@ -784,9 +794,16 @@ class Requirements_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 function javascript($file) {
public function javascript($file, $options = array()) {
$this->javascript[$file] = true;
// Record scripts included in this file
if(isset($options['provides'])) {
$this->providedJavascript[$file] = array_values($options['provides']);
}
}
/**
@ -798,13 +815,58 @@ class Requirements_Backend
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 => $flag) {
// 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_keys(array_diff_key($this->javascript, $this->blocked));
return array_keys(array_diff_key(
$this->javascript,
$this->getBlocked(),
$this->getProvidedScripts()
));
}
/**
@ -1044,90 +1106,90 @@ class Requirements_Backend
* @return string HTML content augmented with the requirements tags
*/
public function includeInHTML($templateFile, $content) {
if(
(strpos($content, '</head>') !== false || strpos($content, '</head ') !== false)
&& ($this->css || $this->javascript || $this->customCSS || $this->customScript || $this->customHeadTags)
) {
$requirements = '';
$jsRequirements = '';
// 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();
// Combine files - updates $this->javascript and $this->css
$this->processCombinedFiles();
foreach($this->getJavascript() as $file) {
$path = Convert::raw2xml($this->pathForFile($file));
if($path) {
$jsRequirements .= "<script type=\"text/javascript\" src=\"$path\"></script>\n";
}
}
// Add all inline JavaScript *after* including external files they might rely on
foreach($this->getCustomScripts() as $script) {
$jsRequirements .= "<script type=\"text/javascript\">\n//<![CDATA[\n";
$jsRequirements .= "$script\n";
$jsRequirements .= "\n//]]>\n</script>\n";
}
foreach($this->getCSS() as $file => $params) {
$path = Convert::raw2xml($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";
}
if ($this->getForceJSToBottom()) {
// Remove all newlines from code to preserve layout
$jsRequirements = preg_replace('/>\n*/', '>', $jsRequirements);
// Forcefully put the scripts at the bottom of the body instead of before the first
// script tag.
$content = preg_replace("/(<\/body[^>]*>)/i", $jsRequirements . "\\1", $content);
// Put CSS at the bottom of the head
$content = preg_replace("/(<\/head>)/i", $requirements . "\\1", $content);
} elseif($this->getWriteJavascriptToBody()) {
// Remove all newlines from code to preserve layout
$jsRequirements = preg_replace('/>\n*/', '>', $jsRequirements);
// 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.
$p2 = stripos($content, '<body');
$p1 = stripos($content, '<script', $p2);
$commentTags = array();
$canWriteToBody = ($p1 !== false)
&&
// Check that the script tag is not inside a html comment tag
!(
preg_match('/.*(?|(<!--)|(-->))/U', $content, $commentTags, 0, $p1)
&&
$commentTags[1] == '-->'
);
if($canWriteToBody) {
$content = substr($content,0,$p1) . $jsRequirements . substr($content,$p1);
} else {
$content = preg_replace("/(<\/body[^>]*>)/i", $jsRequirements . "\\1", $content);
}
// Put CSS at the bottom of the head
$content = preg_replace("/(<\/head>)/i", $requirements . "\\1", $content);
} else {
$content = preg_replace("/(<\/head>)/i", $requirements . "\\1", $content);
$content = preg_replace("/(<\/head>)/i", $jsRequirements . "\\1", $content);
foreach($this->getJavascript() as $file) {
$path = Convert::raw2xml($this->pathForFile($file));
if($path) {
$jsRequirements .= "<script type=\"text/javascript\" src=\"$path\"></script>\n";
}
}
// Add all inline JavaScript *after* including external files they might rely on
foreach($this->getCustomScripts() as $script) {
$jsRequirements .= "<script type=\"text/javascript\">\n//<![CDATA[\n";
$jsRequirements .= "$script\n";
$jsRequirements .= "\n//]]>\n</script>\n";
}
foreach($this->getCSS() as $file => $params) {
$path = Convert::raw2xml($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";
}
if ($this->getForceJSToBottom()) {
// Remove all newlines from code to preserve layout
$jsRequirements = preg_replace('/>\n*/', '>', $jsRequirements);
// Forcefully put the scripts at the bottom of the body instead of before the first
// script tag.
$content = preg_replace("/(<\/body[^>]*>)/i", $jsRequirements . "\\1", $content);
// Put CSS at the bottom of the head
$content = preg_replace("/(<\/head>)/i", $requirements . "\\1", $content);
} elseif($this->getWriteJavascriptToBody()) {
// Remove all newlines from code to preserve layout
$jsRequirements = preg_replace('/>\n*/', '>', $jsRequirements);
// 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.
$p2 = stripos($content, '<body');
$p1 = stripos($content, '<script', $p2);
$commentTags = array();
$canWriteToBody = ($p1 !== false)
&&
// Check that the script tag is not inside a html comment tag
!(
preg_match('/.*(?|(<!--)|(-->))/U', $content, $commentTags, 0, $p1)
&&
$commentTags[1] == '-->'
);
if($canWriteToBody) {
$content = substr($content,0,$p1) . $jsRequirements . substr($content,$p1);
} else {
$content = preg_replace("/(<\/body[^>]*>)/i", $jsRequirements . "\\1", $content);
}
// Put CSS at the bottom of the head
$content = preg_replace("/(<\/head>)/i", $requirements . "\\1", $content);
} else {
$content = preg_replace("/(<\/head>)/i", $requirements . "\\1", $content);
$content = preg_replace("/(<\/head>)/i", $jsRequirements . "\\1", $content);
}
return $content;
}
@ -1426,6 +1488,9 @@ class Requirements_Backend
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'];
@ -1435,7 +1500,13 @@ class Requirements_Backend
// Generate this file, unless blocked
$combinedURL = null;
if(!isset($this->blocked[$combinedFile])) {
$combinedURL = $this->getCombinedFileURL($combinedFile, $fileList, $type);
// 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
@ -1483,11 +1554,13 @@ class Requirements_Backend
* @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 URL to this resource
* @return string|null URL to this resource, if there are files to combine
*/
protected function getCombinedFileURL($combinedFile, $fileList, $type) {
// Filter blocked files
$fileList = array_diff($fileList, $this->getBlocked());
// Skip empty lists
if(empty($fileList)) {
return null;
}
// Generate path (Filename)
$hashQuerystring = Config::inst()->get(static::class, 'combine_hash_querystring');