Upload/browse/delete UI for files under public/uploads/, covered by the existing /admin/* auth gate with no separate feature flag needed (no SQLite dependency to gate). Extension allowlist and max upload size are configurable; filenames are sanitized and de-duplicated on upload, and deletes re-verify the resolved path lands inside the upload directory before touching disk. Docker gains a fourth-turned-third named volume for public/uploads/ so uploads survive a rebuild. images/ (reserved scaffolding for a future image feature) is removed — nothing ever consumed it, and public/uploads/ now covers that use case. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
9.5 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.
Documentation is duplicated on purpose — keep all copies in sync
Every topic (routing, sidecars, libraries, layouts, caching, SEO, Matomo,
admin auth, styling, Docker, project layout, third-party) exists in two
places: a page under novaconium/pages/admin/docs/<topic>/index.twig
(canonical) and a mention in README.md. Any change to framework behavior
or a new feature must update both in the same change:
- Update/add the docs page, and if new, link it from both
admin/docs/index.twigand the nav inadmin/docs/_layout/layout.twig. - Update
README.mdif it affects the feature list, getting-started steps, or the docs index there. - 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/,App/sass/. The only directory a site author is expected to touch.novaconium/— the framework: router/renderer core (novaconium/src/), default pages/libs, 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, Twig's FilesystemLoader, and Sass (see below). 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.
Sass override quirk
novaconium/sass/main.sass does @use 'colors' as * with no
_colors.sass sibling in novaconium/sass/ — on purpose. Dart Sass
resolves a bare @use relative to the importing file's own directory
before --load-path, so a sibling file would always win and silently
defeat the App/sass/_colors.sass override. The framework default lives
at novaconium/sass/defaults/_colors.sass instead. Don't move it back.
Every color rule in main.sass reads a CSS custom property (var(--bg),
etc.), never a Sass variable directly — required for the runtime dark/light
toggle. Adding a color means adding both the plain and -light variable in
both _colors.sass files and wiring it into both :root blocks.
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
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). 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 named404is never routable —Router::resolve()404s on sight. - Sidecars (
index.php) return an array (Twig context) or aResponse.$paramsand$cacheare in scope automatically — seenovaconium/src/Renderer.php::runSidecar(). - No Composer —
novaconium/autoload.phpis a hand-rolled PSR-4 loader. A new framework-core class goes underApp\innovaconium/src/; a newLib\class goes inApp/lib/ornovaconium/lib/. novaconium/bin/holds standalone CLI entry points (php novaconium/bin/<script>.php) — distinct frombootstrap.php/autoload.php/config.php, which are onlyrequire'd.- CSS compiles from
novaconium/sass/main.sass(indented syntax) topublic/css/main.css:sass --load-path=App/sass --load-path=novaconium/sass/defaults --no-source-map novaconium/sass/main.sass public/css/main.css— commit the regenerated CSS. See/admin/docs/stylingfor a Docker fallback ifsassisn't installed locally.