Skip to content

Commit 885cbdb

Browse files
committed
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 ------- 9b18e37 Extract `htmlAttrValue()` from `html_attr` for standalone attribute rendering
2 parents e50f980 + 9b18e37 commit 885cbdb

4 files changed

Lines changed: 162 additions & 49 deletions

File tree

CHANGELOG

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# 3.29.0 (2026-XX-XX)
22

3+
* Add the `HtmlExtension::htmlAttrValue()` method to resolve a single HTML attribute value the way the `html_attr` function renders it
4+
* Fix `html_attr` JSON encoding a `Stringable` value in a `data-*` attribute instead of using its string representation
35
* Add documentation comments to attach metadata to nodes (experimental)
46
* Fix an empty destructuring pattern triggering a PHP fatal error instead of a `SyntaxError`
57
* Fix sequence destructuring of iterators throwing a `TypeError`

doc/functions/html_attr.rst

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,11 @@ attributes are converted to strings ``"true"`` and ``"false"``.
123123
Data Attributes
124124
---------------
125125

126-
For ``data-*`` attributes, boolean ``true`` values will be converted to ``"true"``.
127-
Values that are not scalars are automatically JSON-encoded.
126+
For ``data-*`` attributes, a boolean ``true`` is converted to the string
127+
``"true"``, and any non-scalar value is JSON-encoded. Two exceptions behave as
128+
they do for any other attribute: an iterable is rendered as a token list, and
129+
a ``Stringable`` object is cast to its string representation. When an object
130+
is both, the iterable behavior wins.
128131

129132
.. code-block:: html+twig
130133

extra/html-extra/HtmlExtension.php

