Compare commits

...

164 Commits
8.x-1.x ... 3.x

Author SHA1 Message Date
Thomas Frobieter c096aece9f Issue #3407926: Extend documentation for drupal_url() + add example for taxomy_term 9 months ago
Chi 4b72a2c3da #3356042: Clean-up image builder 9 months ago
Julian Pustkuchen 898b4521d1 Issue #3356042: drupal_image() needs width / height attributes for fully working image cache scale (Width and height calculation only) 9 months ago
James Shields b3947e26b7 Upload New logo.png File 9 months ago
Chi ed7ae10bc6 Issue #3373333 by paulsheldrake, Chi: New filter to base64 encode and image 10 months ago
Chi 34ce474c21 Update hook documentation 10 months ago
Chi 9e1594ce42 Change min PHP version in CI config 10 months ago
Chi 81ac9123dd Add CI config 10 months ago
Jeff Mann 5ad73a7494 Issue #2980324 by JeffM2001, Chi: Document how to use block contexts 10 months ago
Chi 740ce17fd2 Issue #3386691 by juagarc4: drupal_field not working with Layout Builder enabled 10 months ago
Kyrylo Loboda 4111163f51 Issue #3405514 by lobodacyril, kevinquillen: drupal_entity works incorrect when the source content is unpublished 10 months ago
Chi ce34d528ae Issue #3223186: Add a test for external URL 10 months ago
prashantdsala 91992ea10d Applied changes from the patch provided in #5 10 months ago
Chi 41144dddb5 Clean-up documentation 10 months ago
Chi 271e723d67 Issue #3402615: Update documentation for drupal_view_result 10 months ago
Julian Pustkuchen 248ea4d7db Issue #3356080: Add lazy loading parameter to drupal_image() or add attribute example in Cheat Sheet 10 months ago
Reinhard Hutter f2dbdeaa24 Update description of with filter 1 year ago
Bill Seremetis 4a4cf380c5 Issue #3384540: Update 2.x -> 3.x documentation 1 year ago
Akshay Singh a165bdd94e Issue #3379607: use statements MUST be sorted alphabetically 1 year ago
Julian Pustkuchen d7c8b28690 Issue #3359592: Better explain $view_mode parameter for drupal_field() 1 year ago
Julian Pustkuchen 2e11f9b7ad Issue #3359146: Wrong preg_replace in ImageViewBuilderTest removes relevant attributes 1 year ago
Julian Pustkuchen adcf39754b Issue #3356079: Is the drupal_image() responsive parameter example correct? 1 year ago
Alison Jo 832a021ccb Issue #3363045: Feature(s) missing documentation 1 year ago
Julian Pustkuchen 0aea215552 Issue #3356155: Update twig/twig 1 year ago
Chi 25155dde83 Fix tests 1 year ago
Chi d77a4f7b8a Clean-up README.md 2 years ago
Samata soni a0c6fdc056 Update README.md 2 years ago
Chi 2c70fa668f Fix tests 2 years ago
Daniel Korte 75c512a6ca Issue #3324280 by Daniel Korte: Use TwigFilter instead of TwigFunction in hook_twig_tweak_filters_alter() 2 years ago
Chi 6073d18a46 Fix documentation of Field View Builder 2 years ago
Chi 8b33f99a8f Document magic properties in extractor services 2 years ago
Chi 8aa63ea1f2 Code clean-up 2 years ago
JeroenT 3e1a888711 Issue #3279991: drupal_entity_form add support for content moderation 2 years ago
Chi b13ba8b707 Fix image view builder test 2 years ago
Barry Fisher b36c208cc2 Issue #3260248 by Barry_Fisher: BlockViewBuilder error prevents cron from running when called from CLI 2 years ago
Ranjith Kumar K U 2de249ba8c Issue #3295743 by ranjith_kumar_k_u, Dharti Patel, dipesh_goswami, akshaydalvi212, Chi: PHPCS Drupal Coding Standard Issues 2 years ago
Chi f2805a1cc1 Fix tests 2 years ago
Anybody 848e2a8a63 Issue #3309673: (How to) overwrite drupal_view() views settings or provide parameters? 2 years ago
Chi b25684f98a Issue #3134193 by Anybody: Update cheat sheet 2 years ago
Chi 0883b1594e Clean-up documentation 2 years ago
Chi f7a9c7d997 Issue #3282742 by kenrbnsn: Support Twig 3 2 years ago
Jasper Lammens 27b50a6cb6 Add missing reference 3 years ago
kimberllyAmaral 3fd384e455 Update links 3 years ago
Chi 530adc97b8 Support Drupal 10 3 years ago
Chi 8dedaf4e61 Code clean-up 3 years ago
hudri 63947e19ad drupal_image: return first image by fid in case of ambiguous selector 3 years ago
gpotter fd7ee18be2 Issue #3222666 by gpotter, theRuslan: drupal_menu() incorrectly caches the active parent menu item in two-leveled menu 3 years ago
Chi 4037c5f7cf Fix broken tests 3 years ago
kristiaanvandeneynde 7a8e01b9b9 Issue #3245953 by kristiaanvandeneynde: TwigExtension::drupalTitle() should check for NullRouteMatch 3 years ago
Chi d4a7cf8af5 Add entity_url and entity_link filters 3 years ago
Chi c853e4027a Revert "Remove usage of deprecated functions" 3 years ago
Chi 0014fee04c Remove usage of deprecated functions 3 years ago
Chi 700d6c4ef9 Fix tests 3 years ago
Chi 3b17ed3500 Code clean-up 3 years ago
Juan Peña 1aad751621 Issue #3219625: Possible Variable Overwrite 3 years ago
sonfd 58ca88b667 Issue #3222014 by sonfd: Allow custom display settings for drupal_field() 3 years ago
Chi 22adcf19b9 Fix spelling 4 years ago
xandeadx c336ca8a19 Issue #3212340 by xandeadx: drupal_block() show fatal error if block plugin return NULL 4 years ago
Chi 36bb935644 #3211870 Downgrade symfony/polyfill-php80 requirement 4 years ago
WilfredWaltman 04a07da602 Issue #3188441 by seanB, Wilfred Waltman: Allow arguments to be passed to token_replace 4 years ago
Chi 4bbf7f4786 Code clean-up 4 years ago
JeroenT a4531aefe3 Update UriExtractor.php 4 years ago
JeroenT 7eb023dd00 Update UrlExtractor.php 4 years ago
Chi b0d20f011d Code clean-up 4 years ago
git f00f28dd05 Issue #3207943 by nimoatwoodway: Add UUID support to drupal_entity 4 years ago
Chi d3ac495203 Fix composer contraint 4 years ago
Chi e364f07c26 Fix deprecations in tests 4 years ago
Chi c6ce6aeea9 Add 'cache_metadata' Twig filter 4 years ago
Chi bf226278b0 Add docoumentation for translation filter 4 years ago
Chi 7ec37ae520 Issue #3185016 by hanoii, Chi: Translation filter 4 years ago
Chi d1f528277c Issue #3173946 by Charlie ChX Negyesi, Chi: drupal_region() overcaches empty 4 years ago
Chi 2e6361e496 Added docs index 4 years ago
Chi 2345de8977 Clean-up views.md 4 years ago
Chi 6b71d8e16d Clean-up migration guide 4 years ago
Chi 9bee786c06 Clean up Cheat Sheet 4 years ago
Chi cde44d9979 Update blocks.md 4 years ago
Chi c50c2dba28 Update blocks.md 4 years ago
Chi 36ec6eedc0 Clean-up blocks.md 4 years ago
Chi f2ba590e83 Issue #3174273 by Chi, mherchel, peterkokot, bbu23, ryan.gibson, shelane: Add blocks.md 4 years ago
Chi d4d6f763b4 Issue #3174273 by scm6079, rovo, Webbeh: Added views.md 4 years ago
Chi ecb1fd81d3 Issue #3174273: Add migration guide 4 years ago
Chi 7b45fedc79 Issue #3174273 by Chi, richienabuk, HongPong, diamondsea, lennyaspen, mherchel, rpayanm, Dan_Rogers, agileadam, hudri, Webbeh, paper boy, Murz, tobiasb, a_alshamiri, kovtunos, alisonjo315, geek-merlin, seutje: Add cheat-sheet.md 4 years ago
Chi 2149c32ed2 Fix coding standarts 4 years ago
Chi f3576306f3 Add twig-debug command 4 years ago
Chi 326c45b0fa Rename twig-validate command class 4 years ago
Chi 993f69540d Add twig-validate command 4 years ago
Niklan 2fd6b6c5b9 Issue #3174903 by Niklan, ygoex: Error: Call to a member function getRegion() on null in hook_theme_suggestions_block_alter() 4 years ago
Chi e6f49368b6 Use cache metadata from parent entity when rendering fields 4 years ago
Panchuk 1f5da25547 Issue #3162777 by Panchuk: License "GPL-2.0+" is a deprecated SPDX license identifier 4 years ago
Chi 62e3cdd7b7 Code clean-up 4 years ago
aspilicious 30472b7b1c Issue #3068078 by aspilicious: Block attributes and contextual links are removed 4 years ago
Chi 3aad1eb11a Fix tests 4 years ago
Chi 78856ccea6 Rename AbstractExtractorTest.php file 4 years ago
Chi 7cf8595b49 Rename AbstractExtractorTest class 4 years ago
Chi 8ab073bf37 Add URI extractor service 4 years ago
Chi 826cbd5e4c Add URL extractor service 4 years ago
Chi 596951b0ef Fix viewFilter() signature 4 years ago
mdupont f3bfc6d7ce Issue #3095714 by mdupont, Chi: Add file_uri filter 5 years ago
Chi 47e2509afc Issue #3135932 by mxwright: Twig variable in PHP filter 5 years ago
Chi 001f4a3657 Update documentation on contextual links 5 years ago
mdupont 54aed14429 Issue #3095714 by mdupont: Make image_style more powerful 5 years ago
Chi 786963c0ed Add ID to blocks 5 years ago
Chi 904b13614e Issue #2994996 by aangel: Field access check should test using language 5 years ago
Chi a45e6182b5 Add alter hooks for functions, filters and tests. 5 years ago
Chi ca644ba769 Make image path optional 5 years ago
Chi 3f3633dcfe Clean-up BlockViewBuilder.php 5 years ago
Chi 790cc4c261 Add cache keys for drupal_menu() 5 years ago
Chi 14e6be65b2 Add cache keys to block build 5 years ago
Chi fa48bc4895 Clean-up tests 5 years ago
Chi a1bd01c6df Issue #3087368 Add 'format_size' filter 5 years ago
NesleeCanilPinto 2610bbfcff Issue #3124739 by Neslee Canil Pinto: Typo error in codebase 5 years ago
Chi d20dc4e2b8 Require Twig 2 5 years ago
Chi 05f6c338a7 Update README.md 5 years ago
Chi 3f80912345 Add view builders and clean-up 5 years ago
Chi d15f246bd9 Buble access result cache metadata 5 years ago
neclimdul 948ce4e371 Issue #3094257 by neclimdul: contextualLinks typo in function registration 5 years ago
Chi e72e7779c9 Support Drupal 9 5 years ago
Chi f910f7aa7f Add 'core_version_requirement' key 5 years ago
Chi d84e7e30af Format examples 5 years ago
Chi fa6b362aed Deprecate loading entitiies from route 5 years ago
Chi 5372b821b2 Issue #3053556 by phjou: image_style doesn't support SVG 5 years ago
Chi 5ab80a4c36 Fix documentation for drupal_field() function 5 years ago
Chi 50c7bbb671 Clean-up tests 5 years ago
Chi 27237912ef Add contetextual_links() function 5 years ago
Chi fa18205d16 Add documentation with examples 5 years ago
Chi cde0be4a1e Support nested values in 'with' filter 5 years ago
Chi 6234d1a487 Trigger error when image style does not exist 5 years ago
Chi 6b1ae2d7cc Issue #2924925 by Leon Kessler, Etroid: File url from image and media field 5 years ago
Chi 327acbff49 Do not display wrapper for empty blocks 6 years ago
Chi 019921476d Clean up drupal_block() 6 years ago
Chi 2db5ea00a7 Issue #3013136 by Majdi: Page title block always empty 6 years ago
eiriksm 32e89c93a7 Issue #3045120 by eiriksm: drupal_block does not use the plugin cache metadata 6 years ago
git a21b6dc597 Issue #3041966 by Mithun S: Capitalize the name of the module in .info.yml file 6 years ago
Chi 5df42502d6 Issue #3040238 by Peter Törnstrand: Add a test for langcode option 6 years ago
blixxxa 6fb5eeb3e4 Issue #3040238 by Peter Törnstrand: Add language support to drupal_url 6 years ago
Chi 185c743ab4 Add 'children' filter 6 years ago
Chi dd983bf1f7 Fix broken status messages test 6 years ago
hanoii 6aae559040 Issue #2978898 by hanoii: Add support for HTML markup on drupal_link 6 years ago
Chi 9997416595 Added support for 'add entity' forms. 7 years ago
Prashant.c 40d9540c33 Issue #2958571 by elgordogrande, Prashant.c, Fabianx: DrupalBlock cache error when empty 7 years ago
Chi aab4fd350a Added drupal_entity_form() Twig function. 7 years ago
Chi b8e8e0a374 Fixed default dump argument. 7 years ago
Chi 96d4333f0d Fixed wrong dump options. 7 years ago
Chi 6b36f2c266 Cleanup drupal_debug() function. 7 years ago
Chi d5403ae274 Added drupal_breakpoint() function. 7 years ago
Chi ec952a1cd5 Fixed Unicode::truncate() link. 7 years ago
Chi de13264a60 Added 'with' Twig filter. 7 years ago
Chi 3b1bb1e239 Added truncate() Twig function. 7 years ago
Chi 6d5069ef06 Added drupal_breadcrumb() function. 7 years ago
Chi c3791bd1f3 Add drupal_messages Twig function. 7 years ago
Chi 4277c76990 Remove drupal_set_message Twig function. 7 years ago
Chi f8f1a94c22 Issue #2952972 by scotthooker, Chi: Rendering blocks in the same way panels does 7 years ago
Chi 6a024c5df3 Code cleanup. 7 years ago
Chi 2ae527f6ec Added view filter. 7 years ago
Chi 03d0c30e8e Make drupal_field() respect curent site language. 7 years ago
smk-ka ce15ad15ba Issue #2945085 by smk-ka: Add option to expand menu items 7 years ago
Chi 4507a6c1a2 Issue #2848104 by davidraijmakers, sgalindo2388, lzimmerman: Add drupal_image function. 7 years ago
hanoii 16b0cc1923 Issue #2942287 by hanoii: Improve drupal_url with an optional access check and add drupal_link as well 7 years ago
Chi 6a4ea3d943 Issue #2913809 by julien.sibi, Chi: Ouput drupal_region() function using region's Twig template. 7 years ago
julien.sibi 25d45394e7 Issue #2913809 by julien.sibi: Ouput drupal_region() function using region's Twig template 7 years ago
Chi 147fbd900d Issue #2925554 by Mookum: Add drupal_view_result() function. 7 years ago
Chi 0f49ad32bd Removed legacy code. 7 years ago
Chi 8118336510 Issue #2906140 Code cleanup. 7 years ago
Koen.Pasman c1863a4cd7 Issue #2906140 by Koen.Pasman: Rendering Plugin Blocks 7 years ago
  1. 9
      .gitlab-ci.yml
  2. 23
      README.md
  3. 9
      README.txt
  4. 24
      composer.json
  5. 6
      docs/README.md
  6. 126
      docs/blocks.md
  7. 403
      docs/cheat-sheet.md
  8. 69
      docs/migration-to-3.x.md
  9. 63
      docs/views.md
  10. 11
      drush.services.yml
  11. BIN
      logo.png
  12. 16
      phpcs.xml
  13. 52
      src/CacheMetadataExtractor.php
  14. 217
      src/Command/DebugCommand.php
  15. 271
      src/Command/LintCommand.php
  16. 37
      src/Command/ValidateCommand.php
  17. 497
      src/TwigExtension.php
  18. 729
      src/TwigTweakExtension.php
  19. 87
      src/UriExtractor.php
  20. 117
      src/UrlExtractor.php
  21. 186
      src/View/BlockViewBuilder.php
  22. 61
      src/View/EntityFormViewBuilder.php
  23. 57
      src/View/EntityViewBuilder.php
  24. 76
      src/View/FieldViewBuilder.php
  25. 98
      src/View/ImageViewBuilder.php
  26. 84
      src/View/MenuViewBuilder.php
  27. 114
      src/View/RegionViewBuilder.php
  28. 449
      tests/src/Functional/TwigTweakTest.php
  29. 93
      tests/src/Kernel/AbstractExtractorTestCase.php
  30. 45
      tests/src/Kernel/AbstractTestCase.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. 10
      tests/twig_tweak_test/config/install/block.block.claro_powered_by_drupal.yml
  42. 17
      tests/twig_tweak_test/config/install/block.block.classy_page_title.yml
  43. 19
      tests/twig_tweak_test/config/install/block.block.classy_status_messages.yml
  44. 80
      tests/twig_tweak_test/config/install/core.entity_form_display.node.page.default.yml
  45. 28
      tests/twig_tweak_test/config/install/core.entity_view_display.media.image.default.yml
  46. 27
      tests/twig_tweak_test/config/install/core.entity_view_display.media.remote_video.default.yml
  47. 29
      tests/twig_tweak_test/config/install/core.entity_view_display.node.page.default.yml
  48. 31
      tests/twig_tweak_test/config/install/core.entity_view_display.node.page.teaser.yml
  49. 40
      tests/twig_tweak_test/config/install/field.field.media.image.field_media_image.yml
  50. 18
      tests/twig_tweak_test/config/install/field.field.media.remote_video.field_media_oembed_video.yml
  51. 22
      tests/twig_tweak_test/config/install/field.field.node.page.body.yml
  52. 37
      tests/twig_tweak_test/config/install/field.field.node.page.field_image.yml
  53. 29
      tests/twig_tweak_test/config/install/field.field.node.page.field_media.yml
  54. 32
      tests/twig_tweak_test/config/install/field.storage.media.field_media_image.yml
  55. 20
      tests/twig_tweak_test/config/install/field.storage.media.field_media_oembed_video.yml
  56. 31
      tests/twig_tweak_test/config/install/field.storage.node.field_image.yml
  57. 19
      tests/twig_tweak_test/config/install/field.storage.node.field_media.yml
  58. 2
      tests/twig_tweak_test/config/install/filter.format.twig_tweak_test.yml
  59. 13
      tests/twig_tweak_test/config/install/media.type.image.yml
  60. 17
      tests/twig_tweak_test/config/install/media.type.remote_video.yml
  61. 12
      tests/twig_tweak_test/config/install/node.type.page.yml
  62. 2
      tests/twig_tweak_test/config/install/system.menu.twig-tweak-test.yml
  63. 3
      tests/twig_tweak_test/config/install/views.view.twig_tweak_test.yml
  64. 17
      tests/twig_tweak_test/src/Controller/TwigTweakTestController.php
  65. 54
      tests/twig_tweak_test/src/Plugin/Block/FooBlock.php
  66. 89
      tests/twig_tweak_test/templates/twig-tweak-test.html.twig
  67. 10
      tests/twig_tweak_test/twig_tweak_test.info.yml
  68. 87
      tests/twig_tweak_test/twig_tweak_test.module
  69. 7
      tests/twig_tweak_test/twig_tweak_test.routing.yml
  70. 64
      twig_tweak.api.php
  71. 6
      twig_tweak.info.yml
  72. 42
      twig_tweak.services.yml

