# AGENTS.md Context for any coding agent working in this repo — Claude, DeepSeek, or otherwise. Full narrative docs live at `/admin/docs` when the app is running. `README.md` is the GitHub-facing pitch, `novaconium/ISSUES.md` is the roadmap/backlog, and this file is the short, agent-facing version: load-bearing gotchas and conventions only, not narrative history. **This repo has a graphify knowledge graph (`graphify-out/`).** For design rationale, "why was it built this way," or exploring how components relate, query the graph instead of expecting this file to carry that context — this file is kept intentionally short and only lists things that will cause a bug or a broken convention if you don't know them going in. ## Docs live in one place: `/admin/docs` — README stays thin Every topic (routing, sidecars, libraries, layouts, caching, SEO, Matomo, admin auth, styling, Docker, project layout, third-party) has exactly one canonical writeup: a page under `novaconium/pages/admin/docs//index.twig`. `README.md` deliberately does **not** mirror this content — it's a short GitHub-facing pitch (what this is, minimal steps to get it running, a pointer into `/admin/docs`) plus the Third-party section, nothing more. The full feature list lives as a blog post, `App/pages/blog/novaconium-features/` (sample content, replaceable like any other post), not in the README. This was a deliberate change (2026-07-15) away from an earlier "keep README and docs in sync" convention that had made the README long and hard to scan — don't re-add a feature list or per-topic bullet list to README.md. Any change to framework behavior or a new feature: 1. Update/add the docs page, and if new, link it from both `admin/docs/index.twig` and the nav in `admin/docs/_layout/layout.twig`. 2. Update `App/pages/blog/novaconium-features/index.twig` (and its entry in `App/pages/blog/index.php`) if it affects the feature tour. 3. Update `README.md` only if it affects the one-paragraph pitch, the minimal getting-started steps, or the Third-party section — not a per-feature bullet. 4. Update this file only if it affects a convention an agent needs to know before editing code. ## 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 - **`App/`** — the project: `App/pages/`, `App/lib/` (`Lib\` classes), `App/config.php`, `App/migrations/`, `App/sass/`. The only directory a site author is expected to touch. - **`novaconium/`** — the framework: router/renderer core (`novaconium/src/`), default pages/libs, vendored Twig, autoloader, config, bootstrap. Routing/rendering resolve against **both roots, `App/` first** (via `novaconium/src/Overlay.php` for pages, `novaconium/autoload.php` for `Lib\` classes) — same override-by-presence mechanism used for `config.php`, Twig's `FilesystemLoader`, and Sass (see below). A project only lists the config keys it's changing in `App/config.php`; never edit `novaconium/config.php` directly. **`db_connections` is the one config key that isn't a plain shallow-merge.** `Lib\Db::config()` (and the duplicate in `bin/migrate.php`) merges it one level deeper, by connection name, so adding a second connection in `App/config.php` can't silently delete the framework's `default` connection. Capture the defaults *before* the top-level `array_merge()` overwrites `$config['db_connections']`, not after. See `/admin/docs/database`. `Lib\Db` supports multiple, simultaneously-open named connections (`'sqlite'`/`'mysql'` drivers only). Each connection migrates lazily on first use, tracked by path **relative to the repo root** (not bare filename — two roots can share a filename). `migrations_dir` accepts an ordered list of roots, each fully processed before the next. **The default DB path (`data/novaconium.sqlite`) lives outside `public/` (web-accessible) and `novaconium/`** (wholesale-replaced by framework updates) — it's a project-owned top-level dir, gitignored per-content with a tracked `.gitkeep`. Uploaded files (see Media manager, `/admin/docs/media-manager`) live under `public/uploads/` instead, since they need to be web-reachable directly — a separate, plain static directory on its own volume, not coupled to the SQLite path, since a project may run MySQL or no DB at all. ## Standing rule: caching vs. any content-hiding mechanism **Any mechanism that conditionally hides page content from the public must be threaded into `Renderer::render()`'s `$excludeFromCache` param, not just a pre-render auth gate.** `Renderer::render()` writes a sidecar-less page's output to the static HTML cache, and `.htaccess` serves a cached file *before PHP (and therefore any auth check) ever runs again*. A route gated only at the auth-check level still leaks to the public the moment an authorized user views it once, if the page has no sidecar. `draft_routes` and every `/admin/*` route already pass `true` for this reason. Any new feature that gates a route by anything other than a sidecar check needs the same treatment — this has caused a real bug before, twice. Corollary: `Lib\Access` (the sidecar-level content gate, see `/admin/docs/access-control`) is safe by construction — a page with no sidecar can't call `Access`, and only sidecar-less pages get cached, so a gated page can never leak through the cache with no extra wiring needed. ## Reentrancy hazard: ContentIndexer `ContentIndexer::reindex()` renders every routable page, including `/search` itself, which also calls `ContentIndexer::ensureFresh()`. Guarded by a `private static bool $indexing` flag checked at the top of both methods — don't remove it, any new consumer route inherits the same hazard automatically. `reindex()` also forces `$_SERVER['REQUEST_METHOD']` to `'GET'` for the duration of the crawl (restored in a `finally`) so a lazy reindex triggered from a POST can't leak that POST into an unrelated page's sidecar. ## Vendored dependency placement **Server-side-only (PHP, autoloaded) → `novaconium/vendor/`. Anything a browser fetches (`.js`, `.css`, images) → `public/vendor/`** — `novaconium/` is never web-reachable. This matters beyond correctness: `public/` is project-owned and untouched by a framework update, so a `public/vendor/` dependency bump does **not** propagate automatically the way a `novaconium/vendor/` bump would — re-vendoring is a manual step per dependency (see `/admin/docs/upgrading-highlightjs`). ## Twig gotchas that will fatal without `mbstring` Don't use `|slice` on a **string** (calls `mb_substr()` unconditionally) or `|escape('js')`/`'js'` arg to `|e` (calls `mb_ord()`) — both hard-require `mbstring` and fatal without it; this project deliberately avoids that dependency. Truncate strings in PHP with an `mb_substr`/`substr` fallback instead. For markup destined for inline `