• Downloading from our site will require you to have a paid membership. Upgrade to a Premium Membership from 10$ a month today!

    Dont forget read our Rules! Also anyone caught Sharing this content will be banned. By using this site you are agreeing to our rules so read them. Saying I did not know is simply not an excuse! You have been warned.

Hướng dẫn fix lỗi XSS trên Xenforo 2.0.9 - XenForo 2.0.9 Released (Security Fix)

Admin

Well-Known Member
Staff member
Administrator
Xenforo mới thông báo rằng bản Xenforo 2.0.8 bị lỗi XSS

Để fix lỗi XSS bạn làm như sau
Mở src/XF/Str/Formatter.php và thay tất cả bằng

PHP:
<?php

namespace XF\Str;

class Formatter
{
    protected $censorRules = [];
    protected $censorChar = '*';
    protected $censorCache = null;

    protected $smilieTranslate = [];
    protected $smilieReverse = [];

    /**
     * @var callable|null
     */
    protected $smilieHtmlPather = null;

    /**
     * @var callable|null
     */
    protected $proxyHandler;

    protected $htmlPlaceholderId = 0;

    public function censorText($string, $censorChar = null)
    {
        if ($censorChar !== null)
        {
            $map = $this->buildCensorMap($this->censorRules, $censorChar);
        }
        else
        {
            if ($this->censorCache === null)
            {
                $this->censorCache = $this->buildCensorMap($this->censorRules, $this->censorChar);
            }
            $map = $this->censorCache;
        }

        if ($map)
        {
            $string = preg_replace(
                array_keys($map),
                $map,
                $string
            );
        }

        return $string;
    }

    public function setCensorRules(array $censorRules, $censorChar)
    {
        $this->censorRules = $censorRules;
        $this->censorChar = $censorChar;
    }

    protected function buildCensorMap(array $censor, $censorCharacter)
    {
        $map = [];

        foreach ($censor AS $key => $word)
        {
            if (is_string($key) || !isset($word['regex']) || !isset($word['replace']))
            {
                // old format or broken
                continue;
            }

            $regex = $word['regex'];
            $replace = $word['replace'];

            $map[$regex] = is_int($replace) ? str_repeat($censorCharacter, $replace) : $replace;
        }

        return $map;
    }

    public function replacePhrasePlaceholders($string, \XF\Language $language = null)
    {
        if (!preg_match_all(
            '#\{phrase:([a-z0-9_]+)\}#iU', $string, $phraseMatches, PREG_SET_ORDER
        ))
        {
            return $string;
        }

        if (!$language)
        {
            $language = \XF::language();
        }

        $replacements = [];
        foreach ($phraseMatches AS $phraseMatch)
        {
            $replacements[$phraseMatch[0]] = $language->phrase($phraseMatch[1]);
        }

        return strtr($string, $replacements);
    }

    public function replacePhraseSyntax($value, \XF\Language $language = null)
    {
        if (!preg_match_all(
            '#\{\{\s*phrase\(("|\')([a-z0-9_.]+)\\1(,\s*\{([^}]+)\})?\s*\)\s*\}\}#iU',
            $value, $phraseMatches, PREG_SET_ORDER
        ))
        {
            return $value;
        }

        if (!$language)
        {
            $language = \XF::language();
        }

        $replacements = [];
        foreach ($phraseMatches AS $phraseMatch)
        {
            $phraseParams = [];
            if (!empty($phraseMatch[4]))
            {
                preg_match_all('#("|\')([a-z0-9_]+)\\1\s*:\s*("|\')(.*)\\3#siU',
                    $phraseMatch[4], $paramMatches, PREG_SET_ORDER
                );
                foreach ($paramMatches AS $paramMatch)
                {
                    $phraseParams[$paramMatch[2]] = $paramMatch[4];
                }
            }

            $replacements[$phraseMatch[0]] = $language->phrase($phraseMatch[2], $phraseParams);
        }

        if (count($replacements) == 1 && key($replacements) == $value)
        {
            return current($replacements);
        }

        return $replacements ? strtr($value, $replacements) : $value;
    }

    public function addSmilies(array $smilies)
    {
        foreach ($smilies AS $smilie)
        {
            foreach ($smilie['smilieText'] AS $text)
            {
                $this->smilieTranslate[$text] = "\0" . $smilie['smilie_id'] . "\0";
            }

            $this->smilieReverse[$smilie['smilie_id']] = $smilie;
        }
    }

    public function setSmilieHtmlPather(callable $pather = null)
    {
        $this->smilieHtmlPather = $pather;
    }

    public function replaceSmiliesInText($text, $replaceCallback, $escapeCallback = null)
    {
        if ($this->smilieTranslate)
        {
            $text = strtr($text, $this->smilieTranslate);
        }

        if ($escapeCallback)
        {
            /** @var callable $escapeCallback */
            $text = $escapeCallback($text);
        }

        if ($this->smilieTranslate)
        {
            $reverse = $this->smilieReverse;
            $text = preg_replace_callback('#\0(\d+)\0#', function($match) use ($reverse, $replaceCallback)
            {
                $id = $match[1];
                return isset($reverse[$id]) ? $replaceCallback($id, $reverse[$id]) : '';
            }, $text);
        }

        return $text;
    }

    protected $smilieCache = [];

    public function replaceSmiliesHtml($text)
    {
        $cache = &$this->smilieCache;

        $replace = function($id, $smilie) use (&$cache)
        {
            if (isset($cache[$id]))
            {
                return $cache[$id];
            }

            $html = $this->getDefaultSmilieHtml($id, $smilie);
            $cache[$id] = $html;
            return $html;
        };

        return $this->replaceSmiliesInText($text, $replace, 'htmlspecialchars');
    }

    public function getDefaultSmilieHtml($id, array $smilie)
    {
        $smilieTitle = htmlspecialchars($smilie['title']);
        $smilieText = htmlspecialchars(reset($smilie['smilieText']));
        $pather = $this->smilieHtmlPather;

        if (empty($smilie['sprite_params']))
        {
            $url = htmlspecialchars($pather ? $pather($smilie['image_url'], 'base') : $smilie['image_url']);
            $srcSet = '';
            if (!empty($smilie['image_url_2x']))
            {
                $url2x = htmlspecialchars($pather ? $pather($smilie['image_url_2x'], 'base') : $smilie['image_url_2x']);
                $srcSet = 'srcset="' . $url2x . ' 2x"';
            }

            return '<img src="' . $url . '" ' . $srcSet . ' class="smilie" alt="' . $smilieText
                . '" title="' . $smilieTitle . '    ' . $smilieText . '" />';
        }
        else
        {
            // embed a data URI to avoid a request that doesn't respect paths fully
            $url = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
            return '<img src="' . $url . '" class="smilie smilie--sprite smilie--sprite' . $id
                . '" alt="' . $smilieText . '" title="' . $smilieTitle . '    ' . $smilieText . '" />';
        }
    }

    public function moveHtmlToPlaceholders($string, &$restorerClosure)
    {
        $placeholders = [];

        $string = preg_replace_callback(
            '#<[^>]*>#si',
            function (array $match) use (&$placeholders, &$placeholderPosition)
            {
                $placeholder = "\x1A" . $this->htmlPlaceholderId . "\x1A";
                $placeholders[$placeholder] = $match[0];

                $this->htmlPlaceholderId++;

                return $placeholder;
            },
            $string
        );

        $restorerClosure = function($string) use ($placeholders)
        {
            return strtr($string, $placeholders);
        };

        return $string;
    }

    public function removeHtmlPlaceholders($string)
    {
        return preg_replace("#\x1A\\d+\x1A#", '', $string);
    }

    public function autoLinkStructuredText($string)
    {
        $string = $this->moveHtmlToPlaceholders($string, $restorePlaceholders);

        $string = preg_replace_callback(
            '#(?<=[^a-z0-9@-]|^)(https?://|www\.)[^\s"<>{}`]+#i',
            function (array $match)
            {
                $url = $this->removeHtmlPlaceholders($match[0]);
                $url = htmlspecialchars_decode($url, ENT_QUOTES);
                $link = $this->prepareAutoLinkedUrl($url);

                if (!$link['url'])
                {
                    return $url;
                }

                $linkInfo = $this->getLinkClassTarget($link['url']);
                $classAttr = $linkInfo['class'] ? " class=\"$linkInfo[class]\"" : '';
                $targetAttr = $linkInfo['target'] ? " target=\"$linkInfo[target]\"" : '';
                $noFollowAttr = $linkInfo['trusted'] ? '' : ' rel="nofollow"';

                return '<a href="' . htmlspecialchars($link['url'], ENT_QUOTES, 'utf-8')
                    . "\"{$classAttr}{$noFollowAttr}{$targetAttr}>"
                    . htmlspecialchars($link['linkText'], ENT_QUOTES, 'utf-8') . '</a>'
                    . htmlspecialchars($link['suffixText'], ENT_QUOTES, 'utf-8');
            },
            $string
        );

        $string = $restorePlaceholders($string);

        return $string;
    }

    public function getLinkClassTarget($url)
    {
        $target = '_blank';
        $class = 'link link--external';
        $type = 'external';
        $schemeMatch = true;

        $urlInfo = @parse_url($url);
        if ($urlInfo)
        {
            if (empty($urlInfo['host']))
            {
                $isInternal = true;
            }
            else
            {
                $request = \XF::app()->request();
                $host = $urlInfo['host'] . (!empty($urlInfo['port']) ? ":$urlInfo[port]" : '');
                $isInternal = ($host == $request->getHost());

                $scheme = (!empty($urlInfo['scheme']) ? strtolower($urlInfo['scheme']) : 'http');
                $schemeMatch = $scheme == ($request->isSecure() ? 'https' : 'http');
            }

            if ($isInternal)
            {
                $target = '';
                $class = 'link link--internal';
                $type = 'internal';
            }
        }

        return [
            'class' => $class,
            'target' => $target,
            'type' => $type,
            'trusted' => $type == 'internal',
            'local' => $type == 'internal' && $schemeMatch
        ];
    }

    public function prepareAutoLinkedUrl($url)
    {
        $suffixText = '';

        if (preg_match('/&(?:quot|gt|lt);/i', $url, $match, PREG_OFFSET_CAPTURE))
        {
            $suffixText = substr($url, $match[0][1]);
            $url = substr($url, 0, $match[0][1]);
        }

        $linkText = $url;

        if (strpos($url, '://') === false)
        {
            $url = 'http://' . $url;
        }

        do
        {
            $matchedTrailer = false;
            $lastChar = substr($url, -1);
            switch ($lastChar)
            {
                case ')':
                case ']':
                    $closer = $lastChar;
                    $opener = $lastChar == ']' ? '[' : '(';

                    if (substr_count($url, $closer) == substr_count($url, $opener))
                    {
                        break;
                    }
                    // break missing intentionally

                case '(':
                case '[':
                case '.':
                case ',':
                case '!':
                case ':':
                case "'":
                    $suffixText = $lastChar . $suffixText;
                    $url = substr($url, 0, -1);
                    $linkText = substr($linkText, 0, -1);

                    $matchedTrailer = true;
                    break;
            }
        }
        while ($matchedTrailer);

        if (preg_match('/proxy\.php\?[a-z0-9_]+=(http[^&]+)&/i', $url, $match))
        {
            // proxy link of some sort, adjust to the original one
            $proxiedUrl = urldecode($match[1]);
            if (preg_match('/./u', $proxiedUrl))
            {
                if ($proxiedUrl == $linkText)
                {
                    $linkText = $proxiedUrl;
                }
                $url = $proxiedUrl;
            }
        }

        if (!\XF::app()->validator('Url')->isValid($url))
        {
            $url = null;
        }

        return [
            'url' => $url,
            'linkText' => $linkText,
            'suffixText' => $suffixText
        ];
    }

    public function linkStructuredTextMentions($string)
    {
        $string = $this->moveHtmlToPlaceholders($string, $restorePlaceholders);

        $string = preg_replace_callback(
            '#(?<=^|\s|[\](,]|--|@)@\[(\d+):(\'|"|&quot;|)(.*)\\2\]#iU',
            function(array $match)
            {
                $userId = intval($match[1]);
                $username = $this->removeHtmlPlaceholders($match[3]);
                $username = htmlspecialchars($username, ENT_QUOTES, 'utf-8', false);

                $link = \XF::app()->router('public')->buildLink('full:members', ['user_id' => $userId]);

                return sprintf('<a href="%s" class="username" data-user-id="%d" data-username="%s" data-xf-init="member-tooltip">%s</a>',
                    htmlspecialchars($link), $userId, $username, $username
                );
            },
            $string
        );

        $string = $restorePlaceholders($string);

        return $string;
    }

    public function getProxiedUrlIfActive($type, $url)
    {
        if (!$this->proxyHandler)
        {
            return null;
        }

        $handler = $this->proxyHandler;
        return $handler($type, $url);
    }

    public function setProxyHandler(callable $handler = null)
    {
        $this->proxyHandler = $handler;
    }

    public function getProxyHandler()
    {
        return $this->proxyHandler;
    }

    public function wholeWordTrim($string, $maxLength, $offset = 0, $ellipsis = '...')
    {
        $ellipsisLen = strlen($ellipsis);

        if ($offset)
        {
            $string = preg_replace('/^\S*\s+/s', '', utf8_substr($string, $offset));
            if ($maxLength > 0)
            {
                $maxLength = max(1, $maxLength - $ellipsisLen);
            }
        }

        $strLength = utf8_strlen($string);
        if ($maxLength > 0 && $strLength > $maxLength)
        {
            $maxLength -= $ellipsisLen;

            if ($maxLength > 0)
            {
                $string = utf8_substr($string, 0, $maxLength);
                $string = strrev(preg_replace('/^\S*\s+/s', '', strrev($string)));
                $string = rtrim($string, ',.!?:;') . $ellipsis;
            }
            else if ($maxLength <= 0)
            {
                // too short with the ellipsis, can't really display anything
                $string = $ellipsis;
                $offset = 0;
            }
        }

        if ($offset)
        {
            $string = $ellipsis . $string;
        }

        return $string;
    }

    public function wholeWordTrimAroundTerm($string, $maxLength, $term, $ellipsis = '...')
    {
        $stringLength = utf8_strlen($string);

        if ($stringLength > $maxLength)
        {
            $term = strval($term);

            if ($term !== '')
            {
                // TODO: slightly more intelligent search term matching, breaking up multiple words etc.
                $termPosition = utf8_strpos(utf8_strtolower($string), utf8_strtolower($term));
            }
            else
            {
                $termPosition = false;
            }

            if ($termPosition !== false)
            {
                $startPos = $termPosition + utf8_strlen($term); // add term length to term start position
                $startPos -= $maxLength / 2; // count back half the max characters
                $startPos = max(0, $startPos); // don't overflow the beginning
                $startPos = min($startPos, $stringLength - $maxLength); // don't overflow the end
            }
            else
            {
                $startPos = 0;
            }

            $string = $this->wholeWordTrim($string, $maxLength, $startPos);
        }

        return $string;
    }

    public function highlightTermForHtml($string, $term, $class = 'textHighlight')
    {
        $term = trim(preg_replace('#((^|\s)[+|-]|[/()"~^])#', ' ', strval($term)));
        if ($term !== '')
        {
            return preg_replace(
                '/(' . preg_replace('#\s+#', '|', preg_quote(htmlspecialchars($term), '/')) . ')/siu',
                '<em class="' . htmlspecialchars($class) . '">\1</em>',
                \XF::escapeString($string)
            );
        }
        else
        {
            return \XF::escapeString($string);
        }
    }

    public function stripBbCode($string, array $options = [])
    {
        $options = array_merge([
            'stripQuote' => false,
            'hideUnviewable' => true
        ], $options);

        if ($options['stripQuote'])
        {
            $parts = preg_split('#(\[quote[^\]]*\]|\[/quote\])#i', $string, -1, PREG_SPLIT_DELIM_CAPTURE);
            $string = '';
            $quoteLevel = 0;
            foreach ($parts AS $i => $part)
            {
                if ($i % 2 == 0)
                {
                    // always text, only include if not inside quotes
                    if ($quoteLevel == 0)
                    {
                        $string .= rtrim($part) . "\n";
                    }
                }
                else
                {
                    // quote start/end
                    if ($part[1] == '/')
                    {
                        // close tag, down a level if open
                        if ($quoteLevel)
                        {
                            $quoteLevel--;
                        }
                    }
                    else
                    {
                        // up a level
                        $quoteLevel++;
                    }
                }
            }
        }

        // replaces unviewable tags with a text representation
        $string = str_replace('[*]', '', $string);
        $string = preg_replace(
            '#\[(attach|media|img|spoiler)[^\]]*\].*\[/\\1\]#siU',
            $options['hideUnviewable'] ? '' : '[\\1]',
            $string
        );

        // split the string into possible delimiters and text; even keys (from 0) are strings, odd are delimiters
        $parts = preg_split('#(\[[a-z0-9_]+(?:=[^\]]*)?\]|\[/[a-z0-9_]+\])#si', $string, -1, PREG_SPLIT_DELIM_CAPTURE);
        $total = count($parts);
        if ($total < 2)
        {
            return trim($string);
        }

        $closes = [];
        $skips = [];
        $newString = '';

        // first pass: find all the closing tags and note their keys
        for ($i = 1; $i < $total; $i += 2)
        {
            if (preg_match("#^\\[/([a-z0-9]+)]#i", $parts[$i], $match))
            {
                $closes[strtolower($match[1])][$i] = $i;
            }
        }

        // second pass: look for all the text elements and any opens, then find
        // the first corresponding close that comes after it and remove it.
        // if we find that, don't display the open or that close
        for ($i = 0; $i < $total; $i++)
        {
            $part = $parts[$i];
            if ($i % 2 == 0)
            {
                // string part
                $newString .= $part;
                continue;
            }

            if (!empty($skips[$i]))
            {
                // known close
                continue;
            }

            if (preg_match('/^\[([a-z0-9]+)(?:=|\])/i', $part, $match))
            {
                $tagName = strtolower($match[1]);
                if (!empty($closes[$tagName]))
                {
                    do
                    {
                        $closeKey = reset($closes[$tagName]);
                        if ($closeKey)
                        {
                            unset($closes[$tagName][$closeKey]);
                        }
                    }
                    while ($closeKey && $closeKey < $i);
                    if ($closeKey)
                    {
                        // found a matching close after this tag
                        $skips[$closeKey] = true;
                        continue;
                    }
                }
            }

            $newString .= $part;
        }

        return trim($newString);
    }

    public function getBbCodeForQuote($bbCode, $context)
    {
        $bbCodeContainer = \XF::app()->bbCode();

        $processor = $bbCodeContainer->processor()
            ->addProcessorAction('quotes', $bbCodeContainer->processorAction('quotes'))
            ->addProcessorAction('censor', $bbCodeContainer->processorAction('censor'));

        return trim($processor->render($bbCode, $bbCodeContainer->parser(), $bbCodeContainer->rules($context)));
    }

    public function getBbCodeFromSelectionHtml($html)
    {
        // attempt to parse the selected HTML into BB code
        $html = trim(strip_tags($html, '<b><i><u><a><img><span><ul><ol><li><pre><code><br>'));

        // handle CODE output and turn it back into BB code
        $html = preg_replace_callback('/<code data-language="(\w+)">(.*)<\/code>/siU', function(array $matches)
        {
            return "[CODE=$matches[1]]" . str_replace("\n", '<br>', trim($matches[2])) . "[/CODE]";
        }, $html);

        // handle ICODE output to BB code
        $html = preg_replace_callback('/<code class="bbCodeInline">(.*)<\/code>/siU', function(array $matches)
        {
            return "[ICODE]" . trim($matches[1]) . "[/ICODE]";
        }, $html);

        return $html;
    }

    public function snippetString($string, $maxLength = 0, array $options = [])
    {
        $options = array_merge([
            'term' => '',
            'fromStart' => false,
            'stripBbCode' => false,
            'stripQuote' => false,
            'stripHtml' => false,
            'stripPlainTag' => false,
            'censor' => true
        ], $options);

        if ($options['stripQuote'])
        {
            $options['stripBbCode'] = true;
        }

        if ($options['stripHtml'])
        {
            $string = strip_tags($string);
        }
        else if ($options['stripPlainTag'])
        {
            $string = preg_replace(
                '#(?<=^|\s|[\](,]|--|@)@\[(\d+):(\'|"|&quot;|)(.*)\\2\]#iU',
                '\\3',
                $string
            );
        }
        else if ($options['stripBbCode'])
        {
            $string = $this->stripBbCode($string, ['stripQuote' => $options['stripQuote']]);
        }

        if ($maxLength)
        {
            if ($options['fromStart'] || !$options['term'])
            {
                $string = $this->wholeWordTrim($string, $maxLength);
            }
            else
            {
                $string = $this->wholeWordTrimAroundTerm($string, $maxLength, $options['term']);
            }
        }

        $string = trim($string);

        if ($options['censor'])
        {
            $string = $this->censorText($string);
        }

        return $string;
    }

    public function createKeyValueSetFromString($string)
    {
        $values = [];

        preg_match_all('/
            ^\s*
            (?P<name>([^=\r\n])*?)
            \s*=\s*
            (?P<value>.*?)
            \s*$
        /mix', trim($string), $matches, PREG_SET_ORDER);

        foreach ($matches AS $match)
        {
            $value = $this->replacePhraseSyntax($match['value']);
            $values[$match['name']] = $value;
        }

        return $values;
    }

    /**
     * @return \XF\Str\MentionFormatter
     */
    public function getMentionFormatter()
    {
        $class = \XF::extendClass('XF\Str\MentionFormatter');
        return new $class();
    }
}

Mở src/XF/Template/Templater.php và thay tất cả bằng

PHP:
<?php

namespace XF\Template;

use XF\App;
use XF\Language;
use XF\Mvc\Entity\AbstractCollection;
use XF\Mvc\Router;
use XF\Util\Arr;

class Templater
{
    const MAX_EXECUTION_DEPTH = 50;

    /**
     * @var App
     */
    protected $app;

    /**
     * @var Router
     */
    protected $router;
    protected $routerType;

    /**
     * @var \Closure
     */
    protected $pather;

    protected $jsBaseUrl = 'js';

    /**
     * @var Language
     */
    protected $language;

    protected $compiledPath;

    protected $styleId = 0;

    /**
     * @var callable|null
     */
    protected $cssValidator;

    /**
     * @var \XF\Style|null
     */
    protected $style;

    protected $filters = [];
    protected $functions = [];
    protected $tests = [];

    protected $defaultParams = [];

    protected $templateCache = [];

    protected $jQueryVersion;
    protected $jQuerySource = 'local';
    protected $jsVersion = '';

    protected $dynamicDefaultAvatars = true;

    protected $mediaSites = [];

    protected $groupStyles = [];
    protected $userTitleLadder = [];
    protected $userTitleLadderField = 'trophy_points';
    protected $userBanners = [];
    protected $userBannerConfig = [];

    protected $widgetPositions = [];

    /**
     * @var WatcherInterface[]
     */
    protected $watchers = [];

    protected $currentTemplateType;
    protected $currentTemplateName;

