From 9b18e3757d426f3211d4f1baa18e6ae07a0ee728 Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Wed, 26 Aug 2026 07:11:59 +0200 Subject: [PATCH] Extract `htmlAttrValue()` from `html_attr` for standalone attribute rendering --- CHANGELOG | 2 + doc/functions/html_attr.rst | 7 +- extra/html-extra/HtmlExtension.php | 134 ++++++++++++++---------- extra/html-extra/Tests/HtmlAttrTest.php | 86 +++++++++++++++ 4 files changed, 171 insertions(+), 58 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 38e3176a4..f74e7ea4b 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -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` diff --git a/doc/functions/html_attr.rst b/doc/functions/html_attr.rst index 1b3b8d37a..b80b31ee7 100644 --- a/doc/functions/html_attr.rst +++ b/doc/functions/html_attr.rst @@ -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 diff --git a/extra/html-extra/HtmlExtension.php b/extra/html-extra/HtmlExtension.php index 37155bcc5..407f23fcf 100644 --- a/extra/html-extra/HtmlExtension.php +++ b/extra/html-extra/HtmlExtension.php @@ -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; + } } diff --git a/extra/html-extra/Tests/HtmlAttrTest.php b/extra/html-extra/Tests/HtmlAttrTest.php index bbd7dbd97..38898b86f 100644 --- a/extra/html-extra/Tests/HtmlAttrTest.php +++ b/extra/html-extra/Tests/HtmlAttrTest.php @@ -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)