[FEATURE] Native MountPoint support for Site Handling 78/62878/10
authorBenni Mack <benni@typo3.org>
Tue, 14 Jan 2020 13:17:16 +0000 (14:17 +0100)
committerBenni Mack <benni@typo3.org>
Wed, 15 Jan 2020 21:42:45 +0000 (22:42 +0100)
In order to link to mountpoints with Site Handling, the RootlineUtility
needs to receive the MountPoint parameter, to correctly
deal with mountpoint-related subpages.

Mount Points are based on the assumption that the "context" (the original
site environment) is kept. For this to work, the slug of the mount page (doktype=7)
is prefixed to the URL, but the common prefix of the subpages is trimmed
with the value of the mounted page (= pointer record).

The MP parameter is then added to PageArguments within the PageArguments
as "routeArgument" (= safe and clean argument) where TYPO3 is dealing
with this feature again in the same fashion it as before.

Various side-effects when dealing with mount points from other domains
still exist (= different language setup, or non-existing sites).

Feature Set:
* Multi-language setup (= when language setup is the same) with slugs
* Recursive mount points
* No MP parameter available in URLs anymore (at all)
* Multi-site setup (= when language setup is the same)

If a subpage of a mount page does not inherit the slug of the mounted page,
then the slug of the subpage is added in full afterwards.

Resolves: #86331
Resolves: #87473
Resolves: #89039
Releases: master, 9.5
Change-Id: I58f41eb325a07cc0c4a0dfeab1164eb8c58c7314
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/62878
Tested-by: TYPO3com <noreply@typo3.com>
Tested-by: Susanne Moog <look@susi.dev>
Tested-by: Daniel Siepmann <coding@daniel-siepmann.de>
Tested-by: Benni Mack <benni@typo3.org>
Reviewed-by: Susanne Moog <look@susi.dev>
Reviewed-by: Daniel Goerz <daniel.goerz@posteo.de>
Reviewed-by: Benni Mack <benni@typo3.org>
typo3/sysext/core/Classes/Routing/PageRouter.php
typo3/sysext/core/Classes/Routing/PageSlugCandidateProvider.php
typo3/sysext/core/Classes/Site/SiteFinder.php
typo3/sysext/core/Documentation/Changelog/9.5.x/Feature-86331-NativeURLSupportForMountPoints.rst [new file with mode: 0644]
typo3/sysext/frontend/Classes/Typolink/PageLinkBuilder.php
typo3/sysext/frontend/Tests/Functional/SiteHandling/SlugLinkGeneratorTest.php

index 86e2909..2434d69 100644 (file)
@@ -229,11 +229,24 @@ class PageRouter implements RouterInterface
         $context->setAspect('language', LanguageAspectFactory::createFromSiteLanguage($language));
         $pageRepository = GeneralUtility::makeInstance(PageRepository::class, $context);
         $page = $pageRepository->getPage($pageId, true);