9
.gitlab-ci.yml

@ -0,0 +1,9 @@
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

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

@ -1,9 +0,0 @@
-- 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

24
composer.json

@ -3,13 +3,27 @@
"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+", "license": "GPL-2.0-or-later",
"homepage": "https://www.drupal.org/project/twig_tweak", "homepage": "https://www.drupal.org/project/twig_tweak",
"suggest": {
"symfony/var-dumper": "better dump() function for debugging Twig variables"
},
"support": { "support": {
"issues": "https://www.drupal.org/project/issues/twig_tweak", "issues": "https://www.drupal.org/project/issues/twig_tweak",
"source": "http://cgit.drupalcode.org/twig_tweak" "source": "https://git.drupalcode.org/project/twig_tweak"
},
"require": {
"php": ">=7.3",
"ext-json": "*",
"drupal/core": "^9.3 || ^10.0",
"twig/twig": "^2.15.3 || ^3.4.3",
"symfony/polyfill-php80": "^1.17"
},
"suggest": {
"symfony/var-dumper": "Better dump() function for debugging Twig variables"
},
"extra": {
"drush": {
"services": {
"drush.services.yml": "^9 || ^10 || ^11"
}
}
} }
} }

6
docs/README.md

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

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

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

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

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

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

After

Width:  |  Height:  |  Size: 9.6 KiB

