Compare commits

..

2 Commits
3.x ... 8.x-1.x

  1. 9
      .gitlab-ci.yml
  2. 23
      README.md
  3. 9
      README.txt
  4. 23
      composer.json
  5. 6
      docs/README.md
  6. 126
      docs/blocks.md
  7. 403
      docs/cheat-sheet.md
  8. 69
      docs/migration-to-3.x.md
  9. 63
      docs/views.md
  10. 11
      drush.services.yml
  11. BIN
      logo.png
  12. 16
      phpcs.xml
  13. 52
      src/CacheMetadataExtractor.php
  14. 217
      src/Command/DebugCommand.php
  15. 271
      src/Command/LintCommand.php
  16. 37
      src/Command/ValidateCommand.php
  17. 531
      src/TwigExtension.php
  18. 729
      src/TwigTweakExtension.php
  19. 87
      src/UriExtractor.php
  20. 117
      src/UrlExtractor.php
  21. 186
      src/View/BlockViewBuilder.php
  22. 61
      src/View/EntityFormViewBuilder.php
  23. 57
      src/View/EntityViewBuilder.php
  24. 76
      src/View/FieldViewBuilder.php
  25. 98
      src/View/ImageViewBuilder.php
  26. 84
      src/View/MenuViewBuilder.php
  27. 114
      src/View/RegionViewBuilder.php
  28. 445
      tests/src/Functional/TwigTweakTest.php
  29. 93
      tests/src/Kernel/AbstractExtractorTestCase.php
  30. 45
      tests/src/Kernel/AbstractTestCase.php
  31. 212
      tests/src/Kernel/AccessTest.php
  32. 171
      tests/src/Kernel/BlockViewBuilderTest.php
  33. 99
      tests/src/Kernel/CacheMetadataExtractorTest.php
  34. 130
      tests/src/Kernel/EntityFormViewBuilderTest.php
  35. 202
      tests/src/Kernel/EntityViewBuilderTest.php
  36. 140
      tests/src/Kernel/FieldViewBuilderTest.php
  37. 257
      tests/src/Kernel/ImageViewBuilderTest.php
  38. 124
      tests/src/Kernel/MenuViewBuilderTest.php
  39. 154
      tests/src/Kernel/RegionViewBuilderTest.php
  40. 61
      tests/src/Kernel/UriExtractorTest.php
  41. 87
      tests/src/Kernel/UrlExtractorTest.php
  42. 17
      tests/twig_tweak_test/config/install/block.block.classy_page_title.yml
  43. 10
      tests/twig_tweak_test/config/install/block.block.classy_powered_by_drupal.yml
  44. 19
      tests/twig_tweak_test/config/install/block.block.classy_status_messages.yml
  45. 80
      tests/twig_tweak_test/config/install/core.entity_form_display.node.page.default.yml
  46. 28
      tests/twig_tweak_test/config/install/core.entity_view_display.media.image.default.yml
  47. 27
      tests/twig_tweak_test/config/install/core.entity_view_display.media.remote_video.default.yml
  48. 29
      tests/twig_tweak_test/config/install/core.entity_view_display.node.page.default.yml
  49. 31
      tests/twig_tweak_test/config/install/core.entity_view_display.node.page.teaser.yml
  50. 40
      tests/twig_tweak_test/config/install/field.field.media.image.field_media_image.yml
  51. 18
      tests/twig_tweak_test/config/install/field.field.media.remote_video.field_media_oembed_video.yml
  52. 22
      tests/twig_tweak_test/config/install/field.field.node.page.body.yml
  53. 37
      tests/twig_tweak_test/config/install/field.field.node.page.field_image.yml
  54. 29
      tests/twig_tweak_test/config/install/field.field.node.page.field_media.yml
  55. 32
      tests/twig_tweak_test/config/install/field.storage.media.field_media_image.yml
  56. 20
      tests/twig_tweak_test/config/install/field.storage.media.field_media_oembed_video.yml
  57. 31
      tests/twig_tweak_test/config/install/field.storage.node.field_image.yml
  58. 19
      tests/twig_tweak_test/config/install/field.storage.node.field_media.yml
  59. 4
      tests/twig_tweak_test/config/install/filter.format.twig_tweak_test.yml
  60. 13
      tests/twig_tweak_test/config/install/media.type.image.yml
  61. 17
      tests/twig_tweak_test/config/install/media.type.remote_video.yml
  62. 12
      tests/twig_tweak_test/config/install/node.type.page.yml
  63. 4
      tests/twig_tweak_test/config/install/system.menu.twig-tweak-test.yml
  64. 3
      tests/twig_tweak_test/config/install/views.view.twig_tweak_test.yml
  65. 17
      tests/twig_tweak_test/src/Controller/TwigTweakTestController.php
  66. 54
      tests/twig_tweak_test/src/Plugin/Block/FooBlock.php
  67. 99
      tests/twig_tweak_test/templates/twig-tweak-test.html.twig
  68. 10
      tests/twig_tweak_test/twig_tweak_test.info.yml
  69. 89
      tests/twig_tweak_test/twig_tweak_test.module
  70. 7
      tests/twig_tweak_test/twig_tweak_test.routing.yml
  71. 64
      twig_tweak.api.php
  72. 6
      twig_tweak.info.yml
  73. 42
      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'

23
README.md

@ -1,23 +0,0 @@
# 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.
## 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

9
README.txt

@ -0,0 +1,9 @@
-- SUMMARY --
Twig Tweak module provides a Twig extension with some useful functions and
filters. See src/TwigExtension.php for details.
-- LINKS --
Project page: https://www.drupal.org/project/twig_tweak
Twig home page: http://twig.sensiolabs.org
Drupal 8 Twig documentation: https://www.drupal.org/docs/8/theming/twig

23
composer.json

@ -3,27 +3,16 @@
"type": "drupal-module", "type": "drupal-module",
"description": "A Twig extension with some useful functions and filters for Drupal development.", "description": "A Twig extension with some useful functions and filters for Drupal development.",
"keywords": ["Drupal", "Twig"], "keywords": ["Drupal", "Twig"],
"license": "GPL-2.0-or-later", "license": "GPL-2.0+",
"homepage": "https://www.drupal.org/project/twig_tweak", "homepage": "https://www.drupal.org/project/twig_tweak",
"suggest": {
"symfony/var-dumper": "better dump() function for debugging Twig variables"
},
"support": { "support": {
"issues": "https://www.drupal.org/project/issues/twig_tweak", "issues": "https://www.drupal.org/project/issues/twig_tweak",
"source": "https://git.drupalcode.org/project/twig_tweak" "source": "http://cgit.drupalcode.org/twig_tweak"
}, },
"require": { "require": {
"php": ">=7.3", "drupal/core": "^8.5"
"ext-json": "*",
"drupal/core": "^9.3 || ^10.0",
"twig/twig": "^2.15.3 || ^3.4.3",
"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"
}
}
} }
} }

6
docs/README.md

@ -1,6 +0,0 @@
# Twig Tweak documentation
- [Cheat sheet](cheat-sheet.md)
- [Rendering blocks with Twig Tweak](blocks.md)
- [Twig Tweak and Views](views.md)
- [Migrating to Twig Tweak 3.x](migration-to-3.x.md)

126
docs/blocks.md

