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);
+4 -1
View File
@@ -20,6 +20,7 @@ A tiny, Hugo-flavored PHP micro-framework. Routes are directories on disk, pages
- **SQLite/MySQL database, zero setup** — `Lib\Db`, a thin PDO wrapper (no ORM) supporting multiple named connections open at once — e.g. this site's own SQLite data plus a MySQL connection to a legacy database, usable in the same sidecar — each with its own plain-SQL migration convention, applied automatically on first use or via `php novaconium/bin/migrate.php`. SQLite data lives in a project-owned top-level `data/` directory, outside both `public/` and `novaconium/`. See `/admin/docs/database`.
- **Sessions with flash data** — `Lib\Session`, a thin wrapper around native PHP sessions with CodeIgniter-style flash values (set now, readable on exactly the next request) for post/redirect/GET flows without a query-string flag. Lazy-start, same mechanism `Lib\Csrf` already uses. See `/admin/docs/session`.
- **Content index: sitemap, search, tags** — `/sitemap.xml`, full-text `/search` (SQLite FTS5), and blog tag browsing all share one crawler that renders every page and harvests `keywords`/`tags`/`changefreq`/`priority` Twig blocks via Twig's own `renderBlock()` — no front-matter, no separate metadata files. Off by default (depends on SQLite); reindexes lazily on demand or via `php novaconium/bin/index-content.php`. See `/admin/docs/content-index`.
- **Blog RSS feed** — `/blog/feed`, built from the same hand-written post list `App/pages/blog/index.php` itself renders from, so it works with no database at all. `Lib\Rss` (a small RSS 2.0 envelope builder) also backs a per-tag feed, `/blog/tag/<tag>/feed`, once the content index above is enabled. Auto-discovered via a `<link rel="alternate">` on `/blog/*` pages.
- **No build step, no Composer** — clone it, point Apache (or `php -S`) at `public/`, and it runs. Twig is vendored as source; see `/admin/docs/upgrading-twig` for upgrading it.
## Getting started
@@ -80,7 +81,7 @@ php novaconium/bin/create-static-page.php blog/my-new-post
## Documentation
The full framework documentation — routing, sidecars, libraries, database, session, content index, layouts, static caching, SEO, Matomo analytics, admin authentication, draft pages, styling, project layout, and third-party notices — lives at `/admin/docs` on any running instance (so it travels with the code, no internet connection needed). Highlights:
The full framework documentation — routing, sidecars, libraries, database, session, content index, XML sitemap, RSS feeds, layouts, static caching, SEO, Matomo analytics, admin authentication, draft pages, styling, project layout, and third-party notices — lives at `/admin/docs` on any running instance (so it travels with the code, no internet connection needed). Highlights:
- [Getting started](http://127.0.0.1:8000/admin/docs/getting-started)
- [Routing](http://127.0.0.1:8000/admin/docs/routing)
@@ -89,6 +90,8 @@ The full framework documentation — routing, sidecars, libraries, database, ses
- [Database](http://127.0.0.1:8000/admin/docs/database)
- [Session](http://127.0.0.1:8000/admin/docs/session)
- [Content index](http://127.0.0.1:8000/admin/docs/content-index)
- [XML sitemap](http://127.0.0.1:8000/admin/docs/sitemap)
- [RSS feeds](http://127.0.0.1:8000/admin/docs/rss)
- [Layouts](http://127.0.0.1:8000/admin/docs/layouts)
- [Static caching](http://127.0.0.1:8000/admin/docs/caching)
- [SEO](http://127.0.0.1:8000/admin/docs/seo)
+77 -35
View File
@@ -51,18 +51,18 @@ expected vs. actual behavior. For features, include the motivating use case.>
## Backlog
Suggested build order (foundations first, since admin login builds on two
of the others):
Suggested build order (foundations first, since admin login builds on
three of the others):
1. **Blog RSS feed** — no hard dependency; a per-tag feed is now easy to
add too, since tag browsing/the content index shipped (see Done).
2. **Media/file manager** — no hard dependency; usable standalone, though
1. **Media/file manager** — no hard dependency; usable standalone, though
best gated behind admin login once that exists.
3. **Syntax highlighting on code blocks** — no hard dependency on the
2. **Syntax highlighting on code blocks** — no hard dependency on the
copy button (see Done — shipped 2026-07-14), but touches the same
`<pre><code>` markup it did.
4. **Admin login & user management** — SQLite groundwork and session
3. **Admin login & user management** — SQLite groundwork and session
handling it needed are both done; ready to build.
4. **In-house comments** — needs admin login & user management (comments
are tied to real user accounts, not anonymous); build right after it.
5. **Ecommerce functionality** — needs admin login & user management
(order/product admin, and customer accounts); SQLite groundwork and
session handling (cart) it needed are both done.
@@ -75,37 +75,12 @@ of the others):
question when this entry was originally written.
MySQL support, Session handling (with flash sessions), Draft pages
(admin-only preview), and Blog tags/categories + Internal search + XML
sitemap (shipped together as one content index — see Done) all shipped
2026-07-14.
(admin-only preview), Blog tags/categories + Internal search + XML
sitemap (shipped together as one content index — see Done), and Blog RSS
feed all shipped 2026-07-14.
See **Won't Do** below for 404 tracking, dropped in favor of Matomo.
### Blog RSS feed
- **Type:** Feature
- **Status:** Backlog
- **Priority:** Medium
- **Added:** 2026-07-12
An RSS (or Atom) feed for the blog, e.g. `App/pages/blog/feed/index.php`
returning `Response::xml(...)` — the sidecar contract already supports
this, no new mechanism needed. `App/pages/blog/index.php` still
hand-lists every post's slug/title/excerpt in a plain array (unaffected by
the content index shipped alongside Blog tags/categories/Internal
search/XML sitemap below — that index is a derived, metadata-only layer,
not a replacement for this array); the remaining gap is a published-date
field per entry so a feed can sort them. Should link from `<link
rel="alternate" type="application/rss+xml">` in the blog layout (or the
root layout) for feed auto-discovery, and probably wants its own
`App/pages/blog/_layout/` or a sidecar-only page — no `index.twig` needed
since the sidecar returns XML directly (see `/admin/docs/sidecars`'s
JSON-only example for the same pattern, just with `Response::json()`
instead of `Response::xml()`). Now that tag browsing exists
(`/admin/docs/content-index`), consider a per-tag feed too (e.g.
`App/pages/blog/tag/[tag]/feed/index.php`, querying `content_tags` the
same way `App/pages/blog/tag/[tag]/index.php` already does).
### Media/file manager
- **Type:** Feature
@@ -184,6 +159,34 @@ session handling above), and basic user management (create/disable a
user, change password). Ship this by replacing `AdminAuth::requireLogin()`
with the new mechanism, not layering on top of it.
### In-house comments
- **Type:** Feature
- **Status:** Backlog
- **Priority:** Medium
- **Depends on:** Admin login & user management, SQLite groundwork (Done)
- **Added:** 2026-07-14
A self-hosted comments library — no third-party service (Disqus,
Commento, etc.) — a `Lib\` class any sidecar can call to attach comments
to any page, not just blog posts, the same way `Lib\SpamGuard`/
`Lib\FormValidator` are reusable across any form rather than hardcoded to
the contact page. Comments tied to a real user account rather than
anonymous name/email fields, which is why this rides on Admin login &
user management rather than SQLite groundwork alone — needs that
feature's user store to exist first. Likely a `comments` table
(route/user/body/created_at/approved or similar — a migration under
`App/migrations/`, following the two-root convention documented in
`/admin/docs/database`) plus a small set of sidecar-callable methods
(list comments for a route, submit one, moderate one). Needs a decision
on moderation model (auto-approve vs. admin-approval queue, reusing the
`/admin/*` auth gate for the moderation UI) and on spam handling (reuse
`Lib\SpamGuard`'s honeypot/timing approach rather than inventing a second
mechanism, consistent with how CSRF protection already works — see
`/admin/docs/sidecars`'s "Form security" section for the existing
input-cleaning/CSRF/spam-prevention layers a comment form should compose
the same way the contact form does).
### Ecommerce functionality
- **Type:** Feature
@@ -232,6 +235,45 @@ _Nothing yet._
## Done
### Blog RSS feed
- **Type:** Feature
- **Status:** Done
- **Priority:** Medium
- **Added:** 2026-07-12
- **Shipped:** 2026-07-14
`App/pages/blog/feed/index.php`, sidecar-only, `Response::xml(...)` — as
originally scoped, no new mechanism needed. Deliberately independent of
the content index above: it reads the same hand-written `$posts` array
`App/pages/blog/index.php` itself renders from (now with a `published`
date field added per entry, illustrative — this repo's posts all arrived
in one batch import, no authentic per-post history to derive real dates
from), so it works with `content_index_enabled` left at its shipped
default of `false`. Sorted newest-first for the feed only; the array's
own order (and the `/blog` listing page) is untouched. `<link>`/`<guid>`
are site-relative paths, consistent with how `canonical`/`og:url` already
work in this framework (no site-wide base-URL config exists to build
absolute URLs from — not adding one for this alone); `<guid
isPermaLink="false">` is the spec-correct way to mark a non-absolute
identifier.
Shipped a per-tag feed too (`App/pages/blog/tag/[tag]/feed/index.php`),
gated on `content_index_enabled` the same way `blog/tag/[tag]/index.php`
is, `<pubDate>` from each page's `source_mtime` (a stand-in for a real
publish date, which the content index doesn't track). Both feeds share
`Lib\Rss::render()` (`novaconium/lib/Rss.php`, new — a generic RSS 2.0
envelope builder, framework-default since only its two call sites are
blog-specific, not the class itself) rather than duplicating the same XML
-building logic twice.
Feed auto-discovery needed a new `head_extra` block in the root layout
(`novaconium/pages/_layout/layout.twig`, empty by default, rendered right
before `</head>`) — the root layout had no open-ended "extra head
content" extension point before this; `App/pages/blog/_layout/layout.twig`
overrides it with the `<link rel="alternate">`, so it only appears on
`/blog/*` pages, not site-wide.
### Content index: keywords, tags/categories, search, XML sitemap
- **Type:** Feature
+1 -1
View File
@@ -78,7 +78,7 @@ $matomoUrl = $config['matomo_url'] !== '' ? rtrim($config['matomo_url'], '/') .
$adminAuthEnabled = $config['admin_password_hash'] !== '';
$cache = new Cache($config['cache_dir']);
$renderer = new Renderer($config['pages_dirs'], $cache, $adminAuthEnabled, $matomoUrl, $config['matomo_site_id'], $config['site_name']);
$renderer = new Renderer($config['pages_dirs'], $cache, $adminAuthEnabled, $matomoUrl, $config['matomo_site_id'], $config['site_name'], $config['content_index_enabled']);
// A route listed in draft_routes is only visible to an authenticated admin
// — anyone else gets treated exactly like a route that doesn't exist at
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace Lib;
/**
* A minimal RSS 2.0 envelope builder — plain string concatenation, no
* DOMDocument, same style as novaconium/pages/sitemap.xml/index.php.
* Generic on purpose (title/link/description/items in, XML string out) —
* it doesn't know about blog posts specifically; App/pages/blog/feed/ and
* App/pages/blog/tag/[tag]/feed/ are the two call sites that supply
* blog-shaped data to it.
*
* Links/guids are expected to be site-relative paths (e.g.
* "/blog/hello-world"), consistent with how this framework already
* handles canonical/og:url (see /admin/docs/seo) — there's no site-wide
* base-URL config to build absolute URLs from. Every <guid> is emitted
* with isPermaLink="false" for exactly this reason: it's a stable
* identifier, not a real absolute permalink.
*/
final class Rss
{
/**
* @param array<int, array{title: string, link: string, guid: string, pubDateTimestamp: int, description: string}> $items
*/
public static function render(string $channelTitle, string $channelLink, string $channelDescription, array $items): string
{
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
$xml .= '<rss version="2.0">' . "\n";
$xml .= ' <channel>' . "\n";
$xml .= ' <title>' . htmlspecialchars($channelTitle, ENT_XML1) . '</title>' . "\n";
$xml .= ' <link>' . htmlspecialchars($channelLink, ENT_XML1) . '</link>' . "\n";
$xml .= ' <description>' . htmlspecialchars($channelDescription, ENT_XML1) . '</description>' . "\n";
foreach ($items as $item) {
$xml .= ' <item>' . "\n";
$xml .= ' <title>' . htmlspecialchars($item['title'], ENT_XML1) . '</title>' . "\n";
$xml .= ' <link>' . htmlspecialchars($item['link'], ENT_XML1) . '</link>' . "\n";
$xml .= ' <guid isPermaLink="false">' . htmlspecialchars($item['guid'], ENT_XML1) . '</guid>' . "\n";
$xml .= ' <pubDate>' . date(DATE_RSS, $item['pubDateTimestamp']) . '</pubDate>' . "\n";
$xml .= ' <description>' . htmlspecialchars($item['description'], ENT_XML1) . '</description>' . "\n";
$xml .= ' </item>' . "\n";
}
$xml .= ' </channel>' . "\n";
$xml .= '</rss>' . "\n";
return $xml;
}
}
+14
View File
@@ -1,3 +1,4 @@
{% import '_layout/icons.twig' as icons %}
<!DOCTYPE html>
<html lang="en">
<head>
@@ -44,6 +45,15 @@
<link rel="stylesheet" href="/css/main.css">
{% include '_layout/matomo.twig' %}
{# Open-ended extension point for anything a subtree's own layout
needs in <head> that doesn't fit an existing named block — e.g.
App/pages/blog/_layout/layout.twig overrides this with a
<link rel="alternate" type="application/rss+xml"> for feed
auto-discovery, scoped to /blog/* only since only that layout
overrides it. Empty by default, so nothing changes for a page that
doesn't need it. #}
{% block head_extra %}{% endblock %}
</head>
<body>
<header>
@@ -54,6 +64,10 @@
</main>
<footer>
<small>&copy; {{ "now"|date("Y") }} {{ site_name }}</small>
<nav class="footer-menu">
{% if content_index_enabled %}<a class="icon-link" href="/sitemap.xml">{{ icons.sitemap() }}Sitemap</a>{% endif %}
<a class="icon-link" href="/blog/feed">{{ icons.rss() }}RSS Feed</a>
</nav>
</footer>
{% include '_layout/code-copy.twig' %}
</body>
@@ -16,6 +16,8 @@
<li><a class="icon-link" href="/admin/docs/database">{{ icons.book() }}Database</a></li>
<li><a class="icon-link" href="/admin/docs/session">{{ icons.book() }}Session</a></li>
<li><a class="icon-link" href="/admin/docs/content-index">{{ icons.search() }}Content index</a></li>
<li><a class="icon-link" href="/admin/docs/sitemap">{{ icons.sitemap() }}XML sitemap</a></li>
<li><a class="icon-link" href="/admin/docs/rss">{{ icons.rss() }}RSS feeds</a></li>
<li><a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a></li>
<li><a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}Draft pages</a></li>
<li><a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a></li>
@@ -72,12 +72,14 @@ return [
<h2>Sitemap</h2>
<p><code>/sitemap.xml</code> lists every indexed page's <code>&lt;loc&gt;</code>, <code>&lt;lastmod&gt;</code> (the page's own source file mtime), <code>&lt;changefreq&gt;</code>, and <code>&lt;priority&gt;</code>.</p>
<p>See <a href="/admin/docs/sitemap">{{ icons.sitemap() }}XML sitemap</a> for the full write-up — per-page <code>changefreq</code>/<code>priority</code>, what's included/excluded, and a known limitation around relative <code>&lt;loc&gt;</code> URLs.</p>
<h2>Blog tag browsing</h2>
<p><code>App/pages/blog/tag/[tag]/index.php</code> — project-owned, since <code>blog/</code> itself is project content — uses the <a href="/admin/docs/routing">{{ icons.link() }}<code>[param]</code></a> capture to read the tag from the URL and queries <code>content_tags</code> joined to <code>content_pages</code>. <code>App/pages/blog/index.php</code>'s own hand-written post list is untouched by any of this — it stays the source of truth for the main blog listing; <code>content_tags</code> is a derived index built from each post's own <code>tags</code> block, not a replacement for it.</p>
<p><code>App/pages/blog/tag/[tag]/feed/index.php</code> is the same query rendered as an RSS feed instead of an HTML list, one directory deeper — <code>/blog/tag/&lt;tag&gt;/feed</code>. Unlike the main blog feed, this one depends on the content index (gated the same way <code>blog/tag/[tag]/index.php</code> itself is), since tags only exist once it's enabled. See <a href="/admin/docs/rss">{{ icons.rss() }}RSS feeds</a> for the full write-up — <code>Lib\Rss</code>, the main blog feed, and how to add more feeds for other content collections.</p>
<h2>Migrations, and the two-root scan</h2>
<p>The schema (<code>content_pages</code>, <code>content_tags</code>, <code>content_search</code>, <code>content_index_meta</code>) ships as a framework migration, <code>novaconium/migrations/0001_create_content_index.sql</code> — the first framework-owned migration, and the reason <a href="/admin/docs/database">{{ icons.book() }}<code>migrations_dir</code></a> now accepts an ordered list of roots instead of a single path: the default connection's <code>migrations_dir</code> is <code>[novaconium/migrations, App/migrations]</code>, so framework migrations always apply before a project's own on the same connection.</p>
@@ -9,7 +9,7 @@
{% block docs_content %}
<h1>Getting started</h1>
<p><strong>Requirements:</strong> PHP 8.1+ and, for production, Apache with <code>mod_rewrite</code> and <code>AllowOverride All</code>.</p>
<p><strong>Requirements:</strong> PHP 8.1+ (uses <code>readonly</code> constructor-promoted properties) and, for production, Apache with <code>mod_rewrite</code> and <code>AllowOverride All</code>. The <a href="/admin/docs/database">Database</a>/<a href="/admin/docs/content-index">Content index</a> features are optional and off by default — see <a href="/admin/docs">Overview</a> for the extensions they need (<code>pdo_sqlite</code>, optionally <code>pdo_mysql</code>/FTS5) if you turn them on.</p>
<h2>Run it locally (no Apache needed)</h2>
+18 -1
View File
@@ -4,13 +4,28 @@
{% block title %}Docs{% endblock %}
{% block description %}Framework documentation: routing, sidecars, forms, libraries, database, session, content index, admin authentication, draft pages, layouts, caching, styling.{% endblock %}
{% block description %}Framework documentation: routing, sidecars, forms, libraries, database, session, content index, XML sitemap, RSS feeds, admin authentication, draft pages, layouts, caching, styling.{% endblock %}
{% block robots %}noindex, nofollow{% endblock %}
{% block docs_content %}
<h1 class="icon-heading">{{ icons.book() }}Project documentation</h1>
<p>Framework docs, rendered as plain Twig pages — no internet connection needed.</p>
<h2>Requirements</h2>
<p><strong>Minimum:</strong> PHP 8.1+ (uses <code>readonly</code> constructor-promoted properties) and, for production, Apache with <code>mod_rewrite</code> and <code>AllowOverride All</code> — see <a href="/admin/docs/getting-started">Getting started</a>.</p>
<p><strong>Optional, for the database/content-index features</strong> (<a href="/admin/docs/database">Database</a>, <a href="/admin/docs/content-index">Content index</a> — both off by default):</p>
<ul>
<li>The <code>pdo_sqlite</code> extension — bundled with PHP, just needs to be enabled, no separate install. Required for <code>Lib\Db</code>'s default (SQLite) connection.</li>
<li><code>pdo_mysql</code> — only if using a MySQL <code>db_connections</code> entry alongside or instead of SQLite.</li>
<li>SQLite's FTS5 extension — bundled with <code>pdo_sqlite</code> on virtually every modern PHP build. Only needed for <code>/search</code>, i.e. only relevant if <code>content_index_enabled</code> is turned on.</li>
</ul>
<h2>Documentation</h2>
<ul>
<li><a class="icon-link" href="/admin/docs/getting-started">{{ icons.book() }}Getting started</a> — requirements, running locally, deploying on Apache.</li>
<li><a class="icon-link" href="/admin/docs/routing">{{ icons.link() }}Routing</a> — how a URL maps to a directory under <code>App/pages/</code>.</li>
@@ -21,6 +36,8 @@
<li><a class="icon-link" href="/admin/docs/database">{{ icons.book() }}Database</a> — <code>Lib\Db</code>, a thin PDO wrapper (SQLite and MySQL) with named, simultaneous connections and a plain-SQL migration convention.</li>
<li><a class="icon-link" href="/admin/docs/session">{{ icons.book() }}Session</a> — <code>Lib\Session</code>, a thin wrapper around native PHP sessions, with CodeIgniter-style flash data.</li>
<li><a class="icon-link" href="/admin/docs/content-index">{{ icons.search() }}Content index</a> — the shared crawler behind <code>/sitemap.xml</code>, <code>/search</code>, and blog tag browsing. Off by default.</li>
<li><a class="icon-link" href="/admin/docs/sitemap">{{ icons.sitemap() }}XML sitemap</a> — per-page <code>changefreq</code>/<code>priority</code>, what's included, and submitting it to search engines.</li>
<li><a class="icon-link" href="/admin/docs/rss">{{ icons.rss() }}RSS feeds</a> — <code>Lib\Rss</code>, building one feed or several for any content collection.</li>
<li><a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a> — gate <code>/admin/*</code> behind HTTP Basic Auth, reusable for any future admin page.</li>
<li><a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}Draft pages</a> — let an admin preview a page before the public can see it, reusing the same auth check.</li>
<li><a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a> — pages and layouts are overridable, just like <code>Lib\</code>.</li>
+104
View File
@@ -0,0 +1,104 @@
{% extends 'admin/docs/_layout/layout.twig' %}
{% import '_layout/icons.twig' as icons %}
{% block title %}RSS feeds{% endblock %}
{% block description %}Lib\Rss — building one feed, or several, for any content collection.{% endblock %}
{% block robots %}noindex, nofollow{% endblock %}
{% block docs_content %}
<h1 class="icon-heading">{{ icons.rss() }}RSS feeds</h1>
<p><code>Lib\Rss</code> (<code>novaconium/lib/Rss.php</code>) is a small, generic RSS 2.0 envelope builder — plain string concatenation, no <code>DOMDocument</code>, the same style as <code>/sitemap.xml</code> (see <a href="/admin/docs/content-index">{{ icons.search() }}Content index</a>). It doesn't know anything about blog posts, or content in general — it just turns a channel title/link/description plus a list of items into an XML string. Every feed on this site, and any feed a project adds, is a plain sidecar-only page (like <code>sitemap.xml</code>/<code>search</code>) that gathers its own items from wherever they live and hands them to it.</p>
<h2><code>Lib\Rss::render()</code></h2>
<pre><code>use Lib\Rss;
$xml = Rss::render(
'My Site Blog', // channel title
'/blog', // channel link
'Posts from My Site.', // channel description
[
[
'title' =&gt; 'Post title',
'link' =&gt; '/blog/post-slug',
'guid' =&gt; '/blog/post-slug',
'pubDateTimestamp' =&gt; strtotime('2026-07-11'),
'description' =&gt; 'A short excerpt or summary.',
],
// ...one array per item
]
);
return Response::xml($xml);</code></pre>
<p><code>link</code>/<code>guid</code> are expected to be site-relative paths (e.g. <code>/blog/post-slug</code>), consistent with how this framework already handles <code>canonical</code>/<code>og:url</code> (see <a href="/admin/docs/seo">{{ icons.book() }}SEO</a>) — there's no site-wide base-URL config to build absolute URLs from. Every <code>&lt;guid&gt;</code> is emitted with <code>isPermaLink="false"</code> for exactly this reason: it's a stable identifier, not a real absolute permalink.</p>
<h2>The two feeds shipped with this site</h2>
<ul>
<li><code>/blog/feed</code> (<code>App/pages/blog/feed/index.php</code>) — the main blog feed. Reads the same hand-written <code>$posts</code> array <code>App/pages/blog/index.php</code> itself renders from, so it has <strong>zero dependency on the content index</strong> — it works even with <code>content_index_enabled</code> left at its default <code>false</code>.</li>
<li><code>/blog/tag/&lt;tag&gt;/feed</code> (<code>App/pages/blog/tag/[tag]/feed/index.php</code>) — one feed per tag, generated dynamically via the <a href="/admin/docs/routing">{{ icons.link() }}<code>[param]</code></a> capture rather than a static file per tag. This one <em>does</em> need the content index (same gate as <code>blog/tag/[tag]/index.php</code>), since tags only exist there — see <a href="/admin/docs/content-index">{{ icons.search() }}Content index</a>. Its <code>&lt;pubDate&gt;</code> is each page's <code>source_mtime</code>, a stand-in for a real publish date the content index doesn't separately track.</li>
</ul>
<h2>Adding another feed</h2>
<p>Nothing is registered or limited to "one feed" — any sidecar-only page that builds an item list and calls <code>Rss::render()</code> is a feed. For a second content collection (say, <code>App/pages/products/</code>, with its own hand-written array like <code>App/pages/blog/index.php</code>'s), a new <code>App/pages/products/feed/index.php</code> looks almost identical to <code>blog/feed</code>:</p>
<pre><code>&lt;?php
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);
}
$products = (require __DIR__ . '/../index.php')['products'];
$items = array_map(
fn (array $p) =&gt; [
'title' =&gt; $p['title'],
'link' =&gt; '/products/' . $p['slug'],
'guid' =&gt; '/products/' . $p['slug'],
'pubDateTimestamp' =&gt; strtotime($p['published']),
'description' =&gt; $p['excerpt'],
],
$products
);
return Response::xml(Rss::render(
$config['site_name'] . ' Products',
'/products',
'New products from ' . $config['site_name'] . '.',
$items
));</code></pre>
<p>Two independent feeds now exist side by side — <code>/blog/feed</code> and <code>/products/feed</code> — with no shared state, registry, or configuration between them. A project can have as many as it has content collections.</p>
<h2>Auto-discovery for more than one feed</h2>
<p><a href="/admin/docs/seo">{{ icons.book() }}SEO</a>'s <code>head_extra</code> block is how a feed gets a <code>&lt;link rel="alternate" type="application/rss+xml"&gt;</code> tag in <code>&lt;head&gt;</code> so browsers/feed readers can discover it — <code>App/pages/blog/_layout/layout.twig</code> overrides it with exactly one such tag, scoped to <code>/blog/*</code> pages only since only that layout overrides the block. A layout isn't limited to one <code>&lt;link&gt;</code> in that override — list several, each with its own <code>title</code> attribute so a feed reader can tell them apart:</p>
<pre><code>{% verbatim %}{% block head_extra %}
&lt;link rel="alternate" type="application/rss+xml" title="{{ site_name }} Blog" href="/blog/feed"&gt;
&lt;link rel="alternate" type="application/rss+xml" title="{{ site_name }} Products" href="/products/feed"&gt;
{% endblock %}{% endverbatim %}</code></pre>
<p>Where that override lives determines which pages advertise which feeds — put it in the root layout (<code>novaconium/pages/_layout/layout.twig</code>) for a feed that should be discoverable site-wide, or in a subtree's own <code>_layout/layout.twig</code> (like <code>App/pages/blog/_layout/layout.twig</code> does today) to scope it to just that subtree. A page can also declare a feed link that isn't a listing of that page's own subtree at all — there's no rule tying a <code>head_extra</code> override to the feed(s) "belonging" to that directory, it's just the natural place to put it for the common case.</p>
<h2>Verifying a feed</h2>
<p>No test suite — check a feed is well-formed XML with matching item counts before trusting it, the same way this site's own feeds were verified while being built:</p>
<pre><code>curl -s http://127.0.0.1:8000/blog/feed | php -r '
$xml = stream_get_contents(STDIN);
$parsed = simplexml_load_string($xml);
echo $parsed === false ? "INVALID XML\n" : "valid, " . count($parsed-&gt;channel-&gt;item) . " items\n";
'</code></pre>
{% endblock %}
@@ -33,6 +33,7 @@
<tr><td><code>twitter_card</code></td><td><code>summary</code></td><td><code>&lt;meta name="twitter:card"&gt;</code></td></tr>
<tr><td><code>twitter_title</code></td><td><code>{{ '{{ block(\'title\') }}' }}</code></td><td><code>&lt;meta name="twitter:title"&gt;</code></td></tr>
<tr><td><code>twitter_description</code></td><td><code>{{ '{{ block(\'description\') }}' }}</code></td><td><code>&lt;meta name="twitter:description"&gt;</code></td></tr>
<tr><td><code>head_extra</code></td><td>empty</td><td>open-ended — anything a subtree's own layout needs in <code>&lt;head&gt;</code> that doesn't fit an existing block. <code>App/pages/blog/_layout/layout.twig</code> overrides it with the blog's RSS <code>&lt;link rel="alternate"&gt;</code>, scoped to <code>/blog/*</code> only since only that layout overrides it.</td></tr>
</tbody>
</table>
@@ -0,0 +1,56 @@
{% extends 'admin/docs/_layout/layout.twig' %}
{% import '_layout/icons.twig' as icons %}
{% block title %}XML sitemap{% endblock %}
{% block description %}/sitemap.xml — generated from the content index, with per-page changefreq/priority.{% endblock %}
{% block robots %}noindex, nofollow{% endblock %}
{% block docs_content %}
<h1 class="icon-heading">{{ icons.sitemap() }}XML sitemap</h1>
<p><code>/sitemap.xml</code> (<code>novaconium/pages/sitemap.xml/index.php</code>) lists every indexed page for search engine discovery, following the <a href="https://www.sitemaps.org/protocol.html">sitemaps.org protocol</a>: one <code>&lt;url&gt;</code> entry per page with <code>&lt;loc&gt;</code>, <code>&lt;lastmod&gt;</code>, <code>&lt;changefreq&gt;</code>, and <code>&lt;priority&gt;</code>. It's a framework default — a directory literally named <code>sitemap.xml</code> under <code>novaconium/pages/</code>; <code>Router</code> only ever splits the request path on <code>/</code>, so that resolves the literal <code>/sitemap.xml</code> URL correctly, no special extension-routing involved.</p>
<p>Sidecar-only — no <code>index.twig</code>, since <code>Response::xml(...)</code> bypasses Twig entirely (see <a href="/admin/docs/sidecars">{{ icons.book() }}Sidecars</a>' JSON-only example for the same pattern). Built entirely from the <a href="/admin/docs/content-index">{{ icons.search() }}content index</a> — it's one of the three routes gated by <code>content_index_enabled</code> (default <code>false</code>), so it 404s exactly like a route that doesn't exist until that's turned on.</p>
<h2>Per-page <code>changefreq</code>/<code>priority</code></h2>
<p>Two Twig blocks, declared in <code>novaconium/pages/_layout/layout.twig</code> alongside the rest of the <a href="/admin/docs/seo">{{ icons.book() }}SEO</a> blocks — same override mechanism, just harvested by the content index rather than rendered into the page:</p>
<table>
<thead>
<tr><th>Block</th><th>Default</th><th>Sitemap element</th></tr>
</thead>
<tbody>
<tr><td><code>changefreq</code></td><td><code>monthly</code></td><td><code>&lt;changefreq&gt;</code> — how often the page is expected to change (<code>always</code>/<code>hourly</code>/<code>daily</code>/<code>weekly</code>/<code>monthly</code>/<code>yearly</code>/<code>never</code>, per the protocol)</td></tr>
<tr><td><code>priority</code></td><td><code>0.5</code></td><td><code>&lt;priority&gt;</code> — relative priority against this site's own other pages, <code>0.0</code><code>1.0</code></td></tr>
</tbody>
</table>
<pre><code>{% verbatim %}{% block changefreq %}weekly{% endblock %}
{% block priority %}1.0{% endblock %}{% endverbatim %}</code></pre>
<p>A page that doesn't override either just gets the layout's defaults — nothing to set for most pages. Bump <code>priority</code> on a handful of pages that matter most (the homepage, key landing pages) rather than trying to rank every page precisely; search engines treat this as a hint, not a strict ordering.</p>
<h2>What's included, what isn't</h2>
<ul>
<li>Only pages the content index actually indexes — see <a href="/admin/docs/content-index">{{ icons.search() }}Content index</a> for the full crawl rules. In short: any page whose resolved <code>robots</code> block contains <code>noindex</code> (every <code>/admin/*</code> page already does) or that's listed in <a href="/admin/docs/drafts">{{ icons.lock() }}draft_routes</a> is skipped.</li>
<li><code>[param]</code>-wildcard routes (e.g. <code>blog/tag/[tag]</code>) aren't crawled at all — a wildcard's concrete values aren't knowable without a data source, so dynamic routes like individual tag pages don't get their own sitemap entries. This is a known V1 limitation, not an oversight.</li>
<li><code>&lt;lastmod&gt;</code> is the page's own source file's mtime (<code>content_pages.source_mtime</code>), not when the sitemap itself was last regenerated — it reflects when the content actually last changed.</li>
</ul>
<h2>A known limitation: relative <code>&lt;loc&gt;</code> URLs</h2>
<p>The sitemaps.org protocol calls for <code>&lt;loc&gt;</code> to be a fully-qualified absolute URL. This framework's <code>&lt;loc&gt;</code> values are site-relative paths instead (e.g. <code>/about</code>, not <code>https://example.com/about</code>) — consistent with how <code>canonical</code>/<code>og:url</code> already work (see <a href="/admin/docs/seo">{{ icons.book() }}SEO</a>), since there's no site-wide base-URL config to build absolute URLs from. Most tooling tolerates this, but it isn't strictly spec-compliant, and a search engine or validator that enforces the letter of the protocol may reject entries. If that matters for a given deployment, override <code>novaconium/pages/sitemap.xml/index.php</code> with your own <code>App/pages/sitemap.xml/index.php</code> (same override-by-path mechanism as any other framework default) and prefix each <code>&lt;loc&gt;</code> with the site's real domain.</p>
<h2>How it's built</h2>
<p>Calls <code>ContentIndexer::ensureFresh()</code> (the lazy reindex-if-stale check — see <a href="/admin/docs/content-index">{{ icons.search() }}Content index</a>), queries <code>content_pages</code>, and builds the XML with plain string concatenation — no <code>DOMDocument</code>, this site's scale doesn't need one. Every value is still <code>htmlspecialchars(..., ENT_XML1)</code>-escaped, since <code>route</code>/<code>changefreq</code>/<code>priority</code> all ultimately come from page-author-controlled Twig blocks, not hardcoded constants.</p>
<h2>Submitting it to search engines</h2>
<p>This framework doesn't ship a <code>public/robots.txt</code> — add one yourself with a <code>Sitemap:</code> line pointing at wherever <code>/sitemap.xml</code> ends up being served, and/or submit the URL directly through each search engine's own webmaster tools (e.g. Google Search Console). Re-submission isn't needed on every change — crawlers revisit periodically on their own, and <code>&lt;lastmod&gt;</code> is the signal that tells them what's actually changed since their last visit.</p>
{% endblock %}
+11
View File
@@ -186,6 +186,17 @@ label
small
color: var(--muted-color)
footer
display: flex
align-items: center
justify-content: space-between
gap: 1rem
flex-wrap: wrap
.footer-menu
display: flex
gap: 1rem
// Honeypot field for spam prevention (see App/pages/contact/index.php).
// Off-screen positioning rather than display:none/visibility:hidden,
// since some spam bots specifically skip fields hidden that way.
+7
View File
@@ -30,6 +30,7 @@ final class Renderer
string $matomoUrl = '',
string $matomoSiteId = '',
string $siteName = 'My Site',
bool $contentIndexEnabled = false,
) {
$loader = new FilesystemLoader($this->pagesDirs);
$this->twig = new Environment($loader, [
@@ -40,6 +41,12 @@ final class Renderer
$this->twig->addGlobal('matomo_site_id', $matomoSiteId);
$this->twig->addGlobal('site_name', $siteName);
$this->twig->addGlobal('is_404', false);
// Lets the footer conditionally link to /sitemap.xml only when
// that route actually exists (content_index_enabled — see
// /admin/docs/content-index) rather than linking to a 404. The
// blog RSS feed doesn't need an equivalent flag: /blog/feed has no
// content-index dependency, it's always routable.
$this->twig->addGlobal('content_index_enabled', $contentIndexEnabled);
}
/**
+13
View File
@@ -194,6 +194,19 @@ small {
color: var(--muted-color);
}
footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.footer-menu {
display: flex;
gap: 1rem;
}
.hp-field {
position: absolute;
left: -9999px;