[BUGFIX] Fix fallback language handling 76/59676/36
authorAndreas Fernandez <a.fernandez@scripting-base.de>
Sat, 9 Feb 2019 17:20:56 +0000 (18:20 +0100)
committerBenni Mack <benni@typo3.org>
Mon, 4 Mar 2019 15:25:39 +0000 (16:25 +0100)
This commit solves some issues regarding language fallback handling:

- Resolve correct page for a localized variant respecting fallbacks
  The Page Router now respects the configured language fallback chain
  and tries to find a matching page candidate per language.

- Metadata of page (e.g. page title)
  TSFE now respects the reconfigured language content id in case the
  language fallback is active and resolves the correct data.

- Respect existing localizations in menu rendering
  PageRepository, used by the menu, now respects the language fallback
  chain and finds suitable localized pages.
  However, this does not resolve all issues with shortcut pages.

Resolves: #81657
Resolves: #86595
Resolves: #19114
Releases: master, 9.5
Change-Id: Ic2b302989449ec14e7e6b5c54819870770655da9
Reviewed-on: https://review.typo3.org/c/59676
Tested-by: TYPO3com <noreply@typo3.com>
Tested-by: Ralf Merz <mail@merzilla.de>
Tested-by: Anja Leichsenring <aleichsenring@ab-softlab.de>
Tested-by: Benni Mack <benni@typo3.org>
Reviewed-by: Ralf Merz <mail@merzilla.de>
Reviewed-by: Anja Leichsenring <aleichsenring@ab-softlab.de>
Reviewed-by: Benni Mack <benni@typo3.org>
19 files changed:
typo3/sysext/core/Classes/Routing/PageRouter.php
typo3/sysext/frontend/Classes/ContentObject/Menu/AbstractMenuContentObject.php
typo3/sysext/frontend/Classes/Controller/TypoScriptFrontendController.php
typo3/sysext/frontend/Classes/Page/PageRepository.php
typo3/sysext/frontend/Classes/Typolink/PageLinkBuilder.php
typo3/sysext/frontend/Tests/Functional/Rendering/LocalizedContentRenderingTest.php
typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/AbstractLocalizedPagesTestCase.php [new file with mode: 0644]
typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/Fixtures/ScenarioA.yaml [new file with mode: 0644]
typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/Fixtures/ScenarioB.yaml [new file with mode: 0644]
typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/Fixtures/ScenarioC.yaml [new file with mode: 0644]
typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/Fixtures/ScenarioD.yaml [new file with mode: 0644]
typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/Fixtures/ScenarioE.yaml [new file with mode: 0644]
typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/Fixtures/ScenarioF.yaml [new file with mode: 0644]
typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/ScenarioATest.php [new file with mode: 0644]
typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/ScenarioBTest.php [new file with mode: 0644]
typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/ScenarioCTest.php [new file with mode: 0644]
typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/ScenarioDTest.php [new file with mode: 0644]
typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/ScenarioETest.php [new file with mode: 0644]
typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/ScenarioFTest.php [new file with mode: 0644]

index f6e0a9f..4e3afb6 100644 (file)
@@ -24,7 +24,7 @@ use Symfony\Component\Routing\Exception\ResourceNotFoundException;
 use Symfony\Component\Routing\Generator\UrlGenerator;
 use Symfony\Component\Routing\RequestContext;
 use TYPO3\CMS\Core\Context\Context;
-use TYPO3\CMS\Core\Context\LanguageAspect;
+use TYPO3\CMS\Core\Context\LanguageAspectFactory;
 use TYPO3\CMS\Core\Database\ConnectionPool;
 use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
 use TYPO3\CMS\Core\Database\Query\Restriction\FrontendWorkspaceRestriction;
