Files
novaconium/AGENTS.md
T
code fbd4e92e9f Replace v1 with v2 codebase
Full rewrite: swap out the v1 framework (src/, controllers/, views/,
twig/, sass/, skeleton/) for the working v2 codebase from phpproject
(App/, novaconium/, public/).
2026-07-14 01:58:25 +00:00

14 KiB

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

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/<script>.php) — distinct from bootstrap.php/autoload.php/config.php, which are only ever require'd, never invoked directly. clear-cache.php and create-static-page.php (scaffolds a new page from the /admin/docs/seo starter template) both live there; a new CLI tool goes there too.
  • CSS is compiled from novaconium/sass/main.sass (indented syntax) to public/css/main.css. dart-sass is installed in this environment (Arch: pacman -S dart-sass) — after editing Sass source, run: sass --load-path=App/sass --load-path=novaconium/sass/defaults --no-source-map novaconium/sass/main.sass public/css/main.css and commit the regenerated public/css/main.css (--no-source-map avoids a stray main.css.map the project doesn't otherwise use). If sass isn't available in whatever environment you're in, either run it via Docker — /admin/docs/styling has a copy-pasteable Dockerfile that installs the same standalone Dart Sass release used in this environment (1.101.0) directly from GitHub, not via npm, plus the docker build/docker run commands adjusted to this repo's paths — or hand-edit both files in parallel and keep them in sync — that's how the dark/teal theme and the homepage hero/animation styling were originally written before sass was installed here.
  • The Sass color palette follows the same App-over-novaconium override pattern as pages/lib, but with a twist worth understanding before touching it: novaconium/sass/main.sass does @use 'colors' as *, and its own directory (novaconium/sass/) deliberately has no _colors.sass sibling. Dart Sass resolves a bare @use relative to the importing file's own directory before consulting --load-path entries, so if novaconium/sass/_colors.sass existed next to main.sass, it would always win regardless of load-path order — silently defeating the override. Keeping the framework default at novaconium/sass/defaults/_colors.sass (a different directory) forces resolution through the load path, where App/sass (checked first) can actually override it with App/sass/_colors.sass. Don't move defaults/_colors.sass back next to main.sass — it was moved out on purpose, and doing so reintroduces this bug.
  • Every color rule in main.sass reads a CSS custom property (var(--bg), var(--accent), etc.), never a Sass variable directly — that indirection is what makes the dark/light theme toggle possible, since Sass only runs at compile time and can't react to a runtime choice on its own. The two _colors.sass files seed :root (dark, the default) and :root[data-theme="light"] (via -light-suffixed variables — $bg-light, $accent-light, etc., same files, same override mechanism) once at compile time; the toggle button in novaconium/pages/_layout/nav.twig flips the data-theme attribute on <html> at runtime and persists it to localStorage. novaconium/pages/_layout/theme-init.twig re-applies a saved choice early in <head> (before the stylesheet link) to avoid a flash of the wrong theme on load. If you add a new color to the palette, add both the plain and -light variable in both _colors.sass files and wire it into both :root blocks in main.sass — a color that's only themed in one direction will look wrong after a toggle.
  • Sidecars should read request data via Lib\Input::post()/::get() (novaconium/lib/Input.php) rather than $_POST/$_GET directly — it trims, strips tags, and strips null bytes automatically. This is defense-in-depth against HTML/script injection, not SQL-injection protection (no string transform makes input safe to concatenate into a query — use PDO prepared statements once a database layer exists); don't add an sqlSafe()-style method to Input. One documented exception: a field needing an exact, unmodified value (e.g. a password about to be hashed) should read $_POST directly instead — see novaconium/pages/admin/password-hash/index.php. Lib\Csrf (novaconium/lib/Csrf.php) is standalone session-token CSRF protection, not wired into FormValidator — a sidecar calls Csrf::verify() directly. It's the first thing in the framework to start a native PHP session (only lazily, when a form actually calls it), which is otherwise unrelated to AdminAuth's own session-free Basic Auth.
  • Don't use Twig's |slice filter on a string (as opposed to an array) — it unconditionally calls PHP's mb_substr() with no fallback (novaconium/vendor/twig/src/Extension/CoreExtension.php), which hard-requires the mbstring extension and will fatal (Call to undefined function Twig\Extension\mb_substr()) on a PHP install without it — a real regression this project hit once already, back when /blog/hello-world had a sidecar computing an excerpt this way (see the footnote on App/pages/blog/twig-syntax-guide/index.twig for the full story). Truncate strings in PHP instead, guarded with function_exists('mb_substr') falling back to substr(), and pass the already-truncated value into the template.