Skip to content

Commit

Permalink
[FEATURE] Introduce Page-based URL handling
Browse files Browse the repository at this point in the history
This feature adds a new database field "pages.slug" which
allows to fill the database with URL segments which can then
be resolved and built with for URLs for a specific page.

On top, when a site is found with a proper "slug", the
PageRouter of a site now resolves a /home/my-products/
to the correct page ID.

Next steps:
- Add URL enhancers API to allow to further resolve more parts.

Resolves: #85947
Releases: master
Change-Id: Ic64a758e847520b9a8dfc8b484c7613c9ba1f869
Reviewed-on: https://review.typo3.org/57994
Tested-by: Björn Jacob <bjoern.jacob@tritum.de>
Reviewed-by: Christian Kuhn <lolli@schwarzbu.ch>
Tested-by: Christian Kuhn <lolli@schwarzbu.ch>
Reviewed-by: Susanne Moog <susanne.moog@typo3.org>
Tested-by: Susanne Moog <susanne.moog@typo3.org>
Reviewed-by: Wouter Wolters <typo3@wouterwolters.nl>
Reviewed-by: Andreas Fernandez <a.fernandez@scripting-base.de>
Tested-by: Andreas Fernandez <a.fernandez@scripting-base.de>
Tested-by: TYPO3com <no-reply@typo3.com>
Reviewed-by: Richard Haeser <richard@maxserv.com>
Reviewed-by: Oliver Hader <oliver.hader@typo3.org>
Tested-by: Oliver Hader <oliver.hader@typo3.org>
  • Loading branch information
