* @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|array{class:class-string}|array{__class:class-string} $type * @param array $params * @return T * @since 5.9.0 */ public function createFunction(string|array $type, array $params = []): object { $class = is_string($type) ? $type : ($type['__class'] ?? $type['class'] ?? null); if (!$class) { throw new InvalidArgumentException('No class specified for create().'); } // String and object templates (title formats, URI formats, system messages, etc.) are authored // via the control panel/project config rather than the filesystem, so create() isn't allowed there if (Craft::$app->getView()->getIsRenderingStringTemplate()) { throw new InvalidArgumentException('create() cannot be used in string or object templates.'); } $blocklist = [ AttributeTypecastBehavior::class, DirectoryIterator::class, DOMDocument::class, XMLReader::class, XSLTProcessor::class, SoapClient::class, GuzzleClient::class, PDO::class, mysqli::class, Imagick::class, FnStream::class, Process::class, SimpleXMLElement::class, Reflector::class, SQLite3::class, XMLWriter::class, ZipArchive::class, ]; foreach ($blocklist as $c) { if (is_a($class, $c, true)) { throw new InvalidArgumentException(sprintf('create() cannot be used to create instances of %s.', $class)); } } if (StringHelper::startsWith(ltrim($class, '\\'), 'Spl', false)) { throw new InvalidArgumentException(sprintf('create() cannot be used to create instances of %s.', $class)); } if (StringHelper::endsWith(rtrim($class, '\\'), 'Iterator', false)) { throw new InvalidArgumentException(sprintf('create() cannot be used to create instances of %s.', $class)); } /** @var BaseObject */ return Craft::createObject($type, $params); } /** * Generates a base64-encoded [data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs) for the given file path or asset. * * @param string|Asset $file A file path on an asset * @param string|null $mimeType The file’s MIME type. If `null` then it will be determined automatically. * @return string The data URL * @throws InvalidConfigException if `$file` is an invalid file path, or an asset with a missing/invalid volume ID * @throws AssetException if a stream could not be created for the asset * @since 3.5.13 */ public function dataUrlFunction(Asset|string $file, ?string $mimeType = null): string { try { if ($file instanceof Asset) { return $file->getDataUrl(); } return Html::dataUrl(Craft::getAlias($file), $mimeType); } catch (InvalidArgumentException $e) { Craft::warning($e->getMessage(), __METHOD__); return ''; } } /** * Converts an input to a [[\DateTime]] instance. * * @param TwigEnvironment $env * @param DateTimeInterface|string|array|null $date A date, or null to use the current time * @param DateTimeZone|string|false|null $timezone The target timezone, `null` to use the default, `false` to leave unchanged * @return DateTimeInterface */ public function dateFunction(TwigEnvironment $env, mixed $date = null, mixed $timezone = null): DateTimeInterface { // Support for date/time arrays if (is_array($date)) { $date = DateTimeHelper::toDateTime($date, false, false); if ($date === false) { throw new InvalidArgumentException('Invalid date passed to date() function'); } } return $env->getExtension(CoreExtension::class)->convertDate($date, $timezone); } /** * Displays a variable(s). * * @param array $context * @param ...$vars * @return string * @since 4.4.0 */ public function dumpFunction(array $context, ...$vars): string { if (!$vars) { $vars = [TemplateHelper::contextWithoutTemplate($context)]; } $output = ''; foreach ($vars as $var) { ob_start(); Craft::dump($var); $output .= str_replace('', '', 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 `` element. * (This argument is deprecated. The `|attr` filter should be used instead.) * @return string */ public function svgFunction(Asset|string $svg, ?bool $sanitize = null, ?bool $namespace = null, ?string $class = null): string { $svg = Html::svg($svg, $sanitize, $namespace); if ($class !== null) { Craft::$app->getDeprecator()->log('svg()-class', 'The `class` argument of the `svg()` Twig function has been deprecated. The `|attr` filter should be used instead.'); try { $svg = Html::modifyTagAttributes($svg, [ 'class' => $class, ]); } catch (InvalidArgumentException $e) { Craft::warning('Unable to add a class to the SVG: ' . $e->getMessage(), __METHOD__); } } return $svg; } /** * Generates a complete HTML tag. * * @param string $type the tag type ('p', 'div', etc.) * @param array $attributes the HTML tag attributes in terms of name-value pairs. * If `text` is supplied, the value will be HTML-encoded and included as the contents of the tag. * If 'html' is supplied, the value will be included as the contents of the tag, without getting encoded. * @return string * @since 3.3.0 */ public function tagFunction(string $type, string|array $attributes = ''): string { if (is_array($attributes)) { $html = ArrayHelper::remove($attributes, 'html'); $text = ArrayHelper::remove($attributes, 'text'); } else { $text = $attributes; $attributes = []; } $html ??= Html::encode($text ?? ''); return Html::tag($type, $html, $attributes); } /** * Registers global variables. */ public function getGlobals(): array { $isInstalled = Craft::$app->getIsInstalled(); $generalConfig = Craft::$app->getConfig()->getGeneral(); $setPasswordRequestPath = $generalConfig->getSetPasswordRequestPath(); if ($isInstalled && !Craft::$app->getUpdates()->getIsCraftUpdatePending()) { $sitesService = Craft::$app->getSites(); $currentSite = $sitesService->getCurrentSite(); $primarySite = $sitesService->getPrimarySite(); $currentUser = Craft::$app->getUser()->getIdentity(); $siteName = Craft::t('site', $currentSite->getName()); $siteUrl = $currentSite->getBaseUrl(); $systemName = Craft::$app->getSystemName(); } else { $currentSite = $primarySite = $currentUser = $siteName = $siteUrl = $systemName = null; } return [ 'craft' => new CraftVariable(), 'currentSite' => $currentSite, 'currentUser' => $currentUser, 'primarySite' => $primarySite, 'siteName' => $siteName, 'siteUrl' => $siteUrl, 'systemName' => $systemName, 'view' => $this->view, 'devMode' => App::devMode(), 'SORT_ASC' => SORT_ASC, 'SORT_DESC' => SORT_DESC, 'SORT_REGULAR' => SORT_REGULAR, 'SORT_NUMERIC' => SORT_NUMERIC, 'SORT_STRING' => SORT_STRING, 'SORT_LOCALE_STRING' => SORT_LOCALE_STRING, 'SORT_NATURAL' => SORT_NATURAL, 'SORT_FLAG_CASE' => SORT_FLAG_CASE, 'PHP_INT_MAX' => PHP_INT_MAX, 'POS_HEAD' => View::POS_HEAD, 'POS_BEGIN' => View::POS_BEGIN, 'POS_END' => View::POS_END, 'POS_READY' => View::POS_READY, 'POS_LOAD' => View::POS_LOAD, 'isInstalled' => $isInstalled, 'loginUrl' => UrlHelper::siteUrl($generalConfig->getLoginPath()), 'logoutUrl' => UrlHelper::siteUrl($generalConfig->getLogoutPath()), 'setPasswordUrl' => $setPasswordRequestPath !== null ? UrlHelper::siteUrl($setPasswordRequestPath) : null, 'now' => DateTimeHelper::now(), 'today' => DateTimeHelper::today(), 'tomorrow' => DateTimeHelper::tomorrow(), 'yesterday' => DateTimeHelper::yesterday(), ]; } }