Compare commits

..

6 Commits
3.x ... 3.1.x

  1. 9
      .gitlab-ci.yml
  2. 30
      README.md
  3. 11
      composer.json
  4. 73
      docs/cheat-sheet.md
  5. 19
      docs/migration-to-3.x.md
  6. BIN
      logo.png
  7. 65
      src/TwigTweakExtension.php
  8. 1
      src/UriExtractor.php
  9. 15
      src/UrlExtractor.php
  10. 19
      src/View/BlockViewBuilder.php
  11. 7
      src/View/EntityFormViewBuilder.php
  12. 16
      src/View/EntityViewBuilder.php
  13. 8
      src/View/FieldViewBuilder.php
  14. 77
      src/View/ImageViewBuilder.php
  15. 4
      src/View/RegionViewBuilder.php
  16. 67
      tests/src/Functional/TwigTweakTest.php
  17. 8
      tests/src/Kernel/AbstractExtractorTestCase.php
  18. 3
      tests/src/Kernel/CacheMetadataExtractorTest.php
  19. 2
      tests/src/Kernel/EntityFormViewBuilderTest.php
  20. 6
      tests/src/Kernel/EntityViewBuilderTest.php
  21. 2
      tests/src/Kernel/FieldViewBuilderTest.php
  22. 161
      tests/src/Kernel/ImageViewBuilderTest.php
  23. 14
      tests/src/Kernel/RegionViewBuilderTest.php
  24. 2
      tests/src/Kernel/UrlExtractorTest.php
  25. 19
      tests/twig_tweak_test/config/install/block.block.classy_content.yml
  26. 17
      tests/twig_tweak_test/config/install/block.block.classy_page_title.yml
  27. 10
      tests/twig_tweak_test/config/install/block.block.classy_powered_by_drupal.yml
  28. 19
      tests/twig_tweak_test/config/install/block.block.classy_status_messages.yml
  29. 17
      tests/twig_tweak_test/templates/twig-tweak-test.html.twig
  30. 2
      tests/twig_tweak_test/twig_tweak_test.info.yml
  31. 35
      twig_tweak.api.php
  32. 2
      twig_tweak.info.yml
  33. 5
      twig_tweak.services.yml

9
.gitlab-ci.yml

@ -1,9 +0,0 @@
include:
- project: $_GITLAB_TEMPLATES_REPO
ref: $_GITLAB_TEMPLATES_REF
file:
- '/includes/include.drupalci.main.yml'
- '/includes/include.drupalci.variables.yml'
- '/includes/include.drupalci.workflows.yml'
variables:
CORE_PHP_MIN: '8.1'

30
README.md

@ -1,23 +1,15 @@
# Twig Tweak
The Twig Tweak module provides a Twig extension with some useful functions
and filters that can improve development experience.
Some functions and filters are built in the Twig Tweak module, while others are
simple wrappers to integrate Drupal functions and services into Twig (for
example: `format_size`, `drupal_view_result`).
## Requirements
This module requires no modules outside of Drupal core.
The module provides a Twig extension with some useful functions and filters.
## Installation
- Install as you would normally install a contributed Drupal module. For further
information, see [Installing Drupal Modules](https://www.drupal.org/docs/extending-drupal/installing-drupal-modules).
## Links
- Project page: https://www.drupal.org/project/twig_tweak
- Twig home page: https://twig.symfony.com/
- Drupal Twig documentation: https://www.drupal.org/docs/theming-drupal/twig-in-drupal
Install as usual.
```shell
composer require drupal/twig_tweak
drush en twig_tweak
```
## LINKS
* Project page: https://www.drupal.org/project/twig_tweak
* Twig home page: https://twig.sensiolabs.org
* Drupal 8 Twig documentation: https://www.drupal.org/docs/8/theming/twig

11
composer.json

@ -12,18 +12,11 @@
"require": {
"php": ">=7.3",
"ext-json": "*",
"drupal/core": "^9.3 || ^10.0",
"twig/twig": "^2.15.3 || ^3.4.3",
"drupal/core": "^9.0",
"twig/twig": "^2.12",
"symfony/polyfill-php80": "^1.17"
},
"suggest": {
"symfony/var-dumper": "Better dump() function for debugging Twig variables"
},
"extra": {
"drush": {
"services": {
"drush.services.yml": "^9 || ^10 || ^11"
}
}
}
}

73
docs/cheat-sheet.md

