Background

In https://www.lullabot.com/articles/level-your-twiggery, @hawkeye.twolf laid out a method for creating an |as() twig filter that "adds template suggestions to a render array"

With this, the Twig code for wrapping the body field in a <details> element can be shortened to just {{ content.body|as('details') }}, along with a new template, field--details.html.twig, that adds the <details> markup. “How is this different/better” you might be asking “than just using the standard field-name template suggestion, field--node--body--article?” Three main reasons:

  1. Naming the template in a generic way leaves it available for use by other fields. If you have another field that needs the same customization, you no longer have to resort to various forms of chicanery, like copy/pasting the original template into an exact but renamed duplicate, Twig extendsing an otherwise-unrelated file, or symlinking one template to another.
  2. For experienced Drupal developers, the idea of template suggestions and their naming scheme might seem obvious. But to an outsider reviewing the Article’s node template, the source of the <details> wrapper around the body field may prove arcane. The addition of |as('details') makes the code more readable and adds semantic meaning to the source.
  3. Keeps front-end logic in the front-end. Normally, you would implement hook_theme_suggestions_alter() in a theme or module file to make unrelated fields use the same template. While there’s nothing wrong with this approach, it creates a dependency on PHP skills for any developer wishing to work on the front-end. The |as filter allows front-end developers to stay out of the PHP if they want to.

Task

I'd like to move this filter into Drupal core!

Discussion

I've discussed this with the @lauriii. He seemed excited about getting this in, and doesn't anticipate this being unrealistic for Drupal 10. I've also talked to @hawkeye.twolf, who said he has some updated code samples and seems willing to help!

Lets do this! ⚡️

Release highlight

New Twig |add_suggestion filter for adding a theme suggestion without having to write PHP. See https://www.drupal.org/node/3301862 for details.

CommentFileSizeAuthor
#21 mikes-as-test.txt11.7 KBmherchel

Issue fork drupal-3301373

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Support from Acquia helps fund testing for Drupal Acquia logo

Comments

mherchel created an issue. See original summary.

mherchel’s picture

Issue summary: View changes

mherchel’s picture

Issue summary: View changes

Crediting @hawkeye.twolf

hawkeye.twolf’s picture

Version: 10.0.x-dev » 10.1.x-dev
hawkeye.twolf’s picture

Initial implementation pushed to the fork. Needs tests.

lauriii made their first commit to this issue’s fork.

lauriii’s picture

Status: Active » Needs review
Issue tags: +Needs change record
hawkeye.twolf’s picture

What do you think about adding a second parameter to inject a class to the attributes? I find that’s often needed in order to easily make use of the template suggestion. I.e., I want something to display with the markup and a component-specific class, like this:

{{ content.field_event__links|as('list', 'event__link-list') }}
{# Produces: #}
<ul class="list event__link-list">
  <li><a href="/">foo</a></li>
  <li><a href="/">bar</a></li>
</ul>

In this example, the list template can be more easily reused because we get both the list markup and a class to adjust styling for the specific placement.

Do you think this is something desirable and worth trying to add it in still?

mherchel’s picture

What do you think about adding a second parameter to inject a class to the attributes? I find that’s often needed in order to easily make use of the template suggestion. I.e., I want something to display with the markup and a component-specific class, like this:

➕💯 to this!

It's good that you've actually used this before. It totally makes sense that you'd want to inject one or more classes.

mherchel’s picture

Created followup #3301853: Create twig filters: |add_class and |set_attribute to address the option of passing down a CSS class.

mherchel’s picture

Status: Needs review » Needs work

Setting to needs work because tests are failing.

lauriii’s picture

Status: Needs work » Needs review

Fixed the failing test

lauriii’s picture

Issue tags: -Needs change record

Change record exists

ckrina’s picture

Just jumping here to mention that this was implemented on a client project we're working on right now with @hawkeye.twolf and it really helped reducing the number of templates needed. Although the most important benefit for me is that it keeps as much logic as possible in the component itself.

mherchel’s picture

Note that this issue is affected by #2118743: Twig debug output does not display all suggestions when an array of theme hooks is passed to #theme, which causes the template suggestions to not output to HTML comments when Twig debug is enabled.

hudri’s picture

I'd like to add another point why this is a great idea:

In our case we have one common override template for a certain number of fields. This template could not be distinguished by common template suggestions based on entity type, bundle or field name. So this led to a weird situation where we had one

shared_special_field_template.html.twig containing the actual template

and about 30-40 field--node--FIELDNAME--BUNDLE.html.twig templates containing only

{% extends 'shared_special_field_template.html.twig' %}

With the proposed|as filter we could have used the node template and spared all the dummy extend templates.

mherchel’s picture

Status: Needs review » Needs work

Unfortunately, something's broken. I can reproduce a WSOD with the error message below if the field that has an |as filter is not in the {{ content }}

Steps to reproduce

  1. Fresh install of Drupal 10
  2. Add a new text field called field_details
  3. Create a new node--article.html.twig (note this will apply to all view modes)
  4. Add the field in there like {{ content.field_details|as('test') }}
  5. Create a new article node, and populate that field
  6. Clear caches and browse to the homepage where the teaser for the new node should be.
  7. It blows up with the error below
  8. Note that if I modify the node type to show the field_details within the teaser, it works great.

Error

The website encountered an unexpected error. Please try again later.
TypeError: Drupal\Core\Template\TwigExtension::suggestThemeHook(): Argument #1 ($element) must be of type array, null given, called in /Users/mikeherchel/Sites/drupal9head/vendor/twig/twig/src/Environment.php(358) : eval()'d code on line 66 in Drupal\Core\Template\TwigExtension->suggestThemeHook() (line 669 of core/lib/Drupal/Core/Template/TwigExtension.php).
Drupal\Core\Template\TwigExtension->suggestThemeHook(NULL, 'test') (Line: 66)
__TwigTemplate_9d8c511721a281c6d3822799478134f0->doDisplay(Array, Array) (Line: 394)
Twig\Template->displayWithErrorHandling(Array, Array) (Line: 367)
Twig\Template->display(Array) (Line: 379)
Twig\Template->render(Array, Array) (Line: 40)
Twig\TemplateWrapper->render(Array) (Line: 53)
twig_render_template('core/themes/olivero/templates/content/node--article.html.twig', Array) (Line: 372)
Drupal\Core\Theme\ThemeManager->render('node', Array) (Line: 422)
Drupal\Core\Render\Renderer->doRender(Array, ) (Line: 201)
Drupal\Core\Render\Renderer->render(Array) (Line: 477)
Drupal\Core\Template\TwigExtension->escapeFilter(Object, Array, 'html', NULL, 1) (Line: 60)
__TwigTemplate_cb2aa4c5e6552a721f7fa5c2400b8daf->doDisplay(Array, Array) (Line: 394)
Twig\Template->displayWithErrorHandling(Array, Array) (Line: 367)
Twig\Template->display(Array) (Line: 379)
Twig\Template->render(Array, Array) (Line: 40)
Twig\TemplateWrapper->render(Array) (Line: 53)
twig_render_template('core/modules/views/templates/views-view-unformatted.html.twig', Array) (Line: 372)
Drupal\Core\Theme\ThemeManager->render('views_view_unformatted', Array) (Line: 422)
Drupal\Core\Render\Renderer->doRender(Array) (Line: 435)
Drupal\Core\Render\Renderer->doRender(Array, ) (Line: 201)
Drupal\Core\Render\Renderer->render(Array) (Line: 477)
Drupal\Core\Template\TwigExtension->escapeFilter(Object, Array, 'html', NULL, 1) (Line: 112)
__TwigTemplate_851fd4e4abddad8955e01ed08f9b12b1->doDisplay(Array, Array) (Line: 394)
Twig\Template->displayWithErrorHandling(Array, Array) (Line: 367)
Twig\Template->display(Array) (Line: 379)
Twig\Template->render(Array, Array) (Line: 40)
Twig\TemplateWrapper->render(Array) (Line: 53)
twig_render_template('core/themes/olivero/templates/views/views-view--frontpage.html.twig', Array) (Line: 372)
Drupal\Core\Theme\ThemeManager->render('views_view__frontpage', Array) (Line: 422)
Drupal\Core\Render\Renderer->doRender(Array) (Line: 435)
Drupal\Core\Render\Renderer->doRender(Array, ) (Line: 201)
Drupal\Core\Render\Renderer->render(Array, ) (Line: 237)
Drupal\Core\Render\MainContent\HtmlRenderer->Drupal\Core\Render\MainContent\{closure}() (Line: 564)
Drupal\Core\Render\Renderer->executeInRenderContext(Object, Object) (Line: 238)
Drupal\Core\Render\MainContent\HtmlRenderer->prepare(Array, Object, Object) (Line: 128)
Drupal\Core\Render\MainContent\HtmlRenderer->renderResponse(Array, Object, Object) (Line: 90)
Drupal\Core\EventSubscriber\MainContentViewSubscriber->onViewRenderArray(Object, 'kernel.view', Object)
call_user_func(Array, Object, 'kernel.view', Object) (Line: 108)
Drupal\Component\EventDispatcher\ContainerAwareEventDispatcher->dispatch(Object, 'kernel.view') (Line: 157)
Symfony\Component\HttpKernel\HttpKernel->handleRaw(Object, 1) (Line: 74)
Symfony\Component\HttpKernel\HttpKernel->handle(Object, 1, 1) (Line: 58)
Drupal\Core\StackMiddleware\Session->handle(Object, 1, 1) (Line: 48)
Drupal\Core\StackMiddleware\KernelPreHandle->handle(Object, 1, 1) (Line: 106)
Drupal\page_cache\StackMiddleware\PageCache->pass(Object, 1, 1) (Line: 85)
Drupal\page_cache\StackMiddleware\PageCache->handle(Object, 1, 1) (Line: 48)
Drupal\Core\StackMiddleware\ReverseProxyMiddleware->handle(Object, 1, 1) (Line: 51)
Drupal\Core\StackMiddleware\NegotiationMiddleware->handle(Object, 1, 1) (Line: 51)
Drupal\Core\StackMiddleware\StackedHttpKernel->handle(Object, 1, 1) (Line: 670)
Drupal\Core\DrupalKernel->handle(Object) (Line: 19)
hawkeye.twolf’s picture

Status: Needs work » Needs review

Thank you for catching that, @mherchel! I was being overly strict in the function declaration. I've pushed a change to allow NULL values, so missing content should no longer throw an error. I also added test coverage for the fix. Please re-test with your example when you get a chance, thank you!

mherchel’s picture

Status: Needs review » Reviewed & tested by the community
FileSize
11.7 KB

This is sooo awesome!

Marking this as RTBC! I found a couple interesting notes, but after talking to Hawkeye, we think they're OK.

Things to note

  • Capital letters don’t work. So if I do a {{ content.field_myfield|as('Mike-Herchel') }} , it will NOT load a field--mike-herchel.html.twig file
  • This doesn’t append to existing template suggestions (this is expected, I think). So {{ content.field_myfield|as('mike-herchel') }} will NOT load a field--myfield--mike-herchel.html.twig.
  • The handling of template with the same name as the field was somewhat weird. My field name was field_test and I passed in a test argument like {{ content.field_test|as('test') }}. It loaded up the field–field_test.html.twig file (note the underscores) that I had created.

Things I tested

  • Non existent template loads the default: {{ content.field_test|as('not-here') }}
  • Hyphens in passed in string: {{ content.field_test|as('mike-herchel') }} this loads the field--mike-herchel.html.twig file
  • Underscores passed in turn into hyphens: {{ content.field_test|as('mike_herchel') }} this loads the field--mike-herchel.html.twig file
  • Emojis work 👍
  • Passing in weird characters (#!) works
  • Passing in numbers works
  • Sending in wrong data produces an error with a relevant error message. I tested {{ content.field_test|render|as('xx') }}
  • Moving the field out of the view mode no longer blows everything up :)

Things I did not test

I didn’t test entities besides nodes.

Note that I'm attaching a patch with a number of test cases and templates in case myself or someone else wants to do more manual testing.

alexpott’s picture

Status: Reviewed & tested by the community » Needs review

@lauriii asked me to have a look at this issue.

This is a really neat idea. I like how it puts the themer in more control and makes certain theming task less complex and easier to learn and do.

The current MR has really nice test coverage of all the logic in the function. Nice.

I chatted to @lauriii about the semantic-ness of as as a filter name. We agreed that this is really nice for someone that does not know much about how Drupal themes work. But, on the other hand, it does not help them really understand what is going on under-the-hood. One option would be to alias the as filter to addSuggestion so then we can be semantic for people who do have this type of knowledge of the theme system. I would also suggest renaming the function to addThemeSuggestion() as that is what it is doing.

The only awkwardness I see in the current patch is were you have multiple theme suggestions... in the test case we have:

          'kitten__stripy__cute',
          'kitten__cute',
          'kitten__stripy',
          'kitten',

This seems quite tricky if kitten__stripy__cute does not exist but kitten__cute and kitten__stripy do. I’m not sure what I’d expect to happen. One on hand, I can imagine being given a task to add stripy kitten template to a theme which has kitten and kitten__cute templates already and so I’d add a kitten__stripy template and then be surprised when it doesn’t work everywhere. But, as @lauriii pointed out to me, he expect that if I have called |as('cute') in a template that I wouldn’t accidentally override that by adding a theme suggestion for stripy.

I think this could be mitigated completely if we only added a single theme suggestion based on the base theme hook. (@lauriii thinks this might be quite hard to do). Then again, maybe this append to everything is quite powerful.

Also one fun thing is that filters can be chained... field|as(‘details’)|as(‘more_details’) - I'm also not sure how I feel about this especially in light of the append this to every suggestion appraoch.

mherchel’s picture

I'm not opposed to renaming the filter to addSuggestion . You're right that it gives people a helpful gleam of what's happening under the hood. That might cause them to think, "Huh... I wonder if there's other ways to add suggestions?" It would also help with the search-ability of the term when people are looking for support via Google, et al.

alexpott’s picture

@mherchel my suggestion is that both |as and |addSuggestion work.

mherchel’s picture

One of the concerns that I have about having two filters (or whatever) doing the same thing is that it can be confusing to a newbie developer

Their (potential) thoughts:

  • What's the difference between these?
  • Why are there two?
  • Should I favor one or the other?

There's also the "searchability" issue. If someone is searching for the |as filter, they won't find stack overflow answers that include the | addSuggestion filter.

IMHO, we should only have one name for this filter.

mherchel’s picture

Discussed this in person at DrupalCon Prague with @andy_blum, @ckrina, @alexpott.

We came to the conclusion to use add_suggestion only, as this gives insight to front-end developers on what his happening under the hood. We don't want to "muddy the waters" with an alias of this suggestion just yet.

mherchel’s picture

Title: Create twig |as filter » Create twig |add_suggestion filter
Status: Needs review » Needs work

Setting this back to Needs Work. We need to rename the filter.

hawkeye.twolf’s picture

Status: Needs work » Needs review

Filter renamed from |as to |add_suggestion. Thanks @mherchel @andy_blum @ckrina and @alexpott!

@alexpott, thank you for bringing up the concerns about adding the suggestion to every theme hook, very happy to have additional input! Here's some context from Slack where Lauri and I discussed this question and ended up going with "add to every suggestion". Please let me know if you have additional thoughts or if you end up agreeing with the current approach so we can mark this issue back to RTBC. Thank you! 🙏🏻

Hawkeye Tenderwolf
1:28 PM
ah okay I understand now. “Should it extend the lowest or highest priority existing theme hook” (edited)

Lauri Eskola
1:28 PM
or both
1:28
or all of them

Hawkeye Tenderwolf
1:28 PM
aha! Interesting

Lauri Eskola
1:29 PM
I’d say it should extend the lowest
1:29
I think we call it the base hook

Hawkeye Tenderwolf
1:30 PM
yeah, I think either base or “all of them”.
:heavy_plus_sign:
1

Lauri Eskola
1:30 PM
@mherchel
wondering if you have thoughts on this?
1:31
how about in cases where a single string is passed?
1:31
I guess we could convert it to an array and add it to all levels

Hawkeye Tenderwolf
1:32 PM
If someone specifically wanted the kitten__stripy__cute suggestion, and we only prepend the base hook, they could always do:
content.field_kitten|as('stripy__cute`)
(edited)
:heavy_plus_sign:
1

Lauri Eskola
1:33 PM
I think adding it to the base hook is the 90% use case

Hawkeye Tenderwolf
1:33 PM
What do you mean by this?
how about in cases where a single string is passed?
When would an array get passed?

Lauri Eskola
1:33 PM
‘#theme’ could be either string or array of strings (theme hooks) (edited)
1:34
'#theme' => [
'kitten__stripy',
'kitten',
],
and
'#theme' => 'kitten__stripy'
1:34
both are valid

Hawkeye Tenderwolf
1:34 PM
ah I see what you mean. the question of how to determine what is the “base hook” vs. just the “lowest priority hook”

Lauri Eskola
1:35 PM
yeah, but if it’s string, it’s always what’s before the first __

Hawkeye Tenderwolf
1:36 PM
With:
'#theme' => 'kitten__stripy'
kitten is still technically the base hook. So would the expected result of |as('cute') be
['kitten__stripy__cute', 'kitten__stripy']
or
['kitten__cute', 'kitten__stripy']
1:37
I could see either way. I think I’m finally grokking your question :slightly_smiling_face: I guess always going for the base hook feels most natural. I could update the MR to do that (right now it’s just taking the lowest priority) (edited)

Lauri Eskola
1:38 PM
well technically what I said earlier is not true, lol: https://git.drupalcode.org/project/drupal/-/blob/9.5.x/core/lib/Drupal/C...
there’s a lot of complexity there but maybe we can be a bit naive about it
GitLabGitLab
core/lib/Drupal/Core/Theme/ThemeManager.php · 9.5.x · project / drupal · GitLab
For more information about this repository, visit the project page at https://www.drupal.org/project/drupal
1:39
okay let’s look at concrete example. For example 'input__color'

Hawkeye Tenderwolf
1:40 PM
ah i see. I’ve actually done that before, implemented my own theme hook that was technically based off another, like field__kitten so that I could have full control over the theme hook (function template_field__kitten() )

Lauri Eskola
1:40 PM
if you have following code 'input__color'|as('color_picker') what do you expect to happen? (edited)

Hawkeye Tenderwolf
1:41 PM
I would say input__color__color_picker

Lauri Eskola
1:41 PM
I think that probably makes sense
:+1::skin-tone-2:
1

1:43
so I think what is there probably makes sense, but would be interesting to hear from Mike what he thinks

Hawkeye Tenderwolf
1:44 PM
To do “base hook” properly (without the risk of changing from one base hook to another), it would require a BC change to add the theme registry service I think

Lauri Eskola
1:44 PM
I think that all of the cases I saw where we use #theme with a theme suggestions are pretty special and in those it would make sense to have the behavior you said you’d expect
:raised_hands::skin-tone-2:
1

1:45
There’s a way to add constructor arguments but it’s a :hankey: show so I didn’t want us to have to go through that :laughing:

Hawkeye Tenderwolf
1:47 PM
Definitely down to avoid :hankey: shows :)

Mike Herchel
1:49 PM
looking through thread now
1:49
all of the examples make sense that you've discussed

Hawkeye Tenderwolf
1:53 PM
Cool, so if we’re down with keeping “lowest priority” suggestion, I’ll update the MR to remove references to “base” theme hook and rename the variable to make it not misleading.

Lauri Eskola
1:54 PM
that would be great :+1:

Hawkeye Tenderwolf
2:28 PM
I just found something that surprised me…
#theme => kitten__striping__cute
behaves differently than:
#theme => [
kitten__striping__cute
kitten
]
when there’s no “kitten--striping--cute” template. The former automatically includes suggestions for kitten__striping, while the latter doesn’t (see here).
It makes me wonder if we should instead just append the suggestion to the existing theme hook, rather than convert it to an array.
And if the theme hook is already an array, makes me lean more toward “append to all the existing suggestions”, since the fallback mechanisms act differently when the theme hook is a list. (edited)

