Compare commits

..

26 Commits
3.x ... 8.x-2.x

Author SHA1 Message Date
Jeroen Tubex eff02c9d31 Issue #3279991 by JeroenT, Chi: drupal_entity_form add support for content moderation 2 years ago
George Potter 4803620832 Issue #3222666 by gpotter, theRuslan, Chi, smustgrave, superbiche, letrollpoilu: drupal_menu() incorrectly caches the active parent menu item in two-leveled menu 2 years ago
Chi 68bb4e5505 Fix tests 2 years ago
Rolando Payán Mosqueda b39fcea741 3108210-typeerror-on-drupalblock 2 years ago
kristiaanvandeneynde f51df921a0 Issue #3245953 by kristiaanvandeneynde: TwigExtension::drupalTitle() should check for NullRouteMatch 3 years ago
piggito 2206ea3fae Issue #3219625 by piggito: Possible Variable Overwrite 3 years ago
seanpenn079 03da5f2bdf Issue #3188441 by seanB, Wilfred Waltman: Allow arguments to be passed to token_replace 3 years ago
Chi 0b77db785f Fix PHP 7.2 compatibility 4 years ago
Chi b388508f13 Fixed code style issues 4 years ago
Chi 3897d7de66 Issue #3176561 by beram: Add a test for block plugin cache metadata 4 years ago
beram b8ea21be16 Issue #3176561 by beram: drupal_block does not use the plugin cache metadata 4 years ago
Chi b95167bc57 #3185016 Fix typo 4 years ago
hanoii 0f9c68957e Issue #3185016 by hanoii: Translation filter 4 years ago
Niklan 15103a67e3 Issue #3174903 by Niklan, ygoex: Error: Call to a member function getRegion() on null in hook_theme_suggestions_block_alter() 4 years ago
gchauhan ccf146ac6a Issue #3161956 by tripox, gchauhan: No ID in render array for blocks 4 years ago
CharlieChXNegyesi 05c880e09e Issue #3173946 by Charlie ChX Negyesi, larowlan: drupal_region() overcaches empty 4 years ago
Chi ad8c396b7a Remove usage of deprecated in Twig 2 classes 4 years ago
Chi cf7ec3f5d7 Issue #3087368: Add format_size Twig filter 4 years ago
Chi 21b6079272 Issue #3135932 by mxwright: Twig variable in PHP filter 4 years ago
mdupont 0e9a422bec Issue #3095714 by mdupont, Chi: Add file_uri filter 4 years ago
Chi 84c486c2ed Issue #2994996 by aangel: Field access check should test using language 4 years ago
NesleeCanilPinto 2fac0ae51d Issue #3124739 by Neslee Canil Pinto: Typo error in codebase 4 years ago
Chi 8748966d52 Fix code style issues 5 years ago
Chi 10dca235d8 Specify default theme in browser test 5 years ago
Chi 495f34f162 Improve documentation 5 years ago
Chi f1b0efd44d Issue #3116410 by larowlan: The module may not bubble access cacheability metadata 5 years ago
  1. 9
      .gitlab-ci.yml
  2. 23
      README.md
  3. 9
      README.txt
  4. 16
      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. 52
      src/CacheMetadataExtractor.php
  13. 217
      src/Command/DebugCommand.php
  14. 271
      src/Command/LintCommand.php
  15. 37
      src/Command/ValidateCommand.php
  16. 1363
      src/TwigExtension.php
  17. 729
      src/TwigTweakExtension.php
  18. 87
      src/UriExtractor.php
  19. 117
      src/UrlExtractor.php
  20. 186
      src/View/BlockViewBuilder.php
  21. 61
      src/View/EntityFormViewBuilder.php
  22. 57
      src/View/EntityViewBuilder.php
  23. 76
      src/View/FieldViewBuilder.php
  24. 98
      src/View/ImageViewBuilder.php
  25. 84
      src/View/MenuViewBuilder.php
  26. 114
      src/View/RegionViewBuilder.php
  27. 354
      tests/src/Functional/TwigTweakTest.php
  28. 93
      tests/src/Kernel/AbstractExtractorTestCase.php
  29. 45
      tests/src/Kernel/AbstractTestCase.php
  30. 569
      tests/src/Kernel/AccessTest.php
  31. 171
      tests/src/Kernel/BlockViewBuilderTest.php
  32. 99
      tests/src/Kernel/CacheMetadataExtractorTest.php
  33. 130
      tests/src/Kernel/EntityFormViewBuilderTest.php
  34. 202
      tests/src/Kernel/EntityViewBuilderTest.php
  35. 140
      tests/src/Kernel/FieldViewBuilderTest.php
  36. 257
      tests/src/Kernel/ImageViewBuilderTest.php
  37. 124
      tests/src/Kernel/MenuViewBuilderTest.php
  38. 154
      tests/src/Kernel/RegionViewBuilderTest.php
  39. 61
      tests/src/Kernel/UriExtractorTest.php
  40. 87
      tests/src/Kernel/UrlExtractorTest.php
  41. 2
      tests/twig_tweak_test/config/install/block.block.claro_powered_by_drupal.yml
  42. 80
      tests/twig_tweak_test/config/install/core.entity_form_display.node.page.default.yml
  43. 8
      tests/twig_tweak_test/config/install/core.entity_view_display.media.image.default.yml
  44. 3
      tests/twig_tweak_test/config/install/core.entity_view_display.media.remote_video.default.yml
  45. 25
      tests/twig_tweak_test/config/install/core.entity_view_display.node.page.default.yml
  46. 31
      tests/twig_tweak_test/config/install/core.entity_view_display.node.page.teaser.yml
  47. 3
      tests/twig_tweak_test/config/install/field.field.media.image.field_media_image.yml
  48. 3
      tests/twig_tweak_test/config/install/field.field.media.remote_video.field_media_oembed_video.yml
  49. 4
      tests/twig_tweak_test/config/install/field.field.node.page.body.yml
  50. 1
      tests/twig_tweak_test/config/install/field.field.node.page.field_image.yml
  51. 1
      tests/twig_tweak_test/config/install/field.field.node.page.field_media.yml
  52. 3
      tests/twig_tweak_test/config/install/field.storage.media.field_media_image.yml
  53. 3
      tests/twig_tweak_test/config/install/field.storage.media.field_media_oembed_video.yml
  54. 3
      tests/twig_tweak_test/config/install/field.storage.node.field_image.yml
  55. 1
      tests/twig_tweak_test/config/install/field.storage.node.field_media.yml
  56. 4
      tests/twig_tweak_test/config/install/filter.format.twig_tweak_test.yml
  57. 3
      tests/twig_tweak_test/config/install/media.type.image.yml
  58. 5
      tests/twig_tweak_test/config/install/media.type.remote_video.yml
  59. 1
      tests/twig_tweak_test/config/install/node.type.page.yml
  60. 4
      tests/twig_tweak_test/config/install/system.menu.twig-tweak-test.yml
  61. 3
      tests/twig_tweak_test/config/install/views.view.twig_tweak_test.yml
  62. 17
      tests/twig_tweak_test/src/Controller/TwigTweakTestController.php
  63. 25
      tests/twig_tweak_test/src/Plugin/Block/FooBlock.php
  64. 44
      tests/twig_tweak_test/templates/twig-tweak-test.html.twig
  65. 9
      tests/twig_tweak_test/twig_tweak_test.info.yml
  66. 80
      tests/twig_tweak_test/twig_tweak_test.module
  67. 7
      tests/twig_tweak_test/twig_tweak_test.routing.yml
  68. 64
      twig_tweak.api.php
  69. 5
      twig_tweak.info.yml
  70. 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