@ -1,126 +0,0 @@
# Rendering blocks with Twig Tweak
This subject is rather confusing because too many things in Drupal are referred
to as "Blocks". So it is essential to understand what kind of block you are
going to render. This guide covers three main cases you may deal when rendering
blocks in a Twig template.
## Block - plugin
Technically speaking block plugin is a PHP class with a special annotation. See
[Branding block plugin](https://git.drupalcode.org/project/drupal/-/blob/9.1.0/core/modules/system/src/Plugin/Block/SystemBrandingBlock.php#L16-22) as an example.
The simplest way to render block plugin is as follows.
```twig
{{ drupal_block('plugin_id') }}
```
Optionally you can pass block label and plugin configuration in the second
parameter.
```twig
{{ drupal_block('plugin_id', {label: 'Example'|t, some_setting: 'example', setting_array: {value: value}}) }}
```
By default, blocks are rendered using `block.html.twig` template. This can be
turned off by setting wrapper parameter to false.
```twig
{{ drupal_block('plugin_id', wrapper=false) }}
```
The tricky thing here is figuring out block plugin ID. If you know which module
provides a particular plugin, you can find its PHP class under the
`MODULE_NAME/src/Plugin/Block` directory and locate the ID in the class
annotation. For instance the plugin ID of login block can be found in the
following file: `core/modules/user/src/Plugin/Block/UserLoginBlock.php`. When
using the plugin ID, convert its format to snake_case (meaning the words are
lowercase and separated by underscores e.g. `system_branding_block`).
To look up all core block plugins use grep search.
```shell
grep -r ' id = ' core/modules/*/src/Plugin/Block/;'
```
However, this does not work for block types that are defined dynamically using
plugin derivatives (like views blocks).
The best way to get all registered plugin IDs is fetching them with block plugin
manager
```shell
drush ev "print_r(array_keys(\Drupal::service('plugin.manager.block')->getDefinitions()));"
```
Note that the plugin_id needs to be wrapped in quotes. For example,
```twig
{{ drupal_block('system_breadcrumb_block') }}
```
## Block - configuration entity
This is what we configure on `admin/structure/block` page. It's important to know
that eventually these entities are rendered using block plugins described above.
The purpose of the configuration entities is to store plugin IDs and
configuration. Additionally, they reference theme and region where a block
should be printed, but this data are not used when rendering through Twig Tweak.
So having configured a block through administrative interface you can print it
using the following code.
```twig
{{ drupal_entity('block', 'block_id') }}
```
Disabled blocks won't be printed unless you suppress access control as follows.
```twig
{{ drupal_entity('block', 'block_id', check_access=false) }}
```
Note that block_id here has nothing to do with 'block_plugin_id' we discussed
before. It is an ID (machine_name) of block configuration entity. You may copy
it from the block configuration form.
The following Drush command will list all available block entities.
```shell
drush ev 'print_r(\Drupal::configFactory()->listAll("block.block."));'
```
## Block - content entity
Content blocks, also known as custom blocks are configured on
`admin/structure/block/block-content` page. Actually they have little to do with
Drupal block system. These blocks are just content entities like node, user,
comment and so on. Their provider (Custom block module) also offers a plugin to
display them in blocks.
The primary way to display content blocks is like follows.
```twig
{{ drupal_entity('block_content', 'content_block_id') }}
```
Though it looks similar to rendering configuration entities (Section 2), you
should note two important distinctions.
Entity type is 'block_content' not 'block'.
Content block ID stands for an ID of respective content entity. This is a
numeric value that can be found in URL when editing custom block. Getting
content block IDs is as simple as executing a single SQL query.
```shell
drush sqlq 'SELECT id, info FROM block_content_field_data'
```
Since this method does not use block template (`block.html.twig`) you may need to
supply block subject and wrappers manually.
```twig
<div class="block">
<h2>{{ 'Example'|t }}</h2>
{{ drupal_entity('block_content', content_block_id) }}
</div>
```
Another way to accomplish this task is using block plugin (see Section 1).
```twig
{{ drupal_block('block_content:<uuid>', {label: 'Example'|t}) }}
```
Note that plugin ID in this case consists of entity type and entity UUID
separated by a colon.
It is also possible to create a configuration entity for this content block and
print it as described in [Configuration entity section](#block-configuration-entity).

403
docs/cheat-sheet.md

@ -1,403 +0,0 @@
# Cheat sheet
## Drupal View
```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 Block
In order to figure out the plugin IDs list them using block plugin manager.
With Drush, it can be done like follows:
```shell
drush ev "print_r(array_keys(\Drupal::service('plugin.manager.block')->getDefinitions()));"
```
```twig
{# Print block using default configuration. #}
{{ drupal_block('system_branding_block') }}
{# Print block using custom configuration. #}
{{ drupal_block('system_branding_block', {label: 'Branding', use_site_name: false, id}) }}
{# 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.
## Drupal Region
```twig
{# Print 'sidebar_first' region of the default site theme. #}
{{ drupal_region('sidebar_first') }}
{# Print 'sidebar_first' region of Bartik theme. #}
{{ drupal_region('sidebar_first', 'bartik') }}
```
## Drupal Entity
```twig
{# Print a content block which ID is 1. #}
{{ drupal_entity('block_content', 1) }}
{# Print a node's teaser. #}
{{ drupal_entity('node', 123, 'teaser') }}
{# Print Branding block which was previously disabled on #}
{# admin/structure/block page. #}
{{ drupal_entity('block', 'bartik_branding', check_access=false) }}
```
## Drupal Entity Form
```twig
{# Print edit form for node 1. #}
{{ drupal_entity_form('node', 1) }}
{# Print add form for 'article' content type. #}
{{ drupal_entity_form('node', values={type: 'article'}) }}
{# Print user register form. #}
{{ drupal_entity_form('user', NULL, 'register', check_access=false) }}
```
## 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
```twig
{# Print the top level of 'main' menu. #}
{{ drupal_menu('main') }}
{# Expand all menu links. #}
{{ drupal_menu('main', expand=true) }}
```
## Drupal Form
```twig
{{ drupal_form('Drupal\\search\\Form\\SearchBlockForm') }}
```
## Drupal Image
```twig
{# Render image specified by file ID. #}
{{ drupal_image(123) }}
{# Render image specified by file UUID. #}
{{ drupal_image('9bb27144-e6b2-4847-bd24-adcc59613ec0') }}
{# Render image specified by file URI. #}
{{ drupal_image('public://ocean.jpg') }}
{# 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). #}
{{ drupal_image('public://ocean.jpg', 'wide', responsive=true) }}
```
## Drupal Token
```twig
{{ drupal_token('site:name') }}
```
## Drupal Config
```twig
{{ drupal_config('system.site', 'name') }}
```
## Drupal Dump
```twig
{# Basic usage. #}
{{ drupal_dump(var) }}
{# Same as above but shorter. #}
{{ dd(var) }}
{# Dump all available variables for the current template. #}
{{ dd() }}
```
## Drupal Title
```twig
{# The title is cached per URL. #}
{{ drupal_title() }}
```
## 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') }}
{# Complex URL. #}
{{ drupal_url('node/1', {query: {foo: 'bar'}, fragment: 'example', absolute: true}) }}
```
## Drupal Link
```twig
{# It supports the same options as drupal_url(), plus attributes. #}
{{ drupal_link('View'|t, 'node/1', {attributes: {target: '_blank'}}) }}
{# This link will only be shown to the privileged users. #}
{{ drupal_link('Example'|t, '/admin', check_access=true) }}
```
## Drupal Messages
```twig
{{ drupal_messages() }}
```
## Drupal Breadcrumb
```twig
{{ drupal_breadcrumb() }}
```
## Drupal Breakpoint
```twig
{# Make Xdebug break on the specific line in the compiled Twig template. #}
{{ drupal_breakpoint() }}
```
## Contextual Links
```twig
{# Basic usage. #}
<div class="contextual-region">
{{ 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:') }}
{{ content }}
</div>
```
## Token Replace
```twig
{# Basic usage. #}
{{ '<h1>[site:name]</h1><div>[site:slogan]</div>'|token_replace }}
{# This is more suited to large markup. #}
{% apply token_replace %}
<h1>[site:name]</h1>
<div>[site:slogan]</div>
{% endapply %}
```
## Preg Replace
```twig
{{ 'Drupal - community plumbing!'|preg_replace('/(Drupal)/', '<b>$1</b>') }}
```
For simple string interpolation consider using built-in `replace` or `format`
Twig filters.
## Image Style
```twig
{# Basic usage #}
{{ 'public://images/ocean.jpg'|image_style('thumbnail') }}
{# Make sure to check that the URI is valid #}
{% set image_uri = node.field_media_optional_image|file_uri %}
{% if image_uri is not null %}
{{ image_uri|image_style('thumbnail') }}
{% endif %}
```
`image_style` will trigger an error on invalid or empty URIs, to avoid broken
images when used in an `<img/>` tag.
## Transliterate
```twig
{{ 'Привет!'|transliterate }}
```
## Check Markup
```twig
{{ '<b>bold</b> <strong>strong</strong>'|check_markup('restricted_html') }}
```
## Format size
Generates a string representation for the given byte count.
```twig
{{ 12345|format_size }}
```
## Truncate
```twig
{# Truncates a UTF-8-encoded string safely to 10 characters. #}
{{ 'Some long text'|truncate(10) }}
{# Same as above but with respect of words boundary. #}
{{ 'Some long text'|truncate(10, true) }}
```
## View
```twig
{# Do not put this into node.html.twig template to avoid recursion. #}
{{ node|view }}
{{ node|view('teaser') }}
{{ node.field_image|view }}
{{ node.field_image[0]|view }}
{{ node.field_image|view('teaser') }}
{{ node.field_image|view({settings: {image_style: 'thumbnail'}}) }}
```
## With
This is an opposite of core `without` filter and adds properties instead of removing it.
```twig
{# Set top-level value. #}
{{ content.field_image|with('#title', 'Photo'|t) }}
{# Set nested value. #}
{{ 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>
{% for tag in content.field_tags|children %}
<li>{{ tag }}</li>
{% endfor %}
</ul>
```
## File URI
When field item list is passed, the URI will be extracted from the first item.
In order to get URI of specific item specify its delta explicitly using array
notation.
```twig
{{ node.field_image|file_uri }}
{{ node.field_image[0]|file_uri }}
```
Media fields are fully supported including OEmbed resources, in which case
it will return the URL to the resource, similar to the `file_url` filter.
```twig
{{ node.field_media|file_uri }}
```
## File URL
For string arguments it works similar to core `file_url()` Twig function.
```twig
{{ 'public://sea.jpg'|file_url }}
```
In order to generate absolute URL set "relative" parameter to `false`.
```twig
{{ 'public://sea.jpg'|file_url(false) }}
```
When field item list is passed, the URL will be extracted from the first item.
In order to get URL of specific item specify its delta explicitly using array
notation.
```twig
{{ node.field_image|file_url }}
{{ node.field_image[0]|file_url }}
```
Media fields are fully supported including OEmbed resources.
```twig
{{ node.field_media|file_url }}
```
It is also possible to extract file URL directly from an entity.
```twig
{{ image|file_url }}
{{ 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
{{ media|translation.title|view }}
```
## Cache metadata
When using raw values from entities or render arrays it is essential to
ensure that cache metadata are bubbled up.
```twig
<img src="{{ node.field_media|file_url }}" alt="Logo"/>
{{ content.field_media|cache_metadata }}
```
## PHP
PHP filter is disabled by default. You can enable it in `settings.php` file as
follows:
```php
$settings['twig_tweak_enable_php_filter'] = TRUE;
```
```twig
{{ 'return date('Y');'|php }}
```
Using PHP filter is discouraged as it may cause security implications. In fact,
it is very rarely needed. The above code can be replaced with the following.
```twig
{{ 'now'|date('Y') }}
```

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

@ -1,69 +0,0 @@
# Migrating to Twig Tweak 3.x
Twig Tweak 3.x branch is mainly API compatible with 2.x
Below are known BC breaks that may require updating your Twig templates.
## Dependencies
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
with routes and could cause caching issues.
Before:
```twig
{{ drupal_entity('node', null, 'teaser') }}
```
After:
```twig
{{ drupal_entity('node', node.id, 'teaser') }}
```
In case a template does not contain a variable with entity object you may
prepare it in a preprocess hook.
```php
/**
* Implements hook_preprocess_page().
*/
function preprocess_page(array &$variables): void {
$variables['entity'] = \Drupal::routeMatch()->getParameter('entity_type');
}
```
### 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
<div>
<div class="from-block-plugin">Block content</div>
</div>
```
After:
```html
<div class="from-block-plugin">
<div>Block content</div>
</div>
```
See https://www.drupal.org/node/3068078 for details.

63
docs/views.md

@ -1,63 +0,0 @@
# Using Twig Tweak to extend Views module functionality
Twig Tweak's `drupal_view()` method provides access to embed views within any
Twig code, including dynamically from within each row of another view. This
feature provides an alternate method to accomplish the nesting provided by the
[Views Field View](https://www.drupal.org/project/views_field_view) module.
The most basic syntax for Twig Tweak's view embed simply specifies the view, and
the machine name you wish to embed, as follows:
```twig
{{ drupal_view('who_s_new', 'block_1') }}
```
You can, however, also specify additional parameters which map to contextual
filters you have configured in your view.
```twig
{{ drupal_view('who_s_new', 'block_1', arg_1, arg_2, arg_3) }}
```
## Nested View Example
There are a lot of cases in views where you want to embed a list inside each
row. For example, when you have a list of product categories (taxonomy terms)
and for each category, you want to list the 3 newest products. In this example,
assume your content type is 'product', and has a reference field to a taxonomy
named 'product categories'.
### Step1: Create your child view
Create a view of the content of type 'product', and choose to create a block
displaying an unformatted list of teasers, with 3 items per block. In this
example, we'll use a view name of 'products_by_category'. Once the view is
created, choose advanced and add a relationship to the taxonomy term that
references 'product categories', and choose to require the relationship. Next,
choose to add a "Contextual Filter" for "Term ID", and choose an action for when
the filter value is not available (e.g. display contents of no results found).
Set your desired sort and save your view.
### Step 2: Create your parent view
Create a view of taxonomy terms of type 'product categories', and choose to
create a page which displays an unformatted list of fields. Once this is
created, you should see the preview showing all the product categories. Choose
to add a field of type "Term ID", and choose "Exclude from display"; this is
necessary to make the term id available to the next field which uses Twig Tweak.
Now, choose to add a field of type "Custom text" from the "Global" category.
Inside that field, enter the Twig Tweak call to display the child view we
created above, passing the tid as a contextual filter, as such:
```twig
{{ drupal_view('products_by_category', 'block_1', tid) }}
```
You should now save your view, and be able to access the URL you assigned and
see a list of product categories, each followed by the three most recent
products within each.
This example can be applied to any nested view scenario, including
multiple-levels of nesting.
## Check if the view has results
```twig
{% set view = drupal_view_result('related', 'block_1')|length %}
{% if view > 0 %}
{{ drupal_view('related', 'block_1') }}
{% endif %}
```

11
drush.services.yml

@ -1,11 +0,0 @@
services:
twig_tweak.validate:
class: Drupal\twig_tweak\Command\ValidateCommand
arguments: ['@twig']
tags:
- { name: console.command }
twig_tweak.debug:
class: Drupal\twig_tweak\Command\DebugCommand
arguments: ['@twig']
tags:
- { name: console.command }

BIN
logo.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

16
phpcs.xml

@ -1,16 +0,0 @@
<?xml version="1.0"?>
<ruleset name="Twig Tweak">
<description>PHP CodeSniffer configuration for Twig Tweak module.</description>
<arg name="extensions" value="php, module, yml"/>
<rule ref="Drupal"/>
<rule ref="DrupalPractice">
<!-- Dependencies are not injected for performance reason. -->
<exclude name="DrupalPractice.Objects.GlobalDrupal.GlobalDrupal"/>
<!-- False positives. -->
<exclude name="Drupal.Commenting.InlineComment.Empty"/>
<!-- The module does not provide change records. -->
<exclude name="Drupal.Semantics.FunctionTriggerError.TriggerErrorTextLayoutRelaxed"/>
<!-- Code examples have rather long lines. -->
<exclude name="Drupal.Files.LineLength.TooLong"/>
</rule>
</ruleset>

52
src/CacheMetadataExtractor.php

@ -1,52 +0,0 @@
<?php
namespace Drupal\twig_tweak;
use Drupal\Core\Cache\CacheableDependencyInterface;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Render\Element;
/**
* Cache metadata extractor service.
*/
class CacheMetadataExtractor {
/**
* Extracts cache metadata from object or render array.
*
* @param \Drupal\Core\Cache\CacheableDependencyInterface|array $input
* The cacheable object or render array.
*
* @return array
* A render array with extracted cache metadata.
*/
public function extractCacheMetadata($input): array {
if ($input instanceof CacheableDependencyInterface) {
$cache_metadata = CacheableMetadata::createFromObject($input);
}
elseif (is_array($input)) {
$cache_metadata = self::extractFromArray($input);
}
else {
$message = sprintf('The input should be either instance of %s or array. %s was given.', CacheableDependencyInterface::class, \get_debug_type($input));
throw new \InvalidArgumentException($message);
}
$build = [];
$cache_metadata->applyTo($build);
return $build;
}
/**
* Extracts cache metadata from renders array.
*/
private static function extractFromArray(array $build): CacheableMetadata {
$cache_metadata = CacheableMetadata::createFromRenderArray($build);
$keys = Element::children($build);
foreach (array_intersect_key($build, array_flip($keys)) as $item) {
$cache_metadata->addCacheableDependency(self::extractFromArray($item));
}
return $cache_metadata;
}
}

217
src/Command/DebugCommand.php

@ -1,217 +0,0 @@
<?php
namespace Drupal\twig_tweak\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Formatter\OutputFormatter;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Twig\Environment;
use Twig\Loader\ChainLoader;
use Twig\Loader\FilesystemLoader;
/**
* Lists twig functions, filters, and tests present in the current project.
*
* This is a simplified version of Symfony's Debug command.
*
* @see https://github.com/symfony/symfony/blob/5.x/src/Symfony/Bridge/Twig/Command/DebugCommand.php
*/
final class DebugCommand extends Command {
/**
* Twig environment.
*
* @var \Twig\Environment
*/
private $twig;
/**
* {@inheritdoc}
*/
public function __construct(Environment $twig) {
parent::__construct();
$this->twig = $twig;
}
/**
* {@inheritdoc}
*/
protected function configure(): void {
$this
->setName('twig-tweak:debug')
->setAliases(['twig-debug'])
->addOption('filter', NULL, InputOption::VALUE_REQUIRED, 'Show details for all entries matching this filter.')
->setDescription('Shows a list of twig functions, filters, globals and tests')
->setHelp(<<<'EOF'
The <info>%command.name%</info> command outputs a list of twig functions,
filters, globals and tests.
<info>drush %command.name%</info>
The command lists all functions, filters, etc.
<info>drush %command.name% --filter=date</info>
The command lists everything that contains the word date.
EOF);
}
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output): int {
$io = new SymfonyStyle($input, $output);
$filter = $input->getOption('filter');
$types = ['functions', 'filters', 'tests'];
foreach ($types as $type) {
$items = [];
foreach ($this->twig->{'get' . ucfirst($type)}() as $name => $entity) {
if (!$filter || \strpos($name, $filter) !== FALSE) {
$signature = '';
// Tests are typically implemented as Twig nodes so that it is hard
// to get their signatures through reflection.
if ($type == 'filters' || $type == 'functions') {
try {
$meta = $this->getMetadata($type, $entity);
$default_signature = $type == 'functions' ? '()' : '';
$signature = $meta ? '(' . implode(', ', $meta) . ')' : $default_signature;
}
catch (\UnexpectedValueException $exception) {
$signature = sprintf(' <error>%s</error>', OutputFormatter::escape($exception->getMessage()));
}
}
$items[$name] = $name . $signature;
}
}
if (!$items) {
continue;
}
$io->section(\ucfirst($type));
ksort($items);
$io->listing($items);
}
if (!$filter && $loaderPaths = $this->getLoaderPaths()) {
$io->section('Loader Paths');
$rows = [];
foreach ($loaderPaths as $namespace => $paths) {
foreach ($paths as $path) {
$rows[] = [$namespace, $path . \DIRECTORY_SEPARATOR];
}
}
$io->table(['Namespace', 'Paths'], $rows);
}
return 0;
}
/**
* Gets loader paths.
*/
private function getLoaderPaths(): array {
$loaderPaths = [];
foreach ($this->getFilesystemLoaders() as $loader) {
foreach ($loader->getNamespaces() as $namespace) {
$paths = $loader->getPaths($namespace);
$namespace = FilesystemLoader::MAIN_NAMESPACE === $namespace ?
'(None)' : '@' . $namespace;
$loaderPaths[$namespace] = array_merge($loaderPaths[$namespace] ?? [], $paths);
}
}
return $loaderPaths;
}
/**
* Gets metadata.
*/
private function getMetadata(string $type, $entity): ?array {
$callable = $entity->getCallable();
if (!$callable) {
return NULL;
}
if (\is_array($callable)) {
if (!method_exists($callable[0], $callable[1])) {
return NULL;
}
$reflection = new \ReflectionMethod($callable[0], $callable[1]);
}
elseif (\is_object($callable) && method_exists($callable, '__invoke')) {
$reflection = new \ReflectionMethod($callable, '__invoke');
}
elseif (\function_exists($callable)) {
$reflection = new \ReflectionFunction($callable);
}
elseif (\is_string($callable) && preg_match('{^(.+)::(.+)$}', $callable, $m) && method_exists($m[1], $m[2])) {
$reflection = new \ReflectionMethod($m[1], $m[2]);
}
else {
throw new \UnexpectedValueException('Unsupported callback type.');
}
$args = $reflection->getParameters();
// Filter out context/environment args.
if ($entity->needsEnvironment()) {
array_shift($args);
}
if ($entity->needsContext()) {
array_shift($args);
}
if ($type == 'filters') {
// Remove the value the filter is applied on.
array_shift($args);
}
// Format args.
$args = array_map(
static function (\ReflectionParameter $param): string {
$arg = $param->getName();
if ($param->isDefaultValueAvailable()) {
$arg .= ' = ' . \json_encode($param->getDefaultValue());
}
return $arg;
},
$args,
);
return $args;
}
/**
* Returns files system loaders.
*
* @return \Twig\Loader\FilesystemLoader[]
* File system loaders.
*/
private function getFilesystemLoaders(): array {
$loaders = [];
$loader = $this->twig->getLoader();
if ($loader instanceof FilesystemLoader) {
$loaders[] = $loader;
}
elseif ($loader instanceof ChainLoader) {
foreach ($loader->getLoaders() as $chained_loaders) {
if ($chained_loaders instanceof FilesystemLoader) {
$loaders[] = $chained_loaders;
}
}
}
return $loaders;
}
}

271
src/Command/LintCommand.php

@ -1,271 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
// @codingStandardsIgnoreFile
// This code is a literal copy of Symfony's LintCommand.
// @see https://github.com/symfony/symfony/blob/5.x/src/Symfony/Bridge/Twig/Command/LintCommand.php
namespace Drupal\twig_tweak\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Finder\Finder;
use Twig\Environment;
use Twig\Error\Error;
use Twig\Loader\ArrayLoader;
use Twig\Loader\FilesystemLoader;
use Twig\Source;
/**
* Command that will validate your template syntax and output encountered errors.
*
* @author Marc Weistroff <marc.weistroff@sensiolabs.com>
* @author Jérôme Tamarelle <jerome@tamarelle.net>
*/
class LintCommand extends Command
{
protected static $defaultName = 'lint:twig';
private $twig;
public function __construct(Environment $twig)
{
parent::__construct();
$this->twig = $twig;
}
protected function configure()
{
$this
->setDescription('Lints a template and outputs encountered errors')
->addOption('format', null, InputOption::VALUE_REQUIRED, 'The output format', 'txt')
->addOption('show-deprecations', null, InputOption::VALUE_NONE, 'Show deprecations as errors')
->addArgument('filename', InputArgument::IS_ARRAY, 'A file, a directory or "-" for reading from STDIN')
->setHelp(<<<'EOF'
The <info>%command.name%</info> command lints a template and outputs to STDOUT
the first encountered syntax error.
You can validate the syntax of contents passed from STDIN:
<info>cat filename | php %command.full_name% -</info>
Or the syntax of a file:
<info>php %command.full_name% filename</info>
Or of a whole directory:
<info>php %command.full_name% dirname</info>
<info>php %command.full_name% dirname --format=json</info>
EOF
)
;
}
protected function execute(InputInterface $input, OutputInterface $output)
{
$io = new SymfonyStyle($input, $output);
$filenames = $input->getArgument('filename');
$showDeprecations = $input->getOption('show-deprecations');
if (['-'] === $filenames) {
return $this->display($input, $output, $io, [$this->validate(file_get_contents('php://stdin'), uniqid('sf_', true))]);
}
if (!$filenames) {
$loader = $this->twig->getLoader();
if ($loader instanceof FilesystemLoader) {
$paths = [];
foreach ($loader->getNamespaces() as $namespace) {
$paths[] = $loader->getPaths($namespace);
}
$filenames = array_merge(...$paths);
}
if (!$filenames) {
throw new RuntimeException('Please provide a filename or pipe template content to STDIN.');
}
}
if ($showDeprecations) {
$prevErrorHandler = set_error_handler(static function ($level, $message, $file, $line) use (&$prevErrorHandler) {
if (\E_USER_DEPRECATED === $level) {
$templateLine = 0;
if (preg_match('/ at line (\d+)[ .]/', $message, $matches)) {
$templateLine = $matches[1];
}
throw new Error($message, $templateLine);
}
return $prevErrorHandler ? $prevErrorHandler($level, $message, $file, $line) : false;
});
}
try {
$filesInfo = $this->getFilesInfo($filenames);
} finally {
if ($showDeprecations) {
restore_error_handler();
}
}
return $this->display($input, $output, $io, $filesInfo);
}
private function getFilesInfo(array $filenames): array
{
$filesInfo = [];
foreach ($filenames as $filename) {
foreach ($this->findFiles($filename) as $file) {
$filesInfo[] = $this->validate(file_get_contents($file), $file);
}
}
return $filesInfo;
}
protected function findFiles(string $filename)
{
if (is_file($filename)) {
return [$filename];
} elseif (is_dir($filename)) {
return Finder::create()->files()->in($filename)->name('*.twig');
}
throw new RuntimeException(sprintf('File or directory "%s" is not readable.', $filename));
}
private function validate(string $template, string $file): array
{
$realLoader = $this->twig->getLoader();
try {
$temporaryLoader = new ArrayLoader([$file => $template]);
$this->twig->setLoader($temporaryLoader);
$nodeTree = $this->twig->parse($this->twig->tokenize(new Source($template, $file)));
$this->twig->compile($nodeTree);
$this->twig->setLoader($realLoader);
} catch (Error $e) {
$this->twig->setLoader($realLoader);
return ['template' => $template, 'file' => $file, 'line' => $e->getTemplateLine(), 'valid' => false, 'exception' => $e];
}
return ['template' => $template, 'file' => $file, 'valid' => true];
}
private function display(InputInterface $input, OutputInterface $output, SymfonyStyle $io, array $files)
{
switch ($input->getOption('format')) {
case 'txt':
return $this->displayTxt($output, $io, $files);
case 'json':
return $this->displayJson($output, $files);
default:
throw new InvalidArgumentException(sprintf('The format "%s" is not supported.', $input->getOption('format')));
}
}
private function displayTxt(OutputInterface $output, SymfonyStyle $io, array $filesInfo)
{
$errors = 0;
foreach ($filesInfo as $info) {
if ($info['valid'] && $output->isVerbose()) {
$io->comment('<info>OK</info>'.($info['file'] ? sprintf(' in %s', $info['file']) : ''));
} elseif (!$info['valid']) {
++$errors;
$this->renderException($io, $info['template'], $info['exception'], $info['file']);
}
}
if (0 === $errors) {
$io->success(sprintf('All %d Twig files contain valid syntax.', \count($filesInfo)));
} else {
$io->warning(sprintf('%d Twig files have valid syntax and %d contain errors.', \count($filesInfo) - $errors, $errors));
}
return min($errors, 1);
}
private function displayJson(OutputInterface $output, array $filesInfo)
{
$errors = 0;
array_walk($filesInfo, function (&$v) use (&$errors) {
$v['file'] = (string) $v['file'];
unset($v['template']);
if (!$v['valid']) {
$v['message'] = $v['exception']->getMessage();
unset($v['exception']);
++$errors;
}
});
$output->writeln(json_encode($filesInfo, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES));
return min($errors, 1);
}
private function renderException(OutputInterface $output, string $template, Error $exception, string $file = null)
{
$line = $exception->getTemplateLine();
if ($file) {
$output->text(sprintf('<error> ERROR </error> in %s (line %s)', $file, $line));
} else {
$output->text(sprintf('<error> ERROR </error> (line %s)', $line));
}
// If the line is not known (this might happen for deprecations if we fail at detecting the line for instance),
// we render the message without context, to ensure the message is displayed.
if ($line <= 0) {
$output->text(sprintf('<error> >> %s</error> ', $exception->getRawMessage()));
return;
}
foreach ($this->getContext($template, $line) as $lineNumber => $code) {
$output->text(sprintf(
'%s %-6s %s',
$lineNumber === $line ? '<error> >> </error>' : ' ',
$lineNumber,
$code
));
if ($lineNumber === $line) {
$output->text(sprintf('<error> >> %s</error> ', $exception->getRawMessage()));
}
}
}
private function getContext(string $template, int $line, int $context = 3)
{
$lines = explode("\n", $template);
$position = max(0, $line - $context);
$max = min(\count($lines), $line - 1 + $context);
$result = [];
while ($position < $max) {
$result[$position + 1] = $lines[$position];
++$position;
}
return $result;
}
}

37
src/Command/ValidateCommand.php

@ -1,37 +0,0 @@
<?php
namespace Drupal\twig_tweak\Command;
use Symfony\Component\Finder\Finder;
/**
* Implements twig-tweak:lint console command.
*/
final class ValidateCommand extends LintCommand {
/**
* {@inheritdoc}
*/
protected static $defaultName = 'twig-tweak:validate';
/**
* {@inheritdoc}
*/
protected function configure(): void {
if (!\class_exists(Finder::class)) {
throw new \LogicException('To validate Twig templates you must install symfony/finder component.');
}
parent::configure();
$this->setAliases(['twig-validate']);
$this->setHelp(
$this->getHelp() . <<< 'TEXT'
This command only validates Twig Syntax. For checking code style
consider using <info>friendsoftwig/twigcs</info> package.
TEXT
);
}
}

531
src/TwigExtension.php

@ -0,0 +1,531 @@
<?php
namespace Drupal\twig_tweak;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Block\TitleBlockPluginInterface;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Site\Settings;
use Drupal\Core\Url;
use Drupal\image\Entity\ImageStyle;
use Symfony\Cmf\Component\Routing\RouteObjectInterface;
/**
* Twig extension with some useful functions and filters.
*
* As version 1.7 all dependencies are instantiated on demand for performance
* reasons.
*/
class TwigExtension extends \Twig_Extension {
/**
* {@inheritdoc}
*/
public function getFunctions() {
return [
new \Twig_SimpleFunction('drupal_view', 'views_embed_view'),
new \Twig_SimpleFunction('drupal_block', [$this, 'drupalBlock']),
new \Twig_SimpleFunction('drupal_region', [$this, 'drupalRegion']),
new \Twig_SimpleFunction('drupal_entity', [$this, 'drupalEntity']),
new \Twig_SimpleFunction('drupal_field', [$this, 'drupalField']),
new \Twig_SimpleFunction('drupal_menu', [$this, 'drupalMenu']),
new \Twig_SimpleFunction('drupal_form', [$this, 'drupalForm']),
new \Twig_SimpleFunction('drupal_token', [$this, 'drupalToken']),
new \Twig_SimpleFunction('drupal_config', [$this, 'drupalConfig']),
new \Twig_SimpleFunction('drupal_dump', [$this, 'drupalDump']),
new \Twig_SimpleFunction('dd', [$this, 'drupalDump']),
// Wrap drupal_set_message() because it returns some value which is not
// suitable for Twig template.
new \Twig_SimpleFunction('drupal_set_message', [$this, 'drupalSetMessage']),
new \Twig_SimpleFunction('drupal_title', [$this, 'drupalTitle']),
new \Twig_SimpleFunction('drupal_url', [$this, 'drupalUrl']),
];
}
/**
* {@inheritdoc}
*/
public function getFilters() {
$filters = [
new \Twig_SimpleFilter('token_replace', [$this, 'tokenReplaceFilter']),
new \Twig_SimpleFilter('preg_replace', [$this, 'pregReplaceFilter']),
new \Twig_SimpleFilter('image_style', [$this, 'imageStyle']),
new \Twig_SimpleFilter('transliterate', [$this, 'transliterate']),
new \Twig_SimpleFilter('check_markup', [$this, 'checkMarkup']),
];
// PHP filter should be enabled in settings.php file.
if (Settings::get('twig_tweak_enable_php_filter')) {
$filters[] = new \Twig_SimpleFilter('php', [$this, 'phpFilter']);
}
return $filters;
}
/**
* {@inheritdoc}
*/
public function getName() {
return 'twig_tweak';
}
/**
* Builds the render array for the provided block.
*
* @param mixed $id
* The ID of the block to render.
* @param bool $check_access
* (Optional) Indicates that access check is required.
*
* @return null|array
* A render array for the block or NULL if the block does not exist.
*/
public function drupalBlock($id, $check_access = TRUE) {
$entity_type_manager = \Drupal::entityTypeManager();
$block = $entity_type_manager->getStorage('block')->load($id);
if ($block) {
$access = $check_access ? $this->entityAccess($block) : AccessResult::allowed();
if ($access->isAllowed()) {
$build = $entity_type_manager->getViewBuilder('block')->view($block);
CacheableMetadata::createFromRenderArray($build)
->merge(CacheableMetadata::createFromObject($block))
->merge(CacheableMetadata::createFromObject($access))
->applyTo($build);
return $build;
}
}
}
/**
* Builds the render array of a given region.
*
* @param string $region
* The region to build.
* @param string $theme
* (Optional) The name of the theme to load the region. If it is not
* provided then default theme will be used.
*
* @return array
* A render array to display the region content.
*/
public function drupalRegion($region, $theme = NULL) {
$entity_type_manager = \Drupal::entityTypeManager();
$blocks = $entity_type_manager->getStorage('block')->loadByProperties([
'region' => $region,
'theme' => $theme ?: \Drupal::config('system.theme')->get('default'),
]);
$view_builder = $entity_type_manager->getViewBuilder('block');
$build = [];
$cache_metadata = new CacheableMetadata();
/* @var $blocks \Drupal\block\BlockInterface[] */
foreach ($blocks as $id => $block) {
$access = $this->entityAccess($block);
$cache_metadata = $cache_metadata->merge(CacheableMetadata::createFromObject($access));
if ($access->isAllowed()) {
$block_plugin = $block->getPlugin();
if ($block_plugin instanceof TitleBlockPluginInterface) {
$request = \Drupal::request();
if ($route = $request->attributes->get(RouteObjectInterface::ROUTE_OBJECT)) {
$block_plugin->setTitle(\Drupal::service('title_resolver')->getTitle($request, $route));
}
}
$build[$id] = $view_builder->view($block);
}
}
if ($build) {
$cache_metadata->applyTo($build);
}
return $build;
}
/**
* Returns the render array for an entity.
*
* @param string $entity_type
* The entity type.
* @param mixed $id
* The ID of the entity to render.
* @param string $view_mode
* (optional) The view mode that should be used to render the entity.
* @param string $langcode
* (optional) For which language the entity should be rendered, defaults to
* the current content language.
*
* @return null|array
* A render array for the entity or NULL if the entity does not exist.
*/
public function drupalEntity($entity_type, $id = NULL, $view_mode = NULL, $langcode = NULL) {
$entity_type_manager = \Drupal::entityTypeManager();
$entity = $id
? $entity_type_manager->getStorage($entity_type)->load($id)
: \Drupal::routeMatch()->getParameter($entity_type);
if ($entity) {
$access = $this->entityAccess($entity);
if ($access->isAllowed()) {
$build = $entity_type_manager
->getViewBuilder($entity_type)
->view($entity, $view_mode, $langcode);
CacheableMetadata::createFromRenderArray($build)
->merge(CacheableMetadata::createFromObject($entity))
->merge(CacheableMetadata::createFromObject($access))
->applyTo($build);
return $build;
}
}
}
/**
* Returns the render array for a single entity field.
*
* @param string $field_name
* The field name.
* @param string $entity_type
* The entity type.
* @param mixed $id
* The ID of the entity to render.
* @param string $view_mode
* (optional) The view mode that should be used to render the field.
* @param string $langcode
* (optional) Language code to load translation.
*
* @return null|array
* A render array for the field or NULL if the value does not exist.
*/
public function drupalField($field_name, $entity_type, $id = NULL, $view_mode = 'default', $langcode = NULL) {
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
$entity = $id
? \Drupal::entityTypeManager()->getStorage($entity_type)->load($id)
: \Drupal::routeMatch()->getParameter($entity_type);
if ($entity) {
$access = $this->entityAccess($entity);
if ($access->isAllowed()) {
if ($langcode && $entity->hasTranslation($langcode)) {
$entity = $entity->getTranslation($langcode);
}
if (isset($entity->{$field_name})) {
$build = $entity->{$field_name}->view($view_mode);
CacheableMetadata::createFromRenderArray($build)
->merge(CacheableMetadata::createFromObject($access))
->merge(CacheableMetadata::createFromObject($entity))
->applyTo($build);
return $build;
}
}
}
}
/**
* Returns the render array for Drupal menu.
*
* @param string $menu_name
* The name of the menu.
* @param int $level
* (optional) Initial menu level.
* @param int $depth
* (optional) Maximum number of menu levels to display.
*
* @return array
* A render array for the menu.
*/
public function drupalMenu($menu_name, $level = 1, $depth = 0) {
/** @var \Drupal\Core\Menu\MenuLinkTreeInterface $menu_tree */
$menu_tree = \Drupal::service('menu.link_tree');
$parameters = $menu_tree->getCurrentRouteMenuTreeParameters($menu_name);
// Adjust the menu tree parameters based on the block's configuration.
$parameters->setMinDepth($level);
// When the depth is configured to zero, there is no depth limit. When depth
// is non-zero, it indicates the number of levels that must be displayed.
// Hence this is a relative depth that we must convert to an actual
// (absolute) depth, that may never exceed the maximum depth.
if ($depth > 0) {
$parameters->setMaxDepth(min($level + $depth - 1, $menu_tree->maxDepth()));
}
$tree = $menu_tree->load($menu_name, $parameters);
$manipulators = [
['callable' => 'menu.default_tree_manipulators:checkAccess'],
['callable' => 'menu.default_tree_manipulators:generateIndexAndSort'],
];
$tree = $menu_tree->transform($tree, $manipulators);
return $menu_tree->build($tree);
}
/**
* Builds and processes a form for a given form ID.
*
* @param string $form_id
* The form ID.
* @param ...
* Additional arguments are passed to form constructor.
*
* @return array
* A render array to represent the form.
*/
public function drupalForm($form_id) {
$form_builder = \Drupal::formBuilder();
$args = func_get_args();
return call_user_func_array([$form_builder, 'getForm'], $args);
}
/**
* Replaces a given tokens with appropriate value.
*
* @param string $token
* A replaceable token.
* @param array $data
* (optional) An array of keyed objects. For simple replacement scenarios
* 'node', 'user', and others are common keys, with an accompanying node or
* user object being the value. Some token types, like 'site', do not
* require any explicit information from $data and can be replaced even if
* it is empty.
* @param array $options
* (optional) A keyed array of settings and flags to control the token
* replacement process.
*
* @return string
* The token value.
*
* @see \Drupal\Core\Utility\Token::replace()
*/
public function drupalToken($token, array $data = [], array $options = []) {
return \Drupal::token()->replace("[$token]", $data, $options);
}
/**
* Gets data from this configuration.
*
* @param string $name
* The name of the configuration object to construct.
* @param string $key
* A string that maps to a key within the configuration data.
*
* @return mixed
* The data that was requested.
*/
public function drupalConfig($name, $key) {
return \Drupal::config($name)->get($key);
}
/**
* Dumps information about variables.
*/
public function drupalDump($var) {
$var_dumper = '\Symfony\Component\VarDumper\VarDumper';
if (class_exists($var_dumper)) {
call_user_func($var_dumper . '::dump', $var);
}
else {
trigger_error('Could not dump the variable because symfony/var-dumper component is not installed.', E_USER_WARNING);
}
}
/**
* An alias for self::drupalDump().
*
* @see \Drupal\twig_tweak\TwigExtension::drupalDump();
*/
public function dd() {
$this->drupalDump(func_get_args());
}
/**
* Sets a message to display to the user.
*
* @param string|\Drupal\Component\Render\MarkupInterface $message
* (optional) The translated message to be displayed to the user.
* @param string $type
* (optional) The message's type. Defaults to 'status'.
* @param bool $repeat
* (optional) If this is FALSE and the message is already set, then the
* message will not be repeated. Defaults to FALSE.
*
* @return array
* A render array to disable caching.
*/
public function drupalSetMessage($message = NULL, $type = 'status', $repeat = FALSE) {
\Drupal::messenger()->addMessage($message, $type, $repeat);
$build['#cache']['max-age'] = 0;
return $build;
}
/**
* Returns a title for the current route.
*
* @return array
* A render array to represent page title.
*/
public function drupalTitle() {
$title = \Drupal::service('title_resolver')->getTitle(
\Drupal::request(),
\Drupal::routeMatch()->getRouteObject()
);
$build['#markup'] = render($title);
$build['#cache']['contexts'] = ['url'];
return $build;
}
/**
* Generates a URL from internal path.
*
* @param string $user_input
* User input for a link or path.
* @param array $options
* (optional) An array of options.
*
* @return \Drupal\Core\Url
* A new Url object based on user input.
*
* @see \Drupal\Core\Url::fromUserInput()
*/
public function drupalUrl($user_input, array $options = []) {
if (!in_array($user_input[0], ['/', '#', '?'])) {
$user_input = '/' . $user_input;
}
return Url::fromUserInput($user_input, $options);
}
/**
* Replaces all tokens in a given string with appropriate values.
*
* @param string $text
* An HTML string containing replaceable tokens.
*
* @return string
* The entered HTML text with tokens replaced.
*/
public function tokenReplaceFilter($text) {
return \Drupal::token()->replace($text);
}
/**
* Performs a regular expression search and replace.
*
* @param string $text
* The text to search and replace.
* @param string $pattern
* The pattern to search for.
* @param string $replacement
* The string to replace.
*
* @return string
* The new text if matches are found, otherwise unchanged text.
*/
public function pregReplaceFilter($text, $pattern, $replacement) {
// BC layer. Before version 8.x-1.8 the pattern was without delimiters.
// @todo Remove this in Drupal 9.
if (strpos($pattern, '/') !== 0) {
return preg_replace("/$pattern/", $replacement, $text);
}
return preg_replace($pattern, $replacement, $text);
}
/**
* Returns the URL of this image derivative for an original image path or URI.
*
* @param string $path
* The path or URI to the original image.
* @param string $style
* The image style.
*
* @return string
* The absolute URL where a style image can be downloaded, suitable for use
* in an <img> tag. Requesting the URL will cause the image to be created.
*/
public function imageStyle($path, $style) {
/** @var \Drupal\Image\ImageStyleInterface $image_style */
if ($image_style = ImageStyle::load($style)) {
return file_url_transform_relative($image_style->buildUrl($path));
}
}
/**
* Transliterates text from Unicode to US-ASCII.
*
* @param string $string
* The string to transliterate.
* @param string $langcode
* (optional) The language code of the language the string is in. Defaults
* to 'en' if not provided. Warning: this can be unfiltered user input.
* @param string $unknown_character
* (optional) The character to substitute for characters in $string without
* transliterated equivalents. Defaults to '?'.
* @param int $max_length
* (optional) If provided, return at most this many characters, ensuring
* that the transliteration does not split in the middle of an input
* character's transliteration.
*
* @return string
* $string with non-US-ASCII characters transliterated to US-ASCII
* characters, and unknown characters replaced with $unknown_character.
*/
public function transliterate($string, $langcode = 'en', $unknown_character = '?', $max_length = NULL) {
return \Drupal::transliteration()->transliterate($string, $langcode, $unknown_character, $max_length);
}
/**
* Runs all the enabled filters on a piece of text.
*
* @param string $text
* The text to be filtered.
* @param string|null $format_id
* (optional) The machine name of the filter format to be used to filter the
* text. Defaults to the fallback format. See filter_fallback_format().
* @param string $langcode
* (optional) The language code of the text to be filtered.
* @param array $filter_types_to_skip
* (optional) An array of filter types to skip, or an empty array (default)
* to skip no filter types.
*
* @return \Drupal\Component\Render\MarkupInterface
* The filtered text.
*
* @see check_markup()
*/
public function checkMarkup($text, $format_id = NULL, $langcode = '', array $filter_types_to_skip = []) {
return check_markup($text, $format_id, $langcode, $filter_types_to_skip);
}
/**
* Evaluates a string of PHP code.
*
* @param string $code
* Valid PHP code to be evaluated.
*
* @return mixed
* The eval() result.
*/
public function phpFilter($code) {
ob_start();
// @codingStandardsIgnoreStart
print eval($code);
// @codingStandardsIgnoreEnd
$output = ob_get_contents();
ob_end_clean();
return $output;
}
/**
* Checks view access to a given entity.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* Entity to check access.
*
* @return \Drupal\Core\Access\AccessResultInterface
* The access check result.
*
* @TODO Remove "check_access" option in 9.x.
*/
protected function entityAccess(EntityInterface $entity) {
// Prior version 8.x-1.7 entity access was not checked. The "check_access"
// option provides a workaround for possible BC issues.
return Settings::get('twig_tweak_check_access', TRUE) ?
$entity->access('view', NULL, TRUE) : AccessResult::allowed();
}
}

729
src/TwigTweakExtension.php

@ -1,729 +0,0 @@
<?php
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;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Field\FieldItemInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Link;
use Drupal\Core\Render\Element;
use Drupal\Core\Render\Markup;
use Drupal\Core\Site\Settings;
use Drupal\Core\Theme\ThemeManagerInterface;
use Drupal\Core\Url;
use Drupal\image\Entity\ImageStyle;
use Twig\Environment;
use Twig\Extension\AbstractExtension;
use Twig\Markup as TwigMarkup;
use Twig\TwigFilter;
use Twig\TwigFunction;
/**
* Twig extension with some useful functions and filters.
*
* The extension consumes quite a lot of dependencies. Most of them are not used
* on each page request. For performance reasons services are wrapped in static
* callbacks.
*/
class TwigTweakExtension extends AbstractExtension {
/**
* The module handler to invoke alter hooks.
*
* @var \Drupal\Core\Extension\ModuleHandlerInterface
*/
protected $moduleHandler;
/**
* The theme manager to invoke alter hooks.
*
* @var \Drupal\Core\Theme\ThemeManagerInterface
*/
protected $themeManager;
/**
* Constructs the TwigTweakExtension object.
*/
public function __construct(ModuleHandlerInterface $module_handler, ThemeManagerInterface $theme_manager) {
$this->moduleHandler = $module_handler;
$this->themeManager = $theme_manager;
}
/**
* {@inheritdoc}
*/
public function getFunctions(): array {
$context_options = ['needs_context' => TRUE];
$all_options = ['needs_environment' => TRUE, 'needs_context' => TRUE];
$functions = [
new TwigFunction('drupal_view', 'views_embed_view'),
new TwigFunction('drupal_view_result', 'views_get_view_result'),
new TwigFunction('drupal_block', [self::class, 'drupalBlock']),
new TwigFunction('drupal_region', [self::class, 'drupalRegion']),
new TwigFunction('drupal_entity', [self::class, 'drupalEntity']),
new TwigFunction('drupal_entity_form', [self::class, 'drupalEntityForm']),
new TwigFunction('drupal_field', [self::class, 'drupalField']),
new TwigFunction('drupal_menu', [self::class, 'drupalMenu']),
new TwigFunction('drupal_form', [self::class, 'drupalForm']),
new TwigFunction('drupal_image', [self::class, 'drupalImage']),
new TwigFunction('drupal_token', [self::class, 'drupalToken']),
new TwigFunction('drupal_config', [self::class, 'drupalConfig']),
new TwigFunction('drupal_dump', [self::class, 'drupalDump'], $context_options),
new TwigFunction('dd', [self::class, 'drupalDump'], $context_options),
new TwigFunction('drupal_title', [self::class, 'drupalTitle']),
new TwigFunction('drupal_url', [self::class, 'drupalUrl']),
new TwigFunction('drupal_link', [self::class, 'drupalLink']),
new TwigFunction('drupal_messages', function (): array {
return ['#type' => 'status_messages'];
}),
new TwigFunction('drupal_breadcrumb', [self::class, 'drupalBreadcrumb']),
new TwigFunction('drupal_breakpoint', [self::class, 'drupalBreakpoint'], $all_options),
// @phpcs:ignore Drupal.Arrays.Array.LongLineDeclaration
new TwigFunction('drupal_contextual_links', [self::class, 'drupalContextualLinks']),
];
$this->moduleHandler->alter('twig_tweak_functions', $functions);
$this->themeManager->alter('twig_tweak_functions', $functions);
return $functions;
}
/**
* {@inheritdoc}
*/
public function getFilters(): array {
$filters = [
new TwigFilter('token_replace', [self::class, 'tokenReplaceFilter']),
new TwigFilter('preg_replace', [self::class, 'pregReplaceFilter']),
new TwigFilter('image_style', [self::class, 'imageStyleFilter']),
new TwigFilter('transliterate', [self::class, 'transliterateFilter']),
new TwigFilter('check_markup', 'check_markup'),
new TwigFilter('format_size', 'format_size'),
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']),
];
if (Settings::get('twig_tweak_enable_php_filter')) {
$filters[] = new TwigFilter('php', [self::class, 'phpFilter'], ['needs_context' => TRUE]);
}
$this->moduleHandler->alter('twig_tweak_filters', $filters);
$this->themeManager->alter('twig_tweak_filters', $filters);
return $filters;
}
/**
* {@inheritdoc}
*/
public function getTests(): array {
$tests = [];
$this->moduleHandler->alter('twig_tweak_tests', $tests);
$this->themeManager->alter('twig_tweak_tests', $tests);
return $tests;
}
/**
* Builds the render array for a block.
*/
public static function drupalBlock(string $id, array $configuration = [], bool $wrapper = TRUE): array {
return \Drupal::service('twig_tweak.block_view_builder')->build($id, $configuration, $wrapper);
}
/**
* Builds the render array of a given region.
*/
public static function drupalRegion(string $region, ?string $theme = NULL): array {
return \Drupal::service('twig_tweak.region_view_builder')->build($region, $theme);
}
/**
* Returns the render array to represent an entity.
*/
public static function drupalEntity(string $entity_type, string $selector, string $view_mode = 'full', ?string $langcode = NULL, bool $check_access = TRUE): array {
$storage = \Drupal::entityTypeManager()->getStorage($entity_type);
if (Uuid::isValid($selector)) {
$entities = $storage->loadByProperties(['uuid' => $selector]);
$entity = reset($entities);
}
// Fall back to entity ID.
else {
$entity = $storage->load($selector);
}
if ($entity) {
return \Drupal::service('twig_tweak.entity_view_builder')
->build($entity, $view_mode, $langcode, $check_access);
}
return [];
}
/**
* Gets the built and processed entity form for the given entity type.
*/
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);
if ($entity) {
return \Drupal::service('twig_tweak.entity_form_view_builder')
->build($entity, $form_mode, $check_access);
}
return [];
}
/**
* Returns the render array for a single entity field.
*/
public static function drupalField(string $field_name, string $entity_type, string $id, $view_mode = 'full', string $langcode = NULL, bool $check_access = TRUE): array {
$entity = \Drupal::entityTypeManager()->getStorage($entity_type)->load($id);
if ($entity) {
return \Drupal::service('twig_tweak.field_view_builder')
->build($entity, $field_name, $view_mode, $langcode, $check_access);
}
return [];
}
/**
* Returns the render array for Drupal menu.
*/
public static function drupalMenu(string $menu_name, int $level = 1, int $depth = 0, bool $expand = FALSE): array {
return \Drupal::service('twig_tweak.menu_view_builder')->build($menu_name, $level, $depth, $expand);
}
/**
* Builds and processes a form for a given form ID.
*
* @param string $form_id
* The form ID.
* @param mixed $args
* Additional arguments are passed to form constructor.
*
* @return array
* A render array to represent the form.
*/
public static function drupalForm(string $form_id, ...$args): array {
$callback = [\Drupal::formBuilder(), 'getForm'];
return call_user_func_array($callback, func_get_args());
}
/**
* Builds an image.
*/
public static function drupalImage(string $selector, string $style = NULL, array $attributes = [], bool $responsive = FALSE, bool $check_access = TRUE): array {
// Determine selector type by its value.
if (preg_match('/^\d+$/', $selector)) {
$selector_type = 'fid';
}
elseif (Uuid::isValid($selector)) {
$selector_type = 'uuid';
}
else {
$selector_type = 'uri';
}
$files = \Drupal::entityTypeManager()
->getStorage('file')
->loadByProperties([$selector_type => $selector]);
if (count($files) == 0) {
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);
}
/**
* Replaces a given token with appropriate value.
*
* @param string $token
* A replaceable token.
* @param array $data
* (optional) An array of keyed objects. For simple replacement scenarios
* 'node', 'user', and others are common keys, with an accompanying node or
* user object being the value. Some token types, like 'site', do not
* require any explicit information from $data and can be replaced even if
* it is empty.
* @param array $options
* (optional) A keyed array of settings and flags to control the token
* replacement process.
*
* @return string
* The token value.
*
* @see \Drupal\Core\Utility\Token::replace()
*/
public static function drupalToken(string $token, array $data = [], array $options = []): string {
return \Drupal::token()->replace("[$token]", $data, $options);
}
/**
* Retrieves data from a given configuration object.
*
* @param string $name
* The name of the configuration object to construct.
* @param string $key
* A string that maps to a key within the configuration data.
*
* @return mixed
* The data that was requested.
*/
public static function drupalConfig(string $name, string $key) {
return \Drupal::config($name)->get($key);
}
/**
* Dumps information about variables.
*
* @param array $context
* Variables from the Twig template.
* @param mixed $variable
* (optional) The variable to dump.
*/
public static function drupalDump(array $context, $variable = NULL): void {
$var_dumper = '\Symfony\Component\VarDumper\VarDumper';
if (class_exists($var_dumper)) {
call_user_func($var_dumper . '::dump', func_num_args() == 1 ? $context : $variable);
}
else {
trigger_error('Could not dump the variable because symfony/var-dumper component is not installed.', E_USER_WARNING);
}
}
/**
* Returns a title for the current route.
*
* @todo Test it with NullRouteMatch
*/
public static function drupalTitle(): array {
$title = NULL;
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['#cache']['contexts'] = ['url'];
return $build;
}
/**
* Generates a URL from an internal or external path.
*
* @param string $user_input
* User input for a link or path.
* @param array $options
* (optional) An array of options.
* @param bool $check_access
* (optional) Indicates that access check is required.
*
* @return \Drupal\Core\Url|null
* A new Url object or null if the URL is not accessible.
*
* @see \Drupal\Core\Url::fromUserInput()
*/
public static function drupalUrl(string $user_input, array $options = [], bool $check_access = FALSE): ?Url {
if (isset($options['langcode'])) {
$language_manager = \Drupal::languageManager();
if ($language = $language_manager->getLanguage($options['langcode'])) {
$options['language'] = $language;
}
}
if (UrlHelper::isExternal($user_input)) {
return Url::fromUri($user_input, $options);
}
if (!in_array($user_input[0], ['/', '#', '?'])) {
$user_input = '/' . $user_input;
}
$url = Url::fromUserInput($user_input, $options);
return (!$check_access || $url->access()) ? $url : NULL;
}
/**
* Generates a link from an internal path.
*
* @param string|\Twig\Markup $text
* The text to be used for the link.
* @param string $user_input
* User input for a link or path.
* @param array $options
* (optional) An array of options.
* @param bool $check_access
* (optional) Indicates that access check is required.
*
* @return \Drupal\Core\Link|null
* A new Link object or null of the URL is not accessible.
*
* @see \Drupal\Core\Link::fromTextAndUrl()
*/
public static function drupalLink($text, string $user_input, array $options = [], bool $check_access = FALSE): ?Link {
$url = self::drupalUrl($user_input, $options, $check_access);
if ($url) {
// The text has been processed by twig already, convert it to a safe
// object for the render system.
// @see \Drupal\Core\Template\TwigExtension::getLink()
if ($text instanceof TwigMarkup) {
$text = Markup::create($text);
}
return Link::fromTextAndUrl($text, $url);
}
return NULL;
}
/**
* Builds the breadcrumb.
*/
public static function drupalBreadcrumb(): array {
return \Drupal::service('breadcrumb')
->build(\Drupal::routeMatch())
->toRenderable();
}
/**
* Builds contextual links.
*
* @param string $id
* A serialized representation of a #contextual_links property value array.
*
* @return array
* A renderable array representing contextual links.
*
* @see https://www.drupal.org/node/2133283
*/
public static function drupalContextualLinks(string $id): array {
$build['#cache']['contexts'] = ['user.permissions'];
if (\Drupal::currentUser()->hasPermission('access contextual links')) {
$build['#type'] = 'contextual_links_placeholder';
$build['#id'] = $id;
}
return $build;
}
/**
* Emits a breakpoint to the debug client.
*
* @param \Twig\Environment $environment
* The Twig environment instance.
* @param array $context
* Variables from the current Twig template.
*/
public static function drupalBreakpoint(Environment $environment, array $context): void {
if (function_exists('xdebug_break')) {
xdebug_break();
}
else {
trigger_error('Could not make a break because xdebug is not available.', E_USER_WARNING);
}
}
/**
* Replaces all tokens in a given string with appropriate values.
*
* @param string $text
* An HTML string containing replaceable tokens.
* @param array $data
* (optional) An array of keyed objects. For simple replacement scenarios
* 'node', 'user', and others are common keys, with an accompanying node or
* user object being the value. Some token types, like 'site', do not
* require any explicit information from $data and can be replaced even if
* it is empty.
* @param array $options
* (optional) A keyed array of settings and flags to control the token
* replacement process.
*
* @return string
* The entered HTML text with tokens replaced.
*/
public static function tokenReplaceFilter(string $text, array $data = [], array $options = []): string {
return \Drupal::token()->replace($text, $data, $options);
}
/**
* Performs a regular expression search and replace.
*
* @param string $text
* The text to search and replace.
* @param string $pattern
* The pattern to search for.
* @param string $replacement
* The string to replace.
*
* @return string
* The new text if matches are found, otherwise unchanged text.
*/
public static function pregReplaceFilter(string $text, string $pattern, string $replacement): string {
return preg_replace($pattern, $replacement, $text);
}
/**
* Returns the URL of this image derivative for an original image path or URI.
*
* @param string|null $path
* The path or URI to the original image.
* @param string $style
* The image style.
*
* @return string|null
* The absolute URL where a style image can be downloaded, suitable for use
* in an <img> tag. Requesting the URL will cause the image to be created.
*/
public static function imageStyleFilter(?string $path, string $style): ?string {
if (!$path) {
trigger_error('Image path is empty.');
return NULL;
}
if (!$image_style = ImageStyle::load($style)) {
trigger_error(sprintf('Could not load image style %s.', $style));
return NULL;
}
if (!$image_style->supportsUri($path)) {
trigger_error(sprintf('Could not apply image style %s.', $style));
return NULL;
}
return \Drupal::service('file_url_generator')
->transformRelative($image_style->buildUrl($path));
}
/**
* Transliterates text from Unicode to US-ASCII.
*
* @param string $text
* The $text to transliterate.
* @param string $langcode
* (optional) The language code of the language the string is in. Defaults
* to 'en' if not provided. Warning: this can be unfiltered user input.
* @param string $unknown_character
* (optional) The character to substitute for characters in $string without
* transliterated equivalents. Defaults to '?'.
* @param int $max_length
* (optional) If provided, return at most this many characters, ensuring
* that the transliteration does not split in the middle of an input
* character's transliteration.
*
* @return string
* $string with non-US-ASCII characters transliterated to US-ASCII
* characters, and unknown characters replaced with $unknown_character.
*/
public static function transliterateFilter(string $text, string $langcode = 'en', string $unknown_character = '?', int $max_length = NULL) {
return \Drupal::transliteration()->transliterate($text, $langcode, $unknown_character, $max_length);
}
/**
* Returns a render array for entity, field list or field item.
*
* @param object|null $object
* The object to build a render array from.
* @param string|array $view_mode
* Can be either the name of a view mode, or an array of display settings.
* @param string $langcode
* (optional) For which language the entity should be rendered, defaults to
* the current content language.
* @param bool $check_access
* (optional) Indicates that access check for an entity is required.
*
* @return array
* A render array to represent the object.
*/
public static function viewFilter(?object $object, $view_mode = 'default', string $langcode = NULL, bool $check_access = TRUE): array {
$build = [];
if ($object instanceof FieldItemListInterface || $object instanceof FieldItemInterface) {
$build = $object->view($view_mode);
/** @var \Drupal\Core\Entity\Plugin\DataType\EntityAdapter $parent */
if ($parent = $object->getParent()) {
CacheableMetadata::createFromRenderArray($build)
->addCacheableDependency($parent->getEntity())
->applyTo($build);
}
}
elseif ($object instanceof EntityInterface) {
$build = \Drupal::service('twig_tweak.entity_view_builder')->build($object, $view_mode, $langcode, $check_access);
}
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.
*
* @param array $build
* The renderable array to add the child item.
* @param mixed $key
* The key of the new element.
* @param mixed $element
* The element to add.
*
* @return array
* The modified array.
*/
public static function withFilter(array $build, $key, $element): array {
if (is_array($key)) {
NestedArray::setValue($build, $key, $element);
}
else {
$build[$key] = $element;
}
return $build;
}
/**
* Filters out the children of a render array, optionally sorted by weight.
*
* @param array $build
* The render array whose children are to be filtered.
* @param bool $sort
* Boolean to indicate whether the children should be sorted by weight.
*
* @return array
* The element's children.
*/
public static function childrenFilter(array $build, bool $sort = FALSE): array {
$keys = Element::children($build, $sort);
return array_intersect_key($build, array_flip($keys));
}
/**
* Returns a URI to the file.
*
* @param object $input
* An object that contains the URI.
*
* @return string|null
* A URI that may be used to access the file.
*/
public static function fileUriFilter($input): ?string {
return \Drupal::service('twig_tweak.uri_extractor')->extractUri($input);
}
/**
* Returns a URL path to the file.
*
* @param string|object $input
* Can be either file URI or an object that contains the URI.
* @param bool $relative
* (optional) Whether the URL should be root-relative, defaults to true.
*
* @return string|null
* A URL that may be used to access the file.
*/
public static function fileUrlFilter($input, bool $relative = TRUE): ?string {
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.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity to get the translation from.
* @param string $langcode
* (optional) For which language the translation should be looked for,
* defaults to the current language context.
*
* @return \Drupal\Core\Entity\EntityInterface
* The appropriate translation for the given language context.
*/
public static function entityTranslation(EntityInterface $entity, string $langcode = NULL): EntityInterface {
return \Drupal::service('entity.repository')->getTranslationFromContext($entity, $langcode);
}
/**
* Extracts cache metadata from object or render array.
*
* @param \Drupal\Core\Cache\CacheableDependencyInterface|array $input
* The cacheable object or render array.
*
* @return array
* A render array with extracted cache metadata.
*/
public static function cacheMetadata($input): array {
return \Drupal::service('twig_tweak.cache_metadata_extractor')->extractCacheMetadata($input);
}
/**
* Evaluates a string of PHP code.
*
* @param array $context
* Twig context.
* @param string $code
* Valid PHP code to be evaluated.
*
* @return mixed
* The eval() result.
*/
public static function phpFilter(array $context, string $code) {
// Make Twig variables available in PHP code.
extract($context, EXTR_SKIP);
ob_start();
// phpcs:ignore Drupal.Functions.DiscouragedFunctions.Discouraged
print eval($code);
$output = ob_get_contents();
ob_end_clean();
return $output;
}
}

87
src/UriExtractor.php

@ -1,87 +0,0 @@
<?php
namespace Drupal\twig_tweak;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Field\EntityReferenceFieldItemListInterface;
use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem;
use Drupal\file\FileInterface;
use Drupal\media\MediaInterface;
use Drupal\media\Plugin\media\Source\OEmbedInterface;
/**
* URI extractor service.
*/
class UriExtractor {
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* Constructs a UrlExtractor object.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager) {
$this->entityTypeManager = $entity_type_manager;
}
/**
* Returns a URI to the file.
*
* @param Object|null $input
* An object that contains the URI.
*
* @return string|null
* A URI that may be used to access the file.
*/
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;
}
}
elseif ($input instanceof EntityReferenceItem) {
$entity = $input->entity;
}
// Drupal does not clean up references to deleted entities. So that the
// entity property might be empty while the field item might not.
// @see https://www.drupal.org/project/drupal/issues/2723323
return $entity instanceof ContentEntityInterface ?
$this->getUriFromEntity($entity) : NULL;
}
/**
* Extracts file URI from content entity.
*
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* Entity object that contains information about the file.
*
* @return string|null
* A URI that can be used to access the file.
*/
private function getUriFromEntity(ContentEntityInterface $entity): ?string {
if ($entity instanceof MediaInterface) {
$source = $entity->getSource();
$value = $source->getSourceFieldValue($entity);
if ($source instanceof OEmbedInterface) {
return $value;
}
/** @var \Drupal\file\FileInterface $file */
$file = $this->entityTypeManager->getStorage('file')->load($value);
if ($file) {
return $file->getFileUri();
}
}
elseif ($entity instanceof FileInterface) {
return $entity->getFileUri();
}
return NULL;
}
}

117
src/UrlExtractor.php

@ -1,117 +0,0 @@
<?php
namespace Drupal\twig_tweak;
use Drupal\Core\Entity\ContentEntityInterface;
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;
use Drupal\media\Plugin\media\Source\OEmbedInterface;
/**
* URL extractor service.
*/
class UrlExtractor {
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
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) {
$this->entityTypeManager = $entity_type_manager;
$this->fileUrlGenerator = $file_url_generator;
}
/**
* Extracts file URL from a string or object.
*
* @param string|object $input
* Can be either file URI or an object that contains the URI.
* @param bool $relative
* (optional) Whether the URL should be root-relative, defaults to true.
*
* @return string|null
* A URL that may be used to access the file.
*/
public function extractUrl($input, bool $relative = TRUE): ?string {
if (is_string($input)) {
return $this->fileUrlGenerator->{$relative ? 'generateString' : 'generateAbsoluteString'}($input);
}
elseif ($input instanceof LinkItemInterface) {
return $input->getUrl()->toString();
}
elseif ($input instanceof FieldItemList && $input->first() instanceof LinkItemInterface) {
return $input->first()->getUrl()->toString();
}
$entity = $input;
if ($input instanceof EntityReferenceFieldItemListInterface) {
/** @var \Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem $item */
if ($item = $input->first()) {
$entity = $item->entity;
}
}
elseif ($input instanceof EntityReferenceItem) {
$entity = $input->entity;
}
// Drupal does not clean up references to deleted entities. So that the
// entity property might be empty while the field item might not.
// @see https://www.drupal.org/project/drupal/issues/2723323
return $entity instanceof ContentEntityInterface ?
$this->getUrlFromEntity($entity, $relative) : NULL;
}
/**
* Extracts file URL from content entity.
*
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* Entity object that contains information about the file.
* @param bool $relative
* (optional) Whether the URL should be root-relative, defaults to true.
*
* @return string|null
* A URL that may be used to access the file.
*/
private function getUrlFromEntity(ContentEntityInterface $entity, bool $relative = TRUE): ?string {
if ($entity instanceof MediaInterface) {
$source = $entity->getSource();
$value = $source->getSourceFieldValue($entity);
if (!$value) {
return NULL;
}
elseif ($source instanceof OEmbedInterface) {
return $value;
}
else {
/** @var \Drupal\file\FileInterface $file */
$file = $this->entityTypeManager->getStorage('file')->load($value);
if ($file) {
return $file->createFileUrl($relative);
}
}
}
elseif ($entity instanceof FileInterface) {
return $entity->createFileUrl($relative);
}
return NULL;
}
}

186
src/View/BlockViewBuilder.php

@ -1,186 +0,0 @@
<?php
namespace Drupal\twig_tweak\View;
use Drupal\Core\Block\BlockPluginInterface;
use Drupal\Core\Block\TitleBlockPluginInterface;
use Drupal\Core\Cache\CacheableDependencyInterface;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Controller\TitleResolverInterface;
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;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* Block view builder.
*/
class BlockViewBuilder {
/**
* The plugin.manager.block service.
*
* @var \Drupal\Core\Cache\CacheableDependencyInterface
*/
protected $pluginManagerBlock;
/**
* The context repository service.
*
* @var \Drupal\Core\Plugin\Context\ContextRepositoryInterface
*/
protected $contextRepository;
/**
* The plugin context handler.
*
* @var \Drupal\Core\Plugin\Context\ContextHandlerInterface
*/
protected $contextHandler;
/**
* The current user.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $account;
/**
* The request stack.
*
* @var \Symfony\Component\HttpFoundation\RequestStack
*/
protected $requestStack;
/**
* The current route match.
*
* @var \Drupal\Core\Routing\RouteMatchInterface
*/
protected $routeMatch;
/**
* The title resolver.
*
* @var \Drupal\Core\Controller\TitleResolverInterface
*/
protected $titleResolver;
/**
* Constructs a BlockViewBuilder object.
*/
public function __construct(
CacheableDependencyInterface $plugin_manager_block,
ContextRepositoryInterface $context_repository,
ContextHandlerInterface $context_handler,
AccountInterface $account,
RequestStack $request_stack,
RouteMatchInterface $route_match,
TitleResolverInterface $title_resolver
) {
$this->pluginManagerBlock = $plugin_manager_block;
$this->contextRepository = $context_repository;
$this->contextHandler = $context_handler;
$this->account = $account;
$this->requestStack = $request_stack;
$this->routeMatch = $route_match;
$this->titleResolver = $title_resolver;
}
/**
* Builds the render array for a block.
*
* @param string $id
* The string of block plugin to render.
* @param array $configuration
* (optional) Pass on any configuration to the plugin block.
* @param bool $wrapper
* (optional) Whether or not use block template for rendering.
*
* @return array
* A renderable array representing the content of the block.
*/
public function build(string $id, array $configuration = [], bool $wrapper = TRUE): array {
$configuration += ['label_display' => BlockPluginInterface::BLOCK_LABEL_VISIBLE];
/** @var \Drupal\Core\Block\BlockPluginInterface $block_plugin */
$block_plugin = $this->pluginManagerBlock->createInstance($id, $configuration);
// Inject runtime contexts.
if ($block_plugin instanceof ContextAwarePluginInterface) {
$contexts = $this->contextRepository->getRuntimeContexts($block_plugin->getContextMapping());
$this->contextHandler->applyContextMapping($block_plugin, $contexts);
}
$build = [];
$access = $block_plugin->access($this->account, TRUE);
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);
}
}
// Place the content returned by the block plugin into a 'content' child
// element, as a way to allow the plugin to have complete control of its
// properties and rendering (for instance, its own #theme) without
// conflicting with the properties used above.
$build['content'] = $block_plugin->build();
if ($block_plugin instanceof TitleBlockPluginInterface) {
$build['content']['#cache']['contexts'][] = 'url';
}
// Some blocks return 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 += [
'#theme' => 'block',
'#id' => $configuration['id'] ?? NULL,
'#attributes' => [],
'#contextual_links' => [],
'#configuration' => $block_plugin->getConfiguration(),
'#plugin_id' => $block_plugin->getPluginId(),
'#base_plugin_id' => $block_plugin->getBaseId(),
'#derivative_plugin_id' => $block_plugin->getDerivativeId(),
];
// Semantically, the content returned by the plugin is the block, and in
// particular, #attributes and #contextual_links is information about
// the *entire* block. Therefore, we must move these properties into the
// top-level element.
foreach (['#attributes', '#contextual_links'] as $property) {
if (isset($build['content'][$property])) {
$build[$property] = $build['content'][$property];
unset($build['content'][$property]);
}
}
}
}
CacheableMetadata::createFromRenderArray($build)
->addCacheableDependency($access)
->addCacheableDependency($block_plugin)
->applyTo($build);
if (!isset($build['#cache']['keys'])) {
$build['#cache']['keys'] = [
'twig_tweak_block',
$id,
'[configuration]=' . hash('sha256', serialize($configuration)),
'[wrapper]=' . (int) $wrapper,
];
}
return $build;
}
}

61
src/View/EntityFormViewBuilder.php

@ -1,61 +0,0 @@
<?php
namespace Drupal\twig_tweak\View;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Entity\EntityFormBuilderInterface;
use Drupal\Core\Entity\EntityInterface;
/**
* Entity form view builder.
*/
class EntityFormViewBuilder {
/**
* The entity form builder service.
*
* @var \Drupal\Core\Entity\EntityFormBuilderInterface
*/
protected $entityFormBuilder;
/**
* Constructs an EntityFormViewBuilder object.
*/
public function __construct(EntityFormBuilderInterface $entity_form_builder) {
$this->entityFormBuilder = $entity_form_builder;
}
/**
* 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
* (optional) The mode identifying the form variation to be returned.
* @param bool $check_access
* (optional) Indicates that access check is required.
*
* @return array
* 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';
$access = $check_access ? $entity->access($operation, NULL, TRUE) : AccessResult::allowed();
if ($access->isAllowed()) {
$build = $this->entityFormBuilder->getForm($entity, $form_mode);
}
CacheableMetadata::createFromRenderArray($build)
->addCacheableDependency($access)
->addCacheableDependency($entity)
->applyTo($build);
return $build;
}
}

57
src/View/EntityViewBuilder.php

@ -1,57 +0,0 @@
<?php
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;
/**
* Entity view builder.
*/
class EntityViewBuilder {
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
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) {
$this->entityTypeManager = $entity_type_manager;
$this->entityRepository = $entity_repository;
}
/**
* Builds a render array for a given entity.
*/
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
->getViewBuilder($entity->getEntityTypeId())
->view($entity, $view_mode, $langcode);
}
CacheableMetadata::createFromRenderArray($build)
->addCacheableDependency($access)
->addCacheableDependency($entity)
->applyTo($build);
return $build;
}
}

76
src/View/FieldViewBuilder.php

@ -1,76 +0,0 @@
<?php
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;
/**
* Field view builder.
*/
class FieldViewBuilder {
/**
* The entity repository.
*
* @var \Drupal\Core\Entity\EntityRepositoryInterface
*/
protected $entityRepository;
/**
* Constructs a FieldViewBuilder object.
*/
public function __construct(EntityRepositoryInterface $entity_repository) {
$this->entityRepository = $entity_repository;
}
/**
* Returns the render array for a single entity field.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity.
* @param string $field_name
* The field name.
* @param string|array $view_mode
* (optional) The view mode or display options.
* @param string $langcode
* (optional) Language code to load translation.
* @param bool $check_access
* (optional) Indicates that access check is required.
*
* @return array
* A render array for the field.
*
* @see \Drupal\Core\Entity\EntityViewBuilderInterface::viewField()
*/
public function build(
EntityInterface $entity,
string $field_name,
$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()) {
if (!isset($entity->{$field_name})) {
// @todo Trigger error here.
return [];
}
$build = $entity->{$field_name}->view($view_mode);
}
CacheableMetadata::createFromRenderArray($build)
->addCacheableDependency($access)
->addCacheableDependency($entity)
->applyTo($build);
return $build;
}
}

98
src/View/ImageViewBuilder.php

@ -1,98 +0,0 @@
<?php
namespace Drupal\twig_tweak\View;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Image\ImageFactory;
use Drupal\file\FileInterface;
/**
* Image view builder.
*/
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.
*
* @param \Drupal\file\FileInterface $file
* The file object.
* @param string $style
* (optional) Image style.
* @param array $attributes
* (optional) Image attributes.
* @param bool $responsive
* (optional) Indicates that the provided image style is responsive.
* @param bool $check_access
* (optional) Indicates that access check is required.
*
* @return array
* 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;
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();
}
}
if ($responsive) {
$build['#type'] = 'responsive_image';
$build['#responsive_image_style_id'] = $style;
}
else {
$build['#theme'] = 'image_style';
$build['#style_name'] = $style;
}
return $build;
}
}

84
src/View/MenuViewBuilder.php

@ -1,84 +0,0 @@
<?php
namespace Drupal\twig_tweak\View;
use Drupal\Core\Menu\MenuLinkTreeInterface;
/**
* Menu view builder.
*/
class MenuViewBuilder {
/**
* The menu link tree service.
*
* @var \Drupal\Core\Menu\MenuLinkTreeInterface
*/
protected $menuLinkTree;
/**
* Constructs a MenuViewBuilder object.
*/
public function __construct(MenuLinkTreeInterface $menu_link_tree) {
$this->menuLinkTree = $menu_link_tree;
}
/**
* Returns the render array for a menu.
*
* @param string $menu_name
* The name of the menu.
* @param int $level
* (optional) Initial menu level.
* @param int $depth
* (optional) Maximum number of menu levels to display.
* @param bool $expand
* (optional) Expand all menu links.
*
* @return array
* A render array for the menu.
*
* @see \Drupal\system\Plugin\Block\SystemMenuBlock::build()
*/
public function build(string $menu_name, int $level = 1, int $depth = 0, bool $expand = FALSE): array {
$parameters = $this->menuLinkTree->getCurrentRouteMenuTreeParameters($menu_name);
// Adjust the menu tree parameters based on the block's configuration.
$parameters->setMinDepth($level);
// When the depth is configured to zero, there is no depth limit. When depth
// is non-zero, it indicates the number of levels that must be displayed.
// Hence this is a relative depth that we must convert to an actual
// (absolute) depth, that may never exceed the maximum depth.
if ($depth > 0) {
$parameters->setMaxDepth(min($level + $depth - 1, $this->menuLinkTree->maxDepth()));
}
// If expandedParents is empty, the whole menu tree is built.
if ($expand) {
$parameters->expandedParents = [];
}
$tree = $this->menuLinkTree->load($menu_name, $parameters);
$manipulators = [
['callable' => 'menu.default_tree_manipulators:checkAccess'],
['callable' => 'menu.default_tree_manipulators:generateIndexAndSort'],
];
$tree = $this->menuLinkTree->transform($tree, $manipulators);
$build = $this->menuLinkTree->build($tree);
if (!isset($build['#cache']['keys'])) {
$build['#cache']['keys'] = [
'twig_tweak_menu',
$menu_name,
'[level]=' . $level,
'[depth]=' . $depth,
'[expand]=' . (int) $expand,
];
}
$build['#cache']['contexts'][] = 'route.menu_active_trails:' . $menu_name;
return $build;
}
}

114
src/View/RegionViewBuilder.php

@ -1,114 +0,0 @@
<?php
namespace Drupal\twig_tweak\View;
use Drupal\Core\Block\TitleBlockPluginInterface;
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\Component\HttpFoundation\RequestStack;
/**
* Region view builder.
*/
class RegionViewBuilder {
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The config factory.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;
/**
* The request stack.
*
* @var \Symfony\Component\HttpFoundation\RequestStack
*/
protected $requestStack;
/**
* The title resolver.
*
* @var \Drupal\Core\Controller\TitleResolverInterface
*/
protected $titleResolver;
/**
* Constructs a RegionViewBuilder object.
*/
public function __construct(
EntityTypeManagerInterface $entity_type_manager,
ConfigFactoryInterface $config_factory,
RequestStack $request_stack,
TitleResolverInterface $title_resolver
) {
$this->entityTypeManager = $entity_type_manager;
$this->configFactory = $config_factory;
$this->requestStack = $request_stack;
$this->titleResolver = $title_resolver;
}
/**
* Builds the render array of a given region.
*
* @param string $region
* The region to build.
* @param string $theme
* (optional) The name of the theme to load the region. If it is not
* provided then default theme will be used.
*
* @return array
* A render array to display the region content.
*/
public function build(string $region, string $theme = NULL): array {
$blocks = $this->entityTypeManager->getStorage('block')->loadByProperties([
'region' => $region,
'theme' => $theme ?: $this->configFactory->get('system.theme')->get('default'),
]);
$view_builder = $this->entityTypeManager->getViewBuilder('block');
$build = [];
$entity_type = $this->entityTypeManager->getDefinition('block');
$cache_metadata = (new CacheableMetadata())
->addCacheTags($entity_type->getListCacheTags())
->addCacheContexts($entity_type->getListCacheContexts());
/** @var \Drupal\block\BlockInterface[] $blocks */
foreach ($blocks as $id => $block) {
$access = $block->access('view', NULL, TRUE);
$cache_metadata = $cache_metadata->addCacheableDependency($access);
if ($access->isAllowed()) {
$block_plugin = $block->getPlugin();
if ($block_plugin instanceof TitleBlockPluginInterface) {
$request = $this->requestStack->getCurrentRequest();
if ($route = $request->attributes->get(RouteObjectInterface::ROUTE_OBJECT)) {
$block_plugin->setTitle($this->titleResolver->getTitle($request, $route));
}
}
$build[$id] = $view_builder->view($block);
}
}
if ($build) {
$build['#region'] = $region;
$build['#theme_wrappers'] = ['region'];
}
$cache_metadata->applyTo($build);
return $build;
}
}

445
tests/src/Functional/TwigTweakTest.php

@ -2,453 +2,180 @@
namespace Drupal\Tests\twig_tweak\Functional; namespace Drupal\Tests\twig_tweak\Functional;
use Drupal\Core\Link;
use Drupal\Core\Render\Markup;
use Drupal\Core\Url; use Drupal\Core\Url;
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\BrowserTestBase;
use Drupal\Tests\TestFileCreationTrait;
use Drupal\user\Entity\Role;
/** /**
* A test for Twig extension. * A test for Twig extension.
* *
* @group twig_tweak * @group twig_tweak
*/ */
final class TwigTweakTest extends BrowserTestBase { class TwigTweakTest extends BrowserTestBase {
use TestFileCreationTrait;
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
protected $defaultTheme = 'claro'; protected $defaultTheme = 'classy';
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
protected static $modules = [ public static $modules = [
'twig_tweak', 'twig_tweak',
'twig_tweak_test', 'twig_tweak_test',
'views', 'views',
'node', 'node',
'block', 'block',
'image', 'image',
'responsive_image',
'language',
'contextual',
]; ];
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
public function setUp(): void { public function setUp() {
parent::setUp(); parent::setUp();
$this->createContentType(['type' => 'page']);
$test_files = $this->getTestFiles('image'); $this->createNode(['title' => 'Alpha']);
$image_file = File::create([
'uri' => $test_files[0]->uri,
'uuid' => 'b2c22b6f-7bf8-4da4-9de5-316e93487518',
'status' => FileInterface::STATUS_PERMANENT,
]);
$image_file->save();
$media_file = File::create([
'uri' => $test_files[8]->uri,
'uuid' => '5dd794d0-cb75-4130-9296-838aebc1fe74',
'status' => FileInterface::STATUS_PERMANENT,
]);
$media_file->save();
$media = Media::create([
'bundle' => 'image',
'name' => 'Image 1',
'field_media_image' => ['target_id' => $media_file->id()],
]);
$media->save();
$node_values = [
'title' => 'Alpha',
'uuid' => 'ad1b902a-344f-41d1-8c61-a69f0366dbfa',
'field_image' => [
'target_id' => $image_file->id(),
'alt' => 'Alt text',
'title' => 'Title',
],
'field_media' => [
'target_id' => $media->id(),
],
];
$this->createNode($node_values);
$this->createNode(['title' => 'Beta']); $this->createNode(['title' => 'Beta']);
$this->createNode(['title' => 'Gamma']); $this->createNode(['title' => 'Gamma']);
ResponsiveImageStyle::create([
'id' => 'example',
'label' => 'Example',
'breakpoint_group' => 'responsive_image',
])->save();
// Setup Russian language.
ConfigurableLanguage::createFromLangcode('ru')->save();
} }
/** /**
* Tests output produced by the Twig extension. * Tests output produced by the Twig extension.
*/ */
public function testOutput(): void { public function testOutput() {
// Title block rendered through drupal_region() is cached by some reason.
$this->drupalGet('twig-tweak-test'); \Drupal::service('cache_tags.invalidator')->invalidateTags(['block_view']);
$this->drupalGet('<front>');
// -- View (default display). // Test default views display.
$xpath = '//div[@class = "tt-view-default"]'; $xpath = '//div[@class = "tt-view-default"]';
$xpath .= '//div[contains(@class, "view-twig-tweak-test") and contains(@class, "view-display-id-default")]'; $xpath .= '//div[contains(@class, "view-twig-tweak-test") and contains(@class, "view-display-id-default")]';
$xpath .= '/div[@class = "view-content"]//ul[count(./li) = 3]/li'; $xpath .= '/div[@class = "view-content"]//ul[count(./li) = 3]/li';
$this->assertXpath($xpath . '//a[contains(@href, "/node/1") and text() = "Alpha"]'); $this->assertByXpath($xpath . '//a[contains(@href, "/node/1") and text() = "Alpha"]');
$this->assertXpath($xpath . '//a[contains(@href, "/node/2") and text() = "Beta"]'); $this->assertByXpath($xpath . '//a[contains(@href, "/node/2") and text() = "Beta"]');
$this->assertXpath($xpath . '//a[contains(@href, "/node/3") and text() = "Gamma"]'); $this->assertByXpath($xpath . '//a[contains(@href, "/node/3") and text() = "Gamma"]');
// -- View (page_1 display). // Test page_1 view display.
$xpath = '//div[@class = "tt-view-page_1"]'; $xpath = '//div[@class = "tt-view-page_1"]';
$xpath .= '//div[contains(@class, "view-twig-tweak-test") and contains(@class, "view-display-id-page_1")]'; $xpath .= '//div[contains(@class, "view-twig-tweak-test") and contains(@class, "view-display-id-page_1")]';
$xpath .= '/div[@class = "view-content"]//ul[count(./li) = 3]/li'; $xpath .= '/div[@class = "view-content"]//ul[count(./li) = 3]/li';
$this->assertXpath($xpath . '//a[contains(@href, "/node/1") and text() = "Alpha"]'); $this->assertByXpath($xpath . '//a[contains(@href, "/node/1") and text() = "Alpha"]');
$this->assertXpath($xpath . '//a[contains(@href, "/node/2") and text() = "Beta"]'); $this->assertByXpath($xpath . '//a[contains(@href, "/node/2") and text() = "Beta"]');
$this->assertXpath($xpath . '//a[contains(@href, "/node/3") and text() = "Gamma"]'); $this->assertByXpath($xpath . '//a[contains(@href, "/node/3") and text() = "Gamma"]');
// -- View with arguments. // Test view argument.
$xpath = '//div[@class = "tt-view-page_1-with-argument"]'; $xpath = '//div[@class = "tt-view-page_1-with-argument"]';
$xpath .= '//div[contains(@class, "view-twig-tweak-test")]'; $xpath .= '//div[contains(@class, "view-twig-tweak-test")]';
$xpath .= '/div[@class = "view-content"]//ul[count(./li) = 1]/li'; $xpath .= '/div[@class = "view-content"]//ul[count(./li) = 1]/li';
$this->assertXpath($xpath . '//a[contains(@href, "/node/1") and text() = "Alpha"]'); $this->assertByXpath($xpath . '//a[contains(@href, "/node/1") and text() = "Alpha"]');
// -- View result.
$xpath = '//div[@class = "tt-view-result" and text() = 3]';
$this->assertXpath($xpath);
// -- Block. // Test block.
$xpath = '//div[@class = "tt-block"]'; $xpath = '//div[@class = "tt-block"]';
$xpath .= '/img[contains(@src, "/core/themes/claro/logo.svg") and @alt="Home"]'; $xpath .= '/div[@id="block-classy-powered-by-drupal"]/span[contains(., "Powered by Drupal")]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- Block with wrapper. // Test region.
$xpath = '//div[@class = "tt-block-with-wrapper"]'; $xpath = '//div[@class = "tt-region"]';
$xpath .= '/div[@class = "block block-system block-system-branding-block"]'; $xpath .= '/div[contains(@class, "block-page-title-block") and h1[@class="page-title" and text() = "Log in"]]';
$xpath .= '/h2[text() = "Branding"]'; $xpath .= '/following-sibling::div[contains(@class, "block-system-powered-by-block")]/span[. = "Powered by Drupal"]';
$xpath .= '/following-sibling::a[img[contains(@src, "/core/themes/claro/logo.svg") and @alt="Home"]]'; $this->assertByXpath($xpath);
$xpath .= '/following-sibling::div[@class = "site-name"]/a';
$this->assertXpath($xpath); // Test entity default view mode.
// -- Region.
$xpath = '//div[@class = "tt-region"]/div[@class = "region region-highlighted"]';
$xpath .= '/div[contains(@class, "block-system-powered-by-block")]/span[. = "Powered by Drupal"]';
$this->assertXpath($xpath);
// -- Entity (default view mode).
$xpath = '//div[@class = "tt-entity-default"]'; $xpath = '//div[@class = "tt-entity-default"]';
$xpath .= '/article[contains(@class, "node") and not(contains(@class, "node--view-mode-teaser"))]'; $xpath .= '/article[contains(@class, "node") and not(contains(@class, "node--view-mode-teaser"))]';
$xpath .= '/h2/a/span[text() = "Alpha"]'; $xpath .= '/h2/a/span[text() = "Alpha"]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- Entity (teaser view mode). // Test entity teaser view mode.
$xpath = '//div[@class = "tt-entity-teaser"]'; $xpath = '//div[@class = "tt-entity-teaser"]';
$xpath .= '/article[contains(@class, "node") and contains(@class, "node--view-mode-teaser")]'; $xpath .= '/article[contains(@class, "node") and contains(@class, "node--view-mode-teaser")]';
$xpath .= '/h2/a/span[text() = "Alpha"]'; $xpath .= '/h2/a/span[text() = "Alpha"]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- Entity by UUID. // Test loading entity from url.
$xpath = '//div[@class = "tt-entity-uuid"]'; $xpath = '//div[@class = "tt-entity-from-url" and not(text())]';
$this->assertByXpath($xpath);
$this->drupalGet('/node/2');
$xpath = '//div[@class = "tt-entity-from-url"]';
$xpath .= '/article[contains(@class, "node")]'; $xpath .= '/article[contains(@class, "node")]';
$xpath .= '/h2/a/span[text() = "Alpha"]'; $xpath .= '/h2/a/span[text() = "Beta"]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- Entity by UUID (missing). // Test field.
$xpath = '//div[@class = "tt-entity-uuid-missing" and . = ""]';
$this->assertXpath($xpath);
// -- Entity add form (unprivileged user).
$xpath = '//div[@class = "tt-entity-add-form"]/form';
$this->assertSession()->elementNotExists('xpath', $xpath);
// -- Entity edit form (unprivileged user).
$xpath = '//div[@class = "tt-entity-edit-form"]/form';
$this->assertSession()->elementNotExists('xpath', $xpath);
// Grant require permissions and test the forms again.
$permissions = ['create page content', 'edit any page content'];
/** @var \Drupal\user\RoleInterface $role */
$role = Role::load(Role::ANONYMOUS_ID);
$this->grantPermissions($role, $permissions);
$this->drupalGet($this->getUrl());
// -- 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"]';
$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"]';
$this->assertXpath($xpath);
// -- Field.
$xpath = '//div[@class = "tt-field"]/div[contains(@class, "field--name-body")]/p[text() != ""]'; $xpath = '//div[@class = "tt-field"]/div[contains(@class, "field--name-body")]/p[text() != ""]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- Menu. // Test menu (default).
$xpath = '//div[@class = "tt-menu-default"]/ul[@class = "menu"]/li/a[text() = "Link 1"]/../ul[@class = "menu"]/li/ul[@class = "menu"]/li/a[text() = "Link 3"]'; $xpath = '//div[@class = "tt-menu-default"]/ul[@class = "menu"]/li/a[text() = "Link 1"]/../ul[@class = "menu"]/li/ul[@class = "menu"]/li/a[text() = "Link 3"]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- Menu with level option. // Test menu (level).
$xpath = '//div[@class = "tt-menu-level"]/ul[@class = "menu"]/li/a[text() = "Link 2"]/../ul[@class = "menu"]/li/a[text() = "Link 3"]'; $xpath = '//div[@class = "tt-menu-level"]/ul[@class = "menu"]/li/a[text() = "Link 2"]/../ul[@class = "menu"]/li/a[text() = "Link 3"]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- Menu with depth option. // Test menu (depth).
$xpath = '//div[@class = "tt-menu-depth"]/ul[@class = "menu"]/li[not(ul)]/a[text() = "Link 1"]'; $xpath = '//div[@class = "tt-menu-depth"]/ul[@class = "menu"]/li[not(ul)]/a[text() = "Link 1"]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- Form. // Test form.
$xpath = '//div[@class = "tt-form"]/form[@class="system-cron-settings"]/input[@type = "submit" and @value = "Run cron"]'; $xpath = '//div[@class = "tt-form"]/form[@class="system-cron-settings"]/input[@type = "submit" and @value = "Run cron"]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- Image by FID.
$xpath = '//div[@class = "tt-image-by-fid"]/img[contains(@src, "/files/image-test.png")]';
$this->assertXpath($xpath);
// -- Image by URI.
$xpath = '//div[@class = "tt-image-by-uri"]/img[contains(@src, "/files/image-test.png")]';
$this->assertXpath($xpath);
// -- Image by UUID. // Test token.
$xpath = '//div[@class = "tt-image-by-uuid"]/img[contains(@src, "/files/image-test.png")]';
$this->assertXpath($xpath);
// -- Image with style.
$xpath = '//div[@class = "tt-image-with-style"]/img[contains(@src, "/files/styles/thumbnail/public/image-test.png")]';
$this->assertXpath($xpath);
// -- Image with responsive style.
$xpath = '//div[@class = "tt-image-with-responsive-style"]/picture/img[contains(@src, "/files/image-test.png")]';
$this->assertXpath($xpath);
// -- Token.
$xpath = '//div[@class = "tt-token" and text() = "Drupal"]'; $xpath = '//div[@class = "tt-token" and text() = "Drupal"]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- Token with context. // Test token with context.
$xpath = '//div[@class = "tt-token-data" and text() = "Alpha"]'; $xpath = '//div[@class = "tt-token-data" and text() = "Beta"]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- Config. // Test config.
$xpath = '//div[@class = "tt-config" and text() = "Anonymous"]'; $xpath = '//div[@class = "tt-config" and text() = "Anonymous"]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- Page title. // Test status message.
$xpath = '//div[@class = "tt-title" and text() = "Twig Tweak Test"]'; $xpath = '//div[@class = "messages messages--warning" and contains(., "Hi!")]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- URL. // Test page title.
$url = Url::fromUserInput('/node/1', ['absolute' => TRUE])->toString(); $xpath = '//div[@class = "tt-title" and text() = "Beta"]';
$xpath = sprintf('//div[@class = "tt-url"]/div[@data-case="default" and text() = "%s"]', $url); $this->assertByXpath($xpath);
$this->assertXpath($xpath);
// -- URL with langcode.
$url = str_replace('node/1', 'ru/node/1', $url);
$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());
// -- 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());
// -- Status messages.
$xpath = '//div[@class = "tt-messages"]//div[contains(@class, "messages--status") and contains(., "Hello world!")]';
$this->assertXpath($xpath);
// -- Breadcrumb.
$xpath = '//div[@class = "tt-breadcrumb"]/nav[@class = "breadcrumb"]/ol/li/a[text() = "Home"]';
$this->assertXpath($xpath);
// -- Protected link.
$xpath = '//div[@class = "tt-link-access"]';
self::assertSame('', $this->xpath($xpath)[0]->getHtml());
// -- Token replacement.
$xpath = '//div[@class = "tt-token-replace" and text() = "Site name: Drupal"]';
$this->assertXpath($xpath);
// -- Contextual links. // Test URL.
$xpath = '//div[@class="tt-contextual-links" and not(div[@data-contextual-id])]'; $url = Url::fromUserInput('/node/1', ['absolute' => TRUE])->toString();
$this->assertXpath($xpath); $xpath = sprintf('//div[@class = "tt-url" and text() = "%s"]', $url);
$this->assertByXpath($xpath);
/** @var \Drupal\user\RoleInterface $role */ // Test token replacement.
$role = Role::load(Role::ANONYMOUS_ID); $xpath = '//div[@class = "tt-token-replace" and text() = "Site name: Drupal"]';
$this->grantPermissions($role, ['access contextual links']); $this->assertByXpath($xpath);
$this->drupalGet($this->getUrl());
$xpath = '//div[@class="tt-contextual-links" and div[@data-contextual-id]]';
$this->assertXpath($xpath);
// -- Replace (preg). // Test preg replacement.
$xpath = '//div[@class = "tt-preg-replace" and text() = "FOO-bar"]'; $xpath = '//div[@class = "tt-preg-replace" and text() = "FOO-bar"]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// Test preg replacement (legacy).
$xpath = '//div[@class = "tt-preg-replace-legacy" and text() = "foo-bar"]';
$this->assertByXpath($xpath);
// -- Image style. // Test image style.
$xpath = '//div[@class = "tt-image-style" and contains(text(), "styles/thumbnail/public/images/ocean.jpg")]'; $xpath = '//div[@class = "tt-image-style" and contains(text(), "styles/thumbnail/public/images/ocean.jpg")]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- Transliterate. // Test transliteration.
$xpath = '//div[@class = "tt-transliterate" and contains(text(), "Privet!")]'; $xpath = '//div[@class = "tt-transliterate" and contains(text(), "Privet!")]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- Text format. // Test text format.
$xpath = '//div[@class = "tt-check-markup"]'; $xpath = '//div[@class = "tt-check-markup"]';
self::assertSame('<b>bold</b> strong', $this->xpath($xpath)[0]->getHtml()); $this->assertEquals('<b>bold</b> strong', trim($this->xpath($xpath)[0]->getHtml()));
// -- Format size.
$xpath = '//div[@class = "tt-format-size"]';
self::assertSame('12.06 KB', $this->xpath($xpath)[0]->getHtml());
// -- Truncate.
$xpath = '//div[@class = "tt-truncate" and text() = "Hello…"]';
$this->assertXpath($xpath);
// -- 'with'.
$xpath = '//div[@class = "tt-with"]/b[text() = "Example"]';
$this->assertXpath($xpath);
// -- Nested 'with'.
$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);
// -- Entity view.
$xpath = '//div[@class = "tt-node-view"]/article[contains(@class, "node--view-mode-default")]/h2[a/span[text() = "Alpha"]]';
$xpath .= '/following-sibling::div[@class = "node__content"]/div/p';
$this->assertXpath($xpath);
// -- Field list view.
$xpath = '//div[@class = "tt-field-list-view"]/span[contains(@class, "field--name-title") and text() = "Alpha"]';
$this->assertXpath($xpath);
// -- Field item view.
$xpath = '//div[@class = "tt-field-item-view" and text() = "Alpha"]';
$this->assertXpath($xpath);
// -- File URI from image field.
$xpath = '//div[@class = "tt-file-uri-from-image-field" and contains(text(), "public://image-test.png")]';
$this->assertXpath($xpath);
// -- File URI from a specific image field item.
$xpath = '//div[@class = "tt-file-uri-from-image-field-delta" and contains(text(), "public://image-test.png")]';
$this->assertXpath($xpath);
// -- File URI from media field.
$xpath = '//div[@class = "tt-file-uri-from-media-field" and contains(text(), "public://image-1.png")]';
$this->assertXpath($xpath);
// -- Image style from File URI from media field.
$xpath = '//div[@class = "tt-image-style-from-file-uri-from-media-field" and contains(text(), "styles/thumbnail/public/image-1.png")]';
$this->assertXpath($xpath);
// -- File URL from URI (relative).
$xpath = '//div[@class = "tt-file-url-from-uri" and contains(text(), "/files/image-test.png") and not(contains(text(), "http://"))]';
$this->assertXpath($xpath);
// -- File URL from URI (absolute).
$xpath = '//div[@class = "tt-file-url-from-uri-absolute" and contains(text(), "/files/image-test.png") and contains(text(), "http://")]';
$this->assertXpath($xpath);
// -- File URL from image field.
$xpath = '//div[@class = "tt-file-url-from-image-field" and contains(text(), "/files/image-test.png")]';
$this->assertXpath($xpath);
// -- File URL from a specific image field item.
$xpath = '//div[@class = "tt-file-url-from-image-field-delta" and contains(text(), "/files/image-test.png")]';
$this->assertXpath($xpath);
// -- File URL from media field.
$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")]';
$this->assertXpath($xpath);
// -- Hook twig_tweak_functions_alter().
$xpath = '//div[@class = "tt-functions_alter" and text() = "-=bar=-"]';
$this->assertXpath($xpath);
// -- Hook twig_tweak_filters_alter().
$xpath = '//div[@class = "tt-filters_alter" and text() = "bar"]';
$this->assertXpath($xpath);
// -- Hook twig_tweak_tests_alter().
$xpath = '//div[@class = "tt-tests_alter" and text() = "Yes"]';
$this->assertXpath($xpath);
} }
/** /**
* 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 { public function assertByXpath($xpath) {
$this->assertSession()->elementExists('xpath', $xpath); $this->assertSession()->elementExists('xpath', $xpath);
} }

93
tests/src/Kernel/AbstractExtractorTestCase.php

@ -1,93 +0,0 @@
<?php
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;
use Drupal\Tests\TestFileCreationTrait;
/**
* A base class of URL and URI extractor tests.
*/
abstract class AbstractExtractorTestCase extends KernelTestBase {
use TestFileCreationTrait;
/**
* A node to test.
*
* @var \Drupal\node\NodeInterface
*/
protected $node;
/**
* {@inheritdoc}
*/
protected static $modules = [
'twig_tweak',
'twig_tweak_test',
'system',
'views',
'node',
'block',
'image',
'field',
'text',
'media',
'file',
'user',
'filter',
];
/**
* {@inheritdoc}
*/
public function setUp(): void {
parent::setUp();
$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,
]);
$image_file->save();
$media_file = File::create([
'uri' => $test_files[2]->uri,
'uuid' => '5dd794d0-cb75-4130-9296-838aebc1fe74',
'status' => FileInterface::STATUS_PERMANENT,
]);
$media_file->save();
$media = Media::create([
'bundle' => 'image',
'name' => 'Image 1',
'field_media_image' => ['target_id' => $media_file->id()],
]);
$media->save();
$node_values = [
'title' => 'Alpha',
'type' => 'page',
'field_image' => [
'target_id' => $image_file->id(),
],
'field_media' => [
'target_id' => $media->id(),
],
];
$this->node = Node::create($node_values);
}
}

45
tests/src/Kernel/AbstractTestCase.php

@ -1,45 +0,0 @@
<?php
namespace Drupal\Tests\twig_tweak\Kernel;
use Drupal\KernelTests\KernelTestBase;
use PHPUnit\Framework\Assert as PHPUnitAssert;
/**
* A base class for Twig Tweak kernel tests.
*/
abstract class AbstractTestCase extends KernelTestBase {
/**
* Asserts cache metadata.
*/
protected static function assertCache(array $expected_cache, array $actual_cache): void {
self::sortCache($expected_cache);
self::sortCache($actual_cache);
PHPUnitAssert::assertSame($expected_cache, $actual_cache);
}
/**
* Asserts render array.
*/
protected static function assertRenderArray(array $expected_build, array $actual_build): void {
self::sortCache($expected_build['#cache']);
self::sortCache($actual_build['#cache']);
PHPUnitAssert::assertSame($expected_build, $actual_build);
}
/**
* Sort cache metadata.
*
* @see https://www.drupal.org/node/3230171
*/
private static function sortCache(array &$cache): void {
if (\array_key_exists('tags', $cache)) {
sort($cache['tags']);
}
if (\array_key_exists('contexts', $cache)) {
sort($cache['contexts']);
}
}
}

212
tests/src/Kernel/AccessTest.php

@ -0,0 +1,212 @@
<?php
namespace Drupal\Tests\twig_tweak\Kernel;
use Drupal\block\BlockViewBuilder;
use Drupal\block\Entity\Block;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\KernelTests\KernelTestBase;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\node\NodeInterface;
use Drupal\Tests\user\Traits\UserCreationTrait;
/**
* Tests for the Twig Tweak access control.
*
* @group twig_tweak
*/
class AccessTest extends KernelTestBase {
use UserCreationTrait;
/**
* A node for testing.
*
* @var \Drupal\node\NodeInterface
*/
private $node;
/**
* The Twig extension.
*
* @var \Drupal\twig_tweak\TwigExtension
*/
private $twigExtension;
/**
* {@inheritdoc}
*/
public static $modules = [
'twig_tweak',
'twig_tweak_test',
'node',
'user',
'system',
];
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->installEntitySchema('node');
$this->installEntitySchema('user');
$this->installConfig(['system']);
$node_type = NodeType::create([
'type' => 'article',
'name' => 'Article',
]);
$node_type->save();
$values = [
'type' => 'article',
'status' => NodeInterface::PUBLISHED,
// @see twig_tweak_test_node_access()
'title' => 'Entity access test',
];
$this->node = Node::create($values);
$this->node->save();
$this->twigExtension = $this->container->get('twig_tweak.twig_extension');
}
/**
* Test callback.
*/
public function testDrupalEntity() {
// -- Unprivileged user.
$this->setUpCurrentUser(['name' => 'User 1']);
$build = $this->twigExtension->drupalEntity('node', $this->node->id());
self::assertNull($build);
// -- Privileged user.
$this->setUpCurrentUser(['name' => 'User 2'], ['access content']);
$build = $this->twigExtension->drupalEntity('node', $this->node->id());
self::assertArrayHasKey('#node', $build);
$expected_cache = [
'tags' => [
'node:1',
'node_view',
'tag_from_twig_tweak_test_node_access',
],
'contexts' => [
'user',
'user.permissions',
],
'max-age' => 50,
];
self::assertSame($expected_cache, $build['#cache']);
}
/**
* Test callback.
*/
public function testDrupalField() {
// -- Unprivileged user.
$this->setUpCurrentUser(['name' => 'User 1']);
$build = $this->twigExtension->drupalField('title', 'node', $this->node->id());
self::assertNull($build);
// -- Privileged user.
$this->setUpCurrentUser(['name' => 'User 2'], ['access content']);
$build = $this->twigExtension->drupalField('title', 'node', $this->node->id());
self::assertArrayHasKey('#items', $build);
$expected_cache = [
'contexts' => [
'user',
'user.permissions',
],
'tags' => [
'node:1',
'tag_from_twig_tweak_test_node_access',
],
'max-age' => 50,
];
self::assertSame($expected_cache, $build['#cache']);
}
/**
* Test callback.
*/
public function testDrupalRegion() {
// @codingStandardsIgnoreStart
$create_block = function ($id) {
return new class(['id' => $id], 'block') extends Block {
public function access($operation, AccountInterface $account = NULL, $return_as_object = FALSE) {
$result = AccessResult::allowedIf($this->id == 'block_1');
$result->cachePerUser();
$result->addCacheTags(['tag_for_' . $this->id]);
$result->setCacheMaxAge(123);
return $return_as_object ? $result : $result->isAllowed();
}
public function getPlugin() {
return NULL;
}
};
};
// @codingStandardsIgnoreEnd
$storage = $this->createMock(EntityStorageInterface::class);
$blocks = [
'block_1' => $create_block('block_1'),
'block_2' => $create_block('block_2'),
];
$storage->expects($this->any())
->method('loadByProperties')
->willReturn($blocks);
$view_builder = $this->createMock(BlockViewBuilder::class);
$content = [
'#markup' => 'foo',
'#cache' => [
'tags' => ['tag_from_view'],
],
];
$view_builder->expects($this->any())
->method('view')
->willReturn($content);
$entity_type_manager = $this->createMock(EntityTypeManagerInterface::class);
$entity_type_manager->expects($this->any())
->method('getStorage')
->willReturn($storage);
$entity_type_manager->expects($this->any())
->method('getViewBuilder')
->willReturn($view_builder);
$this->container->set('entity_type.manager', $entity_type_manager);
$build = $this->twigExtension->drupalRegion('bar');
$expected_build = [
'block_1' => [
'#markup' => 'foo',
'#cache' => [
'tags' => ['tag_from_view'],
],
],
'#cache' => [
'contexts' => ['user'],
'tags' => [
'tag_for_block_1',
'tag_for_block_2',
],
'max-age' => 123,
],
];
self::assertSame($expected_build, $build);
}
}

171
tests/src/Kernel/BlockViewBuilderTest.php

@ -1,171 +0,0 @@
<?php
namespace Drupal\Tests\twig_tweak\Kernel;
use Drupal\KernelTests\KernelTestBase;
use Drupal\Tests\user\Traits\UserCreationTrait;
/**
* A test for BlockViewBuilder.
*
* @group twig_tweak
*/
final class BlockViewBuilderTest extends KernelTestBase {
use UserCreationTrait;
/**
* {@inheritdoc}
*/
protected static $modules = [
'twig_tweak',
'twig_tweak_test',
'user',
'system',
'block',
];
/**
* Test callback.
*
* @see \Drupal\twig_tweak_test\Plugin\Block\FooBlock
*/
public function testBlockViewBuilder(): void {
$view_builder = $this->container->get('twig_tweak.block_view_builder');
// -- Default output.
$this->setUpCurrentUser(['name' => 'User 1']);
$build = $view_builder->build('twig_tweak_test_foo');
$expected_build = [
'content' => [
'#markup' => 'Foo',
'#cache' => [
'contexts' => ['url'],
'tags' => ['tag_from_build'],
],
],
'#theme' => 'block',
'#id' => NULL,
'#attributes' => [
'id' => 'foo',
],
'#contextual_links' => [],
'#configuration' => [
'id' => 'twig_tweak_test_foo',
'label' => '',
'label_display' => 'visible',
'provider' => 'twig_tweak_test',
'content' => 'Foo',
],
'#plugin_id' => 'twig_tweak_test_foo',
'#base_plugin_id' => 'twig_tweak_test_foo',
'#derivative_plugin_id' => NULL,
'#cache' => [
'contexts' => ['user'],
'tags' => ['tag_from_blockAccess'],
'max-age' => 35,
'keys' => [
'twig_tweak_block',
'twig_tweak_test_foo',
'[configuration]=04c46ea912d2866a3a36c67326da1ef38f1c93cc822d6c45e1639f3decdebbdc',
'[wrapper]=1',
],
],
];
// @todo Remove this once we drop support for Drupal 9.2.
// @see https://www.drupal.org/node/3230199
if (\version_compare(\Drupal::VERSION, '9.3.0-dev', '<')) {
$expected_build['#configuration'] = [
'id' => 'twig_tweak_test_foo',
'label' => '',
'provider' => 'twig_tweak_test',
'label_display' => 'visible',
'content' => 'Foo',
];
}
self::assertSame($expected_build, $build);
self::assertSame('<div id="foo">Foo</div>', $this->renderPlain($build));
// -- Non-default configuration.
$configuration = [
'content' => 'Bar',
'label' => 'Example',
'id' => 'example',
];
$build = $view_builder->build('twig_tweak_test_foo', $configuration);
$expected_build['content']['#markup'] = 'Bar';
$expected_build['#configuration']['label'] = 'Example';
$expected_build['#configuration']['content'] = 'Bar';
$expected_build['#configuration']['id'] = 'example';
$expected_build['#id'] = 'example';
$expected_build['#cache']['keys'] = [
'twig_tweak_block',
'twig_tweak_test_foo',
'[configuration]=8e53716fcf7e5d5c45effd55e9b2a267bbaf333f7253766f572d58e4f7991b36',
'[wrapper]=1',
];
self::assertSame($expected_build, $build);
self::assertSame('<div id="block-example"><h2>Example</h2>Bar</div>', $this->renderPlain($build));
// -- Without wrapper.
$build = $view_builder->build('twig_tweak_test_foo', [], FALSE);
$expected_build = [
'content' => [
'#markup' => 'Foo',
// Since the block is built without wrapper #attributes must remain in
// 'content' element.
'#attributes' => [
'id' => 'foo',
],
'#cache' => [
'contexts' => ['url'],
'tags' => ['tag_from_build'],
],
],
'#cache' => [
'contexts' => ['user'],
'tags' => ['tag_from_blockAccess'],
'max-age' => 35,
'keys' => [
'twig_tweak_block',
'twig_tweak_test_foo',
'[configuration]=04c46ea912d2866a3a36c67326da1ef38f1c93cc822d6c45e1639f3decdebbdc',
'[wrapper]=0',
],
],
];
self::assertSame($expected_build, $build);
self::assertSame('Foo', $this->renderPlain($build));
// -- Unprivileged user.
$this->setUpCurrentUser(['name' => 'User 2']);
$build = $view_builder->build('twig_tweak_test_foo');
$expected_build = [
'#cache' => [
'contexts' => ['user'],
'tags' => ['tag_from_blockAccess'],
'max-age' => 35,
'keys' => [
'twig_tweak_block',
'twig_tweak_test_foo',
'[configuration]=04c46ea912d2866a3a36c67326da1ef38f1c93cc822d6c45e1639f3decdebbdc',
'[wrapper]=1',
],
],
];
self::assertSame($expected_build, $build);
self::assertSame('', $this->renderPlain($build));
}
/**
* Renders a render array.
*/
private function renderPlain(array $build): string {
$renderer = $this->container->get('renderer');
return rtrim(preg_replace('#\s{2,}#', '', $renderer->renderPlain($build)));
}
}

99
tests/src/Kernel/CacheMetadataExtractorTest.php

@ -1,99 +0,0 @@
<?php
namespace Drupal\Tests\twig_tweak\Kernel;
use Drupal\Core\Cache\CacheableMetadata;
/**
* A test for Cache Metadata Extractor service.
*
* @group twig_tweak
*/
final class CacheMetadataExtractorTest extends AbstractTestCase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'twig_tweak',
];
/**
* Test callback.
*/
public function testCacheMetadataExtractor(): void {
$extractor = $this->container->get('twig_tweak.cache_metadata_extractor');
// -- Object.
$input = new CacheableMetadata();
$input->setCacheMaxAge(5);
$input->setCacheContexts(['url', 'user.permissions']);
$input->setCacheTags(['node', 'node.view']);
$build = $extractor->extractCacheMetadata($input);
$expected_build['#cache'] = [
'contexts' => ['url', 'user.permissions'],
'tags' => ['node', 'node.view'],
'max-age' => 5,
];
self::assertSame($expected_build, $build);
// -- Render array.
$input = [
'foo' => [
'#cache' => [
'tags' => ['foo', 'foo.view'],
],
'bar' => [
0 => [
'#cache' => [
'tags' => ['bar-0'],
],
],
1 => [
'#cache' => [
'tags' => ['bar-1'],
],
],
'#cache' => [
'tags' => ['bar', 'bar.view'],
'contexts' => ['url.path'],
'max-age' => 10,
],
],
],
'#cache' => [
'contexts' => ['url', 'user.permissions'],
'tags' => ['node', 'node.view'],
'max-age' => 20,
],
];
$build = $extractor->extractCacheMetadata($input);
$expected_build = [
'#cache' => [
'contexts' => ['url', 'url.path', 'user.permissions'],
'tags' => [
'bar',
'bar-0',
'bar-1',
'bar.view',
'foo',
'foo.view',
'node',
'node.view',
],
'max-age' => 10,
],
];
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);
/* @noinspection PhpParamsInspection */
$extractor->extractCacheMetadata(new \stdClass());
}
}

130
tests/src/Kernel/EntityFormViewBuilderTest.php

@ -1,130 +0,0 @@
<?php
namespace Drupal\Tests\twig_tweak\Kernel;
use Drupal\Core\Cache\Cache;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\Tests\user\Traits\UserCreationTrait;
/**
* A test for EntityFormViewBuilder.
*
* @group twig_tweak
*/
final class EntityFormViewBuilderTest extends AbstractTestCase {
use UserCreationTrait;
/**
* {@inheritdoc}
*/
protected static $modules = [
'twig_tweak',
'twig_tweak_test',
'user',
'system',
'node',
'field',
'text',
];
/**
* {@inheritdoc}
*/
public function setUp(): void {
parent::setUp();
$this->installConfig(['system']);
$this->installEntitySchema('node');
NodeType::create(['type' => 'article'])->save();
$this->setUpCurrentUser(
['name' => 'User 1'],
['edit any article content', 'access content'],
);
}
/**
* Test callback.
*/
public function testEntityFormViewBuilder(): void {
$view_builder = $this->container->get('twig_tweak.entity_form_view_builder');
$values = [
'type' => 'article',
'title' => 'Public node',
];
$public_node = Node::create($values);
$public_node->save();
$values = [
'type' => 'article',
'title' => 'Private node',
];
$private_node = Node::create($values);
$private_node->save();
// -- Default mode.
$build = $view_builder->build($public_node);
self::assertArrayHasKey('#form_id', $build);
$expected_cache = [
'contexts' => [
'user',
'user.permissions',
'user.roles:authenticated',
],
'tags' => [
'config:core.entity_form_display.node.article.default',
'node:1',
'tag_from_twig_tweak_test_node_access',
],
'max-age' => 50,
];
self::assertCache($expected_cache, $build['#cache']);
self::assertStringContainsString('<form class="node-article-form node-form" ', $this->renderPlain($build));
// -- Private node with access check.
$build = $view_builder->build($private_node);
self::assertArrayNotHasKey('#form_id', $build);
$expected_cache = [
'contexts' => [
'user',
'user.permissions',
],
'tags' => [
'node:2',
'tag_from_twig_tweak_test_node_access',
],
'max-age' => 50,
];
self::assertCache($expected_cache, $build['#cache']);
self::assertSame('', $this->renderPlain($build));
// -- Private node without access check.
$build = $view_builder->build($private_node, 'default', FALSE);
self::assertArrayHasKey('#form_id', $build);
$expected_cache = [
'contexts' => [
'user.roles:authenticated',
],
'tags' => [
'config:core.entity_form_display.node.article.default',
'node:2',
],
'max-age' => Cache::PERMANENT,
];
self::assertCache($expected_cache, $build['#cache']);
self::assertStringContainsString('<form class="node-article-form node-form" ', $this->renderPlain($build));
}
/**
* Renders a render array.
*/
private function renderPlain(array $build): string {
return $this->container->get('renderer')->renderPlain($build);
}
}

202
tests/src/Kernel/EntityViewBuilderTest.php

@ -1,202 +0,0 @@
<?php
namespace Drupal\Tests\twig_tweak\Kernel;
use Drupal\Core\Cache\Cache;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\Tests\user\Traits\UserCreationTrait;
/**
* A test for EntityViewBuilder.
*
* @group twig_tweak
*/
final class EntityViewBuilderTest extends AbstractTestCase {
use UserCreationTrait;
/**
* {@inheritdoc}
*/
protected static $modules = [
'twig_tweak',
'twig_tweak_test',
'user',
'system',
'node',
'field',
'text',
];
/**
* {@inheritdoc}
*/
public function setUp(): void {
parent::setUp();
$this->installConfig(['system', 'node']);
$this->installEntitySchema('node');
NodeType::create(['type' => 'article'])->save();
$this->setUpCurrentUser([], ['access content']);
}
/**
* Test callback.
*/
public function testEntityViewBuilder(): void {
$view_builder = $this->container->get('twig_tweak.entity_view_builder');
$values = [
'type' => 'article',
'title' => 'Public node',
];
$public_node = Node::create($values);
$public_node->save();
$values = [
'type' => 'article',
'title' => 'Private node',
];
$private_node = Node::create($values);
$private_node->save();
// -- Full mode.
$build = $view_builder->build($public_node);
self::assertArrayHasKey('#node', $build);
$expected_cache = [
'tags' => [
'node:1',
'node_view',
'tag_from_twig_tweak_test_node_access',
],
'contexts' => [
'user',
'user.permissions',
],
'max-age' => 50,
'keys' => [
'entity_view',
'node',
'1',
'full',
],
'bin' => 'render',
];
self::assertCache($expected_cache, $build['#cache']);
$expected_html = <<< 'HTML'
<article>
<h2><a href="/node/1" rel="bookmark"><span>Public node</span></a></h2>
<div></div>
</article>
HTML;
$actual_html = $this->renderPlain($build);
self::assertSame(self::normalizeHtml($expected_html), self::normalizeHtml($actual_html));
// -- Teaser mode.
$build = $view_builder->build($public_node, 'teaser');
self::assertArrayHasKey('#node', $build);
$expected_cache = [
'tags' => [
'node:1',
'node_view',
'tag_from_twig_tweak_test_node_access',
],
'contexts' => [
'user',
'user.permissions',
],
'max-age' => 50,
'keys' => [
'entity_view',
'node',
'1',
'teaser',
],
'bin' => 'render',
];
self::assertCache($expected_cache, $build['#cache']);
$expected_html = <<< 'HTML'
<article>
<h2><a href="/node/1" rel="bookmark"><span>Public node</span></a></h2>
<div>
<ul class="links inline">
<li>
<a href="/node/1" rel="tag" title="Public node" hreflang="en">
Read more<span class="visually-hidden"> about Public node</span>
</a>
</li>
</ul>
</div>
</article>
HTML;
$actual_html = $this->renderPlain($build);
self::assertSame(self::normalizeHtml($expected_html), self::normalizeHtml($actual_html));
// -- Private node with access check.
$build = $view_builder->build($private_node);
self::assertArrayNotHasKey('#node', $build);
$expected_cache = [
'contexts' => [
'user',
'user.permissions',
],
'tags' => [
'node:2',
'tag_from_twig_tweak_test_node_access',
],
'max-age' => 50,
];
self::assertCache($expected_cache, $build['#cache']);
self::assertSame('', $this->renderPlain($build));
// -- Private node without access check.
$build = $view_builder->build($private_node, 'full', NULL, FALSE);
self::assertArrayHasKey('#node', $build);
$expected_cache = [
'tags' => [
'node:2',
'node_view',
],
'contexts' => [],
'max-age' => Cache::PERMANENT,
'keys' => [
'entity_view',
'node',
'2',
'full',
],
'bin' => 'render',
];
self::assertCache($expected_cache, $build['#cache']);
$expected_html = <<< 'HTML'
<article>
<h2><a href="/node/2" rel="bookmark"><span>Private node</span></a></h2>
<div></div>
</article>
HTML;
$actual_html = $this->renderPlain($build);
self::assertSame(self::normalizeHtml($expected_html), self::normalizeHtml($actual_html));
}
/**
* Renders a render array.
*/
private function renderPlain(array $build): string {
$actual_html = $this->container->get('renderer')->renderPlain($build);
$actual_html = preg_replace('#<footer>.+</footer>#s', '', $actual_html);
return $actual_html;
}
/**
* Normalizes the provided HTML.
*/
private static function normalizeHtml(string $html): string {
return rtrim(preg_replace(['#\s{2,}#', '#\n#'], '', $html));
}
}

140
tests/src/Kernel/FieldViewBuilderTest.php

@ -1,140 +0,0 @@
<?php
namespace Drupal\Tests\twig_tweak\Kernel;
use Drupal\Core\Cache\Cache;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\Tests\user\Traits\UserCreationTrait;
/**
* A test for FieldViewBuilder.
*
* @group twig_tweak
*/
final class FieldViewBuilderTest extends AbstractTestCase {
use UserCreationTrait;
/**
* {@inheritdoc}
*/
protected static $modules = [
'twig_tweak',
'twig_tweak_test',
'user',
'system',
'node',
];
/**
* {@inheritdoc}
*/
public function setUp(): void {
parent::setUp();
$this->installEntitySchema('node');
$this->setUpCurrentUser(['name' => 'User 1'], ['access content']);
NodeType::create(['type' => 'article'])->save();
}
/**
* Test callback.
*/
public function testFieldViewBuilder(): void {
$view_builder = $this->container->get('twig_tweak.field_view_builder');
$values = [
'type' => 'article',
'title' => 'Public node',
];
$public_node = Node::create($values);
$public_node->save();
$values = [
'type' => 'article',
'title' => 'Private node',
];
$private_node = Node::create($values);
$private_node->save();
// -- Full mode.
$build = $view_builder->build($public_node, 'title');
self::assertArrayHasKey(0, $build);
$expected_cache = [
'contexts' => [
'user',
'user.permissions',
],
'tags' => [
'node:1',
'tag_from_twig_tweak_test_node_access',
],
'max-age' => 50,
];
self::assertCache($expected_cache, $build['#cache']);
self::assertSame('<span>Public node</span>', $this->renderPlain($build));
// -- Custom mode.
$build = $view_builder->build($public_node, 'title', ['settings' => ['link_to_entity' => TRUE]]);
self::assertArrayHasKey(0, $build);
$expected_cache = [
'contexts' => [
'user',
'user.permissions',
],
'tags' => [
'node:1',
'tag_from_twig_tweak_test_node_access',
],
'max-age' => 50,
];
self::assertCache($expected_cache, $build['#cache']);
$expected_html = '<span><a href="/node/1" hreflang="en">Public node</a></span>';
self::assertSame($expected_html, $this->renderPlain($build));
// -- Private node with access check.
$build = $view_builder->build($private_node, 'title');
self::assertArrayNotHasKey(0, $build);
$expected_cache = [
'contexts' => [
'user',
'user.permissions',
],
'tags' => [
'node:2',
'tag_from_twig_tweak_test_node_access',
],
'max-age' => 50,
];
self::assertCache($expected_cache, $build['#cache']);
self::assertSame('', $this->renderPlain($build));
// -- Private node without access check.
$build = $view_builder->build($private_node, 'title', 'full', NULL, FALSE);
self::assertArrayHasKey(0, $build);
$expected_cache = [
'contexts' => [],
'tags' => ['node:2'],
'max-age' => Cache::PERMANENT,
];
self::assertSame($expected_cache, $build['#cache']);
self::assertSame('<span>Private node</span>', $this->renderPlain($build));
}
/**
* Renders a render array.
*/
private function renderPlain(array $build): string {
$actual_html = $this->container->get('renderer')->renderPlain($build);
$actual_html = preg_replace('#<footer>.+</footer>#s', '', $actual_html);
$actual_html = preg_replace(['#\s{2,}#', '#\n#'], '', $actual_html);
return $actual_html;
}
}

257
tests/src/Kernel/ImageViewBuilderTest.php

@ -1,257 +0,0 @@
<?php
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.
*
* @group twig_tweak
*/
final class ImageViewBuilderTest extends AbstractTestCase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'twig_tweak',
'twig_tweak_test',
'user',
'system',
'file',
'image',
'responsive_image',
'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();
}
/**
* {@inheritdoc}
*/
public function register(ContainerBuilder $container) {
parent::register($container);
$container->register('stream_wrapper.private', 'Drupal\Core\StreamWrapper\PrivateStream')
->addTag('stream_wrapper', ['scheme' => 'private']);
}
/**
* 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);
// -- Without style.
$build = $view_builder->build($this->publicImage);
$expected_build = [
'#uri' => $this->publicImageUri,
'#attributes' => [],
'#theme' => 'image',
'#cache' => [
'contexts' => [
'user',
'user.permissions',
],
'tags' => [
'file:1',
'tag_for_' . $this->publicImageUri,
],
'max-age' => 70,
],
];
self::assertRenderArray($expected_build, $build);
self::assertSame('<img src="/files/image-test-do.jpg" alt="" />', $this->renderPlain($build));
// -- With style.
$build = $view_builder->build($this->publicImage, 'small', ['alt' => 'Image Test Do']);
$expected_build = [
'#uri' => $this->publicImageUri,
'#attributes' => ['alt' => 'Image Test Do'],
'#width' => $imageOriginalWidth,
'#height' => $imageOriginalHeight,
'#theme' => 'image_style',
'#style_name' => 'small',
'#cache' => [
'contexts' => [
'user',
'user.permissions',
],
'tags' => [
'file:1',
'tag_for_' . $this->publicImageUri,
],
'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));
// -- With responsive style.
$build = $view_builder->build($this->publicImage, 'wide', ['alt' => 'Image Test Do'], TRUE);
$expected_build = [
'#uri' => $this->publicImageUri,
'#attributes' => ['alt' => 'Image Test Do'],
'#width' => $imageOriginalWidth,
'#height' => $imageOriginalHeight,
'#type' => 'responsive_image',
'#responsive_image_style_id' => 'wide',
'#cache' => [
'contexts' => [
'user',
'user.permissions',
],
'tags' => [
'file:1',
'tag_for_' . $this->publicImageUri,
],
'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));
// -- Private image with access check.
$build = $view_builder->build($this->privateImage);
$expected_build = [
'#cache' => [
'contexts' => ['user'],
'tags' => [
'file:2',
'tag_for_' . $this->privateImageUri,
],
'max-age' => 70,
],
];
self::assertRenderArray($expected_build, $build);
self::assertSame('', $this->renderPlain($build));
// -- Private image without access check.
$build = $view_builder->build($this->privateImage, NULL, [], FALSE, FALSE);
$expected_build = [
'#uri' => $this->privateImageUri,
'#attributes' => [],
'#theme' => 'image',
'#cache' => [
'contexts' => [],
'tags' => ['file:2'],
'max-age' => Cache::PERMANENT,
],
];
self::assertRenderArray($expected_build, $build);
self::assertSame('<img src="/files/image-test-do.png" alt="" />', $this->renderPlain($build));
}
/**
* Renders a render array.
*/
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(['#\s{2,}#', '#\n#'], '', $html);
return rtrim($html);
}
}

