[FEATURE] XML Sitemap 57/58057/26
authorRichard Haeser <richard@maxserv.com>
Tue, 28 Aug 2018 05:59:29 +0000 (07:59 +0200)
committerSusanne Moog <susanne.moog@typo3.org>
Sat, 1 Sep 2018 21:47:56 +0000 (23:47 +0200)
It is now possible to generate XML sitemaps for SEO purposes without
using 3rd-party plugins. When enabled, this new feature will create a
sitemapindex with one or more sitemaps in it. Out-of-the-box it will
have one sitemap containing all the pages of the current site and
language. Per site and per language you have the possibility to render
a different sitemap.

Resolves: #84525
Releases: master
Change-Id: Iad74b114b9dd37dbc4dd72e244437691fb8c31b5
Reviewed-on: https://review.typo3.org/58057
Reviewed-by: Björn Jacob <bjoern.jacob@tritum.de>
Reviewed-by: Richard Haeser <richard@maxserv.com>
Tested-by: Richard Haeser <richard@maxserv.com>
Tested-by: Kevin Appelt <kevin.appelt@icloud.com>
Tested-by: TYPO3com <no-reply@typo3.com>
Reviewed-by: Susanne Moog <susanne.moog@typo3.org>
Tested-by: Susanne Moog <susanne.moog@typo3.org>
17 files changed:
typo3/sysext/core/Documentation/Changelog/master/Feature-84525-XMLSitemap.rst [new file with mode: 0644]
typo3/sysext/seo/Classes/XmlSitemap/AbstractXmlSitemapDataProvider.php [new file with mode: 0644]
typo3/sysext/seo/Classes/XmlSitemap/Exception/InvalidConfigurationException.php [new file with mode: 0644]
typo3/sysext/seo/Classes/XmlSitemap/Exception/MissingConfigurationException.php [new file with mode: 0644]
typo3/sysext/seo/Classes/XmlSitemap/PagesXmlSitemapDataProvider.php [new file with mode: 0644]
typo3/sysext/seo/Classes/XmlSitemap/RecordsXmlSitemapDataProvider.php [new file with mode: 0644]
typo3/sysext/seo/Classes/XmlSitemap/XmlSitemapDataProviderInterface.php [new file with mode: 0644]
typo3/sysext/seo/Classes/XmlSitemap/XmlSitemapRenderer.php [new file with mode: 0644]
typo3/sysext/seo/Configuration/TCA/Overrides/sys_template.php [new file with mode: 0644]
typo3/sysext/seo/Configuration/TypoScript/XmlSitemap/constants.typoscript [new file with mode: 0644]
typo3/sysext/seo/Configuration/TypoScript/XmlSitemap/setup.typoscript [new file with mode: 0644]
typo3/sysext/seo/Resources/Private/Templates/XmlSitemap/Index.xml [new file with mode: 0644]
typo3/sysext/seo/Resources/Private/Templates/XmlSitemap/Sitemap.xml [new file with mode: 0644]
typo3/sysext/seo/Resources/Public/CSS/Sitemap.xsl [new file with mode: 0644]
typo3/sysext/seo/Tests/Functional/Fixtures/pages-sitemap.xml [new file with mode: 0644]
typo3/sysext/seo/Tests/Functional/XmlSitemap/XmlSitemapIndexTest.php [new file with mode: 0644]
typo3/sysext/seo/Tests/Unit/XmlSitemap/PagesXmlSitemapDataProviderTest.php [new file with mode: 0644]

