Files
novaconium/AGENTS.md
T
codeandClaude Sonnet 5 f2724972af Add typed text pages; serve CSS as compiled-on-demand /css/main.css
Sidecar-less page bundles whose directory ends in a MediaType extension
(.txt/.css/.xml/.json/.js) now render raw (no HTML layout, autoescape off),
are served with that extension's Content-Type, and cache as a flat file
(public/cache/<path>) so Apache serves them directly via a new .htaccess
rule. They're excluded from the content index.

CSS becomes one of these: novaconium/pages/css/main.css/ holds index.twig
({{ sass('main.sass') }}), main.sass, and _colors.sass. App\SassExtension's
sass() Twig function resolves the entry file through the overlay and shells
out to Dart Sass on a cache miss; Lib\SassCompiler wraps the CLI. This
drops the committed App/css/main.css build artifact and the public/css
bind mount. Override styling by copying the whole bundle to App/pages/.

Also ship robots.txt and humans.txt as framework bundles, and drop the
php -S dev router (public/router.php) - Docker/Apache only now.

New: novaconium/src/MediaType.php, SassExtension.php, lib/SassCompiler.php.
Moved: novaconium/sass/ -> novaconium/pages/css/main.css/ (+ App/sass merged).
Docs: AGENTS.md, new /admin/docs/text-pages, and styling/caching/docker/
design-notes/getting-started/project-layout/config docs updated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Q9cAXC9xcXYnmP7qjsnw7
2026-09-10 20:58:37 +00:00

12 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/. The only directory a site author is expected to touch.
  • novaconium/ — the framework: router/renderer core (novaconium/src/), default pages/libs (including the css/main.css/, robots.txt/, humans.txt/ bundles), 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 and Twig's FilesystemLoader. 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.

Typed text pages (CSS, robots.txt, humans.txt, …)

A sidecar-less page bundle whose directory segment ends in an extension in App\MediaType::MAP (.txt .css .xml .json .js) is a typed text page: Renderer renders its index.twig with no HTML layout and autoescaping off, serves it with that extension's Content-Type, and Cache writes it as a flat file (public/cache/css/main.css, not .../index.html). A rewrite in public/.htaccess (rule 2b) then serves that file straight from Apache on every later request — so the extension list is duplicated there; keep it in sync with MediaType::MAP. These pages are excluded from the content index (ContentIndexer::isCrawlable()). See /admin/docs/text-pages.

The framework ships three: novaconium/pages/css/main.css/ (route /css/main.css), novaconium/pages/robots.txt/, novaconium/pages/humans.txt/. A project overrides one the normal App-over-novaconium way — put its own bundle at the same relative path under App/pages/.

CSS is the interesting one: novaconium/pages/css/main.css/ holds index.twig ({{- sass('main.sass') -}}), main.sass, and _colors.sass. The sass() Twig function (App\SassExtensionLib\SassCompiler) resolves the entry file through the overlay (App first) and shells out to Dart Sass with that file's own directory as the sole --load-path, so @use 'colors' resolves to the sibling _colors.sass in whichever copy of the bundle won. To customise styling, copy the whole css/main.css/ bundle to App/pages/css/main.css/ — there's no partial "just _colors.sass" override. Output is cached like any typed page; run php novaconium/bin/clear-cache.php after editing Sass. Every color rule reads a CSS custom property (var(--bg) …), never a Sass variable directly — required for the runtime dark/light toggle; adding a color means adding its plain and -light variable in _colors.sass and wiring both into the two :root blocks in main.sass.

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

Only via the Docker image built from Dockerfile (Apache reads public/.htaccess directly — there is no php -S dev router). Typically:

docker compose up   # image + volumes from docker-compose.yml

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 is a typed text page (see that section above), not a build artifact: the Sass lives in novaconium/pages/css/main.css/ (override at App/pages/css/main.css/) and is compiled on demand by the sass() Twig function, cached to public/cache/css/main.css. There is nothing to pre-build or commit. The Docker image ships Dart Sass standalone (pinned DART_SASS_VERSION in the Dockerfile, symlinked to /usr/local/bin/sass) as a runtime dependency. See /admin/docs/styling and /admin/docs/text-pages.