124
tests/src/Kernel/MenuViewBuilderTest.php

@ -1,124 +0,0 @@
<?php
namespace Drupal\Tests\twig_tweak\Kernel;
use Drupal\KernelTests\KernelTestBase;
use Drupal\menu_link_content\Entity\MenuLinkContent;
/**
* A test for MenuViewBuilder.
*
* @group twig_tweak
*/
final class MenuViewBuilderTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'twig_tweak',
'user',
'system',
'link',
'menu_link_content',
];
/**
* {@inheritdoc}
*/
public function setUp(): void {
parent::setUp();
$this->installEntitySchema('menu_link_content');
$this->container->get('entity_type.manager')
->getStorage('menu')
->create([
'id' => 'test-menu',
'label' => 'Test menu',
'description' => 'Description text.',
])
->save();
$link_1 = MenuLinkContent::create([
'expanded' => TRUE,
'title' => 'Link 1',
'link' => ['uri' => 'internal:/foo/1'],
'menu_name' => 'test-menu',
]);
$link_1->save();
MenuLinkContent::create([
'title' => 'Link 1.1',
'link' => ['uri' => 'internal:/foo/1/1'],
'menu_name' => 'test-menu',
'parent' => $link_1->getPluginId(),
])->save();
MenuLinkContent::create([
'title' => 'Link 2',
'link' => ['uri' => 'internal:/foo/2'],
'menu_name' => 'test-menu',
])->save();
}
/**
* Test callback.
*/
public function testMenuViewBuilder(): void {
$view_builder = $this->container->get('twig_tweak.menu_view_builder');
$build = $view_builder->build('test-menu');
$expected_output = <<< 'HTML'
<ul>
<li>
<a href="/foo/1">Link 1</a>
<ul>
<li>
<a href="/foo/1/1">Link 1.1</a>
</li>
</ul>
</li>
<li>
<a href="/foo/2">Link 2</a>
</li>
</ul>
HTML;
$this->assertMarkup($expected_output, $build);
$build = $view_builder->build('test-menu', 2);
$expected_output = <<< 'HTML'
<ul>
<li>
<a href="/foo/1/1">Link 1.1</a>
</li>
</ul>
HTML;
$this->assertMarkup($expected_output, $build);
$build = $view_builder->build('test-menu', 1, 1);
$expected_output = <<< 'HTML'
<ul>
<li>
<a href="/foo/1">Link 1</a>
</li>
<li>
<a href="/foo/2">Link 2</a>
</li>
</ul>
HTML;
$this->assertMarkup($expected_output, $build);
}
/**
* Asserts menu markup.
*/
private function assertMarkup(string $expected_markup, array $build): void {
$expected_markup = preg_replace('#\s{2,}#', '', $expected_markup);
$renderer = $this->container->get('renderer');
$actual_markup = preg_replace('#\s{2,}#', '', $renderer->renderPlain($build));
self::assertSame($expected_markup, $actual_markup);
}
}

