TwigExtension.php 17.2 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\Utility\SafeMarkup;
17
use Drupal\Component\Utility\SafeStringInterface;
18
use Drupal\Core\Datetime\DateFormatter;
19
use Drupal\Core\Render\RenderableInterface;
20
use Drupal\Core\Render\RendererInterface;
21
use Drupal\Core\Routing\UrlGeneratorInterface;
22
use Drupal\Core\Theme\ThemeManagerInterface;
23
use Drupal\Core\Url;
24

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

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

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

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

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

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

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

100 101 102 103 104 105 106 107 108 109 110 111 112
  /**
   * 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;
  }

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

126 127 128
  /**
   * {@inheritdoc}
   */
129
  public function getFunctions() {
130
    return [
131
      // This function will receive a renderable array, if an array is detected.
132
      new \Twig_SimpleFunction('render_var', array($this, 'renderVar')),
133 134 135 136
      // 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'))),
137
      new \Twig_SimpleFunction('link', array($this, 'getLink')),
138
      new \Twig_SimpleFunction('file_url', 'file_create_url'),
139 140 141
      new \Twig_SimpleFunction('attach_library', [$this, 'attachLibrary']),
      new \Twig_SimpleFunction('active_theme', [$this, 'getActiveTheme']),
    ];
142 143
  }

144 145 146
  /**
   * {@inheritdoc}
   */
147 148
  public function getFilters() {
    return array(
149
      // Translation filters.
150 151
      new \Twig_SimpleFilter('t', 't', array('is_safe' => array('html'))),
      new \Twig_SimpleFilter('trans', 't', array('is_safe' => array('html'))),
152 153 154 155 156
      // 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()
157
      new \Twig_SimpleFilter('passthrough', 'twig_raw_filter', array('is_safe' => array('html'))),
158
      new \Twig_SimpleFilter('placeholder', [$this, 'escapePlaceholder'], array('is_safe' => array('html'), 'needs_environment' => TRUE)),
159 160

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

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

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

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

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

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

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

207 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
  /**
   * 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()) {
244
    // Generate URL.
245
    $options['absolute'] = TRUE;
246 247
    $generated_url = $this->urlGenerator->generateFromRoute($name, $parameters, $options, TRUE);

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

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

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

298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336
  /**
   * 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
   * constant parameter given. E.g. we know beforehand this will not need to
   * be escaped:
   * - 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();
  }

337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354
  /**
   * 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);
  }

355 356 357 358 359 360 361 362 363 364 365 366 367 368 369
  /**
   * 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>';
  }

370 371 372 373 374
  /**
   * Overrides twig_escape_filter().
   *
   * Replacement function for Twig's escape filter.
   *
375 376 377
   * Note: This function should be kept in sync with
   * theme_render_and_autoescape().
   *
378 379 380 381 382 383 384 385 386 387 388 389 390 391
   * @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.
392 393 394
   *
   * @todo Refactor this to keep it in sync with theme_render_and_autoescape()
   *   in https://www.drupal.org/node/2575065
395 396 397 398 399 400 401 402 403 404 405 406 407
   */
  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.
408
    if ($autoescape && ($arg instanceOf \Twig_Markup || $arg instanceOf SafeStringInterface)) {
409 410 411 412 413 414 415 416 417
      return $arg;
    }

    $return = NULL;

    if (is_scalar($arg)) {
      $return = (string) $arg;
    }
    elseif (is_object($arg)) {
418 419 420 421
      if ($arg instanceof RenderableInterface) {
        $arg = $arg->toRenderable();
      }
      elseif (method_exists($arg, '__toString')) {
422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442
        $return = (string) $arg;
      }
      // You can't throw exceptions in the magic PHP __toString methods, see
      // http://php.net/manual/en/language.oop5.magic.php#object.tostring so
      // we also support a toString method.
      elseif (method_exists($arg, 'toString')) {
        $return = $arg->toString();
      }
      else {
        throw new \Exception(t('Object of type "@class" cannot be printed.', array('@class' => get_class($arg))));
      }
    }

    // We have a string or an object converted to a string: Autoescape it!
    if (isset($return)) {
      if ($autoescape && SafeMarkup::isSafe($return, $strategy)) {
        return $return;
      }
      // Drupal only supports the HTML escaping strategy, so provide a
      // fallback for other strategies.
      if ($strategy == 'html') {
443
        return Html::escape($return);
444 445 446 447 448 449 450 451 452 453 454 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
      }
      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;
    }

490
    // Optimize for scalars as it is likely they come from the escape filter.
491 492 493 494 495
    if (is_scalar($arg)) {
      return $arg;
    }

    if (is_object($arg)) {
496 497 498 499
      if ($arg instanceof RenderableInterface) {
        $arg = $arg->toRenderable();
      }
      elseif (method_exists($arg, '__toString')) {
500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521
        return (string) $arg;
      }
      // You can't throw exceptions in the magic PHP __toString methods, see
      // http://php.net/manual/en/language.oop5.magic.php#object.tostring so
      // we also support a toString method.
      elseif (method_exists($arg, 'toString')) {
        return $arg->toString();
      }
      else {
        throw new \Exception(t('Object of type "@class" cannot be printed.', array('@class' => get_class($arg))));
      }
    }

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

522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543
  /**
   * Joins several strings together safely.
   *
   * @param \Twig_Environment $env
   *   A Twig_Environment instance.
   * @param mixed[]|\Traversable $value
   *   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 = '') {
    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);
    }, $value));
  }

544
}