16
composer.json

@ -3,27 +3,17 @@
"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",
"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": "https://git.drupalcode.org/project/twig_tweak"
}, },
"require": { "require": {
"php": ">=7.3", "drupal/core": "^8.7 || ^9.0",
"ext-json": "*", "twig/twig": "^1.41 || ^2.12"
"drupal/core": "^9.3 || ^10.0",
"twig/twig": "^2.15.3 || ^3.4.3",
"symfony/polyfill-php80": "^1.17"
}, },
"suggest": { "suggest": {
"symfony/var-dumper": "Better dump() function for debugging Twig variables" "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

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
);
}
}

1363
src/TwigExtension.php

File diff suppressed because it is too large Load Diff

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;
}
}

354
tests/src/Functional/TwigTweakTest.php

@ -3,13 +3,12 @@
namespace Drupal\Tests\twig_tweak\Functional; namespace Drupal\Tests\twig_tweak\Functional;
use Drupal\Core\Link; 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\Entity\File;
use Drupal\file\FileInterface;
use Drupal\language\Entity\ConfigurableLanguage; use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\media\Entity\Media; use Drupal\media\Entity\Media;
use Drupal\responsive_image\Entity\ResponsiveImageStyle; use Drupal\responsive_image\Entity\ResponsiveImageStyle;
use Drupal\Core\Render\Markup;
use Drupal\Tests\BrowserTestBase; use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\TestFileCreationTrait; use Drupal\Tests\TestFileCreationTrait;
use Drupal\user\Entity\Role; use Drupal\user\Entity\Role;
@ -19,7 +18,7 @@ use Drupal\user\Entity\Role;
* *
* @group twig_tweak * @group twig_tweak
*/ */
final class TwigTweakTest extends BrowserTestBase { class TwigTweakTest extends BrowserTestBase {
use TestFileCreationTrait; use TestFileCreationTrait;
@ -31,7 +30,7 @@ final class TwigTweakTest extends BrowserTestBase {
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
protected static $modules = [ public static $modules = [
'twig_tweak', 'twig_tweak',
'twig_tweak_test', 'twig_tweak_test',
'views', 'views',
@ -46,7 +45,7 @@ final class TwigTweakTest extends BrowserTestBase {
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
public function setUp(): void { public function setUp() {
parent::setUp(); parent::setUp();
$test_files = $this->getTestFiles('image'); $test_files = $this->getTestFiles('image');
@ -54,14 +53,14 @@ final class TwigTweakTest extends BrowserTestBase {
$image_file = File::create([ $image_file = File::create([
'uri' => $test_files[0]->uri, 'uri' => $test_files[0]->uri,
'uuid' => 'b2c22b6f-7bf8-4da4-9de5-316e93487518', 'uuid' => 'b2c22b6f-7bf8-4da4-9de5-316e93487518',
'status' => FileInterface::STATUS_PERMANENT, 'status' => FILE_STATUS_PERMANENT,
]); ]);
$image_file->save(); $image_file->save();
$media_file = File::create([ $media_file = File::create([
'uri' => $test_files[8]->uri, 'uri' => $test_files[8]->uri,
'uuid' => '5dd794d0-cb75-4130-9296-838aebc1fe74', 'uuid' => '5dd794d0-cb75-4130-9296-838aebc1fe74',
'status' => FileInterface::STATUS_PERMANENT, 'status' => FILE_STATUS_PERMANENT,
]); ]);
$media_file->save(); $media_file->save();
@ -74,7 +73,6 @@ final class TwigTweakTest extends BrowserTestBase {
$node_values = [ $node_values = [
'title' => 'Alpha', 'title' => 'Alpha',
'uuid' => 'ad1b902a-344f-41d1-8c61-a69f0366dbfa',
'field_image' => [ 'field_image' => [
'target_id' => $image_file->id(), 'target_id' => $image_file->id(),
'alt' => 'Alt text', 'alt' => 'Alt text',
@ -95,88 +93,88 @@ final class TwigTweakTest extends BrowserTestBase {
'breakpoint_group' => 'responsive_image', 'breakpoint_group' => 'responsive_image',
])->save(); ])->save();
// Setup Russian language. // Setup Russian.
ConfigurableLanguage::createFromLangcode('ru')->save(); 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.
\Drupal::service('cache_tags.invalidator')->invalidateTags(['block_view']);
$this->drupalGet('<front>');
$this->drupalGet('twig-tweak-test'); // -- Test default views display.
// -- View (default 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. // -- Test view result.
$xpath = '//div[@class = "tt-view-result" and text() = 3]'; $xpath = '//div[@class = "tt-view-result" and text() = 3]';
$this->assertXpath($xpath); $this->assertByXpath($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 .= '/img[contains(@src, "/core/themes/claro/logo.svg") and @alt="Home"]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- Block with wrapper. // -- Test block with wrapper.
$xpath = '//div[@class = "tt-block-with-wrapper"]'; $xpath = '//div[@class = "tt-block-with-wrapper"]';
$xpath .= '/div[@class = "block block-system block-system-branding-block"]'; $xpath .= '/div[@class = "block block-system block-system-branding-block"]';
$xpath .= '/h2[text() = "Branding"]'; $xpath .= '/h2[text() = "Branding"]';
$xpath .= '/following-sibling::a[img[contains(@src, "/core/themes/claro/logo.svg") and @alt="Home"]]'; $xpath .= '/following-sibling::a[img[contains(@src, "/core/themes/claro/logo.svg") and @alt="Home"]]';
$xpath .= '/following-sibling::div[@class = "site-name"]/a'; $xpath .= '/following-sibling::div[@class = "site-name"]/a';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- Region. // -- Test region.
$xpath = '//div[@class = "tt-region"]/div[@class = "region region-highlighted"]'; $xpath = '//div[@class = "tt-region"]/div[@class = "region region-highlighted"]';
$xpath .= '/div[contains(@class, "block-system-powered-by-block")]/span[. = "Powered by Drupal"]'; $xpath .= '/div[contains(@class, "block-system-powered-by-block")]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- Entity (default view mode). // -- Test 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 access to entity add form.
$xpath = '//div[@class = "tt-entity-uuid-missing" and . = ""]';
$this->assertXpath($xpath);
// -- Entity add form (unprivileged user).
$xpath = '//div[@class = "tt-entity-add-form"]/form'; $xpath = '//div[@class = "tt-entity-add-form"]/form';
$this->assertSession()->elementNotExists('xpath', $xpath); $this->assertSession()->elementNotExists('xpath', $xpath);
// -- Entity edit form (unprivileged user). // -- Test access to entity edit form.
$xpath = '//div[@class = "tt-entity-edit-form"]/form'; $xpath = '//div[@class = "tt-entity-edit-form"]/form';
$this->assertSession()->elementNotExists('xpath', $xpath); $this->assertSession()->elementNotExists('xpath', $xpath);
@ -185,271 +183,225 @@ final class TwigTweakTest extends BrowserTestBase {
/** @var \Drupal\user\RoleInterface $role */ /** @var \Drupal\user\RoleInterface $role */
$role = Role::load(Role::ANONYMOUS_ID); $role = Role::load(Role::ANONYMOUS_ID);
$this->grantPermissions($role, $permissions); $this->grantPermissions($role, $permissions);
$this->drupalGet($this->getUrl()); $this->drupalGet('/node/2');
// -- Entity add form. // -- Test entity add form.
$xpath = '//div[@class = "tt-entity-add-form"]/form'; $xpath = '//div[@class = "tt-entity-add-form"]/form';
$xpath .= '//input[@name = "title[0][value]" and @value = ""]'; $xpath .= '//input[@name = "title[0][value]" and @value = ""]';
$xpath .= '/../../../../..//div/input[@type = "submit" and @value = "Save"]'; $xpath .= '/../../../../..//div/input[@type = "submit" and @value = "Save"]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- Entity edit form. // -- Test entity edit form.
$xpath = '//div[@class = "tt-entity-edit-form"]/form'; $xpath = '//div[@class = "tt-entity-edit-form"]/form';
$xpath .= '//input[@name = "title[0][value]" and @value = "Alpha"]'; $xpath .= '//input[@name = "title[0][value]" and @value = "Alpha"]';
$xpath .= '/../../../../..//div/input[@type = "submit" and @value = "Save"]'; $xpath .= '/../../../../..//div/input[@type = "submit" and @value = "Save"]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- Field. // -- Test 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. // -- Test image by FID.
$xpath = '//div[@class = "tt-image-by-fid"]/img[contains(@src, "/files/image-test.png")]'; $xpath = '//div[@class = "tt-image-by-fid"]/img[contains(@src, "/files/image-test.png")]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- Image by URI. // -- Test image by URI.
$xpath = '//div[@class = "tt-image-by-uri"]/img[contains(@src, "/files/image-test.png")]'; $xpath = '//div[@class = "tt-image-by-uri"]/img[contains(@src, "/files/image-test.png")]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- Image by UUID. // -- Test image by UUID.
$xpath = '//div[@class = "tt-image-by-uuid"]/img[contains(@src, "/files/image-test.png")]'; $xpath = '//div[@class = "tt-image-by-uuid"]/img[contains(@src, "/files/image-test.png")]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- Image with style. // -- Test image with style.
$xpath = '//div[@class = "tt-image-with-style"]/img[contains(@src, "/files/styles/thumbnail/public/image-test.png")]'; $xpath = '//div[@class = "tt-image-with-style"]/img[contains(@src, "/files/styles/thumbnail/public/image-test.png")]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- Image with responsive style. // -- Test image with responsive style.
$xpath = '//div[@class = "tt-image-with-responsive-style"]/picture/img[contains(@src, "/files/image-test.png")]'; $xpath = '//div[@class = "tt-image-with-responsive-style"]/picture/img[contains(@src, "/files/image-test.png")]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- Token. // -- Test 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 page title.
$xpath = '//div[@class = "tt-title" and text() = "Twig Tweak Test"]'; $xpath = '//div[@class = "tt-title" and text() = "Beta"]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- URL. // -- Test URL.
$url = Url::fromUserInput('/node/1', ['absolute' => TRUE])->toString(); $url = Url::fromUserInput('/node/1', ['absolute' => TRUE])->toString();
$xpath = sprintf('//div[@class = "tt-url"]/div[@data-case="default" and text() = "%s"]', $url); $xpath = sprintf('//div[@class = "tt-url"]/div[@data-case="default" and text() = "%s"]', $url);
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- URL with langcode. // -- Test URL (with langcode).
$url = str_replace('node/1', 'ru/node/1', $url); $url = str_replace('node/1', 'ru/node/1', $url);
$xpath = sprintf('//div[@class = "tt-url"]/div[@data-case="with-langcode" and text() = "%s"]', $url); $xpath = sprintf('//div[@class = "tt-url"]/div[@data-case="with-langcode" and text() = "%s"]', $url);
$this->assertXpath($xpath); $this->assertByXpath($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. // -- Test link.
$url = Url::fromUserInput('/node/1/edit', ['absolute' => TRUE]); $url = Url::fromUserInput('/node/1/edit', ['absolute' => TRUE]);
$link = Link::fromTextAndUrl('Edit', $url)->toString(); $link = Link::fromTextAndUrl('Edit', $url)->toString();
$xpath = '//div[@class = "tt-link"]'; $xpath = '//div[@class = "tt-link"]';
self::assertSame((string) $link, $this->xpath($xpath)[0]->getHtml()); self::assertEquals($link, trim($this->xpath($xpath)[0]->getHtml()));
// -- Link with HTML. // -- Test link with HTML.
$text = Markup::create('<b>Edit</b>'); $text = Markup::create('<b>Edit</b>');
$url = Url::fromUserInput('/node/1/edit', ['absolute' => TRUE]); $url = Url::fromUserInput('/node/1/edit', ['absolute' => TRUE]);
$link = Link::fromTextAndUrl($text, $url)->toString(); $link = Link::fromTextAndUrl($text, $url)->toString();
$xpath = '//div[@class = "tt-link-html"]'; $xpath = '//div[@class = "tt-link-html"]';
self::assertSame((string) $link, $this->xpath($xpath)[0]->getHtml()); self::assertEquals($link, trim($this->xpath($xpath)[0]->getHtml()));
// -- Status messages. // -- Test status messages.
$xpath = '//div[@class = "tt-messages"]//div[contains(@class, "messages--status") and contains(., "Hello world!")]'; $xpath = '//div[@class = "tt-messages"]//div[contains(@class, "messages--status") and contains(., "Hello world!")]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- Breadcrumb. // -- Test breadcrumb.
$xpath = '//div[@class = "tt-breadcrumb"]/nav[@class = "breadcrumb"]/ol/li/a[text() = "Home"]'; $xpath = '//div[@class = "tt-breadcrumb"]/nav[@class = "breadcrumb"]/ol/li/a[text() = "Home"]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- Protected link. // -- Test protected link.
$xpath = '//div[@class = "tt-link-access"]'; $xpath = '//div[@class = "tt-link-access"]';
self::assertSame('', $this->xpath($xpath)[0]->getHtml()); self::assertEquals('', trim($this->xpath($xpath)[0]->getHtml()));
// -- Token replacement. // -- Test token replacement.
$xpath = '//div[@class = "tt-token-replace" and text() = "Site name: Drupal"]'; $xpath = '//div[@class = "tt-token-replace" and text() = "Site name: Drupal"]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- Contextual links. // -- Test contextual links.
$xpath = '//div[@class="tt-contextual-links" and not(div[@data-contextual-id])]'; $xpath = '//div[@class="tt-contextual-links" and not(div[@data-contextual-id])]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
/** @var \Drupal\user\RoleInterface $role */ /** @var \Drupal\user\RoleInterface $role */
$role = Role::load(Role::ANONYMOUS_ID); $role = Role::load(Role::ANONYMOUS_ID);
$this->grantPermissions($role, ['access contextual links']); $this->grantPermissions($role, ['access contextual links']);
$this->drupalGet($this->getUrl()); $this->drupalGet($this->getUrl());
$xpath = '//div[@class="tt-contextual-links" and div[@data-contextual-id]]'; $xpath = '//div[@class="tt-contextual-links" and div[@data-contextual-id]]';
$this->assertXpath($xpath); $this->assertByXpath($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);
// -- 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()); self::assertEquals('<b>bold</b> strong', trim($this->xpath($xpath)[0]->getHtml()));
// -- Format size. // -- Format size.
$xpath = '//div[@class = "tt-format-size"]'; $xpath = '//div[@class = "tt-format-size"]';
self::assertSame('12.06 KB', $this->xpath($xpath)[0]->getHtml()); self::assertSame('12.06 KB', $this->xpath($xpath)[0]->getHtml());
// -- Truncate. // -- Test truncation.
$xpath = '//div[@class = "tt-truncate" and text() = "Hello…"]'; $xpath = '//div[@class = "tt-truncate" and text() = "Hello…"]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- 'with'. // -- Test 'with'.
$xpath = '//div[@class = "tt-with"]/b[text() = "Example"]'; $xpath = '//div[@class = "tt-with"]/b[text() = "Example"]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- Nested 'with'. // -- Test nested 'with'.
$xpath = '//div[@class = "tt-with-nested" and text() = "{alpha:{beta:{gamma:456}}}"]'; $xpath = '//div[@class = "tt-with-nested" and text() = "{alpha:{beta:{gamma:456}}}"]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- Data URI (SVG). // -- Test 'children'.
$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"]'; $xpath = '//div[@class = "tt-children" and text() = "doremi"]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- Entity view. // -- Test entity view.
$xpath = '//div[@class = "tt-node-view"]/article[contains(@class, "node--view-mode-default")]/h2[a/span[text() = "Alpha"]]'; $xpath = '//div[@class = "tt-node-view"]/article[contains(@class, "node--view-mode-default")]/h2[a/span[text() = "Beta"]]';
$xpath .= '/following-sibling::div[@class = "node__content"]/div/p'; $xpath .= '/following-sibling::div[@class = "node__content"]/div/p';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- Field list view. // -- Test Field list view.
$xpath = '//div[@class = "tt-field-list-view"]/span[contains(@class, "field--name-title") and text() = "Alpha"]'; $xpath = '//div[@class = "tt-field-list-view"]/span[contains(@class, "field--name-title") and text() = "Beta"]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- Field item view. // -- Test field item view.
$xpath = '//div[@class = "tt-field-item-view" and text() = "Alpha"]'; $xpath = '//div[@class = "tt-field-item-view" and text() = "Beta"]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- File URI from image field. // -- Test file URI from image field.
$this->drupalGet('/node/1');
$xpath = '//div[@class = "tt-file-uri-from-image-field" and contains(text(), "public://image-test.png")]'; $xpath = '//div[@class = "tt-file-uri-from-image-field" and contains(text(), "public://image-test.png")]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- File URI from a specific image field item. // -- Test 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")]'; $xpath = '//div[@class = "tt-file-uri-from-image-field-delta" and contains(text(), "public://image-test.png")]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- File URI from media field. // -- Test file URI from media field.
$xpath = '//div[@class = "tt-file-uri-from-media-field" and contains(text(), "public://image-1.png")]'; $xpath = '//div[@class = "tt-file-uri-from-media-field" and contains(text(), "public://image-1.png")]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- Image style from File URI from media field. // -- Test 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")]'; $xpath = '//div[@class = "tt-image-style-from-file-uri-from-media-field" and contains(text(), "styles/thumbnail/public/image-1.png")]';
$this->assertXpath($xpath); $this->assertByXpath($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). // -- Test file URL from URI.
$xpath = '//div[@class = "tt-file-url-from-uri-absolute" and contains(text(), "/files/image-test.png") and contains(text(), "http://")]'; $xpath = '//div[@class = "tt-file-url-from-uri" and contains(text(), "/files/image-test.png")]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- File URL from image field. // -- Test file URL from image field.
$xpath = '//div[@class = "tt-file-url-from-image-field" and contains(text(), "/files/image-test.png")]'; $xpath = '//div[@class = "tt-file-url-from-image-field" and contains(text(), "/files/image-test.png")]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- File URL from a specific image field item. // -- Test 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")]'; $xpath = '//div[@class = "tt-file-url-from-image-field-delta" and contains(text(), "/files/image-test.png")]';
$this->assertXpath($xpath); $this->assertByXpath($xpath);
// -- File URL from media field. // -- Test file URL from media field.
$xpath = '//div[@class = "tt-file-url-from-media-field" and contains(text(), "/files/image-1.png")]'; $xpath = '//div[@class = "tt-file-url-from-media-field" and contains(text(), "/files/image-1.png")]';
$this->assertXpath($xpath); $this->assertByXpath($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);
} }
/**
* {@inheritdoc}
*/
protected function initFrontPage() {
// Intentionally empty. The parent implementation does a request to the
// front page to init cookie. This causes some troubles in rendering
// attached Twig template because page content type is not created at that
// moment. We can skip this step since this test does not rely on any
// session data.
}
} }

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']);
}
}
}

569
tests/src/Kernel/AccessTest.php

@ -0,0 +1,569 @@
<?php
namespace Drupal\Tests\twig_tweak\Kernel;
use Drupal\block\BlockViewBuilder;
use Drupal\block\Entity\Block;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\file\Entity\File;
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}
*/
protected static $modules = [
'twig_tweak',
'twig_tweak_test',
'node',
'file',
'user',
'system',
'block',
];
/**
* {@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 with access check.
$this->setUpCurrentUser(['name' => 'User 1']);
$build = $this->twigExtension->drupalEntity('node', $this->node->id());
self::assertNull($build);
// -- Unprivileged user without access check.
$build = $this->twigExtension->drupalEntity('node', $this->node->id(), NULL, NULL, FALSE);
self::assertArrayHasKey('#node', $build);
$expected_cache = [
'tags' => [
'node_view',
'node:1',
],
'contexts' => [],
'max-age' => Cache::PERMANENT,
];
self::assertSame($expected_cache, $build['#cache']);
// -- Privileged user with access check.
$this->setUpCurrentUser(['name' => 'User 2'], ['access content']);
$build = $this->twigExtension->drupalEntity('node', $this->node->id());
self::assertArrayHasKey('#node', $build);
$expected_cache = [
'tags' => [
'node_view',
'node:1',
'tag_from_twig_tweak_test_node_access',
],
'contexts' => [
'user',
'user.permissions',
],
'max-age' => 50,
];
self::assertSame($expected_cache, $build['#cache']);
// -- Privileged user without access check.
$build = $this->twigExtension->drupalEntity('node', $this->node->id(), NULL, NULL, FALSE);
self::assertArrayHasKey('#node', $build);
$expected_cache = [
'tags' => [
'node_view',
'node:1',
],
'contexts' => [],
'max-age' => Cache::PERMANENT,
];
self::assertSame($expected_cache, $build['#cache']);
}
/**
* Test callback.
*/
public function testDrupalField() {
// -- Unprivileged user with access check.
$this->setUpCurrentUser(['name' => 'User 1']);
$build = $this->twigExtension->drupalField('title', 'node', $this->node->id());
self::assertNull($build);
// -- Unprivileged user without access check.
$build = $this->twigExtension->drupalField('title', 'node', $this->node->id(), 'default', NULL, FALSE);
self::assertArrayHasKey('#items', $build);
$expected_cache = [
'contexts' => [],
'tags' => ['node:1'],
'max-age' => Cache::PERMANENT,
];
self::assertSame($expected_cache, $build['#cache']);
// -- Privileged user with access check.
$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' => [
'tag_from_twig_tweak_test_node_access',
'node:1',
],
'max-age' => 50,
];
self::assertSame($expected_cache, $build['#cache']);
// -- Privileged user without access check.
$build = $this->twigExtension->drupalField('title', 'node', $this->node->id(), 'default', NULL, FALSE);
self::assertArrayHasKey('#items', $build);
$expected_cache = [
'contexts' => [],
'tags' => ['node:1'],
'max-age' => Cache::PERMANENT,
];
self::assertSame($expected_cache, $build['#cache']);
}
/**
* Test callback.
*/
public function testDrupalEntityEditForm() {
// -- Unprivileged user with access check.
$this->setUpCurrentUser(['name' => 'User 1']);
$build = $this->twigExtension->drupalEntityForm('node', $this->node->id());
self::assertNull($build);
// -- Unprivileged user without access check.
$build = $this->twigExtension->drupalEntityForm('node', $this->node->id(), 'default', [], FALSE);
self::assertArrayHasKey('form_id', $build);
$expected_cache = [
'contexts' => ['user.roles:authenticated'],
'tags' => [
'node:1',
'config:core.entity_form_display.node.article.default',
],
'max-age' => Cache::PERMANENT,
];
self::assertSame($expected_cache, $build['#cache']);
// -- Privileged user with access check.
$this->setUpCurrentUser(['name' => 'User 2'], ['access content']);
$build = $this->twigExtension->drupalEntityForm('node', $this->node->id());
self::assertArrayHasKey('#form_id', $build);
$expected_cache = [
'contexts' => [
'user.roles:authenticated',
'user',
'user.permissions',
],
'tags' => [
'node:1',
'config:core.entity_form_display.node.article.default',
'tag_from_twig_tweak_test_node_access',
],
'max-age' => 50,
];
self::assertSame($expected_cache, $build['#cache']);
// -- Privileged user without access check.
$build = $this->twigExtension->drupalEntityForm('node', $this->node->id(), 'default', [], FALSE);
self::assertArrayHasKey('#form_id', $build);
$expected_cache = [
'contexts' => ['user.roles:authenticated'],
'tags' => [
'node:1',
'config:core.entity_form_display.node.article.default',
],
'max-age' => Cache::PERMANENT,
];
self::assertSame($expected_cache, $build['#cache']);
}
/**
* Test callback.
*/
public function testDrupalEntityAddForm() {
$node_values = ['type' => 'article'];
// -- Unprivileged user with access check.
$this->setUpCurrentUser(['name' => 'User 1']);
$build = $this->twigExtension->drupalEntityForm('node', NULL, 'default', $node_values);
self::assertNull($build);
// -- Unprivileged user without access check.
$build = $this->twigExtension->drupalEntityForm('node', NULL, 'default', $node_values, FALSE);
self::assertArrayHasKey('form_id', $build);
$expected_cache = [
'contexts' => ['user.roles:authenticated'],
'tags' => ['config:core.entity_form_display.node.article.default'],
'max-age' => Cache::PERMANENT,
];
self::assertSame($expected_cache, $build['#cache']);
// -- Privileged user with access check.
$this->setUpCurrentUser(
['name' => 'User 2'],
['access content', 'create article content']
);
$build = $this->twigExtension->drupalEntityForm('node', NULL, 'default', $node_values);
self::assertArrayHasKey('form_id', $build);
$expected_cache = [
'contexts' => [
'user.roles:authenticated',
'user.permissions',
],
'tags' => ['config:core.entity_form_display.node.article.default'],
'max-age' => Cache::PERMANENT,
];
self::assertSame($expected_cache, $build['#cache']);
// -- Privileged user without access check.
$build = $this->twigExtension->drupalEntityForm('node', NULL, 'default', $node_values);
self::assertArrayHasKey('form_id', $build);
$expected_cache = [
'contexts' => [
'user.roles:authenticated',
'user.permissions',
],
'tags' => ['config:core.entity_form_display.node.article.default'],
'max-age' => Cache::PERMANENT,
];
self::assertSame($expected_cache, $build['#cache']);
}
/**
* Test callback.
*
* @see \Drupal\twig_tweak_test\Plugin\Block\FooBlock
*/
public function testDrupalBlock() {
// -- Privileged user.
$this->setUpCurrentUser(['name' => 'User 1']);
$build = $this->twigExtension->drupalBlock('twig_tweak_test_foo');
$expected_content = [
'#markup' => 'Foo',
'#cache' => [
'contexts' => ['url'],
'tags' => ['tag_from_build'],
],
];
self::assertSame($expected_content, $build['content']);
$expected_cache = [
'contexts' => ['user'],
'tags' => ['tag_from_blockAccess', 'tag_twig_tweak_test_foo_plugin'],
'max-age' => 35,
];
self::assertSame($expected_cache, $build['#cache']);
// -- Unprivileged user.
$this->setUpCurrentUser(['name' => 'User 2']);
$build = $this->twigExtension->drupalBlock('twig_tweak_test_foo');
self::assertNull($build);
}
/**
* 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 = $this->createMock(EntityTypeInterface::class);
$entity_type->expects($this->any())
->method('getListCacheTags')
->willReturn([]);
$entity_type->expects($this->any())
->method('getListCacheContexts')
->willReturn([]);
$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);
$entity_type_manager->expects($this->any())
->method('getDefinition')
->willReturn($entity_type);
$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'],
],
],
'#region' => 'bar',
'#theme_wrappers' => ['region'],
'#cache' => [
'contexts' => ['user'],
'tags' => [
'tag_for_block_1',
'tag_for_block_2',
],
'max-age' => 123,
],
];
self::assertSame($expected_build, $build);
}
/**
* Test callback.
*/
public function testDrupalImage() {
// @codingStandardsIgnoreStart
$create_image = function ($uri) {
$file = new class(['uri' => $uri], 'file') extends File {
public function access($operation, AccountInterface $account = NULL, $return_as_object = FALSE) {
$is_public = parse_url($this->getFileUri(), PHP_URL_SCHEME) == 'public';
$result = AccessResult::allowedIf($is_public);
$result->cachePerUser();
$result->addCacheTags(['tag_for_' . $this->getFileUri()]);
$result->setCacheMaxAge(123);
return $return_as_object ? $result : $result->isAllowed();
}
public function getPlugin() {
return NULL;
}
};
$file->setFileUri($uri);
return $file;
};
// @codingStandardsIgnoreEnd
$storage = $this->createMock(EntityStorageInterface::class);
$map = [
[
['uri' => 'public://ocean.jpg'],
[$create_image('public://ocean.jpg')],
],
[
['uri' => 'private://sea.jpg'],
[$create_image('private://sea.jpg')],
],
];
$storage->method('loadByProperties')
->will($this->returnValueMap($map));
$entity_type_manager = $this->createMock(EntityTypeManagerInterface::class);
$entity_type_manager->method('getStorage')->willReturn($storage);
$this->container->set('entity_type.manager', $entity_type_manager);
// -- Public image with access check.
$build = $this->twigExtension->drupalImage('public://ocean.jpg');
$expected_build = [
'#uri' => 'public://ocean.jpg',
'#attributes' => [],
'#theme' => 'image',
'#cache' => [
'contexts' => ['user'],
'tags' => ['tag_for_public://ocean.jpg'],
'max-age' => 123,
],
];
self::assertSame($expected_build, $build);
// -- Public image without access check.
$build = $this->twigExtension->drupalImage('public://ocean.jpg', NULL, [], NULL, FALSE);
$expected_build = [
'#uri' => 'public://ocean.jpg',
'#attributes' => [],
'#theme' => 'image',
'#cache' => [
'contexts' => [],
'tags' => [],
'max-age' => Cache::PERMANENT,
],
];
self::assertSame($expected_build, $build);
// -- Private image with access check.
$build = $this->twigExtension->drupalImage('private://sea.jpg');
self::assertNull($build);
// -- Private image without access check.
$build = $this->twigExtension->drupalImage('private://sea.jpg', NULL, [], NULL, FALSE);
$expected_build = [
'#uri' => 'private://sea.jpg',
'#attributes' => [],
'#theme' => 'image',
'#cache' => [
'contexts' => [],
'tags' => [],
'max-age' => Cache::PERMANENT,
],
];
self::assertSame($expected_build, $build);
}
/**
* Test callback.
*/
public function testView() {
// -- Unprivileged user with access check.
$this->setUpCurrentUser(['name' => 'User 1']);
$build = $this->twigExtension->view($this->node);
self::assertNull($build);
// -- Unprivileged user without access check.
$build = $this->twigExtension->view($this->node, NULL, NULL, FALSE);
self::assertArrayHasKey('#node', $build);
$expected_cache = [
'tags' => [
'node_view',
'node:1',
],
'contexts' => [],
'max-age' => Cache::PERMANENT,
];
self::assertSame($expected_cache, $build['#cache']);
// -- Privileged user with access check.
$this->setUpCurrentUser(['name' => 'User 2'], ['access content']);
$build = $this->twigExtension->view($this->node, NULL);
self::assertArrayHasKey('#node', $build);
$expected_cache = [
'tags' => [
'node_view',
'node:1',
'tag_from_twig_tweak_test_node_access',
],
'contexts' => [
'user',
'user.permissions',
],
'max-age' => 50,
];
self::assertSame($expected_cache, $build['#cache']);
// -- Privileged user without access check.
$build = $this->twigExtension->view($this->node, NULL, NULL, FALSE);
self::assertArrayHasKey('#node', $build);
$expected_cache = [
'tags' => [
'node_view',
'node:1',
],
'contexts' => [],
'max-age' => Cache::PERMANENT,
];
self::assertSame($expected_cache, $build['#cache']);
}
}

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);
}
}

