From 13c67c454fb29462e7f5eb8bc86a30d9b84cfa45 Mon Sep 17 00:00:00 2001
From: Claus Due <claus@namelesscoder.net>
Date: Wed, 5 Oct 2016 21:19:17 +0200
Subject: [PATCH] [FEATURE] Introduce PreviewRenderer pattern

This introduces a new approach to registering and rendering
previews; for content elements initially but possible to apply
to any record type, and possible to call from other contexts
than the PageLayoutView, e.g. AJAX-based preview rendering.

Basically, this turns the old hook approach into a proper
pattern where preview renderers are registered for a specific
CType and must implement proper interfaces. A Resolver
pattern is also introduced with a standard implementation and
a standard renderer is registered for backwards compatibility.

Resolves: #78450
Releases: master
Change-Id: Ibf85d9b50b7bc6506d72c1ee63078373eaf9e433
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/50389
Tested-by: TYPO3com <noreply@typo3.com>
Tested-by: Susanne Moog <look@susi.dev>
Tested-by: Georg Ringer <georg.ringer@gmail.com>
Reviewed-by: Susanne Moog <look@susi.dev>
Reviewed-by: Georg Ringer <georg.ringer@gmail.com>
---
 .../Preview/PreviewRendererInterface.php      |  82 ++++
 .../PreviewRendererResolverInterface.php      |  33 ++
 .../StandardContentPreviewRenderer.php        | 450 ++++++++++++++++++
 .../StandardPreviewRendererResolver.php       |  88 ++++
 .../BackendLayout/Grid/GridColumnItem.php     | 211 +-------
 .../Partials/PageLayout/Record/Footer.html    |  12 +-
 typo3/sysext/backend/ext_localconf.php        |   5 +
 ...ageLayoutViewClassInternalIsDeprecated.rst |   6 +-
 ...-78450-IntroducePreviewRendererPattern.rst | 158 ++++++
 .../frontend/Configuration/TCA/tt_content.php |   1 +
 10 files changed, 847 insertions(+), 199 deletions(-)
 create mode 100644 typo3/sysext/backend/Classes/Preview/PreviewRendererInterface.php
 create mode 100644 typo3/sysext/backend/Classes/Preview/PreviewRendererResolverInterface.php
 create mode 100644 typo3/sysext/backend/Classes/Preview/StandardContentPreviewRenderer.php
 create mode 100644 typo3/sysext/backend/Classes/Preview/StandardPreviewRendererResolver.php
 create mode 100644 typo3/sysext/core/Documentation/Changelog/master/Feature-78450-IntroducePreviewRendererPattern.rst