@@ -114,9 +114,35 @@ class PageRouter implements RouterInterface
     public function matchRequest(ServerRequestInterface $request, RouteResultInterface $previousResult = null): RouteResultInterface
     {
         $urlPath = $previousResult->getTail();
+        $prefixedUrlPath = '/' . trim($urlPath, '/');
         $slugCandidates = $this->getCandidateSlugsFromRoutePath($urlPath ?: '/');
+        $pageCandidates = [];
         $language = $previousResult->getLanguage();
-        $pageCandidates = $this->getPagesFromDatabaseForCandidates($slugCandidates, $language->getLanguageId());
+        $languages = [$language->getLanguageId()];
+        if ($language->getFallbackType() === 'fallback') {
+            $languages = array_merge($languages, $language->getFallbackLanguageIds());
+        }
+        // Iterate all defined languages in their configured order to get matching page candidates somewhere in the language fallback chain
+        foreach ($languages as $languageId) {
+            $pageCandidatesFromSlugsAndLanguage = $this->getPagesFromDatabaseForCandidates($slugCandidates, $languageId);
+            // Determine whether fetched page candidates qualify for the request. The incoming URL is checked against all
+            // pages found for the current URL and language.
+            foreach ($pageCandidatesFromSlugsAndLanguage as $candidate) {
+                $slugCandidate = '/' . trim($candidate['slug'], '/');
+                if ($slugCandidate === '/' || strpos($prefixedUrlPath, $slugCandidate) === 0) {
+                    // The slug is a subpart of the requested URL, so it's a possible candidate
+                    if ($prefixedUrlPath === $slugCandidate) {
+                        // The requested URL matches exactly the found slug. We can't find a better match,
+                        // so use that page candidate and stop any further querying.
+                        $pageCandidates = [$candidate];
+                        break 2;
+                    }
+
+                    $pageCandidates[] = $candidate;
+                }
+            }
+        }
+
         // Stop if there are no candidates
         if (empty($pageCandidates)) {
             throw new RouteNotFoundException('No page candidates found for path "' . $urlPath . '"', 1538389999);
@@ -152,7 +178,7 @@ class PageRouter implements RouterInterface
 
         $matcher = new PageUriMatcher($fullCollection);
         try {
-            $result = $matcher->match('/' . trim($urlPath, '/'));
+            $result = $matcher->match($prefixedUrlPath);
             /** @var Route $matchedRoute */
             $matchedRoute = $fullCollection->get($result['_route']);
             return $this->buildPageArguments($matchedRoute, $result, $request->getQueryParams());
@@ -195,7 +221,7 @@ class PageRouter implements RouterInterface
         }
 
         $context = clone GeneralUtility::makeInstance(Context::class);
-        $context->setAspect('language', new LanguageAspect($language->getLanguageId()));
+        $context->setAspect('language', LanguageAspectFactory::createFromSiteLanguage($language));
         $pageRepository = GeneralUtility::makeInstance(PageRepository::class, $context);
         $page = $pageRepository->getPage($pageId, true);
         $pagePath = ltrim($page['slug'] ?? '', '/');
index 3d6da97..82ce339 100644 (file)
@@ -20,6 +20,7 @@ use TYPO3\CMS\Core\Context\LanguageAspect;
 use TYPO3\CMS\Core\Database\ConnectionPool;
 use TYPO3\CMS\Core\Database\RelationHandler;
 use TYPO3\CMS\Core\Routing\SiteMatcher;
+use TYPO3\CMS\Core\Site\Entity\Site;
 use TYPO3\CMS\Core\Site\Entity\SiteInterface;
 use TYPO3\CMS\Core\TimeTracker\TimeTracker;
 use TYPO3\CMS\Core\TypoScript\TemplateService;
@@ -604,12 +605,8 @@ abstract class AbstractMenuContentObject
 
         if ($specialValue === 'auto') {
             $site = $this->getCurrentSite();
-            $languageItems = [];
             $languages = $site->getLanguages();
-
-            foreach ($languages as $languageUid => $language) {
-                $languageItems[] = $languageUid;
-            }
+            $languageItems = array_keys($languages);
         } else {
             $languageItems = GeneralUtility::intExplode(',', $specialValue);
         }
@@ -1230,35 +1227,20 @@ abstract class AbstractMenuContentObject
             && !GeneralUtility::inList($this->doktypeExcludeList, $data['doktype']) // Page may not be 'not_in_menu' or 'Backend User Section'
             && !in_array($data['uid'], $banUidArray, false) // not in banned uid's
         ) {
-            // Checks if the default language version can be shown:
-            // Block page is set, if l18n_cfg allows plus: 1) Either default language or 2) another language but NO overlay record set for page!
-            $languageId = $this->getCurrentLanguageAspect()->getId();
-            $blockPage = GeneralUtility::hideIfDefaultLanguage($data['l18n_cfg']) && (!$languageId || $languageId && !$data['_PAGES_OVERLAY']);
-            if (!$blockPage) {
-                // Checking if a page should be shown in the menu depending on whether a translation exists:
-                $tok = true;
-                // There is an alternative language active AND the current page requires a translation:
-                if ($languageId && GeneralUtility::hideIfNotTranslated($data['l18n_cfg'])) {
-                    if (!$data['_PAGES_OVERLAY']) {
-                        $tok = false;
-                    }
-                }
-                // Continue if token is TRUE:
-                if ($tok) {
-                    // Checking if "&L" should be modified so links to non-accessible pages will not happen.
-                    if ($this->conf['protectLvar']) {
-                        if ($languageId && ($this->conf['protectLvar'] === 'all' || GeneralUtility::hideIfNotTranslated($data['l18n_cfg']))) {
-                            $tsfe = $this->getTypoScriptFrontendController();
-                            $olRec = $tsfe->sys_page->getPageOverlay($data['uid'], $languageId);
-                            if (empty($olRec)) {
-                                // If no page translation record then page can NOT be accessed in
-                                // the language pointed to by "&L" and therefore we protect the link by setting "&L=0"
-                                $data['_ADD_GETVARS'] .= '&L=0';
-                            }
+            // Checking if a page should be shown in the menu depending on whether a translation exists or if the default language is disabled
+            if ($this->sys_page->isPageSuitableForLanguage($data, $this->getCurrentLanguageAspect())) {
+                // Checking if "&L" should be modified so links to non-accessible pages will not happen.
+                if ($this->getCurrentLanguageAspect()->getId() > 0 && $this->conf['protectLvar']) {
+                    if ($this->conf['protectLvar'] === 'all' || GeneralUtility::hideIfNotTranslated($data['l18n_cfg'])) {
+                        $olRec = $this->sys_page->getPageOverlay($data['uid'], $this->getCurrentLanguageAspect()->getId());
+                        if (empty($olRec)) {
+                            // If no page translation record then page can NOT be accessed in
+                            // the language pointed to by "&L" and therefore we protect the link by setting "&L=0"
+                            $data['_ADD_GETVARS'] .= '&L=0';
                         }
                     }
-                    return true;
                 }
+                return true;
             }
         }
         return false;
@@ -1619,6 +1601,7 @@ abstract class AbstractMenuContentObject
             // Only setting url, not target
             $LD['totalURL'] = $this->parent_cObj->typoLink_URL([
                 'parameter' => $shortcut['uid'],
+                'language' => 'current',
                 'additionalParams' => $addParams . $this->I['val']['additionalParams'] . $menuItem['_ADD_GETVARS'],
                 'linkAccessRestrictedPages' => !empty($this->mconf['showAccessRestrictedPages'])
             ]);
@@ -2096,6 +2079,13 @@ abstract class AbstractMenuContentObject
         if ($addParams) {
             $conf['additionalParams'] = $addParams;
         }
+
+        // Ensure that the typolink gets an info which language was actually requested. The $page record could be the record
+        // from page translation language=1 as fallback but page translation language=2 was requested. Search for
+        // "_PAGES_OVERLAY_REQUESTEDLANGUAGE" for more details
+        if ($page['_PAGES_OVERLAY_REQUESTEDLANGUAGE'] ?? 0) {
+            $conf['language'] = $page['_PAGES_OVERLAY_REQUESTEDLANGUAGE'];
+        }
         if ($no_cache) {
             $conf['no_cache'] = true;
         } elseif ($this->useCacheHash) {
index ad341b3..175c482 100644 (file)
@@ -2047,9 +2047,6 @@ class TypoScriptFrontendController implements LoggerAwareInterface
                         // Default is that everything defaults to the default language...
                         $languageId = ($languageContentId = 0);
                 }
-            } else {
-                // Setting sys_language if an overlay record was found (which it is only if a language is used)
-                $this->page = $this->sys_page->getPageOverlay($this->page, $languageAspect->getId());
             }
 
             // Define the language aspect again now
@@ -2060,6 +2057,11 @@ class TypoScriptFrontendController implements LoggerAwareInterface
                 $languageAspect->getOverlayType(),
                 $languageAspect->getFallbackChain()
             );
+
+            // Setting sys_language if an overlay record was found (which it is only if a language is used)
+            // We'll do this every time since the language aspect might have changed now
+            // Doing this ensures that page properties like the page title are returned in the correct language
+            $this->page = $this->sys_page->getPageOverlay($this->page, $languageAspect->getContentId());
         }
 
         // Set the language aspect
index 09815fb..439b6ee 100644 (file)
@@ -30,6 +30,7 @@ use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer;
 use TYPO3\CMS\Core\Database\Query\Restriction\FrontendWorkspaceRestriction;
 use TYPO3\CMS\Core\Error\Http\ShortcutTargetPageNotFoundException;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Core\Utility\MathUtility;
 use TYPO3\CMS\Core\Versioning\VersionState;
 
 /**
@@ -91,6 +92,7 @@ class PageRepository implements LoggerAwareInterface
         '_PAGES_OVERLAY',
         '_PAGES_OVERLAY_UID',
         '_PAGES_OVERLAY_LANGUAGE',
+        '_PAGES_OVERLAY_REQUESTEDLANGUAGE',
     ];
 
     /**
@@ -378,7 +380,6 @@ class PageRepository implements LoggerAwareInterface
         if (empty($pagesInput)) {
             return [];
         }
-        // Initialize:
         if ($languageUid === null) {
             $languageUid = $this->sys_language_uid;
         }
@@ -392,59 +393,25 @@ class PageRepository implements LoggerAwareInterface
             }
         }
         unset($origPage);
+
+        $overlays = [];
         // If language UID is different from zero, do overlay:
         if ($languageUid) {
-            $page_ids = [];
+            $languageUids = array_merge([$languageUid], $this->getLanguageFallbackChain(null));
 
-            $origPage = reset($pagesInput);
+            $pageIds = [];
             foreach ($pagesInput as $origPage) {
                 if (is_array($origPage)) {
                     // Was the whole record
-                    $page_ids[] = $origPage['uid'];
+                    $pageIds[] = (int)$origPage['uid'];
                 } else {
                     // Was the id
-                    $page_ids[] = $origPage;
-                }
-            }
-            // NOTE regarding the query restrictions
-            // Currently the showHiddenRecords of TSFE set will allow
-            // page translation records to be selected as they are
-            // child-records of a page.
-            // However you may argue that the showHiddenField flag should
-            // determine this. But that's not how it's done right now.
-            // Selecting overlay record:
-            $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
-                ->getQueryBuilderForTable('pages');
-            $queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class));
-            $result = $queryBuilder->select('*')
-                ->from('pages')
-                ->where(
-                    $queryBuilder->expr()->in(
-                        $GLOBALS['TCA']['pages']['ctrl']['transOrigPointerField'],
-                        $queryBuilder->createNamedParameter($page_ids, Connection::PARAM_INT_ARRAY)
-                    ),
-                    $queryBuilder->expr()->eq(
-                        $GLOBALS['TCA']['pages']['ctrl']['languageField'],
-                        $queryBuilder->createNamedParameter($languageUid, \PDO::PARAM_INT)
-                    )
-                )
-                ->execute();
-
-            $overlays = [];
-            while ($row = $result->fetch()) {
-                $this->versionOL('pages', $row);
-                if (is_array($row)) {
-                    $row['_PAGES_OVERLAY'] = true;
-                    $row['_PAGES_OVERLAY_UID'] = $row['uid'];
-                    $row['_PAGES_OVERLAY_LANGUAGE'] = $languageUid;
-                    $origUid = $row[$GLOBALS['TCA']['pages']['ctrl']['transOrigPointerField']];
-                    // Unset vital fields that are NOT allowed to be overlaid:
-                    unset($row['uid']);
-                    unset($row['pid']);
-                    $overlays[$origUid] = $row;
+                    $pageIds[] = (int)$origPage;
                 }
             }
+            $overlays = $this->getPageOverlaysForLanguageUids($pageIds, $languageUids);
         }
+
         // Create output:
         $pagesOutput = [];
         foreach ($pagesInput as $key => $origPage) {
@@ -468,6 +435,118 @@ class PageRepository implements LoggerAwareInterface
     }
 
     /**
+     * Checks whether the passed (translated or default language) page is accessible with the given language settings.
+     *
+     * @param array $page the page translation record or the page in the default language
+     * @param LanguageAspect $languageAspect
+     * @return bool true if the given page translation record is suited for the given language ID
+     * @internal only in use for HMENU generation for now
+     */
+    public function isPageSuitableForLanguage(array $page, LanguageAspect $languageAspect): bool
+    {
+        $languageUid = $languageAspect->getId();
+        // Checks if the default language version can be shown
+        // Block page is set, if l18n_cfg allows plus: 1) Either default language or 2) another language but NO overlay record set for page!
+        if (GeneralUtility::hideIfDefaultLanguage($page['l18n_cfg']) && (!$languageUid || $languageUid && !$page['_PAGES_OVERLAY'])) {
+            return false;
+        }
+        if ($languageUid > 0 && GeneralUtility::hideIfNotTranslated($page['l18n_cfg'])) {
+            if (!$page['_PAGES_OVERLAY'] || (int)$page['_PAGES_OVERLAY_LANGUAGE'] !== $languageUid) {
+                return false;
+            }
+        } elseif ($languageUid > 0) {
+            $languageUids = array_merge([$languageUid], $this->getLanguageFallbackChain($languageAspect));
+            return in_array((int)$page['sys_language_uid'], $languageUids, true);
+        }
+        return true;
+    }
+
+    /**
+     * Returns the cleaned fallback chain from the current language aspect, if there is one.
+     *
+     * @param LanguageAspect|null $languageAspect
+     * @return int[]
+     */
+    protected function getLanguageFallbackChain(?LanguageAspect $languageAspect): array
+    {
+        $languageAspect = $languageAspect ?? $this->context->getAspect('language');
+        return array_filter($languageAspect->getFallbackChain(), function ($item) {
+            return MathUtility::canBeInterpretedAsInteger($item);
+        });
+    }
+
+    /**
+     * Returns the first match of overlays for pages in the passed languages.
+     *
+     * NOTE regarding the query restrictions:
+     * Currently the visibility aspect within the FrontendRestrictionContainer will allow
+     * page translation records to be selected as they are child-records of a page.
+     * However you may argue that the visibility flag should determine this.
+     * But that's not how it's done right now.
+     *
+     * @param array $pageUids
+     * @param array $languageUids uid of sys_language, please note that the order is important here.
+     * @return array
+     */
+    protected function getPageOverlaysForLanguageUids(array $pageUids, array $languageUids): array
+    {
+        // Remove default language ("0")
+        $languageUids = array_filter($languageUids);
+        $languageField = $GLOBALS['TCA']['pages']['ctrl']['languageField'];
+        $overlays = [];
+
+        foreach ($pageUids as $pageId) {
+            // Create a map based on the order of values in $languageUids. Those entries reflect the order of the language + fallback chain.
+            // We can't work with database ordering since there is no common SQL clause to order by e.g. [7, 1, 2].
+            $orderedListByLanguages = array_flip($languageUids);
+
+            $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages');
+            $queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class));
+            $result = $queryBuilder->select('*')
+                ->from('pages')
+                ->where(
+                    $queryBuilder->expr()->eq(
+                        $GLOBALS['TCA']['pages']['ctrl']['transOrigPointerField'],
+                        $queryBuilder->createNamedParameter($pageId, \PDO::PARAM_INT)
+                    ),
+                    $queryBuilder->expr()->in(
+                        $GLOBALS['TCA']['pages']['ctrl']['languageField'],
+                        $queryBuilder->createNamedParameter($languageUids, Connection::PARAM_INT_ARRAY)
+                    )
+                )
+                ->execute();
+
+            // Create a list of rows ordered by values in $languageUids
+            while ($row = $result->fetch()) {
+                $orderedListByLanguages[$row[$languageField]] = $row;
+            }
+
+            foreach ($orderedListByLanguages as $languageUid => $row) {
+                if (!is_array($row)) {
+                    continue;
+                }
+
+                // Found a result for the current language id
+                $this->versionOL('pages', $row);
+                if (is_array($row)) {
+                    $row['_PAGES_OVERLAY'] = true;
+                    $row['_PAGES_OVERLAY_UID'] = $row['uid'];
+                    $row['_PAGES_OVERLAY_LANGUAGE'] = $languageUid;
+                    $row['_PAGES_OVERLAY_REQUESTEDLANGUAGE'] = $languageUids[0];
+                    // Unset vital fields that are NOT allowed to be overlaid:
+                    unset($row['uid'], $row['pid']);
+                    $overlays[$pageId] = $row;
+
+                    // Language fallback found, stop querying further languages
+                    break;
+                }
+            }
+        }
+
+        return $overlays;
+    }
+
+    /**
      * Creates language-overlay for records in general (where translation is found
      * in records from the same table)
      *
index 63d32ec..e17f832 100644 (file)
@@ -20,6 +20,7 @@ use Psr\Http\Message\UriInterface;
 use TYPO3\CMS\Core\Cache\CacheManager;
 use TYPO3\CMS\Core\Context\Context;
 use TYPO3\CMS\Core\Context\LanguageAspect;
+use TYPO3\CMS\Core\Context\LanguageAspectFactory;
 use TYPO3\CMS\Core\Database\ConnectionPool;
 use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
 use TYPO3\CMS\Core\Exception\Page\RootLineException;
@@ -176,11 +177,6 @@ class PageLinkBuilder extends AbstractTypolinkBuilder
             $conf['language'] = (int)$queryParameters['L'];
         }
 
-        // Now overlay the page in the target language, in order to have valid title attributes etc.
-        if (isset($conf['language']) && $conf['language'] > 0 && $conf['language'] !== 'current') {
-            $page = $tsfe->sys_page->getPageOverlay($page, (int)$conf['language']);
-        }
-
         // Check if the target page has a site configuration
         try {
             $siteOfTargetPage = GeneralUtility::makeInstance(SiteMatcher::class)->matchByPageId((int)$page['uid']);
@@ -193,6 +189,28 @@ class PageLinkBuilder extends AbstractTypolinkBuilder
 
         // Link to a page that has a site configuration
         if ($siteOfTargetPage instanceof Site) {
+            $siteLanguageOfTargetPage = $this->getSiteLanguageOfTargetPage($siteOfTargetPage, (string)($conf['language'] ?? 'current'));
+            $languageAspect = LanguageAspectFactory::createFromSiteLanguage($siteLanguageOfTargetPage);
+
+            // Now overlay the page in the target language, in order to have valid title attributes etc.
+            if ($siteLanguageOfTargetPage->getLanguageId() > 0) {
+                $context = clone GeneralUtility::makeInstance(Context::class);
+                $context->setAspect('language', $languageAspect);
+                $pageRepository = GeneralUtility::makeInstance(PageRepository::class, $context);
+                $page = $pageRepository->getPageOverlay($page);
+            }
+            // Check if the target page can be access depending on l18n_cfg
+            if (!$tsfe->sys_page->isPageSuitableForLanguage($page, $languageAspect)) {
+                $languageField = $GLOBALS['TCA']['pages']['ctrl']['languageField'] ?? null;
+                $languageOfPageRecord = (int)($page[$languageField] ?? 0);
+                if ($languageOfPageRecord === 0 && GeneralUtility::hideIfDefaultLanguage($page['l18n_cfg'])) {
+                    throw new UnableToLinkException('Default language of page  "' . $linkDetails['typoLinkParameter'] . '" is hidden, so "' . $linkText . '" was not linked.', 1551621985, null, $linkText);
+                }
+                if ($languageOfPageRecord > 0 && !isset($page['_PAGES_OVERLAY']) && GeneralUtility::hideIfNotTranslated($page['l18n_cfg'])) {
+                    throw new UnableToLinkException('Fallback to default language of page "' . $linkDetails['typoLinkParameter'] . '" is disabled, so "' . $linkText . '" was not linked.', 1551621996, null, $linkText);
+                }
+            }
+
             // No need for any L parameter with Site handling
             unset($queryParameters['L']);
             if ($pageType) {
@@ -223,6 +241,19 @@ class PageLinkBuilder extends AbstractTypolinkBuilder
                 }
             }
         } else {
+            // Now overlay the page in the target language, in order to have valid title attributes etc.
+            if (isset($conf['language']) && $conf['language'] > 0 && $conf['language'] !== 'current') {
+                $page = $tsfe->sys_page->getPageOverlay($page, (int)$conf['language']);
+            }
+            $languageField = $GLOBALS['TCA']['pages']['ctrl']['languageField'] ?? null;
+            $languageOfPageRecord = (int)($page[$languageField] ?? 0);
+            if ($languageOfPageRecord === 0 && GeneralUtility::hideIfDefaultLanguage($page['l18n_cfg'])) {
+                throw new UnableToLinkException('Default language of page  "' . $linkDetails['typoLinkParameter'] . '" is hidden, so "' . $linkText . '" was not linked.', 1529527301, null, $linkText);
+            }
+            if ($languageOfPageRecord > 0 && !isset($page['_PAGES_OVERLAY']) && GeneralUtility::hideIfNotTranslated($page['l18n_cfg'])) {
+                throw new UnableToLinkException('Fallback to default language of page "' . $linkDetails['typoLinkParameter'] . '" is disabled, so "' . $linkText . '" was not linked.', 1529527488, null, $linkText);
+            }
+
             // If the typolink.language parameter was set, ensure that this is added to L query parameter
             if (!isset($queryParameters['L']) && MathUtility::canBeInterpretedAsInteger($conf['language'] ?? false)) {
                 $queryParameters['L'] = $conf['language'];
@@ -230,16 +261,6 @@ class PageLinkBuilder extends AbstractTypolinkBuilder
             list($url, $target) = $this->generateUrlForPageWithoutSiteConfiguration($page, $queryParameters, $conf, $pageType, $sectionMark, $target, $MPvarAcc);
         }
 
-        $languageField = $GLOBALS['TCA']['pages']['ctrl']['languageField'] ?? null;
-        $languageOfPageRecord = (int)($page[$languageField] ?? 0);
-
-        if ($languageOfPageRecord === 0 && GeneralUtility::hideIfDefaultLanguage($page['l18n_cfg'])) {
-            throw new UnableToLinkException('Default language of page  "' . $linkDetails['typoLinkParameter'] . '" is hidden, so "' . $linkText . '" was not linked.', 1529527301, null, $linkText);
-        }
-        if ($languageOfPageRecord > 0 && !isset($page['_PAGES_OVERLAY']) && GeneralUtility::hideIfNotTranslated($page['l18n_cfg'])) {
-            throw new UnableToLinkException('Fallback to default language of page "' . $linkDetails['typoLinkParameter'] . '" is disabled, so "' . $linkText . '" was not linked.', 1529527488, null, $linkText);
-        }
-
         // If link is to an access restricted page which should be redirected, then find new URL:
         if (empty($conf['linkAccessRestrictedPages'])
             && $tsfe->config['config']['typolinkLinkAccessRestrictedPages']
@@ -315,17 +336,14 @@ class PageLinkBuilder extends AbstractTypolinkBuilder
     }
 
     /**
-     * Create a UriInterface object when linking to a page with a site configuration
+     * Fetches the requested language of a site that the link should be built for
      *
-     * @param array $page
      * @param Site $siteOfTargetPage
-     * @param array $queryParameters
-     * @param string $fragment
-     * @param array $conf
-     * @return UriInterface
+     * @param string $targetLanguageId "current" or the languageId
+     * @return SiteLanguage
      * @throws UnableToLinkException
      */