2
tests/twig_tweak_test/config/install/block.block.claro_powered_by_drupal.yml

@ -8,7 +8,7 @@ dependencies:
id: claro_powered_by_drupal id: claro_powered_by_drupal
theme: claro theme: claro
region: highlighted region: highlighted
weight: 0 weight: 20
provider: null provider: null
plugin: system_powered_by_block plugin: system_powered_by_block
settings: settings:

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

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

@ -1,12 +1,14 @@
uuid: 42d67a69-5099-4c17-b27e-32080699966b
langcode: en langcode: en
status: true status: true
dependencies: dependencies:
config: config:
- field.field.media.image.field_media_image - field.field.media.image.field_media_image
- image.style.large
- media.type.image - media.type.image
module: module:
- image - image
_core:
default_config_hash: jOwnt_yq6AKAfqU6f0xKnxEkFQ2eTPJWxrk3WMLbL68
id: media.image.default id: media.image.default
targetEntityType: media targetEntityType: media
bundle: image bundle: image
@ -15,8 +17,8 @@ content:
field_media_image: field_media_image:
label: visually_hidden label: visually_hidden
settings: settings:
image_style: large image_style: ''
image_link: '' image_link: file
third_party_settings: { } third_party_settings: { }
type: image type: image
weight: 1 weight: 1

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