154
tests/src/Kernel/RegionViewBuilderTest.php

@ -1,154 +0,0 @@
<?php
namespace Drupal\Tests\twig_tweak\Kernel;
use Drupal\block\Entity\Block;
use Drupal\Component\Utility\Html;
use Drupal\Core\Cache\Cache;
use Drupal\Tests\user\Traits\UserCreationTrait;
/**
* A test for RegionViewBuilder.
*
* @group twig_tweak
*/
final class RegionViewBuilderTest extends AbstractTestCase {
use UserCreationTrait;
/**
* {@inheritdoc}
*/
protected static $modules = [
'twig_tweak',
'twig_tweak_test',
'user',
'system',
'block',
];
/**
* {@inheritdoc}
*/
public function setUp(): void {
parent::setUp();
$this->installEntitySchema('block');
$this->container->get('theme_installer')->install(['stark']);
$values = [
'id' => 'public_block',
'plugin' => 'system_powered_by_block',
'theme' => 'stark',
'region' => 'sidebar_first',
];
Block::create($values)->save();
$values = [
'id' => 'private_block',
'plugin' => 'system_powered_by_block',
'theme' => 'stark',
'region' => 'sidebar_first',
];
Block::create($values)->save();
}
/**
* Test callback.
*/
public function testRegionViewBuilder(): void {
$view_builder = $this->container->get('twig_tweak.region_view_builder');
$renderer = $this->container->get('renderer');
$build = $view_builder->build('sidebar_first');
// The build should be empty because 'stark' is not a default theme.
$expected_build = [
'#cache' => [
'contexts' => [],
'tags' => ['config:block_list'],
'max-age' => Cache::PERMANENT,
],
];
self::assertSame($expected_build, $build);
// Specify the theme name explicitly.
$build = $view_builder->build('sidebar_first', 'stark');
$expected_build = [
// Only public_block should be rendered.
// @see twig_tweak_test_block_access()
'public_block' => [
'#cache' =>
[
'keys' => [
'entity_view',
'block',
'public_block',
],
'contexts' => [],
'tags' => [
'block_view',
'config:block.block.public_block',
],
'max-age' => Cache::PERMANENT,
],
'#weight' => NULL,
'#lazy_builder' => [
'Drupal\\block\\BlockViewBuilder::lazyBuilder',
[
'public_block',
'full',
NULL,
],
],
],
'#region' => 'sidebar_first',
'#theme_wrappers' => ['region'],
// Even if the block is not accessible its cache metadata from access
// callback should be here.
'#cache' => [
'contexts' => ['user'],
'tags' => [
'config:block.block.public_block',
'config:block_list',
'tag_for_private_block',
'tag_for_public_block',
],
'max-age' => 123,
],
];
self::assertRenderArray($expected_build, $build);
$expected_html = <<< 'HTML'
<div>
<div id="block-public-block">
<span>Powered by <a href="https://www.drupal.org">Drupal</a></span>
</div>
</div>
HTML;
$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
// 'theme' argument returns the same result.
$this->container->get('config.factory')
->getEditable('system.theme')
->set('default', 'stark')
->save();
$build = $view_builder->build('sidebar_first');
self::assertRenderArray($expected_build, $build);
Html::resetSeenIds();
$actual_html = $renderer->renderPlain($expected_build);
self::assertSame(self::normalizeHtml($expected_html), self::normalizeHtml($actual_html));
}
/**
* Normalizes the provided HTML.
*/
private static function normalizeHtml(string $html): string {
return rtrim(preg_replace(['#\s{2,}#', '#\n#'], '', $html));
}
}

