TwigExtension.php 17.5 KB
Newer Older
1 2 3 4
<?php

/**
 * @file
5
 * Contains \Drupal\Core\Template\TwigExtension.
6
 *
7 8
 * This provides a Twig extension that registers various Drupal specific
 * extensions to Twig.
9
 *
10
 * @see \Drupal\Core\CoreServiceProvider
11 12 13 14
 */

namespace Drupal\Core\Template;

15
use Drupal\Component\Utility\Html;
16
use Drupal\Component\Render\MarkupInterface;
17
use Drupal\Core\Datetime\DateFormatterInterface;
18
use Drupal\Core\Render\RenderableInterface;
19
use Drupal\Core\Render\RendererInterface;
20
use Drupal\Core\Routing\UrlGeneratorInterface;
21
use Drupal\Core\Theme\ThemeManagerInterface;
22
use Drupal\Core\Url;
23

24
/**
25 26 27
 * A class providing Drupal Twig extensions.
 *
 * Specifically Twig functions, filter and node visitors.
28
 *
29
 * @see \Drupal\Core\CoreServiceProvider
30 31
 */
class TwigExtension extends \Twig_Extension {
32

33 34 35 36 37 38 39
  /**
   * The URL generator.
   *
   * @var \Drupal\Core\Routing\UrlGeneratorInterface
   */
  protected $urlGenerator;

40 41 42 43 44 45 46
  /**
   * The renderer.
   *
   * @var \Drupal\Core\Render\RendererInterface
   */
  protected $renderer;

47 48 49 50 51 52 53
  /**
   * The theme manager.
   *
   * @var \Drupal\Core\Theme\ThemeManagerInterface
   */
  protected $themeManager;

54 55 56
  /**
   * The date formatter.
   *
57
   * @var \Drupal\Core\Datetime\DateFormatterInterface
58 59 60
   */
  protected $dateFormatter;

61 62 63
  /**
   * Constructs \Drupal\Core\Template\TwigExtension.
   *
64 65 66 67 68 69 70 71 72 73
   * @param \Drupal\Core\Render\RendererInterface $renderer
   *   The renderer.
   */
  public function __construct(RendererInterface $renderer) {
    $this->renderer = $renderer;
  }

  /**
   * Sets the URL generator.
   *
74 75
   * @param \Drupal\Core\Routing\UrlGeneratorInterface $url_generator
   *   The URL generator.
76 77
   *
   * @return $this
78 79 80
   *
   * @deprecated in Drupal 8.0.x-dev, will be removed before Drupal 9.0.0.
   *   Use \Drupal\Core\Template\TwigExtension::setUrlGenerator().
81 82
   */
  public function setGenerators(UrlGeneratorInterface $url_generator) {
83 84 85 86 87 88 89 90 91 92 93 94
    return $this->setUrlGenerator($url_generator);
  }

