Add an html_attr function to make outputting HTML attributes easier

This commit is contained in:
Matthias Pigulla
2023-12-06 23:11:49 +01:00
committed by Fabien Potencier
parent 0319c822d1
commit 42c12fa720
15 changed files with 1397 additions and 0 deletions
+1
View File
@@ -3,6 +3,7 @@
* Add support for renaming variables in object destructuring (`{name: userName} = user`)
* Add `html_attr_relaxed` escaping strategy that preserves :, @, [, and ] for front-end framework attribute names
* Add support for short-circuiting in null-safe operator chains
* Add the `html_attr` function and `html_attr_merge` as well as `html_attr_type` filters
# 3.23.0 (2026-01-23)
+141
View File
@@ -0,0 +1,141 @@
``html_attr_merge``
===================
.. _html_attr_merge:
.. versionadded:: 3.24
The ``html_attr_merge`` filter was added in Twig 3.24.
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`
+123
View File
@@ -0,0 +1,123 @@
``html_attr_type``
==================
.. _html_attr_type:
.. versionadded:: 3.24
The ``html_attr_type`` filter was added in Twig 3.24.
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
<img {{ html_attr({
srcset: ['small.jpg 480w', 'large.jpg 1200w']|html_attr_type('cst')
}) }}>
{# Output: <img srcset="small.jpg 480w, large.jpg 1200w"> #}
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') %}
<button {{ html_attr({class: classes}) }}>
Click me
</button>
{# Output: <button class="btn btn-primary">Click me</button> #}
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
<img {{ html_attr({
srcset: ['image-1x.jpg 1x', 'image-2x.jpg 2x', 'image-3x.jpg 3x']|html_attr_type('cst'),
sizes: ['(max-width: 600px) 100vw', '50vw']|html_attr_type('cst')
}) }}>
{# Output: <img srcset="image-1x.jpg 1x, image-2x.jpg 2x, image-3x.jpg 3x" sizes="(max-width: 600px) 100vw, 50vw"> #}
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') %}
<div {{ html_attr({style: styles}) }}>
Styled content
</div>
{# Output: <div style="color: red; font-size: 14px;">Styled content</div> #}
{# Numeric array #}
{% set styles = ['color: red', 'font-size: 14px']|html_attr_type('style') %}
<div {{ html_attr({style: styles}) }}>
Styled content
</div>
{# Output: <div style="color: red; font-size: 14px;">Styled content</div> #}
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`
+2
View File
@@ -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
+202
View File
@@ -0,0 +1,202 @@
``html_attr``
=============
.. _html_attr:
.. versionadded:: 3.23
The ``html_attr`` function was added in Twig 3.24.
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.
.. note::
Attribute names are escaped using the ``html_attr_relaxed`` strategy.
.. code-block:: html+twig
<div {{ html_attr({class: ['foo', 'bar'], id: 'main'}) }}>
Content
</div>
{# Output: <div class="foo bar" id="main">Content</div> #}
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} %}
<button {{ html_attr(base_attrs, variant_attrs) }}>
Click me
</button>
{# Output: <button class="btn btn-primary" disabled="">Click me</button> #}
.. 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' %}
<a {{ html_attr({id, href}) }}>Profile</a>
{# Output: <a id="user-123" href="/profile">Profile</a> #}
``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-*" #}
<input {{ html_attr({disabled: false, title: null}) }}>
{# Output: <input> #}
``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 #}
<input {{ html_attr({required: true}) }}>
{# Output: <input required="">, which is equivalent to <input required> #}
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
<div {{ html_attr({class: ['btn', 'btn-primary', 'btn-lg']}) }}>
Button
</div>
{# Output: <div class="btn btn-primary btn-lg">Button</div> #}
.. 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
<https://www.w3.org/TR/wai-aria-1.2/#typemapping>_`, boolean values for ``aria-*``
attributes are converted to strings ``"true"`` and ``"false"``.
.. code-block:: html+twig
<button {{ html_attr({'aria-pressed': true, 'aria-hidden': false}) }}>
Toggle
</button>
{# Output: <button aria-pressed="true" aria-hidden="false">Toggle</button> #}
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
<div {{ html_attr({'data-config': {theme: 'dark', size: 'large'}, 'data-bool': true, 'data-false': false}) }}>
Content
</div>
{# Output: <div data-config="{&quot;theme&quot;:&quot;dark&quot;,&quot;size&quot;:&quot;large&quot;}" data-bool="true">Content</div> #}
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 #}
<div {{ html_attr({style: {color: 'red', 'font-size': '16px'}}) }}>
Styled text
</div>
{# Output: <div style="color: red; font-size: 16px;">Styled text</div> #}
{# Numeric keys will be assumed to have values that are individual CSS declarations #}
<div {{ html_attr({style: ['color: red', 'font-size: 16px']}) }}>
Styled text
</div>
{# Output: <div style="color: red; font-size: 16px;">Styled text</div> #}
{# Merging style attributes #}
<div {{ html_attr({style: {color: 'red'}}, {style: {color: 'blue', background: 'white'}}) }}>
Styled text
</div>
{# Output: <div style="color: blue; background: white;">Styled text</div> #}
.. 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`
+1
View File
@@ -12,6 +12,7 @@ Functions
dump
enum
enum_cases
html_attr
html_classes
html_cva
include
@@ -0,0 +1,31 @@
<?php
/*
* This file is part of Twig.
*
* (c) Fabien Potencier
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Twig\Extra\Html\HtmlAttr;
/**
* Interface for custom attribute value objects.
*
* Implement this interface to create custom conversion logic when printing out objects as attribute
* values in the `html_attr` function.
*
* @author Matthias Pigulla <mp@webfactory.de>
*/
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;
}
+70
View File
@@ -0,0 +1,70 @@
<?php
/*
* This file is part of Twig.
*
* (c) Fabien Potencier
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Twig\Extra\Html\HtmlAttr;
use Twig\Error\RuntimeError;
/**
* @author Matthias Pigulla <mp@webfactory.de>
*/
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;
}
}
@@ -0,0 +1,55 @@
<?php
/*
* This file is part of Twig.
*
* (c) Fabien Potencier
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Twig\Extra\Html\HtmlAttr;
/**
* Interface for attribute values that support custom merge behavior.
*
* Implement this interface to define how attribute values should be merged when multiple
* attribute arrays are combined using `html_attr_merge`. This allows for
* sophisticated merging logic beyond simple array merging.
*
* Objects implementing this interface probably want to implement either {@see AttributeValueInterface}
* or `\Stringable` as well, in order to provide a reasonable to-string-conversion when being
* the last value after a merge.
*
* @author Matthias Pigulla <mp@webfactory.de>
*/
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;
}
@@ -0,0 +1,68 @@
<?php
/*
* This file is part of Twig.
*
* (c) Fabien Potencier
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Twig\Extra\Html\HtmlAttr;
use Twig\Error\RuntimeError;
/**
* @author Matthias Pigulla <mp@webfactory.de>
*/
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, static 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, static fn ($v) => true !== $v)));
}
}
+134
View File
@@ -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 @@ final class HtmlExtension extends AbstractExtension
{
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 @@ final class HtmlExtension extends AbstractExtension
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']]),
];
}
@@ -125,4 +134,129 @@ final class HtmlExtension extends AbstractExtension
{
return new Cva($base, $variants, $compoundVariants, $defaultVariant);
}
/** @internal */
public static function htmlAttrType(mixed $value, string $type = 'sst'): AttributeValueInterface
{
return match ($type) {
'sst' => new SeparatedTokenList($value, ' '),
'cst' => new SeparatedTokenList($value, ', '),
'style' => new InlineStyle($value),
default => throw new RuntimeError(\sprintf('Unknown attribute type "%s" The only supported types are "sst", "cst" and "style".', $type)),
};
}
/** @internal */
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;
}
/** @internal */
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
try {
$value = json_encode($value, \JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new RuntimeError(\sprintf('The "%s" attribute value cannot be JSON encoded.', $name), previous: $e);
}
} 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;
}
if (\is_object($value) && !$value instanceof \Stringable) {
throw new RuntimeError(\sprintf('The "%s" attribute value should be a scalar, an iterable, or an object implementing "%s", got "%s".', $name, \Stringable::class, get_debug_type($value)));
}
$result .= $runtime->escape($name, 'html_attr_relaxed').'="'.$runtime->escape((string) $value).'" ';
}
return trim($result);
}
}
@@ -0,0 +1,40 @@
--TEST--
"html_attr" function
--TEMPLATE--
Simple attributes: <tag {{ html_attr({ foo: 'bar' }, { bar: 'baz' }) }}/>
Relaxed attribute name escaping: <tag {{ html_attr({ 'v-bind:href': 'url', ':[key]': 'url', '@click': 'doSomething' }) }} />
Appropriate escaping: <tag {{ html_attr({ 'untrusted name': '<>&\'"' }) }}/>
Empty attribute list: <tag {{ html_attr([], {}, null) }}/>
Using a short ternary: <tag {{ html_attr({foo: 'bar'}, false ? {bar: 'baz'}) }}/>
boolean true attribute: <tag {{ html_attr({ checked: true}) }}/>
boolean false attribute: <tag {{ html_attr({ checked: false}) }}/>
null attribute value: <tag {{ html_attr({ something: null}) }}/>
empty string attribute value: <tag {{ html_attr({ title: '' }) }}/>
ARIA attribute boolean conversion: <tag {{ html_attr({ 'aria-yes': true, 'aria-no': false, 'aria-nada': null, 'aria-empty': '' }) }}/>
data-* attribute handling : <tag {{ html_attr({ 'data-array': { foo: 'bar' }, 'data-true': true, 'data-false': false, 'data-null': null }) }}/>
array value concatenation ex. 1: <tag {{ html_attr({ class: ['foo', 'bar'] }, { class: [''] }, { class: [] }) }}/>
array value concatenation ex. 2: <tag {{ html_attr({ 'aria-describedby': ['id1'] }, { 'aria-describedby': ['id2'] }) }}/>
inline styles: <tag {{ html_attr({ style: { color: 'red' }}, { style: ['background-color: blue'] }, { style: { color: 'green', 'font-family': 'Arial' } }) }}/>
style with a plain value: <tag {{ html_attr({ style: 'color: red;' }) }}/>
using a "comma separated token list" attribute: <img {{ html_attr({ srcset: ['small.jpg 480w', 'medium.jpg 800w', 'large.jpg 1200w']|html_attr_type('cst') }) }} />
merging a "comma separated token list" value with more array values: <img {{ html_attr({ srcset: ['small.jpg 480w']|html_attr_type('cst') }, { srcset: ['medium.jpg 800w', 'large.jpg 1200w'] }) }} />
--DATA--
return []
--EXPECT--
Simple attributes: <tag foo="bar" bar="baz"/>
Relaxed attribute name escaping: <tag v-bind:href="url" :[key]="url" @click="doSomething" />
Appropriate escaping: <tag untrusted&#x20;name="&lt;&gt;&amp;&#039;&quot;"/>
Empty attribute list: <tag />
Using a short ternary: <tag foo="bar"/>
boolean true attribute: <tag checked=""/>
boolean false attribute: <tag />
null attribute value: <tag />
empty string attribute value: <tag title=""/>
ARIA attribute boolean conversion: <tag aria-yes="true" aria-no="false" aria-empty=""/>
data-* attribute handling : <tag data-array="{&quot;foo&quot;:&quot;bar&quot;}" data-true="true"/>
array value concatenation ex. 1: <tag class="foo bar"/>
array value concatenation ex. 2: <tag aria-describedby="id1 id2"/>
inline styles: <tag style="color: green; background-color: blue; font-family: Arial;"/>
style with a plain value: <tag style="color: red;"/>
using a "comma separated token list" attribute: <img srcset="small.jpg 480w, medium.jpg 800w, large.jpg 1200w" />
merging a "comma separated token list" value with more array values: <img srcset="small.jpg 480w, medium.jpg 800w, large.jpg 1200w" />
@@ -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"}
@@ -0,0 +1,223 @@
<?php
/*
* This file is part of Twig.
*
* (c) Fabien Potencier
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Twig\Extra\Html\Tests;
use PHPUnit\Framework\TestCase;
use Twig\Error\RuntimeError;
use Twig\Extra\Html\HtmlAttr\MergeableInterface;
use Twig\Extra\Html\HtmlExtension;
class HtmlAttrMergeTest extends TestCase
{
/**
* @dataProvider htmlAttrProvider
*/
public function testMerge(array $expected, array $inputs)
{
$result = HtmlExtension::htmlAttrMerge(...$inputs);
self::assertEquals($expected, $result);
}
public static function htmlAttrProvider(): \Generator
{
yield 'merging different attributes from two arrays' => [
['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;
}
}
+296
View File
@@ -0,0 +1,296 @@
<?php
/*
* This file is part of Twig.
*
* (c) Fabien Potencier
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Twig\Extra\Html\Tests;
use PHPUnit\Framework\TestCase;
use Twig\Environment;
use Twig\Extra\Html\HtmlAttr\AttributeValueInterface;
use Twig\Extra\Html\HtmlAttr\SeparatedTokenList;
use Twig\Extra\Html\HtmlExtension;
use Twig\Loader\ArrayLoader;
class HtmlAttrTest extends TestCase
{
/**
* @dataProvider htmlAttrProvider
*/
public function testPrintingAttributes(string $expected, array $inputs)
{
$result = HtmlExtension::htmlAttr(new Environment(new ArrayLoader()), ...$inputs);
self::assertEquals($expected, $result);
}
public static function htmlAttrProvider(): \Generator
{
yield 'merging from variadic arguments; ignoring null, false and empty string values' => [
'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="{&quot;theme&quot;:&quot;dark&quot;}"',
[
['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&#x20;id="123"',
[
['data-user id' => '123'],
],
];
yield 'attribute value is escaped' => [
'title="&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;"',
[
['title' => '<script>alert("xss")</script>'],
],
];
// 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-&gt;dropdown#toggle mouseover-&gt;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;
}
}