Lines changed: 69 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -200,67 +200,89 @@ public static function htmlAttr(Environment $env, iterable|string|false|null ...
200200
$runtime = $env->getRuntime(EscaperRuntime::class);
201201

202202
foreach ($attr as $name => $value) {
203-
if ($value instanceof \BackedEnum) {
204-
$value = $value->value;
203+
if (null === $value = self::htmlAttrValue($name, $value)) {
204+
continue;
205205
}
206206

207-
if (str_starts_with($name, 'aria-')) {
208-
// For aria-*, convert booleans to "true" and "false" strings
209-
if (true === $value) {
210-
$value = 'true';
211-
} elseif (false === $value) {
212-
$value = 'false';
213-
}
214-
}
207+
$result .= $runtime->escape($name, 'html_attr_relaxed').'="'.$runtime->escape($value).'" ';
208+
}
215209

216-
if (str_starts_with($name, 'data-')) {
217-
if (!$value instanceof AttributeValueInterface && null !== $value && !\is_scalar($value)) {
218-
// ... encode non-null non-scalars as JSON
219-
try {
220-
$value = json_encode($value, \JSON_THROW_ON_ERROR);
221-
} catch (\JsonException $e) {
222-
throw new RuntimeError(\sprintf('The "%s" attribute value cannot be JSON encoded.', $name), previous: $e);
223-
}
224-
} elseif (true === $value) {
225-
// ... and convert boolean true to a 'true' string.
226-
$value = 'true';
227-
}
210+
return trim($result);
211+
}
212+
213+
/**
214+
* Resolves the final value of a single HTML attribute the way the "html_attr"
215+
* function renders it, without escaping it.
216+
*
217+
* The returned string is meant to be printed as the value of the given
218+
* attribute; it MUST be escaped for the HTML attribute context before being
219+
* printed. A null return means the attribute must be omitted (a null or false
220+
* value, except for aria-* attributes where false becomes the "false" string).
221+
*
222+
* @param string $name The attribute name, which drives the aria-*, data-* and style handling
223+
* @param mixed $value The raw attribute value
224+
*/
225+
public static function htmlAttrValue(string $name, mixed $value): ?string
226+
{
227+
if ($value instanceof \BackedEnum) {
228+
$value = $value->value;
229+
}
230+
231+
if (str_starts_with($name, 'aria-')) {
232+
// For aria-*, convert booleans to "true" and "false" strings
233+
if (true === $value) {
234+
$value = 'true';
235+
} elseif (false === $value) {
236+
$value = 'false';
228237
}
238+
}
229239

230-
// Convert iterable values to token lists
231-
if (!$value instanceof AttributeValueInterface && is_iterable($value)) {
232-
if ('style' === $name) {
233-
$value = new InlineStyle($value);
234-
} else {
235-
$value = new SeparatedTokenList($value);
240+
if (str_starts_with($name, 'data-')) {
241+
if (!$value instanceof AttributeValueInterface && !$value instanceof \Stringable && null !== $value && !\is_scalar($value)) {
242+
// ... encode non-null non-scalars as JSON, but leave the string representation
243+
// of a Stringable alone, as it is already the value the object asks to render as
244+
try {
245+
$value = json_encode($value, \JSON_THROW_ON_ERROR);
246+
} catch (\JsonException $e) {
247+
throw new RuntimeError(\sprintf('The "%s" attribute value cannot be JSON encoded.', $name), previous: $e);
236248
}
249+
} elseif (true === $value) {
250+
// ... and convert boolean true to a 'true' string.
251+
$value = 'true';
237252
}
253+
}
238254

239-
if ($value instanceof AttributeValueInterface) {
240-
$value = $value->getValue();
255+
// Convert iterable values to token lists
256+
if (!$value instanceof AttributeValueInterface && is_iterable($value)) {
257+
if ('style' === $name) {
258+
$value = new InlineStyle($value);
259+
} else {
260+
$value = new SeparatedTokenList($value);
241261
}
262+
}
242263

243-
// In general, ...
244-
if (true === $value) {
245-
// ... use attribute="" for boolean true,
246-
// which is XHTML compliant and indicates the "empty value default", see
247-
// https://html.spec.whatwg.org/multipage/syntax.html#attributes-2 and
248-
// https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#boolean-attributes
249-
$value = '';
250-
}
264+
if ($value instanceof AttributeValueInterface) {
265+
$value = $value->getValue();
266+
}
251267

252-
if (null === $value || false === $value) {
253-
// omit null-valued and false attributes completely (note aria-* has been processed before)
254-
continue;
255-
}
268+
// In general, ...
269+
if (true === $value) {
270+
// ... use attribute="" for boolean true,
271+
// which is XHTML compliant and indicates the "empty value default", see
272+
// https://html.spec.whatwg.org/multipage/syntax.html#attributes-2 and
273+
// https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#boolean-attributes
274+
$value = '';
275+
}
256276

257-
if (\is_object($value) && !$value instanceof \Stringable) {
258-
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)));
259-
}
277+
if (null === $value || false === $value) {
278+
// omit null-valued and false attributes completely (note aria-* has been processed before)
279+
return null;
280+
}
260281

261-
$result .= $runtime->escape($name, 'html_attr_relaxed').'="'.$runtime->escape((string) $value).'" ';
282+
if (\is_object($value) && !$value instanceof \Stringable) {
283+
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)));
262284
}
263285

264-
return trim($result);
286+
return (string) $value;
265287
}
266288
}

extra/html-extra/Tests/HtmlAttrTest.php

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,30 @@ public static function htmlAttrProvider(): \Generator
8282
],
8383
];
8484