@ -1,3 +1,4 @@
uuid: af776ed7-7db5-4670-b70b-4c94e06a6867
langcode: en langcode: en
status: true status: true
dependencies: dependencies:
@ -6,6 +7,8 @@ dependencies:
- media.type.remote_video - media.type.remote_video
module: module:
- media - media
_core:
default_config_hash: gUaDZKMQD3lmLKWPn727X3JHVdKJ525g4EJCCcDVAqk
id: media.remote_video.default id: media.remote_video.default
targetEntityType: media targetEntityType: media
bundle: remote_video bundle: remote_video

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

@ -1,3 +1,4 @@
uuid: 3fd16a0f-7a0d-46aa-8b80-2b1dc8484d09
langcode: en langcode: en
status: true status: true
dependencies: dependencies:
@ -7,8 +8,11 @@ dependencies:
- field.field.node.page.field_media - field.field.node.page.field_media
- node.type.page - node.type.page
module: module:
- image
- text - text
- user - user
_core:
default_config_hash: g1S3_GLaxq4l3I9RIca5Mlz02MxI2KmOquZpHw59akM
id: node.page.default id: node.page.default
targetEntityType: node targetEntityType: node
bundle: page bundle: page
@ -21,9 +25,24 @@ content:
region: content region: content
settings: { } settings: { }
third_party_settings: { } third_party_settings: { }
field_image:
weight: 102
label: above
settings:
image_style: ''
image_link: ''
third_party_settings: { }
type: image
region: content
field_media:
weight: 103
label: above
settings:
link: true
third_party_settings: { }
type: entity_reference_label
region: content
links: links:
weight: 101 weight: 101
region: content region: content
hidden: 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

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

