# 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//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//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 order** — `App/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_auth_enabled']` (default `false`) gates every `/admin/*` route behind a session login against the `users` table on `Lib\Db`'s default connection (`novaconium/migrations/0002_create_users.sql`) — the multi-user system from `novaconium/ISSUES.md`'s "Admin login & user management" entry, which **replaced** the old single-user HTTP Basic Auth stopgap (the `admin_username`/`admin_password_hash` config keys and the `/admin/password-hash` page are gone; that stopgap had itself replaced the even older `docs_enabled` flag). Same off-by-default posture as `content_index_enabled`, for the same reason: it depends on SQLite, so when the flag is false, `/admin/*` is wide open, the three auth routes (`/admin/login`, `/admin/logout`, `/admin/users`) 404 as if they didn't exist, their sidecars check the flag (via the same self-loaded two-step config read `/search` uses) *before* touching `Lib\Db`, and no `data/novaconium.sqlite` is ever created by this feature — verified end-to-end. **Two roles** (`users.role`): the **first user ever created is `'admin'`; everyone created after is `'registered'`**, each with an optional single group (`users.user_group`, a plain text label — no groups table). Admins run the site: `/admin/*`, drafts, and every `Lib\Access` rule passes for them. Registered users log in at the same `/admin/login` and see whatever content `Lib\Access` (below) grants their account or group — but `/admin/*` renders a plain 404 for them (they're authenticated; what they lack is the role, so bouncing them to the login form would be wrong). Unlike the Twig-global pattern above, the gate itself is enforced in `bootstrap.php`, before rendering, in two steps: `AdminAuth::requireLogin($config['admin_auth_enabled'])` (`novaconium/src/AdminAuth.php`) redirects anyone not logged in, then `AdminAuth::isAdmin(...)` 404s logged-in non-admins — for any resolved route whose path is `admin` or starts with `admin/`, **except `admin/login` itself, which must stay reachable logged-out or the redirect to it would loop.** **Any new admin page dropped under `App/pages/admin/` or `novaconium/pages/admin/` is automatically protected — no per-page wiring needed.** Bootstrapping the first user: while the `users` table is empty, the gate deliberately returns open access so the first user (the admin) can be created at `/admin/users` (which auto-logs its creator in, closing the gate), mirroring the old "wide open until a password is configured" posture; `novaconium/bin/create-admin-user.php` creates an admin from the CLI instead (password via stdin), which lets a deploy create the user *before* flipping the flag so the open window never exists — it's also the lockout-recovery path (usage: ` `, password via stdin). Every account has a **unique email address** (`users.email`), stored normalized via `Lib\Validate::isEmail()` (trim + lowercase) so the planned email-verification feature (see `novaconium/ISSUES.md` Backlog) can match case-insensitively — not used for login (username) or any mail yet. `/admin/users` is the management UI (create — always `'registered'` except the first / disable / enable / delete / change group / promote-demote / change email / change password); a disabled **or deleted** user fails login and any existing session dies on its next request (`currentUser()` re-checks the row per request), and disabling, demoting, *or deleting* the last **active admin** is refused — the empty-table setup window doesn't reopen once users exist, so that would be a permanent lockout. Delete is a hard delete (username/email become reusable); disable is the keep-but-shut-out option. `/admin/login` accepts a `?return=` path (how `Lib\Access` sends someone back to the gated page after login), validated to a local path (must start `/`, not `//`, no `\`) so a crafted login link can't bounce a fresh login to another site; with no return path, admins land on `/admin`, registered users on `/`. Login regenerates the session id (`Lib\Session::regenerate()`, added for this) against session fixation; logout is a real page now — the pre-router `/admin/logout` special case in `bootstrap.php` is gone — and it is **POST-only with a GET confirm form** (same shape as `/admin/clear-cache`), not logout-on-GET: the content-index crawl runs every page's sidecar as a GET, so a GET side effect there would end the crawling admin's own session mid-reindex. Passwords are read from `$_POST` directly, not `Lib\Input` (the documented exact-value exception — see `Lib\Input`'s doc-comment, which now points at the login/users sidecars). `Renderer` still exposes the `admin_auth_enabled` Twig global (now mirroring the config flag) so `admin/index.twig` can conditionally show the "Admin users"/"Logout" links — a derived display flag, not the enforcement mechanism itself, which never depends on Twig. `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-merge** — `bootstrap.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 `). 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::isAdmin(bool $enabled): bool` / `::isLoggedIn(bool $enabled): bool` (`novaconium/src/AdminAuth.php`) are the access checks on their own, with no response side effects — `requireLogin()` is `isLoggedIn()` plus a 303 redirect to `/admin/login` on failure, and a different caller can react to failure differently. The draft-page gate in `bootstrap.php` is the first such caller, and it uses `isAdmin()` (drafts are admin-only — a logged-in registered user gets the same 404 as an anonymous visitor): on failure it renders a plain 404 via the same path an unmatched route takes, not a login redirect — bouncing to a login at a draft URL would itself reveal that something is gated there, which defeats the point of hiding it. Both return `true` (open access) when `$enabled` is false or while the `users` table is empty, mirroring `requireLogin()`'s posture, so a draft behaves consistently with the rest of `/admin/*`: wide open until the feature is enabled and a first user exists, gated after that. `Lib\Access` (`novaconium/lib/Access.php`) is the sidecar-level content gate — how a page (or a section, one line per page; a shared `_access.php` in the section directory is the documented pattern, since non-`index.*` files are invisible to the router) is assigned to a user or group: `Access::require('group:members', 'user:bob')` returns `null` (allowed — no rules at all means any logged-in user, and **admins pass every rule**) or a `Response` the sidecar returns as-is (anonymous → 303 to `/admin/login?return=`; logged-in-but-not-allowed → plain-text 404, same hide-don't-tease posture as drafts). See `/admin/docs/access-control`. **Public is the default, and static pages are always public**: a page with no sidecar can't call `Access` — and that's load-bearing, since only sidecar-less pages are written to the static HTML cache; a gated page necessarily has a sidecar, so it can never leak through the cache — the caching/auth standing rule below is satisfied by construction, with no bootstrap exclusion needed. Same open-until-configured / zero-DB-footprint posture as the rest of admin auth when the flag is off or no users exist. Deliberately **no side effects on deny** (the return path travels in the redirect URL, not the session): the content-index crawl runs every sidecar as an anonymous GET, so gated pages drop out of `/search`/`/sitemap.xml` automatically (the crawler discards `Response`s) and a crawl must never scribble on the visiting user's session — the same reasoning that made `/admin/logout` POST-only. `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. **Standing rule: a vendored dependency's files go under `novaconium/vendor/` only if they're server-side (PHP, autoloaded, never fetched by a browser) — anything the browser has to fetch (`.js`, `.css`, images) has to live under `public/vendor/` instead, since `public/` is the only web-reachable directory (`novaconium/` isn't reachable at all — see `public/.htaccess`).** Twig lives under `novaconium/vendor/twig/` correctly, since it's pure PHP source. highlight.js (`/admin/docs/upgrading-highlightjs`, `public/vendor/highlightjs/`) is the first vendored dependency that's actually browser-servable, and originally almost got vendored to `novaconium/vendor/` too, following Twig's precedent blindly — that would have silently 404ed on every request, since nothing under `novaconium/` is ever served to a browser. This has a real consequence beyond just placement: `public/` is project-owned and untouched by the "Updating the framework" workflow (`rm -rf novaconium && cp -r ` — see `/admin/docs/getting-started`), so a future framework release that bumps a `public/vendor/`-placed dependency will **not** carry that upgrade to an existing project automatically the way a `novaconium/vendor/` bump would — re-vendoring it is a separate manual step every time, documented per-dependency (see `/admin/docs/upgrading-highlightjs`). **`class="nohighlight"` marks a `
` block whose content is
literal Twig template syntax** (`{% %}`/`{{ }}`), so highlight.js's
auto-detection (`novaconium/pages/_layout/syntax-highlight.twig`,
restricted to `configure({ languages: ['php', 'bash', 'xml', 'css',
'python', 'javascript', 'yaml', 'json', 'ini'] })` — `yaml`/`json`/`ini`
are vendored as separate per-language files under
`public/vendor/highlightjs/languages/`, not part of the core bundle like
the other six; see `/admin/docs/upgrading-highlightjs`) doesn't
force-match it to whichever configured language scores highest — Twig has
no highlight.js grammar, and a restricted auto-detect still always
returns its best guess among the allowed set, never "give up," so an
unmarked Twig block would get colored *wrong*, not just left plain.
Currently on:
`novaconium/pages/admin/docs/{layouts,content-index,rss,sitemap,forms,seo}/index.twig`
and `App/pages/blog/{style-guide,twig-syntax-guide}/index.twig`. A new
Twig-syntax code sample added anywhere needs the same class — a PHP or
Bash sample doesn't (auto-detection handles those reliably on its own,
via strong signals like a leading `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/