bmack authored and ohader committed Aug 23, 2018
1 parent e85d86f commit 6308b46
Show file tree
Hide file tree
Showing 14 changed files with 411 additions and 34 deletions.
2 changes: 1 addition & 1 deletion composer.json
Expand Up @@ -68,7 +68,7 @@
"fiunchinho/phpunit-randomizer": "^4.0",
"friendsofphp/php-cs-fixer": "^2.12.2",
"typo3/cms-styleguide": "~9.2.0",
"typo3/testing-framework": "~4.6.0"
"typo3/testing-framework": "~4.6.1"
},
"suggest": {
"ext-gd": "GDlib/Freetype is required for building images with text (GIFBUILDER) and can also be used to scale images",
Expand Down
12 changes: 6 additions & 6 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Expand Up @@ -36,7 +36,7 @@ class SiteResolving implements FormDataProviderInterface
*/
public function addData(array $result): array
{
$pageIdDefaultLanguage = $result['defaultLanguagePageRow']['uid'] ?? $result['effectivePid'];
$pageIdDefaultLanguage = (int)($result['defaultLanguagePageRow']['uid'] ?? $result['effectivePid']);
$result['site'] = GeneralUtility::makeInstance(SiteMatcher::class)->matchByPageId($pageIdDefaultLanguage);
return $result;
}
Expand Down
3 changes: 3 additions & 0 deletions typo3/sysext/backend/Classes/Utility/BackendUtility.php
Expand Up @@ -396,6 +396,7 @@ public static function BEgetRootLine($uid, $clause = '', $workspaceOL = false, a
'pid' => null,
'title' => '',
'doktype' => null,
'slug' => null,
'tsconfig_includes' => null,
'TSconfig' => null,
'is_siteroot' => null,
Expand All @@ -414,6 +415,7 @@ public static function BEgetRootLine($uid, $clause = '', $workspaceOL = false, a
'pid',
'title',
'doktype',
'slug',
'tsconfig_includes',
'TSconfig',
'is_siteroot',
Expand Down Expand Up @@ -464,6 +466,7 @@ protected static function getPageForRootline($uid, $clause, $workspaceOL, array
'uid',
'title',
'doktype',
'slug',
'tsconfig_includes',
'TSconfig',
'is_siteroot',
Expand Down
168 changes: 168 additions & 0 deletions typo3/sysext/core/Classes/Routing/PageRouter.php
@@ -0,0 +1,168 @@
<?php
declare(strict_types = 1);

namespace TYPO3\CMS\Core\Routing;

/*
* 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 Doctrine\DBAL\Connection;
use Psr\Http\Message\ServerRequestInterface;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
use Symfony\Component\Routing\Matcher\UrlMatcher;
use Symfony\Component\Routing\RequestContext;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer;
use TYPO3\CMS\Core\Site\Entity\SiteInterface;
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
use TYPO3\CMS\Core\Utility\GeneralUtility;

/**
* Page Router looking up the slug of the page path.
*
* This is done via the "Route Candidate" pattern.
*
* Example:
* - /about-us/team/management/
*
* will look for all pages that have
* - /about-us
* - /about-us/
* - /about-us/team
* - /about-us/team/
* - /about-us/team/management
* - /about-us/team/management/
*
* And create route candidates for that.
*
* PageRouter does not restrict the HTTP method or is bound to any domain constraints,
* as the SiteMatcher has done that already.
*
* @internal This API is not public yet and might change in the future, until TYPO3 v9 or TYPO3 v10.
*/
class PageRouter
{
/**
* @param ServerRequestInterface $request
* @param string $routePath
* @param SiteInterface $site
* @param SiteLanguage $language
* @return array|null
*/
public function matchRoute(ServerRequestInterface $request, string $routePath, SiteInterface $site, SiteLanguage $language): ?array
{
$slugCandidates = $this->getCandidateSlugsFromRoutePath($routePath);
if (empty($slugCandidates)) {
return null;
}
$pageCandidates = $this->getPagesFromDatabaseForCandidates($slugCandidates, $site, $language->getLanguageId());
// Stop if there are no candidates
if (empty($pageCandidates)) {
return null;
}

$collection = new RouteCollection();
foreach ($pageCandidates ?? [] as $page) {
$path = $page['slug'];
$route = new Route(
$path . '{next}',
['page' => $page, 'next' => ''],
['next' => '.*'],
['utf8' => true]
);
$collection->add('page_' . $page['uid'], $route);
}

$context = new RequestContext('/', $request->getMethod(), $request->getUri()->getHost());
$matcher = new UrlMatcher($collection, $context);
try {
return $matcher->match('/' . $routePath);
} catch (ResourceNotFoundException $e) {
return null;
}
}

/**
* Check for records in the database which matches one of the slug candidates.
*
* @param array $slugCandidates
* @param SiteInterface $site
* @param int $languageId
* @return array
*/
protected function getPagesFromDatabaseForCandidates(array $slugCandidates, SiteInterface $site, int $languageId): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('pages');
$queryBuilder
->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class));

$statement = $queryBuilder
->select('uid', 'l10n_parent', 'pid', 'slug')
->from('pages')
->where(
$queryBuilder->expr()->eq(
'sys_language_uid',
$queryBuilder->createNamedParameter($languageId, \PDO::PARAM_INT)
),
$queryBuilder->expr()->in(
'slug',
$queryBuilder->createNamedParameter(
$slugCandidates,
Connection::PARAM_STR_ARRAY
)
)
)
// Exact match will be first, that's important
->orderBy('slug', 'desc')
->execute();

$pages = [];
$siteMatcher = GeneralUtility::makeInstance(SiteMatcher::class);
while ($row = $statement->fetch()) {
$pageIdInDefaultLanguage = (int)($languageId > 0 ? $row['l10n_parent'] : $row['uid']);
if ($siteMatcher->matchByPageId($pageIdInDefaultLanguage)->getRootPageId() === $site->getRootPageId()) {
$pages[] = $row;
}
}
return $pages;
}

/**
* Returns possible URL parts for a string like /home/about-us/offices/
* to return
* /home/about-us/offices/
* /home/about-us/offices
* /home/about-us/
* /home/about-us
* /home/
* /home
*
* @param string $routePath
* @return array
*/
protected function getCandidateSlugsFromRoutePath(string $routePath): array
{
$candidatePathParts = [];
$pathParts = GeneralUtility::trimExplode('/', $routePath, true);
while (!empty($pathParts)) {
$prefix = '/' . implode('/', $pathParts);
$candidatePathParts[] = $prefix . '/';
$candidatePathParts[] = $prefix;
array_pop($pathParts);
}
return $candidatePathParts;
}
}
26 changes: 24 additions & 2 deletions typo3/sysext/core/Classes/Routing/PageUriBuilder.php
Expand Up @@ -17,6 +17,7 @@
*/

use Psr\Http\Message\UriInterface;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
use TYPO3\CMS\Core\Http\Uri;
use TYPO3\CMS\Core\SingletonInterface;
Expand Down Expand Up @@ -92,9 +93,22 @@ public function buildUri(int $pageId, array $queryParameters = [], string $fragm
// Only if a language is configured for the site, build a URL with a site prefix / base
if ($siteLanguage) {
unset($options['legacyUrlPrefix']);
$prefix = $siteLanguage->getBase() . '?id=' . $alternativePageId;
// Ensure to fetch the path segment / slug if it exists
if ($siteLanguage->getLanguageId() > 0) {
$pageLocalizations = BackendUtility::getRecordLocalization('pages', $pageId, $siteLanguage->getLanguageId());
$pageRecord = $pageLocalizations[0] ?? false;
} else {
$pageRecord = BackendUtility::getRecord('pages', $pageId);
}
$prefix = $siteLanguage->getBase();
if (!empty($pageRecord['slug'] ?? '')) {
$prefix = rtrim($prefix, '/') . '/' . ltrim($pageRecord['slug'], '/');
} else {
$prefix .= '?id=' . $alternativePageId;
}
} else {
// If nothing is found, use index.php?id=123&additionalParams
// This usually kicks in with "PseudoSites" where no language object can be determined.
$prefix = $options['legacyUrlPrefix'] ?? null;
if ($prefix === null) {
$prefix = $referenceType === self::ABSOLUTE_URL ? GeneralUtility::getIndpEnv('TYPO3_SITE_URL') : '';
Expand All @@ -107,7 +121,15 @@ public function buildUri(int $pageId, array $queryParameters = [], string $fragm

// Add the query parameters as string
$queryString = http_build_query($queryParameters, '', '&', PHP_QUERY_RFC3986);
$uri = new Uri($prefix . ($queryString ? '&' . $queryString : ''));
$prefix = rtrim($prefix, '?');
if (!empty($queryString)) {
if (strpos($prefix, '?') === false) {
$prefix .= '?';
} else {
$prefix .= '&';
}
}
$uri = new Uri($prefix . $queryString);
if ($fragment) {
$uri = $uri->withFragment($fragment);
}
Expand Down
14 changes: 14 additions & 0 deletions typo3/sysext/core/Classes/Site/Entity/Site.php
Expand Up @@ -23,6 +23,8 @@
use TYPO3\CMS\Core\Error\PageErrorHandler\PageErrorHandlerInterface;
use TYPO3\CMS\Core\Error\PageErrorHandler\PageErrorHandlerNotConfiguredException;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Routing\PageRouter;
use TYPO3\CMS\Core\Routing\RouterInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;

/**
Expand Down Expand Up @@ -317,6 +319,18 @@ protected function sanitizeBaseUrl(string $base): string
return $base;
}

/**
* Returns applicable routers for this site
*
* @return RouterInterface[]
*/
public function getRouters(): array
{
return [
new PageRouter()
];
}

/**
* Shorthand functionality for fetching the language service
* @return LanguageService
Expand Down
19 changes: 17 additions & 2 deletions typo3/sysext/core/Configuration/TCA/pages.php
Expand Up @@ -151,6 +151,21 @@
'cols' => 30
]
],
'slug' => [
'exclude' => true,
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:pages.slug',
'config' => [
'type' => 'slug',
'generatorOptions' => [
'fields' => ['title'],
'fieldSeparator' => '/',
'prefixParentPageSlug' => true
],
'fallbackCharacter' => '-',
'eval' => 'uniqueInSite',
'default' => ''
]
],
'TSconfig' => [
'exclude' => true,
'l10n_mode' => 'exclude',
Expand Down Expand Up @@ -1120,11 +1135,11 @@
],
'title' => [
'label' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.palettes.title',
'showitem' => 'title;LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.title_formlabel, --linebreak--, nav_title;LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.nav_title_formlabel, --linebreak--, subtitle;LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.subtitle_formlabel',
'showitem' => 'title;LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.title_formlabel, --linebreak--, slug, --linebreak--, nav_title;LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.nav_title_formlabel, --linebreak--, subtitle;LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.subtitle_formlabel',
],
'titleonly' => [
'label' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.palettes.title',
'showitem' => 'title;LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.title_formlabel',
'showitem' => 'title;LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.title_formlabel, --linebreak--, slug',
],
'visibility' => [
'label' => 'LLL:EXT:frontend/Resources/Private/Language/locallang_tca.xlf:pages.palettes.visibility',
Expand Down

0 comments on commit 6308b46

Please sign in to comment.