    protected $wrapTemplateName = null;
    protected $wrapTemplateParams = null;

    protected $executionDepth = 0;
    protected $templateErrors = [];

    protected $escapeContext = 'html';

    protected $includeCss = [];
    protected $inlineCss = [];
    protected $includeJs = [];
    protected $inlineJs = [];

    protected $sidebar = [];
    protected $sideNav = [];

    protected $uniqueIdCounter = 0;
    protected $uniqueIdPrefix;
    protected $uniqueIdFormat = '_xfUid-%s';

    protected $avatarDefaultStylingCache = [];
    protected $avatarLetterRegex = '/[^\(\)\{\}\[\]\<\>\-\.\+\:\=\*\!\|\^\/\\\\\'`"_,#~ ]/u';

    public $pageParams = [];

    protected $defaultFilters = [
        'default' => 'filterDefault',
        'censor' => 'filterCensor',
        'currency' => 'filterCurrency',
        'escape' => 'filterEscape',
        'for_attr' => 'filterForAttr',
        'file_size' => 'filterFileSize',
        'first' => 'filterFirst',
        'hex' => 'filterHex',
        'host' => 'filterHost',
        'ip' => 'filterIp',
        'join' => 'filterJoin',
        'json' => 'filterJson',
        'last' => 'filterLast',
        'nl2br' => 'filterNl2Br',
        'nl2nl' => 'filterNl2Nl',
        'number' => 'filterNumber',
        'number_short' => 'filterNumberShort',
        'pad' => 'filterPad',
        'parens' => 'filterParens',
        'pluck' => 'filterPluck',
        'preescaped' => 'filterPreEscaped',
        'raw' => 'filterRaw',
        'replace' => 'filterReplace',
        'split' => 'filterSplit',
        'strip_tags' => 'filterStripTags',
        'to_lower' => 'filterToLower',
        'to_upper' => 'filterToUpper',
        'url' => 'filterUrl',
        'urlencode' => 'filterUrlencode',
        'zerofill' => 'filterZeroFill',
    ];

    protected $defaultFunctions = [
        'anchor_target' => 'fnAnchorTarget',
        'array_keys' => 'fnArrayKeys',
        'array_merge' => 'fnArrayMerge',
        'array_values' => 'fnArrayValues',
        'attributes' => 'fnAttributes',
        'avatar' => 'fnAvatar',
        'base_url' => 'fnBaseUrl',
        'bb_code' => 'fnBbCode',
        'bb_code_type' => 'fnBbCodeType',
        'button_icon' => 'fnButtonIcon',
        'callable' => 'fnCallable',
        'captcha' => 'fnCaptcha',
        'ceil' => 'fnCeil',
        'contains' => 'fnContains',
        'copyright' => 'fnCopyright',
        'core_js' => 'fnCoreJs',
        'count' => 'fnCount',
        'csrf_input' => 'fnCsrfInput',
        'csrf_token' => 'fnCsrfToken',
        'css_url' => 'fnCssUrl',
        'date' => 'fnDate',
        'date_from_format' => 'fnDateFromFormat',
        'date_dynamic' => 'fnDateDynamic',
        'date_time' => 'fnDateTime',
        'debug_url' => 'fnDebugUrl',
        'display_totals' => 'fnDisplayTotals',
        'dump' => 'fnDump',
        'dump_simple' => 'fnDumpSimple',
        'empty' => 'fnEmpty',
        'file_size' => 'fnFileSize',
        'floor' => 'fnFloor',
        'gravatar_url' => 'fnGravatarUrl',
        'highlight' => 'fnHighlight',
        'in_array' => 'fnInArray',
        'is_array' => 'fnIsArray',
        'is_addon_active' => 'fnIsAddonActive',
        'is_editor_capable' => 'fnIsEditorCapable',
        'is_toggled' => 'fnIsToggled',
        'js_url' => 'fnJsUrl',
        'last_pages' => 'fnLastPages',
        'likes' => 'fnLikes',
        'likes_content' => 'fnLikesContent',
        'link' => 'fnLink',
        'link_type' => 'fnLinkType',
        'max_length' => 'fnMaxLength',
        'media_sites' => 'fnMediaSites',
        'mustache' => 'fnMustache',
        'number' => 'fnNumber',
        'named_colors' => 'fnNamedColors',
        'page_description' => 'fnPageDescription',
        'page_h1' => 'fnPageH1',
        'page_nav' => 'fnPageNav',
        'page_title' => 'fnPageTitle',
        'parens' => 'fnParens',
        'parse_less_color' => 'fnParseLessColor',
        'prefix' => 'fnPrefix',
        'prefix_group' => 'fnPrefixGroup',
        'prefix_title' => 'fnPrefixTitle',
        'property' => 'fnProperty',
        'rand' => 'fnRand',
        'range' => 'fnRange',
        'redirect_input' => 'fnRedirectInput',
        'repeat' => 'fnRepeat',
        'repeat_raw' => 'fnRepeatRaw',
        'show_ignored' => 'fnShowIgnored',
        'smilie' => 'fnSmilie',
        'snippet' => 'fnSnippet',
        'strlen' => 'fnStrlen',
        'structured_text' => 'fnStructuredText',
        'templater' => 'fnTemplater',
        'time' => 'fnTime',
        'transparent_img' => 'fnTransparentImg',
        'trim' => 'fnTrim',
        'unique_id' => 'fnUniqueId',
        'user_activity' => 'fnUserActivity',
        'user_banners' => 'fnUserBanners',
        'user_blurb' => 'fnUserBlurb',
        'user_title' => 'fnUserTitle',
        'username_link' => 'fnUsernameLink',
        'username_link_email' => 'fnUsernameLinkEmail',
        'widget_data' => 'fnWidgetData'
    ];

    protected $defaultTests = [
        'empty' => 'testEmpty'
    ];

    protected $overlayClickOptions = [
        'data-cache',
        'data-overlay-config',
        'data-force-flash-messages',
        'data-follow-redirects'
    ];

    public function __construct(App $app, Language $language, $compiledPath)
    {
        $this->app = $app;
        $this->language = $language;
        $this->compiledPath = $compiledPath;

        $this->router = $app->router();
        $this->pather = $app->container('request.pather');
        $this->uniqueIdFormat = '_xfUid-%s-' . \XF::$time;
    }

    public function getTemplateFilePath($type, $name, $styleIdOverride = null)
    {
        return $this->compiledPath
            . '/l' . $this->language->getId()
            . '/s' . intval($styleIdOverride !== null ? $styleIdOverride : $this->styleId)
            . '/' . preg_replace('/[^a-zA-Z0-9_.-]/', '', $type)
            . '/' .  preg_replace('/[^a-zA-Z0-9_.-]/', '', $name) . '.php';
    }

    protected function getTemplateDataFromSource($type, $name)
    {
        $file = $this->getTemplateFilePath($type, $name);
        if (!file_exists($file))
        {
            return false;
        }

        return include($file);
    }

    public function getRouter()
    {
        if ($this->currentTemplateType && $this->currentTemplateType != $this->routerType)
        {
            $container = $this->app->container();
            $type = $this->currentTemplateType;

            /** @var \XF\Mvc\Router|null $router */
            $router = isset($container['router.' . $type]) ? $container['router.' . $type] : null;
            if ($router)
            {
                $this->router = $router;
                $this->routerType = $type;
            }
        }

        return $this->router;
    }

    public function getCssLoadUrl(array $templates, $includeValidation = true)
    {
        $url = 'css.php?css='
            . urlencode(implode(',', $templates))
            . '&s=' . $this->styleId
            . '&l=' . $this->language->getId()
            . '&d=' . ($this->style ? $this->style->getLastModified() : \XF::$time);

        if ($includeValidation)
        {
            $validationKey = $this->getCssValidationKey($templates);
            if ($validationKey)
            {
                $url .= '&k=' . urlencode($validationKey);
            }
        }

        $pather = $this->pather;
        return $pather($url, 'base');
    }

    public function getCssValidationKey(array $templates)
    {
        if ($this->cssValidator)
        {
            $cssValidator = $this->cssValidator;
            return $cssValidator($templates);
        }
        else
        {
            return null;
        }
    }

    public function getJsUrl($js)
    {
        if (preg_match('#^(/|[a-z]+:)#i', $js))
        {
            return $js;
        }

        if (!strpos($js, '_v='))
        {
            $js = $js . (strpos($js, '?') ? '&' : '?') . $this->getJsCacheBuster();
        }

        $pather = $this->pather;
        return $pather("{$this->jsBaseUrl}/$js", 'base');
    }

    public function getJsCacheBuster()
    {
        return '_v=' . $this->jsVersion;
    }

    public function getDevJsUrl($addOnId, $js)
    {
        $url = 'js/devjs.php?addon_id=' . urlencode($addOnId) . '&js=' . urlencode($js);

        $pather = $this->pather;
        return $pather($url, 'base');
    }

    public function setLanguage(Language $language)
    {
        $this->language = $language;
    }

    public function getLanguage()
    {
        return $this->language;
    }

    public function setStyle(\XF\Style $style)
    {
        $this->style = $style;
        $this->styleId = $style->getId();
    }

    public function getStyle()
    {
        return $this->style;
    }

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

    public function setCssValidator(callable $cssValidator)
    {
        $this->cssValidator = $cssValidator;
    }

    public function setJquerySource($version, $jQuerySource = null)
    {
        $this->jQueryVersion = $version;
        $this->jQuerySource = $jQuerySource ?: $this->app->options()->jQuerySource;
    }

    public function setJsVersion($version)
    {
        $this->jsVersion = $version;
    }

    public function setJsBaseUrl($baseUrl)
    {
        $this->jsBaseUrl = rtrim($baseUrl, '/') ?: 'js';
    }

    public function setDynamicDefaultAvatars($dynamic)
    {
        $this->dynamicDefaultAvatars = $dynamic;
    }

    public function setMediaSites(array $mediaSites)
    {
        $this->mediaSites = $mediaSites;
    }

    public function setUserTitleLadder(array $ladder, $titleField = '')
    {
        $this->userTitleLadder = $ladder;
        if ($titleField)
        {
            $this->userTitleLadderField = $titleField;
        }
    }

    public function setUserBanners(array $banners, array $config = [])
    {
        $this->userBanners = $banners;
        if ($config)
        {
            $this->userBannerConfig = $config;
        }
    }

    public function setGroupStyles(array $styles)
    {
        $this->groupStyles = $styles;
    }

    public function setWidgetPositions(array $widgetPositions)
    {
        $this->widgetPositions = $widgetPositions;
    }

    public function addDefaultHandlers()
    {
        $this->addFilters($this->defaultFilters);
        $this->addFunctions($this->defaultFunctions);
        $this->addTests($this->defaultTests);
    }

    public function addFilters(array $filters)
    {
        $this->filters = array_merge($this->filters, $filters);
    }

    public function addFilter($name, $filter)
    {
        $this->filters[$name] = $filter;
    }

    public function addFunctions(array $functions)
    {
        $this->functions = array_merge($this->functions, $functions);
    }

    public function addFunction($name, $function)
    {
        $this->functions[$name] = $function;
    }

    public function addTests(array $tests)
    {
        $this->tests = array_merge($this->tests, $tests);
    }

    public function addTest($name, $test)
    {
        $this->tests[$name] = $test;
    }

    public function addDefaultParams(array $params)
    {
        $this->defaultParams = array_merge($this->defaultParams, $params);
    }

    public function addDefaultParam($name, $value)
    {
        $this->defaultParams[$name] = $value;
    }

    public function getTemplate($name, array $params = [])
    {
        return new Template($this, $name, $params);
    }

    public function addTemplateWatcher(WatcherInterface $watcher)
    {
        $this->watchers[] = $watcher;
    }

    public function hasWatcherActionedTemplates()
    {
        foreach ($this->watchers AS $watcher)
        {
            if ($watcher->hasActionedTemplates())
            {
                return true;
            }
        }

        return false;
    }

    public function getTemplateTypeAndName($template)
    {
        $parts = explode(':', $template, 2);
        if (count($parts) == 2)
        {
            return [$parts[0], $parts[1]];
        }
        else
        {
            return [$this->currentTemplateType, $parts[0]];
        }
    }

    public function applyDefaultTemplateType($template)
    {
        list($type, $template) = $this->getTemplateTypeAndName($template);

        if ($type)
        {
            $template = "$type:$template";
        }

        return $template;
    }

    /**
     * @param string $type
     * @param string $template
     *
     * @return \Closure
     */
    public function getTemplateCode($type, $template)
    {
        $data = $this->getTemplateData($type, $template);
        return $data['code'];
    }

    /**
     * @param string $type
     * @param string $template
     * @param string $macro
     *
     * @return \Closure
     */
    public function getTemplateMacro($type, $template, $macro)
    {
        $data = $this->getTemplateData($type, $template);
        if (isset($data['macros'][$macro]))
        {
            return $data['macros'][$macro];
        }

        trigger_error("Macro $type:$template:$macro is unknown", E_USER_WARNING);
        return function() { return ''; };
    }

    protected function getTemplateData($type, $template, $errorOnUnknown = true)
    {
        if (isset($this->templateCache[$type][$template]))
        {
            return $this->templateCache[$type][$template];
        }

        if (preg_match('#[^a-zA-Z0-9_.-]#', $template))
        {
            throw new \InvalidArgumentException("Template name '$template' contains invalid characters");
        }

        foreach ($this->watchers AS $watcher)
        {
            $watcher->watchTemplate($this, $type, $template);
        }

        $data = $this->getTemplateDataFromSource($type, $template);
        if (!$data || !is_array($data) || !isset($data['code']))
        {
            if ($errorOnUnknown)
            {
                trigger_error("Template $type:$template is unknown", E_USER_WARNING);
            }

            $data = [
                'macros' => [],
                'code' => function() { return ''; },
                'unknown' => true
            ];
        }

        $this->templateCache[$type][$template] = $data;

        return $data;
    }

    public function callAdsMacro($position, array $arguments, array $globalVars)
    {
        $templateData = $this->getTemplateData('public', '_ads', false);
        if (!isset($templateData['macros'][$position]))
        {
            return '';
        }
        else
        {
            return $this->callMacro('public:_ads', $position, $arguments, $globalVars);
        }
    }

    public function callMacro($template, $name, array $arguments, array $globalVars)
    {
        if ($this->executionDepth >= self::MAX_EXECUTION_DEPTH)
        {
            trigger_error('Max template execution depth reached', E_USER_WARNING);
            return '';
        }

        if (!$template)
        {
            $template = $this->currentTemplateName;
            $type = $this->currentTemplateType;
        }
        else
        {
            list($type, $template) = $this->getTemplateTypeAndName($template);
        }

        if (!$type)
        {
            trigger_error('No template type was provided. Provide template name in type:name format.', E_USER_WARNING);
            return '';
        }

        if (isset($globalVars['__globals']))
        {
            $globalVars = $globalVars['__globals'];
        }

        $this->app->fire(
            'templater_macro_pre_render',
            [$this, &$type, &$template, &$name, &$arguments, &$globalVars],
            "$type:$template:$name"
        );

        $currentType = $this->currentTemplateType;
        $currentName = $this->currentTemplateName;
        $origWrapTemplateName = $this->wrapTemplateName;
        $origWrapTemplateParams = $this->wrapTemplateParams;

        $this->currentTemplateType = $type;
        $this->currentTemplateName = $template;
        $this->wrapTemplateName = null;
        $this->wrapTemplateParams = null;
        $this->executionDepth++;

        set_error_handler([$this, 'handleTemplateError']);

        try
        {
            $macro = $this->getTemplateMacro($type, $template, $name);
            $output = $macro($this, $arguments, $globalVars);
        }
        catch (\Exception $e)
        {
            $errorPrefix = "$this->currentTemplateType:$this->currentTemplateName :: $name()";
            $this->app->logException($e, false, "Macro $errorPrefix error: ");

            if (\XF::$debugMode)
            {
                $message = $e->getMessage();
                $file = $e->getFile() . ':' . $e->getLine();
                $error = $e instanceof \XF\PrintableException ?
                    "$errorPrefix - $message"
                    : "$errorPrefix - $message in $file";

                if (preg_match('/\.(css|less)$/i', $template))
                {
                    $error = strtr($error, [
                        "'" => '',
                        '\\' => '/',
                        "\r" => '',
                        "\n" => " "
                    ]);

                    $output = "
                        /** Error output **/
                        body:before
                        {
                            background-color: #ccc;
                            color: black;
                            font-weight: bold;
                            display: block;
                            padding: 10px;
                            margin: 10px;
                            border: solid 1px #aaa;
                            border-radius: 5px;
                            content: 'CSS error: " . $error . "';
                        }
                    ";
                }
                else
                {
                    $output = '<div class="error"><h3>Template Compilation Error</h3>'
                        . '<div>' . htmlspecialchars($error) . '</div></div>';
                }
            }
            else
            {
                $output = '';
            }
        }

        restore_error_handler();

        if ($this->wrapTemplateName)
        {
            $output = $this->applyWrappedTemplate($output);
        }

        $this->currentTemplateName = $currentName;
        $this->currentTemplateType = $currentType;
        $this->wrapTemplateName = $origWrapTemplateName;
        $this->wrapTemplateParams = $origWrapTemplateParams;
        $this->executionDepth--;

        $this->app->fire(
            'templater_macro_post_render',
            [$this, $type, $template, &$name, &$output],
            "$type:$template:$name"
        );

        return $output;
    }

    public function renderMacro($template, $name, array $arguments = [])
    {
        return $this->callMacro($template, $name, $arguments, $this->defaultParams);
    }

    public function setupBaseParamsForMacro(array $parentVars, $isGlobal = false)
    {
        if (isset($parentVars['__globals']))
        {
            $globalVars = $parentVars['__globals'];
        }
        else
        {
            $globalVars = $parentVars;
        }

        $params = $isGlobal ? $globalVars : $this->defaultParams;
        $params['__globals'] = $globalVars;

        return $params;
    }

    public function mergeMacroArguments(array $expected, array $provided, array $baseParams)
    {
        foreach ($expected AS $argument => $value)
        {
            if (array_key_exists($argument, $provided))
            {
                $baseParams[$argument] = $provided[$argument];
            }
            else if ($value === '!')
            {
                throw new \LogicException("Macro argument $argument is required and no value was provided");
            }
            else
            {
                $baseParams[$argument] = $value;
            }
        }

        return $baseParams;
    }

    public function extractIntoVarContainer(array &$varContainer, $source)
    {
        if (!$this->isTraversable($source))
        {
            return;
        }

        foreach ($source AS $k => $v)
        {
            $varContainer[$k] = $v;
        }
    }

    public function wrapTemplate($template, array $params)
    {
        $template = $this->applyDefaultTemplateType($template);

        $this->wrapTemplateName = $template;
        $this->wrapTemplateParams = $params;
    }

    protected function applyWrappedTemplate($content)
    {
        if (!$this->wrapTemplateName)
        {
            return $content;
        }

        $template = $this->wrapTemplateName;
        $params = $this->wrapTemplateParams;

        $this->wrapTemplateName = null;
        $this->wrapTemplateParams = null;

        $params['innerContent'] = $this->preEscaped($content, 'html');

        return $this->renderTemplate($template, $params, false);
    }

    public function filter($value, array $filters, $escape = true)
    {
        foreach ($filters AS $filter)
        {
            list($name, $arguments) = $filter;
            $name = strtolower($name);
            if (!isset($this->filters[$name]))
            {
                trigger_error("Filter $name is unknown", E_USER_WARNING);
                continue;
            }

            $callable = $this->filters[$name];
            if (is_string($callable))
            {
                $callable = [$this, $callable];
            }

            if ($arguments)
            {
                array_unshift($arguments, null);
                array_unshift($arguments, $value);
                array_unshift($arguments, $this);
                $arguments[2] =& $escape;
            }
            else
            {
                $arguments = [$this, $value, &$escape];
            }

            $value = call_user_func_array($callable, $arguments);
        }

        return $escape ? $this->escape($value, $escape) : $value;
    }

    public function fn($name, array $arguments = [], $escape = true)
    {
        $name = strtolower($name);
        if (!isset($this->functions[$name]))
        {
            trigger_error("Function $name is unknown", E_USER_WARNING);
            return '';
        }

        $callable = $this->functions[$name];
        if (is_string($callable))
        {
            $callable = [$this, $callable];
        }

        if ($arguments)
        {
            array_unshift($arguments, null);
            array_unshift($arguments, $this);
            $arguments[1] =& $escape;
        }
        else
        {
            $arguments = [$this, &$escape];
        }

        $value = call_user_func_array($callable, $arguments);
        return $escape ? $this->escape($value) : $value;
    }

    public function test($value, $test, array $arguments = [])
    {
        if (!isset($this->tests[$test]))
        {
            trigger_error("Test $test is unknown", E_USER_WARNING);
            return false;
        }

        $callable = $this->tests[$test];
        if (is_string($callable))
        {
            $callable = [$this, $callable];
        }

        if ($arguments)
        {
            array_unshift($arguments, $value);
            array_unshift($arguments, $this);
        }
        else
        {
            $arguments = [$this, $value];
        }

        return (bool)call_user_func_array($callable, $arguments);
    }

    public function arrayKey($var, $key)
    {
        return $var[$key];
    }

    public function isA($object, $class)
    {
        return ($object instanceof $class);
    }

    public function method($var, $fn, array $arguments = [])
    {
        if (!is_object($var))
        {
            $type = gettype($var);
            trigger_error("Cannot call method $fn on a non-object ($type)", E_USER_WARNING);
            return '';
        }

        $call = [$var, $fn];

        if (!is_callable($call))
        {
            $class = get_class($var);
            trigger_error("Method $fn is not callable on the given object ($class)", E_USER_WARNING);
            return '';
        }

        return call_user_func_array($call, $arguments);
    }

    public function escape($value, $type = null)
    {
        if ($type === null || $type === true)
        {
            $type = $this->escapeContext;
        }
        return \XF::escapeString($value, $type);
    }

    public function modifySectionedHtml(array &$ref, $key, $html, $mode = 'replace')
    {
        if ($mode == 'delete')
        {
            if ($key)
            {
                unset($ref[$key]);
            }
            return;
        }

        $html = trim($html);
        if (!strlen($html))
        {
            return;
        }

        $html = $this->preEscaped($html, 'html');

        switch ($mode)
        {
            case 'prepend':
                if ($key)
                {
                    $ref = [$key => $html] + $ref;
                }
                else
                {
                    array_unshift($ref, $html);
                }
                break;

            case 'append':
                if ($key)
                {
                    unset($ref[$key]); // unset to ensure this goes at the end
                    $ref[$key] = $html;
                }
                else
                {
                    $ref[] = $html;
                }
                break;

            case 'replace':
            default:
                if ($key)
                {
                    $ref[$key] = $html;
                }
                else
                {
                    $ref[] = $html;
                }
                break;
        }
    }

    public function modifySidebarHtml($key, $html, $mode = 'replace')
    {
        $this->modifySectionedHtml($this->sidebar, $key, $html, $mode);
    }

    public function getSidebarHtml()
    {
        return $this->sidebar;
    }

