Colors <pre><code> blocks site-wide via vendored highlight.js v11.11.1
(pinned to that stable tag, not main, which tracks an in-progress
11.0.0-beta1), auto-detected and restricted to
configure({ languages: ['php', 'bash', 'xml', 'css', 'python',
'javascript', 'yaml', 'json', 'ini'] }) - no per-block markup needed for
the ~60 existing code blocks across the site. css/python/javascript ship
in the core bundle; yaml/json/ini (ini covers .env-style files too)
don't and are vendored as separate per-language files.
Themes swap with the existing dark/light toggle: ir-black (dark) +
github (light, resolving the entry's own open question), via the same
data-theme-driven mechanism as the main palette
(syntax-highlight-init.twig/syntax-highlight.twig, mirroring
theme-init.twig/nav.twig's split) - a MutationObserver swaps the theme
link live without touching the existing toggle button's click handler.
Twig-syntax code blocks have no highlight.js grammar and are marked
class="nohighlight" by hand (15 blocks across 9 files, found by grepping
for literal {% %}/{{ }} syntax rather than guessing) rather than
force-matched into the restricted candidate set, which would color them
wrong instead of leaving them plain.
One correction to the original backlog entry's suggested approach: it
suggested vendoring highlight.js under novaconium/vendor/ next to Twig.
That would have silently 404ed on every request - Twig is server-side
PHP, never fetched by a browser, but highlight.js's .js/.css files are,
and only public/ is web-reachable. Vendored to public/vendor/highlightjs/
instead; documented in AGENTS.md and a new upgrading-highlightjs doc,
since public/ isn't touched by the usual novaconium/-swap framework
update workflow, so a future highlight.js bump won't propagate to
existing projects automatically the way it does for everything else
under novaconium/.
Caught two real bugs via testing rather than review: hljs.highlightAll()
silently no-ops if called before the document finishes parsing rather
than deferring itself, and a bash example starting with the word "php"
auto-detects as PHP, not bash.
Also adds App/pages/blog/code-highlighting/ - a new blog post
demonstrating the feature with a verified worked example in each of the
nine languages, plus how to force a language via an explicit
language-<name> class when auto-detection isn't enough.
Closes the "Syntax highlighting on code blocks" backlog item in
novaconium/ISSUES.md.
29 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 — a single path, or (since the content index shipped) an
ordered list of roots, each root's own files filename-ordered and
roots processed fully in the order given, not interleaved by filename
across roots (so a framework root always finishes before a project root on
the same connection). Tracked by path relative to the repo root (e.g.
novaconium/migrations/0001_x.sql), not bare filename — two roots can
each contain a same-named file, and tracking by bare filename would make
the second one seen look "already applied" and silently skip it; a
repo-relative path is also portable across environments, unlike a full
absolute path, which would make every migration look new again after a
clone/deploy to a different directory. realpath() normalizes any .. a
migrations_dir like __DIR__ . '/../App/migrations' would otherwise
leave in the tracked name. Each connection's own auto-created
schema_migrations table tracks its migrations independently of any other
connection's; each is only ever run once. 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. The default connection's migrations_dir scans
novaconium/migrations/ (framework-shipped schema, e.g. the content
index below) before App/migrations/ (project schema) — see the
migrations_dir array-form paragraph above. Any other connection a
project adds still defaults to no migrations_dir at all unless it sets
one; the two-root default is specific to default.
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.
Standing rule: any mechanism that conditionally hides page content from
the public must also be threaded into Renderer::render()'s
$excludeFromCache decision, not just a pre-render auth gate. This
bit twice already — once as a designed-around gotcha (draft pages), once
as a real pre-existing bug found while testing that feature (/admin/*
itself). The reason: Renderer::render() writes a sidecar-less page's
output to the static HTML cache (novaconium/src/Cache.php), and
.htaccess serves a cached file before PHP, and therefore any auth
check, ever runs again (see /admin/docs/caching). A route can be
gated by AdminAuth::requireLogin()/::isAuthenticated() and still leak
completely to the public the moment it's viewed once by someone
authorized, if the page has no sidecar and nothing tells Renderer to
skip the cache write for that route. draft_routes (see
/admin/docs/drafts, novaconium/config.php) and every /admin/* route
both pass true for Renderer::render()'s $excludeFromCache param
from novaconium/bootstrap.php for exactly this reason — most pages
under novaconium/pages/admin/ (e.g. admin/index.twig) have no
sidecar, so before this was wired up, visiting /admin once as an
authenticated admin would cache the admin panel and serve it to every
subsequent visitor, unauthenticated, straight from public/cache/admin/.
Any future feature that gates a route by anything other than a sidecar
check (paywall content is the next one on the roadmap likely to hit this)
needs to make the same check here, not just at the point where the
request is first authorized.
AdminAuth::isAuthenticated(string $username, string $passwordHash): bool
(novaconium/src/AdminAuth.php) is the credential check on its own, with
no response side effects, extracted out of requireLogin() (which still
does the same check, then issues the 401 challenge on failure) so a
different caller can react to failure differently. The draft-page gate in
bootstrap.php is the first such caller: on failure it renders a plain
404 via the same path an unmatched route takes, not a login prompt —
prompting for credentials at a draft URL would itself reveal that
something is gated there, which defeats the point of hiding it. Returns
true (open access) when $passwordHash is empty, mirroring
requireLogin()'s existing no-op-when-unset posture, so a draft behaves
consistently with the rest of /admin/*: wide open until a password is
configured, gated once one is.
App\ContentIndexer (novaconium/src/ContentIndexer.php) is the shared
crawler behind /sitemap.xml, /search, and blog tag browsing (see
/admin/docs/content-index) — App\, not Lib\, since it's rendering
infrastructure akin to Renderer/Router, not project-overridable
content. Content stays in files; only metadata is indexed. Per-page
metadata is four Twig blocks declared in the root layout next to the SEO
blocks (keywords, tags, changefreq, priority — the last three
never rendered into the page, only harvested) and pulled via
Renderer::renderForIndex(), which calls Twig's own
TemplateWrapper::renderBlock() per block rather than parsing .twig
source — this is deliberate: it gets App-over-novaconium override and
layout-inheritance resolution for free, the same way a real render does.
config['content_index_enabled'] defaults to false — same posture
as matomo_url/admin_password_hash, since this is a real SQLite
dependency plenty of sites won't want. Every consumer route checks the
flag before touching Lib\Db and 404s if it's off, so the feature has
zero filesystem footprint (no data/novaconium.sqlite) when disabled —
verified end-to-end, not assumed, since "off" silently still creating a
database file would defeat the point.
Reentrancy hazard, already hit once: ContentIndexer::reindex()
renders every routable page as part of the crawl — including /search
itself, which is also a real page and also calls
ContentIndexer::ensureFresh() from its own sidecar. Without a guard,
crawling /search would trigger a nested reindex() call mid-transaction
and fatal on a second PDO::beginTransaction(). ContentIndexer guards
this with a private static bool $indexing flag, checked at the top of
both ensureFresh() and reindex() — either no-ops while a reindex is
already running on the call stack. Any future consumer route added under
this mechanism inherits the same hazard for free (it'll also get crawled,
and if its sidecar also calls ensureFresh(), the guard already covers
it) — don't remove the flag thinking it's unnecessary.
reindex() also forces $_SERVER['REQUEST_METHOD'] to 'GET' for the
duration of the crawl (restoring whatever it was before, in a finally)
— sidecars are expected to be side-effect-free for non-POST requests
anyway (ordinary HTTP-safe-method hygiene), but this guarantees a lazy
reindex triggered from within a POST request can never leak that POST
into an unrelated page's sidecar purely because the crawler happened to
render it. A crawl is a full truncate-and-rebuild inside one transaction,
not incremental — simple and correct at this site's scale; don't add
incremental/diffing logic without a real need for it.
Standing rule: a vendored dependency's files go under novaconium/vendor/
only if they're server-side (PHP, autoloaded, never fetched by a browser)
— anything the browser has to fetch (.js, .css, images) has to live
under public/vendor/ instead, since public/ is the only web-reachable
directory (novaconium/ isn't reachable at all — see public/.htaccess).
Twig lives under novaconium/vendor/twig/ correctly, since it's pure PHP
source. highlight.js (/admin/docs/upgrading-highlightjs,
public/vendor/highlightjs/) is the first vendored dependency that's
actually browser-servable, and originally almost got vendored to
novaconium/vendor/ too, following Twig's precedent blindly — that would
have silently 404ed on every request, since nothing under novaconium/
is ever served to a browser. This has a real consequence beyond just
placement: public/ is project-owned and untouched by the "Updating the
framework" workflow (rm -rf novaconium && cp -r <new-novaconium> — see
/admin/docs/getting-started), so a future framework release that bumps
a public/vendor/-placed dependency will not carry that upgrade to
an existing project automatically the way a novaconium/vendor/ bump
would — re-vendoring it is a separate manual step every time, documented
per-dependency (see /admin/docs/upgrading-highlightjs).
class="nohighlight" marks a <pre><code> block whose content is
literal Twig template syntax ({% %}/{{ }}), so highlight.js's
auto-detection (novaconium/pages/_layout/syntax-highlight.twig,
restricted to configure({ languages: ['php', 'bash', 'xml', 'css', 'python', 'javascript', 'yaml', 'json', 'ini'] }) — yaml/json/ini
are vendored as separate per-language files under
public/vendor/highlightjs/languages/, not part of the core bundle like
the other six; see /admin/docs/upgrading-highlightjs) doesn't
force-match it to whichever configured language scores highest — Twig has
no highlight.js grammar, and a restricted auto-detect still always
returns its best guess among the allowed set, never "give up," so an
unmarked Twig block would get colored wrong, not just left plain.
Currently on:
novaconium/pages/admin/docs/{layouts,content-index,rss,sitemap,forms,seo}/index.twig
and App/pages/blog/{style-guide,twig-syntax-guide}/index.twig. A new
Twig-syntax code sample added anywhere needs the same class — a PHP or
Bash sample doesn't (auto-detection handles those reliably on its own,
via strong signals like a leading <?php).
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.