@ -1,3 +1,4 @@
uuid: 31cfb877-de82-4020-b0f7-9ec72592495f
langcode: en langcode: en
status: true status: true
dependencies: dependencies:
@ -9,6 +10,8 @@ dependencies:
- media - media
module: module:
- image - image
_core:
default_config_hash: pzPA-2JwyxlJ3qMb4L9viAnhNhbEhl2couH8A3FO020
id: media.image.field_media_image id: media.image.field_media_image
field_name: field_media_image field_name: field_media_image
entity_type: media entity_type: media

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

@ -1,9 +1,12 @@
uuid: 83889a69-28e6-470c-bf31-4437a5a2c1cf
langcode: en langcode: en
status: true status: true
dependencies: dependencies:
config: config:
- field.storage.media.field_media_oembed_video - field.storage.media.field_media_oembed_video
- media.type.remote_video - media.type.remote_video
_core:
default_config_hash: Eo4HHenV5iZat_kEWgr_wydD3TgwURMCzwt-7qIEyoM
id: media.remote_video.field_media_oembed_video id: media.remote_video.field_media_oembed_video
field_name: field_media_oembed_video field_name: field_media_oembed_video
entity_type: media entity_type: media

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

@ -1,3 +1,4 @@
uuid: 42500952-ebb3-46c2-9a86-ed2f6c4000c1
langcode: en langcode: en
status: true status: true
dependencies: dependencies:
@ -6,6 +7,8 @@ dependencies:
- node.type.page - node.type.page
module: module:
- text - text
_core:
default_config_hash: rUop-8b6hvxxDYbv-KobTfNIBNbPY9qOPl8f6kBNSpw
id: node.page.body id: node.page.body
field_name: body field_name: body
entity_type: node entity_type: node
@ -18,5 +21,4 @@ default_value: { }
default_value_callback: '' default_value_callback: ''
settings: settings:
display_summary: true display_summary: true
required_summary: false
field_type: text_with_summary field_type: text_with_summary

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

