Redesigns Lib\Db from a single global connection to a config-driven map
of named connections (config['db_connections']), each independently
lazy-connected, each with its own optional migrations_dir and its own
schema_migrations table. A sidecar can use more than one connection in
the same request (e.g. Db::query(...) against SQLite alongside
Db::query(..., 'legacy') against MySQL) — sidecars have full access to
any Lib\ class, so nothing stops a request from needing two databases
at once, which the original single-driver spec didn't account for.
Db::query()/Db::connection() both default to the 'default' connection
name so the common single-database case is unchanged at the call site.
Supersedes the flat db_driver/db_path/db_migrations_dir keys shipped in
the SQLite groundwork commit (a3b9967) — no downstream consumers yet,
so no migration path needed.
db_connections needed one deliberate exception to the project's usual
shallow config-merge rule: merged one level deeper, by connection name,
so App/config.php adding a connection doesn't delete 'default'. Verified
end-to-end against a real local MariaDB instance running alongside the
existing SQLite connection, which caught a real ordering bug in the
initial merge implementation (capturing defaults after they'd already
been overwritten) before it shipped.
Closes the "MySQL support" backlog item in novaconium/ISSUES.md.
19 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:
- Update (or add) the matching page under
novaconium/pages/admin/docs/<topic>/index.twig, and if it's a new topic, link it from bothadmin/docs/index.twigand the nav inadmin/docs/_layout/layout.twig. - Update
README.mdif the change affects the feature list, getting started steps, or the docs index there. - 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) andApp/lib/(project's ownLib\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/admintools), defaultLib\classes (novaconium/lib/), vendored Twig, autoloader, config, bootstrap.
Routing and rendering resolve against both roots, in order —
App/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-merge — bootstrap.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.
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 named404is 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 aResponse(redirect/json/xml/html —novaconium/src/Response.php).$params(route captures) and$cache(theCacheinstance, e.g. for$cache->clear()) are both in scope automatically — seenovaconium/src/Renderer.php::runSidecar(). - No Composer —
novaconium/autoload.phpis a hand-rolled PSR-4 loader. Adding a new framework-core class means adding it underApp\innovaconium/src/; a newLib\class goes inApp/lib/ornovaconium/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 frombootstrap.php/autoload.php/config.php, which are only everrequire'd, never invoked directly.clear-cache.phpandcreate-static-page.php(scaffolds a new page from the/admin/docs/seostarter template) both live there; a new CLI tool goes there too.- CSS is compiled from
novaconium/sass/main.sass(indented syntax) topublic/css/main.css.dart-sassis 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.cssand commit the regeneratedpublic/css/main.css(--no-source-mapavoids a straymain.css.mapthe project doesn't otherwise use). Ifsassisn't available in whatever environment you're in, either run it via Docker —/admin/docs/stylinghas 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 thedocker build/docker runcommands 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 beforesasswas 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.sassdoes@use 'colors' as *, and its own directory (novaconium/sass/) deliberately has no_colors.sasssibling. Dart Sass resolves a bare@userelative to the importing file's own directory before consulting--load-pathentries, so ifnovaconium/sass/_colors.sassexisted next tomain.sass, it would always win regardless of load-path order — silently defeating the override. Keeping the framework default atnovaconium/sass/defaults/_colors.sass(a different directory) forces resolution through the load path, whereApp/sass(checked first) can actually override it withApp/sass/_colors.sass. Don't movedefaults/_colors.sassback next tomain.sass— it was moved out on purpose, and doing so reintroduces this bug. - Every color rule in
main.sassreads 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.sassfiles 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 innovaconium/pages/_layout/nav.twigflips thedata-themeattribute on<html>at runtime and persists it tolocalStorage.novaconium/pages/_layout/theme-init.twigre-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-lightvariable in both_colors.sassfiles and wire it into both:rootblocks inmain.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/$_GETdirectly — 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 ansqlSafe()-style method toInput. One documented exception: a field needing an exact, unmodified value (e.g. a password about to be hashed) should read$_POSTdirectly instead — seenovaconium/pages/admin/password-hash/index.php.Lib\Csrf(novaconium/lib/Csrf.php) is standalone session-token CSRF protection, not wired intoFormValidator— a sidecar callsCsrf::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 toAdminAuth's own session-free Basic Auth. - Don't use Twig's
|slicefilter on a string (as opposed to an array) — it unconditionally calls PHP'smb_substr()with no fallback (novaconium/vendor/twig/src/Extension/CoreExtension.php), which hard-requires thembstringextension 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-worldhad a sidecar computing an excerpt this way (see the footnote onApp/pages/blog/twig-syntax-guide/index.twigfor the full story). Truncate strings in PHP instead, guarded withfunction_exists('mb_substr')falling back tosubstr(), 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 callsTwig\Runtime\mb_ord()(novaconium/vendor/twig/src/Extension/EscaperExtension.php), which hard-requiresmbstringthe same way|slicedoes, and fatals identically without it. Hit for real whennovaconium/pages/_layout/code-copy.twigused 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.innerHTMLgetter instead. Prefer that pattern — or adata-*attribute if the value is plain text, not markup — over|escape('js')any time a Twig value needs to reach JS.