@ -4,17 +4,10 @@
```twig
{{ drupal_view('who_s_new', 'block_1') }}
```
```twig
{# Specify additional parameters which map to contextual filters you have configured in your view. #}
{{ drupal_view('who_s_new', 'block_1', arg_1, arg_2, arg_3) }}
```
## Drupal View Result
Checks results for a given view. Note that the results themselves are not printable.
```twig
{% if drupal_view_result('cart')|length == 0 %}
{{ 'Your cart is empty.'|t }}
{% endif %}
{{ drupal_view_result('who_s_new', 'block_1') }}
```
## Drupal Block
@ -32,9 +25,6 @@ drush ev "print_r(array_keys(\Drupal::service('plugin.manager.block')->getDefini
{# Bypass block.html.twig theming. #}
{{ drupal_block('system_branding_block', wrapper=false) }}
{# For block plugin that has a required context supply a context mapping to tell the block instance where to get that context from. #}
{{ drupal_block('plugin_id', {context_mapping: {node: '@node.node_route_context:node'}}) }}
```
See [rendering blocks with Twig Tweak](blocks.md#block-plugin) for details.
@ -74,23 +64,10 @@ See [rendering blocks with Twig Tweak](blocks.md#block-plugin) for details.
```
## Drupal Field
Note that drupal_field() does not work for view modes powered by Layout Builder.
```twig
{# Render field_image from node 1 in view_mode "full" (default). #}
{{ drupal_field('field_image', 'node', 1) }}
{# Render field_image from node 1 in view_mode "teaser". #}
{{ drupal_field('field_image', 'node', 1, 'teaser') }}
{# Render field_image from node 1 and instead of a view mode, provide an array of display options. #}
{# @see https://api.drupal.org/api/drupal/core!lib!Drupal!Core!Entity!EntityViewBuilderInterface.php/function/EntityViewBuilderInterface%3A%3AviewField #}
{{ drupal_field('field_image', 'node', 1, {type: 'image_url', settings: {image_style: 'large'}}) }}
{# Render field_image from node 1 in view_mode "teaser" in English with access check disabled. #}
{{ drupal_field('field_image', 'node', 1, 'teaser', 'en', FALSE) }}
{# Render field_image from node 1 in view_mode "full" (default) with access check disabled (named argument). #}
{{ drupal_field('field_image', 'node', 1, check_access=false) }}
```
## Drupal Menu
@ -121,11 +98,7 @@ Note that drupal_field() does not work for view modes powered by Layout Builder.
{# Render image using 'thumbnail' image style and custom attributes. #}
{{ drupal_image('public://ocean.jpg', 'thumbnail', {alt: 'The alternative text'|t, title: 'The title text'|t}) }}
{# Render image using 'thumbnail' image style with lazy/eager loading (by attribute). #}
{{ drupal_image('public://ocean.jpg', 'thumbnail', {loading: 'lazy'}) }}
{{ drupal_image('public://ocean.jpg', 'thumbnail', {loading: 'eager'}) }}
{# Render responsive image (using a named argument). #}
{# Render responsive image. #}
{{ drupal_image('public://ocean.jpg', 'wide', responsive=true) }}
```
@ -159,8 +132,6 @@ Note that drupal_field() does not work for view modes powered by Layout Builder.
## Drupal URL
```twig
{# The function accepts a valid internal path, such as "/node/1", "/taxonomy/term/1", a query string like "?query," or a fragment like "#anchor". #}
{# Basic usage. #}
{{ drupal_url('node/1') }}
@ -197,12 +168,12 @@ Note that drupal_field() does not work for view modes powered by Layout Builder.
```twig
{# Basic usage. #}
<div class="contextual-region">
{{ drupal_contextual_links('entity.view.edit_form:view=frontpage:display_id=feed_1') }}
{{ drupal_contextual_links('entity.view.edit_form:view=frontpage&display_id=feed_1:') }}
{{ drupal_view('frontpage') }}
</div>
{# Multiple links. #}
<div class="contextual-region">
{{ drupal_contextual_links('node:node=123:|block_content:block_content=123:') }}
{{ drupal_contextual_links('node:node=123|block_content:block_content=123:') }}
{{ content }}
</div>
```
@ -251,9 +222,8 @@ images when used in an `<img/>` tag.
```
## Format size
Generates a string representation for the given byte count.
```twig
{{ 12345|format_size }}
{{ 12345|format_size() }}
```
## Truncate
@ -278,7 +248,7 @@ Generates a string representation for the given byte count.
```
## With
This is an opposite of core `without` filter and adds properties instead of removing it.
This is an opposite of core `without` filter.
```twig
{# Set top-level value. #}
{{ content.field_image|with('#title', 'Photo'|t) }}
@ -287,15 +257,6 @@ This is an opposite of core `without` filter and adds properties instead of remo
{{ content|with(['field_image', '#title'], 'Photo'|t) }}
```
## Data URI
The filter generates a URL using the data scheme as defined in [RFC 2397](https://datatracker.ietf.org/doc/html/rfc2397)
```twig
{# Inline image. #}
<img src="{{ '<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="50" fill="lime"/></svg>'|data_uri('image/svg+xml') }}" alt="{{ 'Rectangle'|t }}"/>
{# Image from file system. #}
<img src="{{ source(directory ~ '/images/logo.svg')|data_uri('image/svg+xml') }}" alt="{{ 'Logo'|t }}"/>
```
## Children
```twig
<ul>
@ -350,28 +311,6 @@ It is also possible to extract file URL directly from an entity.
{{ media|file_url }}
```
## Entity URL
Gets the URL object for the entity.
See \Drupal\Core\Entity\EntityInterface::toUrl()
```twig
{# Creates canonical URL for the node. #}
{{ node|entity_url }}
{# Creates URL for the node edit form. #}
{{ node|entity_url('edit-form') }}
```
## Entity Link
Generates the HTML for a link to this entity.
See \Drupal\Core\Entity\EntityInterface::toLink()
```twig
{# Creates a link to the node using the node's label. #}
{{ node|entity_link }}
{# Creates link to node comment form. #}
{{ node|entity_link('Add new comment'|t, 'canonical', {fragment: 'comment-form'}) }}
```
## Entity translation
That is typically needed when printing data from referenced entities.
```twig

19
docs/migration-to-3.x.md

@ -7,8 +7,6 @@ Below are known BC breaks that may require updating your Twig templates.
Twig Tweak 3.x requires Drupal 9, Twig 2 and PHP 7.3.
## Rendering entities
### Entity ID is now required
Entity ID parameter in `drupal_entity()` and `drupal_field()` functions is now
mandatory. Previously it was possible to load entities from current route by
omitting entity ID parameter. However, that was making Twig templates coupled
@ -35,32 +33,19 @@ function preprocess_page(array &$variables): void {
}
```
### Default view mode has changed
The view mode parameter in `drupal_field()` has changed from `default` to `full`. If you are using `drupal_field()` without specifying a view mode, you should update your templates to specify the `default` one.
Before:
```
{{ drupal_field('field_name', 'node', node.id) }}
```
After:
```
{{ drupal_field('field_name', 'node', node.id, 'default') }}
```
## Rendering blocks
`drupal_block()` now moves attributes provided by block plugin to the outer
element. This may break some CSS rules.
Before:
```html
```HTML
<div>
<div class="from-block-plugin">Block content</div>
</div>
```
After:
```html
```HTML
<div class="from-block-plugin">
<div>Block content</div>
</div>

BIN
logo.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

65
src/TwigTweakExtension.php

@ -4,7 +4,6 @@ namespace Drupal\twig_tweak;
use Drupal\Component\Utility\NestedArray;
use Drupal\Component\Utility\Unicode;
use Drupal\Component\Utility\UrlHelper;
use Drupal\Component\Uuid\Uuid;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Entity\EntityInterface;
@ -110,12 +109,9 @@ class TwigTweakExtension extends AbstractExtension {
new TwigFilter('truncate', [Unicode::class, 'truncate']),
new TwigFilter('view', [self::class, 'viewFilter']),
new TwigFilter('with', [self::class, 'withFilter']),
new TwigFilter('data_uri', [self::class, 'dataUriFilter']),
new TwigFilter('children', [self::class, 'childrenFilter']),
new TwigFilter('file_uri', [self::class, 'fileUriFilter']),
new TwigFilter('file_url', [self::class, 'fileUrlFilter']),
new TwigFilter('entity_url', [self::class, 'entityUrl']),
new TwigFilter('entity_link', [self::class, 'entityLink']),
new TwigFilter('translation', [self::class, 'entityTranslation']),
new TwigFilter('cache_metadata', [self::class, 'CacheMetadata']),
];
@ -185,8 +181,7 @@ class TwigTweakExtension extends AbstractExtension {
*/
public static function drupalEntityForm(string $entity_type, ?string $id = NULL, string $form_mode = 'default', array $values = [], bool $check_access = TRUE): array {
$entity_storage = \Drupal::entityTypeManager()->getStorage($entity_type);
$entity = $id ?
\Drupal::service('entity.repository')->getActive($entity_type, $id) : $entity_storage->create($values);
$entity = $id ? $entity_storage->load($id) : $entity_storage->create($values);
if ($entity) {
return \Drupal::service('twig_tweak.entity_form_view_builder')
->build($entity, $form_mode, $check_access);
@ -249,13 +244,11 @@ class TwigTweakExtension extends AbstractExtension {
->getStorage('file')
->loadByProperties([$selector_type => $selector]);
if (count($files) == 0) {
// To avoid ambiguity render nothing unless exact one image has been found.
if (count($files) != 1) {
return [];
}
// To avoid ambiguity order by fid.
ksort($files);
$file = reset($files);
return \Drupal::service('twig_tweak.image_view_builder')->build($file, $style, $attributes, $responsive, $check_access);
}
@ -327,14 +320,13 @@ class TwigTweakExtension extends AbstractExtension {
if ($route = \Drupal::routeMatch()->getRouteObject()) {
$title = \Drupal::service('title_resolver')->getTitle(\Drupal::request(), $route);
}
$build['#markup'] = is_array($title) ?
\Drupal::service('renderer')->render($title) : $title;
$build['#markup'] = render($title);
$build['#cache']['contexts'] = ['url'];
return $build;
}
/**
* Generates a URL from an internal or external path.
* Generates a URL from an internal path.
*
* @param string $user_input
* User input for a link or path.
@ -355,9 +347,6 @@ class TwigTweakExtension extends AbstractExtension {
$options['language'] = $language;
}
}
if (UrlHelper::isExternal($user_input)) {
return Url::fromUri($user_input, $options);
}
if (!in_array($user_input[0], ['/', '#', '?'])) {
$user_input = '/' . $user_input;
}
@ -510,8 +499,7 @@ class TwigTweakExtension extends AbstractExtension {
return NULL;
}
return \Drupal::service('file_url_generator')
->transformRelative($image_style->buildUrl($path));
return file_url_transform_relative($image_style->buildUrl($path));
}
/**
@ -561,7 +549,7 @@ class TwigTweakExtension extends AbstractExtension {
/** @var \Drupal\Core\Entity\Plugin\DataType\EntityAdapter $parent */
if ($parent = $object->getParent()) {
CacheableMetadata::createFromRenderArray($build)
->addCacheableDependency($parent->getEntity())
->merge(CacheableMetadata::createFromObject($parent->getEntity()))
->applyTo($build);
}
}
@ -571,19 +559,6 @@ class TwigTweakExtension extends AbstractExtension {
return $build;
}
/**
* Creates a data URI (RFC 2397).
*/
public static function dataUriFilter(string $data, string $mime, array $parameters = []): string {
$uri = 'data:' . $mime;
foreach ($parameters as $key => $value) {
$uri .= ';' . $key . '=' . rawurlencode($value);
}
$uri .= \str_starts_with($data, 'text/') ?
',' . rawurlencode($data) : ';base64,' . base64_encode($data);
return $uri;
}
/**
* Adds new element to the array.
*
@ -651,30 +626,6 @@ class TwigTweakExtension extends AbstractExtension {
return \Drupal::service('twig_tweak.url_extractor')->extractUrl($input, $relative);
}
/**
* Gets the URL object for the entity.
*
* @todo Remove this once Drupal allows `toUrl` method in the sandbox policy.
*
* @see https://www.drupal.org/node/2907810
* @see \Drupal\Core\Entity\EntityInterface::toUrl()
*/
public static function entityUrl(EntityInterface $entity, string $rel = 'canonical', array $options = []): Url {
return $entity->toUrl($rel, $options);
}
/**
* Gets the URL object for the entity.
*
* @todo Remove this once Drupal allows `toLink` method in the sandbox policy.
*
* @see https://www.drupal.org/node/2907810
* @see \Drupal\Core\Entity\EntityInterface::toLink()
*/
public static function entityLink(EntityInterface $entity, ?string $text = NULL, string $rel = 'canonical', array $options = []): Link {
return $entity->toLink($text, $rel, $options);
}
/**
* Returns the translation for the given entity.
*
@ -717,7 +668,7 @@ class TwigTweakExtension extends AbstractExtension {
*/
public static function phpFilter(array $context, string $code) {
// Make Twig variables available in PHP code.
extract($context, EXTR_SKIP);
extract($context);
ob_start();
// phpcs:ignore Drupal.Functions.DiscouragedFunctions.Discouraged
print eval($code);

1
src/UriExtractor.php

@ -41,7 +41,6 @@ class UriExtractor {
public function extractUri(?object $input): ?string {
$entity = $input;
if ($input instanceof EntityReferenceFieldItemListInterface) {
/** @var \Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem $item */
if ($item = $input->first()) {
$entity = $item->entity;
}

15
src/UrlExtractor.php

@ -7,7 +7,6 @@ use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Field\EntityReferenceFieldItemListInterface;
use Drupal\Core\Field\FieldItemList;
use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem;
use Drupal\Core\File\FileUrlGeneratorInterface;
use Drupal\file\FileInterface;
use Drupal\link\LinkItemInterface;
use Drupal\media\MediaInterface;
@ -25,19 +24,11 @@ class UrlExtractor {
*/
protected $entityTypeManager;
/**
* The file URL generator.
*
* @var \Drupal\Core\File\FileUrlGeneratorInterface
*/
protected $fileUrlGenerator;
/**
* Constructs a UrlExtractor object.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager, FileUrlGeneratorInterface $file_url_generator) {
public function __construct(EntityTypeManagerInterface $entity_type_manager) {
$this->entityTypeManager = $entity_type_manager;
$this->fileUrlGenerator = $file_url_generator;
}
/**
@ -53,7 +44,8 @@ class UrlExtractor {
*/
public function extractUrl($input, bool $relative = TRUE): ?string {
if (is_string($input)) {
return $this->fileUrlGenerator->{$relative ? 'generateString' : 'generateAbsoluteString'}($input);
$url = file_create_url($input);
return $relative ? file_url_transform_relative($url) : $url;
}
elseif ($input instanceof LinkItemInterface) {
return $input->getUrl()->toString();
@ -64,7 +56,6 @@ class UrlExtractor {
$entity = $input;
if ($input instanceof EntityReferenceFieldItemListInterface) {
/** @var \Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem $item */
if ($item = $input->first()) {
$entity = $item->entity;
}

19
src/View/BlockViewBuilder.php

@ -7,9 +7,9 @@ use Drupal\Core\Block\TitleBlockPluginInterface;
use Drupal\Core\Cache\CacheableDependencyInterface;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Controller\TitleResolverInterface;
use Drupal\Core\Plugin\ContextAwarePluginInterface;
use Drupal\Core\Plugin\Context\ContextHandlerInterface;
use Drupal\Core\Plugin\Context\ContextRepositoryInterface;
use Drupal\Core\Plugin\ContextAwarePluginInterface;
use Drupal\Core\Render\Element;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Session\AccountInterface;
@ -121,14 +121,9 @@ class BlockViewBuilder {
if ($access->isAllowed()) {
// Title block needs a special treatment.
if ($block_plugin instanceof TitleBlockPluginInterface) {
// Account for the scenario that a NullRouteMatch is returned. This, for
// example, is the case when Search API is indexing the site during
// Drush cron.
if ($route = $this->routeMatch->getRouteObject()) {
$request = $this->requestStack->getCurrentRequest();
$title = $this->titleResolver->getTitle($request, $route);
$block_plugin->setTitle($title);
}
$request = $this->requestStack->getCurrentRequest();
$title = $this->titleResolver->getTitle($request, $this->routeMatch->getRouteObject());
$block_plugin->setTitle($title);
}
// Place the content returned by the block plugin into a 'content' child
@ -140,7 +135,7 @@ class BlockViewBuilder {
if ($block_plugin instanceof TitleBlockPluginInterface) {
$build['content']['#cache']['contexts'][] = 'url';
}
// Some blocks return null instead of array when empty.
// Some blocks returns NULL instead of array when empty.
// @see https://www.drupal.org/project/drupal/issues/3212354
if ($wrapper && is_array($build['content']) && !Element::isEmpty($build['content'])) {
$build += [
@ -167,8 +162,8 @@ class BlockViewBuilder {
}
CacheableMetadata::createFromRenderArray($build)
->addCacheableDependency($access)
->addCacheableDependency($block_plugin)
->merge(CacheableMetadata::createFromObject($access))
->merge(CacheableMetadata::createFromObject($block_plugin))
->applyTo($build);
if (!isset($build['#cache']['keys'])) {

7
src/View/EntityFormViewBuilder.php

@ -29,8 +29,6 @@ class EntityFormViewBuilder {
/**
* Gets the built and processed entity form for the given entity type.
*
* @todo Add langcode parameter.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity.
* @param string $form_mode
@ -42,6 +40,7 @@ class EntityFormViewBuilder {
* The processed form for the given entity type and form mode.
*/
public function build(EntityInterface $entity, string $form_mode = 'default', bool $check_access = TRUE): array {
$build = [];
$operation = $entity->isNew() ? 'create' : 'update';
@ -51,8 +50,8 @@ class EntityFormViewBuilder {
}
CacheableMetadata::createFromRenderArray($build)
->addCacheableDependency($access)
->addCacheableDependency($entity)
->merge(CacheableMetadata::createFromObject($entity))
->merge(CacheableMetadata::createFromObject($access))
->applyTo($build);
return $build;

16
src/View/EntityViewBuilder.php

@ -5,7 +5,6 @@ namespace Drupal\twig_tweak\View;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityRepositoryInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
/**
@ -20,19 +19,11 @@ class EntityViewBuilder {
*/
protected $entityTypeManager;
/**
* The entity repository service.
*
* @var \Drupal\Core\Entity\EntityRepositoryInterface
*/
protected $entityRepository;
/**
* Constructs an EntityViewBuilder object.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager, EntityRepositoryInterface $entity_repository) {
public function __construct(EntityTypeManagerInterface $entity_type_manager) {
$this->entityTypeManager = $entity_type_manager;
$this->entityRepository = $entity_repository;
}
/**
@ -40,7 +31,6 @@ class EntityViewBuilder {
*/
public function build(EntityInterface $entity, string $view_mode = 'full', string $langcode = NULL, bool $check_access = TRUE): array {
$build = [];
$entity = $this->entityRepository->getTranslationFromContext($entity, $langcode);
$access = $check_access ? $entity->access('view', NULL, TRUE) : AccessResult::allowed();
if ($access->isAllowed()) {
$build = $this->entityTypeManager
@ -48,8 +38,8 @@ class EntityViewBuilder {
->view($entity, $view_mode, $langcode);
}
CacheableMetadata::createFromRenderArray($build)
->addCacheableDependency($access)
->addCacheableDependency($entity)
->merge(CacheableMetadata::createFromObject($entity))
->merge(CacheableMetadata::createFromObject($access))
->applyTo($build);
return $build;
}

8
src/View/FieldViewBuilder.php

@ -34,7 +34,7 @@ class FieldViewBuilder {
* @param string $field_name
* The field name.
* @param string|array $view_mode
* (optional) The view mode or display options.
* (optional) The view mode that should be used to render the field.
* @param string $langcode
* (optional) Language code to load translation.
* @param bool $check_access
@ -42,8 +42,6 @@ class FieldViewBuilder {
*
* @return array
* A render array for the field.
*
* @see \Drupal\Core\Entity\EntityViewBuilderInterface::viewField()
*/
public function build(
EntityInterface $entity,
@ -66,8 +64,8 @@ class FieldViewBuilder {
}
CacheableMetadata::createFromRenderArray($build)
->addCacheableDependency($access)
->addCacheableDependency($entity)
->merge(CacheableMetadata::createFromObject($access))
->merge(CacheableMetadata::createFromObject($entity))
->applyTo($build);
return $build;

77
src/View/ImageViewBuilder.php

@ -4,7 +4,6 @@ namespace Drupal\twig_tweak\View;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Image\ImageFactory;
use Drupal\file\FileInterface;
/**
@ -12,20 +11,6 @@ use Drupal\file\FileInterface;
*/
class ImageViewBuilder {
/**
* The provider image factory.
*
* @var \Drupal\Core\Image\ImageFactory
*/
protected $imageFactory;
/**
* Constructs an ImageViewBuilder object.
*/
public function __construct(ImageFactory $imageFactory) {
$this->imageFactory = $imageFactory;
}
/**
* Builds an image.
*
@ -44,53 +29,31 @@ class ImageViewBuilder {
* A renderable array to represent the image.
*/
public function build(FileInterface $file, string $style = NULL, array $attributes = [], bool $responsive = FALSE, bool $check_access = TRUE): array {
$access = $check_access ? $file->access('view', NULL, TRUE) : AccessResult::allowed();
$build = $access->isAllowed() ? $this->doBuild($file, $style, $attributes, $responsive) : [];
CacheableMetadata::createFromRenderArray($build)
->addCacheableDependency($access)
->addCacheableDependency($file)
->applyTo($build);
return $build;
}
/**
* Actually builds the image.
*/
private function doBuild(FileInterface $file, string $style = NULL, array $attributes = [], bool $responsive = FALSE): array {
$build['#uri'] = $file->getFileUri();
$build['#attributes'] = $attributes;
if (!$style) {
$build['#theme'] = 'image';
return $build;
}
$build['#width'] = $attributes['width'] ?? NULL;
$build['#height'] = $attributes['height'] ?? NULL;
$access = $check_access ? $file->access('view', NULL, TRUE) : AccessResult::allowed();
if (!$build['#width'] && !$build['#height']) {
// If an image style is given, image module needs the original image
// dimensions to calculate image style's width and height and set the
// attributes.
// @see https://www.drupal.org/project/twig_tweak/issues/3356042
$image = $this->imageFactory->get($file->getFileUri());
if ($image->isValid()) {
$build['#width'] = $image->getWidth();
$build['#height'] = $image->getHeight();
$build = [];
if ($access->isAllowed()) {
$build['#uri'] = $file->getFileUri();
$build['#attributes'] = $attributes;
if ($style) {
if ($responsive) {
$build['#type'] = 'responsive_image';
$build['#responsive_image_style_id'] = $style;
}
else {
$build['#theme'] = 'image_style';
$build['#style_name'] = $style;
}
}
else {
$build['#theme'] = 'image';
}
}
if ($responsive) {
$build['#type'] = 'responsive_image';
$build['#responsive_image_style_id'] = $style;
}
else {
$build['#theme'] = 'image_style';
$build['#style_name'] = $style;
}
CacheableMetadata::createFromRenderArray($build)
->merge(CacheableMetadata::createFromObject($access))
->applyTo($build);
return $build;
}

4
src/View/RegionViewBuilder.php

@ -7,7 +7,7 @@ use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Controller\TitleResolverInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Routing\RouteObjectInterface;
use Symfony\Cmf\Component\Routing\RouteObjectInterface;
use Symfony\Component\HttpFoundation\RequestStack;
/**
@ -89,7 +89,7 @@ class RegionViewBuilder {
/** @var \Drupal\block\BlockInterface[] $blocks */
foreach ($blocks as $id => $block) {
$access = $block->access('view', NULL, TRUE);
$cache_metadata = $cache_metadata->addCacheableDependency($access);
$cache_metadata = $cache_metadata->merge(CacheableMetadata::createFromObject($access));
if ($access->isAllowed()) {
$block_plugin = $block->getPlugin();
if ($block_plugin instanceof TitleBlockPluginInterface) {

67
tests/src/Functional/TwigTweakTest.php

@ -5,13 +5,12 @@ namespace Drupal\Tests\twig_tweak\Functional;
use Drupal\Core\Link;
use Drupal\Core\Render\Markup;
use Drupal\Core\Url;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\TestFileCreationTrait;
use Drupal\file\Entity\File;
use Drupal\file\FileInterface;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\media\Entity\Media;
use Drupal\responsive_image\Entity\ResponsiveImageStyle;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\TestFileCreationTrait;
use Drupal\user\Entity\Role;
/**
@ -26,7 +25,7 @@ final class TwigTweakTest extends BrowserTestBase {
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'claro';
protected $defaultTheme = 'classy';
/**
* {@inheritdoc}
@ -54,14 +53,14 @@ final class TwigTweakTest extends BrowserTestBase {
$image_file = File::create([
'uri' => $test_files[0]->uri,
'uuid' => 'b2c22b6f-7bf8-4da4-9de5-316e93487518',
'status' => FileInterface::STATUS_PERMANENT,
'status' => FILE_STATUS_PERMANENT,
]);
$image_file->save();
$media_file = File::create([
'uri' => $test_files[8]->uri,
'uuid' => '5dd794d0-cb75-4130-9296-838aebc1fe74',
'status' => FileInterface::STATUS_PERMANENT,
'status' => FILE_STATUS_PERMANENT,
]);
$media_file->save();
@ -134,20 +133,21 @@ final class TwigTweakTest extends BrowserTestBase {
// -- Block.
$xpath = '//div[@class = "tt-block"]';
$xpath .= '/img[contains(@src, "/core/themes/claro/logo.svg") and @alt="Home"]';
$xpath .= '/img[contains(@src, "/core/themes/classy/logo.svg") and @alt="Home"]';
$this->assertXpath($xpath);
// -- Block with wrapper.
$xpath = '//div[@class = "tt-block-with-wrapper"]';
$xpath .= '/div[@class = "block block-system block-system-branding-block"]';
$xpath .= '/h2[text() = "Branding"]';
$xpath .= '/following-sibling::a[img[contains(@src, "/core/themes/claro/logo.svg") and @alt="Home"]]';
$xpath .= '/following-sibling::a[img[contains(@src, "/core/themes/classy/logo.svg") and @alt="Home"]]';
$xpath .= '/following-sibling::div[@class = "site-name"]/a';
$this->assertXpath($xpath);
// -- Region.
$xpath = '//div[@class = "tt-region"]/div[@class = "region region-highlighted"]';
$xpath .= '/div[contains(@class, "block-system-powered-by-block")]/span[. = "Powered by Drupal"]';
$xpath = '//div[@class = "tt-region"]/div[@class = "region region-sidebar-first"]';
$xpath .= '/div[contains(@class, "block-page-title-block") and h1[@class="page-title" and text() = "Twig Tweak Test"]]';
$xpath .= '/following-sibling::div[contains(@class, "block-system-powered-by-block")]/span[. = "Powered by Drupal"]';
$this->assertXpath($xpath);
// -- Entity (default view mode).
@ -190,13 +190,13 @@ final class TwigTweakTest extends BrowserTestBase {
// -- Entity add form.
$xpath = '//div[@class = "tt-entity-add-form"]/form';
$xpath .= '//input[@name = "title[0][value]" and @value = ""]';
$xpath .= '/../../../../..//div/input[@type = "submit" and @value = "Save"]';
$xpath .= '/../../../div/input[@type = "submit" and @value = "Save"]';
$this->assertXpath($xpath);
// -- Entity edit form.
$xpath = '//div[@class = "tt-entity-edit-form"]/form';
$xpath .= '//input[@name = "title[0][value]" and @value = "Alpha"]';
$xpath .= '/../../../../..//div/input[@type = "submit" and @value = "Save"]';
$xpath .= '/../../../div/input[@type = "submit" and @value = "Save"]';
$this->assertXpath($xpath);
// -- Field.
@ -265,23 +265,18 @@ final class TwigTweakTest extends BrowserTestBase {
$xpath = sprintf('//div[@class = "tt-url"]/div[@data-case="with-langcode" and text() = "%s"]', $url);
$this->assertXpath($xpath);
// -- External URL.
$url = 'https://example.com/node?foo=bar&page=1#here';
$xpath = sprintf('//div[@class = "tt-url"]/div[@data-case="external" and text() = "%s"]', $url);
$this->assertXpath($xpath);
// -- Link.
$url = Url::fromUserInput('/node/1/edit', ['absolute' => TRUE]);
$link = Link::fromTextAndUrl('Edit', $url)->toString();
$xpath = '//div[@class = "tt-link"]';
self::assertSame((string) $link, $this->xpath($xpath)[0]->getHtml());
self::assertEquals($link, $this->xpath($xpath)[0]->getHtml());
// -- Link with HTML.
$text = Markup::create('<b>Edit</b>');
$url = Url::fromUserInput('/node/1/edit', ['absolute' => TRUE]);
$link = Link::fromTextAndUrl($text, $url)->toString();
$xpath = '//div[@class = "tt-link-html"]';
self::assertSame((string) $link, $this->xpath($xpath)[0]->getHtml());
self::assertEquals($link, $this->xpath($xpath)[0]->getHtml());
// -- Status messages.
$xpath = '//div[@class = "tt-messages"]//div[contains(@class, "messages--status") and contains(., "Hello world!")]';
@ -342,14 +337,6 @@ final class TwigTweakTest extends BrowserTestBase {
$xpath = '//div[@class = "tt-with-nested" and text() = "{alpha:{beta:{gamma:456}}}"]';
$this->assertXpath($xpath);
// -- Data URI (SVG).
$xpath = '//div[@class = "tt-data-uri-svg"]/img[@src = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxyZWN0IHdpZHRoPSIxMDAiIGhlaWdodD0iNTAiIGZpbGw9ImxpbWUiLz48L3N2Zz4="]';
$this->assertXpath($xpath);
// -- Data URI (Iframe).
$xpath = '//div[@class = "tt-data-uri-iframe"]/iframe[@src = "data:text/html;charset=UTF-8;base64,PGgxPkhlbGxvIHdvcmxkITwvaDE+"]';
$this->assertXpath($xpath);
// -- 'children'.
$xpath = '//div[@class = "tt-children" and text() = "doremi"]';
$this->assertXpath($xpath);
@ -403,30 +390,6 @@ final class TwigTweakTest extends BrowserTestBase {
$xpath = '//div[@class = "tt-file-url-from-media-field" and contains(text(), "/files/image-1.png")]';
$this->assertXpath($xpath);
// -- Entity URL (canonical).
$xpath = '//div[@class = "tt-entity-url" and contains(text(), "/node/1#test") and not(contains(text(), "http"))]';
$this->assertXpath($xpath);
// -- Entity URL (absolute).
$xpath = '//div[@class = "tt-entity-url-absolute" and contains(text(), "/node/1") and contains(text(), "http")]';
$this->assertXpath($xpath);
// -- Entity URL (edit form).
$xpath = '//div[@class = "tt-entity-url-edit-form" and contains(text(), "/node/1/edit")]';
$this->assertXpath($xpath);
// -- Entity Link (canonical).
$xpath = '//div[@class = "tt-entity-link"]/a[text() = "Alpha" and contains(@href, "/node/1") and not(contains(@href, "http"))]';
$this->assertXpath($xpath);
// -- Entity Link (absolute).
$xpath = '//div[@class = "tt-entity-link-absolute"]/a[text() = "Example" and contains(@href, "/node/1") and contains(@href, "http")]';
$this->assertXpath($xpath);
// -- Entity Link (edit form).
$xpath = '//div[@class = "tt-entity-link-edit-form"]/a[text() = "Edit" and contains(@href, "/node/1/edit")]';
$this->assertXpath($xpath);
// -- Entity translation.
// This is just a smoke test because the node is not translatable.
$xpath = '//div[@class = "tt-translation" and contains(text(), "Alpha")]';
@ -446,7 +409,7 @@ final class TwigTweakTest extends BrowserTestBase {
}
/**
* Checks that an element specified by the xpath exists on the current page.
* Checks that an element specified by a the xpath exists on the current page.
*/
private function assertXpath(string $xpath): void {
$this->assertSession()->elementExists('xpath', $xpath);

8
tests/src/Kernel/AbstractExtractorTestCase.php

@ -3,7 +3,6 @@
namespace Drupal\Tests\twig_tweak\Kernel;
use Drupal\file\Entity\File;
use Drupal\file\FileInterface;
use Drupal\KernelTests\KernelTestBase;
use Drupal\media\Entity\Media;
use Drupal\node\Entity\Node;
@ -50,23 +49,22 @@ abstract class AbstractExtractorTestCase extends KernelTestBase {
$this->installConfig(['node', 'twig_tweak_test']);
$this->installSchema('file', 'file_usage');
$this->installEntitySchema('user');
$this->installEntitySchema('file');
$this->installEntitySchema('media');
$test_files = $this->getTestFiles('image');
//
$image_file = File::create([
'uri' => $test_files[0]->uri,
'uuid' => 'a2cb2b6f-7bf8-4da4-9de5-316e93487518',
'status' => FileInterface::STATUS_PERMANENT,
'status' => FILE_STATUS_PERMANENT,
]);
$image_file->save();
$media_file = File::create([
'uri' => $test_files[2]->uri,
'uuid' => '5dd794d0-cb75-4130-9296-838aebc1fe74',
'status' => FileInterface::STATUS_PERMANENT,
'status' => FILE_STATUS_PERMANENT,
]);
$media_file->save();

3
tests/src/Kernel/CacheMetadataExtractorTest.php

@ -90,8 +90,7 @@ final class CacheMetadataExtractorTest extends AbstractTestCase {
self::assertRenderArray($expected_build, $build);
// -- Wrong type.
$exception = new \InvalidArgumentException('The input should be either instance of Drupal\Core\Cache\CacheableDependencyInterface or array. stdClass was given.');
self::expectExceptionObject($exception);
self::expectErrorMessage('The input should be either instance of Drupal\Core\Cache\CacheableDependencyInterface or array. stdClass was given.');
/* @noinspection PhpParamsInspection */
$extractor->extractCacheMetadata(new \stdClass());
}

2
tests/src/Kernel/EntityFormViewBuilderTest.php

@ -3,9 +3,9 @@
namespace Drupal\Tests\twig_tweak\Kernel;
use Drupal\Core\Cache\Cache;
use Drupal\Tests\user\Traits\UserCreationTrait;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\Tests\user\Traits\UserCreationTrait;
/**
* A test for EntityFormViewBuilder.

6
tests/src/Kernel/EntityViewBuilderTest.php

@ -86,7 +86,7 @@ final class EntityViewBuilderTest extends AbstractTestCase {
self::assertCache($expected_cache, $build['#cache']);
$expected_html = <<< 'HTML'
<article>
<article role="article">
<h2><a href="/node/1" rel="bookmark"><span>Public node</span></a></h2>
<div></div>
</article>
@ -119,7 +119,7 @@ final class EntityViewBuilderTest extends AbstractTestCase {
self::assertCache($expected_cache, $build['#cache']);
$expected_html = <<< 'HTML'
<article>
<article role="article">
<h2><a href="/node/1" rel="bookmark"><span>Public node</span></a></h2>
<div>
<ul class="links inline">
@ -174,7 +174,7 @@ final class EntityViewBuilderTest extends AbstractTestCase {
self::assertCache($expected_cache, $build['#cache']);
$expected_html = <<< 'HTML'
<article>
<article role="article">
<h2><a href="/node/2" rel="bookmark"><span>Private node</span></a></h2>
<div></div>
</article>

2
tests/src/Kernel/FieldViewBuilderTest.php

@ -3,9 +3,9 @@
namespace Drupal\Tests\twig_tweak\Kernel;
use Drupal\Core\Cache\Cache;
use Drupal\Tests\user\Traits\UserCreationTrait;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\Tests\user\Traits\UserCreationTrait;
/**
* A test for FieldViewBuilder.

161
tests/src/Kernel/ImageViewBuilderTest.php

@ -4,14 +4,12 @@ namespace Drupal\Tests\twig_tweak\Kernel;
use Drupal\Core\Cache\Cache;
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\Core\File\FileSystemInterface;
use Drupal\file\Entity\File;
use Drupal\file\FileInterface;
use Drupal\image\Entity\ImageStyle;
use Drupal\responsive_image\Entity\ResponsiveImageStyle;
/**
* A test class for testing the image view builder.
* A test for ImageViewBuilderTest.
*
* @group twig_tweak
*/
@ -31,93 +29,15 @@ final class ImageViewBuilderTest extends AbstractTestCase {
'breakpoint',
];
/**
* The public image URI.
*
* @var string
*/
protected string $publicImageUri;
/**
* The private image URI.
*
* @var string
*/
protected string $privateImageUri;
/**
* The public image file.
*
* @var \Drupal\file\FileInterface
*/
protected $publicImage;
/**
* The private image file.
*
* @var \Drupal\file\FileInterface
*/
protected $privateImage;
/**
* {@inheritdoc}
*/
public function setUp(): void {
parent::setUp();
$this->installEntitySchema('file');
$this->installSchema('file', 'file_usage');
$file_system = $this->container->get('file_system');
$file_system->prepareDirectory($this->siteDirectory, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS);
$private_directory = $this->siteDirectory . '/private';
$file_system->prepareDirectory($private_directory, FileSystemInterface::CREATE_DIRECTORY);
$this->setSetting('file_private_path', $private_directory);
$image_style = ImageStyle::create([
'name' => 'small',
'label' => 'Small',
]);
// Add a crop effect:
$image_style->addImageEffect([
'id' => 'image_resize',
'data' => [
'width' => 10,
'height' => 10,
],
'weight' => 0,
]);
$image_style->save();
$responsive_image_style = ResponsiveImageStyle::create([
'id' => 'wide',
'label' => 'Wide',
'breakpoint_group' => 'twig_tweak_image_view_builder',
'fallback_image_style' => 'small',
]);
$responsive_image_style->save();
// Create a copy of a test image file in root. Original sizes: 40x20px.
$this->publicImageUri = 'public://image-test-do.jpg';
$file_system->copy('core/tests/fixtures/files/image-test.jpg', $this->publicImageUri, FileSystemInterface::EXISTS_REPLACE);
$this->assertFileExists($this->publicImageUri);
$this->publicImage = File::create([
'uri' => $this->publicImageUri,
'status' => FileInterface::STATUS_PERMANENT,
]);
$this->publicImage->save();
// Create a copy of a test image file in root. Original sizes: 40x20px.
$this->privateImageUri = 'private://image-test-do.png';
$file_system->copy('core/tests/fixtures/files/image-test.png', $this->privateImageUri, FileSystemInterface::EXISTS_REPLACE);
$this->assertFileExists($this->privateImageUri);
$this->privateImage = File::create([
'uri' => $this->privateImageUri,
'status' => FileInterface::STATUS_PERMANENT,
]);
$this->privateImage->save();
ImageStyle::create(['name' => 'large'])->save();
ResponsiveImageStyle::create(['id' => 'wide'])->save();
}
/**
@ -133,20 +53,21 @@ final class ImageViewBuilderTest extends AbstractTestCase {
* Test callback.
*/
public function testImageViewBuilder(): void {
$view_builder = $this->container->get('twig_tweak.image_view_builder');
$uri = $this->publicImage->getFileUri();
$image = \Drupal::service('image.factory')->get($uri);
$imageOriginalWidth = $image->getWidth();
$imageOriginalHeight = $image->getHeight();
self::assertTrue($image->isValid());
self::assertEquals(40, $imageOriginalWidth);
self::assertEquals(20, $imageOriginalHeight);
/** @var \Drupal\file\FileInterface $public_image */
$public_image = File::create(['uri' => 'public://ocean.jpg']);
$public_image->save();
/** @var \Drupal\file\FileInterface $private_image */
$private_image = File::create(['uri' => 'private://sea.jpg']);
$private_image->save();
// -- Without style.
$build = $view_builder->build($this->publicImage);
$build = $view_builder->build($public_image);
$expected_build = [
'#uri' => $this->publicImageUri,
'#uri' => 'public://ocean.jpg',
'#attributes' => [],
'#theme' => 'image',
'#cache' => [
@ -154,47 +75,37 @@ final class ImageViewBuilderTest extends AbstractTestCase {
'user',
'user.permissions',
],
'tags' => [
'file:1',
'tag_for_' . $this->publicImageUri,
],
'tags' => ['tag_for_public://ocean.jpg'],
'max-age' => 70,
],
];
self::assertRenderArray($expected_build, $build);
self::assertSame('<img src="/files/image-test-do.jpg" alt="" />', $this->renderPlain($build));
self::assertSame('<img src="/files/ocean.jpg" alt="" />', $this->renderPlain($build));
// -- With style.
$build = $view_builder->build($this->publicImage, 'small', ['alt' => 'Image Test Do']);
$build = $view_builder->build($public_image, 'large', ['alt' => 'Ocean']);
$expected_build = [
'#uri' => $this->publicImageUri,
'#attributes' => ['alt' => 'Image Test Do'],
'#width' => $imageOriginalWidth,
'#height' => $imageOriginalHeight,
'#uri' => 'public://ocean.jpg',
'#attributes' => ['alt' => 'Ocean'],
'#theme' => 'image_style',
'#style_name' => 'small',
'#style_name' => 'large',
'#cache' => [
'contexts' => [
'user',
'user.permissions',
],
'tags' => [
'file:1',
'tag_for_' . $this->publicImageUri,
],
'tags' => ['tag_for_public://ocean.jpg'],
'max-age' => 70,
],
];
self::assertRenderArray($expected_build, $build);
self::assertSame('<img alt="Image Test Do" src="/files/styles/small/public/image-test-do.jpg?itok=abc" width="10" height="10" loading="lazy" />', $this->renderPlain($build));
self::assertSame('<img alt="Ocean" src="/files/styles/large/public/ocean.jpg?itok=abc" />', $this->renderPlain($build));
// -- With responsive style.
$build = $view_builder->build($this->publicImage, 'wide', ['alt' => 'Image Test Do'], TRUE);
$build = $view_builder->build($public_image, 'wide', ['alt' => 'Ocean'], TRUE);
$expected_build = [
'#uri' => $this->publicImageUri,
'#attributes' => ['alt' => 'Image Test Do'],
'#width' => $imageOriginalWidth,
'#height' => $imageOriginalHeight,
'#uri' => 'public://ocean.jpg',
'#attributes' => ['alt' => 'Ocean'],
'#type' => 'responsive_image',
'#responsive_image_style_id' => 'wide',
'#cache' => [
@ -202,25 +113,19 @@ final class ImageViewBuilderTest extends AbstractTestCase {
'user',
'user.permissions',
],
'tags' => [
'file:1',
'tag_for_' . $this->publicImageUri,
],
'tags' => ['tag_for_public://ocean.jpg'],
'max-age' => 70,
],
];
self::assertRenderArray($expected_build, $build);
self::assertSame('<picture><img src="/files/styles/small/public/image-test-do.jpg?itok=abc" width="10" height="10" alt="Image Test Do" loading="lazy" /></picture>', $this->renderPlain($build));
self::assertSame('<picture><img src="/files/ocean.jpg" alt="Ocean" /></picture>', $this->renderPlain($build));
// -- Private image with access check.
$build = $view_builder->build($this->privateImage);
$build = $view_builder->build($private_image);
$expected_build = [
'#cache' => [
'contexts' => ['user'],
'tags' => [
'file:2',
'tag_for_' . $this->privateImageUri,
],
'tags' => ['tag_for_private://sea.jpg'],
'max-age' => 70,
],
];
@ -228,19 +133,19 @@ final class ImageViewBuilderTest extends AbstractTestCase {
self::assertSame('', $this->renderPlain($build));
// -- Private image without access check.
$build = $view_builder->build($this->privateImage, NULL, [], FALSE, FALSE);
$build = $view_builder->build($private_image, NULL, [], FALSE, FALSE);
$expected_build = [
'#uri' => $this->privateImageUri,
'#uri' => 'private://sea.jpg',
'#attributes' => [],
'#theme' => 'image',
'#cache' => [
'contexts' => [],
'tags' => ['file:2'],
'tags' => [],
'max-age' => Cache::PERMANENT,
],
];
self::assertRenderArray($expected_build, $build);
self::assertSame('<img src="/files/image-test-do.png" alt="" />', $this->renderPlain($build));
self::assertSame('<img src="/files/sea.jpg" alt="" />', $this->renderPlain($build));
}
/**
@ -249,7 +154,7 @@ final class ImageViewBuilderTest extends AbstractTestCase {
private function renderPlain(array $build): string {
$html = $this->container->get('renderer')->renderPlain($build);
$html = preg_replace('#src=".+/files/#s', 'src="/files/', $html);
$html = preg_replace('#\?itok=.+?"#', '?itok=abc"', $html);
$html = preg_replace('#\?itok=.+"#', '?itok=abc"', $html);
$html = preg_replace(['#\s{2,}#', '#\n#'], '', $html);
return rtrim($html);
}

14
tests/src/Kernel/RegionViewBuilderTest.php

@ -33,12 +33,12 @@ final class RegionViewBuilderTest extends AbstractTestCase {
public function setUp(): void {
parent::setUp();
$this->installEntitySchema('block');
$this->container->get('theme_installer')->install(['stark']);
$this->container->get('theme_installer')->install(['stable']);
$values = [
'id' => 'public_block',
'plugin' => 'system_powered_by_block',
'theme' => 'stark',
'theme' => 'stable',
'region' => 'sidebar_first',
];
Block::create($values)->save();
@ -46,7 +46,7 @@ final class RegionViewBuilderTest extends AbstractTestCase {
$values = [
'id' => 'private_block',
'plugin' => 'system_powered_by_block',
'theme' => 'stark',
'theme' => 'stable',
'region' => 'sidebar_first',
];
Block::create($values)->save();
@ -61,7 +61,7 @@ final class RegionViewBuilderTest extends AbstractTestCase {
$renderer = $this->container->get('renderer');
$build = $view_builder->build('sidebar_first');
// The build should be empty because 'stark' is not a default theme.
// The build should be empty because 'stable' is not a default theme.
$expected_build = [
'#cache' => [
'contexts' => [],
@ -72,7 +72,7 @@ final class RegionViewBuilderTest extends AbstractTestCase {
self::assertSame($expected_build, $build);
// Specify the theme name explicitly.
$build = $view_builder->build('sidebar_first', 'stark');
$build = $view_builder->build('sidebar_first', 'stable');
$expected_build = [
// Only public_block should be rendered.
// @see twig_tweak_test_block_access()
@ -129,11 +129,11 @@ final class RegionViewBuilderTest extends AbstractTestCase {
$actual_html = $renderer->renderPlain($build);
self::assertSame(self::normalizeHtml($expected_html), self::normalizeHtml($actual_html));
// Set 'stark' as default site theme and check if the view builder without
// Set 'stable' as default site theme and check if the view builder without
// 'theme' argument returns the same result.
$this->container->get('config.factory')
->getEditable('system.theme')
->set('default', 'stark')
->set('default', 'stable')
->save();
$build = $view_builder->build('sidebar_first');

2
tests/src/Kernel/UrlExtractorTest.php

@ -15,7 +15,7 @@ final class UrlExtractorTest extends AbstractExtractorTestCase {
public function testUrlExtractor(): void {
$extractor = $this->container->get('twig_tweak.url_extractor');
$base_url = \Drupal::service('file_url_generator')->generateAbsoluteString('');
$base_url = file_create_url('');
$request = \Drupal::request();
$absolute_url = "{$request->getScheme()}://{$request->getHost()}/foo/bar.txt";

19
tests/twig_tweak_test/config/install/block.block.classy_content.yml

@ -0,0 +1,19 @@
langcode: en
status: true
dependencies:
module:
- system
theme:
- classy
id: classy_content
theme: classy
region: content
weight: 0
provider: null
plugin: system_main_block
settings:
id: system_main_block
label: 'Main page content'
provider: system
label_display: '0'
visibility: { }

17
tests/twig_tweak_test/config/install/block.block.classy_page_title.yml

@ -0,0 +1,17 @@
langcode: en
status: true
dependencies:
theme:
- classy
id: classy_page_title
theme: classy
region: sidebar_first
weight: 0
provider: null
plugin: page_title_block
settings:
id: page_title_block
label: 'Page title'
provider: core
label_display: '0'
visibility: { }

10
tests/twig_tweak_test/config/install/block.block.claro_powered_by_drupal.yml → tests/twig_tweak_test/config/install/block.block.classy_powered_by_drupal.yml

@ -4,11 +4,11 @@ dependencies:
module:
- system
theme:
- claro
id: claro_powered_by_drupal
theme: claro
region: highlighted
weight: 0
- classy
id: classy_powered_by_drupal
theme: classy
region: sidebar_first
weight: 20
provider: null
plugin: system_powered_by_block
settings:

19
tests/twig_tweak_test/config/install/block.block.classy_status_messages.yml

@ -0,0 +1,19 @@
langcode: en
status: true
dependencies:
module:
- system
theme:
- classy
id: classy_status_messages
theme: classy
region: sidebar_first
weight: 10
provider: null
plugin: system_messages_block
settings:
id: system_messages_block
label: 'Status messages'
provider: system
label_display: '0'
visibility: { }

17
tests/twig_tweak_test/templates/twig-tweak-test.html.twig

@ -30,7 +30,7 @@
<div class="tt-view-result">{{ drupal_view_result('twig_tweak_test', 'page_1')|length }}</div>
<div class="tt-block">{{ drupal_block('system_branding_block', {use_site_name: false}, false) }}</div>
<div class="tt-block-with-wrapper">{{ drupal_block('system_branding_block', {label: 'Branding'}) }}</div>
<div class="tt-region">{{ drupal_region('highlighted') }}</div>
<div class="tt-region">{{ drupal_region('sidebar_first') }}</div>
<div class="tt-entity-default">{{ drupal_entity('node', 1) }}</div>
<div class="tt-entity-teaser">{{ drupal_entity('node', 1, 'teaser') }}</div>
<div class="tt-entity-uuid">{{ drupal_entity('node', 'ad1b902a-344f-41d1-8c61-a69f0366dbfa') }}</div>
@ -54,7 +54,6 @@
<div class="tt-url">
<div data-case="default">{{ drupal_url('node/1', {absolute: true}) }}</div>
<div data-case="with-langcode">{{ drupal_url('node/1', {absolute: true, langcode: 'ru'}) }}</div>
<div data-case="external">{{ drupal_url('https://example.com/node?foo=bar', {query: {page: 1}, fragment: 'here'}) }}</div>
</div>
<div class="tt-link">{{ drupal_link('Edit', 'node/1/edit', {absolute: true}) }}</div>
<div class="tt-link-html">{% set link_text %}<b>Edit</b>{% endset %}{{ drupal_link(link_text, 'node/1/edit', {absolute: true}) }}</div>
@ -67,16 +66,10 @@
<div class="tt-image-style">{{ 'public://images/ocean.jpg'|image_style('thumbnail') }}</div>
<div class="tt-transliterate">{{ 'Привет!'|transliterate('ru') }}</div>
<div class="tt-check-markup">{{ '<b>bold</b> <strong>strong</strong>'|check_markup('twig_tweak_test') }}</div>
<div class="tt-format-size">{{ 12345|format_size }}</div>
<div class="tt-format-size">{{ 12345|format_size() }}</div>
<div class="tt-truncate">{{ 'Hello world!'|truncate(10, true, true) }}</div>
<div class="tt-with">{{ {'#markup':'Example'}|with('#prefix', '<b>')|with('#suffix', '</b>') }}</div>
<div class="tt-with-nested">{{ {alpha: {beta: {gamma: 123}}}|with(['alpha', 'beta', 'gamma'], 456)|json_encode|replace({'"':''}) }}</div>
<div class="tt-data-uri-svg">
<img src="{{ '<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="50" fill="lime"/></svg>'|data_uri('image/svg+xml') }}" alt="{{ 'Rectangle'|t }}" style="height: 50px;"/>
</div>
<div class="tt-data-uri-iframe">
<iframe src="{{ '<h1>Hello world!</h1>'|data_uri('text/html', {charset: 'UTF-8'}) }}"></iframe>
</div>
<div class="tt-children">
{%-
set build = {
@ -106,12 +99,6 @@
<div class="tt-file-url-from-image-field">{{ node.field_image|file_url }}</div>
<div class="tt-file-url-from-image-field-delta">{{ node.field_image[0]|file_url }}</div>
<div class="tt-file-url-from-media-field">{{ node.field_media|file_url }}</div>
<div class="tt-entity-url">{{ node|entity_url(options={fragment: 'test'}) }}</div>
<div class="tt-entity-url-absolute">{{ node|entity_url(options={absolute: true}) }}</div>
<div class="tt-entity-url-edit-form">{{ node|entity_url('edit-form') }}</div>
<div class="tt-entity-link">{{ node|entity_link }}</div>
<div class="tt-entity-link-absolute">{{ node|entity_link('Example', options={absolute: true}) }}</div>
<div class="tt-entity-link-edit-form">{{ node|entity_link('Edit', 'edit-form') }}</div>
<div class="tt-translation">{{ (node|translation).title.value }}</div>
<div class="tt-functions_alter">{{ foo('bar') }}</div>
<div class="tt-filters_alter">{{ 'foo'|bar }}</div>

2
tests/twig_tweak_test/twig_tweak_test.info.yml

@ -2,7 +2,7 @@ name: Twig tweak test
type: module
description: Support module for Twig tweak testing.
package: Testing
core_version_requirement: ^9 || ^10
core_version_requirement: ^9
dependencies:
- drupal:system (>= 9.0)
- drupal:block

35
twig_tweak.api.php

@ -7,7 +7,6 @@
use Drupal\Component\Utility\Unicode;
use Drupal\node\NodeInterface;
use Twig\TwigFilter;
use Twig\TwigFunction;
use Twig\TwigTest;
@ -23,16 +22,20 @@ use Twig\TwigTest;
* Twig functions to alter.
*/
function hook_twig_tweak_functions_alter(array &$functions): void {
// @phpcs:disable
// A simple way to implement lazy loaded global variables.
$callback = static fn (string $name): ?string =>
match ($name) {
'foo' => 'Foo',
'bar' => 'Bar',
default => NULL,
};
$functions[] = new TwigFunction('var', $callback);
// @phpcs:enable
$functions[] = new TwigFunction('var', function (string $name) {
$value = NULL;
switch ($name) {
case 'foo':
$value = 'Foo';
break;
case 'bar':
$value = 'Bar';
break;
}
return $value;
});
}
/**
@ -42,9 +45,9 @@ function hook_twig_tweak_functions_alter(array &$functions): void {
* Twig filters to alter.
*/
function hook_twig_tweak_filters_alter(array &$filters): void {
$filters[] = new TwigFilter('str_pad', 'str_pad');
$filters[] = new TwigFilter('ucfirst', [Unicode::class, 'ucfirst']);
$filters[] = new TwigFilter('lcfirst', [Unicode::class, 'lcfirst']);
$filters[] = new TwigFunction('str_pad', 'str_pad');
$filters[] = new TwigFunction('ucfirst', [Unicode::class, 'ucfirst']);
$filters[] = new TwigFunction('lcfirst', [Unicode::class, 'lcfirst']);
}
/**
@ -54,9 +57,9 @@ function hook_twig_tweak_filters_alter(array &$filters): void {
* Twig tests to alter.
*/
function hook_twig_tweak_tests_alter(array &$tests): void {
$callback = static fn (NodeInterface $node): bool =>
\Drupal::time()->getRequestTime() - $node->getCreatedTime() > 3600 * 24 * 365;
$tests[] = new TwigTest('outdated', $callback);
$tests[] = new TwigTest('outdated', function (NodeInterface $node): bool {
return \Drupal::time()->getRequestTime() - $node->getCreatedTime() > 3600 * 24 * 365;
});
}
/**

2
twig_tweak.info.yml

@ -1,6 +1,6 @@
name: Twig Tweak
type: module
description: Provides some extra Twig functions and filters.
core_version_requirement: ^9 || ^10
core_version_requirement: ^9
dependencies:
- drupal:system (>=9.0)

5
twig_tweak.services.yml

@ -15,7 +15,7 @@ services:
twig_tweak.entity_view_builder:
class: Drupal\twig_tweak\View\EntityViewBuilder
arguments: ['@entity_type.manager', '@entity.repository']
arguments: ['@entity_type.manager']
twig_tweak.entity_form_view_builder:
class: Drupal\twig_tweak\View\EntityFormViewBuilder
@ -31,11 +31,10 @@ services:
twig_tweak.image_view_builder:
class: Drupal\twig_tweak\View\ImageViewBuilder
arguments: ['@image.factory']
twig_tweak.url_extractor:
class: Drupal\twig_tweak\UrlExtractor
arguments: ['@entity_type.manager', '@file_url_generator']
arguments: ['@entity_type.manager']
twig_tweak.uri_extractor:
class: Drupal\twig_tweak\UriExtractor

Loading…
Cancel
Save