  /**
   * Sets the URL generator.
   *
   * @param \Drupal\Core\Routing\UrlGeneratorInterface $url_generator
   *   The URL generator.
   *
   * @return $this
   */
  public function setUrlGenerator(UrlGeneratorInterface $url_generator) {
95 96 97 98
    $this->urlGenerator = $url_generator;
    return $this;
  }

99 100 101 102 103 104 105 106 107 108 109 110 111
  /**
   * Sets the theme manager.
   *
   * @param \Drupal\Core\Theme\ThemeManagerInterface $theme_manager
   *   The theme manager.
   *
   * @return $this
   */
  public function setThemeManager(ThemeManagerInterface $theme_manager) {
    $this->themeManager = $theme_manager;
    return $this;
  }

112 113 114 115 116 117 118 119
  /**
   * Sets the date formatter.
   *
   * @param \Drupal\Core\Datetime\DateFormatter $date_formatter
   *   The date formatter.
   *
   * @return $this
   */
120
  public function setDateFormatter(DateFormatterInterface $date_formatter) {
121 122 123 124
    $this->dateFormatter = $date_formatter;
    return $this;
  }

125 126 127
  /**
   * {@inheritdoc}
   */
128
  public function getFunctions() {
129
    return [
130
      // This function will receive a renderable array, if an array is detected.
131
      new \Twig_SimpleFunction('render_var', array($this, 'renderVar')),
132 133 134 135
      // The url and path function are defined in close parallel to those found
      // in \Symfony\Bridge\Twig\Extension\RoutingExtension
      new \Twig_SimpleFunction('url', array($this, 'getUrl'), array('is_safe_callback' => array($this, 'isUrlGenerationSafe'))),
      new \Twig_SimpleFunction('path', array($this, 'getPath'), array('is_safe_callback' => array($this, 'isUrlGenerationSafe'))),
136
      new \Twig_SimpleFunction('link', array($this, 'getLink')),
137 138 139
      new \Twig_SimpleFunction('file_url', function ($uri) {
        return file_url_transform_relative(file_create_url($uri));
      }),
140
      new \Twig_SimpleFunction('attach_library', [$this, 'attachLibrary']),
141
      new \Twig_SimpleFunction('active_theme_path', [$this, 'getActiveThemePath']),
142 143
      new \Twig_SimpleFunction('active_theme', [$this, 'getActiveTheme']),
    ];
144 145
  }

146 147 148
  /**
   * {@inheritdoc}
   */
149 150
  public function getFilters() {
    return array(
151
      // Translation filters.
152 153
      new \Twig_SimpleFilter('t', 't', array('is_safe' => array('html'))),
      new \Twig_SimpleFilter('trans', 't', array('is_safe' => array('html'))),
154 155 156 157 158
      // The "raw" filter is not detectable when parsing "trans" tags. To detect
      // which prefix must be used for translation (@, !, %), we must clone the
      // "raw" filter and give it identifiable names. These filters should only
      // be used in "trans" tags.
      // @see TwigNodeTrans::compileString()
159
      new \Twig_SimpleFilter('placeholder', [$this, 'escapePlaceholder'], array('is_safe' => array('html'), 'needs_environment' => TRUE)),
160 161

      // Replace twig's escape filter with our own.
162
      new \Twig_SimpleFilter('drupal_escape', [$this, 'escapeFilter'], array('needs_environment' => true, 'is_safe_callback' => 'twig_escape_filter_is_safe')),
163 164 165 166

      // Implements safe joining.
      // @todo Make that the default for |join? Upstream issue:
      //   https://github.com/fabpot/Twig/issues/1420
167
      new \Twig_SimpleFilter('safe_join', [$this, 'safeJoin'], ['needs_environment' => true, 'is_safe' => ['html']]),
168 169

      // Array filters.
170
      new \Twig_SimpleFilter('without', 'twig_without'),
171 172

      // CSS class and ID filters.
173
      new \Twig_SimpleFilter('clean_class', '\Drupal\Component\Utility\Html::getClass'),
174
      new \Twig_SimpleFilter('clean_id', '\Drupal\Component\Utility\Html::getId'),
175
      // This filter will render a renderable array to use the string results.
176
      new \Twig_SimpleFilter('render', array($this, 'renderVar')),
177
      new \Twig_SimpleFilter('format_date', array($this->dateFormatter, 'format')),
178 179 180
    );
  }

181 182 183
  /**
   * {@inheritdoc}
   */
184 185
  public function getNodeVisitors() {
    // The node visitor is needed to wrap all variables with
186
    // render_var -> TwigExtension->renderVar() function.
187 188 189 190 191
    return array(
      new TwigNodeVisitor(),
    );
  }

192 193 194
  /**
   * {@inheritdoc}
   */
195 196
  public function getTokenParsers() {
    return array(
197
      new TwigTransTokenParser(),
198 199 200
    );
  }

201 202 203 204
  /**
   * {@inheritdoc}
   */
  public function getName() {
205 206 207
    return 'drupal_core';
  }

208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244
  /**
   * Generates a URL path given a route name and parameters.
   *
   * @param $name
   *   The name of the route.
   * @param array $parameters
   *   An associative array of route parameters names and values.
   * @param array $options
   *   (optional) An associative array of additional options. The 'absolute'
   *   option is forced to be FALSE.
   *   @see \Drupal\Core\Routing\UrlGeneratorInterface::generateFromRoute().
   *
   * @return string
   *   The generated URL path (relative URL) for the given route.
   */
  public function getPath($name, $parameters = array(), $options = array()) {
    $options['absolute'] = FALSE;
    return $this->urlGenerator->generateFromRoute($name, $parameters, $options);
  }

