diff --git a/CHANGELOG b/CHANGELOG index b105964dd4f..2d9ccd490c6 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,6 @@ -# 3.22.2 (2026-XX-XX) +# 3.23.0 (2026-XX-XX) - * n/a + * Add the `html_attr` function and `html_attr_merge` as well as `html_attr_type` filters # 3.22.2 (2025-12-14) diff --git a/doc/filters/html_attr_merge.rst b/doc/filters/html_attr_merge.rst new file mode 100644 index 00000000000..84af0f0be60 --- /dev/null +++ b/doc/filters/html_attr_merge.rst @@ -0,0 +1,141 @@ +``html_attr_merge`` +=================== + +.. _html_attr_merge: + +.. versionadded:: 3.23 + + The ``html_attr_merge`` filter was added in Twig 3.23. + +The ``html_attr_merge`` filter merges multiple mappings that represent +HTML attribute values. Such mappings contain the names of the HTML attributes +as keys, and the corresponding values represent the attributes' values. + +It is primarily designed for working with arrays that are passed to the +:ref:`html_attr` function. It closely resembles the :doc:`merge <../filters/merge>` +filter, but has different merge behavior for values that are iterables +themselves, as it will merge such values in turn. + +The filter returns a new merged array: + +.. code-block:: twig + + {% set base = {class: ['btn'], type: 'button'} %} + {% set variant = {class: ['btn-primary'], disabled: true} %} + + {% set merged = base|html_attr_merge(variant) %} + + {# merged is now: { + class: ['btn', 'btn-primary'], + type: 'button', + disabled: true + } #} + +The filter accepts multiple arrays as arguments and merges them from left to right: + +.. code-block:: twig + + {% set merged = base|html_attr_merge(variant1, variant2, variant3) %} + +A common use case is to build attribute mappings conditionally by merging multiple +parts based on conditions. To make this conditional merging more convenient, filter +arguments that are ``false``, ``null`` or empty arrays are ignored: + +.. code-block:: twig + + {% set button_attrs = { + type: 'button', + class: ['btn'] + }|html_attr_merge( + variant == 'primary' ? { class: ['btn-primary'] }, + variant == 'secondary' ? { class: ['btn-secondary'] }, + size == 'large' ? { class: ['btn-lg'] }, + size == 'small' ? { class: ['btn-sm'] }, + disabled ? { disabled: true, class: ['btn-disabled'] }, + loading ? { 'aria-busy': 'true', class: ['btn-loading'] }, + ) %} + + {# Example with variant='primary', size='large', disabled=false, loading=true: + + The false values (secondary variant, small size, disabled state) are ignored. + + button_attrs is: + { + type: 'button', + class: ['btn', 'btn-primary', 'btn-lg', 'btn-loading'], + 'aria-busy': 'true' + } + #} + +Merging Rules +------------- + +The filter follows these rules when merging attribute values: + +**Scalar values**: Later values override earlier ones. + +.. code-block:: twig + + {% set result = {id: 'old'}|html_attr_merge({id: 'new'}) %} + {# result: {id: 'new'} #} + +**Array values**: Arrays are merged like in PHP's ``array_merge`` function - numeric keys are +appended, non-numeric keys replace. + +.. code-block:: twig + + {# Numeric keys (appended): #} + {% set result = {class: ['btn']}|html_attr_merge({class: ['btn-primary']}) %} + {# result: {class: ['btn', 'btn-primary']} #} + + {# Non-numeric keys (replaced): #} + {% set result = {class: {base: 'btn', size: 'small'}}|html_attr_merge({class: {variant: 'primary', size: 'large'}}) %} + {# result: {class: {base: 'btn', size: 'large', variant: 'primary'}} #} + +.. note:: + + Remember, attribute mappings passed to or returned from this filter are regular + Twig mappings after all. If you want to completely replace an attribute value + that is an iterable with another value, you can use the :doc:`merge <../filters/merge>` + filter to do that. + +**``MergeableInterface`` implementations**: For advanced use cases, attribute values can be objects +that implement the ``MergeableInterface``. These objects can define their own, custom merge +behavior that takes precedence over the default rules. See the docblocks in that interface +for details. + +.. note:: + + The ``html_attr_merge`` filter is part of the ``HtmlExtension`` which is not + installed by default. Install it first: + + .. code-block:: bash + + $ composer require twig/html-extra + + Then, on Symfony projects, install the ``twig/extra-bundle``: + + .. code-block:: bash + + $ composer require twig/extra-bundle + + Otherwise, add the extension explicitly on the Twig environment:: + + use Twig\Extra\Html\HtmlExtension; + + $twig = new \Twig\Environment(...); + $twig->addExtension(new HtmlExtension()); + +Arguments +--------- + +The filter accepts a variadic list of arguments to merge. Each argument can be: + +* A map of attributes +* ``false`` or ``null`` (ignored, useful for conditional merging) +* An empty string ``''`` (ignored, to support implicit else in ternary operators) + +.. seealso:: + + :ref:`html_attr`, + :doc:`html_attr_type` diff --git a/doc/filters/html_attr_type.rst b/doc/filters/html_attr_type.rst new file mode 100644 index 00000000000..af5dfa4c72d --- /dev/null +++ b/doc/filters/html_attr_type.rst @@ -0,0 +1,123 @@ +``html_attr_type`` +================== + +.. _html_attr_type: + +.. versionadded:: 3.23 + + The ``html_attr_type`` filter was added in Twig 3.23. + +The ``html_attr_type`` filter converts arrays into specialized attribute value +objects that implement custom rendering logic. It is designed for use +with the :ref:`html_attr` function for attributes where +the attribute value follows special formatting rules. + +.. code-block:: html+twig + + + + {# Output: #} + +Available Types +--------------- + +Space-Separated Token List (``sst``) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Used for attributes that expect space-separated values, like ``class`` or +``aria-labelledby``: + +.. code-block:: html+twig + + {% set classes = ['btn', 'btn-primary']|html_attr_type('sst') %} + + + + {# Output: #} + +This is the default type used when the :ref:`html_attr` function encounters an +array value (except for ``style`` attributes). + +Comma-Separated Token List (``cst``) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Used for attributes that expect comma-separated values, like ``srcset`` or +``sizes``: + +.. code-block:: html+twig + + + + {# Output: #} + +Inline Style (``style``) +~~~~~~~~~~~~~~~~~~~~~~~~ + +Used for style attributes. Handles both maps (property - value pairs) and sequences (CSS declarations): + +.. code-block:: html+twig + + {# Associative array #} + {% set styles = {color: 'red', 'font-size': '14px'}|html_attr_type('style') %} + +
+ Styled content +
+ + {# Output:
Styled content
#} + + {# Numeric array #} + {% set styles = ['color: red', 'font-size: 14px']|html_attr_type('style') %} + +
+ Styled content +
+ + {# Output:
Styled content
#} + +The ``style`` type is automatically applied by the :ref:`html_attr` function when +it encounters an array value for the ``style`` attribute. + +.. note:: + + The ``html_attr_type`` filter is part of the ``HtmlExtension`` which is not + installed by default. Install it first: + + .. code-block:: bash + + $ composer require twig/html-extra + + Then, on Symfony projects, install the ``twig/extra-bundle``: + + .. code-block:: bash + + $ composer require twig/extra-bundle + + Otherwise, add the extension explicitly on the Twig environment:: + + use Twig\Extra\Html\HtmlExtension; + + $twig = new \Twig\Environment(...); + $twig->addExtension(new HtmlExtension()); + +Arguments +--------- + +* ``value``: The sequence of attributes to convert +* ``type``: The attribute type. One of: + + * ``sst`` (default): Space-separated token list + * ``cst``: Comma-separated token list + * ``style``: Inline CSS styles + +.. seealso:: + + :ref:`html_attr`, + :ref:`html_attr_merge` diff --git a/doc/filters/index.rst b/doc/filters/index.rst index 98a5d62dd6b..9827cdd5a7b 100644 --- a/doc/filters/index.rst +++ b/doc/filters/index.rst @@ -26,6 +26,8 @@ Filters format_datetime format_number format_time + html_attr_merge + html_attr_type html_to_markdown inline_css inky_to_html diff --git a/doc/functions/html_attr.rst b/doc/functions/html_attr.rst new file mode 100644 index 00000000000..5dcd7b369a2 --- /dev/null +++ b/doc/functions/html_attr.rst @@ -0,0 +1,198 @@ +``html_attr`` +============= + +.. _html_attr: + +.. versionadded:: 3.23 + + The ``html_attr`` function was added in Twig 3.23. + +The ``html_attr`` function renders HTML attributes from one or more mappings, +taking care of proper escaping. The mappings contain the names of HTML +attributes as keys, and the corresponding values represent the attributes' +values. + +.. code-block:: html+twig + +
+ Content +
+ + {# Output:
Content
#} + +The function accepts multiple attribute maps. Internally, it uses +:ref:`html_attr_merge` to combine the arguments: + +.. code-block:: html+twig + + {% set base_attrs = {class: ['btn']} %} + {% set variant_attrs = {class: ['btn-primary'], disabled: true} %} + + + + {# Output: #} + +.. note:: + + To make best use of the special merge behavior of ``html_attr_merge`` and + to avoid confusion, you should consistently use iterables (mappings or sequences) + for attributes that can take multiple values, like ``class``, ``srcset`` or ``aria-describedby``. + + Use non-iterable values for attributes that contain a single value only, like + ``id`` or ``href``. + +Shorthand notation for mappings can be particularly helpful: + +.. code-block:: html+twig + + {% set id = 'user-123' %} + {% set href = '/profile' %} + + Profile + + {# Output: Profile #} + +``null`` and Boolean Attribute Values +------------------------------------- + +``null`` values always omit printing an attribute altogether. + +The boolean ``false`` value also omits the attribute altogether, with an +exception for ``aria-*`` attribute names, see below. + +.. code-block:: html+twig + + {# null omits the attribute entirely, and so does false for non-"aria-*" #} + + {# Output: #} + +``true`` will print the attribute with the empty value ``""``. This is XHTML compatible, +and in HTML 5 equivalent to using the short attribute notation without a value. An exception +is made for ``data-*`` and ``aria-*`` attributes, see below. + +.. code-block:: html+twig + + {# true becomes an empty string value #} + + {# Output: , which is equivalent to #} + +Array Values +------------ + +Attribute values that are iterables are automatically converted to space-separated +token lists of the values. Exceptions apply for ``data-*`` and ``style`` attributes, +described further below. + +.. code-block:: html+twig + +
+ Button +
+ + {# Output:
Button
#} + +.. note:: + + This is not bound to the ``class`` attribute name, but works for any attribute. + +You can use the :ref:`html_attr_type` filter to specify a different strategy for +concatenating values (e.g., comma-separated for ``srcset`` attributes). This would +also override the special behavior for ``data-*`` and ``style``. + +WAI-ARIA Attributes +------------------- + +To make it more convenient to work with the `WAI-ARIA type mapping for HTML +_`, boolean values for ``aria-*`` +attributes are converted to strings ``"true"`` and ``"false"``. + +.. code-block:: html+twig + + + + {# Output: #} + +Data Attributes +--------------- + +For ``data-*`` attributes, boolean ``true`` values will be converted to ``"true"``. +Values that are not scalars are automatically JSON-encoded. + +.. code-block:: html+twig + +
+ Content +
+ + {# Output:
Content
#} + +Style Attribute +---------------- + +The ``style`` attribute name has special handling when its value is iterable: + +.. code-block:: html+twig + + {# Non-numeric keys will be used as CSS properties and printed #} +
+ Styled text +
+ + {# Output:
Styled text
#} + + {# Numeric keys will be assumed to have values that are individual CSS declarations #} +
+ Styled text +
+ + {# Output:
Styled text
#} + + {# Merging style attributes #} +
+ Styled text +
+ + {# Output:
Styled text
#} + +.. warning:: + + No additional escaping specific to CSS is applied to key or values from this array. + Do not use it to pass untrusted, user-provided data, neither as key nor as value. + +``AttributeValueInterface`` Implementations +------------------------------------------- + +For advanced use cases, attribute values can be objects that implement the ``AttributeValueInterface``. +These objects can define their own conversion logic for the ``html_attr`` function that will take +precedence over all rules described here. See the docblocks in that interface for details. + +.. note:: + + The ``html_attr`` function is part of the ``HtmlExtension`` which is not + installed by default. Install it first: + + .. code-block:: bash + + $ composer require twig/html-extra + + Then, on Symfony projects, install the ``twig/extra-bundle``: + + .. code-block:: bash + + $ composer require twig/extra-bundle + + Otherwise, add the extension explicitly on the Twig environment:: + + use Twig\Extra\Html\HtmlExtension; + + $twig = new \Twig\Environment(...); + $twig->addExtension(new HtmlExtension()); + +.. seealso:: + + :ref:`html_attr_merge`, + :ref:`html_attr_type` diff --git a/doc/functions/index.rst b/doc/functions/index.rst index 557f6938a30..c0de384bb6c 100644 --- a/doc/functions/index.rst +++ b/doc/functions/index.rst @@ -12,6 +12,7 @@ Functions dump enum enum_cases + html_attr html_classes html_cva include diff --git a/extra/html-extra/HtmlAttr/AttributeValueInterface.php b/extra/html-extra/HtmlAttr/AttributeValueInterface.php new file mode 100644 index 00000000000..590b0f3a8cf --- /dev/null +++ b/extra/html-extra/HtmlAttr/AttributeValueInterface.php @@ -0,0 +1,31 @@ + + */ +interface AttributeValueInterface +{ + /** + * Returns the string representation of the attribute value. The returned value + * will automatically be escaped for the HTML attribute context. + * + * @return string|null the attribute value as a string, or null to omit the attribute + */ + public function getValue(): ?string; +} diff --git a/extra/html-extra/HtmlAttr/InlineStyle.php b/extra/html-extra/HtmlAttr/InlineStyle.php new file mode 100644 index 00000000000..8aa36b6462f --- /dev/null +++ b/extra/html-extra/HtmlAttr/InlineStyle.php @@ -0,0 +1,70 @@ + + */ +final class InlineStyle implements MergeableInterface, AttributeValueInterface +{ + private readonly array $value; + + public function __construct(mixed $value) + { + if (!is_iterable($value)) { + throw new RuntimeError('InlineStyle can only be created from iterable values.'); + } + + $this->value = [...$value]; + } + + public function mergeInto(mixed $previous): mixed + { + if ($previous instanceof self) { + return new self([...$previous->value, ...$this->value]); + } + + if (is_iterable($previous)) { + return new self([...$previous, ...$this->value]); + } + + throw new RuntimeError('Attributes using InlineStyle can only be merged with iterables or other InlineStyle instances.'); + } + + public function appendFrom(mixed $newValue): mixed + { + if (!is_iterable($newValue)) { + throw new RuntimeError('Only iterable values can be appended to InlineStyle.'); + } + + return new self([...$this->value, ...$newValue]); + } + + public function getValue(): ?string + { + $style = ''; + foreach ($this->value as $name => $value) { + if (empty($value) || true === $value) { + continue; + } + if (is_numeric($name)) { + $style .= trim($value, '; ').'; '; + } else { + $style .= $name.': '.$value.'; '; + } + } + + return trim($style) ?: null; + } +} diff --git a/extra/html-extra/HtmlAttr/MergeableInterface.php b/extra/html-extra/HtmlAttr/MergeableInterface.php new file mode 100644 index 00000000000..e32693fa2d4 --- /dev/null +++ b/extra/html-extra/HtmlAttr/MergeableInterface.php @@ -0,0 +1,55 @@ + + */ +interface MergeableInterface +{ + /** + * Merge value from $this with another, previous value. In the merge arguments list, $this is on the right + * hand side of $previous. This is the preferred merge method, so that newer (right) values have control + * over the result. + * + * The `$previous` value is whatever value was present for a particular attribute before. Implementations + * are free to completely ignore this value, effectively implementing "override only" merge behavior. + * They can merge it with their own value, resulting in array-like merge behavior. Or they could throw + * an exception when only particular types can be merged. + * + * Returns the new, resulting value as a new instance. Does not modify either $this not $previous. + */ + public function mergeInto(mixed $previous): mixed; + + /** + * Merge value from $this with a new value. In the merge arguments list, $this is on the left hand side of + * $newValue, but $newValue does not implement this interface itself. + * + * The `$newValue` value is whatever was given in the right hand side merge argument. Implementations are + * free to return either the new value, implementing "override" merge behavior. They could also return + * a value that represents a merge result between their current and the new value. Or they could throw + * an exception when only particular types can be merged. + * + * Returns the new, resulting set as a new instance. Does not modify $this. + */ + public function appendFrom(mixed $newValue): mixed; +} diff --git a/extra/html-extra/HtmlAttr/SeparatedTokenList.php b/extra/html-extra/HtmlAttr/SeparatedTokenList.php new file mode 100644 index 00000000000..39e904ea5e2 --- /dev/null +++ b/extra/html-extra/HtmlAttr/SeparatedTokenList.php @@ -0,0 +1,68 @@ + + */ +final class SeparatedTokenList implements AttributeValueInterface, MergeableInterface +{ + private readonly array $value; + + public function __construct(mixed $value, private readonly string $separator = ' ') + { + if (is_iterable($value)) { + $this->value = [...$value]; + } elseif (\is_scalar($value)) { + $this->value = [$value]; + } else { + throw new RuntimeError('SeparatedTokenList can only be constructed from iterable or scalar values.'); + } + } + + public function mergeInto(mixed $previous): mixed + { + if ($previous instanceof self && $previous->separator === $this->separator) { + return new self([...$previous->value, ...$this->value], $this->separator); + } + + if (is_iterable($previous)) { + return new self([...$previous, ...$this->value], $this->separator); + } + + throw new RuntimeError('SeparatedTokenList can only be merged with iterables or other SeparatedTokenList instances using the same separator.'); + } + + public function appendFrom(mixed $newValue): mixed + { + if (!is_iterable($newValue)) { + throw new RuntimeError('Only iterable values can be appended to SeparatedTokenList.'); + } + + return new self([...$this->value, ...$newValue], $this->separator); + } + + public function getValue(): ?string + { + $filtered = array_filter($this->value, fn ($v) => null !== $v && false !== $v); + + // Omit attribute if list contains only false or null values + if (!$filtered) { + return null; + } + + // true values are not printed, but result in the attribute not being omitted + return trim(implode($this->separator, array_filter($filtered, fn ($v) => true !== $v))); + } +} diff --git a/extra/html-extra/HtmlExtension.php b/extra/html-extra/HtmlExtension.php index cba22427ba1..391a2efdfef 100644 --- a/extra/html-extra/HtmlExtension.php +++ b/extra/html-extra/HtmlExtension.php @@ -12,9 +12,15 @@ namespace Twig\Extra\Html; use Symfony\Component\Mime\MimeTypes; +use Twig\Environment; use Twig\Error\RuntimeError; use Twig\Extension\AbstractExtension; +use Twig\Extra\Html\HtmlAttr\AttributeValueInterface; +use Twig\Extra\Html\HtmlAttr\InlineStyle; +use Twig\Extra\Html\HtmlAttr\MergeableInterface; +use Twig\Extra\Html\HtmlAttr\SeparatedTokenList; use Twig\Markup; +use Twig\Runtime\EscaperRuntime; use Twig\TwigFilter; use Twig\TwigFunction; @@ -31,6 +37,8 @@ public function getFilters(): array { return [ new TwigFilter('data_uri', [$this, 'dataUri']), + new TwigFilter('html_attr_merge', [self::class, 'htmlAttrMerge']), + new TwigFilter('html_attr_type', [self::class, 'htmlAttrType']), ]; } @@ -39,6 +47,7 @@ public function getFunctions(): array return [ new TwigFunction('html_classes', [self::class, 'htmlClasses']), new TwigFunction('html_cva', [self::class, 'htmlCva']), + new TwigFunction('html_attr', [self::class, 'htmlAttr'], ['needs_environment' => true, 'is_safe' => ['html']]), ]; } @@ -114,10 +123,10 @@ public static function htmlClasses(...$args): string } /** - * @param string|list $base + * @param string|list $base * @param array>> $variants - * @param array>> $compoundVariants - * @param array $defaultVariant + * @param array>> $compoundVariants + * @param array $defaultVariant * * @internal */ @@ -125,4 +134,123 @@ public static function htmlCva(array|string $base = [], array $variants = [], ar { return new Cva($base, $variants, $compoundVariants, $defaultVariant); } + + /** @internal */ + public static function htmlAttrType(mixed $value, string $type = 'sst'): AttributeValueInterface + { + switch ($type) { + case 'sst': + return new SeparatedTokenList($value, ' '); + case 'cst': + return new SeparatedTokenList($value, ', '); + case 'style': + return new InlineStyle($value); + default: + throw new RuntimeError(\sprintf('Unknown attribute type "%s" The only supported types are "sst", "cst" and "style".', $type)); + } + } + + public static function htmlAttrMerge(iterable|string|false|null ...$arrays): array + { + $result = []; + + foreach ($arrays as $array) { + if (!$array) { + continue; + } + + if (\is_string($array)) { + throw new RuntimeError('Only empty strings may be passed as string arguments to html_attr_merge. This is to support the implicit else clause for ternary operators.'); + } + + foreach ($array as $key => $value) { + if (!isset($result[$key])) { + $result[$key] = $value; + + continue; + } + + $existing = $result[$key]; + + switch (true) { + case $value instanceof MergeableInterface: + $result[$key] = $value->mergeInto($existing); + break; + case $existing instanceof MergeableInterface: + $result[$key] = $existing->appendFrom($value); + break; + case is_iterable($existing) && is_iterable($value): + $result[$key] = [...$existing, ...$value]; + break; + case (\is_scalar($existing) || \is_object($existing)) && (\is_scalar($value) || \is_object($value)): + $result[$key] = $value; + break; + default: + throw new RuntimeError(\sprintf('Cannot merge incompatible values for key "%s".', $key)); + } + } + } + + return $result; + } + + public static function htmlAttr(Environment $env, iterable|string|false|null ...$args): string + { + $attr = self::htmlAttrMerge(...$args); + + $result = ''; + $runtime = $env->getRuntime(EscaperRuntime::class); + + foreach ($attr as $name => $value) { + if (str_starts_with($name, 'aria-')) { + // For aria-*, convert booleans to "true" and "false" strings + if (true === $value) { + $value = 'true'; + } elseif (false === $value) { + $value = 'false'; + } + } + + if (str_starts_with($name, 'data-')) { + if (!$value instanceof AttributeValueInterface && null !== $value && !\is_scalar($value)) { + // ... encode non-null non-scalars as JSON + $value = json_encode($value); + } elseif (true === $value) { + // ... and convert boolean true to a 'true' string. + $value = 'true'; + } + } + + // Convert iterable values to token lists + if (!$value instanceof AttributeValueInterface && is_iterable($value)) { + if ('style' === $name) { + $value = new InlineStyle($value); + } else { + $value = new SeparatedTokenList($value); + } + } + + if ($value instanceof AttributeValueInterface) { + $value = $value->getValue(); + } + + // In general, ... + if (true === $value) { + // ... use attribute="" for boolean true, + // which is XHTML compliant and indicates the "empty value default", see + // https://html.spec.whatwg.org/multipage/syntax.html#attributes-2 and + // https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#boolean-attributes + $value = ''; + } + + if (null === $value || false === $value) { + // omit null-valued and false attributes completely (note aria-* has been processed before) + continue; + } + + $result .= $runtime->escape($name, 'html_attr').'="'.$runtime->escape($value).'" '; + } + + return trim($result); + } } diff --git a/extra/html-extra/Tests/Fixtures/html_attr.test b/extra/html-extra/Tests/Fixtures/html_attr.test new file mode 100644 index 00000000000..ca2728670de --- /dev/null +++ b/extra/html-extra/Tests/Fixtures/html_attr.test @@ -0,0 +1,38 @@ +--TEST-- +"html_attr" function +--TEMPLATE-- +Simple attributes: +Appropriate escaping: &\'"' }) }}/> +Empty attribute list: +Using a short ternary: +boolean true attribute: +boolean false attribute: +null attribute value: +empty string attribute value: +ARIA attribute boolean conversion: +data-* attribute handling : +array value concatenation ex. 1: +array value concatenation ex. 2: +inline styles: +style with a plain value: +using a "comma separated token list" attribute: +merging a "comma separated token list" value with more array values: +--DATA-- +return [] +--EXPECT-- +Simple attributes: +Appropriate escaping: +Empty attribute list: +Using a short ternary: +boolean true attribute: +boolean false attribute: +null attribute value: +empty string attribute value: +ARIA attribute boolean conversion: +data-* attribute handling : +array value concatenation ex. 1: +array value concatenation ex. 2: +inline styles: +style with a plain value: +using a "comma separated token list" attribute: +merging a "comma separated token list" value with more array values: diff --git a/extra/html-extra/Tests/Fixtures/html_attr_merge.test b/extra/html-extra/Tests/Fixtures/html_attr_merge.test new file mode 100644 index 00000000000..03d36905fe2 --- /dev/null +++ b/extra/html-extra/Tests/Fixtures/html_attr_merge.test @@ -0,0 +1,10 @@ +--TEST-- +"html_attr_merge" filter +--TEMPLATE-- +{% autoescape false %} +{{ { foo: 'bar' } | html_attr_merge({ bar: 'baz', foo: 'qux' }, { foo: 'quux' }) | json_encode }} +{% endautoescape %} +--DATA-- +return [] +--EXPECT-- +{"foo":"quux","bar":"baz"} diff --git a/extra/html-extra/Tests/HtmlAttrMergeTest.php b/extra/html-extra/Tests/HtmlAttrMergeTest.php new file mode 100644 index 00000000000..d129a5adf43 --- /dev/null +++ b/extra/html-extra/Tests/HtmlAttrMergeTest.php @@ -0,0 +1,223 @@ + [ + ['id' => 'some-id', 'label' => 'some-label'], + [ + ['id' => 'some-id'], + ['label' => 'some-label'], + ], + ]; + + yield 'merging different attributes from three arrays' => [ + ['id' => 'some-id', 'label' => 'some-label', 'role' => 'main'], + [ + ['id' => 'some-id'], + ['label' => 'some-label'], + ['role' => 'main'], + ], + ]; + + yield 'merging different attributes from Traversables' => [ + ['id' => 'some-id', 'label' => 'some-label', 'role' => 'main'], + [ + new \ArrayIterator(['id' => 'some-id']), + new \ArrayIterator(['label' => 'some-label']), + new \ArrayIterator(['role' => 'main']), + ], + ]; + + yield 'later keys override previous ones' => [ + ['key' => 'other'], + [ + ['key' => 'this'], + ['key' => 'that'], + ['key' => 'other'], + ], + ]; + + yield 'later keys override previous ones - as before, but there is no magic in attribute names like "id" or "class"' => [ + ['class' => 'other'], + [ + ['class' => 'this'], + ['class' => 'that'], + ['class' => 'other'], + ], + ]; + + yield 'in "merge array" mode, array_merge semantics will override non-numerical keys, but combine numerical ones' => [ + ['something' => ['first' => 'baz', 'second' => 'bar', 0 => 'other', 1 => 'more']], + [ + ['something' => ['first' => 'foo']], + ['something' => ['second' => 'bar']], + ['something' => ['first' => 'baz']], + ['something' => ['other']], + ['something' => ['more']], + ], + ]; + + yield 'ignore empty arrays, null or false values passed as arguments' => [ + ['something' => 'foo'], + [ + ['something' => 'foo'], + [], + null, + false, + ], + ]; + + yield 'there is no special handling for scalars like true, false or null' => [ + ['this' => true, 'that' => false, 'other' => null], + [ + ['this' => true], + ['that' => false], + ['other' => null], + ], + ]; + + yield 'inline style values with numerical keys are merely collected' => [ + ['style' => ['font-weight: light', 'color: green', 'font-weight: bold']], + [ + ['style' => ['font-weight: light']], + ['style' => ['color: green', 'font-weight: bold']], + ], + ]; + + yield 'inline style values can be overridden when they use names (array keys)' => [ + ['style' => ['font-weight' => 'bold', 'color' => 'red']], + [ + ['style' => ['font-weight' => 'light']], + ['style' => ['color' => 'green', 'font-weight' => 'bold']], + ['style' => ['color' => 'red']], + ], + ]; + + yield 'no merging happens when mixing numerically indexed inline styles with named ones' => [ + ['style' => ['color: green', 'color' => 'red']], + [ + ['style' => ['color: green']], + ['style' => ['color' => 'red']], + ], + ]; + + // MergeableInterface + yield 'MergeableInterface mergeInto is called when new value implements interface' => [ + ['class' => 'merged: old + new'], + [ + ['class' => 'old'], + ['class' => new MergeableStub('new')], + ], + ]; + + yield 'MergeableInterface appendFrom is called when existing value implements interface' => [ + ['class' => 'appended: old + new'], + [ + ['class' => new MergeableStub('old')], + ['class' => 'new'], + ], + ]; + + yield 'MergeableInterface mergeInto is called when both implement interface' => [ + ['class' => 'merged: value1 + value2'], + [ + ['class' => new MergeableStub('value1')], + ['class' => new MergeableStub('value2')], + ], + ]; + + yield 'MergeableInterface with array value' => [ + ['class' => 'appended: base + extra1, extra2'], + [ + ['class' => new MergeableStub('base')], + ['class' => ['extra1', 'extra2']], + ], + ]; + + // Scalar and object merging + yield 'string replaces object' => [ + ['value' => 'new-string'], + [ + ['value' => new \stdClass()], + ['value' => 'new-string'], + ], + ]; + + yield 'object replaces string' => [ + ['value' => new \stdClass()], + [ + ['value' => 'old-string'], + ['value' => new \stdClass()], + ], + ]; + } + + public function testIncompatibleValuesMergeThrowsException() + { + $this->expectException(RuntimeError::class); + $this->expectExceptionMessage('Cannot merge incompatible values for key "test"'); + + HtmlExtension::htmlAttrMerge( + ['test' => ['array']], + ['test' => 'scalar'] + ); + } +} + +class MergeableStub implements MergeableInterface +{ + public function __construct(private readonly mixed $value) + { + } + + public function mergeInto(mixed $previous): mixed + { + $previousValue = $previous instanceof self ? $previous->value : $previous; + + return new self("merged: {$previousValue} + {$this->value}"); + } + + public function appendFrom(mixed $newValue): mixed + { + if (\is_array($newValue)) { + $newValue = implode(', ', $newValue); + } elseif ($newValue instanceof self) { + $newValue = $newValue->value; + } + + return new self("appended: {$this->value} + {$newValue}"); + } + + public function __toString(): string + { + return (string) $this->value; + } +} diff --git a/extra/html-extra/Tests/HtmlAttrTest.php b/extra/html-extra/Tests/HtmlAttrTest.php new file mode 100644 index 00000000000..5c23ebe7c1e --- /dev/null +++ b/extra/html-extra/Tests/HtmlAttrTest.php @@ -0,0 +1,296 @@ + [ + 'id="some-id" label="some-label" role="main"', + [ + ['id' => 'some-id'], + null, + '', + false, + ['label' => 'some-label'], + ['role' => 'main'], + ], + ]; + + // Boolean attribute handling + yield 'boolean true renders as empty string, except for aria-* and data-* it uses "true"' => [ + 'required="" aria-disabled="true" data-yes="true"', + [ + ['required' => true, 'aria-disabled' => true, 'data-yes' => true], + ], + ]; + + yield 'boolean false omits attribute, except for aria-* it uses "false"' => [ + 'aria-disabled="false"', + [ + ['disabled' => false, 'aria-disabled' => false, 'data-gone' => false], + ], + ]; + + yield 'null value omits attribute, also for special cases' => [ + '', + [ + ['title' => null, 'style' => null, 'aria-gone' => null, 'data-nil' => null], + ], + ]; + + yield 'empty string renders as empty attribute value' => [ + 'title=""', + [ + ['title' => ''], + ], + ]; + + // Data attributes + yield 'data attribute with array is JSON encoded' => [ + 'data-config="{"theme":"dark"}"', + [ + ['data-config' => ['theme' => 'dark']], + ], + ]; + + // In general, array values are printed as space-separated token lists + yield 'array value renders as space-separated token list' => [ + 'class="btn btn-primary btn-lg"', + [ + ['class' => ['btn', 'btn-primary', 'btn-lg']], + ], + ]; + + yield 'arrays with just an empty string produce the empty attribute' => [ + 'foo=""', + [ + ['foo' => ['']], + ], + ]; + + yield 'arrays with just a true value produce the empty attribute' => [ + 'foo=""', + [ + ['foo' => [true]], + ], + ]; + + yield 'arrays with just a null value are not printed' => [ + '', + [ + ['foo' => [null]], + ], + ]; + + // Style attributes + yield 'style with plain string value' => [ + 'style="color: red;"', + [ + ['style' => 'color: red;'], + ], + ]; + + yield 'style with associative array' => [ + 'style="color: red; font-size: 16px;"', + [ + ['style' => ['color' => 'red', 'font-size' => '16px']], + ], + ]; + + yield 'style with numeric array' => [ + 'style="color: red; font-size: 16px;"', + [ + ['style' => ['color: red', 'font-size: 16px']], + ], + ]; + + yield 'merging style attributes overrides by key' => [ + 'style="color: blue; font-size: 14px;"', + [ + ['style' => ['color' => 'red', 'font-size' => '14px']], + ['style' => ['color' => 'blue']], + ], + ]; + + // Escaping + yield 'attribute name is escaped' => [ + 'data-user id="123"', + [ + ['data-user id' => '123'], + ], + ]; + + yield 'attribute value is escaped' => [ + 'title="<script>alert("xss")</script>"', + [ + ['title' => ''], + ], + ]; + + // Variadic merging scenarios + yield 'scalar value overrides from left to right' => [ + 'id="final"', + [ + ['id' => 'first'], + ['id' => 'second'], + ['id' => 'final'], + ], + ]; + + yield 'variadic with mixed false and null values' => [ + 'id="test"', + [ + ['id' => 'test'], + null, + false, + null, + ], + ]; + + yield 'variadic with empty arrays' => [ + 'id="test"', + [ + [], + ['id' => 'test'], + [], + ], + ]; + + yield 'variadic with empty string values' => [ + 'id="test"', + [ + '', + ['id' => 'test'], + '', + ], + ]; + + // AttributeValueInterface + yield 'AttributeValueInterface with string value' => [ + 'custom="custom-value"', + [ + ['custom' => new AttributeValueStub('custom-value')], + ], + ]; + + yield 'AttributeValueInterface with null value omits attribute' => [ + '', + [ + ['custom' => new AttributeValueStub(null)], + ], + ]; + + yield 'AttributeValueInterface wins over special case handling for style and data-*' => [ + 'style="some style" data-custom="not JSON"', + [ + ['style' => new AttributeValueStub('some style'), 'data-custom' => new AttributeValueStub('not JSON')], + ], + ]; + + // Edge cases + yield 'numeric attribute value' => [ + 'tabindex="0"', + [ + ['tabindex' => 0], + ], + ]; + + yield 'zero is not treated as falsy' => [ + 'data-count="0"', + [ + ['data-count' => 0], + ], + ]; + + // Scalar and object merging in rendering + yield 'string replaces object in rendering' => [ + 'value="new-string"', + [ + ['value' => new \stdClass()], + ['value' => 'new-string'], + ], + ]; + + yield 'object replaces string in rendering uses __toString if available' => [ + 'value="stringable-object"', + [ + ['value' => 'old-string'], + ['value' => new StringableStub('stringable-object')], + ], + ]; + } + + public function testIterableObjectCastedToArray() + { + /* + This test case demonstrates how objects could e. g. implement helper logic + to construct more complex attribute combinations and sets, and be passed as + one argument to html_attr as well. + */ + $object = new class implements \IteratorAggregate { + public function getIterator(): \Traversable + { + return new \ArrayIterator([ + 'data-controller' => new SeparatedTokenList(['dropdown', 'tooltip']), + 'data-action' => new SeparatedTokenList(['click->dropdown#toggle', 'mouseover->tooltip#show']), + ]); + } + }; + + $result = HtmlExtension::htmlAttr(new Environment(new ArrayLoader()), $object); + + self::assertSame('data-controller="dropdown tooltip" data-action="click->dropdown#toggle mouseover->tooltip#show"', $result); + } +} + +class StringableStub implements \Stringable +{ + public function __construct(private readonly string $value) + { + } + + public function __toString(): string + { + return $this->value; + } +} + +class AttributeValueStub implements AttributeValueInterface +{ + public function __construct(private readonly ?string $value) + { + } + + public function getValue(): ?string + { + return $this->value; + } +}