<?php
namespace XF\Template;
use XF\App;
use XF\Language;
use XF\Mvc\Entity\AbstractCollection;
use XF\Mvc\Entity\Entity;
use XF\Mvc\Router;
use XF\Util\Arr;
use function array_key_exists, array_slice, boolval, call_user_func, call_user_func_array, count, func_get_args, get_class, gettype, in_array, intval, is_array, is_int, is_integer, is_object, is_scalar, is_string, ord, strlen, strval;
class Templater
{
const MAX_EXECUTION_DEPTH = 50;
const TRANSPARENT_IMG_URI = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
/**
* @var App
*/
protected $app;
/**
* @var Router
*/
protected $router;
protected $routerType;
/**
* @var \Closure
*/
protected $pather;
protected $jsBaseUrl = 'js';
/**
* @var Language
*/
protected $language;
protected $compiledPath;
protected $styleId = 0;
/**
* @var callable|null
*/
protected $cssValidator;
/**
* @var \XF\Style|null
*/
protected $style;
protected $filters = [];
protected $functions = [];
protected $tests = [];
protected $defaultParams = [];
protected $templateCache = [];
protected $jQueryVersion;
protected $jQuerySource = 'local';
protected $jsVersion = '';
protected $dynamicDefaultAvatars = true;
protected $mediaSites = [];
protected $groupStyles = [];
protected $userTitleLadder = [];
protected $userTitleLadderField = 'trophy_points';
protected $userBanners = [];
protected $userBannerConfig = [];
protected $widgetPositions = [];
/**
* @var WatcherInterface[]
*/
protected $watchers = [];
protected $currentTemplateType;
protected $currentTemplateName;
protected $currentMacroName;
protected $currentExtensionName;
/**
* @var ExtensionSet|null
*/
protected $currentExtensionSet;
protected $isExtensionDummyRender = false;
protected $wrapTemplateName = null;
protected $wrapTemplateParams = null;
protected $executionDepth = 0;
protected $templateErrors = [];
protected $escapeContext = 'html';
protected $includeCss = [];
protected $inlineCss = [];
protected $includeJs = [];
protected $inlineJs = [];
protected $sidebar = [];
protected $sideNav = [];
protected $uniqueIdCounter = 0;
protected $uniqueIdPrefix;
protected $uniqueIdFormat = '_xfUid-%s';
protected $avatarDefaultStylingCache = [];
protected $avatarLetterRegex = '/[^\(\)\{\}\[\]\<\>\-\.\+\:\=\*\!\|\^\/\\\\\'`"_,#~ ]/u';
public $pageParams = [];
protected $defaultFilters = [
'default' => 'filterDefault',
'censor' => 'filterCensor',
'count' => 'filterCount',
'currency' => 'filterCurrency',
'emoji' => 'filterEmoji',
'escape' => 'filterEscape',
'for_attr' => 'filterForAttr',
'file_size' => 'filterFileSize',
'first' => 'filterFirst',
'format' => 'filterFormat',
'hex' => 'filterHex',
'host' => 'filterHost',
'htmlspecialchars' => 'filterHtmlspecialchars',
'ip' => 'filterIp',
'join' => 'filterJoin',
'json' => 'filterJson',
'last' => 'filterLast',
'nl2br' => 'filterNl2Br',
'nl2nl' => 'filterNl2Nl',
'number' => 'filterNumber',
'number_short' => 'filterNumberShort',
'numeric_keys_only' => 'filterNumericKeysOnly',
'pad' => 'filterPad',
'parens' => 'filterParens',
'pluck' => 'filterPluck',
'preescaped' => 'filterPreEscaped',
'raw' => 'filterRaw',
'replace' => 'filterReplace',
'split' => 'filterSplit',
'split_long' => 'filterSplitLong',
'strip_tags' => 'filterStripTags',
'to_lower' => 'filterToLower',
'to_upper' => 'filterToUpper',
'de_camel' => 'filterDeCamel',
'substr' => 'filterSubstr',
'url' => 'filterUrl',
'urlencode' => 'filterUrlencode',
'zerofill' => 'filterZeroFill',
];
protected $defaultFunctions = [
'anchor_target' => 'fnAnchorTarget',
'anon_referer' => 'fnAnonReferer',
'array_keys' => 'fnArrayKeys',
'array_merge' => 'fnArrayMerge',
'array_values' => 'fnArrayValues',
'asset' => 'fnAsset',
'attributes' => 'fnAttributes',
'avatar' => 'fnAvatar',
'base_url' => 'fnBaseUrl',
'bb_code' => 'fnBbCode',
'bb_code_snippet' => 'fnBbCodeSnippet',
'bb_code_type' => 'fnBbCodeType',
'bb_code_type_snippet' => 'fnBbCodeTypeSnippet',
'button_icon' => 'fnButtonIcon',
'cache_key' => 'fnCacheKey',
'call_macro' => 'fnCallMacro',
'callable' => 'fnCallable',
'captcha' => 'fnCaptcha',
'ceil' => 'fnCeil',
'contains' => 'fnContains',
'copyright' => 'fnCopyright',
'core_js' => 'fnCoreJs',
'count' => 'fnCount',
'csrf_input' => 'fnCsrfInput',
'csrf_token' => 'fnCsrfToken',
'css_url' => 'fnCssUrl',
'date' => 'fnDate',
'date_from_format' => 'fnDateFromFormat',
'date_dynamic' => 'fnDateDynamic',
'date_time' => 'fnDateTime',
'debug_url' => 'fnDebugUrl',
'display_totals' => 'fnDisplayTotals',
'dump' => 'fnDump',
'dump_simple' => 'fnDumpSimple',
'duration' => 'fnDuration',
'empty' => 'fnEmpty',
'fa_weight' => 'fnFaWeight',
'file_size' => 'fnFileSize',
'floor' => 'fnFloor',
'gravatar_url' => 'fnGravatarUrl',
'highlight' => 'fnHighlight',
'in_array' => 'fnInArray',
'is_array' => 'fnIsArray',
'is_scalar' => 'fnIsScalar',
'is_addon_active' => 'fnIsAddonActive',
'is_editor_capable' => 'fnIsEditorCapable',
'is_toggled' => 'fnIsToggled',
'is_changed' => 'fnIsChanged',
'js_url' => 'fnJsUrl',
'key_exists' => 'fnKeyExists',
'last_pages' => 'fnLastPages',
'likes' => 'fnLikes',
'likes_content' => 'fnLikesContent',
'link' => 'fnLink',
'link_type' => 'fnLinkType',
'min' => 'fnMin',
'max' => 'fnMax',
'max_length' => 'fnMaxLength',
'media_sites' => 'fnMediaSites',
'mustache' => 'fnMustache',
'number' => 'fnNumber',
'number_short' => 'fnNumberShort',
'named_colors' => 'fnNamedColors',
'page_description' => 'fnPageDescription',
'page_h1' => 'fnPageH1',
'page_nav' => 'fnPageNav',
'page_param' => 'fnPageParam',
'page_title' => 'fnPageTitle',
'parens' => 'fnParens',
'parse_less_color' => 'fnParseLessColor',
'phrase_dynamic' => 'fnPhraseDynamic',
'prefix' => 'fnPrefix',
'prefix_group' => 'fnPrefixGroup',
'prefix_title' => 'fnPrefixTitle',
'prefix_description' => 'fnPrefixDescription',
'prefix_usage_help' => 'fnPrefixUsageHelp',
'profile_banner' => 'fnProfileBanner',
'property' => 'fnProperty',
'rand' => 'fnRand',
'range' => 'fnRange',
'react' => 'fnReact',
'alert_reaction' => 'fnAlertReaction',
'reaction' => 'fnReaction',
'reaction_title' => 'fnReactionTitle',
'reactions' => 'fnReactions',
'reactions_content' => 'fnReactionsContent',
'reactions_summary' => 'fnReactionsSummary',
'redirect_input' => 'fnRedirectInput',
'repeat' => 'fnRepeat',
'repeat_raw' => 'fnRepeatRaw',
'short_to_emoji' => 'fnShortToEmoji',
'show_ignored' => 'fnShowIgnored',
'smilie' => 'fnSmilie',
'snippet' => 'fnSnippet',
'sprintf' => 'fnSprintf',
'strlen' => 'fnStrlen',
'structured_text' => 'fnStructuredText',
'templater' => 'fnTemplater',
'time' => 'fnTime',
'transparent_img' => 'fnTransparentImg',
'trim' => 'fnTrim',
'unique_id' => 'fnUniqueId',
'user_activity' => 'fnUserActivity',
'user_banners' => 'fnUserBanners',
'user_blurb' => 'fnUserBlurb',
'user_title' => 'fnUserTitle',
'username_link' => 'fnUsernameLink',
'username_link_email' => 'fnUsernameLinkEmail',
'widget_data' => 'fnWidgetData'
];
protected $defaultTests = [
'empty' => 'testEmpty'
];
protected $overlayClickOptions = [
'data-cache',
'data-overlay-config',
'data-force-flash-message',
'data-follow-redirects'
];
public function __construct(App $app, Language $language, $compiledPath)
{
$this->app = $app;
$this->language = $language;
$this->compiledPath = $compiledPath;
$this->router = $app->router();
$this->pather = $app->container('request.pather');
$this->uniqueIdFormat = '_xfUid-%s-' . \XF::$time;
}
public function getTemplateFilePath($type, $name, $styleIdOverride = null)
{
return $this->compiledPath
. '/l' . $this->language->getId()
. '/s' . intval($styleIdOverride !== null ? $styleIdOverride : $this->styleId)
. '/' . preg_replace('/[^a-zA-Z0-9_.-]/', '', $type)
. '/' . preg_replace('/[^a-zA-Z0-9_.-]/', '', $name) . '.php';
}
protected function getTemplateDataFromSource($type, $name)
{
$path = $this->getTemplateFilePath($type, $name);
try
{
$file = @include($path);
}
catch (\Throwable $e)
{
return false;
}
return $file;
}
public function getRouter()
{
if ($this->currentTemplateType && $this->currentTemplateType != $this->routerType)
{
$container = $this->app->container();
$type = $this->currentTemplateType;
// ?: use over ?? intentional due to PHP bug #71731 in < 7.0.6
/** @var \XF\Mvc\Router|null $router */
$router = isset($container['router.' . $type]) ? $container['router.' . $type] : null;
if ($router)
{
$this->router = $router;
$this->routerType = $type;
}
}
return $this->router;
}
public function getCssLoadUrl(array $templates, $includeValidation = true)
{
$url = 'css.php?css='
. urlencode(implode(',', $templates))
. '&s=' . $this->styleId
. '&l=' . $this->language->getId()
. '&d=' . ($this->style ? $this->style->getLastModified() : \XF::$time);
if ($includeValidation)
{
$validationKey = $this->getCssValidationKey($templates);
if ($validationKey)
{
$url .= '&k=' . urlencode($validationKey);
}
}
$pather = $this->pather;
return $pather($url, 'base');
}
public function getCssValidationKey(array $templates)
{
if ($this->cssValidator)
{
$cssValidator = $this->cssValidator;
return $cssValidator($templates);
}
else
{
return null;
}
}
public function getJsUrl($js, $root = false)
{
if (preg_match('#^[a-z]+:#i', $js))
{
return $js;
}
$pather = $this->pather;
$absolutePath = false;
if ($js && $js[0] == '/')
{
$base = $pather('', 'base');
if (!strpos($js, $base) === 0)
{
// not within the XF path
return $js;
}
$absolutePath = true;
}
if (!strpos($js, '_v='))
{
$js = $js . (strpos($js, '?') ? '&' : '?') . $this->getJsCacheBuster();
}
if ($absolutePath)
{
return $js;
}
else if ($root)
{
return $pather($js, 'base');
}
else
{
return $pather("{$this->jsBaseUrl}/$js", 'base');
}
}
public function getJsCacheBuster()
{
return '_v=' . $this->jsVersion;
}
public function getDevJsUrl($addOnId, $js)
{
$url = 'js/devjs.php?addon_id=' . urlencode($addOnId) . '&js=' . urlencode($js);
$pather = $this->pather;
return $pather($url, 'base');
}
public function setLanguage(Language $language)
{
$this->language = $language;
}
public function getLanguage()
{
return $this->language;
}
/**
* Gets a phrase object using the active language. (Templater language may differ from the
* global XF::language value.)
*
* @param string $name
* @param array $params
* @param bool $allowHtml
*
* @return \XF\Phrase
*/
public function phrase(string $name, array $params = [], bool $allowHtml = true): \XF\Phrase
{
return $this->language->phrase($name, $params, true, $allowHtml);
}
public function setStyle(\XF\Style $style)
{
$this->style = $style;
$this->styleId = $style->getId();
}
public function getStyle()
{
return $this->style;
}
public function getStyleId()
{
return $this->styleId;
}
public function setCssValidator(callable $cssValidator)
{
$this->cssValidator = $cssValidator;
}
public function setJquerySource($version, $jQuerySource = null)
{
$this->jQueryVersion = $version;
$this->jQuerySource = $jQuerySource ?: $this->app->options()->jQuerySource;
}
public function setJsVersion($version)
{
$this->jsVersion = $version;
}
public function setJsBaseUrl($baseUrl)
{
$this->jsBaseUrl = rtrim($baseUrl, '/') ?: 'js';
}
public function setDynamicDefaultAvatars($dynamic)
{
$this->dynamicDefaultAvatars = $dynamic;
}
public function setMediaSites(array $mediaSites)
{
$this->mediaSites = $mediaSites;
}
public function setUserTitleLadder(array $ladder, $titleField = '')
{
$this->userTitleLadder = $ladder;
if ($titleField)
{
$this->userTitleLadderField = $titleField;
}
}
public function setUserBanners(array $banners, array $config = [])
{
$this->userBanners = $banners;
if ($config)
{
$this->userBannerConfig = $config;
}
}
public function setGroupStyles(array $styles)
{
$this->groupStyles = $styles;
}
public function setWidgetPositions(array $widgetPositions)
{
$this->widgetPositions = $widgetPositions;
}
public function addDefaultHandlers()
{
$this->addFilters($this->defaultFilters);
$this->addFunctions($this->defaultFunctions);
$this->addTests($this->defaultTests);
}
public function addFilters(array $filters)
{
$this->filters = array_merge($this->filters, $filters);
}
public function addFilter($name, $filter)
{
$this->filters[$name] = $filter;
}
public function addFunctions(array $functions)
{
$this->functions = array_merge($this->functions, $functions);
}
public function addFunction($name, $function)
{
$this->functions[$name] = $function;
}
public function addTests(array $tests)
{
$this->tests = array_merge($this->tests, $tests);
}
public function addTest($name, $test)
{
$this->tests[$name] = $test;
}
public function addDefaultParams(array $params)
{
$this->defaultParams = array_merge($this->defaultParams, $params);
}
public function addDefaultParam($name, $value)
{
$this->defaultParams[$name] = $value;
}
public function getTemplate($name, array $params = [])
{
return new Template($this, $name, $params);
}
public function addTemplateWatcher(WatcherInterface $watcher)
{
$this->watchers[] = $watcher;
}
public function hasWatcherActionedTemplates()
{
foreach ($this->watchers AS $watcher)
{
if ($watcher->hasActionedTemplates())
{
return true;
}
}
return false;
}
public function getTemplateTypeAndName($template)
{
$parts = explode(':', $template, 2);
if (count($parts) == 2)
{
return [$parts[0], $parts[1]];
}
else
{
return [$this->currentTemplateType, $parts[0]];
}
}
public function applyDefaultTemplateType($template)
{
list($type, $template) = $this->getTemplateTypeAndName($template);
if ($type)
{
$template = "$type:$template";
}
return $template;
}
/**
* @param string $type
* @param string $template
*
* @return \Closure
*/
public function getTemplateCode($type, $template)
{
$data = $this->getTemplateData($type, $template);
return $data['code'];
}
/**
* @param string $type
* @param string $template
* @param string $macro
*
* @return \Closure
*/
public function getTemplateMacro($type, $template, $macro)
{
$data = $this->getTemplateData($type, $template);
if (isset($data['macros'][$macro]))
{
return $data['macros'][$macro];
}
trigger_error("Macro $type:$template:$macro is unknown", E_USER_WARNING);
return function () {
return '';
};
}
protected function getTemplateData($type, $template, $errorOnUnknown = true)
{
$languageId = $this->language->getId();
$cacheKey = "{$languageId}-{$this->styleId}-{$type}-{$template}";
if (isset($this->templateCache[$cacheKey]))
{
return $this->templateCache[$cacheKey];
}
if (preg_match('#[^a-zA-Z0-9_.-]#', $template))
{
throw new \InvalidArgumentException("Template name '$template' contains invalid characters");
}
foreach ($this->watchers AS $watcher)
{
$watcher->watchTemplate($this, $type, $template);
}
$data = $this->getTemplateDataFromSource($type, $template);
if (!$data || !is_array($data) || !isset($data['code']))
{
if ($errorOnUnknown)
{
trigger_error("Template $type:$template is unknown", E_USER_WARNING);
}
$data = [
'code' => function () {
return '';
},
'unknown' => true
];
}
$this->templateCache[$cacheKey] = $data;
return $data;
}
public function callAdsMacro($position, array $arguments, array $globalVars)
{
$templateData = $this->getTemplateData('public', '_ads', false);
if (!isset($templateData['macros'][$position]))
{
return '';
}
else
{
return $this->callMacro('public:_ads', $position, $arguments, $globalVars);
}
}
public function callMacro(
$template, $name, array $arguments, array $globalVars, MacroState $macroState = null
)
{
if ($this->executionDepth >= self::MAX_EXECUTION_DEPTH)
{
trigger_error('Max template execution depth reached', E_USER_WARNING);
return '';
}
if (!$template)
{
$nameParts = explode('::', $name, 2);
if (count($nameParts) == 2)
{
list($type, $template) = $this->getTemplateTypeAndName($nameParts[0]);
$name = $nameParts[1];
}
else
{
$template = $this->currentTemplateName;
$type = $this->currentTemplateType;
}
}
else
{
list($type, $template) = $this->getTemplateTypeAndName($template);
}
if (!$type)
{
trigger_error('No template type was provided. Provide template name in type:name format.', E_USER_WARNING);
return '';
}
if (isset($globalVars['__globals']))
{
$globalVars = $globalVars['__globals'];
}
$this->app->fire(
'templater_macro_pre_render',
[$this, &$type, &$template, &$name, &$arguments, &$globalVars],
"$type:$template:$name"
);
$postRenderCb = $this->setupRenderTemplateElement($type, $template, $name);
$isExtensionDummyRender = $this->isExtensionDummyRender;
try
{
$macro = $this->getTemplateMacro($type, $template, $name);
if (is_array($macro))
{
if (!$macroState)
{
$macroState = new MacroState();
}
if (!empty($macro['extensions']))
{
$macroState->applyExtensionSet(
new ExtensionSet($type, $template, $macro['extensions'], $name)
);
}
if (!empty($macro['arguments']))
{
$macroState->addArguments(
$macro['arguments']($this, $globalVars)
);
}
if (!empty($macro['global']))
{
$macroState->setGlobal(true);
}
$extensions = $macroState->getExtensionSet();
$macroVars = $macroState->getAvailableVars($this, $arguments, $globalVars);
// new, extended format
if (array_key_exists('extends', $macro))
{
$extendsParts = explode('::', $macro['extends'], 2);
if (count($extendsParts) == 2)
{
// template::macro_name format
$extendsTemplate = $this->applyDefaultTemplateType($extendsParts[0]);
$extendsMacro = $extendsParts[1];
}
else
{
// Just macro_name, so default to current template
$extendsTemplate = "$type:$template";
$extendsMacro = $extendsParts[0];
}
$output = $this->callMacro($extendsTemplate, $extendsMacro, $arguments, $globalVars, $macroState);
// Dummy render the contents of the macro to get things like page titles, etc.
// No extensions will be rendered and the output will be thrown away.
$this->isExtensionDummyRender = true;
$macro['code']($this, $macroVars, $extensions);
}
else
{
$output = $macro['code']($this, $macroVars, $extensions);
}
}
else
{
// legacy format -- still has argument/globals processing code within
$output = $macro($this, $arguments, $globalVars);
}
}
catch (\Throwable $e)
{
$errorPrefix = "$this->currentTemplateType:$this->currentTemplateName :: $name()";
$output = $this->handleTemplateException($e, $errorPrefix, "Macro $errorPrefix error: ");
}
finally
{
$this->isExtensionDummyRender = $isExtensionDummyRender;
}
if ($this->wrapTemplateName)
{
$output = $this->applyWrappedTemplate($output);
}
$postRenderCb();
$this->app->fire(
'templater_macro_post_render',
[$this, $type, $template, &$name, &$output],
"$type:$template:$name"
);
return $output;
}
public function renderMacro($template, $name, array $arguments = [])
{
return $this->callMacro($template, $name, $arguments, $this->defaultParams);
}
public function setupBaseParamsForMacro(array $parentVars, $isGlobal = false)
{
if (isset($parentVars['__globals']))
{
$globalVars = $parentVars['__globals'];
}
else
{
$globalVars = $parentVars;
}
$params = $isGlobal ? $globalVars : $this->defaultParams;
$params['__globals'] = $globalVars;
return $params;
}
public function combineMacroArgumentAttributes($argsAttr, array $separateArgs)
{
if (is_array($argsAttr))
{
return array_replace($argsAttr, $separateArgs);
}
else
{
return $separateArgs;
}
}
public function mergeMacroArguments(array $expected, array $provided, array $baseParams)
{
foreach ($expected AS $argument => $value)
{
if (array_key_exists($argument, $provided))
{
$baseParams[$argument] = $provided[$argument];
}
else if ($value === '!')
{
throw new \LogicException("Macro argument $argument is required and no value was provided");
}
else
{
$baseParams[$argument] = $value;
}
}
return $baseParams;
}
public function renderExtension($name, array $params, ExtensionSet $extensions = null)
{
$extensionDef = $extensions ? $extensions->getExtension($name) : null;
if (!$extensionDef)
{
trigger_error("No extension named '$name' could be found.", E_USER_WARNING);
return '';
}
return $this->renderExtensionInternal($name, $extensionDef, $params, $extensions);
}
public function renderExtensionParent(array $params, $name = null, ExtensionSet $extensions = null)
{
if (!$name)
{
if (!$this->currentExtensionName)
{
trigger_error("Cannot call for an extension parent when not rendering an extension.", E_USER_WARNING);
return '';
}
$name = $this->currentExtensionName;
}
if (!$this->currentExtensionSet)
{
trigger_error("No current extension set found. Cannot render extension parent.", E_USER_WARNING);
return '';
}
$parentSet = $this->currentExtensionSet->getBaseSet();
$extensionDef = $parentSet ? $parentSet->getExtension($name) : null;
if (!$extensionDef)
{
trigger_error("No parent version of the extension '$name' could be found.", E_USER_WARNING);
return '';
}
return $this->renderExtensionInternal($name, $extensionDef, $params, $extensions);
}
protected function renderExtensionInternal($name, array $extensionDef, array $params, ExtensionSet $extensions = null)
{
if ($this->isExtensionDummyRender)
{
return '';
}
if ($this->executionDepth >= self::MAX_EXECUTION_DEPTH)
{
trigger_error('Max template execution depth reached', E_USER_WARNING);
return '';
}
$type = $extensionDef['type'];
$template = $extensionDef['template'];
$eventName = $extensionDef['macro'] ? "$extensionDef[macro]:$name" : $name;
$this->app->fire(
'templater_extension_pre_render',
[$this, $type, $template, $eventName, &$params],
"$type:$template:$eventName"
);
$postRenderCb = $this->setupRenderTemplateElement($type, $template, null, $name);
$currentExtensionSet = $this->currentExtensionSet;
$this->currentExtensionSet = $extensionDef['set'];
try
{
$output = $extensionDef['code']($this, $params, $extensions);
}
catch (\Throwable $e)
{
$errorPrefix = "$this->currentTemplateType:$this->currentTemplateName :: $name()";
$output = $this->handleTemplateException($e, $errorPrefix, "Extension $errorPrefix error: ");
}
if ($this->wrapTemplateName)
{
$output = $this->applyWrappedTemplate($output);
}
$postRenderCb();
$this->currentExtensionSet = $currentExtensionSet;
$this->app->fire(
'templater_extension_post_render',
[$this, $type, $template, $eventName, &$output],
"$type:$template:$eventName"
);
return $output;
}
public function extractIntoVarContainer(array &$varContainer, $source)
{
if (!$this->isTraversable($source))
{
return;
}
foreach ($source AS $k => $v)
{
$varContainer[$k] = $v;
}
}
public function wrapTemplate($template, array $params)
{
$template = $this->applyDefaultTemplateType($template);
$this->wrapTemplateName = $template;
$this->wrapTemplateParams = $params;
}
protected function applyWrappedTemplate($content)
{
if (!$this->wrapTemplateName)
{
return $content;
}
$template = $this->wrapTemplateName;
$params = $this->wrapTemplateParams;
$this->wrapTemplateName = null;
$this->wrapTemplateParams = null;
$params['innerContent'] = $this->preEscaped($content, 'html');
return $this->renderTemplate($template, $params, false);
}
public function filter($value, array $filters, $escape = true)
{
foreach ($filters AS $filter)
{
list($name, $arguments) = $filter;
$name = strtolower($name);
if (!isset($this->filters[$name]))
{
trigger_error("Filter $name is unknown", E_USER_WARNING);
continue;
}
$callable = $this->filters[$name];
if (is_string($callable))
{
$callable = [$this, $callable];
}
if ($arguments)
{
array_unshift($arguments, null);
array_unshift($arguments, $value);
array_unshift($arguments, $this);
$arguments[2] =& $escape;
}
else
{
$arguments = [$this, $value, &$escape];
}
$value = call_user_func_array($callable, $arguments);
}
return $escape ? $this->escape($value, $escape) : $value;
}
/**
* @deprecated use func() method below instead. This will be removed in the near future for PHP 7.4 compatibility.
*
* @param $name
* @param array $arguments
* @param bool $escape
*
* @return mixed|string|string[]|null
*/
public function fn($name, array $arguments = [], $escape = true)
{
return $this->func($name, $arguments, $escape);
}
public function func($name, array $arguments = [], $escape = true)
{
$name = strtolower($name);
if (!isset($this->functions[$name]))
{
trigger_error("Function $name is unknown", E_USER_WARNING);
return '';
}
$callable = $this->functions[$name];
if (is_string($callable))
{
$callable = [$this, $callable];
}
if ($arguments)
{
array_unshift($arguments, null);
array_unshift($arguments, $this);
$arguments[1] =& $escape;
}
else
{
$arguments = [$this, &$escape];
}
$value = call_user_func_array($callable, $arguments);
return $escape ? $this->escape($value) : $value;
}
public function test($value, $test, array $arguments = [])
{
if (!isset($this->tests[$test]))
{
trigger_error("Test $test is unknown", E_USER_WARNING);
return false;
}
$callable = $this->tests[$test];
if (is_string($callable))
{
$callable = [$this, $callable];
}
if ($arguments)
{
array_unshift($arguments, $value);
array_unshift($arguments, $this);
}
else
{
$arguments = [$this, $value];
}
return (bool)call_user_func_array($callable, $arguments);
}
public function arrayKey($var, $key)
{
return $var[$key];
}
public function isA($object, $class)
{
return ($object instanceof $class);
}
public function method($var, $fn, array $arguments = [])
{
if (!is_object($var))
{
$type = gettype($var);
trigger_error("Cannot call method $fn on a non-object ($type)", E_USER_WARNING);
return '';
}
$call = [$var, $fn];
if (!is_callable($call))
{
$class = get_class($var);
trigger_error("Method $fn is not callable on the given object ($class)", E_USER_WARNING);
return '';
}
return call_user_func_array($call, $arguments);
}
public function escape($value, $type = null)
{
if ($type === null || $type === true)
{
$type = $this->escapeContext;
}
return \XF::escapeString($value, $type);
}
public function modifySectionedHtml(array &$ref, $key, $html, $mode = 'replace')
{
if ($mode == 'delete')
{
if ($key)
{
unset($ref[$key]);
}
return;
}
$html = trim($html);
if (!strlen($html))
{
return;
}
$html = $this->preEscaped($html, 'html');
switch ($mode)
{
case 'prepend':
if ($key)
{
$ref = [$key => $html] + $ref;
}
else
{
array_unshift($ref, $html);
}
break;
case 'append':
if ($key)
{
unset($ref[$key]); // unset to ensure this goes at the end
$ref[$key] = $html;
}
else
{
$ref[] = $html;
}
break;
case 'replace':
default:
if ($key)
{
$ref[$key] = $html;
}
else
{
$ref[] = $html;
}
break;
}
}
public function modifySidebarHtml($key, $html, $mode = 'replace')
{
$this->modifySectionedHtml($this->sidebar, $key, $html, $mode);
}
public function getSidebarHtml()
{
return $this->sidebar;
}
public function modifySideNavHtml($key, $html, $mode = 'replace')
{
$this->modifySectionedHtml($this->sideNav, $key, $html, $mode);
}
public function getSideNavHtml()
{
return $this->sideNav;
}
public function includeCss($css)
{
list($type, $template) = $this->getTemplateTypeAndName($css);
if (!$type)
{
trigger_error('No template type was provided. Provide template name in type:name format.', E_USER_WARNING);
return;
}
$this->includeCss["$type:$template"] = true;
}
public function getIncludedCss(array $forceAppend = [])
{
$css = array_keys($this->includeCss);
sort($css);
return array_merge($css, $forceAppend);
}
public function inlineCss($css)
{
$this->inlineCss[] = $css;
}
public function getInlineCss()
{
return $this->inlineCss;
}
public function includeJs(array $options)
{
$options = array_replace([
'src' => null,
'addon' => null,
'min' => null,
'dev' => null,
'prod' => null,
'root' => false,
], $options);
$developmentConfig = $this->app->config('development');
$productionMode = empty($developmentConfig['fullJs']);
$src = $this->splitJsSrc($options['src']);
if ($productionMode)
{
if ($options['min'])
{
$src = array_map(function ($path) {
return preg_replace('(\.js$)', '.min.js', $path, 1);
}, $src);
}
$prod = $this->splitJsSrc($options['prod']);
$src = array_merge($src, $prod);
foreach ($src AS $path)
{
$url = $this->getJsUrl($path, boolval($options['root']));
$this->includeJs[$url] = true;
}
}
else
{
$dev = $this->splitJsSrc($options['dev']);
$src = array_merge($src, $dev);
if ($options['addon'])
{
foreach ($src AS $path)
{
$url = $this->getDevJsUrl($options['addon'], $path);
$this->includeJs[$url] = true;
}
}
else
{
foreach ($src AS $path)
{
$url = $this->getJsUrl($path, boolval($options['root']));
$this->includeJs[$url] = true;
}
}
}
}
protected function splitJsSrc($js)
{
if ($js)
{
return Arr::stringToArray($js, '/[, ]/');
}
else
{
return [];
}
}
public function getIncludedJs()
{
return array_keys($this->includeJs);
}
public function inlineJs($js)
{
$this->inlineJs[] = $js;
}
public function getInlineJs()
{
return $this->inlineJs;
}
public function isTraversable($value)
{
return is_array($value) || ($value instanceof \Traversable);
}
public function isArrayAccessible($value)
{
return is_array($value) || ($value instanceof \ArrayAccess);
}
public function handleTemplateError($errorType, $errorString, $file, $line)
{
switch ($errorType)
{
case E_NOTICE:
case E_USER_NOTICE:
// ignore these (generally accessing an invalid variable
return;
case E_STRICT:
case E_DEPRECATED:
case E_USER_DEPRECATED:
// these are only logged in debug mode
if (!\XF::$debugMode)
{
return;
}
break;
case E_WARNING:
if (PHP_MAJOR_VERSION >= 8)
{
if (str_contains($errorString, 'Undefined array key')
|| str_contains($errorString, 'Trying to access array offset on value')
)
{
return;
}
}
break;
}
if ($errorType & error_reporting())
{
$errorString = '[' . \XF\Util\Php::convertErrorCodeToString($errorType) . '] '. $errorString;
$this->templateErrors[] = [
'template' => $this->currentTemplateType . ':' . $this->currentTemplateName,
'type' => $errorType,
'error' => $errorString,
'file' => $file,
'line' => $line
];
$e = new \ErrorException($errorString, 0, $errorType, $file, $line);
$this->app->logException($e, false, "Template error: ");
}
}
public function handleTemplateException(\Throwable $e, $printableName, $exceptionPrefix = '')
{
$this->app->logException($e, false, $exceptionPrefix);
if (\XF::$debugMode)
{
$message = $e->getMessage();
$file = $e->getFile() . ':' . $e->getLine();
$error = $e instanceof \XF\PrintableException
? "$printableName - $message"
: "$printableName - $message in $file";
if (preg_match('/\.(css|less)$/i', $this->currentTemplateName))
{
$error = strtr($error, [
"'" => '',
'\\' => '/',
"\r" => '',
"\n" => " "
]);
$output = "
/** Error output **/
body:before
{
background-color: #ccc;
color: black;
font-weight: bold;
display: block;
padding: 10px;
margin: 10px;
border: solid 1px #aaa;
border-radius: 5px;
content: 'CSS error: " . $error . "';
}
";
}
else
{
$output = '<div class="error"><h3>Template Compilation Error</h3>'
. '<div>' . htmlspecialchars($error) . '</div></div>';
}
}
else
{
$output = '';
}
return $output;
}
public function getTemplateErrors()
{
return $this->templateErrors;
}
public function setupRenderTemplateElement($type, $template, $macro = null, $extension = null)
{
$currentType = $this->currentTemplateType;
$currentName = $this->currentTemplateName;
$currentMacro = $this->currentMacroName;
$currentExtension = $this->currentExtensionName;
$origWrapTemplateName = $this->wrapTemplateName;
$origWrapTemplateParams = $this->wrapTemplateParams;
$this->currentTemplateType = $type;
$this->currentTemplateName = $template;
$this->currentMacroName = $macro;
$this->currentExtensionName = $extension;
$this->wrapTemplateName = null;
$this->wrapTemplateParams = null;
$this->executionDepth++;
set_error_handler([$this, 'handleTemplateError']);
return function() use (
$currentType, $currentName, $currentMacro, $currentExtension,
$origWrapTemplateName, $origWrapTemplateParams
)
{
restore_error_handler();
$this->currentTemplateType = $currentType;
$this->currentTemplateName = $currentName;
$this->currentMacroName = $currentMacro;
$this->currentExtensionName = $currentExtension;
$this->wrapTemplateName = $origWrapTemplateName;
$this->wrapTemplateParams = $origWrapTemplateParams;
$this->executionDepth--;
};
}
public function isKnownTemplate($template)
{
$type = false;
if (strpos($template, ':') !== false)
{
list($type, $template) = explode(':', $template, 2);
}
if (!$type)
{
return false;
}
$data = $this->getTemplateData($type, $template, false);
return empty($data['unknown']) ? true : false;
}
/**
* @param string $template
* @param array $params
* @param bool $addDefaultParams
* @param ExtensionSet|null $extensionOverrides
*
* @return string
*/
public function renderTemplate(
$template, array $params = [], $addDefaultParams = true, ExtensionSet $extensionOverrides = null
)
{
if ($this->executionDepth >= self::MAX_EXECUTION_DEPTH)
{
trigger_error('Max template execution depth reached', E_USER_WARNING);
return '';
}
if ($addDefaultParams)
{
$params = array_merge($this->defaultParams, $params);
}
$type = false;
if (strpos($template, ':') !== false)
{
list($type, $template) = explode(':', $template, 2);
}
if (!$type)
{
trigger_error('No template type was provided. Provide template name in type:name format.', E_USER_WARNING);
return '';
}
$this->app->fire('templater_template_pre_render', [$this, &$type, &$template, &$params], "$type:$template");
$postRenderCb = $this->setupRenderTemplateElement($type, $template);
$isExtensionDummyRender = $this->isExtensionDummyRender;
try
{
$data = $this->getTemplateData($type, $template);
if (!empty($data['extensions']))
{
$extensions = new ExtensionSet($type, $template, $data['extensions']);
}
else
{
$extensions = null;
}
if ($extensionOverrides)
{
if ($extensions)
{
$extensionOverrides->applyBaseSet($extensions);
}
$extensions = $extensionOverrides;
}
$extendsTemplate = isset($data['extends']) ? $data['extends']($this, $params) : null;
if ($extendsTemplate)
{
$extendsTemplate = $this->applyDefaultTemplateType($extendsTemplate);
$output = $this->renderTemplate($extendsTemplate, $params, $addDefaultParams, $extensions);
// Dummy render the contents of the template to get things like page titles, etc.
// No extensions will be rendered and the output will be thrown away.
$this->isExtensionDummyRender = true;
$data['code']($this, $params, $extensions);
}
else
{
$output = $data['code']($this, $params, $extensions);
}
}
catch (\Throwable $e)
{
$errorPrefix = "$this->currentTemplateType:$this->currentTemplateName";
$output = $this->handleTemplateException($e, $errorPrefix, "Template $errorPrefix error: ");
}
finally
{
$this->isExtensionDummyRender = $isExtensionDummyRender;
}
if ($this->wrapTemplateName)
{
$output = $this->applyWrappedTemplate($output);
}
$postRenderCb();
$this->app->fire('templater_template_post_render', [$this, $type, $template, &$output], "$type:$template");
return $output;
}
public function includeTemplate($template, array $params = [])
{
$template = $this->applyDefaultTemplateType($template);
return $this->renderTemplate($template, $params);
}
public function callback($class, $method, $contents, array $params = [])
{
if (!\XF\Util\Php::validateCallbackPhrased($class, $method, $errorPhrase))
{
return $errorPhrase;
}
if (!\XF\Util\Php::nameIndicatesReadOnly($method))
{
return $this->phrase('callback_method_x_does_not_appear_to_indicate_read_only', ['method' => $method]);
}
ob_start();
$output = call_user_func([$class, $method], $contents, $params, $this);
$output .= ob_get_clean();
return $output;
}
public function setPageParams(array $pageParams)
{
$this->pageParams = Arr::mapMerge($this->pageParams, $pageParams);
}
public function setPageParam($name, $value)
{
if (strpos($name, '.') === false)
{
$this->pageParams[$name] = $value;
return;
}
$ref =& $this->pageParams;
$hasValid = false;
foreach (explode('.', $name) AS $part)
{
if (!strlen($part))
{
continue;
}
if (!isset($ref[$part]) || !is_array($ref[$part]))
{
$ref[$part] = [];
}
$ref =& $ref[$part];
$hasValid = true;
}
if ($hasValid)
{
$ref = $value;
}
}
public function breadcrumb($value, $href, array $config)
{
if (!isset($this->pageParams['breadcrumbs']) || !is_array($this->pageParams['breadcrumbs']))
{
$this->pageParams['breadcrumbs'] = [];
}
$crumb = [
'value' => $value,
'href' => $href,
'attributes' => $config
];
$this->pageParams['breadcrumbs'][] = $crumb;
}
public function breadcrumbs(array $crumbs)
{
if (!$crumbs)
{
$this->pageParams['breadcrumbs'] = [];
return;
}
foreach ($crumbs AS $key => $crumb)
{
if (is_string($crumb) || $crumb instanceof \XF\Phrase)
{
$crumb = [
'href' => $key,
'value' => $crumb
];
}
if (!is_array($crumb))
{
trigger_error("Each breadcrumb must be an array", E_USER_WARNING);
continue;
}
if (!isset($crumb['value']))
{
trigger_error("Each breadcrumb provide a 'value' key", E_USER_WARNING);
continue;
}
if (!isset($crumb['href']))
{
trigger_error("Each breadcrumb provide a 'href' key", E_USER_WARNING);
continue;
}
$value = $crumb['value'];
$href = $crumb['href'];
unset($crumb['value'], $crumb['href']);
$this->breadcrumb($value, $href, $crumb);
}
}
public function button($contentHtml, array $options, $menuHtml = '', array $menuOptions = [])
{
$href = $this->processAttributeToRaw($options, 'href', '', true);
if ($href)
{
$element = 'a';
$type = '';
$href = ' href="' . $href . '"';
}
else
{
$element = 'button';
$type = $this->processAttributeToRaw($options, 'type', '', true);
if ($type)
{
$type = ' type="' . $type . '"';
}
else
{
$type = ' type="button"';
}
}
$overlay = $this->processAttributeToRaw($options, 'overlay', '', true);
if ($overlay)
{
$overlay = " data-xf-click=\"overlay\"";
}
$buttonClasses = 'button';
$icon = $this->processAttributeToRaw($options, 'icon');
$fa = '';
if ($icon)
{
$buttonClasses .= ' button--icon button--icon--' . preg_replace('#[^a-zA-Z0-9_-]#', '', $icon);
}
// no predefined icon, so maybe there's an 'fa' (FontAwesome) attribute to use?
else if ($fa = $this->fontAwesome($this->processAttributeToRaw($options, 'fa')))
{
$buttonClasses .= ' button--icon';
}
if ($menuHtml)
{
$buttonClasses .= ' button--splitTrigger';
$menuClass = $this->processAttributeToRaw($menuOptions, 'class', ' %s', true);
$unhandledMenuAttrs = $this->processUnhandledAttributes($menuOptions);
$menuHtml = "<div class=\"menu{$menuClass}\" data-menu=\"menu\" aria-hidden=\"true\"{$unhandledMenuAttrs}>{$menuHtml}</div>";
}
$classAttr = $this->processAttributeToHtmlAttribute($options, 'class', $buttonClasses, true);
$button = strval($this->processAttributeToRaw($options, 'button'));
if (!$button)
{
$button = $contentHtml;
}
if (!$button && $icon)
{
$button = $this->getButtonPhraseFromIcon($icon);
}
$unhandledControlAttrs = $this->processUnhandledAttributes($options);
if ($menuHtml)
{
return "<span{$classAttr}>{$fa}<{$element}{$type}{$href}{$overlay} class=\"button-text\">{$button}</{$element}>"
. "<a class=\"button-menu\" data-xf-click=\"menu\" aria-expanded=\"false\" aria-haspopup=\"true\"></a>"
. $menuHtml
. "</span>";
}
else
{
return "<{$element}{$type}{$href}{$classAttr}{$overlay}{$unhandledControlAttrs}>{$fa}<span class=\"button-text\">{$button}</span></{$element}>";
}
}
public function fontAwesome($iconClasses, array $options = [])
{
$iconClasses = ltrim($iconClasses);
if (preg_match('/^fa[a-z0-9- ]+$/i', $iconClasses))
{
if (!preg_match('/(^|\s)fa(b|l|r|s|d)($|\s)/', $iconClasses))
{
$iconClasses = 'fa' . $this->fnFaWeight(\XF::app()->templater()) . " {$iconClasses}";
}
$class = $this->processAttributeToRaw($options, 'class');
if ($class)
{
$iconClasses = "{$iconClasses} {$class}";
}
$unhandledAttrs = $this->processUnhandledAttributes($options);
return "<i class=\"fa--xf {$iconClasses}\" aria-hidden=\"true\"{$unhandledAttrs}></i>";
}
return '';
}
public function fontAwesomeInputOverlay(array &$controlOptions)
{
if ($fa = $this->processAttributeToRaw($controlOptions, 'fa', '', true))
{
return $this->fontAwesome("fa--inputOverlay {$fa}");
}
return '';
}
public function widgetPosition($positionId, array $contextParams = [])
{
$widgetPositions = $this->widgetPositions;
if (!isset($widgetPositions[$positionId]))
{
return '';
}
$widgetContainer = $this->app->widget();
$widgets = $widgetContainer->position($positionId, $contextParams);
$options = [
'context' => $contextParams
];
$output = '';
foreach ($widgets AS $widget)
{
$output .= $widgetContainer->getCompiledWidget($widget, $options) . "\n";
}
return $output;
}
public function renderWidget($identifier, array $options = [], array $contextParams = [])
{
$options['context'] = $contextParams;
$widgetContainer = $this->app->widget();
$widgetCache = $widgetContainer['widgetCache'];
$widget = null;
foreach ($widgetCache AS $positionId => $widgets)
{
foreach ($widgets AS $widgetId => $_widget)
{
if ($_widget['widget_id'] == $identifier || $_widget['widget_key'] == $identifier)
{
$widget = $_widget;
break;
}
}
}
if ($widget)
{
return $widgetContainer->getCompiledWidget($widget, $options);
}
else
{
$widgetObj = $widgetContainer->widget($identifier, $options);
if ($widgetObj)
{
return $widgetObj->render();
}
}
return '';
}
public function preEscaped($value, $type = null)
{
if ($type === null)
{
$type = $this->escapeContext;
}
return new \XF\PreEscaped($value, $type);
}
////////////////////// FUNCTIONS ////////////////////////
public function fnAnchorTarget($templater, &$escape, $hash)
{
$escape = false;
return '<span class="u-anchorTarget" id="' . htmlspecialchars($this->app->getRedirectHash($hash)) . '"></span>';
}
/**
* @deprecated please use rel="noreferrer noopener" on your anchor tags
*/
public function fnAnonReferer($templater, &$escape, $url)
{
return $url;
}
public function fnArrayKeys($templater, &$escape, $array, $searchValue = null, $strict = null)
{
if (!is_array($array))
{
$array = [];
}
if ($searchValue !== null)
{
if ($strict !== null)
{
return array_keys($array, $searchValue, $strict);
}
else
{
return array_keys($array, $searchValue);
}
}
else
{
return array_keys($array);
}
}
public function fnArrayMerge($templater, &$escape, $array)
{
$arrays = func_get_args();
unset($arrays[0]);
unset($arrays[1]);
return call_user_func_array('array_merge', $arrays);
}
public function fnArrayValues($templater, &$escape, $array)
{
if (!is_array($array))
{
$array = [];
}
return array_values($array);
}
public function fnAsset($templater, &$escape, $key, $suffix = '', $fallback = null, $withPath = true)
{
if (!$this->style)
{
return $fallback;
}
$asset = $this->style->getAsset($key);
if (!$asset)
{
return $fallback;
}
if ($suffix)
{
$asset .= "/{$suffix}";
}
if (strpos($asset, 'data://') === 0)
{
$dataPath = substr($asset, 7); // remove data://
return $this->app->applyExternalDataUrl($dataPath);
}
else
{
$pather = $this->pather;
return $withPath ? $pather($asset, 'base') : $asset;
}
}
public function fnAttributes($templater, &$escape, $attributes, array $skipAttrs = [])
{
if (is_array($attributes))
{
foreach ($skipAttrs AS $attr)
{
unset($attributes[$attr]);
}
$output = $this->processUnhandledAttributes($attributes);
}
else
{
$output = '';
}
$escape = false;
return $output;
}
public function fnAvatar($templater, &$escape, $user, $size, $canonical = false, $attributes = [])
{
$escape = false;
$forceType = $this->processAttributeToRaw($attributes, 'forcetype', '', true);
$noTooltip = $this->processAttributeToRaw($attributes, 'notooltip', '', false);
$update = $this->processAttributeToRaw($attributes, 'update', '');
$size = preg_replace('#[^a-zA-Z0-9_-]#s', '', $size);
if ($user instanceof \XF\Entity\User)
{
$username = $user->username;
if (isset($attributes['href']))
{
$href = $attributes['href'];
$noTooltip = true;
}
else
{
$linkPath = $this->currentTemplateType == 'admin' ? 'users/edit' : 'members';
$href = $this->getRouter()->buildLink(($canonical ? 'canonical:' : '') . $linkPath, $user);
if ($this->currentTemplateType == 'admin')
{
$noTooltip = true;
}
}
$userId = $user->user_id;
if (!$userId)
{
$href = null;
$noTooltip = true;
}
$hrefAttr = $href ? ' href="' . htmlspecialchars($href) . '"' : '';
$avatarType = $forceType ?: $user->getAvatarType();
$canUpdate = ((bool)$update && $user->user_id == \XF::visitor()->user_id && $user->canUploadAvatar());
}
else
{
if (isset($attributes['defaultname']))
{
$username = $attributes['defaultname'];
}
else
{
$username = null;
}
$hrefAttr = '';
$noTooltip = true;
$userId = 0;
$avatarType = 'default';
$canUpdate = false;
}
switch ($avatarType)
{
case 'gravatar':
case 'custom':
$src = $user->getAvatarUrl($size, $forceType, $canonical);
break;
case 'default':
default:
$src = null;
break;
}
$actualSize = $size;
if (!array_key_exists($size, $this->app->container('avatarSizeMap')))
{
$actualSize = 's';
}
$sizeClass = "avatar-u{$userId}-{$actualSize}";
$innerClass = $this->processAttributeToRaw($attributes, 'innerclass', ' %s', true);
$innerClassHtml = $sizeClass . $innerClass;
if ($src && $forceType != 'default')
{
$srcSet = $user->getAvatarUrl2x($size, $forceType, $canonical);
$itemprop = $this->processAttributeToRaw($attributes, 'itemprop', '%s', true);
$pixels = $this->app['avatarSizeMap'][$actualSize];
$innerContent = '<img src="' . htmlspecialchars($src) . '" '
. (!empty($srcSet) ? 'srcset="' . htmlspecialchars($srcSet) . ' 2x"' : '')
. ' alt="' . htmlspecialchars($username) . '"'
. ' class="' . $innerClassHtml . '"'
. ' width="' . $pixels . '" height="' . $pixels . '" loading="lazy"'
. ($itemprop ? ' itemprop="' . $itemprop . '"' : '')
. ' />';
}
else
{
$innerContent = $this->getDynamicAvatarHtml($username, $innerClassHtml, $attributes);
}
$updateLink = '';
$updateLinkClass = '';
if ($canUpdate)
{
$updateLinkClass = ' avatar--updateLink';
$updateLink = '<div class="avatar-update">
<a href="' . htmlspecialchars($update) . '" data-xf-click="overlay">' . $this->phrase('edit_avatar') . '</a>
</div>';
}
$class = $this->processAttributeToRaw($attributes, 'class', ' %s', true);
$xfInit = $this->processAttributeToRaw($attributes, 'data-xf-init', '', true);
if (!$noTooltip)
{
$xfInit = ltrim("$xfInit member-tooltip");
}
$xfInitAttr = $xfInit ? " data-xf-init=\"$xfInit\"" : '';
unset($attributes['defaultname'], $attributes['href'], $attributes['itemprop']);
if (!$hrefAttr && !isset($attributes['title']))
{
$attributes['title'] = $username;
}
$unhandledAttrs = $this->processUnhandledAttributes($attributes);
if ($hrefAttr)
{
$tag = 'a';
}
else
{
$tag = 'span';
}
return "<{$tag}{$hrefAttr} class=\"avatar avatar--{$size}{$updateLinkClass}{$class}\" data-user-id=\"{$userId}\"{$xfInitAttr}{$unhandledAttrs}>
$innerContent $updateLink
</{$tag}>";
}
protected function getDynamicAvatarHtml($username, $innerClassHtml, array &$outerAttributes)
{
if ($username && $this->dynamicDefaultAvatars)
{
return $this->getDefaultAvatarHtml($username, $innerClassHtml, $outerAttributes);
}
else
{
return $this->getFallbackAvatarHtml($innerClassHtml, $outerAttributes);
}
}
protected function getDefaultAvatarHtml($username, $innerClassHtml, array &$outerAttributes)
{
$styling = $this->getDefaultAvatarStyling($username);
if (empty($outerAttributes['style']))
{
$outerAttributes['style'] = '';
}
else
{
$outerAttributes['style'] .= '; ';
}
$outerAttributes['style'] .= "background-color: $styling[bgColor]; color: $styling[color]";
if (empty($outerAttributes['class']))
{
$outerAttributes['class'] = '';
}
else
{
$outerAttributes['class'] .= ' ';
}
$outerAttributes['class'] .= 'avatar--default avatar--default--dynamic';
return '<span class="' . $innerClassHtml . '" role="img" aria-label="' . htmlspecialchars($username) . '">'
. $styling['innerContent'] . '</span>';
}
protected function getDefaultAvatarStyling($username)
{
if (!isset($this->avatarDefaultStylingCache[$username]))
{
$bytes = md5($username, true);
$r = dechex(round(5 * ord($bytes[0]) / 255) * 0x33);
$g = dechex(round(5 * ord($bytes[1]) / 255) * 0x33);
$b = dechex(round(5 * ord($bytes[2]) / 255) * 0x33);
$hexBgColor = sprintf('%02s%02s%02s', $r, $g, $b);
$hslBgColor = \XF\Util\Color::hexToHsl($hexBgColor);
$bgChanged = false;
if ($hslBgColor[1] > 60)
{
$hslBgColor[1] = 60;
$bgChanged = true;
}
else if ($hslBgColor[1] < 15)
{
$hslBgColor[1] = 15;
$bgChanged = true;
}
if ($hslBgColor[2] > 85)
{
$hslBgColor[2] = 85;
$bgChanged = true;
}
else if ($hslBgColor[2] < 15)
{
$hslBgColor[2] = 15;
$bgChanged = true;
}
if ($bgChanged)
{
$hexBgColor = \XF\Util\Color::hslToHex($hslBgColor);
}
$hslColor = \XF\Util\Color::darkenOrLightenHsl($hslBgColor, 35);
$hexColor = \XF\Util\Color::hslToHex($hslColor);
$bgColor = '#' . $hexBgColor;
$color = '#' . $hexColor;
if (preg_match($this->avatarLetterRegex, $username, $match))
{
$innerContent = htmlspecialchars(utf8_strtoupper($match[0]));
}
else
{
$innerContent = '?';
}
$this->avatarDefaultStylingCache[$username] = [
'bgColor' => $bgColor,
'color' => $color,
'innerContent' => $innerContent
];
}
return $this->avatarDefaultStylingCache[$username];
}
protected function getFallbackAvatarHtml($innerClassHtml, array &$outerAttributes)
{
if (empty($outerAttributes['class']))
{
$outerAttributes['class'] = '';
}
else
{
$outerAttributes['class'] .= ' ';
}
$fallbackType = $this->style->getProperty('avatarDefaultType', 'text');
$outerAttributes['class'] .= 'avatar--default avatar--default--' . $fallbackType;
return '<span class="' . $innerClassHtml . '"></span>';
}
public function fnBaseUrl($templater, &$escape, $url = null, $full = false)
{
$pather = $this->pather;
if ($full === true)
{
$modifier = 'full';
}
else if (is_string($full))
{
$modifier = $full;
}
else
{
$modifier = 'base';
}
return $pather($url ?: '', $modifier);
}
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 fnBbCodeSnippet($templater, &$escape, $bbCode, $context, $content, $maxLength, array $options = [], $type = 'html')
{
$bbCodeContainer = $this->app->bbCode();
$parser = $bbCodeContainer->parser();
$rules = $bbCodeContainer->rules($context);
$cleaner = $bbCodeContainer->renderer('bbCodeClean');
$formatter = $this->app->stringFormatter();
$snippet = $cleaner->render($formatter->wholeWordTrimBbCode($bbCode, $maxLength), $parser, $rules);
return $this->fnBbCode($templater, $escape, $snippet, $context, $content, $options, $type);
}
public function fnBbCodeType($templater, &$escape, $type, $bbCode, $context, $content, array $options = [])
{
return $this->fnBbCode($templater, $escape, $bbCode, $context, $content, $options, $type);
}
public function fnBbCodeTypeSnippet($templater, &$escape, $type, $bbCode, $context, $content, $maxLength, array $options = [])
{
return $this->fnBbCodeSnippet($templater, $escape, $bbCode, $context, $content, $maxLength, $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 fnCacheKey($templater, &$escape)
{
return \XF::visitor()->getClientSideCacheKey();
}
public function fnCallMacro($templater, &$escape, $template, $name, array $arguments = [])
{
if (count(func_get_args()) < 5)
{
$arguments = $name;
$name = $template;
$template = null;
}
$escape = false;
return $this->renderMacro($template, $name, $arguments);
}
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, $forceVisible = false)
{
if (!$force && !\XF::visitor()->isShownCaptcha())
{
return '';
}
$captcha = $this->app->captcha(null);
if ($captcha)
{
$escape = false;
$captcha->setForceVisible($forceVisible);
return $captcha->render($templater);
}
return '';
}
public function fnCopyright($templater, &$escape)
{
$escape = false;
return ($this->app instanceof \XF\Admin\App ? \XF::getCopyrightHtmlAcp() : \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.aspnetcdn.com/ajax/jquery/jquery-{$jqVersion}{$jqMin}.js";
break;
}
}
if ($jqRemote)
{
$output = '<script src="' . htmlspecialchars($jqRemote) . '"></script>'
. '<script>window.jQuery || document.write(\'<script src="'
. \XF::escapeString($jqLocal, 'htmljs') . '"><\\/script>\')</script>';
}
else
{
$output = '<script src="' . htmlspecialchars($jqLocal) . '"></script>';
}
$files = [
'vendor/vendor-compiled.js'
];
if ($this->app['config']['development']['fullJs'])
{
$files[] = 'xf/core.js';
foreach (glob(\XF::getRootDirectory() . '/js/xf/core/*.js') AS $file)
{
if (substr($file, -7) == '.min.js')
{
continue;
}
$files[] = 'xf/core/' . basename($file);
}
}
else
{
$files[] = 'xf/core-compiled.js';
}
foreach ($files AS $file)
{
$output .= "\n\t<script src=\"" . htmlspecialchars($this->getJsUrl($file)) . '"></script>';
}
$escape = false;
return $output;
}
public function fnCount($templater, &$escape, $value)
{
if (is_array($value) || $value instanceof \Countable)
{
return count($value);
}
return null;
}
public function fnCsrfInput($templater, &$escape)
{
$escape = false;
return '<input type="hidden" name="_xfToken" value="' . htmlspecialchars($this->app['csrf.token']) . '" />';
}
public function fnCsrfToken($templater, &$escape)
{
return $this->app['csrf.token'];
}
public function fnCssUrl($templater, &$escape, array $templates, $includeValidation = true)
{
return $this->getCssLoadUrl($templates, $includeValidation);
}
public function fnDate($templater, &$escape, $date, $format = null)
{
if (is_integer($format) || $format instanceof \DateTime)
{
// allow date($format, $date) in templates, to match PHP date() syntax
$tmp = $format;
$format = $date;
$date = $tmp;
}
return $this->language->date($date, $format);
}
public function fnDateFromFormat($templater, &$escape, $format, $dateString, $timeZone = null)
{
return \DateTime::createFromFormat($format, $dateString, $timeZone === null
? $this->language->getTimezone()
: new \DateTimeZone($timeZone));
}
public function fnDateDynamic($templater, &$escape, $dateTime, array $attributes = [])
{
if (!($dateTime instanceof \DateTime))
{
$ts = intval($dateTime);
$dateTime = new \DateTime();
$dateTime->setTimestamp($ts);
$dateTime->setTimezone($this->language->getTimeZone());
}
else
{
$ts = $dateTime->getTimestamp();
}
list($date, $time) = $this->language->getDateTimeParts($ts);
$full = $this->language->getDateTimeOutput($date, $time);
$relative = $this->language->getRelativeDateTimeOutput($ts, $date, $time, !empty($attributes['data-full-date']));
$class = $this->processAttributeToHtmlAttribute($attributes, 'class', 'u-dt', true);
unset($attributes['title']);
$unhandledAttrs = $this->processUnhandledAttributes($attributes);
$escape = false;
return '<time ' . $class . ' dir="auto" datetime="' . $dateTime->format(\DateTime::ISO8601)
. '" data-time="' . $ts
. '" data-date-string="' . htmlspecialchars($date)
. '" data-time-string="' . htmlspecialchars($time)
. '" title="' . htmlspecialchars($full)
. '"' . $unhandledAttrs . '>' . htmlspecialchars($relative) . '</time>';
}
public function fnDateTime($templater, &$escape, $date)
{
return $this->language->dateTime($date);
}
public function fnDebugUrl($templater, &$escape, $url = null)
{
if (!$url)
{
$url = $this->app->request()->getRequestUri();
}
if (strpos($url, '?') === false)
{
$url .= '?';
}
else
{
$url .= '&';
}
return $url . '_debug=1';
}
public function fnDump($templater, &$escape, $value)
{
$escape = false;
ob_start();
\XF::dump($value);
$dump = ob_get_clean();
return $dump;
}
public function fnDumpSimple($templater, &$escape, $value)
{
$escape = false;
return \XF::dumpSimple($value, false);
}
public function fnDuration($templater, &$escape, $number, $units)
{
switch ($units)
{
case 'years':
case 'months':
case 'weeks':
case 'days':
{
return $this->language->phrase("x_{$units}", [$units => $number]);
}
case 'hours':
case 'minutes':
case 'seconds':
{
return $this->language->phrase("x_{$units}", ['count' => $number]);
}
default:
{
return "{$number} {$units}";
}
}
}
public function fnEmpty($templater, &$escape, $value)
{
return empty($value);
}
public function fnFaWeight($templater, &$escape = false)
{
$faWeight = $this->fnProperty($templater, $escape, 'fontAwesomeWeight', 400);
if ($faWeight <= 300)
{
return 'l';
}
else if ($faWeight <= 400)
{
return 'r';
}
else if ($faWeight <= 900)
{
return 's';
}
else
{
return 'r';
}
}
public function fnDisplayTotals($templater, &$escape, $count, $total = null)
{
if (is_array($count) || $count instanceof \Countable)
{
$count = count($count);
}
if ($total === null)
{
$total = $count;
}
else if (is_array($total) || $total instanceof \Countable)
{
$total = count($total);
}
$params = [
'count' => $this->language->numberFormat($count),
'total' => $this->language->numberFormat($total)
];
if ($count < 1)
{
$phrase = 'no_items_to_display';
}
else if ($count == $total)
{
$phrase = 'showing_all_items';
}
else
{
$phrase = 'showing_x_of_y_items';
}
$escape = false;
return '<span class="js-displayTotals" data-count="' . $count . '" data-total="' . $total . '"'
. ' data-xf-init="tooltip" title="' . $this->filterForAttr($this, $this->phrase('there_are_x_items_in_total', ['total' => $params['total']]), $null) . '">'
. $this->phrase($phrase, $params) . '</span>';
}
public function fnFileSize($templater, &$escape, $number)
{
return $this->language->fileSizeFormat($number);
}
public function fnCeil($templater, &$escape, $value)
{
return ceil($value);
}
public function fnFloor($templater, &$escape, $value)
{
return floor($value);
}
public function fnGravatarUrl($templater, &$escape, $user, $size)
{
if ($user instanceof \XF\Entity\User)
{
return $user->getGravatarUrl($size);
}
else
{
return '';
}
}
public function fnHighlight($templater, &$escape, $string, $term, $class = 'textHighlight')
{
$escape = false;
return $this->app->stringFormatter()->highlightTermForHtml($string, $term, $class);
}
public function fnKeyExists($templater, &$escape, $array, $key)
{
if (!is_array($array))
{
return false;
}
return array_key_exists($key, $array);
}
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 fnIsScalar($templater, &$escape, $value)
{
$escape = false;
return is_scalar($value);
}
public function fnIsAddonActive($templater, &$escape, $addOnId, $versionId = null, $operator = '>=')
{
return \XF::isAddOnActive($addOnId, $versionId, $operator);
}
public function fnIsEditorCapable($templater, &$escape)
{
if (!\XF::visitor()->canUseRte())
{
return false;
}
$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 fnIsChanged($templater, &$escape, $entity, $key)
{
if ($entity instanceof Entity && $entity->isChanged($key))
{
return true;
}
return false;
}
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' => $this->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' => $this->phrase('likes.deleted_user')
];
}
}
}
if ($liked)
{
$visitorId = \XF::visitor()->user_id;
foreach ($users AS $key => $user)
{
if ($user['user_id'] == $visitorId)
{
unset($users[$key]);
break;
}
}
$users = array_values($users);
if (count($users) == 3)
{
unset($users[2]);
}
}
$user1 = $user2 = $user3 = '';
if (isset($users[0]))
{
$user1 = $this->preEscaped('<bdi>' . \XF::escapeString($users[0]['username']) . '</bdi>', 'html');
if (isset($users[1]))
{
$user2 = $this->preEscaped('<bdi>' . \XF::escapeString($users[1]['username']) . '</bdi>', 'html');
if (isset($users[2]))
{
$user3 = $this->preEscaped('<bdi>' . \XF::escapeString($users[2]['username']) . '</bdi>', 'html');
}
}
}
switch ($count)
{
case 1: $phrase = ($liked ? 'likes.you' : 'likes.user1'); break;
case 2: $phrase = ($liked ? 'likes.you_and_user1' : 'likes.user1_and_user2'); break;
case 3: $phrase = ($liked ? 'likes.you_user1_and_user2' : 'likes.user1_user2_and_user3'); break;
case 4: $phrase = ($liked ? 'likes.you_user1_user2_and_1_other' : 'likes.user1_user2_user3_and_1_other'); break;
default: $phrase = ($liked ? 'likes.you_user1_user2_and_x_others' : 'likes.user1_user2_user3_and_x_others'); break;
}
$params = [
'user1' => $user1,
'user2' => $user2,
'user3' => $user3,
'others' => $this->language->numberFormat($count - 3)
];
return $this->renderTemplate('public:like_list_row', [
'url' => $url,
'likes' => $this->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->func('likes', [$count, $users, $liked, $url, $attributes], false);
}
public function fnLink($templater, &$escape, $link, $data = null, array $params = [], $hash = null)
{
return $this->getRouter()->buildLink($link, $data, $params, $hash);
}
public function fnLinkType($templater, &$escape, $type, $link, $data = null, array $params = [], $hash = null)
{
$container = $this->app->container();
/** @var \XF\Mvc\Router|null $router */
$router = $container['router.' . $type] ?? null;
if ($router)
{
return $router->buildLink($link, $data, $params, $hash);
}
else
{
return '';
}
}
public function fnMin($templater, &$escape, ...$args)
{
return min(...$args);
}
public function fnMax($templater, &$escape, ...$args)
{
return max(...$args);
}
public function fnMaxLength($templater, &$escape, $entity, $column)
{
static $entityCache = [];
// if $entity is not an entity, expect an entity id string like XF:Thread
if (is_string($entity) && preg_match('/^\w+(?:\\\\\w+)?:\w+$/i', $entity))
{
if (!isset($entityCache[$entity]))
{
$entityCache[$entity] = $this->app->em()->create($entity);
}
$entity = $entityCache[$entity];
}
if ($entity instanceof \XF\Mvc\Entity\Entity)
{
$maxlength = $entity->getMaxLength($column);
return $maxlength > 0 ? $maxlength : null;
}
else
{
return null;
}
}
public function fnMediaSites($templater, &$escape)
{
$output = [];
foreach ($this->mediaSites AS $site)
{
if (!$site['supported'])
{
continue;
}
if ($site['site_url'])
{
$output[] = '<a href="' . htmlspecialchars($site['site_url']) . '" target="_blank" rel="nofollow" dir="auto">' . htmlspecialchars($site['site_title']) . '</a>';
}
else
{
$output[] = htmlspecialchars($site['site_title']);
}
}
$escape = false;
return implode(', ', $output);
}
public function fnMustache($templater, &$escape, $name, $inner = null)
{
$escape = false;
$var = '{{' . $name . '}}';
if ($inner === null)
{
return $var;
}
else
{
$close = '{{/' . substr($name, 1) . '}}';
return "{$var}{$inner}{$close}";
}
}
public function fnNumber($templater, &$escape, $number, $precision = 0)
{
return $this->language->numberFormat($number, $precision);
}
public function fnNumberShort($templater, &$escape, $number, $precision = 0)
{
return $this->language->shortNumberFormat($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' => [],
'hash' => null,
'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';
}
if (
$config['hash']
&& is_string($config['hash'])
&& preg_match('/^(>|>=|<|<=|=)(\d+):([^, ]+)$/', $config['hash'], $hashMatch)
)
{
$operator = $hashMatch[1];
$testPage = intval($hashMatch[2]);
$outputHash = $hashMatch[3];
$config['hash'] = function($link, $data, array $parameters) use ($operator, $testPage, $outputHash)
{
$page = $parameters['page'] ?? null;
if (!$page)
{
$page = 1;
}
if ($page === '%page%')
{
// can't do conditional hashes with placeholders
return '';
}
switch ($operator)
{
case '>': $matched = ($page > $testPage); break;
case '>=': $matched = ($page >= $testPage); break;
case '<': $matched = ($page < $testPage); break;
case '<=': $matched = ($page <= $testPage); break;
case '=': $matched = ($page == $testPage); break;
default: return '';
}
return $matched ? $outputHash : '';
};
}
$router = $this->getRouter();
$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],
$config['hash']
);
if (!isset($this->pageParams['head']['prev']))
{
$this->pageParams['head']['prev'] = $this->preEscaped('<link rel="prev" href="' . \XF::escapeString($prev) . '" />');
}
}
$next = false;
if ($current < $totalPages)
{
$next = $router->buildLink(
$config['link'],
$config['data'],
$config['params'] + [$config['pageParam'] => $current + 1],
$config['hash']
);
if (!isset($this->pageParams['head']['next']))
{
$this->pageParams['head']['next'] = $this->preEscaped('<link rel="next" href="' . \XF::escapeString($next) . '" />');
}
}
$html = $this->renderTemplate($config['template'], [
'prev' => $prev,
'current' => $current,
'next' => $next,
'perPage' => $perPage,
'total' => $total,
'totalPages' => $totalPages,
'innerPages' => $innerPages,
'startInner' => $startInner,
'endInner' => $endInner,
'pageParam' => $config['pageParam'],
'link' => $config['link'],
'data' => $config['data'],
'params' => $config['params'],
'hash' => $config['hash'],
'variantClass' => $config['variantClass']
]);
if ($wrapper)
{
$wrapperOpen = $wrapper . ($wrapperClass ? " class=\"$wrapperClass\"" : '');
$html = "<{$wrapperOpen}>{$html}</{$wrapper}>";
}
return $html;
}
public function fnPageParam($templater, &$escape, string $name)
{
if (strpos($name, '.') === false)
{
return $this->pageParams[$name] ?? null;
}
$ref = $this->pageParams;
$hasValid = false;
foreach (explode('.', $name) AS $part)
{
if (!strlen($part))
{
continue;
}
if (!is_array($ref))
{
return null;
}
if (!isset($ref[$part]))
{
return null;
}
$ref = $ref[$part];
$hasValid = true;
}
return $hasValid ? $ref : null;
}
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;
}
}
public function fnParens($templater, &$escape, $value)
{
return $this->filterParens($templater, $value, $escape);
}
public function fnParseLessColor($templater, &$escape, $value)
{
if (!is_string($value))
{
return $value;
}
// normalize color to its rgb components (TODO: support alpha channel in future)
$rgbColor = \XF\Util\Color::colorToRgb($value);
if ($rgbColor)
{
// already a valid color so convert to hex for compatibility and use as-is.
$hex = \XF\Util\Color::rgbToHex($rgbColor);
return '#' . $hex;
}
/** @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 fnPhraseDynamic($templater, &$escape, $phraseName, array $params = [])
{
$phrase = $this->phrase($phraseName, $params);
return $phrase->render();
}
public function fnPrefix($templater, &$escape, $contentType, $prefixId, $format = 'html', $append = null)
{
if ($prefixId instanceof Entity)
{
$prefixId = $prefixId->prefix_id;
}
if (!$prefixId)
{
return '';
}
$prefixCache = $this->app->container('prefixes.' . $contentType);
$prefixClass = $prefixCache[$prefixId] ?? null;
if (!$prefixClass)
{
return '';
}
$output = $this->func('prefix_title', [$contentType, $prefixId], false);
switch ($format)
{
case 'html':
$output = '<span class="' . htmlspecialchars($prefixClass) . '" dir="auto">'
. \XF::escapeString($output, 'html') . '</span>';
if ($append === null)
{
$append = '<span class="label-append"> </span>';
}
break;
case 'plain':
if ($output instanceof \XF\Phrase)
{
$output = $output->render('raw');
}
break; // ok as is
default:
$output = \XF::escapeString($output, 'html'); // just be safe and escape everything else
}
if ($append === null)
{
$append = ' - ';
}
$escape = false;
return $output . $append;
}
public function fnPrefixGroup($templater, &$escape, $contentType, $groupId)
{
if ($groupId == 0)
{
return '(' . $this->phrase('ungrouped') . ')';
}
return $this->phrase(sprintf('%s_prefix_%s.%d', $contentType, 'group', $groupId), [], false);
}
public function fnPrefixTitle($templater, &$escape, $contentType, $prefixId)
{
return $this->getPrefixPhrase($contentType, $prefixId, 'title');
}
public function fnPrefixDescription($templater, &$escape, $contentType, $prefixId)
{
return $this->getPrefixPhrase($contentType, $prefixId, 'desc');
}
public function fnPrefixUsageHelp($templater, &$escape, $contentType, $prefixId)
{
return $this->getPrefixPhrase($contentType, $prefixId, 'help');
}
protected function getPrefixPhrase($contentType, $prefixId, $phraseType)
{
if (!$prefixId)
{
return '';
}
$prefixCache = $this->app->container('prefixes.' . $contentType);
$prefixClass = $prefixCache[$prefixId] ?? null;
if (!$prefixClass)
{
return '';
}
switch ($phraseType)
{
case 'desc':
case 'help':
// these allow HTML but also fallback to empty
$phrase = $this->phrase(sprintf('%s_prefix_%s.%d', $contentType, $phraseType, $prefixId), []);
$phrase->fallback('');
return $phrase;
case 'title':
default:
return $this->phrase(sprintf('%s_prefix.%d', $contentType, $prefixId), [], false);
}
}
public function fnProfileBanner($templater, &$escape, $user, $sizeCode, $canonical = false, $attributes = [], $contentHtml = '')
{
$escape = false;
$class = $this->processAttributeToRaw($attributes, 'class', ' %s', true);
$style = $this->processAttributeToRaw($attributes, 'style', '%s', true);
$toggleClass = $this->processAttributeToRaw($attributes, 'toggle', '%s', true);
$href = $this->processAttributeToRaw($attributes, 'href', '%s', true);
$overlay = $this->processAttributeToRaw($attributes, 'overlay', '%s', true);
$hide = $this->processAttributeToRaw($attributes, 'hideempty', '%s', true);
$sizeCode = preg_replace('#[^a-zA-Z0-9_-]#s', '', $sizeCode);
$bannerUrl = null;
if ($user instanceof \XF\Entity\User)
{
$bannerUrl = $user->Profile->getBannerUrl($sizeCode, $canonical);
$class .= " memberProfileBanner-u{$user->user_id}-{$sizeCode}";
}
if ($hide)
{
$hide = 'data-hide-empty="true"';
if (!$bannerUrl)
{
$class .= ' memberProfileBanner--empty';
}
}
$styleAttr = '';
if ($style || $bannerUrl)
{
$styleAttr = 'style="';
if ($style)
{
$styleAttr .= rtrim($style, ';') . '; ';
}
if ($bannerUrl)
{
$styleAttr .= 'background-image: url(' . $bannerUrl . ');';
if ($user->Profile->banner_position_y !== null)
{
$styleAttr .= ' background-position-y: ' . $user->Profile->banner_position_y . '%;';
}
}
$styleAttr .= '"';
}
$link = '';
if ($href)
{
if ($overlay)
{
$overlay = ' data-xf-click="overlay"';
}
$class .= ' fauxBlockLink';
$link = "<a href=\"$href\" class=\"fauxBlockLink-blockLink\" {$overlay}></a>";
}
$unhandledAttrs = $this->processUnhandledAttributes($attributes);
return "
<div class=\"memberProfileBanner{$class}\" data-toggle-class=\"{$toggleClass}\" {$hide} {$styleAttr}{$unhandledAttrs}>{$link}{$contentHtml}</div>
";
}
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 fnReact($templater, &$escape, array $config)
{
$escape = false;
$config = array_merge([
'template' => $this->applyDefaultTemplateType('react'),
'list' => null,
'content' => null,
'link' => '',
'params' => [],
'class' => 'actionBar-action actionBar-action--reaction'
], $config);
/** @var \XF\Entity\ReactionTrait $content */
$content = $config['content'];
// note: this is quicker and easier than using class_uses which only lists traits on the direct parent
if (!method_exists($content, 'canReact'))
{
trigger_error("React content must be using the XF\Entity\ReactionTrait trait.", E_USER_WARNING);
return '';
}
if (!$content->canReact())
{
return '';
}
$reactionContent = $content->getReactionContent();
$hasReaction = $reactionContent ? true : false;
$reaction = $reactionContent ?: $this->app->container('reactionDefault');
$config['params']['reaction_id'] = $reaction['reaction_id'];
$html = $this->renderTemplate($config['template'], [
'link' => $config['link'],
'content' => $content,
'params' => $config['params'],
'class' => $config['class'],
'list' => $config['list'],
'hasReaction' => $hasReaction,
'reaction' => $reaction
]);
return $html;
}
public function fnAlertReaction($templater, &$escape, $reactionId, $size = 'small')
{
return $this->func('reaction', [
[
'id' => $reactionId,
'showtitle' => true,
$size => true,
'hasreaction' => true
]
], $escape);
}
public function fnReaction($templater, &$escape, array $config)
{
$escape = false;
$baseConfig = [
'id' => null,
'class' => '',
'content' => null,
'link' => '',
'params' => [],
'list' => null,
'hasreaction' => false,
'init' => false,
'showtitle' => false,
'appendtitle' => '',
'small' => false,
'medium' => false,
'tooltip' => false,
'routerType' => 'public'
];
$config = array_replace($baseConfig, $config);
$hasReaction = $config['hasreaction'];
if (is_string($config['hasreaction']) && $config['hasreaction'] == 'false')
{
$hasReaction = false;
}
$reactionId = $config['id'];
if (!is_int($reactionId))
{
$reactionId = $reactionId['reaction_id'];
}
if (!$reactionId)
{
return '';
}
$reactionCache = $this->app->container('reactions');
if (!isset($reactionCache[$reactionId]))
{
return '';
}
$reaction = $reactionCache[$reactionId];
$reactionTitle = htmlspecialchars($this->func('reaction_title', [$reaction]));
$pather = $this->pather;
$tooltip = '';
if ($config['tooltip'])
{
$tooltip = ' data-xf-init="tooltip" data-extra-class="tooltip--basic tooltip--noninteractive"';
}
$html = '<i aria-hidden="true"></i>';
if (empty($reaction['sprite_params']))
{
$url = htmlspecialchars($pather ? $pather($reaction['image_url'], 'base') : $reaction['image_url']);
$srcSet = '';
if (!empty($reaction['image_url_2x']))
{
$url2x = htmlspecialchars($pather ? $pather($reaction['image_url_2x'], 'base') : $reaction['image_url_2x']);
$srcSet = 'srcset="' . $url2x . ' 2x"';
}
$html .= '<img src="' . $url . '" ' . $srcSet . ' class="reaction-image js-reaction" alt="' . $reactionTitle . '" title="' . $reactionTitle . '"' . $tooltip . ' />';
}
else
{
// embed a data URI to avoid a request that doesn't respect paths fully
$html .= '<img src="' . self::TRANSPARENT_IMG_URI . '" class="reaction-sprite js-reaction" alt="' . $reactionTitle . '" title="' . $reactionTitle . '"' . $tooltip . ' />';
}
if ($config['showtitle'])
{
$displayTitle = '<bdi>' . $reactionTitle . '</bdi>';
if ($config['appendtitle'])
{
$displayTitle .= ' ' . $config['appendtitle'];
}
$html .= ' <span class="reaction-text js-reactionText">' . $displayTitle . '</span>';
}
$init = '';
if ($config['init'])
{
$init = ' data-xf-init="reaction"';
if ($config['list'])
{
$init .= ' data-reaction-list="' . $config['list'] . '"';
}
}
$unhandledAttrs = $this->processUnhandledAttributes(array_diff_key($config, $baseConfig));
$tag = 'span';
$href = '';
if ($config['link'])
{
if (is_array($config['link']))
{
$link = $config['link'];
$config['link'] = $link[0];
if (!$config['params'])
{
$config['params'] = $link[1];
}
}
$tag = 'a';
$href = $this->app->router($config['routerType'])->buildLink(
$config['link'], $config['content'], $config['params']
);
}
if ($config['tooltip'] && !$config['link'])
{
$tag = 'a';
$href = '#';
}
return '<' . $tag . ($href ? ' href="' . $href . '"' : '')
. $unhandledAttrs
. ' class="reaction' . ($config['small'] ? ' reaction--small' : '') . ($config['medium'] ? ' reaction--medium' : '') . ($config['class'] ? ' ' . $config['class'] : '') . ($hasReaction ? ' has-reaction' : '') . (!$hasReaction && $config['init'] ? ' reaction--imageHidden' : '') . ' reaction--' . $reactionId . '"'
. ' data-reaction-id="' . $reactionId . '"' . $init . '>' . $html . '</' . $tag . '>';
}
public function fnReactionTitle($templater, &$escape, $reactionId)
{
if (is_array($reactionId))
{
if (isset($reactionId['reaction_id']))
{
$reactionId = $reactionId['reaction_id'];
}
else
{
return '';
}
}
return $this->phrase('reaction_title.' . $reactionId);
}
/**
* @param $templater
* @param $escape
* @param Entity|\XF\Entity\ReactionTrait $content
* @param $link
* @param array $linkParams
*
* @return string
*/
public function fnReactions($templater, &$escape, $content, $link, array $linkParams = [])
{
if (!($content instanceof Entity))
{
trigger_error("Content for reactions is not an entity", E_USER_WARNING);
return '';
}
$escape = false;
$counts = $content->reactions;
$users = $content->reaction_users;
$reactionContent = null;
$userId = \XF::visitor()->user_id;
if ($userId)
{
$reactionContent = $content->getReactionContent();
}
$reacted = $reactionContent ? true : false;
$reactionDefault = $this->app->container('reactionDefault');
if (is_array($counts))
{
if (!$counts)
{
return '';
}
}
else
{
// legacy format, likes only, change format pointing at default reaction
$count = intval($counts);
if ($count <= 0)
{
return '';
}
$counts = [
$reactionDefault['reaction_id'] => $count
];
}
if (is_array($link))
{
$tempLink = $link;
$link = $tempLink[0];
if (!$linkParams)
{
$linkParams = $tempLink[1];
}
}
$total = array_sum($counts);
$reactionIds = array_slice(array_keys($counts), 0, 3); // TODO: Make top x configurable?
if (!$users || !is_array($users))
{
$phrase = ($total > 1 ? 'reactions.x_people' : 'reactions.1_person');
return $this->renderTemplate('public:reaction_list_row', [
'content' => $content,
'link' => $link,
'linkParams' => $linkParams,
'reactionIds' => $reactionIds,
'reactions' => $this->phrase($phrase, ['reactions' => $this->language->numberFormat($total)])
]);
}
$userCount = count($users);
if ($userCount < 5 && $total > $userCount) // indicates some users are deleted
{
for ($i = 0; $i < $total; $i++)
{
if (empty($users[$i]))
{
$users[$i] = [
'user_id' => 0,
'username' => $this->phrase('reactions.deleted_user')
];
}
}
}
if ($reacted)
{
$visitorId = \XF::visitor()->user_id;
foreach ($users AS $key => $user)
{
if ($user['user_id'] == $visitorId)
{
unset($users[$key]);
break;
}
}
$users = array_values($users);
if (count($users) == 3)
{
unset($users[2]);
}
}
$user1 = $user2 = $user3 = '';
if (isset($users[0]))
{
$user1 = $this->preEscaped('<bdi>' . \XF::escapeString($users[0]['username']) . '</bdi>', 'html');
if (isset($users[1]))
{
$user2 = $this->preEscaped('<bdi>' . \XF::escapeString($users[1]['username']) . '</bdi>', 'html');
if (isset($users[2]))
{
$user3 = $this->preEscaped('<bdi>' . \XF::escapeString($users[2]['username']) . '</bdi>', 'html');
}
}
}
switch ($total)
{
case 1: $phrase = ($reacted ? 'reactions.you' : 'reactions.user1'); break;
case 2: $phrase = ($reacted ? 'reactions.you_and_user1' : 'reactions.user1_and_user2'); break;
case 3: $phrase = ($reacted ? 'reactions.you_user1_and_user2' : 'reactions.user1_user2_and_user3'); break;
case 4: $phrase = ($reacted ? 'reactions.you_user1_user2_and_1_other' : 'reactions.user1_user2_user3_and_1_other'); break;
default: $phrase = ($reacted ? 'reactions.you_user1_user2_and_x_others' : 'reactions.user1_user2_user3_and_x_others'); break;
}
$params = [
'user1' => $user1,
'user2' => $user2,
'user3' => $user3,
'others' => $this->language->numberFormat($total - 3)
];
return $this->renderTemplate('public:reaction_list_row', [
'content' => $content,
'link' => $link,
'linkParams' => $linkParams,
'reactionIds' => $reactionIds,
'reactions' => $this->phrase($phrase, $params)
]);
}
public function fnReactionsSummary($templater, &$escape, $reactions)
{
$escape = false;
if (!$reactions)
{
return '';
}
$reactionsCache = $this->app->container('reactions');
foreach ($reactions AS $reactionId => $count)
{
if (!isset($reactionsCache[$reactionId]) || !$reactionsCache[$reactionId]['active'])
{
unset($reactions[$reactionId]);
continue;
}
}
$reactionIds = array_slice(array_keys($reactions), 0, 3); // TODO: Make top x configurable?
return $this->renderTemplate('public:reactions_summary', ['reactionIds' => $reactionIds]);
}
public function fnRedirectInput($templater, &$escape, $url = null, $fallbackUrl = null, $useReferrer = true)
{
$escape = false;
if ($url)
{
$redirect = $this->app->request()->convertToAbsoluteUri($url);
}
else
{
$redirect = $this->app->getDynamicRedirect($fallbackUrl ?: null, (bool)$useReferrer);
}
return '<input type="hidden" name="_xfRedirect" value="' . htmlspecialchars($redirect) . '" />';
}
public function fnRepeat($templater, &$escape, $string, $count)
{
return str_repeat($string, $count);
}
public function fnRepeatRaw($templater, &$escape, $string, $count)
{
$escape = false;
return str_repeat($string, $count);
}
public function fnShortToEmoji($templater, &$escape, $string, $forceStyle = null, $forceCdn = false)
{
$escape = false;
$formatter = $this->app->stringFormatter();
$emoji = $formatter->getEmojiFormatter($forceStyle, $forceCdn);
return $emoji->formatShortnameToImage($string);
}
public function fnShowIgnored($templater, &$escape, array $attributes = [])
{
$escape = false;
if (!\XF::visitor()->user_id)
{
return '';
}
$wrapperClass = $this->processAttributeToRaw($attributes, 'wrapperclass', '', true);
$wrapper = $this->processAttributeToRaw($attributes, 'wrapper');
if ($wrapperClass && !$wrapper)
{
$wrapper = 'div';
}
$class = $this->processAttributeToRaw($attributes, 'class', ' %s', true);
$unhandledAttrs = $this->processUnhandledAttributes($attributes);
$html = '<a href="javascript:"'
. ' class="showIgnoredLink is-hidden js-showIgnored' . $class . '" data-xf-init="tooltip"'
. ' title="' . $this->filterForAttr($this, $this->phrase('show_hidden_content_by_x', ['names' => '{{names}}']), $null) . '"'
. ' ' . $unhandledAttrs . '>' .
$this->phrase('show_ignored_content')
. '</a>';
if ($wrapper)
{
$wrapperOpen = $wrapper . ($wrapperClass ? " class=\"$wrapperClass\"" : '');
$html = "<{$wrapperOpen}>{$html}</{$wrapper}>";
}
return $html;
}
public function fnSmilie($templater, &$escape, $smilieString)
{
$escape = false;
$formatter = $this->app->stringFormatter();
return $formatter->replaceSmiliesHtml($smilieString);
}
public function fnSnippet($templater, &$escape, $string, $maxLength = 0, array $options = [])
{
// if we aren't escaping here
$needsEscaping = ($escape ? true : false);
$escape = false;
$formatter = $this->app->stringFormatter();
$string = $formatter->snippetString($string, $maxLength, $options);
if (!empty($options['term']))
{
return $formatter->highlightTermForHtml(
$string, $options['term'], $options['highlightClass'] ?? 'textHighlight'
);
}
else
{
$returnString = $needsEscaping ? \XF::escapeString($string) : $string;
if (!empty($options['bbWrapper']))
{
return '<div class="bbWrapper">' . $returnString . '</div>';
}
return $returnString;
}
}
public function fnSprintf($templater, &$escape, $string, ...$args)
{
return sprintf($string, ...$args);
}
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)
{
$escape = false;
return $this->app->stringFormatter()->convertStructuredTextToHtml($string, $nl2br);
}
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 self::TRANSPARENT_IMG_URI;
}
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 '';
}
$activityDetail = null;
if ($user->canViewCurrentActivity() && $user->Activity)
{
if ($user->Activity->description)
{
$activityDetail = \XF::escapeString($user->Activity->description);
if ($user->Activity->item_title)
{
$title = \XF::escapeString($user->Activity->item_title);
$url = \XF::escapeString($user->Activity->item_url);
$activityDetail .= " <em><a href=\"{$url}\" dir=\"auto\">{$title}</a></em>";
}
if ($user->Activity->view_state == 'error' && \XF::visitor()->canBypassUserPrivacy())
{
$activityDetail .= ' <span role="presentation" aria-hidden="true">·</span> ';
$activityDetail .= '<i class="fa fa-exclamation-triangle u-muted" title="' . $this->filterForAttr($this,$this->phrase('viewing_an_error'), $null) . '" aria-hidden="true"></i>';
$activityDetail .= ' <span class="u-srOnly">' . $this->phrase('viewing_an_error') . '</span>';
}
}
}
$output = $this->fnDateDynamic($this, $escape, $user->last_activity);
if ($activityDetail)
{
$output .= ' <span role="presentation" aria-hidden="true">·</span> ' . $activityDetail;
}
$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 = $this->phrase('staff_member');
$banners['staff'] = "<{$tag} class=\"userBanner userBanner--staff{$class}\" dir=\"auto\"{$unhandledAttrs}>"
. "<span class=\"userBanner-before\"></span><strong>{$p}</strong><span class=\"userBanner-after\"></span></{$tag}>";
}
$memberGroupIds = $user->secondary_group_ids;
$memberGroupIds[] = $user->user_group_id;
foreach ($this->userBanners AS $groupId => $banner)
{
if (!in_array($groupId, $memberGroupIds))
{
continue;
}
$banners[$groupId] = "<{$tag} class=\"userBanner {$banner['class']}{$class}\"{$unhandledAttrs}>"
. "<span class=\"userBanner-before\"></span><strong>{$banner['text']}</strong><span class=\"userBanner-after\"></span></{$tag}>";
}
if (!$banners)
{
return '';
}
if (!empty($config['displayMultiple']))
{
return implode("\n", $banners);
}
else if (!empty($config['showStaffAndOther']) && isset($banners['staff']) && count($banners) >= 2)
{
$staffBanner = $banners['staff'];
unset($banners['staff']);
return $staffBanner . "\n" . reset($banners);
}
else
{
return reset($banners);
}
}
public function fnUserBlurb($templater, &$escape, $user, $attributes = [])
{
if (!$user instanceof \XF\Entity\User)
{
return '';
}
$blurbParts = [];
$userTitle = $this->fnUserTitle($this, $escape, $user);
if ($userTitle)
{
$blurbParts[] = $userTitle;
}
if ($user->Profile->age)
{
$blurbParts[] = $user->Profile->age;
}
if ($user->Profile->location)
{
$location = \XF::escapeString($user->Profile->location);
if (\XF::options()->geoLocationUrl)
{
$location = '<a href="' . $this->app->router('public')->buildLink('misc/location-info', null, ['location' => $location]) . '" class="u-concealed" target="_blank" rel="nofollow noreferrer">' . $location. '</a>';
}
$blurbParts[] = $this->phrase('from_x_location', ['location' => new \XF\PreEscaped($location)])->render();
}
$tag = $this->processAttributeToRaw($attributes, 'tag');
if (!$tag)
{
$tag = 'div';
}
$class = $this->processAttributeToRaw($attributes, 'class', '%s', true);
$unhandledAttrs = $this->processUnhandledAttributes($attributes);
return "<{$tag} class=\"{$class}\" dir=\"auto\" {$unhandledAttrs}>"
. implode(' <span role="presentation" aria-hidden="true">·</span> ', $blurbParts)
. "</{$tag}>";
}
public function fnUserTitle($templater, &$escape, $user, $withBanner = false, $attributes = [])
{
/** @var \XF\Entity\User $user */
$escape = false;
$userIsValid = ($user instanceof \XF\Entity\User);
$userTitle = null;
if ($userIsValid)
{
$customTitle = $user->custom_title;
if ($customTitle)
{
$userTitle = htmlspecialchars($customTitle);
}
}
if ($userTitle === null)
{
if ($withBanner && !empty($this->userBannerConfig['hideUserTitle']))
{
if (!$userIsValid)
{
return '';
}
if (!empty($this->userBannerConfig['showStaff']) && $user->is_staff)
{
return '';
}
if ($user->isMemberOf(array_keys($this->userBanners)))
{
return '';
}
}
if ($userIsValid)
{
$userTitle = $this->getDefaultUserTitleForUser($user);
}
else
{
$guestGroupId = \XF\Entity\User::GROUP_GUEST;
if (empty($this->groupStyles[$guestGroupId]['user_title']))
{
return '';
}
$userTitle = $this->groupStyles[$guestGroupId]['user_title'];
}
}
if ($userTitle === null || !strlen($userTitle))
{
return '';
}
$class = $this->processAttributeToRaw($attributes, 'class', ' %s', true);
if (!empty($attributes['tag']))
{
$tag = htmlspecialchars($attributes['tag']);
}
else
{
$tag = 'span';
}
unset($attributes['tag']);
$unhandledAttrs = $this->processUnhandledAttributes($attributes);
return "<{$tag} class=\"userTitle{$class}\" dir=\"auto\"{$unhandledAttrs}>{$userTitle}</{$tag}>";
}
public function getDefaultUserTitleForUser(\XF\Entity\User $user)
{
$groupId = $user->display_style_group_id;
if (!empty($this->groupStyles[$groupId]['user_title']))
{
return $this->groupStyles[$groupId]['user_title'];
}
else
{
foreach ($this->userTitleLadder AS $points => $title)
{
if ($user[$this->userTitleLadderField] >= $points)
{
return $title;
}
}
}
return null;
}
public function fnUsernameLink($templater, &$escape, $user, $rich = false, $attributes = [])
{
$escape = false;
if (isset($attributes['username']))
{
$username = $attributes['username'];
}
else if (isset($user['username']) && $user['username'] !== '')
{
$username = $user['username'];
}
else if (isset($attributes['defaultname']))
{
$username = $attributes['defaultname'];
}
else
{
return '';
}
$noTooltip = !empty($attributes['notooltip']);
if (isset($attributes['href']))
{
$href = $attributes['href'];
$noTooltip = true; // custom URL so tooltip won't work and might be misleading
}
else
{
$linkPath = $this->currentTemplateType == 'admin' ? 'users/edit' : 'members';
$href = !empty($user['user_id']) ? $this->getRouter()->buildLink($linkPath, $user) : null;
if (!$href || $this->currentTemplateType == 'admin')
{
$noTooltip = true;
}
}
$hrefAttr = $href ? ' href="' . htmlspecialchars($href) . '"' : '';
$class = $this->processAttributeToRaw($attributes, 'class', ' %s', true);
$usernameStylingClasses = $this->fnUsernameClasses($this, $null, $user, $rich);
$xfInit = $this->processAttributeToRaw($attributes, 'data-xf-init', '', true);
if (!$noTooltip)
{
$xfInit = ltrim("$xfInit member-tooltip");
}
$xfInitAttr = $xfInit ? " data-xf-init=\"$xfInit\"" : '';
unset($attributes['username'], $attributes['defaultname'], $attributes['href'], $attributes['notooltip']);
$unhandledAttrs = $this->processUnhandledAttributes($attributes);
$userId = !empty($user['user_id']) ? intval($user['user_id']) : 0;
$username = htmlspecialchars($username);
if ($usernameStylingClasses)
{
$username = "<span class=\"{$usernameStylingClasses}\">{$username}</span>";
}
if ($hrefAttr)
{
$tag = 'a';
}
else
{
$tag = 'span';
}
return "<{$tag}{$hrefAttr} class=\"username $class\" dir=\"auto\" data-user-id=\"{$userId}\"{$xfInitAttr}{$unhandledAttrs}>{$username}</{$tag}>";
}
public function fnUsernameLinkEmail($templater, &$escape, $user, $defaultName = '', array $attributes = [])
{
$escape = false;
if (isset($attributes['username']))
{
$username = $attributes['username'];
}
else if (isset($user['username']) && $user['username'] !== '')
{
$username = $user['username'];
}
else if ($defaultName !== '')
{
$username = $defaultName;
}
else
{
return '';
}
unset($attributes['username']);
if (isset($attributes['href']))
{
$href = $attributes['href'];
}
else
{
$href = !empty($user['user_id']) ? $this->getRouter()->buildLink('canonical:members', $user) : null;
}
$hrefAttr = $href ? ' href="' . htmlspecialchars($href) . '"' : '';
$tag = $href ? 'a' : 'span';
unset($attributes['username'], $attributes['href']);
$unhandledAttrs = $this->processUnhandledAttributes($attributes);
$username = htmlspecialchars($username);
return "<{$tag} dir=\"auto\"{$hrefAttr}{$unhandledAttrs}>{$username}</{$tag}>";
}
public function fnUsernameClasses($templater, &$escape, $user, $includeGroupStyling = true)
{
$classes = [];
if ($includeGroupStyling)
{
if (!$user || empty($user['user_id']))
{
$displayGroupId = \XF\Entity\User::GROUP_GUEST;
}
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;
}
}
$visitor = \XF::visitor();
if (!empty($user['is_banned']) && ($visitor->canBanUsers() || $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 filterCount($templater, $value, &$escape)
{
return $this->fnCount($templater, $escape, $value);
}
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 filterEmoji($templater, $value, &$escape)
{
$stringFormatter = $this->app->stringFormatter();
$value = \XF::escapeString($value, $escape);
$value = $stringFormatter->getEmojiFormatter()->formatEmojiToImage($value);
$escape = false;
return $value;
}
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
return $this->filterHtmlspecialchars($templater, $value, $escape);
}
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 filterFormat($templater, $value, &$escape, ...$args)
{
return sprintf($value, ...$args);
}
public function filterHex($templater, $value, &$escape)
{
return bin2hex($value);
}
public function filterHost($templater, $value, &$escape)
{
return \XF\Util\Ip::getHost($value);
}
public function filterHtmlspecialchars($templater, $value, &$escape)
{
$escape = false;
return htmlspecialchars(strval($value), ENT_QUOTES, 'UTF-8', false);
}
public function filterIp($templater, $value, &$escape)
{
return \XF\Util\Ip::convertIpBinaryToString($value);
}
public function filterJoin($templater, $value, &$escape, $join = ',')
{
if (!$this->isTraversable($value))
{
return '';
}
$parts = [];
foreach ($value AS $child)
{
$parts[] = $escape ? $this->escape($child, $escape) : $child;
}
$escape = false;
return implode($join, $parts);
}
public function filterJson($templater, $value, &$escape, $prettyPrint = false)
{
if ($prettyPrint)
{
$output = \XF\Util\Json::jsonEncodePretty($value, false);
// do limited slash escaping to improve readability
$output = str_replace('</', '<\\/', $output);
}
else
{
$output = json_encode($value);
}
$output = str_replace('<!', '\u003C!', $output);
return $output;
}
public function filterLast($templater, $value, &$escape)
{
if (is_array($value))
{
return end($value);
}
else if ($value instanceof AbstractCollection)
{
return $value->last();
}
else
{
return $value;
}
}
public function filterNl2Br($templater, $value, &$escape)
{
if ($escape)
{
$value = $this->escape($value, $escape);
}
$escape = false;
return nl2br($value);
}
public function filterNl2Nl($templater, $value, &$escape)
{
if ($escape)
{
$value = $this->escape($value, $escape);
}
$escape = false;
return str_replace('\n', "\n", $value ?? '');
}
public function filterNumber($templater, $value, &$escape, $precision = 0)
{
return $this->language->numberFormat($value, $precision);
}
public function filterNumberShort($templater, $value, &$escape, $precision = 0)
{
return $this->language->shortNumberFormat($value, $precision);
}
public function filterNumericKeysOnly($templater, $value, &$escape)
{
$escape = false;
if (!$this->isTraversable($value))
{
return [];
}
$output = [];
foreach ($value AS $k => $v)
{
if (is_int($k))
{
$output[$k] = $v;
}
}
return $output;
}
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] = $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 = Arr::stringToArray($value, '#\s*,\s*#', $limit);
break;
case 'nl':
$split = Arr::stringToArray($value, '/\r?\n/', $limit);
break;
default:
$split = @explode($delimiter, $value, $limit);
break;
}
if (!is_array($split))
{
$split = [];
}
return $split;
}
public function filterSplitLong($templater, $value, &$escape, $breakLength, $inserter = null)
{
return $this->app->stringFormatter()->splitLongWords($value, $breakLength, $inserter);
}
public function filterStripTags($templater, $value, &$escape, $allowableTags = null)
{
$isPhrase = $value instanceof \XF\Phrase;
$value = strip_tags($value, $allowableTags);
if ($isPhrase)
{
// When rendered, values in the phrase would have already been escaped.
// We can't render those raw as they might appear to be tags and get stripped,
// so we need to render with escaping, strip tags and then re-escape the output
// without double escaping in case the main phrase text had HTML characters.
$value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8', false);
$escape = false;
}
return $value;
}
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 filterDeCamel($templater, $value, &$escape, $glue = ' ')
{
return $this->app->stringFormatter()->fromCamelCase($value, $glue);
}
public function filterSubstr($templater, $value, &$escape, $start = null, $length = null)
{
if ($start === null)
{
return $value;
}
return utf8_substr($value, $start, $length);
}
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 '';
}
}
/**
* Pulls out the named attribute from the attribute list, removes it, and returns the value.
* Value will always be trimmed. May be formatted using a sprintf-style formatter or closure.
* Formatting will only happen if the value is non-empty. If escaping is enabled, the value will be escaped
* before being passed to the formatter.
*
* @param array $attributes
* @param string $name
* @param string|\Closure $formatter
* @param bool $escapeValue
*
* @return string
*/
public function processAttributeToRaw(array &$attributes, $name, $formatter = '', $escapeValue = false)
{
if (isset($attributes[$name]))
{
$value = trim(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;
}
/**
* Pulls out the named attribute if present, removes it from the attribute list, and returns the value.
* This does not do any trimming, escaping or formatting on the value.
*
* @param array $attributes
* @param string $name
*
* @return string
*/
public function processValueAttribute(array &$attributes, $name = 'value')
{
if (isset($attributes[$name]))
{
$value = strval($attributes[$name]);
}
else
{
$value = '';
}
unset($attributes[$name]);
return $value;
}
public function getAttributesAsString(array $attributes): string
{
return $this->processUnhandledAttributes($attributes);
}
protected function processUnhandledAttributes(array $attributes)
{
$output = '';
foreach ($attributes AS $name => $value)
{
if (is_array($value))
{
continue;
}
if ($value instanceof \XF\Phrase)
{
// strval will do escaping of the values or the whole phrase, so get the raw value and escape that here
$value = $value->render('raw');
}
else
{
$value = strval($value);
}
if ($value !== '')
{
$output .= " $name=\"" . \XF::escapeString($value) . "\"";
}
}
return $output;
}
protected function processDynamicAttributes(array &$attributes, array $skip = [])
{
if (!isset($attributes['attributes']))
{
return;
}
foreach ($attributes['attributes'] AS $key => $attribute)
{
if ($key == 'attributes' || isset($attributes[$key]) || isset($skip[$key]))
{
continue;
}
$attributes[$key] = $attribute;
}
unset($attributes['attributes']);
}
protected function handleChoices(array $choices, \Closure $choiceFormatter, \Closure $groupFormatter)
{
$html = '';
foreach ($choices AS $choice)
{
if (isset($choice['_type']))
{
$type = $choice['_type'];
}
else
{
$type = 'option';
}
unset($choice['_type']);
if ($type == 'optgroup')
{
$childHtml = $this->handleChoices($choice['options'], $choiceFormatter, $groupFormatter);
unset($choice['options']);
$html .= $groupFormatter($choice, $childHtml);
}
else
{
$dependent = !empty($choice['_dependent']) ? $choice['_dependent'] : [];
foreach ($dependent AS $key => &$val)
{
$val = trim($val);
if (!strlen($val))
{
unset($dependent[$key]);
}
}
unset($choice['_dependent']);
$html .= $choiceFormatter($choice, $dependent);
}
}
return $html;
}
public function isChoiceSelected(array $choice, $inputValue, $allowMultiple = false)
{
if (isset($choice['selected']))
{
return $choice['selected'];
}
if ($inputValue !== null)
{
$choiceValue = isset($choice['value']) ? strval($choice['value']) : '';
if (is_array($inputValue) && $allowMultiple)
{
return in_array($choiceValue, $inputValue);
}
else if (!is_array($inputValue))
{
return (
($inputValue === true && $choiceValue === '1')
|| ($inputValue === false && $choiceValue === '0')
|| (strval($inputValue) === $choiceValue)
);
}
}
return false;
}
public function formHiddenVal($name, $value, array $extraAttributes = [])
{
$this->processDynamicAttributes($extraAttributes);
$nameHtml = \XF::escapeString($name);
$valueHtml = \XF::escapeString($value);
$extraAttrs = $this->processUnhandledAttributes($extraAttributes);
return "<input type=\"hidden\" name=\"{$nameHtml}\" value=\"{$valueHtml}\"{$extraAttrs} />";
}
public function formCheckBox(array $controlOptions, array $choices)
{
$this->processDynamicAttributes($controlOptions);
$name = \XF::escapeString($this->processAttributeToRaw($controlOptions, 'name'));
if ($name && substr($name, -2) != '[]')
{
$name .= '[]';
}
$readOnly = $this->processAttributeToRaw($controlOptions, 'readonly');
$value = $controlOptions['value'] ?? null;
$standalone = ($this->processAttributeToRaw($controlOptions, 'standalone') && count($choices) == 1);
$choiceFormatter = function(array $choice, array $dependent) use ($name, $readOnly, $value, $standalone)
{
$selected = $this->isChoiceSelected($choice, $value, true);
if (!empty($choice['name']))
{
$localName = \XF::escapeString($choice['name']);
}
else
{
$localName = $name;
}
if ($localName)
{
$nameAttr = ' name="' . $localName . '"';
}
else
{
$nameAttr = '';
}
unset($choice['selected'], $choice['name'], $choice['type']);
$dependentHtml = '';
if ($dependent && !$standalone)
{
$dependentHtmlInner = '';
foreach ($dependent AS $child)
{
$dependentHtmlInner .= "\n\t\t\t\t<li class=\"inputChoices-option\">$child</li>";
}
$dependentHtml = "\n\t\t\t<ul class=\"inputChoices-dependencies\">{$dependentHtmlInner}\n\t\t\t</ul>\n\t\t";
}
if ($dependentHtml)
{
$this->addElementHandler($choice, 'disabler');
}
$label = trim($this->processAttributeToRaw($choice, 'label'));
$labelClass = 'iconic';
$labelClassExtra = $this->processAttributeToRaw($choice, 'labelclass', '', true);
if ($labelClassExtra !== '')
{
$labelClass .= " {$labelClassExtra}";
}
$hiddenLabel = $this->processAttributeToRaw($choice, 'hiddenlabel');
if ($label && $hiddenLabel != '')
{
$hiddenLabel = true;
}
else
{
$hiddenLabel = false;
}
if ($label && $hiddenLabel)
{
$label = '<span class="u-srOnly">' . $label . '</span>';
$labelClass .= ' iconic--hiddenLabel';
}
else if ($label === '')
{
$labelClass .= ' iconic--noLabel';
}
if ($readOnly)
{
$labelClass .= ' is-readonly';
}
$titleAttr = $this->processAttributeToHtmlAttribute($choice, 'title');
$tooltipAttr = '';
if (array_key_exists('data-xf-init', $choice) && $choice['data-xf-init'] == 'tooltip')
{
$tooltipAttr = $this->processAttributeToHtmlAttribute($choice, 'data-xf-init');
}
$checkAll = $this->processAttributeToRaw($choice, 'check-all');
if ($checkAll != '')
{
$choice['data-xf-init'] .= (empty($choice['data-xf-init']) ? '' : ' ') . 'check-all';
$choice['data-container'] = $checkAll;
}
$hint = $this->processAttributeToRaw($choice, 'hint', "\n\t\t\t\t\t<dfn class=\"inputChoices-explain\">%s</dfn>");
$extraHtml = $this->processAttributeToRaw($choice, 'html', "\n\t\t\t\t\t%s");
$afterHint = $this->processAttributeToRaw($choice, 'afterhint', "\n\t\t\t<dfn class=\"inputChoices-explain inputChoices-explain--after\">%s</dfn>");
$afterHtml = $this->processAttributeToRaw($choice, 'afterhtml', "\n\t\t\t%s");
$valueAttr = $this->processAttributeToHtmlAttribute($choice, 'value');
if (!$valueAttr)
{
$valueAttr = ' value="1"';
}
$selectedAttr = $selected ? ' checked="checked"' : '';
if ($this->processAttributeToRaw($choice, 'readonly'))
{
$readOnly = true;
}
$readOnlyAttr = $readOnly ? ' readonly="readonly" onclick="return false"' : '';
if ($readOnly)
{
$labelClass .= ' is-readonly';
}
if (isset($choice['defaultvalue']) && $localName && substr($localName, -2) != '[]')
{
// $localName is escaped
$defaultValueInput = '<input type="hidden" name="' . $localName
. '" value="' . \XF::escapeString($choice['defaultvalue']) . '" />';
unset($choice['defaultvalue']);
}
else
{
$defaultValueInput = '';
}
$attributes = $this->processUnhandledAttributes($choice);
if ($label !== '')
{
$label = "<span class=\"iconic-label\">{$label}</span>";
}
$checkboxHtml = $defaultValueInput . "<label class=\"{$labelClass}\"{$titleAttr}{$tooltipAttr}>"
. "<input type=\"checkbox\" {$nameAttr}{$valueAttr}{$selectedAttr}{$readOnlyAttr}{$attributes} />"
. "<i aria-hidden=\"true\"></i>{$label}</label>{$hint}{$extraHtml}{$dependentHtml}{$afterHint}{$afterHtml}";
if ($standalone)
{
return $checkboxHtml . "\n";
}
else
{
return "<li class=\"inputChoices-choice\">{$checkboxHtml}</li>\n";
}
};
$groupFormatter = function(array $group, $html)
{
$label = $this->processAttributeToRaw($group, 'label');
if ($label)
{
$class = $this->processAttributeToRaw($group, 'class', '', true);
$listClass = $this->processAttributeToRaw($group, 'listclass', '', true);
$headingClass = 'inputChoices-heading';
$checkAll = $this->processAttributeToRaw($group, 'check-all');
if ($checkAll)
{
$label = '<label class="iconic">
<input type="checkbox" data-xf-init="check-all" data-container="< .inputChoices-group" /><i aria-hidden="true"></i>'
. $label . '</label>';
$headingClass .= ' inputChoices-heading--checkAll';
}
$unhandledAttrs = $this->processUnhandledAttributes($group);
$html = "<li class=\"inputChoices-group {$class}\" {$unhandledAttrs}>
<div class=\"{$headingClass}\">{$label}</div>
<ul class=\"inputChoices {$listClass}\">{$html}</ul>
</li>";
}
return $html;
};
$choiceHtml = $this->handleChoices($choices, $choiceFormatter, $groupFormatter);
$hideEmpty = $this->processAttributeToRaw($controlOptions, 'hideempty');
if ($hideEmpty && !$choiceHtml)
{
return '';
}
if ($standalone)
{
return $choiceHtml;
}
$listClassAttr = $this->processAttributeToNamedHtmlAttribute($controlOptions, 'listclass', 'class', 'inputChoices', true);
$unhandledAttrs = $this->processUnhandledAttributes($controlOptions);
return "
<ul{$listClassAttr}{$unhandledAttrs}>
$choiceHtml
</ul>
";
}
public function formCheckBoxRow(array $controlOptions, array $choices, array $rowOptions)
{
if (
empty($controlOptions['role'])
&& isset($rowOptions['label'])
&& trim(strval($rowOptions['label'])) !== ''
)
{
$controlOptions['role'] = 'group';
if (!isset($controlOptions['aria-labelledby']))
{
$controlOptions['aria-labelledby'] = $this->assignRowLabelId($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 = $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']);
$titleAttr = $this->processAttributeToHtmlAttribute($choice, 'title');
$tooltipAttr = '';
if ($choice['data-xf-init'] == 'tooltip')
{
$tooltipAttr = $this->processAttributeToHtmlAttribute($choice, 'data-xf-init');
}
$dependentHtml = '';
if ($dependent)
{
$dependentHtmlInner = '';
foreach ($dependent AS $child)
{
$dependentHtmlInner .= "\n\t\t\t\t<li class=\"inputChoices-choice\">$child</li>";
}
$dependentHtml = "\n\t\t\t<ul class=\"inputChoices-dependencies\">{$dependentHtmlInner}\n\t\t\t</ul>\n\t\t";
}
if ($dependentHtml)
{
$this->addElementHandler($choice, 'disabler');
}
$label = trim($this->processAttributeToRaw($choice, 'label'));
$labelClass = 'iconic iconic--radio';
$labelClassExtra = $this->processAttributeToRaw($choice, 'labelclass', '', true);
if ($labelClassExtra !== '')
{
$labelClass .= " {$labelClassExtra}";
}
$hiddenLabel = $this->processAttributeToRaw($choice, 'hiddenlabel');
if ($label && $hiddenLabel != '')
{
$hiddenLabel = true;
}
else
{
$hiddenLabel = false;
}
if ($label && $hiddenLabel)
{
$label = '<span class="u-srOnly">' . $label . '</span>';
$labelClass .= ' iconic--hiddenLabel';
}
else if ($label === '')
{
$labelClass .= ' iconic--noLabel';
}
if ($readOnly)
{
$labelClass .= ' is-readonly';
}
$hint = $this->processAttributeToRaw($choice, 'hint', "\n\t\t\t\t\t<dfn class=\"inputChoices-explain\">%s</dfn>");
$afterHint = $this->processAttributeToRaw($choice, 'afterhint', "\n\t\t\t<dfn class=\"inputChoices-explain inputChoices-explain--after\">%s</dfn>");
$extraHtml = $this->processAttributeToRaw($choice, 'html', "\n\t\t\t\t\t%s");
$valueAttr = $this->processAttributeToHtmlAttribute($choice, 'value');
if (!$valueAttr)
{
$valueAttr = ' value=""';
}
$selectedAttr = $selected ? ' checked="checked"' : '';
if ($this->processAttributeToRaw($choice, 'readonly'))
{
$readOnly = true;
}
$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);
if ($label !== '')
{
$label = "<span class=\"iconic-label\">{$label}</span>";
}
$radioHtml = "<label class=\"{$labelClass}\"{$titleAttr}{$tooltipAttr}>"
. "<input type=\"radio\" name=\"$name\"{$valueAttr}{$selectedAttr}{$readOnlyAttr}{$attributes} />"
. "<i aria-hidden=\"true\"></i>{$label}</label>{$hint}{$dependentHtml}{$extraHtml}{$afterHint}";
if ($standalone)
{
return $radioHtml . "\n";
}
else
{
return "<li{$listItemClass}>{$radioHtml}</li>\n";
}
};
$groupFormatter = function(array $group, $html)
{
$label = $this->processAttributeToRaw($group, 'label');
if ($label)
{
$class = $this->processAttributeToRaw($group, 'class', '', true);
$listClass = $this->processAttributeToRaw($group, 'listclass', '', true);
$unhandledAttrs = $this->processUnhandledAttributes($group);
$html = "<li class=\"inputChoices-group {$class}\" {$unhandledAttrs}>
<div class=\"inputChoices-heading\">{$label}</div>
<ul class=\"inputChoices {$listClass}\">{$html}</ul>
</li>";
}
return $html;
};
$choiceHtml = $this->handleChoices($choices, $choiceFormatter, $groupFormatter);
$hideEmpty = $this->processAttributeToRaw($controlOptions, 'hideempty');
if ($hideEmpty && !$choiceHtml)
{
return '';
}
if ($standalone)
{
return $choiceHtml;
}
$listClassAttr = $this->processAttributeToNamedHtmlAttribute($controlOptions, 'listclass', 'class', 'inputChoices', true);
$unhandledAttrs = $this->processUnhandledAttributes($controlOptions);
return "
<ul{$listClassAttr}{$unhandledAttrs}>
$choiceHtml
</ul>
";
}
public function formRadioRow(array $controlOptions, array $choices, array $rowOptions)
{
if (empty($controlOptions['role']))
{
$controlOptions['role'] = 'radiogroup';
if (!isset($controlOptions['aria-labelledby']))
{
$controlOptions['aria-labelledby'] = $this->assignRowLabelId($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 = $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 "<option{$valueAttr}{$selectedAttr}{$disabledAttr}{$attributes}>{$label}</option>\n";
};
$groupFormatter = function(array $group, $html)
{
if (!$html)
{
return '';
}
$attributes = $this->processUnhandledAttributes($group);
return "<optgroup{$attributes}>\n$html</optgroup>";
};
$choiceHtml = $this->handleChoices($choices, $choiceFormatter, $groupFormatter);
$hideEmpty = $this->processAttributeToRaw($controlOptions, 'hideempty');
if ($hideEmpty && !$choiceHtml)
{
return '';
}
$readOnly = $this->processAttributeToRaw($controlOptions, 'readonly');
$disabled = $this->processAttributeToRaw($controlOptions, 'disabled');
if ($readOnly)
{
$this->addToClassAttribute($controlOptions, 'is-readonly');
$disabled = true;
}
$disabledAttr = $disabled ? ' disabled="disabled"' : '';
$classAttr = $this->processAttributeToHtmlAttribute($controlOptions, 'class', 'input', true);
$fa = $this->fontAwesomeInputOverlay($controlOptions);
$unhandledAttrs = $this->processUnhandledAttributes($controlOptions);
$select = "
{$fa}<select name=\"{$name}\"{$multipleAttr}{$classAttr}{$disabledAttr}{$unhandledAttrs}>
$choiceHtml
</select>
";
if ($readOnly && $value !== null)
{
if ($multiple)
{
if (is_array($value))
{
foreach ($value AS $subValue)
{
$select .= '<input type="hidden" name="' . $name . '" value="' . \XF::escapeString($subValue) . '" />';
}
}
}
else
{
$select .= '<input type="hidden" name="' . $name . '" value="' . \XF::escapeString($value) . '" />';
}
}
return $select;
}
public function formSelectRow(array $controlOptions, array $choices, array $rowOptions)
{
$this->addToClassAttribute($rowOptions, 'formRow--input', 'rowclass');
$controlId = $this->assignFormControlId($controlOptions);
$controlHtml = $this->formSelect($controlOptions, $choices);
return $controlHtml ? $this->formRow($controlHtml, $rowOptions, $controlId) : '';
}
public function formSubmitRow(array $controlOptions, array $rowOptions)
{
$this->processDynamicAttributes($controlOptions);
$sticky = $this->processAttributeToRaw($controlOptions, 'sticky');
$stickyContainer = $this->processAttributeToRaw($controlOptions, 'sticky-container');
$stickyFixedChild = $this->processAttributeToRaw($controlOptions, 'sticky-fixed-child');
$stickyClass = $this->processAttributeToRaw($controlOptions, 'sticky-class');
$stickyTopOffset = $this->processAttributeToRaw($controlOptions, 'sticky-top-offset');
$stickyMinWindowHeight = $this->processAttributeToRaw($controlOptions, 'sticky-min-window-height');
if ($sticky && $sticky != 'false')
{
$this->addElementHandler($rowOptions, 'form-submit-row', 'rowclass');
if ($stickyContainer)
{
$rowOptions['data-container'] = $stickyContainer;
}
else if ($sticky != 'true' && !is_numeric($sticky)) // indicates a container
{
$rowOptions['data-container'] = $sticky;
}
if ($stickyFixedChild)
{
$rowOptions['data-fixed-child'] = $stickyFixedChild;
}
if ($stickyClass)
{
$rowOptions['data-sticky-class'] = $stickyClass;
}
if ($stickyTopOffset)
{
$rowOptions['data-top-offset'] = $stickyTopOffset;
}
if ($stickyMinWindowHeight)
{
$rowOptions['data-min-window-height'] = $stickyMinWindowHeight;
}
}
$submit = strval($this->processAttributeToRaw($controlOptions, 'submit'));
if (!$submit && !empty($controlOptions['icon']))
{
$submit = $this->getButtonPhraseFromIcon($controlOptions['icon'], 'button.submit');
}
if (strlen($submit))
{
$controlOptions['type'] = 'submit';
if (empty($controlOptions['class']))
{
$controlOptions['class'] = 'button--primary';
}
$controlHtml = $this->button($submit, $controlOptions);
}
else
{
$controlHtml = '';
}
$extraHtml = $this->processAttributeToRaw($rowOptions, 'html', "\n\t\t\t\t%s");
$class = $this->processAttributeToRaw($rowOptions, 'rowclass', ' %s', true);
if ($sticky)
{
$class .= ' formSubmitRow--sticky';
}
$rowType = $this->processAttributeToRaw($rowOptions, 'rowtype');
if ($rowType)
{
$class = $this->appendClassList($class, $rowType, 'formSubmitRow--%s');
}
$unhandledRowAttrs = $this->processUnhandledAttributes($rowOptions);
return "
<dl class=\"formRow formSubmitRow{$class}\"{$unhandledRowAttrs}>
<dt></dt>
<dd>
<div class=\"formSubmitRow-main\">
<div class=\"formSubmitRow-bar\"></div>
<div class=\"formSubmitRow-controls\">{$controlHtml}{$extraHtml}</div>
</div>
</dd>
</dl>
";
}
public function formTextArea(array $controlOptions)
{
$this->processDynamicAttributes($controlOptions);
$this->processCodeAttribute($controlOptions);
$autosize = $this->processAttributeToRaw($controlOptions, 'autosize');
if ($autosize)
{
$this->addElementHandler($controlOptions, 'textarea-handler');
$classAppend = ' input--fitHeight';
}
else
{
$classAppend = '';
}
$maxLength = $this->processAttributeToRaw($controlOptions, 'maxlength');
if ($maxLength)
{
$maxlengthAttr = " maxlength=\"{$maxLength}\"";
}
else
{
$maxlengthAttr = '';
}
$value = \XF::escapeString($this->processValueAttribute($controlOptions));
$readOnlyAttr = $this->processAttributeToRaw($controlOptions, 'readonly') ? ' readonly="readonly"' : '';
$classAttr = $this->processAttributeToHtmlAttribute($controlOptions, 'class', 'input' . $classAppend, true);
$fa = $this->fontAwesomeInputOverlay($controlOptions);
$unhandledAttrs = $this->processUnhandledAttributes($controlOptions);
return "{$fa}<textarea{$classAttr}{$readOnlyAttr}{$maxlengthAttr}{$unhandledAttrs}>{$value}</textarea>";
}
public function formTextAreaRow(array $controlOptions, array $rowOptions)
{
$this->addToClassAttribute($rowOptions, 'formRow--input', 'rowclass');
$controlId = $this->assignFormControlId($controlOptions);
$controlHtml = $this->formTextArea($controlOptions);
return $this->formRow($controlHtml, $rowOptions, $controlId);
}
public function formDateInput(array $controlOptions)
{
$this->processDynamicAttributes($controlOptions);
$class = $this->processAttributeToRaw($controlOptions, 'class', ' %s', true);
$xfInit = $this->processAttributeToRaw($controlOptions, 'data-xf-init', ' %s', true);
$weekStart = $this->processAttributeToRaw($controlOptions, 'week-start', '', true);
if (!$weekStart)
{
$weekStart = $this->language['week_start'];
}
$readOnly = $this->processAttributeToRaw($controlOptions, 'readonly');
if (empty($controlOptions['value']))
{
$controlOptions['value'] = '';
}
else if (is_numeric($controlOptions['value']) || $controlOptions['value'] instanceof \DateTime)
{
$controlOptions['value'] = $this->language->date($controlOptions['value'], 'Y-m-d');
}
$attrsHtml = $this->processUnhandledAttributes($controlOptions);
return $this->renderTemplate('public:date_input', [
'class' => $class,
'xfInit' => $xfInit,
'weekStart' => $weekStart,
'readOnly' => $readOnly,
'attrsHtml' => $attrsHtml
]);
}
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->processValueAttribute($controlOptions);
$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';
}
$rows = $this->processAttributeToRaw($controlOptions, 'rows');
if (!$rows)
{
$rows = 8;
}
$attrsHtml = $this->processUnhandledAttributes($controlOptions);
return $this->renderTemplate('public:code_editor', [
'name' => $name,
'value' => $value,
'lang' => $mode,
'modeConfig' => $modeConfig,
'extraClasses' => $extraClasses,
'readOnly' => $readOnly,
'rows' => $rows,
'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 (!$previewable)
{
$controlOptions['data-preview'] = 'false';
}
$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' => $this->phrase('custom_bb_code_title.' . $k),
'type' => $custom['editor_icon_type'],
'value' => $custom['editor_icon_value'],
'option' => $custom['has_option']
];
}
}
$editorToolbars = \XF::options()->editorToolbarConfig;
$editorDropdowns = \XF::options()->editorDropdownConfig;
$editorToolbarSizes = $this->app['editorToolbarSizes'];
foreach ($editorDropdowns AS $cmd => &$dropdown)
{
$dropdown['title'] = $this->phrase('editor_dropdown.' . $cmd);
}
if (substr($name, -1) == ']')
{
$htmlName = substr($name, 0, -1) . '_html]';
}
else
{
$htmlName = $name . '_html';
}
if ($value !== '')
{
$rendererOpts = [
'attachments' => $attachments
];
if (!empty($controlOptions['rendereropts']) && is_array($controlOptions['rendereropts']))
{
$rendererOpts += $controlOptions['rendereropts'];
}
$htmlValue = $this->app->bbCode()->render($value, 'editorHtml', 'editor', null, $rendererOpts);
}
else
{
$htmlValue = '';
}
if (!isset($controlOptions['data-min-height']))
{
$controlOptions['data-min-height'] = 250;
}
$height = intval($controlOptions['data-min-height']);
$removeButtons = [];
$hasSmilies = $this->app->smilies;
$hasEmoji = (\XF::options()->showEmojiInSmilieMenu && \XF::config('fullUnicode'));
$hasGif = \XF::options()->giphy['enabled'];
if (isset($controlOptions['removebuttons']))
{
$removeButtons = $controlOptions['removebuttons'];
}
if (!$hasSmilies && !$hasEmoji)
{
$removeButtons[] = '_smilies';
}
if (!$hasGif)
{
$removeButtons[] = 'xfInsertGif';
}
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,
'editorToolbars' => $editorToolbars,
'editorToolbarSizes' => $editorToolbarSizes,
'editorDropdowns' => $editorDropdowns,
'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->processValueAttribute($controlOptions, 'textbox-value');
$href = $this->processAttributeToRaw($controlOptions, 'href');
$listenTo = $this->processAttributeToRaw($controlOptions, 'listen-to');
$rows = $this->processAttributeToRaw($controlOptions, 'rows');
$helpHref = $this->processAttributeToRaw($controlOptions, 'help-href');
$helpSkipInitial = (bool)$this->processAttributeToRaw($controlOptions, 'help-skip-initial');
$xfInit = $this->processAttributeToRaw($controlOptions, 'data-xf-init');
$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,
'helpHref' => $helpHref,
'helpSkipInitial' => $helpSkipInitial,
'xfInit' => $xfInit,
'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 = null;
if (array_key_exists('type', $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");
}
$validationError = '';
$validationUrlAttr = '';
$validationUrl = $this->processAttributeToRaw($controlOptions, 'validation-url', '', true);
if ($validationUrl)
{
$validationError = '<div class="inputValidationError js-validationError"></div>';
$validationUrlAttr = " data-validation-url=\"$validationUrl\"";
$xfInit = ltrim("$xfInit input-validator");
}
$xfInitAttr = $xfInit ? " data-xf-init=\"$xfInit\"" : '';
$readOnlyAttr = $this->processAttributeToRaw($controlOptions, 'readonly') ? ' readonly="readonly"' : '';
$fa = $this->fontAwesomeInputOverlay($controlOptions);
$unhandledAttrs = $this->processUnhandledAttributes($controlOptions);
$input = "{$fa}<input{$typeAttr} class=\"" . trim("input {$class}") . "\"{$xfInitAttr}{$validationUrlAttr}{$acSingle}{$readOnlyAttr}{$unhandledAttrs} />{$validationError}";
if ($units)
{
return "<div class=\"inputGroup inputGroup--numbers\">$input<span class=\"inputGroup-text\">$units</span></div>";
}
else
{
return $input;
}
}
public function formTextBoxRow(array $controlOptions, array $rowOptions)
{
$this->addToClassAttribute($rowOptions, 'formRow--input', 'rowclass');
$controlId = $this->assignFormControlId($controlOptions);
$controlHtml = $this->formTextBox($controlOptions);
return $this->formRow($controlHtml, $rowOptions, $controlId);
}
public function formNumberBox(array $controlOptions)
{
$this->processDynamicAttributes($controlOptions);
$min = $controlOptions['min'] ?? null;
$max = $controlOptions['max'] ?? null;
$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['default']))
{
$value = $controlOptions['default'];
}
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"' : '';
$groupClass = $this->processAttributeToRaw($controlOptions, 'group-class', ' %s', true);
$buttonSmaller = $this->processAttributeToRaw($controlOptions, 'data-button-smaller', '', true);
$buttonSmallerAttr = $buttonSmaller ? " data-button-smaller=\"$buttonSmaller\"" : '';
$stepOverride = $this->processAttributeToRaw($controlOptions, 'data-step', '', true);
$stepOverrideAttr = $stepOverride ? " data-step=\"{$stepOverride}\"" : '';
$fa = $this->fontAwesomeInputOverlay($controlOptions);
$unhandledAttrs = $this->processUnhandledAttributes($controlOptions);
$input = "<div class=\"inputGroup inputGroup--numbers inputNumber{$groupClass}\" data-xf-init=\"number-box\"{$buttonSmallerAttr}{$stepOverrideAttr}>"
. "{$fa}<input type=\"{$type}\" pattern=\"{$pattern}\" class=\"input input--number js-numberBoxTextInput{$class}\" value=\"{$value}\" {$minAttr}{$maxAttr}{$stepAttr}{$requiredAttr}{$readOnlyAttr}{$xfInitAttr}{$unhandledAttrs} />"
. "</div>";
if ($units)
{
return "<div class=\"inputGroup\">$input<div class=\"inputGroup\"><span class='inputGroup--splitter'></span><span class=\"inputGroup-text\">$units</span></div></div>";
}
else
{
return $input;
}
}
public function formNumberBoxRow(array $controlOptions, array $rowOptions)
{
$this->addToClassAttribute($rowOptions, 'formRow--input', 'rowclass');
$controlId = $this->assignFormControlId($controlOptions);
$controlHtml = $this->formNumberBox($controlOptions);
return $this->formRow($controlHtml, $rowOptions, $controlId);
}
public function formTokenInput(array $controlOptions)
{
$this->processDynamicAttributes($controlOptions);
$name = $this->processAttributeToRaw($controlOptions, 'name');
$value = $this->processValueAttribute($controlOptions);
$hrefAttr = $this->processAttributeToRaw($controlOptions, 'href');
$styleAttr = $this->processAttributeToRaw($controlOptions, 'style');
$inputClass = $this->processAttributeToRaw($controlOptions, 'inputclass');
$minLength = $this->processAttributeToRaw($controlOptions, 'min-length');
if ($minLength === '')
{
$minLength = 2;
}
$maxLength = $this->processAttributeToRaw($controlOptions, 'max-length');
$maxTokens = $this->processAttributeToRaw($controlOptions, 'max-tokens');
$listData = $this->processAttributeToRaw($controlOptions, 'list-data');
$attrsHtml = $this->processUnhandledAttributes($controlOptions);
return $this->renderTemplate('public:token_input', [
'name' => $name,
'value' => $value,
'hrefAttr' => $hrefAttr,
'styleAttr' => $styleAttr,
'inputClass' => $inputClass,
'minLength' => $minLength,
'maxLength' => $maxLength,
'maxTokens' => $maxTokens,
'listData' => $listData,
'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 formPasswordBox(array $controlOptions)
{
$this->processDynamicAttributes($controlOptions);
$name = $this->processAttributeToRaw($controlOptions, 'name');
$value = $this->processValueAttribute($controlOptions);
$hideShow = true;
if (isset($controlOptions['hideshow']) && ($controlOptions['hideshow'] === 'false' || $controlOptions['hideshow'] === 0))
{
$hideShow = false;
}
$checkStrength = false;
if (isset($controlOptions['checkstrength']) && ($controlOptions['checkstrength'] === 'true' || $controlOptions['checkstrength'] === 1))
{
$checkStrength = true;
}
$afterInputHtml = $this->processAttributeToRaw($controlOptions, 'afterinputhtml');
$attrsHtml = $this->processUnhandledAttributes($controlOptions);
return $this->renderTemplate('public:password_box', [
'name' => $name,
'value' => $value,
'hideShow' => $hideShow,
'checkStrength' => $checkStrength,
'attrsHtml' => $attrsHtml,
'afterInputHtml' => $afterInputHtml
]);
}
public function formPasswordBoxRow(array $controlOptions, array $rowOptions)
{
$this->addToClassAttribute($rowOptions, 'formRow--input', 'rowclass');
$controlId = $this->assignFormControlId($controlOptions);
$controlHtml = $this->formPasswordBox($controlOptions);
return $this->formRow($controlHtml, $rowOptions, $controlId);
}
public function formTelBox(array $controlOptions)
{
$this->processDynamicAttributes($controlOptions);
$name = $this->processAttributeToRaw($controlOptions, 'name');
$value = $this->processValueAttribute($controlOptions);
$dialCodeName = $this->processAttributeToRaw($controlOptions, 'dialcodename');
$intlNumberName = $this->processAttributeToRaw($controlOptions, 'intlnumbername');
$attrsHtml = $this->processUnhandledAttributes($controlOptions);
return $this->renderTemplate('public:tel_box', [
'name' => $name,
'value' => $value,
'dialCodeName' => $dialCodeName,
'intlNumberName' => $intlNumberName,
'attrsHtml' => $attrsHtml
]);
}
public function formTelBoxRow(array $controlOptions, array $rowOptions)
{
$this->addToClassAttribute($rowOptions, 'formRow--input', 'rowclass');
$controlId = $this->assignFormControlId($controlOptions);
$controlHtml = $this->formTelBox($controlOptions);
return $this->formRow($controlHtml, $rowOptions, $controlId);
}
public function formUpload(array $controlOptions)
{
$this->processDynamicAttributes($controlOptions);
$class = $this->processAttributeToRaw($controlOptions, 'class', '', true);
$unhandledAttrs = $this->processUnhandledAttributes($controlOptions);
return "<input type=\"file\" class=\"input {$class}\"{$unhandledAttrs} />";
}
public function formUploadRow(array $controlOptions, array $rowOptions)
{
$this->addToClassAttribute($rowOptions, 'formRow--input', 'rowclass');
$controlId = $this->assignFormControlId($controlOptions);
$controlHtml = $this->formUpload($controlOptions);
return $this->formRow($controlHtml, $rowOptions, $controlId);
}
public function formAssetUpload(array $controlOptions)
{
$this->processDynamicAttributes($controlOptions);
$class = $this->processAttributeToRaw($controlOptions, 'class', '', true);
$assetType = $this->processAttributeToRaw($controlOptions, 'asset', '', true);
$accept = $this->processAttributeToRaw($controlOptions, 'accept');
if (!$accept)
{
$accept = '.gif,.jpeg,.jpg,.jpe,.png';
}
$unhandledAttrs = $this->processUnhandledAttributes($controlOptions);
$uploadText = $this->filterForAttr($this, $this->phrase('upload_file'), $null);
return "
<div class=\"inputGroup inputGroup--joined\" data-xf-init=\"asset-upload\" data-asset=\"{$assetType}\">
<input type=\"text\" class=\"input js-assetPath {$class}\" {$unhandledAttrs} />
<label class=\"inputGroup-text inputUploadButton\" data-xf-init=\"tooltip\" title=\"{$uploadText}\">
<input type=\"file\" class=\"js-uploadAsset\" accept=\"{$accept}\" />
</label>
</div>
";
}
public function formAssetUploadRow(array $controlOptions, array $rowOptions)
{
$this->addToClassAttribute($rowOptions, 'formRow--input', 'rowclass');
$controlId = $this->assignFormControlId($controlOptions);
$controlHtml = $this->formAssetUpload($controlOptions);
return $this->formRow($controlHtml, $rowOptions, $controlId);
}
protected function assignFormControlId(array &$controlOptions)
{
if (!empty($controlOptions['id']))
{
return $controlOptions['id'];
}
$controlOptions['id'] = $this->func('unique_id');
return $controlOptions['id'];
}
protected function assignRowLabelId(array &$rowOptions)
{
if (!empty($rowOptions['labelid']))
{
return $rowOptions['labelid'];
}
$rowOptions['labelid'] = $this->func('unique_id');
return $rowOptions['labelid'];
}
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) . '"' : '';
$labelId = $this->processAttributeToRaw($rowOptions, 'labelid');
$labelIdAttr = $labelId ? ' id="' . htmlspecialchars($labelId) . '"' : '';
$label = $this->processAttributeToRaw(
$rowOptions,
'label',
"\n\t\t\t\t\t<label class=\"formRow-label\"{$labelFor}{$labelIdAttr}>%s</label>"
);
$hint = $this->processAttributeToRaw($rowOptions, 'hint', "\n\t\t\t\t\t<dfn class=\"formRow-hint\">%s</dfn>");
$initialHtml = $this->processAttributeToRaw($rowOptions, 'initialhtml', "\n\t\t\t\t\t%s");
$html = $this->processAttributeToRaw($rowOptions, 'html', "\n\t\t\t\t\t%s");
$explain = $this->processAttributeToRaw($rowOptions, 'explain', "\n\t\t\t\t\t<div class=\"formRow-explain\">%s</div>");
$error = $this->processAttributeToRaw($rowOptions, 'error', "\n\t\t\t\t\t<div class=\"formRow-error\">%s</div>");
$finalHtml = $this->processAttributeToRaw($rowOptions, 'finalhtml', "\n\t\t\t\t\t%s");
$unhandledAttrs = $this->processUnhandledAttributes($rowOptions);
return '
<dl class="formRow' . $class . '"' . $idAttr . $unhandledAttrs . '>
<dt>
<div class="formRow-labelWrapper">' . $label . $hint . '</div>
</dt>
<dd>
' . $initialHtml // stuff to go before the control (rarely)
. $contentHtml // controls etc.
. $html // extra HTML, dependent controls etc.
. $error // error message
. $explain // final <p.explain> that describes all the above
. $finalHtml // used for <input hidden> etc.
. '
</dd>
</dl>
';
}
public function formRowIfContent($contentHtml, array $rowOptions, $controlId = null)
{
$contentHtml = trim($contentHtml);
if (!strlen($contentHtml))
{
return '';
}
else
{
return $this->formRow($contentHtml, $rowOptions, $controlId);
}
}
public function formInfoRow($contentHtml, array $rowOptions)
{
$class = $this->processAttributeToRaw($rowOptions, 'rowclass', ' %s', true);
$rowType = $this->processAttributeToRaw($rowOptions, 'rowtype');
if ($rowType)
{
$class = $this->appendClassList($class, $rowType, 'formInfoRow--%s');
}
$unhandledRowAttrs = $this->processUnhandledAttributes($rowOptions);
return "
<div class=\"formInfoRow{$class}\"{$unhandledRowAttrs}>
{$contentHtml}
</div>
";
}
public function form($contentHtml, array $options)
{
$this->processDynamicAttributes($options);
$method = $this->processAttributeToRaw($options, 'method', '', true);
if (!$method)
{
$method = 'post';
}
$getFormParams = '';
$action = $this->processAttributeToRaw($options, 'action', '', true);
if ($action && strtolower($method) == 'get')
{
$qStart = strpos($action, '?');
if ($qStart !== false)
{
$qString = htmlspecialchars_decode(substr($action, $qStart + 1));
$action = substr($action, 0, $qStart);
if (preg_match('/^([^=&]*)(&|$)/', $qString, $qStringUrl))
{
$route = $qStringUrl[1];
$qString = substr($qString, strlen($qStringUrl[0]));
}
else
{
$route = '';
}
if ($route !== '')
{
$getFormParams .= $this->formHiddenVal('_xfRoute', $route);
}
if ($qString)
{
$params = \XF\Util\Arr::parseQueryString($qString);
foreach ($params AS $name => $value)
{
$getFormParams .= "\n\t" . $this->formHiddenVal($name, $value);
}
}
}
}
$ajax = $this->processAttributeToRaw($options, 'ajax');
$class = $this->processAttributeToRaw($options, 'class', '', true);
$upload = $this->processAttributeToRaw($options, 'upload', '', true);
$encType = $this->processAttributeToRaw($options, 'enctype', '', true);
$preview = $this->processAttributeToRaw($options, 'preview', '', true);
$xfInit = $this->processAttributeToRaw($options, 'data-xf-init', '', true);
if ($ajax)
{
$xfInit = ltrim("$xfInit ajax-submit");
}
$encTypeAttr = '';
if ($encType)
{
$encTypeAttr = " enctype=\"$encType\"";
}
else if ($upload)
{
$encTypeAttr = " enctype=\"multipart/form-data\"";
}
$previewUrlAttr = '';
if ($preview)
{
$xfInit = ltrim("$xfInit preview");
$previewUrlAttr = " data-preview-url=\"$preview\"";
}
$draftAttrs = $this->handleDraftAttribute($options, $class, $xfInit);
$xfInitAttr = $xfInit ? " data-xf-init=\"$xfInit\"" : '';
$unhandledAttrs = $this->processUnhandledAttributes($options);
if (strtolower($method) == 'post')
{
$csrfInput = $this->func('csrf_input');
}
else
{
$csrfInput = '';
}
return "
<form action=\"{$action}\" method=\"{$method}\" class=\"{$class}\"
{$xfInitAttr}{$encTypeAttr}{$previewUrlAttr}{$draftAttrs}{$unhandledAttrs}
>
{$csrfInput}
{$contentHtml}
{$getFormParams}
</form>
";
}
protected function handleDraftAttribute(array &$options, &$class, &$xfInit)
{
$draftOptions = $this->app->options()->saveDrafts;
if (!empty($draftOptions['enabled']))
{
$draft = $this->processAttributeToRaw($options, 'draft', '', true);
if ($draft)
{
$xfInit = ltrim("$xfInit draft");
return " data-draft-url=\"$draft\" data-draft-autosave=\"$draftOptions[saveFrequency]\"";
}
}
unset($options['draft']);
return '';
}
public function dataList($contentHtml, array $options)
{
$this->processDynamicAttributes($options);
$class = $this->processAttributeToRaw($options, 'class', '', true);
$unhandledAttrs = $this->processUnhandledAttributes($options);
return "
<div class=\"dataList {$class}\"{$unhandledAttrs}>
<table class=\"dataList-table\">
{$contentHtml}
</table>
</div>
";
}
public function dataRow(array $options, array $cells = [])
{
if (!empty($options['rowtype']))
{
$rowType = $options['rowtype'];
}
else
{
$rowType = 'row';
}
if ($rowType == 'header')
{
if (!isset($options['rowclass']))
{
$options['rowclass'] = '';
}
$options['rowclass'] = trim($options['rowclass'] . ' dataList-row--header dataList-row--noHover');
}
else if ($rowType == 'subsection' || $rowType == 'subSection')
{
$rowType = 'subSection';
if (!isset($options['rowclass']))
{
$options['rowclass'] = '';
}
$options['rowclass'] = trim($options['rowclass'] . ' dataList-row--subSection');
}
$label = (isset($options['label']) && strlen($options['label'])) ? $options['label'] : null;
if ($label !== null)
{
$cell = [
'_type' => 'main',
'href' => !empty($options['href']) ? $options['href'] : null,
'target' => !empty($options['target']) ? $options['target'] : null,
'label' => $label,
'hint' => (isset($options['hint']) && strlen(trim($options['hint']))) ? $options['hint'] : null,
'explain' => (isset($options['explain']) && strlen(trim($options['explain']))) ? $options['explain'] : null,
'hash' => (isset($options['hash']) && strlen(trim($options['hash']))) ? $options['hash'] : null,
'colspan' => !empty($options['colspan']) ? $options['colspan'] : null,
'html' => ''
];
if (!empty($options['dir']))
{
$cell['dir'] = $options['dir'];
}
if (!empty($options['href']) && !empty($options['overlay']))
{
$cell['overlay'] = $options['overlay'];
foreach ($this->overlayClickOptions AS $attributeName)
{
if (isset($options[$attributeName]))
{
$cell[$attributeName] = $options[$attributeName];
}
}
}
array_unshift($cells, $cell);
}
$icon = (isset($options['icon']) && strlen($options['icon'])) ? $options['icon'] : null;
if ($icon !== null)
{
if ($icon == 'none')
{
$iconHtml = '';
}
else
{
$iconHtml = $this->fontAwesome($options['icon'] . ' fa-lg fa-fw');
}
$cell = [
'class' => 'dataList-cell--min dataList-cell--iconic',
'href' => !empty($options['href']) ? $options['href'] : null,
'html' => $iconHtml
];
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);
$knownParts = [
'colspan',
'delete',
'dir',
'explain',
'hash',
'hint',
'href',
'icon',
'label',
'overlay',
'rowtype',
'target',
];
$knownParts += $this->overlayClickOptions;
foreach ($knownParts AS $known)
{
unset($options[$known]);
}
$unhandledAttrs = $this->processUnhandledAttributes($options);
return "
<tr class=\"dataList-row{$rowClass}\"{$unhandledAttrs}>
{$html}
</tr>
";
}
/**
* @param string $rowType Type of row; currently header or row
* @param array $cell Array of attributes for the cell itself
* @param string $rowClass Allows cells to affect the appearance of the parent row
*
* @return string
*/
protected function getDataRowCell($rowType, array $cell, &$rowClass = '')
{
$type = $cell['_type'] ?? 'cell';
unset($cell['_type']);
$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';
$labelClass = 'iconic';
if (!$inputType)
{
$inputType = 'checkbox';
}
$hiddenHtml = '';
if (isset($cell['value']))
{
$value = $this->processAttributeToRaw($cell, 'value', '', true);
}
else
{
$value = '1';
if ($inputType == 'checkbox')
{
$hiddenHtml = "<input type=\"hidden\" name=\"{$name}\" value=\"0\" />";
}
}
$checkedHtml = $selected ? ' checked="checked"' : '';
$disabled = !empty($cell['disabled']);
unset($cell['disabled']);
$disabledHtml = $disabled ? ' disabled="disabled"' : '';
$tooltip = $this->processAttributeToRaw($cell, 'tooltip', '', true);
if ($tooltip)
{
$tooltipHtml = " data-xf-init=\"tooltip\" title=\"{$tooltip}\"";
}
else
{
$tooltipHtml = '';
}
$submit = $this->processAttributeToRaw($cell, 'submit', '', true);
if ($submit)
{
$submitHtml = ' data-xf-click="submit"';
if ($submit != 'true')
{
$submitHtml .= ' data-target="' . $submit . '"';
}
if ($inputType == 'checkbox')
{
$labelClass .= ' iconic--toggle';
if (!$selected)
{
$rowClass = $rowClass . ' dataList-row--disabled';
}
}
}
else
{
$submitHtml = '';
}
$html = $hiddenHtml
. "<label class=\"{$labelClass}\"{$tooltipHtml}{$submitHtml}>"
. "<input type=\"{$inputType}\" name=\"{$name}\" value=\"{$value}\"{$checkedHtml}{$disabledHtml} /><i aria-hidden=\"true\"></i>"
. "</label>";
}
else if ($type == 'popup')
{
$label = (isset($cell['label']) && strlen(trim($cell['label']))) ? $cell['label'] : $this->phrase('actions');
$outerHtml = '<a data-xf-click="menu" class="menuTrigger" role="button" tabindex="0" aria-expanded="false" aria-haspopup="true">' . $label . '</a>'
. $html;
$html = $outerHtml;
}
else if ($type == 'main')
{
$label = (isset($cell['label']) && strlen(trim($cell['label']))) ? $cell['label'] : null;
if ($label !== null)
{
$hint = (isset($cell['hint']) && strlen(trim($cell['hint']))) ? $cell['hint'] : null;
$explain = (isset($cell['explain']) && strlen(trim($cell['explain']))) ? $cell['explain'] : null;
if (!empty($cell['dir']))
{
$label = '<span dir="' . htmlspecialchars($cell['dir']) . '">' . $label . '</span>';
$explainDirAttr = ' dir="' . htmlspecialchars($cell['dir']) . '"';
}
else
{
$explainDirAttr = '';
}
$html = '<div class="dataList-mainRow">'
. $label
. ($hint !== null ? " <span class=\"dataList-hint\" dir=\"auto\">{$hint}</span>" : '') . '</div>'
. ($explain !== null ? "\n<div class=\"dataList-subRow\"{$explainDirAttr}>{$explain}</div>" : '');
}
unset($cell['dir']);
}
if (isset($cell['hash']) && strlen(trim($cell['hash'])))
{
$html = '<span class="u-anchorTarget" id="'
. htmlspecialchars($this->app->getRedirectHash($cell['hash']))
. '"></span>'
. $html;
}
unset($cell['hash']);
if (!strlen($html))
{
$html = ' ';
}
$isAction = ($type == 'action' || $type == 'delete');
$href = isset($cell['href']) ? htmlspecialchars($cell['href']) : '';
if ($href)
{
$target = $this->processAttributeToRaw($cell, 'target', '', true);
if ($target)
{
$target = " target=\"{$target}\"";
}
if ($type == 'delete')
{
$class .= ' dataList-cell--iconic dataList-cell--alt';
$tooltip = $this->processAttributeToRaw($cell, 'tooltip', '');
if (!$tooltip)
{
$tooltip = $this->phrase('delete');
}
$tooltip = $this->filterForAttr($this, $tooltip, $null);
$html = "<a href=\"{$href}\" class=\"iconic iconic--delete dataList-delete\" data-xf-init=\"tooltip\" title=\"{$tooltip}\" data-xf-click=\"overlay\"{$target}><i aria-hidden=\"true\"></i></a>";
}
else
{
if (!$isAction)
{
$class .= ' dataList-cell--link';
}
$overlay = $this->processAttributeToRaw($cell, 'overlay', '', true);
if ($overlay)
{
$overlay = " data-xf-click=\"overlay\"";
foreach ($this->overlayClickOptions AS $attributeName)
{
if (isset($cell[$attributeName]))
{
$attributeValue = $this->processAttributeToRaw($cell, $attributeName, '', true);
$overlay .= " $attributeName=\"{$attributeValue}\"";
}
}
if (isset($cell['overlaycache']))
{
$overlayCache = $this->processAttributeToRaw($cell, 'overlaycache', '', true);
$overlay .= " data-cache=\"{$overlayCache}\"";
}
}
$html = "<a href=\"{$href}\" {$overlay}{$target}>{$html}</a>";
}
}
if ($isAction)
{
$class .= ' dataList-cell--action';
}
if ($type == 'popup')
{
$class .= ' dataList-cell--alt dataList-cell--link dataList-cell--min';
}
else if ($type == 'main')
{
$class .= ' dataList-cell--main';
}
unset($cell['href'], $cell['label'], $cell['explain'], $cell['hint']);
$unhandledAttrs = $this->processUnhandledAttributes($cell);
$tag = ($rowType == 'header' ? 'th' : 'td');
return "<{$tag} class=\"dataList-cell{$class}\"{$unhandledAttrs}>{$html}</{$tag}>";
}
protected function addToClassAttribute(array &$options, $class, $key = 'class')
{
if (!isset($options[$key]))
{
$options[$key] = '';
}
if (strlen($options[$key]))
{
$options[$key] .= " $class";
}
else
{
$options[$key] = $class;
}
}
protected function appendClassList($existingClasses, $classList, $formatter = '')
{
if (!$classList)
{
return $existingClasses;
}
$classList = preg_replace('#[^a-z0-9_ -]#i', '', $classList);
foreach (Arr::stringToArray($classList, '#\s+#') 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':
case 'show':
case 'hide':
$phrase = 'button.' . $icon;
break;
default:
$phrase = $fallback;
}
return $phrase ? $this->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 (\Throwable $e)
{
if (\XF::$debugMode)
{
throw $e;
}
$this->app->logException($e, false, 'Error rendering navigation: ');
$output = null;
}
restore_error_handler();
return $output;
}
public function renderWidgetClosure(\Closure $widgetHandler, array $options = [])
{
set_error_handler([$this, 'handleTemplateError']);
try
{
$vars = $this->defaultParams;
$vars['context'] = $options['context'] ?? [];
$output = $widgetHandler($this, $vars, $options);
}
catch (\Throwable $e)
{
if (\XF::$debugMode)
{
throw $e;
}
$this->app->logException($e, false, 'Error rendering widget: ');
$output = null;
}
restore_error_handler();
return $output;
}
public function renderUnfurl(\XF\Entity\UnfurlResult $result, array $options = [])
{
$options = array_replace([
'noFollowUrl' => true,
'noProxy' => false,
'simpleUnfurl' => false
], $options);
$formatter = $this->app->stringFormatter();
$linkInfo = $formatter->getLinkClassTarget($result->url);
$rels = [];
if (!$linkInfo['trusted'] && $options['noFollowUrl'])
{
$rels[] = 'nofollow';
$rels[] = 'ugc';
}
if ($linkInfo['target'])
{
$rels[] = 'noopener';
}
$proxyUrl = '';
$imageUrl = $result->image_url;
$iconUrl = $result->favicon_url;
if (!$options['noProxy'])
{
$proxyUrl = $formatter->getProxiedUrlIfActive('link', $result->url);
if ($imageUrl)
{
$linkInfo = $formatter->getLinkClassTarget($imageUrl);
if (!$linkInfo['local'])
{
$imageUrl = $formatter->getProxiedUrlIfActiveExtended('image', $imageUrl, ['return_error' => 1]);
if (!$imageUrl)
{
$imageUrl = $result->image_url;
}
}
}
if ($iconUrl)
{
$linkInfo = $formatter->getLinkClassTarget($iconUrl);
if (!$linkInfo['local'])
{
$iconUrl = $formatter->getProxiedUrlIfActiveExtended('image', $iconUrl, ['return_error' => 1]);
if (!$iconUrl)
{
$iconUrl = $result->favicon_url;
}
}
}
}
$viewParams = [
'linkInfo' => $linkInfo,
'rels' => $rels,
'proxyUrl' => $proxyUrl,
'result' => $result,
'imageUrl' => $imageUrl,
'faviconUrl' => $iconUrl,
'simple' => $options['simpleUnfurl']
];
return $this->renderTemplate('public:bb_code_tag_url_unfurl', $viewParams);
}
}