-    protected function generateUrlForPageWithSiteConfiguration(array $page, Site $siteOfTargetPage, array $queryParameters, string $fragment, array $conf): UriInterface
+    protected function getSiteLanguageOfTargetPage(Site $siteOfTargetPage, string $targetLanguageId): SiteLanguage
     {
         $currentSite = $this->getCurrentSite();
         $currentSiteLanguage = $this->getCurrentSiteLanguage();
@@ -335,7 +353,6 @@ class PageLinkBuilder extends AbstractTypolinkBuilder
             $currentSiteLanguage = $currentSite->getDefaultLanguage();
         }
 
-        $targetLanguageId = $conf['language'] ?? 'current';
         if ($targetLanguageId === 'current') {
             $targetLanguageId = $currentSiteLanguage ? $currentSiteLanguage->getLanguageId() : 0;
         } else {
@@ -346,6 +363,31 @@ class PageLinkBuilder extends AbstractTypolinkBuilder
         } catch (\InvalidArgumentException $e) {
             throw new UnableToLinkException('The target page does not have a language with ID ' . $targetLanguageId . ' configured in its site configuration.', 1535477406);
         }
+        return $siteLanguageOfTargetPage;
+    }
+
+    /**
+     * Create a UriInterface object when linking to a page with a site configuration
+     *
+     * @param array $page
+     * @param Site $siteOfTargetPage
+     * @param array $queryParameters
+     * @param string $fragment
+     * @param array $conf
+     * @return UriInterface
+     * @throws UnableToLinkException
+     */
+    protected function generateUrlForPageWithSiteConfiguration(array $page, Site $siteOfTargetPage, array $queryParameters, string $fragment, array $conf): UriInterface
+    {
+        $currentSite = $this->getCurrentSite();
+        $currentSiteLanguage = $this->getCurrentSiteLanguage();
+        // Happens when currently on a pseudo-site configuration
+        // We assume to use the default language then
+        if ($currentSite && !($currentSiteLanguage instanceof SiteLanguage)) {
+            $currentSiteLanguage = $currentSite->getDefaultLanguage();
+        }
+
+        $siteLanguageOfTargetPage = $this->getSiteLanguageOfTargetPage($siteOfTargetPage, (string)($conf['language'] ?? 'current'));
 
         // By default, it is assumed to ab an internal link or current domain's linking scheme should be used
         // Use the config option to override this.
index 3e1dab5..f00819f 100644 (file)
@@ -721,7 +721,7 @@ class LocalizedContentRenderingTest extends \TYPO3\CMS\Core\Tests\Functional\Dat
                         'image' => [],
                     ],
                 ],
-                'pageTitle' => 'Default language Page', //TODO: change it to "[DK]Page" once #81657 is fixed
+                'pageTitle' => '[DK]Page',
                 'sys_language_uid' => 2,
                 'sys_language_content' => 1,
                 'sys_language_mode' => 'content_fallback',
@@ -823,7 +823,7 @@ class LocalizedContentRenderingTest extends \TYPO3\CMS\Core\Tests\Functional\Dat
                         'image' => ['[Kasper] Image translated to Dansk', '[T3BOARD] Image added in Dansk (without parent)'],
                     ],
                 ],
-                'pageTitle' => 'Default language Page', //TODO: change it to "[DK]Page" once #81657 is fixed
+                'pageTitle' => '[DK]Page',
                 'sys_language_uid' => 2,
                 'sys_language_content' => 1,
                 'sys_language_mode' => 'content_fallback',
@@ -922,7 +922,7 @@ class LocalizedContentRenderingTest extends \TYPO3\CMS\Core\Tests\Functional\Dat
                         'image' => ['[Kasper] Image translated to Dansk', '[T3BOARD] Image added in Dansk (without parent)'],
                     ],
                 ],
