Seditio Source
Root |
./othercms/xenForo 2.2.8/src/XF/CssRenderer.php
<?php

namespace XF;

use
XF\Template\Templater;

use function
is_array, is_scalar, is_string, strlen, strval;

class
CssRenderer
{
   
/**
     * @var App
     */
   
protected $app;

   
/**
     * @var Templater
     */
   
protected $templater;

   
/**
     * @var Style
     */
   
protected $style;

    protected
$renderParams = [];

   
/**
     * @var \Doctrine\Common\Cache\CacheProvider|null
     */
   
protected $cache;

    protected
$allowCached = true;
    protected
$allowFinalCacheUpdate = true;
    protected
$useDevModeCache = false;
    protected
$cacheModifierKey = '';

    protected
$includeExtraParams = true;

    protected
$lastRenderedTemplate = null;

   
/**
     * @var \Less_Parser|null
     */
   
protected $lessParser = null;
    protected
$lessPrepend = null;

    public function
__construct(App $app, Templater $templater, \Doctrine\Common\Cache\CacheProvider $cache = null)
    {
       
$this->app = $app;
       
$this->templater = $templater;
       
$this->cache = $cache;

       
$config = $app->config();
        if (
$config['development']['enabled'] || $config['designer']['enabled'])
        {
           
$this->allowCached = false;
           
$this->useDevModeCache = true;
        }

        if (\
XF::$debugMode)
        {
           
$this->cacheModifierKey = md5('debug');
        }

       
$style = $templater->getStyle();
        if (!
$style)
        {
           
$style = $this->app->get('style.fallback');
        }
       
$this->setStyle($style);
    }

    public function
setStyle(\XF\Style $style)
    {
        if (
$style->getId() === 0)
        {
           
$hueShift = $this->app->config('adminColorHueShift');
            if (
$hueShift)
            {
               
$properties = $this->app->service('XF:StyleProperty\Rebuild')->getMasterPropertiesWithHueShift($hueShift);
               
$style->setProperties($properties);

               
$this->cacheModifierKey = md5($this->cacheModifierKey . 'hueShift=' . $hueShift);
            }
        }

       
$this->style = $style;
       
$this->templater->setStyle($style);
    }

    public function
render($templates, $includeExtraParams = true)
    {
        if (!
is_array($templates))
        {
           
$templates = [$templates];
        }
       
$templates = $this->filterValidTemplates($templates);
        if (!
$templates)
        {
            return
'';
        }

       
$this->includeExtraParams = $includeExtraParams;

       
$output = $this->getFinalCachedOutput($templates);
        if (!
$output)
        {
            if (
$this->allowCached)
            {
               
$cached = $this->getIndividualCachedTemplates($templates);
            }
            else
            {
               
$cached = [];
            }

           
$output = $this->renderTemplates($templates, $cached, $errors);
           
$output = $this->prepareErrorOutput($errors) . $output;
           
$this->cacheFinalOutput($templates, $output);
        }

        return
$output;
    }

    protected function
filterValidTemplates(array $templates)
    {
        foreach (
$templates AS $key => $template)
        {
            if (!
preg_match('/^[a-z0-9_]+:[a-z0-9_.]+$/i', $template))
            {
                unset(
$templates[$key]);
                continue;
            }

            switch (
strrchr($template, '.'))
            {
                case
'.css':
                case
'.less':
                    break;

                default:
                    unset(
$templates[$key]);
            }
        }

        return
$templates;
    }

    protected function
getFinalCachedOutput(array $templates)
    {
        if (!
$this->allowCached || !$this->cache || !$this->includeExtraParams)
        {
            return
false;
        }

       
$key = $this->getFinalCacheKey($templates);
        return
$this->cache->fetch($key);
    }

    protected function
cacheFinalOutput(array $templates, $output)
    {
        if (!
is_string($output) || !strlen($output))
        {
            return;
        }

        if (
$this->allowCached && $this->allowFinalCacheUpdate && $this->cache && $this->includeExtraParams)
        {
           
$this->cache->save($this->getFinalCacheKey($templates), $output, 3600);
        }
    }

    protected function
getFinalCacheKey(array $templates)
    {
       
$elements = $this->getCacheKeyElements();

       
$templates = array_unique($templates);
       
sort($templates);

        return
'xfCssCache_' . md5(
           
'templates=' . implode(',', $templates)
            .
'style=' . $elements['style_id']
            .
'modified=' . $elements['style_last_modified']
            .
'language=' . $elements['language_id']
            .
$elements['modifier']
        );
    }

    protected function
getCacheKeyElements()
    {
       
$style = $this->style;
       
$modified = $style ? $style->getLastModified() : 0;

        return [
           
'style_id' => $this->templater->getStyleId(),
           
'style_last_modified' => $modified,
           
'language_id' => $this->templater->getLanguage()->getId(),
           
'modifier' => $this->cacheModifierKey
       
];
    }

    protected function
getIndividualCachedTemplates(array $templates)
    {
        if (!
$templates)
        {
            return [];
        }

       
$elements = $this->getCacheKeyElements();
       
$db = $this->app->db();

        return
$db->fetchPairs("
            SELECT title, output
            FROM xf_css_cache
            WHERE title IN ("
. $db->quote($templates) . ")
                AND style_id = ?
                AND language_id = ?
                AND modifier_key = ?
                AND cache_date >= ?
        "
, [
               
$elements['style_id'],
               
$elements['language_id'],
               
$elements['modifier'],
               
$elements['style_last_modified']
            ]
        );
    }

    protected function
getIndividualCachedTemplate($template)
    {
       
$output = $this->getIndividualCachedTemplates([$template]);
        return
$output[$template] ?? null;
    }

    protected function
renderTemplates(array $templates, array $cached = [], array &$errors = null)
    {
       
$output = [];
       
$errors = [];
       
$this->renderParams = $this->getRenderParams();

        if (
$this->useDevModeCache)
        {
           
// If we're in dev mode, we need to look for changes to setup.less on its own.
            // Doing this here will force them to be loaded first which will control whether we use the cache
            // in this request.
           
$this->getLessPrepend();
        }

        foreach (
$templates AS $template)
        {
           
$templateIdentifier = "/********* $template ********/\n";

            if (isset(
$cached[$template]))
            {
               
$output[$template] = $templateIdentifier .  $cached[$template];
            }
            else
            {
               
$rendered = $this->renderTemplate($template, $error);
                if (
is_string($rendered))
                {
                    if (
$this->includeExtraParams && ($this->allowCached || $this->useDevModeCache))
                    {
                       
$this->cacheTemplate($template, $rendered);
                    }

                   
$output[$template] = $templateIdentifier . $rendered;
                }
                else if (
$error)
                {
                   
$errors[$template] = $error;
                }
            }
        }

        return
implode("\n\n", $output);
    }

    protected function
getRenderParams()
    {
       
$style = $this->templater->getStyle();
       
$language = $this->templater->getLanguage();

       
$params = [
           
'xf' =>    [
               
'versionId' => \XF::$versionId,
               
'version' => \XF::$version,
               
'app' => $this->app,
               
'time' => \XF::$time,
               
'debug' => \XF::$debugMode,
               
'language' => $language,
               
'style' => $style,
               
'isRtl' => $language->isRtl(),
               
'options' => $this->app->options()
            ]
        ];

        if (
$this->includeExtraParams)
        {
            try
            {
               
$params['reactionColors'] = $this->app->container('reactionColors');
               
$params['reactionSprites'] = $this->app->container('reactionSprites');
            }
            catch (\
Exception $e)
            {
               
$params['reactionColors'] = [];
               
$params['reactionSprites'] = [];
            }

           
$params['smilieSprites'] = $this->app->container('smilieSprites');
           
$params['displayStyles'] = $this->app->container('displayStyles');
        }

       
$this->templater->addDefaultParams($params);

        return
$params;
    }

    public function
renderTemplate($template, &$error = null, &$updateCache = true)
    {
        if (!
$this->templater->isKnownTemplate($template))
        {
            return
false;
        }

        try
        {
           
$this->lastRenderedTemplate = $template;

           
$error = null;
           
$output = $this->templater->renderTemplate($template, $this->renderParams, false);
           
$updateCache = true;

            if (
               
$this->includeExtraParams
               
&& !$this->allowCached
               
&& $this->useDevModeCache
               
&& !$this->templater->hasWatcherActionedTemplates()
            )
            {
               
// if we haven't touched any templates in this pipeline, we can look for a cached template
               
$rendered = $this->getIndividualCachedTemplate($template);
                if (
is_string($rendered))
                {
                   
$updateCache = false;
                    return
$rendered;
                }
            }

            return
trim($this->renderToCss($template, $output));
        }
        catch (
CssRenderException $e)
        {
            \
XF::logException($e, false, "Error rendering template $template: ");
           
$error = $e->getMessage() . "\n" . implode("\n", $e->getContextLinesPrintable());
            return
false;
        }
        catch (\
Exception $e)
        {
            \
XF::logException($e, false, "Error rendering template $template: ");
           
$error = $e->getMessage();
            return
false;
        }
    }

   
/**
     * When given a color value which may contain a mix of XF and Less functions test and return the parsed color.
     * If the provided Less is invalid, or no valid color found, returns null.
     *
     * @param $contents
     * @return null|string
     */
   
public function parseLessColorValue($value)
    {
       
$parser = $this->getFreshLessParser();

       
$value = '@someVar: ' . $value . '; #test { color: @someVar; }';
       
$value = $this->prepareLessForRendering($value);

        try
        {
           
$css = $parser->parse($value)->getCss();
        }
        catch (\
Exception $e)
        {
            return
null;
        }

       
preg_match('/color:\s*(#(?:[0-9a-f]{2}){2,4}|(#[0-9a-f]{3})|(rgb|hsl)a?\((-?\d+%?[,\s]+){2,3}\s*[\d\.]+%?\))/i', $css, $matches);

        if (!
$matches || !isset($matches[1]))
        {
            return
null;
        }

        return
$matches[1];
    }

    protected function
renderToCss($template, $output)
    {
        switch (
strrchr($template, '.'))
        {
            case
'.less':
               
$parser = $this->getFreshLessParser();

               
$output = $this->prepareLessForRendering($output);
               
$output = $this->getLessPrepend() . $this->getLessPrependForPrefix($template) . $output;
               
$renderContents = $output;

                try
                {
                   
$output = $parser->parse($output)->getCss();
                }
                catch (\
Less_Exception_Parser $e)
                {
                    throw
CssRenderException::createFromLessException($e, $template, $renderContents);
                }
                break;

            default:
               
$output = $this->prepareCssForRendering($output);
        }

        return
$this->processRenderedCss($output);
    }

    protected function
prepareLessForRendering($contents)
    {
       
$contents = $this->processPropertyComments($contents);
       
$contents = $this->processStylePropertyShorthand($contents);
       
$contents = $this->processColorAdjustFunctions($contents);
       
$contents = $this->processColorFunctionSafety($contents);
       
$contents = $this->processAdditionalFunctions($contents);

        return
$contents;
    }

    protected function
prepareCssForRendering($contents)
    {
       
// note that we don't parse properties in CSS as they're likely to require LESS functions
       
return $contents;
    }

    protected function
processRenderedCss($css)
    {
       
$css = preg_replace_callback(
           
'/-xf-select-gadget:([^;}]*)(;|})/i',
            function (
$match)
            {
               
$color = trim($match[1]);
                if (!
$color)
                {
                    return
'';
                }

               
$color = \XF::escapeString($color, 'datauri');
               
$url = "data:image/svg+xml,"
                   
. "%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4.9 10'%3E"
                   
. "%3Cstyle%3E._xfG%7Bfill:" . $color . ";%7D%3C/style%3E"
                   
. "%3Cpath class='_xfG' d='M1.4 4.7l1.1-1.5 1 1.5m0 .6l-1 1.5-1.1-1.5'/%3E%3C/svg%3E";

                return
'background-image: url("' . $url . '") !important' . $match[2];
            },
           
$css
       
);

        return
$css;
    }

    protected function
processPropertyComments($contents)
    {
       
$contents = preg_replace_callback('#//([^\n]+)#', function($match)
        {
           
$comment = preg_replace('#(?<=[^a-z0-9-])xf-#i', '_xf-', $match[0]);

            return
$comment;
        },
$contents);

        return
$contents;
    }

    protected function
processStylePropertyShorthand($contents)
    {
       
$contents = preg_replace_callback(
           
'/
                (?<=[^a-z0-9-])
                xf-default\(\s*
                @xf-(?P<prop> [a-z0-9_]+(?!-[a-z0-9_]) (\--[a-z0-9_-]+)? )
                \s*,\s*
                (?P<fallback> (?> [^()]* (\( (?P>fallback) \))? )+ )
                \s*\)
            /ix'
,
            function(
$match)
            {
               
$prop = $this->style->getProperty($match['prop'], '');
                if (!
is_scalar($prop))
                {
                   
$prop = '';
                }
                else
                {
                   
$prop = strval($prop);
                }

                return
strlen($prop) > 0 ? $prop : $match['fallback'];
            },
           
$contents
       
);

       
$contents = preg_replace_callback('/@xf-([a-z0-9_]+(?!-[a-z0-9_])(\--[a-z0-9_-]+)?)/i', function($match)
        {
           
$value = $this->style->getProperty($match[1], '');
            return
is_scalar($value) ? $value : '';
        },
$contents);

       
$contents = preg_replace_callback('/@{xf-([a-z0-9_]+(\--[a-z0-9_-]+)?)}/i', function($match)
        {
           
$value = $this->style->getProperty($match[1], '');
            return
is_scalar($value) ? $value : '';
        },
$contents);

       
$contents = preg_replace_callback('/\.xf-([a-z0-9_]+)(\(([^)]+)*\))?(\s+?!important)?;?/i', function($match)
        {
           
$filters = isset($match[3]) ? trim($match[3]) : null;
           
$retVal = $this->style->getCssProperty($match[1], $filters);
            if (isset(
$match[4]))
            {
               
$retVal .= "/* !important directive is not supported in CSS style properties! */";
            }
            return
$retVal;
        },
$contents);

        return
$contents;
    }

    protected function
processColorAdjustFunctions($contents)
    {
        if (
$this->style->getProperty('styleType', 'light') == 'light')
        {
           
$intensify = 'darken';
           
$diminish = 'lighten';
        }
        else
        {
           
$intensify = 'lighten';
           
$diminish = 'darken';
        }

       
$contents = preg_replace('/(?<=[^a-z0-9-])xf-intensify\(/i', "{$intensify}(", $contents);
       
$contents = preg_replace('/(?<=[^a-z0-9-])xf-diminish\(/i', "{$diminish}(", $contents);

        return
$contents;
    }

    protected
$adjustColorFunctions = [
       
'saturate',
       
'desaturate',
       
'lighten',
       
'darken',
       
'fadein',
       
'fadeout',
       
'fade',
       
'spin',
       
'tint',
       
'shade'
   
];

    protected function
processColorFunctionSafety($contents)
    {
       
$fns = implode('|', $this->adjustColorFunctions);

       
$contents = preg_replace('/(?<=[^a-z0-9-])(' . $fns . ')\s*\(\s*,/i', "\\1(transparent,", $contents);

        return
$contents;
    }

    protected function
processAdditionalFunctions($contents)
    {
       
$language = $this->templater->getLanguage();

       
$contents = preg_replace_callback(
           
'/(?<=[^a-z0-9-])xf-option\(\'([a-z0-9_.]+)\'(,\s*([^\\)]+)\s*)?\)/i',
            function (array
$match)
            {
               
$options = \XF::options();

                if (
strpos($match[1], '.') !== false)
                {
                   
$keys = explode('.', $match[1], 2);
                   
$result = $options[$keys[0]][$keys[1]] ?? null;
                }
                else
                {
                   
$result = $options[$match[1]] ?? null;
                }

                if (
$result && isset($match[2], $match[3]))
                {
                   
$result .= trim($match[3]);
                }

                return
$result;

            },
           
$contents
       
);

       
$contents = preg_replace_callback(
           
'/(?<=[^a-z0-9-])xf-is(Rtl|Ltr)/i',
            function (array
$match) use ($language)
            {
                if (
strtolower($match[1]) == 'rtl')
                {
                    return (
$language->isRtl() ? 'true' : 'false');
                }
                else
                {
                    return (
$language->isRtl() ? 'false' : 'true');
                }
            },
           
$contents
       
);

        return
$contents;
    }

   
/**
     * @return \Less_Parser
     */
   
protected function getLessParser()
    {
        if (!
$this->lessParser)
        {
           
// TODO: option to enable source maps (has potential issues)

           
$isRtl = $this->templater->getLanguage()->isRtl();

           
$options = [
               
'import_callback' => [$this, 'handleLessImport'],
               
'compress' => \XF::$debugMode ? false : true,
               
'plugins' => [
                    new \
XF\Less\RtlVisitorPre(),
                    new \
XF\Less\RtlVisitor($isRtl)
                ]
            ];
           
$this->lessParser = new \Less_Parser($options);
        }

        return
$this->lessParser;
    }

   
/**
     * @return \Less_Parser
     */
   
protected function getFreshLessParser()
    {
        if (
$this->lessParser)
        {
           
$this->lessParser->Reset();
        }

        return
$this->getLessParser();
    }

    protected function
getLessPrepend()
    {
        if (
$this->lessPrepend === null)
        {
           
// this method will be more useful if we do parsed caching or source maps
            //$this->lessPrepend = '@import "public:setup.less";' . "\n\n";

           
$prepend = trim($this->templater->renderTemplate('public:setup.less', $this->renderParams)) . "\n\n";
           
$prepend = $this->prepareLessForRendering($prepend);
           
$this->lessPrepend = $prepend;
        }

        return
$this->lessPrepend;
    }

    protected
$prefixPrepend = [];

    protected function
getLessPrependForPrefix(string $template): string
   
{
        if (
strpos($template, ':') === false)
        {
            return
'';
        }

       
$template = str_replace('.less', '', $template);
        list(
$type, $template) = explode(':', $template, 2);

       
$prefix = strtok($template, '_');

        if (!isset(
$this->prefixPrepend[$prefix]))
        {
           
$prepend = '';
           
$prependTemplate = "$type:{$prefix}_prepend.less";

            if (
$this->templater->isKnownTemplate($prependTemplate))
            {
               
$prepend = trim($this->templater->renderTemplate($prependTemplate, $this->renderParams)) . "\n\n";
               
$prepend = $this->prepareLessForRendering($prepend);
            }

           
$this->prefixPrepend[$prefix] = $prepend;
        }

        return
$this->prefixPrepend[$prefix];
    }

    public function
handleLessImport(\Less_Tree_Import $import)
    {
       
$template = $import->getPath();
       
$parts = explode(':', $template, 2);
        if (!isset(
$parts[1]))
        {
            if (
$this->lastRenderedTemplate)
            {
               
$lastTemplate = explode(':', $this->lastRenderedTemplate, 2);
                if (isset(
$lastTemplate[1]))
                {
                   
$template = "$lastTemplate[0]:$template";
                }
            }
            else
            {
                throw new \
Exception("Cannot process LESS import '$template' without a template type");
            }
        }

       
$tempFile = \XF\Util\File::getTempFile();
       
file_put_contents($tempFile, $this->prepareLessForRendering($this->templater->renderTemplate($template, $this->renderParams)));

        return [
$tempFile, $template];
    }

    public function
cacheTemplate($title, $output)
    {
       
$this->app->db()->insert('xf_css_cache', [
           
'style_id' => $this->style->getId(),
           
'language_id' => $this->getLanguageId(),
           
'title' => $title,
           
'modifier_key' => $this->cacheModifierKey,
           
'output' => $output,
           
'cache_date' => $this->style->getLastModified()
        ],
false, 'output = VALUES(output), cache_date = VALUES(cache_date)');
    }

    public function
prepareErrorOutput(array $errors)
    {
        if (!
$errors || !\XF::$debugMode)
        {
            return
'';
        }

       
$errorOutput = [];
        foreach (
$errors AS $template => $error)
        {
           
$errorOutput[] = strtr("* $template: $error", [
               
'\\' => '\\\\',
               
'\'' => '',
               
"\r" => '',
               
"\n" => '\A '
           
]);
        }

        return
'body:before { display: block; content: \'Errors occurred when rendering CSS:\A ' . implode('\A \A ', $errorOutput)
            .
'\'; background: yellow; color: black; padding: 10px; margin: 10px; white-space: pre-wrap; }'
           
. "\n\n";
    }

    public function
getTemplater()
    {
        return
$this->templater;
    }

    public function
setTemplater(Templater $templater)
    {
       
$this->templater = $templater;
    }

    public function
getStyleId()
    {
        return
$this->templater->getStyleId();
    }

    public function
getLanguageId()
    {
        return
$this->templater->getLanguage()->getId();
    }

    public function
getLastModifiedDate()
    {
        if (
$this->style)
        {
            return
$this->style->getLastModified();
        }
        else
        {
            return \
XF::$time;
        }
    }

    public function
setAllowCached($value)
    {
       
$this->allowCached = (bool)$value;
        if (!
$this->allowCached)
        {
           
// if we're explicitly setting this, then don't allow the dev mode cache either
           
$this->useDevModeCache = false;
        }
    }

    public function
getAllowCached()
    {
        return
$this->allowCached;
    }

    public function
setAllowFinalCacheUpdate($value)
    {
       
$this->allowFinalCacheUpdate = (bool)$value;
    }

    public function
getAllowFinalCacheUpdate()
    {
        return
$this->allowFinalCacheUpdate;
    }
}