@ -1,3 +1,4 @@
uuid: 0f21b708-4832-4777-91ea-236dddea0236
langcode: en langcode: en
status: true status: true
dependencies: dependencies:

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

@ -1,3 +1,4 @@
uuid: cc6b99d7-25b6-4d90-a65e-4021861ec6af
langcode: en langcode: en
status: true status: true
dependencies: dependencies:

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

@ -1,3 +1,4 @@
uuid: e8eb0ecd-b286-4d09-9152-fcc1e7868f17
langcode: en langcode: en
status: true status: true
dependencies: dependencies:
@ -8,6 +9,8 @@ dependencies:
- file - file
- image - image
- media - media
_core:
default_config_hash: 7ZBrcl87ZXaw42v952wwcw_9cQgTBq5_5tgyUkE-VV0
id: media.field_media_image id: media.field_media_image
field_name: field_media_image field_name: field_media_image
entity_type: media entity_type: media

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

@ -1,8 +1,11 @@
uuid: d879f1f4-2730-47bb-9f38-df6c265855b9
langcode: en langcode: en
status: true status: true
dependencies: dependencies:
module: module:
- media - media
_core:
default_config_hash: SJgxR5XWOesQbEKqp8VgInPyJjCFU_t2pi7UzYB78xg
id: media.field_media_oembed_video id: media.field_media_oembed_video
field_name: field_media_oembed_video field_name: field_media_oembed_video
entity_type: media entity_type: media

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