16
phpcs.xml

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

52
src/CacheMetadataExtractor.php

@ -0,0 +1,52 @@
<?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

@ -0,0 +1,217 @@
<?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

@ -0,0 +1,271 @@
<?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

@ -0,0 +1,37 @@
<?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
);
}
}

497
src/TwigExtension.php

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

729
src/TwigTweakExtension.php

@ -0,0 +1,729 @@
<?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

@ -0,0 +1,87 @@
<?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

@ -0,0 +1,117 @@
<?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

@ -0,0 +1,186 @@
<?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

@ -0,0 +1,61 @@
<?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

@ -0,0 +1,57 @@
<?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

@ -0,0 +1,76 @@
<?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

@ -0,0 +1,98 @@
<?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

@ -0,0 +1,84 @@
<?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

@ -0,0 +1,114 @@
<?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;
}
}

449
tests/src/Functional/TwigTweakTest.php

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

93
tests/src/Kernel/AbstractExtractorTestCase.php

@ -0,0 +1,93 @@
<?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

@ -0,0 +1,45 @@
<?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']);
}
}
}

171
tests/src/Kernel/BlockViewBuilderTest.php

@ -0,0 +1,171 @@
<?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

@ -0,0 +1,99 @@
<?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

@ -0,0 +1,130 @@
<?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