-                'pageTitle' => 'Default language Page', //TODO: change it to "[DK]Page" once #81657 is fixed
+                'pageTitle' => '[DK]Page',
                 'sys_language_uid' => 2,
                 'sys_language_content' => 1,
                 'sys_language_mode' => 'content_fallback',
diff --git a/typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/AbstractLocalizedPagesTestCase.php b/typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/AbstractLocalizedPagesTestCase.php
new file mode 100644 (file)
index 0000000..30e478c
--- /dev/null
@@ -0,0 +1,157 @@
+<?php
+declare(strict_types = 1);
+
+/*
+ * This file is part of TYPO3 GmbHs software toolkit.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Frontend\Tests\Functional\SiteHandling\LocalizedPageRendering;
+
+use TYPO3\CMS\Core\Core\Bootstrap;
+use TYPO3\CMS\Frontend\Tests\Functional\SiteHandling\AbstractTestCase;
+use TYPO3\TestingFramework\Core\Functional\Framework\DataHandling\Scenario\DataHandlerFactory;
+use TYPO3\TestingFramework\Core\Functional\Framework\DataHandling\Scenario\DataHandlerWriter;
+use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalRequest;
+use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalRequestContext;
+use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\ResponseContent;
+
+abstract class AbstractLocalizedPagesTestCase extends AbstractTestCase
+{
+    protected const LANGUAGE_PRESETS = [
+        'EN' => ['id' => 0, 'title' => 'English', 'locale' => 'en_US.UTF8', 'iso' => 'en', 'hrefLang' => 'en-US', 'direction' => ''],
+        'DE' => ['id' => 1, 'title' => 'German', 'locale' => 'de_DE.UTF8', 'iso' => 'de', 'hrefLang' => 'de-DE', 'direction' => ''],
+        'DE-CH' => ['id' => 2, 'title' => 'Swiss German', 'locale' => 'de_CH.UTF8', 'iso' => 'de', 'hrefLang' => 'de-CH', 'direction' => ''],
+    ];
+
+    /**
+     * @var string
+     */
+    protected $siteTitle = 'A Company that Manufactures Everything Inc';
+
+    /**
+     * @var InternalRequestContext
+     */
+    private $internalRequestContext;
+
+    public static function setUpBeforeClass(): void
+    {
+        parent::setUpBeforeClass();
+        static::initializeDatabaseSnapshot();
+    }
+
+    public static function tearDownAfterClass(): void
+    {
+        static::destroyDatabaseSnapshot();
+        parent::tearDownAfterClass();
+    }
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        // these settings are forwarded to the frontend sub-request as well
+        $this->internalRequestContext = (new InternalRequestContext())
+            ->withGlobalSettings(['TYPO3_CONF_VARS' => static::TYPO3_CONF_VARS]);
+    }
+
+    protected function setUpDatabaseWithYamlPayload(string $pathToYamlFile): void
+    {
+        $this->withDatabaseSnapshot(function () use ($pathToYamlFile) {
+            $backendUser = $this->setUpBackendUserFromFixture(1);
+            Bootstrap::initializeLanguageObject();
+
+            $factory = DataHandlerFactory::fromYamlFile($pathToYamlFile);
+            $writer = DataHandlerWriter::withBackendUser($backendUser);
+            $writer->invokeFactory($factory);
+            static::failIfArrayIsNotEmpty(
+                $writer->getErrors()
+            );
+        });
+    }
+
+    protected function tearDown(): void
+    {
+        unset($this->internalRequestContext);
+        parent::tearDown();
+    }
+
+    /**
+     * @param string $url
+     * @param array $scopes
+     */
+    protected function assertScopes(string $url, array $scopes): void
+    {
+        $this->setUpFrontendRootPage(
+            1000,
+            ['typo3/sysext/core/Tests/Functional/Fixtures/Frontend/JsonRenderer.typoscript'],
+            [
+                'title' => 'ACME Root',
+                'sitetitle' => $this->siteTitle,
+            ]
+        );
+
+        $response = $this->executeFrontendRequest(new InternalRequest($url), $this->internalRequestContext);
+        $responseStructure = ResponseContent::fromString((string)$response->getBody());
+
+        foreach ($scopes as $scopePath => $expectedScopeValue) {
+            static::assertSame($expectedScopeValue, $responseStructure->getScopePath($scopePath));
+        }
+    }
+
+    /**
+     * @param string $url
+     * @param string $exception
+     */
+    protected function assertException(string $url, string $exception): void
+    {
+        $this->setUpFrontendRootPage(
+            1000,
+            ['typo3/sysext/core/Tests/Functional/Fixtures/Frontend/JsonRenderer.typoscript']
+        );
+
+        $this->expectException($exception);
+        $this->executeFrontendRequest(new InternalRequest($url), $this->internalRequestContext);
+    }
+
+    /**
+     * @param string $url
+     * @param array $expectation
+     */
+    protected function assertMenu(string $url, array $expectation): void
+    {
+        $this->setUpFrontendRootPage(
+            1000,
+            ['typo3/sysext/frontend/Tests/Functional/SiteHandling/Fixtures/LinkGenerator.typoscript'],
+            [
+                'title' => 'ACME Root',
+                'sitetitle' => $this->siteTitle,
+            ]
+        );
+
+        $response = $this->executeFrontendRequest(
+            (new InternalRequest($url))
+                ->withInstructions([
+                    $this->createHierarchicalMenuProcessorInstruction([
+                        'levels' => 1,
+                        'entryLevel' => 0,
+                        'expandAll' => 1,
+                        'includeSpacer' => 1,
+                        'titleField' => 'title',
+                        'as' => 'results',
+                    ]),
+                ]),
+            $this->internalRequestContext
+        );
+
+        $json = json_decode((string)$response->getBody(), true);
+        $json = $this->filterMenu($json);
+
+        static::assertSame($expectation, $json);
+    }
+}
diff --git a/typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/Fixtures/ScenarioA.yaml b/typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/Fixtures/ScenarioA.yaml
new file mode 100644 (file)
index 0000000..2d9bc74
--- /dev/null
@@ -0,0 +1,44 @@
+__variables:
+  - &pageStandard 0
+  - &contentText 'text'
+  - &idAcmeRootPage 1000
+
+entitySettings:
+  '*':
+    nodeColumnName: 'pid'
+    columnNames: {id: 'uid', language: 'sys_language_uid'}
+    defaultValues: {pid: 0}
+  page:
+    isNode: true
+    tableName: 'pages'
+    parentColumnName: 'pid'
+    languageColumnNames: ['l10n_parent', 'l10n_source']
+    columnNames: {type: 'doktype', root: 'is_siteroot', mount: 'mount_pid', visitorGroups: 'fe_group'}
+    defaultValues: {hidden: 0, doktype: *pageStandard}
+    valueInstructions:
+      shortcut:
+        first: {shortcut: 0, shortcut_mode: 1}
+  content:
+    tableName: 'tt_content'
+    languageColumnNames: ['l18n_parent', 'l10n_source']
+    columnNames: {title: 'header', type: 'CType', column: 'colPos'}
+    defaultValues: {hidden: 0, type: *contentText, column: 0}
+  language:
+    tableName: 'sys_language'
+    columnNames: {code: 'language_isocode'}
+  typoscript:
+    tableName: 'sys_template'
+    valueInstructions:
+      type:
+        site: {root: 1, clear: 1}
+
+entities:
+  language:
+    - self: {id: 1, title: 'German', code: 'de'}
+    - self: {id: 2, title: 'Swiss German', code: 'de'}
+  page:
+    - self: {id: *idAcmeRootPage, title: 'EN: Root', root: true, slug: '/'}
+      children:
+        - self: {id: 1100, title: 'EN: Welcome', slug: '/hello'}
+          languageVariants:
+            - self: {id: 1101, title: 'DE: Willkommen', language: 1, slug: '/willkommen'}
diff --git a/typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/Fixtures/ScenarioB.yaml b/typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/Fixtures/ScenarioB.yaml
new file mode 100644 (file)
index 0000000..c395cfa
--- /dev/null
@@ -0,0 +1,48 @@
+__variables:
+  - &pageStandard 0
+  - &contentText 'text'
+  - &idAcmeRootPage 1000
+
+entitySettings:
+  '*':
+    nodeColumnName: 'pid'
+    columnNames: {id: 'uid', language: 'sys_language_uid'}
+    defaultValues: {pid: 0}
+  page:
+    isNode: true
+    tableName: 'pages'
+    parentColumnName: 'pid'
+    languageColumnNames: ['l10n_parent', 'l10n_source']
+    columnNames: {type: 'doktype', root: 'is_siteroot', mount: 'mount_pid', visitorGroups: 'fe_group'}
+    defaultValues: {hidden: 0, doktype: *pageStandard}
+    valueInstructions:
+      shortcut:
+        first: {shortcut: 0, shortcut_mode: 1}
+  content:
+    tableName: 'tt_content'
+    languageColumnNames: ['l18n_parent', 'l10n_source']
+    columnNames: {title: 'header', type: 'CType', column: 'colPos'}
+    defaultValues: {hidden: 0, type: *contentText, column: 0}
+  language:
+    tableName: 'sys_language'
+    columnNames: {code: 'language_isocode'}
+  typoscript:
+    tableName: 'sys_template'
+    valueInstructions:
+      type:
+        site: {root: 1, clear: 1}
+
+entities:
+  language:
+    - self: {id: 1, title: 'German', code: 'de'}
+    - self: {id: 2, title: 'Swiss German', code: 'de'}
+  page:
+    - self: {id: *idAcmeRootPage, title: 'EN: Root', root: true, slug: '/'}
+      children:
+        - self: {id: 1100, title: 'EN: Welcome', slug: '/hello'}
+        - self: {id: 1200, title: 'EN: About us', root: true, slug: '/about-us'}
+          languageVariants:
+            # Slug is not translated on purpose
+            - self: {id: 1202, title: 'DE-CH: Über uns', language: 2, slug: '/about-us'}
+          children:
+            - self: {id: 1300, title: 'EN: Headquarter', slug: '/about-us/headquarter'}
diff --git a/typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/Fixtures/ScenarioC.yaml b/typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/Fixtures/ScenarioC.yaml
new file mode 100644 (file)
index 0000000..21def22
--- /dev/null
@@ -0,0 +1,47 @@
+__variables:
+  - &pageStandard 0
+  - &contentText 'text'
+  - &idAcmeRootPage 1000
+
+entitySettings:
+  '*':
+    nodeColumnName: 'pid'
+    columnNames: {id: 'uid', language: 'sys_language_uid'}
+    defaultValues: {pid: 0}
+  page:
+    isNode: true
+    tableName: 'pages'
+    parentColumnName: 'pid'
+    languageColumnNames: ['l10n_parent', 'l10n_source']
+    columnNames: {type: 'doktype', root: 'is_siteroot', mount: 'mount_pid', visitorGroups: 'fe_group'}
+    defaultValues: {hidden: 0, doktype: *pageStandard, l18n_cfg: 2}
+    valueInstructions:
+      shortcut:
+        first: {shortcut: 0, shortcut_mode: 1}
+  content:
+    tableName: 'tt_content'
+    languageColumnNames: ['l18n_parent', 'l10n_source']
+    columnNames: {title: 'header', type: 'CType', column: 'colPos'}
+    defaultValues: {hidden: 0, type: *contentText, column: 0}
+  language:
+    tableName: 'sys_language'
+    columnNames: {code: 'language_isocode'}
+  typoscript:
+    tableName: 'sys_template'
+    valueInstructions:
+      type:
+        site: {root: 1, clear: 1}
+
+entities:
+  language:
+    - self: {id: 1, title: 'German', code: 'de'}
+    - self: {id: 2, title: 'Swiss German', code: 'de'}
+  page:
+    - self: {id: *idAcmeRootPage, title: 'EN: Root', root: true, slug: '/'}
+      children:
+        - self: {id: 1100, title: 'EN: Welcome', slug: '/hello'}
+          languageVariants:
+            - self: {id: 1101, title: 'DE: Willkommen', language: 1, slug: '/willkommen'}
+        - self: {id: 1200, title: 'EN: About us', root: true, slug: '/about-us'}
+          languageVariants:
+            - self: {id: 1202, title: 'DE-CH: Über uns', language: 2, slug: '/ueber-uns'}
diff --git a/typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/Fixtures/ScenarioD.yaml b/typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/Fixtures/ScenarioD.yaml
new file mode 100644 (file)
index 0000000..fd5fc61
--- /dev/null
@@ -0,0 +1,51 @@
+__variables:
+  - &pageStandard 0
+  - &pageShortcut 4
+  - &contentText 'text'
+  - &idAcmeRootPage 1000
+
+entitySettings:
+  '*':
+    nodeColumnName: 'pid'
+    columnNames: {id: 'uid', language: 'sys_language_uid'}
+    defaultValues: {pid: 0}
+  page:
+    isNode: true
+    tableName: 'pages'
+    parentColumnName: 'pid'
+    languageColumnNames: ['l10n_parent', 'l10n_source']
+    columnNames: {type: 'doktype', root: 'is_siteroot', mount: 'mount_pid', visitorGroups: 'fe_group'}
+    defaultValues: {hidden: 0, doktype: *pageStandard, l18n_cfg: 2}
+    valueInstructions:
+      shortcut:
+        first: {shortcut: 0, shortcut_mode: 1}
+  content:
+    tableName: 'tt_content'
+    languageColumnNames: ['l18n_parent', 'l10n_source']
+    columnNames: {title: 'header', type: 'CType', column: 'colPos'}
+    defaultValues: {hidden: 0, type: *contentText, column: 0}
+  language:
+    tableName: 'sys_language'
+    columnNames: {code: 'language_isocode'}
+  typoscript:
+    tableName: 'sys_template'
+    valueInstructions:
+      type:
+        site: {root: 1, clear: 1}
+
+entities:
+  language:
+    - self: {id: 1, title: 'German', code: 'de'}
+    - self: {id: 2, title: 'Swiss German', code: 'de'}
+  page:
+    - self: {id: *idAcmeRootPage, title: 'EN: Root', root: true, slug: '/'}
+      children:
+        - self: {id: 1100, title: 'EN: Welcome', slug: '/hello'}
+        - self: {id: 1200, title: 'EN: About us', slug: '/about-us'}
+          languageVariants:
+            - self: {id: 1201, title: 'DE: Über uns', language: 1, slug: '/ueber-uns'}
+            - self: {id: 1202, title: 'DE-CH: Über uns', language: 2, slug: '/ueber-uns'}
+        - self: {id: 1300, title: 'EN: Products', slug: '/products'}
+          languageVariants:
+            - self: {id: 1302, title: 'DE-CH: Produkte', language: 2, slug: '/produkte'}
+        - self: {id: 1400, title: 'EN: Shortcut to welcome', slug: '/shortcut-to-welcome', type: *pageShortcut, shortcut: 1100, l18n_cfg: 0}
diff --git a/typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/Fixtures/ScenarioE.yaml b/typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/Fixtures/ScenarioE.yaml
new file mode 100644 (file)
index 0000000..fb1a401
--- /dev/null
@@ -0,0 +1,51 @@
+__variables:
+  - &pageStandard 0
+  - &pageShortcut 4
+  - &contentText 'text'
+  - &idAcmeRootPage 1000
+
+entitySettings:
+  '*':
+    nodeColumnName: 'pid'
+    columnNames: {id: 'uid', language: 'sys_language_uid'}
+    defaultValues: {pid: 0}
+  page:
+    isNode: true
+    tableName: 'pages'
+    parentColumnName: 'pid'
+    languageColumnNames: ['l10n_parent', 'l10n_source']
+    columnNames: {type: 'doktype', root: 'is_siteroot', mount: 'mount_pid', visitorGroups: 'fe_group'}
+    defaultValues: {hidden: 0, doktype: *pageStandard, l18n_cfg: 1}
+    valueInstructions:
+      shortcut:
+        first: {shortcut: 0, shortcut_mode: 1}
+  content:
+    tableName: 'tt_content'
+    languageColumnNames: ['l18n_parent', 'l10n_source']
+    columnNames: {title: 'header', type: 'CType', column: 'colPos'}
+    defaultValues: {hidden: 0, type: *contentText, column: 0}
+  language:
+    tableName: 'sys_language'
+    columnNames: {code: 'language_isocode'}
+  typoscript:
+    tableName: 'sys_template'
+    valueInstructions:
+      type:
+        site: {root: 1, clear: 1}
+
+entities:
+  language:
+    - self: {id: 1, title: 'German', code: 'de'}
+    - self: {id: 2, title: 'Swiss German', code: 'de'}
+  page:
+    - self: {id: *idAcmeRootPage, title: 'EN: Root', root: true, slug: '/'}
+      children:
+        - self: {id: 1100, title: 'EN: Welcome', slug: '/hello'}
+        - self: {id: 1200, title: 'EN: About us', slug: '/about-us'}
+          languageVariants:
+            - self: {id: 1201, title: 'DE: Über uns', language: 1, slug: '/ueber-uns'}
+        - self: {id: 1300, title: 'EN: Products', slug: '/products'}
+          languageVariants:
+            - self: {id: 1301, title: 'DE: Produkte', language: 1, slug: '/produkte'}
+        - self: {id: 1400, title: 'EN: Terms and conditions', slug: '/terms-conditions'}
+        - self: {id: 1500, title: 'EN: Shortcut to welcome', slug: '/shortcut-to-welcome', type: *pageShortcut, shortcut: 1100, l18n_cfg: 0}
diff --git a/typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/Fixtures/ScenarioF.yaml b/typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/Fixtures/ScenarioF.yaml
new file mode 100644 (file)
index 0000000..1472ab3
--- /dev/null
@@ -0,0 +1,54 @@
+__variables:
+  - &pageStandard 0
+  - &pageShortcut 4
+  - &contentText 'text'
+  - &idAcmeRootPage 1000
+
+entitySettings:
+  '*':
+    nodeColumnName: 'pid'
+    columnNames: {id: 'uid', language: 'sys_language_uid'}
+    defaultValues: {pid: 0}
+  page:
+    isNode: true
+    tableName: 'pages'
+    parentColumnName: 'pid'
+    languageColumnNames: ['l10n_parent', 'l10n_source']
+    columnNames: {type: 'doktype', root: 'is_siteroot', mount: 'mount_pid', visitorGroups: 'fe_group'}
+    defaultValues: {hidden: 0, doktype: *pageStandard, l18n_cfg: 3}
+    valueInstructions:
+      shortcut:
+        first: {shortcut: 0, shortcut_mode: 1}
+  content:
+    tableName: 'tt_content'
+    languageColumnNames: ['l18n_parent', 'l10n_source']
+    columnNames: {title: 'header', type: 'CType', column: 'colPos'}
+    defaultValues: {hidden: 0, type: *contentText, column: 0}
+  language:
+    tableName: 'sys_language'
+    columnNames: {code: 'language_isocode'}
+  typoscript:
+    tableName: 'sys_template'
+    valueInstructions:
+      type:
+        site: {root: 1, clear: 1}
+
+entities:
+  language:
+    - self: {id: 1, title: 'German', code: 'de'}
+    - self: {id: 2, title: 'Swiss German', code: 'de'}
+  page:
+    - self: {id: *idAcmeRootPage, title: 'EN: Root', root: true, slug: '/'}
+      children:
+        - self: {id: 1100, title: 'EN: Welcome', root: true, slug: '/hello'}
+          languageVariants:
+            - self: {id: 1101, title: 'DE: Willkommen', language: 1, slug: '/willkommen'}
+        - self: {id: 1200, title: 'EN: About us', slug: '/about-us'}
+          languageVariants:
+            - self: {id: 1201, title: 'DE: Über uns', language: 1, slug: '/ueber-uns'}
+        - self: {id: 1300, title: 'EN: Products', slug: '/products'}
+          languageVariants:
+            - self: {id: 1301, title: 'DE: Produkte', language: 1, slug: '/produkte'}
+            - self: {id: 1302, title: 'DE-CH: Produkte', language: 2, slug: '/produkte'}
+        - self: {id: 1400, title: 'EN: Terms and conditions', slug: '/terms-conditions'}
+        - self: {id: 1500, title: 'EN: Shortcut to welcome', slug: '/shortcut-to-welcome', type: *pageShortcut, shortcut: 1100, l18n_cfg: 0}
diff --git a/typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/ScenarioATest.php b/typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/ScenarioATest.php
new file mode 100644 (file)
index 0000000..0516c92
--- /dev/null
@@ -0,0 +1,136 @@
+<?php
+declare(strict_types = 1);
+
+namespace TYPO3\CMS\Frontend\Tests\Functional\SiteHandling\LocalizedPageRendering;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+/**
+ * Language container test definition
+ *
+ * Scenario prerequisites:
+ *   Site configuration has localizations
+ *     first language: DE
+ *       no fallbacks configured
+ *     second language: DE-CH
+ *       fallback to DE
+ *
+ *   Home page is localized into DE
+ *
+ * Scenario expectations:
+ *   Calling home page in EN renders page in EN
+ *   Calling home page in DE renders page in DE
+ *   Calling home page in DE-CH renders page in DE as defined in the fallback chain
+ *
+ *   Calling "headquarter" page in EN renders page in EN
+ *   Calling "headquarter" page in DE throws a PageNotFoundException because no fallback chain is configured
+ *   Calling "headquarter" page in DE-CH renders page in EN
+ */
+class ScenarioATest extends AbstractLocalizedPagesTestCase
+{
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->writeSiteConfiguration(
+            'acme-com',
+            $this->buildSiteConfiguration(1000, 'https://acme.com/'),
+            [
+                $this->buildDefaultLanguageConfiguration('EN', 'https://acme.com/en'),
+                $this->buildLanguageConfiguration('DE', 'https://acme.com/de'),
+                $this->buildLanguageConfiguration('DE-CH', 'https://acme.com/de-ch', ['DE']),
+            ]
+        );
+
+        $this->setUpDatabaseWithYamlPayload(__DIR__ . '/Fixtures/ScenarioA.yaml');
+    }
+
+    /**
+     * @return array
+     */
+    public function resolvablePagesDataProvider(): array
+    {
+        return [
+            'home page in EN' => [
+                'url' => 'https://acme.com/en/hello',
+                'scopes' => [
+                    'page/title' => 'EN: Welcome',
+                ],
+            ],
+            'home page in DE where page translation exists' => [
+                'url' => 'https://acme.com/de/willkommen',
+                'scopes' => [
+                    'page/title' => 'DE: Willkommen',
+                ],
+            ],
+            'home page in DE-CH where page translation does not exist' => [
+                'url' => 'https://acme.com/de-ch/willkommen',
+                'scopes' => [
+                    'page/title' => 'DE: Willkommen',
+                ],
+            ],
+        ];
+    }
+
+    /**
+     * @param string $url
+     * @param array $scopes
+     *
+     * @test
+     * @dataProvider resolvablePagesDataProvider
+     */
+    public function resolvedPagesMatchScopes(string $url, array $scopes): void
+    {
+        $this->assertScopes($url, $scopes);
+    }
+
+    /**
+     * @return array
+     */
+    public function menuDataProvider(): array
+    {
+        return [
+            [
+                'url' => 'https://acme.com/en/hello',
+                'menu' => [
+                    ['title' => 'EN: Welcome', 'link' => '/en/hello'],
+                ],
+            ],
+            [
+                'url' => 'https://acme.com/de/willkommen',
+                'menu' => [
+                    ['title' => 'DE: Willkommen', 'link' => '/de/willkommen'],
+                ],
+            ],
+            [
+                'url' => 'https://acme.com/de-ch/willkommen',
+                'menu' => [
+                    ['title' => 'DE: Willkommen', 'link' => '/de-ch/willkommen'],
+                ],
+            ],
+        ];
+    }
+
+    /**
+     * @param string $url
+     * @param array $expectedMenu
+     *
+     * @test
+     * @dataProvider menuDataProvider
+     */
+    public function pageMenuIsRendered(string $url, array $expectedMenu): void
+    {
+        $this->assertMenu($url, $expectedMenu);
+    }
+}
diff --git a/typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/ScenarioBTest.php b/typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/ScenarioBTest.php
new file mode 100644 (file)
index 0000000..cac360c
--- /dev/null
@@ -0,0 +1,164 @@
+<?php
+declare(strict_types = 1);
+
+namespace TYPO3\CMS\Frontend\Tests\Functional\SiteHandling\LocalizedPageRendering;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use TYPO3\CMS\Core\Error\Http\PageNotFoundException;
+
+/**
+ * Scenario prerequisites:
+ *   Site configuration has localizations
+ *     default language: EN
+ *     first language: DE
+ *       no fallbacks configured
+ *     second language: DE-CH
+ *       fallback to DE, EN
+ *
+ *   Home page is not localized into any language
+ *
+ * Scenario expectations:
+ *   Calling home page in EN renders page in EN
+ *   Calling home page in DE throws a PageNotFoundException because no fallback chain is configured
+ *   Calling home page in DE-CH renders page in EN as defined in the fallback chain
+ *
+ *   Calling "headquarter" page in EN renders page in EN
+ *   Calling "headquarter" page in DE throws a PageNotFoundException because no fallback chain is configured
+ *   Calling "headquarter" page in DE-CH renders page in EN
+ */
+class ScenarioBTest extends AbstractLocalizedPagesTestCase
+{
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->writeSiteConfiguration(
+            'acme-com',
+            $this->buildSiteConfiguration(1000, 'https://acme.com/'),
+            [
+                $this->buildDefaultLanguageConfiguration('EN', 'https://acme.com/en'),
+                $this->buildLanguageConfiguration('DE', 'https://acme.com/de'),
+                $this->buildLanguageConfiguration('DE-CH', 'https://acme.com/de-ch', ['DE', 'EN']),
+            ]
+        );
+
+        $this->setUpDatabaseWithYamlPayload(__DIR__ . '/Fixtures/ScenarioB.yaml');
+    }
+
+    /**
+     * @return array
+     */
+    public function resolvablePagesDataProvider(): array
+    {
+        return [
+            'home page in EN' => [
+                'url' => 'https://acme.com/en/hello',
+                'scopes' => [
+                    'page/title' => 'EN: Welcome',
+                ],
+            ],
+            'home page in DE-CH where page translation does not exist' => [
+                'url' => 'https://acme.com/de-ch/hello',
+                'scopes' => [
+                    'page/title' => 'EN: Welcome',
+                ],
+            ],
+            'headquarter sub page in EN' => [
+                'url' => 'https://acme.com/en/about-us/headquarter',
+                'scopes' => [
+                    'page/title' => 'EN: Headquarter',
+                ],
+            ],
+            'headquarter sub page in DE-CH where page translation does not exist' => [
+                'url' => 'https://acme.com/de-ch/about-us/headquarter',
+                'scopes' => [
+                    'page/title' => 'EN: Headquarter',
+                ],
+            ],
+        ];
+    }
+
+    /**
+     * @param string $url
+     * @param array $scopes
+     *
+     * @test
+     * @dataProvider resolvablePagesDataProvider
+     */
+    public function resolvedPagesMatchScopes(string $url, array $scopes): void
+    {
+        $this->assertScopes($url, $scopes);
+    }
+
+    /**
+     * @return array
+     */
+    public function pageNotFoundDataProvider(): array
+    {
+        return [
+            'home page in DE where page translation does not exist and has no fallback configured' => [
+                'url' => 'https://acme.com/de/hello',
+                'exception' => PageNotFoundException::class,
+            ],
+        ];
+    }
+
+    /**
+     * @param string $url
+     * @param string $exception
+     *
+     * @test
+     * @dataProvider pageNotFoundDataProvider
+     */
+    public function requestsThrowException(string $url, string $exception): void
+    {
+        $this->assertException($url, $exception);
+    }
+
+    /**
+     * @return array
+     */
+    public function menuDataProvider(): array
+    {
+        return [
+            [
+                'url' => 'https://acme.com/en/hello',
+                'menu' => [
+                    ['title' => 'EN: Welcome', 'link' => '/en/hello'],
+                    ['title' => 'EN: About us', 'link' => '/en/about-us'],
+                ],
+            ],
+            [
+                'url' => 'https://acme.com/de-ch/hello',
+                'menu' => [
+                    ['title' => 'EN: Welcome', 'link' => '/de-ch/hello'],
+                    ['title' => 'DE-CH: Über uns', 'link' => '/de-ch/about-us'],
+                ],
+            ],
+        ];
+    }
+
+    /**
+     * @param string $url
+     * @param array $expectedMenu
+     *
+     * @test
+     * @dataProvider menuDataProvider
+     */
+    public function pageMenuIsRendered(string $url, array $expectedMenu): void
+    {
+        $this->assertMenu($url, $expectedMenu);
+    }
+}
diff --git a/typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/ScenarioCTest.php b/typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/ScenarioCTest.php
new file mode 100644 (file)
index 0000000..df29a50
--- /dev/null
@@ -0,0 +1,181 @@
+<?php
+declare(strict_types = 1);
+
+namespace TYPO3\CMS\Frontend\Tests\Functional\SiteHandling\LocalizedPageRendering;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use TYPO3\CMS\Core\Error\Http\PageNotFoundException;
+
+/**
+ * Scenario prerequisites:
+ *   Site configuration has localizations
+ *     default language: EN
+ *     first language: DE
+ *       no fallbacks configured
+ *     second language: DE-CH
+ *       fallback to DE
+ *
+ *   Home page is localized to DE and has l18n_cfg=2 set.
+ *   "About" page is localized into DE-CH and has l18n_cfg=2 set
+ *
+ * Scenario expectations:
+ *   Calling home page in EN renders page in EN
+ *   Calling home page in DE renders page in DE
+ *   Calling home page in DE-CH throws a PageNotFoundException because fallback chain is not processed due to l18n_cfg=2
+ *
+ *   Calling "about" page in EN renders page in EN
+ *   Calling "about" page in DE throws a PageNotFoundException because fallback chain is not processed due to l18n_cfg=2
+ *   Calling "about" page in DE-CH renders page in DE
+ */
+class ScenarioCTest extends AbstractLocalizedPagesTestCase
+{
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->writeSiteConfiguration(
+            'acme-com',
+            $this->buildSiteConfiguration(1000, 'https://acme.com/'),
+            [
+                $this->buildDefaultLanguageConfiguration('EN', 'https://acme.com/en'),
+                $this->buildLanguageConfiguration('DE', 'https://acme.com/de'),
+                $this->buildLanguageConfiguration('DE-CH', 'https://acme.com/de-ch', ['DE']),
+            ]
+        );
+
+        $this->setUpDatabaseWithYamlPayload(__DIR__ . '/Fixtures/ScenarioC.yaml');
+    }
+
+    /**
+     * @return array
+     */
+    public function resolvablePagesDataProvider(): array
+    {
+        return [
+            'home page in EN' => [
+                'url' => 'https://acme.com/en/hello',
+                'scopes' => [
+                    'page/title' => 'EN: Welcome',
+                ],
+            ],
+            'home page in DE' => [
+                'url' => 'https://acme.com/de/willkommen',
+                'scopes' => [
+                    'page/title' => 'DE: Willkommen',
+                ],
+            ],
+            'about page in EN' => [
+                'url' => 'https://acme.com/en/about-us',
+                'scopes' => [
+                    'page/title' => 'EN: About us',
+                ],
+            ],
+            'about page in DE-CH' => [
+                'url' => 'https://acme.com/de-ch/ueber-uns',
+                'scopes' => [
+                    'page/title' => 'DE-CH: Über uns',
+                ],
+            ],
+        ];
+    }
+
+    /**
+     * @param string $url
+     * @param array $scopes
+     *
+     * @test
+     * @dataProvider resolvablePagesDataProvider
+     */
+    public function resolvedPagesMatchScopes(string $url, array $scopes): void
+    {
+        $this->assertScopes($url, $scopes);
+    }
+
+    /**
+     * @return array
+     */
+    public function pageNotFoundDataProvider(): array
+    {
+        return [
+            'home page in DE-CH where page translation does not exist and is trapped by l18n_cfg' => [
+                'url' => 'https://acme.com/de-ch/wllkommen',
+                'exception' => PageNotFoundException::class,
+            ],
+            'about page in DE where page translation does not exist and is trapped by l18n_cfg' => [
+                'url' => 'https://acme.com/de/ueber-uns',
+                'exception' => PageNotFoundException::class,
+            ],
+        ];
+    }
+
+    /**
+     * @param string $url
+     * @param string $exception
+     *
+     * @test
+     * @dataProvider pageNotFoundDataProvider
+     */
+    public function requestsThrowException(string $url, string $exception): void
+    {
+        $this->assertException($url, $exception);
+    }
+
+    /**
+     * @return array
+     */
+    public function menuDataProvider(): array
+    {
+        return [
+            [
+                'url' => 'https://acme.com/en/hello',
+                'menu' => [
+                    ['title' => 'EN: Welcome', 'link' => '/en/hello'],
+                    ['title' => 'EN: About us', 'link' => '/en/about-us'],
+                ],
+            ],
+            [
+                'url' => 'https://acme.com/de/willkommen',
+                'menu' => [
+                    ['title' => 'DE: Willkommen', 'link' => '/de/willkommen'],
+                ],
+            ],
+            [
+                'url' => 'https://acme.com/en/about-us',
+                'menu' => [
+                    ['title' => 'EN: Welcome', 'link' => '/en/hello'],
+                    ['title' => 'EN: About us', 'link' => '/en/about-us'],
+                ],
+            ],
+            [
+                'url' => 'https://acme.com/de-ch/ueber-uns',
+                'menu' => [
+                    ['title' => 'DE-CH: Über uns', 'link' => '/de-ch/ueber-uns'],
+                ],
+            ],
+        ];
+    }
+
+    /**
+     * @param string $url
+     * @param array $expectedMenu
+     *
+     * @test
+     * @dataProvider menuDataProvider
+     */
+    public function pageMenuIsRendered(string $url, array $expectedMenu): void
+    {
+        $this->assertMenu($url, $expectedMenu);
+    }
+}
diff --git a/typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/ScenarioDTest.php b/typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/ScenarioDTest.php
new file mode 100644 (file)
index 0000000..5f66b4e
--- /dev/null
@@ -0,0 +1,193 @@
+<?php
+declare(strict_types = 1);
+
+namespace TYPO3\CMS\Frontend\Tests\Functional\SiteHandling\LocalizedPageRendering;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use TYPO3\CMS\Core\Error\Http\PageNotFoundException;
+
+/**
+ * Scenario prerequisites:
+ *   Site configuration has localizations
+ *     default language: EN
+ *     first language: DE
+ *       no fallbacks configured
+ *     second language: DE-CH
+ *       fallback to DE, EN
+ *
+ *   Home page is not localized into any language and has l18n_cfg=2 set
+ *   "About" page is localized into DE and has l18n_cfg=2 set
+ *   "Products" page is localized into DE-CH and has l18n_cfg=2 set
+ *
+ * Scenario expectations:
+ *   Calling home page in EN renders page in EN
+ *   Calling home page in DE throws a PageNotFoundException
+ *   Calling home page in DE-CH throws a PageNotFoundException because fallback chain is not processed due to l18n_cfg=2
+ *
+ *   Calling "about" page in EN renders page in EN
+ *   Calling "about" page in DE renders page in DE
+ *   Calling "about" page in DE-CH renders page in DE-CH
+ */
+class ScenarioDTest extends AbstractLocalizedPagesTestCase
+{
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->writeSiteConfiguration(
+            'acme-com',
+            $this->buildSiteConfiguration(1000, 'https://acme.com/'),
+            [
+                $this->buildDefaultLanguageConfiguration('EN', 'https://acme.com/en'),
+                $this->buildLanguageConfiguration('DE', 'https://acme.com/de'),
+                $this->buildLanguageConfiguration('DE-CH', 'https://acme.com/de-ch', ['DE', 'EN']),
+            ]
+        );
+
+        $this->setUpDatabaseWithYamlPayload(__DIR__ . '/Fixtures/ScenarioD.yaml');
+    }
+
+    /**
+     * @return array
+     */
+    public function resolvablePagesDataProvider(): array
+    {
+        return [
+            'home page in EN' => [
+                'url' => 'https://acme.com/en/hello',
+                'scopes' => [
+                    'page/title' => 'EN: Welcome',
+                ],
+            ],
+            'about page in EN' => [
+                'url' => 'https://acme.com/en/about-us',
+                'scopes' => [
+                    'page/title' => 'EN: About us',
+                ],
+            ],
+            'about page in DE where page translation exists' => [
+                'url' => 'https://acme.com/de/ueber-uns',
+                'scopes' => [
+                    'page/title' => 'DE: Über uns',
+                ],
+            ],
+            'about page in DE-CH where page translation exists' => [
+                'url' => 'https://acme.com/de-ch/ueber-uns',
+                'scopes' => [
+                    'page/title' => 'DE-CH: Über uns',
+                ],
+            ],
+        ];
+    }
+
+    /**
+     * @param string $url
+     * @param array $scopes
+     *
+     * @test
+     * @dataProvider resolvablePagesDataProvider
+     */
+    public function resolvedPagesMatchScopes(string $url, array $scopes): void
+    {
+        $this->assertScopes($url, $scopes);
+    }
+
+    /**
+     * @return array
+     */
+    public function pageNotFoundDataProvider(): array
+    {
+        return [
+            'home page in DE where page translation does not exist' => [
+                'url' => 'https://acme.com/de/hello',
+                'exception' => PageNotFoundException::class,
+            ],
+            'home page in DE-CH where page translation does not exist and is trapped by l18n_cfg' => [
+                'url' => 'https://acme.com/de-ch/hello',
+                'exception' => PageNotFoundException::class,
+            ],
+            'DE-CH shortcut to home page where page translation does not exist and is trapped by l18n_cfg' => [
+                'url' => 'https://acme.com/de-ch/shortcut-to-welcome',
+                'exception' => PageNotFoundException::class,
+            ]
+        ];
+    }
+
+    /**
+     * @param string $url
+     * @param string $exception
+     *
+     * @test
+     * @dataProvider pageNotFoundDataProvider
+     */
+    public function requestsThrowException(string $url, string $exception): void
+    {
+        $this->assertException($url, $exception);
+    }
+
+    /**
+     * @return array
+     */
+    public function menuDataProvider(): array
+    {
+        return [
+            [
+                'url' => 'https://acme.com/en/hello',
+                'menu' => [
+                    ['title' => 'EN: Welcome', 'link' => '/en/hello'],
+                    ['title' => 'EN: About us', 'link' => '/en/about-us'],
+                    ['title' => 'EN: Products', 'link' => '/en/products'],
+                    ['title' => 'EN: Shortcut to welcome', 'link' => '/en/hello'],
+                ],
+            ],
+            [
+                'url' => 'https://acme.com/en/about-us',
+                'menu' => [
+                    ['title' => 'EN: Welcome', 'link' => '/en/hello'],
+                    ['title' => 'EN: About us', 'link' => '/en/about-us'],
+                    ['title' => 'EN: Products', 'link' => '/en/products'],
+                    ['title' => 'EN: Shortcut to welcome', 'link' => '/en/hello'],
+                ],
+            ],
+            [
+                'url' => 'https://acme.com/de/ueber-uns',
+                'menu' => [
+                    ['title' => 'DE: Über uns', 'link' => '/de/ueber-uns'],
+                ],
+            ],
+            [
+                'url' => 'https://acme.com/de-ch/ueber-uns',
+                'menu' => [
+                    ['title' => 'DE-CH: Über uns', 'link' => '/de-ch/ueber-uns'],
+                    ['title' => 'DE-CH: Produkte', 'link' => '/de-ch/produkte'],
+                    // FIXME: Page "EN: Shortcut to welcome" must to be rendered in menu, needs a refactored menu generation
+                    ['title' => 'EN: Shortcut to welcome', 'link' => '/de-ch/hello'],
+                ],
+            ],
+        ];
+    }
+
+    /**
+     * @param string $url
+     * @param array $expectedMenu
+     *
+     * @test
+     * @dataProvider menuDataProvider
+     */
+    public function pageMenuIsRendered(string $url, array $expectedMenu): void
+    {
+        $this->assertMenu($url, $expectedMenu);
+    }
+}
diff --git a/typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/ScenarioETest.php b/typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/ScenarioETest.php
new file mode 100644 (file)
index 0000000..aff3b2c
--- /dev/null
@@ -0,0 +1,189 @@
+<?php
+declare(strict_types = 1);
+
+namespace TYPO3\CMS\Frontend\Tests\Functional\SiteHandling\LocalizedPageRendering;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use TYPO3\CMS\Core\Error\Http\PageNotFoundException;
+
+/**
+ * Scenario prerequisites:
+ *   Site configuration has localizations
+ *     default language: EN
+ *     first language: DE
+ *       no fallbacks configured
+ *     second language: DE-CH
+ *       fallback to DE, EN
+ *
+ *   Home page is not localized into any language and has l18n_cfg=1 set
+ *   "About" page is localized into DE and has l18n_cfg=1 set
+ *   "Products" page is localized into DE and has l18n_cfg=1 set
+ *
+ * Scenario expectations:
+ *   Calling home page in EN throws a PageNotFoundException due to l18n_cfg=1
+ *   Calling home page in DE throws a PageNotFoundException
+ *   Calling home page in DE-CH throws a PageNotFoundException because EN is used as fallback but is not processed due to l18n_cfg=1
+ *
+ *   Calling "about" page in EN throws a PageNotFoundException due to l18n_cfg=1
+ *   Calling "about" page in DE renders page in DE
+ *   Calling "about" page in DE-CH renders page in DE
+ *   Calling "about" page in DE with EN slug throws a PageNotFoundException
+ *   Calling "about" page in DE-CH with EN slug renders page in DE
+ */
+class ScenarioETest extends AbstractLocalizedPagesTestCase
+{
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->writeSiteConfiguration(
+            'acme-com',
+            $this->buildSiteConfiguration(1000, 'https://acme.com/'),
+            [
+                $this->buildDefaultLanguageConfiguration('EN', 'https://acme.com/en'),
+                $this->buildLanguageConfiguration('DE', 'https://acme.com/de'),
+                $this->buildLanguageConfiguration('DE-CH', 'https://acme.com/de-ch', ['DE', 'EN']),
+            ]
+        );
+
+        $this->setUpDatabaseWithYamlPayload(__DIR__ . '/Fixtures/ScenarioE.yaml');
+    }
+
+    /**
+     * @return array
+     */
+    public function resolvablePagesDataProvider(): array
+    {
+        return [
+            'about page in DE where page translation exists' => [
+                'url' => 'https://acme.com/de/ueber-uns',
+                'scopes' => [
+                    'page/title' => 'DE: Über uns',
+                ],
+            ],
+            'about page in DE-CH where page translation does not exist' => [
+                'url' => 'https://acme.com/de-ch/ueber-uns',
+                'scopes' => [
+                    'page/title' => 'DE: Über uns',
+                ],
+            ],
+            'about page in DE-CH with EN slug' => [
+                'url' => 'https://acme.com/de-ch/about-us',
+                'scopes' => [
+                    'page/title' => 'DE: Über uns',
+                ],
+            ],
+        ];
+    }
+
+    /**
+     * @param string $url
+     * @param array $scopes
+     *
+     * @test
+     * @dataProvider resolvablePagesDataProvider
+     */
+    public function resolvedPagesMatchScopes(string $url, array $scopes): void
+    {
+        $this->assertScopes($url, $scopes);
+    }
+
+    /**
+     * @return array
+     */
+    public function pageNotFoundDataProvider(): array
+    {
+        return [
+            'home page in EN' => [
+                'url' => 'https://acme.com/en/hello',
+                'exception' => PageNotFoundException::class,
+            ],
+            'home page in DE where page translation does not exist' => [
+                'url' => 'https://acme.com/de/hello',
+                'exception' => PageNotFoundException::class,
+            ],
+            'home page in DE-CH where page translation does not exist and is trapped by l18n_cfg' => [
+                'url' => 'https://acme.com/de-ch/hello',
+                'exception' => PageNotFoundException::class,
+            ],
+            'about page in EN' => [
+                'url' => 'https://acme.com/en/about-us',
+                'exception' => PageNotFoundException::class,
+            ],
+            'about page in DE with EN slug' => [
+                'url' => 'https://acme.com/de/about-us',
+                'exception' => PageNotFoundException::class,
+            ],
+        ];
+    }
+
+    /**
+     * @param string $url
+     * @param string $exception
+     *
+     * @test
+     * @dataProvider pageNotFoundDataProvider
+     */
+    public function requestsThrowException(string $url, string $exception): void
+    {
+        $this->assertException($url, $exception);
+    }
+
+    /**
+     * @return array
+     */
+    public function menuDataProvider(): array
+    {
+        return [
+            [
+                'url' => 'https://acme.com/de/ueber-uns',
+                'menu' => [
+                    ['title' => 'DE: Über uns', 'link' => '/de/ueber-uns'],
+                    ['title' => 'DE: Produkte', 'link' => '/de/produkte'],
+                ],
+            ],
+            [
+                'url' => 'https://acme.com/de-ch/ueber-uns',
+                'menu' => [
+                    ['title' => 'DE: Über uns', 'link' => '/de-ch/ueber-uns'],
+                    ['title' => 'DE: Produkte', 'link' => '/de-ch/produkte'],
+                    // FIXME: Page "EN: Shortcut to welcome" must to be rendered in menu, needs a refactored menu generation
+                    ['title' => 'EN: Shortcut to welcome', 'link' => ''],
+                ],
+            ],
+            [
+                'url' => 'https://acme.com/de-ch/about-us',
+                'menu' => [
+                    ['title' => 'DE: Über uns', 'link' => '/de-ch/ueber-uns'],
+                    ['title' => 'DE: Produkte', 'link' => '/de-ch/produkte'],
+                    // FIXME: Page "EN: Shortcut to welcome" must to be rendered in menu, needs a refactored menu generation
+                    ['title' => 'EN: Shortcut to welcome', 'link' => ''],
+                ],
+            ],
+        ];
+    }
+
+    /**
+     * @param string $url
+     * @param array $expectedMenu
+     *
+     * @test
+     * @dataProvider menuDataProvider
+     */
+    public function pageMenuIsRendered(string $url, array $expectedMenu): void
+    {
+        $this->assertMenu($url, $expectedMenu);
+    }
+}
diff --git a/typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/ScenarioFTest.php b/typo3/sysext/frontend/Tests/Functional/SiteHandling/LocalizedPageRendering/ScenarioFTest.php
new file mode 100644 (file)
index 0000000..8bc98d4
--- /dev/null
@@ -0,0 +1,194 @@
+<?php
+declare(strict_types = 1);
+
+namespace TYPO3\CMS\Frontend\Tests\Functional\SiteHandling\LocalizedPageRendering;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use TYPO3\CMS\Core\Error\Http\PageNotFoundException;
+
+/**
+ * Scenario prerequisites:
+ *   Site configuration has localizations
+ *     default language: EN
+ *     first language: DE
+ *       no fallbacks configured
+ *     second language: DE-CH
+ *       fallback to DE, EN
+ *
+ *   Home page is not localized into any language and has l18n_cfg=3 set
+ *   "About" page is localized into DE and has l18n_cfg=3 set
+ *   "Products" page is localized into DE-CH, DE and has l18n_cfg=3 set
+ *
+ * Scenario expectations:
+ *   Calling home page in EN throws a PageNotFoundException due to l18n_cfg=3
+ *   Calling home page in DE renders page in DE
+ *   Calling home page in DE-CH throws a PageNotFoundException because fallback chain is not processed due to l18n_cfg=3
+ *
+ *   Calling "about" page in EN throws a PageNotFoundException due to l18n_cfg=3
+ *   Calling "about" page in DE renders page in DE
+ *   Calling "about" page in DE-CH throws a PageNotFoundException because fallback chain is not processed due to l18n_cfg=3
+ *
+ *   Calling "products" page in EN throws a PageNotFoundException due to l18n_cfg=3
+ *   Calling "products" page in DE renders page in DE
+ *   Calling "products" page in DE-CH renders page in DE-CH
+ */
+class ScenarioFTest extends AbstractLocalizedPagesTestCase
+{
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->writeSiteConfiguration(
+            'acme-com',
+            $this->buildSiteConfiguration(1000, 'https://acme.com/'),
+            [
+                $this->buildDefaultLanguageConfiguration('EN', 'https://acme.com/en'),
+                $this->buildLanguageConfiguration('DE', 'https://acme.com/de'),
+                $this->buildLanguageConfiguration('DE-CH', 'https://acme.com/de-ch', ['DE', 'EN']),
+            ]
+        );
+
+        $this->setUpDatabaseWithYamlPayload(__DIR__ . '/Fixtures/ScenarioF.yaml');
+    }
+
+    /**
+     * @return array
+     */
+    public function resolvablePagesDataProvider(): array
+    {
+        return [
+            'about page in DE where page translation exists' => [
+                'url' => 'https://acme.com/de/ueber-uns',
+                'scopes' => [
+                    'page/title' => 'DE: Über uns',
+                ],
+            ],
+            'products page in DE where page translation exists' => [
+                'url' => 'https://acme.com/de/produkte',
+                'scopes' => [
+                    'page/title' => 'DE: Produkte',
+                ],
+            ],
+            'products page in DE-CH where page translation exists' => [
+                'url' => 'https://acme.com/de-ch/produkte',
+                'scopes' => [
+                    'page/title' => 'DE-CH: Produkte',
+                ],
+            ],
+        ];
+    }
+
+    /**
+     * @param string $url
+     * @param array $scopes
+     *
+     * @test
+     * @dataProvider resolvablePagesDataProvider
+     */
+    public function resolvedPagesMatchScopes(string $url, array $scopes): void
+    {
+        $this->assertScopes($url, $scopes);
+    }
+
+    /**
+     * @return array
+     */
+    public function pageNotFoundDataProvider(): array
+    {
+        return [
+            'home page in EN' => [
+                'url' => 'https://acme.com/en/hello',
+                'exception' => PageNotFoundException::class,
+            ],
+            'home page in DE where page translation does not exist' => [
+                'url' => 'https://acme.com/de/hello',
+                'exception' => PageNotFoundException::class,
+            ],
+            'home page in DE-CH where page translation does not exist and is trapped by l18n_cfg' => [
+                'url' => 'https://acme.com/de-ch/hello',
+                'exception' => PageNotFoundException::class,
+            ],
+            'about page in EN' => [
+                'url' => 'https://acme.com/en/about-us',
+                'exception' => PageNotFoundException::class,
+            ],
+            'about page in DE-CH where page translation does not exist and is trapped by l18n_cfg' => [
+                'url' => 'https://acme.com/de-ch/ueber-uns',
+                'exception' => PageNotFoundException::class,
+            ],
+            'products page in EN' => [
+                'url' => 'https://acme.com/en/products',
+                'exception' => PageNotFoundException::class,
+            ],
+        ];
+    }
+
+    /**
+     * @param string $url
+     * @param string $exception
+     *
+     * @test
+     * @dataProvider pageNotFoundDataProvider
+     */
+    public function requestsThrowException(string $url, string $exception): void
+    {
+        $this->assertException($url, $exception);
+    }
+
+    /**
+     * @return array
+     */
+    public function menuDataProvider(): array
+    {
+        return [
+            [
+                'url' => 'https://acme.com/de/ueber-uns',
+                'menu' => [
+                    ['title' => 'DE: Willkommen', 'link' => '/de/willkommen'],
+                    ['title' => 'DE: Über uns', 'link' => '/de/ueber-uns'],
+                    ['title' => 'DE: Produkte', 'link' => '/de/produkte'],
+                ],
+            ],
+            [
+                'url' => 'https://acme.com/de/produkte',
+                'menu' => [
+                    ['title' => 'DE: Willkommen', 'link' => '/de/willkommen'],
+                    ['title' => 'DE: Über uns', 'link' => '/de/ueber-uns'],
+                    ['title' => 'DE: Produkte', 'link' => '/de/produkte'],
+                ],
+            ],
+            [
+                'url' => 'https://acme.com/de-ch/produkte',
+                'menu' => [
+                    ['title' => 'DE-CH: Produkte', 'link' => '/de-ch/produkte'],
+                    // FIXME: Page "EN: Shortcut to welcome" must to be rendered in menu, needs a refactored menu generation
+                    ['title' => 'EN: Shortcut to welcome', 'link' => '/de-ch/willkommen'],
+                ],
+            ],
+        ];
+    }
+
+    /**
+     * @param string $url
+     * @param array $expectedMenu
+     *
+     * @test
+     * @dataProvider menuDataProvider
+     */
+    public function pageMenuIsRendered(string $url, array $expectedMenu): void
+    {
+        $this->assertMenu($url, $expectedMenu);
+    }
+}