# 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 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. `Db::query(string $sql, array $params = [])` (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. Connection is lazy (opened on first `Db::query()`/`Db::connection()` call, same shape as `Lib\Csrf`'s lazy session start), and migrates automatically at that point: plain `.sql` files under `App/migrations/` (`config['db_migrations_dir']`), filename-ordered, tracked in an auto-created `schema_migrations` table, run once each. `novaconium/bin/migrate.php` runs the same migration step explicitly (e.g. from a deploy script) without serving a request first. **`config['db_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/` is scanned for now (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. ## 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/