Update to Drupal 8.0.3. For more information, see https://www.drupal.org/drupal-8.0.3-release-notes

This commit is contained in:
Pantheon Automation 2016-02-03 14:56:31 -08:00 committed by Greg Anderson
parent 10f9f7fbde
commit 9db4fae9a7
202 changed files with 3806 additions and 760 deletions

View file

@ -135,7 +135,7 @@ class CssCollectionRenderer implements AssetCollectionRendererInterface {
// assets: output a LINK tag for a file CSS asset.
if (count($css_assets) <= 31) {
$element = $link_element_defaults;
$element['#attributes']['href'] = file_create_url($css_asset['data']) . $query_string_separator . $query_string;
$element['#attributes']['href'] = file_url_transform_relative(file_create_url($css_asset['data'])) . $query_string_separator . $query_string;
$element['#attributes']['media'] = $css_asset['media'];
$element['#browsers'] = $css_asset['browsers'];
$elements[] = $element;
@ -148,7 +148,7 @@ class CssCollectionRenderer implements AssetCollectionRendererInterface {
// LINK tag.
if (!$css_asset['preprocess']) {
$element = $link_element_defaults;
$element['#attributes']['href'] = file_create_url($css_asset['data']) . $query_string_separator . $query_string;
$element['#attributes']['href'] = file_url_transform_relative(file_create_url($css_asset['data'])) . $query_string_separator . $query_string;
$element['#attributes']['media'] = $css_asset['media'];
$element['#browsers'] = $css_asset['browsers'];
$elements[] = $element;
@ -168,7 +168,7 @@ class CssCollectionRenderer implements AssetCollectionRendererInterface {
// control browser-caching. IE7 does not support a media type on
// the @import statement, so we instead specify the media for
// the group on the STYLE tag.
$import[] = '@import url("' . Html::escape(file_create_url($next_css_asset['data']) . '?' . $query_string) . '");';
$import[] = '@import url("' . Html::escape(file_url_transform_relative(file_create_url($next_css_asset['data'])) . '?' . $query_string) . '");';
// Move the outer for loop skip the next item, since we
// processed it here.
$i = $j;

View file

@ -265,7 +265,7 @@ class CssOptimizer implements AssetOptimizerInterface {
$last = $path;
$path = preg_replace('`(^|/)(?!\.\./)([^/]+)/\.\./`', '$1', $path);
}
return 'url(' . file_create_url($path) . ')';
return 'url(' . file_url_transform_relative(file_create_url($path)) . ')';
}
}

View file

@ -79,7 +79,7 @@ class JsCollectionRenderer implements AssetCollectionRendererInterface {
case 'file':
$query_string = $js_asset['version'] == -1 ? $default_query_string : 'v=' . $js_asset['version'];
$query_string_separator = (strpos($js_asset['data'], '?') !== FALSE) ? '&' : '?';
$element['#attributes']['src'] = file_create_url($js_asset['data']);
$element['#attributes']['src'] = file_url_transform_relative(file_create_url($js_asset['data']));
// Only add the cache-busting query string if this isn't an aggregate
// file.
if (!isset($js_asset['preprocessed'])) {

View file

@ -18,7 +18,7 @@ abstract class ConfigFactoryOverrideBase implements EventSubscriberInterface {
* Reacts to the ConfigEvents::COLLECTION_INFO event.
*
* @param \Drupal\Core\Config\ConfigCollectionInfo $collection_info
* The configuration collection names event.
* The configuration collection info event.
*/
abstract public function addCollections(ConfigCollectionInfo $collection_info);

View file

@ -39,16 +39,6 @@ abstract class ConfigEntityBase extends Entity implements ConfigEntityInterface
*/
protected $originalId;
/**
* The name of the property that is used to store plugin configuration.
*
* This is needed when the entity uses a LazyPluginCollection, to dictate
* where the plugin configuration should be stored.
*
* @var string
*/
protected $pluginConfigKey;
/**
* The enabled/disabled status of the configuration entity.
*

View file

@ -169,9 +169,6 @@ interface ConfigEntityInterface extends EntityInterface, ThirdPartySettingsInter
* Dependency types are, for example, entity, module and theme.
*
* @return bool
* TRUE if the entity has changed, FALSE if not.
*
* @return bool
* TRUE if the entity has been changed as a result, FALSE if not.
*
* @see \Drupal\Core\Config\Entity\ConfigDependencyManager

View file

@ -95,12 +95,13 @@ class FileStorage implements StorageInterface {
if (!$this->exists($name)) {
return FALSE;
}
$data = file_get_contents($this->getFilePath($name));
$filepath = $this->getFilePath($name);
$data = file_get_contents($filepath);
try {
$data = $this->decode($data);
}
catch (InvalidDataTypeException $e) {
throw new UnsupportedDataTypeConfigException("Invalid data type in config $name: {$e->getMessage()}");
throw new UnsupportedDataTypeConfigException('Invalid data type in config ' . $name . ', found in file' . $filepath . ' : ' . $e->getMessage());
}
return $data;
}

View file

@ -86,6 +86,7 @@ class TypedConfigManager extends TypedDataManager implements TypedConfigManagerI
// Add default values for data type and replace variables.
$definition += array('type' => 'undefined');
$replace = [];
$type = $definition['type'];
if (strpos($type, ']')) {
// Replace variable names in definition.
@ -102,7 +103,7 @@ class TypedConfigManager extends TypedDataManager implements TypedConfigManagerI
unset($definition['type']);
}
// Add default values from type definition.
$definition += $this->getDefinition($type);
$definition += $this->_getDefinitionWithReplacements($type, $replace);
$data_definition = $this->createDataDefinition($definition['type']);
@ -116,10 +117,17 @@ class TypedConfigManager extends TypedDataManager implements TypedConfigManagerI
}
/**
* {@inheritdoc}
* Determines the typed config type for a plugin ID.
*
* @param string $base_plugin_id
* The plugin ID.
* @param array $definitions
* An array of typed config definitions.
*
* @return string
* The typed config type for the given plugin ID.
*/
public function getDefinition($base_plugin_id, $exception_on_invalid = TRUE) {
$definitions = $this->getDefinitions();
protected function _determineType($base_plugin_id, array $definitions) {
if (isset($definitions[$base_plugin_id])) {
$type = $base_plugin_id;
}
@ -131,6 +139,27 @@ class TypedConfigManager extends TypedDataManager implements TypedConfigManagerI
// If we don't have definition, return the 'undefined' element.
$type = 'undefined';
}
return $type;
}
/**
* Gets a schema definition with replacements for dynamic names.
*
* @param string $base_plugin_id
* A plugin ID.
* @param array $replacements
* An array of replacements for dynamic type names.
* @param bool $exception_on_invalid
* (optional) This parameter is passed along to self::getDefinition().
* However, self::getDefinition() does not respect this parameter, so it is
* effectively useless in this context.
*
* @return array
* A schema definition array.
*/
protected function _getDefinitionWithReplacements($base_plugin_id, array $replacements, $exception_on_invalid = TRUE) {
$definitions = $this->getDefinitions();
$type = $this->_determineType($base_plugin_id, $definitions);
$definition = $definitions[$type];
// Check whether this type is an extension of another one and compile it.
if (isset($definition['type'])) {
@ -138,6 +167,15 @@ class TypedConfigManager extends TypedDataManager implements TypedConfigManagerI
// Preserve integer keys on merge, so sequence item types can override
// parent settings as opposed to adding unused second, third, etc. items.
$definition = NestedArray::mergeDeepArray(array($merge, $definition), TRUE);
// Replace dynamic portions of the definition type.
if (!empty($replacements) && strpos($definition['type'], ']')) {
$sub_type = $this->_determineType($this->replaceName($definition['type'], $replacements), $definitions);
// Merge the newly determined subtype definition with the original
// definition.
$definition = NestedArray::mergeDeepArray([$definitions[$sub_type], $definition], TRUE);
}
// Unset type so we try the merge only once per type.
unset($definition['type']);
$this->definitions[$type] = $definition;
@ -150,6 +188,13 @@ class TypedConfigManager extends TypedDataManager implements TypedConfigManagerI
return $definition;
}
/**
* {@inheritdoc}
*/
public function getDefinition($base_plugin_id, $exception_on_invalid = TRUE) {
return $this->_getDefinitionWithReplacements($base_plugin_id, [], $exception_on_invalid);
}
/**
* {@inheritdoc}
*/

View file

@ -64,10 +64,9 @@ class PagerSelectExtender extends SelectExtender {
* to it.
*/
public function execute() {
// Add convenience tag to mark that this is an extended query. We have to
// do this in the constructor to ensure that it is set before preExecute()
// gets called.
// By calling preExecute() here, we force it to preprocess the extender
// object rather than just the base query object. That means
// hook_query_alter() gets access to the extended object.
if (!$this->preExecute($this)) {
return NULL;
}

View file

@ -968,11 +968,13 @@ class DrupalKernel implements DrupalKernelInterface, TerminableInterface {
}
}
// If the class loader is still the same, possibly upgrade to the APCu class
// If the class loader is still the same, possibly upgrade to the APC class
// loader.
// ApcClassLoader does not support APCu without backwards compatibility
// enabled.
if ($class_loader_class == get_class($this->classLoader)
&& Settings::get('class_loader_auto_detect', TRUE)
&& function_exists('apcu_fetch')) {
&& extension_loaded('apc')) {
$prefix = Settings::getApcuPrefix('class_loader', $this->root);
$apc_loader = new \Symfony\Component\ClassLoader\ApcClassLoader($prefix, $this->classLoader);
$this->classLoader->unregister();

View file

@ -440,7 +440,8 @@ abstract class ContentEntityStorageBase extends EntityStorageBase implements Con
protected function invokeFieldMethod($method, ContentEntityInterface $entity) {
$result = [];
$args = array_slice(func_get_args(), 2);
foreach (array_keys($entity->getTranslationLanguages()) as $langcode) {
$langcodes = array_keys($entity->getTranslationLanguages());
foreach ($langcodes as $langcode) {
$translation = $entity->getTranslation($langcode);
// For non translatable fields, there is only one field object instance
// across all translations and it has as parent entity the entity in the
@ -453,6 +454,20 @@ abstract class ContentEntityStorageBase extends EntityStorageBase implements Con
$result[$langcode][$name] = $args ? call_user_func_array([$items, $method], $args) : $items->{$method}();
}
}
// We need to call the delete method for field items of removed
// translations.
if ($method == 'postSave' && !empty($entity->original)) {
$original_langcodes = array_keys($entity->original->getTranslationLanguages());
foreach (array_diff($original_langcodes, $langcodes) as $removed_langcode) {
$translation = $entity->original->getTranslation($removed_langcode);
$fields = $translation->getTranslatableFields();
foreach ($fields as $name => $items) {
$items->delete();
}
}
}
return $result;
}

View file

@ -330,11 +330,11 @@ class EntityAutocomplete extends Textfield {
// Take "label (entity id)', match the ID from parenthesis when it's a
// number.
if (preg_match("/.+\((\d+)\)/", $input, $matches)) {
if (preg_match("/.+\s\((\d+)\)/", $input, $matches)) {
$match = $matches[1];
}
// Match the ID when it's a string (e.g. for config entity types).
elseif (preg_match("/.+\(([\w.]+)\)/", $input, $matches)) {
elseif (preg_match("/.+\s\(([\w.]+)\)/", $input, $matches)) {
$match = $matches[1];
}

View file

@ -83,8 +83,11 @@ class AuthenticationSubscriber implements EventSubscriberInterface {
$account = $this->authenticationProvider->authenticate($request);
if ($account) {
$this->accountProxy->setAccount($account);
return;
}
}
// No account has been set explicitly, initialize the timezone here.
date_default_timezone_set(drupal_get_user_timezone());
}
}

View file

@ -237,7 +237,9 @@ interface ModuleHandlerInterface {
*
* @return array
* An array of return values of the hook implementations. If modules return
* arrays from their implementations, those are merged into one array.
* arrays from their implementations, those are merged into one array
* recursively. Note: integer keys in arrays will be lost, as the merge is
* done using array_merge_recursive().
*/
public function invokeAll($hook, array $args = array());

View file

@ -70,7 +70,7 @@ abstract class FieldItemBase extends Map implements FieldItemInterface {
* {@inheritdoc}
*/
public function getLangcode() {
return $this->parent->getLangcode();
return $this->getParent()->getLangcode();
}
/**

View file

@ -102,28 +102,28 @@ interface FieldItemListInterface extends ListInterface, AccessibleInterface {
/**
* Magic method: Gets a property value of to the first field item.
*
* @see \Drupal\Core\Field\FieldItemInterface::__get()
* @see \Drupal\Core\Field\FieldItemInterface::__set()
*/
public function __get($property_name);
/**
* Magic method: Sets a property value of the first field item.
*
* @see \Drupal\Core\Field\FieldItemInterface::__set()
* @see \Drupal\Core\Field\FieldItemInterface::__get()
*/
public function __set($property_name, $value);
/**
* Magic method: Determines whether a property of the first field item is set.
*
* @see \Drupal\Core\Field\FieldItemInterface::__isset()
* @see \Drupal\Core\Field\FieldItemInterface::__unset()
*/
public function __isset($property_name);
/**
* Magic method: Unsets a property of the first field item.
*
* @see \Drupal\Core\Field\FieldItemInterface::__unset()
* @see \Drupal\Core\Field\FieldItemInterface::__isset()
*/
public function __unset($property_name);

View file

@ -83,6 +83,7 @@ class BooleanFormatter extends FormatterBase {
}
}
$field_name = $this->fieldDefinition->getName();
$form['format'] = [
'#type' => 'select',
'#title' => $this->t('Output format'),
@ -95,7 +96,7 @@ class BooleanFormatter extends FormatterBase {
'#default_value' => $this->getSetting('format_custom_true'),
'#states' => [
'visible' => [
'select[name="fields[field_boolean][settings_edit_form][settings][format]"]' => ['value' => 'custom'],
'select[name="fields[' . $field_name . '][settings_edit_form][settings][format]"]' => ['value' => 'custom'],
],
],
];
@ -105,7 +106,7 @@ class BooleanFormatter extends FormatterBase {
'#default_value' => $this->getSetting('format_custom_false'),
'#states' => [
'visible' => [
'select[name="fields[field_boolean][settings_edit_form][settings][format]"]' => ['value' => 'custom'],
'select[name="fields[' . $field_name . '][settings_edit_form][settings][format]"]' => ['value' => 'custom'],
],
],
];

View file

@ -55,7 +55,7 @@ class DecimalFormatter extends NumericFormatterBase {
$range = range(0, 10);
$elements['scale'] = array(
'#type' => 'select',
'#title' => t('Scale', array(), array('decimal places')),
'#title' => t('Scale', array(), array('context' => 'decimal places')),
'#options' => array_combine($range, $range),
'#default_value' => $this->getSetting('scale'),
'#description' => t('The number of digits to the right of the decimal.'),

View file

@ -131,7 +131,7 @@ class TimestampFormatter extends FormatterBase implements ContainerFactoryPlugin
);
$elements['custom_date_format']['#states']['visible'][] = array(
':input[name="options[settings][date_format]"]' => array('value' => 'custom'),
':input[name="name="fields[' . $this->fieldDefinition->getName() . '][settings_edit_form][settings][date_format]"]' => array('value' => 'custom'),
);
$elements['timezone'] = array(

View file

@ -81,7 +81,7 @@ class DecimalItem extends NumericItemBase {
$range = range(0, 10);
$element['scale'] = array(
'#type' => 'select',
'#title' => t('Scale', array(), array('decimal places')),
'#title' => t('Scale', array(), array('context' => 'decimal places')),
'#options' => array_combine($range, $range),
'#default_value' => $settings['scale'],
'#description' => t('The number of digits to the right of the decimal.'),

View file

@ -62,6 +62,9 @@ class PasswordItem extends StringItem {
$this->value = $entity->original->{$this->getFieldDefinition()->getName()}->value;
}
}
// Ensure that the existing password is unset to minimise risks of it
// getting serialized and stored somewhere.
$this->existing = NULL;
}
/**

View file

@ -75,7 +75,7 @@ interface WidgetBaseInterface extends PluginSettingsInterface {
/**
* Retrieves processing information about the widget from $form_state.
*
* This method is static so that is can be used in static Form API callbacks.
* This method is static so that it can be used in static Form API callbacks.
*
* @param array $parents
* The array of #parents where the field lives in the form.
@ -95,7 +95,7 @@ interface WidgetBaseInterface extends PluginSettingsInterface {
/**
* Stores processing information about the widget in $form_state.
*
* This method is static so that is can be used in static Form API #callbacks.
* This method is static so that it can be used in static Form API #callbacks.
*
* @param array $parents
* The array of #parents where the widget lives in the form.

View file

@ -146,8 +146,6 @@ function callback_batch_finished($success, $results, $operations) {
*
* @param \Drupal\Core\Ajax\CommandInterface[] $data
* An array of all the rendered commands that will be sent to the client.
*
* @see \Drupal\Core\Ajax\AjaxResponse::ajaxRender()
*/
function hook_ajax_render_alter(array &$data) {
// Inject any new status messages into the content area.

View file

@ -253,6 +253,7 @@ class LanguageManager implements LanguageManagerInterface {
'dz' => array('Dzongkha', 'རྫོང་ཁ'),
'el' => array('Greek', 'Ελληνικά'),
'en' => array('English', 'English'),
'en-x-simple' => array('Simple English', 'Simple English'),
'eo' => array('Esperanto', 'Esperanto'),
'es' => array('Spanish', 'Español'),
'et' => array('Estonian', 'Eesti'),

View file

@ -93,9 +93,17 @@ class LoggerChannel implements LoggerChannelInterface {
$context['request_uri'] = $request->getUri();
$context['referer'] = $request->headers->get('Referer', '');
$context['ip'] = $request->getClientIP();
if ($this->currentUser) {
$context['user'] = $this->currentUser;
$context['uid'] = $this->currentUser->id();
try {
if ($this->currentUser) {
$context['user'] = $this->currentUser;
$context['uid'] = $this->currentUser->id();
}
}
catch (\Exception $e) {
// An exception might be thrown if the database connection is not
// available or due to another unexpected reason. It is more important
// to log the error that we already have so any additional exceptions
// are ignored.
}
}

View file

@ -23,10 +23,7 @@ interface LoggerChannelFactoryInterface {
public function get($channel);
/**
* Adds a logger.
*
* Here is were all services tagged as 'logger' are being retrieved and then
* passed to the channels after instantiation.
* Adds a logger to all the channels.
*
* @param \Psr\Log\LoggerInterface $logger
* The PSR-3 logger to add.

View file

@ -8,6 +8,9 @@
namespace Drupal\Core\Menu;
use Drupal\Core\Access\AccessManagerInterface;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheableDependencyInterface;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Language\LanguageManagerInterface;
@ -182,8 +185,10 @@ class LocalActionManager extends DefaultPluginManager implements LocalActionMana
$links = array();
/** @var $plugin \Drupal\Core\Menu\LocalActionInterface */
foreach ($this->instances[$route_appears] as $plugin_id => $plugin) {
$cacheability = new CacheableMetadata();
$route_name = $plugin->getRouteName();
$route_parameters = $plugin->getRouteParameters($this->routeMatch);
$access = $this->accessManager->checkNamedRoute($route_name, $route_parameters, $this->account, TRUE);
$links[$plugin_id] = array(
'#theme' => 'menu_local_action',
'#link' => array(
@ -191,10 +196,22 @@ class LocalActionManager extends DefaultPluginManager implements LocalActionMana
'url' => Url::fromRoute($route_name, $route_parameters),
'localized_options' => $plugin->getOptions($this->routeMatch),
),
'#access' => $this->accessManager->checkNamedRoute($route_name, $route_parameters, $this->account, TRUE),
'#access' => $access,
'#weight' => $plugin->getWeight(),
);
$cacheability->addCacheableDependency($access);
// For backward compatibility in 8.0.x, plugins that do not implement
// the \Drupal\Core\Cache\CacheableDependencyInterface are assumed
// to be cacheable forever.
if ($plugin instanceof CacheableDependencyInterface) {
$cacheability->addCacheableDependency($plugin);
}
else {
$cacheability->setCacheMaxAge(Cache::PERMANENT);
}
$cacheability->applyTo($links[$plugin_id]);
}
$links['#cache']['contexts'][] = 'route';
return $links;
}

View file

@ -8,7 +8,6 @@
namespace Drupal\Core\Menu\Plugin\Block;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Menu\LocalActionManagerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
@ -84,18 +83,8 @@ class LocalActionsBlock extends BlockBase implements ContainerFactoryPluginInter
public function build() {
$route_name = $this->routeMatch->getRouteName();
$local_actions = $this->localActionManager->getActionsForRoute($route_name);
if (empty($local_actions)) {
return [];
}
return $local_actions;
}
/**
* {@inheritdoc}
*/
public function getCacheContexts() {
return Cache::mergeContexts(parent::getCacheContexts(), ['route']);
}
}

View file

@ -75,7 +75,7 @@ class ImageButton extends Submit {
$element['#attributes']['type'] = 'image';
Element::setAttributes($element, array('id', 'name', 'value'));
$element['#attributes']['src'] = file_create_url($element['#src']);
$element['#attributes']['src'] = file_url_transform_relative(file_create_url($element['#src']));
if (!empty($element['#title'])) {
$element['#attributes']['alt'] = $element['#title'];
$element['#attributes']['title'] = $element['#title'];

View file

@ -22,8 +22,8 @@ use Drupal\Component\Utility\Html as HtmlUtility;
* $form['settings']['active'] = array(
* '#type' => 'radios',
* '#title' => t('Poll status'),
* '#default_value' => 1
* '#options' => array(0 => t('Closed'), 1 => t('Active'),
* '#default_value' => 1,
* '#options' => array(0 => t('Closed'), 1 => t('Active')),
* );
* @endcode
*

View file

@ -34,7 +34,7 @@ use Drupal\Component\Utility\Html as HtmlUtility;
* @code
* $form['contacts'] = array(
* '#type' => 'table',
* '#title' => 'Sample Table',
* '#caption' => 'Sample Table',
* '#header' => array('Name', 'Phone'),
* );
*

View file

@ -16,7 +16,7 @@ use Drupal\Core\StringTranslation\TranslatableMarkup;
* Provides a form element for a table with radios or checkboxes in left column.
*
* Properties:
* - #headers: Table headers used in the table.
* - #header: Table headers used in the table.
* - #options: An associative array where each key is the value returned when
* a user selects the radio button or checkbox, and each value is the row of
* table data.

View file

@ -56,7 +56,7 @@ class AccountProxy implements AccountProxyInterface {
// After the container is rebuilt, DrupalKernel sets the initial
// account to the id of the logged in user. This is necessary in order
// to refresh the user account reference here.
$this->account = $this->loadUserEntity($this->initialAccountId);
$this->setAccount($this->loadUserEntity($this->initialAccountId));
}
else {
$this->account = new AnonymousUserSession();

View file

@ -135,7 +135,9 @@ class TwigExtension extends \Twig_Extension {
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'))),
new \Twig_SimpleFunction('link', array($this, 'getLink')),
new \Twig_SimpleFunction('file_url', 'file_create_url'),
new \Twig_SimpleFunction('file_url', function ($uri) {
return file_url_transform_relative(file_create_url($uri));
}),
new \Twig_SimpleFunction('attach_library', [$this, 'attachLibrary']),
new \Twig_SimpleFunction('active_theme_path', [$this, 'getActiveThemePath']),
new \Twig_SimpleFunction('active_theme', [$this, 'getActiveTheme']),

View file

@ -318,7 +318,6 @@ class ThemeInitialization implements ThemeInitializationInterface {
$stylesheets_remove = array();
// Grab stylesheets from base theme.
foreach ($base_themes as $base) {
$base_theme_path = $base->getPath();
if (!empty($base->info['stylesheets-remove'])) {
foreach ($base->info['stylesheets-remove'] as $css_file) {
$css_file = $this->resolveStyleSheetPlaceholders($css_file);

View file

@ -293,6 +293,11 @@ class Url {
* @see \Drupal\Core\Url::fromUserInput()
*/
public static function fromUri($uri, $options = []) {
// parse_url() incorrectly parses base:number/... as hostname:port/...
// and not the scheme. Prevent that by prefixing the path with a slash.
if (preg_match('/^base:\d/', $uri)) {
$uri = str_replace('base:', 'base:/', $uri);
}
$uri_parts = parse_url($uri);
if ($uri_parts === FALSE) {
throw new \InvalidArgumentException("The URI '$uri' is malformed.");