diff --git a/typo3/sysext/core/Documentation/Changelog/master/Feature-84525-XMLSitemap.rst b/typo3/sysext/core/Documentation/Changelog/master/Feature-84525-XMLSitemap.rst
new file mode 100644 (file)
index 0000000..20fdb7b
--- /dev/null
@@ -0,0 +1,94 @@
+.. include:: ../../Includes.txt
+
+=============================
+Feature: #84525 - XML Sitemap
+=============================
+
+See :issue:`84525`
+
+Description
+===========
+
+It is now possible to generate XML sitemaps for SEO purposes without using 3rd-party plugins.
+When enabled, this new feature will create a sitemapindex with one or more sitemaps in it.
+Out-of-the-box it will have one sitemap containing all the pages of the current site and
+language. Per site and per language you have the possibility to render a different sitemap.
+
+Installation
+------------
+The XML sitemap is disabled by default. You can easily enable it by installing the system
+extension "seo" and including the static TypoScript template XML Sitemap (seo). It is also
+mandatory to have a site configuration for your rootpage(s).
+
+How to access your XML sitemap
+------------------------------
+Until it is possible to have a default route with the new URL handling mechanism, you can access
+the sitemaps by going to https://yourdomain.com/?type=1533906435. You will first see the sitemap
+index. By default you will see one sitemap in the index. This is the sitemap for pages.
+
+If you have multiple siteroots or multiple languages with different domains or language prefixes,
+you can just go to the domain that handles the siteroot / language. The sitemap will be based on
+the settings for that domain.
+
+XmlSitemapDataProviders
+-----------------------
+The rendering of sitemaps is based on XmlSitemapDataProviders. The EXT:seo extension ships with two
+XmlSitemapDataProviders. The first one is the PagesXmlSitemapDataProvider. This will generate a sitemap
+of pages based on the siteroot that is detected. You can configure if you have additional conditions
+for the selection of pages. You also have the possibility to exclude certain doktypes.
+
+.. code-block:: typoscript
+
+   plugin.tx_seo {
+     config {
+       xmlSitemap {
+         sitemaps {
+           pages {
+             config {
+               excludedDoktypes = 137, 138
+               additionalWhere = AND (no_index = 0 OR no_follow = 0)
+             }
+           }
+         }
+       }
+     }
+   }
+
+If you also have an extension installed and want a sitemap of those records, you can use the
+RecordsXmlSitemapDataProvider. You can add for example a sitemap for news records:
+
+.. code-block:: typoscript
+
+   plugin.tx_seo {
+     config {
+       xmlSitemap {
+         sitemaps {
+            <unique key> {
+               provider = TYPO3\CMS\Seo\XmlSitemap\RecordsXmlSitemapDataProvider
+               config {
+                  table = news_table
+                  sortField = sorting
+                  lastModifiedField = tstamp
+                  additionalWhere = AND (no_index = 0 OR no_follow = 0)
+                  pid = <page id('s) containing news records>
+                  url {
+                     pageId = <your detail page id>
+                     fieldToParameterMap {
+                        uid = tx_extension_pi1[news]
+                     }
+                     additionalGetParameters {
+                        tx_extension_pi1.controller = News
+                        tx_extension_pi1.action = detail
+                     }
+                     useCacheHash = 1
+                  }
+               }
+            }
+         }
+       }
+     }
+   }
+
+You can add several sitemaps and those will be added to the sitemap index automatically.
+
+.. index:: Frontend, ext:seo
diff --git a/typo3/sysext/seo/Classes/XmlSitemap/AbstractXmlSitemapDataProvider.php b/typo3/sysext/seo/Classes/XmlSitemap/AbstractXmlSitemapDataProvider.php
new file mode 100644 (file)
index 0000000..acaca3e
--- /dev/null
@@ -0,0 +1,88 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Seo\XmlSitemap;
+
+use Psr\Http\Message\ServerRequestInterface;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
+
+/**
+ * Base class for XmlSitemapProviders to extend
+ */
+abstract class AbstractXmlSitemapDataProvider implements XmlSitemapDataProviderInterface
+{
+    /**
+     * @var string
+     */
+    protected $key;
+
+    /**
+     * @var int
+     */
+    protected $lastModified;
+
+    /**
+     * @var array
+     */
+    protected $items = [];
+
+    /**
+     * @var array
+     */
+    protected $config = [];
+
+    /**
+     * @var ContentObjectRenderer
+     */
+    protected $cObj;
+
+    /**
+     * AbstractXmlSitemapDataProvider constructor
+     *
+     * @param \Psr\Http\Message\ServerRequestInterface $request
+     * @param string $key
+     * @param array $config
+     * @param ContentObjectRenderer $cObj
+     */
+    public function __construct(ServerRequestInterface $request, string $key, array $config = [], ContentObjectRenderer $cObj = null)
+    {
+        $this->key = $key;
+        $this->config = $config;
+
+        if ($cObj === null) {
+            $cObj = GeneralUtility::makeInstance(ContentObjectRenderer::class);
+        }
+        $this->cObj = $cObj;
+    }
+
+    /**
+     * @return string
+     */
+    public function getKey(): string
+    {
+        return $this->key;
+    }
+
+    /**
+     * @return int
+     */
+    public function getLastModified(): int
+    {
+        $lastMod = 0;
+        foreach ($this->items as $item) {
+            if ((int)$item['lastMod'] > $lastMod) {
+                $lastMod = $item['lastMod'];
+            }
+        }
+
+        return $lastMod;
+    }
+
+    /**
+     * @return array
+     */
+    public function getItems(): array
+    {
+        return (array)$this->items;
+    }
+}
diff --git a/typo3/sysext/seo/Classes/XmlSitemap/Exception/InvalidConfigurationException.php b/typo3/sysext/seo/Classes/XmlSitemap/Exception/InvalidConfigurationException.php
new file mode 100644 (file)
index 0000000..0321156
--- /dev/null
@@ -0,0 +1,20 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Seo\XmlSitemap\Exception;
+
+/*
+ * 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!
+ */
+
+class InvalidConfigurationException extends \TYPO3\CMS\Core\Resource\Exception
+{
+}
diff --git a/typo3/sysext/seo/Classes/XmlSitemap/Exception/MissingConfigurationException.php b/typo3/sysext/seo/Classes/XmlSitemap/Exception/MissingConfigurationException.php
new file mode 100644 (file)
index 0000000..322f8ef
--- /dev/null
@@ -0,0 +1,20 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Seo\XmlSitemap\Exception;
+
+/*
+ * 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!
+ */
+
+class MissingConfigurationException extends \TYPO3\CMS\Core\Resource\Exception
+{
+}
diff --git a/typo3/sysext/seo/Classes/XmlSitemap/PagesXmlSitemapDataProvider.php b/typo3/sysext/seo/Classes/XmlSitemap/PagesXmlSitemapDataProvider.php
new file mode 100644 (file)
index 0000000..b93e115
--- /dev/null
@@ -0,0 +1,141 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Seo\XmlSitemap;
+
+/*
+ * 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 Psr\Http\Message\ServerRequestInterface;
+use TYPO3\CMS\Core\Context\Context;
+use TYPO3\CMS\Core\Context\LanguageAspect;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
+use TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController;
+
+/**
+ * Class to generate a XML sitemap for pages
+ */
+class PagesXmlSitemapDataProvider extends AbstractXmlSitemapDataProvider
+{
+    public function __construct(ServerRequestInterface $request, string $key, array $config = [], ContentObjectRenderer $cObj = null)
+    {
+        parent::__construct($request, $key, $config, $cObj);
+
+        $this->generateItems($request);
+    }
+
+    /**
+     * @param \Psr\Http\Message\ServerRequestInterface $request
+     */
+    public function generateItems(ServerRequestInterface $request): void
+    {
+        $site = $request->getAttribute('site');
+        $rootPageId = $site->getRootPageId();
+
+        $additionalWhere = $this->config['additionalWhere'] ?? '';
+        if (!empty($this->config['excludedDoktypes'])) {
+            $excludedDoktypes = GeneralUtility::trimExplode(',', $this->config['excludedDoktypes']);
+            if (!empty($excludedDoktypes)) {
+                $additionalWhere .= ' AND doktype NOT IN (' . implode(',', $excludedDoktypes) . ')';
+            }
+        }
+
+        $rootPage = $this->getTypoScriptFrontendController()->page;
+        $pages = [
+            [
+                'uid' => $rootPage['uid'],
+                'tstamp' => $rootPage['tstamp'],
+                'l18n_cfg' => $rootPage['l18n_cfg'],
+                'SYS_LASTCHANGED' => $rootPage['SYS_LASTCHANGED']
+            ]
+        ];
+
+        $pages = $this->getSubPages($rootPageId, $pages, ltrim($additionalWhere));
+
+        $languageId = $this->getCurrentLanguageAspect()->getId();
+        foreach ($pages as $page) {
+            /**
+             * @todo Checking if the page has to be shown/hidden should normally be handled by the
+             * PageRepository but to prevent major breaking changes this is checked here for now
+             */
+            if (
+                !(
+                    GeneralUtility::hideIfDefaultLanguage($page['l18n_cfg'])
+                    && (!$languageId || ($languageId && !$page['_PAGES_OVERLAY']))
+                )
+                &&
+                !(
+                    $languageId
+                    && GeneralUtility::hideIfNotTranslated($page['l18n_cfg'])
+                    && !$page['_PAGES_OVERLAY']
+                )
+            ) {
+                $typoLinkConfig = [
+                    'parameter' => $page['uid'],
+                    'forceAbsoluteUrl' => 1,
+                ];
+
+                $loc = $this->cObj->typoLink_URL($typoLinkConfig);
+                $lastMod = $page['SYS_LASTCHANGED'] ?: $page['tstamp'];
+
+                $this->items[] = [
+                    'loc' => $loc,
+                    'lastMod' => (int)$lastMod
+                ];
+            }
+        }
+    }
+
+    /**
+     * Get subpages
+     *
+     * @param int $parentPageId
+     * @param array $pages
+     * @param string $additionalWhere
+     * @return array
+     */
+    protected function getSubPages(int $parentPageId, array $pages = [], $additionalWhere = ''): array
+    {
+        $subPages = $this->getTypoScriptFrontendController()->sys_page->getMenu(
+            $parentPageId,
+            'uid, tstamp, SYS_LASTCHANGED, l18n_cfg',
+            'sorting',
+            $additionalWhere,
+            false
+        );
+        $pages = array_merge($pages, $subPages);
+
+        foreach ($subPages as $subPage) {
+            $pages = $this->getSubPages((int)$subPage['uid'], $pages, $additionalWhere);
+        }
+
+        return $pages;
+    }
+
+    /**
+     * @return TypoScriptFrontendController
+     */
+    protected function getTypoScriptFrontendController(): TypoScriptFrontendController
+    {
+        return $GLOBALS['TSFE'];
+    }
+
+    /**
+     * @return LanguageAspect
+     * @throws \TYPO3\CMS\Core\Context\Exception\AspectNotFoundException
+     */
+    protected function getCurrentLanguageAspect(): LanguageAspect
+    {
+        return GeneralUtility::makeInstance(Context::class)->getAspect('language');
+    }
+}
diff --git a/typo3/sysext/seo/Classes/XmlSitemap/RecordsXmlSitemapDataProvider.php b/typo3/sysext/seo/Classes/XmlSitemap/RecordsXmlSitemapDataProvider.php
new file mode 100644 (file)
index 0000000..0d9b06e
--- /dev/null
@@ -0,0 +1,136 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Seo\XmlSitemap;
+
+use Psr\Http\Message\ServerRequestInterface;
+use TYPO3\CMS\Core\Database\ConnectionPool;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
+use TYPO3\CMS\Seo\XmlSitemap\Exception\MissingConfigurationException;
+
+/**
+ * XmlSiteDataProvider will provide information for the XML sitemap for a specific database table
+ */
+class RecordsXmlSitemapDataProvider extends AbstractXmlSitemapDataProvider
+{
+    /**
+     * @param string $key
+     * @param array $config
+     * @param ContentObjectRenderer|null $cObj
+     * @throws MissingConfigurationException
+     */
+    public function __construct(ServerRequestInterface $request, string $key, array $config = [], ContentObjectRenderer $cObj = null)
+    {
+        parent::__construct($request, $key, $config, $cObj);
+
+        $this->generateItems();
+    }
+
+    /**
+     * @throws MissingConfigurationException
+     */
+    public function generateItems(): void
+    {
+        if (empty($this->config['table'])) {
+            throw new MissingConfigurationException(
+                'No configuration found for sitemap ' . $this->getKey(),
+                1535576053
+            );
+        }
+
+        $pids = GeneralUtility::intExplode(',', $this->config['pid']) ?? $GLOBALS['TSFE']->id;
+        $lastModifiedField = $this->config['lastModifiedField'] ?? 'tstamp';
+        $sortField = $this->config['sortField'] ?? 'sorting';
+
+        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
+            ->getQueryBuilderForTable($this->config['table']);
+
+        $constraints = [
+            $queryBuilder->expr()->in('pid', $pids)
+        ];
+
+        if (!empty($this->config['additionalWhere'])) {
+            $constraints[] = $this->config['additionalWhere'];
+        }
+
+        $rows = $queryBuilder->select('*')
+            ->from($this->config['table'])
+            ->where(
+                ...$constraints
+            )
+            ->orderBy($sortField)
+            ->execute()
+            ->fetchAll();
+
+        foreach ($rows as $row) {
+            $this->items[] = [
+                'loc' => $this->defineUrl($row),
+                'lastMod' => $row[$lastModifiedField]
+            ];
+        }
+    }
+
+    /**
+     * @param array $data
+     * @return string
+     */
+    protected function defineUrl(array $data): string
+    {
+        $pageId = $this->config['url']['pageId'] ?? $GLOBALS['TSFE']->id;
+        $additionalParams = [];
+
+        $additionalParams = $this->getUrlFieldParameterMap($additionalParams, $data);
+        $additionalParams = $this->getUrlAdditionalParams($additionalParams);
+
+        $additionalParamsString = http_build_query(
+            $additionalParams,
+            '',
+            '&',
+            PHP_QUERY_RFC3986
+        );
+
+        $typoLinkConfig = [
+            'parameter' => $pageId,
+            'additionalParams' => $additionalParamsString ? '&' . $additionalParamsString : '',
+            'forceAbsoluteUrl' => 1,
+            'useCacheHash' => $this->config['url']['useCacheHash'] ?? 0
+        ];
+
+        return $this->cObj->typoLink_URL($typoLinkConfig);
+    }
+
+    /**
+     * @param array $additionalParams
+     * @param array $data
+     * @return array
+     */
+    protected function getUrlFieldParameterMap(array $additionalParams, array $data): array
+    {
+        if (!empty($this->config['url']['fieldToParameterMap']) &&
+            \is_array($this->config['url']['fieldToParameterMap'])) {
+            foreach ($this->config['url']['fieldToParameterMap'] as $field => $urlPart) {
+                $additionalParams[$urlPart] = $data[$field];
+            }
+        }
+
+        return $additionalParams;
+    }
+
+    /**
+     * @param array $additionalParams
+     * @return array
+     */
+    protected function getUrlAdditionalParams(array $additionalParams): array
+    {
+        if (!empty($this->config['url']['additionalGetParameters']) &&
+            is_array($this->config['url']['additionalGetParameters'])) {
+            foreach ($this->config['url']['additionalGetParameters'] as $extension => $extensionConfig) {
+                foreach ($extensionConfig as $key => $value) {
+                    $additionalParams[$extension . '[' . $key . ']'] = $value;
+                }
+            }
+        }
+
+        return $additionalParams;
+    }
+}
diff --git a/typo3/sysext/seo/Classes/XmlSitemap/XmlSitemapDataProviderInterface.php b/typo3/sysext/seo/Classes/XmlSitemap/XmlSitemapDataProviderInterface.php
new file mode 100644 (file)
index 0000000..152c88a
--- /dev/null
@@ -0,0 +1,17 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Seo\XmlSitemap;
+
+use Psr\Http\Message\ServerRequestInterface;
+use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
+
+/**
+ * Interface for XmlSitemapDataProviders containing the methods that are called by the XmlSitemapRenderer
+ */
+interface XmlSitemapDataProviderInterface
+{
+    public function __construct(ServerRequestInterface $request, string $name, array $config = [], ContentObjectRenderer $cObj = null);
+    public function getKey(): string;
+    public function getItems(): array;
+    public function getLastModified(): int;
+}
diff --git a/typo3/sysext/seo/Classes/XmlSitemap/XmlSitemapRenderer.php b/typo3/sysext/seo/Classes/XmlSitemap/XmlSitemapRenderer.php
new file mode 100644 (file)
index 0000000..0d2bc30
--- /dev/null
@@ -0,0 +1,149 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Seo\XmlSitemap;
+
+use Psr\Http\Message\ServerRequestInterface;
+use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Core\Utility\PathUtility;
+use TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface;
+use TYPO3\CMS\Extbase\Object\ObjectManager;
+use TYPO3\CMS\Fluid\View\StandaloneView;
+use TYPO3\CMS\Seo\XmlSitemap\Exception\InvalidConfigurationException;
+
+/**
+ * Class to render the XML Sitemap to be used as a UserFunction
+ */
+class XmlSitemapRenderer
+{
+    /**
+     * @var array
+     */
+    protected $configuration;
+
+    /**
+     * @var \TYPO3\CMS\Fluid\View\StandaloneView
+     */
+    protected $view;
+
+    public function __construct()
+    {
+        $this->configuration = $this->getConfiguration();
+        $this->view = $this->getStandaloneView();
+        $this->view->assign(
+            'xslFile',
+            PathUtility::stripPathSitePrefix(
+                ExtensionManagementUtility::extPath('seo', 'Resources/Public/CSS/Sitemap.xsl')
+            )
+        );
+    }
+
+    /**
+     * @return string
+     * @throws InvalidConfigurationException
+     */
+    public function render(): string
+    {
+        // Inject request from globals until request will be available to cObj
+        $request = $GLOBALS['TYPO3_REQUEST'];
+        $this->view->assign('type', $request->getQueryParams()['type']);
+        if (!empty($sitemap = $request->getQueryParams()['sitemap'])) {
+            return $this->renderSitemap($request, $sitemap);
+        }
+
+        return $this->renderIndex($request);
+    }
+
+    /**
+     * @param \Psr\Http\Message\ServerRequestInterface $request
+     * @return string
+     */
+    protected function renderIndex(ServerRequestInterface $request): string
+    {
+        $sitemaps = [];
+        foreach ($this->configuration['config']['xmlSitemap']['sitemaps'] ?? [] as $sitemap => $config) {
+            if (class_exists($config['provider']) &&
+                is_subclass_of($config['provider'], XmlSitemapDataProviderInterface::class)) {
+                /** @var XmlSitemapDataProviderInterface $provider */
+                $provider = GeneralUtility::makeInstance(
+                    $config['provider'],
+                    $request,
+                    $sitemap,
+                    (array)$config['config']
+                );
+
+                $sitemaps[] = [
+                    'key' => $sitemap,
+                    'lastMod' => $provider->getLastModified()
+                ];
+            }
+        }
+
+        $this->view->assign('sitemaps', $sitemaps);
+        $this->view->setTemplate('Index');
+
+        return $this->view->render();
+    }
+
+    /**
+     * @param \Psr\Http\Message\ServerRequestInterface $request
+     * @param string $sitemap
+     * @return string
+     * @throws \TYPO3\CMS\Seo\XmlSitemap\Exception\InvalidConfigurationException
+     */
+    protected function renderSitemap(ServerRequestInterface $request, string $sitemap): string
+    {
+        if (!empty($sitemapConfig = $this->configuration['config']['xmlSitemap']['sitemaps'][$sitemap])) {
+            if (class_exists($sitemapConfig['provider']) &&
+                is_subclass_of($sitemapConfig['provider'], XmlSitemapDataProviderInterface::class)) {
+                /** @var XmlSitemapDataProviderInterface $provider */
+                $provider = GeneralUtility::makeInstance(
+                    $sitemapConfig['provider'],
+                    $request,
+                    $sitemap,
+                    (array)$sitemapConfig['config']
+                );
+
+                $items = $provider->getItems();
+
+                $template = $sitemapConfig['config']['template'] ?: 'Sitemap';
+                $this->view->setTemplate($template);
+                $this->view->assign('items', $items);
+
+                return $this->view->render();
+            }
+            throw new InvalidConfigurationException('No valid provider set for ' . $sitemap, 1535578522);
+        }
+
+        throw new InvalidConfigurationException('No valid configuration found for sitemap ' . $sitemap, 1535578569);
+    }
+
+    /**
+     * @return \TYPO3\CMS\Fluid\View\StandaloneView
+     */
+    protected function getStandaloneView(): StandaloneView
+    {
+        $view = GeneralUtility::makeInstance(StandaloneView::class);
+        $view->setTemplateRootPaths($this->configuration['view']['templateRootPaths']);
+        $view->setLayoutRootPaths($this->configuration['view']['layoutRootPaths']);
+        $view->setPartialRootPaths($this->configuration['view']['partialRootPaths']);
+        $view->setFormat('xml');
+
+        return $view;
+    }
+
+    /**
+     * Get the whole typoscript array
+     * @return array
+     */
+    private function getConfiguration(): array
+    {
+        $configurationManager = GeneralUtility::makeInstance(ObjectManager::class)
+            ->get(ConfigurationManagerInterface::class);
+
+        return $configurationManager->getConfiguration(
+            ConfigurationManagerInterface::CONFIGURATION_TYPE_FRAMEWORK,
+            'seo'
+        );
+    }
+}
diff --git a/typo3/sysext/seo/Configuration/TCA/Overrides/sys_template.php b/typo3/sysext/seo/Configuration/TCA/Overrides/sys_template.php
new file mode 100644 (file)
index 0000000..e1988a5
--- /dev/null
@@ -0,0 +1,8 @@
+<?php
+defined('TYPO3_MODE') or die();
+
+\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addStaticFile(
+    'seo',
+    'Configuration/TypoScript/XmlSitemap',
+    'XML Sitemap'
+);
diff --git a/typo3/sysext/seo/Configuration/TypoScript/XmlSitemap/constants.typoscript b/typo3/sysext/seo/Configuration/TypoScript/XmlSitemap/constants.typoscript
new file mode 100644 (file)
index 0000000..4a1cfa5
--- /dev/null
@@ -0,0 +1,10 @@
+plugin.tx_seo {
+  view {
+    # cat=plugin.tx_seo/file; type=string; label=Path to template root (FE)
+    templateRootPath = EXT:seo/Resources/Private/Templates/
+    # cat=plugin.tx_seo/file; type=string; label=Path to template partials (FE)
+    partialRootPath = EXT:seo/Resources/Private/Partials/
+    # cat=plugin.tx_seo/file; type=string; label=Path to template layouts (FE)
+    layoutRootPath = EXT:seo/Resources/Private/Layouts/
+  }
+}
diff --git a/typo3/sysext/seo/Configuration/TypoScript/XmlSitemap/setup.typoscript b/typo3/sysext/seo/Configuration/TypoScript/XmlSitemap/setup.typoscript
new file mode 100644 (file)
index 0000000..1325520
--- /dev/null
@@ -0,0 +1,49 @@
+seo_sitemap = PAGE
+seo_sitemap {
+  typeNum = 1533906435
+
+  config {
+    disableAllHeaderCode = 1
+    admPanel = 0
+    removeDefaultJS = 1
+    removeDefaultCss = 1
+    removePageCss = 1
+    additionalHeaders.10 {
+      header = Content-Type:application/xml;charset=utf-8
+    }
+  }
+
+  10 = USER_INT
+  10.userFunc = TYPO3\CMS\Seo\XmlSitemap\XmlSitemapRenderer->render
+}
+
+plugin.tx_seo {
+  view {
+    templateRootPaths {
+      0 = EXT:seo/Resources/Private/Templates/XmlSitemap
+      10 = {$plugin.tx_seo.view.templateRootPath}
+    }
+    partialRootPaths {
+      0 = EXT:seo/Resources/Private/Partials/XmlSitemap
+      10 = {$plugin.tx_seo.view.partialRootPath}
+    }
+    layoutRootPaths {
+      0 = EXT:seo/Resources/Private/Layouts/XmlSitemap
+      10 = {$plugin.tx_seo.view.layoutRootPath}
+    }
+  }
+
+  config {
+    xmlSitemap {
+      sitemaps {
+        pages {
+          provider = TYPO3\CMS\Seo\XmlSitemap\PagesXmlSitemapDataProvider
+          config {
+            excludedDoktypes =
+            additionalWhere = AND (no_index = 0 OR no_follow = 0)
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/typo3/sysext/seo/Resources/Private/Templates/XmlSitemap/Index.xml b/typo3/sysext/seo/Resources/Private/Templates/XmlSitemap/Index.xml
new file mode 100644 (file)
index 0000000..224f45b
--- /dev/null
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet type="text/xsl" href="{xslFile}"?>
+
+<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:f="http://typo3.org/ns/fluid/ViewHelpers">
+    <f:for each="{sitemaps}" as="sitemap">
+        <sitemap>
+            <loc><f:uri.page additionalParams="{type: type, sitemap: sitemap.key }" absolute="true" noCacheHash="true" /></loc>
+            <lastmod>{sitemap.lastMod -> f:format.date(format: 'c')}</lastmod>
+        </sitemap>
+    </f:for>
+</sitemapindex>
diff --git a/typo3/sysext/seo/Resources/Private/Templates/XmlSitemap/Sitemap.xml b/typo3/sysext/seo/Resources/Private/Templates/XmlSitemap/Sitemap.xml
new file mode 100644 (file)
index 0000000..6c6bfcb
--- /dev/null
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet type="text/xsl" href="{xslFile}"?>
+
+<urlset xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd http://www.google.com/schemas/sitemap-image/1.1 http://www.google.com/schemas/sitemap-image/1.1/sitemap-image.xsd" xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
+    <f:for each="{items}" as="item">
+        <url>
+            <loc>{item.loc}</loc>
+            <lastmod>{item.lastMod -> f:format.date(format: 'c')}</lastmod>
+        </url>
+    </f:for>
+</urlset>
diff --git a/typo3/sysext/seo/Resources/Public/CSS/Sitemap.xsl b/typo3/sysext/seo/Resources/Public/CSS/Sitemap.xsl
new file mode 100644 (file)
index 0000000..59a61df
--- /dev/null
@@ -0,0 +1,130 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<xsl:stylesheet version="2.0"
+                xmlns:html="http://www.w3.org/TR/REC-html40"
+                xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"
+                xmlns:sitemap="http://www.sitemaps.org/schemas/sitemap/0.9"
+                xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
+    <xsl:output method="html" version="1.0" encoding="UTF-8" indent="yes"/>
+    <xsl:template match="/">
+        <html xmlns="http://www.w3.org/1999/xhtml">
+            <head>
+                <title>TYPO3 XML Sitemap</title>
+                <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
+                <style type="text/css">
+                    body {
+                        font-family: Helvetica, Arial, sans-serif;
+                        font-size: 13px;
+                        color: #545353;
+                    }
+                    table {
+                        border: none;
+                        border-collapse: collapse;
+                    }
+                    #sitemap tr:nth-child(odd) td {
+                        background-color: #eee !important;
+                    }
+                    #sitemap tbody tr:hover td {
+                        background-color: #ccc;
+                    }
+                    #sitemap tbody tr:hover td, #sitemap tbody tr:hover td a {
+                        color: #000;
+                    }
+                    #content {
+                        margin: 0 auto;
+                        width: 1000px;
+                    }
+                    .expl {
+                        margin: 18px 3px;
+                        line-height: 1.2em;
+                    }
+                    a {
+                        color: #000;
+                        text-decoration: none;
+                    }
+                    a:visited {
+                        color: #777;
+                    }
+                    a:hover {
+                        text-decoration: underline;
+                    }
+                    td {
+                        font-size:11px;
+                    }
+                    th {
+                        text-align:left;
+                        padding-right:30px;
+                        font-size:11px;
+                    }
+                    thead th {
+                        border-bottom: 1px solid #000;
+                    }
+                </style>
+            </head>
+            <body>
+                <div id="content">
+                    <h1>TYPO3 XML Sitemap</h1>
+                    <xsl:if test="count(sitemap:sitemapindex/sitemap:sitemap) &gt; 0">
+                        <p class="expl">
+                            This XML Sitemap Index file contains <xsl:value-of select="count(sitemap:sitemapindex/sitemap:sitemap)"/> sitemaps.
+                        </p>
+                        <table id="sitemap" cellpadding="3" width="100%">
+                            <thead>
+                                <tr>
+                                    <th>Sitemap</th>
+                                    <th>Last modified</th>
+                                </tr>
+                            </thead>
+                            <tbody>
+                                <xsl:for-each select="sitemap:sitemapindex/sitemap:sitemap">
+                                    <xsl:variable name="sitemapURL">
+                                        <xsl:value-of select="sitemap:loc"/>
+                                    </xsl:variable>
+                                    <tr>
+                                        <td>
+                                            <a href="{$sitemapURL}"><xsl:value-of select="sitemap:loc"/></a>
+                                        </td>
+                                        <td>
+                                            <a href="{$sitemapURL}"><xsl:value-of select="sitemap:lastmod"/></a>
+                                        </td>
+                                    </tr>
+                                </xsl:for-each>
+                            </tbody>
+                        </table>
+                    </xsl:if>
+                    <xsl:if test="count(sitemap:sitemapindex/sitemap:sitemap) &lt; 1">
+                        <p class="expl">
+                            This XML Sitemap contains <xsl:value-of select="count(sitemap:urlset/sitemap:url)"/> URLs.
+                        </p>
+                        <table id="sitemap" cellpadding="3" width="100%">
+                            <thead>
+                                <tr>
+                                    <th width="80%">URL</th>
+                                    <th title="Last Modification Time" width="20%">Last Mod.</th>
+                                </tr>
+                            </thead>
+                            <tbody>
+                                <xsl:variable name="lower" select="'abcdefghijklmnopqrstuvwxyz'"/>
+                                <xsl:variable name="upper" select="'ABCDEFGHIJKLMNOPQRSTUVWXYZ'"/>
+                                <xsl:for-each select="sitemap:urlset/sitemap:url">
+                                    <tr>
+                                        <td>
+                                            <xsl:variable name="itemURL">
+                                                <xsl:value-of select="sitemap:loc"/>
+                                            </xsl:variable>
+                                            <a href="{$itemURL}">
+                                                <xsl:value-of select="sitemap:loc"/>
+                                            </a>
+                                        </td>
+                                        <td>
+                                            <xsl:value-of select="concat(substring(sitemap:lastmod,0,11),concat(' ', substring(sitemap:lastmod,12,5)),concat(' ', substring(sitemap:lastmod,20,6)))"/>
+                                        </td>
+                                    </tr>
+                                </xsl:for-each>
+                            </tbody>
+                        </table>
+                    </xsl:if>
+                </div>
+            </body>
+        </html>
+    </xsl:template>
+</xsl:stylesheet>
diff --git a/typo3/sysext/seo/Tests/Functional/Fixtures/pages-sitemap.xml b/typo3/sysext/seo/Tests/Functional/Fixtures/pages-sitemap.xml
new file mode 100644 (file)
index 0000000..476a68f
--- /dev/null
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="utf-8"?>
+<dataset>
+    <pages>
+        <uid>1</uid>
+        <pid>0</pid>
+        <title>Root 1</title>
+        <is_siteroot>1</is_siteroot>
+        <deleted>0</deleted>
+    </pages>
+    <pages>
+        <uid>2</uid>
+        <pid>1</pid>
+        <title>Dummy 1-2</title>
+        <tstamp>1491811200</tstamp>
+        <deleted>0</deleted>
+    </pages>
+    <pages>
+        <uid>3</uid>
+        <pid>1</pid>
+        <title>Dummy 1-3</title>
+        <SYS_LASTCHANGED>1535657401</SYS_LASTCHANGED>
+        <deleted>0</deleted>
+    </pages>
+    <pages>
+        <uid>4</uid>
+        <pid>1</pid>
+        <title>Dummy 1-4</title>
+        <deleted>0</deleted>
+    </pages>
+    <pages>
+        <uid>5</uid>
+        <pid>2</pid>
+        <title>Dummy 1-2-5</title>
+        <deleted>0</deleted>
+    </pages>
+    <pages>
+        <uid>6</uid>
+        <pid>2</pid>
+        <title>Dummy 1-2-6</title>
+        <deleted>0</deleted>
+    </pages>
+    <pages>
+        <uid>7</uid>
+        <pid>2</pid>
+        <title>Dummy 1-2-7</title>
+        <deleted>0</deleted>
+    </pages>
+    <pages>
+        <uid>8</uid>
+        <pid>3</pid>
+        <title>Dummy 1-3-8</title>
+        <deleted>0</deleted>
+    </pages>
+    <pages>
+        <uid>9</uid>
+        <pid>3</pid>
+        <title>Dummy 1-3-9</title>
+        <deleted>0</deleted>
+    </pages>
+    <pages>
+        <uid>10</uid>
+        <pid>4</pid>
+        <title>Dummy 1-4-10</title>
+        <deleted>0</deleted>
+    </pages>
+</dataset>
diff --git a/typo3/sysext/seo/Tests/Functional/XmlSitemap/XmlSitemapIndexTest.php b/typo3/sysext/seo/Tests/Functional/XmlSitemap/XmlSitemapIndexTest.php
new file mode 100644 (file)
index 0000000..1c90840
--- /dev/null
@@ -0,0 +1,56 @@
+<?php
+declare(strict_types = 1);
+
+namespace TYPO3\CMS\Frontend\Tests\Functional\XmlSitemap;
+
+use TYPO3\CMS\Frontend\Tests\Functional\SiteHandling\AbstractTestCase;
+use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalRequest;
+
+/**
+ * Contains functional tests for the XmlSitemap Index
+ */
+class XmlSitemapIndexTest extends AbstractTestCase
+{
+    /**
+     * @var string[]
+     */
+    protected $coreExtensionsToLoad = [
+        'core', 'frontend', 'seo'
+    ];
+
+    protected function setUp()
+    {
+        parent::setUp();
+        $this->importDataSet('EXT:seo/Tests/Functional/Fixtures/pages-sitemap.xml');
+        $this->setUpFrontendRootPage(
+            1,
+            ['EXT:seo/Configuration/TypoScript/XmlSitemap/setup.typoscript']
+        );
+    }
+
+    /**
+     * @test
+     */
+    public function checkIfSiteMapIndexContainsPagesSitemap(): void
+    {
+        $this->writeSiteConfiguration(
+            'website-local',
+            $this->buildSiteConfiguration(1, 'http://localhost/')
+        );
+
+        $response = $this->executeFrontendRequest(
+            (new InternalRequest())->withQueryParameters([
+                'id' => 1,
+                'type' => 1533906435
+            ])
+        );
+
+        $expectedHeaders = [
+            'Content-Length' => [0 => '451']
+        ];
+        $expectedBody = '#<loc>http://localhost/\?id=1&amp;type=1533906435&amp;sitemap=pages</loc>#';
+        $this->assertEquals(200, $response->getStatusCode());
+        $this->assertEquals($expectedHeaders, $response->getHeaders());
+        $this->assertRegExp($expectedBody, (string)$response->getBody());
+    }
+}
diff --git a/typo3/sysext/seo/Tests/Unit/XmlSitemap/PagesXmlSitemapDataProviderTest.php b/typo3/sysext/seo/Tests/Unit/XmlSitemap/PagesXmlSitemapDataProviderTest.php
new file mode 100644 (file)
index 0000000..8abe2f7
--- /dev/null
@@ -0,0 +1,85 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Seo\Tests\Unit\XmlSitemap;
+
+use Psr\Http\Message\ServerRequestInterface;
+use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
+use TYPO3\CMS\Seo\XmlSitemap\PagesXmlSitemapDataProvider;
+use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
+
+class PagesXmlSitemapDataProviderTest extends UnitTestCase
+{
+    /**
+     * @test
+     */
+    public function checkIfCorrectKeyIsGivenAfterConstruct(): void
+    {
+        $key = 'dummyKey';
+        $cObj = $this->prophesize(ContentObjectRenderer::class);
+
+        $subject = $this->getAccessibleMock(
+            PagesXmlSitemapDataProvider::class,
+            ['generateItems'],
+            [$this->prophesize(ServerRequestInterface::class)->reveal(), $key, [], $cObj->reveal()],
+            '',
+            true
+        );
+        $subject->expects($this->any())->method('generateItems')->willReturn(null);
+        $this->assertEquals($key, $subject->getKey());
+    }
+
+    /**
+     * @test
+     */
+    public function checkGetItemsReturnsDefinedItems(): void
+    {
+        $key = 'dummyKey';
+        $cObj = $this->prophesize(ContentObjectRenderer::class);
+
+        $subject = $this->getAccessibleMock(
+            PagesXmlSitemapDataProvider::class,
+            ['generateItems'],
+            [$key, [], $cObj->reveal()],
+            '',
+            false
+        );
+        $items = ['foo' => 'bar'];
+        $subject->_set('items', $items);
+
+        $this->assertEquals($items, $subject->getItems());
+    }
+
+    /**
+     * @test
+     */
+    public function checkGetLastModReturnsRightDate(): void
+    {
+        $key = 'dummyKey';
+        $cObj = $this->prophesize(ContentObjectRenderer::class);
+
+        $subject = $this->getAccessibleMock(
+            PagesXmlSitemapDataProvider::class,
+            ['generateItems'],
+            [$key, [], $cObj->reveal()],
+            '',
+            false
+        );
+        $items = [
+            [
+                'loc' => 'https://yourdomain.com/page-1',
+                'lastMod' => 1535655601
+            ],
+            [
+                'loc' => 'https://yourdomain.com/page-2',
+                'lastMod' => 1530432000
+            ],
+            [
+                'loc' => 'https://yourdomain.com/page-3',
+                'lastMod' => 1535655756
+            ],
+        ];
+        $subject->_set('items', $items);
+
+        $this->assertEquals(1535655756, $subject->getLastModified());
+    }
+}