Đảo ngược php bin2hex

{$html} "; } /** * @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 = ""; } } $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 . ""; } else if ($type == 'popup') { $label = (isset($cell['label']) && strlen(trim($cell['label']))) ? $cell['label'] : \XF::phrase('actions'); $outerHtml = '' . $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 = '' . $label . ''; $explainDirAttr = ' dir="' . htmlspecialchars($cell['dir']) . '"'; } else { $explainDirAttr = ''; } $html = '
' . $label . ($hint !== null ? " {$hint}" : '') . '
' . ($explain !== null ? "\n
{$explain}
" : ''); } unset($cell['dir']); } if (isset($cell['hash']) && strlen(trim($cell['hash']))) { $html = '' . $html; } unset($cell['hash']); if (!strlen($html)) { $html = ' '; } $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 = ""; } 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 = "{$html}"; } } 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}"; } 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!

 

Xenforo mới thông báo rằng bản Xenforo 2. 0. 8 bị lỗi XSS

Để sửa lỗi XSS bạn làm như sau
Mở src/XF/Str/Formatter. php and instead of all by

PHP.

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 '';
        }
        else
        {
            // embed a data URI to avoid a request that doesn't respect paths fully
            $url = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
            return '';
        }
    }

    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 '"
                    . htmlspecialchars($link['linkText'], ENT_QUOTES, 'utf-8') . ''
                    . 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+):(\'|"|"|)(.*)\\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('%s',
                    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',
                '\1',
                \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, '';
        }
        else
        {
            $innerContent = $this->getDynamicAvatarHtml($username, $innerClassHtml, $attributes);
        }

        $updateLink = '';
        $updateLinkClass = '';
        if ($canUpdate)
        {
            $updateLinkClass = ' avatar--updateLink';
            $updateLink = '';
        }

        $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
        ";
    }

    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 '' . $styling['innerContent'] . '';
    }

    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 '';
    }


    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 = ''
                . '';
        }
        else
        {
            $output = '';
        }

        $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';
        }

        $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 '';
    }

    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 '';
    }

    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 ''
            . \XF::phrase($phrase, $params) . '';
    }

    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('' . \XF::escapeString($users[0]['username']) . '', 'html');
            if (isset($users[1]))
            {
                $user2 = $this->preEscaped('' . \XF::escapeString($users[1]['username']) . '', 'html');
                if (isset($users[2]))
                {
                    $user3 = $this->preEscaped('' . \XF::escapeString($users[2]['username']) . '', '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[] = '' . htmlspecialchars($site['site_title']) . '';
            }
            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('');
            }
        }

        $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('');
            }
        }

        $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}";
        }

        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 = ''
                    . \XF::escapeString($output, 'html') . '';
                if ($append === null)
                {
                    $append = ' ';
                }
                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 '';
    }

    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 = '';

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

        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 .= " {$title}";
                }

                $output .= '  ';
                $hasActivity = true;
            }
        }

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

        if ($hasActivity && $user->Activity->view_state == 'error' && \XF::visitor()->canBypassUserPrivacy())
        {
            $output .= '  ';
            $output .= '';
            $output .= ' ' . \XF::phrase('viewing_an_error') . '';
        }

        $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}>"
                . "{$p}";
        }

        $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}>"
                . "{$banner['text']}";
        }

        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 = '' . $location. '';
            }
            $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('  ', $blurbParts)
            . "";
    }

    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}";
    }

    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 = "{$username}";
        }
        if ($hrefAttr)
        {
            $tag = 'a';
        }
        else
        {
            $tag = 'span';
        }
        return "<{$tag}{$hrefAttr} class=\"username {$class}\" dir=\"auto\" data-user-id=\"{$userId}\"{$xfInitAttr}{$unhandledAttrs}>{$username}";
    }

    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}";
    }

    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('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 "";
    }

    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
  • $child
  • "; } $dependentHtml = "\n\t\t\t
      {$dependentHtmlInner}\n\t\t\t
    \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 = '' . $label . ''; } $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%s"); $extraHtml = $this->processAttributeToRaw($choice, 'html', "\n\t\t\t\t\t%s"); $afterHint = $this->processAttributeToRaw($choice, 'afterhint', "\n\t\t\t%s"); $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 = ''; unset($choice['defaultvalue']); } else { $defaultValueInput = ''; } $attributes = $this->processUnhandledAttributes($choice); $checkboxHtml = $defaultValueInput . "{$hint}{$extraHtml}{$dependentHtml}{$afterHint}{$afterHtml}"; if ($standalone) { return $checkboxHtml . "\n"; } else { return "
  • {$checkboxHtml}
  • \n"; } }; $groupFormatter = function(array $group, $html) { $label = $this->processAttributeToRaw($group, 'label'); if ($label) { $checkAll = $this->processAttributeToRaw($group, 'check-all'); if ($checkAll) { $label = ''; } $class = $this->processAttributeToRaw($group, 'class', '', true); $listClass = $this->processAttributeToRaw($group, 'listclass', '', true); $html = "
  • {$label}
      {$html}
  • "; } 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 " $choiceHtml "; } 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
  • $child
  • "; } $dependentHtml = "\n\t\t\t
      {$dependentHtmlInner}\n\t\t\t
    \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%s"); $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 = "{$hint}{$dependentHtml}{$extraHtml}"; if ($standalone) { return $radioHtml . "\n"; } else { return "{$radioHtml}\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 = "
  • {$label}
      {$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 " $choiceHtml "; } 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 = ' '; } $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 "{$label}\n"; }; $groupFormatter = function(array $group, $html) { if (!$html) { return ''; } $attributes = $this->processUnhandledAttributes($group); return "\n$html"; }; $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 = " "; if ($readOnly && $value !== null) { if ($multiple) { if (is_array($value)) { foreach ($value AS $subValue) { $select .= ''; } } } else { $select .= ''; } } 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 = ""; } 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 "
    {$controlHtml}{$extraHtml}
    "; } 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 "{$value}"; } 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 "
    "; } 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 = ""; if ($units) { return "
    $input$units
    "; } 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 = "
    " . "" . "
    "; if ($units) { return "
    $input
    $units
    "; } 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 ""; } 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"); $hint = $this->processAttributeToRaw($rowOptions, 'hint', "\n\t\t\t\t\t%s"); $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
    %s
    "); $finalHtml = $this->processAttributeToRaw($rowOptions, 'finalhtml', "\n\t\t\t\t\t%s"); $unhandledAttrs = $this->processUnhandledAttributes($rowOptions); return '
    ' . $label . $hint . '
    ' . $initialHtml // stuff to go before the control (rarely) . $contentHtml // controls etc. . $html // extra HTML, dependent controls etc. . $explain // final that describes all the above . $finalHtml // used for etc. . '
    '; } 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 "
    {$contentHtml}
    "; } 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 "
    {$contentHtml} {$csrfInput} {$getFormParams}
    "; } 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 "
    {$contentHtml}
    "; } 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 "