News-Liste: serverseitige Pagination (Kontrakt 0070-FE)

Die Uebersichtsliste (news_newsliststicky/news_pi1 ohne limit und ohne
Auswahl, d.h. genau /insights/news) liefert jetzt pro Request nur die
Karten der angefragten Seite: ?page=<n>&category=<slug>, Antwort additiv
mit pagination {page,pages,itemsPerPage,total}, categories[] mit
Gesamt-Zaehlern und activeCategory. page klemmt auf die letzte Seite,
unbekannte category faellt auf alle zurueck, Sortierung serverseitig
datetime DESC, itemsPerPage aus settings.list.paginate (Default 12).

Caching: page/category stehen in cacheHash.excludedParameters, der
TYPO3-Seitencache unterscheidet die Varianten also nicht - das Element
konvertiert sich deshalb im paginierten Fall per
convertToUserIntObject() zu USER_INT (EXT:headless wrappt den
INT-Marker selbst); ETag/304 je Query-Variante verifiziert, der
SSR-Cache haelt eine Variante pro Pfad+Query.

Karten im paginierten Modus in schlanker Form (Felderliste aus
0070-FE); media[] traegt jetzt ueberall alternative (Alt-Text-Fallback
B5). Teaser-Listen mit limit und Selected-/Search-Listen unveraendert;
ohne pagination-Feld bleibt das Frontend beim bisherigen Verhalten.

