Search by

parisek / twig-attribute

parisek

A Twig extension for adding attributes to an element

Package info

github.com/parisek/twig-attribute

pkg:composer/parisek/twig-attribute

Statistics

Installs: 10 502

Dependents: 2

Suggesters: 0

Stars: 3

Open Issues: 0


README

Packagist Version PHP Version Tests License Twig

A Twig 3 extension that gives templates a create_attribute() function for collecting, sanitizing, and rendering HTML attributes. It is backed by a vendored port of Drupal's Attribute class.

The package ships its own port (under Drupal\Component\Attribute) and does not depend on Drupal core. The maintainer refreshes the vendored sources from Drupal 11.x when upstream changes. The API matches what Drupal templates expect. One deliberate difference: this package rejects unsafe attribute names.

Installation

composer require parisek/twig-attribute

Requires PHP ^8.3 and Twig ^3.27. No Drupal dependencies.

Usage

Plain PHP:

$twig = new \Twig\Environment($loader);
$twig->addExtension(new \Parisek\Twig\AttributeExtension());

Symfony service:

services:
  Parisek\Twig\AttributeExtension:
    tags: [{ name: twig.extension }]

Timber 2 (WordPress). Timber creates the Twig environment itself, so do not build your own. Add the extension with the timber/twig filter, for example in your theme's functions.php:

add_filter( 'timber/twig', function ( \Twig\Environment $twig ) {
    $twig->addExtension( new \Parisek\Twig\AttributeExtension() );

    return $twig;
} );

parisek/timber-kit already registers the extension in its StarterBase. If you use the kit, you need no extra code.

In templates

{% set my_attribute = create_attribute() %}
{% set my_classes = [
  'kittens',
  'llamas',
  isKitten ? 'cats' : 'dogs',
] %}
<div{{ my_attribute.addClass(my_classes).setAttribute('id', 'myUniqueId') }}>
  {{ content }}
</div>

create_attribute() and without register the safe class by themselves. Under Twig autoescape, call AttributeExtension::registerSafeClass($twig) once after addExtension() as well. Without it, a collection that prints before the first such call prints as &quot; text. A subclass of AttributeCollection, or another MarkupInterface class, that Twig already escaped stays escaped. See Autoescape.

<div{{ create_attribute({'class': ['region', 'region--header']}) }}>
  {{ content }}
</div>

Filters

without returns a copy of an attribute collection (or a plain array) without the named keys. It never changes the original. Pass one name, several names or a list.

<div class="{{ attributes.class }} my-class"{{ attributes|without('class') }}>
  {{ content }}
</div>
{{ attributes|without('class', 'id') }}
{{ attributes|without(['class', 'id']) }}

Unknown names are ignored. The behavior matches Drupal's without filter.

The filter itself is not marked safe. A collection prints as HTML only after registerSafeClass() ran (see Autoescape).

Attribute names

A name is checked when the attribute is created: in the constructor, setAttribute(), array access and create_attribute({...}). A name that is empty or contains ASCII whitespace, a control character (NUL included) or any of " ' < > / = throws an \InvalidArgumentException. Without the check, a name such as x onmouseover would render as two attributes.

Names for Alpine, Vue and Angular stay valid: @click, :class, x-on:click.prevent, [hidden], (click), *ngIf, #ref. Drupal does not check names, so this is a deliberate difference. Values are escaped as in Drupal.

Autoescape

With Twig autoescape on (for example 'autoescape' => 'html'), register the safe class once, right after you add the extension:

$twig->addExtension(new \Parisek\Twig\AttributeExtension());
\Parisek\Twig\AttributeExtension::registerSafeClass($twig);

The call is safe to repeat. Call it before anything is rendered. It tells the Twig escaper that objects implementing the package's MarkupInterface are HTML. Strings, other Stringable values and arrays stay escaped.

Timber 2 turns autoescape off by default. A Timber theme, including one that uses parisek/timber-kit, needs nothing.

What to know:

  • The extension also calls registerSafeClass() on the first create_attribute() or without call. This is a fallback, and it depends on the order of calls. A collection from the template context that prints before the first such call is escaped (over-escaped, never printed raw). Do not rely on the fallback.
  • Twig caches the safe lookup per exact class on the first escape. A MarkupInterface object that Twig already escaped in that environment keeps being escaped, including a subclass of AttributeCollection or another implementation. Only AttributeCollection and the interface are repaired. This fails closed: the output is over-escaped, never raw.
  • A class that implements MarkupInterface is declared trusted HTML. Twig prints its whole __toString() output raw.
  • Custom AttributeValueBase subclasses are trusted developer input, as in Drupal. A value subclass that returns markup injects markup. Attribute values from the built-in classes are escaped.
  • Register on the environment you render with. Do not clone an environment (cloning is deprecated in Twig 3.30 and not allowed in Twig 4).

The full API (class methods, escape semantics, without filter behavior) mirrors Drupal's Attribute class.

Development

composer install
composer test      # PHPUnit
composer phpstan   # static analysis
composer cs        # code style check

The vendored Drupal port comes from Drupal 11.x core at git.drupalcode.org/project/drupal/-/tree/11.x/core/lib/Drupal/Core/Template.

A weekly workflow (upstream-watch) compares the vendored classes with upstream and opens an issue when they differ or when a security advisory (SA-CORE) commit appears. Run the same check by hand:

php scripts/check-upstream.php

When upstream changes, copy the relevant files into .upstream/ (a gitignored scratch directory) and port them again. AGENTS.md describes the refresh steps, the inline shims that let the package drop drupal/core-render and drupal/core-utility, and the deliberate differences from Drupal.

Use cases