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
This commit is contained in:
Ruben 2026-02-06 18:24:31 +01:00
parent b03511f99b
commit 1cbfb67a4c
4 changed files with 166 additions and 60 deletions

View file

@ -14,6 +14,9 @@
<?php if (!empty($pageCssUrl)): ?> <?php if (!empty($pageCssUrl)): ?>
<link rel="stylesheet" href="<?= htmlspecialchars($pageCssUrl) ?>?v=<?= htmlspecialchars($pageCssHash ?? '') ?>"> <link rel="stylesheet" href="<?= htmlspecialchars($pageCssUrl) ?>?v=<?= htmlspecialchars($pageCssHash ?? '') ?>">
<?php endif; ?> <?php endif; ?>
<?php if (!empty($feedUrl)): ?>
<link rel="alternate" type="application/atom+xml" title="<?= htmlspecialchars($pageTitle ?? 'Feed') ?>" href="<?= htmlspecialchars($feedUrl) ?>">
<?php endif; ?>
</head> </head>
<body> <body>
<header> <header>

View file

@ -32,11 +32,73 @@ function extractTitle(string $filePath): ?string {
return null; 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)) { if (preg_match('/^(\d{4})-(\d{2})-(\d{2})-/', $folderName, $matches)) {
$dateString = $matches[1] . '-' . $matches[2] . '-' . $matches[3]; return $matches[1] . '-' . $matches[2] . '-' . $matches[3];
// Let plugins format the date }
return Hooks::apply(Hook::PROCESS_CONTENT, $dateString, 'date_format'); return null;
}
function extractDateFromFolder(string $folderName): ?string {
$raw = extractRawDateFromFolder($folderName);
if ($raw) {
return Hooks::apply(Hook::PROCESS_CONTENT, $raw, 'date_format');
} }
return null; return null;
} }

View file

@ -48,13 +48,16 @@ function renderTemplate(Context $ctx, string $content, int $statusCode = 200): v
$navigation = $ctx->navigation; $navigation = $ctx->navigation;
$homeLabel = $ctx->homeLabel; $homeLabel = $ctx->homeLabel;
$pageTitle = null;
$templateVars = Hooks::apply(Hook::TEMPLATE_VARS, [ $templateVars = Hooks::apply(Hook::TEMPLATE_VARS, [
'content' => $content, 'content' => $content,
'navigation' => $navigation, 'navigation' => $navigation,
'homeLabel' => $homeLabel, '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); ], $ctx);
extract($templateVars); extract($templateVars);

View file

@ -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 '<?xml version="1.0" encoding="utf-8"?>' . "\n";
echo '<feed xmlns="http://www.w3.org/2005/Atom">' . "\n";
echo ' <title>' . htmlspecialchars($feedTitle) . '</title>' . "\n";
echo ' <link href="' . htmlspecialchars($listUrl) . '" rel="alternate"/>' . "\n";
echo ' <link href="' . htmlspecialchars($feedUrl) . '" rel="self"/>' . "\n";
echo ' <id>' . htmlspecialchars($listUrl) . '</id>' . "\n";
echo ' <updated>' . $updated . 'T00:00:00Z</updated>' . "\n";
foreach ($items as $item) {
$absoluteUrl = $baseUrl . $item['url'];
$itemDate = ($item['rawDate'] ?? date('Y-m-d')) . 'T00:00:00Z';
echo ' <entry>' . "\n";
echo ' <title>' . htmlspecialchars($item['title']) . '</title>' . "\n";
echo ' <link href="' . htmlspecialchars($absoluteUrl) . '" rel="alternate"/>' . "\n";
echo ' <id>' . htmlspecialchars($absoluteUrl) . '</id>' . "\n";
echo ' <updated>' . $itemDate . '</updated>' . "\n";
if ($item['summary']) {
echo ' <summary>' . htmlspecialchars($item['summary']) . '</summary>' . "\n";
}
if ($item['content']) {
$safeContent = str_replace(']]>', ']]]]><![CDATA[>', $item['content']);
echo ' <content type="html"><![CDATA[' . $safeContent . ']]></content>' . "\n";
}
echo ' </entry>' . "\n";
}
echo '</feed>' . "\n";
exit;
}
}
}
// Handle frontpage // Handle frontpage
if (empty($ctx->requestPath)) { if (empty($ctx->requestPath)) {
$contentFiles = findAllContentFiles($ctx->contentDir); $contentFiles = findAllContentFiles($ctx->contentDir);
@ -121,59 +197,7 @@ switch ($parsedPath['type']) {
} }
// Build list items // Build list items
$subdirs = getSubdirectories($dir); $items = buildListItems($dir, $ctx, $metadata);
$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'] ?? ''));
}
// Prepare all variables for base template // Prepare all variables for base template
$navigation = $ctx->navigation; $navigation = $ctx->navigation;
@ -186,6 +210,19 @@ switch ($parsedPath['type']) {
$pageCssUrl = $pageCss['url'] ?? null; $pageCssUrl = $pageCss['url'] ?? null;
$pageCssHash = $pageCss['hash'] ?? 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 // Let plugins add template variables
$templateVars = Hooks::apply(Hook::TEMPLATE_VARS, [ $templateVars = Hooks::apply(Hook::TEMPLATE_VARS, [
'navigation' => $navigation, 'navigation' => $navigation,
@ -195,7 +232,8 @@ switch ($parsedPath['type']) {
'pageCssUrl' => $pageCssUrl, 'pageCssUrl' => $pageCssUrl,
'pageCssHash' => $pageCssHash, 'pageCssHash' => $pageCssHash,
'items' => $items, 'items' => $items,
'pageContent' => $pageContent 'pageContent' => $pageContent,
'feedUrl' => $feedUrl
], $ctx); ], $ctx);
extract($templateVars); extract($templateVars);