    public function modifySideNavHtml($key, $html, $mode = 'replace')
    {
        $this->modifySectionedHtml($this->sideNav, $key, $html, $mode);
    }

    public function getSideNavHtml()
    {
        return $this->sideNav;
    }

    public function includeCss($css)
    {
        list($type, $template) = $this->getTemplateTypeAndName($css);
        if (!$type)
        {
            trigger_error('No template type was provided. Provide template name in type:name format.', E_USER_WARNING);
            return;
        }

        $this->includeCss["$type:$template"] = true;
    }

    public function getIncludedCss(array $forceAppend = [])
    {
        $css = array_keys($this->includeCss);
        sort($css);
        return array_merge($css, $forceAppend);
    }

    public function inlineCss($css)
    {
        $this->inlineCss[] = $css;
    }

    public function getInlineCss()
    {
        return $this->inlineCss;
    }

    public function includeJs(array $options)
    {
        $options = array_replace([
            'src' => null,
            'addon' => null,
            'min' => null,
            'dev' => null,
            'prod' => null
        ], $options);

        $developmentConfig = $this->app->config('development');
        $productionMode = empty($developmentConfig['fullJs']);

        $src = $this->splitJsSrc($options['src']);

        if ($productionMode)
        {
            if ($options['min'])
            {
                $src = array_map(function($path)
                {
                    return preg_replace('(\.js$)', '.min.js', $path, 1);
                }, $src);
            }

            $prod = $this->splitJsSrc($options['prod']);
            $src = array_merge($src, $prod);

            foreach ($src AS $path)
            {
                $url = $this->getJsUrl($path);
                $this->includeJs[$url] = true;
            }
        }
        else
        {
            $dev = $this->splitJsSrc($options['dev']);
            $src = array_merge($src, $dev);

            if ($options['addon'])
            {
                foreach ($src AS $path)
                {
                    $url = $this->getDevJsUrl($options['addon'], $path);
                    $this->includeJs[$url] = true;
                }
            }
            else
            {
                foreach ($src AS $path)
                {
                    $url = $this->getJsUrl($path);
                    $this->includeJs[$url] = true;
                }
            }
        }
    }

    protected function splitJsSrc($js)
    {
        if ($js)
        {
            return preg_split('/[, ]/', $js, -1, PREG_SPLIT_NO_EMPTY);
        }
        else
        {
            return [];
        }
    }

    public function getIncludedJs()
    {
        return array_keys($this->includeJs);
    }

    public function inlineJs($js)
    {
        $this->inlineJs[] = $js;
    }

    public function getInlineJs()
    {
        return $this->inlineJs;
    }

    public function isTraversable($value)
    {
        return is_array($value) || ($value instanceof \Traversable);
    }

    public function isArrayAccessible($value)
    {
        return is_array($value) || ($value instanceof \ArrayAccess);
    }

    public function handleTemplateError($errorType, $errorString, $file, $line)
    {
        if ($errorType == E_NOTICE || $errorType == E_USER_NOTICE)
        {
            return;
        }

        if ($errorType & error_reporting())
        {
            $this->templateErrors[] = [
                'template' => $this->currentTemplateType . ':' . $this->currentTemplateName,
                'type' => $errorType,
                'error' => $errorString,
                'file' => $file,
                'line' => $line
            ];

            $e = new \ErrorException($errorString, 0, $errorType, $file, $line);
            $this->app->logException($e, false, "Template error: ");
        }
    }

    public function getTemplateErrors()
    {
        return $this->templateErrors;
    }

    public function isKnownTemplate($template)
    {
        $type = false;

        if (strpos($template, ':') !== false)
        {
            list($type, $template) = explode(':', $template, 2);
        }

        if (!$type)
        {
            return false;
        }

        $data = $this->getTemplateData($type, $template, false);
        return empty($data['unknown']) ? true : false;
    }

    /**
     * @param string $template
     * @param array $params
     * @param bool $addDefaultParams
     *
     * @return string
     */
    public function renderTemplate($template, array $params = [], $addDefaultParams = true)
    {
        if ($this->executionDepth >= self::MAX_EXECUTION_DEPTH)
        {
            trigger_error('Max template execution depth reached', E_USER_WARNING);
            return '';
        }

        if ($addDefaultParams)
        {
            $params = array_merge($this->defaultParams, $params);
        }

        $type = false;

        if (strpos($template, ':') !== false)
        {
            list($type, $template) = explode(':', $template, 2);
        }

        if (!$type)
        {
            trigger_error('No template type was provided. Provide template name in type:name format.', E_USER_WARNING);
            return '';
        }

        $this->app->fire('templater_template_pre_render', [$this, &$type, &$template, &$params], "$type:$template");

        $currentType = $this->currentTemplateType;
        $currentName = $this->currentTemplateName;
        $origWrapTemplateName = $this->wrapTemplateName;
        $origWrapTemplateParams = $this->wrapTemplateParams;

        $this->currentTemplateType = $type;
        $this->currentTemplateName = $template;
        $this->wrapTemplateName = null;
        $this->wrapTemplateParams = null;
        $this->executionDepth++;

        set_error_handler([$this, 'handleTemplateError']);

        try
        {
            $code = $this->getTemplateCode($type, $template);
            $output = $code($this, $params);
        }
        catch (\Exception $e)
        {
            $errorPrefix = "$this->currentTemplateType:$this->currentTemplateName";
            $this->app->logException($e, false, "Template $errorPrefix error: ");

            if (\XF::$debugMode)
            {
                $message = $e->getMessage();
                $file = $e->getFile() . ':' . $e->getLine();
                $error = $e instanceof \XF\PrintableException ?
                    "$errorPrefix - $message"
                    : "$errorPrefix - $message in $file";

                if (preg_match('/\.(css|less)$/i', $template))
                {
                    $error = strtr($error, [
                        "'" => '',
                        '\\' => '/',
                        "\r" => '',
                        "\n" => " "
                    ]);

                    $output = "
                        /** Error output **/
                        body:before
                        {
                            background-color: #ccc;
                            color: black;
                            font-weight: bold;
                            display: block;
                            padding: 10px;
                            margin: 10px;
                            border: solid 1px #aaa;
                            border-radius: 5px;
                            content: 'CSS error: " . $error . "';
                        }
                    ";
                }
                else
                {
                    $output = '<div class="error"><h3>Template Compilation Error</h3>'
                        . '<div>' . htmlspecialchars($error) . '</div></div>';
                }
            }
            else
            {
                $output = '';
            }
        }

        restore_error_handler();

        if ($this->wrapTemplateName)
        {
            $output = $this->applyWrappedTemplate($output);
        }

        $this->currentTemplateType = $currentType;
        $this->currentTemplateName = $currentName;
        $this->wrapTemplateName = $origWrapTemplateName;
        $this->wrapTemplateParams = $origWrapTemplateParams;
        $this->executionDepth--;

        $this->app->fire('templater_template_post_render', [$this, $type, $template, &$output], "$type:$template");

        return $output;
    }

    public function includeTemplate($template, array $params = [])
    {
        $template = $this->applyDefaultTemplateType($template);

        return $this->renderTemplate($template, $params);
    }

    public function callback($class, $method, $contents, array $params = [])
    {
        if (!\XF\Util\Php::validateCallbackPhrased($class, $method, $errorPhrase))
        {
            return $errorPhrase;
        }
        if (!\XF\Util\Php::nameIndicatesReadOnly($method))
        {
            return \XF::phrase('callback_method_x_does_not_appear_to_indicate_read_only', ['method' => $method]);
        }

        ob_start();
        $output = call_user_func([$class, $method], $contents, $params, $this);
        $output .= ob_get_clean();

        return $output;
    }

    public function setPageParams(array $pageParams)
    {
        $this->pageParams = Arr::mapMerge($this->pageParams, $pageParams);
    }

    public function setPageParam($name, $value)
    {
        if (strpos($name, '.') === false)
        {
            $this->pageParams[$name] = $value;
            return;
        }

        $ref =& $this->pageParams;
        $hasValid = false;
        foreach (explode('.', $name) AS $part)
        {
            if (!strlen($part))
            {
                continue;
            }

            if (!isset($ref[$part]) || !is_array($ref[$part]))
            {
                $ref[$part] = [];
            }

            $ref =& $ref[$part];
            $hasValid = true;
        }

        if ($hasValid)
        {
            $ref = $value;
        }
    }

    public function breadcrumb($value, $href, array $config)
    {
        if (!isset($this->pageParams['breadcrumbs']) || !is_array($this->pageParams['breadcrumbs']))
        {
            $this->pageParams['breadcrumbs'] = [];
        }

        $crumb = [
            'value' => $value,
            'href' => $href,
            'attributes' => $config
        ];

        $this->pageParams['breadcrumbs'][] = $crumb;
    }

    public function breadcrumbs(array $crumbs)
    {
        if (!$crumbs)
        {
            $this->pageParams['breadcrumbs'] = [];
            return;
        }

        foreach ($crumbs AS $key => $crumb)
        {
            if (is_string($crumb) || $crumb instanceof \XF\Phrase)
            {
                $crumb = [
                    'href' => $key,
                    'value' => $crumb
                ];
            }

            if (!is_array($crumb))
            {
                trigger_error("Each breadcrumb must be an array", E_USER_WARNING);
                continue;
            }
            if (!isset($crumb['value']))
            {
                trigger_error("Each breadcrumb provide a 'value' key", E_USER_WARNING);
                continue;
            }
            if (!isset($crumb['href']))
            {
                trigger_error("Each breadcrumb provide a 'href' key", E_USER_WARNING);
                continue;
            }

            $value = $crumb['value'];
            $href = $crumb['href'];
            unset($crumb['value'], $crumb['href']);

            $this->breadcrumb($value, $href, $crumb);
        }
    }

    public function button($contentHtml, array $options, $menuHtml = '', array $menuOptions = [])
    {
        $href = $this->processAttributeToRaw($options, 'href', '', true);
        if ($href)
        {
            $element = 'a';
            $type = '';
            $href = ' href="' . $href . '"';
        }
        else
        {
            $element = 'button';
            $type = $this->processAttributeToRaw($options, 'type', '', true);
            if ($type)
            {
                $type = ' type="' . $type . '"';
            }
            else
            {
                $type = ' type="button"';
            }
        }

        $overlay = $this->processAttributeToRaw($options, 'overlay', '', true);
        if ($overlay)
        {
            $overlay = " data-xf-click=\"overlay\"";
        }

        $buttonClasses = 'button';
        $icon = $this->processAttributeToRaw($options, 'icon');
        if ($icon)
        {
            $buttonClasses .= ' button--icon button--icon--' . preg_replace('#[^a-zA-Z0-9_-]#', '', $icon);
        }

        if ($menuHtml)
        {
            $buttonClasses .= ' button--splitTrigger';

            $menuClass = $this->processAttributeToRaw($menuOptions, 'class', ' %s', true);
            $unhandledMenuAttrs = $this->processUnhandledAttributes($menuOptions);

            $menuHtml = "<div class=\"menu{$menuClass}\" data-menu=\"menu\" aria-hidden=\"true\"{$unhandledMenuAttrs}>{$menuHtml}</div>";
        }

        $classAttr = $this->processAttributeToHtmlAttribute($options, 'class', $buttonClasses, true);

        $button = strval($this->processAttributeToRaw($options, 'button'));
        if (!$button)
        {
            $button = $contentHtml;
        }
        if (!$button && $icon)
        {
            $button = $this->getButtonPhraseFromIcon($icon);
        }

        $unhandledControlAttrs = $this->processUnhandledAttributes($options);

        if ($menuHtml)
        {
            return "<span{$classAttr}><{$element}{$type}{$href} class=\"button-text\">{$button}</{$element}>"
                . "<a class=\"button-menu\" data-xf-click=\"menu\" aria-expanded=\"false\" aria-haspopup=\"true\"></a>"
                . $menuHtml
                . "</span>";
        }
        else
        {
            return "<{$element}{$type}{$href}{$classAttr}{$overlay}{$unhandledControlAttrs}><span class=\"button-text\">{$button}</span></{$element}>";
        }
    }

    public function widgetPosition($positionId, array $contextParams = [])
    {
        $widgetPositions = $this->widgetPositions;
        if (!isset($widgetPositions[$positionId]))
        {
            return '';
        }
        $widgetContainer = $this->app->widget();
        $widgets = $widgetContainer->position($positionId, $contextParams);

        $output = '';
        foreach ($widgets AS $widget)
        {
            $output .= $widget->render() . "\n";
        }
        return $output;
    }

    public function renderWidget($identifier, array $options = [], array $contextParams = [])
    {
        $options['context'] = $contextParams;
        $widget = $this->app->widget()->widget($identifier, $options);
        return $widget->render();
    }

    public function preEscaped($value, $type = null)
    {
        if ($type === null)
        {
            $type = $this->escapeContext;
        }
        return new \XF\PreEscaped($value, $type);
    }

    ////////////////////// FUNCTIONS ////////////////////////

    public function fnAnchorTarget($templater, &$escape, $hash)
    {
        $escape = false;
        return '<span class="u-anchorTarget" id="' . htmlspecialchars($this->app->getRedirectHash($hash)) . '"></span>';
    }

    public function fnArrayKeys($templater, &$escape, $array)
    {
        if (!is_array($array))
        {
            $array = [];
        }

        return array_keys($array);
    }

    public function fnArrayMerge($templater, &$escape, $array)
    {
        $arrays = func_get_args();
        unset($arrays[0]);
        unset($arrays[1]);

        return call_user_func_array('array_merge', $arrays);
    }

    public function fnArrayValues($templater, &$escape, $array)
    {
        if (!is_array($array))
        {
            $array = [];
        }

        return array_values($array);
    }

    public function fnAttributes($templater, &$escape, $attributes, array $skipAttrs = [])
    {
        if (is_array($attributes))
        {
            foreach ($skipAttrs AS $attr)
            {
                unset($attributes[$attr]);
            }
            $output = $this->processUnhandledAttributes($attributes);
        }
        else
        {
            $output = '';
        }

        $escape = false;
        return $output;
    }

    public function fnAvatar($templater, &$escape, $user, $size, $canonical = false, $attributes = [])
    {
        $escape = false;
        $forceType = $this->processAttributeToRaw($attributes, 'forcetype', '', true);
        $noTooltip = $this->processAttributeToRaw($attributes, 'notooltip', '', false);
        $update = $this->processAttributeToRaw($attributes, 'update', '');

        if ($user instanceof \XF\Entity\User)
        {
            $username = $user->username;
            if (isset($attributes['href']))
            {
                $href = $attributes['href'];
                $noTooltip = true;
            }
            else
            {
                $linkPath = $this->currentTemplateType == 'admin' ? 'users/edit' : 'members';
                $href = $this->getRouter()->buildLink(($canonical ? 'canonical:' : '') . $linkPath, $user);

                if ($this->currentTemplateType == 'admin')
                {
                    $noTooltip = true;
                }
            }
            $userId = $user->user_id;
            if (!$userId)
            {
                $href = null;
                $noTooltip = true;
            }
            $hrefAttr = $href ? ' href="' . htmlspecialchars($href) . '"' : '';
            $avatarType = $forceType ?: $user->getAvatarType();

            $canUpdate = ((bool)$update && $user->user_id == \XF::visitor()->user_id && $user->canUploadAvatar());
        }
        else
        {
            if (isset($attributes['defaultname']))
            {
                $username = $attributes['defaultname'];
            }
            else
            {
                $username = null;
            }
            $hrefAttr = '';
            $noTooltip = true;
            $userId = 0;
            $avatarType = 'default';
            $canUpdate = false;
        }

        switch ($avatarType)
        {
            case 'gravatar':
            case 'custom':
                $src = $user->getAvatarUrl($size, $forceType, $canonical);
                break;

            case 'default':
            default:
                $src = null;
                break;
        }

        $actualSize = $size;
        if (!array_key_exists($size, $this->app->container('avatarSizeMap')))
        {
            $actualSize = 's';
        }

        $sizeClass = "avatar-u{$userId}-{$actualSize}";
        $innerClass = $this->processAttributeToRaw($attributes, 'innerclass', ' %s', true);
        $innerClassHtml = $sizeClass . $innerClass;

        if ($src && $forceType != 'default')
        {
            $srcSet = $user->getAvatarUrl2x($size, $forceType, $canonical);

            $itemprop = $this->processAttributeToRaw($attributes, 'itemprop', '%s', true);

            $innerContent = '<img src="' . $src . '" ' . (!empty($srcSet) ? 'srcset="' . $srcSet . ' 2x"' : '')
                . ' alt="' . htmlspecialchars($username) . '"'
                . ' class="' . $innerClassHtml . '"'
                . ($itemprop ? ' itemprop="' . $itemprop . '"' : '')
                . ' />';
        }
        else
        {
            $innerContent = $this->getDynamicAvatarHtml($username, $innerClassHtml, $attributes);
        }

        $updateLink = '';
        $updateLinkClass = '';
        if ($canUpdate)
        {
            $updateLinkClass = ' avatar--updateLink';
            $updateLink = '<div class="avatar-update">
                <a href="' . htmlspecialchars($update) . '" data-xf-click="overlay">' . \XF::phrase('edit_avatar') . '</a>
            </div>';
        }

        $class = $this->processAttributeToRaw($attributes, 'class', ' %s', true);
        $xfInit = $this->processAttributeToRaw($attributes, 'data-xf-init', '', true);

        if (!$noTooltip)
        {
            $xfInit = ltrim("$xfInit member-tooltip");
        }
        $xfInitAttr = $xfInit ? " data-xf-init=\"$xfInit\"" : '';

        unset($attributes['defaultname'], $attributes['href'], $attributes['itemprop']);

        $unhandledAttrs = $this->processUnhandledAttributes($attributes);

        if ($hrefAttr)
        {
            $tag = 'a';
        }
        else
        {

            $tag = 'span';
        }
        return "<{$tag}{$hrefAttr} class=\"avatar avatar--{$size}{$updateLinkClass}{$class}\" data-user-id=\"{$userId}\"{$xfInitAttr}{$unhandledAttrs}>
            $innerContent $updateLink
        </{$tag}>";
    }

    protected function getDynamicAvatarHtml($username, $innerClassHtml, array &$outerAttributes)
    {
        if ($username && $this->dynamicDefaultAvatars)
        {
            return $this->getDefaultAvatarHtml($username, $innerClassHtml, $outerAttributes);
        }
        else
        {
            return $this->getFallbackAvatarHtml($innerClassHtml, $outerAttributes);
        }
    }

    protected function getDefaultAvatarHtml($username, $innerClassHtml, array &$outerAttributes)
    {
        $styling = $this->getDefaultAvatarStyling($username);

        if (empty($outerAttributes['style']))
        {
            $outerAttributes['style'] = '';
        }
        else
        {
            $outerAttributes['style'] .= '; ';
        }
        $outerAttributes['style'] .= "background-color: $styling[bgColor]; color: $styling[color]";

        if (empty($outerAttributes['class']))
        {
            $outerAttributes['class'] = '';
        }
        else
        {
            $outerAttributes['class'] .= ' ';
        }
        $outerAttributes['class'] .= 'avatar--default avatar--default--dynamic';

        return '<span class="' . $innerClassHtml . '">' . $styling['innerContent'] . '</span>';
    }

    protected function getDefaultAvatarStyling($username)
    {
        if (!isset($this->avatarDefaultStylingCache[$username]))
        {
            $bytes = md5($username, true);
            $r = dechex(round(5 * ord($bytes[0]) / 255) * 0x33);
            $g = dechex(round(5 * ord($bytes[1]) / 255) * 0x33);
            $b = dechex(round(5 * ord($bytes[2]) / 255) * 0x33);
            $hexBgColor = sprintf('%02s%02s%02s', $r, $g, $b);

            $hslBgColor = \XF\Util\Color::hexToHsl($hexBgColor);

            $bgChanged = false;
            if ($hslBgColor[1] > 60)
            {
                $hslBgColor[1] = 60;
                $bgChanged = true;
            }
            else if ($hslBgColor[1] < 15)
            {
                $hslBgColor[1] = 15;
                $bgChanged = true;
            }

            if ($hslBgColor[2] > 85)
            {
                $hslBgColor[2] = 85;
                $bgChanged = true;
            }
            else if ($hslBgColor[2] < 15)
            {
                $hslBgColor[2] = 15;
                $bgChanged = true;
            }

            if ($bgChanged)
            {
                $hexBgColor = \XF\Util\Color::hslToHex($hslBgColor);
            }

            $hslColor = \XF\Util\Color::darkenOrLightenHsl($hslBgColor, 35);
            $hexColor = \XF\Util\Color::hslToHex($hslColor);

            $bgColor = '#' . $hexBgColor;
            $color = '#' . $hexColor;

            if (preg_match($this->avatarLetterRegex, $username, $match))
            {
                $innerContent = htmlspecialchars(utf8_strtoupper($match[0]));
            }
            else
            {
                $innerContent = '?';
            }

            $this->avatarDefaultStylingCache[$username] = [
                'bgColor' => $bgColor,
                'color' => $color,
                'innerContent' => $innerContent
            ];
        }

        return $this->avatarDefaultStylingCache[$username];
    }

    protected function getFallbackAvatarHtml($innerClassHtml, array &$outerAttributes)
    {
        if (empty($outerAttributes['class']))
        {
            $outerAttributes['class'] = '';
        }
        else
        {
            $outerAttributes['class'] .= ' ';
        }

        $fallbackType = $this->style->getProperty('avatarDefaultType', 'text');
        $outerAttributes['class'] .= 'avatar--default avatar--default--' . $fallbackType;

        return '<span class="' . $innerClassHtml . '"></span>';
    }


    public function fnBaseUrl($templater, &$escape, $url = null, $full = false)
    {
        $pather = $this->pather;
        return $pather($url ?: '', $full ? 'full' : 'base');
    }

    public function fnBbCode($templater, &$escape, $bbCode, $context, $content, array $options = [], $type = 'html')
    {
        $escape = false;
        return $this->app->bbCode()->render($bbCode, $type, $context, $content, $options);
    }

    public function fnBbCodeType($templater, &$escape, $type, $bbCode, $context, $content, array $options = [])
    {
        return $this->fnBbCode($templater, $escape, $bbCode, $context, $content, $options, $type);
    }

    public function fnButtonIcon($templater, &$escape, $icon)
    {
        $icon = preg_replace('#[^a-zA-Z0-9_-]#', '', strval($icon));
        if (!$icon)
        {
            return '';
        }

        $escape = false;
        return " button--icon button--icon--" . $icon;
    }

    public function fnCallable($templater, &$escape, $var, $fn)
    {
        $escape = false;

        if (!\XF\Util\Php::validateCallback($var, $fn))
        {
            return false;
        }
        if (!\XF\Util\Php::nameIndicatesReadOnly($fn))
        {
            return false;
        }

        return true;
    }

    public function fnCaptcha($templater, &$escape, $force = false)
    {
        if (!$force && !\XF::visitor()->isShownCaptcha())
        {
            return '';
        }

        $captcha = $this->app->captcha();
        if ($captcha)
        {
            $escape = false;
            return $captcha->render($templater);
        }

        return '';
    }

