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:
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>© {{ "now"|date("Y") }} {{ site_name }}</small>
|
<small>© {{ "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><loc></code>, <code><lastmod></code> (the page's own source file mtime), <code><changefreq></code>, and <code><priority></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><loc></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/<tag>/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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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' => '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);</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><guid></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/<tag>/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><pubDate></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><?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
|
||||||
|
));</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><link rel="alternate" type="application/rss+xml"></code> tag in <code><head></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><link></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 %}
|
||||||
|
<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 %}</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->channel->item) . " items\n";
|
||||||
|
'</code></pre>
|
||||||
|
{% endblock %}
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
<tr><td><code>twitter_card</code></td><td><code>summary</code></td><td><code><meta name="twitter:card"></code></td></tr>
|
<tr><td><code>twitter_card</code></td><td><code>summary</code></td><td><code><meta name="twitter:card"></code></td></tr>
|
||||||
<tr><td><code>twitter_title</code></td><td><code>{{ '{{ block(\'title\') }}' }}</code></td><td><code><meta name="twitter:title"></code></td></tr>
|
<tr><td><code>twitter_title</code></td><td><code>{{ '{{ block(\'title\') }}' }}</code></td><td><code><meta name="twitter:title"></code></td></tr>
|
||||||
<tr><td><code>twitter_description</code></td><td><code>{{ '{{ block(\'description\') }}' }}</code></td><td><code><meta name="twitter:description"></code></td></tr>
|
<tr><td><code>twitter_description</code></td><td><code>{{ '{{ block(\'description\') }}' }}</code></td><td><code><meta name="twitter:description"></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><head></code> that doesn't fit an existing block. <code>App/pages/blog/_layout/layout.twig</code> overrides it with the blog's RSS <code><link rel="alternate"></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><url></code> entry per page with <code><loc></code>, <code><lastmod></code>, <code><changefreq></code>, and <code><priority></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><changefreq></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><priority></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><lastmod></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><loc></code> URLs</h2>
|
||||||
|
|
||||||
|
<p>The sitemaps.org protocol calls for <code><loc></code> to be a fully-qualified absolute URL. This framework's <code><loc></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><loc></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><lastmod></code> is the signal that tells them what's actually changed since their last visit.</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user