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.
This commit is contained in:
code
2026-07-14 17:35:30 +00:00
parent 4862526fa1
commit 37b6764431
30 changed files with 1123 additions and 157 deletions
+27 -4
View File
@@ -48,9 +48,13 @@ return [
// wholly replaced on a framework update — see
// /admin/docs/getting-started's "Updating the framework" section) — a
// top-level data/ directory, project-owned like App/, is the only safe
// place for it. migrations_dir is optional per connection; omit it to
// never run migrations against that connection (e.g. a read-only
// legacy database). NOTE: unlike every other key here, App/config.php
// place for it. migrations_dir is optional per connection (omit it to
// never run migrations against that connection, e.g. a read-only
// legacy database) and accepts either one path or an ordered list of
// roots — the default connection lists novaconium/migrations/ (framework
// -shipped schema, e.g. the content index — see /admin/docs/content-index)
// before App/migrations/ (project migrations), so framework migrations
// always apply first. NOTE: unlike every other key here, App/config.php
// merges into db_connections one level deeper than a normal shallow
// override — see the comment on Lib\Db::config() — so adding a second
// connection there doesn't require repeating 'default'.
@@ -58,7 +62,10 @@ return [
'default' => [
'driver' => 'sqlite',
'path' => __DIR__ . '/../data/novaconium.sqlite',
'migrations_dir' => __DIR__ . '/../App/migrations',
'migrations_dir' => [
__DIR__ . '/migrations',
__DIR__ . '/../App/migrations',
],
],
],
@@ -73,4 +80,20 @@ return [
// since a world-readable cached copy would otherwise permanently leak
// the draft the first time an admin previewed it.
'draft_routes' => [],
// Content index (see /admin/docs/content-index) — backs /sitemap.xml,
// /search, and blog tag browsing. Off by default: all three depend on
// SQLite (Lib\Db), a real dependency plenty of sites built on this
// framework won't want at all, the same reasoning that keeps Matomo
// and admin auth off by default above. When false, all three routes
// 404 exactly as if they didn't exist, and nothing ever touches
// Lib\Db because of this feature — no data/novaconium.sqlite gets
// created just because the code exists. content_index_auto only
// matters once enabled: true (the default) reindexes lazily,
// on-demand, the first time a stale index is actually needed (never on
// a normal page view); false disables that and leaves indexing
// entirely to `php novaconium/bin/index-content.php`, e.g. from a
// deploy step.
'content_index_enabled' => false,
'content_index_auto' => true,
];