Add Blog RSS feed, footer feed/sitemap links, and expanded docs

Blog RSS feed (novaconium/ISSUES.md):
- App/pages/blog/feed/index.php - main feed, built from the same
  hand-written $posts array App/pages/blog/index.php renders from, so it
  works with content_index_enabled left at its default false. Added a
  published date field per post entry.
- App/pages/blog/tag/[tag]/feed/index.php - per-tag feed, gated on
  content_index_enabled the same way blog/tag/[tag]/index.php is.
- novaconium/lib/Rss.php - shared RSS 2.0 envelope builder both feeds
  use, generic (title/link/description/items in, XML string out).
- New head_extra block in the root layout (empty by default) so
  App/pages/blog/_layout/layout.twig can add feed auto-discovery scoped
  to /blog/* only.

Footer:
- New content_index_enabled Twig global (Renderer/bootstrap.php), same
  pattern as admin_auth_enabled.
- Right-aligned footer menu linking to /sitemap.xml (only shown when
  content_index_enabled, so it never links to a 404) and /blog/feed
  (always shown, no content-index dependency).

Docs:
- New /admin/docs/rss page: Lib\Rss API, the two shipped feeds, a worked
  example of adding a feed for another content collection, and how to
  advertise multiple feeds via head_extra.
- New /admin/docs/sitemap page: changefreq/priority blocks, what's
  included/excluded, an honest callout on the relative-<loc>-URL spec
  deviation (consistent with how canonical/og:url already work) with an
  override path for strict compliance, and submitting it to search
  engines.
- /admin/docs (Overview) gains a Requirements section: minimum
  requirements, then optional requirements for the database/content-index
  features (pdo_sqlite, optional pdo_mysql, FTS5) - and a Documentation
  heading separating it from the doc links list.
- /admin/docs/getting-started's Requirements line was stale (missing
  pdo_sqlite entirely, unlike README) - fixed and cross-linked to the
  Overview page's fuller list.
- /admin/docs/content-index trimmed to point at the new RSS/sitemap pages
  instead of duplicating their detail.

Also adds an "In-house comments" entry to novaconium/ISSUES.md's Backlog
- a Lib\ class for sidecar-attached comments on any page, depending on
  the not-yet-built Admin login & user management for real user accounts.

Closes the "Blog RSS feed" backlog item.
This commit is contained in:
code
2026-07-14 18:28:49 +00:00
parent a51faa7208
commit 74c0d59019
19 changed files with 506 additions and 52 deletions
+7
View File
@@ -2,6 +2,13 @@
{% import '_layout/icons.twig' as icons %}
{# Feed auto-discovery — only shows up on /blog/* pages, since only this
layout overrides the root layout's empty head_extra block. See
App/pages/blog/feed/index.php. #}
{% block head_extra %}
<link rel="alternate" type="application/rss+xml" title="{{ site_name }} Blog" href="/blog/feed">
{% endblock %}
{% block content %}
<div class="blog-layout">
<aside>
+48
View File
@@ -0,0 +1,48 @@
<?php
// /blog/feed — sidecar-only (no index.twig), like sitemap.xml/search: no
// point rendering Twig just to return XML. Project-owned (App/pages/),
// since this is blog content specifically, not generic framework
// machinery like sitemap.xml/search are. Deliberately has zero dependency
// on the (off-by-default) content index — it reads the exact same
// hand-written $posts array App/pages/blog/index.php itself renders from,
// so this feed works on a bare install with content_index_enabled left at
// its shipped default of false. Only the per-tag variant
// (App/pages/blog/tag/[tag]/feed/index.php) needs the content index, since
// tags only exist there.
use App\Response;
use Lib\Rss;
$config = require __DIR__ . '/../../../../novaconium/config.php';
$appConfigFile = __DIR__ . '/../../../../App/config.php';
if (is_file($appConfigFile)) {
$config = array_merge($config, require $appConfigFile);
}
$posts = (require __DIR__ . '/../index.php')['posts'];
// Newest first, the RSS convention — the array itself (and therefore the
// /blog listing page, which isn't touched here) keeps its own order;
// sorting only affects this feed's output.
usort($posts, fn (array $a, array $b) => strcmp($b['published'], $a['published']));
$items = array_map(
fn (array $post) => [
'title' => $post['title'],
'link' => '/blog/' . $post['slug'],
'guid' => '/blog/' . $post['slug'],
'pubDateTimestamp' => strtotime($post['published']),
'description' => $post['excerpt'],
],
$posts
);
$xml = Rss::render(
$config['site_name'] . ' Blog',
'/blog',
'Posts from ' . $config['site_name'] . '.',
$items
);
return Response::xml($xml);
+20 -12
View File
@@ -4,27 +4,35 @@
// with its own index.twig — none of them are driven by a repository or
// database, so this listing is just a hand-maintained array pointing at
// each one. Add a new entry here whenever a new post directory is added.
// 'published' (YYYY-MM-DD) is used by App/pages/blog/feed/index.php to
// order and date entries in the RSS feed — illustrative dates here, not
// derived from real history (this repo's posts all arrived in one batch
// import, so there's no authentic per-post date to pull from).
return [
'posts' => [
[
'slug' => 'hello-world',
'title' => 'Hello, World!',
'excerpt' => 'The first post on this blog — a plain sidecar-less page, like every other post here now.',
'slug' => 'hello-world',
'title' => 'Hello, World!',
'excerpt' => 'The first post on this blog — a plain sidecar-less page, like every other post here now.',
'published' => '2026-07-11',
],
[
'slug' => 'second-post',
'title' => 'A Second Post',
'excerpt' => 'A second post at its own URL, showing that adding a new page under App/pages/blog/ needs nothing but a new directory.',
'slug' => 'second-post',
'title' => 'A Second Post',
'excerpt' => 'A second post at its own URL, showing that adding a new page under App/pages/blog/ needs nothing but a new directory.',
'published' => '2026-07-11',
],
[
'slug' => 'twig-syntax-guide',
'title' => 'Twig Syntax Guide',
'excerpt' => 'A tour of the Twig syntax used throughout this site — output, filters, control structures, template inheritance, and a few gotchas worth knowing.',
'slug' => 'twig-syntax-guide',
'title' => 'Twig Syntax Guide',
'excerpt' => 'A tour of the Twig syntax used throughout this site — output, filters, control structures, template inheritance, and a few gotchas worth knowing.',
'published' => '2026-07-13',
],
[
'slug' => 'style-guide',
'title' => 'Style Guide',
'excerpt' => "A showcase of this theme's default styling for headings, lists, tables, code, and other common HTML elements.",
'slug' => 'style-guide',
'title' => 'Style Guide',
'excerpt' => "A showcase of this theme's default styling for headings, lists, tables, code, and other common HTML elements.",
'published' => '2026-07-13',
],
],
];
+70
View File
@@ -0,0 +1,70 @@
<?php
// blog/tag/[tag]/feed/ — same [param] capture as blog/tag/[tag]/index.php
// one level up ($params['tag'] is already populated by the time Router
// resolves this deeper path — see that file's comments for how the
// capture works). Project-owned, mirrors blog/tag/[tag]/index.php's
// query almost exactly, just rendered as RSS instead of an HTML list.
use App\ContentIndexer;
use App\Response;
use Lib\Db;
use Lib\Rss;
// Same two-step config load bootstrap.php/bin scripts use — this sidecar
// isn't handed $config, so it loads its own copy to read
// content_index_enabled before touching Lib\Db at all.
$config = require __DIR__ . '/../../../../../../novaconium/config.php';
$appConfigFile = __DIR__ . '/../../../../../../App/config.php';
if (is_file($appConfigFile)) {
$config = array_merge($config, require $appConfigFile);
}
// Content index is off by default (depends on SQLite) — see
// /admin/docs/content-index. Unlike App/pages/blog/feed/ (the main feed,
// which has zero content-index dependency), this per-tag feed reads
// content_tags/content_pages directly, so it 404s the same way
// blog/tag/[tag]/index.php does when the index is off, and never
// constructs a Lib\Db connection in that case.
if (!$config['content_index_enabled']) {
return Response::html('404 Not Found', 404);
}
ContentIndexer::ensureFresh();
$tag = $params['tag'];
// Same query as blog/tag/[tag]/index.php, plus source_mtime — used below
// as pubDate. This is the page's own source-file mtime, not a true
// "published" date (the content index has no separate published concept
// the way the hand-written main feed's $posts array does) — an honest
// stand-in, not presented as more precise than it is.
$posts = Db::query(
'SELECT content_pages.route, content_pages.title, content_pages.description, content_pages.source_mtime ' .
'FROM content_tags ' .
'JOIN content_pages ON content_pages.route = content_tags.route ' .
'WHERE content_tags.tag = ? ' .
"AND content_pages.route LIKE '/blog/%' " .
'ORDER BY content_pages.source_mtime DESC',
[$tag]
)->fetchAll(PDO::FETCH_ASSOC);
$items = array_map(
fn (array $post) => [
'title' => $post['title'],
'link' => $post['route'],
'guid' => $post['route'],
'pubDateTimestamp' => (int) $post['source_mtime'],
'description' => $post['description'],
],
$posts
);
$xml = Rss::render(
$config['site_name'] . ' Blog — tagged "' . $tag . '"',
'/blog/tag/' . $tag,
'Posts tagged "' . $tag . '" from ' . $config['site_name'] . '.',
$items
);
return Response::xml($xml);