diff --git a/.gitignore b/.gitignore
index 4c5f206..58b6df3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,4 @@
+/public/cache/*
+!/public/cache/.gitkeep
+/novaconium/contact-log.txt
.claude/
diff --git a/AGENTS.md b/AGENTS.md
index 340340c..cc594b3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,59 +1,240 @@
-# NovaconiumPHP
+# AGENTS.md
-A lightweight PHP 8.1+ MVC-ish web framework/CMS toolkit (router + controllers + Twig views + Sass styling), authored by Nick Yeoman (4lt.ca) and distributed via Composer as `4lt/novaconium`. The canonical repo is hosted on a self-hosted Gitea instance (`git.4lt.ca`), not GitHub — do not assume `gh` CLI or GitHub Actions apply here.
+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.
-## Directory map
+## Documentation is duplicated on purpose — keep all copies in sync
-- `src/` — core framework classes, namespace `Novaconium\` (PSR-4 → `src/`): `novaconium.php` (bootstrap), `Router.php`, `Database.php`, `Session.php`, `Redirect.php`, `Post.php`, `Logger.php`, `MessageHandler.php`, `functions.php`, `twig.php`, and `src/Services/` (`Auth.php`, `TagManager.php`).
-- `controllers/` — framework-level default controllers (dashboard, pages, messages, settings, auth/, sitemap, 404, init, create_admin, etc.).
-- `config/routes.php` — framework-level default route definitions, merged with app-level `App/routes.php` at runtime.
-- `views/` — Twig views mirroring controller names (`controllers/dashboard.php` ↔ `views/dashboard.html.twig`).
-- `twig/` — shared/partial Twig templates (layout pieces: `master.html.twig`, `nav.html.twig`, `head.html.twig`, `foot.html.twig`, `cp/` control-panel partials, `javascript/`).
-- `sass/` — framework's default Sass source (indented Sass, not SCSS), 7-1-ish structure: `abstracts/`, `base/`, `framework/`, `controlPanel/`, `coming-soon/`; has its own `Dockerfile` for compiling.
-- `skeleton/` — scaffold copied into a new consumer project on install: `.env`, `docker-compose.yml`, `novaconium/App/{config.php,routes.php,controllers,views,templates}`, `novaconium/public/{index.php,.htaccess,css,js}`, `novaconium/sass/project.sass`.
-- `docs/` — one short Markdown file per topic (see below).
-- `_assets/` — static assets (e.g. logo used in README).
+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//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:
-## Build / run (Docker-only — no host tooling assumed)
+1. Update (or add) the matching page under
+ `novaconium/pages/admin/docs//index.twig`, and if it's a new
+ topic, link it from both `admin/docs/index.twig` and the nav in
+ `admin/docs/_layout/layout.twig`.
+2. Update `README.md` if the change affects the feature list, getting
+ started steps, or the docs index there.
+3. Update this file if the change affects a convention an agent needs to
+ know before editing code (not just narrative docs).
-**Agents (Claude Code, opencode, etc.) run inside containers and have no access to Docker.** Do not attempt to run any `docker`/`docker compose` command below — they will fail. These commands are documented for a human maintainer to run on their own host. If you need to verify a change, do so by reading/reasoning about the code, not by invoking Docker.
+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) and
+ `App/lib/` (project's own `Lib\` 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 `/admin` tools), default `Lib\` 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.
+
+## Running it
-**Composer** (per `docs/Composer.md`):
```
-docker run --rm --interactive --tty --volume $PWD:/app composer:latest require 4lt/novaconium
-docker run --rm --interactive --tty --volume $PWD:/app composer:latest update
+php -S 127.0.0.1:8000 -t public public/router.php
```
-**Sass build** (per `docs/Sass.md`), using `sass/Dockerfile`:
-```
-cd sass && docker build -t sass-container .
-docker run --rm -v $(pwd):/usr/src/app -w /usr/src/app sass-container sass novaconium/sass/project.sass novaconium/public/css/novaconium.css
-```
-Compressed variant: add `--style=compressed`, e.g.
-```
-docker run --rm -v $(pwd):/usr/src/app -w /usr/src/app sass-container --style=compressed sass/novaconium.sass skeleton/novaconium/public/css/novaconium.css
-```
+`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.
-**Dev stack**: `docker compose up -d` from `skeleton/docker-compose.yml` — services: `4lights/corxn:8.5.3` (Apache+PHP), `redis`, `mariadb`, `phpmyadmin`.
+## Conventions worth knowing
-**Testing a cloned copy without reinstalling via Composer**: see `docs/Dev-Fake_autoload.md` for the fake-autoload dev trick.
-
-## No test suite / lint / CI
-
-There is no `phpunit.xml`, no lint config (no PHPCS, `.editorconfig`, ESLint), and no CI pipeline in this repo. Don't hunt for `npm test`/`phpunit`/lint commands — none exist.
-
-## Conventions
-
-- PSR-4 autoloading: `Novaconium\` → `src/`.
-- Controllers and views are name-paired by default (e.g. `controllers/dashboard.php` ↔ `views/dashboard.html.twig`), but this is only a convention — a controller can render any view, they are not required to match.
-- Sass partials are prefixed `_` (e.g. `_forms.sass`), aggregated per-folder via `index.sass`.
-- App-level config (`App/config.php`) is a multi-dimensional PHP array: database credentials, `base_url`, `secure_key`, `logfile`, `loglevel`.
-- Versioning strategy is semantic-versioning (declared in `composer.json` `extra.versioning`).
-
-## Git workflow
-
-Single `master` branch (no `main`), tracking a self-hosted Gitea remote. Solo-maintainer, trunk-based, direct commits to `master` with short informal messages — no conventional-commits enforcement, no PR-based tooling assumptions.
-
-## Further reading
-
-See `docs/*.md` for per-topic detail: `404.md`, `Composer.md`, `ConfigurationFile.md`, `Dev-Fake_autoload.md`, `Logs.md`, `Messages.md`, `Post.md`, `Redirect.md`, `Sass.md`, `Session.md`, `StyleSheets-sass.md`, `Twig-Views.md`, `docker.md`.
+- Reserved segments: any path segment starting with `_` (e.g. `_layout/`)
+ or literally named `404` is 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 a
+ `Response` (redirect/json/xml/html — `novaconium/src/Response.php`).
+ `$params` (route captures) and `$cache` (the `Cache` instance, e.g. for
+ `$cache->clear()`) are both in scope automatically — see
+ `novaconium/src/Renderer.php::runSidecar()`.
+- No Composer — `novaconium/autoload.php` is a hand-rolled PSR-4 loader.
+ Adding a new framework-core class means adding it under `App\` in
+ `novaconium/src/`; a new `Lib\` class goes in `App/lib/` or
+ `novaconium/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/
+{% endif %}
diff --git a/novaconium/pages/_layout/nav.twig b/novaconium/pages/_layout/nav.twig
new file mode 100644
index 0000000..691d57c
--- /dev/null
+++ b/novaconium/pages/_layout/nav.twig
@@ -0,0 +1,25 @@
+{% import '_layout/icons.twig' as icons %}
+
+
diff --git a/novaconium/pages/_layout/theme-init.twig b/novaconium/pages/_layout/theme-init.twig
new file mode 100644
index 0000000..43bee4e
--- /dev/null
+++ b/novaconium/pages/_layout/theme-init.twig
@@ -0,0 +1,12 @@
+{# Applies a saved theme choice before the page paints, so switching to
+ light doesn't flash dark first. Must run early in , before the
+ stylesheet link — see novaconium/pages/_layout/layout.twig. Pairs with
+ the toggle button + its click handler in novaconium/pages/_layout/nav.twig. #}
+
diff --git a/novaconium/pages/admin/clear-cache/index.php b/novaconium/pages/admin/clear-cache/index.php
new file mode 100644
index 0000000..0bb2086
--- /dev/null
+++ b/novaconium/pages/admin/clear-cache/index.php
@@ -0,0 +1,22 @@
+clear();
+
+ return Response::redirect('/admin/clear-cache?cleared=1');
+}
+
+return [
+ 'cleared' => Input::get('cleared') !== null,
+ 'securityError' => Input::get('error') === 'security',
+ 'csrfField' => Csrf::fieldName(),
+ 'csrfToken' => Csrf::token(),
+];
diff --git a/novaconium/pages/admin/clear-cache/index.twig b/novaconium/pages/admin/clear-cache/index.twig
new file mode 100644
index 0000000..42cdfe5
--- /dev/null
+++ b/novaconium/pages/admin/clear-cache/index.twig
@@ -0,0 +1,26 @@
+{% extends layout %}
+
+{% import '_layout/icons.twig' as icons %}
+
+{% block title %}Clear cache{% endblock %}
+
+{% block description %}Clear the static page cache.{% endblock %}
+
+{% block robots %}noindex, nofollow{% endblock %}
+
+{% block content %}
+
+
{{ icons.trash() }}Clear cache
+
Deletes every pre-rendered page under public/cache/. Sidecar-less pages re-render (and re-cache) on their next request.
+ {% if cleared %}
+
Cache cleared.
+ {% endif %}
+ {% if securityError %}
+
Your session expired before submitting — please try again.
Every route under /admin/* — /admin, /admin/clear-cache, the docs section, and any admin page a project adds later — can be gated behind HTTP Basic Auth with two App/config.php keys. It's off by default (same posture as Matomo): an empty admin_password_hash means no gate at all, matching this project's original wide-open behavior.
+
+
This replaced the old docs_enabled config flag, which only hid the docs section specifically. Since this gate covers all of /admin/* — including docs — there's no need for a separate docs-only toggle anymore; set a password and the whole admin area (docs included) requires login.
+
+
Enabling it
+
+
Generate a password hash once — either on the command line:
...or, if you'd rather not touch a terminal, at {{ icons.lock() }}/admin/password-hash — a small built-in form that does the same password_hash() call and hands back a ready-to-paste config snippet. Nothing typed there is stored or logged. Since it lives under /admin/* like everything else here, it's automatically covered by this same gate once a password is set — reachable while admin_password_hash is still empty (so you can generate your first one), then protected like any other admin page afterward.
Every request into /admin/* now requires that username/password via the browser's built-in Basic Auth prompt; anything outside /admin is unaffected.
+
+
How it's wired
+
+
novaconium/src/AdminAuth.php is a single reusable check — AdminAuth::requireLogin($username, $passwordHash) — called once from novaconium/bootstrap.php for any resolved route whose path is admin or starts with admin/, and only for routes that actually resolved (no login prompt on an unrelated 404). Because the check lives in bootstrap.php rather than on each page, a new admin page needs zero extra wiring to be protected — dropping a new directory under App/pages/admin/ or novaconium/pages/admin/ is automatically gated the moment it exists.
+
+
Logging out
+
+
HTTP Basic Auth has no real server-side logout — the browser just keeps resending the same cached credentials on every request to that realm. Visiting /admin/logout works around this: AdminAuth::logout() always issues a fresh 401 challenge, regardless of what credentials were sent, which makes the browser discard what it had cached and prompt again the next time /admin is visited. Credentials themselves aren't invalidated server-side (there's nothing to invalidate — it's just a password check on every request), so this is a client-side-only logout, same as any Basic Auth site. The "Logout" link only appears on /admin when admin_auth_enabled is true (i.e. a password is actually set).
+
+
What this is (and isn't)
+
+
This is HTTP Basic Auth against a single username/password pair in config — no sessions, no user table, no password reset, no multiple accounts. It's a deliberate stopgap: see novaconium/ISSUES.md's "Admin login & user management" entry for the planned real multi-user system (backed by SQLite, with proper sessions). That feature will replace this mechanism, not layer on top of it. Until then, this is enough to keep the general public out of /admin on a production site.
+
+
Basic Auth credentials are sent base64-encoded on every request (not encrypted) — always serve /admin over HTTPS in production, same as any password-protected page.
+{% endblock %}
diff --git a/novaconium/pages/admin/docs/caching/index.twig b/novaconium/pages/admin/docs/caching/index.twig
new file mode 100644
index 0000000..af67f41
--- /dev/null
+++ b/novaconium/pages/admin/docs/caching/index.twig
@@ -0,0 +1,22 @@
+{% extends 'admin/docs/_layout/layout.twig' %}
+
+{% block title %}Static caching{% endblock %}
+
+{% block description %}How sidecar-less pages are pre-rendered and served as static HTML.{% endblock %}
+
+{% block robots %}noindex, nofollow{% endblock %}
+
+{% block docs_content %}
+
Static caching
+
+
If a page has no sidecar, its rendered HTML is written to public/cache/<path>/index.html after the first request. .htaccess checks for that file before PHP ever runs, so repeat visits are served straight by Apache with zero PHP/Twig overhead. Pages with a sidecar are never cached this way, since their output can vary per request.
+
+
To force a single page to re-render, delete its file under public/cache/. To clear everything at once, there are two equivalent options:
+
+
+
CLI:php novaconium/bin/clear-cache.php — a standalone script for deploys, cron jobs, or anywhere you'd rather not go through a browser. Prints Cache cleared. and exits.
Both end up calling the same underlying Cache::clear() — see /admin/docs/config's "For developers: using Cache.php directly" section for how each entry point constructs it. Clearing the cache only deletes the generated static HTML; it doesn't affect App/pages/ or any other source. Any project change that should show up on an already-cached page — a new site_name, a new Sass color, a new admin toggle — needs a cache clear before it's visible, since the old index.html would otherwise keep being served as-is.
novaconium/config.php holds the framework defaults — pages_dirs, cache_dir, debug, site_name, matomo_url, matomo_site_id, admin_username, admin_password_hash — and is not meant to be edited per-project, same as everything else under novaconium/.
+
+
App/config.php ships with the skeleton as an empty, commented placeholder — uncomment (or add) whichever keys you want to change, returning an array of just those:
novaconium/bootstrap.php (and novaconium/bin/clear-cache.php) check whether App/config.php exists and, if so, shallow-merge it over novaconium/config.php's defaults with array_merge() — the same App-overrides-novaconium pattern used for pages and Lib\ classes elsewhere. You only need to list the keys you're changing; anything you omit keeps the framework default.
+
+
Deleting App/config.php entirely is just as valid as leaving it in place returning an empty array — either way, every setting falls back to novaconium/config.php's defaults.
+
+
Site name
+
+
site_name (default 'My Site') is used by the root layout as the default page <title> (when a page doesn't override the title block), og:site_name, and the footer copyright line:
See SEO for the full list of overridable meta blocks. Pages that were already statically cached before this changes need php novaconium/bin/clear-cache.php to pick it up.
+
+
Admin authentication
+
+
admin_username / admin_password_hash gate every /admin/* route behind HTTP Basic Auth — see Admin authentication for the full write-up. Both are set via App/config.php; leaving admin_password_hash empty (the default) disables the gate.
+
+
For developers: using Cache.php directly
+
+
novaconium/src/Cache.php is the class behind the cache_dir config key above — a small, dependency-free wrapper around writing/deleting the static HTML files under public/cache/ that Static caching describes. Like Router (see /admin/docs/routing's "For developers" section), it's plain and easy to reason about in isolation: no Twig, no request state, just a path convention and some filesystem calls.
+
+
How it fits in
+
+
Cache shows up in three places, all constructed the same way — new Cache($config['cache_dir']):
+
+
+
novaconium/bootstrap.php constructs one and hands it to Renderer, which calls $cache->write() after rendering any page that has no sidecar (see /admin/docs/sidecars's Renderer.php section) — Renderer is the only thing that ever writes to the cache.
+
novaconium/bin/clear-cache.php, a standalone CLI script, constructs one and calls $cache->clear() — this is what php novaconium/bin/clear-cache.php runs.
+
novaconium/pages/admin/clear-cache/index.php's sidecar calls $cache->clear() too, but doesn't construct it — $cache is already in scope automatically inside every sidecar, the same instance Renderer is using, injected by Renderer::runSidecar() alongside $params.
+
+
+
Constructing it and its three methods
+
+
use App\Cache;
+
+$cache = new Cache($config['cache_dir']);
+
+$cache->path($requestUri); // string — the .html file a URI maps to, without touching the filesystem
+$cache->write($requestUri, $html); // void — creates parent directories as needed, then writes the file
+$cache->clear(); // void — recursively deletes everything under cache_dir, leaving the directory itself
+
+
The constructor takes just one thing — cache_dir, a single absolute path (novaconium/config.php sets it to public/cache) — unlike Router/Renderer, which take the whole ordered pagesDirs list. That's because caching isn't part of the App-over-novaconium override mechanism; there's exactly one cache directory, not a searched list of them.
+
+
path() mirrors the public URL tree directly: /blog/hello-world maps to {cache_dir}/blog/hello-world/index.html, matching the .htaccess rule that checks for that exact file before letting any request reach PHP. write() calls path() internally and creates any missing parent directories with mkdir(..., true) before writing. clear() recurses over every entry under cache_dir deleting files and subdirectories, but never deletes cache_dir itself — so a stray public/cache/.gitkeep (see .gitignore) survives a clear.
+
+
Because every method here is a straightforward filesystem operation with no hidden state beyond the one constructor argument, testing Cache in isolation is just a matter of pointing it at a temporary directory and asserting on what ends up on disk.
+{% endblock %}
diff --git a/novaconium/pages/admin/docs/design-notes/index.twig b/novaconium/pages/admin/docs/design-notes/index.twig
new file mode 100644
index 0000000..fc37724
--- /dev/null
+++ b/novaconium/pages/admin/docs/design-notes/index.twig
@@ -0,0 +1,84 @@
+{% extends 'admin/docs/_layout/layout.twig' %}
+
+{% block title %}Design notes{% endblock %}
+
+{% block description %}The original design rationale for this framework.{% endblock %}
+
+{% block robots %}noindex, nofollow{% endblock %}
+
+{% block docs_content %}
+
User likes Hugo's file-based routing/pretty-URLs but wants real PHP capability and Twig instead of Markdown. Pages are files on disk (page-bundle style): a directory tree of .twig templates defines the URL structure, and any page needing PHP logic gets an optional sidecar index.php that supplies data or a full custom response (redirect/JSON/XML). Pages without a sidecar are pure/deterministic, so their rendered output is cached to a static .html file and served directly by Apache — PHP is skipped entirely on repeat hits, similar to Hugo's static output but generated lazily on first request instead of an upfront build. Deployment target is Apache only, so .htaccess handles both the cache short-circuit and canonical-URL redirects.
+
+
Pages/layouts are overridable, same mechanism as Lib\ overrides
+
Routing and rendering key off an ordered list of roots (App/pages/ then novaconium/pages/) and resolve every relative path (a page, a sidecar, a _layout/layout.twig) against that list via Overlay::isFile/isDir/findFile/findWildcardDir, first match wins. novaconium/pages/ ships the framework defaults (_layout/layout.twig, 404/index.twig); a project doesn't need to duplicate those to have a working site, but overrides either — or any page — just by placing a file at the same relative path under App/pages/. Twig's FilesystemLoader natively accepts an array of paths, so template rendering (including {% verbatim %}{% extends %}{% endverbatim %}) gets the same override-then-fallback resolution for free — no manual proxying required there.
+
+
Router (novaconium/src/Router.php) — intentionally simple, no rendering
+
+
Take $_SERVER['REQUEST_URI'], strip query string, trim slashes, split into segments (root = []).
+
Walk the page roots via Overlay, skipping any directory starting with _ when matching (those are reserved, e.g. _layout/, never routable). At each level: exact-name subdirectory first (found in any root), else a [param]-named subdirectory (capturing into params[name]), else no match.
+
Returns a Route object: { dir: string|null (relative path, not absolute), params: array, found: bool }. Never touches Twig, sidecars, or output — that's the Renderer's job. This keeps Router a pure "URL -> relative page path" lookup, easy to unit-test on its own.
+
+
+
Sidecar PHP contract — can return more than an array
+
App/pages/**/index.php (optional) returns one of:
+
+
array → treated as Twig context data ($params is in scope for slug etc.).
+
Response object → short-circuits Twig entirely. novaconium/src/Response.php provides factories: Response::redirect($url, $code = 301), Response::json($data), Response::xml($string), Response::html($string) (raw HTML, bypass Twig but still gets cached like a normal page if desired).
+
+
Renderer checks the sidecar's return type: array → render matched index.twig with that data; Response → emit directly (correct headers/status, no Twig, no layout).
+
+
Layout inheritance — walk upward like Hugo
+
Reserved _layout/layout.twig directories are looked up by the Renderer, not baked into Router:
+
+
Starting at the matched page's directory, check for _layout/layout.twig via Overlay (checking App/pages/ before novaconium/pages/ at every level). If absent, check parent directory, and so on up to the root _layout/layout.twig (App/pages/_layout/layout.twig if present, else novaconium/pages/_layout/layout.twig).
+
The resolved layout path is injected into the Twig context as layout, and each index.twig does {% verbatim %}{% extends layout %}{% endverbatim %} — keeps the mechanism transparent in the template rather than magic in the engine.
+
+
+
Static caching (sidecar-less pages only)
+
+
When Renderer serves a page whose directory has noindex.php, after rendering the Twig output it also writes it to public/cache/<segments>/index.html.
+
.htaccess checks public/cache/<REQUEST_URI>/index.html first (RewriteCond ... -f) and serves it directly if present — PHP/Twig never runs again for that route until the cache file is cleared.
+
Cache invalidation is manual for now: clearing public/cache/ (or a specific subfolder) forces regeneration on next hit. (Simple, matches "no build step" philosophy; can add mtime-based invalidation later if needed.)
+
Pages with a sidecar are never cached this way since their output can vary per-request (DB data, params, POST handling, etc.).
+
+
+
Canonical URLs — no trailing slash (SEO)
+
+
Canonical form is without a trailing slash: /about, /blog/hello-world.
+
.htaccess 301-redirects any URL ending in / (except the bare root /) to the same path without it, before any routing/cache logic runs.
+
Router/Renderer internally still key off directory-per-page (pages/about/index.twig), that's just the filesystem convention — it's independent of what the external canonical URL looks like.
+
+
+
Front controller (public/index.php) — kept intentionally light
All real logic — autoload registration, config load, Router::resolve(), Renderer::render(), emitting headers/body — lives in novaconium/bootstrap.php, not in public/index.php.
+
+
Sass (indented syntax, not SCSS)
+
+
Source lives in novaconium/sass/ (structure in main.sass, default colors in defaults/_colors.sass) using indented syntax. App/sass/_colors.sass overrides the color palette.
+
Compiled output goes to public/css/main.css.
+
Compilation is a build step (not a PHP runtime concern) — use the sass CLI (Dart Sass, which supports indented syntax) e.g. sass --load-path=App/sass --load-path=novaconium/sass/defaults novaconium/sass/main.sass public/css/main.css. No PHP Sass library is needed/assumed since PHP-based compilers (scssphp) target SCSS, not indented syntax.
+
main.sass does @use 'colors' as * but its own directory has no _colors.sass — on purpose, so resolution falls through to the load paths above (App/sass checked first) instead of resolving to a same-directory sibling, which Dart Sass would otherwise prefer regardless of load-path order. Same override-by-presence mechanism used for pages/lib, extended to a non-PHP asset.
+
+
+
Autoloading (no Composer)
+
novaconium/autoload.php: spl_autoload_register mapping Twig\ → novaconium/vendor/twig/src/, App\ → novaconium/src/, and Lib\ → checked against App/lib/ first, falling back to novaconium/lib/ — this is the override mechanism: a project drops a same-named class in App/lib/ to replace a framework default without touching novaconium/. Twig vendored manually from a GitHub release zip into novaconium/vendor/twig/.
+
+
Verification
+
+
Configure Apache (or equivalent local Apache setup) with docroot public/ and the .htaccess rules active (AllowOverride All / mod_rewrite enabled).
+
Visit /about/ → confirms 301 redirect to /about (canonical, no trailing slash).
+
Visit /about twice → first hit renders via PHP/Twig and writes public/cache/about/index.html; second hit confirms (e.g. via response headers/timing, or temporarily renaming novaconium/bootstrap.php) that Apache served the cached file directly.
+
Visit /contact (has a sidecar) → confirms it is NOT cached (no sidecar-less caching), and that a Lib\ class (Lib\Mailer) can be called from it. Every post under App/pages/blog/ is sidecar-less by contrast — visiting one twice should show the same static-cache behavior as /about above.
+
Add a sidecar that returns Response::json([...]) → confirms JSON is emitted with correct Content-Type, bypassing Twig.
+
Add a sidecar that returns Response::redirect('/somewhere') → confirms a real HTTP redirect happens.
+
Confirm App/pages/blog/_layout/layout.twig overrides App/pages/_layout/layout.twig for pages under blog/, and that _layout/ directories are never reachable as routes (404 if requested directly).
+
Drop a same-named class into App/lib/ and confirm it's used instead of the novaconium/lib/ default (override mechanism works).
+
Delete App/pages/_layout/ and App/pages/404/ (if present) and confirm the site still renders and 404s using novaconium/pages/'s defaults; then drop a _layout/layout.twig into App/pages/ and confirm it takes precedence.
+
Run sass --load-path=App/sass --load-path=novaconium/sass/defaults novaconium/sass/main.sass public/css/main.css and confirm compiled CSS loads on a page.
+
+{% endblock %}
diff --git a/novaconium/pages/admin/docs/forms/index.twig b/novaconium/pages/admin/docs/forms/index.twig
new file mode 100644
index 0000000..56146b0
--- /dev/null
+++ b/novaconium/pages/admin/docs/forms/index.twig
@@ -0,0 +1,137 @@
+{% extends 'admin/docs/_layout/layout.twig' %}
+
+{% import '_layout/icons.twig' as icons %}
+
+{% block title %}Forms{% endblock %}
+
+{% block description %}Building a custom form with a sidecar, using Lib\FormValidator, Lib\SpamGuard, and Lib\Validate.{% endblock %}
+
+{% block robots %}noindex, nofollow{% endblock %}
+
+{% block docs_content %}
+
Forms
+
+
Every form on this site — the {{ icons.email() }}contact form included — is the same four ingredients: a page with a sidecar, the three validation/spam classes under Lib\, and the POST/redirect/GET pattern. This page builds a second, different form from scratch (a one-field newsletter signup) so the pattern is clear independent of the contact form's specific fields.
+
+
1. Create the page
+
+
A form needs a directory with both an index.twig and an index.php — the sidecar is what makes it a form rather than a static page. Scaffold the Twig half with novaconium/bin/create-static-page.php (see {{ icons.book() }}Getting started) and add the sidecar yourself, or just create both by hand:
+
+
App/pages/newsletter/
+ index.twig
+ index.php
+
+
2. The sidecar
+
+
This is the whole contract: validate $_POST, check for spam, do something with the result, redirect. Nothing here is specific to "newsletter" — swap the one field and the one line that does something with valid input, and this is the shape of any form on the site:
+
+
{% verbatim %}<?php
+// App/pages/newsletter/index.php
+
+use App\Response;
+use Lib\Csrf;
+use Lib\FormValidator;
+use Lib\Input;
+use Lib\SpamGuard;
+
+$errors = [];
+$old = ['email' => ''];
+$spamGuard = new SpamGuard();
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ if (!Csrf::verify(Input::post('csrf_token'))) {
+ return Response::redirect('/newsletter?error=security');
+ }
+
+ $old = [
+ 'email' => Input::post('email', ''),
+ ];
+
+ $validator = (new FormValidator())
+ ->required($old['email'], 'email', 'Email is required.')
+ ->email($old['email'], 'email', 'A valid email is required.');
+
+ if ($validator->passes()) {
+ if (!$spamGuard->isSpam(Input::post())) {
+ // ...store $old['email'] somewhere: a file, SQLite once that
+ // groundwork lands (see novaconium/ISSUES.md), a third-party API, etc.
+ // This is the one line that's actually specific to this form.
+ }
+
+ return Response::redirect('/newsletter?sent=1');
+ }
+
+ $errors = $validator->errors();
+}
+
+return [
+ 'errors' => $errors,
+ 'old' => $old,
+ 'sent' => Input::get('sent') !== null,
+ 'securityError' => Input::get('error') === 'security',
+ 'renderedAt' => $spamGuard->renderedAt(),
+ 'csrfField' => Csrf::fieldName(),
+ 'csrfToken' => Csrf::token(),
+];{% endverbatim %}
FormValidator accumulates named field errors — required(), email(), and maxLength()/minLength() are all available; chain as many as the form needs, then check $validator->passes() once.
+
SpamGuard::isSpam(Input::post()) is checked after validation passes, and the form redirects to the same success URL either way — a bot gets an identical response to a human, it just never reaches the "actually do something" line.
+
$spamGuard->renderedAt() goes into the sidecar's returned context on every request (GET and POST) — the template needs it either way, since a failed validation re-renders the form with a fresh timestamp for the next attempt.
+
Csrf::verify() is checked before anything else, and unlike a failed spam check, a failed CSRF check shows a real, visible error — a CSRF failure is usually a legitimate stale-tab case for a real visitor, not something worth hiding.
+
+
+
3. The template
+
+
The honeypot field and the hidden timestamp are the two pieces every form needs regardless of its actual fields — copy them as-is:
The .hp-field class (defined once in novaconium/sass/main.sass) positions the honeypot off-screen rather than hiding it with display: none, since some spam bots specifically skip fields hidden that way — see {{ icons.book() }}Sidecars for why. Nothing about that field or the timestamp needs to change between forms; only the real fields (email here, name/email/message on the contact form) differ.
+
+
Why it's never cached
+
+
Because this page has a sidecar, it's excluded from the static-cache path entirely (see {{ icons.book() }}Static caching) — every request re-runs the sidecar, which is exactly what a form needs: a fresh rendered_at timestamp, current validation errors, and an up-to-date sent flag from the query string. A form is the canonical example of a page that shouldn't be sidecar-less, even though nothing stops you from technically leaving the sidecar off — without one, there's nothing to receive $_POST at all.
+
+
Going further
+
+
+
Need a phone number, a postal/zip code, or a "confirm email" field? Lib\Validate has isPhone(), isPostalCode()/isZipCode(), and isMatch() — see {{ icons.book() }}Libraries.
+
Want the submission to actually go somewhere? Swap the comment in step 2 for a call to Lib\Mailer::send() (see the contact form), a new Lib\ class of your own, or — once SQLite groundwork lands (see novaconium/ISSUES.md, not yet built) — a database write.
+
Multiple forms on one site can each use their own honeypot/timestamp field names by passing constructor arguments to SpamGuard, e.g. new SpamGuard('url', 'ts', 3), so they don't interfere with each other.
Requirements: PHP 8.1+ and, for production, Apache with mod_rewrite and AllowOverride All.
+
+
Run it locally (no Apache needed)
+
+
php -S 127.0.0.1:8000 -t public public/router.php
+
+
public/router.php is a dev-only script that mimics the .htaccess rules (canonical redirects + static cache lookup) so you can develop without Apache. It is never used in production — Apache reads public/.htaccess directly.
+
+
Visit:
+
+
http://127.0.0.1:8000/ — static home page
+
+
+
Deploy on Apache
+
+
Point the vhost's document root at public/, make sure mod_rewrite is enabled and AllowOverride All is set for that directory so public/.htaccess takes effect, and it just works — no build step required.
+
+
Adding a new page
+
+
Create a directory under App/pages/ with an index.twig — the directory path is the URL (see Routing). SEO has a ready-to-paste starter template with every overridable block (title, description, Open Graph, Twitter Card) plus a content stub — copy it in and fill in the blanks.
Scaffolds App/pages/blog/my-new-post/index.twig from that same starter template, with the title pre-filled from the last path segment ("my-new-post" → "My New Post"). The path can be given with or without a trailing .twig or /index.twig — refuses to run if the page already exists, or if any segment is reserved (starts with _, or is literally 404 — see Routing).
+{% endblock %}
diff --git a/novaconium/pages/admin/docs/layouts/index.twig b/novaconium/pages/admin/docs/layouts/index.twig
new file mode 100644
index 0000000..9030a7b
--- /dev/null
+++ b/novaconium/pages/admin/docs/layouts/index.twig
@@ -0,0 +1,28 @@
+{% extends 'admin/docs/_layout/layout.twig' %}
+
+{% block title %}Layouts{% endblock %}
+
+{% block description %}How pages and layouts are overridable, just like Lib\.{% endblock %}
+
+{% block robots %}noindex, nofollow{% endblock %}
+
+{% block docs_content %}
+
Pages and layouts are overridable, just like Lib\
+
+
Routing doesn't resolve against a single pages/ tree — it resolves against an ordered list of roots: App/pages/ first, then novaconium/pages/ as the framework's fallback default. The default _layout/layout.twig and 404/index.twig ship in novaconium/pages/; a project doesn't need to duplicate them to have a working site, but can override either (or add any page) just by placing a file at the same relative path in App/pages/. Same mechanism as Lib\ overrides in App/lib/, applied to pages.
+
+
Shared layout markup lives in _layout/layout.twig directories. The renderer walks upward from the matched page looking for the nearest one, checking App/pages/ before novaconium/pages/ at every level — so you can override the layout for a whole subtree just by dropping a new _layout/ next to it:
+
+
novaconium/pages/_layout/layout.twig <- framework default, used unless overridden
+App/pages/_layout/layout.twig <- overrides the site-wide default (optional)
+App/pages/blog/_layout/layout.twig <- overrides it for everything under /blog
+
+
A nested layout can extend the parent one (paths are resolved against the page roots, not relative to the current file):
Plain PHP classes live in App/lib/ (or novaconium/lib/ for defaults) under the Lib\ namespace and are autoloaded automatically — call them from any sidecar:
+
+
use Lib\Mailer;
+
+(new Mailer())->send($old['name'], $old['email'], $old['message']);
+
+
Framework-default classes
+
+
novaconium/lib/ ships a few ready-to-use classes any sidecar can call, same as a project's own App/lib/ classes — override any of them by dropping a same-named file in App/lib/:
+
+
+
Lib\Mailer — the contact form's mail stand-in (logs to a file instead of an external mail dependency; see its own source for the note on swapping in a real mail call).
+
Lib\SpamGuard — self-hosted honeypot + submission-timing spam detection, reusable on any form. See Sidecars' "Spam prevention" section for the full write-up.
+
Lib\FormValidator — a small accumulating required-field/email/length validator, so a form sidecar doesn't hand-roll the same checks and $errors array every time. Also covered in Sidecars' "Spam prevention" section.
+
Lib\Validate — the lower-level validation primitives FormValidator calls into (isEmail(), minLength()/maxLength(), isMatch(), isPhone(), isPostalCode()/isZipCode()) — call these directly from a sidecar when you just need a validated/normalized value back rather than an accumulated field-error. See Sidecars' "Spam prevention" section.
+
Lib\Input — a cleaning accessor for $_POST/$_GET (Input::post()/Input::get()), used by every sidecar instead of the superglobals directly. Defense-in-depth against HTML/script injection, not a defense against SQL injection — see Sidecars' "Form security" section for the full caveat.
+
Lib\Csrf — standalone session-token CSRF protection (Csrf::token()/::verify()), called directly from a sidecar rather than through FormValidator. See Sidecars' "Form security" section.
The root layout (novaconium/pages/_layout/layout.twig) includes _layout/matomo.twig, which emits the standard Matomo tracking snippet on every page — off by default, enabled by supplying two App/config.php keys.
Both matomo_url and matomo_site_id default to an empty string in novaconium/config.php. The layout only renders the tracking <script> when both are set — leaving either one out (e.g. in local development) disables tracking entirely, with zero script emitted. A missing trailing slash on matomo_url is normalized automatically in novaconium/bootstrap.php.
+
+
404 tracking
+
+
Matomo doesn't know a page is a 404 unless told — its documented convention is to set the document title to 404/URL = <requested path>/From = <referrer> immediately before trackPageView, so 404 hits are filterable under Behaviour → Page URLs by searching for 404. The layout does this automatically: novaconium/src/Renderer.php::renderNotFound() passes is_404 => true into the 404 template's context (a Twig global elsewhere, defaulting to false), and the layout's tracking script checks that flag to decide whether to push setDocumentTitle before trackPageView.
+
+
What's included
+
+
+
trackPageView on every request.
+
enableLinkTracking for outbound-link and download tracking.
+
404-specific document title tagging as described above.
+
+
+
The snippet is the same one Matomo's own admin UI generates ("JS Tracking Code"), so anything from Matomo's docs that builds on _paq.push([...]) calls (custom events, goal tracking, ecommerce, etc.) can be added directly to it.
+
+
Overriding the snippet
+
+
The snippet lives in its own file, novaconium/pages/_layout/matomo.twig, specifically so it's overridable — same App-over-novaconium mechanism used for every other page and layout, not a special case. Drop a same-named file at App/pages/_layout/matomo.twig to replace it entirely without touching novaconium/: point at a self-hosted tracker proxy, add extra _paq.push calls, gate it behind a consent check, or swap in a completely different analytics snippet. matomo_url, matomo_site_id, and is_404 are all available in the override's context, same as in the original.
+
+
Privacy note
+
+
This only wires up the tracking snippet — it does not add a cookie/consent banner or handle Do Not Track. If your jurisdiction requires consent before loading analytics, gate the matomo_url/matomo_site_id config or override _layout/matomo.twig behind whatever consent mechanism the project uses.
public/ Apache document root
+ index.php thin front controller — just requires novaconium/bootstrap.php
+ .htaccess cache short-circuit, canonical redirects, rewrite to index.php
+ router.php dev-only helper for `php -S` (not used in production)
+ cache/ generated static HTML (safe to delete anytime)
+ css/ compiled CSS output
+App/ your project — the only directory you're expected to edit
+ pages/ your routes — directory tree = URL tree, checked before novaconium/pages/ so you can add or override pages/layouts
+ lib/ your PHP classes (Lib\), checked before novaconium/lib/ so you can override defaults
+ sass/ your Sass overrides — _colors.sass, checked before novaconium/sass/defaults/
+ config.php your config overrides — ships as an empty, commented placeholder; shallow-merged over novaconium/config.php
+novaconium/ the framework itself — boilerplate, not meant to be edited per-project
+ pages/ default pages: _layout/layout.twig (root layout) and 404/index.twig (used when App/pages/ doesn't override them)
+ sass/ Sass source: main.sass (structure) + defaults/_colors.sass (default palette, overridable from App/sass/)
+ lib/ default Lib\ classes (used when App/lib/ doesn't override them)
+ src/ Router, Route, Renderer, Response, Cache, Overlay (the App/-over-novaconium/ lookup used for both pages and lib)
+ vendor/twig/ vendored Twig source (no Composer)
+ bin/
+ clear-cache.php standalone CLI entry point — `php novaconium/bin/clear-cache.php`
+ create-static-page.php scaffolds a new page from the SEO starter template — `php novaconium/bin/create-static-page.php `
+ autoload.php manual PSR-4 autoloader
+ config.php paths and settings
+ bootstrap.php wires everything together for each request
+
+
How a request flows through these files
+
+
There's no framework "kernel" class — just a plain chain of requires, each handing off to the next, deliberately kept flat enough to read top-to-bottom in one sitting:
+
+
+
public/.htaccess runs first, before PHP does anything. If public/cache/<path>/index.html exists for the requested URL, Apache serves that file directly and nothing below this line ever executes — see Static caching. Otherwise it strips a trailing slash (301 redirect) and rewrites everything else to public/index.php.
+
public/index.php is intentionally one line: require __DIR__ . '/../novaconium/bootstrap.php';. (Running locally via php -S instead of Apache? public/router.php mimics the same three .htaccess rules in PHP, then requires index.php the same way — see Getting started.)
+
novaconium/bootstrap.php is where the real wiring happens, top-to-bottom:
+
+
Requires novaconium/autoload.php, registering the Twig\/App\/Lib\ class autoloader (see Libraries) before anything below tries to instantiate a class.
+
Requires novaconium/config.php, then shallow-merges App/config.php over it if that file exists — see Configuration.
+
Special-cases /admin/logout directly against the request path — see Admin authentication — since it isn't a real page for the router to find.
+
Constructs a Router (novaconium/src/Router.php) and calls resolve() to turn the URL into a Route (novaconium/src/Route.php) — see Routing.
+
If the resolved route is under admin/admin/*, calls AdminAuth::requireLogin() (novaconium/src/AdminAuth.php), reading that same Route.
+
Constructs a Cache (novaconium/src/Cache.php) and a Renderer (novaconium/src/Renderer.php), then calls renderNotFound() or render($route, ...) depending on $route->found — see Sidecars for what happens inside Renderer itself (running the matched directory's index.php, if any; resolving the nearest layout; rendering index.twig; writing the static cache for sidecar-less pages).
+
+
+
+
+
Every step after step 1 is plain PHP you can read start to finish in novaconium/bootstrap.php itself — the comments there walk through the same five sub-steps in more detail than this page does.
+{% endblock %}
diff --git a/novaconium/pages/admin/docs/routing/index.twig b/novaconium/pages/admin/docs/routing/index.twig
new file mode 100644
index 0000000..78b759f
--- /dev/null
+++ b/novaconium/pages/admin/docs/routing/index.twig
@@ -0,0 +1,73 @@
+{% extends 'admin/docs/_layout/layout.twig' %}
+
+{% block title %}Routing{% endblock %}
+
+{% block description %}How a URL maps to a directory under App/pages/.{% endblock %}
+
+{% block robots %}noindex, nofollow{% endblock %}
+
+{% block docs_content %}
+
How routing works
+
+
A URL maps directly to a directory under App/pages/:
Every route is App/pages/<segments>/index.twig (and/or index.php — see Sidecars). There are no flat about.twig files and no route table to maintain.
+
A directory named [param] matches any single URL segment and captures it into $params['param'] — that's how you get clean URLs like /products/socks with no query string. (This project's own App/pages/blog/ doesn't currently use this — every post there is a plain directory named after its slug, e.g. App/pages/blog/hello-world/ — but the mechanism is still fully supported.)
+
Canonical URLs never have a trailing slash. /about/ 301-redirects to /about.
+
Directories starting with _ (like _layout/) and a directory literally named 404 are reserved and can never be requested directly.
+
+
+
For developers: using Router.php directly
+
+
novaconium/src/Router.php is the class that does the walk described above. It's deliberately a pure lookup — given a URL, it answers "does a page exist here, and if so which directory and what params?" — and knows nothing about Twig, sidecars, caching, or output. It's also what makes Router easy to use in isolation — in a test, a script, or anywhere you just want "what would this URL resolve to?" without booting the whole render pipeline.
+
+
How Route.php fits in
+
+
novaconium/src/Route.php is the value object Router::resolve() returns — three readonly properties (found, dir, params) and a Route::notFound() factory, no behavior at all. It's the thing that actually flows through the rest of the request, decoupling Router from everything downstream: Router produces a Route and is done, and every later step only ever reads it. See novaconium/bootstrap.php, which runs top-to-bottom as a plain script rather than through a framework "kernel" class:
/admin/logout is special-cased directly against the raw request path, before routing even runs — it isn't a real page, so there'd be no Route for it anyway.
+
Router::resolve() runs and returns a Route — this is the only place a Route gets created.
+
If $route->dir is under admin/admin/*, AdminAuth::requireLogin() gates it — reading $route->dir directly off the value object Router handed back.
+
Renderer takes over, also just reading the same Route: renderNotFound() if $route->found is false, otherwise render($route, ...) — using $route->dir to find the sidecar/layout/template and $route->params as template context.
+
+
+
The key point: Route is passed around, not Router itself — bootstrap.php only calls Router::resolve() once, and the plain data object it gets back is what AdminAuth and Renderer both independently inspect afterward. Neither of them needs a reference to Router, Overlay, or the page-root filesystem lookup that produced the Route — that's why adding a new admin page, or a new reserved segment, or a new render behavior never means touching Route.php itself; it stays a dumb, stable carrier.
+
+
Constructing it
+
+
use App\Router;
+
+$router = new Router($config['pages_dirs']);
+
+
The constructor takes the same ordered list of page roots as everything else in this framework — App/pages/ first, novaconium/pages/ as fallback (see novaconium/config.php's pages_dirs). Order matters: it's what lets a project override a page just by placing one at the same relative path in App/pages/.
+
+
resolve() and the Route it returns
+
+
$route = $router->resolve('/blog/hello-world');
+
+$route->found; // bool — true if a real page/sidecar exists at this path
+$route->dir; // ?string — the matched directory, relative to the page roots
+ // (e.g. "blog/hello-world"), or null if not found
+$route->params; // array<string,string> — captured [param] segments, e.g.
+ // ['id' => 'socks'] for a /products/[id]/ route
+
+
resolve() takes a full request URI (query string and all — it strips that internally with strtok($requestUri, '?')) and returns a Route value object (novaconium/src/Route.php): three readonly properties, no behavior. A not-found result is just Route::notFound() — dir and params are null/empty, found is false. There's no exception thrown for a 404; check $route->found the same way bootstrap.php does.
+
+
What actually makes something "found"
+
+
Walking the URL segment by segment, Router resolves each one against the page roots via Overlay (see novaconium/src/Overlay.php): an exact-name subdirectory wins if one exists in either root, otherwise a [param]-named subdirectory captures the segment. A segment starting with _ or literally named 404 short-circuits straight to not-found, regardless of what's on disk — those are always reserved. At the end of the walk, the resolved directory still has to contain an index.twigor an index.php (a JSON-only API endpoint, say, can skip the template entirely) — no template and no sidecar means not-found even if every segment matched a real directory along the way.
+
+
Since Router has no dependencies beyond Overlay and does no I/O beyond filesystem existence checks, it's straightforward to exercise directly — construct it with a real (or temporary/fixture) pagesDirs array and assert on the Route it returns, without needing to spin up the full HTTP request cycle.
+{% endblock %}
diff --git a/novaconium/pages/admin/docs/seo/index.twig b/novaconium/pages/admin/docs/seo/index.twig
new file mode 100644
index 0000000..3fc2c4f
--- /dev/null
+++ b/novaconium/pages/admin/docs/seo/index.twig
@@ -0,0 +1,94 @@
+{% extends 'admin/docs/_layout/layout.twig' %}
+
+{% block title %}SEO{% endblock %}
+
+{% block description %}The SEO meta tags every page gets for free, and how to override them per-page.{% endblock %}
+
+{% block robots %}noindex, nofollow{% endblock %}
+
+{% block docs_content %}
+
SEO boilerplate
+
+
novaconium/pages/_layout/layout.twig (the root layout every page extends, directly or via a nested layout) renders a full set of SEO meta tags in <head>: viewport, description, robots, canonical link, Open Graph, and Twitter Card. Each piece is a named Twig block with a sensible default, so any page can override just the piece it needs without touching the rest of <head>.
+
+
Blocks you can override
+
+
+
+
Block
Default
Renders as
+
+
+
title
site_name config value
<title>, and reused by og:title / twitter:title
+
description
generic site description
<meta name="description">, and reused by og:description / twitter:description
+
robots
index, follow
<meta name="robots">
+
canonical
{{ '{{ request_path }}' }}
<link rel="canonical">, and reused by og:url
+
og_type
website
<meta property="og:type">
+
og_title
{{ '{{ block(\'title\') }}' }}
<meta property="og:title">
+
og_description
{{ '{{ block(\'description\') }}' }}
<meta property="og:description">
+
og_url
{{ '{{ block(\'canonical\') }}' }}
<meta property="og:url">
+
twitter_card
summary
<meta name="twitter:card">
+
twitter_title
{{ '{{ block(\'title\') }}' }}
<meta name="twitter:title">
+
twitter_description
{{ '{{ block(\'description\') }}' }}
<meta name="twitter:description">
+
+
+
+
Blocks that piggyback on another block (e.g. og_title defaulting to {{ '{{ block(\'title\') }}' }}) use Twig's block() function, not inheritance — so setting title alone is enough to update og:title and twitter:title too, unless the page also overrides those blocks explicitly.
+
+
Overriding on a page
+
+
Any index.twig can override any subset of these blocks, same as title or content:
See any post under App/pages/blog/ (e.g. App/pages/blog/twig-syntax-guide/index.twig) for a working example that sets og_type to article with a hand-written description. If you ever need to derive a description from a longer body string, don't reach for Twig's |slice filter — applied to a string, it calls mb_substr() unconditionally with no fallback, which hard-requires the mbstring extension. Truncate in PHP instead, guarded with function_exists('mb_substr'); see the footnote on the Twig Syntax Guide for the story of why this project cares.
+
+
Copy-paste starter: every block, explicitly
+
+
Every App/pages/*/index.twig page in this project already includes the full block below as a reference — copy it into a new page and fill in the blanks. Nothing here is required (the layout's defaults are fine on their own), but having every knob visible up front makes it obvious what's available. Don't want to copy-paste by hand? php novaconium/bin/create-static-page.php <path> scaffolds this exact template for you — see Getting started.
To keep a page out of search results, change only the robots line to noindex, nofollow and delete the rest — everything else can be left to the layout's defaults, same as the /admin pages do.
+
+
Canonical URLs and request_path
+
+
The canonical link (and og:url) default to a request_path variable that novaconium/src/Renderer.php injects into every template's context — the request URI's path component, computed via parse_url($requestUri, PHP_URL_PATH). You don't need to set this yourself; it's already correct for every route, including the 404 page. If you want an absolute canonical URL instead of a path (e.g. https://example.com/about rather than /about), override the canonical block per-page or add a site-wide base URL to novaconium/config.php and reference it in the layout.
+
+
Keeping admin/internal pages out of search results
+
+
Pages that shouldn't be indexed — /admin, /admin/clear-cache, every /admin/docs/* page, and the 404 page — override robots to noindex, nofollow. Follow the same pattern for any project-specific admin or internal tooling pages you add under App/pages/.
+
+
Favicon
+
+
The layout links <link rel="icon" href="/favicon.ico"> unconditionally — drop a favicon.ico into public/ to have it picked up; there's no fallback or generation step.
$params (the captured route segments, e.g. an [id] directory's id) is already in scope — no need to touch $_GET.
+
+
A Response object — short-circuits Twig entirely
+
+
use App\Response;
+
+Response::redirect('/somewhere');
+Response::json(['ok' => true]);
+Response::xml('<root/>');
+Response::html('<h1>raw</h1>');
+
+
A sidecar-only page (no index.twig at all) is fine too — e.g. an App/pages/api/posts/index.php returning just Response::json([...]) for a JSON-only endpoint. Twig never runs for that route at all; the sidecar's return value is the entire response.
+
+
Form handling — a sidecar can branch on $_SERVER['REQUEST_METHOD'], validate $_POST, call a library, and only return Response::redirect() once it succeeds (the classic POST/redirect/GET pattern, so a page refresh doesn't resubmit the form). See App/pages/contact/index.php + App/pages/contact/index.twig for the full example — it validates name/email/message, calls Lib\Mailer::send(), and redirects to /contact?sent=1 to show a success message.
+
+
Form security
+
+
Every form on this site combines three independent layers, all reusable Lib\ classes: input cleaning, CSRF protection, and spam prevention. None of them depend on each other — a form can use any subset.
+
+
Input cleaning
+
+
Lib\Input (novaconium/lib/Input.php) is a drop-in replacement for reading $_POST/$_GET directly — every sidecar on this site uses it instead of touching the superglobals:
+
+
use Lib\Input;
+
+$name = Input::post('name', ''); // trimmed, tags stripped, null bytes removed
+$sent = Input::get('sent') !== null; // was ?sent= present at all?
+$all = Input::post(); // the whole cleaned $_POST array
+
+
Calling post()/get() with no key returns the entire cleaned array (handy for passing straight to SpamGuard::isSpam(), as below); calling it with a key returns that key's cleaned value, or the given default if it's absent. Nested arrays (e.g. a checkbox group posted as tags[]) are cleaned recursively. The result is memoized per request, so calling Input::post() repeatedly across a sidecar doesn't re-clean the superglobal each time.
+
+
This is not SQL-injection protection. The cleaning Input does (trim + strip tags, via Lib\Validate::clean(), plus null-byte stripping) is defense-in-depth against HTML/script injection in output contexts — Twig already autoescapes {{ }} output by default (see novaconium/src/Renderer.php), so this is a second layer, not the only one. No string transform makes arbitrary input safe to concatenate into a SQL query; the real defense is parameterized queries (PDO prepared statements). There's no database layer in this framework yet (SQLite groundwork is Backlog — see novaconium/ISSUES.md); when one lands, use prepared statements exclusively. Input deliberately has no sqlSafe()-style method, since a method implying "cleaned = safe to interpolate into SQL" would be actively dangerous.
+
+
One documented exception: a field that needs an exact, unmodified value — a password about to be hashed, say — should read $_POST directly instead of going through Input::post(). Cleaning would silently strip characters like </> before hashing, producing a hash that doesn't match what's actually typed later. See novaconium/pages/admin/password-hash/index.php for the one place this framework does that on purpose.
+
+
CSRF protection
+
+
Lib\Csrf (novaconium/lib/Csrf.php) is a standalone session-token CSRF guard — standalone meaning it isn't wired into FormValidator's chain, so a sidecar calls it directly, typically as the very first check on a POST:
Csrf::token() is idempotent per session — it doesn't rotate on every call, so a form that re-renders after a validation error still verifies correctly on the next submit. It's the first thing in the framework that starts a native PHP session, and only lazily: a page that never calls Csrf never gets a session cookie, so the rest of the site stays session-free. The session cookie itself is hardened (httponly, SameSite=Lax, secure when the request is HTTPS) inside Csrf's private ensureSession().
+
+
A failed CSRF check shows a real, visible error — "Your session expired before submitting — please try again." — unlike a failed spam check, which redirects to the same success URL either way. That's deliberate: a CSRF failure is usually a legitimate stale-tab case for a real visitor, not something worth hiding, whereas hiding a spam block from a bot is the whole point of that check.
+
+
Spam prevention
+
+
The contact form fends off basic spam without an external CAPTCHA service (no CDN script, no site key/secret key, no outbound API call on every submission — consistent with this project's self-hosted-everything approach), using Lib\SpamGuard (novaconium/lib/SpamGuard.php) — a framework-default Lib\ class, reusable on any form a project adds, the same way Lib\Mailer is:
+
+
use Lib\Input;
+use Lib\SpamGuard;
+
+$spamGuard = new SpamGuard(); // honeypot field 'website', timestamp field 'rendered_at', 2s minimum
+
+if (!$spamGuard->isSpam(Input::post())) {
+ // ...actually send the message...
+}
+
+// In the sidecar's returned context, for the hidden timestamp field:
+'renderedAt' => $spamGuard->renderedAt(),
+
+
Two checks run inside isSpam(), both purely server-side:
+
+
+
Honeypot field. The paired Twig template renders a website input inside a .hp-field wrapper, positioned off-screen with CSS (position: absolute; left: -9999px — deliberately notdisplay: none or visibility: hidden, since some spam bots specifically skip fields hidden that way) and marked aria-hidden="true" with tabindex="-1" so it's invisible to real visitors and screen readers alike. A bot that auto-fills every input on a form fills this one too; any non-empty value counts as spam.
+
Timing check. A hidden field (rendered via $spamGuard->renderedAt(), read back as rendered_at) carries the Unix timestamp of when the form was rendered. On submit, anything completed in under the constructor's $minSeconds (2 by default) counts as spam — plausible for a script filling and submitting a form instantly, implausible for a human reading the form and typing a message. This value isn't cryptographically signed, so a determined bot could forge it; it's a deterrent against unsophisticated spam, not a security boundary.
+
+
+
isSpam() tripping either check doesn't stop the sidecar from validating and redirecting normally — see App/pages/contact/index.php, which still returns Response::redirect('/contact?sent=1') regardless, and only skips Lib\Mailer::send() when spam is detected. That's deliberate: a bot gets the exact same success response a human would, with nothing revealing which check it tripped, or that a check exists at all. Both the honeypot field name, timestamp field name, and minimum seconds are constructor arguments (new SpamGuard('website', 'rendered_at', 2)), so a second form on the same site can use different field names without the two forms interfering with each other.
+
+
Field validation (required fields, email format, length limits) is its own reusable class, Lib\FormValidator (novaconium/lib/FormValidator.php) — an accumulating validator so a sidecar doesn't hand-roll the same checks and $errors array every time:
+
+
use Lib\FormValidator;
+
+$validator = (new FormValidator())
+ ->required($old['name'], 'name', 'Name is required.')
+ ->email($old['email'], 'email', 'A valid email is required.')
+ ->required($old['message'], 'message', 'Message is required.')
+ ->maxLength($old['message'], 'message', 2000, 'Message is too long.');
+
+if ($validator->passes()) {
+ // ...
+}
+
+$errors = $validator->errors();
+
+
FormValidator doesn't implement validation logic itself — each check delegates to Lib\Validate (novaconium/lib/Validate.php), a set of stateless, static validation primitives modeled after the project author's own reusable validation class: clean(), isEmail(), minLength()/maxLength(), isMatch() (e.g. a "confirm email" field), isPhone() (7- or 10-digit, with optional extension), and isPostalCode()/isZipCode(). Call Validate directly from a sidecar when you just need a validated/normalized value back — e.g. Validate::isPhone($old['phone'], withExtension: true) — rather than an accumulated field-error:
+
+
use Lib\Validate;
+
+$phone = Validate::isPhone($old['phone']); // '5551234567' or false
+
+
All three classes live under novaconium/lib/ as framework defaults — like any other Lib\ class, a project can override any of them by dropping a same-named file in App/lib/ (see Libraries).
+
+
Copy-paste starter: a sidecar, three ways
+
+
Drop this in as App/pages/example/index.php (next to an App/pages/example/index.twig using the SEO starter template) and delete whichever example you don't need:
+
+
{% verbatim %}<?php
+// App/pages/example/index.php
+
+// 1. Say hello — becomes Twig context, so {{ message }} works in index.twig.
+return [
+ 'message' => 'Hello, World!',
+];
+
+// 2. Show the visitor's IP — same idea, just another key in the array.
+// return [
+// 'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
+// ];
+
+// 3. Run phpinfo() — a Response short-circuits Twig entirely, which
+// phpinfo() needs since it echoes its own complete HTML page. Capture
+// that output with ob_start()/ob_get_clean() and hand it to
+// Response::html() rather than letting phpinfo() echo directly (which
+// would print before whatever index.twig renders, out of order).
+// use App\Response;
+//
+// ob_start();
+// phpinfo();
+// return Response::html(ob_get_clean());{% endverbatim %}
+
+
Only one of the three returns in a real sidecar ever runs, obviously — pick one, or branch between them with an if. The IP example works with or without a sidecar-only page (no index.twig); the phpinfo() example needs noindex.twig at all, since Response::html() bypasses Twig — see the JSON-only example above for another sidecar-only page.
+
+
Never ship phpinfo() to production — it dumps environment variables, file paths, loaded extensions, and configuration values that are useful to an attacker mapping your server. Delete the page after you're done with it, or at minimum gate it behind admin authentication the same way /admin/* already is, so it's never reachable by the public.
+
+
For developers: using Response.php directly
+
+
novaconium/src/Response.php is the small value object behind Response::redirect()/::json()/::xml()/::html() above. Like Route (see /admin/docs/routing's "How Route.php fits in" section), it's a dumb data carrier with one job: describe a response, without emitting anything itself, so it can be constructed in a sidecar and only actually acted on later, by Renderer.
+
+
How it fits in
+
+
Its constructor is private — you never call new Response(...) directly, only one of the four named factories, each of which fixes the type/headers that go with that kind of response:
+
+
Response::redirect(string $url, int $status = 301): self // type 'redirect', no extra headers
+Response::json(mixed $data, int $status = 200): self // type 'json', Content-Type: application/json
+Response::xml(string $xml, int $status = 200): self // type 'xml', Content-Type: application/xml
+Response::html(string $html, int $status = 200): self // type 'html', Content-Type: text/html
+
+
Every factory returns a fully-formed, readonly Response — type, body, status, headers — and nothing has happened yet. A sidecar just returns that object; it's Renderer::render() (see this page's Renderer.php section above) that checks $result instanceof Response and, if so, calls $result->emit() and stops, skipping Twig entirely. That's the entire contract between a sidecar and the framework: return an array for Twig context, or return a Response to bypass it.
+
+
What emit() actually does
+
+
emit() is the one method with side effects, and it's only ever called from inside Renderer, never from a sidecar itself:
+
+
+
Sets the HTTP status code via http_response_code($this->status).
+
Sends each header in $this->headers (empty for redirect, a single Content-Type for the other three).
+
For a redirect, sends a Location header (the URL passed to Response::redirect()) and returns — no body.
+
For json, encodes $this->body with json_encode(..., JSON_THROW_ON_ERROR) and echoes it — the JSON_THROW_ON_ERROR flag means an unencodable value (e.g. a resource, or a value containing invalid UTF-8) throws a JsonException rather than silently emitting false as the body.
+
For xml/html, $this->body is already a string (built by the caller), so it's echoed as-is — Response doesn't validate or escape it.
+
+
+
Because every property is readonly and the type/body/status/headers are fixed at construction, a Response is safe to build early in a sidecar and pass around (or return immediately) without worrying about it changing shape before Renderer gets to it — there's no setter to call by mistake.
+
+
For developers: using Renderer.php directly
+
+
novaconium/src/Renderer.php is the class that actually runs a sidecar and turns its return value into a response — everything in this page so far (arrays becoming Twig context, Response objects short-circuiting) is Renderer's doing. Unlike Router (see /admin/docs/routing's "For developers" section), it isn't a pure lookup: render() and renderNotFound() both emit directly — http_response_code(), header(), echo — rather than returning a string, so it's a class you call once per request for its side effects, not one you inspect a return value from.
+
+
How it fits in
+
+
Renderer is the last thing a request touches, in novaconium/bootstrap.php: Router::resolve() produces a Route, AdminAuth optionally gates it, and then Renderer reads that same Route to actually produce output. It never talks back to Router or AdminAuth — by the time it runs, routing and auth are already decided, and its only inputs are the Route and the raw request URI:
The first two constructor arguments are the same pagesDirs override-root list every other class here takes, plus a Cache instance (novaconium/src/Cache.php) it writes sidecar-less pages' output to. The remaining four — admin-auth-enabled flag, Matomo URL/site ID, site name — aren't used for routing or rendering logic at all; they're registered as Twig globals ($this->twig->addGlobal(...)) purely so every template can read matomo_url, site_name, etc. without a sidecar having to pass them through manually. A fifth global, is_404, defaults to false here and is overridden per-render — see below.
+
+
What render() actually does, in order
+
+
+
Looks for index.php at $route->dir via Overlay::findFile() (checking App/pages/ before novaconium/pages/, same as everywhere else) and, if one exists, requires it in a scope where $params and $cache are already defined — see runSidecar().
+
If the sidecar returned a Response, calls $result->emit() and returns immediately — Twig never runs, nothing gets cached.
+
Otherwise treats the return value as Twig context, merging in params, the resolved layout path, and request_path.
+
Renders index.twig at $route->dir with that context, emits 200 + the HTML.
+
Only if there was no sidecar, writes the rendered HTML to the static cache (Cache::write()) — a page with a sidecar is never cached this way, since its output can vary per request.
+
+
+
renderNotFound() is a smaller version of the same idea: no sidecar to run, always emits 404, renders 404/index.twig if one exists in either page root (with is_404 forced to true in that render's context — see /admin/docs/matomo for what that flag is used for), and falls back to a bare 404 Not Found string if even the default 404 template is missing.
+
+
Layout resolution
+
+
Both methods get their layout value from the private relativeLayoutPath(), which walks upward from the matched directory looking for the nearest _layout/layout.twig — checking both page roots at every level before going up one more directory — until it either finds one or runs out of directory to walk (returning null, which only happens if even the root _layout/layout.twig is missing from both roots). This is what lets App/pages/blog/_layout/layout.twig override the site-wide layout for just that subtree, per Layouts.
+
+
Because render()/renderNotFound() write straight to PHP's output buffer and response headers rather than returning anything, testing Renderer in isolation means capturing output (e.g. ob_start()) and inspecting headers, rather than asserting on a return value the way you can with Router::resolve()'s Route.
+{% endblock %}
diff --git a/novaconium/pages/admin/docs/styling/index.twig b/novaconium/pages/admin/docs/styling/index.twig
new file mode 100644
index 0000000..a1b151b
--- /dev/null
+++ b/novaconium/pages/admin/docs/styling/index.twig
@@ -0,0 +1,60 @@
+{% extends 'admin/docs/_layout/layout.twig' %}
+
+{% block title %}Styling{% endblock %}
+
+{% block description %}Sass, indented syntax, compiled to public/css/main.css, with an overridable color palette.{% endblock %}
+
+{% block robots %}noindex, nofollow{% endblock %}
+
+{% block docs_content %}
+
Styling (Sass, indented syntax)
+
+
Source lives in novaconium/sass/main.sass (indented syntax, not SCSS). Compile it with Dart Sass, passing both Sass directories as load paths:
There's no PHP-based Sass compiler in this project (PHP options like scssphp only understand SCSS syntax) — this is a manual/CI build step, not something the app does at runtime.
+
+
Don't have Dart Sass installed? Run it via Docker instead — copy-paste this Dockerfile, which installs the same official standalone Dart Sass release used in this environment (1.101.0, via pacman -S dart-sass on Arch), not the npm-wrapped build:
Check the dart-sass releases page for a newer version and swap both occurrences of 1.101.0 in the download URL if you want to track latest instead of matching this environment.
+
+
Build it once, then run it the same way you'd run the local sass CLI (skip the leading sass in the command — the image's ENTRYPOINT already supplies it):
See git.4lt.ca/4lt/novaconium/docs/Sass.md for this project's own write-up of the Docker approach in general; the Dockerfile and paths/flags above are this project's own, adjusted to use Dart Sass directly and this repo's current layout.
+
+
Overriding just the colors
+
+
novaconium/sass/main.sass starts with @use 'colors' as *, but its own directory has no _colors.sass of its own — on purpose, so that @use falls through to the Sass load path above rather than resolving to a sibling file. App/sass/_colors.sass is checked first; novaconium/sass/defaults/_colors.sass (the framework's own palette) is the fallback. Same override-by-presence mechanism as App/pages/ over novaconium/pages/, just applied to Sass instead of Twig/PHP.
+
+
To reskin the whole site, edit the seven variables in App/sass/_colors.sass — $bg, $surface, $text-color, $muted-color, $border-color, $accent, $accent-hover — and recompile. Nothing under novaconium/ needs to change. Delete App/sass/_colors.sass entirely to fall back to the framework's default palette instead.
+
+
Dark/light theme toggle
+
+
Every color rule in main.sass reads a CSS custom property (var(--bg), var(--accent), etc.) instead of a Sass variable directly. The Sass variables only seed the initial :root values at compile time; a :root[data-theme="light"] block overrides all seven using -light-suffixed variables from the same _colors.sass files ($bg-light, $surface-light, etc.) — same override mechanism, same files, a second palette.
+
+
The toggle button in novaconium/pages/_layout/nav.twig flips a data-theme attribute on <html> at runtime and persists the choice to localStorage. novaconium/pages/_layout/theme-init.twig, included early in <head> before the stylesheet, re-applies a saved choice before first paint on every later page load, so switching to light doesn't flash dark first. The sun/moon icon swap inside the button is pure CSS reacting to the attribute — no JS involved there — so it works correctly even on sidecar-less pages that get statically cached.
+
+
To customize the light theme the same way you'd customize the dark one, edit the -light variables in App/sass/_colors.sass and recompile.
Twig is vendored in source form under novaconium/vendor/twig/ (no Composer — see Upgrading Twig for how to upgrade it). It's BSD-3-Clause licensed; the full license text ships alongside it at novaconium/vendor/twig/LICENSE.
This project has no Composer — Twig is vendored by hand as source files under novaconium/vendor/twig/. There's no lockfile and no composer.json, so upgrading is a manual copy-and-verify process rather than composer update.
+
+
What's currently vendored
+
+
Version: 3.28.0 (see novaconium/vendor/twig/src/Environment.php, the Environment::VERSION constant — that constant is always the source of truth, this doc can drift).
+
Only the src/ directory from the Twig package is vendored — no tests, no docs, no composer.json. novaconium/autoload.php maps the Twig\ namespace straight at novaconium/vendor/twig/src/, so anything Twig autoloads has to live at the matching path under there.
+
novaconium/autoload.php also defines a trigger_deprecation() shim. Twig 3.x calls this function (normally supplied by symfony/deprecation-contracts, which isn't vendored here) whenever it hits a deprecated code path. Without the shim, upgrading to a Twig version that deprecates something this project uses would fatal instead of warn.
+
+
+
How to upgrade
+
+
Pick the target version from Twig's GitHub releases: https://github.com/twigphp/Twig/releases. Read the changelog for breaking changes, in particular anything touching Environment, Loader\FilesystemLoader, {% verbatim %}{% extends %}{% endverbatim %}/{% verbatim %}{% include %}{% endverbatim %} resolution, or autoescaping — those are the parts of Twig this project actually exercises (see novaconium/src/Renderer.php).
+
Download the release source (the "Source code (zip)" asset on the release page, or git clone --branch vX.Y.Z --depth 1 https://github.com/twigphp/Twig if you have network access to GitHub).
+
From the downloaded copy, take only the src/ directory.
+
Replace novaconium/vendor/twig/src/ wholesale with that new src/ directory (delete the old one first so removed files don't linger).
+
Copy the new LICENSE file over novaconium/vendor/twig/LICENSE too, in case it changed.
+
Confirm the namespace layout is unchanged: novaconium/autoload.php assumes Twig\Foo\Bar lives at src/Foo/Bar.php relative to the vendor root. This has been stable across Twig 3.x, but double-check if jumping a major version.
+
Run the app and click through every route (or run the manual checklist in the Design notes' Verification section) — there's no automated test suite, so this is the actual regression check:
+
/blog and any post under it — a sidecar-driven listing plus sidecar-less posts.
+
A _layout/layout.twig overriding the root layout — exercises nested {% verbatim %}{% extends %}{% endverbatim %} resolution across the App/pages/ + novaconium/pages/ overlay (see novaconium/src/Overlay.php).
+
/contact — form handling, {% verbatim %}{% if %}{% endverbatim %} blocks, loop-free but exercises variable escaping ({% verbatim %}{{ old.name }}{% endverbatim %} etc.) — good smoke test for autoescape behavior changes.
+
/admin/docs and its subpages — pure Twig, no |raw — confirm they still render as expected after the upgrade.
+
+
+
If PHP now emits E_USER_DEPRECATED warnings that weren't there before (visible because debug is true in novaconium/config.php), that's the trigger_deprecation() shim doing its job — read the message and update the calling code in novaconium/src/Renderer.php before the next major Twig version removes the deprecated path entirely.
+
Update the version note at the top of this page.
+
+
+
Why not just use Composer?
+
The project intentionally has no build/install step — cloning the repo and pointing a web server at public/ is enough. That's a deliberate trade-off: upgrades are manual, but there's nothing to install, no lockfile to drift, and no vendor/ regeneration step for a fresh deploy.
+
+{% endblock %}
diff --git a/novaconium/pages/admin/password-hash/index.php b/novaconium/pages/admin/password-hash/index.php
new file mode 100644
index 0000000..eba1337
--- /dev/null
+++ b/novaconium/pages/admin/password-hash/index.php
@@ -0,0 +1,38 @@
+ before hashing, producing a hash that
+ // doesn't match what's actually typed later at the Basic Auth prompt
+ // (which does zero sanitization).
+ $password = $_POST['password'] ?? '';
+
+ if ($password === '') {
+ $error = 'Enter a password to hash.';
+ } elseif (strlen($password) < 8) {
+ $error = 'Use at least 8 characters.';
+ } else {
+ // Computed once per request and never stored/logged — the page
+ // that renders this is the only place it's ever seen.
+ $hash = password_hash($password, PASSWORD_DEFAULT);
+ }
+}
+
+return [
+ 'hash' => $hash,
+ 'error' => $error,
+ 'securityError' => Input::get('error') === 'security',
+ 'csrfField' => Csrf::fieldName(),
+ 'csrfToken' => Csrf::token(),
+];
diff --git a/novaconium/pages/admin/password-hash/index.twig b/novaconium/pages/admin/password-hash/index.twig
new file mode 100644
index 0000000..1fdb801
--- /dev/null
+++ b/novaconium/pages/admin/password-hash/index.twig
@@ -0,0 +1,42 @@
+{% extends layout %}
+
+{% import '_layout/icons.twig' as icons %}
+
+{% block title %}Generate admin password hash{% endblock %}
+{% block description %}Generate a password_hash() value for admin_password_hash without using the CLI.{% endblock %}
+{% block robots %}noindex, nofollow{% endblock %}
+
+{% block content %}
+
+
{{ icons.lock() }}Generate admin password hash
+
+
A browser-based alternative to php -r "echo password_hash('yourpassword', PASSWORD_DEFAULT);". Enter a password, get back the hash App/config.php expects for admin_password_hash — see {{ icons.book() }}Admin authentication. Nothing typed here is stored, logged, or sent anywhere except computed once for this response.
+
+
+
+ {% if securityError %}
+
Your session expired before submitting — please try again.
The main purpose of this article is to make sure that all basic HTML Elements are decorated with CSS so as to not miss any possible elements when creating new themes for Hugo.
-
-
Headings
-
-
Let’s start with all possible headings. The HTML <h1>—<h6> elements represent six levels of section headings. <h1> is the highest section level and <h6> is the lowest.
-
-
Heading 1
-
-
Heading 2
-
-
Heading 3
-
-
Heading 4
-
-
Heading 5
-
-
Heading 6
-
-
-
-
Paragraph
-
-
According to the HTML5 specification by W3C, HTML documents consist of a tree of elements and text. Each element is denoted in the source by a start tag, such as <body>, and an end tag, such as </body>. (Certain start tags and end tags can in certain cases be omitted and are implied by other tags.)
-
-
Elements can have attributes, which control how the elements work. For example, hyperlink are formed using the a element and its href attribute.
-
-
List Types
-
-
Ordered List
-
-
-
First item
-
Second item
-
Third item
-
-
-
Unordered List
-
-
-
List item
-
Another item
-
And another item
-
-
-
Nested list
-
-
-
First item
-
Second item
-
-
Second item First subitem
-
Second item second subitem
-
-
Second item Second subitem First sub-subitem
-
Second item Second subitem Second sub-subitem
-
Second item Second subitem Third sub-subitem
-
-
-
Second item Third subitem
-
-
Second item Third subitem First sub-subitem
-
Second item Third subitem Second sub-subitem
-
Second item Third subitem Third sub-subitem
-
-
-
-
Third item
-
-
-
Definition List
-
-
HTML also supports definition lists.
-
-
-
Blanco tequila
-
The purest form of the blue agave spirit...
-
Reposado tequila
-
Typically aged in wooden barrels for between two and eleven months...
-
-
-
Blockquotes
-
-
The blockquote element represents content that is quoted from another source, optionally with a citation which must be within a footer or cite element, and optionally with in-line changes such as annotations and abbreviations.
-
-
-
Quoted text.
-This line is part of the same quote.
-Also you can putMarkdown into a blockquote.
-
-
-
Blockquote with a citation.
-
-
-
My goal wasn't to make a ton of money. It was to build good computers. I only started the company when I realized I could be an engineer forever.
-
-
-
-
According to Mozilla’s website, Firefox 1.0 was released in 2004 and became a big success.
-
-
Tables
-
-
Tables aren’t part of the core Markdown spec, but Hugo supports them.
Press X to win. Or press CTRL+ALT+F to show FPS counter.
-
-
As a unit of information in information theory, the bit has alternatively been called a shannon, named after Claude Shannon, the founder of field of information theory.