diff --git a/core/lib/Drupal/Core/PathProcessor/OutboundPathProcessorInterface.php b/core/lib/Drupal/Core/PathProcessor/OutboundPathProcessorInterface.php index 59f2d0c..cad93c2 100644 --- a/core/lib/Drupal/Core/PathProcessor/OutboundPathProcessorInterface.php +++ b/core/lib/Drupal/Core/PathProcessor/OutboundPathProcessorInterface.php @@ -21,8 +21,27 @@ * @param string $path * The path to process, with a leading slash. * @param array $options - * An array of options such as would be passed to the generator's - * generateFromRoute() method. + * (optional) An associative array of additional options, with the following + * elements: + * - 'query': An array of query key/value-pairs (without any URL-encoding) + * to append to the URL. + * - 'fragment': A fragment identifier (named anchor) to append to the URL. + * Do not include the leading '#' character. + * - 'absolute': Defaults to FALSE. Whether to force the output to be an + * absolute link (beginning with http:). Useful for links that will be + * displayed outside the site, such as in an RSS feed. + * - 'language': An optional language object used to look up the alias + * for the URL. If $options['language'] is omitted, it defaults to the + * current language for the language type LanguageInterface::TYPE_URL. + * - 'https': Whether this URL should point to a secure location. If not + * defined, the current scheme is used, so the user stays on HTTP or HTTPS + * respectively. TRUE enforces HTTPS and FALSE enforces HTTP. + * - 'base_url': Only used internally by a path processor, for example, to + * modify the base URL when a language dependent URL requires so. + * - 'prefix': Only used internally, to modify the path when a language + * dependent URL requires so. + * - 'route': The route object for the given path. It will be set by + * \Drupal\Core\Routing\UrlGenerator::generateFromRoute(). * @param \Symfony\Component\HttpFoundation\Request $request * The HttpRequest object representing the current request. * @param \Drupal\Core\Render\BubbleableMetadata $bubbleable_metadata diff --git a/core/lib/Drupal/Core/Routing/UrlGenerator.php b/core/lib/Drupal/Core/Routing/UrlGenerator.php index f3d1275..62ece64 100644 --- a/core/lib/Drupal/Core/Routing/UrlGenerator.php +++ b/core/lib/Drupal/Core/Routing/UrlGenerator.php @@ -309,6 +309,9 @@ public function generateFromRoute($name, $parameters = array(), $options = array $name = $this->getRouteDebugMessage($name); $this->processRoute($name, $route, $parameters, $generated_url); $path = $this->getInternalPathFromRoute($name, $route, $parameters, $query_params); + // Outbound path processors might need the route object for the path, e.g. + // to get the path pattern. + $options['route'] = $route; $path = $this->processPath($path, $options, $generated_url); if (!empty($options['prefix'])) { diff --git a/core/modules/content_translation/content_translation.module b/core/modules/content_translation/content_translation.module index 0370367..9dae374 100644 --- a/core/modules/content_translation/content_translation.module +++ b/core/modules/content_translation/content_translation.module @@ -53,7 +53,7 @@ function content_translation_help($route_name, RouteMatchInterface $route_match) */ function content_translation_module_implements_alter(&$implementations, $hook) { switch ($hook) { - // Move some of our hook implementations to the end of the list. + // Move our hook_entity_type_alter() implementation to the end of the list. case 'entity_type_alter': $group = $implementations['content_translation']; unset($implementations['content_translation']); @@ -140,7 +140,11 @@ function content_translation_entity_type_alter(array &$entity_types) { if ($entity_type->hasLinkTemplate('canonical')) { // Provide default route names for the translation paths. if (!$entity_type->hasLinkTemplate('drupal:content-translation-overview')) { - $entity_type->setLinkTemplate('drupal:content-translation-overview', $entity_type->getLinkTemplate('canonical') . '/translations'); + $translations_path = $entity_type->getLinkTemplate('canonical') . '/translations'; + $entity_type->setLinkTemplate('drupal:content-translation-overview', $translations_path); + $entity_type->setLinkTemplate('drupal:content-translation-add', $translations_path . '/add/{source}/{target}'); + $entity_type->setLinkTemplate('drupal:content-translation-edit', $translations_path . '/edit/{language}'); + $entity_type->setLinkTemplate('drupal:content-translation-delete', $translations_path . '/delete/{language}'); } // @todo Remove this as soon as menu access checks rely on the // controller. See https://www.drupal.org/node/2155787. diff --git a/core/modules/content_translation/src/ContentTranslationHandler.php b/core/modules/content_translation/src/ContentTranslationHandler.php index d7eb42a..1b8d77d 100644 --- a/core/modules/content_translation/src/ContentTranslationHandler.php +++ b/core/modules/content_translation/src/ContentTranslationHandler.php @@ -652,7 +652,7 @@ public function entityFormSourceChange($form, FormStateInterface $form_state) { $source = $form_state->getValue(array('source_langcode', 'source')); $entity_type_id = $entity->getEntityTypeId(); - $form_state->setRedirect('content_translation.translation_add_' . $entity_type_id, array( + $form_state->setRedirect("entity.$entity_type_id.content_translation_add", array( $entity_type_id => $entity->id(), 'source' => $source, 'target' => $form_object->getFormLangcode($form_state), @@ -689,7 +689,7 @@ function entityFormDeleteTranslation($form, FormStateInterface $form_state) { $form_state->setRedirectUrl($entity->urlInfo('delete-form')); } else { - $form_state->setRedirect('content_translation.translation_delete_' . $entity_type_id, [ + $form_state->setRedirect("entity.$entity_type_id.content_translation_delete", [ $entity_type_id => $entity->id(), 'language' => $form_object->getFormLangcode($form_state), ]); diff --git a/core/modules/content_translation/src/Controller/ContentTranslationController.php b/core/modules/content_translation/src/Controller/ContentTranslationController.php index 9c6879a..8c19de1 100644 --- a/core/modules/content_translation/src/Controller/ContentTranslationController.php +++ b/core/modules/content_translation/src/Controller/ContentTranslationController.php @@ -127,7 +127,7 @@ public function overview(RouteMatchInterface $route_match, $entity_type_id = NUL $langcode = $language->getId(); $add_url = new Url( - 'content_translation.translation_add_' . $entity_type_id, + "entity.$entity_type_id.content_translation_add", array( 'source' => $original, 'target' => $language->getId(), @@ -138,7 +138,7 @@ public function overview(RouteMatchInterface $route_match, $entity_type_id = NUL ) ); $edit_url = new Url( - 'content_translation.translation_edit_' . $entity_type_id, + "entity.$entity_type_id.content_translation_edit", array( 'language' => $language->getId(), $entity_type_id => $entity->id(), @@ -148,7 +148,7 @@ public function overview(RouteMatchInterface $route_match, $entity_type_id = NUL ) ); $delete_url = new Url( - 'content_translation.translation_delete_' . $entity_type_id, + "entity.$entity_type_id.content_translation_delete", array( 'language' => $language->getId(), $entity_type_id => $entity->id(), diff --git a/core/modules/content_translation/src/Routing/ContentTranslationRouteSubscriber.php b/core/modules/content_translation/src/Routing/ContentTranslationRouteSubscriber.php index a2b54a8..ebdf562 100644 --- a/core/modules/content_translation/src/Routing/ContentTranslationRouteSubscriber.php +++ b/core/modules/content_translation/src/Routing/ContentTranslationRouteSubscriber.php @@ -112,7 +112,7 @@ protected function alterRoutes(RouteCollection $collection) { '_admin_route' => $is_admin, ) ); - $collection->add("content_translation.translation_add_$entity_type_id", $route); + $collection->add("entity.$entity_type_id.content_translation_add", $route); $route = new Route( $path . '/edit/{language}', @@ -137,7 +137,7 @@ protected function alterRoutes(RouteCollection $collection) { '_admin_route' => $is_admin, ) ); - $collection->add("content_translation.translation_edit_$entity_type_id", $route); + $collection->add("entity.$entity_type_id.content_translation_edit", $route); $route = new Route( $path . '/delete/{language}', @@ -162,7 +162,7 @@ protected function alterRoutes(RouteCollection $collection) { '_admin_route' => $is_admin, ) ); - $collection->add("content_translation.translation_delete_$entity_type_id", $route); + $collection->add("entity.$entity_type_id.content_translation_delete", $route); } } diff --git a/core/modules/content_translation/src/Tests/ContentTranslationUITestBase.php b/core/modules/content_translation/src/Tests/ContentTranslationUITestBase.php index 9ed2e46..c3ab826 100644 --- a/core/modules/content_translation/src/Tests/ContentTranslationUITestBase.php +++ b/core/modules/content_translation/src/Tests/ContentTranslationUITestBase.php @@ -107,7 +107,8 @@ protected function doTestBasicTranslation() { $language = ConfigurableLanguage::load($langcode); $values[$langcode] = $this->getNewEntityValues($langcode); - $add_url = Url::fromRoute('content_translation.translation_add_' . $entity->getEntityTypeId(), [ + $entity_type_id = $entity->getEntityTypeId(); + $add_url = Url::fromRoute("entity.$entity_type_id.content_translation_add", [ $entity->getEntityTypeId() => $entity->id(), 'source' => $default_langcode, 'target' => $langcode @@ -167,7 +168,8 @@ protected function doTestBasicTranslation() { $language = ConfigurableLanguage::load($langcode); $source_langcode = 'it'; $edit = array('source_langcode[source]' => $source_langcode); - $add_url = Url::fromRoute('content_translation.translation_add_' . $entity->getEntityTypeId(), [ + $entity_type_id = $entity->getEntityTypeId(); + $add_url = Url::fromRoute("entity.$entity_type_id.content_translation_add", [ $entity->getEntityTypeId() => $entity->id(), 'source' => $default_langcode, 'target' => $langcode @@ -180,7 +182,8 @@ protected function doTestBasicTranslation() { // Add another translation and mark the other ones as outdated. $values[$langcode] = $this->getNewEntityValues($langcode); $edit = $this->getEditValues($values, $langcode) + array('content_translation[retranslate]' => TRUE); - $add_url = Url::fromRoute('content_translation.translation_add_' . $entity->getEntityTypeId(), [ + $entity_type_id = $entity->getEntityTypeId(); + $add_url = Url::fromRoute("entity.$entity_type_id.content_translation_add", [ $entity->getEntityTypeId() => $entity->id(), 'source' => $source_langcode, 'target' => $langcode @@ -207,13 +210,15 @@ protected function doTestBasicTranslation() { */ protected function doTestTranslationOverview() { $entity = entity_load($this->entityTypeId, $this->entityId, TRUE); - $this->drupalGet($entity->urlInfo('drupal:content-translation-overview')); + $translate_url = $entity->urlInfo('drupal:content-translation-overview'); + $this->drupalGet($translate_url); + $translate_url->setAbsolute(FALSE); foreach ($this->langcodes as $langcode) { if ($entity->hasTranslation($langcode)) { $language = new Language(array('id' => $langcode)); - $view_path = $entity->url('canonical', array('language' => $language)); - $elements = $this->xpath('//table//a[@href=:href]', array(':href' => $view_path)); + $view_url = $entity->url('canonical', ['language' => $language]); + $elements = $this->xpath('//table//a[@href=:href]', [':href' => $view_url]); $this->assertEqual((string) $elements[0], $entity->getTranslation($langcode)->label(), format_string('Label correctly shown for %language translation.', array('%language' => $langcode))); $edit_path = $entity->url('edit-form', array('language' => $language)); $elements = $this->xpath('//table//ul[@class="dropbutton"]/li/a[@href=:href]', array(':href' => $edit_path)); @@ -343,7 +348,7 @@ protected function doTestTranslationDeletion() { // Check that the translator cannot delete the original translation. $args = [$this->entityTypeId => $entity->id(), 'language' => 'en']; - $this->drupalGet(Url::fromRoute('content_translation.translation_delete_' . $this->entityTypeId, $args)); + $this->drupalGet(Url::fromRoute("entity.$this->entityTypeId.content_translation_delete", $args)); $this->assertResponse(403); } diff --git a/core/modules/content_translation/src/Tests/ContentTranslationWorkflowsTest.php b/core/modules/content_translation/src/Tests/ContentTranslationWorkflowsTest.php index b602be0..1216c0c 100644 --- a/core/modules/content_translation/src/Tests/ContentTranslationWorkflowsTest.php +++ b/core/modules/content_translation/src/Tests/ContentTranslationWorkflowsTest.php @@ -73,7 +73,7 @@ protected function setupEntity() { // Create a translation. $this->drupalLogin($this->translator); - $add_translation_url = Url::fromRoute('content_translation.translation_add_' . $this->entityTypeId, [$this->entityTypeId => $this->entity->id(), 'source' => $default_langcode, 'target' => $this->langcodes[2]]); + $add_translation_url = Url::fromRoute("entity.$this->entityTypeId.content_translation_add", [$this->entityTypeId => $this->entity->id(), 'source' => $default_langcode, 'target' => $this->langcodes[2]]); $this->drupalPostForm($add_translation_url, array(), t('Save')); $this->rebuildContainer(); } @@ -175,7 +175,7 @@ protected function doTestWorkflows(UserInterface $user, $expected_status) { $this->assertResponse($expected_status['overview'], SafeMarkup::format('The @user_label has the expected translation overview access.', $args)); // Check whether the user is allowed to create a translation. - $add_translation_url = Url::fromRoute('content_translation.translation_add_' . $this->entityTypeId, [$this->entityTypeId => $this->entity->id(), 'source' => $default_langcode, 'target' => $langcode], $options); + $add_translation_url = Url::fromRoute("entity.$this->entityTypeId.content_translation_add", [$this->entityTypeId => $this->entity->id(), 'source' => $default_langcode, 'target' => $langcode], $options); if ($expected_status['add_translation'] == 200) { $this->clickLink('Add'); $this->assertUrl($add_translation_url->toString(), [], 'The translation overview points to the translation form when creating translations.'); @@ -193,7 +193,7 @@ protected function doTestWorkflows(UserInterface $user, $expected_status) { // Check whether the user is allowed to edit a translation. $langcode = $this->langcodes[2]; $options['language'] = $languages[$langcode]; - $edit_translation_url = Url::fromRoute('content_translation.translation_edit_' . $this->entityTypeId, [$this->entityTypeId => $this->entity->id(), 'language' => $langcode], $options); + $edit_translation_url = Url::fromRoute("entity.$this->entityTypeId.content_translation_edit", [$this->entityTypeId => $this->entity->id(), 'language' => $langcode], $options); if ($expected_status['edit_translation'] == 200) { $this->drupalGet($translations_url); $editor = $expected_status['edit'] == 200; @@ -221,7 +221,7 @@ protected function doTestWorkflows(UserInterface $user, $expected_status) { // Check whether the user is allowed to delete a translation. $langcode = $this->langcodes[2]; $options['language'] = $languages[$langcode]; - $delete_translation_url = Url::fromRoute('content_translation.translation_delete_' . $this->entityTypeId, [$this->entityTypeId => $this->entity->id(), 'language' => $langcode], $options); + $delete_translation_url = Url::fromRoute("entity.$this->entityTypeId.content_translation_delete", [$this->entityTypeId => $this->entity->id(), 'language' => $langcode], $options); if ($expected_status['delete_translation'] == 200) { $this->drupalGet($translations_url); $editor = $expected_status['delete'] == 200; diff --git a/core/modules/language/language.services.yml b/core/modules/language/language.services.yml index fc9bba1..19d0f21 100644 --- a/core/modules/language/language.services.yml +++ b/core/modules/language/language.services.yml @@ -9,7 +9,7 @@ services: - [initLanguageManager] language.config_subscriber: class: Drupal\language\EventSubscriber\ConfigSubscriber - arguments: ['@language_manager', '@language.default', '@config.factory'] + arguments: ['@language_manager', '@language.default', '@config.factory', '@language_negotiator'] tags: - { name: event_subscriber } language.config_factory_override: diff --git a/core/modules/language/src/EventSubscriber/ConfigSubscriber.php b/core/modules/language/src/EventSubscriber/ConfigSubscriber.php index e1875ce..e43697b 100644 --- a/core/modules/language/src/EventSubscriber/ConfigSubscriber.php +++ b/core/modules/language/src/EventSubscriber/ConfigSubscriber.php @@ -14,6 +14,8 @@ use Drupal\Core\Config\ConfigCrudEvent; use Drupal\Core\Config\ConfigEvents; use Drupal\language\ConfigurableLanguageManager; +use Drupal\language\HttpKernel\PathProcessorLanguage; +use Drupal\language\LanguageNegotiatorInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; /** @@ -43,6 +45,20 @@ class ConfigSubscriber implements EventSubscriberInterface { protected $configFactory; /** + * The language negotiator. + * + * @var \Drupal\language\LanguageNegotiatorInterface + */ + protected $languageNegotiator; + + /** + * The language path processor. + * + * @var \Drupal\language\HttpKernel\PathProcessorLanguage + */ + protected $pathProcessorLanguage; + + /** * Constructs a new class object. * * @param \Drupal\Core\Language\LanguageManagerInterface $language_manager @@ -51,11 +67,14 @@ class ConfigSubscriber implements EventSubscriberInterface { * The default language. * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory * The configuration factory. + * @param \Drupal\language\LanguageNegotiatorInterface $language_negotiator + * The language negotiator. */ - public function __construct(LanguageManagerInterface $language_manager, LanguageDefault $language_default, ConfigFactoryInterface $config_factory) { + public function __construct(LanguageManagerInterface $language_manager, LanguageDefault $language_default, ConfigFactoryInterface $config_factory, LanguageNegotiatorInterface $language_negotiator) { $this->languageManager = $language_manager; $this->languageDefault = $language_default; $this->configFactory = $config_factory; + $this->languageNegotiator = $language_negotiator; } /** @@ -102,6 +121,25 @@ public function onConfigSave(ConfigCrudEvent $event) { // Trigger a container rebuild on the next request by invalidating it. ConfigurableLanguageManager::rebuildServices(); } + elseif($saved_config->getName() == 'language.types' && $event->isChanged('negotiation')) { + // If the negotiation configuration changed the language negotiator and + // the language path processor have to be reset so that they regenerate + // the method instances and also sort them accordingly to the new config. + $this->languageNegotiator->reset(); + if (isset($this->pathProcessorLanguage)) { + $this->pathProcessorLanguage->reset(); + } + } + } + + /** + * Injects the language path processors on multilingual site configuration. + * + * @param \Drupal\language\HttpKernel\PathProcessorLanguage $path_processor_language + * The language path processor. + */ + public function setPathProcessorLanguage(PathProcessorLanguage $path_processor_language) { + $this->pathProcessorLanguage = $path_processor_language; } /** diff --git a/core/modules/language/src/HttpKernel/PathProcessorLanguage.php b/core/modules/language/src/HttpKernel/PathProcessorLanguage.php index caddfc0..aa58709 100644 --- a/core/modules/language/src/HttpKernel/PathProcessorLanguage.php +++ b/core/modules/language/src/HttpKernel/PathProcessorLanguage.php @@ -13,6 +13,7 @@ use Drupal\Core\PathProcessor\OutboundPathProcessorInterface; use Drupal\Core\Render\BubbleableMetadata; use Drupal\language\ConfigurableLanguageManagerInterface; +use Drupal\language\EventSubscriber\ConfigSubscriber; use Drupal\language\LanguageNegotiatorInterface; use Symfony\Component\HttpFoundation\Request; use Drupal\Core\Session\AccountInterface; @@ -58,6 +59,14 @@ class PathProcessorLanguage implements InboundPathProcessorInterface, OutboundPa protected $multilingual; /** + * The language configuration event subscriber. + * + * @var \Drupal\language\EventSubscriber\ConfigSubscriber + */ + protected $configSubscriber; + + + /** * Constructs a PathProcessorLanguage object. * * @param \Drupal\Core\Config\ConfigFactoryInterface $config @@ -68,12 +77,15 @@ class PathProcessorLanguage implements InboundPathProcessorInterface, OutboundPa * The language negotiator. * @param \Drupal\Core\Session\AccountInterface $current_user * The current active user. + * @param \Drupal\language\EventSubscriber\ConfigSubscriber $config_subscriber + * The language configuration event subscriber. */ - public function __construct(ConfigFactoryInterface $config, ConfigurableLanguageManagerInterface $language_manager, LanguageNegotiatorInterface $negotiator, AccountInterface $current_user) { + public function __construct(ConfigFactoryInterface $config, ConfigurableLanguageManagerInterface $language_manager, LanguageNegotiatorInterface $negotiator, AccountInterface $current_user, ConfigSubscriber $config_subscriber) { $this->config = $config; $this->languageManager = $language_manager; $this->negotiator = $negotiator; $this->negotiator->setCurrentUser($current_user); + $this->configSubscriber = $config_subscriber; } /** @@ -152,4 +164,22 @@ protected function initProcessors($scope) { }); } + /** + * Initializes the injected event subscriber with the language path processor. + * + * The language path processor service is registered only on multilingual + * site configuration, thus we inject it in the event subscriber only when + * it is initialized. + */ + public function initConfigSubscriber() { + $this->configSubscriber->setPathProcessorLanguage($this); + } + + /** + * Resets the collected processors instances. + */ + public function reset() { + $this->processors = array(); + } + } diff --git a/core/modules/language/src/LanguageServiceProvider.php b/core/modules/language/src/LanguageServiceProvider.php index 2f1840c..c3f1cd1 100644 --- a/core/modules/language/src/LanguageServiceProvider.php +++ b/core/modules/language/src/LanguageServiceProvider.php @@ -39,7 +39,9 @@ public function register(ContainerBuilder $container) { ->addArgument(new Reference('config.factory')) ->addArgument(new Reference('language_manager')) ->addArgument(new Reference('language_negotiator')) - ->addArgument(new Reference('current_user')); + ->addArgument(new Reference('current_user')) + ->addArgument(new Reference('language.config_subscriber')) + ->addMethodCall('initConfigSubscriber'); } } diff --git a/core/modules/language/src/Plugin/LanguageNegotiation/LanguageNegotiationContentEntity.php b/core/modules/language/src/Plugin/LanguageNegotiation/LanguageNegotiationContentEntity.php new file mode 100644 index 0000000..fe32741 --- /dev/null +++ b/core/modules/language/src/Plugin/LanguageNegotiation/LanguageNegotiationContentEntity.php @@ -0,0 +1,294 @@ +entityManager = $entity_manager; + $this->paths = new \SplObjectStorage(); + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static($container->get('entity.manager')); + } + + /** + * {@inheritdoc} + */ + public function getLangcode(Request $request = NULL) { + $langcode = $request->get(static::QUERY_PARAMETER); + + $language_enabled = array_key_exists($langcode, $this->languageManager->getLanguages()); + return $language_enabled ? $langcode : NULL; + } + + /** + * {@inheritdoc} + */ + public function processOutbound($path, &$options = [], Request $request = NULL, BubbleableMetadata $bubbleable_metadata = NULL) { + // If appropriate, process outbound to add a query parameter to the url and + // remove the language option, so that url negotiator does not rewrite the + // url. + + // First, check if processing conditions are met. + if (!($request && !empty($options['route']) && $this->hasHigherLanguageNegotiationOrder() && $this->meetsContentEntityRoutesCondition($options['route'], $request))) { + return $path; + } + + if (isset($options['language']) || $langcode = $this->getLangcode($request)) { + // If the language option is set, unset it, so that the url language + // negotiator does not rewrite the url. + if (isset($options['language'])) { + $langcode = $options['language']->getId(); + unset($options['language']); + } + + if (isset($options['query']) && is_string($options['query'])) { + $query = []; + parse_str($options['query'], $query); + $options['query'] = $query; + } + else { + $options['query'] = []; + } + + if (!isset($options['query'][static::QUERY_PARAMETER])) { + $query_addon = [static::QUERY_PARAMETER => $langcode]; + $options['query'] += $query_addon; + // @todo Remove this once https://www.drupal.org/node/2507005 lands. + $path .= (strpos($path, '?') !== FALSE ? '&' : '?') . UrlHelper::buildQuery($query_addon); + } + + if ($bubbleable_metadata) { + // Cached URLs that have been processed by this outbound path + // processor must be: + $bubbleable_metadata + // - varied by the content language query parameter. + ->addCacheContexts(['url.query_args:' . static::QUERY_PARAMETER]); + } + } + + return $path; + } + + /** + * {@inheritdoc} + */ + public function getLanguageSwitchLinks(Request $request, $type, Url $url) { + $links = []; + $query = []; + parse_str($request->getQueryString(), $query); + + foreach ($this->languageManager->getNativeLanguages() as $language) { + $langcode = $language->getId(); + $query[static::QUERY_PARAMETER] = $langcode; + $links[$langcode] = [ + 'url' => $url, + 'title' => $language->getName(), + 'attributes' => ['class' => ['language-link']], + 'query' => $query, + ]; + } + + return $links; + } + + /** + * Determines if content entity language negotiator has higher priority. + * + * The content entity language negotiator having higher priority than the url + * language negotiator, is a criteria in + * \Drupal\language\Plugin\LanguageNegotiation\LanguageNegotiationContentEntity::processOutbound(). + * + * @return bool + * TRUE if the the content entity language negotiator has higher priority + * than the url language negotiator, FALSE otherwise. + */ + protected function hasHigherLanguageNegotiationOrder() { + if (!isset($this->hasHigherLanguageNegotiationOrderResult)) { + // Only run if the LanguageNegotiationContentEntity outbound function is + // being executed before the outbound function of LanguageNegotiationUrl. + $enabled_methods_content = $this->config->get('language.types')->get('negotiation.language_content.enabled') ?: []; + + // Check if the content language is configured to be dependent on the + // url negotiator directly or indirectly over the interface negotiator. + if (isset($enabled_methods_content[LanguageNegotiationUrl::METHOD_ID]) && ($enabled_methods_content[static::METHOD_ID] > $enabled_methods_content[LanguageNegotiationUrl::METHOD_ID])) { + $this->hasHigherLanguageNegotiationOrderResult = FALSE; + } + else { + $check_interface_method = FALSE; + if (isset($enabled_methods_content[LanguageNegotiationUI::METHOD_ID])) { + $enabled_methods_interface = $this->config->get('language.types')->get('negotiation.language_interface.enabled') ?: []; + $check_interface_method = isset($enabled_methods_interface[LanguageNegotiationUrl::METHOD_ID]); + } + if ($check_interface_method) { + $check_against_weight = $enabled_methods_content[LanguageNegotiationUI::METHOD_ID]; + $check_against_weight = isset($enabled_methods_content[LanguageNegotiationUrl::METHOD_ID]) ? max($check_against_weight, $enabled_methods_content[LanguageNegotiationUrl::METHOD_ID]) : $check_against_weight; + } + else { + $check_against_weight = isset($enabled_methods_content[LanguageNegotiationUrl::METHOD_ID]) ? $enabled_methods_content[LanguageNegotiationUrl::METHOD_ID] : PHP_INT_MAX; + } + + $this->hasHigherLanguageNegotiationOrderResult = $enabled_methods_content[static::METHOD_ID] < $check_against_weight; + } + } + + return $this->hasHigherLanguageNegotiationOrderResult; + } + + /** + * Determines if content entity route condition is met. + * + * Requirements: currently being on an content entity route and processing + * outbound url pointing to the same content entity. + * + * @param \Symfony\Component\Routing\Route $outbound_route + * The route object for the current outbound url being processed. + * @param \Symfony\Component\HttpFoundation\Request $request + * The HttpRequest object representing the current request. + * + * @return bool + * TRUE if the content entity route condition is met, FALSE otherwise. + */ + protected function meetsContentEntityRoutesCondition(SymfonyRoute $outbound_route, Request $request) { + $outbound_path_pattern = $outbound_route->getPath(); + $storage = isset($this->paths[$request]) ? $this->paths[$request] : []; + if (!isset($storage[$outbound_path_pattern])) { + $storage[$outbound_path_pattern] = FALSE; + + // Check if the outbound route points to the current entity. + if ($content_entity_type_id_for_current_route = $this->getContentEntityTypeIdForCurrentRequest($request)) { + if (!empty($this->getContentEntityPaths()[$outbound_path_pattern]) && $content_entity_type_id_for_current_route == $this->getContentEntityPaths()[$outbound_path_pattern]) { + $storage[$outbound_path_pattern] = TRUE; + } + } + + $this->paths[$request] = $storage; + } + + return $storage[$outbound_path_pattern]; + } + + /** + * Returns the content entity type ID from the current request for the route. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The HttpRequest object representing the current request. + * + * @return string + * The entity type ID for the route from the request. + */ + protected function getContentEntityTypeIdForCurrentRequest(Request $request) { + $content_entity_type_id_for_current_route = ''; + + if ($current_route = $request->attributes->get(RouteObjectInterface::ROUTE_OBJECT)) { + $current_route_path = $current_route->getPath(); + $content_entity_type_id_for_current_route = isset($this->getContentEntityPaths()[$current_route_path]) ? $this->getContentEntityPaths()[$current_route_path] : ''; + } + + return $content_entity_type_id_for_current_route; + } + + /** + * Returns the paths for the link templates of all content entities. + * + * @return array + * An array of all content entity type IDs, keyed by the corresponding link + * template paths. + */ + protected function getContentEntityPaths() { + if (!isset($this->contentEntityPaths)) { + $this->contentEntityPaths = []; + $entity_types = $this->entityManager->getDefinitions(); + foreach ($entity_types as $entity_type_id => $entity_type) { + if ($entity_type->isSubclassOf(ContentEntityInterface::class)) { + $entity_paths = array_fill_keys($entity_type->getLinkTemplates(), $entity_type_id); + $this->contentEntityPaths = array_merge($this->contentEntityPaths, $entity_paths); + } + } + } + + return $this->contentEntityPaths; + } + +} diff --git a/core/modules/language/src/Tests/EntityUrlLanguageTest.php b/core/modules/language/src/Tests/EntityUrlLanguageTest.php index 915f7f6..f172b38 100644 --- a/core/modules/language/src/Tests/EntityUrlLanguageTest.php +++ b/core/modules/language/src/Tests/EntityUrlLanguageTest.php @@ -7,22 +7,34 @@ namespace Drupal\language\Tests; +use Drupal\Core\Language\LanguageInterface; use Drupal\entity_test\Entity\EntityTest; use Drupal\language\Entity\ConfigurableLanguage; -use Drupal\simpletest\KernelTestBase; +use Drupal\language\Plugin\LanguageNegotiation\LanguageNegotiationContentEntity; +use Drupal\language\Plugin\LanguageNegotiation\LanguageNegotiationUrl; +use Symfony\Cmf\Component\Routing\RouteObjectInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Routing\Route; /** * Tests the language of entity URLs. * @group language */ -class EntityUrlLanguageTest extends KernelTestBase { +class EntityUrlLanguageTest extends LanguageTestBase { /** * Modules to enable. * * @var array */ - public static $modules = ['language', 'entity_test', 'user', 'system']; + public static $modules = ['entity_test', 'user']; + + /** + * The entity being used for testing. + * + * @var \Drupal\Core\Entity\ContentEntityInterface + */ + protected $entity; protected function setUp() { parent::setUp(); @@ -37,33 +49,93 @@ protected function setUp() { ConfigurableLanguage::create(['id' => 'es'])->save(); ConfigurableLanguage::create(['id' => 'fr'])->save(); - $this->config('language.types')->setData([ - 'configurable' => ['language_interface'], - 'negotiation' => ['language_interface' => ['enabled' => ['language-url' => 0]]], - ])->save(); - $this->config('language.negotiation')->setData([ - 'url' => [ - 'source' => 'path_prefix', - 'prefixes' => ['en' => 'en', 'es' => 'es', 'fr' => 'fr'] - ], - ])->save(); + $config = $this->config('language.negotiation'); + $config->set('url.prefixes', ['en' => 'en', 'es' => 'es', 'fr' => 'fr']) + ->save(); $this->kernel->rebuildContainer(); - $this->container = $this->kernel->getContainer(); - \Drupal::setContainer($this->container); + + $this->createTranslatableEntity(); } /** * Ensures that entity URLs in a language have the right language prefix. */ public function testEntityUrlLanguage() { - $entity = EntityTest::create(); - $entity->addTranslation('es', ['name' => 'name spanish']); - $entity->addTranslation('fr', ['name' => 'name french']); - $entity->save(); - - $this->assertTrue(strpos($entity->urlInfo()->toString(), '/en/entity_test/' . $entity->id()) !== FALSE); - $this->assertTrue(strpos($entity->getTranslation('es')->urlInfo()->toString(), '/es/entity_test/' . $entity->id()) !== FALSE); - $this->assertTrue(strpos($entity->getTranslation('fr')->urlInfo()->toString(), '/fr/entity_test/' . $entity->id()) !== FALSE); + $this->assertTrue(strpos($this->entity->urlInfo()->toString(), '/en/entity_test/' . $this->entity->id()) !== FALSE); + $this->assertTrue(strpos($this->entity->getTranslation('es')->urlInfo()->toString(), '/es/entity_test/' . $this->entity->id()) !== FALSE); + $this->assertTrue(strpos($this->entity->getTranslation('fr')->urlInfo()->toString(), '/fr/entity_test/' . $this->entity->id()) !== FALSE); + } + + /** + * Ensures correct entity URLs with the method language-content-entity enabled. + * + * Test case with the method language-content-entity enabled and configured + * with higher and also with lower priority than the method language-url. + */ + public function testEntityUrlLanguageWithLanguageContentEnabled() { + // Define the method language-content-entity with a higher priority than + // language-url. + $config = $this->config('language.types'); + $config->set('configurable', [LanguageInterface::TYPE_INTERFACE, LanguageInterface::TYPE_CONTENT]); + $config->set('negotiation.language_content.enabled', [ + LanguageNegotiationContentEntity::METHOD_ID => 0, + LanguageNegotiationUrl::METHOD_ID => 1 + ]); + $config->save(); + + // Without being on an content entity route the default entity URL tests + // should still pass. + $this->testEntityUrlLanguage(); + + // Now switching to an entity route, so that the URL links are generated + // while being on an entity route. + $this->setCurrentRequestForRoute('/entity_test/{entity_test}', 'entity.entity_test.canonical'); + + // The method language-content-entity should run before language-url and + // append query parameter for the content language and prevent language-url + // from overwriting the url. + $this->assertTrue(strpos($this->entity->urlInfo('canonical')->toString(), '/en/entity_test/' . $this->entity->id() . '?' . LanguageNegotiationContentEntity::QUERY_PARAMETER . '=en') !== FALSE); + $this->assertTrue(strpos($this->entity->getTranslation('es')->urlInfo('canonical')->toString(), '/en/entity_test/' . $this->entity->id() . '?' . LanguageNegotiationContentEntity::QUERY_PARAMETER . '=es') !== FALSE); + $this->assertTrue(strpos($this->entity->getTranslation('fr')->urlInfo('canonical')->toString(), '/en/entity_test/' . $this->entity->id() . '?' . LanguageNegotiationContentEntity::QUERY_PARAMETER . '=fr') !== FALSE); + + // Define the method language-url with a higher priority than + // language-content-entity. This configuration should match the default one, + // where the language-content-entity is turned off. + $config->set('negotiation.language_content.enabled', [ + LanguageNegotiationUrl::METHOD_ID => 0, + LanguageNegotiationContentEntity::METHOD_ID => 1 + ]); + $config->save(); + + // The default entity URL tests should pass again with the current + // configuration. + $this->testEntityUrlLanguage(); + } + + /** + * Creates a translated entity. + */ + protected function createTranslatableEntity() { + $this->entity = EntityTest::create(); + $this->entity->addTranslation('es', ['name' => 'name spanish']); + $this->entity->addTranslation('fr', ['name' => 'name french']); + $this->entity->save(); + } + + /** + * Sets the current request to a specific path with the corresponding route. + * + * @param string $path + * The path for which the current request should be created. + * @param string $route_name + * The route name for which the route object for the request should be + * created. + */ + protected function setCurrentRequestForRoute($path, $route_name) { + $request = Request::create($path); + $request->attributes->set(RouteObjectInterface::ROUTE_NAME, $route_name); + $request->attributes->set(RouteObjectInterface::ROUTE_OBJECT, new Route($path)); + $this->container->get('request_stack')->push($request); } } diff --git a/core/modules/language/src/Tests/LanguageNegotiationContentEntityTest.php b/core/modules/language/src/Tests/LanguageNegotiationContentEntityTest.php new file mode 100644 index 0000000..5373096 --- /dev/null +++ b/core/modules/language/src/Tests/LanguageNegotiationContentEntityTest.php @@ -0,0 +1,182 @@ + 'es'])->save(); + ConfigurableLanguage::create(['id' => 'fr'])->save(); + + // In order to reflect the changes for a multilingual site in the container + // we have to rebuild it. + $this->rebuildContainer(); + + $this->createTranslatableEntity(); + + $user = $this->drupalCreateUser(array('view test entity')); + $this->drupalLogin($user); + } + + /** + * Tests default with content language remaining same as interface language. + */ + public function testDefaultConfiguration() { + $translation = $this->entity; + $this->drupalGet($translation->urlInfo()); + $last = $this->container->get('state')->get('language_test.language_negotiation_last'); + $last_content_language = $last[LanguageInterface::TYPE_CONTENT]; + $last_interface_language = $last[LanguageInterface::TYPE_INTERFACE]; + $this->assertTrue(($last_interface_language == $last_content_language) && ($last_content_language == $translation->language()->getId()), new FormattableMarkup('Interface language %interface_language and Content language %content_language are the same as the translation language %translation_language of the entity.', ['%interface_language' => $last_interface_language, '%content_language' => $last_content_language, '%translation_language' => $translation->language()->getId()])); + + $translation = $this->entity->getTranslation('es'); + $this->drupalGet($translation->urlInfo()); + $last = $this->container->get('state')->get('language_test.language_negotiation_last'); + $last_content_language = $last[LanguageInterface::TYPE_CONTENT]; + $last_interface_language = $last[LanguageInterface::TYPE_INTERFACE]; + $this->assertTrue(($last_interface_language == $last_content_language) && ($last_content_language == $translation->language()->getId()), new FormattableMarkup('Interface language %interface_language and Content language %content_language are the same as the translation language %translation_language of the entity.', ['%interface_language' => $last_interface_language, '%content_language' => $last_content_language, '%translation_language' => $translation->language()->getId()])); + + $translation = $this->entity->getTranslation('fr'); + $this->drupalGet($translation->urlInfo()); + $last = $this->container->get('state')->get('language_test.language_negotiation_last'); + $last_content_language = $last[LanguageInterface::TYPE_CONTENT]; + $last_interface_language = $last[LanguageInterface::TYPE_INTERFACE]; + $this->assertTrue(($last_interface_language == $last_content_language) && ($last_content_language == $translation->language()->getId()), new FormattableMarkup('Interface language %interface_language and Content language %content_language are the same as the translation language %translation_language of the entity.', ['%interface_language' => $last_interface_language, '%content_language' => $last_content_language, '%translation_language' => $translation->language()->getId()])); + } + + /** + * Tests enabling the language negotiator language_content_entity. + */ + public function testEnabledLanguageContentNegotiator() { + // Define the method language-url with a higher priority than + // language-content-entity. This configuration should match the default one, + // where the language-content-entity is turned off. + $config = $this->config('language.types'); + $config->set('configurable', [LanguageInterface::TYPE_INTERFACE, LanguageInterface::TYPE_CONTENT]); + $config->set('negotiation.language_content.enabled', [ + LanguageNegotiationUrl::METHOD_ID => 0, + LanguageNegotiationContentEntity::METHOD_ID => 1 + ]); + $config->save(); + + // In order to reflect the changes for a multilingual site in the container + // we have to rebuild it. + $this->rebuildContainer(); + + // The tests for the default configuration should still pass. + $this->testDefaultConfiguration(); + + // Define the method language-content-entity with a higher priority than + // language-url. + $config->set('negotiation.language_content.enabled', [ + LanguageNegotiationContentEntity::METHOD_ID => 0, + LanguageNegotiationUrl::METHOD_ID => 1 + ]); + $config->save(); + + // In order to reflect the changes for a multilingual site in the container + // we have to rebuild it. + $this->rebuildContainer(); + + // The method language-content-entity should run before language-url and + // append query parameter for the content language and prevent language-url + // from overwriting the URL. + $default_site_langcode = $this->config('system.site')->get('default_langcode'); + + // Now switching to an entity route, so that the URL links are generated + // while being on an entity route. + $this->setCurrentRequestForRoute('/entity_test/{entity_test}', 'entity.entity_test.canonical'); + + $translation = $this->entity; + $this->drupalGet($translation->urlInfo()); + $last = $this->container->get('state')->get('language_test.language_negotiation_last'); + $last_content_language = $last[LanguageInterface::TYPE_CONTENT]; + $last_interface_language = $last[LanguageInterface::TYPE_INTERFACE]; + $this->assertTrue(($last_interface_language == $default_site_langcode) && ($last_interface_language == $last_content_language) && ($last_content_language == $translation->language()->getId()), 'Interface language and Content language are the same as the default translation language of the entity.'); + $this->assertTrue($last_interface_language == $default_site_langcode, 'Interface language did not change from the default site language.'); + $this->assertTrue($last_content_language == $translation->language()->getId(), 'Content language matches the current entity translation language.'); + + $translation = $this->entity->getTranslation('es'); + $this->drupalGet($translation->urlInfo()); + $last = $this->container->get('state')->get('language_test.language_negotiation_last'); + $last_content_language = $last[LanguageInterface::TYPE_CONTENT]; + $last_interface_language = $last[LanguageInterface::TYPE_INTERFACE]; + $this->assertTrue($last_interface_language == $default_site_langcode, 'Interface language did not change from the default site language.'); + $this->assertTrue($last_content_language == $translation->language()->getId(), 'Content language matches the current entity translation language.'); + + $translation = $this->entity->getTranslation('fr'); + $this->drupalGet($translation->urlInfo()); + $last = $this->container->get('state')->get('language_test.language_negotiation_last'); + $last_content_language = $last[LanguageInterface::TYPE_CONTENT]; + $last_interface_language = $last[LanguageInterface::TYPE_INTERFACE]; + $this->assertTrue($last_interface_language == $default_site_langcode, 'Interface language did not change from the default site language.'); + $this->assertTrue($last_content_language == $translation->language()->getId(), 'Content language matches the current entity translation language.'); + } + + /** + * Creates a translated entity. + */ + protected function createTranslatableEntity() { + $this->entity = EntityTest::create(); + $this->entity->addTranslation('es', ['name' => 'name spanish']); + $this->entity->addTranslation('fr', ['name' => 'name french']); + $this->entity->save(); + } + + /** + * Sets the current request to a specific path with the corresponding route. + * + * @param string $path + * The path for which the current request should be created. + * @param string $route_name + * The route name for which the route object for the request should be + * created. + */ + protected function setCurrentRequestForRoute($path, $route_name) { + $request = Request::create($path); + $request->attributes->set(RouteObjectInterface::ROUTE_NAME, $route_name); + $request->attributes->set(RouteObjectInterface::ROUTE_OBJECT, new Route($path)); + $this->container->get('request_stack')->push($request); + } + +} diff --git a/core/modules/language/src/Tests/LanguageUILanguageNegotiationTest.php b/core/modules/language/src/Tests/LanguageUILanguageNegotiationTest.php index 4645d68..16ba13e 100644 --- a/core/modules/language/src/Tests/LanguageUILanguageNegotiationTest.php +++ b/core/modules/language/src/Tests/LanguageUILanguageNegotiationTest.php @@ -221,8 +221,13 @@ function testUILanguageNegotiation() { // Unknown language prefix should return 404. $definitions = \Drupal::languageManager()->getNegotiator()->getNegotiationMethods(); + // Enable only methods, which are either not limited to a specific language + // type or are supporting the interface language type. + $language_interface_method_definitions = array_filter($definitions, function ($method_definition) { + return !isset($method_definition['types']) || (isset($method_definition['types']) && in_array(LanguageInterface::TYPE_INTERFACE, $method_definition['types'])); + }); $this->config('language.types') - ->set('negotiation.' . LanguageInterface::TYPE_INTERFACE . '.enabled', array_flip(array_keys($definitions))) + ->set('negotiation.' . LanguageInterface::TYPE_INTERFACE . '.enabled', array_flip(array_keys($language_interface_method_definitions))) ->save(); $this->drupalGet("$langcode_unknown/admin/config", array(), $http_header_browser_fallback); $this->assertResponse(404, "Unknown language path prefix should return 404"); diff --git a/core/modules/node/src/Tests/NodeTranslationUITest.php b/core/modules/node/src/Tests/NodeTranslationUITest.php index 490f5b1..45bb340 100644 --- a/core/modules/node/src/Tests/NodeTranslationUITest.php +++ b/core/modules/node/src/Tests/NodeTranslationUITest.php @@ -94,7 +94,8 @@ function testPublishedStatusNoFields() { $language = ConfigurableLanguage::load($langcode); $values[$langcode] = array('title' => array(array('value' => $this->randomMachineName()))); - $add_url = Url::fromRoute('content_translation.translation_add_' . $entity->getEntityTypeId(), [ + $entity_type_id = $entity->getEntityTypeId(); + $add_url = Url::fromRoute("entity.$entity_type_id.content_translation_add", [ $entity->getEntityTypeId() => $entity->id(), 'source' => $default_langcode, 'target' => $langcode diff --git a/core/tests/Drupal/Tests/Core/PathProcessor/PathProcessorTest.php b/core/tests/Drupal/Tests/Core/PathProcessor/PathProcessorTest.php index d01a1f6..da61f5f 100644 --- a/core/tests/Drupal/Tests/Core/PathProcessor/PathProcessorTest.php +++ b/core/tests/Drupal/Tests/Core/PathProcessor/PathProcessorTest.php @@ -150,11 +150,16 @@ function testProcessInbound() { $current_user = $this->getMockBuilder('Drupal\Core\Session\AccountInterface') ->getMock(); + // Create a config event subscriber stub. + $config_subscriber = $this->getMockBuilder('Drupal\language\EventSubscriber\ConfigSubscriber') + ->disableOriginalConstructor() + ->getMock(); + // Create the processors. $alias_processor = new PathProcessorAlias($alias_manager); $decode_processor = new PathProcessorDecode(); $front_processor = new PathProcessorFront($config_factory_stub); - $language_processor = new PathProcessorLanguage($config_factory_stub, $this->languageManager, $negotiator, $current_user); + $language_processor = new PathProcessorLanguage($config_factory_stub, $this->languageManager, $negotiator, $current_user, $config_subscriber); // First, test the processor manager with the processors in the incorrect // order. The alias processor will run before the language processor, meaning