Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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().
Expand All @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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().
*
Expand All @@ -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);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
<?php

declare(strict_types=1);

namespace Drupal\Tests\oe_multilingual_url_suffix\Functional;

use Drupal\Core\Language\Language;
use Drupal\node\Entity\Node;
use Drupal\redirect\Entity\Redirect;
use Drupal\Tests\BrowserTestBase;

/**
* Tests redirect integration with URL suffix language negotiation.
*
* @group oe_multilingual_url_suffix
*/
class RedirectLanguageSuffixTest extends BrowserTestBase {

/**
* {@inheritdoc}
*/
protected static $modules = [
'language',
'node',
'path',
'path_alias',
'redirect',
'oe_multilingual_url_suffix',
'content_translation',
];

/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';

/**
* A test node with translations.
*
* @var \Drupal\node\NodeInterface
*/
protected $node;

/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();

// Create an Article node type.
$this->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');
}

}
Loading