diff --git a/README.md b/README.md new file mode 100644 index 0000000..cded392 --- /dev/null +++ b/README.md @@ -0,0 +1,294 @@ +## SUMMARY + +Twig Tweak module provides a Twig extension with some useful functions and +filters. + +## Usage + +### Drupal View +```twig +{{ drupal_view('who_s_new', 'block_1') }} +``` + +### Drupal View Result +```twig +{{ drupal_view('who_s_new', 'block_1') }} +``` + +### Drupal Block +In order to list all registered plugin IDs fetch them with block plugin manager. +With Drush it can be done like follows: +``` +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}) + +{# Bypass block.html.twig theming. #} +{{ drupal_block('system_branding_block', wrapper=false) }} +``` + +@see https://www.drupal.org/node/2964457#block-plugin + +### 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 +```twig +{{ drupal_field('field_image', 'node', 1) }} +{{ drupal_field('field_image', 'node', 1, 'teaser') }} +{{ drupal_field('field_image', 'node', 1, {type: 'image_url', settings: {image_style: 'large'}}) }} +``` + +### Drupal Menu +```twig +{{ drupal_menu('main') }} +``` + +### 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 responsive image. #} +{{ 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 +{{ drupal_title() }} +``` + +### Drupal URL +```twig +{# 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 for privileged users. #} +{{ drupal_link('Example'|t, '/admin', check_access=true) }} +``` + +### Drupal Messages +```twig +{{ drupal_messages() }} +``` + +### Drupal Breadcrumb +```twig +{{ drupal_breadcrumb() }} +``` + +### Drupal Breakpoint +```twig +{{ drupal_breakpoint() }} +``` + +### Contextual Links +```twig +{# Basic usage. #} +
+ {{ contextual_links('entity.view.edit_form:view=frontpage&display_id=feed_1') }} + {{ drupal_view('frontpage') }} +
+{# Multiple links. #} +
+ {{ contextual_links('node:node=123|block_content:block_content=123') }} + {{ content }} +
+``` + +### Token Replace +```twig +{# Basic usage. #} +{{ '

[site:name]

[site:slogan]
'|token_replace }} + +{# This is more suited to large markup. #} +{% apply token_replace %} +

[site:name]

+
[site:slogan]
+{% endapply %} +``` + +### Preg Replace +```twig +{{ 'Drupal - community plumbing!'|preg_replace('/(Drupal)/', '$1') }} +``` +For simple string interpolation consider using built-in `replace` or `format` +Twig filters. + +### Image Style +```twig +{{ 'public://images/ocean.jpg'|image_style('thumbnail') }} +``` + +### Transliterate +```twig +{{ 'Привет!'|transliterate }} +``` +new TwigFilter('transliterate', [self::class, 'transliterateFilter']), + +### Check Markup +```twig +{{ 'bold strong'|check_markup('restricted_html') }} +``` + +### Truncate +```twig +{{ '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 +```twig +{# Set top level value. #} +{{ content.field_image|with('#title', 'Photo'|t) }} + +{# Set nested value. #} +{{ content|with(['field_image', '#title'], 'Photo'|t) }} +``` + +### Children +```twig + +``` + +### File URL +For string arguments it works similar to core `file_url()` Twig function. +```twig +{{ 'public://sea.jpg'|file_url }} +``` + +When field item list 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 }} +``` + +### PHP +PHP filter is disabled by default. You can enable it in settings.php file as +follows: +```twug +$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 following. +```twig +{{ 'now'|date('Y') }} +``` + +## 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 diff --git a/README.txt b/README.txt deleted file mode 100644 index 7c57093..0000000 --- a/README.txt +++ /dev/null @@ -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 diff --git a/composer.json b/composer.json index 63882ab..29a3a6e 100644 --- a/composer.json +++ b/composer.json @@ -1,18 +1,19 @@ { - "name": "drupal/twig_tweak", - "type": "drupal-module", - "description": "A Twig extension with some useful functions and filters for Drupal development.", - "keywords": ["Drupal", "Twig"], - "license": "GPL-2.0+", - "homepage": "https://www.drupal.org/project/twig_tweak", - "support": { - "issues": "https://www.drupal.org/project/issues/twig_tweak", - "source": "https://git.drupalcode.org/project/twig_tweak" - }, - "require": { - "drupal/core": "^8.7 || ^9.0" - }, - "suggest": { - "symfony/var-dumper": "Better dump() function for debugging Twig variables" - } + "name": "drupal/twig_tweak", + "type": "drupal-module", + "description": "A Twig extension with some useful functions and filters for Drupal development.", + "keywords": ["Drupal", "Twig"], + "license": "GPL-2.0+", + "homepage": "https://www.drupal.org/project/twig_tweak", + "support": { + "issues": "https://www.drupal.org/project/issues/twig_tweak", + "source": "https://git.drupalcode.org/project/twig_tweak" + }, + "require": { + "php": ">=7.3", + "drupal/core": "^9.0" + }, + "suggest": { + "symfony/var-dumper": "Better dump() function for debugging Twig variables" + } } diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..e172d4a --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,16 @@ + + + PHP CodeSniffer configuration for Twig Tweak module. + + + + + + + + + + + + + diff --git a/src/TwigExtension.php b/src/TwigExtension.php deleted file mode 100644 index 5fedc92..0000000 --- a/src/TwigExtension.php +++ /dev/null @@ -1,1201 +0,0 @@ - TRUE]; - $all_options = ['needs_environment' => TRUE, 'needs_context' => TRUE]; - return [ - new \Twig_SimpleFunction('drupal_view', 'views_embed_view'), - new \Twig_SimpleFunction('drupal_view_result', 'views_get_view_result'), - 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_entity_form', [$this, 'drupalEntityForm']), - 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_image', [$this, 'drupalImage']), - new \Twig_SimpleFunction('drupal_token', [$this, 'drupalToken']), - new \Twig_SimpleFunction('drupal_config', [$this, 'drupalConfig']), - new \Twig_SimpleFunction('drupal_dump', [$this, 'drupalDump'], $context_options), - new \Twig_SimpleFunction('dd', [$this, 'drupalDump'], $context_options), - new \Twig_SimpleFunction('drupal_title', [$this, 'drupalTitle']), - new \Twig_SimpleFunction('drupal_url', [$this, 'drupalUrl']), - new \Twig_SimpleFunction('drupal_link', [$this, 'drupalLink']), - new \Twig_SimpleFunction('drupal_messages', [$this, 'drupalMessages']), - new \Twig_SimpleFunction('drupal_breadcrumb', [$this, 'drupalBreadcrumb']), - new \Twig_SimpleFunction('drupal_breakpoint', [$this, 'drupalBreakpoint'], $all_options), - new \Twig_SimpleFunction('contextual_links', [$this, 'contextualLinks']), - ]; - } - - /** - * {@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']), - new \Twig_SimpleFilter('truncate', [$this, 'truncate']), - new \Twig_SimpleFilter('view', [$this, 'view']), - new \Twig_SimpleFilter('with', [$this, 'with']), - new \Twig_SimpleFilter('children', [$this, 'children']), - new \Twig_SimpleFilter('file_url', [$this, 'fileUrl']), - ]; - // 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 a block. - * - * In order to list all registered plugin IDs fetch them with block plugin - * manager. With Drush it can be done like follows: - * @code - * drush ev "print_r(array_keys(\Drupal::service('plugin.manager.block')->getDefinitions()));" - * @endcode - * - * Examples: - * @code - * {# 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}) - * - * {# Bypass block.html.twig theming. #} - * {{ drupal_block('system_branding_block', wrapper=false) }} - * @endcode - * - * @see https://www.drupal.org/node/2964457#block-plugin - * - * @param mixed $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 null|array - * A render array for the block or NULL if the block cannot be rendered. - */ - public function drupalBlock($id, array $configuration = [], $wrapper = TRUE) { - - $configuration += ['label_display' => BlockPluginInterface::BLOCK_LABEL_VISIBLE]; - - /** @var \Drupal\Core\Block\BlockPluginInterface $block_plugin */ - $block_plugin = \Drupal::service('plugin.manager.block') - ->createInstance($id, $configuration); - - // Inject runtime contexts. - if ($block_plugin instanceof ContextAwarePluginInterface) { - $contexts = \Drupal::service('context.repository')->getRuntimeContexts($block_plugin->getContextMapping()); - \Drupal::service('context.handler')->applyContextMapping($block_plugin, $contexts); - } - - $build = []; - $access = $block_plugin->access(\Drupal::currentUser(), TRUE); - if ($access->isAllowed()) { - // Title block needs special treatment. - if ($block_plugin instanceof TitleBlockPluginInterface) { - $request = \Drupal::request(); - $route_match = \Drupal::routeMatch(); - $title = \Drupal::service('title_resolver')->getTitle($request, $route_match->getRouteObject()); - $block_plugin->setTitle($title); - } - - $build['content'] = $block_plugin->build(); - - if ($block_plugin instanceof TitleBlockPluginInterface) { - $build['content']['#cache']['contexts'][] = 'url'; - } - - if ($wrapper && !Element::isEmpty($build['content'])) { - $build += [ - '#theme' => 'block', - '#attributes' => [], - '#contextual_links' => [], - '#configuration' => $block_plugin->getConfiguration(), - '#plugin_id' => $block_plugin->getPluginId(), - '#base_plugin_id' => $block_plugin->getBaseId(), - '#derivative_plugin_id' => $block_plugin->getDerivativeId(), - ]; - } - } - - CacheableMetadata::createFromRenderArray($build) - ->merge(CacheableMetadata::createFromObject($access)) - ->applyTo($build); - - return $build; - } - - /** - * Builds the render array of a given region. - * - * Examples: - * @code - * {# 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') }} - * @endcode - * - * @param string $region - * The region to build. - * @param string $theme - * (optional) The name of the theme to load the region. If it is not - * provided then default theme will be used. - * - * @return array - * A render array to display the region content. - */ - public function drupalRegion($region, $theme = NULL) { - - $entity_type_manager = \Drupal::entityTypeManager(); - $blocks = $entity_type_manager->getStorage('block')->loadByProperties([ - 'region' => $region, - 'theme' => $theme ?: \Drupal::config('system.theme')->get('default'), - ]); - - $view_builder = $entity_type_manager->getViewBuilder('block'); - - $build = []; - - $cache_metadata = new CacheableMetadata(); - - /* @var $blocks \Drupal\block\BlockInterface[] */ - foreach ($blocks as $id => $block) { - $access = $block->access('view', NULL, TRUE); - $cache_metadata = $cache_metadata->merge(CacheableMetadata::createFromObject($access)); - if ($access->isAllowed()) { - $block_plugin = $block->getPlugin(); - if ($block_plugin instanceof TitleBlockPluginInterface) { - $request = \Drupal::request(); - if ($route = $request->attributes->get(RouteObjectInterface::ROUTE_OBJECT)) { - $block_plugin->setTitle(\Drupal::service('title_resolver')->getTitle($request, $route)); - } - } - $build[$id] = $view_builder->view($block); - } - } - - if ($build) { - $build['#region'] = $region; - $build['#theme_wrappers'] = ['region']; - $cache_metadata->applyTo($build); - } - - return $build; - } - - /** - * Returns the render array to represent and entity. - * - * Examples: - * @code - * {# 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) }} - * @endcode - * - * @param string $entity_type - * The entity type. - * @param mixed $id - * (optional) The ID of the entity to build. - * @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. - * @param bool $check_access - * (optional) Indicates that access check is required. - * - * @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, $check_access = TRUE) { - $entity_type_manager = \Drupal::entityTypeManager(); - if ($id) { - $entity = $entity_type_manager->getStorage($entity_type)->load($id); - } - else { - @trigger_error('Loading entities from route is deprecated in Twig Tweak 2.4 and will not be supported in Twig Tweak 3.0', E_USER_DEPRECATED); - $entity = \Drupal::routeMatch()->getParameter($entity_type); - } - - $build = []; - - if ($entity) { - $access = $check_access ? $entity->access('view', NULL, TRUE) : AccessResult::allowed(); - if ($access->isAllowed()) { - $build = $entity_type_manager - ->getViewBuilder($entity_type) - ->view($entity, $view_mode, $langcode); - } - CacheableMetadata::createFromRenderArray($build) - ->merge(CacheableMetadata::createFromObject($entity)) - ->merge(CacheableMetadata::createFromObject($access)) - ->applyTo($build); - } - - return $build; - } - - /** - * Gets the built and processed entity form for the given entity type. - * - * Examples: - * @code - * {# 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) }} - * @endcode - * - * @param string $entity_type - * The entity type. - * @param mixed $id - * (optional) The ID of the entity to build. If empty then new entity will - * be created. - * @param string $form_mode - * (optional) The mode identifying the form variation to be returned. - * @param array $values - * (optional) An array of values to set, keyed by property name. - * @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 drupalEntityForm($entity_type, $id = NULL, $form_mode = 'default', array $values = [], $check_access = TRUE) { - $entity_storage = \Drupal::entityTypeManager()->getStorage($entity_type); - if ($id) { - $entity = $entity_storage->load($id); - $operation = 'update'; - } - else { - $entity = $entity_storage->create($values); - $operation = 'create'; - } - - $build = []; - - if ($entity) { - $access = $check_access ? $entity->access($operation, NULL, TRUE) : AccessResult::allowed(); - if ($access->isAllowed()) { - $build = \Drupal::service('entity.form_builder')->getForm($entity, $form_mode); - } - CacheableMetadata::createFromRenderArray($build) - ->merge(CacheableMetadata::createFromObject($entity)) - ->merge(CacheableMetadata::createFromObject($access)) - ->applyTo($build); - } - - return $build; - } - - /** - * Returns the render array for a single entity field. - * - * Example: - * @code - * {{ drupal_field('field_image', 'node', 1) }} - * {{ drupal_field('field_image', 'node', 1, 'teaser') }} - * {{ drupal_field('field_image', 'node', 1, {type: 'image_url', settings: {image_style: 'large'}}) }} - * @endcode - * - * @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. - * @param bool $check_access - * (optional) Indicates that access check is required. - * - * @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, $check_access = TRUE) { - $entity_type_manager = \Drupal::entityTypeManager(); - - if ($id) { - $entity = $entity_type_manager->getStorage($entity_type)->load($id); - } - else { - @trigger_error('Loading entities from route is deprecated in Twig Tweak 2.4 and will not be supported in Twig Tweak 3.0', E_USER_DEPRECATED); - $entity = \Drupal::routeMatch()->getParameter($entity_type); - } - - $build = []; - - if ($entity) { - $access = $check_access ? $entity->access('view', NULL, TRUE) : AccessResult::allowed(); - if ($access->isAllowed()) { - $entity = \Drupal::service('entity.repository') - ->getTranslationFromContext($entity, $langcode); - if (!isset($entity->{$field_name})) { - // @todo Trigger error here. - return; - } - $build = $entity->{$field_name}->view($view_mode); - } - CacheableMetadata::createFromRenderArray($build) - ->merge(CacheableMetadata::createFromObject($access)) - ->merge(CacheableMetadata::createFromObject($entity)) - ->applyTo($build); - } - - return $build; - } - - /** - * Returns the render array for Drupal menu. - * - * Example: - * @code - * {{ drupal_menu('main') }} - * @endcode - * - * @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. - */ - public function drupalMenu($menu_name, $level = 1, $depth = 0, $expand = FALSE) { - /** @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())); - } - - // If expandedParents is empty, the whole menu tree is built. - if ($expand) { - $parameters->expandedParents = []; - } - - $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. - * - * Example: - * @code - * {{ drupal_form('Drupal\\search\\Form\\SearchBlockForm') }} - * @endcode - * - * @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) { - $callback = [\Drupal::formBuilder(), 'getForm']; - return call_user_func_array($callback, func_get_args()); - } - - /** - * Builds an image. - * - * Examples: - * @code - * {# 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 responsive image. #} - * {{ drupal_image('public://ocean.jpg', 'wide', responsive=true) }} - * @endcode - * - * @param mixed $property - * A property to identify the image. - * @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|null - * A render array to represent the image. - */ - public function drupalImage($property, $style = NULL, array $attributes = [], $responsive = FALSE, $check_access = TRUE) { - - // Determine property type by its value. - if (preg_match('/^\d+$/', $property)) { - $property_type = 'fid'; - } - elseif (Uuid::isValid($property)) { - $property_type = 'uuid'; - } - else { - $property_type = 'uri'; - } - - $files = \Drupal::entityTypeManager() - ->getStorage('file') - ->loadByProperties([$property_type => $property]); - - $build = []; - - // To avoid ambiguity render nothing unless exact one image has been found. - if (count($files) != 1) { - return $build; - } - - $file = reset($files); - - $access = $check_access ? $file->access('view', NULL, TRUE) : AccessResult::allowed(); - - if ($access->isAllowed()) { - $build['#uri'] = $file->getFileUri(); - $build['#attributes'] = $attributes; - if ($style) { - if ($responsive) { - $build['#type'] = 'responsive_image'; - $build['#responsive_image_style_id'] = $style; - } - else { - $build['#theme'] = 'image_style'; - $build['#style_name'] = $style; - } - } - else { - $build['#theme'] = 'image'; - } - } - - CacheableMetadata::createFromRenderArray($build) - ->merge(CacheableMetadata::createFromObject($access)) - ->applyTo($build); - - return $build; - } - - /** - * Replaces a given tokens with appropriate value. - * - * Example: - * @code - * {{ drupal_token('site:name') }} - * @endcode - * - * @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); - } - - /** - * Retrieves data from a given configuration object. - * - * Example: - * @code - * {{ drupal_config('system.site', 'name') }} - * @endcode - * - * @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. - * - * Examples: - * @code - * {# Basic usage. #} - * {{ drupal_dump(var) }} - * - * {# Same as above but shorter. #} - * {{ dd(var) }} - * - * {# Dump all available variables for the current template. #} - * {{ dd() }} - * @endcode - * - * @param array $context - * Variables from the Twig template. - * @param mixed $variable - * (optional) The variable to dump. - */ - public function drupalDump(array $context, $variable = NULL) { - $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. - * - * @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 an internal path. - * - * Examples: - * @code - * {# Basic usage. #} - * {{ drupal_url('node/1) }} - * - * {# Complex URL. #} - * {{ drupal_url('node/1', {query: {foo: 'bar'}, fragment: 'example', absolute: true}) }} - * @endcode - * - * @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 function drupalUrl($user_input, array $options = [], $check_access = FALSE) { - if (isset($options['langcode'])) { - $language_manager = \Drupal::languageManager(); - if ($language = $language_manager->getLanguage($options['langcode'])) { - $options['language'] = $language; - } - } - if (!in_array($user_input[0], ['/', '#', '?'])) { - $user_input = '/' . $user_input; - } - $url = Url::fromUserInput($user_input, $options); - if (!$check_access || $url->access()) { - return $url; - } - } - - /** - * Generates a link from an internal path. - * - * Examples: - * @code - * {# 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 for privileged users. #} - * {{ drupal_link('Example'|t, '/admin', check_access=true) }} - * @endcode - * - * @param string $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 function drupalLink($text, $user_input, array $options = [], $check_access = FALSE) { - $url = $this->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 \Twig_Markup) { - $text = Markup::create($text); - } - return Link::fromTextAndUrl($text, $url); - } - } - - /** - * Displays status messages. - */ - public function drupalMessages() { - return ['#type' => 'status_messages']; - } - - /** - * Builds the breadcrumb. - */ - public function drupalBreadcrumb() { - return \Drupal::service('breadcrumb') - ->build(\Drupal::routeMatch()) - ->toRenderable(); - } - - /** - * Builds contextual links. - * - * Examples: - * @code - * {# Basic usage. #} - *
- * {{ contextual_links('entity.view.edit_form:view=frontpage&display_id=feed_1') }} - * {{ drupal_view('frontpage') }} - *
- * - * {# Multiple links. #} - *
- * {{ contextual_links('node:node=123|block_content:block_content=123') }} - * {{ content }} - *
- * @endcode - * - * @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 function contextualLinks($id) { - $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 Twig template. - */ - public function drupalBreakpoint(\Twig_Environment $environment, array $context) { - 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. - * - * Example: - * @code - * {# Basic usage. #} - * {{ '

[site:name]

[site:slogan]
'|token_replace }} - * - * {# This is more suited to large markup (requires Twig >= 1.41). #} - * {% apply token_replace %} - *

[site:name]

- *
[site:slogan]
- * {% endapply %} - * @endcode - * - * @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. - * - * Example: - * @code - * {{ 'Drupal - community plumbing!'|preg_replace('/(Drupal)/', '$1') }} - * @endcode - * - * For simple string interpolation consider using built-in 'replace' or - * 'format' Twig filters. - * - * @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) { - return preg_replace($pattern, $replacement, $text); - } - - /** - * Returns the URL of this image derivative for an original image path or URI. - * - * Example: - * @code - * {{ 'public://images/ocean.jpg'|image_style('thumbnail') }} - * @endcode - * - * @param string $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 tag. Requesting the URL will cause the image to be created. - */ - public function imageStyle($path, $style) { - - if (!$image_style = ImageStyle::load($style)) { - trigger_error(sprintf('Could not load image style %s.', $style)); - return; - } - - if (!$image_style->supportsUri($path)) { - trigger_error(sprintf('Could not apply image style %s.', $style)); - return; - } - - return file_url_transform_relative($image_style->buildUrl($path)); - } - - /** - * Transliterates text from Unicode to US-ASCII. - * - * Example: - * @code - * {{ 'Привет!'|transliterate }} - * @endcod - * - * @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. - * - * Example. - * @code - * {{ 'bold strong'|check_markup('restricted_html') }} - * @endcode - * - * @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); - } - - /** - * Truncates a UTF-8-encoded string safely to a number of characters. - * - * Example: - * @code - * {{ 'Some long text'|truncate(10, true) }} - * @endcode - * - * @param string $string - * The string to truncate. - * @param int $max_length - * An upper limit on the returned string length, including trailing ellipsis - * if $add_ellipsis is TRUE. - * @param bool $wordsafe - * (optional) If TRUE, attempt to truncate on a word boundary. - * @param bool $add_ellipsis - * (optional) If TRUE, add '...' to the end of the truncated string. - * @param int $min_wordsafe_length - * (optional) If TRUE, the minimum acceptable length for truncation. - * - * @return string - * The truncated string. - * - * @see \Drupal\Component\Utility\Unicode::truncate() - */ - public function truncate($string, $max_length, $wordsafe = FALSE, $add_ellipsis = FALSE, $min_wordsafe_length = 1) { - return Unicode::truncate($string, $max_length, $wordsafe, $add_ellipsis, $min_wordsafe_length); - } - - /** - * Adds new element to the array. - * - * Examples: - * @code - * {# Set top level value. #} - * {{ content.field_image|with('#title', 'Photo'|t) }} - * - * {# Set nested value. #} - * {{ content|with(['field_image', '#title'], 'Photo'|t) }} - * @endcode - * - * @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 function with(array $build, $key, $element) { - if (is_array($key)) { - NestedArray::setValue($build, $key, $element); - } - else { - $build[$key] = $element; - } - return $build; - } - - /** - * Returns a render array for entity, field list or field item. - * - * Examples: - * - * Do not put this into node.html.twig template to avoid recursion. - * @code - * {{ node|view }} - * {{ node|view('teaser') }} - * @endcode - * - * @code - * {{ node.field_image|view }} - * {{ node.field_image[0]|view }} - * {{ node.field_image|view('teaser') }} - * {{ node.field_image|view({settings: {image_style: 'thumbnail'}}) }} - * @endcode - * - * @param mixed $object - * The object to build a render array from. - * @param string|array $display_options - * 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 function view($object, $display_options = 'default', $langcode = NULL, $check_access = TRUE) { - $build = []; - if ($object instanceof FieldItemListInterface || $object instanceof FieldItemInterface) { - return $object->view($display_options); - } - elseif ($object instanceof EntityInterface) { - $build = []; - $access = $check_access ? $object->access('view', NULL, TRUE) : AccessResult::allowed(); - if ($access->isAllowed()) { - $build = \Drupal::entityTypeManager() - ->getViewBuilder($object->getEntityTypeId()) - ->view($object, $display_options, $langcode); - } - CacheableMetadata::createFromRenderArray($build) - ->merge(CacheableMetadata::createFromObject($object)) - ->merge(CacheableMetadata::createFromObject($access)) - ->applyTo($build); - } - return $build; - } - - /** - * Filters out the children of a render array, optionally sorted by weight. - * - * Example: - * @code - * - * @endcode - * - * @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 function children(array $build, $sort = FALSE) { - $keys = Element::children($build, $sort); - return array_intersect_key($build, array_flip($keys)); - } - - /** - * Returns a URL path to the file. - * - * Examples: - * - * For string arguments it works similar to core file_url() Twig function. - * @code - * {{ 'public://sea.jpg'|file_url }} - * @endcode - * - * When field item list 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. - * @code - * {{ node.field_image|file_url }} - * {{ node.field_image[0]|file_url }} - * @endcode - * - * Media fields are fully supported including OEmbed resources. - * @code - * {{ node.field_media|file_url }} - * @endcode - * - * @param string|object $input - * Can be either file URI or an object that contains the URI. - * - * @return string|null - * A URL that may be used to access the file. - */ - public function fileUrl($input) { - if (is_string($input)) { - return file_url_transform_relative(file_create_url($input)); - } - if ($input instanceof EntityReferenceFieldItemListInterface) { - $referenced_entities = $input->referencedEntities(); - if (isset($referenced_entities[0])) { - return self::getUrlFromEntity($referenced_entities[0]); - } - } - elseif ($input instanceof EntityReferenceItem) { - return self::getUrlFromEntity($input->entity); - } - } - - /** - * Extracts file URL form content entity. - * - * @param object $entity - * Entity object that contains information about the file. - * - * @return string|null - * A URL that may be used to access the file. - */ - private static function getUrlFromEntity($entity) { - if ($entity instanceof MediaInterface) { - $source = $entity->getSource(); - $value = $source->getSourceFieldValue($entity); - if ($source instanceof OEmbedInterface) { - return $value; - } - elseif ($file = File::load($value)) { - return $file->createFileUrl(); - } - } - elseif ($entity instanceof FileInterface) { - return $entity->createFileUrl(); - } - } - - /** - * Evaluates a string of PHP code. - * - * PHP filter is disabled by default. You can enable it in settings.php file - * as follows: - * @code - * $settings['twig_tweak_enable_php_filter'] = TRUE; - * @endcode - * - * Usage example: - * @code - * {{ 'return date('Y');'|php }} - * @endcode - * - * 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 following. - * @code - * {{ 'now'|date('Y') }} - * @endcode - * - * @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; - } - -} diff --git a/src/TwigTweakExtension.php b/src/TwigTweakExtension.php new file mode 100644 index 0000000..1b83e3f --- /dev/null +++ b/src/TwigTweakExtension.php @@ -0,0 +1,591 @@ + TRUE]; + $all_options = ['needs_environment' => TRUE, 'needs_context' => TRUE]; + + return [ + 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), + new TwigFunction('drupal_contextual_links', [self::class, 'drupalContextualLinks']), + ]; + } + + /** + * {@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('truncate', [Unicode::class, 'truncate']), + new TwigFilter('view', [self::class, 'viewFilter']), + new TwigFilter('with', [self::class, 'withFilter']), + new TwigFilter('children', [self::class, 'childrenFilter']), + new TwigFilter('file_url', [self::class, 'fileUrlFilter']), + ]; + + if (Settings::get('twig_tweak_enable_php_filter')) { + $filters[] = new TwigFilter('php', [self::class, 'phpFilter']); + } + + return $filters; + } + + /** + * 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 $id, string $view_mode = 'full', string $langcode = NULL, bool $check_access = TRUE): array { + $entity = \Drupal::entityTypeManager()->getStorage($entity_type)->load((string) $id); + 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 ? $entity_storage->load($id) : $entity_storage->create($values); + if ($entity) { + return \Drupal::service('twig_tweak.entity_form_view_builder') + ->build($entity, $form_mode, $check_access); + } + return []; + } + + /** + * Returns the render array for a single entity field. + */ + public static function drupalField(string $field_name, ?string $entity_type, ?string $id, string $view_mode = 'full', string $langcode = NULL, bool $check_access = TRUE): array { + $entity = \Drupal::entityTypeManager()->getStorage($entity_type)->load((string) $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 $property, string $style = NULL, array $attributes = [], bool $responsive = FALSE, bool $check_access = TRUE): array { + + // Determine property type by its value. + if (preg_match('/^\d+$/', $property)) { + $property_type = 'fid'; + } + elseif (Uuid::isValid($property)) { + $property_type = 'uuid'; + } + else { + $property_type = 'uri'; + } + + $files = \Drupal::entityTypeManager() + ->getStorage('file') + ->loadByProperties([$property_type => $property]); + + // To avoid ambiguity render nothing unless exact one image has been found. + if (count($files) != 1) { + return []; + } + + $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. + */ + public static function drupalTitle(): array { + $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 an internal 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 (!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. + * + * @return string + * The entered HTML text with tokens replaced. + */ + public static function tokenReplaceFilter(string $text): string { + 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 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 $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 tag. Requesting the URL will cause the image to be created. + */ + public static function imageStyleFilter(string $path, string $style): ?string { + + 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 file_url_transform_relative($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 mixed $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, ?string $view_mode = 'default', string $langcode = NULL, bool $check_access = TRUE): array { + $build = []; + if ($object instanceof FieldItemListInterface || $object instanceof FieldItemInterface) { + $build = $object->view($view_mode); + } + elseif ($object instanceof EntityInterface) { + $build = \Drupal::service('twig_tweak.entity_view_builder')->build($object, $view_mode, $langcode, $check_access); + } + return $build; + } + + /** + * 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 URL path to the file. + * + * @param string|object $input + * Can be either file URI or an object that contains the URI. + * + * @return string|null + * A URL that may be used to access the file. + */ + public static function fileUrlFilter($input): ?string { + if (is_string($input)) { + return file_url_transform_relative(file_create_url($input)); + } + if ($input instanceof EntityReferenceFieldItemListInterface) { + $referenced_entities = $input->referencedEntities(); + if (isset($referenced_entities[0])) { + return self::getUrlFromEntity($referenced_entities[0]); + } + } + elseif ($input instanceof EntityReferenceItem) { + return self::getUrlFromEntity($input->entity); + } + } + + /** + * Evaluates a string of PHP code. + * + * @param string $code + * Valid PHP code to be evaluated. + * + * @return mixed + * The eval() result. + */ + public static function phpFilter(string $code) { + ob_start(); + // @codingStandardsIgnoreStart + print eval($code); + // @codingStandardsIgnoreEnd + $output = ob_get_contents(); + ob_end_clean(); + return $output; + } + + /** + * Extracts file URL form content entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * Entity object that contains information about the file. + * + * @return string|null + * A URL that may be used to access the file. + */ + private static function getUrlFromEntity(EntityInterface $entity): ?string { + if ($entity instanceof MediaInterface) { + $source = $entity->getSource(); + $value = $source->getSourceFieldValue($entity); + if ($source instanceof OEmbedInterface) { + return $value; + } + elseif ($file = File::load($value)) { + return $file->createFileUrl(); + } + } + elseif ($entity instanceof FileInterface) { + return $entity->createFileUrl(); + } + } + +} diff --git a/src/View/BlockViewBuilder.php b/src/View/BlockViewBuilder.php new file mode 100644 index 0000000..02339f8 --- /dev/null +++ b/src/View/BlockViewBuilder.php @@ -0,0 +1,155 @@ +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) { + $request = $this->requestStack->getCurrentRequest(); + $title = $this->titleResolver->getTitle($request, $this->routeMatch->getRouteObject()); + $block_plugin->setTitle($title); + } + + $build['content'] = $block_plugin->build(); + + if ($block_plugin instanceof TitleBlockPluginInterface) { + $build['content']['#cache']['contexts'][] = 'url'; + } + + if ($wrapper && !Element::isEmpty($build['content'])) { + $build += [ + '#theme' => 'block', + '#attributes' => [], + '#contextual_links' => [], + '#configuration' => $block_plugin->getConfiguration(), + '#plugin_id' => $block_plugin->getPluginId(), + '#base_plugin_id' => $block_plugin->getBaseId(), + '#derivative_plugin_id' => $block_plugin->getDerivativeId(), + ]; + } + } + + CacheableMetadata::createFromRenderArray($build) + ->merge(CacheableMetadata::createFromObject($access)) + ->applyTo($build); + + return $build; + } + +} diff --git a/src/View/EntityFormViewBuilder.php b/src/View/EntityFormViewBuilder.php new file mode 100644 index 0000000..77a0b40 --- /dev/null +++ b/src/View/EntityFormViewBuilder.php @@ -0,0 +1,60 @@ +entityFormBuilder = $entity_form_builder; + } + + /** + * Gets the built and processed entity form for the given entity type. + * + * @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) + ->merge(CacheableMetadata::createFromObject($entity)) + ->merge(CacheableMetadata::createFromObject($access)) + ->applyTo($build); + + return $build; + } + +} diff --git a/src/View/EntityViewBuilder.php b/src/View/EntityViewBuilder.php new file mode 100644 index 0000000..9395fac --- /dev/null +++ b/src/View/EntityViewBuilder.php @@ -0,0 +1,47 @@ +entityTypeManager = $entity_type_manager; + } + + /** + * 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 = []; + $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) + ->merge(CacheableMetadata::createFromObject($entity)) + ->merge(CacheableMetadata::createFromObject($access)) + ->applyTo($build); + return $build; + } + +} diff --git a/src/View/FieldViewBuilder.php b/src/View/FieldViewBuilder.php new file mode 100644 index 0000000..e259567 --- /dev/null +++ b/src/View/FieldViewBuilder.php @@ -0,0 +1,74 @@ +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 that should be used to render the field. + * @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. + */ + public function build( + EntityInterface $entity, + string $field_name, + $view_mode = 'full', + string $langcode = NULL, + bool $check_access = TRUE + ): array { + + $build = []; + + $access = $check_access ? $entity->access('view', NULL, TRUE) : AccessResult::allowed(); + if ($access->isAllowed()) { + $entity = $this->entityRepository->getTranslationFromContext($entity, $langcode); + if (!isset($entity->{$field_name})) { + // @todo Trigger error here. + return []; + } + $build = $entity->{$field_name}->view($view_mode); + } + + CacheableMetadata::createFromRenderArray($build) + ->merge(CacheableMetadata::createFromObject($access)) + ->merge(CacheableMetadata::createFromObject($entity)) + ->applyTo($build); + + return $build; + } + +} diff --git a/src/View/ImageViewBuilder.php b/src/View/ImageViewBuilder.php new file mode 100644 index 0000000..64f8326 --- /dev/null +++ b/src/View/ImageViewBuilder.php @@ -0,0 +1,61 @@ +access('view', NULL, TRUE) : AccessResult::allowed(); + + $build = []; + if ($access->isAllowed()) { + $build['#uri'] = $file->getFileUri(); + $build['#attributes'] = $attributes; + if ($style) { + if ($responsive) { + $build['#type'] = 'responsive_image'; + $build['#responsive_image_style_id'] = $style; + } + else { + $build['#theme'] = 'image_style'; + $build['#style_name'] = $style; + } + } + else { + $build['#theme'] = 'image'; + } + } + + CacheableMetadata::createFromRenderArray($build) + ->merge(CacheableMetadata::createFromObject($access)) + ->applyTo($build); + + return $build; + } + +} diff --git a/src/View/MenuViewBuilder.php b/src/View/MenuViewBuilder.php new file mode 100644 index 0000000..23c4b8f --- /dev/null +++ b/src/View/MenuViewBuilder.php @@ -0,0 +1,70 @@ +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); + return $this->menuLinkTree->build($tree); + } + +} diff --git a/src/View/RegionViewBuilder.php b/src/View/RegionViewBuilder.php new file mode 100644 index 0000000..d11c0a9 --- /dev/null +++ b/src/View/RegionViewBuilder.php @@ -0,0 +1,111 @@ +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 = []; + + $cache_metadata = new CacheableMetadata(); + + /* @var $blocks \Drupal\block\BlockInterface[] */ + foreach ($blocks as $id => $block) { + $access = $block->access('view', NULL, TRUE); + $cache_metadata = $cache_metadata->merge(CacheableMetadata::createFromObject($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; + } + +} diff --git a/tests/src/Functional/TwigTweakTest.php b/tests/src/Functional/TwigTweakTest.php index 4e4f24e..e85ea68 100644 --- a/tests/src/Functional/TwigTweakTest.php +++ b/tests/src/Functional/TwigTweakTest.php @@ -3,14 +3,14 @@ namespace Drupal\Tests\twig_tweak\Functional; use Drupal\Core\Link; +use Drupal\Core\Render\Markup; use Drupal\Core\Url; +use Drupal\Tests\BrowserTestBase; +use Drupal\Tests\TestFileCreationTrait; use Drupal\file\Entity\File; use Drupal\language\Entity\ConfigurableLanguage; use Drupal\media\Entity\Media; use Drupal\responsive_image\Entity\ResponsiveImageStyle; -use Drupal\Core\Render\Markup; -use Drupal\Tests\BrowserTestBase; -use Drupal\Tests\TestFileCreationTrait; use Drupal\user\Entity\Role; /** @@ -18,10 +18,15 @@ use Drupal\user\Entity\Role; * * @group twig_tweak */ -class TwigTweakTest extends BrowserTestBase { +final class TwigTweakTest extends BrowserTestBase { use TestFileCreationTrait; + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'classy'; + /** * {@inheritdoc} */ @@ -40,7 +45,7 @@ class TwigTweakTest extends BrowserTestBase { /** * {@inheritdoc} */ - public function setUp() { + public function setUp(): void { parent::setUp(); $test_files = $this->getTestFiles('image'); @@ -88,292 +93,274 @@ class TwigTweakTest extends BrowserTestBase { 'breakpoint_group' => 'responsive_image', ])->save(); - // Setup Russian. + // Setup Russian language. ConfigurableLanguage::createFromLangcode('ru')->save(); } /** * Tests output produced by the Twig extension. */ - public function testOutput() { - // Title block rendered through drupal_region() is cached by some reason. - \Drupal::service('cache_tags.invalidator')->invalidateTags(['block_view']); - $this->drupalGet(''); + public function testOutput(): void { + + $this->drupalGet('twig-tweak-test'); - // -- Test default views display. + // -- View (default display). $xpath = '//div[@class = "tt-view-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'; - $this->assertByXpath($xpath . '//a[contains(@href, "/node/1") and text() = "Alpha"]'); - $this->assertByXpath($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/1") and text() = "Alpha"]'); + $this->assertXpath($xpath . '//a[contains(@href, "/node/2") and text() = "Beta"]'); + $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[contains(@class, "view-twig-tweak-test") and contains(@class, "view-display-id-page_1")]'; $xpath .= '/div[@class = "view-content"]//ul[count(./li) = 3]/li'; - $this->assertByXpath($xpath . '//a[contains(@href, "/node/1") and text() = "Alpha"]'); - $this->assertByXpath($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/1") and text() = "Alpha"]'); + $this->assertXpath($xpath . '//a[contains(@href, "/node/2") and text() = "Beta"]'); + $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[contains(@class, "view-twig-tweak-test")]'; $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 view result. + // -- View result. $xpath = '//div[@class = "tt-view-result" and text() = 3]'; - $this->assertByXpath($xpath); + $this->assertXpath($xpath); - // -- Test block. + // -- Block. $xpath = '//div[@class = "tt-block"]'; $xpath .= '/img[contains(@src, "/core/themes/classy/logo.svg") and @alt="Home"]'; - $this->assertByXpath($xpath); + $this->assertXpath($xpath); - // -- Test block with wrapper. + // -- 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/classy/logo.svg") and @alt="Home"]]'; $xpath .= '/following-sibling::div[@class = "site-name"]/a'; - $this->assertByXpath($xpath); + $this->assertXpath($xpath); - // -- Test region. + // -- Region. $xpath = '//div[@class = "tt-region"]/div[@class = "region region-sidebar-first"]'; - $xpath .= '/div[contains(@class, "block-page-title-block") and h1[@class="page-title" and text() = "Log in"]]'; + $xpath .= '/div[contains(@class, "block-page-title-block") and h1[@class="page-title" and text() = "Twig Tweak Test"]]'; $xpath .= '/following-sibling::div[contains(@class, "block-system-powered-by-block")]/span[. = "Powered by Drupal"]'; - $this->assertByXpath($xpath); + $this->assertXpath($xpath); - // -- Test entity default view mode. + // -- Entity (default view mode). $xpath = '//div[@class = "tt-entity-default"]'; $xpath .= '/article[contains(@class, "node") and not(contains(@class, "node--view-mode-teaser"))]'; $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 .= '/article[contains(@class, "node") and contains(@class, "node--view-mode-teaser")]'; $xpath .= '/h2/a/span[text() = "Alpha"]'; - $this->assertByXpath($xpath); - - // -- Test loading entity from URL. - $xpath = '//div[@class = "tt-entity-from-url" and not(text())]'; - $this->assertByXpath($xpath); - $this->drupalGet('/node/2'); - $xpath = '//div[@class = "tt-entity-from-url"]'; - $xpath .= '/article[contains(@class, "node")]'; - $xpath .= '/h2/a/span[text() = "Beta"]'; - $this->assertByXpath($xpath); - - // -- Test access to entity add form. + $this->assertXpath($xpath); + + // -- Entity add form (unprivileged user). $xpath = '//div[@class = "tt-entity-add-form"]/form'; $this->assertSession()->elementNotExists('xpath', $xpath); - // -- Test access to entity edit form. + // -- 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']; - $this->grantPermissions(Role::load(Role::ANONYMOUS_ID), $permissions); - $this->drupalGet('/node/2'); + /** @var \Drupal\user\RoleInterface $role */ + $role = Role::load(Role::ANONYMOUS_ID); + $this->grantPermissions($role, $permissions); + $this->drupalGet($this->getUrl()); - // -- Test entity add form. + // -- 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->assertByXpath($xpath); + $this->assertXpath($xpath); - // -- Test entity edit form. + // -- 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->assertByXpath($xpath); + $this->assertXpath($xpath); - // -- Test field. + // -- Field. $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"]'; - $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"]'; - $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"]'; - $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"]'; - $this->assertByXpath($xpath); + $this->assertXpath($xpath); - // -- Test image by FID. + // -- Image by FID. $xpath = '//div[@class = "tt-image-by-fid"]/img[contains(@src, "/files/image-test.png")]'; - $this->assertByXpath($xpath); + $this->assertXpath($xpath); - // -- Test image by URI. + // -- Image by URI. $xpath = '//div[@class = "tt-image-by-uri"]/img[contains(@src, "/files/image-test.png")]'; - $this->assertByXpath($xpath); + $this->assertXpath($xpath); - // -- Test image by UUID. + // -- Image by UUID. $xpath = '//div[@class = "tt-image-by-uuid"]/img[contains(@src, "/files/image-test.png")]'; - $this->assertByXpath($xpath); + $this->assertXpath($xpath); - // -- Test image with style. + // -- Image with style. $xpath = '//div[@class = "tt-image-with-style"]/img[contains(@src, "/files/styles/thumbnail/public/image-test.png")]'; - $this->assertByXpath($xpath); + $this->assertXpath($xpath); - // -- Test image with responsive style. + // -- Image with responsive style. $xpath = '//div[@class = "tt-image-with-responsive-style"]/picture/img[contains(@src, "/files/image-test.png")]'; - $this->assertByXpath($xpath); + $this->assertXpath($xpath); - // -- Test token. + // -- Token. $xpath = '//div[@class = "tt-token" and text() = "Drupal"]'; - $this->assertByXpath($xpath); + $this->assertXpath($xpath); - // -- Test token with context. - $xpath = '//div[@class = "tt-token-data" and text() = "Beta"]'; - $this->assertByXpath($xpath); + // -- Token with context. + $xpath = '//div[@class = "tt-token-data" and text() = "Alpha"]'; + $this->assertXpath($xpath); - // -- Test config. + // -- Config. $xpath = '//div[@class = "tt-config" and text() = "Anonymous"]'; - $this->assertByXpath($xpath); + $this->assertXpath($xpath); - // -- Test page title. - $xpath = '//div[@class = "tt-title" and text() = "Beta"]'; - $this->assertByXpath($xpath); + // -- Page title. + $xpath = '//div[@class = "tt-title" and text() = "Twig Tweak Test"]'; + $this->assertXpath($xpath); - // -- Test URL. + // -- URL. $url = Url::fromUserInput('/node/1', ['absolute' => TRUE])->toString(); $xpath = sprintf('//div[@class = "tt-url"]/div[@data-case="default" and text() = "%s"]', $url); - $this->assertByXpath($xpath); + $this->assertXpath($xpath); - // -- Test URL (with langcode). + // -- 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->assertByXpath($xpath); + $this->assertXpath($xpath); - // -- Test link. + // -- Link. $url = Url::fromUserInput('/node/1/edit', ['absolute' => TRUE]); $link = Link::fromTextAndUrl('Edit', $url)->toString(); $xpath = '//div[@class = "tt-link"]'; self::assertEquals($link, trim($this->xpath($xpath)[0]->getHtml())); - // -- Test link with HTML. + // -- Link with HTML. $text = Markup::create('Edit'); $url = Url::fromUserInput('/node/1/edit', ['absolute' => TRUE]); $link = Link::fromTextAndUrl($text, $url)->toString(); $xpath = '//div[@class = "tt-link-html"]'; self::assertEquals($link, trim($this->xpath($xpath)[0]->getHtml())); - // -- Test status messages. + // -- Status messages. $xpath = '//div[@class = "tt-messages"]//div[contains(@class, "messages--status") and contains(., "Hello world!")]'; - $this->assertByXpath($xpath); + $this->assertXpath($xpath); - // -- Test breadcrumb. + // -- Breadcrumb. $xpath = '//div[@class = "tt-breadcrumb"]/nav[@class = "breadcrumb"]/ol/li/a[text() = "Home"]'; - $this->assertByXpath($xpath); + $this->assertXpath($xpath); - // -- Test protected link. + // -- Protected link. $xpath = '//div[@class = "tt-link-access"]'; self::assertEquals('', trim($this->xpath($xpath)[0]->getHtml())); - // -- Test token replacement. + // -- Token replacement. $xpath = '//div[@class = "tt-token-replace" and text() = "Site name: Drupal"]'; - $this->assertByXpath($xpath); + $this->assertXpath($xpath); - // -- Test contextual links. + // -- Contextual links. $xpath = '//div[@class="tt-contextual-links" and not(div[@data-contextual-id])]'; - $this->assertByXpath($xpath); + $this->assertXpath($xpath); - $this->grantPermissions(Role::load(Role::ANONYMOUS_ID), ['access contextual links']); + /** @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->assertByXpath($xpath); + $this->assertXpath($xpath); - // -- Test preg replacement. + // -- Replace (preg). $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")]'; - $this->assertByXpath($xpath); + $this->assertXpath($xpath); - // -- Test transliteration. + // -- Transliterate. $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"]'; self::assertEquals('bold strong', trim($this->xpath($xpath)[0]->getHtml())); - // -- Test truncation. + // -- Truncate. $xpath = '//div[@class = "tt-truncate" and text() = "Hello…"]'; - $this->assertByXpath($xpath); + $this->assertXpath($xpath); - // -- Test 'with'. + // -- 'with'. $xpath = '//div[@class = "tt-with"]/b[text() = "Example"]'; - $this->assertByXpath($xpath); + $this->assertXpath($xpath); - // -- Test nested 'with'. + // -- Nested 'with'. $xpath = '//div[@class = "tt-with-nested" and text() = "{alpha:{beta:{gamma:456}}}"]'; - $this->assertByXpath($xpath); + $this->assertXpath($xpath); - // -- Test 'children'. + // -- 'children'. $xpath = '//div[@class = "tt-children" and text() = "doremi"]'; - $this->assertByXpath($xpath); + $this->assertXpath($xpath); - // -- Test entity view. - $xpath = '//div[@class = "tt-node-view"]/article[contains(@class, "node--view-mode-default")]/h2[a/span[text() = "Beta"]]'; + // -- 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->assertByXpath($xpath); + $this->assertXpath($xpath); - // -- Test Field list view. - $xpath = '//div[@class = "tt-field-list-view"]/span[contains(@class, "field--name-title") and text() = "Beta"]'; - $this->assertByXpath($xpath); + // -- Field list view. + $xpath = '//div[@class = "tt-field-list-view"]/span[contains(@class, "field--name-title") and text() = "Alpha"]'; + $this->assertXpath($xpath); - // -- Test field item view. - $xpath = '//div[@class = "tt-field-item-view" and text() = "Beta"]'; - $this->assertByXpath($xpath); + // -- Field item view. + $xpath = '//div[@class = "tt-field-item-view" and text() = "Alpha"]'; + $this->assertXpath($xpath); - // -- Test file URL from URI. + // -- File URL from URI. $xpath = '//div[@class = "tt-file-url-from-uri" and contains(text(), "/files/image-test.png")]'; - $this->assertByXpath($xpath); + $this->assertXpath($xpath); - // -- Test file URL from image field. - $this->drupalGet('/node/1'); + // -- File URL from image field. $xpath = '//div[@class = "tt-file-url-from-image-field" and contains(text(), "/files/image-test.png")]'; - $this->assertByXpath($xpath); + $this->assertXpath($xpath); - // -- Test file URL from a specific image field item. + // -- 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->assertByXpath($xpath); + $this->assertXpath($xpath); - // -- Test file URL from media field. + // -- File URL from media field. $xpath = '//div[@class = "tt-file-url-from-media-field" and contains(text(), "/files/image-1.png")]'; - $this->assertByXpath($xpath); + $this->assertXpath($xpath); } /** * Checks that an element specified by a the xpath exists on the current page. */ - public function assertByXpath($xpath) { + private function assertXpath(string $xpath): void { $this->assertSession()->elementExists('xpath', $xpath); } - /** - * {@inheritdoc} - */ - protected function initFrontPage() { - // Intentionally empty. The parent implementation does a request to the - // front page to init cookie. This causes some troubles in rendering - // attached Twig template because page content type is not created at that - // moment. We can skip this step since this test does not rely on any - // session data. - } - } diff --git a/tests/src/Kernel/AccessTest.php b/tests/src/Kernel/AccessTest.php deleted file mode 100644 index ee8d547..0000000 --- a/tests/src/Kernel/AccessTest.php +++ /dev/null @@ -1,598 +0,0 @@ -installEntitySchema('node'); - $this->installEntitySchema('user'); - $this->installConfig(['system']); - - $node_type = NodeType::create([ - 'type' => 'article', - 'name' => 'Article', - ]); - $node_type->save(); - - $values = [ - 'type' => 'article', - 'status' => NodeInterface::PUBLISHED, - // @see twig_tweak_test_node_access() - 'title' => 'Entity access test', - ]; - $this->node = Node::create($values); - $this->node->save(); - - $this->twigExtension = $this->container->get('twig_tweak.twig_extension'); - } - - /** - * Test callback. - */ - public function testDrupalEntity() { - - // -- Unprivileged user with access check. - $this->setUpCurrentUser(['name' => 'User 1']); - - $build = $this->twigExtension->drupalEntity('node', $this->node->id()); - self::assertArrayNotHasKey('#node', $build); - $expected_cache = [ - 'contexts' => ['user.permissions'], - 'tags' => ['node:1'], - 'max-age' => Cache::PERMANENT, - ]; - self::assertSame($expected_cache, $build['#cache']); - - // -- Unprivileged user without access check. - $build = $this->twigExtension->drupalEntity('node', $this->node->id(), NULL, NULL, FALSE); - self::assertArrayHasKey('#node', $build); - $expected_cache = [ - 'tags' => [ - 'node:1', - 'node_view', - ], - 'contexts' => [], - 'max-age' => Cache::PERMANENT, - ]; - self::assertSame($expected_cache, $build['#cache']); - - // -- Privileged user with access check. - $this->setUpCurrentUser(['name' => 'User 2'], ['access content']); - - $build = $this->twigExtension->drupalEntity('node', $this->node->id()); - self::assertArrayHasKey('#node', $build); - $expected_cache = [ - 'tags' => [ - 'node:1', - 'node_view', - 'tag_from_twig_tweak_test_node_access', - ], - 'contexts' => [ - 'user', - 'user.permissions', - ], - 'max-age' => 50, - ]; - self::assertSame($expected_cache, $build['#cache']); - - // -- Privileged user without access check. - $build = $this->twigExtension->drupalEntity('node', $this->node->id(), NULL, NULL, FALSE); - self::assertArrayHasKey('#node', $build); - $expected_cache = [ - 'tags' => [ - 'node:1', - 'node_view', - ], - 'contexts' => [], - 'max-age' => Cache::PERMANENT, - ]; - self::assertSame($expected_cache, $build['#cache']); - } - - /** - * Test callback. - */ - public function testDrupalField() { - - // -- Unprivileged user with access check. - $this->setUpCurrentUser(['name' => 'User 1']); - - $build = $this->twigExtension->drupalField('title', 'node', $this->node->id()); - self::assertArrayNotHasKey('#items', $build); - $expected_cache = [ - 'contexts' => ['user.permissions'], - 'tags' => ['node:1'], - 'max-age' => Cache::PERMANENT, - ]; - self::assertSame($expected_cache, $build['#cache']); - - // -- Unprivileged user without access check. - $build = $this->twigExtension->drupalField('title', 'node', $this->node->id(), 'default', NULL, FALSE); - self::assertArrayHasKey('#items', $build); - $expected_cache = [ - 'contexts' => [], - 'tags' => ['node:1'], - 'max-age' => Cache::PERMANENT, - ]; - self::assertSame($expected_cache, $build['#cache']); - - // -- Privileged user with access check. - $this->setUpCurrentUser(['name' => 'User 2'], ['access content']); - - $build = $this->twigExtension->drupalField('title', 'node', $this->node->id()); - self::assertArrayHasKey('#items', $build); - $expected_cache = [ - 'contexts' => [ - 'user', - 'user.permissions', - ], - 'tags' => [ - 'node:1', - 'tag_from_twig_tweak_test_node_access', - ], - 'max-age' => 50, - ]; - self::assertSame($expected_cache, $build['#cache']); - - // -- Privileged user without access check. - $build = $this->twigExtension->drupalField('title', 'node', $this->node->id(), 'default', NULL, FALSE); - self::assertArrayHasKey('#items', $build); - $expected_cache = [ - 'contexts' => [], - 'tags' => ['node:1'], - 'max-age' => Cache::PERMANENT, - ]; - self::assertSame($expected_cache, $build['#cache']); - } - - /** - * Test callback. - */ - public function testDrupalEntityEditForm() { - - // -- Unprivileged user with access check. - $this->setUpCurrentUser(['name' => 'User 1']); - - $build = $this->twigExtension->drupalEntityForm('node', $this->node->id()); - self::assertArrayNotHasKey('form_id', $build); - $expected_cache = [ - 'contexts' => ['user.permissions'], - 'tags' => ['node:1'], - 'max-age' => Cache::PERMANENT, - ]; - self::assertSame($expected_cache, $build['#cache']); - - // -- Unprivileged user without access check. - $build = $this->twigExtension->drupalEntityForm('node', $this->node->id(), 'default', [], FALSE); - self::assertArrayHasKey('form_id', $build); - $expected_cache = [ - 'contexts' => ['user.roles:authenticated'], - 'tags' => [ - 'config:core.entity_form_display.node.article.default', - 'node:1', - ], - 'max-age' => Cache::PERMANENT, - ]; - self::assertSame($expected_cache, $build['#cache']); - - // -- Privileged user with access check. - $this->setUpCurrentUser(['name' => 'User 2'], ['access content']); - - $build = $this->twigExtension->drupalEntityForm('node', $this->node->id()); - 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::assertSame($expected_cache, $build['#cache']); - - // -- Privileged user without access check. - $build = $this->twigExtension->drupalEntityForm('node', $this->node->id(), 'default', [], FALSE); - self::assertArrayHasKey('#form_id', $build); - $expected_cache = [ - 'contexts' => ['user.roles:authenticated'], - 'tags' => [ - 'config:core.entity_form_display.node.article.default', - 'node:1', - ], - 'max-age' => Cache::PERMANENT, - ]; - self::assertSame($expected_cache, $build['#cache']); - } - - /** - * Test callback. - */ - public function testDrupalEntityAddForm() { - - $node_values = ['type' => 'article']; - - // -- Unprivileged user with access check. - $this->setUpCurrentUser(['name' => 'User 1']); - - $build = $this->twigExtension->drupalEntityForm('node', NULL, 'default', $node_values); - self::assertArrayNotHasKey('form_id', $build); - $expected_cache = [ - 'contexts' => ['user.permissions'], - 'tags' => [], - 'max-age' => Cache::PERMANENT, - ]; - self::assertSame($expected_cache, $build['#cache']); - - // -- Unprivileged user without access check. - $build = $this->twigExtension->drupalEntityForm('node', NULL, 'default', $node_values, FALSE); - self::assertArrayHasKey('form_id', $build); - $expected_cache = [ - 'contexts' => ['user.roles:authenticated'], - 'tags' => ['config:core.entity_form_display.node.article.default'], - 'max-age' => Cache::PERMANENT, - ]; - self::assertSame($expected_cache, $build['#cache']); - - // -- Privileged user with access check. - $this->setUpCurrentUser(['name' => 'User 2'], ['access content', 'create article content']); - - $build = $this->twigExtension->drupalEntityForm('node', NULL, 'default', $node_values); - self::assertArrayHasKey('form_id', $build); - $expected_cache = [ - 'contexts' => [ - 'user.permissions', - 'user.roles:authenticated', - ], - 'tags' => ['config:core.entity_form_display.node.article.default'], - 'max-age' => Cache::PERMANENT, - ]; - self::assertSame($expected_cache, $build['#cache']); - - // -- Privileged user without access check. - $build = $this->twigExtension->drupalEntityForm('node', NULL, 'default', $node_values); - self::assertArrayHasKey('form_id', $build); - $expected_cache = [ - 'contexts' => [ - 'user.permissions', - 'user.roles:authenticated', - ], - 'tags' => ['config:core.entity_form_display.node.article.default'], - 'max-age' => Cache::PERMANENT, - ]; - self::assertSame($expected_cache, $build['#cache']); - } - - /** - * Test callback. - * - * @see \Drupal\twig_tweak_test\Plugin\Block\FooBlock - */ - public function testDrupalBlock() { - - // -- Privileged user. - $this->setUpCurrentUser(['name' => 'User 1']); - - $build = $this->twigExtension->drupalBlock('twig_tweak_test_foo'); - $expected_content = [ - '#markup' => 'Foo', - '#cache' => [ - 'contexts' => ['url'], - 'tags' => ['tag_from_build'], - ], - ]; - self::assertSame($expected_content, $build['content']); - $expected_cache = [ - 'contexts' => ['user'], - 'tags' => ['tag_from_blockAccess'], - 'max-age' => 35, - ]; - self::assertSame($expected_cache, $build['#cache']); - - // -- Unprivileged user. - $this->setUpCurrentUser(['name' => 'User 2']); - - $build = $this->twigExtension->drupalBlock('twig_tweak_test_foo'); - self::assertArrayNotHasKey('content', $build); - $expected_cache = [ - 'contexts' => ['user'], - 'tags' => ['tag_from_blockAccess'], - 'max-age' => 35, - ]; - self::assertSame($expected_cache, $build['#cache']); - } - - /** - * Test callback. - */ - public function testDrupalRegion() { - - // @codingStandardsIgnoreStart - $create_block = function ($id) { - return new class(['id' => $id], 'block') extends Block { - public function access($operation, AccountInterface $account = NULL, $return_as_object = FALSE) { - $result = AccessResult::allowedIf($this->id == 'block_1'); - $result->cachePerUser(); - $result->addCacheTags(['tag_for_' . $this->id]); - $result->setCacheMaxAge(123); - return $return_as_object ? $result : $result->isAllowed(); - } - public function getPlugin() { - return NULL; - } - }; - }; - // @codingStandardsIgnoreEnd - - $storage = $this->createMock(EntityStorageInterface::class); - $blocks = [ - 'block_1' => $create_block('block_1'), - 'block_2' => $create_block('block_2'), - ]; - $storage->expects($this->any()) - ->method('loadByProperties') - ->willReturn($blocks); - - $view_builder = $this->createMock(BlockViewBuilder::class); - $content = [ - '#markup' => 'foo', - '#cache' => [ - 'tags' => ['tag_from_view'], - ], - ]; - $view_builder->expects($this->any()) - ->method('view') - ->willReturn($content); - - $entity_type_manager = $this->createMock(EntityTypeManagerInterface::class); - $entity_type_manager->expects($this->any()) - ->method('getStorage') - ->willReturn($storage); - $entity_type_manager->expects($this->any()) - ->method('getViewBuilder') - ->willReturn($view_builder); - - $this->container->set('entity_type.manager', $entity_type_manager); - - $build = $this->twigExtension->drupalRegion('bar'); - $expected_build = [ - 'block_1' => [ - '#markup' => 'foo', - '#cache' => [ - 'tags' => ['tag_from_view'], - ], - ], - '#region' => 'bar', - '#theme_wrappers' => ['region'], - '#cache' => [ - 'contexts' => ['user'], - 'tags' => [ - 'tag_for_block_1', - 'tag_for_block_2', - ], - 'max-age' => 123, - ], - ]; - self::assertSame($expected_build, $build); - } - - /** - * Test callback. - */ - public function testDrupalImage() { - - // @codingStandardsIgnoreStart - $create_image = function ($uri) { - $file = new class(['uri' => $uri], 'file') extends File { - public function access($operation, AccountInterface $account = NULL, $return_as_object = FALSE) { - $is_public = parse_url($this->getFileUri(), PHP_URL_SCHEME) == 'public'; - $result = AccessResult::allowedIf($is_public); - $result->cachePerUser(); - $result->addCacheTags(['tag_for_' . $this->getFileUri()]); - $result->setCacheMaxAge(123); - return $return_as_object ? $result : $result->isAllowed(); - } - public function getPlugin() { - return NULL; - } - }; - $file->setFileUri($uri); - return $file; - }; - // @codingStandardsIgnoreEnd - - $storage = $this->createMock(EntityStorageInterface::class); - $map = [ - [ - ['uri' => 'public://ocean.jpg'], - [$create_image('public://ocean.jpg')], - ], - [ - ['uri' => 'private://sea.jpg'], - [$create_image('private://sea.jpg')], - ], - ]; - $storage->method('loadByProperties') - ->will($this->returnValueMap($map)); - - $entity_type_manager = $this->createMock(EntityTypeManagerInterface::class); - $entity_type_manager->method('getStorage')->willReturn($storage); - - $this->container->set('entity_type.manager', $entity_type_manager); - - // -- Public image with access check. - $build = $this->twigExtension->drupalImage('public://ocean.jpg'); - $expected_build = [ - '#uri' => 'public://ocean.jpg', - '#attributes' => [], - '#theme' => 'image', - '#cache' => [ - 'contexts' => ['user'], - 'tags' => ['tag_for_public://ocean.jpg'], - 'max-age' => 123, - ], - ]; - self::assertSame($expected_build, $build); - - // -- Public image without access check. - $build = $this->twigExtension->drupalImage('public://ocean.jpg', NULL, [], NULL, FALSE); - $expected_build = [ - '#uri' => 'public://ocean.jpg', - '#attributes' => [], - '#theme' => 'image', - '#cache' => [ - 'contexts' => [], - 'tags' => [], - 'max-age' => Cache::PERMANENT, - ], - ]; - self::assertSame($expected_build, $build); - - // -- Private image with access check. - $build = $this->twigExtension->drupalImage('private://sea.jpg'); - $expected_build = [ - '#cache' => [ - 'contexts' => ['user'], - 'tags' => ['tag_for_private://sea.jpg'], - 'max-age' => 123, - ], - ]; - self::assertSame($expected_build, $build); - - // -- Private image without access check. - $build = $this->twigExtension->drupalImage('private://sea.jpg', NULL, [], NULL, FALSE); - $expected_build = [ - '#uri' => 'private://sea.jpg', - '#attributes' => [], - '#theme' => 'image', - '#cache' => [ - 'contexts' => [], - 'tags' => [], - 'max-age' => Cache::PERMANENT, - ], - ]; - self::assertSame($expected_build, $build); - } - - /** - * Test callback. - */ - public function testView() { - - // -- Unprivileged user with access check. - $this->setUpCurrentUser(['name' => 'User 1']); - - $build = $this->twigExtension->view($this->node); - self::assertArrayNotHasKey('#node', $build); - $expected_cache = [ - 'contexts' => ['user.permissions'], - 'tags' => ['node:1'], - 'max-age' => Cache::PERMANENT, - ]; - self::assertSame($expected_cache, $build['#cache']); - - // -- Unprivileged user without access check. - $build = $this->twigExtension->view($this->node, NULL, NULL, FALSE); - self::assertArrayHasKey('#node', $build); - $expected_cache = [ - 'tags' => [ - 'node:1', - 'node_view', - ], - 'contexts' => [], - 'max-age' => Cache::PERMANENT, - ]; - self::assertSame($expected_cache, $build['#cache']); - - // -- Privileged user with access check. - $this->setUpCurrentUser(['name' => 'User 2'], ['access content']); - - $build = $this->twigExtension->view($this->node, NULL); - self::assertArrayHasKey('#node', $build); - $expected_cache = [ - 'tags' => [ - 'node:1', - 'node_view', - 'tag_from_twig_tweak_test_node_access', - ], - 'contexts' => [ - 'user', - 'user.permissions', - ], - 'max-age' => 50, - ]; - self::assertSame($expected_cache, $build['#cache']); - - // -- Privileged user without access check. - $build = $this->twigExtension->view($this->node, NULL, NULL, FALSE); - self::assertArrayHasKey('#node', $build); - $expected_cache = [ - 'tags' => [ - 'node:1', - 'node_view', - ], - 'contexts' => [], - 'max-age' => Cache::PERMANENT, - ]; - self::assertSame($expected_cache, $build['#cache']); - } - -} diff --git a/tests/src/Kernel/BlockViewBuilderTest.php b/tests/src/Kernel/BlockViewBuilderTest.php new file mode 100644 index 0000000..e075075 --- /dev/null +++ b/tests/src/Kernel/BlockViewBuilderTest.php @@ -0,0 +1,119 @@ +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', + '#attributes' => [], + '#contextual_links' => [], + '#configuration' => [ + 'id' => 'twig_tweak_test_foo', + 'label' => '', + 'provider' => 'twig_tweak_test', + 'label_display' => 'visible', + '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, + ], + ]; + self::assertSame($expected_build, $build); + self::assertSame('
Foo
', $this->renderPlain($build)); + + // -- Non-default configuration. + $build = $view_builder->build('twig_tweak_test_foo', ['content' => 'Bar', 'label' => 'Example']); + $expected_build['content']['#markup'] = 'Bar'; + $expected_build['#configuration']['label'] = 'Example'; + $expected_build['#configuration']['content'] = 'Bar'; + self::assertSame($expected_build, $build); + self::assertSame('

Example

Bar
', $this->renderPlain($build)); + + // -- Without wrapper. + $build = $view_builder->build('twig_tweak_test_foo', [], FALSE); + $expected_build = [ + 'content' => [ + '#markup' => 'Foo', + '#cache' => [ + 'contexts' => ['url'], + 'tags' => ['tag_from_build'], + ], + ], + '#cache' => [ + 'contexts' => ['user'], + 'tags' => ['tag_from_blockAccess'], + 'max-age' => 35, + ], + ]; + 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, + ], + ]; + 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))); + } + +} diff --git a/tests/src/Kernel/EntityFormViewBuilderTest.php b/tests/src/Kernel/EntityFormViewBuilderTest.php new file mode 100644 index 0000000..1df47bb --- /dev/null +++ b/tests/src/Kernel/EntityFormViewBuilderTest.php @@ -0,0 +1,128 @@ +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 testEntityViewBuilder(): 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::assertSame($expected_cache, $build['#cache']); + self::assertContains('
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::assertSame($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::assertSame($expected_cache, $build['#cache']); + self::assertContains('renderPlain($build)); + } + + /** + * Renders a render array. + */ + private function renderPlain(array $build): string { + return $this->container->get('renderer')->renderPlain($build); + } + +} diff --git a/tests/src/Kernel/EntityViewBuilderTest.php b/tests/src/Kernel/EntityViewBuilderTest.php new file mode 100644 index 0000000..7a4912e --- /dev/null +++ b/tests/src/Kernel/EntityViewBuilderTest.php @@ -0,0 +1,203 @@ +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::assertSame($expected_cache, $build['#cache']); + + $expected_html = <<< 'HTML' + + 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::assertSame($expected_cache, $build['#cache']); + + $expected_html = <<< 'HTML' + + 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::assertSame($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::assertSame($expected_cache, $build['#cache']); + + $expected_html = <<< 'HTML' + + 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('#
.+
#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)); + } + +} diff --git a/tests/src/Kernel/FieldViewBuilderTest.php b/tests/src/Kernel/FieldViewBuilderTest.php new file mode 100644 index 0000000..d5feddc --- /dev/null +++ b/tests/src/Kernel/FieldViewBuilderTest.php @@ -0,0 +1,141 @@ +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::assertSame($expected_cache, $build['#cache']); + + self::assertSame('Public node', $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::assertSame($expected_cache, $build['#cache']); + $expected_html = 'Public node'; + 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::assertSame($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('Private node', $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('#
.+
#s', '', $actual_html); + $actual_html = preg_replace(['#\s{2,}#', '#\n#'], '', $actual_html); + return $actual_html; + } + +} diff --git a/tests/src/Kernel/ImageViewBuilderTest.php b/tests/src/Kernel/ImageViewBuilderTest.php new file mode 100644 index 0000000..ab43bef --- /dev/null +++ b/tests/src/Kernel/ImageViewBuilderTest.php @@ -0,0 +1,163 @@ +installEntitySchema('file'); + $this->installSchema('file', 'file_usage'); + ImageStyle::create(['name' => 'large'])->save(); + ResponsiveImageStyle::create(['id' => 'wide'])->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'); + + /** @var \Drupal\file\FileInterface $public_image */ + $public_image = File::create(['uri' => 'public://ocean.jpg']); + $public_image->save(); + + /** @var \Drupal\file\FileInterface $private_image */ + $private_image = File::create(['uri' => 'private://sea.jpg']); + $private_image->save(); + + // -- Without style. + $build = $view_builder->build($public_image); + $expected_build = [ + '#uri' => 'public://ocean.jpg', + '#attributes' => [], + '#theme' => 'image', + '#cache' => [ + 'contexts' => [ + 'user', + 'user.permissions', + ], + 'tags' => ['tag_for_public://ocean.jpg'], + 'max-age' => 70, + ], + ]; + self::assertSame($expected_build, $build); + self::assertSame('', $this->renderPlain($build)); + + // -- With style. + $build = $view_builder->build($public_image, 'large', ['alt' => 'Ocean']); + $expected_build = [ + '#uri' => 'public://ocean.jpg', + '#attributes' => ['alt' => 'Ocean'], + '#theme' => 'image_style', + '#style_name' => 'large', + '#cache' => [ + 'contexts' => [ + 'user', + 'user.permissions', + ], + 'tags' => ['tag_for_public://ocean.jpg'], + 'max-age' => 70, + ], + ]; + self::assertSame($expected_build, $build); + self::assertSame('Ocean', $this->renderPlain($build)); + + // -- With responsive style. + $build = $view_builder->build($public_image, 'wide', ['alt' => 'Ocean'], TRUE); + $expected_build = [ + '#uri' => 'public://ocean.jpg', + '#attributes' => ['alt' => 'Ocean'], + '#type' => 'responsive_image', + '#responsive_image_style_id' => 'wide', + '#cache' => [ + 'contexts' => [ + 'user', + 'user.permissions', + ], + 'tags' => ['tag_for_public://ocean.jpg'], + 'max-age' => 70, + ], + ]; + self::assertSame($expected_build, $build); + self::assertSame('Ocean', $this->renderPlain($build)); + + // -- Private image with access check. + $build = $view_builder->build($private_image); + $expected_build = [ + '#cache' => [ + 'contexts' => ['user'], + 'tags' => ['tag_for_private://sea.jpg'], + 'max-age' => 70, + ], + ]; + self::assertSame($expected_build, $build); + self::assertSame('', $this->renderPlain($build)); + + // -- Private image without access check. + $build = $view_builder->build($private_image, NULL, [], FALSE, FALSE); + $expected_build = [ + '#uri' => 'private://sea.jpg', + '#attributes' => [], + '#theme' => 'image', + '#cache' => [ + 'contexts' => [], + 'tags' => [], + 'max-age' => Cache::PERMANENT, + ], + ]; + self::assertSame($expected_build, $build); + self::assertSame('', $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); + } + +} diff --git a/tests/src/Kernel/MenuViewBuilderTest.php b/tests/src/Kernel/MenuViewBuilderTest.php new file mode 100644 index 0000000..09bdbaf --- /dev/null +++ b/tests/src/Kernel/MenuViewBuilderTest.php @@ -0,0 +1,124 @@ +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' + + HTML; + $this->assertMarkup($expected_output, $build); + + $build = $view_builder->build('test-menu', 2); + $expected_output = <<< 'HTML' + + HTML; + $this->assertMarkup($expected_output, $build); + + $build = $view_builder->build('test-menu', 1, 1); + $expected_output = <<< 'HTML' + + 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); + } + +} diff --git a/tests/src/Kernel/RegionViewBuilderTest.php b/tests/src/Kernel/RegionViewBuilderTest.php new file mode 100644 index 0000000..aafba4a --- /dev/null +++ b/tests/src/Kernel/RegionViewBuilderTest.php @@ -0,0 +1,145 @@ +installEntitySchema('block'); + $this->container->get('theme_installer')->install(['stable']); + + $values = [ + 'id' => 'public_block', + 'plugin' => 'system_powered_by_block', + 'theme' => 'stable', + 'region' => 'sidebar_first', + ]; + Block::create($values)->save(); + + $values = [ + 'id' => 'private_block', + 'plugin' => 'system_powered_by_block', + 'theme' => 'stable', + '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 'stable' is not a default theme. + self::assertSame([], $build); + + // Specify the theme name explicitly. + $build = $view_builder->build('sidebar_first', 'stable'); + $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', + 'tag_for_private_block', + 'tag_for_public_block', + ], + 'max-age' => 123, + ], + ]; + self::assertSame($expected_build, $build); + + $expected_html = <<< 'HTML' +
+ +
+ HTML; + $actual_html = $renderer->renderPlain($build); + self::assertSame(self::normalizeHtml($expected_html), self::normalizeHtml($actual_html)); + + // Set 'stable' as default site theme and check if the view builder without + // 'theme' argument returns the same result. + $this->container->get('config.factory') + ->getEditable('system.theme') + ->set('default', 'stable') + ->save(); + + $build = $view_builder->build('sidebar_first'); + self::assertSame($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)); + } + +} diff --git a/tests/twig_tweak_test/config/install/block.block.classy_content.yml b/tests/twig_tweak_test/config/install/block.block.classy_content.yml new file mode 100644 index 0000000..38293c0 --- /dev/null +++ b/tests/twig_tweak_test/config/install/block.block.classy_content.yml @@ -0,0 +1,19 @@ +langcode: en +status: true +dependencies: + module: + - system + theme: + - classy +id: classy_content +theme: classy +region: content +weight: 0 +provider: null +plugin: system_main_block +settings: + id: system_main_block + label: 'Main page content' + provider: system + label_display: '0' +visibility: { } diff --git a/tests/twig_tweak_test/config/install/core.entity_form_display.node.page.default.yml b/tests/twig_tweak_test/config/install/core.entity_form_display.node.page.default.yml new file mode 100644 index 0000000..1d61203 --- /dev/null +++ b/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 diff --git a/tests/twig_tweak_test/config/install/core.entity_view_display.media.image.default.yml b/tests/twig_tweak_test/config/install/core.entity_view_display.media.image.default.yml index ee76691..175efd2 100644 --- a/tests/twig_tweak_test/config/install/core.entity_view_display.media.image.default.yml +++ b/tests/twig_tweak_test/config/install/core.entity_view_display.media.image.default.yml @@ -1,14 +1,12 @@ -uuid: 42d67a69-5099-4c17-b27e-32080699966b langcode: en status: true dependencies: config: - field.field.media.image.field_media_image + - image.style.large - media.type.image module: - image -_core: - default_config_hash: jOwnt_yq6AKAfqU6f0xKnxEkFQ2eTPJWxrk3WMLbL68 id: media.image.default targetEntityType: media bundle: image @@ -17,8 +15,8 @@ content: field_media_image: label: visually_hidden settings: - image_style: '' - image_link: file + image_style: large + image_link: '' third_party_settings: { } type: image weight: 1 diff --git a/tests/twig_tweak_test/config/install/core.entity_view_display.media.remote_video.default.yml b/tests/twig_tweak_test/config/install/core.entity_view_display.media.remote_video.default.yml index 7ae980f..cc8c138 100644 --- a/tests/twig_tweak_test/config/install/core.entity_view_display.media.remote_video.default.yml +++ b/tests/twig_tweak_test/config/install/core.entity_view_display.media.remote_video.default.yml @@ -1,4 +1,3 @@ -uuid: af776ed7-7db5-4670-b70b-4c94e06a6867 langcode: en status: true dependencies: @@ -7,8 +6,6 @@ dependencies: - media.type.remote_video module: - media -_core: - default_config_hash: gUaDZKMQD3lmLKWPn727X3JHVdKJ525g4EJCCcDVAqk id: media.remote_video.default targetEntityType: media bundle: remote_video diff --git a/tests/twig_tweak_test/config/install/core.entity_view_display.node.page.default.yml b/tests/twig_tweak_test/config/install/core.entity_view_display.node.page.default.yml index e3df791..30bd8fc 100644 --- a/tests/twig_tweak_test/config/install/core.entity_view_display.node.page.default.yml +++ b/tests/twig_tweak_test/config/install/core.entity_view_display.node.page.default.yml @@ -1,4 +1,3 @@ -uuid: 3fd16a0f-7a0d-46aa-8b80-2b1dc8484d09 langcode: en status: true dependencies: @@ -8,11 +7,8 @@ dependencies: - field.field.node.page.field_media - node.type.page module: - - image - text - user -_core: - default_config_hash: g1S3_GLaxq4l3I9RIca5Mlz02MxI2KmOquZpHw59akM id: node.page.default targetEntityType: node bundle: page @@ -25,24 +21,9 @@ content: region: content settings: { } third_party_settings: { } - field_image: - weight: 102 - label: above - settings: - image_style: '' - image_link: '' - third_party_settings: { } - type: image - region: content - field_media: - weight: 103 - label: above - settings: - link: true - third_party_settings: { } - type: entity_reference_label - region: content links: weight: 101 region: content -hidden: { } +hidden: + field_image: true + field_media: true diff --git a/tests/twig_tweak_test/config/install/core.entity_view_display.node.page.teaser.yml b/tests/twig_tweak_test/config/install/core.entity_view_display.node.page.teaser.yml new file mode 100644 index 0000000..aac44f9 --- /dev/null +++ b/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 diff --git a/tests/twig_tweak_test/config/install/field.field.media.image.field_media_image.yml b/tests/twig_tweak_test/config/install/field.field.media.image.field_media_image.yml index 0719e90..f6a62cc 100644 --- a/tests/twig_tweak_test/config/install/field.field.media.image.field_media_image.yml +++ b/tests/twig_tweak_test/config/install/field.field.media.image.field_media_image.yml @@ -1,4 +1,3 @@ -uuid: 31cfb877-de82-4020-b0f7-9ec72592495f langcode: en status: true dependencies: @@ -10,8 +9,6 @@ dependencies: - media module: - image -_core: - default_config_hash: pzPA-2JwyxlJ3qMb4L9viAnhNhbEhl2couH8A3FO020 id: media.image.field_media_image field_name: field_media_image entity_type: media diff --git a/tests/twig_tweak_test/config/install/field.field.media.remote_video.field_media_oembed_video.yml b/tests/twig_tweak_test/config/install/field.field.media.remote_video.field_media_oembed_video.yml index 6c98669..6ff378f 100644 --- a/tests/twig_tweak_test/config/install/field.field.media.remote_video.field_media_oembed_video.yml +++ b/tests/twig_tweak_test/config/install/field.field.media.remote_video.field_media_oembed_video.yml @@ -1,12 +1,9 @@ -uuid: 83889a69-28e6-470c-bf31-4437a5a2c1cf langcode: en status: true dependencies: config: - field.storage.media.field_media_oembed_video - media.type.remote_video -_core: - default_config_hash: Eo4HHenV5iZat_kEWgr_wydD3TgwURMCzwt-7qIEyoM id: media.remote_video.field_media_oembed_video field_name: field_media_oembed_video entity_type: media diff --git a/tests/twig_tweak_test/config/install/field.field.node.page.body.yml b/tests/twig_tweak_test/config/install/field.field.node.page.body.yml index ba42549..4ff17d0 100644 --- a/tests/twig_tweak_test/config/install/field.field.node.page.body.yml +++ b/tests/twig_tweak_test/config/install/field.field.node.page.body.yml @@ -1,4 +1,3 @@ -uuid: 42500952-ebb3-46c2-9a86-ed2f6c4000c1 langcode: en status: true dependencies: @@ -7,8 +6,6 @@ dependencies: - node.type.page module: - text -_core: - default_config_hash: rUop-8b6hvxxDYbv-KobTfNIBNbPY9qOPl8f6kBNSpw id: node.page.body field_name: body entity_type: node @@ -21,4 +18,5 @@ default_value: { } default_value_callback: '' settings: display_summary: true + required_summary: false field_type: text_with_summary diff --git a/tests/twig_tweak_test/config/install/field.field.node.page.field_image.yml b/tests/twig_tweak_test/config/install/field.field.node.page.field_image.yml index 95b54ba..ac0bff8 100644 --- a/tests/twig_tweak_test/config/install/field.field.node.page.field_image.yml +++ b/tests/twig_tweak_test/config/install/field.field.node.page.field_image.yml @@ -1,4 +1,3 @@ -uuid: 0f21b708-4832-4777-91ea-236dddea0236 langcode: en status: true dependencies: diff --git a/tests/twig_tweak_test/config/install/field.field.node.page.field_media.yml b/tests/twig_tweak_test/config/install/field.field.node.page.field_media.yml index a6012b6..9bfdf74 100644 --- a/tests/twig_tweak_test/config/install/field.field.node.page.field_media.yml +++ b/tests/twig_tweak_test/config/install/field.field.node.page.field_media.yml @@ -1,4 +1,3 @@ -uuid: cc6b99d7-25b6-4d90-a65e-4021861ec6af langcode: en status: true dependencies: diff --git a/tests/twig_tweak_test/config/install/field.storage.media.field_media_image.yml b/tests/twig_tweak_test/config/install/field.storage.media.field_media_image.yml index 6d31729..231200d 100644 --- a/tests/twig_tweak_test/config/install/field.storage.media.field_media_image.yml +++ b/tests/twig_tweak_test/config/install/field.storage.media.field_media_image.yml @@ -1,4 +1,3 @@ -uuid: e8eb0ecd-b286-4d09-9152-fcc1e7868f17 langcode: en status: true dependencies: @@ -9,8 +8,6 @@ dependencies: - file - image - media -_core: - default_config_hash: 7ZBrcl87ZXaw42v952wwcw_9cQgTBq5_5tgyUkE-VV0 id: media.field_media_image field_name: field_media_image entity_type: media diff --git a/tests/twig_tweak_test/config/install/field.storage.media.field_media_oembed_video.yml b/tests/twig_tweak_test/config/install/field.storage.media.field_media_oembed_video.yml index ad0b3fa..7485f1a 100644 --- a/tests/twig_tweak_test/config/install/field.storage.media.field_media_oembed_video.yml +++ b/tests/twig_tweak_test/config/install/field.storage.media.field_media_oembed_video.yml @@ -1,11 +1,8 @@ -uuid: d879f1f4-2730-47bb-9f38-df6c265855b9 langcode: en status: true dependencies: module: - media -_core: - default_config_hash: SJgxR5XWOesQbEKqp8VgInPyJjCFU_t2pi7UzYB78xg id: media.field_media_oembed_video field_name: field_media_oembed_video entity_type: media diff --git a/tests/twig_tweak_test/config/install/field.storage.node.field_image.yml b/tests/twig_tweak_test/config/install/field.storage.node.field_image.yml index 6f5bdaa..e4da708 100644 --- a/tests/twig_tweak_test/config/install/field.storage.node.field_image.yml +++ b/tests/twig_tweak_test/config/install/field.storage.node.field_image.yml @@ -1,4 +1,3 @@ -uuid: 7d45d63c-3a03-4a65-8103-b12e70c69948 langcode: en status: true dependencies: @@ -6,8 +5,6 @@ dependencies: - file - image - node -_core: - default_config_hash: SkXIPKZYiIMMtnBmfnxk58RYfbZ8cHSw5NZPY_JByME id: node.field_image field_name: field_image entity_type: node diff --git a/tests/twig_tweak_test/config/install/field.storage.node.field_media.yml b/tests/twig_tweak_test/config/install/field.storage.node.field_media.yml index e4d608a..bbec21d 100644 --- a/tests/twig_tweak_test/config/install/field.storage.node.field_media.yml +++ b/tests/twig_tweak_test/config/install/field.storage.node.field_media.yml @@ -1,4 +1,3 @@ -uuid: 8301cf4c-fba5-440b-8323-9a0019c12d34 langcode: en status: true dependencies: diff --git a/tests/twig_tweak_test/config/install/filter.format.twig_tweak_test.yml b/tests/twig_tweak_test/config/install/filter.format.twig_tweak_test.yml index 6421ab5..a5b829e 100644 --- a/tests/twig_tweak_test/config/install/filter.format.twig_tweak_test.yml +++ b/tests/twig_tweak_test/config/install/filter.format.twig_tweak_test.yml @@ -1,7 +1,7 @@ langcode: en status: true -dependencies: {} -name: Twig tweak test +dependencies: { } +name: 'Twig tweak test' format: twig_tweak_test weight: 0 filters: diff --git a/tests/twig_tweak_test/config/install/media.type.image.yml b/tests/twig_tweak_test/config/install/media.type.image.yml index 9cb8c00..b92ef42 100644 --- a/tests/twig_tweak_test/config/install/media.type.image.yml +++ b/tests/twig_tweak_test/config/install/media.type.image.yml @@ -1,9 +1,6 @@ -uuid: 6ef5b8e7-988c-4057-8a44-55fa195fef14 langcode: en status: true dependencies: { } -_core: - default_config_hash: 6Qope5wG7HUpV0tPOBMtDI_GZkHFcF1Xj4hgD9Cu_hM id: image label: Image description: 'Use local images for reusable media.' diff --git a/tests/twig_tweak_test/config/install/media.type.remote_video.yml b/tests/twig_tweak_test/config/install/media.type.remote_video.yml index 2d48601..f2e7b33 100644 --- a/tests/twig_tweak_test/config/install/media.type.remote_video.yml +++ b/tests/twig_tweak_test/config/install/media.type.remote_video.yml @@ -1,15 +1,12 @@ -uuid: 1b4d7544-7b5e-47d2-b79e-d50d6b2442a0 langcode: en status: true dependencies: { } -_core: - default_config_hash: d_nPD2eMknkYAnSTV4FkaqijceyFJPwT5i_Ih0lEEtc id: remote_video label: 'Remote video' description: 'A remotely hosted video from YouTube or Vimeo.' source: 'oembed:video' queue_thumbnail_downloads: false -new_revision: false +new_revision: true source_configuration: thumbnails_directory: 'public://oembed_thumbnails' providers: diff --git a/tests/twig_tweak_test/config/install/node.type.page.yml b/tests/twig_tweak_test/config/install/node.type.page.yml index a7c2c41..a6ddd46 100644 --- a/tests/twig_tweak_test/config/install/node.type.page.yml +++ b/tests/twig_tweak_test/config/install/node.type.page.yml @@ -1,4 +1,3 @@ -uuid: 963c690a-b7c3-48dd-bc35-351857b9a7ce langcode: en status: true dependencies: { } diff --git a/tests/twig_tweak_test/config/install/system.menu.twig-tweak-test.yml b/tests/twig_tweak_test/config/install/system.menu.twig-tweak-test.yml index 54d5301..46dcbda 100644 --- a/tests/twig_tweak_test/config/install/system.menu.twig-tweak-test.yml +++ b/tests/twig_tweak_test/config/install/system.menu.twig-tweak-test.yml @@ -1,7 +1,7 @@ langcode: en status: true -dependencies: {} +dependencies: { } id: twig-tweak-test -label: Twig tweak test +label: 'Twig tweak test' description: '' locked: false diff --git a/tests/twig_tweak_test/config/install/views.view.twig_tweak_test.yml b/tests/twig_tweak_test/config/install/views.view.twig_tweak_test.yml index 959fb25..0b1a552 100644 --- a/tests/twig_tweak_test/config/install/views.view.twig_tweak_test.yml +++ b/tests/twig_tweak_test/config/install/views.view.twig_tweak_test.yml @@ -11,7 +11,6 @@ description: '' tag: '' base_table: node_field_data base_field: nid -core: 8.x display: default: display_plugin: default @@ -181,7 +180,7 @@ display: position: 1 display_options: display_extenders: { } - path: twig-tweak-test + path: example cache_metadata: max-age: -1 contexts: diff --git a/tests/twig_tweak_test/src/Controller/TwigTweakTestController.php b/tests/twig_tweak_test/src/Controller/TwigTweakTestController.php new file mode 100644 index 0000000..907d0cb --- /dev/null +++ b/tests/twig_tweak_test/src/Controller/TwigTweakTestController.php @@ -0,0 +1,17 @@ + 'twig_tweak_test']; + } + +} diff --git a/tests/twig_tweak_test/src/Plugin/Block/FooBlock.php b/tests/twig_tweak_test/src/Plugin/Block/FooBlock.php index 88028c4..46b1e04 100644 --- a/tests/twig_tweak_test/src/Plugin/Block/FooBlock.php +++ b/tests/twig_tweak_test/src/Plugin/Block/FooBlock.php @@ -15,12 +15,19 @@ use Drupal\Core\Session\AccountInterface; * category = @Translation("Twig Tweak") * ) */ -class FooBlock extends BlockBase { +final class FooBlock extends BlockBase { /** * {@inheritdoc} */ - protected function blockAccess(AccountInterface $account) { + 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); @@ -31,9 +38,9 @@ class FooBlock extends BlockBase { /** * {@inheritdoc} */ - public function build() { + public function build(): array { return [ - '#markup' => 'Foo', + '#markup' => $this->getConfiguration()['content'], '#cache' => [ 'contexts' => ['url'], 'tags' => ['tag_from_' . __FUNCTION__], diff --git a/tests/twig_tweak_test/templates/twig-tweak-test.html.twig b/tests/twig_tweak_test/templates/twig-tweak-test.html.twig index 847ced8..99201a1 100644 --- a/tests/twig_tweak_test/templates/twig-tweak-test.html.twig +++ b/tests/twig_tweak_test/templates/twig-tweak-test.html.twig @@ -1,9 +1,16 @@ {% set image_attributes = {style: 'width: 30px; height 30px;'} %}