diff --git a/language_switcher_menu.module b/language_switcher_menu.module index 5f9c35d47e1ab7d79ce8340d4233e9918a69d301..f5a95ebb319301c82311fd0d757c64357a01c67b 100644 --- a/language_switcher_menu.module +++ b/language_switcher_menu.module @@ -9,10 +9,13 @@ use Drupal\Core\Routing\RouteMatchInterface; /** * Implements hook_help(). + * + * @phpstan-param string $route_name + * @phpstan-return array<mixed> */ function language_switcher_menu_help($route_name, RouteMatchInterface $route_match) { if ($route_name !== 'help.page.language_switcher_menu') { - return; + return []; } $build = []; diff --git a/language_switcher_menu.services.yml b/language_switcher_menu.services.yml index 573f7f9f4ac93e01974b28f3f7bc976f14549144..1fd2257a5f17e82bfb85433ea735adc1114138ef 100644 --- a/language_switcher_menu.services.yml +++ b/language_switcher_menu.services.yml @@ -5,3 +5,4 @@ services: - '@access_manager' - '@current_user' - '@entity_type.manager' + - '@path.validator' diff --git a/src/Form/SettingsForm.php b/src/Form/SettingsForm.php index ac7a5ae180ffee2723dc2d6e27b380609ac78bd1..2b7a8eaf48799a7d98b9b52d0411b75706b7e8f9 100644 --- a/src/Form/SettingsForm.php +++ b/src/Form/SettingsForm.php @@ -68,6 +68,8 @@ class SettingsForm extends ConfigFormBase { /** * {@inheritdoc} + * + * @phpstan-return self */ public static function create(ContainerInterface $container) { return new static( @@ -81,6 +83,8 @@ class SettingsForm extends ConfigFormBase { /** * {@inheritdoc} + * + * @phpstan-return array<string> */ protected function getEditableConfigNames() { return [ @@ -90,6 +94,8 @@ class SettingsForm extends ConfigFormBase { /** * {@inheritdoc} + * + * @phpstan-return string */ public function getFormId() { return 'language_switcher_menu_settings_form'; @@ -97,6 +103,9 @@ class SettingsForm extends ConfigFormBase { /** * {@inheritdoc} + * + * @phpstan-param array<mixed> $form + * @phpstan-return array<mixed> */ public function buildForm(array $form, FormStateInterface $form_state) { $config = $this->config('language_switcher_menu.settings'); @@ -109,7 +118,7 @@ class SettingsForm extends ConfigFormBase { } $default = $config->get('type') ? $config->get('type') : NULL; if (empty($default) && count($configurable_types) === 1) { - $default = isset($type) ? $type : NULL; + $default = $type ?? NULL; } $form['type'] = [ '#type' => 'select', @@ -166,6 +175,9 @@ class SettingsForm extends ConfigFormBase { /** * {@inheritdoc} + * + * @phpstan-param array<mixed> $form + * @phpstan-return void */ public function submitForm(array &$form, FormStateInterface $form_state) { parent::submitForm($form, $form_state); diff --git a/src/LanguageLinkAccessMenuTreeManipulator.php b/src/LanguageLinkAccessMenuTreeManipulator.php index 6917a9c00deb018a5dfb87a18ed716d96dbd61e5..58e2294d454b358a695b6b07665bb6e0432723c4 100644 --- a/src/LanguageLinkAccessMenuTreeManipulator.php +++ b/src/LanguageLinkAccessMenuTreeManipulator.php @@ -2,38 +2,65 @@ namespace Drupal\language_switcher_menu; +use Drupal\Core\Access\AccessManagerInterface; +use Drupal\Core\Access\AccessResult; +use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Menu\DefaultMenuLinkTreeManipulators; use Drupal\Core\Menu\MenuLinkInterface; -use Drupal\Core\Access\AccessResult; +use Drupal\Core\Path\PathValidatorInterface; +use Drupal\Core\Session\AccountInterface; use Drupal\language_switcher_menu\Plugin\Menu\LanguageSwitcherLink; /** * Extends access check tree manipulator provided by Drupal Core. * - * @todo Remove once https://www.drupal.org/project/drupal/issues/3008889 has + * @todo Revisit once https://www.drupal.org/project/drupal/issues/3008889 has * been fixed. */ class LanguageLinkAccessMenuTreeManipulator extends DefaultMenuLinkTreeManipulators { + /** + * The path validator. + * + * @var \Drupal\Core\Path\PathValidatorInterface + */ + protected $pathValidator; + + /** + * Constructs a LanguageLinkAccessMenuTreeManipulator object. + * + * @param \Drupal\Core\Access\AccessManagerInterface $access_manager + * The access manager. + * @param \Drupal\Core\Session\AccountInterface $account + * The current user. + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager + * The entity type manager. + * @param \Drupal\Core\Path\PathValidatorInterface $path_validator + * The path validator. + */ + public function __construct(AccessManagerInterface $access_manager, AccountInterface $account, EntityTypeManagerInterface $entity_type_manager, PathValidatorInterface $path_validator) { + parent::__construct($access_manager, $account, $entity_type_manager); + $this->pathValidator = $path_validator; + } + /** * {@inheritdoc} */ protected function menuLinkCheckAccess(MenuLinkInterface $instance) { $access_result = parent::menuLinkCheckAccess($instance); - if (!$access_result->isNeutral()) { + if (!$instance instanceof LanguageSwitcherLink) { return $access_result; } - if ($instance->getUrlObject()->getRouteName() !== '<current>') { - return $access_result; - } - // @note Third party modules may alter language switch links and they would - // still be an instance of our class, so we check for it relatively late. - if (!$instance instanceof LanguageSwitcherLink) { - return $access_result; + if (!$instance->hasLink()) { + return $access_result->isAllowed() ? AccessResult::neutral() : $access_result; } - $has_access = $this->account->hasPermission('view language_switcher_menu links'); - return AccessResult::allowedIf($has_access)->cachePerPermissions(); + + $url = $instance->getUrlObject()->setAbsolute(TRUE)->toString(); + $validated_url = $this->pathValidator->getUrlIfValid($url); + + return AccessResult::allowedIfHasPermission($this->account, 'view language_switcher_menu links') + ->andIf(AccessResult::allowedIf($validated_url)); } } diff --git a/src/Plugin/Derivative/LanguageSwitcherLink.php b/src/Plugin/Derivative/LanguageSwitcherLink.php index afd322dccc00fadf4397c56aed1a3307687fce5e..1a941c7b39b74a9e5439097b425e62a1b66917a4 100644 --- a/src/Plugin/Derivative/LanguageSwitcherLink.php +++ b/src/Plugin/Derivative/LanguageSwitcherLink.php @@ -5,9 +5,7 @@ namespace Drupal\language_switcher_menu\Plugin\Derivative; use Drupal\Component\Plugin\Derivative\DeriverBase; use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Language\LanguageManagerInterface; -use Drupal\Core\Path\PathMatcherInterface; use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface; -use Drupal\Core\Url; use Symfony\Component\DependencyInjection\ContainerInterface; /** @@ -29,13 +27,6 @@ class LanguageSwitcherLink extends DeriverBase implements ContainerDeriverInterf */ protected $languageManager; - /** - * The path matcher. - * - * @var \Drupal\Core\Path\PathMatcherInterface - */ - protected $pathMatcher; - /** * Constructs a new LanguageSwitcherLink instance. * @@ -43,13 +34,10 @@ class LanguageSwitcherLink extends DeriverBase implements ContainerDeriverInterf * The factory for configuration objects. * @param \Drupal\Core\Language\LanguageManagerInterface $language_manager * The language manager. - * @param \Drupal\Core\Path\PathMatcherInterface $path_matcher - * The path matcher. */ - public function __construct(ConfigFactoryInterface $config_factory, LanguageManagerInterface $language_manager, PathMatcherInterface $path_matcher) { + public function __construct(ConfigFactoryInterface $config_factory, LanguageManagerInterface $language_manager) { $this->configFactory = $config_factory; $this->languageManager = $language_manager; - $this->pathMatcher = $path_matcher; } /** @@ -58,13 +46,15 @@ class LanguageSwitcherLink extends DeriverBase implements ContainerDeriverInterf public static function create(ContainerInterface $container, $base_plugin_id) { return new static( $container->get('config.factory'), - $container->get('language_manager'), - $container->get('path.matcher'), + $container->get('language_manager') ); } /** * {@inheritdoc} + * + * @phpstan-param mixed $base_plugin_definition + * @phpstan-return array<string, mixed> */ public function getDerivativeDefinitions($base_plugin_definition) { $links = []; @@ -82,12 +72,6 @@ class LanguageSwitcherLink extends DeriverBase implements ContainerDeriverInterf return $links; } - $route_name = $this->pathMatcher->isFrontPage() ? '<front>' : '<current>'; - $link_info = $this->languageManager->getLanguageSwitchLinks($config->get('type'), Url::fromRoute($route_name)); - if (!isset($link_info->links)) { - return $links; - } - $parent_config = explode(':', $config->get('parent')); $menu_name = $parent_config[0]; $parent = NULL; @@ -97,23 +81,22 @@ class LanguageSwitcherLink extends DeriverBase implements ContainerDeriverInterf } $weight = (int) $config->get('weight'); - - foreach ($link_info->links as $id => $link) { - $links[$id] = [ - 'title' => $link['title'], - 'route_name' => $link['url']->getRouteName(), - 'route_parameters' => $link['url']->getRouteParameters(), + $type = $config->get('type'); + + foreach ($this->languageManager->getLanguages() as $langcode => $language) { + $links[$langcode] = [ + 'title' => $language->getName(), + 'metadata' => [ + 'language' => $language, + 'langcode' => $langcode, + 'langtype' => $type, + ], + 'route_name' => '<current>', + 'route_parameters' => [], 'menu_name' => $menu_name, 'parent' => $parent, 'weight' => $weight, - 'options' => [ - 'language' => $link['language'], - 'set_active_class' => TRUE, - ] + (isset($link['query']) ? [ - 'query' => $link['query'], - ] : []) + (isset($link['attributes']) ? [ - 'attributes' => $link['attributes'], - ] : []), + 'options' => [], ] + $base_plugin_definition; $weight + 1; } diff --git a/src/Plugin/Menu/LanguageSwitcherLink.php b/src/Plugin/Menu/LanguageSwitcherLink.php index 005521fc56509ffd6b4db824a6519549f14776cf..1a904ce9ca1613b467660a65c62d0033a695b152 100644 --- a/src/Plugin/Menu/LanguageSwitcherLink.php +++ b/src/Plugin/Menu/LanguageSwitcherLink.php @@ -2,13 +2,188 @@ namespace Drupal\language_switcher_menu\Plugin\Menu; +use Drupal\Core\Url; +use Drupal\Core\Language\LanguageManagerInterface; use Drupal\Core\Menu\MenuLinkDefault; +use Drupal\Core\Menu\StaticMenuLinkOverridesInterface; +use Drupal\Core\Path\PathMatcherInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; /** * Represents a menu link to switch to a specific language. */ class LanguageSwitcherLink extends MenuLinkDefault { + /** + * The language manager. + * + * @var \Drupal\Core\Language\LanguageManagerInterface + */ + protected $languageManager; + + /** + * The path matcher. + * + * @var \Drupal\Core\Path\PathMatcherInterface + */ + protected $pathMatcher; + + /** + * Language switch links for active route. + * + * NULL, if never initialized. FALSE, if unsuccessfully initialized. An array + * of language switch links, if successfully initialized. + * + * @var array|null|false + * @phpstan-var array<string,mixed>|null|false + */ + protected $links = NULL; + + /** + * Constructs a new LanguageSwitcherLink. + * + * @param array $configuration + * A configuration array containing information about the plugin instance. + * @param string $plugin_id + * The plugin_id for the plugin instance. + * @param mixed $plugin_definition + * The plugin implementation definition. + * @param \Drupal\Core\Language\LanguageManagerInterface $language_manager + * The language manager. + * @param \Drupal\Core\Menu\StaticMenuLinkOverridesInterface $static_override + * The static override storage. + * @param \Drupal\Core\Path\PathMatcherInterface $path_matcher + * The path matcher. + * + * @phpstan-param array<mixed> $configuration + */ + public function __construct(array $configuration, $plugin_id, $plugin_definition, LanguageManagerInterface $language_manager, StaticMenuLinkOverridesInterface $static_override, PathMatcherInterface $path_matcher) { + parent::__construct($configuration, $plugin_id, $plugin_definition, $static_override); + $this->languageManager = $language_manager; + $this->pathMatcher = $path_matcher; + } + + /** + * {@inheritdoc} + * + * @phpstan-param array<mixed> $configuration + * @phpstan-param string $plugin_id + * @phpstan-param mixed $plugin_definition + * @phpstan-return self + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('language_manager'), + $container->get('menu_link.static.overrides'), + $container->get('path.matcher') + ); + } + + /** + * Initializes the "links" property. + * + * If the "links" property has not been initialized yet, gets links from + * language switcher and assigns them to the "links" property. + */ + protected function initLinks(): void { + if ($this->links !== NULL) { + return; + } + $route_name = $this->pathMatcher->isFrontPage() ? '<front>' : '<current>'; + $link_info = $this->languageManager->getLanguageSwitchLinks($this->getLanguageType(), Url::fromRoute($route_name)); + $this->links = $link_info->links ?? FALSE; + } + + /** + * Whether a language switch link for this menu link's language code exists. + * + * @return bool + * Whether a language switch link for this menu link's language code exists. + */ + public function hasLink(): bool { + $this->initLinks(); + return isset($this->links[$this->getLangCode()]); + } + + /** + * Gets link for language code of this language switcher menu link. + * + * @return array + * Link for language code of this language switcher menu link. + * + * @phpstan-return array<mixed> + */ + protected function getLink() { + $this->initLinks(); + return $this->hasLink() ? $this->links[$this->getLangCode()] : []; + } + + /** + * Gets the language code. + * + * @return string + * Language code. + */ + protected function getLangCode(): string { + return $this->pluginDefinition['metadata']['langcode']; + } + + /** + * Gets the language type. + * + * @return string + * Language type. + */ + protected function getLanguageType(): string { + return $this->pluginDefinition['metadata']['langtype']; + } + + /** + * {@inheritdoc} + */ + public function getTitle() { + $link = $this->getLink(); + return (string) ($link['title'] ?? parent::getTitle()); + } + + /** + * {@inheritdoc} + * + * @phpstan-return array<string, mixed> + */ + public function getOptions() { + $link = $this->getLink(); + return [ + 'language' => $link['language'] ?? NULL, + 'set_active_class' => TRUE, + ] + (isset($link['query']) ? [ + 'query' => $link['query'], + ] : []) + (isset($link['attributes']) ? [ + 'attributes' => $link['attributes'], + ] : []) + parent::getOptions(); + } + + /** + * {@inheritdoc} + */ + public function getRouteName() { + $link = $this->getLink(); + return isset($link['url']) ? $link['url']->getRouteName() : ''; + } + + /** + * {@inheritdoc} + * + * @phpstan-return array<string, mixed> + */ + public function getRouteParameters() { + $link = $this->getLink(); + return isset($link['url']) ? $link['url']->getRouteParameters() : []; + } + /** * {@inheritdoc} */ diff --git a/tests/src/Functional/LanguageSwitcherMenuTest.php b/tests/src/Functional/LanguageSwitcherMenuTest.php index 8919af96eadd9577c31783490e1060f84df5a13c..448e892477ab9b7e9990791b837e798386cd4aec 100644 --- a/tests/src/Functional/LanguageSwitcherMenuTest.php +++ b/tests/src/Functional/LanguageSwitcherMenuTest.php @@ -70,7 +70,7 @@ class LanguageSwitcherMenuTest extends BrowserTestBase { /** * Tests language switch links provided by Language Switcher Menu module. */ - public function testLanguageSwitchLinks() { + public function testLanguageSwitchLinks(): void { $this->drupalLogin($this->users['admin_user']); // Add a language. @@ -155,6 +155,8 @@ class LanguageSwitcherMenuTest extends BrowserTestBase { $this->assertSession()->statusCodeEquals(403); $this->drupalLogin($this->users['view_links']); + $this->drupalGet('/user/' . $this->users['view_links']->id()); + $this->assertSession()->statusCodeEquals(200); $this->assertMenuLinks([ '/user/' . $this->users['view_links']->id() => 'English', '/fr/user/' . $this->users['view_links']->id() => 'French', @@ -168,16 +170,16 @@ class LanguageSwitcherMenuTest extends BrowserTestBase { $this->drupalGet('user/' . $admin_id . '/edit'); $this->assertSession()->statusCodeEquals(403); $this->assertMenuLinks([ - '/system/403' => 'English', - '/fr/system/403' => 'French', + '/system/403?destination=/user/' . $admin_id . '/edit&_exception_statuscode=403' => 'English', + '/fr/system/403?destination=/user/' . $admin_id . '/edit&_exception_statuscode=403' => 'French', '/' => 'Home', ], 'The language links are visible on pages that deny access.'); // Check that links are shown in a non-standard language. $this->clickLink('French'); $this->assertMenuLinks([ - '/system/403' => 'English', - '/fr/system/403' => 'French', + '/system/403?destination=/user/' . $admin_id . '/edit&_exception_statuscode=403' => 'English', + '/fr/system/403?destination=/user/' . $admin_id . '/edit&_exception_statuscode=403' => 'French', '/fr' => 'Home', ], 'The menu links are correct in a non-standard language.'); @@ -254,7 +256,7 @@ class LanguageSwitcherMenuTest extends BrowserTestBase { * @param string $text * Assert message. */ - protected function assertMenuLinks(array $expected, string $text) { + protected function assertMenuLinks(array $expected, string $text): void { $language_switchers = $this->xpath('//nav/ul/li'); $labels = []; $urls = []; @@ -267,7 +269,13 @@ class LanguageSwitcherMenuTest extends BrowserTestBase { // through \Drupal\Core\Url to get the correct URL in use. $expected_compare = []; foreach ($expected as $url => $label) { - $url = Url::fromUserInput($url)->toString(); + $url = Url::fromUserInput($url); + $options = $url->getOptions(); + if (isset($options['query']['destination'])) { + $options['query']['destination'] = Url::fromUserInput($options['query']['destination'])->toString(); + } + $url->setOptions($options); + $url = $url->toString(); $expected_compare[$url] = $label; } $this->assertSame(array_keys($expected_compare), $urls, $text);