GitLabGitLab
core/lib/Drupal/Core/Theme/ThemeManager.php · 9.5.x · project / drupal · GitLab
For more information about this repository, visit the project page at https://www.drupal.org/project/drupal

Lauri Eskola
2:29 PM
That probably makes sense. The array of theme suggestions is pretty rare I think. But I’m not sure since there could be some popular use case which I can’t think of where it’s used..

Hawkeye Tenderwolf
2:31 PM
Sweet. I’ll make the change.

Hawkeye Tenderwolf
3:15 PM
Okay one more edge case. So edgey that I may just be overthinking it, so let me know. Let’s say I have a kitten render array that includes striping:
#theme => kitten__striping
And I want to render my kitten as cute so I do:
{{ kitten|as('cute') }}
And I make a template called kitten--cute… It won’t get picked used because the suggestion is looking for kitten--striping--cute
Goes back to the question of “do we append to the base theme hook, or the lowest, or the highest?”
Catching up with your thought process from earlier
@lauriii
, the “naive” approach of just adding them all (splitting by __) :allthethings: In the above example, we would get:
{# Add "cute" suggestion to both "kitten__striping" and "kitten" #}
#theme => [
'kitten__striping__cute',
'kitten__cute',
'kitten__striping',
]
I might just be totally overthinking this :thinking_face:
I am stepping away for a while, will be back later. (edited)