@ -1,3 +1,4 @@
uuid: 7d45d63c-3a03-4a65-8103-b12e70c69948
langcode: en langcode: en
status: true status: true
dependencies: dependencies:
@ -5,6 +6,8 @@ dependencies:
- file - file
- image - image
- node - node
_core:
default_config_hash: SkXIPKZYiIMMtnBmfnxk58RYfbZ8cHSw5NZPY_JByME
id: node.field_image id: node.field_image
field_name: field_image field_name: field_image
entity_type: node entity_type: node

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

@ -1,3 +1,4 @@
uuid: 8301cf4c-fba5-440b-8323-9a0019c12d34
langcode: en langcode: en
status: true status: true
dependencies: dependencies:

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:

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

@ -1,6 +1,9 @@
uuid: 6ef5b8e7-988c-4057-8a44-55fa195fef14
langcode: en langcode: en
status: true status: true
dependencies: { } dependencies: { }
_core:
default_config_hash: 6Qope5wG7HUpV0tPOBMtDI_GZkHFcF1Xj4hgD9Cu_hM
id: image id: image
label: Image label: Image
description: 'Use local images for reusable media.' description: 'Use local images for reusable media.'

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

@ -1,12 +1,15 @@
uuid: 1b4d7544-7b5e-47d2-b79e-d50d6b2442a0
langcode: en langcode: en
status: true status: true
dependencies: { } dependencies: { }
_core:
default_config_hash: d_nPD2eMknkYAnSTV4FkaqijceyFJPwT5i_Ih0lEEtc
id: remote_video id: remote_video
label: 'Remote video' label: 'Remote video'
description: 'A remotely hosted video from YouTube or Vimeo.' description: 'A remotely hosted video from YouTube or Vimeo.'
source: 'oembed:video' source: 'oembed:video'
queue_thumbnail_downloads: false queue_thumbnail_downloads: false
new_revision: true new_revision: false
source_configuration: source_configuration:
thumbnails_directory: 'public://oembed_thumbnails' thumbnails_directory: 'public://oembed_thumbnails'
providers: providers:

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

