# 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_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`, filename-ordered, tracked in that connection's own auto-created `schema_migrations` table (each connection's applied migrations are independent of any other connection's), run once each. `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. Only `App/migrations/` (the framework default connection's `migrations_dir`) is scanned by default — the framework ships no core tables of its own yet — if a future framework feature needs a shipped migration, extend this to the same App-over-novaconium two-root scan `Overlay.php` already does for pages/lib, don't invent a second mechanism. `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. ## 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/