elboletaire/less-cake-plugin

View on GitHub
src/View/Helper/LessHelper.php

Summary

Maintainability
B
5 hrs
Test Coverage
<?php
/**
 * Helper for using less.php with cakephp
 *
 * @author Òscar Casajuana <elboletaire@underave.net>
 * @license Apache-2.0
 * @copyright Òscar Casajuana 2013-2015
 */
namespace Less\View\Helper;

use Cake\Core\Configure;
use Cake\Core\Plugin;
use Cake\Log\Log;
use Cake\Utility\Inflector;
use Cake\View\Helper;
use Cake\View\View;

class LessHelper extends Helper
{
    /**
     * {@inheritdoc}
     *
     * @var array
     */
    public $helpers = [
        'Html', 'Url'
    ];

    /**
     * Default lessjs options. Some are defined on setOptions due to the need of using methods.
     *
     * @var array
     */
    public $lessjsDefaults = [
        'env' => 'production'
    ];

    /**
     * Default lessc options. Some are defined on setOptions due to the need of using methods.
     *
     * @var array
     */
    private $parserDefaults = [
        'compress' => true,
        'cache' => true
    ];

    /**
     * Stores the compilation error, in case it occurs
     *
     * @var boolean
     */
    public $error = false;

    /**
     * The css path name, where the output files will be stored
     * (including all the cache generated by less.php, if enabled)
     *
     * @var string
     */
    private $cssPath  = 'css';

    /**
     * {@inheritdoc}
     *
     * Initializes Lessc and cleans less and css paths
     *
     * @param \Cake\View\View $view   The View this helper is being attached to.
     * @param array           $config Configuration settings for the helper.
     */
    public function __construct(View $view, array $config = [])
    {
        parent::__construct($view, $config);

        // Initialize oyejorge/less.php parser
        require_once ROOT . DS . 'vendor' . DS . 'oyejorge' . DS . 'less.php' .
            DS . 'lib' . DS . 'Less' . DS . 'Autoloader.php';

        \Less_Autoloader::register();

        $this->cssPath  = WWW_ROOT . trim($this->cssPath, '/');
    }

    /**
     * Fetches less stylesheets added to css block
     * and compiles them
     *
     * @param  array $options     Options passed to less method.
     * @param  array $modifyVars  ModifyVars passed to less method.
     * @return string             Resulting parsed files.
     */
    public function fetch(array $options = [], array $modifyVars = [])
    {
        if (empty($options['overwrite'])) {
            $options['overwrite'] = true;
        }
        $overwrite = $options['overwrite'];
        unset($options['overwrite']);

        $matches = $css = $less = [];
        preg_match_all('@(<link[^>]+>)@', $this->_View->fetch('css'), $matches);

        if (empty($matches)) {
            return null;
        }

        $matches = array_shift($matches);

        foreach ($matches as $stylesheet) {
            if (strpos($stylesheet, 'rel="stylesheet/less"') !== false) {
                $match = [];
                preg_match('@href="([^"]+)"@', $stylesheet, $match);
                $file = rtrim(array_pop($match), '?');
                array_push($less, $this->less($file, $options, $modifyVars));
                continue;
            }
            array_push($css, $stylesheet);
        }

        if ($overwrite) {
            $this->_View->Blocks->set('css', join($css));
        }

        return join($less);
    }

    /**
     * Compiles any less files passed and returns the compiled css.
     * In case of error, it will load less with the javascritp parser so you'll be
     * able to see any errors on screen. If not, check out the error.log file in your
     * CakePHP's logs folder.
     *
     * @param  mixed $less         The input .less file to be compiled or an array
     *                             of .less files.
     * @param  array  $options     Options in 'js' key will be pased to the less.js
     *                             parser and options in 'parser' will be passed to the less.php parser.
     * @param  array  $modifyVars  Array of modify vars.
     * @return string
     */
    public function less($less = 'styles.less', array $options = [], array $modifyVars = [])
    {
        $options = $this->setOptions($options);
        $less    = (array)$less;

        if ($options['js']['env'] == 'development') {
            return $this->jsBlock($less, $options);
        }

        try {
            $css = $this->compile($less, $options['cache'], $options['parser'], $modifyVars);
            if (isset($options['tag']) && !$options['tag']) {
                return $css;
            }
            if (!$options['cache']) {
                return $this->Html->formatTemplate('style', ['content' => $css]);
            }
            return $this->Html->css($css);
        } catch (\Exception $e) {
            // env must be development in order to see errors on-screen
            if (Configure::read('debug')) {
                $options['js']['env'] = 'development';
            }

            $this->error = $e->getMessage();
            Log::write('error', "Error compiling less file: " . $this->error);

            return $this->jsBlock($less, $options);
        }
    }

    /**
     * Returns the required script and link tags to get less.js working
     *
     * @param  string $less    The input .less file to be loaded.
     * @param  array  $options An array of options to be passed to the `less` configuration var.
     * @return string          The link + script tags need to launch lesscss.
     */
    protected function jsBlock($less, array $options = [])
    {
        $return = '';
        $less   = (array)$less;

        // Append the user less files
        foreach ($less as $les) {
            $return .= $this->Html->meta('link', null, [
                'link' => $les,
                'rel'  => 'stylesheet/less'
            ]);
        }
        // Less.js configuration
        $return .= $this->Html->scriptBlock(sprintf('less = %s;', json_encode($options['js'], JSON_UNESCAPED_SLASHES)));
        // <script> tag for less.js file
        $return .= $this->Html->script($options['less']);

        return $return;
    }

