Files
novaconium/AGENTS.md
T
code c0455241ea Slim down README: features to a blog post, docs pointer, ASCII banner
Features section becomes a "Novaconium Features" blog post
(App/pages/blog/novaconium-features/), demonstrating the framework's own
content model rather than living as a long bullet list in README.md.
Getting started is trimmed to the minimum needed to run the site and
reach /admin/docs, which is now the single canonical source for every
topic (Docker, deploying, project layout, etc.) instead of being
mirrored into README.md. Third-party section kept as-is. AGENTS.md's
documentation-duplication rule updated to describe this new split so
future changes don't re-bloat the README. Also adds an ASCII art banner
above the title.

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

10 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.

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/<topic>/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 <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.