diff --git a/typo3/sysext/backend/Classes/Preview/PreviewRendererInterface.php b/typo3/sysext/backend/Classes/Preview/PreviewRendererInterface.php
new file mode 100644
index 000000000000..0f902e852261
--- /dev/null
+++ b/typo3/sysext/backend/Classes/Preview/PreviewRendererInterface.php
@@ -0,0 +1,82 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Backend\Preview;
+
+/*
+ * 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\Backend\View\BackendLayout\Grid\GridColumnItem;
+
+/**
+ * Interface PreviewRendererInterface
+ *
+ * Conctract for classes capable of rendering previews of a given record
+ * from a table. Responsible for rendering preview heeader, preview content
+ * and wrapping of those two values.
+ *
+ * Responsibilities are segmented into three methods, one for each responsibility,
+ * which is done in order to allow overriding classes to change those parts
+ * individually without having to replace other parts. Rather than relying on
+ * implementations to be friendly and divide code into smaller pieces and
+ * give them (at least) protected visibility, the key methods are instead required
+ * on the interface directly.
+ *
+ * Callers are then responsible for calling each method and combining/wrapping
+ * the output appropriately.
+ */
+interface PreviewRendererInterface
+{
+    /**
+     * Dedicated method for rendering preview header HTML for
+     * the page module only. Receives the the GridColumnItem
+     * that contains the record for which a preview header
+     * should be rendered and returned.
+     *
+     * @param GridColumnItem $item
+     * @return string
+     */
+    public function renderPageModulePreviewHeader(GridColumnItem $item): string;
+
+    /**
+     * Dedicated method for rendering preview body HTML for
+     * the page module only. Receives the the GridColumnItem
+     * that contains the record for which a preview should be
+     * rendered and returned.
+     *
+     * @param GridColumnItem $item
+     * @return string
+     */
+    public function renderPageModulePreviewContent(GridColumnItem $item): string;
+
+    /**
+     * Render a footer for the record to display in page module below
+     * the body of the item's preview.
+     *
+     * @param GridColumnItem $item
+     * @return string
+     */
+    public function renderPageModulePreviewFooter(GridColumnItem $item): string;
+
+    /**
+     * Dedicated method for wrapping a preview header and body
+     * HTML. Receives $item, an instance of GridColumnItem holding
+     * among other things the record, which can be used to determine
+     * appropriate wrapping.
+     *
+     * @param string $previewHeader
+     * @param string $previewContent
+     * @param GridColumnItem $item
+     * @return string
+     */
+    public function wrapPageModulePreview(string $previewHeader, string $previewContent, GridColumnItem $item): string;
+}
diff --git a/typo3/sysext/backend/Classes/Preview/PreviewRendererResolverInterface.php b/typo3/sysext/backend/Classes/Preview/PreviewRendererResolverInterface.php
new file mode 100644
index 000000000000..10832f8a88a0
--- /dev/null
+++ b/typo3/sysext/backend/Classes/Preview/PreviewRendererResolverInterface.php
@@ -0,0 +1,33 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Backend\Preview;
+
+/*
+ * 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!
+ */
+
+/**
+ * Interface PreviewRendererResolverInterface
+ *
+ * Contract for classes capable of resolving PreviewRenderInterface
+ * implementations based on table and record.
+ */
+interface PreviewRendererResolverInterface
+{
+    /**
+     * @param string $table The name of the table the returned PreviewRenderer must work with
+     * @param array $row A record from $table which will be previewed - allows returning a different PreviewRenderer based on record attributes
+     * @param int $pageUid The UID of the page on which the preview will be rendered - allows returning a different PreviewRenderer based on for example pageTSconfig
+     * @return PreviewRendererInterface
+     */
+    public function resolveRendererFor(string $table, array $row, int $pageUid): PreviewRendererInterface;
+}
diff --git a/typo3/sysext/backend/Classes/Preview/StandardContentPreviewRenderer.php b/typo3/sysext/backend/Classes/Preview/StandardContentPreviewRenderer.php
new file mode 100644
index 000000000000..d64b26f9794a
--- /dev/null
+++ b/typo3/sysext/backend/Classes/Preview/StandardContentPreviewRenderer.php
@@ -0,0 +1,450 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Backend\Preview;
+
+/*
+ * 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\Log\LoggerAwareInterface;
+use Psr\Log\LoggerAwareTrait;
+use TYPO3\CMS\Backend\Routing\UriBuilder;
+use TYPO3\CMS\Backend\Utility\BackendUtility;
+use TYPO3\CMS\Backend\View\BackendLayout\Grid\GridColumnItem;
+use TYPO3\CMS\Backend\View\Drawing\DrawingConfiguration;
+use TYPO3\CMS\Backend\View\PageLayoutView;
+use TYPO3\CMS\Backend\View\PageLayoutViewDrawFooterHookInterface;
+use TYPO3\CMS\Backend\View\PageLayoutViewDrawItemHookInterface;
+use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
+use TYPO3\CMS\Core\Core\Environment;
+use TYPO3\CMS\Core\Imaging\Icon;
+use TYPO3\CMS\Core\Imaging\IconFactory;
+use TYPO3\CMS\Core\Localization\LanguageService;
+use TYPO3\CMS\Core\Service\FlexFormService;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Fluid\View\StandaloneView;
+
+/**
+ * Class StandardContentPreviewRenderer
+ *
+ * Legacy preview rendering refactored from PageLayoutView.
+ * Provided as default preview rendering mechanism via
+ * StandardPreviewRendererResolver which detects the renderer
+ * based on TCA configuration.
+ *
+ * Can be replaced and/or subclassed by custom implementations
+ * by changing this TCA configuration.
+ *
+ * See also PreviewRendererInterface documentation.
+ */
+class StandardContentPreviewRenderer implements PreviewRendererInterface, LoggerAwareInterface
+{
+    use LoggerAwareTrait;
+
+    public function renderPageModulePreviewHeader(GridColumnItem $item): string
+    {
+        $record = $item->getRecord();
+        $itemLabels = $item->getBackendLayout()->getDrawingConfiguration()->getItemLabels();
+
+        $outHeader = '';
+
+        if ($record['header']) {
+            $infoArr = [];
+            $this->getProcessedValue($item, 'header_position,header_layout,header_link', $infoArr);
+            $hiddenHeaderNote = '';
+            // If header layout is set to 'hidden', display an accordant note:
+            if ($record['header_layout'] == 100) {
+                $hiddenHeaderNote = ' <em>[' . htmlspecialchars($this->getLanguageService()->sL('LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:labels.hidden')) . ']</em>';
+            }
+            $outHeader = $record['date']
+                ? htmlspecialchars($itemLabels['date'] . ' ' . BackendUtility::date($record['date'])) . '<br />'
+                : '';
+            $outHeader .= '<strong>' . $this->linkEditContent($this->renderText($record['header']), $record)
+                . $hiddenHeaderNote . '</strong><br />';
+        }
+
+        return $outHeader;
+    }
+
+    public function renderPageModulePreviewContent(GridColumnItem $item): string
+    {
+        $out = '';
+        $record = $item->getRecord();
+
+        $contentTypeLabels = $item->getBackendLayout()->getDrawingConfiguration()->getContentTypeLabels();
+        $languageService = $this->getLanguageService();
+
+        // Check if a Fluid-based preview template was defined for this CType
+        // and render it via Fluid. Possible option:
+        // mod.web_layout.tt_content.preview.media = EXT:site_mysite/Resources/Private/Templates/Preview/Media.html
+        $infoArr = [];
+        $this->getProcessedValue($item, 'header_position,header_layout,header_link', $infoArr);
+        $tsConfig = BackendUtility::getPagesTSconfig($record['pid'])['mod.']['web_layout.']['tt_content.']['preview.'] ?? [];
+        if (!empty($tsConfig[$record['CType']])) {
+            $fluidPreview = $this->renderContentElementPreviewFromFluidTemplate($record);
+            if ($fluidPreview !== null) {
+                return $fluidPreview;
+            }
+        }
+
+        // Draw preview of the item depending on its CType
+        switch ($record['CType']) {
+            case 'header':
+                if ($record['subheader']) {
+                    $out .= $this->linkEditContent($this->renderText($record['subheader']), $record) . '<br />';
+                }
+                break;
+            case 'uploads':
+                if ($record['media']) {
+                    $out .= $this->linkEditContent($this->getThumbCodeUnlinked($record, 'tt_content', 'media'), $record) . '<br />';
+                }
+                break;
+            case 'menu':
+                $contentType = $contentTypeLabels[$record['CType']];
+                $out .= $this->linkEditContent('<strong>' . htmlspecialchars($contentType) . '</strong>', $record) . '<br />';
+                // Add Menu Type
+                $menuTypeLabel = $languageService->sL(
+                    BackendUtility::getLabelFromItemListMerged($record['pid'], 'tt_content', 'menu_type', $record['menu_type'])
+                );
+                $menuTypeLabel = $menuTypeLabel ?: 'invalid menu type';
+                $out .= $this->linkEditContent($menuTypeLabel, $record);
+                if ($record['menu_type'] !== '2' && ($record['pages'] || $record['selected_categories'])) {
+                    // Show pages if menu type is not "Sitemap"
+                    $out .= ':' . $this->linkEditContent($this->generateListForCTypeMenu($record), $record) . '<br />';
+                }
+                break;
+            case 'shortcut':
+                if (!empty($record['records'])) {
+                    $shortcutContent = [];
+                    $recordList = explode(',', $record['records']);
+                    foreach ($recordList as $recordIdentifier) {
+                        $split = BackendUtility::splitTable_Uid($recordIdentifier);
+                        $tableName = empty($split[0]) ? 'tt_content' : $split[0];
+                        $shortcutRecord = BackendUtility::getRecord($tableName, $split[1]);
+                        if (is_array($shortcutRecord)) {
+                            $icon = $this->getIconFactory()->getIconForRecord($tableName, $shortcutRecord, Icon::SIZE_SMALL)->render();
+                            $icon = BackendUtility::wrapClickMenuOnIcon(
+                                $icon,
+                                $tableName,
+                                $shortcutRecord['uid'],
+                                1,
+                                '',
+                                '+copy,info,edit,view'
+                            );
+                            $shortcutContent[] = $icon
+                                . htmlspecialchars(BackendUtility::getRecordTitle($tableName, $shortcutRecord));
+                        }
+                    }
+                    $out .= implode('<br />', $shortcutContent) . '<br />';
+                }
+                break;
+            case 'list':
+                $hookOut = '';
+                if (!empty($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/layout/class.tx_cms_layout.php']['list_type_Info'])) {
+                    $pageLayoutView = $this->createEmulatedPageLayoutViewFromDrawingConfiguration($item->getBackendLayout()->getDrawingConfiguration());
+                    $_params = ['pObj' => &$pageLayoutView, 'row' => $record];
+                    foreach (
+                        $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/layout/class.tx_cms_layout.php']['list_type_Info'][$record['list_type']] ??
+                        $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/layout/class.tx_cms_layout.php']['list_type_Info']['_DEFAULT'] ??
+                        [] as $_funcRef
+                    ) {
+                        $hookOut .= GeneralUtility::callUserFunction($_funcRef, $_params, $pageLayoutView);
+                    }
+                }
+
+                if ((string)$hookOut !== '') {
+                    $out .= $hookOut;
+                } elseif (!empty($record['list_type'])) {
+                    $label = BackendUtility::getLabelFromItemListMerged($record['pid'], 'tt_content', 'list_type', $record['list_type']);
+                    if (!empty($label)) {
+                        $out .= $this->linkEditContent('<strong>' . htmlspecialchars($languageService->sL($label)) . '</strong>', $record) . '<br />';
+                    } else {
+                        $message = sprintf($languageService->sL('LLL:EXT:lang/locallang_core.xlf:labels.noMatchingValue'), $record['list_type']);
+                        $out .= '<span class="label label-warning">' . htmlspecialchars($message) . '</span>';
+                    }
+                } elseif (!empty($record['select_key'])) {
+                    $out .= htmlspecialchars($languageService->sL(BackendUtility::getItemLabel('tt_content', 'select_key')))
+                        . ' ' . htmlspecialchars($record['select_key']) . '<br />';
+                } else {
+                    $out .= '<strong>' . $languageService->getLL('noPluginSelected') . '</strong>';
+                }
+                $out .= htmlspecialchars($languageService->sL(BackendUtility::getLabelFromItemlist('tt_content', 'pages', $record['pages']))) . '<br />';
+                break;
+            default:
+                $contentType = $contentTypeLabels[$record['CType']];
+
+                if (isset($contentType)) {
+                    $out .= $this->linkEditContent('<strong>' . htmlspecialchars($contentType) . '</strong>', $record) . '<br />';
+                    if ($record['bodytext']) {
+                        $out .= $this->linkEditContent($this->renderText($record['bodytext']), $record) . '<br />';
+                    }
+                    if ($record['image']) {
+                        $out .= $this->linkEditContent($this->getThumbCodeUnlinked($record, 'tt_content', 'image'), $record) . '<br />';
+                    }
+                } else {
+                    $message = sprintf(
+                        $languageService->sL('LLL:EXT:lang/locallang_core.xlf:labels.noMatchingValue'),
+                        $record['CType']
+                    );
+                    $out .= '<span class="label label-warning">' . htmlspecialchars($message) . '</span>';
+                }
+        }
+
+        return $out;
+    }
+
+    /**
+     * Render a footer for the record
+     *
+     * @param GridColumnItem $item
+     * @return string
+     */
+    public function renderPageModulePreviewFooter(GridColumnItem $item): string
+    {
+        $content = '';
+        $info = [];
+        $record = $item->getRecord();
+        $this->getProcessedValue($item, 'starttime,endtime,fe_group,space_before_class,space_after_class', $info);
+
+        if (!empty($GLOBALS['TCA']['tt_content']['ctrl']['descriptionColumn']) && !empty($this->record[$GLOBALS['TCA']['tt_content']['ctrl']['descriptionColumn']])) {
+            $info[] = $this->record[$GLOBALS['TCA']['tt_content']['ctrl']['descriptionColumn']];
+        }
+
+        // Call drawFooter hooks
+        if (!empty($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/layout/class.tx_cms_layout.php']['tt_content_drawFooter'])) {
+            $pageLayoutView = $this->createEmulatedPageLayoutViewFromDrawingConfiguration($item->getBackendLayout()->getDrawingConfiguration());
+            foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/layout/class.tx_cms_layout.php']['tt_content_drawFooter'] ?? [] as $className) {
+                $hookObject = GeneralUtility::makeInstance($className);
+                if (!$hookObject instanceof PageLayoutViewDrawFooterHookInterface) {
+                    throw new \UnexpectedValueException($className . ' must implement interface ' . PageLayoutViewDrawFooterHookInterface::class, 1582574541);
+                }
+                $hookObject->preProcess($pageLayoutView, $info, $record);
+            }
+        }
+
+        if (!empty($info)) {
+            $content = implode('<br>', $info);
+        }
+
+        if (!empty($content)) {
+            $content = '<div class="t3-page-ce-footer">' . $content . '</div>';
+        }
+
+        return $content;
+    }
+
+    public function wrapPageModulePreview(string $previewHeader, string $previewContent, GridColumnItem $item): string
+    {
+        $drawItem = true;
+        $record = $item->getRecord();
+        $hookPreviewContent = '';
+
+        // Hook: Render an own preview of a record
+        if (!empty($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/layout/class.tx_cms_layout.php']['tt_content_drawItem'])) {
+            $pageLayoutView = $this->createEmulatedPageLayoutViewFromDrawingConfiguration($item->getBackendLayout()->getDrawingConfiguration());
+            foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/layout/class.tx_cms_layout.php']['tt_content_drawItem'] ?? [] as $className) {
+                $hookObject = GeneralUtility::makeInstance($className);
+                if (!$hookObject instanceof PageLayoutViewDrawItemHookInterface) {
+                    throw new \UnexpectedValueException($className . ' must implement interface ' . PageLayoutViewDrawItemHookInterface::class, 1582574553);
+                }
+                $hookObject->preProcess($pageLayoutView, $drawItem, $previewHeader, $hookPreviewContent, $record);
+            }
+        }
+
+        $content = $previewHeader;
+
+        $content .= $hookPreviewContent;
+        if ($drawItem) {
+            $content .= $previewContent;
+        }
+
+        $out = '<span class="exampleContent">' . $content . '</span>';
+
+        if ($item->isDisabled()) {
+            return '<span class="text-muted">' . $out . '</span>';
+        }
+        return $out;
+    }
+
+    protected function getProcessedValue(GridColumnItem $item, string $fieldList, array &$info): void
+    {
+        $itemLabels = $item->getBackendLayout()->getDrawingConfiguration()->getItemLabels();
+        $record = $item->getRecord();
+        $fieldArr = explode(',', $fieldList);
+        foreach ($fieldArr as $field) {
+            if ($record[$field]) {
+                $info[] = '<strong>' . htmlspecialchars($itemLabels[$field]) . '</strong> '
+                    . htmlspecialchars(BackendUtility::getProcessedValue('tt_content', $field, $record[$field]));
+            }
+        }
+    }
+
+    protected function renderContentElementPreviewFromFluidTemplate(array $row): ?string
+    {
+        $tsConfig = BackendUtility::getPagesTSconfig($row['pid'])['mod.']['web_layout.']['tt_content.']['preview.'] ?? [];
+        $fluidTemplateFile = '';
+
+        if ($row['CType'] === 'list' && !empty($row['list_type'])
+            && !empty($tsConfig['list.'][$row['list_type']])
+        ) {
+            $fluidTemplateFile = $tsConfig['list.'][$row['list_type']];
+        } elseif (!empty($tsConfig[$row['CType']])) {
+            $fluidTemplateFile = $tsConfig[$row['CType']];
+        }
+
+        if ($fluidTemplateFile) {
+            $fluidTemplateFile = GeneralUtility::getFileAbsFileName($fluidTemplateFile);
+            if ($fluidTemplateFile) {
+                try {
+                    $view = GeneralUtility::makeInstance(StandaloneView::class);
+                    $view->setTemplatePathAndFilename($fluidTemplateFile);
+                    $view->assignMultiple($row);
+                    if (!empty($row['pi_flexform'])) {
+                        $flexFormService = GeneralUtility::makeInstance(FlexFormService::class);
+                        $view->assign('pi_flexform_transformed', $flexFormService->convertFlexFormContentToArray($row['pi_flexform']));
+                    }
+                    return $view->render();
+                } catch (\Exception $e) {
+                    $this->logger->warning(sprintf(
+                        'The backend preview for content element %d can not be rendered using the Fluid template file "%s": %s',
+                        $row['uid'],
+                        $fluidTemplateFile,
+                        $e->getMessage()
+                    ));
+
+                    if ($GLOBALS['TYPO3_CONF_VARS']['BE']['debug'] && $this->getBackendUser()->isAdmin()) {
+                        $view = GeneralUtility::makeInstance(StandaloneView::class);
+                        $view->assign('error', [
+                            'message' => str_replace(Environment::getProjectPath(), '', $e->getMessage()),
+                            'title' => 'Error while rendering FluidTemplate preview using ' . str_replace(Environment::getProjectPath(), '', $fluidTemplateFile),
+                        ]);
+                        $view->setTemplateSource('<f:be.infobox title="{error.title}" state="2">{error.message}</f:be.infobox>');
+                        return $view->render();
+                    }
+                }
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Create thumbnail code for record/field but not linked
+     *
+     * @param mixed[] $row Record array
+     * @param string $table Table (record is from)
+     * @param string $field Field name for which thumbnail are to be rendered.
+     * @return string HTML for thumbnails, if any.
+     */
+    protected function getThumbCodeUnlinked($row, $table, $field): string
+    {
+        return BackendUtility::thumbCode($row, $table, $field, '', '', null, 0, '', '', false);
+    }
+
+    /**
+     * Processing of larger amounts of text (usually from RTE/bodytext fields) with word wrapping etc.
+     *
+     * @param string $input Input string
+     * @return string Output string
+     */
+    protected function renderText(string $input): string
+    {
+        $input = strip_tags($input);
+        $input = GeneralUtility::fixed_lgd_cs($input, 1500);
+        return nl2br(htmlspecialchars(trim($input), ENT_QUOTES, 'UTF-8', false));
+    }
+
+    /**
+     * Generates a list of selected pages or categories for the CType menu
+     *
+     * @param array $record row from pages
+     * @return string
+     */
+    protected function generateListForCTypeMenu(array $record): string
+    {
+        $table = 'pages';
+        $field = 'pages';
+        // get categories instead of pages
+        if (strpos($record['menu_type'], 'categorized_') !== false) {
+            $table = 'sys_category';
+            $field = 'selected_categories';
+        }
+        if (trim($record[$field]) === '') {
+            return '';
+        }
+        $content = '';
+        $uidList = explode(',', $record[$field]);
+        foreach ($uidList as $uid) {
+            $uid = (int)$uid;
+            $pageRecord = BackendUtility::getRecord($table, $uid, 'title');
+            $content .= '<br>' . $pageRecord['title'] . ' (' . $uid . ')';
+        }
+        return $content;
+    }
+
+    /**
+     * Will create a link on the input string and possibly a big button after the string which links to editing in the RTE.
+     * Used for content element content displayed so the user can click the content / "Edit in Rich Text Editor" button
+     *
+     * @param string $linkText String to link. Must be prepared for HTML output.
+     * @param array $row The row.
+     * @return string If the whole thing was editable $str is return with link around. Otherwise just $str.
+     */
+    protected function linkEditContent(string $linkText, $row): string
+    {
+        if ($this->getBackendUser()->recordEditAccessInternals('tt_content', $row)) {
+            $urlParameters = [
+                'edit' => [
+                    'tt_content' => [
+                        $row['uid'] => 'edit'
+                    ]
+                ],
+                'returnUrl' => GeneralUtility::getIndpEnv('REQUEST_URI') . '#element-tt_content-' . $row['uid']
+            ];
+            $uriBuilder = GeneralUtility::makeInstance(UriBuilder::class);
+            $url = (string)$uriBuilder->buildUriFromRoute('record_edit', $urlParameters);
+            return '<a href="' . htmlspecialchars($url) . '" title="' . htmlspecialchars($this->getLanguageService()->getLL('edit')) . '">' . $linkText . '</a>';
+        }
+        return $linkText;
+    }
+
+    protected function createEmulatedPageLayoutViewFromDrawingConfiguration(DrawingConfiguration $drawingConfiguration): PageLayoutView
+    {
+        $pageLayoutView = GeneralUtility::makeInstance(PageLayoutView::class);
+        $pageLayoutView->option_newWizard = $drawingConfiguration->getShowNewContentWizard();
+        $pageLayoutView->defLangBinding = $drawingConfiguration->getDefaultLanguageBinding();
+        $pageLayoutView->tt_contentConfig['cols'] = implode(',', $drawingConfiguration->getActiveColumns());
+        $pageLayoutView->tt_contentConfig['activeCols'] = implode(',', $drawingConfiguration->getActiveColumns());
+        $pageLayoutView->tt_contentConfig['showHidden'] = $drawingConfiguration->getShowHidden();
+        $pageLayoutView->tt_contentConfig['sys_language_uid'] = $drawingConfiguration->getLanguageColumnsPointer();
+        if ($drawingConfiguration->getLanguageMode()) {
+            $pageLayoutView->tt_contentConfig['languageMode'] = 1;
+            $pageLayoutView->tt_contentConfig['languageCols'] = $drawingConfiguration->getLanguageColumns();
+            $pageLayoutView->tt_contentConfig['languageColsPointer'] = $drawingConfiguration->getLanguageColumnsPointer();
+        }
+        return $pageLayoutView;
+    }
+
+    protected function getBackendUser(): BackendUserAuthentication
+    {
+        return $GLOBALS['BE_USER'];
+    }
+
+    protected function getLanguageService(): LanguageService
+    {
+        return $GLOBALS['LANG'];
+    }
+
+    protected function getIconFactory(): IconFactory
+    {
+        return GeneralUtility::makeInstance(IconFactory::class);
+    }
+}
diff --git a/typo3/sysext/backend/Classes/Preview/StandardPreviewRendererResolver.php b/typo3/sysext/backend/Classes/Preview/StandardPreviewRendererResolver.php
new file mode 100644
index 000000000000..073fd7f23690
--- /dev/null
+++ b/typo3/sysext/backend/Classes/Preview/StandardPreviewRendererResolver.php
@@ -0,0 +1,88 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Backend\Preview;
+
+/*
+ * 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\Utility\GeneralUtility;
+
+/**
+ * Class StandardPreviewRendererResolver
+ *
+ * Default implementation of PreviewRendererResolverInterface.
+ * Scans TCA configuration to detect:
+ *
+ * - TCA.$table.types.$typeFromTypeField.previewRenderer
+ * - TCA.$table.ctrl.previewRenderer
+ *
+ * Depending on which one is defined and checking the first, type-specific
+ * variant first.
+ */
+class StandardPreviewRendererResolver implements PreviewRendererResolverInterface
+{
+    /**
+     * @param string $table The name of the table the returned PreviewRenderer must work with
+     * @param array $row A record from $table which will be previewed - allows returning a different PreviewRenderer based on record attributes
+     * @param int $pageUid The UID of the page on which the preview will be rendered - allows returning a different PreviewRenderer based on for example pageTSconfig
+     * @return PreviewRendererInterface
+     * @throws \UnexpectedValueException
+     * @throws \RuntimeException
+     */
+    public function resolveRendererFor(string $table, array $row, int $pageUid): PreviewRendererInterface
+    {
+        $tca = $GLOBALS['TCA'][$table];
+        $tcaTypeField = $tca['ctrl']['type'] ?? null;
+        $previewRendererClassName = null;
+        if ($tcaTypeField) {
+            $tcaTypeOfRow = $row[$tcaTypeField];
+            $typeConfiguration = $tca['types'][$tcaTypeOfRow] ?? [];
+
+            $subTypeValueField = $typeConfiguration['subtype_value_field'] ?? null;
+            if (!empty($subTypeValueField) && !empty($typeConfiguration['previewRenderer']) && is_array($typeConfiguration['previewRenderer'])) {
+                // An array of subtype_value_field indexed preview renderers was defined, look up the right
+                // class to use for the sub-type defined in this $row.
+                $previewRendererClassName = $typeConfiguration['previewRenderer'][$row[$subTypeValueField]] ?? null;
+            }
+
+            // If no class was found in the subtype_value_field
+            if (!$previewRendererClassName && !empty($typeConfiguration['previewRenderer'])) {
+                // A type-specific preview renderer was configured for the TCA type (and one was not detected
+                // based on the higher-priority lookups above).
+                $previewRendererClassName = $typeConfiguration['previewRenderer'];
+            }
+        }
+
+        if (!$previewRendererClassName) {
+
+            // Table either has no type field or no custom preview renderer was defined for the type.
+            // Use table's standard renderer if any is defined.
+            $previewRendererClassName = $tca['ctrl']['previewRenderer'] ?? null;
+        }
+
+        if (!empty($previewRendererClassName)) {
+            if (!is_a($previewRendererClassName, PreviewRendererInterface::class, true)) {
+                throw new \UnexpectedValueException(
+                    sprintf(
+                        'Class %s must implement %s',
+                        $previewRendererClassName,
+                        PreviewRendererInterface::class
+                    ),
+                    1477512798
+                );
+            }
+            return GeneralUtility::makeInstance($previewRendererClassName);
+        }
+        throw new \RuntimeException(sprintf('No Preview renderer registered for table %s', $table), 1477520356);
+    }
+}
diff --git a/typo3/sysext/backend/Classes/View/BackendLayout/Grid/GridColumnItem.php b/typo3/sysext/backend/Classes/View/BackendLayout/Grid/GridColumnItem.php
index 9dc2ad9d568f..dd39aa9200f0 100644
--- a/typo3/sysext/backend/Classes/View/BackendLayout/Grid/GridColumnItem.php
+++ b/typo3/sysext/backend/Classes/View/BackendLayout/Grid/GridColumnItem.php
@@ -16,6 +16,7 @@ namespace TYPO3\CMS\Backend\View\BackendLayout\Grid;
  * The TYPO3 project - inspiring people to share!
  */
 
+use TYPO3\CMS\Backend\Preview\StandardPreviewRendererResolver;
 use TYPO3\CMS\Backend\Routing\UriBuilder;
 use TYPO3\CMS\Backend\Utility\BackendUtility;
 use TYPO3\CMS\Backend\View\BackendLayout\BackendLayout;
@@ -60,124 +61,16 @@ class GridColumnItem extends AbstractGridObject
 
     public function getPreview(): string
     {
-        $item = $this;
-        $row = $item->getRecord();
-        $configuration = $this->backendLayout->getDrawingConfiguration();
-        $out = '';
-        $outHeader = '';
-
-        if ($row['header']) {
-            $hiddenHeaderNote = '';
-            // If header layout is set to 'hidden', display an accordant note:
-            if ($row['header_layout'] == 100) {
-                $hiddenHeaderNote = ' <em>[' . htmlspecialchars($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.hidden')) . ']</em>';
-            }
-            $outHeader = $row['date']
-                ? htmlspecialchars($configuration->getItemLabels()['date'] . ' ' . BackendUtility::date($row['date'])) . '<br />'
-                : '';
-            $outHeader .= '<strong>' . $this->linkEditContent($this->renderText($row['header']), $row)
-                . $hiddenHeaderNote . '</strong><br />';
-        }
-
-        $drawItem = true;
-
-        // Draw preview of the item depending on its CType (if not disabled by previous hook):
-        if ($drawItem) {
-            switch ($row['CType']) {
-                case 'header':
-                    if ($row['subheader']) {
-                        $out .= $this->linkEditContent($this->renderText($row['subheader']), $row) . '<br />';
-                    }
-                    break;
-                case 'bullets':
-                case 'table':
-                    if ($row['bodytext']) {
-                        $out .= $this->linkEditContent($this->renderText($row['bodytext']), $row) . '<br />';
-                    }
-                    break;
-                case 'uploads':
-                    if ($row['media']) {
-                        $out .= $this->linkEditContent($this->getThumbCodeUnlinked($row, 'tt_content', 'media'), $row) . '<br />';
-                    }
-                    break;
-                case 'shortcut':
-                    if (!empty($row['records'])) {
-                        $shortcutContent = [];
-                        $recordList = explode(',', $row['records']);
-                        foreach ($recordList as $recordIdentifier) {
-                            $split = BackendUtility::splitTable_Uid($recordIdentifier);
-                            $tableName = empty($split[0]) ? 'tt_content' : $split[0];
-                            $shortcutRecord = BackendUtility::getRecord($tableName, $split[1]);
-                            if (is_array($shortcutRecord)) {
-                                $icon = $this->iconFactory->getIconForRecord($tableName, $shortcutRecord, Icon::SIZE_SMALL)->render();
-                                $icon = BackendUtility::wrapClickMenuOnIcon(
-                                    $icon,
-                                    $tableName,
-                                    $shortcutRecord['uid']
-                                );
-                                $shortcutContent[] = $icon
-                                    . htmlspecialchars(BackendUtility::getRecordTitle($tableName, $shortcutRecord));
-                            }
-                        }
-                        $out .= implode('<br />', $shortcutContent) . '<br />';
-                    }
-                    break;
-                case 'list':
-                    $hookOut = '';
-                    $_params = ['pObj' => &$this, 'row' => $row, 'infoArr' => []];
-                    foreach (
-                        $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/layout/class.tx_cms_layout.php']['list_type_Info'][$row['list_type']] ??
-                        $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/layout/class.tx_cms_layout.php']['list_type_Info']['_DEFAULT'] ??
-                        [] as $_funcRef
-                    ) {
-                        $hookOut .= GeneralUtility::callUserFunction($_funcRef, $_params, $this);
-                    }
-                    if ((string)$hookOut !== '') {
-                        $out .= $hookOut;
-                    } elseif (!empty($row['list_type'])) {
-                        $label = BackendUtility::getLabelFromItemListMerged($row['pid'], 'tt_content', 'list_type', $row['list_type']);
-                        if (!empty($label)) {
-                            $out .= $this->linkEditContent('<strong>' . htmlspecialchars($this->getLanguageService()->sL($label)) . '</strong>', $row) . '<br />';
-                        } else {
-                            $message = sprintf($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.noMatchingValue'), $row['list_type']);
-                            $out .= '<span class="label label-warning">' . htmlspecialchars($message) . '</span>';
-                        }
-                    } else {
-                        $out .= '<strong>' . $this->getLanguageService()->getLL('noPluginSelected') . '</strong>';
-                    }
-                    $out .= htmlspecialchars($this->getLanguageService()->sL(
-                        BackendUtility::getLabelFromItemlist('tt_content', 'pages', $row['pages'])
-                    )) . '<br />';
-                    break;
-                default:
-                    $contentType = $this->backendLayout->getDrawingConfiguration()->getContentTypeLabels()[$row['CType']];
-                    if (!isset($contentType)) {
-                        $contentType =  BackendUtility::getLabelFromItemListMerged($row['pid'], 'tt_content', 'CType', $row['CType']);
-                    }
-
-                    if ($contentType) {
-                        $out .= $this->linkEditContent('<strong>' . htmlspecialchars($contentType) . '</strong>', $row) . '<br />';
-                        if ($row['bodytext']) {
-                            $out .= $this->linkEditContent($this->renderText($row['bodytext']), $row) . '<br />';
-                        }
-                        if ($row['image']) {
-                            $out .= $this->linkEditContent($this->getThumbCodeUnlinked($row, 'tt_content', 'image'), $row) . '<br />';
-                        }
-                    } else {
-                        $message = sprintf(
-                            $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.noMatchingValue'),
-                            $row['CType']
-                        );
-                        $out .= '<span class="label label-warning">' . htmlspecialchars($message) . '</span>';
-                    }
-            }
-        }
-        $out = '<span class="exampleContent">' . $out . '</span>';
-        $out = $outHeader . $out;
-        if ($item->isDisabled()) {
-            return '<span class="text-muted">' . $out . '</span>';
-        }
-        return $out;
+        $record = $this->getRecord();
+        $previewRenderer = GeneralUtility::makeInstance(StandardPreviewRendererResolver::class)
+            ->resolveRendererFor(
+                'tt_content',
+                $record,
+                $this->backendLayout->getDrawingConfiguration()->getPageId()
+            );
+        $previewHeader = $previewRenderer->renderPageModulePreviewHeader($this);
+        $previewContent = $previewRenderer->renderPageModulePreviewContent($this);
+        return $previewRenderer->wrapPageModulePreview($previewHeader, $previewContent, $this);
     }
 
     public function getWrapperClassName(): string
@@ -222,16 +115,16 @@ class GridColumnItem extends AbstractGridObject
         return $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_common.xlf:cancel');
     }
 
-    public function getFooterInfo(): iterable
+    public function getFooterInfo(): string
     {
-        $info = [];
-        $this->getProcessedValue('starttime,endtime,fe_group,space_before_class,space_after_class', $info);
-
-        if (!empty($GLOBALS['TCA']['tt_content']['ctrl']['descriptionColumn']) && !empty($this->record[$GLOBALS['TCA']['tt_content']['ctrl']['descriptionColumn']])) {
-            $info[] = $this->record[$GLOBALS['TCA']['tt_content']['ctrl']['descriptionColumn']];
-        }
-
-        return $info;
+        $record = $this->getRecord();
+        $previewRenderer = GeneralUtility::makeInstance(StandardPreviewRendererResolver::class)
+            ->resolveRendererFor(
+                'tt_content',
+                $record,
+                $this->backendLayout->getDrawingConfiguration()->getPageId()
+            );
+        return $previewRenderer->renderPageModulePreviewFooter($this);
     }
 
     /**
@@ -253,70 +146,6 @@ class GridColumnItem extends AbstractGridObject
         return $title;
     }
 
-    /**
-     * Create thumbnail code for record/field but not linked
-     *
-     * @param mixed[] $row Record array
-     * @param string $table Table (record is from)
-     * @param string $field Field name for which thumbnail are to be rendered.
-     * @return string HTML for thumbnails, if any.
-     */
-    protected function getThumbCodeUnlinked($row, $table, $field)
-    {
-        return BackendUtility::thumbCode($row, $table, $field, '', '', null, 0, '', '', false);
-    }
-
-    /**
-     * Will create a link on the input string and possibly a big button after the string which links to editing in the RTE.
-     * Used for content element content displayed so the user can click the content / "Edit in Rich Text Editor" button
-     *
-     * @param string $str String to link. Must be prepared for HTML output.
-     * @param array $row The row.
-     * @return string If the whole thing was editable $str is return with link around. Otherwise just $str.
-     */
-    public function linkEditContent($str, $row)
-    {
-        if ($this->getBackendUser()->recordEditAccessInternals('tt_content', $row)) {
-            $urlParameters = [
-                'edit' => [
-                    'tt_content' => [
-                        $row['uid'] => 'edit'
-                    ]
-                ],
-                'returnUrl' => GeneralUtility::getIndpEnv('REQUEST_URI') . '#element-tt_content-' . $row['uid']
-            ];
-            $uriBuilder = GeneralUtility::makeInstance(UriBuilder::class);
-            $url = (string)$uriBuilder->buildUriFromRoute('record_edit', $urlParameters);
-            return '<a href="' . htmlspecialchars($url) . '" title="' . htmlspecialchars($this->getLanguageService()->getLL('edit')) . '">' . $str . '</a>';
-        }
-        return $str;
-    }
-
-    /**
-     * Processing of larger amounts of text (usually from RTE/bodytext fields) with word wrapping etc.
-     *
-     * @param string $input Input string
-     * @return string Output string
-     */
-    public function renderText($input): string
-    {
-        $input = strip_tags($input);
-        $input = GeneralUtility::fixed_lgd_cs($input, 1500);
-        return nl2br(htmlspecialchars(trim($input), ENT_QUOTES, 'UTF-8', false));
-    }
-
-    protected function getProcessedValue(string $fieldList, array &$info): void
-    {
-        $itemLabels = $this->backendLayout->getDrawingConfiguration()->getItemLabels();
-        $fieldArr = explode(',', $fieldList);
-        foreach ($fieldArr as $field) {
-            if ($this->record[$field]) {
-                $info[] = '<strong>' . htmlspecialchars($itemLabels[$field]) . '</strong> '
-                    . htmlspecialchars(BackendUtility::getProcessedValue('tt_content', $field, $this->record[$field]));
-            }
-        }
-    }
-
     public function getIcons(): string
     {
         $table = 'tt_content';
diff --git a/typo3/sysext/backend/Resources/Private/Partials/PageLayout/Record/Footer.html b/typo3/sysext/backend/Resources/Private/Partials/PageLayout/Record/Footer.html
index c0b4c95e89c8..2f6108320401 100644
--- a/typo3/sysext/backend/Resources/Private/Partials/PageLayout/Record/Footer.html
+++ b/typo3/sysext/backend/Resources/Private/Partials/PageLayout/Record/Footer.html
@@ -1,7 +1,7 @@
-<div class="t3-page-ce-footer">
-    <div class="t3-page-ce-info">
-        <f:for each="{item.footerInfo}" as="infoLine" iteration="iteration">
-            {infoLine -> f:format.raw()}<f:if condition="!{iteration.isLast}"><br /></f:if>
-        </f:for>
+<f:if condition="{item.footerInfo}">
+    <div class="t3-page-ce-footer">
+        <div class="t3-page-ce-info">
+            {item.footerInfo -> f:format.raw()}
+        </div>
     </div>
-</div>
+</f:if>
diff --git a/typo3/sysext/backend/ext_localconf.php b/typo3/sysext/backend/ext_localconf.php
index d3c15687601b..d592adfc82ae 100644
--- a/typo3/sysext/backend/ext_localconf.php
+++ b/typo3/sysext/backend/ext_localconf.php
@@ -28,6 +28,11 @@ $GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['nodeRegistry'][1460321142] = [
 // Register search key shortcuts
 $GLOBALS['TYPO3_CONF_VARS']['SYS']['livesearch']['page'] = 'pages';
 
+// Register standard preview renderer resolver implementation.
+// Resolves PreviewRendererInterface implementations for a given table and record.
+// Can be replaced with custom implementation by overriding this value in extensions.
+$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['backend']['previewRendererResolver'] = \TYPO3\CMS\Backend\Preview\StandardPreviewRendererResolver::class;
+
 // Include base TSconfig setup
 \TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addPageTSConfig(
     "@import 'EXT:backend/Configuration/TSconfig/Page/Mod/Wizards/NewContentElement.tsconfig'"
diff --git a/typo3/sysext/core/Documentation/Changelog/master/Deprecation-90348-PageLayoutViewClassInternalIsDeprecated.rst b/typo3/sysext/core/Documentation/Changelog/master/Deprecation-90348-PageLayoutViewClassInternalIsDeprecated.rst
index 140c4d24f473..b3453af73054 100644
--- a/typo3/sysext/core/Documentation/Changelog/master/Deprecation-90348-PageLayoutViewClassInternalIsDeprecated.rst
+++ b/typo3/sysext/core/Documentation/Changelog/master/Deprecation-90348-PageLayoutViewClassInternalIsDeprecated.rst
@@ -21,8 +21,10 @@ Implementations which depend on :php:`PageLayoutView` should prepare to use the
 Affected Installations
 ======================
 
-Any site which uses PSR-14 events or backend content rendering hooks associated with :php:`PageLayoutView` such as :php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['cms/layout/class.tx_cms_layout.php']['tt_content_drawFooter']`.
-
+* Any site which overrides the ``PageLayoutView`` class. The overridden class will still be instanced when rendering previews in BE page module - but no methods will be called on the instance **unless** they are called by a third party hook subscriber.
+* Any site which depends on PSR-14 events associated with ``PageLayoutView`` will only have those events dispatched if the ``fluidBasedPageModule`` feature flag is ``false``.
+  * Affects ``\TYPO3\CMS\Backend\View\Event\AfterSectionMarkupGeneratedEvent``.
+  * Affects ``\TYPO3\CMS\Backend\View\Event\BeforeSectionMarkupGeneratedEvent``.
 
 Migration
 =========
diff --git a/typo3/sysext/core/Documentation/Changelog/master/Feature-78450-IntroducePreviewRendererPattern.rst b/typo3/sysext/core/Documentation/Changelog/master/Feature-78450-IntroducePreviewRendererPattern.rst
new file mode 100644
index 000000000000..8d768c394b1a
--- /dev/null
+++ b/typo3/sysext/core/Documentation/Changelog/master/Feature-78450-IntroducePreviewRendererPattern.rst
@@ -0,0 +1,158 @@
+.. include:: ../../Includes.txt
+
+===================================================
+Feature: #78450 - Introduce PreviewRenderer pattern
+===================================================
+
+See :issue:`78450`
+
+Pre-requisites
+==============
+
+The PreviewRenderer usage is only active if the "fluid based page layout module" feature is enabled. This feature
+is enabled by default in TYPO3 versions 10.3 and later.
+
+The feature toggle can be located in the `Settings` admin module under `Feature Toggles`. Or it can be set in
+PHP using :php:``$GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['fluidBasedPageModule'] = true;``.
+
+
+Description
+===========
+
+A new pattern has been introduced to facilitate (record) previews in TYPO3. A default implementation has been
+added which provides support for the previous methods of generating previews (content previews - using hooks
+or by defining a Fluid template to render).
+
+The new pattern creates a strict contract for code which generates such previews and enables switching out the
+implementation of both the resolving logic (which finds a preview renderer for a given table and record) as well
+as the rendering logic (which now renders both the actual preview and has contract methods for adding wrapping).
+
+The main differences between the old and the new approach are:
+
+* The class used to render previews is now defined in TCA and can be defined per-type or for any type.
+* The resolver used to find preview renderers is a global implementation overridable in configuration.
+* A single preview renderer will now be used; before, hook subscribers had to toggle passed-by-reference flags.
+* Wrapping is no longer forced to be a `<span>` tag so you are not restricted to inline and inline-block display.
+* Preview renderers have a public contract which splits up actual preview and wrapping, allowing third party renderers
+  to subclass the original renderer and for example only change the wrapping tag.
+* Preview rendering can now be done ad-hoc through; the pattern can be used from any context where the old pattern
+  could only be used (was only used) in the PageLayoutView for content previews.
+
+
+Impact
+======
+
+The feature adds two new concepts:
+
+* `PreviewRendererResolver` which is a global implementation to detect which `PreviewRenderer` a given record needs.
+* `PreviewRenderer` which is the class responsible for generating the preview and the wrapping.
+
+
+Configuring the implementation
+------------------------------
+
+The PreviewRendererResolver can be overridden by setting:
+
+.. code-block:: php
+
+    $GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['backend']['previewRendererResolver'] = \TYPO3\CMS\Backend\Preview\StandardPreviewRendererResolver::class;
+
+
+(the class shown is the standard implementation TYPO3 provides, inspect this class for further developer information)
+
+Once overridden the old resolver will no longer be consulted.
+
+And individual preview renderers can be defined using one of the following two approaches:
+
+.. code-block:: php
+
+    $GLOBALS['TCA'][$table]['ctrl']['previewRenderer'] = My\PreviewRenderer::class;
+
+
+Which specifies the PreviewRenderer to use for any record in `$table`
+
+Or if your table has a "type" field/attribute:
+
+.. code-block:: php
+
+    $GLOBALS['TCA'][$table]['types'][$type]['previewRenderer'] = My\PreviewRenderer::class;
+
+Which specifies the PreviewRenderer for only records of type `$type` as determined by the type field of your table.
+
+Or finally, if your table and field has a `subtype_value_field` TCA setting (like `tt_content.list_type` for example)
+and you want register a preview renderer that applies only when that value is selected (e.g. when a certain plugin type
+is selected and you can't match it with the "type" of the record alone):
+
+.. code-block:: php
+
+    $GLOBALS['TCA'][$table]['types'][$type]['previewRenderer'][$subType] = My\PreviewRenderer::class;
+
+Where `$type` is for example `list` (indicating a plugin) and `$subType` is the value of the `list_type` field when the
+type of plugin you want to target, is selected as plugin type.
+
+Note: recommended location is in the `ctrl` array in your extension's `Configuration/TCA/$table.php` or
+`Configuration/TCA/Overrides/$table.php` file. The former is used when your extension is the one that creates the table -
+the latter is used when you need to override TCA properties of tables added by the core or other extensions.
+
+
+The PreviewRenderer interface
+-----------------------------
+
+`\TYPO3\CMS\Backend\Preview\PreviewRendererResolverInterface` must be implemented by PreviewRendererResolvers and
+contains a single API method, `public function resolveRendererFor($table, array $row, int $pageUid);` which
+unsurprisingly returns a single PreviewRenderer based on the given input.
+
+`\TYPO3\CMS\Backend\Preview\PreviewRendererInterface` must be implemented by any PreviewRenderer and contains a few
+API methods:
+
+.. code-block:: php
+    /**
+     * Dedicated method for rendering preview header HTML for
+     * the page module only. Receives $item which is an instance
+     * GridColumnItem which has a getter method to return the record.
+     *
+     * @param GridColumnItem
+     * @return string
+     */
+    public function renderPageModulePreviewHeader(GridColumnItem $item);
+
+    /**
+     * Dedicated method for rendering preview body HTML for
+     * the page module only.
+     *
+     * @param GridColumnItem $item
+     * @return string
+     */
+    public function renderPageModulePreviewContent(GridColumnItem $item);
+
+    /**
+     * Render a footer for the record to display in page module below
+     * the body of the item's preview.
+     *
+     * @param GridColumnItem $item
+     * @return string
+     */
+    public function renderPageModulePreviewFooter(GridColumnItem $item): string;
+
+    /**
+     * Dedicated method for wrapping a preview header and body HTML.
+     *
+     * @param string $previewHeader
+     * @param string $previewContent
+     * @param GridColumnItem $item
+     * @return string
+     */
+    public function wrapPageModulePreview($previewHeader, $previewContent, GridColumnItem $item);
+
+With further methods expected to be added to support generic preview rendering, e.g. usages outside PageLayoutView.
+Implementing these methods allows you to control the exact composition of the preview.
+
+This means assuming your PreviewRenderer returns `<h4>Header</h4>` from the header render method and `<p>Body</p>` from
+the preview content rendering method and your wrapping method does `return '<div>' . $previewHeader . $previewContent . '</div>';` then the
+entire output becomes `<div><h4>Header</h4><p>Body</p></div>` when combined.
+
+Should you wish to reuse parts of the default preview rendering and only change, for example, the method that renders
+the preview body content, you can subclass ``\TYPO3\CMS\Backend\Preview\StandardContentPreviewRenderer`` in your
+own PreviewRenderer class - and selectively override the methods from the API displayed above.
+
+.. index:: Backend, TCA
diff --git a/typo3/sysext/frontend/Configuration/TCA/tt_content.php b/typo3/sysext/frontend/Configuration/TCA/tt_content.php
index d8b60475de21..01ad1774377e 100644
--- a/typo3/sysext/frontend/Configuration/TCA/tt_content.php
+++ b/typo3/sysext/frontend/Configuration/TCA/tt_content.php
@@ -23,6 +23,7 @@ return [
         'transOrigDiffSourceField' => 'l18n_diffsource',
         'languageField' => 'sys_language_uid',
         'translationSource' => 'l10n_source',
+        'previewRenderer' => \TYPO3\CMS\Backend\Preview\StandardContentPreviewRenderer::class,
         'enablecolumns' => [
             'disabled' => 'hidden',
             'starttime' => 'starttime',
-- 
GitLab