Update to Drupal 8.0.0 beta 14. For more information, see https://drupal.org/node/2544542

This commit is contained in:
Pantheon Automation 2015-08-27 12:03:05 -07:00 committed by Greg Anderson
parent 3b2511d96d
commit 81ccda77eb
2155 changed files with 54307 additions and 46870 deletions

View file

@ -73,6 +73,40 @@ class BubbleableMetadata extends CacheableMetadata implements AttachmentsInterfa
return $meta;
}
/**
* Creates a bubbleable metadata object from a depended object.
*
* @param \Drupal\Core\Cache\CacheableDependencyInterface|mixed $object
* The object whose cacheability metadata to retrieve. If it implements
* CacheableDependencyInterface, its cacheability metadata will be used,
* otherwise, the passed in object must be assumed to be uncacheable, so
* max-age 0 is set.
*
* @return static
*/
public static function createFromObject($object) {
$meta = parent::createFromObject($object);
if ($object instanceof AttachmentsInterface) {
$meta->attachments = $object->getAttachments();
}
return $meta;
}
/**
* {@inheritdoc}
*/
public function addCacheableDependency($other_object) {
parent::addCacheableDependency($other_object);
if ($other_object instanceof AttachmentsInterface) {
$this->addAttachments($other_object->getAttachments());
}
return $this;
}
/**
* Merges two attachments arrays (which live under the '#attached' key).
*

View file

@ -8,6 +8,7 @@
namespace Drupal\Core\Render;
use Drupal\Component\Utility\SafeMarkup;
use Drupal\Core\Access\AccessResultInterface;
/**
* Provides helper methods for Drupal render elements.
@ -136,11 +137,6 @@ class Element {
foreach (static::children($elements) as $key) {
$child = $elements[$key];
// Skip un-accessible children.
if (isset($child['#access']) && !$child['#access']) {
continue;
}
// Skip value and hidden elements, since they are not rendered.
if (!static::isVisibleElement($child)) {
continue;
@ -162,7 +158,9 @@ class Element {
* TRUE if the element is visible, otherwise FALSE.
*/
public static function isVisibleElement($element) {
return (!isset($element['#type']) || !in_array($element['#type'], ['value', 'hidden', 'token'])) && (!isset($element['#access']) || $element['#access']);
return (!isset($element['#type']) || !in_array($element['#type'], ['value', 'hidden', 'token']))
&& (!isset($element['#access'])
|| (($element['#access'] instanceof AccessResultInterface && $element['#access']->isAllowed()) || ($element['#access'] === TRUE)));
}
/**

View file

@ -14,6 +14,18 @@ use Drupal\Component\Utility\Color as ColorUtility;
/**
* Provides a form element for choosing a color.
*
* Properties:
* - #default_value: Default value, in a format like #ffffff.
*
* Example usage:
* @code
* $form['color'] = array(
* '#type' => 'color',
* '#title' => 'Color',
* '#default_value' => '#ffffff',
* );
* @endcode
*
* @FormElement("color")
*/
class Color extends FormElement {

View file

@ -7,15 +7,23 @@
namespace Drupal\Core\Render\Element;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Render\Element;
/**
* Provides a form element for date selection.
*
* The #default_value will be today's date if no value is supplied. The format
* for the #default_value and the #return_value is an array with three elements
* with the keys: 'year', month', and 'day'. For example,
* array('year' => 2007, 'month' => 2, 'day' => 15)
* Properties:
* - #default_value: An array with the keys: 'year', 'month', and 'day'.
* Defaults to the current date if no value is supplied.
*
* @code
* $form['expiration'] = array(
* '#type' => 'date',
* '#title' => t('Content expiration'),
* '#default_value' => array('year' => 2020, 'month' => 2, 'day' => 15,)
* );
* @endcode
*
* @FormElement("date")
*/
@ -26,22 +34,47 @@ class Date extends FormElement {
*/
public function getInfo() {
$class = get_class($this);
return array(
return [
'#input' => TRUE,
'#theme' => 'input__date',
'#pre_render' => array(
array($class, 'preRenderDate'),
),
'#theme_wrappers' => array('form_element'),
);
'#process' => [[$class, 'processDate']],
'#pre_render' => [[$class, 'preRenderDate']],
'#theme_wrappers' => ['form_element'],
];
}
/**
* Processes a date form element.
*
* @param array $element
* The form element to process. Properties used:
* - #attributes: An associative array containing:
* - type: The type of date field rendered.
* - #date_date_format: The date format used in PHP formats.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
* @param array $complete_form
* The complete form structure.
*
* @return array
* The processed element.
*/
public static function processDate(&$element, FormStateInterface $form_state, &$complete_form) {
// Attach JS support for the date field, if we can determine which date
// format should be used.
if ($element['#attributes']['type'] == 'date' && !empty($element['#date_date_format'])) {
$element['#attached']['library'][] = 'core/drupal.date';
$element['#attributes']['data-drupal-date-format'] = [$element['#date_date_format']];
}
return $element;
}
/**
* Adds form-specific attributes to a 'date' #type element.
*
* Supports HTML5 types of 'date', 'datetime', 'datetime-local', and 'time'.
* Falls back to a plain textfield. Used as a sub-element by the datetime
* element type.
* Falls back to a plain textfield with JS datepicker support. Used as a
* sub-element by the datetime element type.
*
* @param array $element
* An associative array containing the properties of the element.

View file

@ -13,6 +13,19 @@ use Drupal\Core\Render\Element;
/**
* Provides a form input element for entering an email address.
*
* Properties:
* - #default_value: An RFC-compliant email address.
*
* Example usage:
* @code
* $form['email'] = array(
* '#type' => 'email',
* '#title' => t('Email'),
* );
* @end
*
* @see \Drupal\Core\Render\Element\Render\Textfield
*
* @FormElement("email")
*/
class Email extends FormElement {

View file

@ -13,6 +13,13 @@ use Drupal\Core\Render\Element;
/**
* Provides a form element for uploading a file.
*
* If you add this element to a form the enctype="multipart/form-data" attribute
* will automatically be added to the form element.
*
* Properties:
* - #multiple: A Boolean indicating whether multiple files may be uploaded.
* - #size: The size of the file input element in characters.
*
* @FormElement("file")
*/
class File extends FormElement {

View file

@ -8,6 +8,8 @@
namespace Drupal\Core\Render\Element;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Render\BubbleableMetadata;
use Drupal\Core\Url;
/**
* Provides a base class for form element plugins.
@ -111,18 +113,29 @@ abstract class FormElement extends RenderElement implements FormElementInterface
* The form element.
*/
public static function processAutocomplete(&$element, FormStateInterface $form_state, &$complete_form) {
$url = NULL;
$access = FALSE;
if (!empty($element['#autocomplete_route_name'])) {
$parameters = isset($element['#autocomplete_route_parameters']) ? $element['#autocomplete_route_parameters'] : array();
$path = \Drupal::urlGenerator()->generate($element['#autocomplete_route_name'], $parameters);
$access = \Drupal::service('access_manager')->checkNamedRoute($element['#autocomplete_route_name'], $parameters, \Drupal::currentUser());
$url = Url::fromRoute($element['#autocomplete_route_name'], $parameters)->toString(TRUE);
/** @var \Drupal\Core\Access\AccessManagerInterface $access_manager */
$access_manager = \Drupal::service('access_manager');
$access = $access_manager->checkNamedRoute($element['#autocomplete_route_name'], $parameters, \Drupal::currentUser(), TRUE);
}
if ($access) {
$element['#attributes']['class'][] = 'form-autocomplete';
$element['#attached']['library'][] = 'core/drupal.autocomplete';
// Provide a data attribute for the JavaScript behavior to bind to.
$element['#attributes']['data-autocomplete-path'] = $path;
$metadata = BubbleableMetadata::createFromRenderArray($element);
if ($access->isAllowed()) {
$element['#attributes']['class'][] = 'form-autocomplete';
$metadata->addAttachments(['library' => ['core/drupal.autocomplete']]);
// Provide a data attribute for the JavaScript behavior to bind to.
$element['#attributes']['data-autocomplete-path'] = $url->getGeneratedUrl();
$metadata = $metadata->merge($url);
}
$metadata
->merge(BubbleableMetadata::createFromObject($access))
->applyTo($element);
}
return $element;

View file

@ -8,6 +8,7 @@
namespace Drupal\Core\Render\Element;
use Drupal\Component\Utility\SafeMarkup;
use Drupal\Component\Utility\Xss;
use Drupal\Core\Template\Attribute;
/**
@ -50,7 +51,7 @@ class HtmlTag extends RenderElement {
* pre-render callback being a #markup element, it is not passed through
* \Drupal\Component\Utility\Xss::filterAdmin(). This is because it is marked
* safe here, which causes
* \Drupal\Component\Utility\SafeMarkup::checkAdminXss() to regard it as safe
* \Drupal\Core\Render\Renderer::xssFilterAdminIfUnsafe() to regard it as safe
* and bypass the call to \Drupal\Component\Utility\Xss::filterAdmin().
*
* @param array $element
@ -161,7 +162,7 @@ class HtmlTag extends RenderElement {
}
else {
// The IE expression might contain some user input data.
$expression = SafeMarkup::checkAdminXss($browsers['IE']);
$expression = Xss::filterAdmin($browsers['IE']);
}
// If the #prefix and #suffix properties are used, wrap them with
@ -173,8 +174,8 @@ class HtmlTag extends RenderElement {
// Ensure what we are dealing with is safe.
// This would be done later anyway in drupal_render().
$prefix = isset($elements['#prefix']) ? SafeMarkup::checkAdminXss($elements['#prefix']) : '';
$suffix = isset($elements['#suffix']) ? SafeMarkup::checkAdminXss($elements['#suffix']) : '';
$prefix = isset($elements['#prefix']) ? Xss::filterAdmin($elements['#prefix']) : '';
$suffix = isset($elements['#suffix']) ? Xss::filterAdmin($elements['#suffix']) : '';
// Now calling SafeMarkup::set is safe, because we ensured the
// data coming in was at least admin escaped.

View file

@ -10,6 +10,7 @@ namespace Drupal\Core\Render\Element;
use Drupal\Component\Utility\NestedArray;
use Drupal\Component\Utility\Html as HtmlUtility;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Render\BubbleableMetadata;
use Drupal\Core\Url as CoreUrl;
/**
@ -83,7 +84,7 @@ class Link extends RenderElement {
$link_generator = \Drupal::service('link_generator');
$generated_link = $link_generator->generate($element['#title'], $element['#url']->setOptions($options), TRUE);
$element['#markup'] = $generated_link->getGeneratedLink();
$generated_link->merge(CacheableMetadata::createFromRenderArray($element))
$generated_link->merge(BubbleableMetadata::createFromRenderArray($element))
->applyTo($element);
}
return $element;

View file

@ -15,9 +15,58 @@ use Drupal\Core\Language\LanguageInterface;
* Provides a machine name render element.
*
* Provides a form element to enter a machine name, which is validated to ensure
* that the name is unique and does not contain disallowed characters. All
* disallowed characters are replaced with a replacement character via
* JavaScript.
* that the name is unique and does not contain disallowed characters.
*
* The element may be automatically populated via JavaScript when used in
* conjunction with a separate "source" form element (typically specifying the
* human-readable name). As the user types text into the source element, the
* JavaScript converts all values to lower case, replaces any remaining
* disallowed characters with a replacement, and populates the associated
* machine name form element.
*
* Properties:
* - #machine_name: An associative array containing:
* - exists: A callable to invoke for checking whether a submitted machine
* name value already exists. The submitted value is passed as an argument.
* In most cases, an existing API or menu argument loader function can be
* re-used. The callback is only invoked if the submitted value differs from
* the element's #default_value.
* - source: (optional) The #array_parents of the form element containing the
* human-readable name (i.e., as contained in the $form structure) to use as
* source for the machine name. Defaults to array('label').
* - label: (optional) Text to display as label for the machine name value
* after the human-readable name form element. Defaults to t('Machine name').
* - replace_pattern: (optional) A regular expression (without delimiters)
* matching disallowed characters in the machine name. Defaults to
* '[^a-z0-9_]+'.
* - replace: (optional) A character to replace disallowed characters in the
* machine name via JavaScript. Defaults to '_' (underscore). When using a
* different character, 'replace_pattern' needs to be set accordingly.
* - error: (optional) A custom form error message string to show, if the
* machine name contains disallowed characters.
* - standalone: (optional) Whether the live preview should stay in its own
* form element rather than in the suffix of the source element. Defaults
* to FALSE.
* - #maxlength: (optional) Maximum allowed length of the machine name. Defaults
* to 64.
* - #disabled: (optional) Should be set to TRUE if an existing machine name
* must not be changed after initial creation.
*
* Usage example:
* @code
* $form['id'] = array(
* '#type' => 'machine_name',
* '#default_value' => $this->entity->id(),
* '#disabled' => !$this->entity->isNew(),
* '#maxlength' => 64,
* '#description' => $this->t('A unique name for this item. It must only contain lowercase letters, numbers, and underscores.'),
* '#machine_name' => array(
* 'exists' => array($this, 'exists'),
* ),
* );
* @endcode
*
* @see \Drupal\Core\Render\Element\Textfield
*
* @FormElement("machine_name")
*/
@ -62,35 +111,7 @@ class MachineName extends Textfield {
* Processes a machine-readable name form element.
*
* @param array $element
* The form element to process. Properties used:
* - #machine_name: An associative array containing:
* - exists: A callable to invoke for checking whether a submitted machine
* name value already exists. The submitted value is passed as an
* argument. In most cases, an existing API or menu argument loader
* function can be re-used. The callback is only invoked if the
* submitted value differs from the element's #default_value.
* - source: (optional) The #array_parents of the form element containing
* the human-readable name (i.e., as contained in the $form structure)
* to use as source for the machine name. Defaults to array('label').
* - label: (optional) Text to display as label for the machine name value
* after the human-readable name form element. Defaults to "Machine
* name".
* - replace_pattern: (optional) A regular expression (without delimiters)
* matching disallowed characters in the machine name. Defaults to
* '[^a-z0-9_]+'.
* - replace: (optional) A character to replace disallowed characters in
* the machine name via JavaScript. Defaults to '_' (underscore). When
* using a different character, 'replace_pattern' needs to be set
* accordingly.
* - error: (optional) A custom form error message string to show, if the
* machine name contains disallowed characters.
* - standalone: (optional) Whether the live preview should stay in its
* own form element rather than in the suffix of the source
* element. Defaults to FALSE.
* - #maxlength: (optional) Maximum allowed length of the machine name.
* Defaults to 64.
* - #disabled: (optional) Should be set to TRUE if an existing machine
* name must not be changed after initial creation.
* The form element to process. See main class documentation for properties.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
* @param array $complete_form

View file

@ -14,6 +14,25 @@ use Drupal\Component\Utility\Number as NumberUtility;
/**
* Provides a form element for numeric input, with special numeric validation.
*
* Properties:
* - #default_value: A valid floating point number.
* - #min: Minimum value.
* - #max: Maximum value.
* - #step: Ensures that the number is an even multiple of step, offset by #min
* if specified. A #min of 1 and a #step of 2 would allow values of 1, 3, 5,
* etc.
*
* Usage example:
* @code
* $form['quantity'] = array(
* '#type' => 'number',
* '#title' => t('Quantity'),
* );
* @endcode
*
* @see \Drupal\Core\Render\Element\Range
* @see \Drupal\Core\Render\Element\Textfield
*
* @FormElement("number")
*/
class Number extends FormElement {

View file

@ -34,18 +34,29 @@ class Pager extends RenderElement{
'#quantity' => 9,
// An array of labels for the controls in the pager.
'#tags' => [],
// The name of the route to be used to build pager links. By default no
// path is provided, which will make links relative to the current URL.
// This makes the page more effectively cacheable.
'#route_name' => '<none>',
];
}
/**
* #pre_render callback to associate the appropriate cache context.
*
*
* @param array $pager
* A renderable array of #type => pager.
*
* @return array
*/
public static function preRenderPager(array $pager) {
// Note: the default pager theme process function
// template_preprocess_pager() also calls pager_query_add_page(), which
// maintains the existing query string. Therefore
// template_preprocess_pager() adds the 'url.query_args' cache context,
// which causes the more specific cache context below to be optimized away.
// In other themes, however, that may not be the case.
$pager['#cache']['contexts'][] = 'url.query_args.pagers:' . $pager['#element'];
return $pager;
}

View file

@ -12,6 +12,18 @@ use Drupal\Core\Render\Element;
/**
* Provides a form element for entering a password, with hidden text.
*
* Usage example:
* @code
* $form['pass'] = array(
* '#type' => 'password',
* '#title => t('Password'),
* '#size' => 25,
* );
* @endcode
*
* @see \Drupal\Core\Render\Element\PasswordConfirm
* @see \Drupal\Core\Render\Element\Textfield
*
* @FormElement("password")
*/
class Password extends FormElement {

View file

@ -15,6 +15,17 @@ use Drupal\Core\Form\FormStateInterface;
* Formats as a pair of password fields, which do not validate unless the two
* entered passwords match.
*
* Usage example:
* @code
* $form['pass'] = array(
* '#type' => 'password_confirm',
* '#title' => t('Password'),
* '#size' => 25,
* );
* @endcode
*
* @see \Drupal\Core\Render\Element\Password
*
* @FormElement("password_confirm")
*/
class PasswordConfirm extends FormElement {

View file

@ -11,7 +11,24 @@ use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Render\Element;
/**
* Provides a form element for input of a number within a specific range.
* Provides a slider for input of a number within a specific range.
*
* Provides an HTML5 input element with type of "range".
*
* Properties:
* - #min: Minimum value (defaults to 0).
* - #max: Maximum value (defaults to 100).
* Refer to \Drupal\Core\Render\Element\Number for additional properties.
*
* Usage example:
* @code
* $form['quantity'] = array(
* '#type' => 'number',
* '#title' => t('Quantity'),
* );
* @endcode
*
* @see \Drupal\Core\Render\Element\Number
*
* @FormElement("range")
*/

View file

@ -10,6 +10,7 @@ namespace Drupal\Core\Render\Element;
use Drupal\Core\Form\FormBuilderInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\PluginBase;
use Drupal\Core\Render\BubbleableMetadata;
use Drupal\Core\Render\Element;
use Drupal\Core\Url;
@ -128,14 +129,7 @@ abstract class RenderElement extends PluginBase implements ElementInterface {
* @see self::preRenderAjaxForm()
*/
public static function processAjaxForm(&$element, FormStateInterface $form_state, &$complete_form) {
$element = static::preRenderAjaxForm($element);
// If the element was processed as an #ajax element, and a custom URL was
// provided, set the form to be cached.
if (!empty($element['#ajax_processed']) && !empty($element['#ajax']['url'])) {
$form_state->setCached();
}
return $element;
return static::preRenderAjaxForm($element);
}
/**
@ -238,10 +232,6 @@ abstract class RenderElement extends PluginBase implements ElementInterface {
// content negotiation takes care of formatting the response appropriately.
// However, 'url' and 'options' may be set when wanting server processing
// to be substantially different for a JavaScript triggered submission.
// One such substantial difference is form elements that use
// #ajax['callback'] for determining which part of the form needs
// re-rendering. For that, we have a special 'system.ajax' route which
// must be manually set.
$settings += [
'url' => NULL,
'options' => ['query' => []],
@ -264,7 +254,11 @@ abstract class RenderElement extends PluginBase implements ElementInterface {
// Convert \Drupal\Core\Url object to string.
if (isset($settings['url']) && $settings['url'] instanceof Url) {
$settings['url'] = $settings['url']->setOptions($settings['options'])->toString();
$url = $settings['url']->setOptions($settings['options'])->toString(TRUE);
BubbleableMetadata::createFromRenderArray($element)
->merge($url)
->applyTo($element);
$settings['url'] = $url->getGeneratedUrl();
}
else {
$settings['url'] = NULL;

View file

@ -10,11 +10,17 @@ namespace Drupal\Core\Render\Element;
use Drupal\Core\Render\Element;
/**
* Provides a form input element for searching.
* Provides an HTML5 input element with type of "search".
*
* This is commonly used to provide a filter or search box at the top of a
* long listing page, to allow users to find specific items in the list for
* faster input.
* Usage example:
* @code
* $form['search'] = array(
* '#type' => 'search',
* '#title' => t('Search'),
* );
* @endcode
*
* @see \Drupal\Core\Render\Element\Textfield
*
* @FormElement("search")
*/

View file

@ -12,6 +12,19 @@ use Drupal\Core\Render\Element;
/**
* Provides a form element for entering a telephone number.
*
* Provides an HTML5 input element with type of "tel". It provides no special
* validation.
*
* Usage example:
* @code
* $form['phone'] = array(
* '#type' => 'tel',
* '#title' => t('Phone'),
* );
* @endcode
*
* @see \Drupal\Core\Render\Element
*
* @FormElement("tel")
*/
class Tel extends FormElement {

View file

@ -12,6 +12,23 @@ use Drupal\Core\Render\Element;
/**
* Provides a form element for input of multiple-line text.
*
* Properties:
* - #rows: Number of rows in the text box.
* - #cols: Number of columns in the text box.
* - #resizable: Controls whether the text area is resizable. Allowed values
* are "none", "vertical", "horizontal", or "both" (defaults to "vertical").
*
* Usage example:
* @code
* $form['text'] = array(
* '#type' => 'textarea',
* '#title' => t('Text'),
* );
* @endcode
*
* @see \Drupal\Core\Render\Element\Textfield
* @see \Drupal\filter\Element\TextFormat
*
* @FormElement("textarea")
*/
class Textarea extends FormElement {

View file

@ -13,6 +13,36 @@ use Drupal\Core\Render\Element;
/**
* Provides a one-line text field form element.
*
* Properties:
* - #maxlength: Maximum number of characters of input allowed.
* - #size: The size of the input element in characters.
* - #autocomplete_route_name: A route to be used as callback URL by the
* autocomplete JavaScript library.
* - #autocomplete_route_parameters: An array of parameters to be used in
* conjunction with the route name.
*
* Usage example:
* @code
* $form['title'] = array(
* '#type' => 'textfield',
* '#title' => t('Subject'),
* '#default_value' => $node->title,
* '#size' => 60,
* '#maxlength' => 128,
* '#required' => TRUE,
* );
* @endcode
*
* @see \Drupal\Core\Render\Element\Color
* @see \Drupal\Core\Render\Element\Email
* @see \Drupal\Core\Render\Element\MachineName
* @see \Drupal\Core\Render\Element\Number
* @see \Drupal\Core\Render\Element\Password
* @see \Drupal\Core\Render\Element\PasswordConfirm
* @see \Drupal\Core\Render\Element\Range
* @see \Drupal\Core\Render\Element\Tel
* @see \Drupal\Core\Render\Element\Url
*
* @FormElement("textfield")
*/
class Textfield extends FormElement {

View file

@ -14,6 +14,21 @@ use Drupal\Core\Render\Element;
/**
* Provides a form element for input of a URL.
*
* Properties:
* - #default_value: A valid URL string.
*
* Usage example:
* @code
* $form['homepage'] = array(
* '#type' => 'url',
* '#title' => t('Home Page'),
* '#size' => 30,
* ...
* );
* @end_code
*
* @see \Drupal\Core\Render\Element\Textfield
*
* @FormElement("url")
*/
class Url extends FormElement {

View file

@ -36,6 +36,7 @@ class HtmlResponse extends Response implements CacheableResponseInterface, Attac
// A render array can automatically be converted to a string and set the
// necessary metadata.
if (is_array($content) && (isset($content['#markup']))) {
$content += ['#attached' => ['html_response_placeholders' => []]];
$this->addCacheableDependency(CacheableMetadata::createFromRenderArray($content));
$this->setAttachments($content['#attached']);
$content = $content['#markup'];

View file

@ -64,7 +64,7 @@ class AjaxRenderer implements MainContentRendererInterface {
}
}
$html = $this->drupalRenderRoot($main_content);
$html = (string) $this->drupalRenderRoot($main_content);
$response->setAttachments($main_content['#attached']);
// The selector for the insert command is NULL as the new content will
@ -72,7 +72,7 @@ class AjaxRenderer implements MainContentRendererInterface {
// behavior can be changed with #ajax['method'].
$response->addCommand(new InsertCommand(NULL, $html));
$status_messages = array('#type' => 'status_messages');
$output = $this->drupalRenderRoot($status_messages);
$output = (string) $this->drupalRenderRoot($status_messages);
if (!empty($output)) {
$response->addCommand(new PrependCommand(NULL, $output));
}

View file

@ -14,6 +14,7 @@ use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Render\HtmlResponse;
use Drupal\Core\Render\PageDisplayVariantSelectionEvent;
use Drupal\Core\Render\RenderCacheInterface;
use Drupal\Core\Render\RenderContext;
use Drupal\Core\Render\RendererInterface;
use Drupal\Core\Render\RenderEvents;
use Drupal\Core\Routing\RouteMatchInterface;
@ -181,7 +182,15 @@ class HtmlRenderer implements MainContentRendererInterface {
// ::renderResponse().
// @todo Remove this once https://www.drupal.org/node/2359901 lands.
if (!empty($main_content)) {
$this->renderer->render($main_content, FALSE);
$this->renderer->executeInRenderContext(new RenderContext(), function() use (&$main_content) {
if (isset($main_content['#cache']['keys'])) {
// Retain #title, otherwise, dynamically generated titles would be
// missing for controllers whose entire returned render array is
// render cached.
$main_content['#cache_properties'][] = '#title';
}
return $this->renderer->render($main_content, FALSE);
});
$main_content = $this->renderCache->getCacheableRenderArray($main_content) + [
'#title' => isset($main_content['#title']) ? $main_content['#title'] : NULL
];
@ -192,7 +201,9 @@ class HtmlRenderer implements MainContentRendererInterface {
if (!$page_display instanceof PageVariantInterface) {
throw new \LogicException('Cannot render the main content for this page because the provided display variant does not implement PageVariantInterface.');
}
$page_display->setMainContent($main_content);
$page_display
->setMainContent($main_content)
->setConfiguration($event->getPluginConfiguration());
// Generate a #type => page render array using the page display variant,
// the page display will build the content for the various page regions.

View file

@ -0,0 +1,142 @@
<?php
/**
* @file
* Contains \Drupal\Core\Render\MetadataBubblingUrlGenerator.
*/
namespace Drupal\Core\Render;
use Drupal\Core\GeneratedUrl;
use Drupal\Core\Routing\UrlGeneratorInterface;
use Symfony\Component\Routing\RequestContext as SymfonyRequestContext;
/**
* Decorator for the URL generator, which bubbles bubbleable URL metadata.
*
* Implements a decorator for the URL generator that allows to automatically
* collect and bubble up bubbleable metadata associated with URLs due to
* outbound path and route processing. This approach helps keeping the render
* and the routing subsystems decoupled.
*
* @see \Drupal\Core\RouteProcessor\OutboundRouteProcessorInterface
* @see \Drupal\Core\PathProcessor\OutboundPathProcessorInterface
* @see \Drupal\Core\Render\BubbleableMetadata
*/
class MetadataBubblingUrlGenerator implements UrlGeneratorInterface {
/**
* The non-bubbling URL generator.
*
* @var \Drupal\Core\Routing\UrlGeneratorInterface
*/
protected $urlGenerator;
/**
* The renderer.
*
* @var \Drupal\Core\Render\RendererInterface
*/
protected $renderer;
/**
* Constructs a new bubbling URL generator service.
*
* @param \Drupal\Core\Routing\UrlGeneratorInterface $url_generator
* The non-bubbling URL generator.
* @param \Drupal\Core\Render\RendererInterface $renderer
* The renderer.
*/
public function __construct(UrlGeneratorInterface $url_generator, RendererInterface $renderer) {
$this->urlGenerator = $url_generator;
$this->renderer = $renderer;
}
/**
* {@inheritdoc}
*/
public function setContext(SymfonyRequestContext $context) {
$this->urlGenerator->setContext($context);
}
/**
* {@inheritdoc}
*/
public function getContext() {
return $this->urlGenerator->getContext();
}
/**
* {@inheritdoc}
*/
public function getPathFromRoute($name, $parameters = array()) {
return $this->urlGenerator->getPathFromRoute($name, $parameters);
}
/**
* Bubbles the bubbleable metadata to the current render context.
*
* @param \Drupal\Core\GeneratedUrl $generated_url
* The generated URL whose bubbleable metadata to bubble.
* @param array $options
* (optional) The URL options. Defaults to none.
*/
protected function bubble(GeneratedUrl $generated_url, array $options = []) {
// Bubbling metadata makes sense only if the code is executed inside a
// render context. All code running outside controllers has no render
// context by default, so URLs used there are not supposed to affect the
// response cacheability.
if ($this->renderer->hasRenderContext()) {
$build = [];
$generated_url->applyTo($build);
$this->renderer->render($build);
}
}
/**
* {@inheritdoc}
*/
public function generate($name, $parameters = array(), $absolute = FALSE) {
$options['absolute'] = $absolute;
$generated_url = $this->generateFromRoute($name, $parameters, $options, TRUE);
$this->bubble($generated_url);
return $generated_url->getGeneratedUrl();
}
/**
* {@inheritdoc}
*/
public function generateFromRoute($name, $parameters = array(), $options = array(), $collect_bubbleable_metadata = FALSE) {
$generated_url = $this->urlGenerator->generateFromRoute($name, $parameters, $options, TRUE);
if (!$collect_bubbleable_metadata) {
$this->bubble($generated_url, $options);
}
return $collect_bubbleable_metadata ? $generated_url : $generated_url->getGeneratedUrl();
}
/**
* {@inheritdoc}
*/
public function generateFromPath($path = NULL, $options = array(), $collect_bubbleable_metadata = FALSE) {
$generated_url = $this->urlGenerator->generateFromPath($path, $options, TRUE);
if (!$collect_bubbleable_metadata) {
$this->bubble($generated_url, $options);
}
return $collect_bubbleable_metadata ? $generated_url : $generated_url->getGeneratedUrl();
}
/**
* {@inheritdoc}
*/
public function supports($name) {
return $this->urlGenerator->supports($name);
}
/**
* {@inheritdoc}
*/
public function getRouteDebugMessage($name, array $parameters = array()) {
return $this->urlGenerator->getRouteDebugMessage($name, $parameters);
}
}

View file

@ -25,6 +25,13 @@ class PageDisplayVariantSelectionEvent extends Event {
*/
protected $pluginId;
/**
* The configuration for the selected page display variant.
*
* @var array
*/
protected $pluginConfiguration = [];
/**
* The current route match.
*
@ -50,9 +57,12 @@ class PageDisplayVariantSelectionEvent extends Event {
*
* @param string $plugin_id
* The ID of the page display variant plugin to use.
*
* @return $this
*/
public function setPluginId($plugin_id) {
$this->pluginId = $plugin_id;
return $this;
}
/**
@ -64,6 +74,28 @@ class PageDisplayVariantSelectionEvent extends Event {
return $this->pluginId;
}
/**
* Set the configuration for the selected page display variant.
*
* @param array $configuration
* The configuration for the selected page display variant.
*
* @return $this
*/
public function setPluginConfiguration(array $configuration) {
$this->pluginConfiguration = $configuration;
return $this;
}
/**
* Get the configuration for the selected page display variant.
*
* @return array
*/
public function getPluginConfiguration() {
return $this->pluginConfiguration;
}
/**
* Gets the current route match.
*

View file

@ -32,6 +32,7 @@ class SimplePageVariant extends VariantBase implements PageVariantInterface {
*/
public function setMainContent(array $main_content) {
$this->mainContent = $main_content;
return $this;
}
/**

View file

@ -7,7 +7,9 @@
namespace Drupal\Core\Render;
use Drupal\Component\Utility\SafeMarkup;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Cache\Context\CacheContextsManager;
use Drupal\Core\Cache\CacheFactoryInterface;
use Symfony\Component\HttpFoundation\RequestStack;
@ -96,7 +98,6 @@ class RenderCache implements RenderCacheInterface {
$data = $this->getCacheableRenderArray($elements);
$bin = isset($elements['#cache']['bin']) ? $elements['#cache']['bin'] : 'render';
$expire = ($elements['#cache']['max-age'] === Cache::PERMANENT) ? Cache::PERMANENT : (int) $this->requestStack->getMasterRequest()->server->get('REQUEST_TIME') + $elements['#cache']['max-age'];
$cache = $this->cacheFactory->get($bin);
// Calculate the pre-bubbling CID.
@ -207,26 +208,28 @@ class RenderCache implements RenderCacheInterface {
// across requests. That's the strategy employed below and tested in
// \Drupal\Tests\Core\Render\RendererBubblingTest::testConditionalCacheContextBubblingSelfHealing().
// The set of cache contexts for this element, including the bubbled ones,
// for which we are handling a cache miss.
$cache_contexts = $data['#cache']['contexts'];
// Get the contexts by which this element should be varied according to
// the current redirecting cache item, if any.
$stored_cache_contexts = [];
$stored_cache_tags = [];
// Get the cacheability of this element according to the current (stored)
// redirecting cache item, if any.
$redirect_cacheability = new CacheableMetadata();
if ($stored_cache_redirect = $cache->get($pre_bubbling_cid)) {
$stored_cache_contexts = $stored_cache_redirect->data['#cache']['contexts'];
$stored_cache_tags = $stored_cache_redirect->data['#cache']['tags'];
$redirect_cacheability = CacheableMetadata::createFromRenderArray($stored_cache_redirect->data);
}
// Calculate the union of the cache contexts for this request and the
// stored cache contexts.
$merged_cache_contexts = Cache::mergeContexts($stored_cache_contexts, $cache_contexts);
// Calculate the union of the cacheability for this request and the
// current (stored) redirecting cache item. We need:
// - the union of cache contexts, because that is how we know which cache
// item to redirect to;
// - the union of cache tags, because that is how we know when the cache
// redirect cache item itself is invalidated;
// - the union of max ages, because that is how we know when the cache
// redirect cache item itself becomes stale. (Without this, we might end
// up toggling between a permanently and a briefly cacheable cache
// redirect, because the last update's max-age would always "win".)
$redirect_cacheability_updated = CacheableMetadata::createFromRenderArray($data)->merge($redirect_cacheability);
// Stored cache contexts incomplete: this request causes cache contexts to
// be added to the redirecting cache item.
if (array_diff($merged_cache_contexts, $stored_cache_contexts)) {
if (array_diff($redirect_cacheability_updated->getCacheContexts(), $redirect_cacheability->getCacheContexts())) {
$redirect_data = [
'#cache_redirect' => TRUE,
'#cache' => [
@ -234,14 +237,16 @@ class RenderCache implements RenderCacheInterface {
// across requests.
'keys' => $elements['#cache']['keys'],
// The union of the current element's and stored cache contexts.
'contexts' => $merged_cache_contexts,
'contexts' => $redirect_cacheability_updated->getCacheContexts(),
// The union of the current element's and stored cache tags.
'tags' => Cache::mergeTags($stored_cache_tags, $data['#cache']['tags']),
'tags' => $redirect_cacheability_updated->getCacheTags(),
// The union of the current element's and stored cache max-ages.
'max-age' => $redirect_cacheability_updated->getCacheMaxAge(),
// The same cache bin as the one for the actual render cache items.
'bin' => $bin,
],
];
$cache->set($pre_bubbling_cid, $redirect_data, $expire, Cache::mergeTags($redirect_data['#cache']['tags'], ['rendered']));
$cache->set($pre_bubbling_cid, $redirect_data, $this->maxAgeToExpire($redirect_cacheability_updated->getCacheMaxAge()), Cache::mergeTags($redirect_data['#cache']['tags'], ['rendered']));
}
// Current cache contexts incomplete: this request only uses a subset of
@ -249,20 +254,35 @@ class RenderCache implements RenderCacheInterface {
// additional (conditional) cache contexts as well, otherwise the
// redirecting cache item would be pointing to a cache item that can never
// exist.
if (array_diff($merged_cache_contexts, $cache_contexts)) {
if (array_diff($redirect_cacheability_updated->getCacheContexts(), $data['#cache']['contexts'])) {
// Recalculate the cache ID.
$recalculated_cid_pseudo_element = [
'#cache' => [
'keys' => $elements['#cache']['keys'],
'contexts' => $merged_cache_contexts,
'contexts' => $redirect_cacheability_updated->getCacheContexts(),
]
];
$cid = $this->createCacheID($recalculated_cid_pseudo_element);
// Ensure the about-to-be-cached data uses the merged cache contexts.
$data['#cache']['contexts'] = $merged_cache_contexts;
$data['#cache']['contexts'] = $redirect_cacheability_updated->getCacheContexts();
}
}
$cache->set($cid, $data, $expire, Cache::mergeTags($data['#cache']['tags'], ['rendered']));
$cache->set($cid, $data, $this->maxAgeToExpire($elements['#cache']['max-age']), Cache::mergeTags($data['#cache']['tags'], ['rendered']));
}
/**
* Maps a #cache[max-age] value to an "expire" value for the Cache API.
*
* @param int $max_age
* A #cache[max-age] value.
*
* @return int
* A corresponding "expire" value.
*
* @see \Drupal\Core\Cache\CacheBackendInterface::set()
*/
protected function maxAgeToExpire($max_age) {
return ($max_age === Cache::PERMANENT) ? Cache::PERMANENT : (int) $this->requestStack->getMasterRequest()->server->get('REQUEST_TIME') + $max_age;
}
/**
@ -270,13 +290,13 @@ class RenderCache implements RenderCacheInterface {
*
* Creates the cache ID string based on #cache['keys'] + #cache['contexts'].
*
* @param array $elements
* @param array &$elements
* A renderable array.
*
* @return string
* The cache ID string, or FALSE if the element may not be cached.
*/
protected function createCacheID(array $elements) {
protected function createCacheID(array &$elements) {
// If the maximum age is zero, then caching is effectively prohibited.
if (isset($elements['#cache']['max-age']) && $elements['#cache']['max-age'] === 0) {
return FALSE;
@ -285,8 +305,11 @@ class RenderCache implements RenderCacheInterface {
if (isset($elements['#cache']['keys'])) {
$cid_parts = $elements['#cache']['keys'];
if (!empty($elements['#cache']['contexts'])) {
$contexts = $this->cacheContextsManager->convertTokensToKeys($elements['#cache']['contexts']);
$cid_parts = array_merge($cid_parts, $contexts);
$context_cache_keys = $this->cacheContextsManager->convertTokensToKeys($elements['#cache']['contexts']);
$cid_parts = array_merge($cid_parts, $context_cache_keys->getKeys());
CacheableMetadata::createFromRenderArray($elements)
->merge($context_cache_keys)
->applyTo($elements);
}
return implode(':', $cid_parts);
}
@ -313,6 +336,13 @@ class RenderCache implements RenderCacheInterface {
// the cache entry size.
if (!empty($elements['#cache_properties']) && is_array($elements['#cache_properties'])) {
$data['#cache_properties'] = $elements['#cache_properties'];
// Ensure that any safe strings are a SafeString object.
foreach (Element::properties(array_flip($elements['#cache_properties'])) as $cache_property) {
if (isset($elements[$cache_property]) && is_scalar($elements[$cache_property]) && SafeMarkup::isSafe($elements[$cache_property])) {
$elements[$cache_property] = SafeString::create($elements[$cache_property]);
}
}
// Extract all the cacheable items from the element using cache
// properties.
$cacheable_items = array_intersect_key($elements, array_flip($elements['#cache_properties']));
@ -321,12 +351,14 @@ class RenderCache implements RenderCacheInterface {
$data['#markup'] = '';
// Cache only cacheable children's markup.
foreach ($cacheable_children as $key) {
$cacheable_items[$key] = ['#markup' => $cacheable_items[$key]['#markup']];
// We can assume that #markup is safe at this point.
$cacheable_items[$key] = ['#markup' => SafeString::create($cacheable_items[$key]['#markup'])];
}
}
$data += $cacheable_items;
}
$data['#markup'] = SafeString::create($data['#markup']);
return $data;
}

View file

@ -0,0 +1,62 @@
<?php
/**
* @file
* Contains \Drupal\Core\Render\RenderContext.
*/
namespace Drupal\Core\Render;
/**
* The render context: a stack containing bubbleable rendering metadata.
*
* A stack of \Drupal\Core\Render\BubbleableMetadata objects.
*
* @see \Drupal\Core\Render\RendererInterface
* @see \Drupal\Core\Render\Renderer
* @see \Drupal\Core\Render\BubbleableMetadata
*
* @internal
*/
class RenderContext extends \SplStack {
/**
* Updates the current frame of the stack.
*
* @param array &$element
* The element of the render array that has just been rendered. The stack
* frame for this element will be updated with the bubbleable rendering
* metadata of this element.
*/
public function update(&$element) {
// The latest frame represents the bubbleable metadata for the subtree.
$frame = $this->pop();
// Update the frame, but also update the current element, to ensure it
// contains up-to-date information in case it gets render cached.
$updated_frame = BubbleableMetadata::createFromRenderArray($element)->merge($frame);
$updated_frame->applyTo($element);
$this->push($updated_frame);
}
/**
* Bubbles the stack.
*
* Whenever another level in the render array has been rendered, the stack
* must be bubbled, to merge its rendering metadata with that of the parent
* element.
*/
public function bubble() {
// If there's only one frame on the stack, then this is the root call, and
// we can't bubble up further. ::renderRoot() will reset the stack, but we
// must not reset it here to allow users of ::executeInRenderContext() to
// access the stack directly.
if ($this->count() === 1) {
return;
}
// Merge the current and the parent stack frame.
$current = $this->pop();
$parent = $this->pop();
$this->push($current->merge($parent));
}
}

View file

@ -7,14 +7,16 @@
namespace Drupal\Core\Render;
use Drupal\Component\Utility\NestedArray;
use Drupal\Component\Utility\SafeMarkup;
use Drupal\Component\Utility\UrlHelper;
use Drupal\Component\Utility\Xss;
use Drupal\Core\Access\AccessResultInterface;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Controller\ControllerResolverInterface;
use Drupal\Core\Template\Attribute;
use Drupal\Core\Theme\ThemeManagerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* Turns a render array into a HTML string.
@ -57,11 +59,35 @@ class Renderer implements RendererInterface {
protected $rendererConfig;
/**
* The stack containing bubbleable rendering metadata.
* Whether we're currently in a ::renderRoot() call.
*
* @var \SplStack|null
* @var bool
*/
protected static $stack;
protected $isRenderingRoot = FALSE;
/**
* The request stack.
*
* @var \Symfony\Component\HttpFoundation\RequestStack
*/
protected $requestStack;
/**
* The render context collection.
*
* An individual global render context is tied to the current request. We then
* need to maintain a different context for each request to correctly handle
* rendering in subrequests.
*
* This must be static as long as some controllers rebuild the container
* during a request. This causes multiple renderer instances to co-exist
* simultaneously, render state getting lost, and therefore causing pages to
* fail to render correctly. As soon as it is guaranteed that during a request
* the same container is used, it no longer needs to be static.
*
* @var \Drupal\Core\Render\RenderContext[]
*/
protected static $contextCollection;
/**
* Constructs a new Renderer.
@ -74,33 +100,52 @@ class Renderer implements RendererInterface {
* The element info.
* @param \Drupal\Core\Render\RenderCacheInterface $render_cache
* The render cache service.
* @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
* The request stack.
* @param array $renderer_config
* The renderer configuration array.
*/
public function __construct(ControllerResolverInterface $controller_resolver, ThemeManagerInterface $theme, ElementInfoManagerInterface $element_info, RenderCacheInterface $render_cache, array $renderer_config) {
public function __construct(ControllerResolverInterface $controller_resolver, ThemeManagerInterface $theme, ElementInfoManagerInterface $element_info, RenderCacheInterface $render_cache, RequestStack $request_stack, array $renderer_config) {
$this->controllerResolver = $controller_resolver;
$this->theme = $theme;
$this->elementInfo = $element_info;
$this->renderCache = $render_cache;
$this->rendererConfig = $renderer_config;
$this->requestStack = $request_stack;
// Initialize the context collection if needed.
if (!isset(static::$contextCollection)) {
static::$contextCollection = new \SplObjectStorage();
}
}
/**
* {@inheritdoc}
*/
public function renderRoot(&$elements) {
return $this->render($elements, TRUE);
// Disallow calling ::renderRoot() from within another ::renderRoot() call.
if ($this->isRenderingRoot) {
$this->isRenderingRoot = FALSE;
throw new \LogicException('A stray renderRoot() invocation is causing bubbling of attached assets to break.');
}
// Render in its own render context.
$this->isRenderingRoot = TRUE;
$output = $this->executeInRenderContext(new RenderContext(), function () use (&$elements) {
return $this->render($elements, TRUE);
});
$this->isRenderingRoot = FALSE;
return $output;
}
/**
* {@inheritdoc}
*/
public function renderPlain(&$elements) {
$current_stack = static::$stack;
$this->resetStack();
$output = $this->renderRoot($elements);
static::$stack = $current_stack;
return $output;
return $this->executeInRenderContext(new RenderContext(), function () use (&$elements) {
return $this->render($elements, TRUE);
});
}
/**
@ -150,16 +195,17 @@ class Renderer implements RendererInterface {
// possible that any of them throw an exception that will cause a different
// page to be rendered (e.g. throwing
// \Symfony\Component\HttpKernel\Exception\NotFoundHttpException will cause
// the 404 page to be rendered). That page might also use Renderer::render()
// but if exceptions aren't caught here, the stack will be left in an
// inconsistent state.
// Hence, catch all exceptions and reset the stack and re-throw them.
// the 404 page to be rendered). That page might also use
// Renderer::renderRoot() but if exceptions aren't caught here, it will be
// impossible to call Renderer::renderRoot() again.
// Hence, catch all exceptions, reset the isRenderingRoot property and
// re-throw exceptions.
try {
return $this->doRender($elements, $is_root_call);
}
catch (\Exception $e) {
// Reset stack and re-throw exception.
$this->resetStack();
// Mark the ::rootRender() call finished due to this exception & re-throw.
$this->isRenderingRoot = FALSE;
throw $e;
}
}
@ -168,6 +214,10 @@ class Renderer implements RendererInterface {
* See the docs for ::render().
*/
protected function doRender(&$elements, $is_root_call = FALSE) {
if (empty($elements)) {
return '';
}
if (!isset($elements['#access']) && isset($elements['#access_callback'])) {
if (is_string($elements['#access_callback']) && strpos($elements['#access_callback'], '::') === FALSE) {
$elements['#access_callback'] = $this->controllerResolver->getControllerFromDefinition($elements['#access_callback']);
@ -176,8 +226,18 @@ class Renderer implements RendererInterface {
}
// Early-return nothing if user does not have access.
if (empty($elements) || (isset($elements['#access']) && !$elements['#access'])) {
return '';
if (isset($elements['#access'])) {
// If #access is an AccessResultInterface object, we must apply it's
// cacheability metadata to the render array.
if ($elements['#access'] instanceof AccessResultInterface) {
$this->addCacheableDependency($elements, $elements['#access']);
if (!$elements['#access']->isAllowed()) {
return '';
}
}
elseif ($elements['#access'] === FALSE) {
return '';
}
}
// Do not print elements twice.
@ -185,10 +245,11 @@ class Renderer implements RendererInterface {
return '';
}
if (!isset(static::$stack)) {
static::$stack = new \SplStack();
$context = $this->getCurrentRenderContext();
if (!isset($context)) {
throw new \LogicException("Render context is empty, because render() was called outside of a renderRoot() or renderPlain() call. Use renderPlain()/renderRoot() or #lazy_builder/#pre_render instead.");
}
static::$stack->push(new BubbleableMetadata());
$context->push(new BubbleableMetadata());
// Set the bubbleable rendering metadata that has configurable defaults, if:
// - this is the root call, to ensure that the final render array definitely
@ -217,22 +278,16 @@ class Renderer implements RendererInterface {
if ($is_root_call) {
$this->replacePlaceholders($elements);
}
// Mark the element markup as safe. If we have cached children, we need
// to mark them as safe too. The parent markup contains the child
// markup, so if the parent markup is safe, then the markup of the
// individual children must be safe as well.
$elements['#markup'] = SafeMarkup::set($elements['#markup']);
if (!empty($elements['#cache_properties'])) {
foreach (Element::children($cached_element) as $key) {
SafeMarkup::set($cached_element[$key]['#markup']);
}
// Mark the element markup as safe if is it a string.
if (is_string($elements['#markup'])) {
$elements['#markup'] = SafeString::create($elements['#markup']);
}
// The render cache item contains all the bubbleable rendering metadata
// for the subtree.
$this->updateStack($elements);
$context->update($elements);
// Render cache hit, so rendering is finished, all necessary info
// collected!
$this->bubbleStack();
$context->bubble();
return $elements['#markup'];
}
}
@ -330,9 +385,9 @@ class Renderer implements RendererInterface {
if (!empty($elements['#printed'])) {
// The #printed element contains all the bubbleable rendering metadata for
// the subtree.
$this->updateStack($elements);
$context->update($elements);
// #printed, so rendering is finished, all necessary info collected!
$this->bubbleStack();
$context->bubble();
return '';
}
@ -350,10 +405,10 @@ class Renderer implements RendererInterface {
$elements['#children'] = '';
}
if (isset($elements['#markup'])) {
if (!empty($elements['#markup'])) {
// @todo Decide how to support non-HTML in the render API in
// https://www.drupal.org/node/2501313.
$elements['#markup'] = SafeMarkup::checkAdminXss($elements['#markup']);
$elements['#markup'] = $this->xssFilterAdminIfUnsafe($elements['#markup']);
}
// Assume that if #theme is set it represents an implemented hook.
@ -367,7 +422,7 @@ class Renderer implements RendererInterface {
);
foreach ($markup_keys as $key) {
if (!empty($elements[$key]) && is_scalar($elements[$key])) {
$elements[$key] = SafeMarkup::checkAdminXss($elements[$key]);
$elements[$key] = $this->xssFilterAdminIfUnsafe($elements[$key]);
}
}
}
@ -392,7 +447,7 @@ class Renderer implements RendererInterface {
foreach ($children as $key) {
$elements['#children'] .= $this->doRender($elements[$key]);
}
$elements['#children'] = SafeMarkup::set($elements['#children']);
$elements['#children'] = SafeString::create($elements['#children']);
}
// If #theme is not implemented and the element has raw #markup as a
@ -403,7 +458,7 @@ class Renderer implements RendererInterface {
// required. Eventually #theme_wrappers will expect both #markup and
// #children to be a single string as #children.
if (!$theme_is_implemented && isset($elements['#markup'])) {
$elements['#children'] = SafeMarkup::set($elements['#markup'] . $elements['#children']);
$elements['#children'] = SafeString::create($elements['#markup'] . $elements['#children']);
}
// Let the theme functions in #theme_wrappers add markup around the rendered
@ -453,13 +508,13 @@ class Renderer implements RendererInterface {
// with how render cached output gets stored. This ensures that placeholder
// replacement logic gets the same data to work with, no matter if #cache is
// disabled, #cache is enabled, there is a cache hit or miss.
$prefix = isset($elements['#prefix']) ? SafeMarkup::checkAdminXss($elements['#prefix']) : '';
$suffix = isset($elements['#suffix']) ? SafeMarkup::checkAdminXss($elements['#suffix']) : '';
$prefix = isset($elements['#prefix']) ? $this->xssFilterAdminIfUnsafe($elements['#prefix']) : '';
$suffix = isset($elements['#suffix']) ? $this->xssFilterAdminIfUnsafe($elements['#suffix']) : '';
$elements['#markup'] = $prefix . $elements['#children'] . $suffix;
// We've rendered this element (and its subtree!), now update the stack.
$this->updateStack($elements);
// We've rendered this element (and its subtree!), now update the context.
$context->update($elements);
// Cache the processed element if both $pre_bubbling_elements and $elements
// have the metadata necessary to generate a cache ID.
@ -481,66 +536,71 @@ class Renderer implements RendererInterface {
// that is handled earlier in Renderer::render().
if ($is_root_call) {
$this->replacePlaceholders($elements);
if (static::$stack->count() !== 1) {
// @todo remove as part of https://www.drupal.org/node/2511330.
if ($context->count() !== 1) {
throw new \LogicException('A stray drupal_render() invocation with $is_root_call = TRUE is causing bubbling of attached assets to break.');
}
}
// Rendering is finished, all necessary info collected!
$this->bubbleStack();
$context->bubble();
$elements['#printed'] = TRUE;
$elements['#markup'] = SafeMarkup::set($elements['#markup']);
return $elements['#markup'];
return SafeString::create($elements['#markup']);
}
/**
* Resets the renderer service's internal stack (used for bubbling metadata).
*
* Only necessary in very rare/advanced situations, such as when rendering an
* error page if an exception occurred *during* rendering.
* {@inheritdoc}
*/
protected function resetStack() {
static::$stack = NULL;
public function hasRenderContext() {
return (bool) $this->getCurrentRenderContext();
}
/**
* Updates the stack.
*
* @param array &$element
* The element of the render array that has just been rendered. The stack
* frame for this element will be updated with the bubbleable rendering
* metadata of this element.
* {@inheritdoc}
*/
protected function updateStack(&$element) {
// The latest frame represents the bubbleable metadata for the subtree.
$frame = static::$stack->pop();
// Update the frame, but also update the current element, to ensure it
// contains up-to-date information in case it gets render cached.
$updated_frame = BubbleableMetadata::createFromRenderArray($element)->merge($frame);
$updated_frame->applyTo($element);
static::$stack->push($updated_frame);
}
public function executeInRenderContext(RenderContext $context, callable $callable) {
// Store the current render context.
$previous_context = $this->getCurrentRenderContext();
/**
* Bubbles the stack.
*
* Whenever another level in the render array has been rendered, the stack
* must be bubbled, to merge its rendering metadata with that of the parent
* element.
*/
protected function bubbleStack() {
// If there's only one frame on the stack, then this is the root call, and
// we can't bubble up further. Reset the stack for the next root call.
if (static::$stack->count() === 1) {
$this->resetStack();
return;
// Set the provided context and call the callable, it will use that context.
$this->setCurrentRenderContext($context);
$result = $callable();
// @todo Convert to an assertion in https://www.drupal.org/node/2408013
if ($context->count() > 1) {
throw new \LogicException('Bubbling failed.');
}
// Merge the current and the parent stack frame.
$current = static::$stack->pop();
$parent = static::$stack->pop();
static::$stack->push($current->merge($parent));
// Restore the original render context.
$this->setCurrentRenderContext($previous_context);
return $result;
}
/**
* Returns the current render context.
*
* @return \Drupal\Core\Render\RenderContext
* The current render context.
*/
protected function getCurrentRenderContext() {
$request = $this->requestStack->getCurrentRequest();
return isset(static::$contextCollection[$request]) ? static::$contextCollection[$request] : NULL;
}
/**
* Sets the current render context.
*
* @param \Drupal\Core\Render\RenderContext|null $context
* The render context. This can be NULL for instance when restoring the
* original render context, which is in fact NULL.
*
* @return $this
*/
protected function setCurrentRenderContext(RenderContext $context = NULL) {
$request = $this->requestStack->getCurrentRequest();
static::$contextCollection[$request] = $context;
return $this;
}
/**
@ -638,4 +698,24 @@ class Renderer implements RendererInterface {
$meta_a->merge($meta_b)->applyTo($elements);
}
/**
* Applies a very permissive XSS/HTML filter for admin-only use.
*
* Note: This method only filters if $string is not marked safe already. This
* ensures that HTML intended for display is not filtered.
*
* @param string|\Drupal\Core\Render\SafeString $string
* A string.
*
* @return \Drupal\Core\Render\SafeString
* The escaped string wrapped in a SafeString object. If
* SafeMarkup::isSafe($string) returns TRUE, it won't be escaped again.
*/
protected function xssFilterAdminIfUnsafe($string) {
if (!SafeMarkup::isSafe($string)) {
$string = Xss::filterAdmin($string);
}
return SafeString::create($string);
}
}

View file

@ -22,13 +22,18 @@ interface RendererInterface {
* - system internals that are responsible for rendering the final HTML
* - render arrays for non-HTML responses, such as feeds
*
* (Cannot be executed within another render context.)
*
* @param array $elements
* The structured array describing the data to be rendered.
*
* @return string
* @return \Drupal\Component\Utility\SafeStringInterface
* The rendered HTML.
*
* @see ::render()
*
* @throws \LogicException
* When called from inside another renderRoot() call.
*/
public function renderRoot(&$elements);
@ -45,13 +50,15 @@ interface RendererInterface {
* ::renderRoot() call, but that is generally highly problematic (and hence an
* exception is thrown when a ::renderRoot() call happens within another
* ::renderRoot() call). However, in this case, we only care about the output,
* not about the bubbling. Hence this uses a separate render stack, to not
* not about the bubbling. Hence this uses a separate render context, to not
* affect the parent ::renderRoot() call.
*
* (Can be executed within another render context: it runs in isolation.)
*
* @param array $elements
* The structured array describing the data to be rendered.
*
* @return string
* @return \Drupal\Component\Utility\SafeStringInterface
* The rendered HTML.
*
* @see ::renderRoot()
@ -88,8 +95,8 @@ interface RendererInterface {
* or configuration that can affect that rendering changes.
* - Placeholders, with associated self-contained placeholder render arrays,
* for executing code to handle dynamic requirements that cannot be cached.
* A stack of \Drupal\Core\Render\BubbleableMetadata objects can be used to
* perform this bubbling.
* A render context (\Drupal\Core\Render\RenderContext) can be used to perform
* bubbling; it is a stack of \Drupal\Core\Render\BubbleableMetadata objects.
*
* Additionally, whether retrieving from cache or not, it is important to
* know all of the assets (CSS and JavaScript) required by the rendered HTML,
@ -103,9 +110,9 @@ interface RendererInterface {
* - If this element has already been printed (#printed = TRUE) or the user
* does not have access to it (#access = FALSE), then an empty string is
* returned.
* - If no stack data structure has been created yet, it is done now. Next,
* - If no render context is set yet, an exception is thrown. Otherwise,
* an empty \Drupal\Core\Render\BubbleableMetadata is pushed onto the
* stack.
* render context.
* - If this element has #cache defined then the cached markup for this
* element will be returned if it exists in Renderer::render()'s cache. To
* use Renderer::render() caching, set the element's #cache property to an
@ -295,17 +302,16 @@ interface RendererInterface {
* (Internal use only.) Whether this is a recursive call or not. See
* ::renderRoot().
*
* @return string
* @return \Drupal\Component\Utility\SafeStringInterface
* The rendered HTML.
*
* @throws \LogicException
* If a root call to ::render() does not result in an empty stack, this
* indicates an erroneous ::render() root call (a root call within a
* root call, which makes no sense). Therefore, a logic exception is thrown.
* When called outside of a render context. (i.e. outside of a renderRoot(),
* renderPlain() or executeInRenderContext() call.)
* @throws \Exception
* If a #pre_render callback throws an exception, it is caught to reset the
* stack used for bubbling rendering metadata, and then the exception is re-
* thrown.
* If a #pre_render callback throws an exception, it is caught to mark the
* renderer as no longer being in a root render call, if any. Then the
* exception is rethrown.
*
* @see \Drupal\Core\Render\ElementInfoManagerInterface::getInfo()
* @see \Drupal\Core\Theme\ThemeManagerInterface::render()
@ -315,6 +321,49 @@ interface RendererInterface {
*/
public function render(&$elements, $is_root_call = FALSE);
/**
* Checks whether a render context is active.
*
* This is useful only in very specific situations to determine whether the
* system is already capable of collecting bubbleable metadata. Normally it
* should not be necessary to be concerned about this.
*
* @return bool
* TRUE if the renderer has a render context active, FALSE otherwise.
*/
public function hasRenderContext();
/**
* Executes a callable within a render context.
*
* Only for very advanced use cases. Prefer using ::renderRoot() and
* ::renderPlain() instead.
*
* All rendering must happen within a render context. Within a render context,
* all bubbleable metadata is bubbled and hence tracked. Outside of a render
* context, it would be lost. This could lead to missing assets, incorrect
* cache variations (and thus security issues), insufficient cache
* invalidations, and so on.
*
* Any and all rendering must therefore happen within a render context, and it
* is this method that provides that.
*
* @see \Drupal\Core\Render\BubbleableMetadata
*
* @param \Drupal\Core\Render\RenderContext $context
* The render context to execute the callable within.
* @param callable $callable
* The callable to execute.
* @return mixed
* The callable's return value.
*
* @see \Drupal\Core\Render\RenderContext
*
* @throws \LogicException
* In case bubbling has failed, can only happen in case of broken code.
*/
public function executeInRenderContext(RenderContext $context, callable $callable);
/**
* Merges the bubbleable rendering metadata o/t 2nd render array with the 1st.
*

View file

@ -0,0 +1,84 @@
<?php
/**
* @file
* Contains \Drupal\Core\Render\SafeString.
*/
namespace Drupal\Core\Render;
use Drupal\Component\Utility\SafeStringInterface;
use Drupal\Component\Utility\Unicode;
/**
* Defines an object that passes safe strings through the render system.
*
* This object should only be constructed with a known safe string. If there is
* any risk that the string contains user-entered data that has not been
* filtered first, it must not be used.
*
* @internal
* This object is marked as internal because it should only be used during
* rendering. Currently, there is no use case for this object by contrib or
* custom code.
*
* @see \Drupal\Core\Template\TwigExtension::escapeFilter
* @see \Twig_Markup
* @see \Drupal\Component\Utility\SafeMarkup
*/
class SafeString implements SafeStringInterface, \Countable {
/**
* The safe string.
*
* @var string
*/
protected $string;
/**
* Creates a SafeString object if necessary.
*
* If $string is equal to a blank string then it is not necessary to create a
* SafeString object. If $string is an object that implements
* SafeStringInterface it is returned unchanged.
*
* @param mixed $string
* The string to mark as safe. This value will be cast to a string.
*
* @return string|\Drupal\Component\Utility\SafeStringInterface
* A safe string.
*/
public static function create($string) {
if ($string instanceof SafeStringInterface) {
return $string;
}
$string = (string) $string;
if ($string === '') {
return '';
}
$safe_string = new static();
$safe_string->string = $string;
return $safe_string;
}
/**
* Returns the string version of the SafeString object.
*
* @return string
* The safe string content.
*/
public function __toString() {
return $this->string;
}
/**
* Returns the string length.
*
* @return int
* The length of the string.
*/
public function count() {
return Unicode::strlen($this->string);
}
}