Replace v1 with v2 codebase

Full rewrite: swap out the v1 framework (src/, controllers/, views/,
twig/, sass/, skeleton/) for the working v2 codebase from phpproject
(App/, novaconium/, public/).
This commit is contained in:
code
2026-07-14 01:58:25 +00:00
parent d9c4b64d9c
commit fbd4e92e9f
460 changed files with 30107 additions and 4661 deletions
+3
View File
@@ -1 +1,4 @@
/public/cache/*
!/public/cache/.gitkeep
/novaconium/contact-log.txt
.claude/ .claude/
+229 -48
View File
@@ -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`). Every topic (routing, sidecars, libraries, layouts, caching, SEO, Matomo,
- `controllers/` — framework-level default controllers (dashboard, pages, messages, settings, auth/, sitemap, 404, init, create_admin, etc.). admin authentication, styling, project layout, third-party) exists in
- `config/routes.php` — framework-level default route definitions, merged with app-level `App/routes.php` at runtime. **two** places: a page under `novaconium/pages/admin/docs/<topic>/index.twig`
- `views/` — Twig views mirroring controller names (`controllers/dashboard.php``views/dashboard.html.twig`). (the canonical reference), and (for anything a README-reading human needs
- `twig/` — shared/partial Twig templates (layout pieces: `master.html.twig`, `nav.html.twig`, `head.html.twig`, `foot.html.twig`, `cp/` control-panel partials, `javascript/`). up front) a mention in `README.md`. This is intentional — `/admin/docs` is
- `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. for reading against a running instance with no internet needed, and
- `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`. `README.md` is the GitHub-facing pitch — but it means **any agent that
- `docs/` — one short Markdown file per topic (see below). changes framework behavior or adds a feature must update both copies in
- `_assets/` — static assets (e.g. logo used in README). 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/<topic>/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 php -S 127.0.0.1:8000 -t public public/router.php
docker run --rm --interactive --tty --volume $PWD:/app composer:latest update
``` ```
**Sass build** (per `docs/Sass.md`), using `sass/Dockerfile`: `public/router.php` is dev-only, mimics `public/.htaccess`. There is no
``` test suite — verification is manual route-by-route (see
cd sass && docker build -t sass-container . `/admin/docs/design-notes`'s Verification section for the checklist used
docker run --rm -v $(pwd):/usr/src/app -w /usr/src/app sass-container sass novaconium/sass/project.sass novaconium/public/css/novaconium.css after any framework change).
``` After testing, clear stray cache with `php novaconium/bin/clear-cache.php` or
Compressed variant: add `--style=compressed`, e.g. POST `/admin/clear-cache`, and remove anything written to `App/lib/` or
``` `App/pages/` that was only for testing an override — nothing here is
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 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. - Reserved segments: any path segment starting with `_` (e.g. `_layout/`)
or literally named `404` is never routable — `Router::resolve()` 404s on
## No test suite / lint / CI sight, don't try to serve content directly at those paths.
- Sidecars (`index.php`) return either an array (Twig context) or a
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. `Response` (redirect/json/xml/html — `novaconium/src/Response.php`).
`$params` (route captures) and `$cache` (the `Cache` instance, e.g. for
## Conventions `$cache->clear()`) are both in scope automatically — see
`novaconium/src/Renderer.php::runSidecar()`.
- PSR-4 autoloading: `Novaconium\``src/`. - No Composer — `novaconium/autoload.php` is a hand-rolled PSR-4 loader.
- 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. Adding a new framework-core class means adding it under `App\` in
- Sass partials are prefixed `_` (e.g. `_forms.sass`), aggregated per-folder via `index.sass`. `novaconium/src/`; a new `Lib\` class goes in `App/lib/` or
- App-level config (`App/config.php`) is a multi-dimensional PHP array: database credentials, `base_url`, `secure_key`, `logfile`, `loglevel`. `novaconium/lib/` depending on whether it's project- or
- Versioning strategy is semantic-versioning (declared in `composer.json` `extra.versioning`). framework-specific.
- `novaconium/bin/` holds standalone CLI entry points meant to be run
## Git workflow directly (`php novaconium/bin/<script>.php`) — distinct from
`bootstrap.php`/`autoload.php`/`config.php`, which are only ever
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. `require`'d, never invoked directly. `clear-cache.php` and
`create-static-page.php` (scaffolds a new page from the `/admin/docs/seo`
## Further reading starter template) both live there; a new CLI tool goes there too.
- CSS is compiled from `novaconium/sass/main.sass` (indented syntax) to
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`. `public/css/main.css`. `dart-sass` is 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.css`
and commit the regenerated `public/css/main.css` (`--no-source-map`
avoids a stray `main.css.map` the project doesn't otherwise use). If
`sass` isn't available in whatever environment you're in, either run it
via Docker — `/admin/docs/styling` has 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
the `docker build`/`docker run` commands 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 before `sass` was 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.sass` does `@use 'colors' as *`, and
its own directory (`novaconium/sass/`) deliberately has **no**
`_colors.sass` sibling. Dart Sass resolves a bare `@use` relative to the
importing file's own directory *before* consulting `--load-path`
entries, so if `novaconium/sass/_colors.sass` existed next to
`main.sass`, it would always win regardless of load-path order —
silently defeating the override. Keeping the framework default at
`novaconium/sass/defaults/_colors.sass` (a different directory) forces
resolution through the load path, where `App/sass` (checked first) can
actually override it with `App/sass/_colors.sass`. Don't move
`defaults/_colors.sass` back next to `main.sass` — it was moved out on
purpose, and doing so reintroduces this bug.
- Every color rule in `main.sass` reads 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.sass` files 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 in
`novaconium/pages/_layout/nav.twig` flips the `data-theme` attribute on
`<html>` at runtime and persists it to `localStorage`.
`novaconium/pages/_layout/theme-init.twig` re-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 `-light` variable in **both** `_colors.sass` files and
wire it into both `:root` blocks in `main.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`/`$_GET` directly — 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 an `sqlSafe()`-style method to `Input`. One documented exception: a
field needing an exact, unmodified value (e.g. a password about to be
hashed) should read `$_POST` directly instead — see
`novaconium/pages/admin/password-hash/index.php`. `Lib\Csrf`
(`novaconium/lib/Csrf.php`) is standalone session-token CSRF protection, not
wired into `FormValidator` — a sidecar calls `Csrf::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 to
`AdminAuth`'s own session-free Basic Auth.
- Don't use Twig's `|slice` filter on a **string** (as opposed to an
array) — it unconditionally calls PHP's `mb_substr()` with no fallback
(`novaconium/vendor/twig/src/Extension/CoreExtension.php`), which
hard-requires the `mbstring` extension 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-world` had a sidecar computing an excerpt this
way (see the footnote on `App/pages/blog/twig-syntax-guide/index.twig`
for the full story). Truncate strings in PHP instead, guarded with
`function_exists('mb_substr')` falling back to `substr()`, and pass the
already-truncated value into the template.
+23
View File
@@ -0,0 +1,23 @@
<?php
// Override any subset of novaconium/config.php's defaults here — only list
// the keys you want to change. novaconium/bootstrap.php (and
// novaconium/bin/clear-cache.php) shallow-merge this over the framework
// defaults; see /admin/docs/config. Uncomment and adjust any of the
// examples below, or leave this file returning an empty array to keep
// every framework default as-is.
return [
// 'debug' => false,
// 'site_name' => 'My Site',
// Docs: /admin/docs/matomo
// 'matomo_url' => 'https://matomo.example.com/',
// 'matomo_site_id' => '1',
// Docs: /admin/docs/admin-auth — generate a hash with:
// php -r "echo password_hash('yourpassword', PASSWORD_DEFAULT), PHP_EOL;"
// or use the built-in /admin/password-hash form.
// 'admin_username' => 'admin',
// 'admin_password_hash' => '$2y$10$...',
];
View File
+38
View File
@@ -0,0 +1,38 @@
{% extends layout %}
{% import '_layout/icons.twig' as icons %}
{% block title %}About{% endblock %}
{% block description %}How this site is built: Novaconium, a tiny PHP micro-framework with file-based routing, Twig templates, and static caching.{% endblock %}
{# Every block below is optional — the layout already supplies a sensible
default for each (see /admin/docs/seo). Shown here uncommented as a
reference for every override a page can make; delete what you don't
need. #}
{% block robots %}index, follow{% endblock %}
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
{% block og_type %}website{% endblock %}
{% block og_title %}{{ block('title') }}{% endblock %}
{% block og_description %}{{ block('description') }}{% endblock %}
{% block og_url %}{{ block('canonical') }}{% endblock %}
{% block twitter_card %}summary{% endblock %}
{% block twitter_title %}{{ block('title') }}{% endblock %}
{% block twitter_description %}{{ block('description') }}{% endblock %}
{% block content %}
<article>
<h1>About</h1>
<p>This site runs on <strong>Novaconium</strong>, a tiny PHP micro-framework built around one idea: a directory on disk <em>is</em> a route. There's no router configuration to maintain, no ORM, and no Composer — <a href="https://twig.symfony.com/">Twig</a> is the only dependency it has, and it's vendored directly into the repo as plain source files rather than pulled in through a package manager.</p>
<p>Pages render with Twig and are optionally backed by a PHP "sidecar" — a plain <code>index.php</code> file that supplies template data, or short-circuits templating entirely by returning a redirect, JSON, or raw HTML. Pages with no sidecar are pure and deterministic, so they're rendered once and served straight from Apache as static HTML on every later request, with no PHP or Twig overhead at all.</p>
<p>Everything the framework itself ships — default pages, default library classes, the root layout — lives under <code>novaconium/</code>, and can be overridden by placing a same-named file under <code>App/</code>, the only directory a site author is expected to touch. The same override mechanism covers configuration, too: any key in <code>novaconium/config.php</code> can be replaced piecemeal from <code>App/config.php</code>.</p>
<p>Beyond routing and templating, Novaconium ships with SEO meta tags (canonical links, Open Graph, Twitter Card), optional Matomo analytics with automatic 404 tracking, and an HTTP Basic Auth gate reusable across every <code>/admin/*</code> page — all off or sensible by default, and all documented at <a class="icon-link" href="/admin/docs">{{ icons.book() }}/admin/docs</a>, rendered live from this same running instance rather than a separate website.</p>
<p>It's a good fit for small marketing sites, blogs, and internal tools where a full framework would be overkill but a flat-file site generator alone falls short of real server-side logic. The source is on Git if you want to see how it's put together: <a class="icon-link" href="https://git.4lt.ca/4lt/novaconium">{{ icons.git() }}git.4lt.ca/4lt/novaconium</a>.</p>
</article>
{% endblock %}
+15
View File
@@ -0,0 +1,15 @@
{% extends '_layout/layout.twig' %}
{% import '_layout/icons.twig' as icons %}
{% block content %}
<div class="blog-layout">
<aside>
<p>This sidebar exists because <code>App/pages/blog/_layout/layout.twig</code> overrides the site-wide root layout for everything under <code>/blog</code> — the nearest <code>_layout/layout.twig</code> wins, so a subtree can look different without touching the pages themselves.</p>
<p><a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts docs</a></p>
</aside>
<article>
{% block blog_content %}{% endblock %}
</article>
</div>
{% endblock %}
+26
View File
@@ -0,0 +1,26 @@
{% extends layout %}
{% import '_layout/icons.twig' as icons %}
{% block title %}Hello, World!{% endblock %}
{% block description %}The first post on this blog — a plain sidecar-less page, like every other post here now.{% endblock %}
{% block robots %}index, follow{% endblock %}
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
{% block og_type %}article{% endblock %}
{% block og_title %}{{ block('title') }}{% endblock %}
{% block og_description %}{{ block('description') }}{% endblock %}
{% block og_url %}{{ block('canonical') }}{% endblock %}
{% block twitter_card %}summary{% endblock %}
{% block twitter_title %}{{ block('title') }}{% endblock %}
{% block twitter_description %}{{ block('description') }}{% endblock %}
{% block blog_content %}
<h1>Hello, World!</h1>
<p>The first post on this blog — a plain page at <code>App/pages/blog/hello-world/index.twig</code>, sidecar-less like <a class="icon-link" href="/blog/twig-syntax-guide">{{ icons.book() }}the Twig Syntax Guide</a> and the <a class="icon-link" href="/blog/style-guide">{{ icons.book() }}Style Guide</a>. Since it has no sidecar, it's rendered once and served as static HTML from <code>public/cache/blog/hello-world/index.html</code> on every later request — see <a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a>.</p>
<p>This URL used to be backed by a wildcard <code>App/pages/blog/[slug]/</code> route pulling from a <code>Lib\PostRepository</code> class — a live demo of route-param capture. That mechanism (any single URL segment captured into <code>$params</code>) is still fully supported by the framework; see <a class="icon-link" href="/admin/docs/routing">{{ icons.link() }}Routing</a>. It just isn't what renders this particular post anymore, now that this post is fixed content rather than a stand-in for arbitrary slugs.</p>
{% endblock %}
+30
View File
@@ -0,0 +1,30 @@
<?php
// Every post under App/pages/blog/ is now a plain, sidecar-less directory
// with its own index.twig — none of them are driven by a repository or
// database, so this listing is just a hand-maintained array pointing at
// each one. Add a new entry here whenever a new post directory is added.
return [
'posts' => [
[
'slug' => 'hello-world',
'title' => 'Hello, World!',
'excerpt' => 'The first post on this blog — a plain sidecar-less page, like every other post here now.',
],
[
'slug' => 'second-post',
'title' => 'A Second Post',
'excerpt' => 'A second post at its own URL, showing that adding a new page under App/pages/blog/ needs nothing but a new directory.',
],
[
'slug' => 'twig-syntax-guide',
'title' => 'Twig Syntax Guide',
'excerpt' => 'A tour of the Twig syntax used throughout this site — output, filters, control structures, template inheritance, and a few gotchas worth knowing.',
],
[
'slug' => 'style-guide',
'title' => 'Style Guide',
'excerpt' => "A showcase of this theme's default styling for headings, lists, tables, code, and other common HTML elements.",
],
],
];
+30
View File
@@ -0,0 +1,30 @@
{% extends layout %}
{% block title %}Blog{% endblock %}
{% block description %}All posts on this blog — a working example of a sidecar-driven listing page.{% endblock %}
{% block robots %}index, follow{% endblock %}
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
{% block og_type %}website{% endblock %}
{% block og_title %}{{ block('title') }}{% endblock %}
{% block og_description %}{{ block('description') }}{% endblock %}
{% block og_url %}{{ block('canonical') }}{% endblock %}
{% block twitter_card %}summary{% endblock %}
{% block twitter_title %}{{ block('title') }}{% endblock %}
{% block twitter_description %}{{ block('description') }}{% endblock %}
{% block blog_content %}
<h1>Blog</h1>
<p>Rendered by <code>App/pages/blog/index.php</code> and <code>index.twig</code> — a sidecar that returns a hand-maintained array pointing at each post directory under <code>App/pages/blog/</code>. Because this page has a sidecar, it's never served from the static cache; the list is rebuilt on every request, even though the array itself only changes when a post is added.</p>
<ul class="post-list">
{% for post in posts %}
<li>
<h2><a href="/blog/{{ post.slug }}">{{ post.title }}</a></h2>
<p>{{ post.excerpt }}&hellip;</p>
</li>
{% endfor %}
</ul>
{% endblock %}
+26
View File
@@ -0,0 +1,26 @@
{% extends layout %}
{% import '_layout/icons.twig' as icons %}
{% block title %}A Second Post{% endblock %}
{% block description %}A second post at its own URL, showing that adding a new page under App/pages/blog/ needs nothing but a new directory.{% endblock %}
{% block robots %}index, follow{% endblock %}
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
{% block og_type %}article{% endblock %}
{% block og_title %}{{ block('title') }}{% endblock %}
{% block og_description %}{{ block('description') }}{% endblock %}
{% block og_url %}{{ block('canonical') }}{% endblock %}
{% block twitter_card %}summary{% endblock %}
{% block twitter_title %}{{ block('title') }}{% endblock %}
{% block twitter_description %}{{ block('description') }}{% endblock %}
{% block blog_content %}
<h1>A Second Post</h1>
<p>A second post at its own URL — <code>App/pages/blog/second-post/index.twig</code>. Just another plain, sidecar-less directory under <code>App/pages/blog/</code>, same as <a class="icon-link" href="/blog/hello-world">{{ icons.book() }}Hello, World!</a> next to it. Adding a new post is nothing more than a new directory with an <code>index.twig</code> — no route to register, no repository entry to add.</p>
<p>Both posts are listed on <a class="icon-link" href="/blog">{{ icons.book() }}the blog index</a>, built by <code>App/pages/blog/index.php</code> — see <a class="icon-link" href="/admin/docs/routing">{{ icons.link() }}Routing</a> for how a URL maps to a directory in the first place.</p>
{% endblock %}
+128
View File
@@ -0,0 +1,128 @@
{% extends layout %}
{% import '_layout/icons.twig' as icons %}
{% block title %}Style Guide{% endblock %}
{% block description %}A showcase of this theme's default styling for headings, lists, tables, code, and other common HTML elements.{% endblock %}
{% block robots %}index, follow{% endblock %}
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
{% block og_type %}article{% endblock %}
{% block og_title %}{{ block('title') }}{% endblock %}
{% block og_description %}{{ block('description') }}{% endblock %}
{% block og_url %}{{ block('canonical') }}{% endblock %}
{% block twitter_card %}summary{% endblock %}
{% block twitter_title %}{{ block('title') }}{% endblock %}
{% block twitter_description %}{{ block('description') }}{% endblock %}
{% block blog_content %}
<h1>Style Guide</h1>
<p>A plain page, like <a class="icon-link" href="/blog/twig-syntax-guide">{{ icons.book() }}the Twig Syntax Guide</a>, this time showing off the default styling every element on this site gets for free from <code>novaconium/sass/main.sass</code> — no per-page CSS involved. If you've changed <code>App/sass/_colors.sass</code> (see <a class="icon-link" href="/admin/docs/styling">{{ icons.book() }}Styling</a>), this page is the fastest way to see the new palette applied across everything at once.</p>
<h2>Headings</h2>
<h1>Heading level 1</h1>
<h2>Heading level 2</h2>
<h3>Heading level 3</h3>
<h4>Heading level 4</h4>
<h5>Heading level 5</h5>
<h6>Heading level 6</h6>
<h2>Paragraph &amp; inline text</h2>
<p>A normal paragraph, with <strong>bold</strong>, <em>italic</em>, <code>inline code</code>, and <a href="/">a link</a> mixed in. <small>Small print, like this, is used for captions and secondary detail throughout the site.</small></p>
<h2>Blockquote</h2>
<blockquote>
<p>Design is not just what it looks like and feels like. Design is how it works.</p>
</blockquote>
<h2>Lists</h2>
<h3>Unordered</h3>
<ul>
<li>File-based routing</li>
<li>Optional sidecars</li>
<li>Static caching</li>
</ul>
<h3>Ordered</h3>
<ol>
<li>Write a Twig template</li>
<li>Optionally add a sidecar</li>
<li>Visit the URL</li>
</ol>
<h3>Nested</h3>
<ul>
<li>App/
<ul>
<li>pages/</li>
<li>lib/</li>
<li>sass/</li>
</ul>
</li>
<li>novaconium/
<ul>
<li>pages/</li>
<li>src/</li>
</ul>
</li>
</ul>
<h2>Table</h2>
<table>
<thead>
<tr><th>Element</th><th>Styled by</th></tr>
</thead>
<tbody>
<tr><td>Headings</td><td><code>h1, h2, h3, h4, h5, h6</code></td></tr>
<tr><td>Links</td><td><code>a</code>, <code>a:hover</code></td></tr>
<tr><td>Code</td><td><code>code</code>, <code>pre</code></td></tr>
<tr><td>Tables</td><td><code>table</code>, <code>th</code>, <code>td</code></td></tr>
</tbody>
</table>
<h2>Code</h2>
<p>Inline: <code>(new Mailer())-&gt;send($old['name'], $old['email'], $old['message']);</code></p>
<pre><code>{% verbatim %}{% extends layout %}
{% block content %}
...
{% endblock %}{% endverbatim %}</code></pre>
<h2>Horizontal rule</h2>
<p>Above this line:</p>
<hr>
<p>Below this line.</p>
<h2>Form elements</h2>
<form>
<p>
<label for="style-guide-example">A label</label><br>
<input type="text" id="style-guide-example" placeholder="A text input">
</p>
<p>
<label for="style-guide-textarea">A textarea</label><br>
<textarea id="style-guide-textarea" rows="3" placeholder="Some longer text"></textarea>
</p>
<p>
<label for="style-guide-select">A dropdown</label><br>
<select id="style-guide-select">
<option>Option one</option>
<option>Option two</option>
<option>Option three</option>
</select>
</p>
<p>
Radio buttons<br>
<label><input type="radio" name="style-guide-radio" checked> First choice</label><br>
<label><input type="radio" name="style-guide-radio"> Second choice</label><br>
<label><input type="radio" name="style-guide-radio"> Third choice</label>
</p>
<p>
<label><input type="checkbox" checked> A checkbox, too, while we're here</label>
</p>
<button type="button">A button</button>
</form>
<h2>Icons</h2>
<p>{{ icons.home() }} {{ icons.git() }} {{ icons.book() }} {{ icons.link() }} {{ icons.sitemap() }} {{ icons.email() }} {{ icons.search() }} {{ icons.rss() }} {{ icons.tag() }} {{ icons.lock() }} {{ icons.trash() }} {{ icons.external_link() }} {{ icons.menu() }} {{ icons.back_to_top() }} — every inline SVG icon in <code>novaconium/pages/_layout/icons.twig</code>, all inheriting the surrounding text color via <code>currentColor</code>.</p>
{% endblock %}
+113
View File
@@ -0,0 +1,113 @@
{% extends layout %}
{% import '_layout/icons.twig' as icons %}
{% block title %}Twig Syntax Guide{% endblock %}
{% block description %}A tour of the Twig syntax used throughout this site — output, filters, control structures, inheritance, and a few gotchas.{% endblock %}
{% block robots %}index, follow{% endblock %}
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
{% block og_type %}article{% endblock %}
{% block og_title %}{{ block('title') }}{% endblock %}
{% block og_description %}{{ block('description') }}{% endblock %}
{% block og_url %}{{ block('canonical') }}{% endblock %}
{% block twitter_card %}summary{% endblock %}
{% block twitter_title %}{{ block('title') }}{% endblock %}
{% block twitter_description %}{{ block('description') }}{% endblock %}
{% block blog_content %}
<h1>Twig Syntax Guide</h1>
<p>Every page on this site is a Twig template. This post is a quick tour of the syntax that shows up throughout <code>App/pages/</code> and <code>novaconium/pages/</code> — not a full Twig reference (see <a class="icon-link" href="https://twig.symfony.com/doc/3.x/templates.html">{{ icons.external_link() }}the official docs</a> for that), just the parts this framework actually leans on, plus a couple of gotchas learned the hard way<sup><a href="#fn-1">1</a></sup>.</p>
<h2>Output &amp; variables</h2>
<p>Twig prints an expression with <code>{{ '{{ ... }}' }}</code>. Sidecar data, route params, and a handful of framework-provided variables (<code>request_path</code>, <code>layout</code>, <code>site_name</code>) are all just variables in scope:</p>
<pre><code>{% verbatim %}{{ title }}
{{ params.slug }}
{{ post.title }}{% endverbatim %}</code></pre>
<p>Dot notation (<code>post.title</code>) works whether <code>post</code> is an array key or an object property — Twig tries both, so templates don't need to care which.</p>
<h2>Filters</h2>
<p>Filters transform a value with a pipe: <code>{{ '{{ value|filter }}' }}</code>, and chain left to right. A few used on this site:</p>
<table>
<thead>
<tr><th>Filter</th><th>Example</th><th>Does</th></tr>
</thead>
<tbody>
<tr><td><code>default</code></td><td><code>{{ '{{ request_path|default(\'/\') }}' }}</code></td><td>Falls back when a variable is undefined or empty.</td></tr>
<tr><td><code>date</code></td><td><code>{{ '{{ "now"|date("Y") }}' }}</code></td><td>Formats a date — used for the footer's copyright year.</td></tr>
<tr><td><code>e</code> (escape)</td><td><code>{{ '{{ matomo_url|e(\'js\') }}' }}</code></td><td>Escapes for a context other than HTML, here JavaScript string literals.</td></tr>
<tr><td><code>raw</code></td><td><code>{{ '{{ html_string|raw }}' }}</code></td><td>Opts out of autoescaping — see Other elements below.</td></tr>
</tbody>
</table>
<p><strong>One filter to avoid:</strong> <code>|slice</code> on a <em>string</em> (not an array) calls PHP's <code>mb_substr()</code> internally with no fallback — see the footnote<sup><a href="#fn-1">1</a></sup>.</p>
<h2>Control structures</h2>
<p>The two workhorses are <code>{% verbatim %}{% if %}{% endverbatim %}</code> and <code>{% verbatim %}{% for %}{% endverbatim %}</code>:</p>
<pre><code>{% verbatim %}{% if sent %}
&lt;p&gt;Thanks — your message has been sent.&lt;/p&gt;
{% endif %}{% endverbatim %}</code></pre>
<pre><code>{% verbatim %}{% for post in posts %}
&lt;li&gt;&lt;a href="/blog/{{ post.slug }}"&gt;{{ post.title }}&lt;/a&gt;&lt;/li&gt;
{% endfor %}{% endverbatim %}</code></pre>
<p>This exact loop is what renders the <a href="/blog">blog listing page</a> you probably followed a link from to get here.</p>
<h2>Comments</h2>
<p>Anything between <code>{% verbatim %}{# and #}{% endverbatim %}</code> is stripped entirely from the output — unlike an HTML comment, it never reaches the browser:</p>
<pre><code>{% verbatim %}{# Open Graph / Facebook #}{% endverbatim %}</code></pre>
<p>That one's real — it's the comment sitting above the Open Graph block in <code>novaconium/pages/_layout/layout.twig</code>.</p>
<h2>Template inheritance &amp; includes</h2>
<p><code>{% verbatim %}{% extends %}{% endverbatim %}</code> is how every page on this site gets its <code>&lt;html&gt;</code>/<code>&lt;head&gt;</code>/nav/footer for free — a child template only fills in named <code>{% verbatim %}{% block %}{% endverbatim %}</code> slots the parent declares:</p>
<pre><code>{% verbatim %}{% extends layout %}
{% block title %}Blog{% endblock %}
{% block blog_content %}
...
{% endblock %}{% endverbatim %}</code></pre>
<p><code>{% verbatim %}{% include %}{% endverbatim %}</code> pulls in a whole template inline (used for <code>_layout/nav.twig</code> and <code>_layout/matomo.twig</code>), while <code>{% verbatim %}{% import %}{% endverbatim %}</code> pulls in reusable <strong>macros</strong> — parameterized snippets like the icons used throughout this page:</p>
<pre><code>{% verbatim %}{% import '_layout/icons.twig' as icons %}
{{ icons.book() }}{% endverbatim %}</code></pre>
<p>See <a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a> for how <code>{% verbatim %}{% extends %}{% endverbatim %}</code> resolution walks the <code>App/</code>-over-<code>novaconium/</code> override chain.</p>
<h2>Lists, three ways</h2>
<p>Ordered:</p>
<ol>
<li>Parse the template source into an AST.</li>
<li>Compile the AST into a plain PHP class.</li>
<li>Cache and execute that compiled class (caching is disabled in this project's <code>Renderer</code>, since <code>novaconium/vendor/twig/</code> — the source — is already about as fast to re-parse as anything else here).</li>
</ol>
<p>Unordered:</p>
<ul>
<li><code>{{ '{{ }}' }}</code> — output</li>
<li><code>{% verbatim %}{% %}{% endverbatim %}</code> — tags/control structures</li>
<li><code>{# #}</code> — comments</li>
</ul>
<p>Nested — the three page kinds this framework recognizes:</p>
<ul>
<li>Sidecar-less pages
<ul>
<li>Rendered once, then cached as static HTML</li>
<li>e.g. this page's siblings <code>/</code>, <code>/about</code></li>
</ul>
</li>
<li>Pages with a sidecar
<ul>
<li>Return array (Twig context) or a <code>Response</code></li>
<li>Never cached, since output can vary per request</li>
</ul>
</li>
</ul>
<h2>Other elements</h2>
<p><strong>Whitespace control:</strong> a hyphen inside a tag delimiter — <code>{% verbatim %}{%- -%}{% endverbatim %}</code> or <code>{{ '{{- -}}' }}</code> — trims adjacent whitespace/newlines from the output. Not heavily used in this project, since the HTML here isn't whitespace-sensitive, but useful when generating something like JSON or plain text from a template.</p>
<p><strong>Autoescaping:</strong> Twig HTML-escapes every <code>{{ '{{ }}' }}</code> output by default, so a sidecar can safely return user input (like the contact form's <code>old.name</code>) without it being able to inject markup. The <code>|raw</code> filter opts out where the content is trusted and intentionally HTML — used nowhere on the public site, but by the <code>|raw</code>-free docs pages under <code>/admin/docs</code>.</p>
<p><strong>Functions vs. filters:</strong> <code>block('title')</code> (used throughout this site's SEO blocks to reuse the <code>title</code> block's content for <code>og:title</code>) is a <em>function</em>, called with parentheses, not piped like a filter.</p>
<div class="footnotes">
<ol>
<li id="fn-1">Twig's <code>|slice</code> filter, applied to a string, calls PHP's <code>mb_substr()</code> with no fallback — a hard dependency on the <code>mbstring</code> extension that isn't guaranteed on every PHP install. This project hit that exact fatal error on <code>/blog/hello-world</code> once already, back when that page had a sidecar computing an excerpt this way. The fix: truncate in PHP instead, guarded with <code>function_exists('mb_substr')</code> falling back to plain <code>substr()</code> — see <code>AGENTS.md</code> for the standing rule.</li>
</ol>
</div>
{% endblock %}
+54
View File
@@ -0,0 +1,54 @@
<?php
use App\Response;
use Lib\Csrf;
use Lib\FormValidator;
use Lib\Input;
use Lib\Mailer;
use Lib\SpamGuard;
$errors = [];
$old = ['name' => '', 'email' => '', 'message' => ''];
$spamGuard = new SpamGuard();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!Csrf::verify(Input::post('csrf_token'))) {
return Response::redirect('/contact?error=security');
}
$old = [
'name' => Input::post('name', ''),
'email' => Input::post('email', ''),
'message' => Input::post('message', ''),
];
$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.');
if ($validator->passes()) {
// $spamGuard checks the honeypot field + submission timing (see
// Lib\SpamGuard and index.twig's hidden fields) — a bot gets the
// exact same redirect a human would either way, with nothing in
// the response revealing which check it tripped, or that a check
// exists at all. Only Mailer::send() is skipped for spam.
if (!$spamGuard->isSpam(Input::post())) {
(new Mailer())->send($old['name'], $old['email'], $old['message']);
}
return Response::redirect('/contact?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(),
];
+63
View File
@@ -0,0 +1,63 @@
{% extends layout %}
{% block title %}Contact{% endblock %}
{% block description %}Get in touch — send a message using the contact form.{% endblock %}
{# Every block below is optional — the layout already supplies a sensible
default for each (see /admin/docs/seo). Shown here uncommented as a
reference for every override a page can make; delete what you don't
need. #}
{% block robots %}index, follow{% endblock %}
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
{% block og_type %}website{% endblock %}
{% block og_title %}{{ block('title') }}{% endblock %}
{% block og_description %}{{ block('description') }}{% endblock %}
{% block og_url %}{{ block('canonical') }}{% endblock %}
{% block twitter_card %}summary{% endblock %}
{% block twitter_title %}{{ block('title') }}{% endblock %}
{% block twitter_description %}{{ block('description') }}{% endblock %}
{% import '_layout/icons.twig' as icons %}
{% block content %}
<article>
<h1 class="icon-heading">{{ icons.email() }}Contact</h1>
<p>This form is handled entirely by <code>App/pages/contact/index.php</code>, a sidecar demonstrating the classic POST/redirect/GET pattern: it validates <code>$_POST</code>, calls <code>Lib\Mailer::send()</code> on success, and redirects to <code>/contact?sent=1</code> — so refreshing the page after submitting never resubmits the form. Because this page has a sidecar, it's never served from the static cache; see <a class="icon-link" href="/admin/docs/sidecars">{{ icons.book() }}Sidecars</a> and <a class="icon-link" href="/admin/docs/libraries">{{ icons.book() }}Libraries</a> for the full contract. It's also a working example of self-hosted spam prevention (honeypot field + submission-timing check, no external CAPTCHA service) — see <a class="icon-link" href="/admin/docs/sidecars">{{ icons.book() }}Sidecars</a>' "Spam prevention" section.</p>
{% if sent %}
<p><strong>Thanks — your message has been sent.</strong></p>
{% endif %}
{% if securityError %}
<p><strong>Your session expired before submitting — please try again.</strong></p>
{% endif %}
<form method="post" action="/contact">
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
<div class="hp-field" aria-hidden="true">
<label for="website">Leave this field blank</label>
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off">
</div>
<input type="hidden" name="rendered_at" value="{{ renderedAt }}">
<p>
<label for="name">Name</label><br>
<input type="text" id="name" name="name" value="{{ old.name }}">
{% if errors.name %}<br><small>{{ errors.name }}</small>{% endif %}
</p>
<p>
<label for="email">Email</label><br>
<input type="text" id="email" name="email" value="{{ old.email }}">
{% if errors.email %}<br><small>{{ errors.email }}</small>{% endif %}
</p>
<p>
<label for="message">Message</label><br>
<textarea id="message" name="message" rows="5">{{ old.message }}</textarea>
{% if errors.message %}<br><small>{{ errors.message }}</small>{% endif %}
</p>
<button type="submit">Send</button>
</form>
</article>
{% endblock %}
+77
View File
@@ -0,0 +1,77 @@
{% extends layout %}
{% block title %}Home{% endblock %}
{% block description %}A tiny, Hugo-flavored PHP micro-framework: file-based routing, Twig templates, and static caching.{% endblock %}
{# Every block below is optional — the layout already supplies a sensible
default for each (see /admin/docs/seo). Shown here uncommented as a
reference for every override a page can make; delete what you don't
need. #}
{% block robots %}index, follow{% endblock %}
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
{% block og_type %}website{% endblock %}
{% block og_title %}{{ block('title') }}{% endblock %}
{% block og_description %}{{ block('description') }}{% endblock %}
{% block og_url %}{{ block('canonical') }}{% endblock %}
{% block twitter_card %}summary{% endblock %}
{% block twitter_title %}{{ block('title') }}{% endblock %}
{% block twitter_description %}{{ block('description') }}{% endblock %}
{% import '_layout/icons.twig' as icons %}
{% block content %}
<section class="hero">
<span class="hero-eyebrow">novaconium</span>
<h1>You're up and running.</h1>
<p class="hero-lede">A tiny, Hugo-flavored PHP micro-framework: file-based routing, Twig templates, and static caching — no Composer, no build step. This page lives at <code>App/pages/index.twig</code>; start editing there.</p>
<div class="hero-actions">
<a class="button-link" href="/admin/docs">Read the docs</a>
<a class="button-link button-link--ghost" href="https://git.4lt.ca/4lt/novaconium">{{ icons.git() }}View source</a>
</div>
<ul class="hero-badges">
<li class="badge">PHP 8.1+</li>
<li class="badge">No Composer</li>
<li class="badge">Twig vendored</li>
<li class="badge">Static caching</li>
</ul>
</section>
<section class="feature-grid">
<article class="feature-card">
<h2>File-based routing</h2>
<p>A directory under <code>App/pages/</code> <em>is</em> a route. <code>[param]</code> segments capture into <code>$params</code>. No route table to maintain.</p>
</article>
<article class="feature-card">
<h2>Optional sidecars</h2>
<p>Drop an <code>index.php</code> next to any <code>index.twig</code> for real logic, or return a <code>Response</code> to short-circuit templating entirely.</p>
</article>
<article class="feature-card">
<h2>Static caching</h2>
<p>Sidecar-less pages render once and get served straight from Apache afterwards — this page included.</p>
</article>
<article class="feature-card">
<h2>SEO out of the box</h2>
<p>Meta description, canonical links, Open Graph, and Twitter Card tags ship by default, all overridable per page.</p>
</article>
<article class="feature-card">
<h2>Matomo &amp; admin auth</h2>
<p>Built-in analytics tracking and an HTTP Basic Auth gate for <code>/admin/*</code> — both off until you turn them on in <code>App/config.php</code>.</p>
</article>
<article class="feature-card">
<h2>Override anything</h2>
<p><code>App/</code> is checked before <code>novaconium/</code> for every page, layout, and <code>Lib\</code> class — override by dropping a file at the same relative path.</p>
</article>
</section>
<section class="next-steps">
<h2>Where to next</h2>
<ul>
<li><a href="/admin/docs/getting-started">Getting started</a> — requirements, running locally, deploying on Apache.</li>
<li><a href="/admin/docs/routing">Routing</a> and <a href="/admin/docs/sidecars">sidecars</a> — how pages and logic fit together.</li>
<li><a href="/blog">The blog example</a> — a listing sidecar, individual posts, and a layout override.</li>
<li><a href="/admin">Admin</a> — clear the cache and browse these docs live.</li>
</ul>
</section>
{% endblock %}
+25
View File
@@ -0,0 +1,25 @@
// This project's color palette — overrides
// novaconium/sass/defaults/_colors.sass's framework defaults. Same
// mechanism as App/pages/ overriding novaconium/pages/: same relative
// filename, checked first on the Sass load path. Change any of these and
// recompile (see /admin/docs/styling) to reskin the whole site from one
// file. Delete this file entirely to fall back to the framework's
// default palette instead.
$bg: #14181c
$surface: #1b2126
$text-color: #e7ebee
$muted-color: #98a3ac
$border-color: #2a3238
$accent: #2dd4bf
$accent-hover: #5eead4
// Light theme, used when a visitor toggles it (see the theme-toggle
// button in novaconium/pages/_layout/nav.twig) same seven names with a
// -light suffix, same override mechanism.
$bg-light: #ffffff
$surface-light: #f1f4f6
$text-color-light: #14181c
$muted-color-light: #5b6570
$border-color-light: #d8dee3
$accent-light: #0f9488
$accent-hover-light: #0c7a70
-9
View File
@@ -1,9 +0,0 @@
MIT License
Copyright (c) 2024 4lt
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+59 -40
View File
@@ -1,65 +1,84 @@
![Novaconium PHP](/_assets/novaconium-logo.png) # novaconium
# Novaconium PHP: A PHP Framework Built from the Past A tiny, Hugo-flavored PHP micro-framework. Routes are directories on disk, pages render with [Twig](https://twig.symfony.com/), and any page that needs real logic gets an optional PHP "sidecar" file. Pages without a sidecar are pre-rendered once and served as static HTML straight from Apache afterwards. No Composer — Twig is vendored directly into the repo as plain source files.
NovaconiumPHP is a high-performance PHP framework designed with inspiration from classic coding principles. ## Features
Pronounced: Noh-vah-koh-nee-um - **File-based routing** — a directory under `App/pages/` *is* a route (Hugo-style page bundles). No route table to maintain.
- **`[param]` segments** — a directory literally named `[param]` (e.g. `App/pages/products/[id]/`) captures any single URL segment into `$params['param']` for clean URLs, no query strings.
- **Optional PHP "sidecars"** — drop an `index.php` next to any `index.twig` to supply Twig context data, or return a `Response` (redirect/JSON/XML/HTML) to short-circuit templating entirely.
- **Static caching, zero config** — sidecar-less pages render once and are written to `public/cache/`; `.htaccess` serves the cached file directly on every later hit, skipping PHP and Twig entirely.
- **Override-by-path** — `App/` (your project) is checked before `novaconium/` (the framework defaults) for every page, layout, `Lib\` class, and even the Sass color palette (`App/sass/_colors.sass`). Drop a file at the same relative path to override it; nothing needs duplicating to get a working site.
- **Layout inheritance** — `_layout/layout.twig` directories are resolved by walking upward from the matched page, so you can override the layout for a whole subtree.
- **SEO boilerplate out of the box** — the default layout ships meta description, canonical link, robots, Open Graph, and Twitter Card tags, all overridable per-page via Twig blocks.
- **Built-in Matomo analytics** — set `matomo_url` and `matomo_site_id` in `App/config.php` to enable tracking site-wide, including automatic 404 tracking. Off by default.
- **Admin authentication** — gate every `/admin/*` route behind HTTP Basic Auth by setting `admin_username`/`admin_password_hash` in `App/config.php`; reusable for any admin page a project adds later, with a `/admin/logout` link to clear cached credentials and a built-in `/admin/password-hash` form so generating the hash doesn't require the CLI. Off by default.
- **Dark/light theme toggle** — a nav button flips a `data-theme` attribute (persisted to `localStorage`) that swaps every color via CSS custom properties; both palettes live in `App/sass/_colors.sass`, same override mechanism as everything else.
- **Self-hosted spam prevention & form validation** — `Lib\SpamGuard`, a reusable class for any form: a CSS-hidden honeypot field plus a submission-timing check, no external CAPTCHA service, site key, or outbound API call. Pairs with `Lib\FormValidator` (accumulating required-field/email/length checks) and `Lib\Validate` (the underlying validation primitives — email, length, phone, postal/zip, spam-word checks). All three ship in `novaconium/lib/`, demonstrated on the contact form.
- **Form security by default** — `Lib\Input`, a cleaning accessor for `$_POST`/`$_GET` (defense-in-depth against HTML/script injection, not a substitute for parameterized queries), and `Lib\Csrf`, standalone session-token CSRF protection called directly from a sidecar. Both ship in `novaconium/lib/`, wired into the contact form, `/admin/clear-cache`, and `/admin/password-hash`.
- **No build step, no Composer** — clone it, point Apache (or `php -S`) at `public/`, and it runs. Twig is vendored as source; see `/admin/docs/upgrading-twig` for upgrading it.
* Packagist: https://packagist.org/packages/4lt/novaconium ## Getting started
* Master Repo: https://git.4lt.ca/4lt/novaconium
## Getting Started **Requirements:** PHP 8.1+ (uses `readonly` constructor-promoted properties) and, for production, Apache with `mod_rewrite` and `AllowOverride All`.
Novaconium is designed to be developed primarily using Docker. Instead of relying on tools installed directly on your system, common development tasks are executed inside containers: ### Run it locally (no Apache needed)
* Composer runs inside a Docker container rather than on your host machine ```
* Apache / PHP are served from containers instead of your local environment php -S 127.0.0.1:8000 -t public public/router.php
* Sass compilation is also handled within a container ```
As long as you have Docker installed, you can use the full development environment without installing additional dependencies on your system. `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.
You can [learn more about how novaconium works with composer](https://git.4lt.ca/4lt/novaconium/src/branch/master/docs/Composer.md). Visit `http://127.0.0.1:8000/` for the static home page, then click around — `/about`, `/blog/hello-world`, `/contact`, and `/admin` (cache clearing + these same docs, rendered live) are all included as working examples.
```bash ### Deploy on Apache
PROJECTNAME=novaproject;
mkdir -p $PROJECTNAME/novaconium;
cd $PROJECTNAME;
docker run --rm --interactive --tty --volume ./novaconium/:/app composer:latest require 4lt/novaconium; 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.
cp -R novaconium/vendor/4lt/novaconium/skeleton/. .; ### Add a page
# Edit .env Create a directory under `App/pages/` with an `index.twig` — the directory path *is* the URL:
# pwgen -cnsB1v 12 # root password
# pwgen -cnsB1v 12 # mysql user password (need in both config and env)
# pwgen -cnsB1v 64 # framework key (need in config)
# Edit novaconium/App/config.php
docker compose up -d ```
App/pages/pricing/index.twig -> /pricing
```
Add an `index.php` next to it if the page needs data or logic. See [Sidecars](http://127.0.0.1:8000/admin/docs/sidecars) in the docs for the full contract, or [SEO](http://127.0.0.1:8000/admin/docs/seo) for a ready-to-paste starter template with every overridable block — or skip the copy-paste and scaffold it:
```
php novaconium/bin/create-static-page.php blog/my-new-post
``` ```
## Documentation ## Documentation
* [Novaconiumm Official Repo](https://git.4lt.ca/4lt/novaconium) The full framework documentation — routing, sidecars, libraries, layouts, static caching, SEO, Matomo analytics, admin authentication, styling, project layout, and third-party notices — lives at `/admin/docs` on any running instance (so it travels with the code, no internet connection needed). Highlights:
* [CORXN Apache and PHP Container for Novaconium](https://git.4lt.ca/4lt/CORXN)
- [Getting started](http://127.0.0.1:8000/admin/docs/getting-started)
- [Routing](http://127.0.0.1:8000/admin/docs/routing)
- [Sidecars](http://127.0.0.1:8000/admin/docs/sidecars)
- [Libraries](http://127.0.0.1:8000/admin/docs/libraries)
- [Layouts](http://127.0.0.1:8000/admin/docs/layouts)
- [Static caching](http://127.0.0.1:8000/admin/docs/caching)
- [SEO](http://127.0.0.1:8000/admin/docs/seo)
- [Matomo](http://127.0.0.1:8000/admin/docs/matomo)
- [Admin authentication](http://127.0.0.1:8000/admin/docs/admin-auth)
- [Styling](http://127.0.0.1:8000/admin/docs/styling)
- [Project layout](http://127.0.0.1:8000/admin/docs/project-layout)
- [Third-party](http://127.0.0.1:8000/admin/docs/third-party)
### How it works `AGENTS.md` is the short, agent-facing version for coding assistants working in this repo, and `novaconium/ISSUES.md` is the roadmap/backlog.
#### htaccess ## Project layout
htaccess ensures that all requests are sent to index.php ```
App/ your project — pages/ (routes), lib/ (Lib\ classes), sass/ (color overrides) — the only directory you're expected to edit
public/ Apache document root — front controller, .htaccess, static cache, compiled CSS
novaconium/ the framework itself — router, renderer, vendored Twig, default pages/lib/sass — not edited per-project
```
#### index.php See [Project layout](http://127.0.0.1:8000/admin/docs/project-layout) for the full tree with every file explained.
index.php does two things: ## Third-party
1. Allows you to turn on error reporting (off by default)
2. Loads the novaconium bootstrap file novaconium.php
#### novaconium.php [Twig](https://twig.symfony.com/) is vendored in source form under `novaconium/vendor/twig/` (no Composer — see `/admin/docs/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`.
What happens here:
1. Autoload composer
1. Loads configurations
Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

-28
View File
@@ -1,28 +0,0 @@
{
"name": "4lt/novaconium",
"description": "A high-performance PHP framework built from the past.",
"license": "MIT",
"authors": [
{
"name": "Nick Yeoman",
"email": "dev@4lt.ca",
"homepage": "https://www.4lt.ca"
}
],
"autoload": {
"psr-4": {
"Novaconium\\": "src/"
}
},
"require": {
"php": "^8.1",
"twig/twig": "*",
"nickyeoman/php-validation-class": "^5.0"
},
"minimum-stability": "stable",
"extra": {
"versioning": {
"strategy": "semantic-versioning"
}
}
}
-53
View File
@@ -1,53 +0,0 @@
<?php
$framework_routes = [
'/novaconium' => [
'get' => 'NOVACONIUM/init'
],
'/novaconium/create_admin' => [
'post' => 'NOVACONIUM/create_admin'
],
'/novaconium/login' => [
'post' => 'NOVACONIUM/authenticate',
'get' => 'NOVACONIUM/auth/login'
],
'/novaconium/dashboard' => [
'get' => 'NOVACONIUM/dashboard'
],
'/novaconium/settings' => [
'get' => 'NOVACONIUM/settings'
],
'/novaconium/pages' => [
'get' => 'NOVACONIUM/pages'
],
'/novaconium/page/edit/{id}' => [
'get' => 'NOVACONIUM/editpage'
],
'/novaconium/page/create' => [
'get' => 'NOVACONIUM/editpage'
],
'/novaconium/savePage' => [
'post' => 'NOVACONIUM/savepage'
],
'/novaconium/messages' => [
'get' => 'NOVACONIUM/messages'
],
'/novaconium/messages/delete/{id}' => [
'get' => 'NOVACONIUM/message_delete'
],
'/novaconium/messages/edit/{id}' => [
'get' => 'NOVACONIUM/message_edit'
],
'/novaconium/message_save' => [
'post' => 'NOVACONIUM/message_save'
],
'/novaconium/logout' => [
'post' => 'NOVACONIUM/auth/logout',
'get' => 'NOVACONIUM/auth/logout'
],
'/novaconium/sitemap.xml' => [
'get' => 'NOVACONIUM/sitemap'
],
'/novaconium/sample/{slug}' => [
'get' => 'NOVACONIUM/samples'
],
];
-4
View File
@@ -1,4 +0,0 @@
<?php
http_response_code('404');
header("Content-Type: text/html");
view('@novacore/404');
-11
View File
@@ -1,11 +0,0 @@
<?php
$data = array_merge($data, [
'title' => 'Novaconium Login Page',
'pageclass' => 'novaconium'
]);
// Don't come here if logged in
if ($session->get('username')) {
$redirect->url('/novaconium/dashboard');
makeitso();
}
view('@novacore/auth/login');
-5
View File
@@ -1,5 +0,0 @@
<?php
$session->kill();
$log->info("Logout - Logout Success - " . $_SERVER['REMOTE_ADDR']);
$redirect->url('/');
makeitso();
-70
View File
@@ -1,70 +0,0 @@
<?php
use Nickyeoman\Validation;
$v = new Nickyeoman\Validation\Validate();
$url_success = '/novaconium/dashboard';
$url_fail = '/novaconium/login';
// Don't go further if already logged in
if ( !empty($session->get('username')) ) {
$redirect->url($url_success);
makeitso();
}
// Make sure Session Token is correct
if ($session->get('token') != $post->get('token')) {
$messages->addMessage('error', "Invalid Session.");
$log->error("Login Authentication - Invalid Session Token");
}
// Handle Username
$rawUsername = $post->get('username', null);
$cleanUsername = $v->clean($rawUsername); // Clean the input
$username = strtolower($cleanUsername); // Convert to lowercase
if (!$username) {
$messages->addMessage('error', "No Username given.");
}
// Handle Password
$password = $v->clean($post->get('password', null));
if ( empty($password) ) {
$messages->addMessage('error', "Password Empty.");
}
/*************************************************************************************************************
* Query Database
************************************************************************************************************/
if ($messages->count('error') === 0) {
$query = "SELECT id, username, email, password, blocked FROM users WHERE username = ? OR email = ?";
$matched = $db->getRow($query, [$username, $username]);
if (empty($matched)) {
$messages->addMessage('error', "User or Password incorrect.");
$log->warning("Login Authentication - Login Error, user doesn't exist");
}
}
if ($messages->count('error') === 0) {
// Re-apply pepper
$peppered = hash_hmac('sha3-512', $password, $config['secure_key']);
// Verify hashed password
if (!password_verify($peppered, $matched['password'])) {
$messages->addMessage('error', "User or Password incorrect.");
$log->warning("Login Authentication - Login Error, password wrong");
}
}
// Process Login or Redirect
if ($messages->count('error') === 0) {
$query = "SELECT groupName FROM user_groups WHERE user_id = ?";
$groups = $db->getRow($query, [$matched['id']]);
$session->set('username', $cleanUsername);
$session->set('group', $groups['groupName']);
$redirect->url($url_success);
$log->info("Login Authentication - Login Success");
} else {
$redirect->url($url_fail);
}
-9
View File
@@ -1,9 +0,0 @@
<?php
$data = array_merge($data, [
'title' => 'Coming Soon',
'heading' => 'Coming Soon',
'countdown' => true,
'launch_date' => '2026-01-01T00:00:00'
]);
view('@novacore/coming-soon', $data);
-60
View File
@@ -1,60 +0,0 @@
<?php
// Create an admin user (POST)
use Nickyeoman\Validation;
$validate = new Validation\Validate();
$valid = true;
$p = $post->all();
// Check secure key
if (empty($p['secure_key']) || $p['secure_key'] !== $config['secure_key']) {
$valid = false;
}
// Username
$name = $validate->clean($p['username']);
if (!$validate->minLength($name, 1)) {
$valid = false;
}
// Email
if (empty($p['email'])) {
$valid = false;
} elseif (!$validate->isEmail($p['email'])) {
$valid = false;
}
// Password
if (empty($p['password'])) {
$valid = false;
} else {
// Use pepper + Argon2id
$peppered = hash_hmac('sha3-512', $p['password'], $config['secure_key']);
$hashed_password = password_hash($peppered, PASSWORD_ARGON2ID);
}
if ($valid) {
// Insert user
$query = <<<EOSQL
INSERT INTO `users`
(`username`, `password`, `email`, `validate`, `confirmationToken`, `reset`, `created`, `updated`, `confirmed`, `blocked`)
VALUES
(?, ?, ?, NULL, NULL, NULL, NOW(), NOW(), 1, 0);
EOSQL;
$params = [$name, $hashed_password, $p['email']];
$db->query($query, $params);
$userid = $db->lastid();
// Assign admin group
$groupInsertQuery = <<<EOSQL
INSERT INTO `user_groups` (`user_id`, `groupName`) VALUES (?, ?);
EOSQL;
$db->query($groupInsertQuery, [$userid, 'admin']);
}
// Always redirect at end
$redirect->url('/novaconium');
-14
View File
@@ -1,14 +0,0 @@
<?php
$data = array_merge($data, [
'title' => 'Novaconium Dashboard Page',
'pageclass' => 'novaconium',
'pageid' => 'controlPanel'
]);
if ( empty($session->get('username'))) {
$redirect->url('/novaconium/login');
$messages->error('You are not loggedin');
makeitso();
}
view('@novacore/dashboard', $data);
-85
View File
@@ -1,85 +0,0 @@
<?php
$data = array_merge($data, [
'title' => 'Novaconium Edit Page',
'pageclass' => 'novaconium',
'pageid' => 'controlPanel',
'editor' => 'ace'
]);
// Check if logged in
if (empty($session->get('username'))) {
$messages->error('You are not logged in');
$redirect->url('/novaconium/login');
makeitso();
}
// Get page ID from router parameters
$pageid = $router->parameters['id'] ?? null;
if (!empty($pageid)) {
// Existing page: fetch from database
$query = <<<EOSQL
WITH all_tags AS (
SELECT GROUP_CONCAT(DISTINCT name ORDER BY name SEPARATOR ',') AS tags_list
FROM tags
)
SELECT
p.id,
p.title,
p.heading,
p.description,
p.keywords,
p.author,
p.slug,
p.path,
p.intro,
p.body,
p.notes,
p.draft,
p.changefreq,
p.priority,
p.created,
p.updated,
COALESCE(GROUP_CONCAT(DISTINCT t.name ORDER BY t.name SEPARATOR ','), '') AS page_tags,
at.tags_list AS existing_tags
FROM pages p
LEFT JOIN page_tags pt ON p.id = pt.page_id
LEFT JOIN tags t ON pt.tag_id = t.id
CROSS JOIN all_tags at -- Zero-cost join for scalar
WHERE p.id = ?
GROUP BY p.id;
EOSQL;
$data['rows'] = $db->getRow($query, [$pageid]);
// If no row is found, treat as new page
if (!$data['rows']) {
$pageid = null;
}
}
if (empty($pageid)) {
// New page: set default values for all fields
$data['rows'] = [
'id' => 'newpage',
'title' => '',
'heading' => '',
'description' => '',
'keywords' => '',
'author' => $session->get('username') ?? '',
'slug' => '',
'path' => '',
'intro' => '',
'body' => '',
'notes' => '',
'draft' => 0,
'changefreq' => 'monthly',
'priority' => 0.0,
'created' => date('Y-m-d H:i:s'),
'updated' => date('Y-m-d H:i:s')
];
}
// Render the edit page view
view('@novacore/editpage/index', $data);
-202
View File
@@ -1,202 +0,0 @@
<?php
$data = [
'secure_key' => false,
'gen_key' => NULL,
'users_created' => false,
'empty_users' => false,
'show_login' => false,
'token' => $session->get('token'),
'pageclass' => 'novaconium',
'title' => 'Novaconium Admin'
];
// Check if SECURE KEY is Set in
if ($config['secure_key'] !== null && strlen($config['secure_key']) === 64) {
$data['secure_key'] = true;
} else {
$data['gen_key'] = substr(bin2hex(random_bytes(32)), 0, 64);
$log->warn('secure_key not detected');
}
// Check if user table exists
$query = <<<EOSQL
SELECT TABLE_NAME
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND TABLE_NAME = 'users';
EOSQL;
$result = $db->query($query);
if ($result->num_rows === 0) {
$query = <<<EOSQL
CREATE TABLE `users` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`username` varchar(30) NOT NULL,
`password` varchar(255) NOT NULL,
`email` varchar(255) NOT NULL,
`validate` varchar(32) DEFAULT NULL,
`confirmationToken` varchar(255) DEFAULT NULL,
`reset` varchar(32) DEFAULT NULL,
`created` datetime NOT NULL,
`updated` datetime DEFAULT NULL,
`confirmed` tinyint(1) NOT NULL DEFAULT 0,
`blocked` tinyint(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci;
EOSQL;
$db->query($query);
$data['users_created'] = true;
$log->info('Users Table Created');
}
// Check Usergroup
$query = <<<EOSQL
SELECT TABLE_NAME
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND TABLE_NAME = 'user_groups';
EOSQL;
$result = $db->query($query);
if ($result->num_rows === 0) {
$query = <<<EOSQL
CREATE TABLE `user_groups` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`user_id` INT(11) UNSIGNED NOT NULL,
`groupName` VARCHAR(40) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
EOSQL;
$db->query($query);
$log->info('User_groups Table Created');
}
// Check Pages Table
$query = <<<EOSQL
SELECT TABLE_NAME
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND TABLE_NAME = 'pages';
EOSQL;
$result = $db->query($query);
if ($result->num_rows === 0) {
$query = <<<EOSQL
CREATE TABLE `pages` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`title` varchar(255) NOT NULL,
`heading` varchar(255) NOT NULL,
`description` varchar(255) NOT NULL,
`keywords` varchar(255) NOT NULL,
`author` varchar(255) NOT NULL,
`slug` varchar(255) NOT NULL,
`path` varchar(255) DEFAULT NULL,
`intro` text DEFAULT NULL,
`body` text DEFAULT NULL,
`notes` text DEFAULT NULL,
`created` datetime NOT NULL,
`updated` datetime DEFAULT NULL,
`draft` tinyint(1) NOT NULL DEFAULT 1,
`changefreq` varchar(7) NOT NULL DEFAULT 'monthly',
`priority` float(4,1) NOT NULL DEFAULT 0.0,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci;
EOSQL;
$db->query($query);
$log->info('Pages Table Created');
}
// Check ContactForm Table
$query = <<<EOSQL
SELECT TABLE_NAME
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND TABLE_NAME = 'contactForm';
EOSQL;
$result = $db->query($query);
if ($result->num_rows === 0) {
$query = <<<EOSQL
CREATE TABLE `contactForm` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL,
`email` varchar(255) NOT NULL,
`message` text DEFAULT NULL,
`created` datetime NOT NULL DEFAULT current_timestamp(),
`unread` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'Unread is true by default',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci;
EOSQL;
$db->query($query);
$log->info('ContactForm Table Created');
}
// Check if a user exists
$result = $db->query("SELECT COUNT(*) as total FROM users");
$row = $result->fetch_assoc();
if ($row['total'] < 1) {
$data['empty_users'] = true;
} else {
$log->info('Init Run complete, all sql tables exist with a user.');
// Everything is working, send them to login page
$redirect->url('/novaconium/login');
makeitso();
}
// Check Tags Table
$query = <<<EOSQL
SELECT TABLE_NAME
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND TABLE_NAME = 'tags';
EOSQL;
$result = $db->query($query);
if ($result->num_rows === 0) {
$query = <<<EOSQL
CREATE TABLE IF NOT EXISTS `tags` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL UNIQUE,
`created` datetime NOT NULL,
`updated` datetime DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci;
EOSQL;
$db->query($query);
$log->info('Tags Table Created');
}
// Check Page Tags Junction Table (after tags table)
$query = <<<EOSQL
SELECT TABLE_NAME
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND TABLE_NAME = 'page_tags';
EOSQL;
$result = $db->query($query);
if ($result->num_rows === 0) {
$query = <<<EOSQL
CREATE TABLE IF NOT EXISTS `page_tags` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`page_id` int(11) NOT NULL,
`tag_id` int(11) NOT NULL,
PRIMARY KEY (`id`),
KEY `page_id` (`page_id`),
KEY `tag_id` (`tag_id`),
FOREIGN KEY (`page_id`) REFERENCES `pages` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`tag_id`) REFERENCES `tags` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci;
EOSQL;
$db->query($query);
$log->info('Page Tags Junction Table Created');
}
view('@novacore/init', $data);
-15
View File
@@ -1,15 +0,0 @@
<?php
if ( empty($session->get('username'))) {
$redirect->url('/novaconium/login');
$messages->error('You are not loggedin');
makeitso();
}
$messageid = $router->parameters['id'];
$query="DELETE FROM contactForm WHERE `contactForm`.`id` = ?";
$db->query($query, [$messageid]);
$redirect->url('/novaconium/messages');
$messages->notice("Removed Message $messageid");
makeitso();
-19
View File
@@ -1,19 +0,0 @@
<?php
$data = array_merge($data, [
'title' => 'Novaconium Message Page',
'pageclass' => 'novaconium'
]);
if ( empty($session->get('username'))) {
$redirect->url('/novaconium/login');
$messages->error('You are not loggedin');
makeitso();
}
$messageid = $router->parameters['id'];
$query = "SELECT id, name, email, message, created, unread FROM contactForm WHERE id = '$messageid'";
$data['themessage'] = $db->getRow($query);
view('@novacore/editmessage', $data);
-57
View File
@@ -1,57 +0,0 @@
<?php
use Nickyeoman\Validation;
$v = new Nickyeoman\Validation\Validate();
$url_success = '/novaconium/messages';
$url_error = '/novaconium/messages/edit/' . $post->get('id'); // Redirect back to the message edit form on error
// Check if logged in
if (empty($session->get('username'))) {
$messages->error('You are not logged in');
$redirect->url('/novaconium/login');
makeitso();
}
// Check CSRF token
if ($session->get('token') != $post->get('token')) {
$messages->error('Invalid token');
$redirect->url($url_success);
makeitso();
}
// Get POST data
$id = $post->get('id');
$name = $post->get('name');
$email = $post->get('email');
$message = $post->get('message');
$unread = !empty($post->get('unread')) ? 1 : 0;
// Validate required fields
if (empty($id) || empty($message) || empty($email)) {
$messages->error('One of the required fields was empty.');
$redirect->url($url_error);
makeitso();
}
try {
// Prepare update query
$query = "UPDATE `contactForm`
SET `name` = ?, `email` = ?, `message` = ?, `unread` = ?
WHERE `id` = ?";
$params = [$name, $email, $message, $unread, $id];
$db->query($query, $params);
$messages->notice('Message updated successfully');
} catch (Exception $e) {
$messages->error('Error updating message: ' . $e->getMessage());
$redirect->url($url_error);
makeitso();
}
// Redirect to success page
$redirect->url($url_success);
-22
View File
@@ -1,22 +0,0 @@
<?php
$data = array_merge($data, [
'title' => 'Novaconium Messages',
'pageclass' => 'novaconium',
'pageid' => 'controlPanel'
]);
if ( empty($session->get('username'))) {
$redirect->url('/novaconium/login');
$messages->error('You are not loggedin');
makeitso();
}
// Get the pages
$query = "SELECT id, name, email, LEFT(message, 40) AS message, created, unread FROM contactForm";
$matched = $db->getRows($query);
$data['messages'] = $matched;
view('@novacore/messages', $data);
-21
View File
@@ -1,21 +0,0 @@
<?php
$data = array_merge($data, [
'title' => 'Novaconium Pages',
'pageclass' => 'novaconium',
'pageid' => 'controlPanel'
]);
if ( empty($session->get('username'))) {
$redirect->url('/novaconium/login');
$messages->error('You are not loggedin');
makeitso();
}
// Get the pages
$query = "SELECT id, title, created, updated, draft FROM pages";
$matched = $db->getRows($query);
$data['pages'] = $matched;
view('@novacore/pages', $data);
-34
View File
@@ -1,34 +0,0 @@
<?php
/**
* Pure Twig, no db example
*
* Replicate Hugo but with html and twig (not markdown)
**/
// Variables
$pt = '@novacore/samples'; //Define the view directory
//$pt = 'samples'; //drop the core for your project
//Grab the slug
$slug = $router->parameters['slug'];
//build path
$tmpl = $pt . '/' . $slug;
//Check if file exits
$baseDir = (strpos($pt, 'novacore') !== false) ? FRAMEWORKPATH : BASEPATH;
if (strpos($pt, '@novacore') !== false) {
$baseDir = str_replace('@novacore', FRAMEWORKPATH . '/views', $pt);
} else {
$baseDir = str_replace('@novacore', BASEPATH . '/views', $pt);
}
$possibleFile = $baseDir . '/' . $slug . '.html.twig'; // add .twig extension if needed
if (is_file($possibleFile) && is_readable($possibleFile)) {
view($tmpl, $data);
} else {
http_response_code('404');
header("Content-Type: text/html");
view('@novacore/404');
}
-116
View File
@@ -1,116 +0,0 @@
<?php
use Nickyeoman\Validation;
use Novaconium\Services\TagManager;
$v = new Nickyeoman\Validation\Validate();
$url_error = '/novaconium/page/edit/' . $post->get('id'); // fallback for errors
// -------------------------
// Check login
// -------------------------
if (empty($session->get('username'))) {
$messages->error('You are not logged in');
$redirect->url('/novaconium/login');
makeitso();
}
// -------------------------
// Check CSRF token
// -------------------------
if ($session->get('token') != $post->get('token')) {
$messages->error('Invalid Token');
$redirect->url('/novaconium/pages');
makeitso();
}
// -------------------------
// Gather POST data
// -------------------------
$id = $post->get('id');
$title = $_POST['title'] ?? '';
$heading = $_POST['heading'] ?? '';
$description = $_POST['description'] ?? '';
$keywords = $_POST['keywords'] ?? '';
$author = $_POST['author'] ?? '';
$slug = $_POST['slug'] ?? '';
$path = $_POST['path'] ?? null;
$intro = $_POST['intro'] ?? '';
$body = $_POST['body'] ?? '';
$notes = $_POST['notes'] ?? '';
$draft = !empty($post->get('draft')) ? 1 : 0;
$changefreq = $_POST['changefreq'] ?? 'monthly';
$priority = $_POST['priority'] ?? 0.0;
$tags_json = $_POST['tags_json'] ?? '[]';
// -------------------------
// Decode & sanitize tags
// -------------------------
$tags = json_decode($tags_json, true);
if (!is_array($tags)) $tags = [];
$tags = array_map('trim', $tags);
$tags = array_filter($tags, fn($t) => $t !== '');
$tags = array_unique($tags);
// -------------------------
// Validate required fields
// -------------------------
if (empty($title) || empty($slug) || empty($body)) {
$messages->error('Title, Slug, and Body are required.');
$redirect->url($url_error);
makeitso();
}
try {
$tagManager = new TagManager();
if ($id == 'newpage') {
// -------------------------
// Create new page
// -------------------------
$query = "INSERT INTO `pages`
(`title`, `heading`, `description`, `keywords`, `author`,
`slug`, `path`, `intro`, `body`, `notes`,
`draft`, `changefreq`, `priority`, `created`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())";
$params = [
$title, $heading, $description, $keywords, $author,
$slug, $path, $intro, $body, $notes,
$draft, $changefreq, $priority
];
$db->query($query, $params);
$id = $db->lastid;
$messages->notice('Page Created');
} else {
// -------------------------
// Update existing page
// -------------------------
$query = "UPDATE `pages` SET
`title` = ?, `heading` = ?, `description` = ?, `keywords` = ?, `author` = ?,
`slug` = ?, `path` = ?, `intro` = ?, `body` = ?, `notes` = ?,
`draft` = ?, `changefreq` = ?, `priority` = ?, `updated` = NOW()
WHERE `id` = ?";
$params = [
$title, $heading, $description, $keywords, $author,
$slug, $path, $intro, $body, $notes,
$draft, $changefreq, $priority, $id
];
$db->query($query, $params);
$messages->notice('Page Updated');
}
// -------------------------
// Save tags (for both new and existing pages)
// -------------------------
$tagManager->setTagsForPage($id, $tags);
} catch (Exception $e) {
$messages->error($e->getMessage());
$redirect->url($url_error);
makeitso();
}
// Redirect back to edit page
$redirect->url('/novaconium/page/edit/' . $id);
-15
View File
@@ -1,15 +0,0 @@
<?php
$data = array_merge($data, [
'title' => 'Novaconium Settings',
'pageclass' => 'novaconium',
'pageid' => 'controlPanel'
]);
if ( empty($session->get('username'))) {
$redirect->url('/novaconium/login');
$messages->error('You are not loggedin');
makeitso();
}
view('@novacore/settings', $data);
-42
View File
@@ -1,42 +0,0 @@
<?php
header('Content-Type: text/xml');
// https://www.sitemaps.org/protocol.html
// Check it here: https://www.mysitemapgenerator.com/service/check.html
$query=<<<EOSQL
SELECT draft, slug, updated, changefreq, priority, path
FROM pages
WHERE priority > 0
AND draft = 0
ORDER BY updated DESC;
EOSQL;
$thepages = $db->getRows($query);
// Start the view
echo '<?xml version="1.0" encoding="UTF-8"?>';
echo '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">';
// Loop through the pages
if ( ! empty($thepages) ) {
foreach( $thepages as $v) {
$date = (new \DateTime($v['updated']))->format('Y-m-d');
echo "<url>";
if ( empty($v['path']) )
echo "<loc>" . $config['base_url'] . '/page/' . $v['slug'] . "</loc>";
else
echo "<loc>" . $config['base_url'] . $v['path'] . "</loc>";
echo "<lastmod>" . $date . "</lastmod>";
echo "<changefreq>" . $v['changefreq'] . "</changefreq>";
echo "<priority>" . sprintf("%.1f", $v['priority']) . "</priority>";
echo "</url>";
}
} else {
echo "no pages added yet";
}
echo "</urlset>";
-6
View File
@@ -1,6 +0,0 @@
# 404 Page
404 page is created like any other page.
Create a 404.php in your controllers and a 404.html.twig in your views.
anytime a resource is not found by the router, it will default to this controller.
if you do not have this controller in your app, it will default to the novaconium 404 page.
-18
View File
@@ -1,18 +0,0 @@
# PHP Composer Cheatsheet
Install novaconium with composer: ```composer require 4lt/novaconium```
Install novaconium with composer in docker: ```docker run --rm --interactive --tty --volume $PWD:/app composer:latest require 4lt/novaconium```
Update novaconium with composer in docker: ```docker run --rm --interactive --tty --volume $PWD:/app composer:latest update```
## Install Composer natively on Debian
Assuming you have nala installed (otherwise use apt-get):
```bash
sudo nala install curl php-cli php-mbstring git unzip
curl -sS https://getcomposer.org/installer -o composer-setup.php
sudo php composer-setup.php --install-dir=/usr/local/bin --filename=composer
rm composer-setup.php
```
-52
View File
@@ -1,52 +0,0 @@
# Configuration File
## App/config.php
The configuration file holds a php multi dimentional array for configuration.
#### database
This is the connection setting for mariadb.
```
'database' => [
'host' => 'ny-db',
'name' => 'nydb',
'user' => 'nydbu',
'pass' => 'as7!d5fLKJ2DLKJS5',
'port' => 3306
],
```
#### base_url
Defines the url to use
```
'base_url' => 'https://www.nickyeoman.com',
```
#### secure_key
The security key is used to verify admin account and salt encrpytion functions.
You can generate a key with ```pwgen -cnsB1v 64```
but if you don't set one, novaconium will generate one for you to use (you have to explicily set it though).
```
'secure_key' => '',
```
#### logfile
sets the path of the log file.
```
'logfile' => '/logs/novaconium.log',
```
#### loglevel
Sets the logging level for the app.
```
'loglevel' => 'ERROR' // 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'NONE'
```
-18
View File
@@ -1,18 +0,0 @@
# Fake autoload for dev
put this in index.php
```
// --- Dev-only autoloader for manually cloned vendor copy ---
spl_autoload_register(function ($class) {
if (str_starts_with($class, 'Novaconium\\')) {
$baseDir = BASEPATH . '/vendor/4lt/novaconium/src/';
$relativeClass = substr($class, strlen('Novaconium\\'));
$file = $baseDir . str_replace('\\', '/', $relativeClass) . '.php';
if (file_exists($file)) {
require_once $file;
}
}
});
```
-19
View File
@@ -1,19 +0,0 @@
# Logging
You can use the logging class to output to a file.
use ```$log->info(The Message');```
Logging levels are:
```
'DEBUG' => 0,
'INFO' => 1,
'WARNING' => 2,
'ERROR' => 3,
];
```
It's recommended that production is set to ERROR.
You set the log level in /App/config.php under 'loglevel' => 'ERROR'
-3
View File
@@ -1,3 +0,0 @@
# Messages
Messages is $messages.
-5
View File
@@ -1,5 +0,0 @@
# Post
There is a post class.
It cleans the post.
You can access it with $post.
-6
View File
@@ -1,6 +0,0 @@
# Redirect
How to use redirect class.
$redirect->url;
it's called on every page, if you set it more than once the last one is used.
-33
View File
@@ -1,33 +0,0 @@
# Sass
## Docker
There is a dockerfile in the sass directory you can build an image with.
```bash
cd sass
docker build -t sass-container .
```
## Running Sass
```bash
sudo 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:
```bash
# Build Novaconium (compressed)
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
```
Dev:
```bash
docker run --rm \
-v "$(pwd)/sass:/usr/src/sass" \
-v "/home/nick/tmp/novaproject/novaconium/public/css:/usr/src/css" \
-w /usr/src \
sass-container \
sass sass/novaconium.sass css/novaconium.css --no-source-map --style=compressed
```
-5
View File
@@ -1,5 +0,0 @@
# Sessions
There is a sessions handler built into Novaconium.
$session
-7
View File
@@ -1,7 +0,0 @@
# Style Sheets
The idea is to use sass to generate only what you need for style sheets.
```bash
sudo docker run --rm -v $(pwd):/usr/src/app sass-container sass sass/project.sass public/css/main.css
```
-21
View File
@@ -1,21 +0,0 @@
# Twig
## Overrides
You can override twig templates by creating the same file in the templates directory.
## Calling View
There is a $data that the system uses to store arrays for twig you can save to this array:
```
$data['newinfo'] = 'stuff';
view('templatename');
```
and that will automotically go to twig.
or you can create a new array and pass it in:
```
$anotherArr['newinfo'] = 'stuff';
view('templatename',$anotherArr);
```
-9
View File
@@ -1,9 +0,0 @@
# Docker Cheatsheet (for Novaconium)
## Sample Docker Compose File
See the skeleton directory for an example docker setup.
## Start Docker
```docker compose up -d```
+442
View File
@@ -0,0 +1,442 @@
# Backlog & Roadmap
**Official issue tracker:** https://git.4lt.ca/4lt/novaconium/issues — bug
reports and feature requests are filed and discussed there, not here.
This file is the roadmap that sits *above* the tracker: a curated,
lower-noise list of what's planned, in progress, or decided against, used to
triage and clean up the tracker (grouping related issues, deciding
priority/sequencing, deciding what's not going to happen) rather than to
replace it. An entry here should generally reference the tracker issue(s)
it corresponds to once one exists; an entry can also exist here before any
tracker issue is filed, for things that are still just an idea.
## How to use this file
- Add new items to **Backlog** using the template below. Don't build
anything the moment it's added — Backlog is "known, not yet started."
- Link the tracker issue once one is filed (`Issue:` line). Not every
Backlog entry needs one yet — file the tracker issue when it's ready to
be actionable/discussed, not necessarily when the idea is first written
down here.
- When work begins, move the item to **In Progress**.
- When shipped, move it to **Done**, keep the entry (don't delete), and add
a `Shipped:` line with the date and, once committed, the commit/PR
reference.
- If something is decided against, move it to **Won't Do** with a `Reason:`
line rather than deleting it — the "why not" is worth keeping. Close the
corresponding tracker issue with a link back to that entry.
- Keep entries terse. This file is a map, not a design doc — link out to
`/admin/docs/design-notes`, the tracker issue, or a future `docs/` note for anything long
enough to need one.
### Entry template
```
### <Short title>
- **Type:** Feature | Bug
- **Status:** Backlog | In Progress | Done | Won't Do
- **Priority:** Low | Medium | High
- **Added:** YYYY-MM-DD
- **Depends on:** <other entry title(s)> — omit if none
- **Issue:** https://git.4lt.ca/4lt/novaconium/issues/N — once filed
- **Shipped:** YYYY-MM-DD (commit/PR ref) — only once Done
<1-3 sentence description: what and why. For bugs, include repro steps and
expected vs. actual behavior. For features, include the motivating use case.>
```
---
## Backlog
Suggested build order (foundations first, since admin login builds on two
of the others):
1. **SQLite groundwork** — no dependencies.
2. **MySQL support** — builds directly on SQLite groundwork's `Db`
abstraction; do right after so the abstraction is driver-agnostic from
the start rather than retrofitted.
3. **Session handling** — no dependencies; can be built in parallel with 1.
4. **Blog tags/categories** — no dependencies, but now needs a metadata
source design decision first (`PostRepository` was removed when
`hello-world`/`second-post` became plain Twig pages — see the entry
below), so worth doing first or together with Blog RSS feed.
5. **Blog RSS feed** — no hard dependency, but a per-tag feed is easiest
once tags/categories exist.
6. **Internal search** — needs SQLite groundwork for storage.
7. **XML sitemap** — no hard dependency, but shares crawling logic with
Internal search, so easiest right after (or alongside) it.
8. **Media/file manager** — no hard dependency; usable standalone, though
best gated behind admin login once that exists.
9. **Draft pages (admin-only preview)** — no hard dependency; the admin
authentication it reuses already shipped (see `/admin/docs/admin-auth`).
10. **Copy-to-clipboard button on code blocks** — no dependency; a
self-contained styling/JS feature, can be picked up any time.
11. **Syntax highlighting on code blocks** — no hard dependency on the
copy button above, but touches the same `<pre><code>` markup, so
worth sequencing together to avoid two separate passes over every
code block.
12. **Admin login & user management** — needs both SQLite groundwork (user
store) and session handling (logged-in state).
13. **Ecommerce functionality** — needs SQLite groundwork, session handling
(cart), and admin login & user management (order/product admin, and
customer accounts).
14. **Paywall functionality** — needs everything Ecommerce needs, plus
Ecommerce itself for the recurring-billing/payment-gateway plumbing;
build after it rather than in parallel.
See **Won't Do** below for 404 tracking, dropped in favor of Matomo.
### SQLite groundwork
- **Type:** Feature
- **Status:** Backlog
- **Priority:** High
- **Added:** 2026-07-12
Lay the groundwork for optional SQLite storage (a `Db` or similar `Lib\`
wrapper around `PDO`/`sqlite3`, a data directory outside `public/`, a
migration/schema convention) so features that need persistence — 404
tracking and admin login below, and anything future — have a common place
to store data instead of ad hoc flat files. No ORM; stay consistent with
the project's no-Composer, no-build-step philosophy. Foundational — nothing
else here depends on this being skipped, but 404 tracking and admin login
both depend on it existing. Design the `Db` wrapper's interface with MySQL
support (below) in mind from the start — PDO already abstracts most of the
driver difference, so the schema/migration convention should avoid
SQLite-only syntax where a MySQL-compatible equivalent exists, to avoid a
retrofit.
### MySQL support
- **Type:** Feature
- **Status:** Backlog
- **Priority:** Medium
- **Depends on:** SQLite groundwork
- **Added:** 2026-07-12
Let a project point the `Db` wrapper at MySQL instead of SQLite — via a
`db_driver` (or similar) `App/config.php` key plus connection settings
(host/user/password/database) — for projects that want a real MySQL
server rather than an embedded file, without maintaining two separate data
layers. PDO already supports both drivers under one API, so this should be
mostly a matter of: (1) not writing SQLite-only SQL in the groundwork
above, (2) a config-driven DSN builder, (3) a migration convention that
works on both (or per-driver migration files if syntax genuinely diverges).
No new persistence features depend on this — it's an alternate backend for
the same `Db` abstraction, not a separate feature surface.
### Session handling (with flash sessions)
- **Type:** Feature
- **Status:** Backlog
- **Priority:** High
- **Added:** 2026-07-12
A `Lib\`/framework-core wrapper around PHP's native session handling
(`session_start()` etc., not a custom session store) so sidecars and the
future admin login have a consistent way to read/write session data
instead of touching `$_SESSION` directly. Include CodeIgniter-style flash
data — a value set now that survives exactly one subsequent request (e.g.
`$session->flash('message', 'Saved.')` readable on the next request only,
via a set-now/expire-after-read scheme) — for post/redirect/GET flows like
`App/pages/contact/index.php` already does manually with `?sent=1`.
Foundational alongside SQLite groundwork — no dependencies of its own, but
admin login depends on it.
### Blog tags/categories
- **Type:** Feature
- **Status:** Backlog
- **Priority:** Medium
- **Added:** 2026-07-12
Tag (or category) each blog post so posts can be browsed/filtered by
topic — e.g. `App/pages/blog/tag/[tag]/index.php` listing matching posts,
using the `[param]` route-capture mechanism (see `/admin/docs/routing`;
this project's own `App/pages/blog/` doesn't currently use `[param]` for
posts, every post is its own plain directory named after its slug).
`Lib\PostRepository` (which used to back `hello-world`/`second-post`) was
removed when those two posts became plain Twig pages, like
`twig-syntax-guide` and `style-guide` — so there's no shared metadata
store anymore. `App/pages/blog/index.php` now just hand-lists each
post's slug/title/excerpt in a plain array; adding tags means adding a
`tags` field to each entry there and a lookup by tag over that same
array. Fine at current scale (4 posts); if the array grows unwieldy or
tags need real querying, that's a SQLite-groundwork question — no need
to block on it now. Once a feed exists, consider a per-tag feed too
(e.g. `App/pages/blog/tag/[tag]/feed/index.php`).
### Blog RSS feed
- **Type:** Feature
- **Status:** Backlog
- **Priority:** Medium
- **Added:** 2026-07-12
An RSS (or Atom) feed for the blog, e.g. `App/pages/blog/feed/index.php`
returning `Response::xml(...)` — the sidecar contract already supports
this, no new mechanism needed. `App/pages/blog/index.php` already
hand-lists every post's slug/title/excerpt in a plain array (no
`PostRepository` anymore — see the Blog tags/categories entry above for
why); the remaining gap is a published-date field per entry so a feed
can sort them, regardless of whether that array stays hardcoded or moves
onto SQLite later. Should link from `<link rel="alternate"
type="application/rss+xml">` in the blog layout (or the root layout) for
feed auto-discovery, and probably wants its own `App/pages/blog/_layout/`
or a sidecar-only page — no `index.twig` needed since the sidecar returns
XML directly (see `/admin/docs/sidecars`'s JSON-only example for the same
pattern, just with `Response::json()` instead of `Response::xml()`).
### Internal search
- **Type:** Feature
- **Status:** Backlog
- **Priority:** Medium
- **Depends on:** SQLite groundwork
- **Added:** 2026-07-12
Crawl the site's own pages (likely via `App/pages/` + rendered output,
rather than an external HTTP crawl, to avoid hitting the static cache/
`.htaccess` layer) and index the content into SQLite once the groundwork
above exists, so a search box can query it — a `search` sidecar (e.g.
`App/pages/search/index.php`) that reads the index and returns matching
pages, no external search service. Needs a decision on crawl trigger
(on-demand via an admin action vs. a cron-like re-crawl) and on indexing
granularity (whole-page vs. per-section). Consider reusing the SQLite
FTS5 extension if available, rather than hand-rolling text search.
### XML sitemap
- **Type:** Feature
- **Status:** Backlog
- **Priority:** Medium
- **Added:** 2026-07-12
Generate a `/sitemap.xml` listing every routable page (`App/pages/` +
`novaconium/pages/` overlay, same resolution `Router`/`Overlay` already do)
for search engine discovery — one more piece of the SEO groundwork already
laid (`/admin/docs/seo`, canonical links, etc.). No hard dependency on
SQLite: a first version can be built by walking `pages_dirs` directly
(skipping reserved `_`/`404` segments, resolving `[param]` routes only if
their concrete values are knowable from some data source — moot for
`App/pages/blog/` today, since every post there is now a plain directory
rather than a `[param]` route) the same way `Router::resolve()` walks it,
with no separate crawl step.
That said, it may be worth sharing a crawler with Internal search rather
than building two separate page-enumeration mechanisms — if Internal
search's crawler already walks and records every real URL (including
resolved `[param]` values), the sitemap can just be a different
serialization of that same data instead of its own logic. Worth deciding
together when either is picked up. Should also exclude `/admin/docs/*` if
it isn't publicly reachable (see admin authentication) and the `noindex`
pages already marked via the `robots` block.
### Media/file manager
- **Type:** Feature
- **Status:** Backlog
- **Priority:** Medium
- **Added:** 2026-07-12
An upload/browse/delete UI for media (images, PDFs, etc.) so sidecars and
Twig templates have a consistent place to reference uploaded files from
— e.g. a blog post's header image — instead of authors manually copying
files into `public/`. Likely a new `/admin/media` page — already covered
by the admin authentication gate (every `/admin/*` route) the moment it's
added, no extra wiring needed — backed by a plain directory under
`public/uploads/` rather than a database (files are already static
assets; no need for SQLite here unless metadata like alt text/captions is
wanted later, in which case that part could ride on SQLite groundwork).
Needs basic safety handling: extension allowlist, filename sanitization,
and a max upload size, since this is a file-write surface.
### Draft pages (admin-only preview)
- **Type:** Feature
- **Status:** Backlog
- **Priority:** Medium
- **Added:** 2026-07-13
Let a page under `App/pages/` (or `novaconium/pages/`) be written and
previewed by an admin without being visible to the public — a draft blog
post, an in-progress redesign of a page, etc. Likely a config-driven list
(e.g. `'draft_routes' => ['blog/upcoming-post']` in `App/config.php`) or a
per-sidecar flag (`return ['draft' => true, ...]`), checked in
`novaconium/bootstrap.php` right alongside the existing `/admin/*` gate —
reusing `AdminAuth::requireLogin()` (see `/admin/docs/admin-auth`) rather
than inventing a second auth mechanism: not logged in → 404 (not a login
prompt, so a draft's existence isn't revealed to anyone poking at the
URL), logged in → renders normally.
**The gotcha to design around from day one:** sidecar-less pages get
written to the static HTML cache and served by `.htaccess` *before PHP
ever runs* (see `/admin/docs/caching`) — if a draft page took that path,
the cached file would be world-readable the moment an admin previewed it
once, completely bypassing the auth check for anyone hitting the same URL
afterward. So a draft page must either always go through a sidecar (never
cached, checked via the config/flag above) or `Renderer`/`Cache` need an
explicit "never write this route to the cache" exception. Whichever
approach ships, add a line to `/admin/docs/caching` and `AGENTS.md`
calling this out, the same way the `mb_substr`/`|slice` gotcha got a
standing-rule note — it's exactly the kind of interaction between two
independently-reasonable features that's easy to get wrong once and
should only need explaining once.
### Copy-to-clipboard button on code blocks
- **Type:** Feature
- **Status:** Backlog
- **Priority:** Low
- **Added:** 2026-07-13
Every `<pre><code>` block across `/admin/docs/*` and the blog's reference
posts (Twig Syntax Guide, Style Guide) is meant to be copy-pasted — add a
small button on hover that copies the block's text via the
[Clipboard API](https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/writeText)
(`navigator.clipboard.writeText(...)`), consistent with this project's
no-build-step philosophy: vanilla JS, no dependency, same pattern as the
dark/light theme toggle (`novaconium/pages/_layout/theme-toggle.twig`) —
a small inline script using event delegation rather than a script per
button. Needs a new icon (a "copy"/clipboard glyph — see
`novaconium/pages/_layout/icons.twig` for the existing set and its
inline-SVG-not-icon-font convention) and matching Sass in
`novaconium/sass/main.sass`, plus a way to actually grab a code block's
raw text without the HTML entities used to keep literal tags/Twig syntax
from rendering inside `<pre><code>` (e.g. `&lt;h1&gt;` in the SEO starter
template) — reading `textContent` rather than `innerHTML` handles the
entity-decoding automatically, so this should be low-risk, but worth
calling out since it's the same class of escaping issue documented in
`AGENTS.md`/fixed across `/admin/docs/seo` and the Twig Syntax Guide.
Should show a brief "Copied!" confirmation (swap the button label/icon
for ~12 seconds) rather than a silent copy, so it's clear it worked.
### Syntax highlighting on code blocks
- **Type:** Feature
- **Status:** Backlog
- **Priority:** Low
- **Added:** 2026-07-13
Color the PHP/Twig/Bash/HTML snippets across `/admin/docs/*` and the
blog's reference posts instead of the current flat, single-color
`<pre><code>` rendering. PHP has a built-in `highlight_string()`, but it
only understands PHP — everything else on this site's code blocks (Twig
template syntax, Bash/Docker commands in `/admin/docs/styling`, plain
HTML) needs something else, so use [highlight.js](https://highlightjs.org/)
client-side for everything uniformly, with the **ir-black** theme (dark,
high-contrast — fits this project's existing dark/teal default palette).
ir-black is a dark-only theme, though, and this site now has a light
theme too (see the dark/light toggle) — decide whether code blocks stay
ir-black regardless of site theme (simplest, and arguably fine since
code blocks are visually distinct boxes already), or whether a second,
light-appropriate highlight.js theme gets swapped in via the same
`data-theme` attribute the color-palette toggle already sets.
Staying consistent with the no-CDN, no-build-step philosophy (Twig itself
is vendored, not `npm install`ed) means vendoring highlight.js's built
`highlight.min.js` + the `ir-black.min.css` theme file directly under
`novaconium/vendor/` next to `twig/`, following the same
one-subdirectory-per-vendor convention established there — rather than
pulling from a CDN. highlight.js doesn't ship a Twig grammar out of the
box; either register a custom language definition for it (Twig's syntax
is close enough to Jinja2 that an existing community Jinja grammar may
mostly work) or leave Twig snippets using the plain/no-highlight class
and accept that only PHP/Bash/HTML/XML get colored initially.
No hard dependency on the copy-to-clipboard button above, but both touch
every `<pre><code>` block on the site, so doing them in the same pass
avoids visiting each doc page's code samples twice. If both ship, the
copy button must keep copying the plain, unhighlighted text (via
`textContent`, not `innerHTML` — see that entry) even after this adds
`<span>` wrappers around tokens, so a copied snippet doesn't come out
full of stray markup.
### Admin login & user management
- **Type:** Feature
- **Status:** Backlog
- **Priority:** Medium
- **Depends on:** SQLite groundwork, Session handling (with flash sessions)
- **Added:** 2026-07-12
A single-user HTTP Basic Auth stopgap now gates `/admin/*`
(`novaconium/src/AdminAuth.php`, `admin_username`/`admin_password_hash` in
`App/config.php` — see `/admin/docs/admin-auth`), so `/admin` is no longer
wide open by default choice. This entry is the real, larger replacement:
multiple accounts, a user store (the SQLite groundwork above — a `users`
table with hashed passwords via `password_hash()`/`password_verify()`, no
external auth library), proper sessions instead of Basic Auth (rides on
session handling above), and basic user management (create/disable a
user, change password). Ship this by replacing `AdminAuth::requireLogin()`
with the new mechanism, not layering on top of it.
### Ecommerce functionality
- **Type:** Feature
- **Status:** Backlog
- **Priority:** Low
- **Depends on:** SQLite groundwork, Session handling (with flash sessions), Admin login & user management
- **Added:** 2026-07-12
Product catalog, cart, checkout, and order storage — a `products` /
`orders` table in SQLite, a session-based cart (rides on the flash-session
work above), and a payment gateway integration for actually taking money.
Given the project's no-Composer/no-vendored-SDK philosophy, prefer calling
a payment provider's HTTP API directly (e.g. Stripe's REST API via cURL)
over vendoring a full SDK, same reasoning as vendoring only Twig's `src/`
rather than pulling in a package manager. Needs a decision on which
provider(s) to support first. Order/product management rides on admin
login above. Large feature — likely worth its own sub-breakdown (catalog,
cart, checkout, order admin) once it's actually picked up rather than
planning it all up front here.
### Paywall functionality
- **Type:** Feature
- **Status:** Backlog
- **Priority:** Low
- **Depends on:** Ecommerce functionality (recurring billing/payment plumbing), SQLite groundwork, Session handling (with flash sessions), Admin login & user management
- **Added:** 2026-07-12
Subscription/membership content gating, similar to OnlyFans/Patreon:
recurring billing tied to a user account, content (posts, pages, media)
marked as gated behind an active subscription, and access checks in
sidecars (`$_SESSION`'s logged-in user + subscription status, similar to
how admin login gates `/admin/*`). Reuses Ecommerce's payment-gateway
plumbing for the recurring-charge side rather than integrating a payment
provider a second time — build after Ecommerce rather than in parallel.
Also needs a decision on how gated content is authored (a `gated: true`
flag in a sidecar's returned context vs. a separate content root) and
what happens to cached pages once caching only applies to sidecar-less
pages — gated pages will need a sidecar to check access, so they're never
statically cached, which is consistent with the existing caching model
but worth being explicit about up front.
## In Progress
_Nothing yet._
## Done
_Nothing yet._
## Won't Do
### 404 tracking
- **Type:** Feature
- **Status:** Won't Do
- **Priority:** Medium
- **Added:** 2026-07-12
- **Reason:** Matomo (see `/admin/docs/matomo`) already tracks 404 hits —
`Renderer::renderNotFound()` sets `is_404 => true` and the tracking
snippet tags the document title as `404/URL = <path>/From = <referrer>`
before `trackPageView`, so 404s are already filterable in Matomo under
Behaviour → Page URLs. A separate in-app SQLite-backed 404 log would just
duplicate what Matomo already captures, for no real benefit.
Originally proposed as: log requests that hit the 404 page (path,
timestamp, referrer, user agent) into SQLite, similar to Joomla's 404 log,
with an `/admin` page to view them.
+96
View File
@@ -0,0 +1,96 @@
<?php
/**
* Manual PSR-4 autoloader (no Composer).
*
* There's no vendor/autoload.php here — this file *is* the autoloader.
* `spl_autoload_register()` below registers a callback that PHP invokes
* automatically the first time an unknown class/namespace is referenced
* (e.g. `new Router(...)` or `use Lib\Mailer;`), so nothing else in this
* project ever has to manually `require` a class file.
*
* Two different resolution strategies live in that one callback:
* - `Twig\` and `App\` are plain one-directory-per-namespace PSR-4 (see
* $__psr4_prefixes below) — no override behavior, just a straight
* namespace-to-path mapping.
* - `Lib\` is resolved against an *ordered list* of directories (see
* $__lib_dirs below) — the first one where the file exists wins. This
* is what lets a project drop a same-named class into `App/lib/` to
* override a `novaconium/lib/` default, without editing novaconium/ at
* all — the same App-over-novaconium pattern used for pages/layouts
* (see Overlay.php) and Sass colors, just implemented here instead.
*/
// Straight namespace -> directory mapping, one entry per prefix. No
// override list here because neither of these is meant to be replaced by
// a project: Twig is vendored framework code, and App\ is the framework's
// own core classes (Router, Renderer, Cache, etc.) — see
// novaconium/src/. (A project's own code goes under Lib\, below.)
$__psr4_prefixes = [
'Twig\\' => __DIR__ . '/vendor/twig/src/',
'App\\' => __DIR__ . '/src/',
];
// Lib\ is resolved against App/lib first so a project can add/override
// classes, falling back to novaconium's default lib/ when not found there.
// Order matters: this array is walked top-to-bottom and the first file
// that actually exists on disk wins, so App/lib/ always gets first look.
$__lib_dirs = [
__DIR__ . '/../App/lib/',
__DIR__ . '/lib/',
];
// The callback PHP calls whenever a referenced class hasn't been loaded
// yet. It only ever needs to `require` the right file (or silently return
// if nothing matches, which just leaves the class undefined and PHP throws
// its own "Class not found" error) — there's no class map to build or
// cache, since every lookup is a cheap `is_file()` check against a handful
// of candidate paths.
spl_autoload_register(function (string $class) use (&$__psr4_prefixes, &$__lib_dirs): void {
// Lib\Foo\Bar -> try App/lib/Foo/Bar.php, then novaconium/lib/Foo/Bar.php.
if (str_starts_with($class, 'Lib\\')) {
$relative = substr($class, strlen('Lib\\'));
foreach ($__lib_dirs as $baseDir) {
$file = $baseDir . str_replace('\\', '/', $relative) . '.php';
if (is_file($file)) {
require $file;
return;
}
}
return;
}
// Twig\Foo\Bar -> novaconium/vendor/twig/src/Foo/Bar.php,
// App\Foo\Bar -> novaconium/src/Foo/Bar.php.
// Only one prefix can ever match (they don't overlap), so the loop
// just finds which one applies and returns right after handling it.
foreach ($__psr4_prefixes as $prefix => $baseDir) {
if (!str_starts_with($class, $prefix)) {
continue;
}
$relative = substr($class, strlen($prefix));
$file = $baseDir . str_replace('\\', '/', $relative) . '.php';
if (is_file($file)) {
require $file;
}
return;
}
});
// Twig 3.x calls trigger_deprecation() (normally provided by symfony/deprecation-contracts,
// which we don't vendor). Provide the same signature so deprecated code paths don't fatal.
// This has nothing to do with class autoloading above — it's defined here
// simply because this file already runs on every request before Twig does,
// making it a convenient, guaranteed-to-run-first place for the shim to live.
if (!function_exists('trigger_deprecation')) {
function trigger_deprecation(string $package, string $version, string $message, mixed ...$args): void
{
@trigger_error(
($package || $version ? "Since $package $version: " : '') . ($args ? vsprintf($message, $args) : $message),
E_USER_DEPRECATED
);
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
use App\Cache;
require __DIR__ . '/../autoload.php';
$config = require __DIR__ . '/../config.php';
$appConfigFile = __DIR__ . '/../../App/config.php';
if (is_file($appConfigFile)) {
$config = array_merge($config, require $appConfigFile);
}
(new Cache($config['cache_dir']))->clear();
echo "Cache cleared.\n";
+98
View File
@@ -0,0 +1,98 @@
<?php
/**
* Scaffolds a new static page from the same copy-paste starter template
* documented at /admin/docs/seo, so adding a page doesn't require manually
* copying it by hand.
*
* Usage:
* php novaconium/bin/create-static-page.php <path>
*
* <path> is relative to App/pages/ and becomes the route — with or
* without a trailing index.twig/.twig, so any of these are equivalent:
* php novaconium/bin/create-static-page.php pricing
* php novaconium/bin/create-static-page.php blog/my-new-post
* php novaconium/bin/create-static-page.php blog/my-new-post.twig
* php novaconium/bin/create-static-page.php blog/my-new-post/index.twig
*
* Creates App/pages/<path>/index.twig and nothing else — no sidecar, since
* the point of this template is a sidecar-less, statically-cacheable page
* (see /admin/docs/caching). Add an index.php next to it yourself if the
* page ends up needing one (see /admin/docs/sidecars).
*/
$path = $argv[1] ?? null;
if ($path === null || trim($path) === '') {
fwrite(STDERR, "Usage: php novaconium/bin/create-static-page.php <path>\n");
fwrite(STDERR, "Example: php novaconium/bin/create-static-page.php blog/my-new-post\n");
exit(1);
}
// Normalize away a trailing /index.twig or .twig, so the path can be
// passed either as a bare route or as a file-shaped argument.
$path = trim($path, '/');
$path = preg_replace('#/index\.twig$#', '', $path);
$path = preg_replace('#\.twig$#', '', $path);
if ($path === '') {
fwrite(STDERR, "Error: path cannot be empty.\n");
exit(1);
}
// Reserved segments (see Router::resolve()) can never be routed to
// directly — refuse to scaffold a page nothing could ever visit.
foreach (explode('/', $path) as $segment) {
if ($segment === '' || str_starts_with($segment, '_') || $segment === '404') {
fwrite(STDERR, "Error: \"$segment\" is a reserved path segment (starts with _, or is literally \"404\") and can never be routed to directly.\n");
exit(1);
}
}
$pageDir = __DIR__ . '/../../App/pages/' . $path;
$templateFile = $pageDir . '/index.twig';
if (is_file($templateFile)) {
fwrite(STDERR, "Error: $templateFile already exists — not overwriting.\n");
exit(1);
}
if (!is_dir($pageDir) && !mkdir($pageDir, 0775, true) && !is_dir($pageDir)) {
fwrite(STDERR, "Error: could not create directory $pageDir\n");
exit(1);
}
// "my-new-post" -> "My New Post", used to pre-fill the title/heading.
$title = ucwords(str_replace(['-', '_'], ' ', basename($path)));
$template = <<<TWIG
{% extends layout %}
{% block title %}$title{% endblock %}
{% block description %}One or two sentences describing this page.{% endblock %}
{% block robots %}index, follow{% endblock %}
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
{% block og_type %}website{% endblock %}
{% block og_title %}{{ block('title') }}{% endblock %}
{% block og_description %}{{ block('description') }}{% endblock %}
{% block og_url %}{{ block('canonical') }}{% endblock %}
{% block twitter_card %}summary{% endblock %}
{% block twitter_title %}{{ block('title') }}{% endblock %}
{% block twitter_description %}{{ block('description') }}{% endblock %}
{% block content %}
<article>
<h1>$title</h1>
<p>...</p>
</article>
{% endblock %}
TWIG;
file_put_contents($templateFile, $template);
echo "Created $templateFile\n";
echo "Visit it at /$path once you fill in the content.\n";
+92
View File
@@ -0,0 +1,92 @@
<?php
/**
* Front-controller wiring. Every request — via public/index.php (Apache) or
* public/router.php (php -S) — ends up require'ing this one file, which:
* 1. loads config (framework defaults + optional App/ override)
* 2. handles the one hardcoded route (/admin/logout) that exists outside
* the normal page tree
* 3. resolves the URL to a page directory (Router)
* 4. gates /admin/* behind Basic Auth, if configured (AdminAuth)
* 5. renders the matched page, or a 404 (Renderer)
* There's no framework "kernel" class doing this — it's just a plain
* top-to-bottom script, deliberately, so a dev can read the whole
* request lifecycle in one file without chasing an abstraction.
*/
use App\AdminAuth;
use App\Cache;
use App\Renderer;
use App\Router;
require __DIR__ . '/autoload.php';
// Framework defaults live in novaconium/config.php. If the project defines
// its own App/config.php, shallow-merge it on top — a project only needs to
// list the keys it wants to change (same override pattern as pages/lib,
// just for an array instead of a file lookup). See /admin/docs/config.
$config = require __DIR__ . '/config.php';
$appConfigFile = __DIR__ . '/../App/config.php';
if (is_file($appConfigFile)) {
$config = array_merge($config, require $appConfigFile);
}
if ($config['debug']) {
error_reporting(E_ALL);
ini_set('display_errors', '1');
}
// Trim the query string and any trailing slash so /admin/logout/ and
// /admin/logout?x=1 both match the check below the same way $router does
// internally for real page routes.
$requestUri = $_SERVER['REQUEST_URI'] ?? '/';
$requestPath = rtrim(parse_url($requestUri, PHP_URL_PATH) ?: '/', '/');
// /admin/logout isn't a real page under App/pages/ or novaconium/pages/ —
// there's nothing to route to, so it's special-cased here before the
// router even runs. AdminAuth::logout() always sends a fresh 401 challenge
// (the standard trick for "logging out" of HTTP Basic Auth, which has no
// real server-side session to invalidate) and exits immediately.
if ($requestPath === '/admin/logout') {
AdminAuth::logout();
}
// Router::resolve() only answers "does a page exist at this URL, and if
// so which directory / what params?" — it never touches Twig, sidecars, or
// output. See novaconium/src/Router.php and /admin/docs/routing.
$router = new Router($config['pages_dirs']);
$route = $router->resolve($requestUri);
// Every route under /admin/* — clear-cache, docs, password-hash, and any
// admin page a project adds later — is gated here, once, rather than in
// each page individually. A new admin page is automatically protected the
// moment it exists; nothing to remember to wire up. No-op (open access)
// when admin_password_hash is empty, which is the default. See
// novaconium/src/AdminAuth.php and /admin/docs/admin-auth.
if ($route->found && ($route->dir === 'admin' || str_starts_with((string) $route->dir, 'admin/'))) {
AdminAuth::requireLogin($config['admin_username'], $config['admin_password_hash']);
}
// Both of these are derived, request-independent config values that get
// handed to the Renderer so it can expose them to every Twig template as
// globals (matomo_url/matomo_site_id/admin_auth_enabled) — see
// Renderer::__construct(). Normalizing the trailing slash here means every
// template can safely do `matomo_url + 'matomo.php'` without checking.
$matomoUrl = $config['matomo_url'] !== '' ? rtrim($config['matomo_url'], '/') . '/' : '';
$adminAuthEnabled = $config['admin_password_hash'] !== '';
$cache = new Cache($config['cache_dir']);
$renderer = new Renderer($config['pages_dirs'], $cache, $adminAuthEnabled, $matomoUrl, $config['matomo_site_id'], $config['site_name']);
// $route->found is false for anything Router couldn't match to a real page
// (no index.twig or index.php at the resolved directory) — render the 404
// page and stop. Otherwise render the matched page: runs its sidecar (if
// any), resolves the nearest layout, renders Twig, and writes the static
// cache for sidecar-less pages. See novaconium/src/Renderer.php.
if (!$route->found) {
$renderer->renderNotFound($requestUri);
return;
}
$renderer->render($route, $requestUri);
+41
View File
@@ -0,0 +1,41 @@
<?php
// These are the framework defaults. A project overrides any subset of them
// by creating App/config.php returning an array of just the keys it wants
// to change — novaconium/bootstrap.php shallow-merges it over this file, the
// same App-over-novaconium override pattern used for pages/ and lib/. This
// file itself is not meant to be edited per-project.
return [
// Ordered override roots: App/pages is checked first so a project can
// override any page, sidecar, or layout by placing one at the same
// relative path there; novaconium/pages supplies the framework defaults.
'pages_dirs' => [
__DIR__ . '/../App/pages',
__DIR__ . '/pages',
],
'cache_dir' => __DIR__ . '/../public/cache',
'debug' => true,
// Site name used as the default page title, og:site_name, and the
// footer copyright line in novaconium/pages/_layout/layout.twig.
'site_name' => 'Novaconium Website',
// Matomo analytics. Leave both empty (the default) to disable tracking
// entirely — the layout emits no tracking script at all in that case.
// Set both via App/config.php to enable, e.g.:
// 'matomo_url' => 'https://matomo.example.com/',
// 'matomo_site_id' => '1',
'matomo_url' => '',
'matomo_site_id' => '',
// Gates every /admin/* route (clear-cache, docs, and any future admin
// page) behind HTTP Basic Auth. Leave admin_password_hash empty (the
// default) to disable the gate entirely — matches this project's
// existing wide-open behavior until a project opts in. Generate a hash
// with: php -r "echo password_hash('yourpassword', PASSWORD_DEFAULT), PHP_EOL;"
// and set both via App/config.php, e.g.:
// 'admin_username' => 'admin',
// 'admin_password_hash' => '$2y$10$...',
'admin_username' => 'admin',
'admin_password_hash' => '',
];
+73
View File
@@ -0,0 +1,73 @@
<?php
namespace Lib;
/**
* Session-token CSRF protection, standalone from Lib\FormValidator — a
* sidecar calls Csrf::verify() directly, typically before running any other
* validation:
*
* if (!Csrf::verify(Input::post('csrf_token'))) {
* return Response::redirect('/contact?error=security');
* }
*
* and the template renders a hidden field for it:
*
* <input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
*
* This is the first thing in the framework that starts a native PHP session
* — but only lazily, the moment token()/verify() is actually called. A page
* that never touches Csrf never gets a session cookie. This is unrelated to
* Lib\AdminAuth, which stays fully stateless (HTTP Basic Auth, no sessions
* for login state) — see novaconium/src/AdminAuth.php.
*/
final class Csrf
{
public const FIELD_NAME = 'csrf_token';
private const SESSION_KEY = '_csrf_token';
public static function token(): string
{
self::ensureSession();
if (empty($_SESSION[self::SESSION_KEY])) {
$_SESSION[self::SESSION_KEY] = bin2hex(random_bytes(32));
}
return $_SESSION[self::SESSION_KEY];
}
public static function verify(?string $submittedToken): bool
{
self::ensureSession();
$expected = $_SESSION[self::SESSION_KEY] ?? null;
if ($submittedToken === null || $expected === null) {
return false;
}
return hash_equals($expected, $submittedToken);
}
public static function fieldName(): string
{
return self::FIELD_NAME;
}
private static function ensureSession(): void
{
if (session_status() === PHP_SESSION_ACTIVE) {
return;
}
// Must be called before session_start() — after is a silent no-op.
session_set_cookie_params([
'httponly' => true,
'samesite' => 'Lax',
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
]);
session_start();
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
namespace Lib;
/**
* A small accumulating validator for sidecar forms, so a sidecar doesn't
* hand-roll the same required()/email() checks and $errors array for
* every form it validates. See App/pages/contact/index.php for a working
* example, and /admin/docs/sidecars for the full write-up.
*
* Each check here just records a field/message pair on failure — for the
* lower-level validation logic itself (what counts as a valid email,
* phone number, etc.), see Lib\Validate, which this class calls into
* rather than duplicating.
*
* Usage:
* $validator = (new FormValidator())
* ->required($old['name'], 'name', 'Name is required.')
* ->email($old['email'], 'email', 'A valid email is required.')
* ->maxLength($old['message'], 'message', 2000, 'Message is too long.');
*
* if ($validator->passes()) { ... }
* $errors = $validator->errors();
*/
final class FormValidator
{
/** @var array<string,string> */
private array $errors = [];
public function required(string $value, string $field, string $message): static
{
if (trim($value) === '') {
$this->errors[$field] = $message;
}
return $this;
}
public function email(string $value, string $field, string $message): static
{
if (Validate::isEmail($value) === false) {
$this->errors[$field] = $message;
}
return $this;
}
public function minLength(string $value, string $field, int $length, string $message): static
{
if (Validate::minLength($value, $length) === false) {
$this->errors[$field] = $message;
}
return $this;
}
public function maxLength(string $value, string $field, int $length, string $message): static
{
if (!Validate::maxLength($value, $length)) {
$this->errors[$field] = $message;
}
return $this;
}
public function passes(): bool
{
return $this->errors === [];
}
/** @return array<string,string> */
public function errors(): array
{
return $this->errors;
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace Lib;
/**
* A cleaning accessor for $_POST and $_GET, so sidecars never touch the
* superglobals directly.
*
* Cleaning here (trim + strip_tags, via Lib\Validate::clean(), plus null-byte
* stripping) is defense-in-depth against HTML/script injection in output
* contexts — it is NOT a defense against SQL injection, and must never be
* treated as one. Twig already autoescapes {{ }} output by default (see
* novaconium/src/Renderer.php), so this cleaning is a second layer, not the
* only one. The real defense against SQL injection is parameterized queries
* (PDO prepared statements) — never string concatenation or
* sanitize-then-interpolate, however "cleaned" the input looks. There's no
* database layer in this framework yet (SQLite groundwork is tracked as
* Backlog in novaconium/ISSUES.md); when one lands, use PDO prepared statements
* exclusively. This class deliberately does not (and will not) expose an
* "sqlSafe()"-style method — no string transform makes arbitrary input safe
* to concatenate into SQL, and a method implying otherwise would be actively
* dangerous.
*
* One documented exception: a field that needs an exact, unmodified value
* (e.g. a password about to be hashed) should read $_POST directly instead —
* cleaning would silently strip characters like < and > before hashing,
* producing a hash that doesn't match what the user actually typed. See
* novaconium/pages/admin/password-hash/index.php for the one place this
* framework does that on purpose.
*/
final class Input
{
/** @var array<string,mixed>|null */
private static ?array $cleanedPost = null;
/** @var array<string,mixed>|null */
private static ?array $cleanedGet = null;
public static function post(?string $key = null, mixed $default = null): mixed
{
return self::read(self::cleanedPost(), $key, $default);
}
public static function get(?string $key = null, mixed $default = null): mixed
{
return self::read(self::cleanedGet(), $key, $default);
}
private static function read(array $source, ?string $key, mixed $default): mixed
{
if ($key === null) {
return $source;
}
return array_key_exists($key, $source) ? $source[$key] : $default;
}
private static function cleanedPost(): array
{
return self::$cleanedPost ??= self::cleanArray($_POST);
}
private static function cleanedGet(): array
{
return self::$cleanedGet ??= self::cleanArray($_GET);
}
private static function cleanArray(array $values): array
{
$result = [];
foreach ($values as $key => $value) {
$result[$key] = is_array($value) ? self::cleanArray($value) : self::cleanValue($value);
}
return $result;
}
private static function cleanValue(mixed $value): mixed
{
if (!is_string($value)) {
return $value;
}
return Validate::clean(str_replace("\0", '', $value));
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace Lib;
final class Mailer
{
public function send(string $name, string $email, string $message): bool
{
// Stand in for a real mail call — log instead so the example has no
// external dependency (swap this out for mail()/an API call/etc).
$line = sprintf(
"[%s] %s <%s>: %s\n",
date('c'),
$name,
$email,
str_replace("\n", ' ', $message)
);
file_put_contents(__DIR__ . '/../contact-log.txt', $line, FILE_APPEND);
return true;
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace Lib;
/**
* Self-hosted spam detection for any form sidecar: a honeypot field plus
* a submission-timing check. No external CAPTCHA service, no CDN script,
* no site key/secret key, no outbound API call — see /admin/docs/sidecars's
* "Spam prevention" section for the full write-up and App/pages/contact/
* for a working example.
*
* Pair this with a hidden honeypot input (named per $honeypotField below,
* hidden off-screen via the .hp-field CSS class — not display:none, since
* some bots specifically skip fields hidden that way) and a hidden
* `renderedAt()`-valued timestamp field in the form's Twig template.
*/
final class SpamGuard
{
public function __construct(
private readonly string $honeypotField = 'website',
private readonly string $timestampField = 'rendered_at',
private readonly int $minSeconds = 2,
) {
}
/**
* Call this when rendering the form (i.e. in the sidecar's return
* array, GET or POST) and put the result in a hidden field named
* $timestampField for isSpam() to read back on submit.
*/
public function renderedAt(): int
{
return time();
}
/**
* @param array<string,mixed> $post typically $_POST
*/
public function isSpam(array $post): bool
{
$honeypotFilled = trim((string) ($post[$this->honeypotField] ?? '')) !== '';
$renderedAt = (int) ($post[$this->timestampField] ?? 0);
// Not cryptographically signed, so a determined bot could forge
// this — it's a deterrent against unsophisticated spam, not a
// security boundary.
$tooFast = $renderedAt === 0 || (time() - $renderedAt) < $this->minSeconds;
return $honeypotFilled || $tooFast;
}
}
+116
View File
@@ -0,0 +1,116 @@
<?php
namespace Lib;
/**
* General-purpose input validation primitives, modeled after the
* project author's own reusable validation class
* (https://github.com/nickyeoman/php-validation-class) — rewritten here
* as stateless static methods so any sidecar can call them directly, no
* instance to construct.
*
* Each method returns the cleaned/validated value itself (or `false`),
* not just a pass/fail boolean, so a sidecar can use the normalized
* result. For accumulating named field errors across a whole form (the
* "is this form valid, and what's wrong with it" question), see
* Lib\FormValidator, which uses isEmail() below internally.
*/
final class Validate
{
/**
* Trims whitespace and strips HTML tags from a piece of user input.
*/
public static function clean(?string $value): ?string
{
if ($value === null) {
return null;
}
return trim(strip_tags($value));
}
/**
* @return string|false the lowercased, trimmed email if valid, else false
*/
public static function isEmail(string $email): string|false
{
$email = strtolower(trim($email));
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false ? $email : false;
}
/**
* @return int|false the trimmed string's length if it meets the minimum, else false
*/
public static function minLength(string $value, int $length): int|false
{
$len = mb_strlen(trim($value));
return $len >= $length ? $len : false;
}
public static function maxLength(string $value, int $length): bool
{
return mb_strlen(trim($value)) <= $length;
}
/**
* Compares two values for equality after cleaning both (e.g. a
* "confirm email" or "confirm password" field).
*/
public static function isMatch(string $a, string $b): bool
{
return self::clean($a) === self::clean($b);
}
/**
* Accepts a 7- or 10-digit phone number, with any non-digit
* formatting (spaces, dashes, parens) stripped. If $withExtension is
* true, an "x123"/"ext. 123" suffix is split out separately instead
* of causing validation to fail.
*
* @return string|array{number: string, ext: ?string}|false
*/
public static function isPhone(string $phone, bool $withExtension = false): string|array|false
{
$ext = null;
if ($withExtension && preg_match('/^(.*?)(?:x|ext\.?)\s*(\d+)$/i', $phone, $matches)) {
$phone = $matches[1];
$ext = $matches[2];
}
$digits = preg_replace('/\D+/', '', $phone);
if (!in_array(strlen($digits), [7, 10], true)) {
return false;
}
return $withExtension ? ['number' => $digits, 'ext' => $ext] : $digits;
}
/**
* @return string|false the uppercased, space-normalized postal code if a valid Canadian format, else false
*/
public static function isPostalCode(string $postal): string|false
{
$postal = strtoupper(str_replace(' ', '', $postal));
if (!preg_match('/^[A-CEGHJ-NPR-TVXY]\d[A-CEGHJ-NPR-TV-Z]\d[A-CEGHJ-NPR-TV-Z]\d$/', $postal)) {
return false;
}
return substr($postal, 0, 3) . ' ' . substr($postal, 3);
}
/**
* @return string|false the 5 digits of a US ZIP code, else false
*/
public static function isZipCode(string $zip): string|false
{
$digits = preg_replace('/\D+/', '', $zip);
return strlen($digits) === 5 ? $digits : false;
}
}
+14
View File
@@ -0,0 +1,14 @@
{% extends layout %}
{% block title %}Not Found{% endblock %}
{% block description %}The page you're looking for doesn't exist.{% endblock %}
{% block robots %}noindex, nofollow{% endblock %}
{% block content %}
<article>
<h1>404 — Page not found</h1>
<p>Nothing lives at this address.</p>
</article>
{% endblock %}
+146
View File
@@ -0,0 +1,146 @@
{# Shared inline SVG icons — no icon font, no CDN (consistent with this
project's no-external-dependency approach: Twig is vendored, Matomo is
self-hosted, so icons are inline SVG rather than a Font Awesome
webfont/CDN just for a couple of glyphs). Each macro takes an optional
css class suffix; `currentColor` means an icon always matches its
surrounding link/text color, including on :hover, with no extra CSS.
Available: home, git, book, link, sitemap, email, search, rss, tag,
lock, trash, external_link, menu, back_to_top, sun, moon. Some
(search, rss, tag) are ahead of the features that will use them (see
novaconium/ISSUES.md) — added now so those features don't need an
icons.twig change later. #}
{% macro home(class) %}
<svg class="icon icon-home {{ class }}" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M3 11.5 12 4l9 7.5" />
<path d="M5.5 9.5V20a1 1 0 0 0 1 1H9a1 1 0 0 0 1-1v-4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v4a1 1 0 0 0 1 1h2.5a1 1 0 0 0 1-1V9.5" />
</svg>
{% endmacro %}
{% macro git(class) %}
<svg class="icon icon-git {{ class }}" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<circle cx="6" cy="6" r="2.25" />
<circle cx="6" cy="18" r="2.25" />
<circle cx="18" cy="9" r="2.25" />
<path d="M6 8.25V15.75" />
<path d="M6 8.25a6 6 0 0 0 6 6h3.75" />
</svg>
{% endmacro %}
{% macro book(class) %}
<svg class="icon icon-book {{ class }}" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M4 19.5V5.5a2 2 0 0 1 2-2h6v18H6a2 2 0 0 1-2-2Z" />
<path d="M12 3.5h6a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-6" />
<path d="M8 7.5h2" />
<path d="M8 11h2" />
</svg>
{% endmacro %}
{% macro link(class) %}
<svg class="icon icon-link {{ class }}" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M9.5 14.5 14.5 9.5" />
<path d="M11 6.5 12.5 5a3.54 3.54 0 0 1 5 5L16 11.5" />
<path d="M13 17.5 11.5 19a3.54 3.54 0 0 1-5-5L8 12.5" />
</svg>
{% endmacro %}
{% macro sitemap(class) %}
<svg class="icon icon-sitemap {{ class }}" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<circle cx="12" cy="4.5" r="2" />
<circle cx="5" cy="19.5" r="2" />
<circle cx="12" cy="19.5" r="2" />
<circle cx="19" cy="19.5" r="2" />
<path d="M12 6.5V13" />
<path d="M5 17.5V13h14v4.5" />
</svg>
{% endmacro %}
{% macro email(class) %}
<svg class="icon icon-email {{ class }}" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<rect x="3" y="5.5" width="18" height="13" rx="2" />
<path d="M3.5 6.5 12 13l8.5-6.5" />
</svg>
{% endmacro %}
{% macro search(class) %}
<svg class="icon icon-search {{ class }}" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<circle cx="10.5" cy="10.5" r="6.5" />
<path d="M20 20l-4.35-4.35" />
</svg>
{% endmacro %}
{% macro rss(class) %}
<svg class="icon icon-rss {{ class }}" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M5 5a14 14 0 0 1 14 14" />
<path d="M5 11a8 8 0 0 1 8 8" />
<circle cx="6" cy="18" r="1.5" fill="currentColor" stroke="none" />
</svg>
{% endmacro %}
{% macro tag(class) %}
<svg class="icon icon-tag {{ class }}" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M4 4h7.5L20 12.5 12.5 20 4 11.5V4Z" />
<circle cx="8.5" cy="8.5" r="1.25" fill="currentColor" stroke="none" />
</svg>
{% endmacro %}
{% macro lock(class) %}
<svg class="icon icon-lock {{ class }}" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<rect x="5" y="11" width="14" height="9" rx="2" />
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
</svg>
{% endmacro %}
{% macro trash(class) %}
<svg class="icon icon-trash {{ class }}" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M4 7h16" />
<path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
<path d="M6 7l1 13a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-13" />
<path d="M10 11v6" />
<path d="M14 11v6" />
</svg>
{% endmacro %}
{% macro external_link(class) %}
<svg class="icon icon-external-link {{ class }}" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M14 4h6v6" />
<path d="M20 4 10 14" />
<path d="M18 13v6a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1h6" />
</svg>
{% endmacro %}
{% macro menu(class) %}
<svg class="icon icon-menu {{ class }}" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M4 6h16" />
<path d="M4 12h16" />
<path d="M4 18h16" />
</svg>
{% endmacro %}
{% macro back_to_top(class) %}
<svg class="icon icon-back-to-top {{ class }}" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M12 19V6" />
<path d="M6 11l6-6 6 6" />
</svg>
{% endmacro %}
{% macro sun(class) %}
<svg class="icon icon-sun {{ class }}" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="M4.93 4.93l1.41 1.41" />
<path d="M17.66 17.66l1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="M4.93 19.07l1.41-1.41" />
<path d="M17.66 6.34l1.41-1.41" />
</svg>
{% endmacro %}
{% macro moon(class) %}
<svg class="icon icon-moon {{ class }}" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5Z" />
</svg>
{% endmacro %}
+40
View File
@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
{% include '_layout/theme-init.twig' %}
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ site_name }}{% endblock %}</title>
<meta name="description" content="{% block description %}A tiny, Hugo-flavored PHP micro-framework site.{% endblock %}">
<meta name="robots" content="{% block robots %}index, follow{% endblock %}">
<link rel="canonical" href="{% block canonical %}{{ request_path|default('/') }}{% endblock %}">
<link rel="icon" href="/favicon.ico">
{# Open Graph / Facebook #}
<meta property="og:type" content="{% block og_type %}website{% endblock %}">
<meta property="og:title" content="{% block og_title %}{{ block('title') }}{% endblock %}">
<meta property="og:description" content="{% block og_description %}{{ block('description') }}{% endblock %}">
<meta property="og:url" content="{% block og_url %}{{ block('canonical') }}{% endblock %}">
<meta property="og:site_name" content="{{ site_name }}">
{# Twitter #}
<meta name="twitter:card" content="{% block twitter_card %}summary{% endblock %}">
<meta name="twitter:title" content="{% block twitter_title %}{{ block('title') }}{% endblock %}">
<meta name="twitter:description" content="{% block twitter_description %}{{ block('description') }}{% endblock %}">
<link rel="stylesheet" href="/css/main.css">
{% include '_layout/matomo.twig' %}
</head>
<body>
<header>
{% include '_layout/nav.twig' %}
</header>
<main>
{% block content %}{% endblock %}
</main>
<footer>
<small>&copy; {{ "now"|date("Y") }} {{ site_name }}</small>
</footer>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
{% if matomo_url and matomo_site_id %}
<script>
var _paq = window._paq = window._paq || [];
{% if is_404 %}
_paq.push(['setDocumentTitle', '404/URL = ' + encodeURIComponent(document.location.pathname + document.location.search) + '/From = ' + encodeURIComponent(document.referrer)]);
{% endif %}
_paq.push(['trackPageView']);
_paq.push(['enableLinkTracking']);
(function() {
var u = "{{ matomo_url|e('js') }}";
_paq.push(['setTrackerUrl', u + 'matomo.php']);
_paq.push(['setSiteId', '{{ matomo_site_id|e('js') }}']);
var d = document, g = d.createElement('script'), s = d.getElementsByTagName('script')[0];
g.async = true; g.src = u + 'matomo.js'; s.parentNode.insertBefore(g, s);
})();
</script>
{% endif %}
+25
View File
@@ -0,0 +1,25 @@
{% import '_layout/icons.twig' as icons %}
<nav>
<a href="/">{{ icons.home() }}Home</a>
<a href="/about">About</a>
<a href="/blog">Blog</a>
<a href="/contact">Contact</a>
<a href="/admin">Admin</a>
<button type="button" class="theme-toggle icon-link" aria-label="Toggle dark/light theme">
{{ icons.sun('theme-toggle-sun') }}{{ icons.moon('theme-toggle-moon') }}
</button>
</nav>
<script>
document.addEventListener('click', function (event) {
var button = event.target.closest('.theme-toggle');
if (!button) {
return;
}
var isLight = document.documentElement.getAttribute('data-theme') === 'light';
var next = isLight ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
});
</script>
+12
View File
@@ -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 <head>, before the
stylesheet link — see novaconium/pages/_layout/layout.twig. Pairs with
the toggle button + its click handler in novaconium/pages/_layout/nav.twig. #}
<script>
(function () {
var saved = localStorage.getItem('theme');
if (saved === 'light' || saved === 'dark') {
document.documentElement.setAttribute('data-theme', saved);
}
})();
</script>
@@ -0,0 +1,22 @@
<?php
use App\Response;
use Lib\Csrf;
use Lib\Input;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!Csrf::verify(Input::post('csrf_token'))) {
return Response::redirect('/admin/clear-cache?error=security');
}
$cache->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(),
];
@@ -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 %}
<article>
<h1 class="icon-heading">{{ icons.trash() }}Clear cache</h1>
<p>Deletes every pre-rendered page under <code>public/cache/</code>. Sidecar-less pages re-render (and re-cache) on their next request.</p>
{% if cleared %}
<p><strong>Cache cleared.</strong></p>
{% endif %}
{% if securityError %}
<p><strong>Your session expired before submitting — please try again.</strong></p>
{% endif %}
<form method="post">
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
<button type="submit">Clear cache</button>
</form>
</article>
{% endblock %}
@@ -0,0 +1,32 @@
{% extends '_layout/layout.twig' %}
{% import '_layout/icons.twig' as icons %}
{% block content %}
<div class="docs">
<nav class="docs-nav">
<ul>
<li><a class="icon-link" href="/admin/docs">{{ icons.book() }}Overview</a></li>
<li><a class="icon-link" href="/admin/docs/getting-started">{{ icons.book() }}Getting started</a></li>
<li><a class="icon-link" href="/admin/docs/routing">{{ icons.link() }}Routing</a></li>
<li><a class="icon-link" href="/admin/docs/sidecars">{{ icons.book() }}Sidecars</a></li>
<li><a class="icon-link" href="/admin/docs/forms">{{ icons.email() }}Forms</a></li>
<li><a class="icon-link" href="/admin/docs/libraries">{{ icons.book() }}Libraries</a></li>
<li><a class="icon-link" href="/admin/docs/config">{{ icons.book() }}Configuration</a></li>
<li><a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a></li>
<li><a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a></li>
<li><a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a></li>
<li><a class="icon-link" href="/admin/docs/seo">{{ icons.book() }}SEO</a></li>
<li><a class="icon-link" href="/admin/docs/matomo">{{ icons.book() }}Matomo</a></li>
<li><a class="icon-link" href="/admin/docs/styling">{{ icons.book() }}Styling</a></li>
<li><a class="icon-link" href="/admin/docs/project-layout">{{ icons.sitemap() }}Project layout</a></li>
<li><a class="icon-link" href="/admin/docs/third-party">{{ icons.external_link() }}Third-party</a></li>
<li><a class="icon-link" href="/admin/docs/design-notes">{{ icons.book() }}Design notes</a></li>
<li><a class="icon-link" href="/admin/docs/upgrading-twig">{{ icons.book() }}Upgrading Twig</a></li>
</ul>
</nav>
<div class="docs-content">
{% block docs_content %}{% endblock %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,50 @@
{% extends 'admin/docs/_layout/layout.twig' %}
{% import '_layout/icons.twig' as icons %}
{% block title %}Admin authentication{% endblock %}
{% block description %}Gating /admin/* behind HTTP Basic Auth, reusable for any future admin page.{% endblock %}
{% block robots %}noindex, nofollow{% endblock %}
{% block docs_content %}
<h1>Admin authentication</h1>
<p>Every route under <code>/admin/*</code> — <code>/admin</code>, <code>/admin/clear-cache</code>, the docs section, and any admin page a project adds later — can be gated behind HTTP Basic Auth with two <code>App/config.php</code> keys. It's off by default (same posture as Matomo): an empty <code>admin_password_hash</code> means no gate at all, matching this project's original wide-open behavior.</p>
<p>This replaced the old <code>docs_enabled</code> config flag, which only hid the docs section specifically. Since this gate covers all of <code>/admin/*</code> — 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.</p>
<h2>Enabling it</h2>
<p>Generate a password hash once — either on the command line:</p>
<pre><code>php -r "echo password_hash('yourpassword', PASSWORD_DEFAULT), PHP_EOL;"</code></pre>
<p>...or, if you'd rather not touch a terminal, at <a class="icon-link" href="/admin/password-hash">{{ icons.lock() }}/admin/password-hash</a> — a small built-in form that does the same <code>password_hash()</code> call and hands back a ready-to-paste config snippet. Nothing typed there is stored or logged. Since it lives under <code>/admin/*</code> like everything else here, it's automatically covered by this same gate once a password is set — reachable while <code>admin_password_hash</code> is still empty (so you can generate your first one), then protected like any other admin page afterward.</p>
<p>Then set both keys in <code>App/config.php</code>:</p>
<pre><code>&lt;?php
// App/config.php
return [
'admin_username' =&gt; 'admin',
'admin_password_hash' =&gt; '$2y$10$...',
];</code></pre>
<p>Every request into <code>/admin/*</code> now requires that username/password via the browser's built-in Basic Auth prompt; anything outside <code>/admin</code> is unaffected.</p>
<h2>How it's wired</h2>
<p><code>novaconium/src/AdminAuth.php</code> is a single reusable check — <code>AdminAuth::requireLogin($username, $passwordHash)</code> — called once from <code>novaconium/bootstrap.php</code> for any resolved route whose path is <code>admin</code> or starts with <code>admin/</code>, and only for routes that actually resolved (no login prompt on an unrelated 404). Because the check lives in <code>bootstrap.php</code> rather than on each page, <strong>a new admin page needs zero extra wiring</strong> to be protected — dropping a new directory under <code>App/pages/admin/</code> or <code>novaconium/pages/admin/</code> is automatically gated the moment it exists.</p>
<h2>Logging out</h2>
<p>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 <code>/admin/logout</code> works around this: <code>AdminAuth::logout()</code> always issues a fresh <code>401</code> challenge, regardless of what credentials were sent, which makes the browser discard what it had cached and prompt again the next time <code>/admin</code> 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 <code>/admin</code> when <code>admin_auth_enabled</code> is true (i.e. a password is actually set).</p>
<h2>What this is (and isn't)</h2>
<p>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 <code>novaconium/ISSUES.md</code>'s "Admin login &amp; user management" entry for the planned real multi-user system (backed by SQLite, with proper sessions). That feature will <em>replace</em> this mechanism, not layer on top of it. Until then, this is enough to keep the general public out of <code>/admin</code> on a production site.</p>
<p>Basic Auth credentials are sent base64-encoded on every request (not encrypted) — always serve <code>/admin</code> over HTTPS in production, same as any password-protected page.</p>
{% endblock %}
@@ -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 %}
<h1>Static caching</h1>
<p>If a page has <strong>no</strong> sidecar, its rendered HTML is written to <code>public/cache/&lt;path&gt;/index.html</code> after the first request. <code>.htaccess</code> 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.</p>
<p>To force a single page to re-render, delete its file under <code>public/cache/</code>. To clear everything at once, there are two equivalent options:</p>
<ul>
<li><strong>CLI:</strong> <code>php novaconium/bin/clear-cache.php</code> — a standalone script for deploys, cron jobs, or anywhere you'd rather not go through a browser. Prints <code>Cache cleared.</code> and exits.</li>
<li><strong>Web:</strong> <a href="/admin/clear-cache">/admin/clear-cache</a> — a POST form under <code>/admin</code>, covered by the same <a href="/admin/docs/admin-auth">HTTP Basic Auth gate</a> as the rest of <code>/admin/*</code> once a password is set.</li>
</ul>
<p>Both end up calling the same underlying <code>Cache::clear()</code> — see <code>/admin/docs/config</code>'s "For developers: using <code>Cache.php</code> directly" section for how each entry point constructs it. Clearing the cache only deletes the generated static HTML; it doesn't affect <code>App/pages/</code> or any other source. Any project change that should show up on an already-cached page — a new <code>site_name</code>, a new Sass color, a new admin toggle — needs a cache clear before it's visible, since the old <code>index.html</code> would otherwise keep being served as-is.</p>
{% endblock %}
@@ -0,0 +1,71 @@
{% extends 'admin/docs/_layout/layout.twig' %}
{% block title %}Configuration{% endblock %}
{% block description %}How to override framework settings without editing novaconium/config.php.{% endblock %}
{% block robots %}noindex, nofollow{% endblock %}
{% block docs_content %}
<h1>Configuration</h1>
<p><code>novaconium/config.php</code> holds the framework defaults — <code>pages_dirs</code>, <code>cache_dir</code>, <code>debug</code>, <code>site_name</code>, <code>matomo_url</code>, <code>matomo_site_id</code>, <code>admin_username</code>, <code>admin_password_hash</code> — and is not meant to be edited per-project, same as everything else under <code>novaconium/</code>.</p>
<p><code>App/config.php</code> ships with the skeleton as an empty, commented placeholder — uncomment (or add) whichever keys you want to change, returning an array of just those:</p>
<pre><code>&lt;?php
// App/config.php
return [
'debug' => false,
];</code></pre>
<p><code>novaconium/bootstrap.php</code> (and <code>novaconium/bin/clear-cache.php</code>) check whether <code>App/config.php</code> exists and, if so, shallow-merge it over <code>novaconium/config.php</code>'s defaults with <code>array_merge()</code> — the same App-overrides-novaconium pattern used for pages and <code>Lib\</code> classes elsewhere. You only need to list the keys you're changing; anything you omit keeps the framework default.</p>
<p>Deleting <code>App/config.php</code> entirely is just as valid as leaving it in place returning an empty array — either way, every setting falls back to <code>novaconium/config.php</code>'s defaults.</p>
<h2>Site name</h2>
<p><code>site_name</code> (default <code>'My Site'</code>) is used by the root layout as the default page <code>&lt;title&gt;</code> (when a page doesn't override the <code>title</code> block), <code>og:site_name</code>, and the footer copyright line:</p>
<pre><code>&lt;?php
// App/config.php
return [
'site_name' => 'Nick Yeoman',
];</code></pre>
<p>See <a href="/admin/docs/seo">SEO</a> for the full list of overridable meta blocks. Pages that were already statically cached before this changes need <code>php novaconium/bin/clear-cache.php</code> to pick it up.</p>
<h2>Admin authentication</h2>
<p><code>admin_username</code> / <code>admin_password_hash</code> gate every <code>/admin/*</code> route behind HTTP Basic Auth — see <a href="/admin/docs/admin-auth">Admin authentication</a> for the full write-up. Both are set via <code>App/config.php</code>; leaving <code>admin_password_hash</code> empty (the default) disables the gate.</p>
<h2>For developers: using <code>Cache.php</code> directly</h2>
<p><code>novaconium/src/Cache.php</code> is the class behind the <code>cache_dir</code> config key above — a small, dependency-free wrapper around writing/deleting the static HTML files under <code>public/cache/</code> that <a href="/admin/docs/caching">Static caching</a> describes. Like <code>Router</code> (see <code>/admin/docs/routing</code>'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.</p>
<h3>How it fits in</h3>
<p><code>Cache</code> shows up in three places, all constructed the same way — <code>new Cache($config['cache_dir'])</code>:</p>
<ul>
<li><code>novaconium/bootstrap.php</code> constructs one and hands it to <code>Renderer</code>, which calls <code>$cache-&gt;write()</code> after rendering any page that has <strong>no</strong> sidecar (see <code>/admin/docs/sidecars</code>'s <code>Renderer.php</code> section) — <code>Renderer</code> is the only thing that ever writes to the cache.</li>
<li><code>novaconium/bin/clear-cache.php</code>, a standalone CLI script, constructs one and calls <code>$cache-&gt;clear()</code> — this is what <code>php novaconium/bin/clear-cache.php</code> runs.</li>
<li><code>novaconium/pages/admin/clear-cache/index.php</code>'s sidecar calls <code>$cache-&gt;clear()</code> too, but doesn't construct it — <code>$cache</code> is already in scope automatically inside every sidecar, the same instance <code>Renderer</code> is using, injected by <code>Renderer::runSidecar()</code> alongside <code>$params</code>.</li>
</ul>
<h3>Constructing it and its three methods</h3>
<pre><code>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</code></pre>
<p>The constructor takes just one thing — <code>cache_dir</code>, a single absolute path (<code>novaconium/config.php</code> sets it to <code>public/cache</code>) — unlike <code>Router</code>/<code>Renderer</code>, which take the whole ordered <code>pagesDirs</code> 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.</p>
<p><code>path()</code> mirrors the public URL tree directly: <code>/blog/hello-world</code> maps to <code>{cache_dir}/blog/hello-world/index.html</code>, matching the <code>.htaccess</code> rule that checks for that exact file before letting any request reach PHP. <code>write()</code> calls <code>path()</code> internally and creates any missing parent directories with <code>mkdir(..., true)</code> before writing. <code>clear()</code> recurses over every entry under <code>cache_dir</code> deleting files and subdirectories, but never deletes <code>cache_dir</code> itself — so a stray <code>public/cache/.gitkeep</code> (see <code>.gitignore</code>) survives a clear.</p>
<p>Because every method here is a straightforward filesystem operation with no hidden state beyond the one constructor argument, testing <code>Cache</code> in isolation is just a matter of pointing it at a temporary directory and asserting on what ends up on disk.</p>
{% endblock %}
@@ -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 %}
<h1>File-based PHP + Twig micro-router (Hugo-style, static-cached, Apache)</h1>
<h2>Context</h2>
<p>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 <code>.twig</code> templates defines the URL structure, and any page needing PHP logic gets an optional sidecar <code>index.php</code> that supplies data or a full custom response (redirect/JSON/XML). Pages <em>without</em> a sidecar are pure/deterministic, so their rendered output is cached to a static <code>.html</code> 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 <code>.htaccess</code> handles both the cache short-circuit and canonical-URL redirects.</p>
<h2>Pages/layouts are overridable, same mechanism as Lib\ overrides</h2>
<p>Routing and rendering key off an <em>ordered list</em> of roots (<code>App/pages/</code> then <code>novaconium/pages/</code>) and resolve every relative path (a page, a sidecar, a <code>_layout/layout.twig</code>) against that list via <code>Overlay::isFile</code>/<code>isDir</code>/<code>findFile</code>/<code>findWildcardDir</code>, first match wins. <code>novaconium/pages/</code> ships the framework defaults (<code>_layout/layout.twig</code>, <code>404/index.twig</code>); 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 <code>App/pages/</code>. Twig's <code>FilesystemLoader</code> natively accepts an array of paths, so template rendering (including <code>{% verbatim %}{% extends %}{% endverbatim %}</code>) gets the same override-then-fallback resolution for free — no manual proxying required there.</p>
<h2>Router (novaconium/src/Router.php) — intentionally simple, no rendering</h2>
<ol>
<li>Take <code>$_SERVER['REQUEST_URI']</code>, strip query string, trim slashes, split into segments (root = <code>[]</code>).</li>
<li>Walk the page roots via <code>Overlay</code>, skipping any directory starting with <code>_</code> when matching (those are reserved, e.g. <code>_layout/</code>, never routable). At each level: exact-name subdirectory first (found in <em>any</em> root), else a <code>[param]</code>-named subdirectory (capturing into <code>params[name]</code>), else no match.</li>
<li>Returns a <code>Route</code> object: <code>{ dir: string|null (relative path, not absolute), params: array, found: bool }</code>. 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.</li>
</ol>
<h2>Sidecar PHP contract — can return more than an array</h2>
<p><code>App/pages/**/index.php</code> (optional) returns one of:</p>
<ul>
<li><strong>array</strong> → treated as Twig context data (<code>$params</code> is in scope for slug etc.).</li>
<li><strong>Response object</strong> → short-circuits Twig entirely. <code>novaconium/src/Response.php</code> provides factories: <code>Response::redirect($url, $code = 301)</code>, <code>Response::json($data)</code>, <code>Response::xml($string)</code>, <code>Response::html($string)</code> (raw HTML, bypass Twig but still gets cached like a normal page if desired).</li>
</ul>
<p>Renderer checks the sidecar's return type: array → render matched <code>index.twig</code> with that data; <code>Response</code> → emit directly (correct headers/status, no Twig, no layout).</p>
<h2>Layout inheritance — walk upward like Hugo</h2>
<p>Reserved <code>_layout/layout.twig</code> directories are looked up by the Renderer, not baked into Router:</p>
<ol>
<li>Starting at the matched page's directory, check for <code>_layout/layout.twig</code> via <code>Overlay</code> (checking <code>App/pages/</code> before <code>novaconium/pages/</code> at every level). If absent, check parent directory, and so on up to the root <code>_layout/layout.twig</code> (<code>App/pages/_layout/layout.twig</code> if present, else <code>novaconium/pages/_layout/layout.twig</code>).</li>
<li>The resolved layout path is injected into the Twig context as <code>layout</code>, and each <code>index.twig</code> does <code>{% verbatim %}{% extends layout %}{% endverbatim %}</code> — keeps the mechanism transparent in the template rather than magic in the engine.</li>
</ol>
<h2>Static caching (sidecar-less pages only)</h2>
<ol>
<li>When Renderer serves a page whose directory has <strong>no</strong> <code>index.php</code>, after rendering the Twig output it also writes it to <code>public/cache/&lt;segments&gt;/index.html</code>.</li>
<li><code>.htaccess</code> checks <code>public/cache/&lt;REQUEST_URI&gt;/index.html</code> first (<code>RewriteCond ... -f</code>) and serves it directly if present — PHP/Twig never runs again for that route until the cache file is cleared.</li>
<li>Cache invalidation is manual for now: clearing <code>public/cache/</code> (or a specific subfolder) forces regeneration on next hit. (Simple, matches "no build step" philosophy; can add mtime-based invalidation later if needed.)</li>
<li>Pages <em>with</em> a sidecar are never cached this way since their output can vary per-request (DB data, params, POST handling, etc.).</li>
</ol>
<h2>Canonical URLs — no trailing slash (SEO)</h2>
<ul>
<li>Canonical form is <strong>without</strong> a trailing slash: <code>/about</code>, <code>/blog/hello-world</code>.</li>
<li><code>.htaccess</code> 301-redirects any URL ending in <code>/</code> (except the bare root <code>/</code>) to the same path without it, before any routing/cache logic runs.</li>
<li>Router/Renderer internally still key off directory-per-page (<code>pages/about/index.twig</code>), that's just the filesystem convention — it's independent of what the external canonical URL looks like.</li>
</ul>
<h2>Front controller (public/index.php) — kept intentionally light</h2>
<pre><code>&lt;?php
require __DIR__ . '/../novaconium/bootstrap.php';</code></pre>
<p>All real logic — autoload registration, config load, <code>Router::resolve()</code>, <code>Renderer::render()</code>, emitting headers/body — lives in <code>novaconium/bootstrap.php</code>, not in <code>public/index.php</code>.</p>
<h2>Sass (indented syntax, not SCSS)</h2>
<ul>
<li>Source lives in <code>novaconium/sass/</code> (structure in <code>main.sass</code>, default colors in <code>defaults/_colors.sass</code>) using indented syntax. <code>App/sass/_colors.sass</code> overrides the color palette.</li>
<li>Compiled output goes to <code>public/css/main.css</code>.</li>
<li>Compilation is a build step (not a PHP runtime concern) — use the <code>sass</code> CLI (Dart Sass, which supports indented syntax) e.g. <code>sass --load-path=App/sass --load-path=novaconium/sass/defaults novaconium/sass/main.sass public/css/main.css</code>. No PHP Sass library is needed/assumed since PHP-based compilers (scssphp) target SCSS, not indented syntax.</li>
<li><code>main.sass</code> does <code>@use 'colors' as *</code> but its own directory has no <code>_colors.sass</code> — on purpose, so resolution falls through to the load paths above (<code>App/sass</code> 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.</li>
</ul>
<h2>Autoloading (no Composer)</h2>
<p><code>novaconium/autoload.php</code>: <code>spl_autoload_register</code> mapping <code>Twig\</code> → <code>novaconium/vendor/twig/src/</code>, <code>App\</code> → <code>novaconium/src/</code>, and <code>Lib\</code> → checked against <code>App/lib/</code> first, falling back to <code>novaconium/lib/</code> — this is the override mechanism: a project drops a same-named class in <code>App/lib/</code> to replace a framework default without touching <code>novaconium/</code>. Twig vendored manually from a GitHub release zip into <code>novaconium/vendor/twig/</code>.</p>
<h2>Verification</h2>
<ul>
<li>Configure Apache (or equivalent local Apache setup) with docroot <code>public/</code> and the <code>.htaccess</code> rules active (<code>AllowOverride All</code> / mod_rewrite enabled).</li>
<li>Visit <code>/about/</code> → confirms 301 redirect to <code>/about</code> (canonical, no trailing slash).</li>
<li>Visit <code>/about</code> twice → first hit renders via PHP/Twig and writes <code>public/cache/about/index.html</code>; second hit confirms (e.g. via response headers/timing, or temporarily renaming <code>novaconium/bootstrap.php</code>) that Apache served the cached file directly.</li>
<li>Visit <code>/contact</code> (has a sidecar) → confirms it is NOT cached (no sidecar-less caching), and that a <code>Lib\</code> class (<code>Lib\Mailer</code>) can be called from it. Every post under <code>App/pages/blog/</code> is sidecar-less by contrast — visiting one twice should show the same static-cache behavior as <code>/about</code> above.</li>
<li>Add a sidecar that returns <code>Response::json([...])</code> → confirms JSON is emitted with correct <code>Content-Type</code>, bypassing Twig.</li>
<li>Add a sidecar that returns <code>Response::redirect('/somewhere')</code> → confirms a real HTTP redirect happens.</li>
<li>Confirm <code>App/pages/blog/_layout/layout.twig</code> overrides <code>App/pages/_layout/layout.twig</code> for pages under <code>blog/</code>, and that <code>_layout/</code> directories are never reachable as routes (404 if requested directly).</li>
<li>Drop a same-named class into <code>App/lib/</code> and confirm it's used instead of the <code>novaconium/lib/</code> default (override mechanism works).</li>
<li>Delete <code>App/pages/_layout/</code> and <code>App/pages/404/</code> (if present) and confirm the site still renders and 404s using <code>novaconium/pages/</code>'s defaults; then drop a <code>_layout/layout.twig</code> into <code>App/pages/</code> and confirm it takes precedence.</li>
<li>Run <code>sass --load-path=App/sass --load-path=novaconium/sass/defaults novaconium/sass/main.sass public/css/main.css</code> and confirm compiled CSS loads on a page.</li>
</ul>
{% endblock %}
@@ -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 %}
<h1>Forms</h1>
<p>Every form on this site — the <a class="icon-link" href="/contact">{{ icons.email() }}contact form</a> included — is the same four ingredients: a page with a sidecar, the three validation/spam classes under <code>Lib\</code>, 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.</p>
<h2>1. Create the page</h2>
<p>A form needs a directory with both an <code>index.twig</code> and an <code>index.php</code> — the sidecar is what makes it a form rather than a static page. Scaffold the Twig half with <code>novaconium/bin/create-static-page.php</code> (see <a class="icon-link" href="/admin/docs/getting-started">{{ icons.book() }}Getting started</a>) and add the sidecar yourself, or just create both by hand:</p>
<pre><code>App/pages/newsletter/
index.twig
index.php</code></pre>
<h2>2. The sidecar</h2>
<p>This is the whole contract: validate <code>$_POST</code>, 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:</p>
<pre><code>{% verbatim %}&lt;?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 %}</code></pre>
<p>Four things worth noticing, all covered in full in <a class="icon-link" href="/admin/docs/sidecars">{{ icons.book() }}Sidecars</a>' "Form security" section:</p>
<ul>
<li><code>FormValidator</code> accumulates named field errors — <code>required()</code>, <code>email()</code>, and <code>maxLength()</code>/<code>minLength()</code> are all available; chain as many as the form needs, then check <code>$validator->passes()</code> once.</li>
<li><code>SpamGuard::isSpam(Input::post())</code> is checked <em>after</em> 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.</li>
<li><code>$spamGuard->renderedAt()</code> goes into the sidecar's returned context on every request (GET <em>and</em> POST) — the template needs it either way, since a failed validation re-renders the form with a fresh timestamp for the next attempt.</li>
<li><code>Csrf::verify()</code> is checked <em>before</em> 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.</li>
</ul>
<h2>3. The template</h2>
<p>The honeypot field and the hidden timestamp are the two pieces every form needs regardless of its actual fields — copy them as-is:</p>
<pre><code>{% verbatim %}{% extends layout %}
{% block title %}Newsletter{% endblock %}
{% block description %}Sign up for occasional updates.{% endblock %}
{% block content %}
&lt;article&gt;
&lt;h1&gt;Newsletter&lt;/h1&gt;
{% if sent %}
&lt;p&gt;&lt;strong&gt;Thanks — you're signed up.&lt;/strong&gt;&lt;/p&gt;
{% endif %}
{% if securityError %}
&lt;p&gt;&lt;strong&gt;Your session expired before submitting — please try again.&lt;/strong&gt;&lt;/p&gt;
{% endif %}
&lt;form method="post" action="/newsletter"&gt;
&lt;input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}"&gt;
&lt;div class="hp-field" aria-hidden="true"&gt;
&lt;label for="website"&gt;Leave this field blank&lt;/label&gt;
&lt;input type="text" id="website" name="website" tabindex="-1" autocomplete="off"&gt;
&lt;/div&gt;
&lt;input type="hidden" name="rendered_at" value="{{ renderedAt }}"&gt;
&lt;p&gt;
&lt;label for="email"&gt;Email&lt;/label&gt;&lt;br&gt;
&lt;input type="text" id="email" name="email" value="{{ old.email }}"&gt;
{% if errors.email %}&lt;br&gt;&lt;small&gt;{{ errors.email }}&lt;/small&gt;{% endif %}
&lt;/p&gt;
&lt;button type="submit"&gt;Sign up&lt;/button&gt;
&lt;/form&gt;
&lt;/article&gt;
{% endblock %}{% endverbatim %}</code></pre>
<p>The <code>.hp-field</code> class (defined once in <code>novaconium/sass/main.sass</code>) positions the honeypot off-screen rather than hiding it with <code>display: none</code>, since some spam bots specifically skip fields hidden that way — see <a class="icon-link" href="/admin/docs/sidecars">{{ icons.book() }}Sidecars</a> for why. Nothing about that field or the timestamp needs to change between forms; only the real fields (<code>email</code> here, <code>name</code>/<code>email</code>/<code>message</code> on the contact form) differ.</p>
<h2>Why it's never cached</h2>
<p>Because this page has a sidecar, it's excluded from the static-cache path entirely (see <a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a>) — every request re-runs the sidecar, which is exactly what a form needs: a fresh <code>rendered_at</code> timestamp, current validation errors, and an up-to-date <code>sent</code> flag from the query string. A form is the canonical example of a page that <em>shouldn't</em> be sidecar-less, even though nothing stops you from technically leaving the sidecar off — without one, there's nothing to receive <code>$_POST</code> at all.</p>
<h2>Going further</h2>
<ul>
<li>Need a phone number, a postal/zip code, or a "confirm email" field? <code>Lib\Validate</code> has <code>isPhone()</code>, <code>isPostalCode()</code>/<code>isZipCode()</code>, and <code>isMatch()</code> — see <a class="icon-link" href="/admin/docs/libraries">{{ icons.book() }}Libraries</a>.</li>
<li>Want the submission to actually go somewhere? Swap the comment in step 2 for a call to <code>Lib\Mailer::send()</code> (see the contact form), a new <code>Lib\</code> class of your own, or — once SQLite groundwork lands (see <code>novaconium/ISSUES.md</code>, not yet built) — a database write.</li>
<li>Multiple forms on one site can each use their own honeypot/timestamp field names by passing constructor arguments to <code>SpamGuard</code>, e.g. <code>new SpamGuard('url', 'ts', 3)</code>, so they don't interfere with each other.</li>
</ul>
{% endblock %}
@@ -0,0 +1,38 @@
{% extends 'admin/docs/_layout/layout.twig' %}
{% block title %}Getting started{% endblock %}
{% block description %}Requirements, running locally, and deploying on Apache.{% endblock %}
{% block robots %}noindex, nofollow{% endblock %}
{% block docs_content %}
<h1>Getting started</h1>
<p><strong>Requirements:</strong> PHP 8.1+ and, for production, Apache with <code>mod_rewrite</code> and <code>AllowOverride All</code>.</p>
<h2>Run it locally (no Apache needed)</h2>
<pre><code>php -S 127.0.0.1:8000 -t public public/router.php</code></pre>
<p><code>public/router.php</code> is a dev-only script that mimics the <code>.htaccess</code> rules (canonical redirects + static cache lookup) so you can develop without Apache. It is never used in production — Apache reads <code>public/.htaccess</code> directly.</p>
<p>Visit:</p>
<ul>
<li><code>http://127.0.0.1:8000/</code> — static home page</li>
</ul>
<h2>Deploy on Apache</h2>
<p>Point the vhost's document root at <code>public/</code>, make sure <code>mod_rewrite</code> is enabled and <code>AllowOverride All</code> is set for that directory so <code>public/.htaccess</code> takes effect, and it just works — no build step required.</p>
<h2>Adding a new page</h2>
<p>Create a directory under <code>App/pages/</code> with an <code>index.twig</code> — the directory path <em>is</em> the URL (see <a href="/admin/docs/routing">Routing</a>). <a href="/admin/docs/seo">SEO</a> 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.</p>
<p>Or skip the copy-paste entirely:</p>
<pre><code>php novaconium/bin/create-static-page.php blog/my-new-post</code></pre>
<p>Scaffolds <code>App/pages/blog/my-new-post/index.twig</code> 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 <code>.twig</code> or <code>/index.twig</code> — refuses to run if the page already exists, or if any segment is reserved (starts with <code>_</code>, or is literally <code>404</code> — see <a href="/admin/docs/routing">Routing</a>).</p>
{% endblock %}
+32
View File
@@ -0,0 +1,32 @@
{% extends 'admin/docs/_layout/layout.twig' %}
{% import '_layout/icons.twig' as icons %}
{% block title %}Docs{% endblock %}
{% block description %}Framework documentation: routing, sidecars, forms, libraries, layouts, caching, styling.{% endblock %}
{% block robots %}noindex, nofollow{% endblock %}
{% block docs_content %}
<h1 class="icon-heading">{{ icons.book() }}Project documentation</h1>
<p>Framework docs, rendered as plain Twig pages — no internet connection needed.</p>
<ul>
<li><a class="icon-link" href="/admin/docs/getting-started">{{ icons.book() }}Getting started</a> — requirements, running locally, deploying on Apache.</li>
<li><a class="icon-link" href="/admin/docs/routing">{{ icons.link() }}Routing</a> — how a URL maps to a directory under <code>App/pages/</code>.</li>
<li><a class="icon-link" href="/admin/docs/sidecars">{{ icons.book() }}Sidecars</a> — where your PHP logic goes.</li>
<li><a class="icon-link" href="/admin/docs/forms">{{ icons.email() }}Forms</a> — building a custom form with a sidecar, using the novaconium validation/spam libraries.</li>
<li><a class="icon-link" href="/admin/docs/libraries">{{ icons.book() }}Libraries</a> — plain PHP classes under <code>Lib\</code>.</li>
<li><a class="icon-link" href="/admin/docs/config">{{ icons.book() }}Configuration</a> — override framework settings from <code>App/config.php</code>.</li>
<li><a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a> — gate <code>/admin/*</code> behind HTTP Basic Auth, reusable for any future admin page.</li>
<li><a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a> — pages and layouts are overridable, just like <code>Lib\</code>.</li>
<li><a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a> — how sidecar-less pages get served as static HTML.</li>
<li><a class="icon-link" href="/admin/docs/seo">{{ icons.book() }}SEO</a> — the meta tags every page gets for free, and how to override them.</li>
<li><a class="icon-link" href="/admin/docs/matomo">{{ icons.book() }}Matomo</a> — built-in analytics tracking, including 404 tracking.</li>
<li><a class="icon-link" href="/admin/docs/styling">{{ icons.book() }}Styling</a> — Sass, indented syntax, with an overridable color palette.</li>
<li><a class="icon-link" href="/admin/docs/project-layout">{{ icons.sitemap() }}Project layout</a> — a map of the whole tree.</li>
<li><a class="icon-link" href="/admin/docs/third-party">{{ icons.external_link() }}Third-party</a> — vendored Twig and its license.</li>
<li><a class="icon-link" href="/admin/docs/design-notes">{{ icons.book() }}Design notes</a> — the original design rationale.</li>
<li><a class="icon-link" href="/admin/docs/upgrading-twig">{{ icons.book() }}Upgrading Twig</a> — how to bump the vendored copy.</li>
</ul>
{% endblock %}
@@ -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 %}
<h1>Pages and layouts are overridable, just like Lib\</h1>
<p>Routing doesn't resolve against a single <code>pages/</code> tree — it resolves against an ordered list of roots: <code>App/pages/</code> first, then <code>novaconium/pages/</code> as the framework's fallback default. The default <code>_layout/layout.twig</code> and <code>404/index.twig</code> ship in <code>novaconium/pages/</code>; 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 <code>App/pages/</code>. Same mechanism as <code>Lib\</code> overrides in <code>App/lib/</code>, applied to pages.</p>
<p>Shared layout markup lives in <code>_layout/layout.twig</code> directories. The renderer walks upward from the matched page looking for the nearest one, checking <code>App/pages/</code> before <code>novaconium/pages/</code> at every level — so you can override the layout for a whole subtree just by dropping a new <code>_layout/</code> next to it:</p>
<pre><code>novaconium/pages/_layout/layout.twig &lt;- framework default, used unless overridden
App/pages/_layout/layout.twig &lt;- overrides the site-wide default (optional)
App/pages/blog/_layout/layout.twig &lt;- overrides it for everything under /blog</code></pre>
<p>A nested layout can extend the parent one (paths are resolved against the page roots, not relative to the current file):</p>
<pre><code>{% verbatim %}{% extends 'admin/docs/_layout/layout.twig' %}{% endverbatim %}</code></pre>
<p>Every <code>index.twig</code> extends whichever layout was resolved for it, via the <code>layout</code> variable that's always injected into the context:</p>
<pre><code>{% verbatim %}{% extends layout %}
{% block content %}...{% endblock %}{% endverbatim %}</code></pre>
{% endblock %}
@@ -0,0 +1,30 @@
{% extends 'admin/docs/_layout/layout.twig' %}
{% block title %}Libraries{% endblock %}
{% block description %}Plain PHP classes under the Lib\ namespace.{% endblock %}
{% block robots %}noindex, nofollow{% endblock %}
{% block docs_content %}
<h1>Libraries</h1>
<p>Plain PHP classes live in <code>App/lib/</code> (or <code>novaconium/lib/</code> for defaults) under the <code>Lib\</code> namespace and are autoloaded automatically — call them from any sidecar:</p>
<pre><code>use Lib\Mailer;
(new Mailer())-&gt;send($old['name'], $old['email'], $old['message']);</code></pre>
<h2>Framework-default classes</h2>
<p><code>novaconium/lib/</code> ships a few ready-to-use classes any sidecar can call, same as a project's own <code>App/lib/</code> classes — override any of them by dropping a same-named file in <code>App/lib/</code>:</p>
<ul>
<li><code>Lib\Mailer</code> — 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).</li>
<li><code>Lib\SpamGuard</code> — self-hosted honeypot + submission-timing spam detection, reusable on any form. See <a href="/admin/docs/sidecars">Sidecars</a>' "Spam prevention" section for the full write-up.</li>
<li><code>Lib\FormValidator</code> — a small accumulating required-field/email/length validator, so a form sidecar doesn't hand-roll the same checks and <code>$errors</code> array every time. Also covered in <a href="/admin/docs/sidecars">Sidecars</a>' "Spam prevention" section.</li>
<li><code>Lib\Validate</code> — the lower-level validation primitives <code>FormValidator</code> calls into (<code>isEmail()</code>, <code>minLength()</code>/<code>maxLength()</code>, <code>isMatch()</code>, <code>isPhone()</code>, <code>isPostalCode()</code>/<code>isZipCode()</code>) — call these directly from a sidecar when you just need a validated/normalized value back rather than an accumulated field-error. See <a href="/admin/docs/sidecars">Sidecars</a>' "Spam prevention" section.</li>
<li><code>Lib\Input</code> — a cleaning accessor for <code>$_POST</code>/<code>$_GET</code> (<code>Input::post()</code>/<code>Input::get()</code>), used by every sidecar instead of the superglobals directly. Defense-in-depth against HTML/script injection, <strong>not</strong> a defense against SQL injection — see <a href="/admin/docs/sidecars">Sidecars</a>' "Form security" section for the full caveat.</li>
<li><code>Lib\Csrf</code> — standalone session-token CSRF protection (<code>Csrf::token()</code>/<code>::verify()</code>), called directly from a sidecar rather than through <code>FormValidator</code>. See <a href="/admin/docs/sidecars">Sidecars</a>' "Form security" section.</li>
</ul>
{% endblock %}
@@ -0,0 +1,46 @@
{% extends 'admin/docs/_layout/layout.twig' %}
{% block title %}Matomo{% endblock %}
{% block description %}Built-in Matomo analytics tracking, including 404 page tracking.{% endblock %}
{% block robots %}noindex, nofollow{% endblock %}
{% block docs_content %}
<h1>Matomo analytics</h1>
<p>The root layout (<code>novaconium/pages/_layout/layout.twig</code>) includes <code>_layout/matomo.twig</code>, which emits the standard Matomo tracking snippet on every page — off by default, enabled by supplying two <code>App/config.php</code> keys.</p>
<h2>Enabling it</h2>
<pre><code>&lt;?php
// App/config.php
return [
'matomo_url' =&gt; 'https://matomo.example.com/',
'matomo_site_id' =&gt; '1',
];</code></pre>
<p>Both <code>matomo_url</code> and <code>matomo_site_id</code> default to an empty string in <code>novaconium/config.php</code>. The layout only renders the tracking <code>&lt;script&gt;</code> when <strong>both</strong> are set — leaving either one out (e.g. in local development) disables tracking entirely, with zero script emitted. A missing trailing slash on <code>matomo_url</code> is normalized automatically in <code>novaconium/bootstrap.php</code>.</p>
<h2>404 tracking</h2>
<p>Matomo doesn't know a page is a 404 unless told — its documented convention is to set the document title to <code>404/URL = &lt;requested path&gt;/From = &lt;referrer&gt;</code> immediately before <code>trackPageView</code>, so 404 hits are filterable under Behaviour → Page URLs by searching for <code>404</code>. The layout does this automatically: <code>novaconium/src/Renderer.php::renderNotFound()</code> passes <code>is_404 =&gt; true</code> into the 404 template's context (a Twig global elsewhere, defaulting to <code>false</code>), and the layout's tracking script checks that flag to decide whether to push <code>setDocumentTitle</code> before <code>trackPageView</code>.</p>
<h2>What's included</h2>
<ul>
<li><code>trackPageView</code> on every request.</li>
<li><code>enableLinkTracking</code> for outbound-link and download tracking.</li>
<li>404-specific document title tagging as described above.</li>
</ul>
<p>The snippet is the same one Matomo's own admin UI generates ("JS Tracking Code"), so anything from Matomo's docs that builds on <code>_paq.push([...])</code> calls (custom events, goal tracking, ecommerce, etc.) can be added directly to it.</p>
<h2>Overriding the snippet</h2>
<p>The snippet lives in its own file, <code>novaconium/pages/_layout/matomo.twig</code>, 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 <code>App/pages/_layout/matomo.twig</code> to replace it entirely without touching <code>novaconium/</code>: point at a self-hosted tracker proxy, add extra <code>_paq.push</code> calls, gate it behind a consent check, or swap in a completely different analytics snippet. <code>matomo_url</code>, <code>matomo_site_id</code>, and <code>is_404</code> are all available in the override's context, same as in the original.</p>
<h2>Privacy note</h2>
<p>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 <code>matomo_url</code>/<code>matomo_site_id</code> config or override <code>_layout/matomo.twig</code> behind whatever consent mechanism the project uses.</p>
{% endblock %}
@@ -0,0 +1,56 @@
{% extends 'admin/docs/_layout/layout.twig' %}
{% block title %}Project layout{% endblock %}
{% block description %}A map of the whole project tree.{% endblock %}
{% block robots %}noindex, nofollow{% endblock %}
{% block docs_content %}
<h1>Project layout</h1>
<pre><code>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 <path>`
autoload.php manual PSR-4 autoloader
config.php paths and settings
bootstrap.php wires everything together for each request</code></pre>
<h2>How a request flows through these files</h2>
<p>There's no framework "kernel" class — just a plain chain of <code>require</code>s, each handing off to the next, deliberately kept flat enough to read top-to-bottom in one sitting:</p>
<ol>
<li><strong><code>public/.htaccess</code></strong> runs first, before PHP does anything. If <code>public/cache/&lt;path&gt;/index.html</code> exists for the requested URL, Apache serves that file directly and nothing below this line ever executes — see <a href="/admin/docs/caching">Static caching</a>. Otherwise it strips a trailing slash (301 redirect) and rewrites everything else to <code>public/index.php</code>.</li>
<li><strong><code>public/index.php</code></strong> is intentionally one line: <code>require __DIR__ . '/../novaconium/bootstrap.php';</code>. (Running locally via <code>php -S</code> instead of Apache? <code>public/router.php</code> mimics the same three <code>.htaccess</code> rules in PHP, then requires <code>index.php</code> the same way — see <a href="/admin/docs/getting-started">Getting started</a>.)</li>
<li><strong><code>novaconium/bootstrap.php</code></strong> is where the real wiring happens, top-to-bottom:
<ol>
<li>Requires <strong><code>novaconium/autoload.php</code></strong>, registering the <code>Twig\</code>/<code>App\</code>/<code>Lib\</code> class autoloader (see <a href="/admin/docs/libraries">Libraries</a>) before anything below tries to instantiate a class.</li>
<li>Requires <strong><code>novaconium/config.php</code></strong>, then shallow-merges <strong><code>App/config.php</code></strong> over it if that file exists — see <a href="/admin/docs/config">Configuration</a>.</li>
<li>Special-cases <code>/admin/logout</code> directly against the request path — see <a href="/admin/docs/admin-auth">Admin authentication</a> — since it isn't a real page for the router to find.</li>
<li>Constructs a <strong><code>Router</code></strong> (<code>novaconium/src/Router.php</code>) and calls <code>resolve()</code> to turn the URL into a <strong><code>Route</code></strong> (<code>novaconium/src/Route.php</code>) — see <a href="/admin/docs/routing">Routing</a>.</li>
<li>If the resolved route is under <code>admin</code>/<code>admin/*</code>, calls <strong><code>AdminAuth::requireLogin()</code></strong> (<code>novaconium/src/AdminAuth.php</code>), reading that same <code>Route</code>.</li>
<li>Constructs a <strong><code>Cache</code></strong> (<code>novaconium/src/Cache.php</code>) and a <strong><code>Renderer</code></strong> (<code>novaconium/src/Renderer.php</code>), then calls <code>renderNotFound()</code> or <code>render($route, ...)</code> depending on <code>$route-&gt;found</code> — see <a href="/admin/docs/sidecars">Sidecars</a> for what happens inside <code>Renderer</code> itself (running the matched directory's <code>index.php</code>, if any; resolving the nearest layout; rendering <code>index.twig</code>; writing the static cache for sidecar-less pages).</li>
</ol>
</li>
</ol>
<p>Every step after step 1 is plain PHP you can read start to finish in <code>novaconium/bootstrap.php</code> itself — the comments there walk through the same five sub-steps in more detail than this page does.</p>
{% endblock %}
@@ -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 %}
<h1>How routing works</h1>
<p>A URL maps directly to a directory under <code>App/pages/</code>:</p>
<pre><code>App/pages/
index.twig -> /
about/
index.twig -> /about
products/
[id]/
index.twig -> /products/&lt;anything&gt; ($params['id'] = &lt;anything&gt;)
index.php -> optional sidecar for that page</code></pre>
<ul>
<li>Every route is <code>App/pages/&lt;segments&gt;/index.twig</code> (and/or <code>index.php</code> — see <a href="/admin/docs/sidecars">Sidecars</a>). There are no flat <code>about.twig</code> files and no route table to maintain.</li>
<li>A directory named <code>[param]</code> matches any single URL segment and captures it into <code>$params['param']</code> — that's how you get clean URLs like <code>/products/socks</code> with no query string. (This project's own <code>App/pages/blog/</code> doesn't currently use this — every post there is a plain directory named after its slug, e.g. <code>App/pages/blog/hello-world/</code> — but the mechanism is still fully supported.)</li>
<li>Canonical URLs never have a trailing slash. <code>/about/</code> 301-redirects to <code>/about</code>.</li>
<li>Directories starting with <code>_</code> (like <code>_layout/</code>) and a directory literally named <code>404</code> are reserved and can never be requested directly.</li>
</ul>
<h2>For developers: using <code>Router.php</code> directly</h2>
<p><code>novaconium/src/Router.php</code> is the class that does the walk described above. It's deliberately a <strong>pure lookup</strong> — 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 <code>Router</code> 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.</p>
<h3>How <code>Route.php</code> fits in</h3>
<p><code>novaconium/src/Route.php</code> is the value object <code>Router::resolve()</code> returns — three readonly properties (<code>found</code>, <code>dir</code>, <code>params</code>) and a <code>Route::notFound()</code> factory, no behavior at all. It's the thing that actually flows through the rest of the request, decoupling <code>Router</code> from everything downstream: <code>Router</code> produces a <code>Route</code> and is done, and every later step only ever reads it. See <code>novaconium/bootstrap.php</code>, which runs top-to-bottom as a plain script rather than through a framework "kernel" class:</p>
<ol>
<li>Config loads (framework defaults + optional <code>App/config.php</code> override).</li>
<li><code>/admin/logout</code> is special-cased directly against the raw request path, before routing even runs — it isn't a real page, so there'd be no <code>Route</code> for it anyway.</li>
<li><strong><code>Router::resolve()</code> runs</strong> and returns a <code>Route</code> — this is the only place a <code>Route</code> gets created.</li>
<li>If <code>$route-&gt;dir</code> is under <code>admin</code>/<code>admin/*</code>, <code>AdminAuth::requireLogin()</code> gates it — reading <code>$route-&gt;dir</code> directly off the value object <code>Router</code> handed back.</li>
<li><code>Renderer</code> takes over, also just reading the same <code>Route</code>: <code>renderNotFound()</code> if <code>$route-&gt;found</code> is <code>false</code>, otherwise <code>render($route, ...)</code> — using <code>$route-&gt;dir</code> to find the sidecar/layout/template and <code>$route-&gt;params</code> as template context.</li>
</ol>
<p>The key point: <code>Route</code> is passed around, not <code>Router</code> itself — <code>bootstrap.php</code> only calls <code>Router::resolve()</code> once, and the plain data object it gets back is what <code>AdminAuth</code> and <code>Renderer</code> both independently inspect afterward. Neither of them needs a reference to <code>Router</code>, <code>Overlay</code>, or the page-root filesystem lookup that produced the <code>Route</code> — that's why adding a new admin page, or a new reserved segment, or a new render behavior never means touching <code>Route.php</code> itself; it stays a dumb, stable carrier.</p>
<h3>Constructing it</h3>
<pre><code>use App\Router;
$router = new Router($config['pages_dirs']);</code></pre>
<p>The constructor takes the same ordered list of page roots as everything else in this framework — <code>App/pages/</code> first, <code>novaconium/pages/</code> as fallback (see <code>novaconium/config.php</code>'s <code>pages_dirs</code>). Order matters: it's what lets a project override a page just by placing one at the same relative path in <code>App/pages/</code>.</p>
<h3><code>resolve()</code> and the <code>Route</code> it returns</h3>
<pre><code>$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&lt;string,string&gt; — captured [param] segments, e.g.
// ['id' =&gt; 'socks'] for a /products/[id]/ route</code></pre>
<p><code>resolve()</code> takes a full request URI (query string and all — it strips that internally with <code>strtok($requestUri, '?')</code>) and returns a <code>Route</code> value object (<code>novaconium/src/Route.php</code>): three readonly properties, no behavior. A not-found result is just <code>Route::notFound()</code> — <code>dir</code> and <code>params</code> are <code>null</code>/empty, <code>found</code> is <code>false</code>. There's no exception thrown for a 404; check <code>$route->found</code> the same way <code>bootstrap.php</code> does.</p>
<h3>What actually makes something "found"</h3>
<p>Walking the URL segment by segment, <code>Router</code> resolves each one against the page roots via <code>Overlay</code> (see <code>novaconium/src/Overlay.php</code>): an exact-name subdirectory wins if one exists in <em>either</em> root, otherwise a <code>[param]</code>-named subdirectory captures the segment. A segment starting with <code>_</code> or literally named <code>404</code> 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 <code>index.twig</code> <em>or</em> an <code>index.php</code> (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.</p>
<p>Since <code>Router</code> has no dependencies beyond <code>Overlay</code> and does no I/O beyond filesystem existence checks, it's straightforward to exercise directly — construct it with a real (or temporary/fixture) <code>pagesDirs</code> array and assert on the <code>Route</code> it returns, without needing to spin up the full HTTP request cycle.</p>
{% endblock %}
@@ -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 %}
<h1>SEO boilerplate</h1>
<p><code>novaconium/pages/_layout/layout.twig</code> (the root layout every page extends, directly or via a nested layout) renders a full set of SEO meta tags in <code>&lt;head&gt;</code>: 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 <code>&lt;head&gt;</code>.</p>
<h2>Blocks you can override</h2>
<table>
<thead>
<tr><th>Block</th><th>Default</th><th>Renders as</th></tr>
</thead>
<tbody>
<tr><td><code>title</code></td><td><code>site_name</code> config value</td><td><code>&lt;title&gt;</code>, and reused by <code>og:title</code> / <code>twitter:title</code></td></tr>
<tr><td><code>description</code></td><td>generic site description</td><td><code>&lt;meta name="description"&gt;</code>, and reused by <code>og:description</code> / <code>twitter:description</code></td></tr>
<tr><td><code>robots</code></td><td><code>index, follow</code></td><td><code>&lt;meta name="robots"&gt;</code></td></tr>
<tr><td><code>canonical</code></td><td><code>{{ '{{ request_path }}' }}</code></td><td><code>&lt;link rel="canonical"&gt;</code>, and reused by <code>og:url</code></td></tr>
<tr><td><code>og_type</code></td><td><code>website</code></td><td><code>&lt;meta property="og:type"&gt;</code></td></tr>
<tr><td><code>og_title</code></td><td><code>{{ '{{ block(\'title\') }}' }}</code></td><td><code>&lt;meta property="og:title"&gt;</code></td></tr>
<tr><td><code>og_description</code></td><td><code>{{ '{{ block(\'description\') }}' }}</code></td><td><code>&lt;meta property="og:description"&gt;</code></td></tr>
<tr><td><code>og_url</code></td><td><code>{{ '{{ block(\'canonical\') }}' }}</code></td><td><code>&lt;meta property="og:url"&gt;</code></td></tr>
<tr><td><code>twitter_card</code></td><td><code>summary</code></td><td><code>&lt;meta name="twitter:card"&gt;</code></td></tr>
<tr><td><code>twitter_title</code></td><td><code>{{ '{{ block(\'title\') }}' }}</code></td><td><code>&lt;meta name="twitter:title"&gt;</code></td></tr>
<tr><td><code>twitter_description</code></td><td><code>{{ '{{ block(\'description\') }}' }}</code></td><td><code>&lt;meta name="twitter:description"&gt;</code></td></tr>
</tbody>
</table>
<p>Blocks that piggyback on another block (e.g. <code>og_title</code> defaulting to <code>{{ '{{ block(\'title\') }}' }}</code>) use Twig's <code>block()</code> function, not inheritance — so setting <code>title</code> alone is enough to update <code>og:title</code> and <code>twitter:title</code> too, unless the page also overrides those blocks explicitly.</p>
<h2>Overriding on a page</h2>
<p>Any <code>index.twig</code> can override any subset of these blocks, same as <code>title</code> or <code>content</code>:</p>
<pre><code>{% verbatim %}{% extends layout %}
{% block title %}Pricing{% endblock %}
{% block description %}Plans and pricing for the whole team.{% endblock %}
{% block og_type %}product{% endblock %}
{% block content %}
...
{% endblock %}{% endverbatim %}</code></pre>
<p>See any post under <code>App/pages/blog/</code> (e.g. <code>App/pages/blog/twig-syntax-guide/index.twig</code>) for a working example that sets <code>og_type</code> to <code>article</code> with a hand-written <code>description</code>. If you ever need to derive a description from a longer body string, don't reach for Twig's <code>|slice</code> filter — applied to a string, it calls <code>mb_substr()</code> unconditionally with no fallback, which hard-requires the <code>mbstring</code> extension. Truncate in PHP instead, guarded with <code>function_exists('mb_substr')</code>; see the footnote on <a href="/blog/twig-syntax-guide">the Twig Syntax Guide</a> for the story of why this project cares.</p>
<h2>Copy-paste starter: every block, explicitly</h2>
<p>Every <code>App/pages/*/index.twig</code> 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? <code>php novaconium/bin/create-static-page.php &lt;path&gt;</code> scaffolds this exact template for you — see <a href="/admin/docs/getting-started">Getting started</a>.</p>
<pre><code>{% verbatim %}{% extends layout %}
{% block title %}Page title{% endblock %}
{% block description %}One or two sentences describing this page.{% endblock %}
{% block robots %}index, follow{% endblock %}
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
{% block og_type %}website{% endblock %}
{% block og_title %}{{ block('title') }}{% endblock %}
{% block og_description %}{{ block('description') }}{% endblock %}
{% block og_url %}{{ block('canonical') }}{% endblock %}
{% block twitter_card %}summary{% endblock %}
{% block twitter_title %}{{ block('title') }}{% endblock %}
{% block twitter_description %}{{ block('description') }}{% endblock %}
{% block content %}
&lt;article&gt;
&lt;h1&gt;Page title&lt;/h1&gt;
&lt;p&gt;...&lt;/p&gt;
&lt;/article&gt;
{% endblock %}{% endverbatim %}</code></pre>
<p>To keep a page out of search results, change only the <code>robots</code> line to <code>noindex, nofollow</code> and delete the rest — everything else can be left to the layout's defaults, same as the <code>/admin</code> pages do.</p>
<h2>Canonical URLs and <code>request_path</code></h2>
<p>The canonical link (and <code>og:url</code>) default to a <code>request_path</code> variable that <code>novaconium/src/Renderer.php</code> injects into every template's context — the request URI's path component, computed via <code>parse_url($requestUri, PHP_URL_PATH)</code>. 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. <code>https://example.com/about</code> rather than <code>/about</code>), override the <code>canonical</code> block per-page or add a site-wide base URL to <code>novaconium/config.php</code> and reference it in the layout.</p>
<h2>Keeping admin/internal pages out of search results</h2>
<p>Pages that shouldn't be indexed — <code>/admin</code>, <code>/admin/clear-cache</code>, every <code>/admin/docs/*</code> page, and the 404 page — override <code>robots</code> to <code>noindex, nofollow</code>. Follow the same pattern for any project-specific admin or internal tooling pages you add under <code>App/pages/</code>.</p>
<h2>Favicon</h2>
<p>The layout links <code>&lt;link rel="icon" href="/favicon.ico"&gt;</code> unconditionally — drop a <code>favicon.ico</code> into <code>public/</code> to have it picked up; there's no fallback or generation step.</p>
{% endblock %}
@@ -0,0 +1,242 @@
{% extends 'admin/docs/_layout/layout.twig' %}
{% block title %}Sidecars{% endblock %}
{% block description %}Where your PHP logic goes — the optional index.php sidecar.{% endblock %}
{% block robots %}noindex, nofollow{% endblock %}
{% block docs_content %}
<h1>Sidecars — where your PHP logic goes</h1>
<p>Drop an <code>index.php</code> next to any <code>index.twig</code> and it becomes that page's data provider. It runs before the template and can return one of two things:</p>
<h2>An array — becomes the Twig context</h2>
<pre><code>&lt;?php
// App/pages/contact/index.php
use Lib\Input;
use Lib\Mailer;
$errors = [];
$old = ['name' =&gt; '', 'email' =&gt; '', 'message' =&gt; ''];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// ...validate Input::post() into $old/$errors...
if (!$errors) {
(new Mailer())-&gt;send($old['name'], $old['email'], $old['message']);
return \App\Response::redirect('/contact?sent=1');
}
}
return ['errors' =&gt; $errors, 'old' =&gt; $old];</code></pre>
<p><code>$params</code> (the captured route segments, e.g. an <code>[id]</code> directory's <code>id</code>) is already in scope — no need to touch <code>$_GET</code>.</p>
<h2>A Response object — short-circuits Twig entirely</h2>
<pre><code>use App\Response;
Response::redirect('/somewhere');
Response::json(['ok' =&gt; true]);
Response::xml('&lt;root/&gt;');
Response::html('&lt;h1&gt;raw&lt;/h1&gt;');</code></pre>
<p>A sidecar-only page (no <code>index.twig</code> at all) is fine too — e.g. an <code>App/pages/api/posts/index.php</code> returning just <code>Response::json([...])</code> for a JSON-only endpoint. Twig never runs for that route at all; the sidecar's return value is the entire response.</p>
<p><strong>Form handling</strong> — a sidecar can branch on <code>$_SERVER['REQUEST_METHOD']</code>, validate <code>$_POST</code>, call a library, and only return <code>Response::redirect()</code> once it succeeds (the classic POST/redirect/GET pattern, so a page refresh doesn't resubmit the form). See <code>App/pages/contact/index.php</code> + <code>App/pages/contact/index.twig</code> for the full example — it validates name/email/message, calls <code>Lib\Mailer::send()</code>, and redirects to <code>/contact?sent=1</code> to show a success message.</p>
<h2>Form security</h2>
<p>Every form on this site combines three independent layers, all reusable <code>Lib\</code> classes: input cleaning, CSRF protection, and spam prevention. None of them depend on each other — a form can use any subset.</p>
<h3>Input cleaning</h3>
<p><strong><code>Lib\Input</code></strong> (<code>novaconium/lib/Input.php</code>) is a drop-in replacement for reading <code>$_POST</code>/<code>$_GET</code> directly — every sidecar on this site uses it instead of touching the superglobals:</p>
<pre><code>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</code></pre>
<p>Calling <code>post()</code>/<code>get()</code> with no key returns the entire cleaned array (handy for passing straight to <code>SpamGuard::isSpam()</code>, 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 <code>tags[]</code>) are cleaned recursively. The result is memoized per request, so calling <code>Input::post()</code> repeatedly across a sidecar doesn't re-clean the superglobal each time.</p>
<p><strong>This is not SQL-injection protection.</strong> The cleaning <code>Input</code> does (trim + strip tags, via <code>Lib\Validate::clean()</code>, plus null-byte stripping) is defense-in-depth against HTML/script injection in output contexts — Twig already autoescapes <code>{{ }}</code> output by default (see <code>novaconium/src/Renderer.php</code>), 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 <code>novaconium/ISSUES.md</code>); when one lands, use prepared statements exclusively. <code>Input</code> deliberately has no <code>sqlSafe()</code>-style method, since a method implying "cleaned = safe to interpolate into SQL" would be actively dangerous.</p>
<p>One documented exception: a field that needs an exact, unmodified value — a password about to be hashed, say — should read <code>$_POST</code> directly instead of going through <code>Input::post()</code>. Cleaning would silently strip characters like <code>&lt;</code>/<code>&gt;</code> before hashing, producing a hash that doesn't match what's actually typed later. See <code>novaconium/pages/admin/password-hash/index.php</code> for the one place this framework does that on purpose.</p>
<h3>CSRF protection</h3>
<p><strong><code>Lib\Csrf</code></strong> (<code>novaconium/lib/Csrf.php</code>) is a standalone session-token CSRF guard — standalone meaning it isn't wired into <code>FormValidator</code>'s chain, so a sidecar calls it directly, typically as the very first check on a POST:</p>
<pre><code>use Lib\Csrf;
use Lib\Input;
if (!Csrf::verify(Input::post('csrf_token'))) {
return \App\Response::redirect('/contact?error=security');
}</code></pre>
<p>with a matching hidden field alongside the honeypot/timestamp fields:</p>
<pre><code>&lt;input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}"&gt;</code></pre>
<p>and two extra keys in the sidecar's returned context:</p>
<pre><code>'csrfField' =&gt; Csrf::fieldName(),
'csrfToken' =&gt; Csrf::token(),</code></pre>
<p><code>Csrf::token()</code> 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 <code>Csrf</code> never gets a session cookie, so the rest of the site stays session-free. The session cookie itself is hardened (<code>httponly</code>, <code>SameSite=Lax</code>, <code>secure</code> when the request is HTTPS) inside <code>Csrf</code>'s private <code>ensureSession()</code>.</p>
<p>A failed CSRF check shows a real, visible error — <strong>"Your session expired before submitting — please try again."</strong> — 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.</p>
<h3>Spam prevention</h3>
<p>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 <strong><code>Lib\SpamGuard</code></strong> (<code>novaconium/lib/SpamGuard.php</code>) — a framework-default <code>Lib\</code> class, reusable on any form a project adds, the same way <code>Lib\Mailer</code> is:</p>
<pre><code>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(),</code></pre>
<p>Two checks run inside <code>isSpam()</code>, both purely server-side:</p>
<ul>
<li><strong>Honeypot field.</strong> The paired Twig template renders a <code>website</code> input inside a <code>.hp-field</code> wrapper, positioned off-screen with CSS (<code>position: absolute; left: -9999px</code> — deliberately <em>not</em> <code>display: none</code> or <code>visibility: hidden</code>, since some spam bots specifically skip fields hidden that way) and marked <code>aria-hidden="true"</code> with <code>tabindex="-1"</code> 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.</li>
<li><strong>Timing check.</strong> A hidden field (rendered via <code>$spamGuard-&gt;renderedAt()</code>, read back as <code>rendered_at</code>) carries the Unix timestamp of when the form was rendered. On submit, anything completed in under the constructor's <code>$minSeconds</code> (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.</li>
</ul>
<p><code>isSpam()</code> tripping either check doesn't stop the sidecar from validating and redirecting normally — see <code>App/pages/contact/index.php</code>, which still returns <code>Response::redirect('/contact?sent=1')</code> regardless, and only skips <code>Lib\Mailer::send()</code> 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 (<code>new SpamGuard('website', 'rendered_at', 2)</code>), so a second form on the same site can use different field names without the two forms interfering with each other.</p>
<p>Field validation (required fields, email format, length limits) is its own reusable class, <strong><code>Lib\FormValidator</code></strong> (<code>novaconium/lib/FormValidator.php</code>) — an accumulating validator so a sidecar doesn't hand-roll the same checks and <code>$errors</code> array every time:</p>
<pre><code>use Lib\FormValidator;
$validator = (new FormValidator())
-&gt;required($old['name'], 'name', 'Name is required.')
-&gt;email($old['email'], 'email', 'A valid email is required.')
-&gt;required($old['message'], 'message', 'Message is required.')
-&gt;maxLength($old['message'], 'message', 2000, 'Message is too long.');
if ($validator->passes()) {
// ...
}
$errors = $validator->errors();</code></pre>
<p><code>FormValidator</code> doesn't implement validation logic itself — each check delegates to <strong><code>Lib\Validate</code></strong> (<code>novaconium/lib/Validate.php</code>), a set of stateless, static validation primitives modeled after <a href="https://github.com/nickyeoman/php-validation-class">the project author's own reusable validation class</a>: <code>clean()</code>, <code>isEmail()</code>, <code>minLength()</code>/<code>maxLength()</code>, <code>isMatch()</code> (e.g. a "confirm email" field), <code>isPhone()</code> (7- or 10-digit, with optional extension), and <code>isPostalCode()</code>/<code>isZipCode()</code>. Call <code>Validate</code> directly from a sidecar when you just need a validated/normalized value back — e.g. <code>Validate::isPhone($old['phone'], withExtension: true)</code> — rather than an accumulated field-error:</p>
<pre><code>use Lib\Validate;
$phone = Validate::isPhone($old['phone']); // '5551234567' or false</code></pre>
<p>All three classes live under <code>novaconium/lib/</code> as framework defaults — like any other <code>Lib\</code> class, a project can override any of them by dropping a same-named file in <code>App/lib/</code> (see <a href="/admin/docs/libraries">Libraries</a>).</p>
<h2>Copy-paste starter: a sidecar, three ways</h2>
<p>Drop this in as <code>App/pages/example/index.php</code> (next to an <code>App/pages/example/index.twig</code> using the <a href="/admin/docs/seo">SEO starter template</a>) and delete whichever example you don't need:</p>
<pre><code>{% verbatim %}&lt;?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 %}</code></pre>
<p>Only one of the three <code>return</code>s in a real sidecar ever runs, obviously — pick one, or branch between them with an <code>if</code>. The IP example works with or without a sidecar-only page (no <code>index.twig</code>); the <code>phpinfo()</code> example needs <em>no</em> <code>index.twig</code> at all, since <code>Response::html()</code> bypasses Twig — see the JSON-only example above for another sidecar-only page.</p>
<p><strong>Never ship <code>phpinfo()</code> to production</strong> — 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 <a href="/admin/docs/admin-auth">admin authentication</a> the same way <code>/admin/*</code> already is, so it's never reachable by the public.</p>
<h2>For developers: using <code>Response.php</code> directly</h2>
<p><code>novaconium/src/Response.php</code> is the small value object behind <code>Response::redirect()</code>/<code>::json()</code>/<code>::xml()</code>/<code>::html()</code> above. Like <code>Route</code> (see <code>/admin/docs/routing</code>'s "How <code>Route.php</code> 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 <code>Renderer</code>.</p>
<h3>How it fits in</h3>
<p>Its constructor is <code>private</code> — you never call <code>new Response(...)</code> directly, only one of the four named factories, each of which fixes the <code>type</code>/<code>headers</code> that go with that kind of response:</p>
<pre><code>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</code></pre>
<p>Every factory returns a fully-formed, readonly <code>Response</code> — <code>type</code>, <code>body</code>, <code>status</code>, <code>headers</code> — and nothing has happened yet. A sidecar just returns that object; it's <code>Renderer::render()</code> (see this page's <code>Renderer.php</code> section above) that checks <code>$result instanceof Response</code> and, if so, calls <code>$result-&gt;emit()</code> 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 <code>Response</code> to bypass it.</p>
<h3>What <code>emit()</code> actually does</h3>
<p><code>emit()</code> is the one method with side effects, and it's only ever called from inside <code>Renderer</code>, never from a sidecar itself:</p>
<ol>
<li>Sets the HTTP status code via <code>http_response_code($this-&gt;status)</code>.</li>
<li>Sends each header in <code>$this-&gt;headers</code> (empty for <code>redirect</code>, a single <code>Content-Type</code> for the other three).</li>
<li>For a <code>redirect</code>, sends a <code>Location</code> header (the URL passed to <code>Response::redirect()</code>) and returns — no body.</li>
<li>For <code>json</code>, encodes <code>$this-&gt;body</code> with <code>json_encode(..., JSON_THROW_ON_ERROR)</code> and echoes it — the <code>JSON_THROW_ON_ERROR</code> flag means an unencodable value (e.g. a resource, or a value containing invalid UTF-8) throws a <code>JsonException</code> rather than silently emitting <code>false</code> as the body.</li>
<li>For <code>xml</code>/<code>html</code>, <code>$this-&gt;body</code> is already a string (built by the caller), so it's echoed as-is — <code>Response</code> doesn't validate or escape it.</li>
</ol>
<p>Because every property is <code>readonly</code> and the type/body/status/headers are fixed at construction, a <code>Response</code> is safe to build early in a sidecar and pass around (or return immediately) without worrying about it changing shape before <code>Renderer</code> gets to it — there's no setter to call by mistake.</p>
<h2>For developers: using <code>Renderer.php</code> directly</h2>
<p><code>novaconium/src/Renderer.php</code> 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, <code>Response</code> objects short-circuiting) is <code>Renderer</code>'s doing. Unlike <code>Router</code> (see <code>/admin/docs/routing</code>'s "For developers" section), it isn't a pure lookup: <code>render()</code> and <code>renderNotFound()</code> both emit directly — <code>http_response_code()</code>, <code>header()</code>, <code>echo</code> — 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.</p>
<h3>How it fits in</h3>
<p><code>Renderer</code> is the last thing a request touches, in <code>novaconium/bootstrap.php</code>: <code>Router::resolve()</code> produces a <code>Route</code>, <code>AdminAuth</code> optionally gates it, and then <code>Renderer</code> reads that same <code>Route</code> to actually produce output. It never talks back to <code>Router</code> or <code>AdminAuth</code> — by the time it runs, routing and auth are already decided, and its only inputs are the <code>Route</code> and the raw request URI:</p>
<pre><code>$renderer = new Renderer($config['pages_dirs'], $cache, $adminAuthEnabled, $matomoUrl, $config['matomo_site_id'], $config['site_name']);
if (!$route->found) {
$renderer->renderNotFound($requestUri);
return;
}
$renderer->render($route, $requestUri);</code></pre>
<h3>Constructing it</h3>
<p>The first two constructor arguments are the same <code>pagesDirs</code> override-root list every other class here takes, plus a <code>Cache</code> instance (<code>novaconium/src/Cache.php</code>) 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 (<code>$this->twig->addGlobal(...)</code>) purely so every template can read <code>matomo_url</code>, <code>site_name</code>, etc. without a sidecar having to pass them through manually. A fifth global, <code>is_404</code>, defaults to <code>false</code> here and is overridden per-render — see below.</p>
<h3>What <code>render()</code> actually does, in order</h3>
<ol>
<li>Looks for <code>index.php</code> at <code>$route-&gt;dir</code> via <code>Overlay::findFile()</code> (checking <code>App/pages/</code> before <code>novaconium/pages/</code>, same as everywhere else) and, if one exists, requires it in a scope where <code>$params</code> and <code>$cache</code> are already defined — see <code>runSidecar()</code>.</li>
<li>If the sidecar returned a <code>Response</code>, calls <code>$result-&gt;emit()</code> and returns immediately — Twig never runs, nothing gets cached.</li>
<li>Otherwise treats the return value as Twig context, merging in <code>params</code>, the resolved <code>layout</code> path, and <code>request_path</code>.</li>
<li>Renders <code>index.twig</code> at <code>$route-&gt;dir</code> with that context, emits <code>200</code> + the HTML.</li>
<li>Only if there was <strong>no</strong> sidecar, writes the rendered HTML to the static cache (<code>Cache::write()</code>) — a page with a sidecar is never cached this way, since its output can vary per request.</li>
</ol>
<p><code>renderNotFound()</code> is a smaller version of the same idea: no sidecar to run, always emits <code>404</code>, renders <code>404/index.twig</code> if one exists in either page root (with <code>is_404</code> forced to <code>true</code> in that render's context — see <code>/admin/docs/matomo</code> for what that flag is used for), and falls back to a bare <code>404 Not Found</code> string if even the default 404 template is missing.</p>
<h3>Layout resolution</h3>
<p>Both methods get their <code>layout</code> value from the private <code>relativeLayoutPath()</code>, which walks upward from the matched directory looking for the nearest <code>_layout/layout.twig</code> — 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 <code>null</code>, which only happens if even the root <code>_layout/layout.twig</code> is missing from both roots). This is what lets <code>App/pages/blog/_layout/layout.twig</code> override the site-wide layout for just that subtree, per <a href="/admin/docs/layouts">Layouts</a>.</p>
<p>Because <code>render()</code>/<code>renderNotFound()</code> write straight to PHP's output buffer and response headers rather than returning anything, testing <code>Renderer</code> in isolation means capturing output (e.g. <code>ob_start()</code>) and inspecting headers, rather than asserting on a return value the way you can with <code>Router::resolve()</code>'s <code>Route</code>.</p>
{% endblock %}
@@ -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 %}
<h1>Styling (Sass, indented syntax)</h1>
<p>Source lives in <code>novaconium/sass/main.sass</code> (indented syntax, not SCSS). Compile it with <a href="https://sass-lang.com/dart-sass/">Dart Sass</a>, passing both Sass directories as load paths:</p>
<pre><code>sass --load-path=App/sass --load-path=novaconium/sass/defaults novaconium/sass/main.sass public/css/main.css</code></pre>
<p>There's no PHP-based Sass compiler in this project (PHP options like <code>scssphp</code> only understand SCSS syntax) — this is a manual/CI build step, not something the app does at runtime.</p>
<p>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 (<code>1.101.0</code>, via <code>pacman -S dart-sass</code> on Arch), not the npm-wrapped build:</p>
<pre><code>FROM debian:bookworm-slim
RUN apt-get update \
&amp;&amp; apt-get install -y --no-install-recommends curl ca-certificates \
&amp;&amp; curl -fsSLo /tmp/dart-sass.tar.gz \
https://github.com/sass/dart-sass/releases/download/1.101.0/dart-sass-1.101.0-linux-x64.tar.gz \
&amp;&amp; tar -xzf /tmp/dart-sass.tar.gz -C /usr/local/lib \
&amp;&amp; ln -s /usr/local/lib/dart-sass/sass /usr/local/bin/sass \
&amp;&amp; rm /tmp/dart-sass.tar.gz \
&amp;&amp; apt-get purge -y curl \
&amp;&amp; apt-get autoremove -y \
&amp;&amp; rm -rf /var/lib/apt/lists/*
WORKDIR /usr/src/app
ENTRYPOINT ["sass"]</code></pre>
<p>Check <a href="https://github.com/sass/dart-sass/releases">the dart-sass releases page</a> for a newer version and swap both occurrences of <code>1.101.0</code> in the download URL if you want to track latest instead of matching this environment.</p>
<p>Build it once, then run it the same way you'd run the local <code>sass</code> CLI (skip the leading <code>sass</code> in the command — the image's <code>ENTRYPOINT</code> already supplies it):</p>
<pre><code>docker build -t novaconium-sass -f Dockerfile .
docker run --rm -v "$(pwd):/usr/src/app" -w /usr/src/app novaconium-sass \
--load-path=App/sass --load-path=novaconium/sass/defaults novaconium/sass/main.sass public/css/main.css</code></pre>
<p>See <a href="https://git.4lt.ca/4lt/novaconium/src/branch/master/docs/Sass.md">git.4lt.ca/4lt/novaconium/docs/Sass.md</a> 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.</p>
<h2>Overriding just the colors</h2>
<p><code>novaconium/sass/main.sass</code> starts with <code>@use 'colors' as *</code>, but its own directory has no <code>_colors.sass</code> of its own — on purpose, so that <code>@use</code> falls through to the Sass load path above rather than resolving to a sibling file. <code>App/sass/_colors.sass</code> is checked first; <code>novaconium/sass/defaults/_colors.sass</code> (the framework's own palette) is the fallback. Same override-by-presence mechanism as <code>App/pages/</code> over <code>novaconium/pages/</code>, just applied to Sass instead of Twig/PHP.</p>
<p>To reskin the whole site, edit the seven variables in <code>App/sass/_colors.sass</code> — <code>$bg</code>, <code>$surface</code>, <code>$text-color</code>, <code>$muted-color</code>, <code>$border-color</code>, <code>$accent</code>, <code>$accent-hover</code> — and recompile. Nothing under <code>novaconium/</code> needs to change. Delete <code>App/sass/_colors.sass</code> entirely to fall back to the framework's default palette instead.</p>
<h2>Dark/light theme toggle</h2>
<p>Every color rule in <code>main.sass</code> reads a CSS custom property (<code>var(--bg)</code>, <code>var(--accent)</code>, etc.) instead of a Sass variable directly. The Sass variables only seed the initial <code>:root</code> values at compile time; a <code>:root[data-theme="light"]</code> block overrides all seven using <code>-light</code>-suffixed variables from the same <code>_colors.sass</code> files (<code>$bg-light</code>, <code>$surface-light</code>, etc.) — same override mechanism, same files, a second palette.</p>
<p>The toggle button in <code>novaconium/pages/_layout/nav.twig</code> flips a <code>data-theme</code> attribute on <code>&lt;html&gt;</code> at runtime and persists the choice to <code>localStorage</code>. <code>novaconium/pages/_layout/theme-init.twig</code>, included early in <code>&lt;head&gt;</code> 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.</p>
<p>To customize the light theme the same way you'd customize the dark one, edit the <code>-light</code> variables in <code>App/sass/_colors.sass</code> and recompile.</p>
{% endblock %}
+13
View File
@@ -0,0 +1,13 @@
{% extends 'admin/docs/_layout/layout.twig' %}
{% block title %}Third-party{% endblock %}
{% block description %}Vendored Twig and its license.{% endblock %}
{% block robots %}noindex, nofollow{% endblock %}
{% block docs_content %}
<h1>Third-party</h1>
<p><a href="https://twig.symfony.com/">Twig</a> is vendored in source form under <code>novaconium/vendor/twig/</code> (no Composer — see <a href="/admin/docs/upgrading-twig">Upgrading Twig</a> for how to upgrade it). It's BSD-3-Clause licensed; the full license text ships alongside it at <code>novaconium/vendor/twig/LICENSE</code>.</p>
{% endblock %}
@@ -0,0 +1,44 @@
{% extends 'admin/docs/_layout/layout.twig' %}
{% block title %}Upgrading Twig{% endblock %}
{% block description %}How to bump the vendored copy of Twig.{% endblock %}
{% block robots %}noindex, nofollow{% endblock %}
{% block docs_content %}
<h1>Upgrading vendored Twig</h1>
<p>This project has no Composer — Twig is vendored by hand as source files under <code>novaconium/vendor/twig/</code>. There's no lockfile and no <code>composer.json</code>, so upgrading is a manual copy-and-verify process rather than <code>composer update</code>.</p>
<h2>What's currently vendored</h2>
<ul>
<li>Version: <strong>3.28.0</strong> (see <code>novaconium/vendor/twig/src/Environment.php</code>, the <code>Environment::VERSION</code> constant — that constant is always the source of truth, this doc can drift).</li>
<li>Only the <code>src/</code> directory from the Twig package is vendored — no tests, no docs, no <code>composer.json</code>. <code>novaconium/autoload.php</code> maps the <code>Twig\</code> namespace straight at <code>novaconium/vendor/twig/src/</code>, so anything Twig autoloads has to live at the matching path under there.</li>
<li><code>novaconium/autoload.php</code> also defines a <code>trigger_deprecation()</code> shim. Twig 3.x calls this function (normally supplied by <code>symfony/deprecation-contracts</code>, 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.</li>
</ul>
<h2>How to upgrade</h2>
<ol>
<li>Pick the target version from Twig's GitHub releases: <code>https://github.com/twigphp/Twig/releases</code>. Read the changelog for breaking changes, in particular anything touching <code>Environment</code>, <code>Loader\FilesystemLoader</code>, <code>{% verbatim %}{% extends %}{% endverbatim %}</code>/<code>{% verbatim %}{% include %}{% endverbatim %}</code> resolution, or autoescaping — those are the parts of Twig this project actually exercises (see <code>novaconium/src/Renderer.php</code>).</li>
<li>Download the release source (the "Source code (zip)" asset on the release page, or <code>git clone --branch vX.Y.Z --depth 1 https://github.com/twigphp/Twig</code> if you have network access to GitHub).</li>
<li>From the downloaded copy, take only the <code>src/</code> directory.</li>
<li>Replace <code>novaconium/vendor/twig/src/</code> wholesale with that new <code>src/</code> directory (delete the old one first so removed files don't linger).</li>
<li>Copy the new <code>LICENSE</code> file over <code>novaconium/vendor/twig/LICENSE</code> too, in case it changed.</li>
<li>Confirm the namespace layout is unchanged: <code>novaconium/autoload.php</code> assumes <code>Twig\Foo\Bar</code> lives at <code>src/Foo/Bar.php</code> relative to the vendor root. This has been stable across Twig 3.x, but double-check if jumping a major version.</li>
<li>Run the app and click through every route (or run the manual checklist in the <a href="/admin/docs/design-notes">Design notes</a>' Verification section) — there's no automated test suite, so this is the actual regression check:
<ul>
<li><code>/</code>, <code>/about</code> — static pages, exercise <code>{% verbatim %}{% extends layout %}{% endverbatim %}</code>.</li>
<li><code>/blog</code> and any post under it — a sidecar-driven listing plus sidecar-less posts.</li>
<li>A <code>_layout/layout.twig</code> overriding the root layout — exercises nested <code>{% verbatim %}{% extends %}{% endverbatim %}</code> resolution across the <code>App/pages/</code> + <code>novaconium/pages/</code> overlay (see <code>novaconium/src/Overlay.php</code>).</li>
<li><code>/contact</code> — form handling, <code>{% verbatim %}{% if %}{% endverbatim %}</code> blocks, loop-free but exercises variable escaping (<code>{% verbatim %}{{ old.name }}{% endverbatim %}</code> etc.) — good smoke test for autoescape behavior changes.</li>
<li><code>/admin/docs</code> and its subpages — pure Twig, no <code>|raw</code> — confirm they still render as expected after the upgrade.</li>
</ul>
</li>
<li>If PHP now emits <code>E_USER_DEPRECATED</code> warnings that weren't there before (visible because <code>debug</code> is <code>true</code> in <code>novaconium/config.php</code>), that's the <code>trigger_deprecation()</code> shim doing its job — read the message and update the calling code in <code>novaconium/src/Renderer.php</code> before the next major Twig version removes the deprecated path entirely.</li>
<li>Update the version note at the top of this page.</li>
</ol>
<h2>Why not just use Composer?</h2>
<p>The project intentionally has no build/install step — cloning the repo and pointing a web server at <code>public/</code> is enough. That's a deliberate trade-off: upgrades are manual, but there's nothing to install, no lockfile to drift, and no <code>vendor/</code> regeneration step for a fresh deploy.</p>
{% endblock %}
+21
View File
@@ -0,0 +1,21 @@
{% extends layout %}
{% import '_layout/icons.twig' as icons %}
{% block title %}Admin{% endblock %}
{% block description %}Site administration tools.{% endblock %}
{% block robots %}noindex, nofollow{% endblock %}
{% block content %}
<article>
<h1>Admin</h1>
<ul>
<li><a class="icon-link" href="/admin/clear-cache">{{ icons.trash() }}Clear cache</a></li>
<li><a class="icon-link" href="/admin/docs">{{ icons.book() }}Project docs</a></li>
<li><a class="icon-link" href="/admin/password-hash">{{ icons.lock() }}Generate admin password hash</a></li>
{% if admin_auth_enabled %}<li><a class="icon-link" href="/admin/logout">{{ icons.external_link() }}Logout</a></li>{% endif %}
</ul>
</article>
{% endblock %}
@@ -0,0 +1,38 @@
<?php
use App\Response;
use Lib\Csrf;
use Lib\Input;
$hash = null;
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!Csrf::verify(Input::post('csrf_token'))) {
return Response::redirect('/admin/password-hash?error=security');
}
// Read directly, not via Input::post() — that cleaning would silently
// strip characters like < and > 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(),
];
@@ -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 %}
<article>
<h1 class="icon-heading">{{ icons.lock() }}Generate admin password hash</h1>
<p>A browser-based alternative to <code>php -r "echo password_hash('yourpassword', PASSWORD_DEFAULT);"</code>. Enter a password, get back the hash <code>App/config.php</code> expects for <code>admin_password_hash</code> — see <a class="icon-link" href="/admin/docs/admin-auth">{{ icons.book() }}Admin authentication</a>. Nothing typed here is stored, logged, or sent anywhere except computed once for this response.</p>
<form method="post" action="/admin/password-hash">
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
<p>
<label for="password">Password</label><br>
<input type="password" id="password" name="password" autocomplete="new-password">
</p>
<button type="submit">Generate hash</button>
</form>
{% if securityError %}
<p><strong>Your session expired before submitting — please try again.</strong></p>
{% endif %}
{% if error %}
<p><strong>{{ error }}</strong></p>
{% endif %}
{% if hash %}
<p>Add this to <code>App/config.php</code>:</p>
<pre><code>&lt;?php
// App/config.php
return [
'admin_username' =&gt; 'admin',
'admin_password_hash' =&gt; '{{ hash }}',
];</code></pre>
{% endif %}
</article>
{% endblock %}
+27
View File
@@ -0,0 +1,27 @@
// Framework default color palette. Used only when a project doesn't
// supply App/sass/_colors.sass. novaconium/sass/main.sass does
// `@use 'colors' as *` its own directory (novaconium/sass/) has no
// _colors.sass of its own on purpose, so that bare `@use` falls through
// to the Sass load path, compiled with App/sass first and this
// directory (novaconium/sass/defaults/) as the fallback:
// sass --load-path=App/sass --load-path=novaconium/sass/defaults \
// novaconium/sass/main.sass public/css/main.css
// Not meant to be edited per-project; override by creating
// App/sass/_colors.sass with the same variable names instead.
$bg: #16181d
$surface: #1e2129
$text-color: #e8eaed
$muted-color: #9099a6
$border-color: #2b2f3a
$accent: #5b8cff
$accent-hover: #7ea1ff
// Light theme counterpart, used when a visitor toggles it. Same naming
// convention as the dark set above: -light suffix, same seven names.
$bg-light: #ffffff
$surface-light: #eef1f6
$text-color-light: #16181d
$muted-color-light: #5a6270
$border-color-light: #d7dce3
$accent-light: #3b6fe0
$accent-hover-light: #2f5bc0
+392
View File
@@ -0,0 +1,392 @@
// Colors resolve via the Sass load path: App/sass first, novaconium/sass
// as fallback (see novaconium/sass/defaults/_colors.sass and
// App/sass/_colors.sass). `as *` imports the variables unqualified, so
// they're used below only to seed the CSS custom properties in :root —
// every rule after that reads the color via var(--bg), var(--accent),
// etc., not the Sass variable directly. That indirection is what makes
// the dark/light theme toggle possible: novaconium/pages/_layout/theme-
// init.twig flips a data-theme attribute on <html> at runtime (something
// Sass, which only runs at compile time, can't do on its own), and the
// second block below swaps every custom property in response.
@use 'colors' as *
:root
--bg: #{$bg}
--surface: #{$surface}
--text-color: #{$text-color}
--muted-color: #{$muted-color}
--border-color: #{$border-color}
--accent: #{$accent}
--accent-hover: #{$accent-hover}
:root[data-theme="light"]
--bg: #{$bg-light}
--surface: #{$surface-light}
--text-color: #{$text-color-light}
--muted-color: #{$muted-color-light}
--border-color: #{$border-color-light}
--accent: #{$accent-light}
--accent-hover: #{$accent-hover-light}
$max-width: 40rem
body
font-family: -apple-system, sans-serif
background: var(--bg)
color: var(--text-color)
max-width: $max-width
margin: 2rem auto
padding: 0 1rem
line-height: 1.6
a
color: var(--accent)
text-decoration: none
&:hover
color: var(--accent-hover)
text-decoration: underline
h1, h2, h3, h4, h5, h6
color: var(--text-color)
line-height: 1.3
blockquote
margin: 1.5rem 0
padding: 0.25rem 0 0.25rem 1rem
border-left: 3px solid var(--accent)
color: var(--muted-color)
font-style: italic
table
width: 100%
margin: 1.5rem 0
border-collapse: collapse
th, td
padding: 0.5rem 0.75rem
border: 1px solid var(--border-color)
text-align: left
th
color: var(--text-color)
font-weight: 600
img
max-width: 100%
height: auto
border-radius: 6px
.icon
flex-shrink: 0
.icon-link
display: inline-flex
align-items: center
gap: 0.35rem
.icon-heading
display: inline-flex
align-items: center
gap: 0.5rem
nav
margin-bottom: 2rem
padding-bottom: 1rem
border-bottom: 1px solid var(--border-color)
a
display: inline-flex
align-items: center
gap: 0.35rem
margin-right: 1rem
font-weight: 600
.theme-toggle
background: none
border: none
color: inherit
font-weight: normal
padding: 0
cursor: pointer
&:hover
background: none
.icon-moon
display: none
:root[data-theme="light"] .theme-toggle
.icon-sun
display: none
.icon-moon
display: inline
code
background: var(--surface)
color: var(--accent)
padding: 0.15em 0.4em
border-radius: 4px
font-size: 0.9em
pre
background: var(--surface)
border: 1px solid var(--border-color)
border-radius: 6px
padding: 1rem
overflow-x: auto
code
background: none
padding: 0
color: var(--text-color)
hr
border: none
border-top: 1px solid var(--border-color)
margin: 2rem 0
ul, ol
padding-left: 1.25rem
label
color: var(--muted-color)
small
color: var(--muted-color)
// Honeypot field for spam prevention (see App/pages/contact/index.php).
// Off-screen positioning rather than display:none/visibility:hidden,
// since some spam bots specifically skip fields hidden that way.
.hp-field
position: absolute
left: -9999px
width: 1px
height: 1px
overflow: hidden
button, select, textarea, input:not([type="radio"]):not([type="checkbox"])
font-family: inherit
font-size: 1rem
background: var(--surface)
color: var(--text-color)
border: 1px solid var(--border-color)
border-radius: 6px
padding: 0.5rem 0.75rem
select
cursor: pointer
input[type="radio"], input[type="checkbox"]
accent-color: var(--accent)
margin-right: 0.35rem
vertical-align: middle
button
background: var(--accent)
color: var(--bg)
border: none
font-weight: 600
cursor: pointer
&:hover
background: var(--accent-hover)
.blog-layout
display: flex
gap: 2rem
aside
color: var(--muted-color)
flex: 0 0 8rem
article
flex: 1
@keyframes fade-in-up
from
opacity: 0
transform: translateY(0.75rem)
to
opacity: 1
transform: translateY(0)
@keyframes glow-drift
0%
transform: translate(-50%, -50%) rotate(0deg)
100%
transform: translate(-50%, -50%) rotate(360deg)
.hero
position: relative
padding: 3rem 0 2.5rem
text-align: center
overflow: hidden
&::before
content: ""
position: absolute
top: 50%
left: 50%
width: 40rem
height: 40rem
background: conic-gradient(from 0deg, transparent 0deg, rgba(45, 212, 191, 0.16) 90deg, transparent 180deg)
animation: glow-drift 18s linear infinite
pointer-events: none
z-index: 0
> *
position: relative
z-index: 1
animation: fade-in-up 0.6s ease-out both
h1
font-size: 2.5rem
margin: 0.5rem 0 1rem
animation-delay: 0.08s
.hero-lede
animation-delay: 0.16s
.hero-actions
animation-delay: 0.24s
.hero-badges
animation-delay: 0.32s
.hero-eyebrow
display: inline-block
color: var(--accent)
font-weight: 700
letter-spacing: 0.08em
text-transform: uppercase
font-size: 0.85rem
.hero-lede
max-width: 36rem
margin: 0 auto
color: var(--muted-color)
font-size: 1.05rem
.hero-actions
display: flex
justify-content: center
gap: 1rem
margin-top: 1.75rem
.button-link
display: inline-flex
align-items: center
gap: 0.4rem
background: var(--accent)
color: var(--bg)
font-weight: 600
border-radius: 6px
padding: 0.65rem 1.25rem
text-decoration: none
&:hover
background: var(--accent-hover)
color: var(--bg)
text-decoration: none
&.button-link--ghost
background: transparent
color: var(--text-color)
border: 1px solid var(--border-color)
&:hover
background: var(--surface)
color: var(--text-color)
.hero-badges
display: flex
flex-wrap: wrap
justify-content: center
gap: 0.5rem
margin: 1.5rem 0 0
padding: 0
list-style: none
.badge
background: var(--surface)
border: 1px solid var(--border-color)
color: var(--muted-color)
border-radius: 999px
padding: 0.3rem 0.85rem
font-size: 0.8rem
font-weight: 600
.feature-grid
display: grid
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr))
gap: 1.25rem
margin: 2.5rem 0
.feature-card
background: var(--surface)
border: 1px solid var(--border-color)
border-radius: 8px
padding: 1.25rem 1.5rem
opacity: 0
animation: fade-in-up 0.5s ease-out forwards
@for $i from 1 through 6
&:nth-child(#{$i})
animation-delay: #{0.3 + $i * 0.06}s
h2
font-size: 1.1rem
margin: 0 0 0.5rem
p
color: var(--muted-color)
margin: 0
font-size: 0.95rem
@media (prefers-reduced-motion: reduce)
.hero::before
animation: none
.hero > *, .feature-card
animation: none
opacity: 1
transform: none
.next-steps
border-top: 1px solid var(--border-color)
padding-top: 2rem
margin-top: 1rem
ul
padding-left: 1.25rem
.post-list
list-style: none
padding: 0
li
padding: 1rem 0
border-bottom: 1px solid var(--border-color)
&:last-child
border-bottom: none
h2
margin: 0 0 0.35rem
font-size: 1.15rem
p
color: var(--muted-color)
margin: 0
.footnotes
border-top: 1px solid var(--border-color)
margin-top: 2.5rem
padding-top: 1rem
font-size: 0.9rem
color: var(--muted-color)
ol
padding-left: 1.25rem
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace App;
/**
* Reusable HTTP Basic Auth gate for /admin/*. A single check, called once
* from bootstrap.php for any route under admin/, so protecting a new admin
* page (clear-cache today, anything future) needs no per-page wiring.
*
* Deliberately not a full user system — no sessions, no user table, no
* password reset. It's a stopgap until proper multi-user admin login
* (see novaconium/ISSUES.md) lands; that feature will replace this, not extend it.
*
* Note: Lib\Csrf (unrelated to login state here) does start a native PHP
* session when a form calls it — so "no sessions" above is specifically
* about this class's own login check, not a framework-wide guarantee.
*/
final class AdminAuth
{
/**
* Exits with a 401 challenge if the request isn't authenticated.
* A no-op (auth disabled) when $passwordHash is empty.
*/
public static function requireLogin(string $username, string $passwordHash): void
{
if ($passwordHash === '') {
return;
}
$providedUser = $_SERVER['PHP_AUTH_USER'] ?? null;
$providedPass = $_SERVER['PHP_AUTH_PW'] ?? null;
if (
$providedUser === $username
&& $providedPass !== null
&& password_verify($providedPass, $passwordHash)
) {
return;
}
header('WWW-Authenticate: Basic realm="Admin"');
http_response_code(401);
header('Content-Type: text/plain; charset=utf-8');
echo "401 Unauthorized\n";
exit;
}
/**
* HTTP Basic Auth has no real server-side "log out" — the browser just
* keeps resending the cached credentials. The standard workaround: always
* issue a fresh 401 challenge here regardless of what was sent, which
* makes the browser discard the credentials it had cached for this realm
* and prompt again next time /admin is visited.
*/
public static function logout(): void
{
header('WWW-Authenticate: Basic realm="Admin"');
http_response_code(401);
header('Content-Type: text/html; charset=utf-8');
echo '<p>You have been logged out. <a href="/">Return home</a>.</p>';
exit;
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace App;
/**
* Static HTML cache mirroring the public URL tree, e.g. /blog/hello
* -> public/cache/blog/hello/index.html. Apache's .htaccess serves these
* directly, bypassing PHP entirely, when present.
*/
final class Cache
{
public function __construct(private readonly string $cacheDir)
{
}
public function path(string $requestUri): string
{
$path = trim(strtok($requestUri, '?') ?: '/', '/');
$suffix = $path === '' ? '' : '/' . $path;
return rtrim($this->cacheDir, '/') . $suffix . '/index.html';
}
public function write(string $requestUri, string $html): void
{
$file = $this->path($requestUri);
$dir = dirname($file);
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
file_put_contents($file, $html);
}
/**
* Deletes every cached page, leaving the cache directory itself in place.
*/
public function clear(): void
{
$dir = rtrim($this->cacheDir, '/');
if (!is_dir($dir)) {
return;
}
$this->clearDir($dir);
}
private function clearDir(string $dir): void
{
foreach (scandir($dir) ?: [] as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$path = $dir . '/' . $entry;
if (is_dir($path)) {
$this->clearDir($path);
rmdir($path);
} else {
unlink($path);
}
}
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
namespace App;
/**
* Resolves a relative path against an ordered list of page roots (App/pages
* first, novaconium/pages as the fallback default) so a project can override
* any file — a page, a sidecar, a layout — just by placing one at the same
* relative path in App/pages, without copying the rest of the tree.
*/
final class Overlay
{
/**
* @param string[] $roots
*/
public static function isFile(array $roots, string $relative): bool
{
return self::findFile($roots, $relative) !== null;
}
/**
* @param string[] $roots
*/
public static function isDir(array $roots, string $relative): bool
{
foreach ($roots as $root) {
if (is_dir(self::join($root, $relative))) {
return true;
}
}
return false;
}
/**
* @param string[] $roots
*/
public static function findFile(array $roots, string $relative): ?string
{
foreach ($roots as $root) {
$path = self::join($root, $relative);
if (is_file($path)) {
return $path;
}
}
return null;
}
/**
* Finds a `[param]`-named subdirectory of $relative, checking each root
* in order.
*
* @param string[] $roots
*/
public static function findWildcardDir(array $roots, string $relative): ?string
{
foreach ($roots as $root) {
$dir = self::join($root, $relative);
foreach (scandir($dir) ?: [] as $entry) {
if ($entry !== '' && $entry[0] === '[' && str_ends_with($entry, ']') && is_dir($dir . '/' . $entry)) {
return $entry;
}
}
}
return null;
}
private static function join(string $root, string $relative): string
{
$root = rtrim($root, '/');
return $relative === '' ? $root : $root . '/' . $relative;
}
}
+129
View File
@@ -0,0 +1,129 @@
<?php
namespace App;
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
/**
* Renders a matched Route: runs the sidecar (if any), resolves the nearest
* upward _layout/layout.twig, renders the page's index.twig, and — for
* sidecar-less pages only — writes the output to the static HTML cache.
*
* Every lookup goes through the ordered $pagesDirs list (App/pages first,
* novaconium/pages as fallback), so a project can override any page,
* sidecar, or layout by placing one at the same relative path in App/pages.
* Twig's own FilesystemLoader natively supports multiple search paths, so
* template/layout resolution (including {% extends %}) gets this for free.
*/
final class Renderer
{
private Environment $twig;
/**
* @param string[] $pagesDirs ordered override roots, most specific first
*/
public function __construct(
private readonly array $pagesDirs,
private readonly Cache $cache,
bool $adminAuthEnabled = false,
string $matomoUrl = '',
string $matomoSiteId = '',
string $siteName = 'My Site',
) {
$loader = new FilesystemLoader($this->pagesDirs);
$this->twig = new Environment($loader, [
'cache' => false,
]);
$this->twig->addGlobal('admin_auth_enabled', $adminAuthEnabled);
$this->twig->addGlobal('matomo_url', $matomoUrl);
$this->twig->addGlobal('matomo_site_id', $matomoSiteId);
$this->twig->addGlobal('site_name', $siteName);
$this->twig->addGlobal('is_404', false);
}
public function render(Route $route, string $requestUri): void
{
$sidecarRel = $this->withFile($route->dir, 'index.php');
$sidecar = Overlay::findFile($this->pagesDirs, $sidecarRel);
$hasSidecar = $sidecar !== null;
$result = $hasSidecar ? $this->runSidecar($sidecar, $route->params) : [];
if ($result instanceof Response) {
$result->emit();
return;
}
$data = array_merge((array) $result, ['params' => $route->params]);
$data['layout'] = $this->relativeLayoutPath($route->dir);
$data['request_path'] = parse_url($requestUri, PHP_URL_PATH) ?: '/';
$templateName = $this->withFile($route->dir, 'index.twig');
$html = $this->twig->render($templateName, $data);
http_response_code(200);
header('Content-Type: text/html; charset=utf-8');
echo $html;
if (!$hasSidecar) {
$this->cache->write($requestUri, $html);
}
}
public function renderNotFound(string $requestUri): void
{
http_response_code(404);
header('Content-Type: text/html; charset=utf-8');
if (Overlay::isFile($this->pagesDirs, '404/index.twig')) {
echo $this->twig->render('404/index.twig', [
'layout' => $this->relativeLayoutPath('404'),
'request_path' => parse_url($requestUri, PHP_URL_PATH) ?: '/',
'is_404' => true,
]);
return;
}
echo '404 Not Found';
}
/**
* $cache is exposed to every sidecar's scope (e.g. to call
* $cache->clear() from an admin action) alongside $params.
*/
private function runSidecar(string $file, array $params): mixed
{
return (static function (string $__file, array $params, Cache $cache) {
return require $__file;
})($file, $params, $this->cache);
}
private function withFile(string $dir, string $file): string
{
return $dir === '' ? $file : $dir . '/' . $file;
}
/**
* Walk upward from $dir looking for the nearest _layout/layout.twig,
* falling back to the root _layout/layout.twig.
*/
private function relativeLayoutPath(string $dir): ?string
{
$current = $dir;
while (true) {
$candidate = $this->withFile($current, '_layout/layout.twig');
if (Overlay::isFile($this->pagesDirs, $candidate)) {
return $candidate;
}
if ($current === '') {
return null;
}
$slash = strrpos($current, '/');
$current = $slash === false ? '' : substr($current, 0, $slash);
}
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace App;
/**
* Returned by a page sidecar (index.php) when it needs to short-circuit
* Twig entirely — a redirect, JSON, XML, or raw HTML body.
*/
final class Response
{
private function __construct(
public readonly string $type,
public readonly mixed $body,
public readonly int $status,
public readonly array $headers,
) {
}
public static function redirect(string $url, int $status = 301): self
{
return new self('redirect', $url, $status, []);
}
public static function json(mixed $data, int $status = 200): self
{
return new self('json', $data, $status, ['Content-Type' => 'application/json']);
}
public static function xml(string $xml, int $status = 200): self
{
return new self('xml', $xml, $status, ['Content-Type' => 'application/xml']);
}
public static function html(string $html, int $status = 200): self
{
return new self('html', $html, $status, ['Content-Type' => 'text/html']);
}
public function emit(): void
{
http_response_code($this->status);
foreach ($this->headers as $name => $value) {
header("$name: $value");
}
if ($this->type === 'redirect') {
header('Location: ' . $this->body);
return;
}
if ($this->type === 'json') {
echo json_encode($this->body, JSON_THROW_ON_ERROR);
return;
}
echo $this->body;
}
}

Some files were not shown because too many files have changed in this diff Show More