Files
novaconium/AGENTS.md
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

26 KiB

AGENTS.md

Context for any coding agent working in this repo — Claude, DeepSeek, or otherwise; this file (and the maintenance rule below) applies regardless of which model or CLI is driving. Full narrative docs live at /admin/docs when the app is running (also the only place Twig upgrade instructions live now — see /admin/docs/upgrading-twig; there's no separate MAINTENANCE.md, keeping one copy in the docs page avoids drift). README.md is the GitHub-facing pitch, novaconium/ISSUES.md is the roadmap/backlog, and this file is the short, agent-facing version. The original design rationale used to live in a standalone plan.md; it's now folded into /admin/docs/design-notes (everything in it shipped) and the file was deleted. There used to also be a GUIDE.md mirroring /admin/docs for offline reading — it was removed to cut a doc copy that had to be kept in sync; /admin/docs is the only narrative reference now.

Documentation is duplicated on purpose — keep all copies in sync

Every topic (routing, sidecars, libraries, layouts, caching, SEO, Matomo, admin authentication, styling, project layout, third-party) exists in two places: a page under novaconium/pages/admin/docs/<topic>/index.twig (the canonical reference), and (for anything a README-reading human needs up front) a mention in README.md. This is intentional — /admin/docs is for reading against a running instance with no internet needed, and README.md is the GitHub-facing pitch — but it means any agent that changes framework behavior or adds a feature must update both copies in the same change, not just the one that was open. Concretely, after touching routing/rendering/caching/SEO behavior or adding a new top-level docs topic:

  1. Update (or add) the matching page under novaconium/pages/admin/docs/<topic>/index.twig, and if it's a new topic, link it from both admin/docs/index.twig and the nav in admin/docs/_layout/layout.twig.
  2. Update README.md if the change affects the feature list, getting started steps, or the docs index there.
  3. Update this file if the change affects a convention an agent needs to know before editing code (not just narrative docs).

A doc change that only touches one of these copies is incomplete — verify the other copy before considering the task done.

What this is

A dependency-light PHP + Twig micro-framework: directories under pages/ map directly to URLs (Hugo-style page bundles), optional index.php sidecars supply data or short-circuit to a Response, and sidecar-less pages get pre-rendered to static HTML on first request and served straight from Apache after that. No Composer, no build step to install — Twig is vendored as plain source files.

The two-root split — read this before editing anything under pages/ or lib/

Everything lives in one of two places:

  • App/ — the actual project: App/pages/ (routes/content) and App/lib/ (project's own Lib\ classes). This is the only directory a site author is expected to touch.
  • novaconium/ — the framework itself: router/renderer core (novaconium/src/), default pages (novaconium/pages/ — root layout, 404, the /admin tools), default Lib\ classes (novaconium/lib/), vendored Twig, autoloader, config, bootstrap.

Routing and rendering resolve against both roots, in orderApp/pages/ first, novaconium/pages/ as fallback — via novaconium/src/Overlay.php. Same mechanism for Lib\ classes: App/lib/ is checked before novaconium/lib/ in novaconium/autoload.php. Concretely: dropping a file at the same relative path in App/ overrides the novaconium/ default; nothing needs to be duplicated for the site to work, since novaconium/pages/ already supplies a working layout and 404.

Twig's FilesystemLoader is constructed with both paths as an array, so {% extends %} / {% include %} get this override-then-fallback resolution for free — no custom logic needed there.

The same override-by-presence pattern applies to novaconium/config.php: if App/config.php exists, novaconium/bootstrap.php and novaconium/bin/clear-cache.php shallow-merge it over the framework defaults with array_merge(). A project only needs to list the keys it's changing — never edit novaconium/config.php directly.

config['matomo_url'] / config['matomo_site_id'] (both default '') gate the Matomo tracking script emitted by the root layout — set both via App/config.php to enable it, since either being empty disables tracking entirely. bootstrap.php normalizes a missing trailing slash on matomo_url before passing it to Renderer, which exposes matomo_url, matomo_site_id, and is_404 as Twig globals (is_404 is overridden to true in the 404 template's local render context by Renderer::renderNotFound(), per Twig's local-context-over-global precedence). Any new Twig global added to Renderer's constructor should follow this same pattern: default value, addGlobal() call, documented here and in /admin/docs.

config['site_name'] (default 'My Site') is the same pattern — passed to Renderer and exposed as the site_name Twig global, used by novaconium/pages/_layout/layout.twig for the default title block, og:site_name, and the footer copyright line. Any other hardcoded site-identity string that shows up in a shared template (as opposed to a per-page override) should become a config.php key the same way, not stay hardcoded in the template.

config['admin_username'] / config['admin_password_hash'] (username defaults to 'admin', password hash defaults to '') gate every /admin/* route behind HTTP Basic Auth — this replaced the old docs_enabled flag entirely (removed); a single gate over all of /admin/* (docs included) made a docs-only toggle redundant. Unlike the Twig-global pattern above, the gate itself is enforced in bootstrap.php, before rendering: AdminAuth::requireLogin(...) (novaconium/src/AdminAuth.php) is called once for any resolved route whose path is admin or starts with admin/. Any new admin page dropped under App/pages/admin/ or novaconium/pages/admin/ is automatically protected — no per-page wiring needed. bootstrap.php also special-cases the literal path /admin/logout before router resolution — no page exists there — to call AdminAuth::logout(), which always issues a fresh 401 so the browser drops its cached credentials (there's no server-side session to invalidate). Renderer separately exposes an admin_auth_enabled Twig global (true when a password hash is set) so admin/index.twig can conditionally show the "Logout" link — this is a derived display flag, not the enforcement mechanism itself, which never depends on Twig. novaconium/pages/admin/password-hash/ is a built-in password_hash() form (no CLI needed) for generating admin_password_hash — a normal admin page, so it's covered by the same gate: reachable while no password is set yet (to generate the first one), then protected like everything else under /admin/* afterward. It computes and displays the hash per-request only; nothing is persisted or logged. This is a single-user HTTP Basic Auth stopgap, not the full multi-user system tracked in novaconium/ISSUES.md ("Admin login & user management"); don't extend this class toward multi-user/session-based auth — that's a separate, larger feature that will replace it.

Lib\Db (novaconium/lib/Db.php) is the SQLite/MySQL groundwork tracked in novaconium/ISSUES.md — a thin, no-ORM PDO wrapper, Lib\ (not App\) so a project can override it via App/lib/Db.php like any other Lib\ class. It supports multiple, independently-configured, simultaneously open named connections (config['db_connections'], keyed by name) rather than one global connection — because sidecars are plain PHP with full access to any Lib\ class, a single request can legitimately need more than one database at once (e.g. this site's own SQLite data alongside a MySQL connection to a legacy database). Db::query(string $sql, array $params = [], string $connection = 'default') (prepare+execute) is the only query-running helper — never add a string-interpolation shortcut; see Lib\Input's doc-comment, which already commits this project to parameterized queries as the sole SQL-injection defense. Each connection is lazy and independent (opened on first Db::query()/Db::connection() call naming it, same shape as Lib\Csrf's lazy session start), and migrates automatically at that point: plain .sql files under that connection's own migrations_dir — a single path, or (since the content index shipped) an ordered list of roots, each root's own files filename-ordered and roots processed fully in the order given, not interleaved by filename across roots (so a framework root always finishes before a project root on the same connection). Tracked by path relative to the repo root (e.g. novaconium/migrations/0001_x.sql), not bare filename — two roots can each contain a same-named file, and tracking by bare filename would make the second one seen look "already applied" and silently skip it; a repo-relative path is also portable across environments, unlike a full absolute path, which would make every migration look new again after a clone/deploy to a different directory. realpath() normalizes any .. a migrations_dir like __DIR__ . '/../App/migrations' would otherwise leave in the tracked name. Each connection's own auto-created schema_migrations table tracks its migrations independently of any other connection's; each is only ever run once. novaconium/bin/migrate.php loops every configured connection and runs the same migration step explicitly (e.g. from a deploy script) without serving a request first. Only 'sqlite' and 'mysql' drivers are implemented; a connection's migrations_dir is optional — omit it to never run migrations against that connection (e.g. a legacy database this project shouldn't manage schema for).

db_connections is the one config key in the project that isn't plain shallow-mergebootstrap.php/bin/*.php's usual array_merge($config, $appConfig) would let a project's App/config.php silently delete the framework's default connection just by adding a second named connection (a shallow merge replaces the whole key, it doesn't merge inside it). So Lib\Db::config() (and the copy of this logic duplicated in bin/migrate.php, same duplication precedent as the two-step config load already duplicated across bootstrap.php/bin/clear-cache.php) merges db_connections one level deeper, by connection name, after capturing the framework defaults — capture the defaults before the top-level array_merge() overwrites $config['db_connections'], not after, or the deeper merge silently operates on the already-overwritten value and the default connection vanishes anyway. (This exact bug was hit once while building this feature — verified by testing a real App/config.php override end-to-end, not just reading the code — so it's worth re-checking by hand if this logic is ever touched again.) See /admin/docs/database for the worked example.

config['db_connections']['default']['path'] must stay outside both public/ (would be web-accessible) and novaconium/ — unlike cache_dir/contact-log.txt, which are disposable, a SQLite file is data a project can't afford to lose, and novaconium/ gets wholesale-replaced by the "Updating the framework" workflow (/admin/docs/getting-started: rm -rf novaconium && cp -r <new-novaconium>). The default (data/novaconium.sqlite) lives in a new top-level data/ directory instead — project-owned like App/, gitignored per-file (*.sqlite/-journal/-wal/-shm, with a tracked .gitkeep so the directory exists in a fresh clone) rather than wholesale like public/cache/, since a project might reasonably want other non-DB files there later. The default connection's migrations_dir scans novaconium/migrations/ (framework-shipped schema, e.g. the content index below) before App/migrations/ (project schema) — see the migrations_dir array-form paragraph above. Any other connection a project adds still defaults to no migrations_dir at all unless it sets one; the two-root default is specific to default.

Lib\Session (novaconium/lib/Session.php) is a thin wrapper around native PHP sessions (session_start()/$_SESSION, not a custom store), all-static and lazy-start like Lib\Csrf — nothing calls session_start() until the first real call to a Session method. Its ensureSession() is a deliberate duplicate of Csrf::ensureSession() (same cookie params, same session_status() guard) rather than a shared helper — keeps Csrf standalone with zero new dependencies on a class that didn't exist when it shipped, same tolerance for small duplication already established by the config-load block duplicated across bootstrap.php/bin/clear-cache.php/ Lib\Db::config(). Both classes touching the same native session in the same request is safe either way, since session_start() silently no-ops if a session is already active — there's no ordering requirement between Csrf::token()/::verify() and any Session method.

Flash data (Session::flash()/::getFlash()) is one swap, not a sweep/expiry pass: the first Session method call in a request snapshots whatever was flashed on the previous request into an in-memory static (self::$currentFlash) for that request's getFlash() reads, then immediately empties the stored flash bucket so flash() calls made during the current request start filling a fresh bucket for the request after this one. This relies on static properties not persisting across requests (true under php -S, mod_php, and PHP-FPM alike — each request gets fresh PHP state regardless of worker-process reuse) — don't add any caching/memoization to Session that assumes static state survives between requests, since none of it does. See /admin/docs/session for a worked flash example.

Standing rule: any mechanism that conditionally hides page content from the public must also be threaded into Renderer::render()'s $excludeFromCache decision, not just a pre-render auth gate. This bit twice already — once as a designed-around gotcha (draft pages), once as a real pre-existing bug found while testing that feature (/admin/* itself). The reason: Renderer::render() writes a sidecar-less page's output to the static HTML cache (novaconium/src/Cache.php), and .htaccess serves a cached file before PHP, and therefore any auth check, ever runs again (see /admin/docs/caching). A route can be gated by AdminAuth::requireLogin()/::isAuthenticated() and still leak completely to the public the moment it's viewed once by someone authorized, if the page has no sidecar and nothing tells Renderer to skip the cache write for that route. draft_routes (see /admin/docs/drafts, novaconium/config.php) and every /admin/* route both pass true for Renderer::render()'s $excludeFromCache param from novaconium/bootstrap.php for exactly this reason — most pages under novaconium/pages/admin/ (e.g. admin/index.twig) have no sidecar, so before this was wired up, visiting /admin once as an authenticated admin would cache the admin panel and serve it to every subsequent visitor, unauthenticated, straight from public/cache/admin/. Any future feature that gates a route by anything other than a sidecar check (paywall content is the next one on the roadmap likely to hit this) needs to make the same check here, not just at the point where the request is first authorized.

AdminAuth::isAuthenticated(string $username, string $passwordHash): bool (novaconium/src/AdminAuth.php) is the credential check on its own, with no response side effects, extracted out of requireLogin() (which still does the same check, then issues the 401 challenge on failure) so a different caller can react to failure differently. The draft-page gate in bootstrap.php is the first such caller: on failure it renders a plain 404 via the same path an unmatched route takes, not a login prompt — prompting for credentials at a draft URL would itself reveal that something is gated there, which defeats the point of hiding it. Returns true (open access) when $passwordHash is empty, mirroring requireLogin()'s existing no-op-when-unset posture, so a draft behaves consistently with the rest of /admin/*: wide open until a password is configured, gated once one is.

App\ContentIndexer (novaconium/src/ContentIndexer.php) is the shared crawler behind /sitemap.xml, /search, and blog tag browsing (see /admin/docs/content-index) — App\, not Lib\, since it's rendering infrastructure akin to Renderer/Router, not project-overridable content. Content stays in files; only metadata is indexed. Per-page metadata is four Twig blocks declared in the root layout next to the SEO blocks (keywords, tags, changefreq, priority — the last three never rendered into the page, only harvested) and pulled via Renderer::renderForIndex(), which calls Twig's own TemplateWrapper::renderBlock() per block rather than parsing .twig source — this is deliberate: it gets App-over-novaconium override and layout-inheritance resolution for free, the same way a real render does. config['content_index_enabled'] defaults to false — same posture as matomo_url/admin_password_hash, since this is a real SQLite dependency plenty of sites won't want. Every consumer route checks the flag before touching Lib\Db and 404s if it's off, so the feature has zero filesystem footprint (no data/novaconium.sqlite) when disabled — verified end-to-end, not assumed, since "off" silently still creating a database file would defeat the point.

Reentrancy hazard, already hit once: ContentIndexer::reindex() renders every routable page as part of the crawl — including /search itself, which is also a real page and also calls ContentIndexer::ensureFresh() from its own sidecar. Without a guard, crawling /search would trigger a nested reindex() call mid-transaction and fatal on a second PDO::beginTransaction(). ContentIndexer guards this with a private static bool $indexing flag, checked at the top of both ensureFresh() and reindex() — either no-ops while a reindex is already running on the call stack. Any future consumer route added under this mechanism inherits the same hazard for free (it'll also get crawled, and if its sidecar also calls ensureFresh(), the guard already covers it) — don't remove the flag thinking it's unnecessary.

reindex() also forces $_SERVER['REQUEST_METHOD'] to 'GET' for the duration of the crawl (restoring whatever it was before, in a finally) — sidecars are expected to be side-effect-free for non-POST requests anyway (ordinary HTTP-safe-method hygiene), but this guarantees a lazy reindex triggered from within a POST request can never leak that POST into an unrelated page's sidecar purely because the crawler happened to render it. A crawl is a full truncate-and-rebuild inside one transaction, not incremental — simple and correct at this site's scale; don't add incremental/diffing logic without a real need for it.

Running it

php -S 127.0.0.1:8000 -t public public/router.php

public/router.php is dev-only, mimics public/.htaccess. There is no test suite — verification is manual route-by-route (see /admin/docs/design-notes's Verification section for the checklist used after any framework change). After testing, clear stray cache with php novaconium/bin/clear-cache.php or POST /admin/clear-cache, and remove anything written to App/lib/ or App/pages/ that was only for testing an override — nothing here is gitignored except public/cache/* and novaconium/contact-log.txt, so test debris left in App/ will otherwise get committed or silently change site behavior for the next person.

Conventions worth knowing

  • Reserved segments: any path segment starting with _ (e.g. _layout/) or literally named 404 is never routable — Router::resolve() 404s on sight, don't try to serve content directly at those paths.
  • Sidecars (index.php) return either an array (Twig context) or a Response (redirect/json/xml/html — novaconium/src/Response.php). $params (route captures) and $cache (the Cache instance, e.g. for $cache->clear()) are both in scope automatically — see novaconium/src/Renderer.php::runSidecar().
  • No Composer — novaconium/autoload.php is a hand-rolled PSR-4 loader. Adding a new framework-core class means adding it under App\ in novaconium/src/; a new Lib\ class goes in App/lib/ or novaconium/lib/ depending on whether it's project- or framework-specific.
  • novaconium/bin/ holds standalone CLI entry points meant to be run directly (php novaconium/bin/<script>.php) — distinct from bootstrap.php/autoload.php/config.php, which are only ever require'd, never invoked directly. clear-cache.php and create-static-page.php (scaffolds a new page from the /admin/docs/seo starter template) both live there; a new CLI tool goes there too.
  • CSS is compiled from novaconium/sass/main.sass (indented syntax) to public/css/main.css. dart-sass is installed in this environment (Arch: pacman -S dart-sass) — after editing Sass source, run: sass --load-path=App/sass --load-path=novaconium/sass/defaults --no-source-map novaconium/sass/main.sass public/css/main.css and commit the regenerated public/css/main.css (--no-source-map avoids a stray main.css.map the project doesn't otherwise use). If sass isn't available in whatever environment you're in, either run it via Docker — /admin/docs/styling has a copy-pasteable Dockerfile that installs the same standalone Dart Sass release used in this environment (1.101.0) directly from GitHub, not via npm, plus the docker build/docker run commands adjusted to this repo's paths — or hand-edit both files in parallel and keep them in sync — that's how the dark/teal theme and the homepage hero/animation styling were originally written before sass was installed here.
  • The Sass color palette follows the same App-over-novaconium override pattern as pages/lib, but with a twist worth understanding before touching it: novaconium/sass/main.sass does @use 'colors' as *, and its own directory (novaconium/sass/) deliberately has no _colors.sass sibling. Dart Sass resolves a bare @use relative to the importing file's own directory before consulting --load-path entries, so if novaconium/sass/_colors.sass existed next to main.sass, it would always win regardless of load-path order — silently defeating the override. Keeping the framework default at novaconium/sass/defaults/_colors.sass (a different directory) forces resolution through the load path, where App/sass (checked first) can actually override it with App/sass/_colors.sass. Don't move defaults/_colors.sass back next to main.sass — it was moved out on purpose, and doing so reintroduces this bug.
  • Every color rule in main.sass reads a CSS custom property (var(--bg), var(--accent), etc.), never a Sass variable directly — that indirection is what makes the dark/light theme toggle possible, since Sass only runs at compile time and can't react to a runtime choice on its own. The two _colors.sass files seed :root (dark, the default) and :root[data-theme="light"] (via -light-suffixed variables — $bg-light, $accent-light, etc., same files, same override mechanism) once at compile time; the toggle button in novaconium/pages/_layout/nav.twig flips the data-theme attribute on <html> at runtime and persists it to localStorage. novaconium/pages/_layout/theme-init.twig re-applies a saved choice early in <head> (before the stylesheet link) to avoid a flash of the wrong theme on load. If you add a new color to the palette, add both the plain and -light variable in both _colors.sass files and wire it into both :root blocks in main.sass — a color that's only themed in one direction will look wrong after a toggle.
  • Sidecars should read request data via Lib\Input::post()/::get() (novaconium/lib/Input.php) rather than $_POST/$_GET directly — it trims, strips tags, and strips null bytes automatically. This is defense-in-depth against HTML/script injection, not SQL-injection protection (no string transform makes input safe to concatenate into a query — use PDO prepared statements once a database layer exists); don't add an sqlSafe()-style method to Input. One documented exception: a field needing an exact, unmodified value (e.g. a password about to be hashed) should read $_POST directly instead — see novaconium/pages/admin/password-hash/index.php. Lib\Csrf (novaconium/lib/Csrf.php) is standalone session-token CSRF protection, not wired into FormValidator — a sidecar calls Csrf::verify() directly. It's the first thing in the framework to start a native PHP session (only lazily, when a form actually calls it), which is otherwise unrelated to AdminAuth's own session-free Basic Auth.
  • Don't use Twig's |slice filter on a string (as opposed to an array) — it unconditionally calls PHP's mb_substr() with no fallback (novaconium/vendor/twig/src/Extension/CoreExtension.php), which hard-requires the mbstring extension and will fatal (Call to undefined function Twig\Extension\mb_substr()) on a PHP install without it — a real regression this project hit once already, back when /blog/hello-world had a sidecar computing an excerpt this way (see the footnote on App/pages/blog/twig-syntax-guide/index.twig for the full story). Truncate strings in PHP instead, guarded with function_exists('mb_substr') falling back to substr(), and pass the already-truncated value into the template.
  • Same class of bug, different filter: don't use Twig's |escape('js') (or the 'js' arg to |e) either — it calls Twig\Runtime\mb_ord() (novaconium/vendor/twig/src/Extension/EscaperExtension.php), which hard-requires mbstring the same way |slice does, and fatals identically without it. Hit for real when novaconium/pages/_layout/code-copy.twig used it to pass SVG icon markup into an inline <script> as a JS string literal. Fixed by not needing string-escaped markup in JS at all: render the markup as plain HTML into a <template> element (default autoescaping, no mbstring dependency) and read it in JS via that template element's .innerHTML getter instead. Prefer that pattern — or a data-* attribute if the value is plain text, not markup — over |escape('js') any time a Twig value needs to reach JS.