61
tests/src/Kernel/UriExtractorTest.php

@ -1,61 +0,0 @@
<?php
namespace Drupal\Tests\twig_tweak\Kernel;
/**
* A test for URI extractor service.
*
* @group twig_tweak
*/
final class UriExtractorTest extends AbstractExtractorTestCase {
/**
* Test callback.
*/
public function testUriExtractor(): void {
$extractor = $this->container->get('twig_tweak.uri_extractor');
$url = $extractor->extractUri(NULL);
self::assertNull($url);
$url = $extractor->extractUri($this->node);
self::assertNull($url);
$url = $extractor->extractUri($this->node->get('title'));
self::assertNull($url);
$url = $extractor->extractUri($this->node->get('field_image')[0]);
self::assertSame('public://image-test.png', $url);
$url = $extractor->extractUri($this->node->get('field_image')[1]);
self::assertNull($url);
$url = $extractor->extractUri($this->node->get('field_image'));
self::assertSame('public://image-test.png', $url);
$url = $extractor->extractUri($this->node->get('field_image')->entity);
self::assertSame('public://image-test.png', $url);
$this->node->get('field_image')->removeItem(0);
$url = $extractor->extractUri($this->node->get('field_image'));
self::assertNull($url);
$url = $extractor->extractUri($this->node->get('field_media')[0]);
self::assertSame('public://image-test.gif', $url);
$url = $extractor->extractUri($this->node->get('field_media')[1]);
self::assertNull($url);
$url = $extractor->extractUri($this->node->get('field_media'));
self::assertSame('public://image-test.gif', $url);
$url = $extractor->extractUri($this->node->get('field_media')->entity);
self::assertSame('public://image-test.gif', $url);
$this->node->get('field_media')->removeItem(0);
$url = $extractor->extractUri($this->node->get('field_media'));
self::assertNull($url);
}
}