    public function fnCopyright($templater, &$escape)
    {
        $escape = false;
        return \XF::getCopyrightHtml();
    }

    public function fnCoreJs($templater, &$escape)
    {
        $jqVersion = $this->jQueryVersion;
        $jqMin = '.min';
        $jqLocal = $this->getJsUrl("vendor/jquery/jquery-{$jqVersion}{$jqMin}.js");
        $jqRemote = '';

        if ($this->app['app.defaultType'] == 'public')
        {
            switch ($this->jQuerySource)
            {
                case 'jquery':
                    $jqRemote = "https://code.jquery.com/jquery-{$jqVersion}{$jqMin}.js";
                    break;

                case 'google':
                    $jqRemote = "https://ajax.googleapis.com/ajax/libs/jquery/{$jqVersion}/jquery{$jqMin}.js";
                    break;

                case 'microsoft':
                    $jqRemote = "https://ajax.microsoft.com/ajax/jquery/jquery-{$jqVersion}{$jqMin}.js";
                    break;
            }
        }

        if ($jqRemote)
        {
            $output = '<script src="' . htmlspecialchars($jqRemote) . '"></script>'
                . '<script>window.jQuery || document.write(\'<script src="'
                . \XF::escapeString($jqLocal, 'htmljs') . '"><\\/script>\')</script>';
        }
        else
        {
            $output = '<script src="' . htmlspecialchars($jqLocal) . '"></script>';
        }

        $files = [
            'vendor/vendor-compiled.js'
        ];
        if ($this->app['config']['development']['fullJs'])
        {
            $files[] = 'xf/core.js';
            foreach (glob(\XF::getRootDirectory() . '/js/xf/core/*.js') AS $file)
            {
                if (substr($file, -7) == '.min.js')
                {
                    continue;
                }
                $files[] = 'xf/core/' . basename($file);
            }
        }
        else
        {
            $files[] = 'xf/core-compiled.js';
        }
        foreach ($files AS $file)
        {
            $output .= "\n\t<script src=\"" . htmlspecialchars($this->getJsUrl($file)) . '"></script>';
        }

        $escape = false;
        return $output;
    }

    public function fnCount($templater, &$escape, $value)
    {
        if (is_array($value) || $value instanceof \Countable)
        {
            return count($value);
        }

        return null;
    }

    public function fnCsrfInput($templater, &$escape)
    {
        $escape = false;
        return '<input type="hidden" name="_xfToken" value="' . htmlspecialchars($this->app['csrf.token']) . '" />';
    }

    public function fnCsrfToken($templater, &$escape)
    {
        return $this->app['csrf.token'];
    }

    public function fnCssUrl($templater, &$escape, array $templates, $includeValidation = true)
    {
        return $this->getCssLoadUrl($templates, $includeValidation);
    }

    public function fnDate($templater, &$escape, $date, $format = null)
    {
        return $this->language->date($date, $format);
    }

    public function fnDateFromFormat($templater, &$escape, $format, $dateString, $timeZone = null)
    {
         return \DateTime::createFromFormat($format, $dateString, $timeZone === null
             ? $this->language->getTimezone()
             : new \DateTimeZone($timeZone));
    }

    public function fnDateDynamic($templater, &$escape, $dateTime, array $attributes = [])
    {
        if (!($dateTime instanceof \DateTime))
        {
            $ts = intval($dateTime);
            $dateTime = new \DateTime();
            $dateTime->setTimestamp($ts);
            $dateTime->setTimezone($this->language->getTimeZone());
        }
        else
        {
            $ts = $dateTime->getTimestamp();
        }

        list($date, $time) = $this->language->getDateTimeParts($ts);
        $full = $this->language->getDateTimeOutput($date, $time);
        $relative = $this->language->getRelativeDateTimeOutput($ts, $date, $time, !empty($attributes['data-full-date']));

        $class = $this->processAttributeToHtmlAttribute($attributes, 'class', 'u-dt', true);

        unset($attributes['title']);
        $unhandledAttrs = $this->processUnhandledAttributes($attributes);

        $escape = false;

        return '<time ' . $class . ' dir="auto" datetime="' . $dateTime->format(\DateTime::ISO8601)
            . '" data-time="' . $ts
            . '" data-date-string="' . htmlspecialchars($date)
            . '" data-time-string="' . htmlspecialchars($time)
            . '" title="' . htmlspecialchars($full)
            . '"' . $unhandledAttrs . '>' . htmlspecialchars($relative) . '</time>';
    }

    public function fnDateTime($templater, &$escape, $date)
    {
        return $this->language->dateTime($date);
    }

    public function fnDebugUrl($templater, &$escape, $url = null)
    {
        if (!$url)
        {
            $url = $this->app->request()->getRequestUri();
        }

        if (strpos($url, '?') === false)
        {
            $url .= '?';
        }
        else
        {
            $url .= '&';
        }

        return $url . '_debug=1';
    }

    public function fnDump($templater, &$escape, $value)
    {
        $escape = false;
        ob_start();
        \XF::dump($value);
        $dump = ob_get_clean();

        return $dump;
    }

    public function fnDumpSimple($templater, &$escape, $value)
    {
        $escape = false;
        return \XF::dumpSimple($value, false);
    }

    public function fnEmpty($templater, &$escape, $value)
    {
        return empty($value);
    }

    public function fnDisplayTotals($templater, &$escape, $count, $total = null)
    {
        if (is_array($count) || $count instanceof \Countable)
        {
            $count = count($count);
        }

        if ($total === null)
        {
            $total = $count;
        }
        else if (is_array($total) || $total instanceof \Countable)
        {
            $total = count($total);
        }

        $params = [
            'count' => $this->language->numberFormat($count),
            'total' => $this->language->numberFormat($total)
        ];

        if ($count < 1)
        {
            $phrase = 'no_items_to_display';
        }
        else if ($count == $total)
        {
            $phrase = 'showing_all_items';
        }
        else
        {
            $phrase = 'showing_x_of_y_items';
        }

        $escape = false;
        return '<span class="js-displayTotals" data-count="' . $count . '" data-total="' . $total . '"'
            . ' data-xf-init="tooltip" title="' . \XF::phrase('there_are_x_items_in_total', ['total' => $params['total']]) . '">'
            . \XF::phrase($phrase, $params) . '</span>';
    }

    public function fnFileSize($templater, &$escape, $number)
    {
        return $this->language->fileSizeFormat($number);
    }

    public function fnCeil($templater, &$escape, $value)
    {
        return ceil($value);
    }

    public function fnFloor($templater, &$escape, $value)
    {
        return floor($value);
    }

    public function fnGravatarUrl($templater, &$escape, $user, $size)
    {
        if ($user instanceof \XF\Entity\User)
        {
            return $user->getGravatarUrl($size);
        }
        else
        {
            return '';
        }
    }

    public function fnHighlight($templater, &$escape, $string, $term, $class = 'textHighlight')
    {
        $escape = false;
        return $this->app->stringFormatter()->highlightTermForHtml($string, $term, $class);
    }

    public function fnInArray($templater, &$escape, $needle, $haystack, $strict = false)
    {
        $escape = false;
        if ($haystack instanceof \Traversable)
        {
            $haystack = iterator_to_array($haystack);
        }

        if (!is_array($haystack))
        {
            return false;
        }

        return in_array($needle, $haystack, $strict);
    }

    public function fnIsArray($templater, &$escape, $array)
    {
        $escape = false;
        return is_array($array);
    }

    public function fnIsAddonActive($templater, &$escape, $addOnId, $versionId = null, $operator = '>=')
    {
        $addOns = $this->app->registry()['addOns'];

        if (!isset($addOns[$addOnId]))
        {
            return false;
        }

        if ($versionId === null)
        {
            return $addOns[$addOnId];
        }
        else
        {
            $activeVersionId = $addOns[$addOnId];

            switch ($operator)
            {
                case '>':
                    return ($activeVersionId > $versionId);
                case '>=':
                    return ($activeVersionId >= $versionId);
                case '<':
                    return ($activeVersionId < $versionId);
                case '<=':
                    return ($activeVersionId <= $versionId);
                default:
                    return $addOns[$addOnId];
            }
        }
    }

    public function fnIsEditorCapable($templater, &$escape)
    {
        $ua = $this->app->request()->getUserAgent();
        if (!$ua)
        {
            return true;
        }

        if (preg_match('#blackberry|opera mini|opera mobi#i', $ua))
        {
            // older/limited mobile browsers
            return false;
        }

        if (preg_match('#msie (\d+)#i', $ua, $match) && intval($match[1]) < 10)
        {
            // only supported in IE10+
            return false;
        }

        if (preg_match('#android (\d+)\.#i', $ua, $match) && intval($match[1]) < 5)
        {
            // Froala only officially supports Android 6 and above.
            // However, it seems Froala actually still works on Android 5.1.1 (at least) so we'll go with that.
            // So far we've only had issues reported with Android 4.x.
            // Older Android versions do support Chrome and Firefox so if those are installed
            // They will likely be up to date and work fine with the RTE.
            if (preg_match('#(Firefox/|Chrome/)#i', $ua))
            {
                return true;
            }
            else
            {
                return false;
            }
        }

        if (preg_match('#(iphone|ipod|ipad).+OS (\d+)_#i', $ua, $match) && intval($match[2]) < 8)
        {
            // only supported in iOS 8+
            return false;
        }

        return true;
    }

    public function fnIsToggled($templater, &$escape, $storageKey, $storageContainer = 'toggle')
    {
        $cookie = $this->app->request()->getCookie($storageContainer);
        if (!$cookie)
        {
            return false;
        }

        $cookieDecoded = @json_decode($cookie, true);
        if (!$cookieDecoded)
        {
            return false;
        }

        if (!isset($cookieDecoded[$storageKey]))
        {
            return false;
        }

        return empty($cookieDecoded[$storageKey][2]);
    }

    public function fnJsUrl($templater, &$escape, $file)
    {
        return $this->getJsUrl($file);
    }

    public function fnLastPages($templater, &$escape, $total, $perPage, $max = 2)
    {
        $escape = false;

        $perPage = intval($perPage);
        if ($perPage <= 0)
        {
            return [];
        }

        $total = intval($total);
        if ($total <= $perPage)
        {
            return [];
        }

        $max = max(1, intval($max));

        $totalPages = ceil($total / $perPage);
        if ($totalPages == 2)
        {
            return [2];
        }

        // + 1 represents that range covers including the start, whereas we want only the last X, which is start + 1
        $start = max($totalPages - $max + 1, 2);
        return range($start, $totalPages);
    }

    public function fnLikes($templater, &$escape, $count, $users, $liked, $url, array $attributes = [])
    {
        $escape = false;

        $count = intval($count);
        if ($count <= 0)
        {
            return '';
        }

        if (!$users || !is_array($users))
        {
            $phrase = ($count > 1 ? 'likes.x_people' : 'likes.1_person');
            return $this->renderTemplate('public:like_list_row', [
                'url' => $url,
                'likes' => \XF::phrase($phrase, ['likes' => $this->language->numberFormat($count)])
            ]);
        }

        $userCount = count($users);
        if ($userCount < 5 && $count > $userCount) // indicates some users are deleted
        {
            for ($i = 0; $i < $count; $i++)
            {
                if (empty($users[$i]))
                {
                    $users[$i] = [
                        'user_id' => 0,
                        'username' => \XF::phrase('likes.deleted_user')
                    ];
                }
            }
        }

        if ($liked)
        {
            $visitorId = \XF::visitor()->user_id;
            foreach ($users AS $key => $user)
            {
                if ($user['user_id'] == $visitorId)
                {
                    unset($users[$key]);
                    break;
                }
            }

            $users = array_values($users);

            if (count($users) == 3)
            {
                unset($users[2]);
            }
        }

        $user1 = $user2 = $user3 = '';

        if (isset($users[0]))
        {
            $user1 = $this->preEscaped('<bdi>' . \XF::escapeString($users[0]['username']) . '</bdi>', 'html');
            if (isset($users[1]))
            {
                $user2 = $this->preEscaped('<bdi>' . \XF::escapeString($users[1]['username']) . '</bdi>', 'html');
                if (isset($users[2]))
                {
                    $user3 = $this->preEscaped('<bdi>' . \XF::escapeString($users[2]['username']) . '</bdi>', 'html');
                }
            }
        }

        switch ($count)
        {
            case 1: $phrase = ($liked ? 'likes.you' : 'likes.user1'); break;
            case 2: $phrase = ($liked ? 'likes.you_and_user1' : 'likes.user1_and_user2'); break;
            case 3: $phrase = ($liked ? 'likes.you_user1_and_user2' : 'likes.user1_user2_and_user3'); break;
            case 4: $phrase = ($liked ? 'likes.you_user1_user2_and_1_other' : 'likes.user1_user2_user3_and_1_other'); break;
            default: $phrase = ($liked ? 'likes.you_user1_user2_and_x_others' : 'likes.user1_user2_user3_and_x_others'); break;
        }

        $params = [
            'user1' => $user1,
            'user2' => $user2,
            'user3' => $user3,
            'others' => $this->language->numberFormat($count - 3)
        ];

        return $this->renderTemplate('public:like_list_row', [
            'url' => $url,
            'likes' => \XF::phrase($phrase, $params)
        ]);
    }

    public function fnLikesContent($templater, &$escape, $content, $url, array $attributes = [])
    {
        $escape = false;
        if (!($content instanceof \XF\Mvc\Entity\Entity))
        {
            trigger_error("Content must be an entity link likes_content (given " . gettype($content) . ")", E_USER_WARNING);
            return '';
        }

        $count = $content->likes;
        $users = $content->like_users;

        $userId = \XF::visitor()->user_id;
        $liked = $userId ? isset($content->Likes[$userId]) : false;

        return $this->fn('likes', [$count, $users, $liked, $url, $attributes], false);
    }

    public function fnLink($templater, &$escape, $link, $data = null, array $params = [])
    {
        return $this->getRouter()->buildLink($link, $data, $params);
    }

    public function fnLinkType($templater, &$escape, $type, $link, $data = null, array $params = [])
    {
        $container = $this->app->container();

        /** @var \XF\Mvc\Router|null $router */
        $router = isset($container['router.' . $type]) ? $container['router.' . $type] : null;
        if ($router)
        {
            return $router->buildLink($link, $data, $params);
        }
        else
        {
            return '';
        }
    }

    public function fnMaxLength($templater, &$escape, $entity, $column)
    {
        static $entityCache = [];

        // if $entity is not an entity, expect an entity id string like XF:Thread
        if (is_string($entity) && preg_match('/^\w+(?:\\\w+)?:\w+$/i', $entity))
        {
            if (!isset($entityCache[$entity]))
            {
                $entityCache[$entity] = $this->app->em()->create($entity);
            }

            $entity = $entityCache[$entity];
        }

        if ($entity instanceof \XF\Mvc\Entity\Entity)
        {
            $maxlength = $entity->getMaxLength($column);

            return $maxlength > 0 ? $maxlength : null;
        }
        else
        {
            return null;
        }
    }

    public function fnMediaSites($templater, &$escape)
    {
        $output = [];
        foreach ($this->mediaSites AS $site)
        {
            if (!$site['supported'])
            {
                continue;
            }
            if ($site['site_url'])
            {
                $output[] = '<a href="' . htmlspecialchars($site['site_url']) . '" target="_blank" rel="nofollow" dir="auto">' . htmlspecialchars($site['site_title']) . '</a>';
            }
            else
            {
                $output[] = htmlspecialchars($site['site_title']);
            }
        }
        $escape = false;
        return implode(', ', $output);
    }

    public function fnMustache($templater, &$escape, $name, $inner = null)
    {
        $escape = false;

        $var = '{{' . $name . '}}';

        if ($inner === null)
        {
            return $var;
        }
        else
        {
            $close = '{{/' . substr($name, 1) . '}}';
            return "{$var}{$inner}{$close}";
        }
    }

    public function fnNumber($templater, &$escape, $number, $precision = 0)
    {
        return $this->language->numberFormat($number, $precision);
    }


    public function fnNamedColors($templater, &$escape)
    {
        return \XF\Util\Color::getNamedColors();
    }

    public function fnPageDescription($templater, &$escape)
    {
        if (isset($this->pageParams['pageDescription']))
        {
            return $this->pageParams['pageDescription'];
        }
        else
        {
            return '';
        }
    }

    public function fnPageH1($templater, &$escape, $fallback = '')
    {
        if (isset($this->pageParams['pageH1']))
        {
            return $this->pageParams['pageH1'];
        }
        else if (isset($this->pageParams['pageTitle']))
        {
            return $this->pageParams['pageTitle'];
        }
        else
        {
            return $fallback;
        }
    }

    public function fnPageNav($templater, &$escape, array $config)
    {
        $escape = false;

        $config = array_merge([
            'pageParam' => 'page',

            'page' => 0,
            'perPage' => 0,
            'total' => 0,
            'range' => 2,

            'template' => $this->applyDefaultTemplateType('page_nav'),
            'variantClass' => '',

            'link' => '',
            'data' => null,
            'params' => [],

            'wrapper' => '',
            'wrapperclass' => '',
        ], $config);

        if (!is_array($config['params']))
        {
            $config['params'] = [];
        }

        $perPage = intval($config['perPage']);
        if ($perPage <= 0)
        {
            return '';
        }

        $total = intval($config['total']);
        if ($total <= $perPage)
        {
            return '';
        }

        $totalPages = ceil($total / $perPage);

        $current = intval($config['page']);
        $current = max(1, min($current, $totalPages));

        // number of pages either side of the current page
        $range = intval($config['range']);

        $startInner = max(2, $current - $range);
        $endInner = min($current + $range, $totalPages - 1);

        if ($startInner <= $endInner)
        {
            $innerPages = range($startInner, $endInner);
        }
        else
        {
            $innerPages = [];
        }

        $wrapperClass = $this->processAttributeToRaw($config, 'wrapperclass', '', true);
        $wrapper = $this->processAttributeToRaw($config, 'wrapper');
        if ($wrapperClass && !$wrapper)
        {
            $wrapper = 'div';
        }

        $router = $this->router;

        $prev = false;
        if ($current > 1)
        {
            $prevPageParam = $current - 1;
            if ($prevPageParam <= 1)
            {
                $prevPageParam = null;
            }

            $prev = $router->buildLink($config['link'], $config['data'], $config['params'] + [$config['pageParam'] => $prevPageParam]);
            if (!isset($this->pageParams['head']['prev']))
            {
                $this->pageParams['head']['prev'] = $this->preEscaped('<link rel="prev" href="' . \XF::escapeString($prev) . '" />');
            }
        }

        $next = false;
        if ($current < $totalPages)
        {
            $next = $router->buildLink($config['link'], $config['data'], $config['params'] + [$config['pageParam'] => $current + 1]);
            if (!isset($this->pageParams['head']['next']))
            {
                $this->pageParams['head']['next'] = $this->preEscaped('<link rel="next" href="' . \XF::escapeString($next) . '" />');
            }
        }

        $html = $this->renderTemplate($config['template'], [
            'prev' => $prev,
            'current' => $current,
            'next' => $next,
            'perPage' => $perPage,
            'total' => $total,
            'totalPages' => $totalPages,
            'innerPages' => $innerPages,
            'startInner' => $startInner,
            'endInner' => $endInner,
            'pageParam' => $config['pageParam'],
            'link' => $config['link'],
            'data' => $config['data'],
            'params' => $config['params'],
            'variantClass' => $config['variantClass']
        ]);

        if ($wrapper)
        {
            $wrapperOpen = $wrapper . ($wrapperClass ? " class=\"$wrapperClass\"" : '');
            $html = "<{$wrapperOpen}>{$html}</{$wrapper}>";
        }

        return $html;
    }

    public function fnPageTitle($templater, &$escape, $formatter = null, $fallback = '', $page = null)
    {
        if (isset($this->pageParams['pageTitle']) && strlen($this->pageParams['pageTitle']))
        {
            $pageTitle = $this->pageParams['pageTitle'];

            $page = intval($page);
            if ($page > 1)
            {
                $pageAppend = $this->language->phrase('title_page_x', ['page' => $page]);
                if ($pageTitle instanceof \XF\PreEscaped)
                {
                    $pageTitle = clone $pageTitle;
                    $pageTitle->value .= $pageAppend;
                }
                else
                {
                    $pageTitle .= $pageAppend;
                }
            }

            if ($formatter)
            {
                $value = sprintf($formatter,
                    $this->escape($pageTitle, $escape),
                    $this->escape($fallback, $escape)
                );

                $escape = false;
                return $value;
            }
            else
            {
                return $pageTitle;
            }
        }
        else
        {
            return $fallback;
        }
    }

    protected function getPrefixPhraseName($contentType, $id, $group = false)
    {
        return $contentType . '_prefix' . ($group ? '_group.' : '.') . $id;
    }

    public function fnParens($templater, &$escape, $value)
    {
        return $this->filterParens($templater, $value, $escape);
    }

    public function fnParseLessColor($templater, &$escape, $value)
    {
        /** @var \XF\CssRenderer $renderer */
        $rendererClass = $this->app->extendClass('XF\CssRenderer');
        $renderer = new $rendererClass($this->app, $this);
        $renderer->setStyle($this->style);

        return $renderer->parseLessColorValue($value);
    }

    public function fnPrefix($templater, &$escape, $contentType, $prefixId, $format = 'html', $append = null)
    {
        if (!is_int($prefixId))
        {
            $prefixId = $prefixId->prefix_id;
        }

        if (!$prefixId)
        {
            return '';
        }

        $prefixCache = $this->app->container('prefixes.' . $contentType);
        $prefixClass = isset($prefixCache[$prefixId]) ? $prefixCache[$prefixId] : null;

        if (!$prefixClass)
        {
            return '';
        }

        $output = $this->fn('prefix_title', [$contentType, $prefixId], false);

        switch ($format)
        {
            case 'html':
                $output = '<span class="' . htmlspecialchars($prefixClass) . '" dir="auto">'
                    . \XF::escapeString($output, 'html') . '</span>';
                if ($append === null)
                {
                    $append = '<span class="label-append">&nbsp;</span>';
                }
                break;

            case 'plain':
                if ($output instanceof \XF\Phrase)
                {
                    $output = $output->render('raw');
                }
                break; // ok as is

            default:
                $output = \XF::escapeString($output, 'html'); // just be safe and escape everything else
        }

        if ($append === null)
        {
            $append = ' - ';
        }

        $escape = false;
        return $output . $append;
    }

    public function fnPrefixGroup($templater, &$escape, $contentType, $groupId)
    {
        if ($groupId == 0)
        {
            return '(' . \XF::phrase('ungrouped') . ')';
        }

        return \XF::phrase($this->getPrefixPhraseName($contentType, $groupId, true), [], false);
    }

    public function fnPrefixTitle($templater, &$escape, $contentType, $prefixId)
    {
        return \XF::phrase($this->getPrefixPhraseName($contentType, $prefixId), [], false);
    }

    public function fnProperty($templater, &$escape, $name, $fallback = null)
    {
        $escape = false;

        if (!$this->style)
        {
            return $fallback;
        }

        return $this->style->getProperty($name, $fallback);
    }

