Files
novaconium/novaconium/pages/sitemap.xml/index.php
T
code 37b6764431 Add content index: keywords, tags/categories, search, and XML sitemap
Ships Blog tags/categories, Internal search, and XML sitemap together as
one shared mechanism, plus a new meta keywords request, rather than three
separate ones - the "worth deciding together" call ISSUES.md made when
XML sitemap was first written.

Content stays in files. Per-page metadata is four Twig blocks in the
root layout, the same override mechanism already used for
title/description/og_* - keywords (rendered), tags/changefreq/priority
(not rendered, harvested only). App\ContentIndexer crawls every routable
page (Overlay::listPageDirs(), new) and pulls each block via
Renderer::renderForIndex() (new) calling Twig's own renderBlock() API,
not regex-parsing .twig source, so overrides and layout inheritance
resolve exactly like a real render. Rendered HTML is stripped and
indexed into a SQLite FTS5 table for search.

Off by default (content_index_enabled) - same posture as Matomo/admin
auth, since this is a real SQLite dependency plenty of sites won't want.
Verified true zero footprint when disabled: no data/novaconium.sqlite
gets created, all three consumer routes 404 like they don't exist.
content_index_auto (default true) reindexes lazily on first stale touch
of a consumer route, never on a normal page view; both that path and the
explicit `php novaconium/bin/index-content.php` share one reindex().

Two real bugs caught by testing, not review: a reentrancy bug where
/search's own sidecar calling ensureFresh() during the crawl triggered a
nested reindex() mid-transaction (fixed with a static re-entrancy guard),
and a wrong PDO constant in the search sidecar. Also fixed two unrelated
pre-existing bugs found while building this: an unescaped {{ }} in
admin/docs/sidecars that fataled Twig on any real render of that page,
and a stale "no database layer yet" claim there and in Lib\Input's
docblock, left over from before Lib\Db shipped.

Lib\Db's migrations_dir now accepts an ordered list of roots, not just
one path, so the content index's schema could ship as a framework
migration (novaconium/migrations/) without colliding with project
migrations in App/migrations/ - the two-root extension point flagged in
AGENTS.md when SQLite groundwork shipped. Migrations are tracked by path
relative to the repo root rather than bare filename so two roots with a
same-named file can't shadow each other.

New consumer routes: novaconium/pages/sitemap.xml/ and
novaconium/pages/search/ (framework defaults), App/pages/blog/tag/[tag]/
(project-owned, since blog/ is project content - the existing hand
-written post array in App/pages/blog/index.php is untouched). Added
tags to the 4 existing blog posts as a real demonstration.

Closes the Blog tags/categories, Internal search, and XML sitemap
backlog items in novaconium/ISSUES.md.
2026-07-14 17:35:30 +00:00

74 lines
3.5 KiB
PHP

<?php
// /sitemap.xml — a directory literally named "sitemap.xml" under
// novaconium/pages/, a framework default (like /admin/*), since sitemap
// generation is generic machinery, not project content. Router only ever
// splits the request path on "/", so a directory named "sitemap.xml"
// resolves the literal /sitemap.xml URL correctly — there's no special
// extension-routing mechanism involved. Sidecar-only: no index.twig next
// to this file, since Response::xml() bypasses Twig entirely (see
// /admin/docs/sidecars' JSON-only example for the same pattern).
use App\ContentIndexer;
use App\Response;
use Lib\Db;
// 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__ . '/../../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. When it's off, this route must 404 exactly
// like a page that doesn't exist, and never construct a Lib\Db connection
// (which would otherwise create data/novaconium.sqlite just because this
// file exists, even on a site that never opted in).
if (!$config['content_index_enabled']) {
return Response::html('404 Not Found', 404);
}
// Lazy reindex-if-stale — a no-op on most requests (only actually
// reindexes when a page's source file changed since the last index).
// ContentIndexer's crawl itself never renders this route: a sidecar
// returning a Response (this one always does) has nothing meaningful to
// index, so Renderer::renderForIndex() returns null for it and
// ContentIndexer skips it — no reentrancy concern here, unlike /search
// (see that file's comments), which does get crawled since it renders
// normally on an empty query.
ContentIndexer::ensureFresh();
// One row per indexed page — populated entirely by ContentIndexer::
// reindex(), never written to directly here. changefreq/priority come
// from each page's own {% block changefreq %}/{% block priority %}
// (defaults: monthly / 0.5 — see novaconium/pages/_layout/layout.twig),
// source_mtime is that page's source file's own mtime, used below as
// <lastmod> so it reflects when the content actually last changed, not
// when the index was last rebuilt.
$pages = Db::query('SELECT route, changefreq, priority, source_mtime FROM content_pages ORDER BY route')
->fetchAll(PDO::FETCH_ASSOC);
// Plain string concatenation rather than DOMDocument — this site's scale
// doesn't need a real XML builder, but every value is still
// htmlspecialchars()-escaped (ENT_XML1, not the HTML default) since
// route/changefreq/priority all ultimately come from page-author-controlled
// Twig blocks, not hardcoded constants.
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
$xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
foreach ($pages as $page) {
$xml .= ' <url>' . "\n";
$xml .= ' <loc>' . htmlspecialchars($page['route'], ENT_XML1) . '</loc>' . "\n";
$xml .= ' <lastmod>' . gmdate('Y-m-d', (int) $page['source_mtime']) . '</lastmod>' . "\n";
$xml .= ' <changefreq>' . htmlspecialchars($page['changefreq'], ENT_XML1) . '</changefreq>' . "\n";
$xml .= ' <priority>' . htmlspecialchars($page['priority'], ENT_XML1) . '</priority>' . "\n";
$xml .= ' </url>' . "\n";
}
$xml .= '</urlset>' . "\n";
return Response::xml($xml);