Files
novaconium/AGENTS.md
T
code a0ae58c29e Add Media manager (/admin/media), remove unused images/ scaffolding
Upload/browse/delete UI for files under public/uploads/, covered by the
existing /admin/* auth gate with no separate feature flag needed (no
SQLite dependency to gate). Extension allowlist and max upload size are
configurable; filenames are sanitized and de-duplicated on upload, and
deletes re-verify the resolved path lands inside the upload directory
before touching disk. Docker gains a fourth-turned-third named volume
for public/uploads/ so uploads survive a rebuild.

images/ (reserved scaffolding for a future image feature) is removed —
nothing ever consumed it, and public/uploads/ now covers that use case.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 07:35:45 +00:00

9.5 KiB

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.

Documentation is duplicated on purpose — keep all copies in sync

Every topic (routing, sidecars, libraries, layouts, caching, SEO, Matomo, admin auth, styling, Docker, project layout, third-party) exists in two places: a page under novaconium/pages/admin/docs/<topic>/index.twig (canonical) and a mention in README.md. Any change to framework behavior or a new feature must update both in the same change:

  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 README.md if it affects the feature list, getting-started steps, or the docs index there.
  3. 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 <script>, render into a <template> element and read .innerHTML in JS rather than |escape('js').

class="nohighlight" marks a <pre><code> block containing literal Twig syntax ({% %}/{{ }}) — highlight.js has no Twig grammar and a restricted auto-detect still always guesses wrong without this class. Any new Twig-syntax code sample needs it; PHP/Bash samples don't.

Sass override quirk

novaconium/sass/main.sass does @use 'colors' as * with no _colors.sass sibling in novaconium/sass/ — on purpose. Dart Sass resolves a bare @use relative to the importing file's own directory before --load-path, so a sibling file would always win and silently defeat the App/sass/_colors.sass override. The framework default lives at novaconium/sass/defaults/_colors.sass instead. Don't move it back.

Every color rule in main.sass reads a CSS custom property (var(--bg), etc.), never a Sass variable directly — required for the runtime dark/light toggle. Adding a color means adding both the plain and -light variable in both _colors.sass files and wiring it into both :root blocks.

Input handling

Sidecars read request data via Lib\Input::post()/::get(), not $_POST/$_GET directly (trims, strips tags/null bytes — XSS defense-in-depth, not SQL-injection protection; use PDO prepared statements via Lib\Db::query() for that, never string-interpolated SQL). Exception: fields needing an exact unmodified value (e.g. a password about to be hashed) read $_POST directly — see login/users sidecars. Lib\Csrf::verify() is called directly by a sidecar, not wired into FormValidator.

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). After testing, clear stray cache with php novaconium/bin/clear-cache.php and remove any test-only debris from App/lib//App/pages/ — nothing there is gitignored except public/cache/* and novaconium/contact-log.txt.

Conventions worth knowing

  • Reserved segments: any path segment starting with _ or literally named 404 is never routable — Router::resolve() 404s on sight.
  • Sidecars (index.php) return an array (Twig context) or a Response. $params and $cache are in scope automatically — see novaconium/src/Renderer.php::runSidecar().
  • No Composer — novaconium/autoload.php is a hand-rolled PSR-4 loader. A new framework-core class goes under App\ in novaconium/src/; a new Lib\ class goes in App/lib/ or novaconium/lib/.
  • novaconium/bin/ holds standalone CLI entry points (php novaconium/bin/<script>.php) — distinct from bootstrap.php/autoload.php/config.php, which are only require'd.
  • CSS compiles from novaconium/sass/main.sass (indented syntax) to public/css/main.css: sass --load-path=App/sass --load-path=novaconium/sass/defaults --no-source-map novaconium/sass/main.sass public/css/main.css — commit the regenerated CSS. See /admin/docs/styling for a Docker fallback if sass isn't installed locally.