diff --git a/modules/oe_multilingual_url_suffix/oe_multilingual_url_suffix.module b/modules/oe_multilingual_url_suffix/oe_multilingual_url_suffix.module index 5c398d4d..0f253119 100644 --- a/modules/oe_multilingual_url_suffix/oe_multilingual_url_suffix.module +++ b/modules/oe_multilingual_url_suffix/oe_multilingual_url_suffix.module @@ -8,7 +8,9 @@ declare(strict_types=1); use Drupal\Core\Hook\Attribute\LegacyHook; +use Drupal\Core\Routing\TrustedRedirectResponse; use Drupal\oe_multilingual_url_suffix\Hook\OeMultilingualUrlSuffixHooks; +use Drupal\redirect\Entity\Redirect; /** * Implements hook_language_types_info_alter(). @@ -27,3 +29,13 @@ use Drupal\oe_multilingual_url_suffix\Hook\OeMultilingualUrlSuffixHooks; function oe_multilingual_url_suffix_language_types_info_alter(array &$language_types) { return \Drupal::service(OeMultilingualUrlSuffixHooks::class)->languageTypesInfoAlter($language_types); } + +/** + * Implements hook_redirect_response_alter(). + * + * @phpstan-ignore-next-line + */ +#[LegacyHook] +function oe_multilingual_url_suffix_redirect_response_alter(TrustedRedirectResponse $response, Redirect $redirect) { + return \Drupal::service(OeMultilingualUrlSuffixHooks::class)->redirectResponseAlter($response, $redirect); +} diff --git a/modules/oe_multilingual_url_suffix/src/Hook/OeMultilingualUrlSuffixHooks.php b/modules/oe_multilingual_url_suffix/src/Hook/OeMultilingualUrlSuffixHooks.php index 1627fefa..857bac0c 100644 --- a/modules/oe_multilingual_url_suffix/src/Hook/OeMultilingualUrlSuffixHooks.php +++ b/modules/oe_multilingual_url_suffix/src/Hook/OeMultilingualUrlSuffixHooks.php @@ -4,16 +4,48 @@ namespace Drupal\oe_multilingual_url_suffix\Hook; +use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Hook\Attribute\Hook; +use Drupal\Core\Language\Language; use Drupal\Core\Language\LanguageInterface; +use Drupal\Core\Language\LanguageManagerInterface; +use Drupal\Core\Routing\TrustedRedirectResponse; use Drupal\language\Plugin\LanguageNegotiation\LanguageNegotiationUrlFallback; use Drupal\oe_multilingual_url_suffix\Plugin\LanguageNegotiation\LanguageNegotiationUrlSuffix; +use Drupal\redirect\Entity\Redirect; /** * Hook implementations for OE Multilingual URL Suffix module. */ class OeMultilingualUrlSuffixHooks { + /** + * The language manager. + * + * @var \Drupal\Core\Language\LanguageManagerInterface + */ + protected LanguageManagerInterface $languageManager; + + /** + * The config factory. + * + * @var \Drupal\Core\Config\ConfigFactoryInterface + */ + protected ConfigFactoryInterface $configFactory; + + /** + * Constructs an OeMultilingualUrlSuffixHooks object. + * + * @param \Drupal\Core\Language\LanguageManagerInterface $language_manager + * The language manager. + * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory + * The config factory. + */ + public function __construct(LanguageManagerInterface $language_manager, ConfigFactoryInterface $config_factory) { + $this->languageManager = $language_manager; + $this->configFactory = $config_factory; + } + /** * Implements hook_language_types_info_alter(). * @@ -34,4 +66,93 @@ public function languageTypesInfoAlter(array &$language_types): void { ]; } + /** + * Implements hook_redirect_response_alter(). + * + * Ensures that redirects resolve to the correct language when using URL + * suffix language negotiation. Handles two cases: + * - When the redirect destination path contains an explicit language suffix + * (e.g., /test-page_fr), the suffix is detected and used. + * - When the redirect entity has a specific language set, that language is + * used instead of the current request's URL language. + * + * @param \Drupal\Core\Routing\TrustedRedirectResponse $response + * The redirect response. + * @param \Drupal\redirect\Entity\Redirect $redirect + * The redirect entity. + * + * @phpstan-ignore-next-line + */ + #[Hook('redirect_response_alter')] + public function redirectResponseAlter(TrustedRedirectResponse $response, Redirect $redirect): void { + // First, check if the redirect destination path contains a language suffix. + $language = $this->detectDestinationLanguage($redirect); + + // If no suffix language found, check the redirect entity's language field. + if (!$language) { + $redirect_langcode = $redirect->language()->getId(); + if ($redirect_langcode === Language::LANGCODE_NOT_SPECIFIED) { + return; + } + $language = $this->languageManager->getLanguage($redirect_langcode); + } + + if (!$language) { + return; + } + + // If the detected language matches the current URL language, no change + // needed. + $current_language = $this->languageManager->getCurrentLanguage(LanguageInterface::TYPE_URL); + if ($current_language && $current_language->getId() === $language->getId()) { + return; + } + + // Regenerate the URL with the correct language. + $url = $redirect->getRedirectUrl(); + if ($url) { + $url->setOption('language', $language); + $response->setTrustedTargetUrl($url->setAbsolute()->toString()); + } + } + + /** + * Detects a language suffix in the redirect destination path. + * + * @param \Drupal\redirect\Entity\Redirect $redirect + * The redirect entity. + * + * @return \Drupal\Core\Language\LanguageInterface|null + * The language if a valid suffix was found, NULL otherwise. + */ + protected function detectDestinationLanguage(Redirect $redirect): ?LanguageInterface { + $uri = $redirect->get('redirect_redirect')->uri; + if (!$uri) { + return NULL; + } + + $path = parse_url($uri, PHP_URL_PATH); + if (!$path) { + return NULL; + } + + $url_suffixes = $this->configFactory->get('oe_multilingual_url_suffix.settings')->get('url_suffixes'); + if (empty($url_suffixes)) { + return NULL; + } + + $parts = explode(LanguageNegotiationUrlSuffix::SUFFIX_DELIMITER, trim($path, '/')); + if (count($parts) < 2) { + return NULL; + } + + $suffix = array_pop($parts); + $langcode = array_search($suffix, $url_suffixes); + if (!$langcode) { + return NULL; + } + + return $this->languageManager->getLanguage($langcode); + } + } diff --git a/modules/oe_multilingual_url_suffix/src/Plugin/LanguageNegotiation/LanguageNegotiationUrlSuffix.php b/modules/oe_multilingual_url_suffix/src/Plugin/LanguageNegotiation/LanguageNegotiationUrlSuffix.php index 0dbae592..57b62221 100644 --- a/modules/oe_multilingual_url_suffix/src/Plugin/LanguageNegotiation/LanguageNegotiationUrlSuffix.php +++ b/modules/oe_multilingual_url_suffix/src/Plugin/LanguageNegotiation/LanguageNegotiationUrlSuffix.php @@ -161,7 +161,8 @@ public function processInbound($path, Request $request) { // If the suffix is one of the configured language suffix, rebuild the // path to remove it. - if (array_search($suffix, $url_suffixes)) { + $langcode = array_search($suffix, $url_suffixes); + if ($langcode) { $path = '/' . implode(static::SUFFIX_DELIMITER, $parts); } } diff --git a/modules/oe_multilingual_url_suffix/tests/src/Functional/RedirectLanguageSuffixTest.php b/modules/oe_multilingual_url_suffix/tests/src/Functional/RedirectLanguageSuffixTest.php new file mode 100644 index 00000000..f6b4bf50 --- /dev/null +++ b/modules/oe_multilingual_url_suffix/tests/src/Functional/RedirectLanguageSuffixTest.php @@ -0,0 +1,171 @@ +drupalCreateContentType(['type' => 'article']); + + // Enable content translation for articles. + $this->container->get('content_translation.manager')->setEnabled('node', 'article', TRUE); + $this->container->get('router.builder')->rebuild(); + + // Create a user with necessary permissions. + $user = $this->drupalCreateUser([ + 'administer languages', + 'access administration pages', + 'administer redirects', + 'administer nodes', + 'create article content', + 'edit any article content', + 'create url aliases', + 'translate any entity', + 'create content translations', + ]); + $this->drupalLogin($user); + + // Enable URL suffix language detection. + $edit = [ + 'language_interface[enabled][oe-multilingual-url-suffix-negotiation-method]' => 1, + 'language_interface[enabled][language-url]' => 0, + 'language_content[enabled][oe-multilingual-url-suffix-negotiation-method]' => 1, + 'language_content[enabled][language-url]' => 0, + ]; + $this->drupalGet('admin/config/regional/language/detection'); + $this->submitForm($edit, 'Save settings'); + + // Create a node with English and French translations. + $this->node = Node::create([ + 'type' => 'article', + 'title' => 'English Title', + 'langcode' => 'en', + 'path' => ['alias' => '/test-page'], + ]); + $this->node->save(); + + $this->node->addTranslation('fr', [ + 'title' => 'French Title', + 'path' => ['alias' => '/test-page'], + ]); + $this->node->save(); + } + + /** + * Tests redirect with path containing language suffix in destination. + */ + public function testRedirectWithPathSuffixDestination(): void { + // Create a redirect using a path with French suffix as destination. + $redirect = Redirect::create([ + 'redirect_source' => 'my-redirect', + 'redirect_redirect' => 'internal:/test-page_fr', + 'language' => Language::LANGCODE_NOT_SPECIFIED, + 'status_code' => 301, + ]); + $redirect->save(); + + // Visit without language suffix - should still redirect to French. + // Use 'external' => FALSE to skip client-side path processing, as the + // test process's PathProcessorLanguage may have stale processors cached + // from before the language negotiation configuration was changed. + $this->drupalGet('/my-redirect', ['external' => FALSE]); + $current_url = $this->getSession()->getCurrentUrl(); + $this->assertStringContainsString('_fr', $current_url, 'Redirect should preserve French suffix from destination path.'); + $this->assertSession()->pageTextContains('French Title'); + } + + /** + * Tests redirect with entity reference uses current request language. + */ + public function testRedirectWithEntityReference(): void { + // Create a redirect to an entity, without specific language. + $redirect = Redirect::create([ + 'redirect_source' => 'entity-redirect', + 'redirect_redirect' => 'internal:/node/' . $this->node->id(), + 'language' => Language::LANGCODE_NOT_SPECIFIED, + 'status_code' => 301, + ]); + $redirect->save(); + + // Visit without language suffix, current language defaults to English. + $this->drupalGet('/entity-redirect', ['external' => FALSE]); + $current_url = $this->getSession()->getCurrentUrl(); + $this->assertStringContainsString('_en', $current_url, 'Entity redirect should use current language (English).'); + $this->assertSession()->pageTextContains('English Title'); + + // Visit with French suffix, should redirect to French. + $this->drupalGet('/entity-redirect_fr', ['external' => FALSE]); + $current_url = $this->getSession()->getCurrentUrl(); + $this->assertStringContainsString('_fr', $current_url, 'Entity redirect should use request language (French).'); + $this->assertSession()->pageTextContains('French Title'); + } + + /** + * Tests that redirect Language field controls when redirect matches. + */ + public function testRedirectLanguageFieldControlsMatching(): void { + // Create a redirect that only matches French requests. + $redirect = Redirect::create([ + 'redirect_source' => 'french-only', + 'redirect_redirect' => 'internal:/node/' . $this->node->id(), + 'language' => 'fr', + 'status_code' => 301, + ]); + $redirect->save(); + + // Visit without French suffix, redirect should NOT match. + $this->drupalGet('/french-only', ['external' => FALSE]); + $current_url = $this->getSession()->getCurrentUrl(); + $this->assertStringContainsString('french-only', $current_url, 'Redirect should not match non-French request.'); + + // Visit with French suffix, redirect should match. + $this->drupalGet('/french-only_fr', ['external' => FALSE]); + $current_url = $this->getSession()->getCurrentUrl(); + $this->assertStringNotContainsString('french-only', $current_url, 'Redirect should match French request.'); + $this->assertSession()->pageTextContains('French Title'); + } + +} diff --git a/modules/oe_multilingual_url_suffix/tests/src/Unit/RedirectResponseAlterTest.php b/modules/oe_multilingual_url_suffix/tests/src/Unit/RedirectResponseAlterTest.php new file mode 100644 index 00000000..59fc5a55 --- /dev/null +++ b/modules/oe_multilingual_url_suffix/tests/src/Unit/RedirectResponseAlterTest.php @@ -0,0 +1,169 @@ +prophesize(LanguageInterface::class); + $english->getId()->willReturn('en'); + $this->languages['en'] = $english->reveal(); + + $french = $this->prophesize(LanguageInterface::class); + $french->getId()->willReturn('fr'); + $this->languages['fr'] = $french->reveal(); + + $this->languageManager = $this->prophesize(LanguageManagerInterface::class); + $this->languageManager->getLanguage('en')->willReturn($this->languages['en']); + $this->languageManager->getLanguage('fr')->willReturn($this->languages['fr']); + $this->languageManager->getLanguage(Language::LANGCODE_NOT_SPECIFIED)->willReturn(NULL); + + $suffixConfig = $this->prophesize(ImmutableConfig::class); + $suffixConfig->get('url_suffixes')->willReturn([ + 'en' => 'en', + 'fr' => 'fr', + ]); + + $this->configFactory = $this->prophesize(ConfigFactoryInterface::class); + $this->configFactory->get('oe_multilingual_url_suffix.settings')->willReturn($suffixConfig->reveal()); + + $this->hooksService = new OeMultilingualUrlSuffixHooks( + $this->languageManager->reveal(), + $this->configFactory->reveal() + ); + } + + /** + * Tests hook skips redirects without specific language or suffix. + */ + public function testHookSkipsUnspecifiedLanguageWithoutSuffix(): void { + $redirectLanguage = $this->prophesize(LanguageInterface::class); + $redirectLanguage->getId()->willReturn(Language::LANGCODE_NOT_SPECIFIED); + + $fieldItemList = new \stdClass(); + $fieldItemList->uri = 'internal:/test-page'; + + $redirect = $this->prophesize(Redirect::class); + $redirect->language()->willReturn($redirectLanguage->reveal()); + $redirect->get('redirect_redirect')->willReturn($fieldItemList); + + $originalUrl = 'http://example.com/test-page_en'; + $response = new TrustedRedirectResponse($originalUrl); + + $this->hooksService->redirectResponseAlter($response, $redirect->reveal()); + + $this->assertEquals($originalUrl, $response->getTargetUrl()); + } + + /** + * Tests hook regenerates URL when redirect language differs from current. + */ + public function testHookRegeneratesUrlWhenLanguageDiffers(): void { + $this->languageManager->getCurrentLanguage(LanguageInterface::TYPE_URL) + ->willReturn($this->languages['en']); + + $redirectLanguage = $this->prophesize(LanguageInterface::class); + $redirectLanguage->getId()->willReturn('fr'); + + $fieldItemList = new \stdClass(); + $fieldItemList->uri = 'internal:/node/1'; + + $url = $this->prophesize(Url::class); + $url->setOption('language', $this->languages['fr'])->willReturn($url->reveal()); + $url->setAbsolute()->willReturn($url->reveal()); + $url->toString()->willReturn('http://example.com/test-page_fr'); + + $redirect = $this->prophesize(Redirect::class); + $redirect->language()->willReturn($redirectLanguage->reveal()); + $redirect->get('redirect_redirect')->willReturn($fieldItemList); + $redirect->getRedirectUrl()->willReturn($url->reveal()); + + $response = new TrustedRedirectResponse('http://example.com/test-page_en'); + + $this->hooksService->redirectResponseAlter($response, $redirect->reveal()); + + $this->assertEquals('http://example.com/test-page_fr', $response->getTargetUrl()); + } + + /** + * Tests hook detects language suffix in redirect destination path. + */ + public function testHookDetectsDestinationPathSuffix(): void { + $this->languageManager->getCurrentLanguage(LanguageInterface::TYPE_URL) + ->willReturn($this->languages['en']); + + $redirectLanguage = $this->prophesize(LanguageInterface::class); + $redirectLanguage->getId()->willReturn(Language::LANGCODE_NOT_SPECIFIED); + + $fieldItemList = new \stdClass(); + $fieldItemList->uri = 'internal:/test-page_fr'; + + $url = $this->prophesize(Url::class); + $url->setOption('language', $this->languages['fr'])->willReturn($url->reveal()); + $url->setAbsolute()->willReturn($url->reveal()); + $url->toString()->willReturn('http://example.com/test-page_fr'); + + $redirect = $this->prophesize(Redirect::class); + $redirect->language()->willReturn($redirectLanguage->reveal()); + $redirect->get('redirect_redirect')->willReturn($fieldItemList); + $redirect->getRedirectUrl()->willReturn($url->reveal()); + + $response = new TrustedRedirectResponse('http://example.com/test-page_en'); + + $this->hooksService->redirectResponseAlter($response, $redirect->reveal()); + + $this->assertEquals('http://example.com/test-page_fr', $response->getTargetUrl()); + } + +}