    public function fnRand($templater, &$escape, $min = 0, $max = 999)
    {
        return mt_rand($min, $max);
    }

    public function fnRange($templater, &$escape, $start, $end, $step = 1)
    {
        return range($start, $end, $step);
    }

    public function fnRedirectInput($templater, &$escape, $url = null, $fallbackUrl = null, $useReferrer = true)
    {
        $escape = false;

        if ($url)
        {
            $redirect = $this->app->request()->convertToAbsoluteUri($url);
        }
        else
        {
            $redirect = $this->app->getDynamicRedirect($fallbackUrl ?: null, (bool)$useReferrer);
        }
        return '<input type="hidden" name="_xfRedirect" value="' . htmlspecialchars($redirect) . '" />';
    }

    public function fnRepeat($templater, &$escape, $string, $count)
    {
        return str_repeat($string, $count);
    }

    public function fnRepeatRaw($templater, &$escape, $string, $count)
    {
        $escape = false;
        return str_repeat($string, $count);
    }

    public function fnShowIgnored($templater, &$escape, array $attributes = [])
    {
        $escape = false;

        if (!\XF::visitor()->user_id)
        {
            return '';
        }

        $wrapperClass = $this->processAttributeToRaw($attributes, 'wrapperclass', '', true);
        $wrapper = $this->processAttributeToRaw($attributes, 'wrapper');
        if ($wrapperClass && !$wrapper)
        {
            $wrapper = 'div';
        }

        $class = $this->processAttributeToRaw($attributes, 'class', ' %s', true);
        $unhandledAttrs = $this->processUnhandledAttributes($attributes);

        $html = '<a href="javascript:"'
            . ' class="showIgnoredLink is-hidden js-showIgnored' . $class . '" data-xf-init="tooltip"'
            . ' title="' . \XF::phrase('show_hidden_content_by_x', ['names' => '{{names}}']) . '"'
            . ' ' . $unhandledAttrs . '>' .
            \XF::phrase('show_ignored_content')
            . '</a>';

        if ($wrapper)
        {
            $wrapperOpen = $wrapper . ($wrapperClass ? " class=\"$wrapperClass\"" : '');
            $html = "<{$wrapperOpen}>{$html}</{$wrapper}>";
        }

        return $html;
    }

    public function fnSmilie($templater, &$escape, $smilieString)
    {
        $escape = false;

        $formatter = $this->app->stringFormatter();
        return $formatter->replaceSmiliesHtml($smilieString);
    }

    public function fnSnippet($templater, &$escape, $string, $maxLength = 0, array $options = [])
    {
        // if we aren't escaping here
        $needsEscaping = ($escape ? true : false);
        $escape = false;

        $formatter = $this->app->stringFormatter();
        $string = $formatter->snippetString($string, $maxLength, $options);

        if (!empty($options['term']))
        {
            return $formatter->highlightTermForHtml(
                $string, $options['term'], isset($options['highlightClass']) ? $options['highlightClass'] : 'textHighlight'
            );
        }
        else
        {
            return $needsEscaping ? \XF::escapeString($string) : $string;
        }
    }

    public function fnStrlen($templater, &$escape, $string)
    {
        return utf8_strlen($string);
    }

    public function fnContains($templater, &$escape, $haystack, $needle)
    {
        return utf8_strpos(utf8_strtolower($haystack), utf8_strtolower($needle)) !== false;
    }

    public function fnStructuredText($templater, &$escape, $string, $nl2br = true)
    {
        $stringFormatter = $this->app->stringFormatter();

        $string = $stringFormatter->censorText($string);
        $string = \XF::escapeString($string);
        $string = $stringFormatter->autoLinkStructuredText($string);
        $string = $stringFormatter->linkStructuredTextMentions($string);

        if ($nl2br)
        {
            $string = nl2br($string);
        }

        $escape = false;
        return $string;
    }

    public function fnTemplater($templater, &$escape)
    {
        $escape = false;
        return $templater;
    }

    public function fnTime($templater, &$escape, $time, $format = null)
    {
        return $this->language->time($time, $format);
    }

    public function fnTransparentImg($templater, &$escape)
    {
        return 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
    }

    public function fnTrim($templater, &$escape, $str, $charlist = " \t\n\r\0\x0B")
    {
        return trim($str, $charlist);
    }

    public function fnUniqueId($templater, &$escape, $baseValue = null)
    {
        if ($baseValue === null)
        {
            $this->uniqueIdCounter++;
            $baseValue = $this->uniqueIdCounter;
        }

        return sprintf($this->uniqueIdFormat, $baseValue);
    }

    public function fnUserActivity($templater, &$escape, $user)
    {
        if (!$user instanceof \XF\Entity\User || !$user->user_id)
        {
            return '';
        }

        if (!$user->canViewOnlineStatus())
        {
            return '';
        }

        $output = '';
        $hasActivity = false;
        if ($user->canViewCurrentActivity() && $user->Activity)
        {
            if ($user->Activity->description)
            {
                $output .= \XF::escapeString($user->Activity->description);
                if ($user->Activity->item_title)
                {
                    $title = \XF::escapeString($user->Activity->item_title);
                    $url = \XF::escapeString($user->Activity->item_url);

                    $output .= " <em><a href=\"{$url}\" dir=\"auto\">{$title}</a></em>";
                }

                $output .= ' <span role="presentation" aria-hidden="true">&middot;</span> ';
                $hasActivity = true;
            }
        }

        $output .= $this->fnDateDynamic($this, $escape, $user->last_activity);

        if ($hasActivity && $user->Activity->view_state == 'error' && \XF::visitor()->canBypassUserPrivacy())
        {
            $output .= ' <span role="presentation" aria-hidden="true">&middot;</span> ';
            $output .= '<i class="fa fa-warning u-muted" title="' . $this->filterForAttr($this,\XF::phrase('viewing_an_error'), $null) . '" aria-hidden="true"></i>';
            $output .= ' <span class="u-srOnly">' . \XF::phrase('viewing_an_error') . '</span>';
        }

        $escape = false;

        return $output;
    }

    public function fnUserBanners($templater, &$escape, $user, $attributes = [])
    {
        /** @var \XF\Entity\User $user */

        $escape = false;

        if (!$user || !($user instanceof \XF\Entity\User) || !$user->user_id)
        {
            /** @var \XF\Repository\User $userRepo */
            $userRepo = $this->app->repository('XF:User');
            $user = $userRepo->getGuestUser();
        }

        $class = $this->processAttributeToRaw($attributes, 'class', ' %s', true);

        if (!empty($attributes['tag']))
        {
            $tag = htmlspecialchars($attributes['tag']);
        }
        else
        {
            $tag = 'em';
        }

        unset($attributes['tag']);

        $unhandledAttrs = $this->processUnhandledAttributes($attributes);

        $banners = [];
        $config = $this->userBannerConfig;

        if (!empty($config['showStaff']) && $user->is_staff)
        {
            $p = \XF::phrase('staff_member');
            $banners['staff'] = "<{$tag} class=\"userBanner userBanner--staff{$class}\" dir=\"auto\"{$unhandledAttrs}>"
                . "<span class=\"userBanner-before\"></span><strong>{$p}</strong><span class=\"userBanner-after\"></span></{$tag}>";
        }

        $memberGroupIds = $user->secondary_group_ids;
        $memberGroupIds[] = $user->user_group_id;

        foreach ($this->userBanners AS $groupId => $banner)
        {
            if (!in_array($groupId, $memberGroupIds))
            {
                continue;
            }

            $banners[$groupId] = "<{$tag} class=\"userBanner {$banner['class']}{$class}\"{$unhandledAttrs}>"
                . "<span class=\"userBanner-before\"></span><strong>{$banner['text']}</strong><span class=\"userBanner-after\"></span></{$tag}>";
        }

        if (!$banners)
        {
            return '';
        }

        if (!empty($config['displayMultiple']))
        {
            return implode("\n", $banners);
        }
        else if (!empty($config['showStaffAndOther']) && isset($banners['staff']) && count($banners) >= 2)
        {
            $staffBanner = $banners['staff'];
            unset($banners['staff']);
            return $staffBanner . "\n" . reset($banners);
        }
        else
        {
            return reset($banners);
        }
    }

    public function fnUserBlurb($templater, &$escape, $user, $attributes = [])
    {
        if (!$user instanceof \XF\Entity\User)
        {
            return '';
        }

        $blurbParts = [];

        $userTitle = $this->fnUserTitle($this, $escape, $user);
        if ($userTitle)
        {
            $blurbParts[] = $userTitle;
        }
        if ($user->Profile->age)
        {
            $blurbParts[] = $user->Profile->age;
        }
        if ($user->Profile->location)
        {
            $location = \XF::escapeString($user->Profile->location);
            if (\XF::options()->geoLocationUrl)
            {
                $location = '<a href="' . $this->app->router('public')->buildLink('misc/location-info', null, ['location' => $location]) . '" class="u-concealed" target="_blank" rel="nofollow noreferrer">' . $location. '</a>';
            }
            $blurbParts[] = \XF::phrase('from_x_location', ['location' => new \XF\PreEscaped($location)])->render();
        }

        $tag = $this->processAttributeToRaw($attributes, 'tag');
        if (!$tag)
        {
            $tag = 'div';
        }

        $class = $this->processAttributeToRaw($attributes, 'class', '%s', true);
        $unhandledAttrs = $this->processUnhandledAttributes($attributes);

        return "<{$tag} class=\"{$class}\" dir=\"auto\" {$unhandledAttrs}>"
            . implode(' <span role="presentation" aria-hidden="true">&middot;</span> ', $blurbParts)
            . "</{$tag}>";
    }

    public function fnUserTitle($templater, &$escape, $user, $withBanner = false, $attributes = [])
    {
        /** @var \XF\Entity\User $user */

        $escape = false;
        $userIsValid = ($user instanceof \XF\Entity\User);

        $userTitle = null;

        if ($userIsValid)
        {
            $customTitle = $user->custom_title;
            if ($customTitle)
            {
                $userTitle = htmlspecialchars($customTitle);
            }
        }

        if ($userTitle === null)
        {
            if ($withBanner && !empty($this->userBannerConfig['hideUserTitle']))
            {
                if (!$userIsValid)
                {
                    return '';
                }

                if (!empty($this->userBannerConfig['showStaff']) && $user->is_staff)
                {
                    return '';
                }

                if ($user->isMemberOf(array_keys($this->userBanners)))
                {
                    return '';
                }
            }

            if ($userIsValid)
            {
                $groupId = $user->display_style_group_id;
                if (!empty($this->groupStyles[$groupId]['user_title']))
                {
                    $userTitle = $this->groupStyles[$groupId]['user_title'];
                }
                else
                {
                    foreach ($this->userTitleLadder AS $points => $title)
                    {
                        if ($user[$this->userTitleLadderField] >= $points)
                        {
                            $userTitle = $title;
                            break;
                        }
                    }
                }
            }
            else
            {
                $guestGroupId = 1;
                if (empty($this->groupStyles[$guestGroupId]['user_title']))
                {
                    return '';
                }

                $userTitle = $this->groupStyles[$guestGroupId]['user_title'];
            }
        }

        if ($userTitle === null || !strlen($userTitle))
        {
            return '';
        }

        $class = $this->processAttributeToRaw($attributes, 'class', ' %s', true);

        if (!empty($attributes['tag']))
        {
            $tag = htmlspecialchars($attributes['tag']);
        }
        else
        {
            $tag = 'span';
        }

        unset($attributes['tag']);

        $unhandledAttrs = $this->processUnhandledAttributes($attributes);

        return "<{$tag} class=\"userTitle{$class}\" dir=\"auto\"{$unhandledAttrs}>{$userTitle}</{$tag}>";
    }

    public function fnUsernameLink($templater, &$escape, $user, $rich = false, $attributes = [])
    {
        $escape = false;

        if (isset($attributes['username']))
        {
            $username = $attributes['username'];
        }
        else if (isset($user['username']) && $user['username'] !== '')
        {
            $username = $user['username'];
        }
        else if (isset($attributes['defaultname']))
        {
            $username = $attributes['defaultname'];
        }
        else
        {
            return '';
        }

        $noTooltip = !empty($attributes['notooltip']);

        if (isset($attributes['href']))
        {
            $href = $attributes['href'];
            $noTooltip = true; // custom URL so tooltip won't work and might be misleading
        }
        else
        {
            $linkPath = $this->currentTemplateType == 'admin' ? 'users/edit' : 'members';
            $href = !empty($user['user_id']) ? $this->getRouter()->buildLink($linkPath, $user) : null;
            if (!$href || $this->currentTemplateType == 'admin')
            {
                $noTooltip = true;
            }
        }
        $hrefAttr = $href ? ' href="' . htmlspecialchars($href) . '"' : '';

        $class = $this->processAttributeToRaw($attributes, 'class', ' %s', true);
        $usernameStylingClasses = $this->fnUsernameClasses($this, $null, $user, $rich);
        $xfInit = $this->processAttributeToRaw($attributes, 'data-xf-init', '', true);

        if (!$noTooltip)
        {
            $xfInit = ltrim("$xfInit member-tooltip");
        }
        $xfInitAttr = $xfInit ? " data-xf-init=\"$xfInit\"" : '';

        unset($attributes['username'], $attributes['defaultname'], $attributes['href'], $attributes['notooltip']);
        $unhandledAttrs = $this->processUnhandledAttributes($attributes);

        $userId = !empty($user['user_id']) ? intval($user['user_id']) : 0;

        $username = htmlspecialchars($username);
        if ($usernameStylingClasses)
        {
            $username = "<span class=\"{$usernameStylingClasses}\">{$username}</span>";
        }
        if ($hrefAttr)
        {
            $tag = 'a';
        }
        else
        {
            $tag = 'span';
        }
        return "<{$tag}{$hrefAttr} class=\"username {$class}\" dir=\"auto\" data-user-id=\"{$userId}\"{$xfInitAttr}{$unhandledAttrs}>{$username}</{$tag}>";
    }

    public function fnUsernameLinkEmail($templater, &$escape, $user, $defaultName = '', array $attributes = [])
    {
        $escape = false;

        if (isset($attributes['username']))
        {
            $username = $attributes['username'];
        }
        else if (isset($user['username']) && $user['username'] !== '')
        {
            $username = $user['username'];
        }
        else if ($defaultName !== '')
        {
            $username = $defaultName;
        }
        else
        {
            return '';
        }

        unset($attributes['username']);

        if (isset($attributes['href']))
        {
            $href = $attributes['href'];
        }
        else
        {
            $href = !empty($user['user_id']) ? $this->getRouter()->buildLink('canonical:members', $user) : null;

        }
        $hrefAttr = $href ? ' href="' . htmlspecialchars($href) . '"' : '';
        $tag = $href ? 'a' : 'span';

        unset($attributes['username'], $attributes['href']);
        $unhandledAttrs = $this->processUnhandledAttributes($attributes);

        $username = htmlspecialchars($username);

        return "<{$tag} dir=\"auto\"{$hrefAttr}{$unhandledAttrs}>{$username}</{$tag}>";
    }

    public function fnUsernameClasses($templater, &$escape, $user, $includeGroupStyling = true)
    {
        $classes = [];

        if ($includeGroupStyling)
        {
            if (!$user || empty($user['user_id']))
            {
                $displayGroupId = 1;
            }
            else
            {
                if (!empty($user['display_style_group_id']))
                {
                    $displayGroupId = $user['display_style_group_id'];
                }
                else
                {
                    $displayGroupId = 0;
                }
            }

            if ($displayGroupId && !empty($this->groupStyles[$displayGroupId]['username_css']))
            {
                $classes[] = 'username--style' . $displayGroupId;
            }
        }

        if (!empty($user['is_banned']) && \XF::visitor()->canBypassUserPrivacy())
        {
            $classes[] = 'username--banned';
        }

        foreach (['staff', 'moderator', 'admin'] AS $userType)
        {
            if (!empty($user["is_{$userType}"]))
            {
                $classes[] = "username--{$userType}";
            }
        }

        $escape = false; // note: not doing this explicitly, shouldn't be needed for the output format

        return implode(' ', $classes);
    }

    public function fnWidgetData($templater, &$escape, $widgetData, $asArray = false)
    {
        $output = [];

        $escape = false;

        if (isset($widgetData['id']))
        {
            if ($asArray)
            {
                $output['data-widget-id'] = $widgetData['id'];
            }
            else
            {
                $output[] = 'data-widget-id="' . $widgetData['id'] . '"';
            }
        }
        if (isset($widgetData['key']))
        {
            if ($asArray)
            {
                $output['data-widget-key'] = $widgetData['key'];
            }
            else
            {
                $output[] = 'data-widget-key="' . $widgetData['key'] . '"';
            }
        }
        if (isset($widgetData['definition']))
        {
            if ($asArray)
            {
                $output['data-widget-definition'] = $widgetData['definition'];
            }
            else
            {
                $output[] = 'data-widget-definition="' . $widgetData['definition'] . '"';
            }
        }

        if ($asArray)
        {
            return $output ? $output : [];
        }
        else
        {
            return $output ? ' ' . implode(' ', $output) : '';
        }
    }

    ////////////////////// FILTERS //////////////////////////

    public function filterDefault($templater, $value, &$escape, $defaultValue)
    {
        if ($value === null)
        {
            $value = $defaultValue;
        }

        return $value;
    }

    public function filterCensor($templater, $value, &$escape, $censorChar = null)
    {
        return $this->app->stringFormatter()->censorText($value, $censorChar);
    }

    public function filterCurrency($templater, $value, &$escape, $code = '', $format = null)
    {
        $currency = $this->app->data('XF:Currency');
        return $currency->languageFormat($value, $code, $this->language, $format);
    }

    public function filterEscape($templater, $value, &$escape, $type = true)
    {
        $escape = $type;
        return $value;
    }

    public function filterForAttr($templater, $value, &$escape)
    {
        // this is a sanity check to make sure even pre-escaped values are escaped and can't break out of
        // an HTML attribute
        $escape = false;
        return htmlspecialchars(strval($value), ENT_QUOTES, 'UTF-8', false);
    }

    public function filterFileSize($templater, $value, &$escape)
    {
        return $this->language->fileSizeFormat($value);
    }

    public function filterFirst($templater, $value, &$escape)
    {
        if (is_array($value))
        {
            return reset($value);
        }
        else if ($value instanceof AbstractCollection)
        {
            return $value->first();
        }
        else
        {
            return $value;
        }
    }

    public function filterHex($templater, $value, &$escape)
    {
        return bin2hex($value);
    }

    public function filterHost($templater, $value, &$escape)
    {
        return \XF\Util\Ip::getHost($value);
    }

    public function filterIp($templater, $value, &$escape)
    {
        return \XF\Util\Ip::convertIpBinaryToString($value);
    }

    public function filterJoin($templater, $value, &$escape, $join = ',')
    {
        if (!$this->isTraversable($value))
        {
            return '';
        }

        $parts = [];
        foreach ($value AS $child)
        {
            $parts[] = $escape ? $this->escape($child, $escape) : $child;
        }

        $escape = false;
        return implode($join, $parts);
    }

    public function filterJson($templater, $value, &$escape, $prettyPrint = false)
    {
        if ($prettyPrint)
        {
            $output = \XF\Util\Json::jsonEncodePretty($value, false);

            // do limited slash escaping to improve readability
            $output = str_replace('</', '<\\/', $output);
        }
        else
        {
            $output = json_encode($value);
        }

        $output = str_replace('<!', '\u003C!', $output);

        return $output;
    }

    public function filterLast($templater, $value, &$escape)
    {
        if (is_array($value))
        {
            return end($value);
        }
        else if ($value instanceof AbstractCollection)
        {
            return $value->last();
        }
        else
        {
            return $value;
        }
    }

    public function filterNl2Br($templater, $value, &$escape)
    {
        if ($escape)
        {
            $value = $this->escape($value, $escape);
        }
        $escape = false;

        return nl2br($value);
    }

    public function filterNl2Nl($templater, $value, &$escape)
    {
        if ($escape)
        {
            $value = $this->escape($value, $escape);
        }
        $escape = false;

        return str_replace('\n', "\n", $value);
    }

    public function filterNumber($templater, $value, &$escape, $precision = 0)
    {
        return $this->language->numberFormat($value, $precision);
    }

    public function filterNumberShort($templater, $value, &$escape)
    {
        return $this->language->shortNumberFormat($value);
    }

    public function filterZeroFill($templater, $value, &$escape, $length = 3)
    {
        if (is_int($value))
        {
            $length = intval($length);
            return sprintf("%0{$length}d", $value);
        }

        return $value;
    }

    public function filterPad($templater, $value, &$escape, $padChar, $length, $postPad = false)
    {
        $length = intval($length);
        $padChar = substr($padChar, 0, 1);
        $postPad = $postPad ? '-' : '';

        return sprintf("%{$postPad}'{$padChar}{$length}s", $value);
    }

    public function filterParens($templater, $value, &$escape)
    {
        $value = (string)$value;
        if (strlen($value))
        {
            $value = $this->language['parenthesis_open'] . $value . $this->language['parenthesis_close'];
        }

        return $value;
    }

    public function filterPluck($templater, $value, &$escape, $valueField, $keyField = null)
    {
        if (!$this->isTraversable($value))
        {
            return [];
        }

        $parts = [];
        foreach ($value AS $key => $child)
        {
            if ($keyField !== null && isset($child[$keyField]))
            {
                $key = $child[$keyField];
            }
            $parts[$key] = isset($child[$valueField]) ? $child[$valueField] : null;
        }

        return $parts;
    }

    public function filterPreEscaped($templater, $value, &$escape, $type = 'html')
    {
        $escape = false;

        return $this->preEscaped($value, $type);
    }

    public function filterRaw($templater, $value, &$escape)
    {
        $escape = false;
        return $value;
    }

    public function filterReplace($templater, $value, &$escape, $from, $to = null)
    {
        if ($value instanceof \XF\Mvc\Entity\AbstractCollection)
        {
            $value = $value->toArray();
        }

        if (!is_array($from))
        {
            $from = [$from => $to];
        }

        if (!is_array($from))
        {
            return $value;
        }

        if (is_array($value))
        {
            return array_replace($value, $from);
        }
        else if (is_string($value))
        {
            return str_replace(array_keys($from), $from, $value);
        }
        else
        {
            return $value;
        }
    }

    public function filterSplit($templater, $value, &$escape, $delimiter = ',', $limit = PHP_INT_MAX)
    {
        switch ($delimiter)
        {
            case ',':
                $split = @preg_split('#\s*,\s*#', $value, $limit, PREG_SPLIT_NO_EMPTY);
                break;

            case 'nl':
                $split = @preg_split('/\r?\n/', $value, $limit, PREG_SPLIT_NO_EMPTY);
                break;

            default:
                $split = @explode($delimiter, $value, $limit);
                break;
        }

        if (!is_array($split))
        {
            $split = [];
        }

        return $split;
    }

    public function filterStripTags($templater, $value, &$escape, $allowableTags = null)
    {
        return strip_tags($value, $allowableTags);
    }

