From 1cbfb67a4c412f4c9bc3889c76ae594c12023fd7 Mon Sep 17 00:00:00 2001 From: Ruben Date: Fri, 6 Feb 2026 18:24:31 +0100 Subject: [PATCH] Add Atom feed support Add feed URL to base template Refactor list item building into separate function Improve date extraction logic Add feed XML generation handler Update template variables handling --- app/default/templates/base.php | 3 + app/helpers.php | 70 +++++++++++++++- app/rendering.php | 7 +- app/router.php | 146 +++++++++++++++++++++------------ 4 files changed, 166 insertions(+), 60 deletions(-) diff --git a/app/default/templates/base.php b/app/default/templates/base.php index ccf18d6..ce25bfd 100644 --- a/app/default/templates/base.php +++ b/app/default/templates/base.php @@ -14,6 +14,9 @@ + + +
diff --git a/app/helpers.php b/app/helpers.php index a84c786..489202e 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -32,11 +32,73 @@ function extractTitle(string $filePath): ?string { return null; } -function extractDateFromFolder(string $folderName): ?string { +// Build sorted list items for a directory +function buildListItems(string $dir, Context $ctx, ?array $parentMetadata): array { + $subdirs = getSubdirectories($dir); + + $items = array_filter(array_map(function($item) use ($dir, $ctx) { + $itemPath = "$dir/$item"; + $metadata = loadMetadata($itemPath); + $coverImage = findCoverImage($itemPath); + $pdfFile = findPdfFile($itemPath); + + $title = $metadata['title'] ?? extractTitle($itemPath) ?? $item; + + $rawDate = null; + $date = null; + if (isset($metadata['date'])) { + $rawDate = $metadata['date']; + $date = Hooks::apply(Hook::PROCESS_CONTENT, $rawDate, 'date_format'); + } else { + $rawDate = extractRawDateFromFolder($item); + if ($rawDate) { + $date = Hooks::apply(Hook::PROCESS_CONTENT, $rawDate, 'date_format'); + } else { + $rawDate = date("Y-m-d", filemtime($itemPath)); + $date = Hooks::apply(Hook::PROCESS_CONTENT, $rawDate, 'date_format'); + } + } + + $urlSlug = ($metadata && isset($metadata['slug'])) ? $metadata['slug'] : $item; + + $langPrefix = $ctx->get('langPrefix', ''); + $baseUrl = $langPrefix . '/' . trim($ctx->requestPath, '/') . '/' . urlencode($urlSlug); + $assetUrl = $langPrefix . '/' . trim($ctx->requestPath, '/') . '/' . urlencode($item); + + return [ + 'title' => $title, + 'url' => $baseUrl . '/', + 'date' => $date, + 'rawDate' => $rawDate, + 'summary' => $metadata['summary'] ?? null, + 'cover' => $coverImage ? "$assetUrl/$coverImage" : null, + 'pdf' => $pdfFile ? "$assetUrl/$pdfFile" : null, + 'redirect' => $metadata['redirect'] ?? null, + 'dirPath' => $itemPath + ]; + }, $subdirs)); + + $sortOrder = strtolower($parentMetadata['order'] ?? 'descending'); + if ($sortOrder === 'ascending') { + usort($items, fn($a, $b) => strcmp($a['date'] ?? '', $b['date'] ?? '')); + } else { + usort($items, fn($a, $b) => strcmp($b['date'] ?? '', $a['date'] ?? '')); + } + + return $items; +} + +function extractRawDateFromFolder(string $folderName): ?string { if (preg_match('/^(\d{4})-(\d{2})-(\d{2})-/', $folderName, $matches)) { - $dateString = $matches[1] . '-' . $matches[2] . '-' . $matches[3]; - // Let plugins format the date - return Hooks::apply(Hook::PROCESS_CONTENT, $dateString, 'date_format'); + return $matches[1] . '-' . $matches[2] . '-' . $matches[3]; + } + return null; +} + +function extractDateFromFolder(string $folderName): ?string { + $raw = extractRawDateFromFolder($folderName); + if ($raw) { + return Hooks::apply(Hook::PROCESS_CONTENT, $raw, 'date_format'); } return null; } diff --git a/app/rendering.php b/app/rendering.php index 7d61f89..ef4e57a 100644 --- a/app/rendering.php +++ b/app/rendering.php @@ -48,13 +48,16 @@ function renderTemplate(Context $ctx, string $content, int $statusCode = 200): v $navigation = $ctx->navigation; $homeLabel = $ctx->homeLabel; - $pageTitle = null; $templateVars = Hooks::apply(Hook::TEMPLATE_VARS, [ 'content' => $content, 'navigation' => $navigation, 'homeLabel' => $homeLabel, - 'pageTitle' => $pageTitle + 'pageTitle' => $ctx->get('pageTitle'), + 'metaDescription' => $ctx->get('metaDescription'), + 'pageCssUrl' => $ctx->get('pageCssUrl'), + 'pageCssHash' => $ctx->get('pageCssHash'), + 'feedUrl' => $ctx->get('feedUrl') ], $ctx); extract($templateVars); diff --git a/app/router.php b/app/router.php index 64e706a..d9710e0 100644 --- a/app/router.php +++ b/app/router.php @@ -52,6 +52,82 @@ if (file_exists($contentAssetPath) && is_file($contentAssetPath)) { } } +// Handle Atom feed requests +if (str_ends_with($ctx->requestPath, 'feed.xml')) { + $feedPath = preg_replace('#/?feed\.xml$#', '', $ctx->requestPath); + + // Temporarily set requestPath to the parent directory for resolution + $reflection = new ReflectionProperty($ctx, 'requestPath'); + $originalPath = $ctx->requestPath; + $reflection->setValue($ctx, $feedPath); + + $parsedFeed = parseRequestPath($ctx); + + if ($parsedFeed['type'] !== 'list') { + $reflection->setValue($ctx, $originalPath); + } else { + $dir = $parsedFeed['path']; + $metadata = loadMetadata($dir); + + if (!isset($metadata['feed']) || !$metadata['feed']) { + $reflection->setValue($ctx, $originalPath); + } else { + $items = buildListItems($dir, $ctx, $metadata); + + // Render full content for each item + foreach ($items as &$item) { + $item['content'] = ''; + $contentFiles = findAllContentFiles($item['dirPath']); + foreach ($contentFiles as $file) { + $item['content'] .= renderContentFile($file, $ctx); + } + } + unset($item); + + // Build Atom XML + $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; + $host = $_SERVER['HTTP_HOST'] ?? 'localhost'; + $baseUrl = "$scheme://$host"; + $langPrefix = $ctx->get('langPrefix', ''); + $listUrl = $baseUrl . $langPrefix . '/' . trim($feedPath, '/') . '/'; + $feedUrl = $baseUrl . $langPrefix . '/' . trim($feedPath, '/') . '/feed.xml'; + $feedTitle = $metadata['title'] ?? 'Feed'; + $updated = !empty($items) ? ($items[0]['rawDate'] ?? date('Y-m-d')) : date('Y-m-d'); + + header('Content-Type: application/atom+xml; charset=utf-8'); + echo '' . "\n"; + echo '' . "\n"; + echo ' ' . htmlspecialchars($feedTitle) . '' . "\n"; + echo ' ' . "\n"; + echo ' ' . "\n"; + echo ' ' . htmlspecialchars($listUrl) . '' . "\n"; + echo ' ' . $updated . 'T00:00:00Z' . "\n"; + + foreach ($items as $item) { + $absoluteUrl = $baseUrl . $item['url']; + $itemDate = ($item['rawDate'] ?? date('Y-m-d')) . 'T00:00:00Z'; + + echo ' ' . "\n"; + echo ' ' . htmlspecialchars($item['title']) . '' . "\n"; + echo ' ' . "\n"; + echo ' ' . htmlspecialchars($absoluteUrl) . '' . "\n"; + echo ' ' . $itemDate . '' . "\n"; + if ($item['summary']) { + echo ' ' . htmlspecialchars($item['summary']) . '' . "\n"; + } + if ($item['content']) { + $safeContent = str_replace(']]>', ']]]]>', $item['content']); + echo ' ' . "\n"; + } + echo ' ' . "\n"; + } + + echo '' . "\n"; + exit; + } + } +} + // Handle frontpage if (empty($ctx->requestPath)) { $contentFiles = findAllContentFiles($ctx->contentDir); @@ -121,59 +197,7 @@ switch ($parsedPath['type']) { } // Build list items - $subdirs = getSubdirectories($dir); - - $items = array_filter(array_map(function($item) use ($dir, $ctx) { - $itemPath = "$dir/$item"; - $metadata = loadMetadata($itemPath); - $coverImage = findCoverImage($itemPath); - $pdfFile = findPdfFile($itemPath); - - $title = $metadata['title'] ?? extractTitle($itemPath) ?? $item; - $date = null; - if (isset($metadata['date'])) { - $date = $metadata['date']; - // Let plugins format date - $date = Hooks::apply(Hook::PROCESS_CONTENT, $date, 'date_format'); - } else { - $extractedDate = extractDateFromFolder($item); - if ($extractedDate) { - $date = $extractedDate; - } else { - // Convert timestamp to ISO format and let plugins format it - $isoDate = date("Y-m-d", filemtime($itemPath)); - $date = Hooks::apply(Hook::PROCESS_CONTENT, $isoDate, 'date_format'); - } - } - - // Use slug if available - $urlSlug = ($metadata && isset($metadata['slug'])) ? $metadata['slug'] : $item; - - $langPrefix = $ctx->get('langPrefix', ''); - $baseUrl = $langPrefix . '/' . trim($ctx->requestPath, '/') . '/' . urlencode($urlSlug); - - // Assets (cover, PDF) must use actual folder name, not translated slug - $assetUrl = $langPrefix . '/' . trim($ctx->requestPath, '/') . '/' . urlencode($item); - - return [ - 'title' => $title, - 'url' => $baseUrl . '/', - 'date' => $date, - 'summary' => $metadata['summary'] ?? null, - 'cover' => $coverImage ? "$assetUrl/$coverImage" : null, - 'pdf' => $pdfFile ? "$assetUrl/$pdfFile" : null, - 'redirect' => $metadata['redirect'] ?? null - ]; - }, $subdirs)); - - // Sort by date - check metadata for order preference - $sortOrder = strtolower($metadata['order'] ?? 'descending'); - if ($sortOrder === 'ascending') { - usort($items, fn($a, $b) => strcmp($a['date'] ?? '', $b['date'] ?? '')); - } else { - // Default: descending (newest first) - usort($items, fn($a, $b) => strcmp($b['date'] ?? '', $a['date'] ?? '')); - } + $items = buildListItems($dir, $ctx, $metadata); // Prepare all variables for base template $navigation = $ctx->navigation; @@ -186,6 +210,19 @@ switch ($parsedPath['type']) { $pageCssUrl = $pageCss['url'] ?? null; $pageCssHash = $pageCss['hash'] ?? null; + // Build feed URL if feed is enabled + $langPrefix = $ctx->get('langPrefix', ''); + $feedUrl = (isset($metadata['feed']) && $metadata['feed']) + ? $langPrefix . '/' . trim($ctx->requestPath, '/') . '/feed.xml' + : null; + + // Store for base template (renderTemplate reads these from context) + $ctx->set('pageTitle', $pageTitle); + $ctx->set('metaDescription', $metaDescription); + $ctx->set('pageCssUrl', $pageCssUrl); + $ctx->set('pageCssHash', $pageCssHash); + $ctx->set('feedUrl', $feedUrl); + // Let plugins add template variables $templateVars = Hooks::apply(Hook::TEMPLATE_VARS, [ 'navigation' => $navigation, @@ -195,7 +232,8 @@ switch ($parsedPath['type']) { 'pageCssUrl' => $pageCssUrl, 'pageCssHash' => $pageCssHash, 'items' => $items, - 'pageContent' => $pageContent + 'pageContent' => $pageContent, + 'feedUrl' => $feedUrl ], $ctx); extract($templateVars);