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 %} {% 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 %} {% block content %}
<div class="blog-layout"> <div class="blog-layout">
<aside> <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 // 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 // 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. // 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 [ return [
'posts' => [ 'posts' => [
[ [
'slug' => 'hello-world', 'slug' => 'hello-world',
'title' => 'Hello, World!', 'title' => 'Hello, World!',
'excerpt' => 'The first post on this blog — a plain sidecar-less page, like every other post here now.', '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', 'slug' => 'second-post',
'title' => 'A 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.', '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', 'slug' => 'twig-syntax-guide',
'title' => '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.', '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', 'slug' => 'style-guide',
'title' => 'Style Guide', 'title' => 'Style Guide',
'excerpt' => "A showcase of this theme's default styling for headings, lists, tables, code, and other common HTML elements.", '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`. - **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`. - **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`. - **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. - **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 ## Getting started
@@ -80,7 +81,7 @@ php novaconium/bin/create-static-page.php blog/my-new-post
## Documentation ## 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) - [Getting started](http://127.0.0.1:8000/admin/docs/getting-started)
- [Routing](http://127.0.0.1:8000/admin/docs/routing) - [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) - [Database](http://127.0.0.1:8000/admin/docs/database)
- [Session](http://127.0.0.1:8000/admin/docs/session) - [Session](http://127.0.0.1:8000/admin/docs/session)
- [Content index](http://127.0.0.1:8000/admin/docs/content-index) - [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) - [Layouts](http://127.0.0.1:8000/admin/docs/layouts)
- [Static caching](http://127.0.0.1:8000/admin/docs/caching) - [Static caching](http://127.0.0.1:8000/admin/docs/caching)
- [SEO](http://127.0.0.1:8000/admin/docs/seo) - [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 ## Backlog
Suggested build order (foundations first, since admin login builds on two Suggested build order (foundations first, since admin login builds on
of the others): three of the others):
1. **Blog RSS feed** — no hard dependency; a per-tag feed is now easy to 1. **Media/file manager** — no hard dependency; usable standalone, though
add too, since tag browsing/the content index shipped (see Done).
2. **Media/file manager** — no hard dependency; usable standalone, though
best gated behind admin login once that exists. 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 copy button (see Done — shipped 2026-07-14), but touches the same
`<pre><code>` markup it did. `<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. 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 5. **Ecommerce functionality** — needs admin login & user management
(order/product admin, and customer accounts); SQLite groundwork and (order/product admin, and customer accounts); SQLite groundwork and
session handling (cart) it needed are both done. session handling (cart) it needed are both done.
@@ -75,37 +75,12 @@ of the others):
question when this entry was originally written. question when this entry was originally written.
MySQL support, Session handling (with flash sessions), Draft pages MySQL support, Session handling (with flash sessions), Draft pages
(admin-only preview), and Blog tags/categories + Internal search + XML (admin-only preview), Blog tags/categories + Internal search + XML
sitemap (shipped together as one content index — see Done) all shipped sitemap (shipped together as one content index — see Done), and Blog RSS
2026-07-14. feed all shipped 2026-07-14.
See **Won't Do** below for 404 tracking, dropped in favor of Matomo. 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 ### Media/file manager
- **Type:** Feature - **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()` user, change password). Ship this by replacing `AdminAuth::requireLogin()`
with the new mechanism, not layering on top of it. 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 ### Ecommerce functionality
- **Type:** Feature - **Type:** Feature
@@ -232,6 +235,45 @@ _Nothing yet._
## Done ## 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 ### Content index: keywords, tags/categories, search, XML sitemap
- **Type:** Feature - **Type:** Feature
+1 -1
View File
@@ -78,7 +78,7 @@ $matomoUrl = $config['matomo_url'] !== '' ? rtrim($config['matomo_url'], '/') .
$adminAuthEnabled = $config['admin_password_hash'] !== ''; $adminAuthEnabled = $config['admin_password_hash'] !== '';
$cache = new Cache($config['cache_dir']); $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 // 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 // — 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> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -44,6 +45,15 @@
<link rel="stylesheet" href="/css/main.css"> <link rel="stylesheet" href="/css/main.css">
{% include '_layout/matomo.twig' %} {% 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> </head>
<body> <body>
<header> <header>
@@ -54,6 +64,10 @@
</main> </main>
<footer> <footer>
<small>&copy; {{ "now"|date("Y") }} {{ site_name }}</small> <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> </footer>
{% include '_layout/code-copy.twig' %} {% include '_layout/code-copy.twig' %}
</body> </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/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/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/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/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/drafts">{{ icons.lock() }}Draft pages</a></li>
<li><a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a></li> <li><a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a></li>
@@ -72,12 +72,14 @@ return [
<h2>Sitemap</h2> <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> <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]/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> <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> <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 %} {% block docs_content %}
<h1>Getting started</h1> <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> <h2>Run it locally (no Apache needed)</h2>
+18 -1
View File
@@ -4,13 +4,28 @@
{% block title %}Docs{% endblock %} {% 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 robots %}noindex, nofollow{% endblock %}
{% block docs_content %} {% block docs_content %}
<h1 class="icon-heading">{{ icons.book() }}Project documentation</h1> <h1 class="icon-heading">{{ icons.book() }}Project documentation</h1>
<p>Framework docs, rendered as plain Twig pages — no internet connection needed.</p> <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> <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/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> <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/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/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/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/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/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> <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_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_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>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> </tbody>
</table> </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 small
color: var(--muted-color) 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). // Honeypot field for spam prevention (see App/pages/contact/index.php).
// Off-screen positioning rather than display:none/visibility:hidden, // Off-screen positioning rather than display:none/visibility:hidden,
// since some spam bots specifically skip fields hidden that way. // since some spam bots specifically skip fields hidden that way.
+7
View File
@@ -30,6 +30,7 @@ final class Renderer
string $matomoUrl = '', string $matomoUrl = '',
string $matomoSiteId = '', string $matomoSiteId = '',
string $siteName = 'My Site', string $siteName = 'My Site',
bool $contentIndexEnabled = false,
) { ) {
$loader = new FilesystemLoader($this->pagesDirs); $loader = new FilesystemLoader($this->pagesDirs);
$this->twig = new Environment($loader, [ $this->twig = new Environment($loader, [
@@ -40,6 +41,12 @@ final class Renderer
$this->twig->addGlobal('matomo_site_id', $matomoSiteId); $this->twig->addGlobal('matomo_site_id', $matomoSiteId);
$this->twig->addGlobal('site_name', $siteName); $this->twig->addGlobal('site_name', $siteName);
$this->twig->addGlobal('is_404', false); $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); 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 { .hp-field {
position: absolute; position: absolute;
left: -9999px; left: -9999px;