85+
yield 'Stringable renders its string, with or without a data- prefix' => [
86+
'title="stringable-object" data-value="stringable-object"',
87+
[
88+
[
89+
'title' => new StringableStub('stringable-object'),
90+
'data-value' => new StringableStub('stringable-object'),
91+
],
92+
],
93+
];
94+
95+
yield 'Stringable takes precedence over JsonSerializable in a data attribute' => [
96+
'data-id="01JABC"',
97+
[
98+
['data-id' => new StringableJsonSerializableStub('01JABC')],
99+
],
100+
];
101+
102+
yield 'iterable takes precedence over Stringable in a data attribute' => [
103+
'data-list="a b"',
104+
[
105+
['data-list' => new StringableTraversableStub()],
106+
],
107+
];
108+
85109
// In general, array values are printed as space-separated token lists
86110
yield 'array value renders as space-separated token list' => [
87111
'class="btn btn-primary btn-lg"',
@@ -328,6 +352,38 @@ public function testNonStringableObjectAsAttributeValueThrowsRuntimeError(): voi
328352
['title' => new \stdClass()]
329353
);
330354
}
355+
356+
/**
357+
* @dataProvider htmlAttrValueProvider
358+
*/
359+
public function testHtmlAttrValue(?string $expected, string $name, mixed $value): void
360+
{
361+
self::assertSame($expected, HtmlExtension::htmlAttrValue($name, $value));
362+
}
363+
364+
public static function htmlAttrValueProvider(): \Generator
365+
{
366+
yield 'plain string' => ['foo', 'class', 'foo'];
367+
yield 'integer is cast to string' => ['0', 'tabindex', 0];
368+
yield 'boolean true renders an empty string' => ['', 'required', true];
369+
yield 'boolean false is omitted' => [null, 'disabled', false];
370+
yield 'null is omitted' => [null, 'title', null];
371+
yield 'aria-* true renders "true"' => ['true', 'aria-hidden', true];
372+
yield 'aria-* false renders "false"' => ['false', 'aria-hidden', false];
373+
yield 'data-* true renders "true"' => ['true', 'data-open', true];
374+
yield 'data-* array is JSON encoded, unescaped' => ['{"theme":"dark"}', 'data-config', ['theme' => 'dark']];
375+
yield 'iterable becomes a space-separated token list' => ['btn btn-primary', 'class', ['btn', 'btn-primary']];
376+
yield 'style iterable becomes an inline style' => ['color: red; font-size: 16px;', 'style', ['color' => 'red', 'font-size' => '16px']];
377+
yield 'string-backed enum uses its value' => ['card', 'data-view', StringBackedStub::CARD];
378+
yield 'int-backed enum uses its value' => ['10', 'tabindex', IntBackedStub::HIGH];
379+
yield 'Stringable is cast to string' => ['stringable-object', 'title', new StringableStub('stringable-object')];
380+
yield 'Stringable in a data-* attribute is cast to string, not JSON encoded' => ['stringable-object', 'data-value', new StringableStub('stringable-object')];
381+
yield 'Stringable takes precedence over JsonSerializable in a data-* attribute' => ['01JABC', 'data-id', new StringableJsonSerializableStub('01JABC')];
382+
yield 'iterable takes precedence over Stringable' => ['a b', 'class', new StringableTraversableStub()];
383+
yield 'iterable takes precedence over Stringable in a data-* attribute' => ['a b', 'data-list', new StringableTraversableStub()];
384+
yield 'AttributeValueInterface uses getValue()' => ['custom-value', 'custom', new AttributeValueStub('custom-value')];
385+
yield 'AttributeValueInterface returning null is omitted' => [null, 'custom', new AttributeValueStub(null)];
386+
}
331387
}
332388

333389
class StringableStub implements \Stringable
@@ -342,6 +398,36 @@ public function __toString(): string
342398
}
343399
}
344400

401+
class StringableTraversableStub implements \Stringable, \IteratorAggregate
402+
{
403+
public function __toString(): string
404+
{
405+
return 'from-toString';
406+
}
407+
408+
public function getIterator(): \Traversable
409+
{
410+
return new \ArrayIterator(['a', 'b']);
411+
}
412+
}
413+
414+
class StringableJsonSerializableStub implements \Stringable, \JsonSerializable
415+
{
416+
public function __construct(private readonly string $value)
417+
{
418+
}
419+
420+
public function __toString(): string
421+
{
422+
return $this->value;
423+
}
424+
425+
public function jsonSerialize(): mixed
426+
{
427+
return ['value' => $this->value];
428+
}
429+
}
430+
345431
class AttributeValueStub implements AttributeValueInterface
346432
{
347433
public function __construct(private readonly ?string $value)

0 commit comments

Comments
 (0)