  /**
   * Generates an absolute URL given a route name and parameters.
   *
   * @param $name
   *   The name of the route.
   * @param array $parameters
   *   An associative array of route parameter names and values.
   * @param array $options
   *   (optional) An associative array of additional options. The 'absolute'
   *   option is forced to be TRUE.
   *
   * @return string
   *   The generated absolute URL for the given route.
   *
   * @todo Add an option for scheme-relative URLs.
   */
  public function getUrl($name, $parameters = array(), $options = array()) {
245
    // Generate URL.
246
    $options['absolute'] = TRUE;
247 248
    $generated_url = $this->urlGenerator->generateFromRoute($name, $parameters, $options, TRUE);

249
    // Return as render array, so we can bubble the bubbleable metadata.
250 251 252
    $build = ['#markup' => $generated_url->getGeneratedUrl()];
    $generated_url->applyTo($build);
    return $build;
253 254
  }

255 256 257 258 259 260 261
  /**
   * Gets a rendered link from an url object.
   *
   * @param string $text
   *   The link text for the anchor tag as a translated string.
   * @param \Drupal\Core\Url|string $url
   *   The URL object or string used for the link.
262 263
   * @param array|\Drupal\Core\Template\Attribute $attributes
   *   An optional array or Attribute object of link attributes.
264
   *
265 266
   * @return array
   *   A render array representing a link to the given URL.
267
   */
268
  public function getLink($text, $url, $attributes = []) {
269 270
    if (!$url instanceof Url) {
      $url = Url::fromUri($url);
271
    }
272
    if ($attributes) {
273 274 275
      if ($attributes instanceof Attribute) {
        $attributes = $attributes->toArray();
      }
276 277 278 279 280
      if ($existing_attributes = $url->getOption('attributes')) {
        $attributes = array_merge($existing_attributes, $attributes);
      }
      $url->setOption('attributes', $attributes);
    }
281 282 283 284 285 286
    $build = [
      '#type' => 'link',
      '#title' => $text,
      '#url' => $url,
    ];
    return $build;
287 288
  }

289 290 291 292 293 294 295 296 297 298
  /**
   * Gets the name of the active theme.
   *
   * @return string
   *   The name of the active theme.
   */
  public function getActiveTheme() {
    return $this->themeManager->getActiveTheme()->getName();
  }

299 300 301 302 303 304 305 306 307 308
  /**
   * Gets the path of the active theme.
   *
   * @return string
   *   The path to the active theme.
   */
  public function getActiveThemePath() {
    return $this->themeManager->getActiveTheme()->getPath();
  }

309 310 311 312 313 314 315 316 317 318
  /**
   * Determines at compile time whether the generated URL will be safe.
   *
   * Saves the unneeded automatic escaping for performance reasons.
   *
   * The URL generation process percent encodes non-alphanumeric characters.
   * Thus, the only character within an URL that must be escaped in HTML is the
   * ampersand ("&") which separates query params. Thus we cannot mark
   * the generated URL as always safe, but only when we are sure there won't be
   * multiple query params. This is the case when there are none or only one
319 320
   * constant parameter given. For instance, we know beforehand this will not
   * need to be escaped:
321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347
   * - path('route')
   * - path('route', {'param': 'value'})
   * But the following may need to be escaped:
   * - path('route', var)
   * - path('route', {'param': ['val1', 'val2'] }) // a sub-array
   * - path('route', {'param1': 'value1', 'param2': 'value2'})
   * If param1 and param2 reference placeholders in the route, it would not
   * need to be escaped, but we don't know that in advance.
   *
   * @param \Twig_Node $args_node
   *   The arguments of the path/url functions.
   *
   * @return array
   *   An array with the contexts the URL is safe
   */
  public function isUrlGenerationSafe(\Twig_Node $args_node) {
    // Support named arguments.
    $parameter_node = $args_node->hasNode('parameters') ? $args_node->getNode('parameters') : ($args_node->hasNode(1) ? $args_node->getNode(1) : NULL);

    if (!isset($parameter_node) || $parameter_node instanceof \Twig_Node_Expression_Array && count($parameter_node) <= 2 &&
        (!$parameter_node->hasNode(1) || $parameter_node->getNode(1) instanceof \Twig_Node_Expression_Constant)) {
      return array('html');
    }

    return array();
  }

348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365
  /**
   * Attaches an asset library to the template, and hence to the response.
   *
   * Allows Twig templates to attach asset libraries using
   * @code
   * {{ attach_library('extension/library_name') }}
   * @endcode
   *
   * @param string $library
   *   An asset library.
   */
  public function attachLibrary($library) {
    // Use Renderer::render() on a temporary render array to get additional
    // bubbleable metadata on the render stack.
    $template_attached = ['#attached' => ['library' => [$library]]];
    $this->renderer->render($template_attached);
  }

366 367 368 369 370 371 372 373 374 375 376 377 378 379 380
  /**
   * Provides a placeholder wrapper around ::escapeFilter.
   *
   * @param \Twig_Environment $env
   *   A Twig_Environment instance.
   * @param mixed $string
   *   The value to be escaped.
   *
   * @return string|null
   *   The escaped, rendered output, or NULL if there is no valid output.
   */
  public function escapePlaceholder($env, $string) {
    return '<em class="placeholder">' . $this->escapeFilter($env, $string) . '</em>';
  }

381 382 383 384 385
  /**
   * Overrides twig_escape_filter().
   *
   * Replacement function for Twig's escape filter.
   *
386 387 388
   * Note: This function should be kept in sync with
   * theme_render_and_autoescape().
   *
389 390 391 392 393 394 395 396 397 398 399 400 401 402
   * @param \Twig_Environment $env
   *   A Twig_Environment instance.
   * @param mixed $arg
   *   The value to be escaped.
   * @param string $strategy
   *   The escaping strategy. Defaults to 'html'.
   * @param string $charset
   *   The charset.
   * @param bool $autoescape
   *   Whether the function is called by the auto-escaping feature (TRUE) or by
   *   the developer (FALSE).
   *
   * @return string|null
   *   The escaped, rendered output, or NULL if there is no valid output.
403 404 405
   *
   * @todo Refactor this to keep it in sync with theme_render_and_autoescape()
   *   in https://www.drupal.org/node/2575065
406 407 408 409 410 411 412 413 414 415 416 417 418
   */
  public function escapeFilter(\Twig_Environment $env, $arg, $strategy = 'html', $charset = NULL, $autoescape = FALSE) {
    // Check for a numeric zero int or float.
    if ($arg === 0 || $arg === 0.0) {
      return 0;
    }

    // Return early for NULL and empty arrays.
    if ($arg == NULL) {
      return NULL;
    }

    // Keep Twig_Markup objects intact to support autoescaping.
419
    if ($autoescape && ($arg instanceof \Twig_Markup || $arg instanceof MarkupInterface)) {
420 421 422 423 424 425 426 427 428
      return $arg;
    }

    $return = NULL;

    if (is_scalar($arg)) {
      $return = (string) $arg;
    }
    elseif (is_object($arg)) {
429 430 431 432
      if ($arg instanceof RenderableInterface) {
        $arg = $arg->toRenderable();
      }
      elseif (method_exists($arg, '__toString')) {
433 434 435
        $return = (string) $arg;
      }
      // You can't throw exceptions in the magic PHP __toString methods, see
436
      // http://php.net/manual/language.oop5.magic.php#object.tostring so
437 438 439 440 441
      // we also support a toString method.
      elseif (method_exists($arg, 'toString')) {
        $return = $arg->toString();
      }
      else {
442
        throw new \Exception('Object of type ' . get_class($arg) . ' cannot be printed.');
443 444 445 446 447
      }
    }

    // We have a string or an object converted to a string: Autoescape it!
    if (isset($return)) {
448
      if ($autoescape && $return instanceof MarkupInterface) {
449 450 451 452 453
        return $return;
      }
      // Drupal only supports the HTML escaping strategy, so provide a
      // fallback for other strategies.
      if ($strategy == 'html') {
454
        return Html::escape($return);
455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500
      }
      return twig_escape_filter($env, $return, $strategy, $charset, $autoescape);
    }

    // This is a normal render array, which is safe by definition, with
    // special simple cases already handled.

    // Early return if this element was pre-rendered (no need to re-render).
    if (isset($arg['#printed']) && $arg['#printed'] == TRUE && isset($arg['#markup']) && strlen($arg['#markup']) > 0) {
      return $arg['#markup'];
    }
    $arg['#printed'] = FALSE;
    return $this->renderer->render($arg);
  }

