* @since 3.0.0
*/
class Extension extends AbstractExtension implements GlobalsInterface
{
/**
* @since 5.4.3
*/
public static function arraySome(TwigEnvironment $env, $array, $arrow, $isSandboxed = false)
{
CoreExtension::checkArrow($isSandboxed, $arrow, 'has some', 'operator');
return CoreExtension::arraySome($env, $array, $arrow, $isSandboxed);
}
/**
* @since 5.4.3
*/
public static function arrayEvery(TwigEnvironment $env, $array, $arrow, $isSandboxed = false)
{
CoreExtension::checkArrow($isSandboxed, $arrow, 'has every', 'operator');
return CoreExtension::arrayEvery($env, $array, $arrow, $isSandboxed);
}
/**
* @var View|null
*/
protected ?View $view = null;
/**
* @var TwigEnvironment|null
*/
protected ?TwigEnvironment $environment = null;
/**
* Constructor
*
* @param View $view
* @param TwigEnvironment $environment
*/
public function __construct(View $view, TwigEnvironment $environment)
{
$this->view = $view;
$this->environment = $environment;
}
/**
* @inheritdoc
*/
public function getNodeVisitors(): array
{
$eventTagAdder = new EventTagAdder($this->view);
return [
new Profiler(),
new GetAttrAdjuster(),
new EventTagFinder($eventTagAdder),
$eventTagAdder,
];
}
/**
* @inheritdoc
*/
public function getTokenParsers(): array
{
return [
new CacheTokenParser(),
new DeprecatedTokenParser(),
new DdTokenParser(),
new DumpTokenParser(),
new ExitTokenParser(),
new ExpiresTokenParser(),
new HeaderTokenParser(),
new HookTokenParser(),
new RegisterResourceTokenParser('css', TemplateHelper::class . '::css', [
'allowTagPair' => true,
'allowOptions' => true,
]),
new RegisterResourceTokenParser('html', 'Craft::$app->getView()->registerHtml', [
'allowTagPair' => true,
'allowPosition' => true,
]),
new RegisterResourceTokenParser('js', TemplateHelper::class . '::js', [
'allowTagPair' => true,
'allowPosition' => true,
'allowRuntimePosition' => true,
'allowOptions' => true,
]),
new RegisterResourceTokenParser('script', 'Craft::$app->getView()->registerScript', [
'allowTagPair' => true,
'allowPosition' => true,
'allowOptions' => true,
'defaultPosition' => View::POS_END,
]),
new NamespaceTokenParser(),
new NavTokenParser(),
new PaginateTokenParser(),
new RedirectTokenParser(),
new RequireAdminTokenParser(),
new RequireEditionTokenParser(),
new RequireLoginTokenParser(),
new RequireGuestTokenParser(),
new RequirePermissionTokenParser(),
new SwitchTokenParser(),
new TagTokenParser(),
];
}
/**
* @inheritdoc
*/
public function getFilters(): array
{
return [
new TwigFilter('address', [$this, 'addressFilter'], ['is_safe' => ['html']]),
new TwigFilter('append', [$this, 'appendFilter'], ['is_safe' => ['html']]),
new TwigFilter('ascii', [StringHelper::class, 'toAscii']),
new TwigFilter('atom', [$this, 'atomFilter'], ['needs_environment' => true]),
new TwigFilter('attr', [$this, 'attrFilter'], ['is_safe' => ['html']]),
new TwigFilter('base64_decode', 'base64_decode'),
new TwigFilter('base64_encode', 'base64_encode'),
new TwigFilter('boolean', 'boolval'),
new TwigFilter('camel', [$this, 'camelFilter']),
new TwigFilter('column', [$this, 'columnFilter'], ['needs_environment' => true, 'needs_is_sandboxed' => true]),
new TwigFilter('contains', [$this, 'containsFilter'], ['needs_is_sandboxed' => true]),
new TwigFilter('currency', [$this, 'currencyFilter']),
new TwigFilter('date', [$this, 'dateFilter'], ['needs_environment' => true]),
new TwigFilter('datetime', [$this, 'datetimeFilter'], ['needs_environment' => true]),
new TwigFilter('default', [$this, 'defaultFilter'], ['node_class' => DefaultFilter::class]),
new TwigFilter('diff', 'array_diff'),
new TwigFilter('duration', [DateTimeHelper::class, 'humanDuration']),
new TwigFilter('encenc', [$this, 'encencFilter']),
new TwigFilter('explodeClass', [Html::class, 'explodeClass']),
new TwigFilter('explodeStyle', [Html::class, 'explodeStyle']),
new TwigFilter('filesize', [$this, 'filesizeFilter']),
new TwigFilter('filter', [$this, 'filterFilter'], ['needs_environment' => true, 'needs_is_sandboxed' => true]),
new TwigFilter('filterByValue', [$this, 'filterByValueFilter'], ['needs_is_sandboxed' => true, 'deprecation_info' => new DeprecatedCallableInfo('craftcms/cms', '3.5.0', 'where')]),
new TwigFilter('firstWhere', [$this, 'firstWhereFilter'], ['needs_is_sandboxed' => true]),
new TwigFilter('flatten', [Arr::class, 'flatten']),
new TwigFilter('group', [$this, 'groupFilter'], ['needs_is_sandboxed' => true]),
new TwigFilter('hash', [$this, 'hashFilter']),
new TwigFilter('httpdate', [$this, 'httpdateFilter'], ['needs_environment' => true]),
new TwigFilter('id', [Html::class, 'id']),
new TwigFilter('index', [$this, 'indexFilter'], ['needs_is_sandboxed' => true]),
new TwigFilter('indexOf', [$this, 'indexOfFilter']),
new TwigFilter('integer', 'intval'),
new TwigFilter('intersect', 'array_intersect'),
new TwigFilter('float', 'floatval'),
new TwigFilter('json_encode', [$this, 'jsonEncodeFilter']),
new TwigFilter('json_decode', [Json::class, 'decode']),
new TwigFilter('kebab', [$this, 'kebabFilter']),
new TwigFilter('length', [$this, 'lengthFilter'], ['needs_environment' => true]),
new TwigFilter('lcfirst', [$this, 'lcfirstFilter']),
new TwigFilter('literal', [$this, 'literalFilter']),
new TwigFilter('map', [$this, 'mapFilter'], ['needs_environment' => true, 'needs_is_sandboxed' => true]),
new TwigFilter('markdown', [$this, 'markdownFilter'], ['is_safe' => ['html']]),
new TwigFilter('md', [$this, 'markdownFilter'], ['is_safe' => ['html']]),
new TwigFilter('merge', [$this, 'mergeFilter']),
new TwigFilter('money', [$this, 'moneyFilter']),
new TwigFilter('multisort', [$this, 'multisortFilter'], ['needs_is_sandboxed' => true]),
new TwigFilter('namespace', [$this->view, 'namespaceInputs'], ['is_safe' => ['html']]),
new TwigFilter('namespaceAttributes', [Html::class, 'namespaceAttributes'], ['is_safe' => ['html']]),
new TwigFilter('ns', [$this->view, 'namespaceInputs'], ['is_safe' => ['html']]),
new TwigFilter('namespaceInputName', [$this->view, 'namespaceInputName']),
new TwigFilter('namespaceInputId', [$this->view, 'namespaceInputId']),
new TwigFilter('number', [$this, 'numberFilter']),
new TwigFilter('parseAttr', [$this, 'parseAttrFilter']),
new TwigFilter('parseRefs', [$this, 'parseRefsFilter'], ['is_safe' => ['html']]),
new TwigFilter('pascal', [$this, 'pascalFilter']),
new TwigFilter('percentage', [$this, 'percentageFilter']),
new TwigFilter('prepend', [$this, 'prependFilter'], ['is_safe' => ['html']]),
new TwigFilter('purify', [$this, 'purifyFilter'], ['is_safe' => ['html']]),
new TwigFilter('push', [$this, 'pushFilter']),
new TwigFilter('reduce', [$this, 'reduceFilter'], ['needs_environment' => true, 'needs_is_sandboxed' => true]),
new TwigFilter('removeClass', [$this, 'removeClassFilter'], ['is_safe' => ['html']]),
new TwigFilter('replace', [$this, 'replaceFilter']),
new TwigFilter('rss', [$this, 'rssFilter'], ['needs_environment' => true]),
new TwigFilter('snake', [$this, 'snakeFilter']),
new TwigFilter('sort', [$this, 'sortFilter'], ['needs_environment' => true, 'needs_is_sandboxed' => true]),
new TwigFilter('string', 'strval'),
new TwigFilter('time', [$this, 'timeFilter'], ['needs_environment' => true]),
new TwigFilter('timestamp', [$this, 'timestampFilter']),
new TwigFilter('translate', [$this, 'translateFilter']),
new TwigFilter('truncate', [$this, 'truncateFilter']),
new TwigFilter('t', [$this, 'translateFilter']),
new TwigFilter('ucfirst', [$this, 'ucfirstFilter']),
new TwigFilter('ucwords', [$this, 'ucwordsFilter'], ['needs_environment' => true]),
new TwigFilter('unique', 'array_unique'),
new TwigFilter('unshift', [$this, 'unshiftFilter']),
new TwigFilter('values', 'array_values'),
new TwigFilter('where', [$this, 'whereFilter'], ['needs_is_sandboxed' => true]),
new TwigFilter('widont', [$this, 'widontFilter'], ['is_safe' => ['html']]),
new TwigFilter('without', [$this, 'withoutFilter']),
new TwigFilter('withoutKey', [$this, 'withoutKeyFilter']),
];
}
/**
* @inheritdoc
*/
public function getTests(): array
{
return [
new TwigTest('array', fn($obj): bool => is_array($obj)),
new TwigTest('boolean', fn($obj): bool => is_bool($obj)),
new TwigTest('callable', fn($obj): bool => is_callable($obj)),
new TwigTest('countable', fn($obj): bool => is_countable($obj)),
new TwigTest('empty', function($obj): bool {
if ($obj instanceof Model) {
// assume the IteratorAggregate implementation was not intentional
return false;
}
return CoreExtension::testEmpty($obj);
}),
new TwigTest('float', fn($obj): bool => is_float($obj)),
new TwigTest('instance of', fn($obj, $class) => $obj instanceof $class),
new TwigTest('integer', fn($obj): bool => is_int($obj)),
new TwigTest('missing', fn($obj) => $obj instanceof MissingComponentInterface),
new TwigTest('numeric', fn($obj): bool => is_numeric($obj)),
new TwigTest('object', fn($obj): bool => is_object($obj)),
new TwigTest('resource', fn($obj): bool => is_resource($obj)),
new TwigTest('scalar', fn($obj): bool => is_scalar($obj)),
new TwigTest('string', fn($obj): bool => is_string($obj)),
];
}
/**
* @inheritdoc
*/
public function getExpressionParsers(): array
{
return [
new BinaryOperatorExpressionParser(HasSomeBinary::class, 'has some', 20),
new BinaryOperatorExpressionParser(HasEveryBinary::class, 'has every', 20),
];
}
/**
* @param Address|null $address
* @param array $options
* @param FormatterInterface|null $formatter
* @return string
* @since 4.0.0
*/
public function addressFilter(?Address $address, array $options = [], FormatterInterface $formatter = null): string
{
if ($address === null) {
return '';
}
return Craft::$app->getAddresses()->formatAddress($address, $options, $formatter);
}
/**
* Outputs a value from a Money object.
*
* @param Money|null $money
* @param string|null $formatLocale
* @return string|null
*/
public function moneyFilter(?Money $money, ?string $formatLocale = null): ?string
{
if ($money === null) {
return null;
}
return MoneyHelper::toString($money, $formatLocale);
}
/**
* Translates the given message.
*
* @param mixed $message The message to be translated.
* @param string|array|null $category the message category.
* @param array|string|null $params The parameters that will be used to replace the corresponding placeholders in the message.
* @param string|null $language The language code (e.g. `en-US`, `en`). If this is null, the current
* [[\yii\base\Application::language|application language]] will be used.
* @return string the translated message.
*/
public function translateFilter(mixed $message, mixed $category = null, mixed $params = null, ?string $language = null): string
{
// The front end site doesn't need to specify the category
/** @noinspection CallableParameterUseCaseInTypeContextInspection */
if (is_array($category)) {
/** @noinspection CallableParameterUseCaseInTypeContextInspection */
$language = $params;
/** @noinspection CallableParameterUseCaseInTypeContextInspection */
$params = $category;
$category = 'site';
} elseif ($category === null) {
$category = 'site';
}
if ($params === null) {
$params = [];
}
try {
return Craft::t($category, (string)$message, $params, $language);
} catch (InvalidConfigException) {
return $message;
}
}
/**
* Truncates the string to a given length, while ensuring that it does not split words.
*
* @param string $string The string to truncate
* @param int $length The maximum number of characters for the truncated string
* @param string $suffix The string that should be appended to `$string`, if it must be truncated
* @param bool $splitSingleWord Whether to split up `$string` if it only contains one word
* @return string The truncated string
* @since 3.5.10
*/
public function truncateFilter(string $string, int $length, string $suffix = '…', bool $splitSingleWord = true): string
{
// Override default behavior where the substring would be returned in this case
if ($string === '' || $length <= 0) {
return $string;
}
return StringHelper::safeTruncate($string, $length, $suffix, $splitSingleWord);
}
/**
* Uppercases the first character of a multibyte string.
*
* @param mixed $string The multibyte string.
* @return string The string with the first character converted to upercase.
* @deprecated in 5.6.0
*/
public function ucfirstFilter(mixed $string): string
{
Craft::$app->getDeprecator()->log('ucfirst', 'The `|ucfirst` filter has been deprecated. Use `|capitalize` instead.');
return StringHelper::upperCaseFirst((string)$string);
}
/**
* Uppercases the first character of each word in a string.
*
* @param TwigEnvironment $env
* @param string $string
* @return string
* @deprecated in 3.5.0
*/
public function ucwordsFilter(TwigEnvironment $env, string $string): string
{
Craft::$app->getDeprecator()->log('ucwords', 'The `|ucwords` filter has been deprecated. Use `|title` instead.');
$charset = $env->getCharset();
if ($charset) {
return mb_convert_case($string, MB_CASE_TITLE, $charset);
}
return ucwords(strtolower($string));
}
/**
* Lowercases the first character of a multibyte string.
*
* @param mixed $string The multibyte string.
* @return string The string with the first character converted to lowercase.
*/
public function lcfirstFilter(mixed $string): string
{
return StringHelper::lowercaseFirst((string)$string);
}
/**
* kebab-cases a string.
*
* @param mixed $string The string
* @param string $glue The string used to glue the words together (default is a hyphen)
* @param bool $lower Whether the string should be lowercased (default is true)
* @param bool $removePunctuation Whether punctuation marks should be removed (default is true)
* @return string The kebab-cased string
*/
public function kebabFilter(mixed $string, string $glue = '-', bool $lower = true, bool $removePunctuation = true): string
{
return StringHelper::toKebabCase((string)$string, $glue, $lower, $removePunctuation);
}
/**
* camelCases a string.
*
* @param mixed $string The string
* @return string
*/
public function camelFilter(mixed $string): string
{
return StringHelper::toCamelCase((string)$string);
}
/**
* Throws a RuntimeError if the given name/key is a string containing a "." character and the environment is
* sandboxed.
*
* @param bool $isSandboxed
* @param mixed $name
* @param string $filterName
* @throws RuntimeError
*/
private function preventDottedNameInSandbox(bool $isSandboxed, mixed $name, string $filterName): void
{
if (!$isSandboxed || !is_string($name)) {
return;
}
foreach (['.', '[', ']'] as $char) {
if (str_contains($name, $char)) {
throw new RuntimeError(sprintf('The key name passed to the "%s" filter must not contain any "%s" characters in sandbox mode.', $filterName, $char));
}
}
}
/**
* @param bool $isSandboxed
* @param mixed $array
* @param mixed $name
* @param bool $keepKeys
* @return array
* @throws RuntimeError
* @throws SecurityError if `$array` (or one of its elements) is an object and `$name` isn’t an allowed property/method in a sandboxed environment
*/
public function columnFilter(TwigEnvironment $env, bool $isSandboxed, mixed $array, mixed $name, bool $keepKeys = true): array
{
$this->preventDottedNameInSandbox($isSandboxed, $name, 'column');
if (!$isSandboxed || !is_string($name)) {
return ArrayHelper::getColumn($array, $name, $keepKeys);
}
$sandbox = $env->getExtension(SandboxExtension::class);
if (is_object($array) && !$array instanceof Traversable) {
// $array is a single object rather than a list of rows, so extracting a “column” from it would
// otherwise mean enumerating its public properties without any sandbox checks. Treat this the same
// way the template would if it had accessed the property directly, e.g. `array.name`.
$sandbox->checkPropertyAllowed($array, $name);
return [ArrayHelper::getValue($array, $name)];
}
// Check each object element’s property/method the same way the template would if it had accessed it
// directly, e.g. `element.name`, rather than letting ArrayHelper::getValue() read it unchecked.
$result = [];
foreach ($array as $k => $element) {
if (is_object($element)) {
$sandbox->checkPropertyAllowed($element, $name);
}
if ($keepKeys) {
$result[$k] = ArrayHelper::getValue($element, $name);
} else {
$result[] = ArrayHelper::getValue($element, $name);
}
}
return $result;
}
/**
* @param bool $isSandboxed
* @param iterable $array
* @param callable|string $key
* @param mixed $value
* @param bool $strict
* @return bool
* @throws RuntimeError
*/
public function containsFilter(bool $isSandboxed, iterable $array, callable|string $key, mixed $value = true, bool $strict = false): bool
{
$this->preventDottedNameInSandbox($isSandboxed, $key, 'contains');
return ArrayHelper::contains($array, $key, $value, $strict);
}
/**
* @param bool $isSandboxed
* @param iterable $array
* @param callable|string $key
* @param mixed $value
* @param bool $strict
* @return mixed
* @throws RuntimeError
*/
public function firstWhereFilter(bool $isSandboxed, iterable $array, callable|string $key, mixed $value = true, bool $strict = false): mixed
{
$this->preventDottedNameInSandbox($isSandboxed, $key, 'firstWhere');
return ArrayHelper::firstWhere($array, $key, $value, $strict);
}
/**
* PascalCases a string.
*
* @param mixed $string The string
* @return string
*/
public function pascalFilter(mixed $string): string
{
return StringHelper::toPascalCase((string)$string);
}
/**
* snake_cases a string.
*
* @param mixed $string The string
* @return string
*/
public function snakeFilter(mixed $string): string
{
return StringHelper::toSnakeCase((string)$string);
}
/**
* Sorts an array.
*
* @param TwigEnvironment $env
* @param iterable $array
* @param string|callable|null $arrow
* @return array
* @throws RuntimeError
* @since 4.3.2
*/
public function sortFilter(TwigEnvironment $env, bool $isSandboxed, iterable $array, string|callable|null $arrow = null): array
{
CoreExtension::checkArrow($isSandboxed, $arrow, 'sort', 'filter');
return CoreExtension::sort($env, $isSandboxed, $array, $arrow);
}
/**
* Reduces an array.
*
* @param TwigEnvironment $env
* @param mixed $array
* @param mixed $arrow
* @param mixed $initial
* @return mixed
* @throws RuntimeError
* @since 4.4.16
*/
public function reduceFilter(TwigEnvironment $env, bool $isSandboxed, mixed $array, mixed $arrow, mixed $initial = null): mixed
{
CoreExtension::checkArrow($isSandboxed, $arrow, 'reduce', 'filter');
return CoreExtension::reduce($env, $isSandboxed, $array, $arrow, $initial);
}
/**
* Maps an array.
*
* @param TwigEnvironment $env
* @param mixed $array
* @param mixed $arrow
* @return array
* @throws RuntimeError
* @since 4.4.16
*/
public function mapFilter(TwigEnvironment $env, bool $isSandboxed, mixed $array, mixed $arrow = null): array
{
CoreExtension::checkArrow($isSandboxed, $arrow, 'map', 'filter');
return CoreExtension::map($env, $isSandboxed, $array, $arrow);
}
/**
* Formats the value as a currency number.
*
* @param mixed $value
* @param string|null $currency
* @param array $options
* @param array $textOptions
* @param bool $stripZeros
* @return string
* @since 3.6.0
*/
public function currencyFilter(mixed $value, ?string $currency = null, array $options = [], array $textOptions = [], bool $stripZeros = false): string
{
if ($value === null || $value === '') {
return '';
}
try {
return Craft::$app->getFormatter()->asCurrency($value, $currency, $options, $textOptions, $stripZeros);
} catch (InvalidArgumentException) {
return $value;
}
}
/**
* Formats the value in bytes as a size in human-readable form, for example `12 kB`.
*
* @param mixed $value
* @param int|null $decimals
* @param array $options
* @param array $textOptions
* @return string
* @since 3.6.0
*/
public function filesizeFilter(mixed $value, ?int $decimals = null, array $options = [], array $textOptions = []): string
{
if ($value === null || $value === '') {
return '';
}
try {
return Craft::$app->getFormatter()->asShortSize($value, $decimals, $options, $textOptions);
} catch (InvalidArgumentException) {
return $value;
}
}
/**
* Formats the value as a decimal number.
*
* @param mixed $value
* @param int|null $decimals
* @param array $options
* @param array $textOptions
* @param string|null $locale
* @return string
* @since 3.6.0
*/
public function numberFilter(
mixed $value,
?int $decimals = null,
array $options = [],
array $textOptions = [],
?string $locale = null,
): string {
if ($value === null || $value === '') {
return '';
}
$formatter = $locale ? Craft::$app->getI18n()->getLocaleById($locale)->getFormatter() : Craft::$app->getFormatter();
try {
if (!$formatter->willBeMisrepresented($value)) {
return $formatter->asDecimal($value, $decimals, $options, $textOptions);
}
} catch (InvalidArgumentException) {
}
return $value;
}
/**
* Formats the value as a percent number with "%" sign.
*
* @param mixed $value
* @param int|null $decimals
* @param array $options
* @param array $textOptions
* @return string
* @since 3.6.0
*/
public function percentageFilter(mixed $value, ?int $decimals = null, array $options = [], array $textOptions = []): string
{
if ($value === null || $value === '') {
return '';
}
try {
return Craft::$app->getFormatter()->asPercent($value, $decimals, $options, $textOptions);
} catch (InvalidArgumentException) {
return $value;
}
}
/**
* Formats the value as a human-readable timestamp.
*
* @param mixed $value
* @param string|null $format
* @param bool $withPreposition
* @return string
* @since 3.6.0
*/
public function timestampFilter(mixed $value, ?string $format = null, bool $withPreposition = false): string
{
if ($value === null || $value === '') {
$value = DateTimeHelper::now();
}
try {
return Craft::$app->getFormatter()->asTimestamp($value, $format, $withPreposition);
} catch (InvalidArgumentException) {
return $value;
}
}
/**
* This method will JSON encode a variable. We're overriding Twig's default implementation to set some stricter
* encoding options on text/html/xml requests.
*
* @param mixed $value The value to JSON encode.
* @param int|null $options Either null or a bitmask consisting of JSON_HEX_QUOT, JSON_HEX_TAG, JSON_HEX_AMP,
* JSON_HEX_APOS, JSON_NUMERIC_CHECK, JSON_PRETTY_PRINT, JSON_UNESCAPED_SLASHES,
* JSON_FORCE_OBJECT
* @param int $depth The maximum depth
* @return string|false The JSON encoded value.
*/
public function jsonEncodeFilter(mixed $value, ?int $options = null, int $depth = 512): string|false
{
if ($options === null) {
if (
!Craft::$app->getRequest()->getIsConsoleRequest() &&
in_array(Craft::$app->getResponse()->getContentType(), ['text/html', 'application/xhtml+xml'], true)
) {
$options = JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_QUOT;
} else {
$options = 0;
}
}
// todo: remove the $depth arg in Craft 5
if ($depth !== 512) {
return json_encode($value, $options, $depth);
}
return Json::encode($value, $options);
}
/**
* @param bool $isSandboxed
* @param iterable $array
* @param callable|string $key
* @param mixed $value
* @param bool $strict
* @param bool $keepKeys
* @return array
* @throws RuntimeError
*/
public function whereFilter(bool $isSandboxed, iterable $array, callable|string $key, mixed $value = true, bool $strict = false, bool $keepKeys = true): array
{
$this->preventDottedNameInSandbox($isSandboxed, $key, 'where');
return ArrayHelper::where($array, $key, $value, $strict, $keepKeys);
}
/**
* Inserts a non-breaking space between the last two words of a string.
*
* @param string $string
* @return string
* @since 3.7.0
*/
public function widontFilter(string $string): string
{
return Html::widont($string);
}
/**
* Returns an array without certain values.
*
* @param mixed $arr
* @param mixed $exclude
* @param bool $strict
* @return array
*/
public function withoutFilter(mixed $arr, mixed $exclude, bool $strict = false): array
{
$arr = (array)$arr;
if (!is_array($exclude)) {
$exclude = [$exclude];
}
foreach ($exclude as $value) {
ArrayHelper::removeValue($arr, $value, $strict);
}
return $arr;
}
/**
* Returns an array without a certain key.
*
* @param mixed $arr
* @param string|string[] $key
* @return array
* @since 3.2.0
*/
public function withoutKeyFilter(mixed $arr, array|string $key): array
{
$arr = (array)$arr;
if (!is_array($key)) {
$key = [$key];
}
foreach ($key as $k) {
ArrayHelper::remove($arr, $k);
}
return $arr;
}
/**
* Parses an HTML tag to find its attributes.
*
* @param string $tag The HTML tag to parse
* @return array The parsed HTML tag attributes
* @since 3.4.0
*/
public function parseAttrFilter(string $tag): array
{
try {
return Html::parseTagAttributes($tag, 0, $start, $end, true);
} catch (InvalidArgumentException $e) {
return [];
}
}
/**
* Parses a string for reference tags.
*
* @param mixed $str
* @param int|null $siteId
* @return string
*/
public function parseRefsFilter(mixed $str, ?int $siteId = null): string
{
return Craft::$app->getElements()->parseRefs((string)$str, $siteId);
}
/**
* Prepends HTML to the beginning of given tag.
*
* @param string $tag The HTML tag that `$html` should be prepended to
* @param string $html The HTML to prepend to `$tag`.
* @param string|null $ifExists What to do if `$tag` already contains a child of the same type as the element
* defined by `$html`. Set to `'keep'` if no action should be taken, or `'replace'` if it should be replaced
* by `$tag`.
* @return string The modified HTML
* @since 3.3.0
*/
public function prependFilter(string $tag, string $html, ?string $ifExists = null): string
{
try {
return Html::prependToTag($tag, $html, $ifExists);
} catch (InvalidArgumentException $e) {
Craft::warning($e->getMessage(), __METHOD__);
return $tag;
}
}
/**
* Purifies the given HTML using HTML Purifier.
*
* @param string|null $html The HTML to be purified
* @param string|array|null $config The HTML Purifier config. This can either be the name of a JSON file within
* `config/htmlpurifier/` (sans `.json` extension) or a config array.
* @return string|null The purified HTML
* @since 3.4.0
*/
public function purifyFilter(?string $html, array|string|null $config = null): ?string
{
if ($html === null) {
return null;
}
if (is_string($config)) {
$path = Craft::$app->getPath()->getConfigPath() . DIRECTORY_SEPARATOR . 'htmlpurifier' .
DIRECTORY_SEPARATOR . $config . '.json';
$config = null;
if (!is_file($path)) {
Craft::warning("No HTML Purifier config found at $path.");
} else {
try {
$config = Json::decode(file_get_contents($path));
} catch (InvalidArgumentException) {
Craft::warning("Invalid HTML Purifier config at $path.");
}
}
}
return HtmlPurifier::process($html, $config);
}
/**
* Pushes one or more items onto the end of an array.
*
* @param array $array
* @return array
* @since 3.5.0
*/
public function pushFilter(array $array): array
{
$args = func_get_args();
array_shift($args);
array_push($array, ...$args);
return $array;
}
/**
* Prepends one or more items to the beginning of an array.
*
* @param array $array
* @return array
* @since 3.5.0
*/
public function unshiftFilter(array $array): array
{
$args = func_get_args();
array_shift($args);
array_unshift($array, ...$args);
return $array;
}
/**
* Removes a class (or classes) from the given HTML tag.
*
* @param string $tag The HTML tag to modify
* @param string|string[] $class
* @return string The modified HTML tag
* @since 3.7.0
*/
public function removeClassFilter(string $tag, array|string $class): string
{
try {
$oldClasses = Html::parseTagAttributes($tag)['class'] ?? [];
$newClasses = array_filter($oldClasses, fn(string $oldClass) => is_string($class) ? $oldClass !== $class : !in_array($oldClass, $class, true));
$newTag = Html::modifyTagAttributes($tag, ['class' => false]);
if (!empty($newClasses)) {
$newTag = Html::modifyTagAttributes($newTag, ['class' => $newClasses]);
}
return $newTag;
} catch (InvalidArgumentException $e) {
return $tag;
}
}
/**
* Replaces Twig's |replace filter, adding support for passing in separate
* search and replace arrays.
*
* @param mixed $str
* @param mixed $search
* @param mixed $replace
* @param bool|null $regex
* @return mixed
*/
public function replaceFilter(mixed $str, mixed $search, mixed $replace = null, ?bool $regex = null): mixed
{
if ($str === null) {
return '';
}
if ($search instanceof Traversable) {
$search = iterator_to_array($search);
}
// Are they using the standard Twig syntax?
if (is_array($search) && $replace === null) {
// If there aren’t any regex patterns, we can safely use strtr()
if (
$regex === false ||
(
$regex === null &&
!ArrayHelper::contains(array_keys($search), fn(string $str) => $this->isRegex($str))
)
) {
return strtr($str, $search);
}
} else {
$search = [$search => $replace];
}
foreach ($search as $s => $r) {
// Is this a regular expression?
if ($regex ?? $this->isRegex($s)) {
$str = preg_replace($s, $r, $str);
} else {
// Otherwise use str_replace
$str = str_replace($s, $r, $str);
}
}
return $str;
}
private function isRegex(string $str): bool
{
if (!preg_match('/^\/([^\r\n]+)\/([imsxADSUXJun]*)$/', $str, $match)) {
return false;
}
// make sure there's no unescaped slashes within it
if (preg_match('/(?getExtension(CoreExtension::class)->formatDate($date, $format, $timezone);
}
// Is this a custom PHP date format?
if ($format !== null && !in_array($format, [Locale::LENGTH_SHORT, Locale::LENGTH_MEDIUM, Locale::LENGTH_LONG, Locale::LENGTH_FULL], true)) {
if (str_starts_with($format, 'icu:')) {
$format = substr($format, 4);
} else {
$format = StringHelper::ensureLeft($format, 'php:');
}
}
$date = $env->getExtension(CoreExtension::class)->convertDate($date, $timezone);
$formatter = $locale ? Craft::$app->getI18n()->getLocaleById($locale)->getFormatter() : Craft::$app->getFormatter();
$fmtTimeZone = $formatter->timeZone;
$formatter->timeZone = $timezone !== null ? $date->getTimezone()->getName() : $formatter->timeZone;
$formatted = $formatter->asDate(DateTime::createFromInterface($date), $format);
$formatter->timeZone = $fmtTimeZone;
return $formatted;
}
/**
* Appends HTML to the end of the given tag.
*
* @param string $tag The HTML tag that `$html` should be appended to
* @param string $html The HTML to append to `$tag`.
* @param string|null $ifExists What to do if `$tag` already contains a child of the same type as the element
* defined by `$html`. Set to `'keep'` if no action should be taken, or `'replace'` if it should be replaced
* by `$tag`.
* @return string The modified HTML
* @since 3.3.0
*/
public function appendFilter(string $tag, string $html, ?string $ifExists = null): string
{
try {
return Html::appendToTag($tag, $html, $ifExists);
} catch (InvalidArgumentException $e) {
Craft::warning($e->getMessage(), __METHOD__);
return $tag;
}
}
/**
* Converts a date to the Atom format.
*
* @param TwigEnvironment $env
* @param DateTime|DateTimeInterface|string $date A date
* @param DateTimeZone|string|false|null $timezone The target timezone, null to use the default, false to leave unchanged
* @return string The formatted date
*/
public function atomFilter(TwigEnvironment $env, mixed $date, mixed $timezone = null): string
{
return $env->getExtension(CoreExtension::class)->formatDate($date, DateTime::ATOM, $timezone);
}
/**
* Modifies a HTML tag’s attributes, supporting the same attribute definitions as [[Html::renderTagAttributes()]].
*
* @param string $tag The HTML tag whose attributes should be modified.
* @param array $attributes The attributes to be added to the tag.
* @return string The modified HTML tag.
* @since 3.3.0
*/
public function attrFilter(string $tag, array $attributes): string
{
try {
return Html::modifyTagAttributes($tag, $attributes);
} catch (InvalidArgumentException $e) {
return $tag;
}
}
/**
* Converts a date to the RSS format.
*
* @param TwigEnvironment $env
* @param DateTime|DateTimeInterface|string $date A date
* @param DateTimeZone|string|false|null $timezone The target timezone, null to use the default, false to leave unchanged
* @return string The formatted date
*/
public function rssFilter(TwigEnvironment $env, mixed $date, mixed $timezone = null): string
{
return $env->getExtension(CoreExtension::class)->formatDate($date, DateTime::RSS, $timezone);
}
/**
* Formats the value as a time.
*
* @param TwigEnvironment $env
* @param DateTimeInterface|string $date A date
* @param string|null $format The target format, null to use the default
* @param DateTimeZone|string|false|null $timezone The target timezone, null to use the default, false to leave unchanged
* @param string|null $locale The target locale the date should be formatted for. By default, the current system locale will be used.
* @param bool $withTimeZone Whether the time zone abbreviation should be appended to the formatted time
* @return string
*/
public function timeFilter(
TwigEnvironment $env,
mixed $date,
?string $format = null,
mixed $timezone = null,
?string $locale = null,
bool $withTimeZone = false,
): string {
// Is this a custom PHP date format?
if ($format !== null && !in_array($format, [Locale::LENGTH_SHORT, Locale::LENGTH_MEDIUM, Locale::LENGTH_LONG, Locale::LENGTH_FULL], true)) {
if (str_starts_with($format, 'icu:')) {
$format = substr($format, 4);
} else {
$format = StringHelper::ensureLeft($format, 'php:');
}
}
$date = DateTime::createFromInterface($env->getExtension(CoreExtension::class)->convertDate($date, $timezone));
$formatter = $locale ? Craft::$app->getI18n()->getLocaleById($locale)->getFormatter() : Craft::$app->getFormatter();
$fmtTimeZone = $formatter->timeZone;
$formatter->timeZone = $timezone !== null ? $date->getTimezone()->getName() : $formatter->timeZone;
$formatted = $formatter->asTime($date, $format, $withTimeZone);
$formatter->timeZone = $fmtTimeZone;
return $formatted;
}
/**
* Formats the value as a date+time.
*
* @param TwigEnvironment $env
* @param DateTimeInterface|string $date A date
* @param string|null $format The target format, null to use the default
* @param DateTimeZone|string|false|null $timezone The target timezone, null to use the default, false to leave unchanged
* @param string|null $locale The target locale the date should be formatted for. By default, the current system locale will be used.
* @param bool $withTimeZone Whether the time zone abbreviation should be appended to the formatted time
* @return string
*/
public function datetimeFilter(
TwigEnvironment $env,
mixed $date,
?string $format = null,
mixed $timezone = null,
?string $locale = null,
bool $withTimeZone = false,
): string {
// Is this a custom PHP date format?
if ($format !== null && !in_array($format, [Locale::LENGTH_SHORT, Locale::LENGTH_MEDIUM, Locale::LENGTH_LONG, Locale::LENGTH_FULL], true)) {
if (str_starts_with($format, 'icu:')) {
$format = substr($format, 4);
} else {
$format = StringHelper::ensureLeft($format, 'php:');
}
}
$date = $env->getExtension(CoreExtension::class)->convertDate($date, $timezone);
$formatter = $locale ? Craft::$app->getI18n()->getLocaleById($locale)->getFormatter() : Craft::$app->getFormatter();
$fmtTimeZone = $formatter->timeZone;
$formatter->timeZone = $timezone !== null ? $date->getTimezone()->getName() : $formatter->timeZone;
$formatted = $formatter->asDatetime(DateTime::createFromInterface($date), $format, $withTimeZone);
$formatter->timeZone = $fmtTimeZone;
return $formatted;
}
/**
* Returns the passed-in value if it’s not empty; otherwise, the provided default value.
*
* @param mixed $value
* @param mixed $default
* @return mixed
* @since 5.10.0
*/
public static function defaultFilter(mixed $value, mixed $default = ''): mixed
{
if ($value instanceof Model) {
// assume the IteratorAggregate implementation was not intentional
return $value;
}
if (CoreExtension::testEmpty($value)) {
return $default;
}
return $value;
}
/**
* Encrypts and base64-encodes a string.
*
* @param mixed $str the string
* @return string
*/
public function encencFilter(mixed $str): string
{
return StringHelper::encenc((string)$str);
}
/**
* Filters an array.
*
* @param TwigEnvironment $env
* @param iterable $arr
* @param callable|null $arrow
* @return array
* @throws RuntimeError
*/
public function filterFilter(TwigEnvironment $env, bool $isSandboxed, iterable $arr, ?callable $arrow = null): array
{
/** @var array|Traversable $arr */
if ($arrow === null) {
if ($arr instanceof Traversable) {
$arr = iterator_to_array($arr);
}
return array_filter($arr);
}
CoreExtension::checkArrow($isSandboxed, $arrow, 'filter', 'filter');
$filtered = CoreExtension::filter($env, $isSandboxed, $arr, $arrow);
if (is_array($filtered)) {
return $filtered;
}
return iterator_to_array($filtered);
}
/**
* @param bool $isSandboxed
* @param iterable $array
* @param callable|string $key
* @param mixed $value
* @param bool $strict
* @param bool $keepKeys
* @return array
* @throws RuntimeError
* @deprecated in 3.5.0. [[whereFilter()]] should be used instead.
*/
public function filterByValueFilter(bool $isSandboxed, iterable $array, callable|string $key, mixed $value = true, bool $strict = false, bool $keepKeys = true): array
{
$this->preventDottedNameInSandbox($isSandboxed, $key, 'filterByValue');
return ArrayHelper::where($array, $key, $value, $strict, $keepKeys);
}
/**
* Groups an array by the results of an arrow function, or value of a property.
*
* @param bool $isSandboxed
* @param iterable $arr
* @param callable|string $arrow The arrow function or property name that determines the group the item should be grouped in
* @return array[] The grouped items
* @throws RuntimeError if $arr is not of type array or Traversable
*/
public function groupFilter(bool $isSandboxed, iterable $arr, callable|string $arrow): array
{
$groups = [];
if (is_string($arrow)) {
// No need to call checkArrow() here since strings are always interpreted as nested fields,
// which should be passed to renderObjectTemplate() as `{name}`
$template = '{' . $arrow . '}';
$view = Craft::$app->getView();
foreach ($arr as $item) {
$groupKey = $view->renderObjectTemplate($template, $item);
$groups[$groupKey][] = $item;
}
} else {
CoreExtension::checkArrow($isSandboxed, $arrow, 'group', 'filter');
foreach ($arr as $key => $item) {
$groupKey = (string)$arrow($item, $key);
$groups[$groupKey][] = $item;
}
}
return $groups;
}
/**
* Hashes the given data
*
* @param string $data The data to be hashed
* @param string|null $algo The hashing algorithm to use, e.g. `md5` or `sha256`.
* @return string
* @since 5.9.0
*/
public function hashFilter(string $data, ?string $algo = null): string
{
if ($algo === null) {
return Craft::$app->getSecurity()->hashData($data);
}
return hash($algo, $data);
}
/**
* Converts a date to the HTTP format (used by HTTP headers such as `Expires`).
*
* @param TwigEnvironment $env
* @param DateTime|DateTimeInterface|string $date A date
* @param DateTimeZone|string|false|null $timezone The target timezone, null to use the default, false to leave unchanged
* @return string The formatted date
* @since 3.6.10
*/
public function httpdateFilter(TwigEnvironment $env, mixed $date, mixed $timezone = null): string
{
return $env->getExtension(CoreExtension::class)->formatDate($date, DateTime::RFC7231, $timezone);
}
/**
* @param bool $isSandboxed
* @param mixed $array
* @param mixed $key
* @param mixed $groups
* @return array
* @throws RuntimeError
*/
public function indexFilter(bool $isSandboxed, mixed $array, mixed $key, mixed $groups = []): array
{
$this->preventDottedNameInSandbox($isSandboxed, $key, 'index');
$groups = (array)$groups;
foreach ($groups as $group) {
$this->preventDottedNameInSandbox($isSandboxed, $group, 'index');
}
return ArrayHelper::index($array, $key, $groups);
}
/**
* Returns the index of an item in a string or array, or -1 if it cannot be found.
*
* @param mixed $haystack
* @param mixed $needle
* @param int|null $default
* @return int|null
*/
public function indexOfFilter(mixed $haystack, mixed $needle, ?int $default = -1): ?int
{
if (is_string($haystack)) {
$index = strpos($haystack, (string) $needle);
} elseif (is_array($haystack)) {
$index = array_search($needle, $haystack, false);
} elseif (is_object($haystack) && $haystack instanceof IteratorAggregate) {
$index = false;
foreach ($haystack as $i => $item) {
if ($item == $needle) {
$index = $i;
break;
}
}
}
/** @noinspection UnSafeIsSetOverArrayInspection - FP */
if (isset($index) && $index !== false) {
return $index;
}
return $default;
}
/**
* Returns the length of an array, or the total result count of a query.
*
* @param TwigEnvironment $env
* @param mixed $value A variable
* @return int The length of the value
* @since 4.2.0
*/
public function lengthFilter(TwigEnvironment $env, mixed $value): int
{
if ($value instanceof QueryInterface) {
return $value->count();
}
return CoreExtension::length($env->getCharset(), $value);
}
/**
* Escapes commas and asterisks in a string so they are not treated as special characters in
* [[Db::parseParam()]].
*
* @param mixed $value The param value.
* @return string The escaped param value.
*/
public function literalFilter(mixed $value): string
{
return Db::escapeParam((string)$value);
}
/**
* Parses text through Markdown.
*
* @param mixed $markdown The markdown text to parse
* @param string|null $flavor The markdown flavor to use. Can be 'original', 'gfm' (GitHub-Flavored Markdown),
* 'gfm-comment' (GFM with newlines converted to `
`s),
* or 'extra' (Markdown Extra). Default is 'original'.
* @param bool $inlineOnly Whether to only parse inline elements, omitting any `
` tags.
* @param bool $encode Whether special characters should be pre-encoded, before parsing the text as Markdown.
* Note that the `flavor` cannot be specified if this option is used.
* @return string
*/
public function markdownFilter(
mixed $markdown,
?string $flavor = null,
bool $inlineOnly = false,
bool $encode = false,
): string {
if ($encode) {
if ($flavor !== null && !in_array($flavor, ['original', 'pre-encoded'])) {
throw new InvalidArgumentException('The Markdown flavor cannot be specified when passing `encode=true`.');
}
$markdown = Html::encode($markdown);
$flavor = 'pre-encoded';
}
if ($inlineOnly) {
return Markdown::processParagraph((string)$markdown, $flavor);
}
return Markdown::process((string)$markdown, $flavor);
}
/**
* Merges an array with another one.
*
* @param iterable $arr1 An array
* @param iterable $arr2 An array
* @param bool $recursive Whether the arrays should be merged recursively using [[\yii\helpers\BaseArrayHelper::merge()]]
* @return array The merged array
* @since 3.4.0
*/
public function mergeFilter(iterable $arr1, iterable $arr2, bool $recursive = false): array
{
if ($arr1 instanceof Traversable) {
$arr1 = iterator_to_array($arr1);
}
if ($arr2 instanceof Traversable) {
$arr2 = iterator_to_array($arr2);
}
if ($recursive) {
return ArrayHelper::merge($arr1, $arr2);
}
return CoreExtension::merge($arr1, $arr2);
}
/**
* Duplicates an array and sorts it with [[\craft\helpers\ArrayHelper::multisort()]].
*
* @param bool $isSandboxed
* @param mixed $array the array to be sorted. The array will be modified after calling this method.
* @param string|callable|array $key the key(s) to be sorted by. This refers to a key name of the sub-array
* elements, a property name of the objects, or an anonymous function returning the values for comparison
* purpose. The anonymous function signature should be: `function($item)`.
* To sort by multiple keys, provide an array of keys here.
* @param int|array $direction the sorting direction. It can be either `SORT_ASC` or `SORT_DESC`.
* When sorting by multiple keys with different sorting directions, use an array of sorting directions.
* @param int|array $sortFlag the PHP sort flag. Valid values include
* `SORT_REGULAR`, `SORT_NUMERIC`, `SORT_STRING`, `SORT_LOCALE_STRING`, `SORT_NATURAL` and `SORT_FLAG_CASE`.
* Please refer to [PHP manual](https://php.net/manual/en/function.sort.php)
* for more details. When sorting by multiple keys with different sort flags, use an array of sort flags.
* @return array the sorted array
* @throws InvalidArgumentException if the $direction or $sortFlag parameters do not have
* correct number of elements as that of $key.
* @throws RuntimeError
*/
public function multisortFilter(bool $isSandboxed, mixed $array, mixed $key, int|array $direction = SORT_ASC, int|array $sortFlag = SORT_REGULAR): array
{
foreach (is_array($key) ? $key : [$key] as $k) {
$this->preventDottedNameInSandbox($isSandboxed, $k, 'multisort');
}
// Prevent multisort() from modifying the original array
$array = array_merge($array);
ArrayHelper::multisort($array, $key, $direction, $sortFlag);
return $array;
}
/**
* @inheritdoc
*/
public function getFunctions(): array
{
return [
new TwigFunction('actionUrl', [UrlHelper::class, 'actionUrl']),
new TwigFunction('alias', [Craft::class, 'getAlias']),
new TwigFunction('ceil', 'ceil'),
new TwigFunction('className', 'get_class'),
new TwigFunction('clone', [$this, 'cloneFunction']),
new TwigFunction('collect', [$this, 'collectFunction']),
new TwigFunction('combine', 'array_combine'),
new TwigFunction('configure', [Craft::class, 'configure']),
new TwigFunction('cpUrl', [UrlHelper::class, 'cpUrl']),
new TwigFunction('create', [$this, 'createFunction']),
new TwigFunction('dataUrl', [$this, 'dataUrlFunction']),
new TwigFunction('date', [$this, 'dateFunction'], ['needs_environment' => true]),
new TwigFunction('dump', [$this, 'dumpFunction'], ['is_safe' => ['html'], 'needs_context' => true, 'is_variadic' => true]),
new TwigFunction('encodeUrl', [UrlHelper::class, 'encodeUrl']),
new TwigFunction('entryType', [$this, 'entryTypeFunction']),
new TwigFunction('expression', [$this, 'expressionFunction']),
new TwigFunction('fieldValueSql', [$this, 'fieldValueSqlFunction']),
new TwigFunction('floor', 'floor'),
new TwigFunction('getenv', [App::class, 'env']),
new TwigFunction('gql', [$this, 'gqlFunction']),
new TwigFunction('parseEnv', [App::class, 'parseEnv']),
new TwigFunction('parseBooleanEnv', [App::class, 'parseBooleanEnv']),
new TwigFunction('plugin', [$this, 'pluginFunction']),
new TwigFunction('randomString', [StringHelper::class, 'randomString']),
new TwigFunction('raw', [TemplateHelper::class, 'raw']),
new TwigFunction('renderObjectTemplate', [$this, 'renderObjectTemplate']),
new TwigFunction('seq', [$this, 'seqFunction']),
new TwigFunction('shuffle', [$this, 'shuffleFunction']),
new TwigFunction('siteUrl', [UrlHelper::class, 'siteUrl']),
new TwigFunction('url', [UrlHelper::class, 'url']),
new TwigFunction('uuid', [StringHelper::class, 'UUID']),
// Element authorization functions
new TwigFunction('canCreateDrafts', fn(ElementInterface $element, ?User $user = null) => Craft::$app->getElements()->canCreateDrafts($element, $user)),
new TwigFunction('canDelete', fn(ElementInterface $element, ?User $user = null) => Craft::$app->getElements()->canDelete($element, $user)),
new TwigFunction('canDeleteForSite', fn(ElementInterface $element, ?User $user = null) => Craft::$app->getElements()->canDeleteForSite($element, $user)),
new TwigFunction('canDuplicate', fn(ElementInterface $element, ?User $user = null) => Craft::$app->getElements()->canDuplicate($element, $user)),
new TwigFunction('canSave', fn(ElementInterface $element, ?User $user = null) => Craft::$app->getElements()->canSave($element, $user)),
new TwigFunction('canView', fn(ElementInterface $element, ?User $user = null) => Craft::$app->getElements()->canView($element, $user)),
// HTML generation functions
new TwigFunction('actionInput', [Html::class, 'actionInput'], ['is_safe' => ['html']]),
new TwigFunction('attr', [Html::class, 'renderTagAttributes'], ['is_safe' => ['html']]),
new TwigFunction('csrfInput', [Html::class, 'csrfInput'], ['is_safe' => ['html']]),
new TwigFunction('failMessageInput', [Html::class, 'failMessageInput'], ['is_safe' => ['html']]),
new TwigFunction('h', [$this, 'headingFunction'], ['is_safe' => ['html']]),
new TwigFunction('h1', fn(array|string $attributes = '') => $this->headingFunction(1, $attributes), ['is_safe' => ['html']]),
new TwigFunction('h2', fn(array|string $attributes = '') => $this->headingFunction(2, $attributes), ['is_safe' => ['html']]),
new TwigFunction('h3', fn(array|string $attributes = '') => $this->headingFunction(3, $attributes), ['is_safe' => ['html']]),
new TwigFunction('h4', fn(array|string $attributes = '') => $this->headingFunction(4, $attributes), ['is_safe' => ['html']]),
new TwigFunction('h5', fn(array|string $attributes = '') => $this->headingFunction(5, $attributes), ['is_safe' => ['html']]),
new TwigFunction('h6', fn(array|string $attributes = '') => $this->headingFunction(6, $attributes), ['is_safe' => ['html']]),
new TwigFunction('heading', [$this, 'headingFunction'], ['is_safe' => ['html']]),
new TwigFunction('hiddenInput', [Html::class, 'hiddenInput'], ['is_safe' => ['html']]),
new TwigFunction('input', [Html::class, 'input'], ['is_safe' => ['html']]),
new TwigFunction('ol', [Html::class, 'ol'], ['is_safe' => ['html']]),
new TwigFunction('redirectInput', [Html::class, 'redirectInput'], ['is_safe' => ['html']]),
new TwigFunction('successMessageInput', [Html::class, 'successMessageInput'], ['is_safe' => ['html']]),
new TwigFunction('svg', [$this, 'svgFunction'], ['is_safe' => ['html']]),
new TwigFunction('tag', [$this, 'tagFunction'], ['is_safe' => ['html']]),
new TwigFunction('ul', [Html::class, 'ul'], ['is_safe' => ['html']]),
// DOM event functions
new TwigFunction('head', [$this->view, 'head']),
new TwigFunction('beginBody', [$this->view, 'beginBody']),
new TwigFunction('endBody', [$this->view, 'endBody']),
];
}
/**
* Returns a clone of the given variable.
*
* @param mixed $var
* @return mixed
*/
public function cloneFunction(mixed $var): mixed
{
return clone $var;
}
/**
* Returns a new collection.
*
* @param mixed $var
* @return Collection
* @since 4.0.0
*/
public function collectFunction(mixed $var): Collection
{
$collection = Collection::make($var);
// If all the items are elements, return an ElementCollection instead
if ($collection->isNotEmpty() && $collection->doesntContain(fn($item) => !$item instanceof ElementInterface)) {
return ElementCollection::make($collection);
}
return $collection;
}
/**
* Creates a new object.
*
* @template T of object
* @param class-string', '', ob_get_clean());
}
return $output;
}
/**
* @param string $handle
* @return EntryType
* @since 5.0.0
*/
public function entryTypeFunction(string $handle): EntryType
{
$entryType = Craft::$app->getEntries()->getEntryTypeByHandle($handle);
if ($entryType === null) {
throw new InvalidArgumentException("Invalid entry type handle: $handle");
}
return $entryType;
}
/**
* @param mixed $expression
* @param array $params
* @param array $config
* @return Expression
* @since 3.1.0
*/
public function expressionFunction(mixed $expression, array $params = [], array $config = []): Expression
{
return new Expression($expression, $params, $config);
}
/**
* @param FieldLayoutProviderInterface $provider
* @param string $fieldHandle
* @param string|null $key
* @return string|null
* @since 5.0.0
*/
public function fieldValueSqlFunction(FieldLayoutProviderInterface $provider, string $fieldHandle, ?string $key = null): ?string
{
return $provider->getFieldLayout()->getFieldByHandle($fieldHandle)->getValueSql($key);
}
/**
* Executes a GraphQL query against the full schema.
*
* @param string $query The GraphQL query
* @param array|null $variables Query variables
* @param string|null $operationName The operation name
* @return array The query result
* @since 3.3.12
*/
public function gqlFunction(string $query, ?array $variables = null, ?string $operationName = null): array
{
$schema = Gql::createFullAccessSchema();
return Craft::$app->getGql()->executeQuery($schema, $query, $variables, $operationName);
}
/**
* Returns a plugin instance by its handle.
*
* @param string $handle The plugin handle
* @return PluginInterface|null The plugin, or `null` if it’s not installed
* @since 3.1.0
*/
public function pluginFunction(string $handle): ?PluginInterface
{
return Craft::$app->getPlugins()->getPlugin($handle);
}
/**
* Returns the next number in a given sequence, or the current number in the sequence.
*
* @param string $name The sequence name.
* @param int|null $length The minimum string length that should be returned. (Numbers that are too short will be left-padded with `0`s.)
* @param bool $next Whether the next number in the sequence should be returned (and the sequence should be incremented).
* If set to `false`, the current number in the sequence will be returned instead.
* @return int|string
* @throws Throwable if reasons
* @throws Exception
* @since 3.0.31
*/
public function seqFunction(string $name, ?int $length = null, bool $next = true): int|string
{
if ($next) {
return Sequence::next($name, $length);
}
return Sequence::current($name, $length);
}
/**
* @param string $template
* @param mixed $object
* @return string
*/
public function renderObjectTemplate(string $template, mixed $object): string
{
return Craft::$app->getView()->renderObjectTemplate($template, $object);
}
/**
* Shuffles an array.
*
* @param iterable $arr
* @return array
*/
public function shuffleFunction(iterable $arr): array
{
/** @var array|Traversable $arr */
if ($arr instanceof Traversable) {
$arr = iterator_to_array($arr, false);
} else {
$arr = array_merge($arr);
}
shuffle($arr);
return $arr;
}
public function headingFunction(int $level, array|string $attributes = ''): string
{
if ($level < 1 || $level > 6) {
throw new InvalidArgumentException("Invalid heading level: $level");
}
return $this->tagFunction("h$level", $attributes);
}
/**
* Returns the contents of a given SVG file.
*
* @param string|Asset $svg An SVG asset, a file path, or raw SVG markup
* @param bool|null $sanitize Whether the SVG should be sanitized of potentially
* malicious scripts. By default, the SVG will only be sanitized if an asset
* or markup is passed in. (File paths are assumed to be safe.)
* @param bool|null $namespace Whether class names and IDs within the SVG
* should be namespaced to avoid conflicts with other elements in the DOM.
* By default, the SVG will only be namespaced if an asset or markup is passed in.
* @param string|null $class A CSS class name that should be added to the `