mirror of
https://github.com/twigphp/Twig.git
synced 2026-08-31 04:27:00 +00:00
Extract htmlAttrValue() from html_attr for standalone attribute rendering
This commit is contained in:
committed by
Fabien Potencier
parent
e50f980542
commit
9b18e3757d
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user