Files
novaconium/AGENTS.md
T
code 5deb298b91 Add Lib\Session: native session wrapper with flash data
Lib\Session (novaconium/lib/Session.php) is a thin, all-static wrapper
around PHP's native session handling — get/set/has/remove plus
CodeIgniter-style flash data (flash()/getFlash()): a value set now is
readable on exactly the next request, then gone, for post/redirect/GET
flows like the contact form's hand-rolled ?sent=1 (not refactored here —
the original spec cites it as a motivating example, not a mandate).

Lazy-start, same shape as the already-shipped Lib\Csrf, which the two
classes can share a native session with in the same request without
conflict. Flash data is a single per-request swap (snapshot last
request's bucket, clear the stored one) rather than a sweep/expiry pass.

Verified end-to-end across three separate HTTP requests sharing a cookie
jar (not just in-process calls), confirming a flashed value survives
exactly one subsequent request.

Closes the "Session handling (with flash sessions)" backlog item in
novaconium/ISSUES.md.
2026-07-14 06:43:11 +00:00

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

Lib\Db (novaconium/lib/Db.php) is the SQLite/MySQL groundwork tracked in novaconium/ISSUES.md — a thin, no-ORM PDO wrapper, Lib\ (not App\) so a project can override it via App/lib/Db.php like any other Lib\ class. It supports multiple, independently-configured, simultaneously open named connections (config['db_connections'], keyed by name) rather than one global connection — because sidecars are plain PHP with full access to any Lib\ class, a single request can legitimately need more than one database at once (e.g. this site's own SQLite data alongside a MySQL connection to a legacy database). Db::query(string $sql, array $params = [], string $connection = 'default') (prepare+execute) is the only query-running helper — never add a string-interpolation shortcut; see Lib\Input's doc-comment, which already commits this project to parameterized queries as the sole SQL-injection defense. Each connection is lazy and independent (opened on first Db::query()/Db::connection() call naming it, same shape as Lib\Csrf's lazy session start), and migrates automatically at that point: plain .sql files under that connection's own migrations_dir, filename-ordered, tracked in that connection's own auto-created schema_migrations table (each connection's applied migrations are independent of any other connection's), run once each. novaconium/bin/migrate.php loops every configured connection and runs the same migration step explicitly (e.g. from a deploy script) without serving a request first. Only 'sqlite' and 'mysql' drivers are implemented; a connection's migrations_dir is optional — omit it to never run migrations against that connection (e.g. a legacy database this project shouldn't manage schema for).

db_connections is the one config key in the project that isn't plain shallow-mergebootstrap.php/bin/*.php's usual array_merge($config, $appConfig) would let a project's App/config.php silently delete the framework's default connection just by adding a second named connection (a shallow merge replaces the whole key, it doesn't merge inside it). So Lib\Db::config() (and the copy of this logic duplicated in bin/migrate.php, same duplication precedent as the two-step config load already duplicated across bootstrap.php/bin/clear-cache.php) merges db_connections one level deeper, by connection name, after capturing the framework defaults — capture the defaults before the top-level array_merge() overwrites $config['db_connections'], not after, or the deeper merge silently operates on the already-overwritten value and the default connection vanishes anyway. (This exact bug was hit once while building this feature — verified by testing a real App/config.php override end-to-end, not just reading the code — so it's worth re-checking by hand if this logic is ever touched again.) See /admin/docs/database for the worked example.

config['db_connections']['default']['path'] must stay outside both public/ (would be web-accessible) and novaconium/ — unlike cache_dir/contact-log.txt, which are disposable, a SQLite file is data a project can't afford to lose, and novaconium/ gets wholesale-replaced by the "Updating the framework" workflow (/admin/docs/getting-started: rm -rf novaconium && cp -r <new-novaconium>). The default (data/novaconium.sqlite) lives in a new top-level data/ directory instead — project-owned like App/, gitignored per-file (*.sqlite/-journal/-wal/-shm, with a tracked .gitkeep so the directory exists in a fresh clone) rather than wholesale like public/cache/, since a project might reasonably want other non-DB files there later. Only App/migrations/ (the framework default connection's migrations_dir) is scanned by default — the framework ships no core tables of its own yet — if a future framework feature needs a shipped migration, extend this to the same App-over-novaconium two-root scan Overlay.php already does for pages/lib, don't invent a second mechanism.

Lib\Session (novaconium/lib/Session.php) is a thin wrapper around native PHP sessions (session_start()/$_SESSION, not a custom store), all-static and lazy-start like Lib\Csrf — nothing calls session_start() until the first real call to a Session method. Its ensureSession() is a deliberate duplicate of Csrf::ensureSession() (same cookie params, same session_status() guard) rather than a shared helper — keeps Csrf standalone with zero new dependencies on a class that didn't exist when it shipped, same tolerance for small duplication already established by the config-load block duplicated across bootstrap.php/bin/clear-cache.php/ Lib\Db::config(). Both classes touching the same native session in the same request is safe either way, since session_start() silently no-ops if a session is already active — there's no ordering requirement between Csrf::token()/::verify() and any Session method.

Flash data (Session::flash()/::getFlash()) is one swap, not a sweep/expiry pass: the first Session method call in a request snapshots whatever was flashed on the previous request into an in-memory static (self::$currentFlash) for that request's getFlash() reads, then immediately empties the stored flash bucket so flash() calls made during the current request start filling a fresh bucket for the request after this one. This relies on static properties not persisting across requests (true under php -S, mod_php, and PHP-FPM alike — each request gets fresh PHP state regardless of worker-process reuse) — don't add any caching/memoization to Session that assumes static state survives between requests, since none of it does. See /admin/docs/session for a worked flash example.

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.
  • Same class of bug, different filter: don't use Twig's |escape('js') (or the 'js' arg to |e) either — it calls Twig\Runtime\mb_ord() (novaconium/vendor/twig/src/Extension/EscaperExtension.php), which hard-requires mbstring the same way |slice does, and fatals identically without it. Hit for real when novaconium/pages/_layout/code-copy.twig used it to pass SVG icon markup into an inline <script> as a JS string literal. Fixed by not needing string-escaped markup in JS at all: render the markup as plain HTML into a <template> element (default autoescaping, no mbstring dependency) and read it in JS via that template element's .innerHTML getter instead. Prefer that pattern — or a data-* attribute if the value is plain text, not markup — over |escape('js') any time a Twig value needs to reach JS.