Lib\Db (novaconium/lib/Db.php) is a thin, no-ORM PDO wrapper — lazy-connect like Lib\Csrf, Db::query() as the only query-running helper (prepared statements only, no interpolation shortcut, per Lib\Input's existing security stance). Plain .sql migrations under App/migrations/, applied in filename order and tracked in an auto-created schema_migrations table, run automatically on first connection or via novaconium/bin/migrate.php. Data lives in a new top-level data/ directory rather than novaconium/ or public/ — outside public/ so it's never web-accessible, and outside novaconium/ since that directory gets wholesale-replaced by the "Updating the framework" workflow, which would otherwise destroy it on every update. New config keys: db_driver (only 'sqlite' implemented), db_path, db_migrations_dir. Documented at /admin/docs/database and in AGENTS.md. Closes the "SQLite groundwork" backlog item in novaconium/ISSUES.md.
17 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 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.
Db::query(string $sql, array $params = []) (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. Connection is
lazy (opened on first Db::query()/Db::connection() call, same shape as
Lib\Csrf's lazy session start), and migrates automatically at that point:
plain .sql files under App/migrations/ (config['db_migrations_dir']),
filename-ordered, tracked in an auto-created schema_migrations table, run
once each. novaconium/bin/migrate.php runs the same migration step
explicitly (e.g. from a deploy script) without serving a request first.
config['db_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/ is scanned for now (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.