87
tests/src/Kernel/UrlExtractorTest.php

@ -1,87 +0,0 @@
<?php
namespace Drupal\Tests\twig_tweak\Kernel;
/**
* A test for URL Extractor service.
*
* @group twig_tweak
*/
final class UrlExtractorTest extends AbstractExtractorTestCase {
/**
* Test callback.
*/
public function testUrlExtractor(): void {
$extractor = $this->container->get('twig_tweak.url_extractor');
$base_url = \Drupal::service('file_url_generator')->generateAbsoluteString('');
$request = \Drupal::request();
$absolute_url = "{$request->getScheme()}://{$request->getHost()}/foo/bar.txt";
$url = $extractor->extractUrl($absolute_url);
self::assertSame('/foo/bar.txt', $url);
$url = $extractor->extractUrl($absolute_url, FALSE);
self::assertSame($base_url . 'foo/bar.txt', $url);
$url = $extractor->extractUrl('foo/bar.jpg');
self::assertSame('/foo/bar.jpg', $url);
$url = $extractor->extractUrl('foo/bar.jpg', FALSE);
self::assertSame($base_url . 'foo/bar.jpg', $url);
$url = $extractor->extractUrl('');
self::assertSame('/', $url);
$url = $extractor->extractUrl('', FALSE);
self::assertSame($base_url, $url);
$url = $extractor->extractUrl(NULL);
self::assertNull($url);
$url = $extractor->extractUrl($this->node);
self::assertNull($url);
$url = $extractor->extractUrl($this->node->get('title'));
self::assertNull($url);
$url = $extractor->extractUrl($this->node->get('field_image')[0]);
self::assertStringEndsWith('/files/image-test.png', $url);
self::assertStringNotContainsString($base_url, $url);
$url = $extractor->extractUrl($this->node->get('field_image')[0], FALSE);
self::assertStringStartsWith($base_url, $url);
self::assertStringEndsWith('/files/image-test.png', $url);
$url = $extractor->extractUrl($this->node->get('field_image')[1]);
self::assertNull($url);
$url = $extractor->extractUrl($this->node->get('field_image'));
self::assertStringEndsWith('/files/image-test.png', $url);
$url = $extractor->extractUrl($this->node->get('field_image')->entity);
self::assertStringEndsWith('/files/image-test.png', $url);
$this->node->get('field_image')->removeItem(0);
$url = $extractor->extractUrl($this->node->get('field_image'));
self::assertNull($url);
$url = $extractor->extractUrl($this->node->get('field_media')[0]);
self::assertStringEndsWith('/files/image-test.gif', $url);
$url = $extractor->extractUrl($this->node->get('field_media')[1]);
self::assertNull($url);
$url = $extractor->extractUrl($this->node->get('field_media'));
self::assertStringEndsWith('/files/image-test.gif', $url);
$url = $extractor->extractUrl($this->node->get('field_media')->entity);
self::assertStringEndsWith('/files/image-test.gif', $url);
$this->node->get('field_media')->removeItem(0);
$url = $extractor->extractUrl($this->node->get('field_media'));
self::assertNull($url);
}
}

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: module:
- system - system
theme: theme:
- claro - classy
id: claro_powered_by_drupal id: classy_powered_by_drupal
theme: claro theme: classy
region: highlighted region: sidebar_first
weight: 0 weight: 20
provider: null provider: null
plugin: system_powered_by_block plugin: system_powered_by_block
settings: 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: { }