-        $pagePath = ltrim($page['slug'] ?? '', '/');
+        $pagePath = $page['slug'] ?? '';
+
+        if ($parameters['MP'] ?? false) {
+            $pagePath = $this->resolveMountPointParameterIntoPageSlug(
+                $pageId,
+                $pagePath,
+                explode(',', $parameters['MP']),
+                $pageRepository
+            );
+            // Store the MP parameter in the page record, so it could be used for any enhancers
+            $page['MPvar'] = $parameters['MP'];
+            unset($parameters['MP']);
+        }
+
         $originalParameters = $parameters;
         $collection = new RouteCollection();
         $defaultRouteForPage = new Route(
-            '/' . $pagePath,
+            '/' . ltrim($pagePath, '/'),
             [],
             [],
             ['utf8' => true, '_page' => $page]
@@ -327,6 +340,59 @@ class PageRouter implements RouterInterface
     }
 
     /**
+     * When a MP parameter is given, the mount point parameter is resolved, and the slug of the new page
+     * is added while the same parts of the original pagePath is removed (before).
+     * This way, the subpage to a mounted page has now a different "base" (= prefixed with the slug of the
+     * mount point).
+     *
+     * This is done recursively when multiple mount point parameter pairs
+     *
+     * @param int $pageId
+     * @param string $pagePath the original path of the page
+     * @param array $mountPointPairs an array with MP pairs (like ['13-3', '4-2'] for recursive mount points)
+     * @param PageRepository $pageRepository
+     * @return string
+     */
+    protected function resolveMountPointParameterIntoPageSlug(
+        int $pageId,
+        string $pagePath,
+        array $mountPointPairs,
+        PageRepository $pageRepository
+    ): string {
+        // Handle recursive mount points
+        $prefixesToRemove = [];
+        $slugPrefixesToAdd = [];
+        foreach ($mountPointPairs as $mountPointPair) {
+            [$mountRoot, $mountedPage] = GeneralUtility::intExplode('-', $mountPointPair);
+            $mountPageInformation = $pageRepository->getMountPointInfo($mountedPage);
+            if ($mountPageInformation) {
+                if ($pageId === $mountedPage) {
+                    continue;
+                }
+                // Get slugs in the translated page
+                $mountedPage = $pageRepository->getPage($mountedPage);
+                $mountRoot = $pageRepository->getPage($mountRoot);
+                $slugPrefix = $mountedPage['slug'] ?? '';
+                $prefixToRemove = $mountRoot['slug'] ?? '';
+                $prefixesToRemove[] = $prefixToRemove;
+                $slugPrefixesToAdd[] = $slugPrefix;
+            }
+        }
+        $slugPrefixesToAdd = array_reverse($slugPrefixesToAdd);
+        $prefixesToRemove = array_reverse($prefixesToRemove);
+        foreach ($prefixesToRemove as $prefixToRemove) {
+            // Slug prefixes are taken from the beginning of the array, where as the parts to be removed
+            // Are taken from the end.
+            $replacement = array_shift($slugPrefixesToAdd);
+            if (strpos($pagePath, $prefixToRemove) === 0) {
+                $pagePath = substr($pagePath, strlen($prefixToRemove));
+            }
+            $pagePath = $replacement . '/' . ltrim($pagePath, '/');
+        }
+        return $pagePath;
+    }
+
+    /**
      * Fetch possible enhancers + aspects based on the current page configuration and the site configuration put
      * into "routeEnhancers"
      *
@@ -414,6 +480,10 @@ class PageRouter implements RouterInterface
         $page = $route->getOption('_page');
         $pageId = (int)($page['l10n_parent'] > 0 ? $page['l10n_parent'] : $page['uid']);
         $type = $this->resolveType($route, $remainingQueryParameters);
+        // See PageSlugCandidateProvider where this is added.
+        if ($page['MPvar']) {
+            $routeArguments['MP'] = $page['MPvar'];
+        }
         return new PageArguments($pageId, $type, $routeArguments, [], $remainingQueryParameters);
     }
 
index edfa10c..73dea06 100644 (file)
@@ -29,6 +29,7 @@ use TYPO3\CMS\Core\Site\Entity\Site;
 use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
 use TYPO3\CMS\Core\Site\SiteFinder;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Core\Utility\RootlineUtility;
 
 /**
  * Provides possible pages (from the database) that _could_ match a certain URL path,
@@ -198,7 +199,7 @@ class PageSlugCandidateProvider
             ->add(GeneralUtility::makeInstance(FrontendWorkspaceRestriction::class, null, null, $searchLiveRecordsOnly));
 
         $statement = $queryBuilder
-            ->select('uid', 'l10n_parent', 'pid', 'slug')
+            ->select('uid', 'l10n_parent', 'pid', 'slug', 'mount_pid', 'mount_pid_ol', 't3ver_state', 'doktype')
             ->from('pages')
             ->where(
                 $queryBuilder->expr()->eq(
@@ -223,13 +224,136 @@ class PageSlugCandidateProvider
         while ($row = $statement->fetch()) {
             $pageRepository->fixVersioningPid('pages', $row);
             $pageIdInDefaultLanguage = (int)($languageId > 0 ? $row['l10n_parent'] : $row['uid']);
+            $mountPageInformation = $pageRepository->getMountPointInfo($pageIdInDefaultLanguage, $row);
+            if ($mountPageInformation) {
+                // Add the MPvar to the row, so it can be used later-on in the PageRouter / PageArguments
+                $row['MPvar'] = $mountPageInformation['MPvar'];
+            }
+
             try {
-                if ($siteFinder->getSiteByPageId($pageIdInDefaultLanguage)->getRootPageId() === $this->site->getRootPageId()) {
+                $isOnSameSite = $siteFinder->getSiteByPageId($pageIdInDefaultLanguage)->getRootPageId() === $this->site->getRootPageId();
+                if ($isOnSameSite && !$row['mount_pid_ol']) {
                     $pages[] = $row;
                 }
             } catch (SiteNotFoundException $e) {
                 // Page is not in a site, so it's not considered
             }
+
+            if ($mountPageInformation) {
+                $mountedPage = $pageRepository->getPage_noCheck($mountPageInformation['mount_pid_rec']['uid']);
+                // Ensure to fetch the slug in the translated page
+                $mountedPage = $pageRepository->getPageOverlay($mountedPage, $languageId);
+                // Mount wasn't connected properly, so it is skipped
+                if (!$mountedPage) {
+                    continue;
+                }
+
+                $siteOfMountedPage = $siteFinder->getSiteByPageId((int)$mountedPage['uid']);
+                $morePageCandidates = $this->findPageCandidatesOfMountPoint(
+                    $row,
+                    $mountedPage,
+                    $siteOfMountedPage,
+                    $languageId,
+                    $slugCandidates
+                );
+                foreach ($morePageCandidates as $candidate) {
+                    $pages[] = $candidate;
+                }
+            }
+        }
+        return $pages;
+    }
+
+    /**
+     * Check if the page candidate is a mount point, if so, we need to
+     * re-start the slug candidates procedure with the mount point as a prefix (= context of the subpage).
+     *
+     * Before doing the slugCandidates are adapted to remove the slug of the mount point (actively moving the pointer
+     * of the path to strip away the existing prefix), then checking for more pages.
+     *
+     * Once possible candidates are found, the slug prefix needs to be re-added so the PageRouter finds the page,
+     * with an additional 'MPvar' attribute.
+     * However, all page candidates needs to be checked if they are connected in the proper mount page.
+     *
+     * @param array $mountPointPage the page with doktype=7
+     * @param array $mountedPage the target page where the mountpoint is pointing to
+     * @param Site $siteOfMountedPage the site of the target page, which could be different from the current page
+     * @param int $languageId the current language id
+     * @param array $slugCandidates the existing slug candidates that were looked for previously
+     * @return array more candidates
+     */
+    protected function findPageCandidatesOfMountPoint(
+        array $mountPointPage,
+        array $mountedPage,
+        Site $siteOfMountedPage,
+        int $languageId,
+        array $slugCandidates
+    ): array {
+        $pages = [];
+        $slugOfMountPoint = $mountPointPage['slug'] ?? '';
+        $commonSlugPrefixOfMountedPage = rtrim($mountedPage['slug'] ?? '', '/');
+        $narrowedDownSlugPrefixes = [];
+        foreach ($slugCandidates as $slugCandidate) {
+            // Remove the mount point prefix (that we just found) from the slug candidates
+            if (strpos($slugCandidate, $slugOfMountPoint) === 0) {
+                // Find pages without the common prefix
+                $narrowedDownSlugPrefix = '/' . trim(substr($slugCandidate, strlen($slugOfMountPoint)), '/');
+                $narrowedDownSlugPrefixes[] = $narrowedDownSlugPrefix;
+                $narrowedDownSlugPrefixes[] = $narrowedDownSlugPrefix . '/';
+                // Find pages with the prefix of the mounted page as well
+                if ($commonSlugPrefixOfMountedPage) {
+                    $narrowedDownSlugPrefix = $commonSlugPrefixOfMountedPage . $narrowedDownSlugPrefix;
+                    $narrowedDownSlugPrefixes[] = $narrowedDownSlugPrefix;
+                    $narrowedDownSlugPrefixes[] = $narrowedDownSlugPrefix . '/';
+                }
+            }
+        }
+        $trimmedSlugPrefixes = [];
+        $narrowedDownSlugPrefixes = array_unique($narrowedDownSlugPrefixes);
+        foreach ($narrowedDownSlugPrefixes as $narrowedDownSlugPrefix) {
+            $narrowedDownSlugPrefix = trim($narrowedDownSlugPrefix, '/');
+            $trimmedSlugPrefixes[] = '/' . $narrowedDownSlugPrefix;
+            if (!empty($narrowedDownSlugPrefix)) {
+                $trimmedSlugPrefixes[] = '/' . $narrowedDownSlugPrefix . '/';
+            }
+        }
+        $slugProviderForMountPage = GeneralUtility::makeInstance(static::class, $this->context, $siteOfMountedPage, $this->enhancerFactory);
+        // Find the right pages for which have been matched
+        $pageCandidates = $slugProviderForMountPage->getPagesFromDatabaseForCandidates(
+            $trimmedSlugPrefixes,
+            $languageId
+        );
+        // Depending on the "mount_pid_ol" parameter, the mountedPage or the mounted page is in the rootline
+        $pageWhichMustBeInRootLine = (int)($mountPointPage['mount_pid_ol'] ? $mountedPage['uid'] : $mountPointPage['uid']);
+        foreach ($pageCandidates as $pageCandidate) {
+            $pageCandidate['MPvar'] = $mountPointPage['MPvar'] . ($pageCandidate['MPvar'] ? ',' . $pageCandidate['MPvar'] : '');
+            // In order to avoid the possibility that any random page like /about-us which is not connected to the mount
+            // point is not possible to be called via /my-mount-point/about-us, let's check the
+            $pageCandidateIsConnectedInMountPoint = false;
+            $rootLine = GeneralUtility::makeInstance(
+                RootlineUtility::class,
+                $pageCandidate['uid'],
+                $pageCandidate['MPvar'],
+                $this->context
+            )->get();
+            foreach ($rootLine as $pageInRootLine) {
+                if ((int)$pageInRootLine['uid'] === $pageWhichMustBeInRootLine) {
+                    $pageCandidateIsConnectedInMountPoint = true;
+                    break;
+                }
+            }
+            if ($pageCandidateIsConnectedInMountPoint === false) {
+                continue;
+            }
+            // Rewrite the slug of the subpage to match the PageRouter matching again
+            // This is done by first removing the "common" prefix possibly provided by the Mounted Page
+            // But more importantly adding the $slugOfMountPoint of the MountPoint Page
+            $slugOfSubpage = $pageCandidate['slug'];
+            if ($commonSlugPrefixOfMountedPage && strpos($slugOfSubpage, $commonSlugPrefixOfMountedPage) === 0) {
+                $slugOfSubpage = substr($slugOfSubpage, strlen($commonSlugPrefixOfMountedPage));
+            }
+            $pageCandidate['slug'] = $slugOfMountPoint . ($slugOfSubpage ? '/' . trim($slugOfSubpage, '/') : '');
+            $pages[] = $pageCandidate;
         }
         return $pages;
     }