JSON der Liste: 700 kB -> ~100 kB pro Seite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
orascheandClaude Fable 5 committed 2026-11-10 11:11:55 +01:00
1 parent be163a88c2
commit c9b00e6f10
1 file changed
+217
@@ -155,7 +155,39 @@ final class NewsJsonRenderer
return json_encode($response) ?: ''; return json_encode($response) ?: '';
} }
// Server-side pagination for the big overview list (contract
// 0070-FE/0075-BE): only a plain/sticky list WITHOUT a limit and
// WITHOUT a hand-picked selection paginates. That is exactly the
// /insights/news element (#130); every teaser list carries a
// limit (1-3) and keeps today's full behaviour, as do selected/
// search lists - the frontend falls back to client-side paging
// wherever the `pagination` key is absent.
$paginated = in_array($type, ['news_pi1', 'news_newsliststicky'], true)
&& (int)($settings['limit'] ?? 0) === 0
&& trim((string)($settings['selectedList'] ?? '')) === '';
if ($paginated
&& $this->cObj !== null
&& $this->cObj->getUserObjectType() === ContentObjectRenderer::OBJECTTYPE_USER
) {
// `page`/`category` sit in cacheHash.excludedParameters, so the
// TYPO3 page cache does NOT distinguish the query variants -
// a cacheable render would bake page 1 in for every URL. As
// USER_INT the element renders per request; EXT:headless wraps
// the INT marker itself (JsonContentObject detects
// '<!--INT_SCRIPT.'), and the SSR full-page cache keeps one
// variant per path+query, so per-request cost stays off the
// visitors' hot path.
$this->cObj->convertToUserIntObject();
return '';
}
$newsRows = $this->fetchNewsList($contentElement, $settings); $newsRows = $this->fetchNewsList($contentElement, $settings);
if ($paginated) {
return $this->renderPaginatedList($contentElement, $settings, $newsRows);
}
$items = array_map(fn (array $newsRow): array => $this->serializeNews($newsRow, $settings, 'list'), $newsRows); $items = array_map(fn (array $newsRow): array => $this->serializeNews($newsRow, $settings, 'list'), $newsRows);
$response = [ $response = [
@@ -360,6 +392,188 @@ final class NewsJsonRenderer
]; ];
} }
/**
* Paginated list payload (contract 0070-FE/0075-BE): only the requested
* page's cards plus pagination meta, filter chips with full-list counts
* and the active category. Request: ?page=<n>&category=<slug> on the page
* URL; page is clamped into [1..pages] (page > pages serves the last
* page), an unknown category slug falls back to "all".
*
* @param array<string,mixed> $contentElement
* @param array<string,mixed> $settings
* @param array<int,array<string,mixed>> $rows full filtered list (no limit)
*/
private function renderPaginatedList(array $contentElement, array $settings, array $rows): string
{
$queryParams = $GLOBALS['TYPO3_REQUEST']?->getQueryParams() ?? [];
$requestedPage = max(1, (int)($queryParams['page'] ?? 1));
$requestedCategory = strtolower(trim((string)($queryParams['category'] ?? '')));
// All categories of all entries in ONE query - feeds both the filter
// chips (counts over the FULL list, independent of the active filter)
// and the per-card category labels.
$categoriesByNews = $this->categoriesForNewsUids(
array_map(static fn (array $row): int => (int)($row['uid'] ?? 0), $rows)
);
$categoryIndex = [];
foreach ($rows as $row) {
foreach ($categoriesByNews[(int)($row['uid'] ?? 0)] ?? [] as $category) {
$slug = $this->slugify((string)$category['title']);
if ($slug === '') {
continue;
}
if (!isset($categoryIndex[$slug])) {
$categoryIndex[$slug] = [
'uid' => (int)$category['uid'],
'title' => (string)$category['title'],
'slug' => $slug,
'count' => 0,
];
}
$categoryIndex[$slug]['count']++;
}
}
ksort($categoryIndex);
$activeCategory = isset($categoryIndex[$requestedCategory]) ? $requestedCategory : '';
if ($activeCategory !== '') {
$rows = array_values(array_filter($rows, function (array $row) use ($categoriesByNews, $activeCategory): bool {
foreach ($categoriesByNews[(int)($row['uid'] ?? 0)] ?? [] as $category) {
if ($this->slugify((string)$category['title']) === $activeCategory) {
return true;
}
}
return false;
}));
}
// Newest first, server-side (the flexform historically says
// "tstamp asc"; the frontend re-sorted - now it no longer has to).
usort($rows, static fn (array $a, array $b): int => ((int)($b['datetime'] ?? 0)) <=> ((int)($a['datetime'] ?? 0)));
$itemsPerPage = (int)($settings['list']['paginate']['itemsPerPage'] ?? 0);
if ($itemsPerPage <= 0) {
$itemsPerPage = 12;
}
$total = count($rows);
$pages = max(1, (int)ceil($total / $itemsPerPage));
$page = min($requestedPage, $pages);
$pageRows = array_slice($rows, ($page - 1) * $itemsPerPage, $itemsPerPage);
$items = array_map(
fn (array $row): array => $this->serializeListCard($row, $settings, $categoriesByNews[(int)($row['uid'] ?? 0)] ?? []),
$pageRows
);
$response = [
'mode' => 'list',
'items' => $items,
'pagination' => [
'page' => $page,
'pages' => $pages,
'itemsPerPage' => $itemsPerPage,
'total' => $total,
],
'categories' => array_values($categoryIndex),
'activeCategory' => $activeCategory,
'settings' => $this->buildSettingsPayload($contentElement, $settings),
];
return json_encode($response) ?: '';
}
/**
* Lean card shape for paginated pages - only what the cards render
* (0070-FE field list); canonicalUrl, pathSegment, tags, related* are
* deliberately absent here.
*
* @param array<string,mixed> $news
* @param array<string,mixed> $settings
* @param array<int,array<string,mixed>> $categories
* @return array<string,mixed>
*/
private function serializeListCard(array $news, array $settings, array $categories): array
{
$uid = (int)($news['uid'] ?? 0);
$urls = $this->buildNewsUrls($news, $settings);
$teaser = trim((string)($news['teaser'] ?? ''));
return [
'uid' => $uid,
'title' => (string)($news['title'] ?? ''),
'alternativeTitle' => (string)($news['alternative_title'] ?? ''),
'type' => (string)($news['type'] ?? '0'),
'detailUrl' => $urls['detailUrl'],
'datetime' => (int)($news['datetime'] ?? 0),
'istopnews' => (bool)($news['istopnews'] ?? false),
'teaser' => $teaser,
'bodytext' => $teaser !== '' ? '' : $this->listExcerpt((string)($news['bodytext'] ?? '')),
'categories' => array_map(
static fn (array $category): array => ['uid' => (int)$category['uid'], 'title' => (string)$category['title']],
$categories
),
'media' => $this->resolveFiles('tx_news_domain_model_news', 'fal_media', $uid),
];
}
/**
* uid => categories map for a set of news records, resolved in one query
* (the per-record resolveCategories() would fire ~600 queries here).
*
* @param list<int> $newsUids
* @return array<int,array<int,array{uid:int,title:string}>>
*/
private function categoriesForNewsUids(array $newsUids): array
{
$newsUids = array_values(array_filter($newsUids));
if ($newsUids === []) {
return [];
}
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_category');
$rows = $queryBuilder
->select('c.uid', 'c.title', 'mm.uid_foreign')
->from('sys_category', 'c')
->join('c', 'sys_category_record_mm', 'mm', 'mm.uid_local = c.uid')
->where(
$queryBuilder->expr()->eq('mm.tablenames', $queryBuilder->createNamedParameter('tx_news_domain_model_news', ParameterType::STRING)),
$queryBuilder->expr()->eq('mm.fieldname', $queryBuilder->createNamedParameter('categories', ParameterType::STRING)),
$queryBuilder->expr()->in('mm.uid_foreign', $queryBuilder->createNamedParameter($newsUids, ArrayParameterType::INTEGER)),
$queryBuilder->expr()->eq('c.deleted', 0),
$queryBuilder->expr()->eq('c.hidden', 0)
)
->orderBy('c.title', 'ASC')
->executeQuery()
->fetchAllAssociative();
$map = [];
foreach ($rows as $row) {
$map[(int)$row['uid_foreign']][] = [
'uid' => (int)$row['uid'],
'title' => (string)$row['title'],
];
}
return $map;
}
/**
* Category title -> filter slug ("Press Release" -> "press-release"),
* mirroring the slugs the page URLs already carry.
*/
private function slugify(string $value): string
{
$value = mb_strtolower(trim($value));
$value = preg_replace('/[^a-z0-9]+/u', '-', $value) ?? $value;
return trim($value, '-');
}
/** /**
* Plain-text excerpt of an RTE body for list cards (teaser fallback). * Plain-text excerpt of an RTE body for list cards (teaser fallback).
*/ */
@@ -697,6 +911,9 @@ final class NewsJsonRenderer
$files[] = [ $files[] = [
'uid' => (int)$row['uid'], 'uid' => (int)$row['uid'],
'url' => $imageService->getImageUri($processed), 'url' => $imageService->getImageUri($processed),
// Editor-maintained alt text; the frontend falls back to
// the article title when this is empty (0070-FE, B5).
'alternative' => (string)($fileReference->getAlternative() ?? ''),
]; ];
} catch (\Throwable $e) { } catch (\Throwable $e) {
continue; continue;