80
tests/twig_tweak_test/config/install/core.entity_form_display.node.page.default.yml

@ -1,80 +0,0 @@
langcode: en
status: true
dependencies:
config:
- field.field.node.page.body
- field.field.node.page.field_image
- field.field.node.page.field_media
- node.type.page
module:
- path
- text
id: node.page.default
targetEntityType: node
bundle: page
mode: default
content:
body:
type: text_textarea_with_summary
weight: 31
region: content
settings:
rows: 9
summary_rows: 3
placeholder: ''
show_summary: false
third_party_settings: { }
created:
type: datetime_timestamp
weight: 10
region: content
settings: { }
third_party_settings: { }
path:
type: path
weight: 30
region: content
settings: { }
third_party_settings: { }
promote:
type: boolean_checkbox
settings:
display_label: true
weight: 15
region: content
third_party_settings: { }
status:
type: boolean_checkbox
settings:
display_label: true
weight: 120
region: content
third_party_settings: { }
sticky:
type: boolean_checkbox
settings:
display_label: true
weight: 16
region: content
third_party_settings: { }
title:
type: string_textfield
weight: -5
region: content
settings:
size: 60
placeholder: ''
third_party_settings: { }
uid:
type: entity_reference_autocomplete
weight: 5
region: content
settings:
match_operator: CONTAINS
match_limit: 10
size: 60
placeholder: ''
third_party_settings: { }
hidden:
field_image: true
field_media: true

28
tests/twig_tweak_test/config/install/core.entity_view_display.media.image.default.yml

@ -1,28 +0,0 @@
langcode: en
status: true
dependencies:
config:
- field.field.media.image.field_media_image
- image.style.large
- media.type.image
module:
- image
id: media.image.default
targetEntityType: media
bundle: image
mode: default
content:
field_media_image:
label: visually_hidden
settings:
image_style: large
image_link: ''
third_party_settings: { }
type: image
weight: 1
region: content
hidden:
created: true
name: true
thumbnail: true
uid: true

27
tests/twig_tweak_test/config/install/core.entity_view_display.media.remote_video.default.yml

@ -1,27 +0,0 @@
langcode: en
status: true
dependencies:
config:
- field.field.media.remote_video.field_media_oembed_video
- media.type.remote_video
module:
- media
id: media.remote_video.default
targetEntityType: media
bundle: remote_video
mode: default
content:
field_media_oembed_video:
type: oembed
weight: 0
label: hidden
settings:
max_width: 0
max_height: 0
third_party_settings: { }
region: content
hidden:
created: true
name: true
thumbnail: true
uid: true

29
tests/twig_tweak_test/config/install/core.entity_view_display.node.page.default.yml

@ -1,29 +0,0 @@
langcode: en
status: true
dependencies:
config:
- field.field.node.page.body
- field.field.node.page.field_image
- field.field.node.page.field_media
- node.type.page
module:
- text
- user
id: node.page.default
targetEntityType: node
bundle: page
mode: default
content:
body:
label: hidden
type: text_default
weight: 100
region: content
settings: { }
third_party_settings: { }
links:
weight: 101
region: content
hidden:
field_image: true
field_media: true

31
tests/twig_tweak_test/config/install/core.entity_view_display.node.page.teaser.yml

@ -1,31 +0,0 @@
langcode: en
status: true
dependencies:
config:
- core.entity_view_mode.node.teaser
- field.field.node.page.body
- field.field.node.page.field_image
- field.field.node.page.field_media
- node.type.page
module:
- text
- user
id: node.page.teaser
targetEntityType: node
bundle: page
mode: teaser
content:
body:
label: hidden
type: text_summary_or_trimmed
weight: 100
region: content
settings:
trim_length: 600
third_party_settings: { }
links:
weight: 101
region: content
hidden:
field_image: true
field_media: true

40
tests/twig_tweak_test/config/install/field.field.media.image.field_media_image.yml

@ -1,40 +0,0 @@
langcode: en
status: true
dependencies:
config:
- field.storage.media.field_media_image
- media.type.image
enforced:
module:
- media
module:
- image
id: media.image.field_media_image
field_name: field_media_image
entity_type: media
bundle: image
label: Image
description: ''
required: true
translatable: true
default_value: { }
default_value_callback: ''
settings:
alt_field: true
alt_field_required: true
title_field: false
title_field_required: false
max_resolution: ''
min_resolution: ''
default_image:
uuid: null
alt: ''
title: ''
width: null
height: null
file_directory: '[date:custom:Y]-[date:custom:m]'
file_extensions: 'png gif jpg jpeg'
max_filesize: ''
handler: 'default:file'
handler_settings: { }
field_type: image

18
tests/twig_tweak_test/config/install/field.field.media.remote_video.field_media_oembed_video.yml

@ -1,18 +0,0 @@
langcode: en
status: true
dependencies:
config:
- field.storage.media.field_media_oembed_video
- media.type.remote_video
id: media.remote_video.field_media_oembed_video
field_name: field_media_oembed_video
entity_type: media
bundle: remote_video
label: 'Video URL'
description: ''
required: true
translatable: true
default_value: { }
default_value_callback: ''
settings: { }
field_type: string

22
tests/twig_tweak_test/config/install/field.field.node.page.body.yml

@ -1,22 +0,0 @@
langcode: en
status: true
dependencies:
config:
- field.storage.node.body
- node.type.page
module:
- text
id: node.page.body
field_name: body
entity_type: node
bundle: page
label: Body
description: ''
required: false
translatable: true
default_value: { }
default_value_callback: ''
settings:
display_summary: true
required_summary: false
field_type: text_with_summary

37
tests/twig_tweak_test/config/install/field.field.node.page.field_image.yml

@ -1,37 +0,0 @@
langcode: en
status: true
dependencies:
config:
- field.storage.node.field_image
- node.type.page
module:
- image
id: node.page.field_image
field_name: field_image
entity_type: node
bundle: page
label: Image
description: ''
required: false
translatable: true
default_value: { }
default_value_callback: ''
settings:
file_directory: '[date:custom:Y]-[date:custom:m]'
file_extensions: 'png gif jpg jpeg'
max_filesize: ''
max_resolution: ''
min_resolution: ''
alt_field: true
alt_field_required: true
title_field: false
title_field_required: false
default_image:
uuid: ''
alt: ''
title: ''
width: null
height: null
handler: 'default:file'
handler_settings: { }
field_type: image