index 8d49491..0329069 100644 (file)
@@ -105,10 +105,11 @@ class SiteFinder
      *
      * @param int $pageId
      * @param array $rootLine
+     * @param string|null $mountPointParameter
      * @return Site
      * @throws SiteNotFoundException
      */
-    public function getSiteByPageId(int $pageId, array $rootLine = null): Site
+    public function getSiteByPageId(int $pageId, array $rootLine = null, string $mountPointParameter = null): Site
     {
         if ($pageId === 0) {
             // page uid 0 has no root line. We don't need to ask the root line resolver to know that.
@@ -116,7 +117,7 @@ class SiteFinder
         }
         if (!is_array($rootLine)) {
             try {
-                $rootLine = GeneralUtility::makeInstance(RootlineUtility::class, $pageId)->get();
+                $rootLine = GeneralUtility::makeInstance(RootlineUtility::class, $pageId, $mountPointParameter)->get();
             } catch (PageNotFoundException $e) {
                 // Usually when a page was hidden or disconnected
                 // This could be improved by handing in a Context object and decide whether hidden pages
diff --git a/typo3/sysext/core/Documentation/Changelog/9.5.x/Feature-86331-NativeURLSupportForMountPoints.rst b/typo3/sysext/core/Documentation/Changelog/9.5.x/Feature-86331-NativeURLSupportForMountPoints.rst
new file mode 100644 (file)
index 0000000..4ec6b50
--- /dev/null
@@ -0,0 +1,53 @@
+.. include:: ../../Includes.txt
+
+====================================================
+Feature: #86331 - Native URL support for MountPoints
+====================================================
+
+See :issue:`86331`
+
+Description
+===========
+
+MountPoints allow TYPO3 editors to mount a page (and its subpages) from a different area of the site in the current page tree.
+
+The definitions are as follows:
+- MountPoint Page: A page with doktype=7 - a page pointing to a different page ("web mount") that should act as replacement for this page and possible descendants.
+- Mounted Page a.k.a. "Mount Target": A regular page containing content and subpages.
+
+The idea behind it is to manage content only once and "link" / "mount" to a tree to be used multiple times - while keeping the website visitor under the impression to actually navigate just a regular subpage. There are concerns regarding SEO for having duplicate content, but TYPO3 can be used for more than just simple websites, as Mount Points are an important tool for heavy multi-site installations or Intranet/Extranet installations.
+
+A MountPoint Page has the option to either display the content of the MountPoint page itself, or the content of the target page, when visiting this page.
+
+Linking to a subpage will result in adding "MP" GET Parameters, and altering the root line (tree structure) of visiting the website, as the "MP" is containing the context. The MP parameter found throughout TYPO3 Core contains the ID of the Mounted Page and the ID of the MountPoint page - e.g. "13-23" whereas 13 would be the Mounted Page and 23 the MountPoint page (doktype=7).
+
+Recursive mount points are added to the "MP" parameter with ",", like "13-23,84-26". Recursive mount points are defined as follows: A Mounted Page could have a subpage which in turn have a subpage which is again a MountPoint page again.
+
+MountPoint support is now added in TYPO3 v9 with Site Handling and slug handling. Due to TYPO3's principles of slug handling where a page only contains one single slug containing the URL path, and not various slugs for different places where it might be used, TYPO3 will work by combining the slug of the MountPoint page and a smaller part of the Mounted Page or subpages of the Mounted Page, which will be added to the URL string - removing the necessity to actually deal with the query parameter `MP` which will never be added again, as it is part of the URL path now.
+
+Using MountPoint functionality on a website plays an important role for menus as this is the only way to actually link to the subpages in a MountPoint context.
+
+.. Multi-Site support
+The context for cross-domain sites is also kept, ensuring that the user will never notice that content might be coming from a completely different site / pagetree within TYPO3.
+Creating links for multi-site support is the same as if a Mounted Page is on the same site.
+
+
+Impact
+======
+
+Limitations:
+
+1. Multi-language support
+Please be aware that multi-language setups are supported in general, but this would only fit if both sites support the same language IDs.
+
+2. Slug Uniqueness when using Multi-Site setups not possible
+If a MountPoint Page has the slug "/more", mounting a page with "/imprint" subpage, but the MountPoint Page has a regular sibling page with "/more/imprint" a collision cannot be detected, whereas the non-mounted page would always work and a subpage of a Mounted Page.
+would never be reached.
+
+For the sake of completeness, please consider the TYPO3 documentation on the following TypoScript properties related to mount points:
+
+- :typoscript:`config.MP_defaults`
+- :typoscript:`config.MP_mapRootPoints`
+- :typoscript:`config.MP_disableTypolinkClosestMPvalue`
+
+.. index:: Frontend, ext:frontend
index 6452433..5deb979 100644 (file)
@@ -171,7 +171,7 @@ class PageLinkBuilder extends AbstractTypolinkBuilder
 
         // Check if the target page has a site configuration
         try {
-            $siteOfTargetPage = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByPageId((int)$page['uid']);
+            $siteOfTargetPage = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByPageId((int)$page['uid'], null, $queryParameters['MP'] ?? '');
             $currentSite = $this->getCurrentSite();
         } catch (SiteNotFoundException $e) {
             // Usually happens in tests, as sites with configuration should be available everywhere.
index 9a70b7b..ceb52c8 100644 (file)
@@ -691,15 +691,15 @@ class SlugLinkGeneratorTest extends AbstractTestCase
                         'children' => [
                             [
                                 'title' => 'Markets',
-                                'link' => 'https://common.acme.com/common/markets?MP=7100-1700',
+                                'link' => '/news/common/markets',
                             ],
                             [
                                 'title' => 'Products',
-                                'link' => 'https://common.acme.com/common/products?MP=7100-1700',
+                                'link' => '/news/common/products',
                             ],
                             [
                                 'title' => 'Partners',
-                                'link' => 'https://common.acme.com/common/partners?MP=7100-1700',
+                                'link' => '/news/common/partners',
                             ],
                         ],
                     ],
@@ -732,15 +732,15 @@ class SlugLinkGeneratorTest extends AbstractTestCase
                             'children' => [
                                 [
                                     'title' => 'Markets',
-                                    'link' => 'https://common.acme.com/common/markets?MP=7100-2700',
+                                    'link' => '/news/common/markets',
                                 ],
                                 [
                                     'title' => 'Products',
-                                    'link' => 'https://common.acme.com/common/products?MP=7100-2700',
+                                    'link' => '/news/common/products',
                                 ],
                                 [
                                     'title' => 'Partners',
-                                    'link' => 'https://common.acme.com/common/partners?MP=7100-2700',
+                                    'link' => '/news/common/partners',
                                 ],
                             ],
                         ],