    /**
     * Compiles an input less file to an output css file using the PHP compiler.
     *
     * @param  array   $input       The input .less files to be compiled.
     * @param  array   $options     Options to be passed to the php parser.
     * @param  array   $modifyVars  Less modifyVars.
     * @param  bool    $cache       Whether to cache or not.
     * @return string               If cache is not enabled will return the full
     *                              CSS compiled. Otherwise it will return the
     *                              resulting filename from the compilation.
     */
    protected function compile(array $input, $cache, array $options = [], array $modifyVars = [])
    {
        $parse = $this->prepareInputFilesForParsing($input);

        if ($cache) {
            $options += ['cache_dir' => $this->cssPath];
            return \Less_Cache::Get($parse, $options, $modifyVars);
        }

        $lessc = new \Less_Parser($options);

        foreach ($parse as $file => $path) {
            $lessc->parseFile($file, $path);
        }
        // ModifyVars must be called at the bottom of the parsing,
        // this way we're ensuring they override their default values.
        // http://lesscss.org/usage/#command-line-usage-modify-variable
        $lessc->ModifyVars($modifyVars);

        return $lessc->getCss();
    }

    /**
     * Prepares input files as Less.php parser wants them.
     *
     * @param  array  $input An array with all the input files.
     * @return array
     */
    protected function prepareInputFilesForParsing(array $input = [])
    {
        $parse = [];
        foreach ($input as $in) {
            $less = realpath(WWW_ROOT . $in);
            // If we have plugin notation (Plugin.less/file.less)
            // ensure to properly load the files
            list($plugin, $basefile) = $this->_View->pluginSplit($in, false);
            if (!empty($plugin)) {
                $less = realpath(Plugin::path($plugin) . 'webroot' . DS . $basefile);

                if ($less !== false) {
                    $parse[$less] = $this->assetBaseUrl($plugin, $basefile);
                    continue;
                }
            }
            if ($less !== false) {
                $parse[$less] = '';
                continue;
            }
            // Plugins without plugin notation (/plugin/less/file.less)
            list($plugin, $basefile) = $this->assetSplit($in);
            if ($file = $this->pluginAssetFile([$plugin, $basefile])) {
                $parse[$file] = $this->assetBaseUrl($plugin, $basefile);
                continue;
            }

            // Will probably throw a not found error
            $parse[$in] = '';
        }

        return $parse;
    }

    /**
     * Sets the less configuration var options based on the ones given by the user
     * and our default ones.
     *
     * Here's also where we define the import_callback used by less.php parser,
     * so it can find files successfully even if they're on plugin folders.
     *
     * @param array  $options An array of options containing our options
     *                        combined with the ones for the parsers.
     * @return array $options The resulting $options array.
     */
    protected function setOptions(array $options)
    {
        // @codeCoverageIgnoreStart
        $this->parserDefaults = array_merge($this->parserDefaults, [
            // The import callback ensures that if a file is not found in the
            // app's webroot, it will search for that file in its plugin's
            // webroot path
            'import_callback' => function ($lessTree) {
                if ($pathAndUri = $lessTree->PathAndUri()) {
                    return $pathAndUri;
                }

                $file = $lessTree->getPath();
                list($plugin, $basefile) = $this->assetSplit($file);
                $file = $this->pluginAssetFile([$plugin, $basefile]);

                if ($file) {
                    return [
                        $file,
                        $this->assetBaseUrl($plugin, $basefile)
                    ];
                }

                return null;
            }
        ]);
        // @codeCoverageIgnoreEnd

        if (empty($options['parser'])) {
            $options['parser'] = [];
        }
        if (Configure::read('debug') && !isset($options['parser']['sourceMap'])) {
            $options['parser']['sourceMap'] = true;
        }
        $options['parser'] = array_merge($this->parserDefaults, $options['parser']);

        if (empty($options['js'])) {
            $options['js'] = [];
        }
        $options['js'] = array_merge($this->lessjsDefaults, $options['js']);

        if (empty($options['less'])) {
            $options['less'] = 'Less.less.min';
        }

        if (!isset($options['cache'])) {
            $options['cache'] = true;
        }

        return $options;
    }

    /**
     * Returns tha full base url for the given asset
     *
     * @param  string $plugin Plugin where the asset resides.
     * @param  string $asset  The asset path.
     * @return string
     */
    protected function assetBaseUrl($plugin, $asset)
    {
        $dir  = dirname($asset);
        $path = !empty($dir) && $dir != '.' ? "/$dir" : null;

        return $this->Url->assetUrl($plugin . $path);
    }

    /**
     * Builds asset file path for a plugin based on url.
     *
     * @param string  $url Asset URL.
     * @return string      Absolute path for asset file.
     */
    protected function pluginAssetFile(array $url)
    {
        list($plugin, $basefile) = $url;

        if ($plugin && Plugin::loaded($plugin)) {
            return realpath(Plugin::path($plugin) . 'webroot' . DS . $basefile);
        }

        return false;
    }

    /**
     * Splits an asset URL
     *
     * @param  string $url Asset URL.
     * @return array       The plugin as first key and the rest basefile as second key.
     */
    protected function assetSplit($url)
    {
        $basefile = ltrim(ltrim($url, '.'), '/');
        $exploded = explode('/', $basefile);
        $plugin   = Inflector::camelize(array_shift($exploded));
        $basefile = implode(DS, $exploded);

        return [
            $plugin, $basefile
        ];
    }
}