  /**
   * Wrapper around render() for twig printed output.
   *
   * If an object is passed that has no __toString method an exception is thrown;
   * other objects are casted to string. However in the case that the object is an
   * instance of a Twig_Markup object it is returned directly to support auto
   * escaping.
   *
   * If an array is passed it is rendered via render() and scalar values are
   * returned directly.
   *
   * @param mixed $arg
   *   String, Object or Render Array.
   *
   * @return mixed
   *   The rendered output or an Twig_Markup object.
   *
   * @see render
   * @see TwigNodeVisitor
   */
  public function renderVar($arg) {
    // Check for a numeric zero int or float.
    if ($arg === 0 || $arg === 0.0) {
      return 0;
    }

    // Return early for NULL and empty arrays.
    if ($arg == NULL) {
      return NULL;
    }

501
    // Optimize for scalars as it is likely they come from the escape filter.
502 503 504 505 506
    if (is_scalar($arg)) {
      return $arg;
    }

    if (is_object($arg)) {
507 508 509 510
      if ($arg instanceof RenderableInterface) {
        $arg = $arg->toRenderable();
      }
      elseif (method_exists($arg, '__toString')) {
511 512 513
        return (string) $arg;
      }
      // You can't throw exceptions in the magic PHP __toString methods, see
514
      // http://php.net/manual/language.oop5.magic.php#object.tostring so
515 516 517 518 519
      // we also support a toString method.
      elseif (method_exists($arg, 'toString')) {
        return $arg->toString();
      }
      else {
520
        throw new \Exception('Object of type ' . get_class($arg) . ' cannot be printed.');
521 522 523 524 525 526 527 528 529 530 531 532
      }
    }

    // This is a render array, with special simple cases already handled.
    // Early return if this element was pre-rendered (no need to re-render).
    if (isset($arg['#printed']) && $arg['#printed'] == TRUE && isset($arg['#markup']) && strlen($arg['#markup']) > 0) {
      return $arg['#markup'];
    }
    $arg['#printed'] = FALSE;
    return $this->renderer->render($arg);
  }

533 534 535 536 537
  /**
   * Joins several strings together safely.
   *
   * @param \Twig_Environment $env
   *   A Twig_Environment instance.
538
   * @param mixed[]|\Traversable|NULL $value
539 540 541 542 543 544 545 546 547 548
   *   The pieces to join.
   * @param string $glue
   *   The delimiter with which to join the string. Defaults to an empty string.
   *   This value is expected to be safe for output and user provided data
   *   should never be used as a glue.
   *
   * @return string
   *   The strings joined together.
   */
  public function safeJoin(\Twig_Environment $env, $value, $glue = '') {
549 550 551 552
    if ($value instanceof \Traversable) {
      $value = iterator_to_array($value, false);
    }

553 554 555
    return implode($glue, array_map(function($item) use ($env) {
      // If $item is not marked safe then it will be escaped.
      return $this->escapeFilter($env, $item, 'html', NULL, TRUE);
556
    }, (array) $value));
557 558
  }

559
}