feature #4895 Extract htmlAttrValue() from html_attr for standalone attribute rendering (Kocal)

This PR was squashed before being merged into the 3.x branch.

Discussion
----------

Extract `htmlAttrValue()` from `html_attr` for standalone attribute rendering

The per-value resolution behind `html_attr` is extracted into a new public `HtmlExtension::htmlAttrValue()`, returning the unescaped value or `null` to omit the attribute; `html_attr()` now delegates to it, output unchanged, existing tests untouched. This lets third parties render a single attribute exactly like `html_attr` without a Twig `Environment`, since the resolution is escaper-free. symfony/ux#3820 and symfony/ux#3821 depend on this PR.

The `data-*` branch only tested `is_scalar()`, so a `\Stringable` was JSON-encoded instead of using its string representation; the same object already rendered its string form in `title` or `class`, and `AttributeValueInterface` was already excluded from that branch.

| Value in `data-value` | Before | After |
| --- | --- | --- |
| a `\Stringable` | `data-value="{}"` | `data-value="hello"` |
| a `\Stringable` that is also `JsonSerializable` | `data-value=""01JABC""` | `data-value="01JABC"` |

Commits
-------

9b18e3757d Extract `htmlAttrValue()` from `html_attr` for standalone attribute rendering
This commit is contained in:
Fabien Potencier
2026-08-26 16:47:06 +02:00
4 changed files with 171 additions and 58 deletions
+2
View File
@@ -1,5 +1,7 @@
# 3.29.0 (2026-XX-XX)
* Add the `HtmlExtension::htmlAttrValue()` method to resolve a single HTML attribute value the way the `html_attr` function renders it
* Fix `html_attr` JSON encoding a `Stringable` value in a `data-*` attribute instead of using its string representation
* Add documentation comments to attach metadata to nodes (experimental)
* Fix an empty destructuring pattern triggering a PHP fatal error instead of a `SyntaxError`
* Fix sequence destructuring of iterators throwing a `TypeError`
+5 -2
View File
@@ -123,8 +123,11 @@ attributes are converted to strings ``"true"`` and ``"false"``.
Data Attributes
---------------
For ``data-*`` attributes, boolean ``true`` values will be converted to ``"true"``.
Values that are not scalars are automatically JSON-encoded.
For ``data-*`` attributes, a boolean ``true`` is converted to the string
``"true"``, and any non-scalar value is JSON-encoded. Two exceptions behave as
they do for any other attribute: an iterable is rendered as a token list, and
a ``Stringable`` object is cast to its string representation. When an object
is both, the iterable behavior wins.
.. code-block:: html+twig
+78 -56
View File
@@ -200,67 +200,89 @@ final class HtmlExtension extends AbstractExtension
$runtime = $env->getRuntime(EscaperRuntime::class);
foreach ($attr as $name => $value) {
if ($value instanceof \BackedEnum) {
$value = $value->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)
if (null === $value = self::htmlAttrValue($name, $value)) {
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).'" ';
$result .= $runtime->escape($name, 'html_attr_relaxed').'="'.$runtime->escape($value).'" ';
}
return trim($result);
}
/**
* Resolves the final value of a single HTML attribute the way the "html_attr"
* function renders it, without escaping it.
*
* The returned string is meant to be printed as the value of the given
* attribute; it MUST be escaped for the HTML attribute context before being
* printed. A null return means the attribute must be omitted (a null or false
* value, except for aria-* attributes where false becomes the "false" string).
*
* @param string $name The attribute name, which drives the aria-*, data-* and style handling
* @param mixed $value The raw attribute value
*/
public static function htmlAttrValue(string $name, mixed $value): ?string
{
if ($value instanceof \BackedEnum) {
$value = $value->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 && !$value instanceof \Stringable && null !== $value && !\is_scalar($value)) {
// ... encode non-null non-scalars as JSON, but leave the string representation
// of a Stringable alone, as it is already the value the object asks to render as
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)
return null;
}
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)));
}
return (string) $value;
}
}
+86
View File
@@ -82,6 +82,30 @@ class HtmlAttrTest extends TestCase
],
];
yield 'Stringable renders its string, with or without a data- prefix' => [
'title="stringable-object" data-value="stringable-object"',
[
[
'title' => new StringableStub('stringable-object'),
'data-value' => new StringableStub('stringable-object'),
],
],
];
yield 'Stringable takes precedence over JsonSerializable in a data attribute' => [
'data-id="01JABC"',
[
['data-id' => new StringableJsonSerializableStub('01JABC')],
],
];
yield 'iterable takes precedence over Stringable in a data attribute' => [
'data-list="a b"',
[
['data-list' => new StringableTraversableStub()],
],
];
// 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"',
@@ -328,6 +352,38 @@ class HtmlAttrTest extends TestCase
['title' => new \stdClass()]
);
}
/**
* @dataProvider htmlAttrValueProvider
*/
public function testHtmlAttrValue(?string $expected, string $name, mixed $value): void
{
self::assertSame($expected, HtmlExtension::htmlAttrValue($name, $value));
}
public static function htmlAttrValueProvider(): \Generator
{
yield 'plain string' => ['foo', 'class', 'foo'];
yield 'integer is cast to string' => ['0', 'tabindex', 0];
yield 'boolean true renders an empty string' => ['', 'required', true];
yield 'boolean false is omitted' => [null, 'disabled', false];
yield 'null is omitted' => [null, 'title', null];
yield 'aria-* true renders "true"' => ['true', 'aria-hidden', true];
yield 'aria-* false renders "false"' => ['false', 'aria-hidden', false];
yield 'data-* true renders "true"' => ['true', 'data-open', true];
yield 'data-* array is JSON encoded, unescaped' => ['{"theme":"dark"}', 'data-config', ['theme' => 'dark']];
yield 'iterable becomes a space-separated token list' => ['btn btn-primary', 'class', ['btn', 'btn-primary']];
yield 'style iterable becomes an inline style' => ['color: red; font-size: 16px;', 'style', ['color' => 'red', 'font-size' => '16px']];
yield 'string-backed enum uses its value' => ['card', 'data-view', StringBackedStub::CARD];
yield 'int-backed enum uses its value' => ['10', 'tabindex', IntBackedStub::HIGH];
yield 'Stringable is cast to string' => ['stringable-object', 'title', new StringableStub('stringable-object')];
yield 'Stringable in a data-* attribute is cast to string, not JSON encoded' => ['stringable-object', 'data-value', new StringableStub('stringable-object')];
yield 'Stringable takes precedence over JsonSerializable in a data-* attribute' => ['01JABC', 'data-id', new StringableJsonSerializableStub('01JABC')];
yield 'iterable takes precedence over Stringable' => ['a b', 'class', new StringableTraversableStub()];
yield 'iterable takes precedence over Stringable in a data-* attribute' => ['a b', 'data-list', new StringableTraversableStub()];
yield 'AttributeValueInterface uses getValue()' => ['custom-value', 'custom', new AttributeValueStub('custom-value')];
yield 'AttributeValueInterface returning null is omitted' => [null, 'custom', new AttributeValueStub(null)];
}
}
class StringableStub implements \Stringable
@@ -342,6 +398,36 @@ class StringableStub implements \Stringable
}
}
class StringableTraversableStub implements \Stringable, \IteratorAggregate
{
public function __toString(): string
{
return 'from-toString';
}
public function getIterator(): \Traversable
{
return new \ArrayIterator(['a', 'b']);
}
}
class StringableJsonSerializableStub implements \Stringable, \JsonSerializable
{
public function __construct(private readonly string $value)
{
}
public function __toString(): string
{
return $this->value;
}
public function jsonSerialize(): mixed
{
return ['value' => $this->value];
}
}
class AttributeValueStub implements AttributeValueInterface
{
public function __construct(private readonly ?string $value)