@ -0,0 +1,202 @@
<?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

@ -0,0 +1,140 @@
<?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

@ -0,0 +1,257 @@
<?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

@ -0,0 +1,124 @@
<?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

@ -0,0 +1,154 @@
<?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

@ -0,0 +1,61 @@
<?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

@ -0,0 +1,87 @@
<?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);
}
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -2,6 +2,6 @@ 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,7 +11,6 @@ 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
@ -181,7 +180,7 @@ display:
position: 1 position: 1
display_options: display_options:
display_extenders: { } display_extenders: { }
path: twig-tweak-test path: example
cache_metadata: cache_metadata:
max-age: -1 max-age: -1
contexts: contexts:

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

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

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

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

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

@ -1,8 +1,16 @@
{% 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; margin: 15px auto;
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);
@ -19,26 +27,93 @@
<div class="tt-view-default">{{ drupal_view('twig_tweak_test') }}</div> <div class="tt-view-default">{{ drupal_view('twig_tweak_test') }}</div>
<div class="tt-view-page_1">{{ drupal_view('twig_tweak_test', 'page_1') }}</div> <div class="tt-view-page_1">{{ drupal_view('twig_tweak_test', 'page_1') }}</div>
<div class="tt-view-page_1-with-argument">{{ drupal_view('twig_tweak_test', 'page_1', 1) }}</div> <div class="tt-view-page_1-with-argument">{{ drupal_view('twig_tweak_test', 'page_1', 1) }}</div>
<div class="tt-block">{{ drupal_block('classy_powered_by_drupal') }}</div> <div class="tt-view-result">{{ drupal_view_result('twig_tweak_test', 'page_1')|length }}</div>
<div class="tt-region">{{ drupal_region('sidebar_first') }}</div> <div class="tt-block">{{ drupal_block('system_branding_block', {use_site_name: false}, false) }}</div>
<div class="tt-block-with-wrapper">{{ drupal_block('system_branding_block', {label: 'Branding'}) }}</div>
<div class="tt-region">{{ drupal_region('highlighted') }}</div>
<div class="tt-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-from-url">{{ drupal_entity('node') }}</div> <div class="tt-entity-uuid">{{ drupal_entity('node', 'ad1b902a-344f-41d1-8c61-a69f0366dbfa') }}</div>
<div class="tt-entity-uuid-missing">{{ drupal_entity('node', 'zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz') }}</div>
<div class="tt-entity-add-form">{{ drupal_entity_form('node', values={type: 'page'}) }}</div>
<div class="tt-entity-edit-form">{{ drupal_entity_form('node', 1) }}</div>
<div class="tt-field">{{ drupal_field('body', 'node', 1) }}</div> <div class="tt-field">{{ drupal_field('body', 'node', 1) }}</div>
<div class="tt-menu-default">{{ drupal_menu('twig-tweak-test') }}</div> <div class="tt-menu-default">{{ drupal_menu('twig-tweak-test') }}</div>
<div class="tt-menu-level">{{ drupal_menu('twig-tweak-test', 2) }}</div> <div class="tt-menu-level">{{ drupal_menu('twig-tweak-test', 2) }}</div>
<div class="tt-menu-depth">{{ drupal_menu('twig-tweak-test', 1, 1) }}</div> <div class="tt-menu-depth">{{ drupal_menu('twig-tweak-test', 1, 1) }}</div>
<div class="tt-form">{{ drupal_form('Drupal\\system\\Form\\CronForm') }}</div> <div class="tt-form">{{ drupal_form('Drupal\\system\\Form\\CronForm') }}</div>
<div class="tt-image-by-fid">{{ drupal_image(1, attributes=image_attributes) }}</div>
<div class="tt-image-by-uri">{{ drupal_image('public://image-test.png', attributes=image_attributes) }}</div>
<div class="tt-image-by-uuid">{{ drupal_image('b2c22b6f-7bf8-4da4-9de5-316e93487518', attributes=image_attributes) }}</div>
<div class="tt-image-with-style">{{ drupal_image(1, 'thumbnail', image_attributes) }}</div>
<div class="tt-image-with-responsive-style">{{ drupal_image(1, 'example', image_attributes, true) }}</div>
<div class="tt-token">{{ drupal_token('site:name') }}</div> <div class="tt-token">{{ drupal_token('site:name') }}</div>
<div class="tt-token-data">{{ drupal_token('node:title', {'node': node}) }}</div> <div class="tt-token-data">{{ drupal_token('node:title', {'node': node}) }}</div>
<div class="tt-config">{{ drupal_config('user.settings', 'anonymous') }}</div> <div class="tt-config">{{ drupal_config('user.settings', 'anonymous') }}</div>
<div class="tt-status-message">{{ drupal_set_message('Hi!', 'warning') }}</div>
<div class="tt-title">{{ drupal_title() }}</div> <div class="tt-title">{{ drupal_title() }}</div>
<div class="tt-url">{{ drupal_url('node/1', {absolute: true}) }}</div> <div class="tt-url">
<div data-case="default">{{ drupal_url('node/1', {absolute: true}) }}</div>
<div data-case="with-langcode">{{ drupal_url('node/1', {absolute: true, langcode: 'ru'}) }}</div>
<div data-case="external">{{ drupal_url('https://example.com/node?foo=bar', {query: {page: 1}, fragment: 'here'}) }}</div>
</div>
<div class="tt-link">{{ drupal_link('Edit', 'node/1/edit', {absolute: true}) }}</div>
<div class="tt-link-html">{% set link_text %}<b>Edit</b>{% endset %}{{ drupal_link(link_text, 'node/1/edit', {absolute: true}) }}</div>
<div class="tt-messages">{{ drupal_messages() }}</div>
<div class="tt-breadcrumb">{{ drupal_breadcrumb() }}</div>
<div class="tt-link-access">{{ drupal_link('Administration', 'admin', {absolute: true}, true) }}</div>
<div class="tt-contextual-links">{{ drupal_contextual_links('node:node=1') }}</div>
<div class="tt-token-replace">{{ 'Site name: [site:name]'|token_replace }}</div> <div class="tt-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-preg-replace-legacy">{{ 'foo' | preg_replace('(foo)', '$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-truncate">{{ 'Hello world!'|truncate(10, true, true) }}</div>
<div class="tt-with">{{ {'#markup':'Example'}|with('#prefix', '<b>')|with('#suffix', '</b>') }}</div>
<div class="tt-with-nested">{{ {alpha: {beta: {gamma: 123}}}|with(['alpha', 'beta', 'gamma'], 456)|json_encode|replace({'"':''}) }}</div>
<div class="tt-data-uri-svg">
<img src="{{ '<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="50" fill="lime"/></svg>'|data_uri('image/svg+xml') }}" alt="{{ 'Rectangle'|t }}" style="height: 50px;"/>
</div>
<div class="tt-data-uri-iframe">
<iframe src="{{ '<h1>Hello world!</h1>'|data_uri('text/html', {charset: 'UTF-8'}) }}"></iframe>
</div>
<div class="tt-children">
{%-
set build = {
're': {'#markup': 're'},
'#sol': {'#markup': '#sol'},
'mi': {'#markup': 'mi'},
'#fa': {'#markup': '#fa'},
'do': {'#markup': 'do', '#weight': -10},
}
-%}
{%- for value in build|children(true) -%}
{{- value -}}
{%- endfor -%}
</div>
<div class="tt-node-view">{{ node|view }}</div>
<div class="tt-field-list-view">{{ node.title|view }}</div>
<div class="tt-field-item-view">{{ node.title[0]|view }}</div>
<div class="tt-file-uri-from-image-field">{{ node.field_image|file_uri }}</div>
<div class="tt-file-uri-from-image-field-delta">{{ node.field_image[0]|file_uri }}</div>
{% set media_uri = node.field_media|file_uri %}
<div class="tt-file-uri-from-media-field">{{ media_uri }}</div>
{% if media_uri is not null %}
<div class="tt-image-style-from-file-uri-from-media-field">{{ media_uri|image_style('thumbnail') }}</div>
{% endif %}
<div class="tt-file-url-from-uri">{{ 'public://image-test.png'|file_url }}</div>
<div class="tt-file-url-from-uri-absolute">{{ 'public://image-test.png'|file_url(false) }}</div>
<div class="tt-file-url-from-image-field">{{ node.field_image|file_url }}</div>
<div class="tt-file-url-from-image-field-delta">{{ node.field_image[0]|file_url }}</div>
<div class="tt-file-url-from-media-field">{{ node.field_media|file_url }}</div>
<div class="tt-entity-url">{{ node|entity_url(options={fragment: 'test'}) }}</div>
<div class="tt-entity-url-absolute">{{ node|entity_url(options={absolute: true}) }}</div>
<div class="tt-entity-url-edit-form">{{ node|entity_url('edit-form') }}</div>
<div class="tt-entity-link">{{ node|entity_link }}</div>
<div class="tt-entity-link-absolute">{{ node|entity_link('Example', options={absolute: true}) }}</div>
<div class="tt-entity-link-edit-form">{{ node|entity_link('Edit', 'edit-form') }}</div>
<div class="tt-translation">{{ (node|translation).title.value }}</div>
<div class="tt-functions_alter">{{ foo('bar') }}</div>
<div class="tt-filters_alter">{{ 'foo'|bar }}</div>
<div class="tt-tests_alter">{{ 'ok' is ok ? 'Yes' : 'No' }}</div>
</div> </div>

10
tests/twig_tweak_test/twig_tweak_test.info.yml

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

87
tests/twig_tweak_test/twig_tweak_test.module

@ -5,23 +5,100 @@
* 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\AccessResultInterface;
use Drupal\file\FileInterface;
use Drupal\node\Entity\Node;
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(array &$page_bottom) { function twig_tweak_test_page_bottom(): void {
$page_bottom['twig_tweak_test']['#theme'] = 'twig_tweak_test'; Drupal::service('messenger')->addMessage('Hello world!');
} }
/** /**
* Implements hook_theme(). * Implements hook_theme().
*/ */
function twig_tweak_test_theme() { function twig_tweak_test_theme(): array {
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(&$vars) { function template_preprocess_twig_tweak_test(array &$vars): void {
$vars['node'] = Drupal::routeMatch()->getParameter('node'); $vars['node'] = Node::load(1);
}
/**
* 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().
*
* @see \Drupal\Tests\twig_tweak\Kernel\EntityViewBuilderTest
*/
function twig_tweak_test_node_access(NodeInterface $node): AccessResultInterface {
$result = AccessResult::forbiddenIf($node->getTitle() == 'Private node');
$result->addCacheTags(['tag_from_' . __FUNCTION__]);
$result->cachePerUser();
$result->setCacheMaxAge(50);
return $result;
}
/**
* Implements hook_file_access().
*
* @see \Drupal\Tests\twig_tweak\Kernel\ImageViewBuilderTest
*/
function twig_tweak_test_file_access(FileInterface $file): AccessResultInterface {
$is_public = parse_url($file->getFileUri(), PHP_URL_SCHEME) == 'public';
$result = AccessResult::allowedIf($is_public);
$result->cachePerUser();
$result->addCacheTags(['tag_for_' . $file->getFileUri()]);
$result->setCacheMaxAge(70);
return $result;
}
/**
* Implements hook_twig_tweak_functions_alter().
*/
function twig_tweak_test_twig_tweak_functions_alter(array &$functions) {
$functions[] = new TwigFunction('foo', function (string $value): string {
return "-=$value=-";
});
}
/**
* Implements hook_twig_tweak_filters_alter().
*/
function twig_tweak_test_twig_tweak_filters_alter(array &$filters) {
$filters[] = new TwigFilter('bar', function (string $input): string {
return str_replace('foo', 'bar', $input);
});
}
/**
* Implements hook_twig_tweak_tests_alter().
*/
function twig_tweak_test_twig_tweak_tests_alter(array &$tests) {
$tests[] = new TwigTest('ok', function ($input): bool {
return $input == 'ok';
});
} }

7
tests/twig_tweak_test/twig_tweak_test.routing.yml

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

@ -0,0 +1,64 @@
<?php
/**
* @file
* Hooks specific to the Twig Tweak module.
*/
use Drupal\Component\Utility\Unicode;
use Drupal\node\NodeInterface;
use Twig\TwigFilter;
use Twig\TwigFunction;
use Twig\TwigTest;
/**
* @addtogroup hooks
* @{
*/
/**
* Alters Twig Tweak functions.
*
* @param \Twig\TwigFunction[] $functions
* Twig functions to alter.
*/
function hook_twig_tweak_functions_alter(array &$functions): void {
// @phpcs:disable
// A simple way to implement lazy loaded global variables.
$callback = static fn (string $name): ?string =>
match ($name) {
'foo' => 'Foo',
'bar' => 'Bar',
default => NULL,
};
$functions[] = new TwigFunction('var', $callback);
// @phpcs:enable
}
/**
* Alters Twig Tweak filters.
*
* @param \Twig\TwigFilter[] $filters
* Twig filters to alter.
*/
function hook_twig_tweak_filters_alter(array &$filters): void {
$filters[] = new TwigFilter('str_pad', 'str_pad');
$filters[] = new TwigFilter('ucfirst', [Unicode::class, 'ucfirst']);
$filters[] = new TwigFilter('lcfirst', [Unicode::class, 'lcfirst']);
}
/**
* Alters Twig Tweak tests.
*
* @param \Twig\TwigTest[] $tests
* Twig tests to alter.
*/
function hook_twig_tweak_tests_alter(array &$tests): void {
$callback = static fn (NodeInterface $node): bool =>
\Drupal::time()->getRequestTime() - $node->getCreatedTime() > 3600 * 24 * 365;
$tests[] = new TwigTest('outdated', $callback);
}
/**
* @} End of "addtogroup hooks".
*/

6
twig_tweak.info.yml

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

42
twig_tweak.services.yml

@ -1,5 +1,45 @@
services: services:
twig_tweak.twig_extension: twig_tweak.twig_extension:
class: Drupal\twig_tweak\TwigExtension class: Drupal\twig_tweak\TwigTweakExtension
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