29
tests/twig_tweak_test/config/install/field.field.node.page.field_media.yml

@ -1,29 +0,0 @@
langcode: en
status: true
dependencies:
config:
- field.storage.node.field_media
- media.type.image
- media.type.remote_video
- node.type.page
id: node.page.field_media
field_name: field_media
entity_type: node
bundle: page
label: Media
description: ''
required: false
translatable: true
default_value: { }
default_value_callback: ''
settings:
handler: 'default:media'
handler_settings:
target_bundles:
image: image
remote_video: remote_video
sort:
field: _none
auto_create: false
auto_create_bundle: image
field_type: entity_reference

32
tests/twig_tweak_test/config/install/field.storage.media.field_media_image.yml

@ -1,32 +0,0 @@
langcode: en
status: true
dependencies:
enforced:
module:
- media
module:
- file
- image
- media
id: media.field_media_image
field_name: field_media_image
entity_type: media
type: image
settings:
default_image:
uuid: null
alt: ''
title: ''
width: null
height: null
target_type: file
display_field: false
display_default: false
uri_scheme: public
module: image
locked: false
cardinality: 1
translatable: true
indexes: { }
persist_with_no_fields: false
custom_storage: false

20
tests/twig_tweak_test/config/install/field.storage.media.field_media_oembed_video.yml

@ -1,20 +0,0 @@
langcode: en
status: true
dependencies:
module:
- media
id: media.field_media_oembed_video
field_name: field_media_oembed_video
entity_type: media
type: string
settings:
max_length: 255
is_ascii: false
case_sensitive: false
module: core
locked: false
cardinality: 1
translatable: true
indexes: { }
persist_with_no_fields: false
custom_storage: false

31
tests/twig_tweak_test/config/install/field.storage.node.field_image.yml

@ -1,31 +0,0 @@
langcode: en
status: true
dependencies:
module:
- file
- image
- node
id: node.field_image
field_name: field_image
entity_type: node
type: image
settings:
uri_scheme: public
default_image:
uuid: null
alt: ''
title: ''
width: null
height: null
target_type: file
display_field: false
display_default: false
module: image
locked: false
cardinality: 1
translatable: true
indexes:
target_id:
- target_id
persist_with_no_fields: false
custom_storage: false

19
tests/twig_tweak_test/config/install/field.storage.node.field_media.yml

@ -1,19 +0,0 @@
langcode: en
status: true
dependencies:
module:
- media
- node
id: node.field_media
field_name: field_media
entity_type: node
type: entity_reference
settings:
target_type: media
module: core
locked: false
cardinality: 1
translatable: true
indexes: { }
persist_with_no_fields: false
custom_storage: false

4
tests/twig_tweak_test/config/install/filter.format.twig_tweak_test.yml

@ -1,7 +1,7 @@
langcode: en langcode: en
status: true status: true
dependencies: { } dependencies: {}
name: 'Twig tweak test' name: Twig tweak test
format: twig_tweak_test format: twig_tweak_test
weight: 0 weight: 0
filters: filters:

13
tests/twig_tweak_test/config/install/media.type.image.yml

@ -1,13 +0,0 @@
langcode: en
status: true
dependencies: { }
id: image
label: Image
description: 'Use local images for reusable media.'
source: image
queue_thumbnail_downloads: false
new_revision: true
source_configuration:
source_field: field_media_image
field_map:
name: name

17
tests/twig_tweak_test/config/install/media.type.remote_video.yml

@ -1,17 +0,0 @@
langcode: en
status: true
dependencies: { }
id: remote_video
label: 'Remote video'
description: 'A remotely hosted video from YouTube or Vimeo.'
source: 'oembed:video'
queue_thumbnail_downloads: false
new_revision: true
source_configuration:
thumbnails_directory: 'public://oembed_thumbnails'
providers:
- YouTube
- Vimeo
source_field: field_media_oembed_video
field_map:
title: name

12
tests/twig_tweak_test/config/install/node.type.page.yml

@ -1,12 +0,0 @@
langcode: en
status: true
dependencies: { }
_core:
default_config_hash: KuyA4NHPXcmKAjRtwa0vQc2ZcyrUJy6IlS2TAyMNRbc
name: 'Basic page'
type: page
description: 'Use <em>basic pages</em> for your static content, such as an ''About us'' page.'
help: ''
new_revision: true
preview_mode: 1
display_submitted: false

4
tests/twig_tweak_test/config/install/system.menu.twig-tweak-test.yml

@ -1,7 +1,7 @@
langcode: en langcode: en
status: true status: true
dependencies: { } dependencies: {}
id: twig-tweak-test id: twig-tweak-test
label: 'Twig tweak test' label: Twig tweak test
description: '' description: ''
locked: false locked: false

3
tests/twig_tweak_test/config/install/views.view.twig_tweak_test.yml

@ -11,6 +11,7 @@ description: ''
tag: '' tag: ''
base_table: node_field_data base_table: node_field_data
base_field: nid base_field: nid
core: 8.x
display: display:
default: default:
display_plugin: default display_plugin: default
@ -180,7 +181,7 @@ display:
position: 1 position: 1
display_options: display_options:
display_extenders: { } display_extenders: { }
path: example path: twig-tweak-test
cache_metadata: cache_metadata:
max-age: -1 max-age: -1
contexts: contexts:

17
tests/twig_tweak_test/src/Controller/TwigTweakTestController.php

@ -1,17 +0,0 @@
<?php
namespace Drupal\twig_tweak_test\Controller;
/**
* Returns responses for Twig Tweak Test routes.
*/
final class TwigTweakTestController {
/**
* Builds the response.
*/
public function build(): array {
return ['#theme' => 'twig_tweak_test'];
}
}

54
tests/twig_tweak_test/src/Plugin/Block/FooBlock.php

@ -1,54 +0,0 @@
<?php
namespace Drupal\twig_tweak_test\Plugin\Block;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\Session\AccountInterface;
/**
* Provides a foo block.
*
* @Block(
* id = "twig_tweak_test_foo",
* admin_label = @Translation("Foo"),
* category = @Translation("Twig Tweak")
* )
*/
final class FooBlock extends BlockBase {
/**
* {@inheritdoc}
*/
public function defaultConfiguration(): array {
return ['content' => 'Foo'];
}
/**
* {@inheritdoc}
*/
protected function blockAccess(AccountInterface $account): AccessResult {
$result = AccessResult::allowedIf($account->getAccountName() == 'User 1');
$result->addCacheTags(['tag_from_' . __FUNCTION__]);
$result->setCacheMaxAge(35);
$result->cachePerUser();
return $result;
}
/**
* {@inheritdoc}
*/
public function build(): array {
return [
'#markup' => $this->getConfiguration()['content'],
'#attributes' => [
'id' => 'foo',
],
'#cache' => [
'contexts' => ['url'],
'tags' => ['tag_from_' . __FUNCTION__],
],
];
}
}

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

@ -1,16 +1,8 @@
{% set image_attributes = {style: 'width: 30px; height 30px;'} %}
<style> <style>
main {
background-color: lightyellow;
padding: 15px;
border: double 3px darkgrey;
}
.tt-test > div { .tt-test > div {
margin: 15px auto; margin: 15px;
padding: 10px; padding: 10px;
outline: solid 2px dodgerblue; outline: solid 2px dodgerblue;
max-width: 1200px;
background-color: white;
} }
.tt-test > div::before { .tt-test > div::before {
content: attr(class); content: attr(class);
@ -27,93 +19,26 @@
<div class="tt-view-default">{{ drupal_view('twig_tweak_test') }}</div> <div class="tt-view-default">{{ drupal_view('twig_tweak_test') }}</div>
<div class="tt-view-page_1">{{ drupal_view('twig_tweak_test', 'page_1') }}</div> <div class="tt-view-page_1">{{ drupal_view('twig_tweak_test', 'page_1') }}</div>
<div class="tt-view-page_1-with-argument">{{ drupal_view('twig_tweak_test', 'page_1', 1) }}</div> <div class="tt-view-page_1-with-argument">{{ drupal_view('twig_tweak_test', 'page_1', 1) }}</div>
<div class="tt-view-result">{{ drupal_view_result('twig_tweak_test', 'page_1')|length }}</div> <div class="tt-block">{{ drupal_block('classy_powered_by_drupal') }}</div>
<div class="tt-block">{{ drupal_block('system_branding_block', {use_site_name: false}, false) }}</div> <div class="tt-region">{{ drupal_region('sidebar_first') }}</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-entity-default">{{ drupal_entity('node', 1) }}</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-teaser">{{ drupal_entity('node', 1, 'teaser') }}</div>
<div class="tt-entity-uuid">{{ drupal_entity('node', 'ad1b902a-344f-41d1-8c61-a69f0366dbfa') }}</div> <div class="tt-entity-from-url">{{ drupal_entity('node') }}</div>
<div class="tt-entity-uuid-missing">{{ drupal_entity('node', 'zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz') }}</div>
<div class="tt-entity-add-form">{{ drupal_entity_form('node', values={type: 'page'}) }}</div>
<div class="tt-entity-edit-form">{{ drupal_entity_form('node', 1) }}</div>
<div class="tt-field">{{ drupal_field('body', 'node', 1) }}</div> <div class="tt-field">{{ drupal_field('body', 'node', 1) }}</div>
<div class="tt-menu-default">{{ drupal_menu('twig-tweak-test') }}</div> <div class="tt-menu-default">{{ drupal_menu('twig-tweak-test') }}</div>
<div class="tt-menu-level">{{ drupal_menu('twig-tweak-test', 2) }}</div> <div class="tt-menu-level">{{ drupal_menu('twig-tweak-test', 2) }}</div>
<div class="tt-menu-depth">{{ drupal_menu('twig-tweak-test', 1, 1) }}</div> <div class="tt-menu-depth">{{ drupal_menu('twig-tweak-test', 1, 1) }}</div>
<div class="tt-form">{{ drupal_form('Drupal\\system\\Form\\CronForm') }}</div> <div class="tt-form">{{ drupal_form('Drupal\\system\\Form\\CronForm') }}</div>
<div class="tt-image-by-fid">{{ drupal_image(1, attributes=image_attributes) }}</div>
<div class="tt-image-by-uri">{{ drupal_image('public://image-test.png', attributes=image_attributes) }}</div>
<div class="tt-image-by-uuid">{{ drupal_image('b2c22b6f-7bf8-4da4-9de5-316e93487518', attributes=image_attributes) }}</div>
<div class="tt-image-with-style">{{ drupal_image(1, 'thumbnail', image_attributes) }}</div>
<div class="tt-image-with-responsive-style">{{ drupal_image(1, 'example', image_attributes, true) }}</div>
<div class="tt-token">{{ drupal_token('site:name') }}</div> <div class="tt-token">{{ drupal_token('site:name') }}</div>
<div class="tt-token-data">{{ drupal_token('node:title', {'node': node}) }}</div> <div class="tt-token-data">{{ drupal_token('node:title', {'node': node}) }}</div>
<div class="tt-config">{{ drupal_config('user.settings', 'anonymous') }}</div> <div class="tt-config">{{ drupal_config('user.settings', 'anonymous') }}</div>
<div class="tt-status-message">{{ drupal_set_message('Hi!', 'warning') }}</div>
<div class="tt-title">{{ drupal_title() }}</div> <div class="tt-title">{{ drupal_title() }}</div>
<div class="tt-url"> <div class="tt-url">{{ drupal_url('node/1', {absolute: true}) }}</div>
<div data-case="default">{{ drupal_url('node/1', {absolute: true}) }}</div> <div class="tt-token-replace">{{ 'Site name: [site:name]' | token_replace }}</div>
<div data-case="with-langcode">{{ drupal_url('node/1', {absolute: true, langcode: 'ru'}) }}</div> <div class="tt-preg-replace">{{ 'FOO' | preg_replace('/(foo)/i', '$1-bar') }}</div>
<div data-case="external">{{ drupal_url('https://example.com/node?foo=bar', {query: {page: 1}, fragment: 'here'}) }}</div> <div class="tt-preg-replace-legacy">{{ 'foo' | preg_replace('(foo)', '$1-bar') }}</div>
</div> <div class="tt-image-style">{{ 'public://images/ocean.jpg' | image_style('thumbnail') }}</div>
<div class="tt-link">{{ drupal_link('Edit', 'node/1/edit', {absolute: true}) }}</div> <div class="tt-transliterate">{{ 'Привет!' | transliterate('ru') }}</div>
<div class="tt-link-html">{% set link_text %}<b>Edit</b>{% endset %}{{ drupal_link(link_text, 'node/1/edit', {absolute: true}) }}</div> <div class="tt-check-markup">{{ '<b>bold</b> <strong>strong</strong>' | check_markup('twig_tweak_test') }}</div>
<div class="tt-messages">{{ drupal_messages() }}</div>
<div class="tt-breadcrumb">{{ drupal_breadcrumb() }}</div>
<div class="tt-link-access">{{ drupal_link('Administration', 'admin', {absolute: true}, true) }}</div>
<div class="tt-contextual-links">{{ drupal_contextual_links('node:node=1') }}</div>
<div class="tt-token-replace">{{ 'Site name: [site:name]'|token_replace }}</div>
<div class="tt-preg-replace">{{ 'FOO'|preg_replace('/(foo)/i', '$1-bar') }}</div>
<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-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 = {
're': {'#markup': 're'},
'#sol': {'#markup': '#sol'},
'mi': {'#markup': 'mi'},
'#fa': {'#markup': '#fa'},
'do': {'#markup': 'do', '#weight': -10},
}
-%}
{%- for value in build|children(true) -%}
{{- value -}}
{%- endfor -%}
</div>
<div class="tt-node-view">{{ node|view }}</div>
<div class="tt-field-list-view">{{ node.title|view }}</div>
<div class="tt-field-item-view">{{ node.title[0]|view }}</div>
<div class="tt-file-uri-from-image-field">{{ node.field_image|file_uri }}</div>
<div class="tt-file-uri-from-image-field-delta">{{ node.field_image[0]|file_uri }}</div>
{% set media_uri = node.field_media|file_uri %}
<div class="tt-file-uri-from-media-field">{{ media_uri }}</div>
{% if media_uri is not null %}
<div class="tt-image-style-from-file-uri-from-media-field">{{ media_uri|image_style('thumbnail') }}</div>
{% endif %}
<div class="tt-file-url-from-uri">{{ 'public://image-test.png'|file_url }}</div>
<div class="tt-file-url-from-uri-absolute">{{ 'public://image-test.png'|file_url(false) }}</div>
<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>
<div class="tt-tests_alter">{{ 'ok' is ok ? 'Yes' : 'No' }}</div>
</div> </div>

10
tests/twig_tweak_test/twig_tweak_test.info.yml

@ -1,12 +1,8 @@
name: Twig tweak test name: Twig tweak test
type: module type: module
description: Support module for Twig tweak testing. description: Support module for Tweak twig testing.
package: Testing package: Testing
core_version_requirement: ^9 || ^10 core: 8.x
dependencies: dependencies:
- drupal:system (>= 9.0) - drupa:twig_tweak
- drupal:block
- drupal:media
- drupal:node - drupal:node
- drupal:path
- drupal:twig_tweak

89
tests/twig_tweak_test/twig_tweak_test.module

@ -5,100 +5,41 @@
* Primary module hooks for Twig Tweak test module. * Primary module hooks for Twig Tweak test module.
*/ */
use Drupal\block\BlockInterface;
use Drupal\Core\Access\AccessResult; use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultInterface;
use Drupal\file\FileInterface;
use Drupal\node\Entity\Node;
use Drupal\node\NodeInterface; use Drupal\node\NodeInterface;
use Twig\TwigFilter;
use Twig\TwigFunction;
use Twig\TwigTest;
/** /**
* Implements hook_page_bottom(). * Implements hook_page_bottom().
*/ */
function twig_tweak_test_page_bottom(): void { function twig_tweak_test_page_bottom(array &$page_bottom) {
Drupal::service('messenger')->addMessage('Hello world!'); $page_bottom['twig_tweak_test']['#theme'] = 'twig_tweak_test';
} }
/** /**
* Implements hook_theme(). * Implements hook_theme().
*/ */
function twig_tweak_test_theme(): array { function twig_tweak_test_theme() {
return ['twig_tweak_test' => ['variables' => []]]; return ['twig_tweak_test' => ['variables' => []]];
} }
/** /**
* Prepares variables for twig-tweak-test template. * Prepares variables for twig-tweak-test template.
*/ */
function template_preprocess_twig_tweak_test(array &$vars): void { function template_preprocess_twig_tweak_test(&$vars) {
$vars['node'] = Node::load(1); $vars['node'] = Drupal::routeMatch()->getParameter('node');
}
/**
* Implements hook_block_access().
*
* @see \Drupal\Tests\twig_tweak\Kernel\RegionViewBuilderTest
*/
function twig_tweak_test_block_access(BlockInterface $block): AccessResultInterface {
$result = AccessResult::forbiddenIf($block->id() == 'private_block');
$result->cachePerUser();
$result->addCacheTags(['tag_for_' . $block->id()]);
$result->setCacheMaxAge(123);
return $result;
} }
/** /**
* Implements hook_node_access(). * Implements hook_node_access().
* *
* @see \Drupal\Tests\twig_tweak\Kernel\EntityViewBuilderTest * @see \Drupal\Tests\twig_tweak\Kernel\AccessTest
*/ */
function twig_tweak_test_node_access(NodeInterface $node): AccessResultInterface { function twig_tweak_test_node_access(NodeInterface $node) {
$result = AccessResult::forbiddenIf($node->getTitle() == 'Private node'); if ($node->getTitle() == 'Entity access test') {
$result->addCacheTags(['tag_from_' . __FUNCTION__]); $result = AccessResult::allowed();
$result->cachePerUser(); $result->addCacheTags(['tag_from_' . __FUNCTION__]);
$result->setCacheMaxAge(50); $result->cachePerUser();
return $result; $result->setCacheMaxAge(50);
} return $result;
}
/**
* Implements hook_file_access().
*
* @see \Drupal\Tests\twig_tweak\Kernel\ImageViewBuilderTest
*/
function twig_tweak_test_file_access(FileInterface $file): AccessResultInterface {
$is_public = parse_url($file->getFileUri(), PHP_URL_SCHEME) == 'public';
$result = AccessResult::allowedIf($is_public);
$result->cachePerUser();
$result->addCacheTags(['tag_for_' . $file->getFileUri()]);
$result->setCacheMaxAge(70);
return $result;
}
/**
* Implements hook_twig_tweak_functions_alter().
*/
function twig_tweak_test_twig_tweak_functions_alter(array &$functions) {
$functions[] = new TwigFunction('foo', function (string $value): string {
return "-=$value=-";
});
}
/**
* Implements hook_twig_tweak_filters_alter().
*/
function twig_tweak_test_twig_tweak_filters_alter(array &$filters) {
$filters[] = new TwigFilter('bar', function (string $input): string {
return str_replace('foo', 'bar', $input);
});
}
/**
* Implements hook_twig_tweak_tests_alter().
*/
function twig_tweak_test_twig_tweak_tests_alter(array &$tests) {
$tests[] = new TwigTest('ok', function ($input): bool {
return $input == 'ok';
});
} }

7
tests/twig_tweak_test/twig_tweak_test.routing.yml

@ -1,7 +0,0 @@
twig_tweak_test.test:
path: 'twig-tweak-test'
defaults:
_title: 'Twig Tweak Test'
_controller: '\Drupal\twig_tweak_test\Controller\TwigTweakTestController::build'
requirements:
_permission: 'access content'

64
twig_tweak.api.php

@ -1,64 +0,0 @@
<?php
/**
* @file
* Hooks specific to the Twig Tweak module.
*/
use Drupal\Component\Utility\Unicode;
use Drupal\node\NodeInterface;
use Twig\TwigFilter;
use Twig\TwigFunction;
use Twig\TwigTest;
/**
* @addtogroup hooks
* @{
*/
/**
* Alters Twig Tweak functions.
*
* @param \Twig\TwigFunction[] $functions
* 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
}
/**
* Alters Twig Tweak filters.
*
* @param \Twig\TwigFilter[] $filters
* 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']);
}
/**
* Alters Twig Tweak tests.
*
* @param \Twig\TwigTest[] $tests
* 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);
}
/**
* @} End of "addtogroup hooks".
*/

6
twig_tweak.info.yml

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

42
twig_tweak.services.yml

@ -1,45 +1,5 @@
services: services:
twig_tweak.twig_extension: twig_tweak.twig_extension:
class: Drupal\twig_tweak\TwigTweakExtension class: Drupal\twig_tweak\TwigExtension
arguments: ['@module_handler', '@theme.manager']
tags: tags:
- { name: twig.extension } - { name: twig.extension }
twig_tweak.block_view_builder:
class: Drupal\twig_tweak\View\BlockViewBuilder
arguments: ['@plugin.manager.block', '@context.repository', '@context.handler', '@current_user', '@request_stack', '@current_route_match', '@title_resolver']
twig_tweak.region_view_builder:
class: Drupal\twig_tweak\View\RegionViewBuilder
arguments: ['@entity_type.manager', '@config.factory', '@request_stack', '@title_resolver']
twig_tweak.entity_view_builder:
class: Drupal\twig_tweak\View\EntityViewBuilder
arguments: ['@entity_type.manager', '@entity.repository']
twig_tweak.entity_form_view_builder:
class: Drupal\twig_tweak\View\EntityFormViewBuilder
arguments: ['@entity.form_builder']
twig_tweak.field_view_builder:
class: Drupal\twig_tweak\View\FieldViewBuilder
arguments: ['@entity.repository']
twig_tweak.menu_view_builder:
class: Drupal\twig_tweak\View\MenuViewBuilder
arguments: ['@menu.link_tree']
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']
twig_tweak.uri_extractor:
class: Drupal\twig_tweak\UriExtractor
arguments: ['@entity_type.manager']
twig_tweak.cache_metadata_extractor:
class: Drupal\twig_tweak\CacheMetadataExtractor

Loading…
Cancel
Save