    public function filterToLower($templater, $value, &$escape, $type = 'strtolower')
    {
        switch ($type)
        {
            case 'lcfirst': return lcfirst($value);
            case 'strtolower': return utf8_strtolower($value);

            default:
                trigger_error("Invalid to lower type '{$type}' provided.", E_USER_WARNING);
                return '';
        }

    }

    public function filterToUpper($templater, $value, &$escape, $type = 'strtoupper')
    {
        switch ($type)
        {
            case 'ucfirst':
            case 'ucwords':
            case 'strtoupper':
                $f = 'utf8_' . $type;
                return $f($value);

            default:
                trigger_error("Invalid to upper type '{$type}' provided.", E_USER_WARNING);
                return '';
        }
    }

    public function filterUrl($templater, $value, &$escape, $component = null, $fallback = '')
    {
        $result = @parse_url($value);
        if (!$result)
        {
            return $fallback;
        }

        if (!$component)
        {
            return $value;
        }

        if (isset($result[$component]))
        {
            return $result[$component];
        }
        else
        {
            return $fallback;
        }
    }

    public function filterUrlencode($templater, $value, &$escape)
    {
        return urlencode($value);
    }

    ////////////////////// TESTS ////////////////////////

    public function testEmpty($templater, $value)
    {
        if (is_object($value) && is_callable([$value, '__toString']))
        {
            return strval($value) === '';
        }

        if ($value instanceof \Countable)
        {
            return count($value) == 0;
        }

        return ($value === '' || $value === false || $value === null || $value === []);
    }

    ////////////////////// FORM ELEMENTS ////////////////////////

    public function mergeChoiceOptions($original, $additional)
    {
        if ($original instanceof \Traversable)
        {
            $original = iterator_to_array($original, false);
        }
        else if (!is_array($original))
        {
            $original = [];
        }

        if ($this->isTraversable($additional))
        {
            foreach ($additional AS $key => $option)
            {
                if (is_string($option)
                    || is_numeric($option)
                    || (is_object($option) && method_exists($option, '__toString'))
                )
                {
                    $original[] = [
                        'value' => $key,
                        'label' => \XF::escapeString($option),
                        '_type' => 'option'
                    ];
                }
            }
        }

        return $original;
    }

    public function processAttributeToHtmlAttribute(array &$attributes, $name, $fallbackValue = '', $appendFallback = false)
    {
        return $this->processAttributeToNamedHtmlAttribute($attributes, $name, $name, $fallbackValue, $appendFallback);
    }

    public function processAttributeToNamedHtmlAttribute(array &$attributes, $sourceName, $targetName, $fallbackValue = '', $appendFallback = false)
    {
        if (isset($attributes[$sourceName]))
        {
            $value = $attributes[$sourceName];
            if ($appendFallback && $fallbackValue)
            {
                $value .= " $fallbackValue";
            }
        }
        else
        {
            $value = $fallbackValue;
        }

        unset($attributes[$sourceName]);

        if (is_array($value))
        {
            return '';
        }

        $value = strval($value);
        if ($value === '')
        {
            return '';
        }
        else
        {
            return " $targetName=\"" . \XF::escapeString($value) . "\"";
        }
    }

    public function processCodeAttribute(array &$attributes)
    {
        if (isset($attributes['code']))
        {
            if ($attributes['code'] === 'true' || $attributes['code'] === 1)
            {
                $attributes['dir'] = 'ltr';
                $attributes['class'] = (empty($attributes['class']) ? 'input--code' : $attributes['class'] . ' input--code');
            }

            unset($attributes['code']);
        }
    }

    public function processBooleanAttributeHtml(array &$attributes, $name, $outputAttribute)
    {
        if (!isset($attributes[$name]))
        {
            return '';
        }

        $value = $attributes[$name];
        unset($attributes[$name]);

        if ($value)
        {
            return " $outputAttribute";
        }
        else
        {
            return '';
        }
    }

    public function processAttributeToRaw(array &$attributes, $name, $formatter = '', $escapeValue = false)
    {
        if (isset($attributes[$name]))
        {
            $value = strval($attributes[$name]);
            if ($value !== '')
            {
                if ($escapeValue)
                {
                    $value = \XF::escapeString($value);
                }

                if ($formatter)
                {
                    if ($formatter instanceof \Closure)
                    {
                        $value = $formatter($value);
                    }
                    else
                    {
                        $value =  sprintf($formatter, $value);
                    }
                }
            }
        }
        else
        {
            $value = '';
        }

        unset($attributes[$name]);

        return $value;
    }

    protected function processUnhandledAttributes(array $attributes)
    {
        $output = '';
        foreach ($attributes AS $name => $value)
        {
            if (is_array($value))
            {
                continue;
            }

            if ($value instanceof \XF\Phrase)
            {
                // strval will do escaping of the values or the whole phrase, so get the raw value and escape that here
                $value = $value->render('raw');
            }
            else
            {
                $value = strval($value);
            }

            if ($value !== '')
            {
                $output .= " $name=\"" . \XF::escapeString($value) . "\"";
            }
        }

        return $output;
    }

    protected function processDynamicAttributes(array &$attributes, array $skip = [])
    {
        if (!isset($attributes['attributes']))
        {
            return;
        }

        foreach ($attributes['attributes'] AS $key => $attribute)
        {
            if ($key == 'attributes' || isset($attributes[$key]) || isset($skip[$key]))
            {
                continue;
            }
            $attributes[$key] = $attribute;
        }
        unset($attributes['attributes']);
    }

    protected function handleChoices(array $choices, \Closure $choiceFormatter, \Closure $groupFormatter)
    {
        $html = '';

        foreach ($choices AS $choice)
        {
            if (isset($choice['_type']))
            {
                $type = $choice['_type'];
            }
            else
            {
                $type = 'option';
            }
            unset($choice['_type']);

            if ($type == 'optgroup')
            {
                $childHtml = $this->handleChoices($choice['options'], $choiceFormatter, $groupFormatter);
                unset($choice['options']);

                $html .= $groupFormatter($choice, $childHtml);
            }
            else
            {
                $dependent = !empty($choice['_dependent']) ? $choice['_dependent'] : [];
                foreach ($dependent AS $key => &$val)
                {
                    $val = trim($val);
                    if (!strlen($val))
                    {
                        unset($dependent[$key]);
                    }
                }
                unset($choice['_dependent']);

                $html .= $choiceFormatter($choice, $dependent);
            }
        }

        return $html;
    }

    public function isChoiceSelected(array $choice, $inputValue, $allowMultiple = false)
    {
        if (isset($choice['selected']))
        {
            return $choice['selected'];
        }

        if ($inputValue !== null)
        {
            $choiceValue = isset($choice['value']) ? strval($choice['value']) : '';

            if (is_array($inputValue) && $allowMultiple)
            {
                return in_array($choiceValue, $inputValue);
            }
            else if (!is_array($inputValue))
            {
                return (
                    ($inputValue === true && $choiceValue === '1')
                    || ($inputValue === false && $choiceValue === '0')
                    || (strval($inputValue) === $choiceValue)
                );
            }
        }

        return false;
    }

    public function formHiddenVal($name, $value, array $extraAttributes = [])
    {
        $this->processDynamicAttributes($extraAttributes);

        $nameHtml = \XF::escapeString($name);
        $valueHtml = \XF::escapeString($value);
        $extraAttrs = $this->processUnhandledAttributes($extraAttributes);

        return "<input type=\"hidden\" name=\"{$nameHtml}\" value=\"{$valueHtml}\"{$extraAttrs} />";
    }

    public function formCheckBox(array $controlOptions, array $choices)
    {
        $this->processDynamicAttributes($controlOptions);

        $name = \XF::escapeString($this->processAttributeToRaw($controlOptions, 'name'));
        if ($name && substr($name, -2) != '[]')
        {
            $name .= '[]';
        }

        $readOnly = $this->processAttributeToRaw($controlOptions, 'readonly');

        $value = isset($controlOptions['value']) ? $controlOptions['value'] : null;

        $standalone = ($this->processAttributeToRaw($controlOptions, 'standalone') && count($choices) == 1);

        $choiceFormatter = function(array $choice, array $dependent) use ($name, $readOnly, $value, $standalone)
        {
            $selected = $this->isChoiceSelected($choice, $value, true);
            if (!empty($choice['name']))
            {
                $localName = \XF::escapeString($choice['name']);
            }
            else
            {
                $localName = $name;
            }
            if ($localName)
            {
                $nameAttr = ' name="' . $localName . '"';
            }
            else
            {
                $nameAttr = '';
            }

            unset($choice['selected'], $choice['name'], $choice['type']);

            $dependentHtml = '';
            if ($dependent && !$standalone)
            {
                $dependentHtmlInner = '';
                foreach ($dependent AS $child)
                {
                    $dependentHtmlInner .= "\n\t\t\t\t<li class=\"inputChoices-option\">$child</li>";
                }
                $dependentHtml = "\n\t\t\t<ul class=\"inputChoices-dependencies\">{$dependentHtmlInner}\n\t\t\t</ul>\n\t\t";
            }
            if ($dependentHtml)
            {
                $this->addElementHandler($choice, 'disabler');
            }

            $labelClass = 'iconic iconic--checkbox';
            $label = trim($this->processAttributeToRaw($choice, 'label'));
            if ($label !== '')
            {
                $labelClass .= ' iconic--labelled';
            }
            $labelClas***tra = $this->processAttributeToRaw($choice, 'labelclass', '', true);
            if ($labelClas***tra !== '')
            {
                $labelClass .= " {$labelClas***tra}";
            }
            $hiddenLabel = $this->processAttributeToRaw($choice, 'hiddenlabel');
            if ($label && $hiddenLabel != '')
            {
                $hiddenLabel = true;
            }
            else
            {
                $hiddenLabel = false;
            }
            if ($label && $hiddenLabel)
            {
                $label = '<span class="u-srOnly">' . $label . '</span>';
            }

            $titleAttr = $this->processAttributeToHtmlAttribute($choice, 'title');

            $tooltipAttr = '';
            if ($choice['data-xf-init'] == 'tooltip')
            {
                $tooltipAttr = $this->processAttributeToHtmlAttribute($choice, 'data-xf-init');
            }

            $checkAll = $this->processAttributeToRaw($choice, 'check-all');
            if ($checkAll != '')
            {
                $choice['data-xf-init'] .= (empty($choice['data-xf-init']) ? '' : ' ') . 'check-all';
                $choice['data-container'] = $checkAll;
            }

            $hint = $this->processAttributeToRaw($choice, 'hint', "\n\t\t\t\t\t<dfn class=\"inputChoices-explain\">%s</dfn>");
            $extraHtml = $this->processAttributeToRaw($choice, 'html', "\n\t\t\t\t\t%s");
            $afterHint = $this->processAttributeToRaw($choice, 'afterhint', "\n\t\t\t<dfn class=\"inputChoices-explain inputChoices-explain--after\">%s</dfn>");
            $afterHtml = $this->processAttributeToRaw($choice, 'afterhtml', "\n\t\t\t%s");

            $valueAttr = $this->processAttributeToHtmlAttribute($choice, 'value');
            if (!$valueAttr)
            {
                $valueAttr = ' value="1"';
            }
            $selectedAttr = $selected ? ' checked="checked"' : '';
            $readOnlyAttr = $readOnly ? ' readonly="readonly" onclick="return false"' : '';

            if ($readOnly)
            {
                $labelClass .= ' is-readonly';
            }

            if (isset($choice['defaultvalue']) && $localName && substr($localName, -2) != '[]')
            {
                // $localName is escaped
                $defaultValueInput = '<input type="hidden" name="' . $localName
                    . '" value="' . \XF::escapeString($choice['defaultvalue']) . '" />';

                unset($choice['defaultvalue']);
            }
            else
            {
                $defaultValueInput = '';
            }

            $attributes = $this->processUnhandledAttributes($choice);

            $checkboxHtml = $defaultValueInput . "<label class=\"{$labelClass}\"{$titleAttr}{$tooltipAttr}>"
                . "<input type=\"checkbox\" {$nameAttr}{$valueAttr}{$selectedAttr}{$readOnlyAttr}{$attributes} />"
                . "<i aria-hidden=\"true\"></i>{$label}</label>{$hint}{$extraHtml}{$dependentHtml}{$afterHint}{$afterHtml}";

            if ($standalone)
            {
                return $checkboxHtml . "\n";
            }
            else
            {
                return "<li class=\"inputChoices-choice\">{$checkboxHtml}</li>\n";
            }
        };
        $groupFormatter = function(array $group, $html)
        {
            $label = $this->processAttributeToRaw($group, 'label');
            if ($label)
            {
                $checkAll = $this->processAttributeToRaw($group, 'check-all');
                if ($checkAll)
                {
                    $label = '<label class="iconic iconic--checkbox iconic--labelled inputChoices-heading-checkAll">
                        <input type="checkbox" data-xf-init="check-all" data-container="< .inputChoices-group" /><i aria-hidden="true"></i>'
                        . $label . '</label>';
                }
                $class = $this->processAttributeToRaw($group, 'class', '', true);
                $listClass = $this->processAttributeToRaw($group, 'listclass', '', true);

                $html = "<li class=\"inputChoices-group {$class}\">
                    <div class=\"inputChoices-heading\">{$label}</div>
                    <ul class=\"inputChoices {$listClass}\">{$html}</ul>
                </li>";
            }

            return $html;
        };

        $choiceHtml = $this->handleChoices($choices, $choiceFormatter, $groupFormatter);

        $hideEmpty = $this->processAttributeToRaw($controlOptions, 'hideempty');
        if ($hideEmpty && !$choiceHtml)
        {
            return '';
        }

        if ($standalone)
        {
            return $choiceHtml;
        }

        $listClassAttr = $this->processAttributeToNamedHtmlAttribute($controlOptions, 'listclass', 'class', 'inputChoices', true);

        $unhandledAttrs = $this->processUnhandledAttributes($controlOptions);

        return "
            <ul{$listClassAttr}{$unhandledAttrs}>
                $choiceHtml
            </ul>
        ";
    }

    public function formCheckBoxRow(array $controlOptions, array $choices, array $rowOptions)
    {
        $controlHtml = $this->formCheckBox($controlOptions, $choices);
        return $controlHtml ? $this->formRow($controlHtml, $rowOptions) : '';
    }

    public function formRadio(array $controlOptions, array $choices)
    {
        $this->processDynamicAttributes($controlOptions);

        $name = \XF::escapeString($this->processAttributeToRaw($controlOptions, 'name'));
        $readOnly = $this->processAttributeToRaw($controlOptions, 'readonly');

        $value = isset($controlOptions['value']) ? $controlOptions['value'] : null;
        unset($controlOptions['value']);

        $standalone = ($this->processAttributeToRaw($controlOptions, 'standalone') && count($choices) == 1);

        $choiceFormatter = function(array $choice, array $dependent) use ($name, $readOnly, $value, $standalone)
        {
            $selected = $this->isChoiceSelected($choice, $value, false);

            unset($choice['selected'], $choice['type']);

            $dependentHtml = '';
            if ($dependent)
            {
                $dependentHtmlInner = '';
                foreach ($dependent AS $child)
                {
                    $dependentHtmlInner .= "\n\t\t\t\t<li class=\"inputChoices-choice\">$child</li>";
                }
                $dependentHtml = "\n\t\t\t<ul class=\"inputChoices-dependencies\">{$dependentHtmlInner}\n\t\t\t</ul>\n\t\t";
            }
            if ($dependentHtml)
            {
                $this->addElementHandler($choice, 'disabler');
            }

            $labelClass = 'iconic iconic--radio';
            $label = trim($this->processAttributeToRaw($choice, 'label'));
            if ($label !== '')
            {
                $labelClass .= ' iconic--labelled';
            }
            $labelClas***tra = $this->processAttributeToRaw($choice, 'labelclass', '', true);
            if ($labelClas***tra !== '')
            {
                $labelClass .= " {$labelClas***tra}";
            }

            $titleAttr = $this->processAttributeToHtmlAttribute($choice, 'title');

            $tooltipAttr = '';
            if ($choice['data-xf-init'] == 'tooltip')
            {
                $tooltipAttr = $this->processAttributeToHtmlAttribute($choice, 'data-xf-init');
            }

            $hint = $this->processAttributeToRaw($choice, 'hint', "\n\t\t\t\t\t<dfn class=\"inputChoices-explain\">%s</dfn>");
            $extraHtml = $this->processAttributeToRaw($choice, 'html', "\n\t\t\t\t\t%s");
            $valueAttr = $this->processAttributeToHtmlAttribute($choice, 'value');
            if (!$valueAttr)
            {
                $valueAttr = ' value=""';
            }
            $selectedAttr = $selected ? ' checked="checked"' : '';
            $readOnlyAttr = $readOnly ? ' readonly="readonly" onclick="return false"' : '';

            if ($readOnly)
            {
                $labelClass .= ' is-readonly';
            }

            $listItemClass = $this->processAttributeToNamedHtmlAttribute($choice, 'listitemclass', 'class', 'inputChoices-choice', true);
            $attributes = $this->processUnhandledAttributes($choice);

            $radioHtml = "<label class=\"{$labelClass}\"{$titleAttr}{$tooltipAttr}>"
                . "<input type=\"radio\" name=\"$name\"{$valueAttr}{$selectedAttr}{$readOnlyAttr}{$attributes} />"
                . "<i aria-hidden=\"true\"></i>{$label}</label>{$hint}{$dependentHtml}{$extraHtml}";

            if ($standalone)
            {
                return $radioHtml . "\n";
            }
            else
            {
                return "<li{$listItemClass}>{$radioHtml}</li>\n";
            }
        };
        $groupFormatter = function(array $group, $html)
        {
            $label = $this->processAttributeToRaw($group, 'label');
            if ($label)
            {
                $class = $this->processAttributeToRaw($group, 'class', '', true);
                $listClass = $this->processAttributeToRaw($group, 'listclass', '', true);

                $html = "<li class=\"inputChoices-group {$class}\">
                    <div class=\"inputChoices-heading\">{$label}</div>
                    <ul class=\"inputChoices {$listClass}\">{$html}</ul>
                </li>";
            }
        };

        $choiceHtml = $this->handleChoices($choices, $choiceFormatter, $groupFormatter);

        $hideEmpty = $this->processAttributeToRaw($controlOptions, 'hideempty');
        if ($hideEmpty && !$choiceHtml)
        {
            return '';
        }

        if ($standalone)
        {
            return $choiceHtml;
        }

        $listClassAttr = $this->processAttributeToNamedHtmlAttribute($controlOptions, 'listclass', 'class', 'inputChoices', true);

        $unhandledAttrs = $this->processUnhandledAttributes($controlOptions);

        return "
            <ul{$listClassAttr}{$unhandledAttrs}>
                $choiceHtml
            </ul>
        ";
    }

    public function formRadioRow(array $controlOptions, array $choices, array $rowOptions)
    {
        $controlHtml = $this->formRadio($controlOptions, $choices);
        return $controlHtml ? $this->formRow($controlHtml, $rowOptions) : '';
    }

    public function formSelect(array $controlOptions, array $choices)
    {
        $this->processDynamicAttributes($controlOptions);

        $name = \XF::escapeString($this->processAttributeToRaw($controlOptions, 'name'));

        $value = isset($controlOptions['value']) ? $controlOptions['value'] : null;
        unset($controlOptions['value']);

        $multiple = !empty($controlOptions['multiple']);
        if ($multiple)
        {
            $multipleAttr = ' multiple="multiple"';
            if ($name && substr($name, -2) != '[]')
            {
                $name .= '[]';
            }
        }
        else
        {
            $multipleAttr = '';
        }
        unset($controlOptions['multiple']);

        $choiceFormatter = function(array $choice) use ($name, $value, $multiple)
        {
            $selected = $this->isChoiceSelected($choice, $value, $multiple);
            unset($choice['selected'], $choice['explain']);

            $label = trim($this->processAttributeToRaw($choice, 'label'));
            if ($label === '')
            {
                $label = '&nbsp;';
            }
            $valueAttr = $this->processAttributeToHtmlAttribute($choice, 'value');
            if (!$valueAttr)
            {
                $valueAttr = ' value=""';
            }
            $selectedAttr = $selected ? ' selected="selected"' : '';
            $disabled = $this->processAttributeToRaw($choice, 'disabled');
            $disabledAttr = $disabled ? ' disabled="disabled"': '';
            $attributes = $this->processUnhandledAttributes($choice);

            return "<option{$valueAttr}{$selectedAttr}{$disabledAttr}{$attributes}>{$label}</option>\n";
        };
        $groupFormatter = function(array $group, $html)
        {
            if (!$html)
            {
                return '';
            }

            $attributes = $this->processUnhandledAttributes($group);
            return "<optgroup{$attributes}>\n$html</optgroup>";
        };

        $choiceHtml = $this->handleChoices($choices, $choiceFormatter, $groupFormatter);
        $hideEmpty = $this->processAttributeToRaw($controlOptions, 'hideempty');
        if ($hideEmpty && !$choiceHtml)
        {
            return '';
        }

        $readOnly = $this->processAttributeToRaw($controlOptions, 'readonly');
        $disabled = $this->processAttributeToRaw($controlOptions, 'disabled');
        if ($readOnly)
        {
            $this->addToClassAttribute($controlOptions, 'is-readonly');
            $disabled = true;
        }

        $disabledAttr = $disabled ? ' disabled="disabled"' : '';

        $classAttr = $this->processAttributeToHtmlAttribute($controlOptions, 'class', 'input', true);
        $unhandledAttrs = $this->processUnhandledAttributes($controlOptions);

        $select = "
            <select name=\"{$name}\"{$multipleAttr}{$classAttr}{$disabledAttr}{$unhandledAttrs}>
                $choiceHtml
            </select>
        ";
        if ($readOnly && $value !== null)
        {
            if ($multiple)
            {
                if (is_array($value))
                {
                    foreach ($value AS $subValue)
                    {
                        $select .= '<input type="hidden" name="' . $name . '" value="' . \XF::escapeString($subValue) . '" />';
                    }
                }
            }
            else
            {
                $select .= '<input type="hidden" name="' . $name . '" value="' . \XF::escapeString($value) . '" />';
            }
        }

        return $select;
    }

    public function formSelectRow(array $controlOptions, array $choices, array $rowOptions)
    {
        $this->addToClassAttribute($rowOptions, 'formRow--input', 'rowclass');

        $controlId = $this->assignFormControlId($controlOptions);
        $controlHtml = $this->formSelect($controlOptions, $choices);
        return $controlHtml ? $this->formRow($controlHtml, $rowOptions, $controlId) : '';
    }

