Sidecar-less page bundles whose directory ends in a MediaType extension
(.txt/.css/.xml/.json/.js) now render raw (no HTML layout, autoescape off),
are served with that extension's Content-Type, and cache as a flat file
(public/cache/<path>) so Apache serves them directly via a new .htaccess
rule. They're excluded from the content index.
CSS becomes one of these: novaconium/pages/css/main.css/ holds index.twig
({{ sass('main.sass') }}), main.sass, and _colors.sass. App\SassExtension's
sass() Twig function resolves the entry file through the overlay and shells
out to Dart Sass on a cache miss; Lib\SassCompiler wraps the CLI. This
drops the committed App/css/main.css build artifact and the public/css
bind mount. Override styling by copying the whole bundle to App/pages/.
Also ship robots.txt and humans.txt as framework bundles, and drop the
php -S dev router (public/router.php) - Docker/Apache only now.
New: novaconium/src/MediaType.php, SassExtension.php, lib/SassCompiler.php.
Moved: novaconium/sass/ -> novaconium/pages/css/main.css/ (+ App/sass merged).
Docs: AGENTS.md, new /admin/docs/text-pages, and styling/caching/docker/
design-notes/getting-started/project-layout/config docs updated.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Q9cAXC9xcXYnmP7qjsnw7
12 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.
Docs live in one place: /admin/docs — README stays thin
Every topic (routing, sidecars, libraries, layouts, caching, SEO, Matomo,
admin auth, styling, Docker, project layout, third-party) has exactly one
canonical writeup: a page under novaconium/pages/admin/docs/<topic>/index.twig.
README.md deliberately does not mirror this content — it's a short
GitHub-facing pitch (what this is, minimal steps to get it running, a
pointer into /admin/docs) plus the Third-party section, nothing more. The
full feature list lives as a blog post, App/pages/blog/novaconium-features/
(sample content, replaceable like any other post), not in the README. This
was a deliberate change (2026-07-15) away from an earlier "keep README and
docs in sync" convention that had made the README long and hard to scan —
don't re-add a feature list or per-topic bullet list to README.md.
Any change to framework behavior or a new feature:
- Update/add the docs page, and if new, link it from both
admin/docs/index.twigand the nav inadmin/docs/_layout/layout.twig. - Update
App/pages/blog/novaconium-features/index.twig(and its entry inApp/pages/blog/index.php) if it affects the feature tour. - Update
README.mdonly if it affects the one-paragraph pitch, the minimal getting-started steps, or the Third-party section — not a per-feature bullet. - 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/. The only directory a site author is expected to touch.novaconium/— the framework: router/renderer core (novaconium/src/), default pages/libs (including thecss/main.css/,robots.txt/,humans.txt/bundles), 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 and Twig's FilesystemLoader. 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.
Typed text pages (CSS, robots.txt, humans.txt, …)
A sidecar-less page bundle whose directory segment ends in an extension
in App\MediaType::MAP (.txt .css .xml .json .js) is a typed
text page: Renderer renders its index.twig with no HTML layout and
autoescaping off, serves it with that extension's Content-Type, and
Cache writes it as a flat file (public/cache/css/main.css, not
.../index.html). A rewrite in public/.htaccess (rule 2b) then serves
that file straight from Apache on every later request — so the extension
list is duplicated there; keep it in sync with MediaType::MAP. These
pages are excluded from the content index (ContentIndexer::isCrawlable()).
See /admin/docs/text-pages.
The framework ships three: novaconium/pages/css/main.css/ (route
/css/main.css), novaconium/pages/robots.txt/, novaconium/pages/humans.txt/.
A project overrides one the normal App-over-novaconium way — put its own
bundle at the same relative path under App/pages/.
CSS is the interesting one: novaconium/pages/css/main.css/ holds
index.twig ({{- sass('main.sass') -}}), main.sass, and _colors.sass.
The sass() Twig function (App\SassExtension → Lib\SassCompiler)
resolves the entry file through the overlay (App first) and shells out to
Dart Sass with that file's own directory as the sole --load-path, so
@use 'colors' resolves to the sibling _colors.sass in whichever copy of
the bundle won. To customise styling, copy the whole css/main.css/
bundle to App/pages/css/main.css/ — there's no partial "just
_colors.sass" override.
Output is cached like any typed page; run
php novaconium/bin/clear-cache.php after editing Sass. Every color rule
reads a CSS custom property (var(--bg) …), never a Sass variable directly
— required for the runtime dark/light toggle; adding a color means adding
its plain and -light variable in _colors.sass and wiring both into the
two :root blocks in main.sass.
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
Only via the Docker image built from Dockerfile (Apache reads
public/.htaccess directly — there is no php -S dev router). Typically:
docker compose up # image + volumes from docker-compose.yml
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 is a typed text page (see that section above), not a build
artifact: the Sass lives in
novaconium/pages/css/main.css/(override atApp/pages/css/main.css/) and is compiled on demand by thesass()Twig function, cached topublic/cache/css/main.css. There is nothing to pre-build or commit. The Docker image ships Dart Sass standalone (pinnedDART_SASS_VERSIONin theDockerfile, symlinked to/usr/local/bin/sass) as a runtime dependency. See/admin/docs/stylingand/admin/docs/text-pages.