@ -1,3 +1,4 @@
uuid: 963c690a-b7c3-48dd-bc35-351857b9a7ce
langcode: en langcode: en
status: true status: true
dependencies: { } dependencies: { }

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'];
}
}

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

@ -15,19 +15,12 @@ use Drupal\Core\Session\AccountInterface;
* category = @Translation("Twig Tweak") * category = @Translation("Twig Tweak")
* ) * )
*/ */
final class FooBlock extends BlockBase { class FooBlock extends BlockBase {
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
public function defaultConfiguration(): array { protected function blockAccess(AccountInterface $account) {
return ['content' => 'Foo'];
}
/**
* {@inheritdoc}
*/
protected function blockAccess(AccountInterface $account): AccessResult {
$result = AccessResult::allowedIf($account->getAccountName() == 'User 1'); $result = AccessResult::allowedIf($account->getAccountName() == 'User 1');
$result->addCacheTags(['tag_from_' . __FUNCTION__]); $result->addCacheTags(['tag_from_' . __FUNCTION__]);
$result->setCacheMaxAge(35); $result->setCacheMaxAge(35);
@ -38,12 +31,9 @@ final class FooBlock extends BlockBase {
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
public function build(): array { public function build() {
return [ return [
'#markup' => $this->getConfiguration()['content'], '#markup' => 'Foo',
'#attributes' => [
'id' => 'foo',
],
'#cache' => [ '#cache' => [
'contexts' => ['url'], 'contexts' => ['url'],
'tags' => ['tag_from_' . __FUNCTION__], 'tags' => ['tag_from_' . __FUNCTION__],
@ -51,4 +41,11 @@ final class FooBlock extends BlockBase {
]; ];
} }
/**
* {@inheritdoc}
*/
public function getCacheTags() {
return ['tag_twig_tweak_test_foo_plugin'];
}
} }

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

@ -1,16 +1,9 @@
{% set image_attributes = {style: 'width: 30px; height 30px;'} %} {% 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);
@ -33,8 +26,7 @@
<div class="tt-region">{{ drupal_region('highlighted') }}</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', view_mode='teaser') }}</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-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-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>
@ -54,29 +46,22 @@
<div class="tt-url"> <div class="tt-url">
<div data-case="default">{{ drupal_url('node/1', {absolute: true}) }}</div> <div data-case="default">{{ drupal_url('node/1', {absolute: true}) }}</div>
<div data-case="with-langcode">{{ drupal_url('node/1', {absolute: true, langcode: 'ru'}) }}</div> <div data-case="with-langcode">{{ drupal_url('node/1', {absolute: true, langcode: 'ru'}) }}</div>
<div data-case="external">{{ drupal_url('https://example.com/node?foo=bar', {query: {page: 1}, fragment: 'here'}) }}</div>
</div> </div>
<div class="tt-link">{{ drupal_link('Edit', 'node/1/edit', {absolute: true}) }}</div> <div class="tt-link">{{ drupal_link('Edit', 'node/1/edit', {absolute: true}) }}</div>
<div class="tt-link-html">{% set link_text %}<b>Edit</b>{% endset %}{{ drupal_link(link_text, 'node/1/edit', {absolute: true}) }}</div> <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-messages">{{ drupal_messages() }}</div> <div class="tt-messages">{{ drupal_messages() }}</div>
<div class="tt-breadcrumb">{{ drupal_breadcrumb() }}</div> <div class="tt-breadcrumb">{{ drupal_breadcrumb() }}</div>
<div class="tt-link-access">{{ drupal_link('Administration', 'admin', {absolute: true}, true) }}</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-contextual-links">{{ contextual_links('node:node=1') }}</div>
<div class="tt-token-replace">{{ 'Site name: [site:name]'|token_replace }}</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-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-image-style">{{ 'public://images/ocean.jpg' | image_style('thumbnail') }}</div>
<div class="tt-transliterate">{{ 'Привет!'|transliterate('ru') }}</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-check-markup">{{ '<b>bold</b> <strong>strong</strong>' | check_markup('twig_tweak_test') }}</div>
<div class="tt-format-size">{{ 12345|format_size }}</div> <div class="tt-format-size">{{ 12345|format_size() }}</div>
<div class="tt-truncate">{{ 'Hello world!'|truncate(10, true, true) }}</div> <div class="tt-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">{{ {'#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-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"> <div class="tt-children">
{%- {%-
set build = { set build = {
@ -102,18 +87,7 @@
<div class="tt-image-style-from-file-uri-from-media-field">{{ media_uri|image_style('thumbnail') }}</div> <div class="tt-image-style-from-file-uri-from-media-field">{{ media_uri|image_style('thumbnail') }}</div>
{% endif %} {% endif %}
<div class="tt-file-url-from-uri">{{ 'public://image-test.png'|file_url }}</div> <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">{{ 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-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-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>

9
tests/twig_tweak_test/twig_tweak_test.info.yml

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

80
tests/twig_tweak_test/twig_tweak_test.module

@ -5,100 +5,42 @@
* 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!'); \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 = AccessResult::allowed();
$result->addCacheTags(['tag_from_' . __FUNCTION__]); $result->addCacheTags(['tag_from_' . __FUNCTION__]);
$result->cachePerUser(); $result->cachePerUser();
$result->setCacheMaxAge(50); $result->setCacheMaxAge(50);
return $result; 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".
*/

5
twig_tweak.info.yml

@ -1,6 +1,7 @@
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
core_version_requirement: ^8 || ^9
dependencies: dependencies:
- drupal:system (>=9.0) - drupal:system (>=8.7)

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