/feed`, once the content index above is enabled. Auto-discovered via a `` 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)
diff --git a/novaconium/ISSUES.md b/novaconium/ISSUES.md
index 481c1d6..313a878 100644
--- a/novaconium/ISSUES.md
+++ b/novaconium/ISSUES.md
@@ -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
`` 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 `` 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. ``/``
+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); `` 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, `` 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 ``) — the root layout had no open-ended "extra head
+content" extension point before this; `App/pages/blog/_layout/layout.twig`
+overrides it with the ``, so it only appears on
+`/blog/*` pages, not site-wide.
+
### Content index: keywords, tags/categories, search, XML sitemap
- **Type:** Feature
diff --git a/novaconium/bootstrap.php b/novaconium/bootstrap.php
index f523edd..97b0db4 100644
--- a/novaconium/bootstrap.php
+++ b/novaconium/bootstrap.php
@@ -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
diff --git a/novaconium/lib/Rss.php b/novaconium/lib/Rss.php
new file mode 100644
index 0000000..4ce9724
--- /dev/null
+++ b/novaconium/lib/Rss.php
@@ -0,0 +1,49 @@
+ is emitted
+ * with isPermaLink="false" for exactly this reason: it's a stable
+ * identifier, not a real absolute permalink.
+ */
+final class Rss
+{
+ /**
+ * @param array $items
+ */
+ public static function render(string $channelTitle, string $channelLink, string $channelDescription, array $items): string
+ {
+ $xml = '' . "\n";
+ $xml .= '' . "\n";
+ $xml .= ' ' . "\n";
+ $xml .= ' ' . htmlspecialchars($channelTitle, ENT_XML1) . '' . "\n";
+ $xml .= ' ' . htmlspecialchars($channelLink, ENT_XML1) . '' . "\n";
+ $xml .= ' ' . htmlspecialchars($channelDescription, ENT_XML1) . '' . "\n";
+
+ foreach ($items as $item) {
+ $xml .= ' - ' . "\n";
+ $xml .= ' ' . htmlspecialchars($item['title'], ENT_XML1) . '' . "\n";
+ $xml .= ' ' . htmlspecialchars($item['link'], ENT_XML1) . '' . "\n";
+ $xml .= ' ' . htmlspecialchars($item['guid'], ENT_XML1) . '' . "\n";
+ $xml .= ' ' . date(DATE_RSS, $item['pubDateTimestamp']) . '' . "\n";
+ $xml .= ' ' . htmlspecialchars($item['description'], ENT_XML1) . '' . "\n";
+ $xml .= '
' . "\n";
+ }
+
+ $xml .= ' ' . "\n";
+ $xml .= '' . "\n";
+
+ return $xml;
+ }
+}
diff --git a/novaconium/pages/_layout/layout.twig b/novaconium/pages/_layout/layout.twig
index 47a85d4..60ab4d7 100644
--- a/novaconium/pages/_layout/layout.twig
+++ b/novaconium/pages/_layout/layout.twig
@@ -1,3 +1,4 @@
+{% import '_layout/icons.twig' as icons %}
@@ -44,6 +45,15 @@
{% include '_layout/matomo.twig' %}
+
+ {# Open-ended extension point for anything a subtree's own layout
+ needs in that doesn't fit an existing named block — e.g.
+ App/pages/blog/_layout/layout.twig overrides this with a
+ 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 %}
@@ -54,6 +64,10 @@
{% include '_layout/code-copy.twig' %}
diff --git a/novaconium/pages/admin/docs/_layout/layout.twig b/novaconium/pages/admin/docs/_layout/layout.twig
index 7ead60b..1148008 100644
--- a/novaconium/pages/admin/docs/_layout/layout.twig
+++ b/novaconium/pages/admin/docs/_layout/layout.twig
@@ -16,6 +16,8 @@
{{ icons.book() }}Database
{{ icons.book() }}Session
{{ icons.search() }}Content index
+ {{ icons.sitemap() }}XML sitemap
+ {{ icons.rss() }}RSS feeds
{{ icons.lock() }}Admin authentication
{{ icons.lock() }}Draft pages
{{ icons.book() }}Layouts
diff --git a/novaconium/pages/admin/docs/content-index/index.twig b/novaconium/pages/admin/docs/content-index/index.twig
index edfbf06..7f8c484 100644
--- a/novaconium/pages/admin/docs/content-index/index.twig
+++ b/novaconium/pages/admin/docs/content-index/index.twig
@@ -72,12 +72,14 @@ return [
Sitemap
- /sitemap.xml lists every indexed page's <loc>, <lastmod> (the page's own source file mtime), <changefreq>, and <priority>.
+ See {{ icons.sitemap() }}XML sitemap for the full write-up — per-page changefreq/priority, what's included/excluded, and a known limitation around relative <loc> URLs.
Blog tag browsing
App/pages/blog/tag/[tag]/index.php — project-owned, since blog/ itself is project content — uses the {{ icons.link() }}[param] capture to read the tag from the URL and queries content_tags joined to content_pages. App/pages/blog/index.php's own hand-written post list is untouched by any of this — it stays the source of truth for the main blog listing; content_tags is a derived index built from each post's own tags block, not a replacement for it.
+ App/pages/blog/tag/[tag]/feed/index.php is the same query rendered as an RSS feed instead of an HTML list, one directory deeper — /blog/tag/<tag>/feed. Unlike the main blog feed, this one depends on the content index (gated the same way blog/tag/[tag]/index.php itself is), since tags only exist once it's enabled. See {{ icons.rss() }}RSS feeds for the full write-up — Lib\Rss, the main blog feed, and how to add more feeds for other content collections.
+
Migrations, and the two-root scan
The schema (content_pages, content_tags, content_search, content_index_meta) ships as a framework migration, novaconium/migrations/0001_create_content_index.sql — the first framework-owned migration, and the reason {{ icons.book() }}migrations_dir now accepts an ordered list of roots instead of a single path: the default connection's migrations_dir is [novaconium/migrations, App/migrations], so framework migrations always apply before a project's own on the same connection.
diff --git a/novaconium/pages/admin/docs/getting-started/index.twig b/novaconium/pages/admin/docs/getting-started/index.twig
index 366321d..0fe505c 100644
--- a/novaconium/pages/admin/docs/getting-started/index.twig
+++ b/novaconium/pages/admin/docs/getting-started/index.twig
@@ -9,7 +9,7 @@
{% block docs_content %}
Getting started
- Requirements: PHP 8.1+ and, for production, Apache with mod_rewrite and AllowOverride All.
+ Requirements: PHP 8.1+ (uses readonly constructor-promoted properties) and, for production, Apache with mod_rewrite and AllowOverride All. The Database/Content index features are optional and off by default — see Overview for the extensions they need (pdo_sqlite, optionally pdo_mysql/FTS5) if you turn them on.
Run it locally (no Apache needed)
diff --git a/novaconium/pages/admin/docs/index.twig b/novaconium/pages/admin/docs/index.twig
index c097ff7..ce2def6 100644
--- a/novaconium/pages/admin/docs/index.twig
+++ b/novaconium/pages/admin/docs/index.twig
@@ -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 %}
{{ icons.book() }}Project documentation
Framework docs, rendered as plain Twig pages — no internet connection needed.
+
+ Requirements
+
+ Minimum: PHP 8.1+ (uses readonly constructor-promoted properties) and, for production, Apache with mod_rewrite and AllowOverride All — see Getting started.
+
+ Optional, for the database/content-index features (Database, Content index — both off by default):
+
+
+ - The
pdo_sqlite extension — bundled with PHP, just needs to be enabled, no separate install. Required for Lib\Db's default (SQLite) connection.
+ pdo_mysql — only if using a MySQL db_connections entry alongside or instead of SQLite.
+ - SQLite's FTS5 extension — bundled with
pdo_sqlite on virtually every modern PHP build. Only needed for /search, i.e. only relevant if content_index_enabled is turned on.
+
+
+ Documentation
+
- {{ icons.book() }}Getting started — requirements, running locally, deploying on Apache.
- {{ icons.link() }}Routing — how a URL maps to a directory under
App/pages/.
@@ -21,6 +36,8 @@
- {{ icons.book() }}Database —
Lib\Db, a thin PDO wrapper (SQLite and MySQL) with named, simultaneous connections and a plain-SQL migration convention.
- {{ icons.book() }}Session —
Lib\Session, a thin wrapper around native PHP sessions, with CodeIgniter-style flash data.
- {{ icons.search() }}Content index — the shared crawler behind
/sitemap.xml, /search, and blog tag browsing. Off by default.
+ - {{ icons.sitemap() }}XML sitemap — per-page
changefreq/priority, what's included, and submitting it to search engines.
+ - {{ icons.rss() }}RSS feeds —
Lib\Rss, building one feed or several for any content collection.
- {{ icons.lock() }}Admin authentication — gate
/admin/* behind HTTP Basic Auth, reusable for any future admin page.
- {{ icons.lock() }}Draft pages — let an admin preview a page before the public can see it, reusing the same auth check.
- {{ icons.book() }}Layouts — pages and layouts are overridable, just like
Lib\.
diff --git a/novaconium/pages/admin/docs/rss/index.twig b/novaconium/pages/admin/docs/rss/index.twig
new file mode 100644
index 0000000..b1e615f
--- /dev/null
+++ b/novaconium/pages/admin/docs/rss/index.twig
@@ -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 %}
+ {{ icons.rss() }}RSS feeds
+
+ Lib\Rss (novaconium/lib/Rss.php) is a small, generic RSS 2.0 envelope builder — plain string concatenation, no DOMDocument, the same style as /sitemap.xml (see {{ icons.search() }}Content index). 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 sitemap.xml/search) that gathers its own items from wherever they live and hands them to it.
+
+ Lib\Rss::render()
+
+
use Lib\Rss;
+
+$xml = Rss::render(
+ 'My Site Blog', // channel title
+ '/blog', // channel link
+ 'Posts from My Site.', // channel description
+ [
+ [
+ 'title' => 'Post title',
+ 'link' => '/blog/post-slug',
+ 'guid' => '/blog/post-slug',
+ 'pubDateTimestamp' => strtotime('2026-07-11'),
+ 'description' => 'A short excerpt or summary.',
+ ],
+ // ...one array per item
+ ]
+);
+
+return Response::xml($xml);
+
+ link/guid are expected to be site-relative paths (e.g. /blog/post-slug), consistent with how this framework already handles canonical/og:url (see {{ icons.book() }}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.
+
+ The two feeds shipped with this site
+
+
+ /blog/feed (App/pages/blog/feed/index.php) — the main blog feed. Reads the same hand-written $posts array App/pages/blog/index.php itself renders from, so it has zero dependency on the content index — it works even with content_index_enabled left at its default false.
+ /blog/tag/<tag>/feed (App/pages/blog/tag/[tag]/feed/index.php) — one feed per tag, generated dynamically via the {{ icons.link() }}[param] capture rather than a static file per tag. This one does need the content index (same gate as blog/tag/[tag]/index.php), since tags only exist there — see {{ icons.search() }}Content index. Its <pubDate> is each page's source_mtime, a stand-in for a real publish date the content index doesn't separately track.
+
+
+ Adding another feed
+
+ Nothing is registered or limited to "one feed" — any sidecar-only page that builds an item list and calls Rss::render() is a feed. For a second content collection (say, App/pages/products/, with its own hand-written array like App/pages/blog/index.php's), a new App/pages/products/feed/index.php looks almost identical to blog/feed:
+
+ <?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) => [
+ 'title' => $p['title'],
+ 'link' => '/products/' . $p['slug'],
+ 'guid' => '/products/' . $p['slug'],
+ 'pubDateTimestamp' => strtotime($p['published']),
+ 'description' => $p['excerpt'],
+ ],
+ $products
+);
+
+return Response::xml(Rss::render(
+ $config['site_name'] . ' Products',
+ '/products',
+ 'New products from ' . $config['site_name'] . '.',
+ $items
+));
+
+ Two independent feeds now exist side by side — /blog/feed and /products/feed — with no shared state, registry, or configuration between them. A project can have as many as it has content collections.
+
+ Auto-discovery for more than one feed
+
+ {{ icons.book() }}SEO's head_extra block is how a feed gets a <link rel="alternate" type="application/rss+xml"> tag in <head> so browsers/feed readers can discover it — App/pages/blog/_layout/layout.twig overrides it with exactly one such tag, scoped to /blog/* pages only since only that layout overrides the block. A layout isn't limited to one <link> in that override — list several, each with its own title attribute so a feed reader can tell them apart:
+
+ {% verbatim %}{% block head_extra %}
+ <link rel="alternate" type="application/rss+xml" title="{{ site_name }} Blog" href="/blog/feed">
+ <link rel="alternate" type="application/rss+xml" title="{{ site_name }} Products" href="/products/feed">
+{% endblock %}{% endverbatim %}
+
+ Where that override lives determines which pages advertise which feeds — put it in the root layout (novaconium/pages/_layout/layout.twig) for a feed that should be discoverable site-wide, or in a subtree's own _layout/layout.twig (like App/pages/blog/_layout/layout.twig 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 head_extra override to the feed(s) "belonging" to that directory, it's just the natural place to put it for the common case.
+
+ Verifying a feed
+
+ 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:
+
+ 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->channel->item) . " items\n";
+'
+{% endblock %}
diff --git a/novaconium/pages/admin/docs/seo/index.twig b/novaconium/pages/admin/docs/seo/index.twig
index db24dc0..59a455c 100644
--- a/novaconium/pages/admin/docs/seo/index.twig
+++ b/novaconium/pages/admin/docs/seo/index.twig
@@ -33,6 +33,7 @@
twitter_card | summary | <meta name="twitter:card"> |
twitter_title | {{ '{{ block(\'title\') }}' }} | <meta name="twitter:title"> |
twitter_description | {{ '{{ block(\'description\') }}' }} | <meta name="twitter:description"> |
+ head_extra | empty | open-ended — anything a subtree's own layout needs in <head> that doesn't fit an existing block. App/pages/blog/_layout/layout.twig overrides it with the blog's RSS <link rel="alternate">, scoped to /blog/* only since only that layout overrides it. |
diff --git a/novaconium/pages/admin/docs/sitemap/index.twig b/novaconium/pages/admin/docs/sitemap/index.twig
new file mode 100644
index 0000000..34fb4a4
--- /dev/null
+++ b/novaconium/pages/admin/docs/sitemap/index.twig
@@ -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 %}
+ {{ icons.sitemap() }}XML sitemap
+
+ /sitemap.xml (novaconium/pages/sitemap.xml/index.php) lists every indexed page for search engine discovery, following the sitemaps.org protocol: one <url> entry per page with <loc>, <lastmod>, <changefreq>, and <priority>. It's a framework default — a directory literally named sitemap.xml under novaconium/pages/; Router only ever splits the request path on /, so that resolves the literal /sitemap.xml URL correctly, no special extension-routing involved.
+
+ Sidecar-only — no index.twig, since Response::xml(...) bypasses Twig entirely (see {{ icons.book() }}Sidecars' JSON-only example for the same pattern). Built entirely from the {{ icons.search() }}content index — it's one of the three routes gated by content_index_enabled (default false), so it 404s exactly like a route that doesn't exist until that's turned on.
+
+ Per-page changefreq/priority
+
+ Two Twig blocks, declared in novaconium/pages/_layout/layout.twig alongside the rest of the {{ icons.book() }}SEO blocks — same override mechanism, just harvested by the content index rather than rendered into the page:
+
+
+
+ | Block | Default | Sitemap element |
+
+
+ changefreq | monthly | <changefreq> — how often the page is expected to change (always/hourly/daily/weekly/monthly/yearly/never, per the protocol) |
+ priority | 0.5 | <priority> — relative priority against this site's own other pages, 0.0–1.0 |
+
+
+
+ {% verbatim %}{% block changefreq %}weekly{% endblock %}
+{% block priority %}1.0{% endblock %}{% endverbatim %}
+
+ A page that doesn't override either just gets the layout's defaults — nothing to set for most pages. Bump priority 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.
+
+ What's included, what isn't
+
+
+ - Only pages the content index actually indexes — see {{ icons.search() }}Content index for the full crawl rules. In short: any page whose resolved
robots block contains noindex (every /admin/* page already does) or that's listed in {{ icons.lock() }}draft_routes is skipped.
+ [param]-wildcard routes (e.g. blog/tag/[tag]) 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.
+ <lastmod> is the page's own source file's mtime (content_pages.source_mtime), not when the sitemap itself was last regenerated — it reflects when the content actually last changed.
+
+
+ A known limitation: relative <loc> URLs
+
+ The sitemaps.org protocol calls for <loc> to be a fully-qualified absolute URL. This framework's <loc> values are site-relative paths instead (e.g. /about, not https://example.com/about) — consistent with how canonical/og:url already work (see {{ icons.book() }}SEO), 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 novaconium/pages/sitemap.xml/index.php with your own App/pages/sitemap.xml/index.php (same override-by-path mechanism as any other framework default) and prefix each <loc> with the site's real domain.
+
+ How it's built
+
+ Calls ContentIndexer::ensureFresh() (the lazy reindex-if-stale check — see {{ icons.search() }}Content index), queries content_pages, and builds the XML with plain string concatenation — no DOMDocument, this site's scale doesn't need one. Every value is still htmlspecialchars(..., ENT_XML1)-escaped, since route/changefreq/priority all ultimately come from page-author-controlled Twig blocks, not hardcoded constants.
+
+ Submitting it to search engines
+
+ This framework doesn't ship a public/robots.txt — add one yourself with a Sitemap: line pointing at wherever /sitemap.xml 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 <lastmod> is the signal that tells them what's actually changed since their last visit.
+{% endblock %}
diff --git a/novaconium/sass/main.sass b/novaconium/sass/main.sass
index b189442..9e9cc84 100644
--- a/novaconium/sass/main.sass
+++ b/novaconium/sass/main.sass
@@ -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.
diff --git a/novaconium/src/Renderer.php b/novaconium/src/Renderer.php
index 3ef8e1c..e2238b6 100644
--- a/novaconium/src/Renderer.php
+++ b/novaconium/src/Renderer.php
@@ -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);
}
/**
diff --git a/public/css/main.css b/public/css/main.css
index 99e1081..c54cbf8 100644
--- a/public/css/main.css
+++ b/public/css/main.css
@@ -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;