From b9a29b3a69381f335ee1f1bd0aa2ea6b3dcb1f15 Mon Sep 17 00:00:00 2001 From: alexandresalome Date: Sun, 22 Sep 2013 11:40:28 +0200 Subject: [PATCH] initial commit --- .gitignore | 3 + .travis.yml | 5 + AlexAsseticExtraBundle.php | 9 + Assetic/Filter/AssetDirectoryFilter.php | 66 +++++++ Assetic/Util/AssetDirectory.php | 170 ++++++++++++++++++ Assetic/Util/PathUtils.php | 170 ++++++++++++++++++ .../AlexAsseticExtraExtension.php | 27 +++ DependencyInjection/Configuration.php | 30 ++++ README.rst | 76 ++++++++ Resources/config/filters/assetdirectory.xml | 30 ++++ .../Filter/AsseticDirectoryFilterTest.php | 44 +++++ Tests/Assetic/Util/AssetDirectoryTest.php | 71 ++++++++ composer.json | 16 ++ phpunit.xml.dist | 32 ++++ 14 files changed, 749 insertions(+) create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 AlexAsseticExtraBundle.php create mode 100644 Assetic/Filter/AssetDirectoryFilter.php create mode 100644 Assetic/Util/AssetDirectory.php create mode 100644 Assetic/Util/PathUtils.php create mode 100644 DependencyInjection/AlexAsseticExtraExtension.php create mode 100644 DependencyInjection/Configuration.php create mode 100644 README.rst create mode 100644 Resources/config/filters/assetdirectory.xml create mode 100644 Tests/Assetic/Filter/AsseticDirectoryFilterTest.php create mode 100644 Tests/Assetic/Util/AssetDirectoryTest.php create mode 100644 composer.json create mode 100644 phpunit.xml.dist diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d95451e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/vendor/ +/composer.lock + diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..2a75188 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,5 @@ +language: php +before_script: + - composer install +php: + - 5.3 diff --git a/AlexAsseticExtraBundle.php b/AlexAsseticExtraBundle.php new file mode 100644 index 0000000..4125195 --- /dev/null +++ b/AlexAsseticExtraBundle.php @@ -0,0 +1,9 @@ + + */ +class AssetDirectoryFilter extends BaseCssFilter +{ + /** + * @var AssetDirectory + */ + protected $directory; + + /** + * @param AssetDirectory $directory directory where to store assets + */ + public function __construct(AssetDirectory $directory) + { + $this->directory = $directory; + } + + /** + * {@inheritdoc} + */ + public function filterLoad(AssetInterface $asset) + { + } + + /** + * {@inheritdoc} + */ + public function filterDump(AssetInterface $asset) + { + $directory = $this->directory; + + if (null === $asset->getSourcePath() || null === $asset->getTargetPath()) { + return; + } + + $content = $this->filterReferences($asset->getContent(), function ($matches) use ($asset, $directory) { + $url = $matches['url']; + + if (false !== strpos($url, '://')) { + return $matches[0]; + } + + $file = PathUtils::resolveUrl($asset, $url); + $target = $this->directory->add($file); + + $path = PathUtils::resolveRelative($target, $asset->getTargetPath()).basename($file); + + return str_replace($matches['url'], $path, $matches[0]); + }); + + $asset->setContent($content); + } +} diff --git a/Assetic/Util/AssetDirectory.php b/Assetic/Util/AssetDirectory.php new file mode 100644 index 0000000..2ed8fa0 --- /dev/null +++ b/Assetic/Util/AssetDirectory.php @@ -0,0 +1,170 @@ + + */ +class AssetDirectory +{ + /** + * @var string + */ + protected $directory; + + /** + * @var string + */ + protected $target; + + /** + * @var CacheInterface + */ + protected $cache; + + /** + * Constructs a new asset directory + * + * @param string $directory path to the directory of assets + * @param CacheInterface $cache a cache to use, to avoid copying the same file + */ + public function __construct($directory, $target = null, CacheInterface $cache = null) + { + $this->directory = $directory; + $this->target = $target; + $this->cache = $cache; + } + + /** + * Copy a file to the directory. + * + * @param string $file fullpath of file to add + * @param boolean $force ignore cache and add file to directory + * + * @throws InvalidArgumentException file does not exist + * @throws RuntimeException filesystem errors + * + * @return string target image path + */ + public function add($file, $force = false) + { + if (!file_exists($file)) { + throw new \InvalidArgumentException(sprintf('File "%s" does not exist.', $file)); + } + + if (false === $force && null !== $path = $this->getCache($file)) { + return null === $this->target ? $path : $this->target.'/'.$path; + } + + $name = $this->findAvailableName($file); + + if (null !== $this->cache) { + $this->cache->set(md5($file), serialize(array(filemtime($file), $name))); + } + + if (!is_dir($dir = dirname($target = $this->directory.'/'.$name))) { + mkdir($dir, 0777, true); + } + + if (false === @copy($file, $target)) { + throw new \RuntimeException(sprintf('Error while copying "%s" to "%s".', $file, $target)); + } + + return null === $this->target ? $name : $this->target.'/'.$name; + } + + /** + * Returns directory. + * + * @return string + */ + public function getDirectory() + { + return $this->directory; + } + + /** + * Returns target. + * + * @return string + */ + public function getTarget() + { + return $this->target; + } + + /** + * Changes target value. + * + * @param string $target a target path + * + * @return AssetDirectory fluid interface + */ + public function setTarget($target) + { + $this->target = $target; + + return $this; + } + + /** + * Searches a fresh cached file for the given file. + * + * @throws RuntimeException filesystem errors + */ + private function getCache($file) + { + if (null === $this->cache) { + return null; + } + + $key = md5($file); + + // File already present + if ($this->cache->has($key)) { + list($mtime, $path) = unserialize($this->cache->get($key)); + + if ($mtime === filemtime($file)) { + return $path; + } + + $delete = $this->directory.'/'.$path; + if (file_exists($delete) && false === @unlink($delete)) { + throw new \RuntimeException('Unable to remove file '.$delete); + } + } + + return null; + } + + /** + * Finds a name not used, incrementing if file already + * exists in storage : foo.png, foo_1.png, foo_2.png... + * + * @return string a relative path + */ + private function findAvailableName($file) + { + $name = basename($file); + + if (!file_exists($this->directory.'/'.$name)) { + return $name; + } + + $dotPos = strrpos($name, '.'); + $prefix = substr($name, 0, $dotPos); + $suffix = substr($name, $dotPos); + + $count = 1; + do { + $name = $prefix.'_'.$count.$suffix; + $count++; + } while (file_exists($this->directory.'/'.$name)); + + return $name; + } +} diff --git a/Assetic/Util/PathUtils.php b/Assetic/Util/PathUtils.php new file mode 100644 index 0000000..fe9c630 --- /dev/null +++ b/Assetic/Util/PathUtils.php @@ -0,0 +1,170 @@ + + */ +abstract class PathUtils +{ + /** + * Returns relative path to the source directory from the target path. + * + * @return string + */ + public static function resolveRelative($sourcePath, $targetPath) + { + if ('.' == dirname($sourcePath)) { + $path = str_repeat('../', substr_count($targetPath, '/')); + } elseif ('.' == $targetDir = dirname($targetPath)) { + $path = dirname($sourcePath).'/'; + } else { + $path = ''; + while (0 !== strpos($sourcePath, $targetDir)) { + if (false !== $pos = strrpos($targetDir, '/')) { + $targetDir = substr($targetDir, 0, $pos); + $path .= '../'; + } else { + $targetDir = ''; + $path .= '../'; + break; + } + } + $path .= ltrim(substr(dirname($sourcePath).'/', strlen($targetDir)), '/'); + } + + return $path; + } + + /** + * Resolves an URL from an asset. + * + * @param AssetInterface $asset the asset containing the URL + * @param string $url url read in file + * + * @return string an URL, a filepath + */ + public static function resolveUrl(AssetInterface $asset, $url) + { + // given URL is absolute URL + if (false !== strpos($url, '://')) { + return $url; + } + + $root = $asset->getSourceRoot(); + $path = dirname($asset->getTargetPath()); + + if ('.' === $path) { + $image = $url; + } else { + $image = $path.'/'.$url; + } + + if (null !== $root) { + $image = $root.'/'.$image; + } + + // cleanup local URLs + if (false === strpos($image, '://')) { + $image = self::removeQueryString($image); + $image = self::removeAnchor($image); + + return self::resolveUps($image); + } + + return $image; + } + + /** + * Resolves "../" segments in a path. + * + * "foo/bar/../baz" will return "foo/baz". + * + * @param string $path + * + * @return string + */ + public static function resolveUps($path) + { + $parts = array(); + foreach (explode('/', $path) as $part) { + if ('..' === $part && count($parts) && '..' !== end($parts)) { + array_pop($parts); + } else { + $parts[] = $part; + } + } + + return implode('/', $parts); + } + + /** + * Removes from "?" position in string. If not found, + * returns the original string. + * + * @param string $path + * + * @return string + */ + public static function removeQueryString($path) + { + if (false === $pos = strpos($path, '?')) { + return $path; + } + + $anchorPos = strpos($path, '#', $pos); + + $end = false === $anchorPos ? strlen($path) : $anchorPos; + + return substr($path, 0, $pos).substr($path, $end); + } + + /** + * Removes from "#" position in string. If not found, + * returns the original string. + * + * @param string $path + * + * @return string + */ + public static function removeAnchor($path) + { + if (false !== $pos = strpos($path, '#')) { + return substr($path, 0, $pos); + } + + return $path; + } + + /** + * Tests if an URL is a path or not. + * + * If not, it's an absolute or protocol-relative or data uri. + * + * @param string $url + * + * @return boolean + */ + public static function isPath($url) + { + return false === strpos($url, '://') && 0 !== strpos($url, '//') && 0 !== strpos($url, 'data:'); + } + + /** + * Tests if given path is a root path. + * + * @return boolean + */ + public static function isRootPath($url) + { + return isset($url[0]) && '/' == $url[0]; + } + + final private function __construct() { } +} diff --git a/DependencyInjection/AlexAsseticExtraExtension.php b/DependencyInjection/AlexAsseticExtraExtension.php new file mode 100644 index 0000000..0f2466e --- /dev/null +++ b/DependencyInjection/AlexAsseticExtraExtension.php @@ -0,0 +1,27 @@ +getConfiguration($configs, $container); + $config = $this->processConfiguration($configuration, $configs); + + if ($config['asset_directory']['enabled']) { + $loader->load('filters/assetdirectory.xml'); + $container->setParameter('assetic.filter.assetdirectory.path', $config['asset_directory']['path']); + $container->setParameter('assetic.filter.assetdirectory.target', $config['asset_directory']['target']); + } + } +} diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php new file mode 100644 index 0000000..2b65573 --- /dev/null +++ b/DependencyInjection/Configuration.php @@ -0,0 +1,30 @@ +root('alex_assetic_extra') + ->children() + ->arrayNode('asset_directory') + ->addDefaultsIfNotSet() + ->treatTrueLike(array('enabled' => true)) + ->children() + ->booleanNode('enabled')->defaultValue(false)->end() + ->scalarNode('path')->defaultValue('%kernel.root_dir%/../web/assets')->end() + ->scalarNode('target')->defaultValue('assets')->end() + ->end() + ->end() + ->end() + ; + + return $builder; + } +} diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..4dd1e6c --- /dev/null +++ b/README.rst @@ -0,0 +1,76 @@ +AlexAsseticExtraBundle +====================== + +.. image: + +.. image:: https://travis-ci.org/alexandresalome/assetic-extra-bundle.png?branch=master + :alt: Build status + :target: https://travis-ci.org/alexandresalome/assetic-extra-bundle + +Provides an additional filter for `Assetic `_: +**asset directory**. + +This filter will process your CSS and copy assets to a directory, usually in ``web/`` +folder. + +By doing so, you can include CSS images and fonts from external libraries without storing +dependency in a public folder. + +Installation +------------ + +Edit your ``composer.json`` and add the following package as a **require**: + +.. code-block:: json + + { + "require": { + "alexandresalome/assetic-extra-bundle": "dev-master" + } + } + +Edit your ``app/AppKernel.php`` and add the bundle to the **registerBundles** method: + +Configuration +------------- + +Edit your ``config.yml`` and add a section **alex_assetic_extra**: + +.. code-block:: yaml + + alex_assetic_extra: + asset_directory: + enabled: true + + # Indicates where assets should be copied to + # when processing CSS files. + path: %kernel.root_dir%/../web/assets + + # Not really clear yet + target: assets + +Or to quickly use it: + + alex_assetic_extra: + asset_directory: true + +Usage +----- + +To use it, use the filter in your ``{% stylesheets %}`` template blocks: + +.. code-block:: html+jinja + + {% stylesheets filters="combine,assetdirectory" + "@SomeBundle/Resources/assets/form.css" + "../vendor/path/to/some.js" + %} + {# ... #} + {% endstylsheets %} + +Changelog +--------- + +**v0.1** + +* Initial version diff --git a/Resources/config/filters/assetdirectory.xml b/Resources/config/filters/assetdirectory.xml new file mode 100644 index 0000000..61f2849 --- /dev/null +++ b/Resources/config/filters/assetdirectory.xml @@ -0,0 +1,30 @@ + + + + + + Alex\AsseticExtraBundle\Assetic\Filter\AssetDirectoryFilter + Alex\AsseticExtraBundle\Assetic\Util\AssetDirectory + Assetic\Cache\FilesystemCache + %kernel.cache_dir%/assetic_assets + + + + + + + + + + %assetic.filter.assetdirectory.path% + %assetic.filter.assetdirectory.target% + + + + + %assetic.filter.assetdirectory.cache.path% + + + diff --git a/Tests/Assetic/Filter/AsseticDirectoryFilterTest.php b/Tests/Assetic/Filter/AsseticDirectoryFilterTest.php new file mode 100644 index 0000000..1d94aa0 --- /dev/null +++ b/Tests/Assetic/Filter/AsseticDirectoryFilterTest.php @@ -0,0 +1,44 @@ + + */ +class AssetDirectoryFilterTest extends \PHPUnit_Framework_TestCase +{ + public function testSimple() + { + $directory = $this->getMockBuilder('Alex\AsseticExtraBundle\Assetic\Util\AssetDirectory') + ->disableOriginalConstructor() + ->getMock() + ; + + $directory + ->expects($this->any()) + ->method('getTarget') + ->will($this->returnValue('assets')) + ; + + $directory + ->expects($this->once()) + ->method('add') + ->with('images/foo.png') + ->will($this->returnValue('assets/foo.png')) + ; + + $filter = new AssetDirectoryFilter($directory); + + $asset = new StringAsset('body { background: url("../images/foo.png"); }', array($filter), null, 'css/main.css'); + $asset->setTargetPath('css/main.css'); + $asset->load(); + + $filter->filterLoad($asset); + $filter->filterDump($asset); + + $this->assertEquals('body { background: url("../assets/foo.png"); }', $asset->getContent(), 'AssetDirectoryFilter filters URL'); + } +} diff --git a/Tests/Assetic/Util/AssetDirectoryTest.php b/Tests/Assetic/Util/AssetDirectoryTest.php new file mode 100644 index 0000000..5e89ee6 --- /dev/null +++ b/Tests/Assetic/Util/AssetDirectoryTest.php @@ -0,0 +1,71 @@ +createAssetDirectory(); + $file = $this->createFile('foo'); + $expectedTarget = $dir->getDirectory().'/'.basename($file); + + $path = $dir->add($file); + $this->assertTrue(file_exists($expectedTarget), "File should be copied on disk"); + $this->assertEquals("foo", file_get_contents($expectedTarget), "File content should be correct"); + } + + public function testFileNotAddedTwice() + { + $dir = $this->createAssetDirectory(); + $file = $this->createFile('foo'); + + $this->assertEquals($dir->add($file), $dir->add($file), "Path should be the same"); + } + + public function __destruct() + { + foreach ($this->toDelete as $path) { + if (!file_exists($path) || !is_dir($path)) { + continue; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $file) { + $file->isDir() ? rmdir($file) : unlink($file); + } + } + } + + private function createFile($content) + { + $file = tempnam($dir = sys_get_temp_dir(), 'assetic_'); + file_put_contents($file, $content); + $toDelete[] = $file; + + return $file; + } + + private function createAssetDirectory($cache = true) + { + $dir = tempnam(sys_get_temp_dir(), 'assetic_'); + unlink($dir); + + if ($cache) { + $cache = new ArrayCache(); + } + + $this->toDelete[] = $dir; + + return new AssetDirectory($dir, null, $cache); + } +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..b2b50bb --- /dev/null +++ b/composer.json @@ -0,0 +1,16 @@ +{ + "name": "alexandresalome/assetic-extra-bundle", + "description": "Extra feature for Assetic (asset directory)", + + "autoload": { + "psr-0": { + "Alex\\AsseticExtraBundle": "" + } + }, + + "target-dir": "Alex/AsseticExtraBundle", + + "require": { + "kriswallsmith/assetic": ">=1.1,<2.0" + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..dcc90f7 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,32 @@ + + + + + + + + Tests + + + + + + . + + Resources + Tests + + + + +