HTML attributes as Twig mappings instead of PHP objects

Created on 28 June 2024, 5 months ago
Updated 28 August 2024, 3 months ago

Problem/Motivation

There are 2 issues in our Twig templating which can be fixed in a single change:

1. Attribute PHP objects in templates.

Attribute PHP objects are great when coding with PHP, but they don't belong to a Twig template:

  • where we are expected to manipulate primitives (string, number, boolean, mapping & sequence) with filters, not PHP objects with methods
  • where operations should be immutable but #3296456: Twig attribute setter methods are not immutable
  • where create_attribute(mapping), which is now safe to use thanks to 📌 Prevent TypeError when using create_attribute Twig function Fixed , looks alien. A Twig function is supposed to create data, not transform data.
  • where empty Attribute PHP objects are not resolved to false, so this is not working:
    {% set wrapper_attributes = create_attribute() %}
    {% if wrapper_attributes %}<div{{ wrapper_attributes }}>{% endif %}

Also, it would be great to help SDC component authors to stop using a PHP namespace as a prop type, which is not JSON Schema valid:

props:
  type: object
  properties:
    wrapper_attributes:
      title: Wrapper attributes
      type: Drupal\Core\Template\Attribute

2. We have a fatal error when we print a mapping

{{ {foo: "bar"} }}

Raises:

InvalidArgumentException: "pop" is an invalid render array key. Value should be an array but got a string. in Drupal\Core\Render\Element::children() (line 97 of core/lib/Drupal/Core/Render/Element.php).

Because print nodes are caught by TwigNodeVisitor::leaveNode():

    if ($node instanceof PrintNode) {
      ...
      $class = get_class($node);
      $line = $node->getTemplateLine();
      return new $class(
        new FunctionExpression('render_var', new Node([$node->getNode('expr')]), $line),
        $line
      );
    }

Then TwigExtension::renderVar() calls the rendering service, considering any array is a render array.

Twig template is supposed to be a "safe" job, so we must avoid any situation raising errors.

On Symfony 7, the same Twig snippet raises Warning: Array to string conversion.

Proposed resolution

Print mapping as attributes

So {{ {foo: "bar"} }} will be printed foo="bar"

Not sure where, not sure how.

For example, and without any tests, we can leverage Element::isRenderArray and act as soon as renderVar:

  public function renderVar($arg) {
    ...
    if (is_array($arg) && !Element::isRenderArray($arg)) {
       $arg = new Attribute($arg);
   }
    if (is_object($arg)) {
      ...
    }
    ...
    return $this->renderer->render($arg);
  }

Or later, in the renderer service.

We are not raising the error anymore.

Use filters to manipulate mappings

"Chainable" methods from Attribute PHP object needs to be converted to filters:

If we have same add_class and set_attribute filters for renderable arrays and attributes mapping, we need to decide how to handle empty mappings:

  • empty or missing renderables must stay empty and resolve to false
  • empty or missing attribute mapping will need to be created and/or altered

What do we do with the other methods from Attribute PHP object?

  • ::getClass(): AttributeValueBase ➤ my_attributes["class"] ?
  • ::getIterator(): ArrayIterator ➤ Useless because we already have an associative array
  • ::hasAttribute($name) : bool ➤ is x in my_attributes|keys()?
  • ::hasClass($class) : bool ➤ x in my_attributes["class"]?
  • ::jsonSerialize() : string ➤ https://twig.symfony.com/doc/3.x/filters/json_encode.html
  • ::offsetExists($name) : bool ➤ is x in my_attributes|keys()?
  • ::offsetGet($name) : mixed ➤ my_attributes[x]?
  • ::offsetSet($name, $value) : void ➤ Useless if we have set_attribute?
  • ::offsetUnset($name) : void ➤ Useless if we already have a filters replacing ::removeAttribute?
  • ::storage(): array ➤ Useless because we already have an associative array
  • ::toArray(): array ➤ Useless because we already have an associative array

Change management

Once merged in Core, let's promote the use of attributes as mappings instead of PHP objects in our documentation. Especially for SDC templates which are expected to be more "dumb" (it is a compliment) and UI oriented, so with minimal use of PHP objects.

Out of scope

Default attribute variable

Both ThemeManager et SDC are injecting a default attribute variable which is such an AttributePHP object.

This automatic injection is useful and must be kept. Maybe this variable will be a mapping instead of a PHP object in the future, but it will be a compatibility break.

Printing logic

Let's use the logic currently implemented in Attribute::__toString():

{ foo: "bar", baz: ["lorem", "ipsum"] }
foo="bar" baz="lorem ipsum"

{ foo: "bar", baz: [{"lorem": "hello"}, "ipsum"] }
foo="bar" baz="Array ipsum"

{ foo: "bar", baz: {"lorem": {"ipsum": "hello"} } }
foo="bar" baz="Array"

If we are unhappy with it, let's create a distinct ticket.

API changes

Let's try to not break anything yet.

Feature request
Status

Active

Version

11.0 🔥

Component
single-directory components 

Last updated 4 days ago

Created by

🇫🇷France pdureau Paris

Live updates comments and jobs are added and updated live.
Sign in to follow issues

Comments & Activities

Production build 0.71.5 2024