Lauri Eskola
3:23 PM
The use cases I saw for that were along the lines of animal__cat and animal__dog and you would never expect to change animal__cat to animal__dog but do something like animal__cat__cute. But then there’s a question if animal__cute makes sense or not?

Hawkeye Tenderwolf
12:47 AM
In the end I think the best compromise is adding a new suggestion for each existing theme hook, but not to break them apart and add new suggestions for each “level” (__) of each theme hook. I pushed that change, and in so doing obviated 3 tests, since a new suggestion is always added now. I’m not sure that’s a good thing or not :shrug::skin-tone-2: (edited)

mherchel’s picture

Status: Needs review » Reviewed & tested by the community

Changes look good. Gave it a run-through again, and it works as expected. Thanks @hawkeye.twolf!

alexpott’s picture

Once the CR https://www.drupal.org/node/3301862 is updated for the new name and has something about #28 then I think we're good to go. It's important the CR documents how it affects all existing theme suggestions and talks about the precedence stuff.

alexpott’s picture

Version: 10.1.x-dev » 10.0.x-dev
Status: Reviewed & tested by the community » Fixed
Issue tags: +10.0.0 release highlights

Discussed with @lauriii and we agreed that as a pure addition this can go into 10.0.0 and be there from the beginning of Drupal 10.

Can someone add a release notes section?

Committed and pushed d985697c26 to 10.1.x and 75f7ef8b29 to 10.0.x. Thanks!

  • alexpott committed d985697 on 10.1.x
    Issue #3301373 by hawkeye.twolf, lauriii, alexpott, mherchel: Create...

  • alexpott committed 75f7ef8 on 10.0.x
    Issue #3301373 by hawkeye.twolf, lauriii, alexpott, mherchel: Create...
lauriii’s picture

Issue summary: View changes

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.