    public function formSubmitRow(array $controlOptions, array $rowOptions)
    {
        $this->processDynamicAttributes($controlOptions);

        $sticky = $this->processAttributeToRaw($controlOptions, 'sticky');
        if ($sticky && $sticky != 'false')
        {
            $this->addElementHandler($rowOptions, 'form-submit-row', 'rowclass');

            if ($sticky != 'true' && !is_numeric($sticky)) // indicates a container selector
            {
                $rowOptions['data-container'] = $sticky;
            }
        }

        $buttonClasses = 'button button--primary';
        $icon = $this->processAttributeToRaw($controlOptions, 'icon');
        if ($icon)
        {
            $buttonClasses .= ' button--icon button--icon--' . preg_replace('#[^a-zA-Z0-9_-]#', '', $icon);
        }
        $classAttr = $this->processAttributeToHtmlAttribute($controlOptions, 'class', $buttonClasses, true);

        $submit = strval($this->processAttributeToRaw($controlOptions, 'submit'));
        if (!$submit && $icon)
        {
            $submit = $this->getButtonPhraseFromIcon($icon, 'button.submit');
        }

        $unhandledControlAttrs = $this->processUnhandledAttributes($controlOptions);

        if (strlen($submit))
        {
            $controlHtml = "<button {$classAttr}{$unhandledControlAttrs}><span class=\"button-text\">{$submit}</span></button>";
        }
        else
        {
            $controlHtml = '';
        }

        $extraHtml = $this->processAttributeToRaw($rowOptions, 'html', "\n\t\t\t\t%s");

        $class = $this->processAttributeToRaw($rowOptions, 'rowclass', ' %s', true);
        if ($sticky)
        {
            $class .= ' formSubmitRow--sticky';
        }

        $rowType = $this->processAttributeToRaw($rowOptions, 'rowtype');
        if ($rowType)
        {
            $class = $this->appendClassList($class, $rowType, 'formSubmitRow--%s');
        }

        $unhandledRowAttrs = $this->processUnhandledAttributes($rowOptions);

        return "
            <dl class=\"formRow formSubmitRow{$class}\"{$unhandledRowAttrs}>
                <dt></dt>
                <dd>
                    <div class=\"formSubmitRow-main\">
                        <div class=\"formSubmitRow-bar\"></div>
                        <div class=\"formSubmitRow-controls\">{$controlHtml}{$extraHtml}</div>
                    </div>
                </dd>
            </dl>
        ";
    }

    public function formTextArea(array $controlOptions)
    {
        $this->processDynamicAttributes($controlOptions);

        $this->processCodeAttribute($controlOptions);

        $autosize = $this->processAttributeToRaw($controlOptions, 'autosize');
        if ($autosize)
        {
            $this->addElementHandler($controlOptions, 'textarea-handler');
            $classAppend = ' input--fitHeight';
        }
        else
        {
            $classAppend = '';
        }

        $maxLength = $this->processAttributeToRaw($controlOptions, 'maxlength');
        if ($maxLength)
        {
            $maxlengthAttr = " maxlength=\"{$maxLength}\"";
        }
        else
        {
            $maxlengthAttr = '';
        }

        $value = \XF::escapeString($this->processAttributeToRaw($controlOptions, 'value'));
        $readOnlyAttr = $this->processAttributeToRaw($controlOptions, 'readonly') ? ' readonly="readonly"' : '';
        $classAttr = $this->processAttributeToHtmlAttribute($controlOptions, 'class', 'input' . $classAppend, true);

        $unhandledAttrs = $this->processUnhandledAttributes($controlOptions);

        return "<textarea{$classAttr}{$readOnlyAttr}{$maxlengthAttr}{$unhandledAttrs}>{$value}</textarea>";
    }

    public function formTextAreaRow(array $controlOptions, array $rowOptions)
    {
        $this->addToClassAttribute($rowOptions, 'formRow--input', 'rowclass');

        $controlId = $this->assignFormControlId($controlOptions);
        $controlHtml = $this->formTextArea($controlOptions);
        return $this->formRow($controlHtml, $rowOptions, $controlId);
    }

    public function formDateInput(array $controlOptions)
    {
        $this->processDynamicAttributes($controlOptions);

        $class = $this->processAttributeToRaw($controlOptions, 'class', ' %s', true);
        $xfInit = $this->processAttributeToRaw($controlOptions, 'data-xf-init', ' %s', true);
        $xfInitAttr = " data-xf-init=\"date-input$xfInit\"";
        $weekStart = $this->processAttributeToRaw($controlOptions, 'week-start', '', true);
        if (!$weekStart)
        {
            $weekStart = $this->language['week_start'];
        }
        $weekStartAttr = " data-week-start=\"$weekStart\"";
        $readOnly = $this->processAttributeToRaw($controlOptions, 'readonly');
        $readOnlyAttr = $readOnly ? ' readonly="readonly"' : '';

        $unhandledAttrs = $this->processUnhandledAttributes($controlOptions);

        return "<div class=\"inputGroup inputGroup--date inputGroup--joined inputDate\"><input type=\"text\" class=\"input input--date {$class}\"{$xfInitAttr}{$weekStartAttr}{$readOnlyAttr}{$unhandledAttrs} /><span class=\"inputGroup-text inputDate-icon\"></span></div>";
    }

    public function formDateInputRow(array $controlOptions, array $rowOptions)
    {
        $this->addToClassAttribute($rowOptions, 'formRow--input', 'rowclass');

        $controlId = $this->assignFormControlId($controlOptions);
        $controlHtml = $this->formDateInput($controlOptions);
        return $this->formRow($controlHtml, $rowOptions, $controlId);
    }

    public function formCodeEditor(array $controlOptions)
    {
        $this->processDynamicAttributes($controlOptions);

        $name = $this->processAttributeToRaw($controlOptions, 'name');
        $value = $this->processAttributeToRaw($controlOptions, 'value');
        $extraClasses = $this->processAttributeToRaw($controlOptions, 'class');

        /** @var \XF\Data\CodeLanguage $codeLanguageData */
        $codeLanguageData = $this->app->data('XF:CodeLanguage');
        $supportedLanguages = $codeLanguageData->getSupportedLanguages();

        $mode = $this->processAttributeToRaw($controlOptions, 'mode');
        if (isset($supportedLanguages[$mode]))
        {
            $modeConfig = $supportedLanguages[$mode];
        }
        else
        {
            $modeConfig = [];
        }

        $readOnly = $this->processAttributeToRaw($controlOptions, 'readonly');
        if ($readOnly)
        {
            $extraClasses .= ' is-readonly';
        }

        $attrsHtml = $this->processUnhandledAttributes($controlOptions);

        return $this->renderTemplate('public:code_editor', [
            'name' => $name,
            'value' => $value,
            'lang' => $mode,
            'modeConfig' => $modeConfig,
            'extraClasses' => $extraClasses,
            'readOnly' => $readOnly,
            'attrsHtml' => $attrsHtml
        ]);
    }

    public function formCodeEditorRow(array $controlOptions, array $rowOptions)
    {
        $this->addToClassAttribute($rowOptions, 'formRow--input', 'rowclass');

        $controlId = $this->assignFormControlId($controlOptions);
        $controlHtml = $this->formCodeEditor($controlOptions);
        return $this->formRow($controlHtml, $rowOptions, $controlId);
    }

    public function formEditor(array $controlOptions)
    {
        $this->processDynamicAttributes($controlOptions);

        $name = $this->processAttributeToRaw($controlOptions, 'name');
        $value = $this->processAttributeToRaw($controlOptions, 'value');
        $styleAttr = $this->processAttributeToRaw($controlOptions, 'style');

        if (!isset($controlOptions['previewable']))
        {
            $previewable = true;
        }
        else
        {
            $previewable = (bool)$this->processAttributeToRaw($controlOptions, 'previewable');
        }

        if (!isset($controlOptions['rows']))
        {
            $controlOptions['rows'] = '10';
        }

        $attachments = isset($controlOptions['attachments']) ?  $controlOptions['attachments'] :[];
        if (!$this->isTraversable($attachments))
        {
            $attachments = [];
        }

        unset($controlOptions['attachments']);

        $bbCodeContainer = $this->app->bbCode();
        $customIcons = [];
        foreach ($bbCodeContainer['custom'] AS $k => $custom)
        {
            if ($custom['editor_icon_type'])
            {
                $customIcons[$k] = [
                    'title' => \XF::phrase('custom_bb_code_title.' . $k),
                    'type' => $custom['editor_icon_type'],
                    'value' => $custom['editor_icon_value'],
                    'option' => $custom['has_option']
                ];
            }
        }

        if (substr($name, -1) == ']')
        {
            $htmlName = substr($name, 0, -1) . '_html]';
        }
        else
        {
            $htmlName = $name . '_html';
        }

        if ($value !== '')
        {
            $htmlValue = $this->app->bbCode()->render($value, 'editorHtml', 'editor', null, [
                'attachments' => $attachments
            ]);
        }
        else
        {
            $htmlValue = '';
        }

        if (!isset($controlOptions['data-min-height']))
        {
            $controlOptions['data-min-height'] = 250;
        }
        $height = intval($controlOptions['data-min-height']);

        $removeButtons = [];
        $hasSmilies = $this->app->smilies;
        if (isset($controlOptions['removebuttons']))
        {
            $removeButtons = $controlOptions['removebuttons'];
        }
        if (!$hasSmilies)
        {
            $removeButtons[] = '_smilies';
        }

        if (isset($controlOptions['maxlength']) && empty($controlOptions['maxlength']))
        {
            unset($controlOptions['maxlength']);
        }

        $attrsHtml = $this->processUnhandledAttributes($controlOptions);

        $config = $this->app->config();

        return $this->renderTemplate('public:editor', [
            'name' => $name,
            'htmlName' => $htmlName,
            'value' => $value,
            'attachments' => $attachments,
            'htmlValue' => $htmlValue,
            'styleAttr' => $styleAttr,
            'attrsHtml' => $attrsHtml,
            'customIcons' => $customIcons,
            'previewable' => $previewable,
            'height' => $height,
            'removeButtons' => array_unique($removeButtons),
            'fullEditorJs' => ($config['development']['fullJs'] && $config['development']['fullEditorJs'])
        ]);
    }

    public function formEditorRow(array $controlOptions, array $rowOptions)
    {
        $this->addToClassAttribute($rowOptions, 'formRow--input', 'rowclass');

        $controlId = $this->assignFormControlId($controlOptions);
        $controlHtml = $this->formEditor($controlOptions);
        return $this->formRow($controlHtml, $rowOptions, $controlId);
    }

    public function formPrefixInput($prefixes, array $controlOptions)
    {
        $this->processDynamicAttributes($controlOptions);

        $prefixType = $this->processAttributeToRaw($controlOptions, 'type');

        $prefixName = $this->processAttributeToRaw($controlOptions, 'prefix-name');
        $textboxName = $this->processAttributeToRaw($controlOptions, 'textbox-name');

        $prefixClass = $this->processAttributeToRaw($controlOptions, 'prefix-class', ' %s');
        $textboxClass = $this->processAttributeToRaw($controlOptions, 'textbox-class', ' %s');

        $prefixValue = $this->processAttributeToRaw($controlOptions, 'prefix-value');
        $textboxValue = $this->processAttributeToRaw($controlOptions, 'textbox-value');

        $href = $this->processAttributeToRaw($controlOptions, 'href');
        $listenTo = $this->processAttributeToRaw($controlOptions, 'listen-to');
        $rows = $this->processAttributeToRaw($controlOptions, 'rows');

        $attrsHtml = $this->processUnhandledAttributes($controlOptions);

        return $this->renderTemplate('public:prefix_input', [
            'prefixes' => $prefixes ?: [],
            'prefixType' => $prefixType,
            'prefixName' => $prefixName ?: 'prefix_id',
            'prefixClass' => $prefixClass,
            'textboxClass' => $textboxClass,
            'textboxName' => $textboxName ?: 'title',
            'prefixValue' => $prefixValue ?: 0,
            'textboxValue' => $textboxValue ?: $this->zeroValueValid($textboxValue),
            'href' => $href,
            'listenTo' => $listenTo,
            'rows' => $rows,
            'attrsHtml' => $attrsHtml
        ]);
    }

    protected function zeroValueValid($var)
    {
        if ($var === 0 || $var === '0')
        {
            return $var;
        }

        return '';
    }

    public function formPrefixInputRow($prefixes, array $controlOptions, array $rowOptions)
    {
        $this->addToClassAttribute($rowOptions, 'formRow--input', 'rowclass');

        $controlId = $this->assignFormControlId($controlOptions);
        $controlHtml = $this->formPrefixInput($prefixes, $controlOptions);
        return $this->formRow($controlHtml, $rowOptions, $controlId);
    }

    public function formTextBox(array $controlOptions)
    {
        $this->processDynamicAttributes($controlOptions);

        $units = ($controlOptions['type'] == 'number' && !empty($controlOptions['units'])
            ?  $controlOptions['units']
            : '');
        unset($controlOptions['units']);

        $this->processCodeAttribute($controlOptions);
        $typeAttr = $this->processAttributeToHtmlAttribute($controlOptions, 'type', 'text');

        $class = $this->processAttributeToRaw($controlOptions, 'class', '', true);
        $xfInit = $this->processAttributeToRaw($controlOptions, 'data-xf-init', '', true);
        $acSingle = '';
        $autoComplete = $this->processAttributeToRaw($controlOptions, 'ac');
        if ($autoComplete)
        {
            if ($autoComplete == 'single')
            {
                $acSingle = " data-single=\"true\"";
            }
            $xfInit = ltrim("$xfInit auto-complete");
        }
        $xfInitAttr = $xfInit ? " data-xf-init=\"$xfInit\"" : '';
        $readOnlyAttr = $this->processAttributeToRaw($controlOptions, 'readonly') ? ' readonly="readonly"' : '';

        $unhandledAttrs = $this->processUnhandledAttributes($controlOptions);

        $input = "<input{$typeAttr} class=\"" . trim("input {$class}") . "\"{$xfInitAttr}{$acSingle}{$readOnlyAttr}{$unhandledAttrs} />";

        if ($units)
        {
            return "<div class=\"inputGroup inputGroup--numbers\">$input<span class=\"inputGroup-text\">$units</span></div>";
        }
        else
        {
            return $input;
        }
    }

    public function formTextBoxRow(array $controlOptions, array $rowOptions)
    {
        $this->addToClassAttribute($rowOptions, 'formRow--input', 'rowclass');

        $controlId = $this->assignFormControlId($controlOptions);
        $controlHtml = $this->formTextBox($controlOptions);
        return $this->formRow($controlHtml, $rowOptions, $controlId);
    }

    public function formNumberBox(array $controlOptions)
    {
        $this->processDynamicAttributes($controlOptions);

        $min = isset($controlOptions['min']) ? $controlOptions['min'] : null;
        $max = isset($controlOptions['max']) ? $controlOptions['max'] : null;
        $step = isset($controlOptions['step']) ? $controlOptions['step'] : 1;

        $minAttr = '';
        $maxAttr = '';
        $stepAttr = '';
        if ($min !== null)
        {
            $minAttr = ' min="' . htmlspecialchars($min) . '"';
        }
        if ($max !== null)
        {
            $maxAttr = ' max="' . htmlspecialchars($max) . '"';
        }
        if ($step)
        {
            $stepAttr = ' step="' . htmlspecialchars($step) . '"';
        }

        $type = 'number';
        if ($typeAttr = $this->processAttributeToRaw($controlOptions, 'type', '', true))
        {
            $type = $typeAttr;
        }

        // This is mostly targeting iOS which presents a symbol + number keyboard by default for the number input.
        // If step contains a decimal point or could support negative values then don't force a pattern, otherwise
        // assume it's \d* which will force the numeric only keypad on iOS.
        if ($step == 'any' || strpos($step, '.') !== false || ($min === null || $min < 0))
        {
            $pattern = '';
        }
        else
        {
            $pattern = '\d*';
        }

        if (isset($controlOptions['value']))
        {
            $controlOptions['value'] = trim($controlOptions['value']);
            if (preg_match('/[^0-9.-]/', $controlOptions['value']))
            {
                if (preg_match('/^{{(?:\s+)?(?:.*)(?:\s+)?}}$/', $controlOptions['value']))
                {
                    // not a valid number but looks like a mustache/field adder template
                    $value = $controlOptions['value'];
                }
                else
                {
                    // value isn't a valid number
                    $value = '';
                }
            }
            else
            {
                $value = $controlOptions['value'];
            }
        }
        else if (isset($controlOptions['min']))
        {
            $value = $controlOptions['min'];
        }
        else
        {
            $value = '';
        }

        $hasRequired = isset($controlOptions['required']);
        $required = $this->processAttributeToRaw($controlOptions, 'required');
        if (isset($controlOptions['min']) && !$hasRequired)
        {
            $required = true;
        }
        $requiredAttr = $required ? ' required="required"' : '';

        $units = !empty($controlOptions['units']) ?  $controlOptions['units'] : '';

        unset(
            $controlOptions['min'],
            $controlOptions['max'],
            $controlOptions['step'],
            $controlOptions['value'],
            $controlOptions['units']
        );

        $class = $this->processAttributeToRaw($controlOptions, 'class', ' %s', true);
        $xfInit = $this->processAttributeToRaw($controlOptions, 'data-xf-init', '', true);
        $xfInitAttr = $xfInit ? " data-xf-init=\"$xfInit\"" : '';
        $readOnlyAttr = $this->processAttributeToRaw($controlOptions, 'readonly') ? ' readonly="readonly"' : '';

        $unhandledAttrs = $this->processUnhandledAttributes($controlOptions);

        $input = "<div class=\"inputGroup inputGroup--numbers inputNumber\" data-xf-init=\"number-box\">"
            . "<input type=\"{$type}\" pattern=\"{$pattern}\" class=\"input input--number js-numberBoxTextInput{$class}\" value=\"{$value}\" {$minAttr}{$maxAttr}{$stepAttr}{$requiredAttr}{$readOnlyAttr}{$xfInitAttr}{$unhandledAttrs} />"
            . "</div>";

        if ($units)
        {
            return "<div class=\"inputGroup\">$input<div class=\"inputGroup\"><span class='inputGroup--splitter'></span><span class=\"inputGroup-text\">$units</span></div></div>";
        }
        else
        {
            return $input;
        }
    }

    public function formNumberBoxRow(array $controlOptions, array $rowOptions)
    {
        $this->addToClassAttribute($rowOptions, 'formRow--input', 'rowclass');

        $controlId = $this->assignFormControlId($controlOptions);
        $controlHtml = $this->formNumberBox($controlOptions);
        return $this->formRow($controlHtml, $rowOptions, $controlId);
    }

    public function formTokenInput(array $controlOptions)
    {
        $this->processDynamicAttributes($controlOptions);

        $name = $this->processAttributeToRaw($controlOptions, 'name');
        $value = $this->processAttributeToRaw($controlOptions, 'value');
        $hrefAttr = $this->processAttributeToRaw($controlOptions, 'href');
        $styleAttr = $this->processAttributeToRaw($controlOptions, 'style');

        $minLength = $this->processAttributeToRaw($controlOptions, 'min-length');
        $maxLength = $this->processAttributeToRaw($controlOptions, 'max-length');
        $maxTokens = $this->processAttributeToRaw($controlOptions, 'max-tokens');

        $attrsHtml = $this->processUnhandledAttributes($controlOptions);

        return $this->renderTemplate('public:token_input', [
            'name' => $name,
            'value' => $value,
            'hrefAttr' => $hrefAttr,
            'styleAttr' => $styleAttr,
            'minLength' => $minLength,
            'maxLength' => $maxLength,
            'maxTokens' => $maxTokens,
            'attrsHtml' => $attrsHtml
        ]);
    }

    public function formTokenInputRow(array $controlOptions, array $rowOptions)
    {
        $this->addToClassAttribute($rowOptions, 'formRow--input', 'rowclass');

        $controlId = $this->assignFormControlId($controlOptions);
        $controlHtml = $this->formTokenInput($controlOptions);
        return $this->formRow($controlHtml, $rowOptions, $controlId);
    }

    public function formUpload(array $controlOptions)
    {
        $this->processDynamicAttributes($controlOptions);

        $class = $this->processAttributeToRaw($controlOptions, 'class', '', true);

        $unhandledAttrs = $this->processUnhandledAttributes($controlOptions);

        return "<input type=\"file\" class=\"input {$class}\"{$unhandledAttrs} />";
    }

    public function formUploadRow(array $controlOptions, array $rowOptions)
    {
        $this->addToClassAttribute($rowOptions, 'formRow--input', 'rowclass');

        $controlId = $this->assignFormControlId($controlOptions);
        $controlHtml = $this->formUpload($controlOptions);
        return $this->formRow($controlHtml, $rowOptions, $controlId);
    }

    protected function assignFormControlId(array &$controlOptions)
    {
        if (!empty($controlOptions['id']))
        {
            return $controlOptions['id'];
        }

        $controlOptions['id'] = $this->fn('unique_id');
        return $controlOptions['id'];
    }

    public function formRow($contentHtml, array $rowOptions, $controlId = null)
    {
        $class = $this->processAttributeToRaw($rowOptions, 'rowclass', ' %s', true);
        $rowType = $this->processAttributeToRaw($rowOptions, 'rowtype');
        if ($rowType)
        {
            $class = $this->appendClassList($class, $rowType, 'formRow--%s');
        }

        $id = $this->processAttributeToRaw($rowOptions, 'rowid');
        $idAttr = $id ? ' id="' . htmlspecialchars($id) . '"' : '';

        if (isset($rowOptions['controlid']))
        {
            $controlId = $rowOptions['controlid'];
            unset($rowOptions['controlid']);
        }

        $labelFor = $controlId ? ' for="' . htmlspecialchars($controlId) . '"' : '';

        $label = $this->processAttributeToRaw($rowOptions, 'label', "\n\t\t\t\t\t<label class=\"formRow-label\"{$labelFor}>%s</label>");
        $hint = $this->processAttributeToRaw($rowOptions, 'hint', "\n\t\t\t\t\t<dfn class=\"formRow-hint\">%s</dfn>");

        $initialHtml = $this->processAttributeToRaw($rowOptions, 'initialhtml', "\n\t\t\t\t\t%s");
        $html = $this->processAttributeToRaw($rowOptions, 'html', "\n\t\t\t\t\t%s");
        $explain = $this->processAttributeToRaw($rowOptions, 'explain', "\n\t\t\t\t\t<div class=\"formRow-explain\">%s</div>");
        $finalHtml = $this->processAttributeToRaw($rowOptions, 'finalhtml', "\n\t\t\t\t\t%s");

        $unhandledAttrs = $this->processUnhandledAttributes($rowOptions);

        return '
            <dl class="formRow' . $class . '"' . $idAttr . $unhandledAttrs . '>
                <dt>
                    <div class="formRow-labelWrapper">' . $label . $hint . '</div>
                </dt>
                <dd>
                    ' . $initialHtml // stuff to go before the control (rarely)
                      . $contentHtml // controls etc.
                      . $html // extra HTML, dependent controls etc.
                      . $explain // final <p.explain> that describes all the above
                      . $finalHtml // used for <input hidden> etc.
                      . '
                </dd>
            </dl>
        ';
    }

    public function formRowIfContent($contentHtml, array $rowOptions, $controlId = null)
    {
        $contentHtml = trim($contentHtml);
        if (!strlen($contentHtml))
        {
            return '';
        }
        else
        {
            return $this->formRow($contentHtml, $rowOptions, $controlId);
        }
    }

    public function formInfoRow($contentHtml, array $rowOptions)
    {
        $class = $this->processAttributeToRaw($rowOptions, 'rowclass', ' %s', true);
        $rowType = $this->processAttributeToRaw($rowOptions, 'rowtype');
        if ($rowType)
        {
            $class = $this->appendClassList($class, $rowType, 'formInfoRow--%s');
        }

        $unhandledRowAttrs = $this->processUnhandledAttributes($rowOptions);

        return "
            <div class=\"formInfoRow{$class}\"{$unhandledRowAttrs}>
                {$contentHtml}
            </div>
        ";
    }

    public function form($contentHtml, array $options)
    {
        $this->processDynamicAttributes($options);

        $method = $this->processAttributeToRaw($options, 'method', '', true);
        if (!$method)
        {
            $method = 'post';
        }

        $getFormParams = '';
        $action = $this->processAttributeToRaw($options, 'action', '', true);
        if ($action && strtolower($method) == 'get')
        {
            $qStart = strpos($action, '?');
            if ($qStart !== false)
            {
                $qString = htmlspecialchars_decode(substr($action, $qStart + 1));
                $action = substr($action, 0, $qStart);

                if (preg_match('/^([^=&]*)(&|$)/', $qString, $qStringUrl))
                {
                    $route = $qStringUrl[1];
                    $qString = substr($qString, strlen($qStringUrl[0]));
                }
                else
                {
                    $route = '';
                }


                if ($route !== '')
                {
                    $getFormParams .= $this->formHiddenVal('_xfRoute', $route);
                }

                if ($qString)
                {
                    $params = \XF\Util\Arr::parseQueryString($qString);
                    foreach ($params AS $name => $value)
                    {
                        $getFormParams .= "\n\t" . $this->formHiddenVal($name, $value);
                    }
                }
            }
        }

        $ajax = $this->processAttributeToRaw($options, 'ajax');
        $class = $this->processAttributeToRaw($options, 'class', '', true);
        $upload = $this->processAttributeToRaw($options, 'upload', '', true);
        $encType = $this->processAttributeToRaw($options, 'enctype', '', true);
        $preview = $this->processAttributeToRaw($options, 'preview', '', true);
        $xfInit = $this->processAttributeToRaw($options, 'data-xf-init', '', true);
        if ($ajax)
        {
            $xfInit = ltrim("$xfInit ajax-submit");
        }

        $encTypeAttr = '';
        if ($encType)
        {
            $encTypeAttr = " enctype=\"$encType\"";
        }
        else if ($upload)
        {
            $encTypeAttr = " enctype=\"multipart/form-data\"";
        }

        $previewUrlAttr = '';
        if ($preview)
        {
            $xfInit = ltrim("$xfInit preview");
            $previewUrlAttr = " data-preview-url=\"$preview\"";
        }

        $draftAttrs = $this->handleDraftAttribute($options, $class, $xfInit);

        $xfInitAttr = $xfInit ? " data-xf-init=\"$xfInit\"" : '';
        $unhandledAttrs = $this->processUnhandledAttributes($options);

        if (strtolower($method) == 'post')
        {
            $csrfInput = $this->fn('csrf_input');
        }
        else
        {
            $csrfInput = '';
        }

        return "
            <form action=\"{$action}\" method=\"{$method}\" class=\"{$class}\"
                {$xfInitAttr}{$encTypeAttr}{$previewUrlAttr}{$draftAttrs}{$unhandledAttrs}
            >
                {$contentHtml}
                {$csrfInput}
                {$getFormParams}
            </form>
        ";
    }

    protected function handleDraftAttribute(array &$options, &$class, &$xfInit)
    {
        $draftOptions = $this->app->options()->saveDrafts;
        if (!empty($draftOptions['enabled']))
        {
            $draft = $this->processAttributeToRaw($options, 'draft', '', true);
            if ($draft)
            {
                $xfInit = ltrim("$xfInit draft");

                return " data-draft-url=\"$draft\" data-draft-autosave=\"$draftOptions[saveFrequency]\"";
            }
        }

        unset($options['draft']);
        return '';
    }

    public function dataList($contentHtml, array $options)
    {
        $this->processDynamicAttributes($options);

        $class = $this->processAttributeToRaw($options, 'class', '', true);
        $unhandledAttrs = $this->processUnhandledAttributes($options);

        return "
            <div class=\"dataList {$class}\"{$unhandledAttrs}>
            <table class=\"dataList-table\">
                {$contentHtml}
            </table>
            </div>
        ";
    }

    public function dataRow(array $options, array $cells = [])
    {
        if (!empty($options['rowtype']))
        {
            $rowType = $options['rowtype'];
        }
        else
        {
            $rowType = 'row';
        }

        if ($rowType == 'header')
        {
            if (!isset($options['rowclass']))
            {
                $options['rowclass'] = '';
            }

            $options['rowclass'] = trim($options['rowclass'] . ' dataList-row--header dataList-row--noHover');
        }
        else if ($rowType == 'subsection' || $rowType == 'subSection')
        {
            $rowType = 'subSection';

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

            $options['rowclass'] = trim($options['rowclass'] . ' dataList-row--subSection');
        }

        $label = (isset($options['label']) && strlen($options['label'])) ? $options['label'] : null;
        if ($label !== null)
        {
            $cell = [
                '_type' => 'main',
                'href' => !empty($options['href']) ? $options['href'] : null,
                'target' => !empty($options['target']) ? $options['target'] : null,
                'label' => $label,
                'hint' => (isset($options['hint']) && strlen(trim($options['hint']))) ? $options['hint'] : null,
                'explain' => (isset($options['explain']) && strlen(trim($options['explain']))) ? $options['explain'] : null,
                'hash' => (isset($options['hash']) && strlen(trim($options['hash']))) ? $options['hash'] : null,
                'colspan' => !empty($options['colspan']) ? $options['colspan'] : null,
                'html' => ''
            ];
            if (!empty($options['dir']))
            {
                $cell['dir'] = $options['dir'];
            }
            if (!empty($options['href']) && !empty($options['overlay']))
            {
                $cell['overlay'] = $options['overlay'];

                foreach ($this->overlayClickOptions AS $attributeName)
                {
                    if (isset($options[$attributeName]))
                    {
                        $cell[$attributeName] = $options[$attributeName];
                    }
                }
            }
            array_unshift($cells, $cell);
        }

        $delete = (isset($options['delete']) && $options['delete']) ? $options['delete'] : null;
        if ($delete)
        {
            $cells[] = [
                '_type' => 'delete',
                'href' => $delete,
                'html' => ''
            ];
        }

        $rowClass = $this->processAttributeToRaw($options, 'rowclass', ' %s', true);

        $cellsHtml = [];
        foreach ($cells AS $cell)
        {
            $cellHtml = $this->getDataRowCell($rowType, $cell, $rowClass);
            if ($cellHtml)
            {
                $cellsHtml[] = $cellHtml;
            }
        }

        $html = implode("\n", $cellsHtml);

        $unhandledAttrs = $this->processUnhandledAttributes($options);

        return "
            <tr class=\"dataList-row{$rowClass}\"{$unhandledAttrs}>
                {$html}
            </tr>
        ";
    }

    /**
     * @param string $rowType Type of row; currently header or row
     * @param array  $cell Array of attributes for the cell itself
     * @param string $rowClass Allows cells to affect the appearance of the parent row
     *
     * @return string
     */
    protected function getDataRowCell($rowType, array $cell, &$rowClass = '')
    {
        $type = isset($cell['_type']) ? $cell['_type'] : 'cell';
        unset($cell['_type']);

        $html = isset($cell['html']) ? $cell['html'] : '';
        unset($cell['html']);

        $selected = !empty($cell['selected']);
        unset($cell['selected']);

        $class = $this->processAttributeToRaw($cell, 'class', ' %s', true);

        if ($type == 'delete')
        {
            $html = ''; // ignored
        }
        else if ($type == 'toggle')
        {
            $name = $this->processAttributeToRaw($cell, 'name', '', true);
            $inputType = $this->processAttributeToRaw($cell, 'type', '', true);
            $class .= ' dataList-cell--iconic';

            if (!$inputType)
            {
                $inputType = 'checkbox';
            }

            $hiddenHtml = '';

            if (isset($cell['value']))
            {
                $value = $this->processAttributeToRaw($cell, 'value', '', true);
            }
            else
            {
                $value = '1';
                if ($inputType == 'checkbox')
                {
                    $hiddenHtml = "<input type=\"hidden\" name=\"{$name}\" value=\"0\" />";
                }
            }

            $checkedHtml = $selected ? ' checked="checked"' : '';

            $disabled = !empty($cell['disabled']);
            unset($cell['disabled']);
            $disabledHtml = $disabled ? ' disabled="disabled"' : '';

            $tooltip = $this->processAttributeToRaw($cell, 'tooltip', '', true);
            if ($tooltip)
            {
                $tooltipHtml = " data-xf-init=\"tooltip\" title=\"{$tooltip}\"";
            }
            else
            {
                $tooltipHtml = '';
            }

            $submit = $this->processAttributeToRaw($cell, 'submit', '', true);
            if ($submit)
            {
                $labelClass = 'iconic iconic--toggle';
                $submitHtml = ' data-xf-click="submit"';
                if ($submit != 'true')
                {
                    $submitHtml .= ' data-target="' . $submit . '"';
                }

                if ($inputType == 'checkbox' && !$selected)
                {
                    $rowClass = $rowClass . ' dataList-row--disabled';
                }
            }
            else
            {
                $labelClass = 'iconic';
                $submitHtml = '';
            }

            $html = $hiddenHtml
                . "<label class=\"{$labelClass}\"{$tooltipHtml}{$submitHtml}>"
                . "<input type=\"{$inputType}\" name=\"{$name}\" value=\"{$value}\"{$checkedHtml}{$disabledHtml} /><i aria-hidden=\"true\"></i>"
                . "</label>";
        }
        else if ($type == 'popup')
        {
            $label = (isset($cell['label']) && strlen(trim($cell['label']))) ? $cell['label'] : \XF::phrase('actions');

            $outerHtml = '<a data-xf-click="menu" class="menuTrigger" role="button" tabindex="0" aria-expanded="false" aria-haspopup="true">' . $label . '</a>'
                . $html;

            $html = $outerHtml;
        }
        else if ($type == 'main')
        {
            $label = (isset($cell['label']) && strlen(trim($cell['label']))) ? $cell['label'] : null;
            if ($label !== null)
            {
                $hint = (isset($cell['hint']) && strlen(trim($cell['hint']))) ? $cell['hint'] : null;
                $explain = (isset($cell['explain']) && strlen(trim($cell['explain']))) ? $cell['explain'] : null;

                if (!empty($cell['dir']))
                {
                    $label = '<span dir="' . htmlspecialchars($cell['dir']) . '">' . $label . '</span>';
                    $explainDirAttr = ' dir="' . htmlspecialchars($cell['dir']) . '"';
                }
                else
                {
                    $explainDirAttr = '';
                }

                $html = '<div class="dataList-mainRow">'
                    . $label
                    . ($hint !== null ? " <span class=\"dataList-hint\" dir=\"auto\">{$hint}</span>" : '') . '</div>'
                    . ($explain !== null ? "\n<div class=\"dataList-subRow\"{$explainDirAttr}>{$explain}</div>" : '');
            }

            unset($cell['dir']);
        }

        if (isset($cell['hash']) && strlen(trim($cell['hash'])))
        {
            $html = '<span class="u-anchorTarget" id="'
                . htmlspecialchars($this->app->getRedirectHash($cell['hash']))
                . '"></span>'
                . $html;
        }
        unset($cell['hash']);

        if (!strlen($html))
        {
            $html = '&nbsp;';
        }

        $isAction = ($type == 'action' || $type == 'delete');
        $href = isset($cell['href']) ? htmlspecialchars($cell['href']) : '';

        if ($href)
        {
            if (!$isAction)
            {
                $class .= ' dataList-cell--link';
            }

            $target = $this->processAttributeToRaw($cell, 'target', '', true);
            if ($target)
            {
                $target = " target=\"{$target}\"";
            }

            if ($type == 'delete')
            {
                $class .= ' dataList-cell--iconic';

                $tooltip = $this->processAttributeToRaw($cell, 'tooltip', '', true);
                if (!$tooltip)
                {
                    $tooltip = \XF::phrase('delete');
                }
                $html = "<a href=\"{$href}\" class=\"dataList-delete\" data-xf-init=\"tooltip\" title=\"{$tooltip}\" data-xf-click=\"overlay\"{$target}></a>";
            }
            else
            {
                $overlay = $this->processAttributeToRaw($cell, 'overlay', '', true);
                if ($overlay)
                {
                    $overlay = " data-xf-click=\"overlay\"";

                    foreach ($this->overlayClickOptions AS $attributeName)
                    {
                        if (isset($cell[$attributeName]))
                        {
                            $attributeValue = $this->processAttributeToRaw($cell, $attributeName, '', true);
                            $overlay .= " $attributeName=\"{$attributeValue}\"";
                        }
                    }

                    if (isset($cell['overlaycache']))
                    {
                        $overlayCache = $this->processAttributeToRaw($cell, 'overlaycache', '', true);
                        $overlay .= " data-cache=\"{$overlayCache}\"";
                    }
                }
                $html = "<a href=\"{$href}\" {$overlay}{$target}>{$html}</a>";
            }
        }

        if ($isAction)
        {
            $class .= ' dataList-cell--action';
        }
        if ($type == 'toggle')
        {
            $class .= ' dataList-cell--link dataList-cell--min dataList-cell--toggle';
        }
        else if ($type == 'popup')
        {
            $class .= ' dataList-cell--alt dataList-cell--link dataList-cell--min';
        }
        else if ($type == 'main')
        {
            $class .= ' dataList-cell--main';
        }

        unset($cell['href'], $cell['label'], $cell['explain'], $cell['hint']);

        $unhandledAttrs = $this->processUnhandledAttributes($cell);

        $tag = ($rowType == 'header' ? 'th' : 'td');

        return "<{$tag} class=\"dataList-cell{$class}\"{$unhandledAttrs}>{$html}</{$tag}>";
    }

    protected function addToClassAttribute(array &$options, $class, $key = 'class')
    {
        if (!isset($options[$key]))
        {
            $options[$key] = '';
        }

        if (strlen($options[$key]))
        {
            $options[$key] .= " $class";
        }
        else
        {
            $options[$key] = $class;
        }
    }

    protected function appendClassList($existingClasses, $classList, $formatter = '')
    {
        if (!$classList)
        {
            return $existingClasses;
        }

        $classList = preg_replace('#[^a-z0-9_ -]#i', '', $classList);

        foreach (preg_split('#\s+#', $classList, -1, PREG_SPLIT_NO_EMPTY) AS $class)
        {
            if ($formatter)
            {
                $class = sprintf($formatter, $class);
            }

            $existingClasses .= ' ' . $class;
        }

        return $existingClasses;
    }

    protected function addElementHandler(array &$attributes, $handler, $classAttr = 'class')
    {
        if (!isset($attributes['data-xf-init']))
        {
            $attributes['data-xf-init'] = '';
        }
        if (!preg_match('/(^|\s)' . $handler . '($|\s)/', $attributes['data-xf-init']))
        {
            if (strlen($attributes['data-xf-init']))
            {
                $attributes['data-xf-init'] .= ' ' . $handler;
            }
            else
            {
                $attributes['data-xf-init'] = $handler;
            }
        }
    }

    protected function getButtonPhraseFromIcon($icon, $fallback = '')
    {
        switch ($icon)
        {
            case 'attach':
            case 'cancel':
            case 'confirm':
            case 'copy':
            case 'delete':
            case 'edit':
            case 'export':
            case 'import':
            case 'login':
            case 'merge':
            case 'move':
            case 'preview':
            case 'purchase':
            case 'save':
            case 'search':
            case 'sort':
            case 'submit':
            case 'translate':
                $phrase = 'button.' . $icon;
                break;

            default:
                $phrase = $fallback;
        }

        return $phrase ? \XF::phrase($phrase) : '';
    }

    public function renderNavigationClosure(\Closure $navHandler, $selectedNav = '', array $params = [], $addDefaultParams = true)
    {
        if ($addDefaultParams)
        {
            $params = array_merge($this->defaultParams, $params);
        }

        set_error_handler([$this, 'handleTemplateError']);

        try
        {
            $output = $navHandler($this, $selectedNav, $params);
        }
        catch (\Exception $e)
        {
            if (\XF::$debugMode)
            {
                throw $e;
            }

            $this->app->logException($e, false, 'Error rendering navigation: ');
            $output = null;
        }

        restore_error_handler();

        return $output;
    }
}

Mọi thắc mắc vui lòng phản hồi bên dưới
Chúc các bạn thành công!
 

Facebook Comments

Similar threads
Thread starter Title Forum Replies Date
Admin Hướng dẫn fix lỗi không vào được mạng laptop Masstel E140 Sử dụng, chia sẻ, hỏi đáp 0
Admin Hướng dẫn fix lỗi Communication Error cho máy in Canon LBP 2900, Canon LBP 3300 Windows 11 mới nhất - Fix Communication Error Canon 2900, Canon 3300 Windows 11 Sử dụng, chia sẻ, hỏi đáp 0
Admin Hướng dẫn fix lỗi GET GENUINE OFFICE Your license isn't genuine cho office thành công 100% Sử dụng, chia sẻ, hỏi đáp 0
Admin Hướng dẫn gõ tiếng việt có dấu trên vba - Fix lỗi font tiếng việt excel vba Sử dụng, chia sẻ, hỏi đáp 0
Admin Hướng dẫn fix lỗi không xóa được bôi đen trong Word bằng phím Backspace - Backspace doesn't delete highlighted text word Sử dụng, chia sẻ, hỏi đáp 4
Admin Hướng dẫn fix lỗi No Internet trên Windows 10 20H2 Sử dụng, chia sẻ, hỏi đáp 0
Admin Hướng dẫn fix lỗi tắt kiếm tiền (TKT) lỗi quốc gia Ad Breaks Tut, tool, mmo 0
Admin Hướng dẫn fix lỗi đăng nhập từ iPhone đã ghép nối trên Watch OS 6.2.6 Điện thoại di động 0
Admin Hướng dẫn fix lỗi ABI mismatch xamarin forms Android, iOS 0
Admin Hướng dẫn fix lỗi The selected build configuration is using the shared Mono runtime for faster deployment Xamarin Forms Android, iOS 0
Admin Hướng dẫn fix lỗi WHPX is not configured khi dùng Visual Studio 2019 Android, ios, java, windows phone 0
Admin Hướng dẫn fix lỗi We couldn’t complete the updates, Undoing changes không vào được windows Hệ điều hành 0
Admin Hướng dẫn fix lỗi không hiện IP online xenforo - Fix don't display IP members online xenforo Xenforo 0
Admin Hướng dẫn fix lỗi xinput1_3.dll or d3dx9_43.dll is missing khi chơi PES 2017 - How to fix xinput1_3.dll or d3dx9_43.dll is missing simple Thủ thuật máy tính 0
Admin Hướng dẫn fix lỗi Not enough memory RAM - An integer between 96 and 8 is required Thiết kế đồ họa 0
Admin Hướng dẫn fix lỗi /includes/vfchh/php/vfc_hide_core.php on line 163 mod vFCoders - Hide Hack v4 Vbb tutorial 0
Hong98 Hướng dẫn Cách fix lỗi thời gian chạy sai dạng Timeago All Shared Scripts 1
Admin Hướng dẫn fix lỗi android rung liên tục không ngừng ngày 31.12.2016 Android, iOS 0
N Có ai biết fix lỗi invalid request trong vuivc.xyz không hỏi cái Trò chuyện linh tinh 0
K Share AvatarQ 250 v8 Hỗ Trợ Event Fix Lỗi Crack, hack, mod, ghép game, ứng dụng 0
Admin Hướng dẫn fix lỗi nginx tải về tập tin php trên Ubuntu 14.04 VPS & Dedicated Server 0
yoona Help Cần hướng dẫn fix lỗi rule .htaccess Chuyên đề htaccess 5
Admin Hướng dẫn fix lỗi thư mục Libraries tự bật khi khởi động máy tính Sử dụng, chia sẻ, hỏi đáp 0
Admin Hướng dẫn fix lỗi font ỡ, õ, ữ cho android 4.0.4 trở lên đơn giản và chi tiết Android, iOS 0
Admin Hướng dẫn fix lỗi trắng trang admincp xenforo Xenforo 0
T Share mod bình luận bằng facebook cho johncms (fix lỗi trùng trang) Johncms 0
Admin Hướng dẫn fix lỗi table passwordhistory doesn't exist cho vbulletin Vbb tutorial 0
K Share Code grab wap xalo fix lỗi All Shared Scripts 0
C Help Mọi người vào fix lỗi mod like/dislike john cms giúp mình Johncms 2
C Hỏi Ai biết fix lỗi bug topic bang hội thì vào giúp mình. Johncms 0
H Hương dẫn em fix lỗi upload Johncms 14
D Hỏi Cần cao thủ vbb fix giúp lỗi forum 4.2 Vbulletin 5
H Help fix lỗi nghiêm trọng không biết lý do Wordpress 2
Lee_Jin Help Mong mọi người vào giúp fix lỗi này All Shared Scripts 6
G Share [AVATAR ONLINE]Mã Auto Kim Cương Xanh Tối Ưu, Fix Lỗi Trong Avatar Trò chơi 0
Admin Share code phim phimsd.com fix lỗi Mã nguồn web 0
Admin Hướng dẫn fix lỗi Deprecated: Function ereg() is deprecated PHP 0
Admin Hướng dẫn fix lỗi vfcoders hide hack trên vbb 4.2.2 Vbb tutorial 0
Admin Hướng dẫn fix toàn bộ lỗi vbb 4.2.2 Vbb tutorial 5
Admin Hướng dẫn fix lỗi lặp lại bài viết cho vbb 4.2.2 Vbb tutorial 1
Admin Hướng dẫn fix lỗi timezone khi cài đặt vbb 4.2.2 Vbb tutorial 1
Admin Hướng dẫn fix lỗi thay font sập nguồn điện thoại bằng video Sử dụng, chia sẻ, hỏi đáp 0
T Help [XenForo] Ai giúp với. Lỗi nặng rồi. Không tài nào fix đc....... Xenforo 8
Admin Hướng dẫn fix lỗi Thread Prefix Listing 1.2.0 Xenforo 0
Admin Share mod quản lý menu johncms fix lỗi Johncms 0
Admin Hướng dẫn fix lỗi smile johncms 5.2.0 Johncms 0
Admin Share template blogger cuoicangay.com bản chuẩn fix lỗi Blogger 1
Admin Lỗi SQLI nghiêm trọng của WHMCS 5.2.7 (cách bug và cách fix) Khác 0
Admin Share code blog xtgem mod bản mới fix lỗi phân trang Wap builder, wapego, xtgem, wen.ru, wapka, wap4 0
Admin Make gif v1.1 việt hoá - Tạo ảnh động trên điện thoại fix lỗi ứng dụng sai Crack, hack, mod, ghép game, ứng dụng 0

Similar threads

New posts New threads New resources

Back
Top