Compare commits
64 Commits
1.0.2
..
b37b13120e
| Author | SHA1 | Date | |
|---|---|---|---|
| b37b13120e | |||
| b882c304b1 | |||
| cb64836901 | |||
| 74c0d59019 | |||
| a51faa7208 | |||
| 37b6764431 | |||
| 4862526fa1 | |||
| 5deb298b91 | |||
| 3a59269eaa | |||
| a3b996719a | |||
| 672f997d8b | |||
| fe5f5ee131 | |||
| fbd4e92e9f | |||
| d9c4b64d9c | |||
| 27c15f747a | |||
| d5871ed8e7 | |||
| ad6e07c76d | |||
| 06310d9eb9 | |||
| f679d0b20e | |||
| 9feccf9eaa | |||
| 14ec6b7e7a | |||
| 42a828a778 | |||
| 81c239f043 | |||
| 0d91b61bd0 | |||
| fd9a71c4d6 | |||
| 71413283c8 | |||
| a32956b4a2 | |||
| 7bf3dc6610 | |||
| 7b064eb6da | |||
| 934e134941 | |||
| 6f410b5d0e | |||
| e5aadb3b82 | |||
| 08b8009dec | |||
| 208534b5fb | |||
| 466d34c39f | |||
| a14df54cd9 | |||
| bba62180fe | |||
| 4c598340a8 | |||
| 6d7a7a5e9d | |||
| 1cdf4f1fe8 | |||
| 12783d351c | |||
| 39a14a759b | |||
| 869c3a8d6a | |||
| a459b86169 | |||
| fb5407a60b | |||
| 344786ee95 | |||
| 892110703b | |||
| 2f76c1ae35 | |||
| 4aebef12c8 | |||
| 2021ada52b | |||
| caca552cae | |||
| 8f462953b7 | |||
| 20d01d0d4c | |||
| 7b02960b46 | |||
| bedf615ad3 | |||
| 5d2281b713 | |||
| 7e877465a6 | |||
| 7360c279ae | |||
| 641fdb17c5 | |||
| 45e10dcacd | |||
| 28513d367d | |||
| 0c41ca9b65 | |||
| d183c4c1e0 | |||
| f76bbfb27c |
@@ -0,0 +1,8 @@
|
||||
/public/cache/*
|
||||
!/public/cache/.gitkeep
|
||||
/novaconium/contact-log.txt
|
||||
/data/*.sqlite
|
||||
/data/*.sqlite-journal
|
||||
/data/*.sqlite-wal
|
||||
/data/*.sqlite-shm
|
||||
.claude/
|
||||
@@ -0,0 +1,545 @@
|
||||
# AGENTS.md
|
||||
|
||||
Context for any coding agent working in this repo — Claude, DeepSeek, or
|
||||
otherwise; this file (and the maintenance rule below) applies regardless of
|
||||
which model or CLI is driving. Full narrative docs live at `/admin/docs`
|
||||
when the app is running (also the *only* place Twig upgrade instructions
|
||||
live now — see `/admin/docs/upgrading-twig`; there's no separate
|
||||
MAINTENANCE.md, keeping one copy in the docs page avoids drift). `README.md`
|
||||
is the GitHub-facing pitch, `novaconium/ISSUES.md` is the roadmap/backlog,
|
||||
and this file is the short, agent-facing version. The original design
|
||||
rationale used to live in a standalone `plan.md`; it's now folded into
|
||||
`/admin/docs/design-notes` (everything in it shipped) and the file was
|
||||
deleted. There used to also be a
|
||||
`GUIDE.md` mirroring `/admin/docs` for offline reading — it was removed to
|
||||
cut a doc copy that had to be kept in sync; `/admin/docs` is the only
|
||||
narrative reference now.
|
||||
|
||||
## Documentation is duplicated on purpose — keep all copies in sync
|
||||
|
||||
Every topic (routing, sidecars, libraries, layouts, caching, SEO, Matomo,
|
||||
admin authentication, styling, project layout, third-party) exists in
|
||||
**two** places: a page under `novaconium/pages/admin/docs/<topic>/index.twig`
|
||||
(the canonical reference), and (for anything a README-reading human needs
|
||||
up front) a mention in `README.md`. This is intentional — `/admin/docs` is
|
||||
for reading against a running instance with no internet needed, and
|
||||
`README.md` is the GitHub-facing pitch — but it means **any agent that
|
||||
changes framework behavior or adds a feature must update both copies in
|
||||
the same change**, not just the one that was open. Concretely, after
|
||||
touching routing/rendering/caching/SEO behavior or adding a new top-level
|
||||
docs topic:
|
||||
|
||||
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).
|
||||
|
||||
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_auth_enabled']` (default `false`) gates every `/admin/*`
|
||||
route behind a session login against the `users` table on `Lib\Db`'s
|
||||
default connection (`novaconium/migrations/0002_create_users.sql`) — the
|
||||
multi-user system from `novaconium/ISSUES.md`'s "Admin login & user
|
||||
management" entry, which **replaced** the old single-user HTTP Basic Auth
|
||||
stopgap (the `admin_username`/`admin_password_hash` config keys and the
|
||||
`/admin/password-hash` page are gone; that stopgap had itself replaced
|
||||
the even older `docs_enabled` flag). Same off-by-default posture as
|
||||
`content_index_enabled`, for the same reason: it depends on SQLite, so
|
||||
when the flag is false, `/admin/*` is wide open, the three auth routes
|
||||
(`/admin/login`, `/admin/logout`, `/admin/users`) 404 as if they didn't
|
||||
exist, their sidecars check the flag (via the same self-loaded two-step
|
||||
config read `/search` uses) *before* touching `Lib\Db`, and no
|
||||
`data/novaconium.sqlite` is ever created by this feature — verified
|
||||
end-to-end. **Two roles** (`users.role`): the **first user ever created
|
||||
is `'admin'`; everyone created after is `'registered'`**, each with an
|
||||
optional single group (`users.user_group`, a plain text label — no
|
||||
groups table). Admins run the site: `/admin/*`, drafts, and every
|
||||
`Lib\Access` rule passes for them. Registered users log in at the same
|
||||
`/admin/login` and see whatever content `Lib\Access` (below) grants
|
||||
their account or group — but `/admin/*` renders a plain 404 for them
|
||||
(they're authenticated; what they lack is the role, so bouncing them to
|
||||
the login form would be wrong). Unlike the Twig-global pattern above,
|
||||
the gate itself is enforced in `bootstrap.php`, before rendering, in two
|
||||
steps: `AdminAuth::requireLogin($config['admin_auth_enabled'])`
|
||||
(`novaconium/src/AdminAuth.php`) redirects anyone not logged in, then
|
||||
`AdminAuth::isAdmin(...)` 404s logged-in non-admins — for any resolved
|
||||
route whose path is `admin` or starts with `admin/`, **except
|
||||
`admin/login` itself, which must stay reachable logged-out or the
|
||||
redirect to it would loop.** **Any new admin page dropped under
|
||||
`App/pages/admin/` or `novaconium/pages/admin/` is automatically
|
||||
protected — no per-page wiring needed.** Bootstrapping the first user:
|
||||
while the `users` table is empty, the gate deliberately returns open
|
||||
access so the first user (the admin) can be created at `/admin/users`
|
||||
(which auto-logs its creator in, closing the gate), mirroring the old
|
||||
"wide open until a password is configured" posture;
|
||||
`novaconium/bin/create-admin-user.php` creates an admin from the CLI
|
||||
instead (password via stdin), which lets a deploy create the user
|
||||
*before* flipping the flag so the open window never exists — it's also
|
||||
the lockout-recovery path (usage: `<username> <email>`, password via
|
||||
stdin). Every account has a **unique email address** (`users.email`),
|
||||
stored normalized via `Lib\Validate::isEmail()` (trim + lowercase) so
|
||||
the planned email-verification feature (see `novaconium/ISSUES.md`
|
||||
Backlog) can match case-insensitively — not used for login (username)
|
||||
or any mail yet. `/admin/users` is the management UI (create — always
|
||||
`'registered'` except the first / disable / enable / delete / change
|
||||
group / promote-demote / change email / change password); a disabled
|
||||
**or deleted** user fails login and any existing session dies on its
|
||||
next request (`currentUser()` re-checks the row per request), and
|
||||
disabling, demoting, *or deleting* the last **active admin** is refused
|
||||
— the empty-table setup window doesn't reopen once users exist, so that
|
||||
would be a permanent lockout. Delete is a hard delete (username/email
|
||||
become reusable); disable is the keep-but-shut-out option. `/admin/login` accepts a
|
||||
`?return=` path (how `Lib\Access` sends someone back to the gated page
|
||||
after login), validated to a local path (must start `/`, not `//`, no
|
||||
`\`) so a crafted login link can't bounce a fresh login to another
|
||||
site; with no return path, admins land on `/admin`, registered users on
|
||||
`/`. Login
|
||||
regenerates the session id (`Lib\Session::regenerate()`, added for this)
|
||||
against session fixation; logout is a real page now — the pre-router
|
||||
`/admin/logout` special case in `bootstrap.php` is gone — and it is
|
||||
**POST-only with a GET confirm form** (same shape as
|
||||
`/admin/clear-cache`), not logout-on-GET: the content-index crawl runs
|
||||
every page's sidecar as a GET, so a GET side effect there would end the
|
||||
crawling admin's own session mid-reindex. Passwords are read from `$_POST` directly, not
|
||||
`Lib\Input` (the documented exact-value exception — see `Lib\Input`'s
|
||||
doc-comment, which now points at the login/users sidecars). `Renderer`
|
||||
still exposes the `admin_auth_enabled` Twig global (now mirroring the
|
||||
config flag) so `admin/index.twig` can conditionally show the
|
||||
"Admin users"/"Logout" links — a derived display flag, not the
|
||||
enforcement mechanism itself, which never depends on Twig.
|
||||
|
||||
`Lib\Db` (`novaconium/lib/Db.php`) is the SQLite/MySQL groundwork tracked
|
||||
in `novaconium/ISSUES.md` — a thin, no-ORM PDO wrapper, `Lib\` (not `App\`)
|
||||
so a project can override it via `App/lib/Db.php` like any other `Lib\`
|
||||
class. It supports multiple, independently-configured, **simultaneously
|
||||
open** named connections (`config['db_connections']`, keyed by name) rather
|
||||
than one global connection — because sidecars are plain PHP with full
|
||||
access to any `Lib\` class, a single request can legitimately need more
|
||||
than one database at once (e.g. this site's own SQLite data alongside a
|
||||
MySQL connection to a legacy database). `Db::query(string $sql, array
|
||||
$params = [], string $connection = 'default')` (prepare+execute) is the
|
||||
only query-running helper — never add a string-interpolation shortcut; see
|
||||
`Lib\Input`'s doc-comment, which already commits this project to
|
||||
parameterized queries as the sole SQL-injection defense. Each connection is
|
||||
lazy and independent (opened on first `Db::query()`/`Db::connection()` call
|
||||
naming it, same shape as `Lib\Csrf`'s lazy session start), and migrates
|
||||
automatically at that point: plain `.sql` files under that connection's own
|
||||
`migrations_dir` — a single path, or (since the content index shipped) an
|
||||
**ordered list of roots**, each root's own files filename-ordered and
|
||||
roots processed fully in the order given, not interleaved by filename
|
||||
across roots (so a framework root always finishes before a project root on
|
||||
the same connection). Tracked by path **relative to the repo root** (e.g.
|
||||
`novaconium/migrations/0001_x.sql`), not bare filename — two roots can
|
||||
each contain a same-named file, and tracking by bare filename would make
|
||||
the second one seen look "already applied" and silently skip it; a
|
||||
repo-relative path is also portable across environments, unlike a full
|
||||
absolute path, which would make every migration look new again after a
|
||||
clone/deploy to a different directory. `realpath()` normalizes any `..` a
|
||||
`migrations_dir` like `__DIR__ . '/../App/migrations'` would otherwise
|
||||
leave in the tracked name. Each connection's own auto-created
|
||||
`schema_migrations` table tracks its migrations independently of any other
|
||||
connection's; each is only ever run once. `novaconium/bin/migrate.php`
|
||||
loops every configured connection and runs the same migration step
|
||||
explicitly (e.g. from a deploy script) without serving a request first.
|
||||
Only `'sqlite'` and `'mysql'` drivers are implemented; a connection's
|
||||
`migrations_dir` is optional — omit it to never run migrations against
|
||||
that connection (e.g. a legacy database this project shouldn't manage
|
||||
schema for).
|
||||
|
||||
**`db_connections` is the one config key in the project that isn't plain
|
||||
shallow-merge** — `bootstrap.php`/`bin/*.php`'s usual `array_merge($config,
|
||||
$appConfig)` would let a project's `App/config.php` silently delete the
|
||||
framework's `default` connection just by adding a second named connection
|
||||
(a shallow merge replaces the whole key, it doesn't merge inside it). So
|
||||
`Lib\Db::config()` (and the copy of this logic duplicated in
|
||||
`bin/migrate.php`, same duplication precedent as the two-step config load
|
||||
already duplicated across `bootstrap.php`/`bin/clear-cache.php`) merges
|
||||
`db_connections` one level deeper, by connection name, **after** capturing
|
||||
the framework defaults — capture the defaults *before* the top-level
|
||||
`array_merge()` overwrites `$config['db_connections']`, not after, or the
|
||||
deeper merge silently operates on the already-overwritten value and the
|
||||
`default` connection vanishes anyway. (This exact bug was hit once while
|
||||
building this feature — verified by testing a real `App/config.php`
|
||||
override end-to-end, not just reading the code — so it's worth re-checking
|
||||
by hand if this logic is ever touched again.) See
|
||||
`/admin/docs/database` for the worked example.
|
||||
|
||||
**`config['db_connections']['default']['path']` must stay outside both
|
||||
`public/` (would be web-accessible) and `novaconium/`** — unlike
|
||||
`cache_dir`/`contact-log.txt`, which are disposable, a SQLite file is data a
|
||||
project can't afford to lose, and `novaconium/` gets wholesale-replaced by
|
||||
the "Updating the framework" workflow (`/admin/docs/getting-started`: `rm
|
||||
-rf novaconium && cp -r <new-novaconium>`). The default
|
||||
(`data/novaconium.sqlite`) lives in a new top-level `data/` directory
|
||||
instead — project-owned like `App/`, gitignored per-file
|
||||
(`*.sqlite`/`-journal`/`-wal`/`-shm`, with a tracked `.gitkeep` so the
|
||||
directory exists in a fresh clone) rather than wholesale like
|
||||
`public/cache/`, since a project might reasonably want other non-DB files
|
||||
there later. The default connection's `migrations_dir` scans
|
||||
`novaconium/migrations/` (framework-shipped schema, e.g. the content
|
||||
index below) before `App/migrations/` (project schema) — see the
|
||||
`migrations_dir` array-form paragraph above. Any *other* connection a
|
||||
project adds still defaults to no `migrations_dir` at all unless it sets
|
||||
one; the two-root default is specific to `default`.
|
||||
|
||||
`Lib\Session` (`novaconium/lib/Session.php`) is a thin wrapper around
|
||||
native PHP sessions (`session_start()`/`$_SESSION`, not a custom store),
|
||||
all-static and lazy-start like `Lib\Csrf` — nothing calls `session_start()`
|
||||
until the first real call to a `Session` method. Its `ensureSession()` is a
|
||||
**deliberate duplicate** of `Csrf::ensureSession()` (same cookie params,
|
||||
same `session_status()` guard) rather than a shared helper — keeps `Csrf`
|
||||
standalone with zero new dependencies on a class that didn't exist when it
|
||||
shipped, same tolerance for small duplication already established by the
|
||||
config-load block duplicated across `bootstrap.php`/`bin/clear-cache.php`/
|
||||
`Lib\Db::config()`. Both classes touching the same native session in the
|
||||
same request is safe either way, since `session_start()` silently no-ops
|
||||
if a session is already active — there's no ordering requirement between
|
||||
`Csrf::token()`/`::verify()` and any `Session` method.
|
||||
|
||||
Flash data (`Session::flash()`/`::getFlash()`) is one swap, not a
|
||||
sweep/expiry pass: the first `Session` method call in a request snapshots
|
||||
whatever was flashed on the *previous* request into an in-memory static
|
||||
(`self::$currentFlash`) for that request's `getFlash()` reads, then
|
||||
immediately empties the stored flash bucket so `flash()` calls made
|
||||
*during* the current request start filling a fresh bucket for the request
|
||||
after this one. This relies on static properties not persisting across
|
||||
requests (true under `php -S`, mod_php, and PHP-FPM alike — each request
|
||||
gets fresh PHP state regardless of worker-process reuse) — don't add any
|
||||
caching/memoization to `Session` that assumes static state survives
|
||||
between requests, since none of it does. See `/admin/docs/session` for a
|
||||
worked flash example.
|
||||
|
||||
**Standing rule: any mechanism that conditionally hides page content from
|
||||
the public must also be threaded into `Renderer::render()`'s
|
||||
`$excludeFromCache` decision, not just a pre-render auth gate.** This
|
||||
bit twice already — once as a designed-around gotcha (draft pages), once
|
||||
as a real pre-existing bug found while testing that feature (`/admin/*`
|
||||
itself). The reason: `Renderer::render()` writes a sidecar-less page's
|
||||
output to the static HTML cache (`novaconium/src/Cache.php`), and
|
||||
`.htaccess` serves a cached file *before PHP, and therefore any auth
|
||||
check, ever runs again* (see `/admin/docs/caching`). A route can be
|
||||
gated by `AdminAuth::requireLogin()`/`::isAuthenticated()` and still leak
|
||||
completely to the public the moment it's viewed once by someone
|
||||
authorized, if the page has no sidecar and nothing tells `Renderer` to
|
||||
skip the cache write for that route. `draft_routes` (see
|
||||
`/admin/docs/drafts`, `novaconium/config.php`) and every `/admin/*` route
|
||||
both pass `true` for `Renderer::render()`'s `$excludeFromCache` param
|
||||
from `novaconium/bootstrap.php` for exactly this reason — most pages
|
||||
under `novaconium/pages/admin/` (e.g. `admin/index.twig`) have no
|
||||
sidecar, so before this was wired up, visiting `/admin` once as an
|
||||
authenticated admin would cache the admin panel and serve it to every
|
||||
subsequent visitor, unauthenticated, straight from `public/cache/admin/`.
|
||||
Any future feature that gates a route by anything other than a sidecar
|
||||
check (paywall content is the next one on the roadmap likely to hit this)
|
||||
needs to make the same check here, not just at the point where the
|
||||
request is first authorized.
|
||||
|
||||
`AdminAuth::isAdmin(bool $enabled): bool` / `::isLoggedIn(bool
|
||||
$enabled): bool` (`novaconium/src/AdminAuth.php`) are the access checks
|
||||
on their own, with no response side effects — `requireLogin()` is
|
||||
`isLoggedIn()` plus a 303 redirect to `/admin/login` on failure, and a
|
||||
different caller can react to failure differently. The draft-page gate
|
||||
in `bootstrap.php` is the first such caller, and it uses `isAdmin()`
|
||||
(drafts are admin-only — a logged-in registered user gets the same 404
|
||||
as an anonymous visitor): on failure it renders a plain 404 via the same
|
||||
path an unmatched route takes, not a login redirect — bouncing to a
|
||||
login at a draft URL would itself reveal that something is gated there,
|
||||
which defeats the point of hiding it. Both return `true` (open access)
|
||||
when `$enabled` is false or while the `users` table is empty, mirroring
|
||||
`requireLogin()`'s posture, so a draft behaves consistently with the
|
||||
rest of `/admin/*`: wide open until the feature is enabled and a first
|
||||
user exists, gated after that.
|
||||
|
||||
`Lib\Access` (`novaconium/lib/Access.php`) is the sidecar-level content
|
||||
gate — how a page (or a section, one line per page; a shared
|
||||
`_access.php` in the section directory is the documented pattern, since
|
||||
non-`index.*` files are invisible to the router) is assigned to a user
|
||||
or group: `Access::require('group:members', 'user:bob')` returns `null`
|
||||
(allowed — no rules at all means any logged-in user, and **admins pass
|
||||
every rule**) or a `Response` the sidecar returns as-is (anonymous → 303
|
||||
to `/admin/login?return=<path>`; logged-in-but-not-allowed → plain-text
|
||||
404, same hide-don't-tease posture as drafts). See
|
||||
`/admin/docs/access-control`. **Public is the default, and static pages
|
||||
are always public**: a page with no sidecar can't call `Access` — and
|
||||
that's load-bearing, since only sidecar-less pages are written to the
|
||||
static HTML cache; a gated page necessarily has a sidecar, so it can
|
||||
never leak through the cache — the caching/auth standing rule below is
|
||||
satisfied by construction, with no bootstrap exclusion needed. Same
|
||||
open-until-configured / zero-DB-footprint posture as the rest of admin
|
||||
auth when the flag is off or no users exist. Deliberately **no side
|
||||
effects on deny** (the return path travels in the redirect URL, not the
|
||||
session): the content-index crawl runs every sidecar as an anonymous
|
||||
GET, so gated pages drop out of `/search`/`/sitemap.xml` automatically
|
||||
(the crawler discards `Response`s) and a crawl must never scribble on
|
||||
the visiting user's session — the same reasoning that made
|
||||
`/admin/logout` POST-only.
|
||||
|
||||
`App\ContentIndexer` (`novaconium/src/ContentIndexer.php`) is the shared
|
||||
crawler behind `/sitemap.xml`, `/search`, and blog tag browsing (see
|
||||
`/admin/docs/content-index`) — `App\`, not `Lib\`, since it's rendering
|
||||
infrastructure akin to `Renderer`/`Router`, not project-overridable
|
||||
content. Content stays in files; only metadata is indexed. Per-page
|
||||
metadata is four Twig blocks declared in the root layout next to the SEO
|
||||
blocks (`keywords`, `tags`, `changefreq`, `priority` — the last three
|
||||
never rendered into the page, only harvested) and pulled via
|
||||
`Renderer::renderForIndex()`, which calls Twig's own
|
||||
`TemplateWrapper::renderBlock()` per block rather than parsing `.twig`
|
||||
source — this is deliberate: it gets App-over-novaconium override and
|
||||
layout-inheritance resolution for free, the same way a real render does.
|
||||
**`config['content_index_enabled']` defaults to `false`** — same posture
|
||||
as `matomo_url`/`admin_password_hash`, since this is a real SQLite
|
||||
dependency plenty of sites won't want. Every consumer route checks the
|
||||
flag *before* touching `Lib\Db` and 404s if it's off, so the feature has
|
||||
zero filesystem footprint (no `data/novaconium.sqlite`) when disabled —
|
||||
verified end-to-end, not assumed, since "off" silently still creating a
|
||||
database file would defeat the point.
|
||||
|
||||
**Reentrancy hazard, already hit once:** `ContentIndexer::reindex()`
|
||||
renders every routable page as part of the crawl — including `/search`
|
||||
itself, which is also a real page and also calls
|
||||
`ContentIndexer::ensureFresh()` from its own sidecar. Without a guard,
|
||||
crawling `/search` would trigger a nested `reindex()` call mid-transaction
|
||||
and fatal on a second `PDO::beginTransaction()`. `ContentIndexer` guards
|
||||
this with a `private static bool $indexing` flag, checked at the top of
|
||||
both `ensureFresh()` and `reindex()` — either no-ops while a reindex is
|
||||
already running on the call stack. Any future consumer route added under
|
||||
this mechanism inherits the same hazard for free (it'll also get crawled,
|
||||
and if its sidecar also calls `ensureFresh()`, the guard already covers
|
||||
it) — don't remove the flag thinking it's unnecessary.
|
||||
|
||||
`reindex()` also forces `$_SERVER['REQUEST_METHOD']` to `'GET'` for the
|
||||
duration of the crawl (restoring whatever it was before, in a `finally`)
|
||||
— sidecars are expected to be side-effect-free for non-POST requests
|
||||
anyway (ordinary HTTP-safe-method hygiene), but this guarantees a lazy
|
||||
reindex triggered from within a POST request can never leak that POST
|
||||
into an unrelated page's sidecar purely because the crawler happened to
|
||||
render it. A crawl is a full truncate-and-rebuild inside one transaction,
|
||||
not incremental — simple and correct at this site's scale; don't add
|
||||
incremental/diffing logic without a real need for it.
|
||||
|
||||
**Standing rule: a vendored dependency's files go under `novaconium/vendor/`
|
||||
only if they're server-side (PHP, autoloaded, never fetched by a browser)
|
||||
— anything the browser has to fetch (`.js`, `.css`, images) has to live
|
||||
under `public/vendor/` instead, since `public/` is the only web-reachable
|
||||
directory (`novaconium/` isn't reachable at all — see `public/.htaccess`).**
|
||||
Twig lives under `novaconium/vendor/twig/` correctly, since it's pure PHP
|
||||
source. highlight.js (`/admin/docs/upgrading-highlightjs`,
|
||||
`public/vendor/highlightjs/`) is the first vendored dependency that's
|
||||
actually browser-servable, and originally almost got vendored to
|
||||
`novaconium/vendor/` too, following Twig's precedent blindly — that would
|
||||
have silently 404ed on every request, since nothing under `novaconium/`
|
||||
is ever served to a browser. This has a real consequence beyond just
|
||||
placement: `public/` is project-owned and untouched by the "Updating the
|
||||
framework" workflow (`rm -rf novaconium && cp -r <new-novaconium>` — see
|
||||
`/admin/docs/getting-started`), so a future framework release that bumps
|
||||
a `public/vendor/`-placed dependency will **not** carry that upgrade to
|
||||
an existing project automatically the way a `novaconium/vendor/` bump
|
||||
would — re-vendoring it is a separate manual step every time, documented
|
||||
per-dependency (see `/admin/docs/upgrading-highlightjs`).
|
||||
|
||||
**`class="nohighlight"` marks a `<pre><code>` block whose content is
|
||||
literal Twig template syntax** (`{% %}`/`{{ }}`), so highlight.js's
|
||||
auto-detection (`novaconium/pages/_layout/syntax-highlight.twig`,
|
||||
restricted to `configure({ languages: ['php', 'bash', 'xml', 'css',
|
||||
'python', 'javascript', 'yaml', 'json', 'ini'] })` — `yaml`/`json`/`ini`
|
||||
are vendored as separate per-language files under
|
||||
`public/vendor/highlightjs/languages/`, not part of the core bundle like
|
||||
the other six; see `/admin/docs/upgrading-highlightjs`) doesn't
|
||||
force-match it to whichever configured language scores highest — Twig has
|
||||
no highlight.js grammar, and a restricted auto-detect still always
|
||||
returns its best guess among the allowed set, never "give up," so an
|
||||
unmarked Twig block would get colored *wrong*, not just left plain.
|
||||
Currently on:
|
||||
`novaconium/pages/admin/docs/{layouts,content-index,rss,sitemap,forms,seo}/index.twig`
|
||||
and `App/pages/blog/{style-guide,twig-syntax-guide}/index.twig`. A new
|
||||
Twig-syntax code sample added anywhere needs the same class — a PHP or
|
||||
Bash sample doesn't (auto-detection handles those reliably on its own,
|
||||
via strong signals like a leading `<?php`).
|
||||
|
||||
## Running it
|
||||
|
||||
```
|
||||
php -S 127.0.0.1:8000 -t public public/router.php
|
||||
```
|
||||
|
||||
`public/router.php` is dev-only, mimics `public/.htaccess`. There is no
|
||||
test suite — verification is manual route-by-route (see
|
||||
`/admin/docs/design-notes`'s Verification section for the checklist used
|
||||
after any framework change).
|
||||
After testing, clear stray cache with `php novaconium/bin/clear-cache.php` or
|
||||
POST `/admin/clear-cache`, and remove anything written to `App/lib/` or
|
||||
`App/pages/` that was only for testing an override — nothing here is
|
||||
gitignored except `public/cache/*` and `novaconium/contact-log.txt`, so
|
||||
test debris left in `App/` will otherwise get committed or silently change
|
||||
site behavior for the next person.
|
||||
|
||||
## Conventions worth knowing
|
||||
|
||||
- Reserved segments: any path segment starting with `_` (e.g. `_layout/`)
|
||||
or literally named `404` is never routable — `Router::resolve()` 404s on
|
||||
sight, don't try to serve content directly at those paths.
|
||||
- Sidecars (`index.php`) return either an array (Twig context) or a
|
||||
`Response` (redirect/json/xml/html — `novaconium/src/Response.php`).
|
||||
`$params` (route captures) and `$cache` (the `Cache` instance, e.g. for
|
||||
`$cache->clear()`) are both in scope automatically — see
|
||||
`novaconium/src/Renderer.php::runSidecar()`.
|
||||
- No Composer — `novaconium/autoload.php` is a hand-rolled PSR-4 loader.
|
||||
Adding a new framework-core class means adding it under `App\` in
|
||||
`novaconium/src/`; a new `Lib\` class goes in `App/lib/` or
|
||||
`novaconium/lib/` depending on whether it's project- or
|
||||
framework-specific.
|
||||
- `novaconium/bin/` holds standalone CLI entry points meant to be run
|
||||
directly (`php novaconium/bin/<script>.php`) — distinct from
|
||||
`bootstrap.php`/`autoload.php`/`config.php`, which are only ever
|
||||
`require`'d, never invoked directly. `clear-cache.php` and
|
||||
`create-static-page.php` (scaffolds a new page from the `/admin/docs/seo`
|
||||
starter template) both live there; a new CLI tool goes there too.
|
||||
- CSS is compiled from `novaconium/sass/main.sass` (indented syntax) to
|
||||
`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 or verified) should read `$_POST` directly instead — see the
|
||||
password fields in `novaconium/pages/admin/users/index.php` and
|
||||
`novaconium/pages/admin/login/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 was the first thing in the framework to start a native PHP session
|
||||
(only lazily, when a form actually calls it); `Lib\Session` and
|
||||
`AdminAuth`'s session login now share that same native session, safely
|
||||
in any order.
|
||||
- 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.
|
||||
- Same class of bug, different filter: don't use Twig's `|escape('js')` (or
|
||||
the `'js'` arg to `|e`) either — it calls `Twig\Runtime\mb_ord()`
|
||||
(`novaconium/vendor/twig/src/Extension/EscaperExtension.php`), which
|
||||
hard-requires `mbstring` the same way `|slice` does, and fatals
|
||||
identically without it. Hit for real when
|
||||
`novaconium/pages/_layout/code-copy.twig` used it to pass SVG icon
|
||||
markup into an inline `<script>` as a JS string literal. Fixed by not
|
||||
needing string-escaped markup in JS at all: render the markup as plain
|
||||
HTML into a `<template>` element (default autoescaping, no mbstring
|
||||
dependency) and read it in JS via that template element's `.innerHTML`
|
||||
getter instead. Prefer that pattern — or a `data-*` attribute if the
|
||||
value is plain text, not markup — over `|escape('js')` any time a Twig
|
||||
value needs to reach JS.
|
||||
@@ -0,0 +1,54 @@
|
||||
<?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 — session login for /admin/* against
|
||||
// the SQLite-backed users table. After enabling, create the first user
|
||||
// at /admin/users, or beforehand (safer) with:
|
||||
// php novaconium/bin/create-admin-user.php <username>
|
||||
// 'admin_auth_enabled' => true,
|
||||
|
||||
// Docs: /admin/docs/database — adds (or overrides) named Lib\Db
|
||||
// connections. This merges into db_connections by name rather than
|
||||
// replacing the whole map, so adding 'legacy' here doesn't require
|
||||
// repeating 'default' — see Lib\Db::config().
|
||||
// 'db_connections' => [
|
||||
// 'legacy' => [
|
||||
// 'driver' => 'mysql',
|
||||
// 'host' => 'localhost',
|
||||
// 'port' => 3306,
|
||||
// 'database' => 'legacy_app',
|
||||
// 'username' => 'root',
|
||||
// 'password' => '...',
|
||||
// 'charset' => 'utf8mb4', // optional, defaults to utf8mb4
|
||||
// 'migrations_dir' => __DIR__ . '/migrations/legacy', // optional
|
||||
// ],
|
||||
// ],
|
||||
|
||||
// Docs: /admin/docs/drafts — requires admin_auth_enabled above (and at
|
||||
// least one user) to actually gate anything; open access otherwise,
|
||||
// same as the rest of /admin/*.
|
||||
// 'draft_routes' => ['blog/upcoming-post'],
|
||||
|
||||
// Docs: /admin/docs/content-index — powers /sitemap.xml, /search, and
|
||||
// blog tag browsing. Off by default (depends on SQLite); enable with:
|
||||
// 'content_index_enabled' => true,
|
||||
//
|
||||
// Or enable but skip the automatic lazy reindex, relying only on
|
||||
// `php novaconium/bin/index-content.php` (e.g. from a deploy step):
|
||||
// 'content_index_enabled' => true,
|
||||
// 'content_index_auto' => false,
|
||||
];
|
||||
@@ -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 a multi-user admin login (with browser-based user management) covering 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 %}
|
||||
@@ -0,0 +1,22 @@
|
||||
{% extends '_layout/layout.twig' %}
|
||||
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
{# Feed auto-discovery — only shows up on /blog/* pages, since only this
|
||||
layout overrides the root layout's empty head_extra block. See
|
||||
App/pages/blog/feed/index.php. #}
|
||||
{% block head_extra %}
|
||||
<link rel="alternate" type="application/rss+xml" title="{{ site_name }} Blog" href="/blog/feed">
|
||||
{% endblock %}
|
||||
|
||||
{% 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 %}
|
||||
@@ -0,0 +1,90 @@
|
||||
{% extends layout %}
|
||||
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
{% block title %}Code Highlighting{% endblock %}
|
||||
{% block description %}How syntax highlighting works on this site, with worked examples in bash, HTML, CSS, YAML, Python, JavaScript, JSON, and INI/env.{% endblock %}
|
||||
|
||||
{% block robots %}index, follow{% endblock %}
|
||||
{% block tags %}highlighting, reference{% 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>Code Highlighting</h1>
|
||||
|
||||
<p>Every <code><pre><code></code> block on this site gets colored automatically via vendored <a href="https://highlightjs.org/">highlight.js</a> — see <a class="icon-link" href="/admin/docs/styling">{{ icons.book() }}Styling</a> for the mechanism and <a class="icon-link" href="/admin/docs/upgrading-highlightjs">{{ icons.book() }}Upgrading highlight.js</a> for what's vendored. This post is a plain reference: how to write a code block, and one worked example in each language this site highlights.</p>
|
||||
|
||||
<h2>How to use it</h2>
|
||||
|
||||
<p>Write a normal code block — nothing extra required, the language is auto-detected:</p>
|
||||
|
||||
<pre><code class="nohighlight"><pre><code>your code here</code></pre></code></pre>
|
||||
|
||||
<p>Auto-detection is restricted to the languages this site actually uses (see <code>hljs.configure(...)</code> in <code>novaconium/pages/_layout/syntax-highlight.twig</code>) so it doesn't misfire trying to match dozens of unrelated bundled languages against a short snippet. If a snippet is ambiguous, or ever gets detected as the wrong language, force it with an explicit <code>language-<name></code> class instead of relying on auto-detection:</p>
|
||||
|
||||
<pre><code class="nohighlight"><pre><code class="language-yaml">your code here</code></pre></code></pre>
|
||||
|
||||
<p>A code block written in Twig template syntax (<code>{{ '{%' }} ... {{ '%}' }}</code>, <code>{{ '{{' }} ... {{ '}}' }}</code>) has no highlight.js grammar to match — mark those <code>class="nohighlight"</code> instead, the same way the two snippets above are marked (they're showing literal HTML as plain text, not being highlighted as HTML themselves). See <a class="icon-link" href="/blog/twig-syntax-guide">{{ icons.book() }}the Twig Syntax Guide</a> for Twig's own syntax, written the same way.</p>
|
||||
|
||||
<h2>Bash</h2>
|
||||
<pre><code>#!/bin/bash
|
||||
for f in App/pages/blog/*/index.twig; do
|
||||
echo "Post: $f"
|
||||
done</code></pre>
|
||||
|
||||
<h2>HTML</h2>
|
||||
<pre><code><article class="post">
|
||||
<h1>Hello, World!</h1>
|
||||
<p>A short excerpt.</p>
|
||||
</article></code></pre>
|
||||
|
||||
<h2>CSS</h2>
|
||||
<pre><code>.post-list li {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 0.75rem 0;
|
||||
}</code></pre>
|
||||
|
||||
<h2>YAML</h2>
|
||||
<pre><code>site:
|
||||
name: Novaconium Website
|
||||
theme: dark
|
||||
tags:
|
||||
- php
|
||||
- twig
|
||||
- highlighting</code></pre>
|
||||
|
||||
<h2>Python</h2>
|
||||
<pre><code>def excerpt(text, length=140):
|
||||
return text[:length].rsplit(" ", 1)[0] + "..."</code></pre>
|
||||
|
||||
<h2>JavaScript</h2>
|
||||
<pre><code>function toggleTheme() {
|
||||
const html = document.documentElement;
|
||||
const next = html.dataset.theme === "light" ? "dark" : "light";
|
||||
html.setAttribute("data-theme", next);
|
||||
}</code></pre>
|
||||
|
||||
<h2>JSON</h2>
|
||||
<pre><code>{
|
||||
"title": "Code Highlighting",
|
||||
"tags": ["highlighting", "reference"],
|
||||
"published": true
|
||||
}</code></pre>
|
||||
|
||||
<h2>INI / .env</h2>
|
||||
<pre><code>; App/config.php equivalent, .ini-style
|
||||
[matomo]
|
||||
url = https://matomo.example.com/
|
||||
site_id = 1</code></pre>
|
||||
|
||||
<p>YAML, JSON, and INI aren't part of highlight.js's core bundle the way PHP/Bash/CSS/Python/JavaScript/XML are — they're vendored as three separate files under <code>public/vendor/highlightjs/languages/</code>, loaded after the core bundle. See <a class="icon-link" href="/admin/docs/upgrading-highlightjs">{{ icons.book() }}Upgrading highlight.js</a> for how to add another language the same way.</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
// /blog/feed — sidecar-only (no index.twig), like sitemap.xml/search: no
|
||||
// point rendering Twig just to return XML. Project-owned (App/pages/),
|
||||
// since this is blog content specifically, not generic framework
|
||||
// machinery like sitemap.xml/search are. Deliberately has zero dependency
|
||||
// on the (off-by-default) content index — it reads the exact same
|
||||
// hand-written $posts array App/pages/blog/index.php itself renders from,
|
||||
// so this feed works on a bare install with content_index_enabled left at
|
||||
// its shipped default of false. Only the per-tag variant
|
||||
// (App/pages/blog/tag/[tag]/feed/index.php) needs the content index, since
|
||||
// tags only exist there.
|
||||
|
||||
use App\Response;
|
||||
use Lib\Rss;
|
||||
|
||||
$config = require __DIR__ . '/../../../../novaconium/config.php';
|
||||
$appConfigFile = __DIR__ . '/../../../../App/config.php';
|
||||
if (is_file($appConfigFile)) {
|
||||
$config = array_merge($config, require $appConfigFile);
|
||||
}
|
||||
|
||||
$posts = (require __DIR__ . '/../index.php')['posts'];
|
||||
|
||||
// Newest first, the RSS convention — the array itself (and therefore the
|
||||
// /blog listing page, which isn't touched here) keeps its own order;
|
||||
// sorting only affects this feed's output.
|
||||
usort($posts, fn (array $a, array $b) => strcmp($b['published'], $a['published']));
|
||||
|
||||
$items = array_map(
|
||||
fn (array $post) => [
|
||||
'title' => $post['title'],
|
||||
'link' => '/blog/' . $post['slug'],
|
||||
'guid' => '/blog/' . $post['slug'],
|
||||
'pubDateTimestamp' => strtotime($post['published']),
|
||||
'description' => $post['excerpt'],
|
||||
],
|
||||
$posts
|
||||
);
|
||||
|
||||
$xml = Rss::render(
|
||||
$config['site_name'] . ' Blog',
|
||||
'/blog',
|
||||
'Posts from ' . $config['site_name'] . '.',
|
||||
$items
|
||||
);
|
||||
|
||||
return Response::xml($xml);
|
||||
@@ -0,0 +1,27 @@
|
||||
{% 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 tags %}welcome, meta{% 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 %}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?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.
|
||||
// 'published' (YYYY-MM-DD) is used by App/pages/blog/feed/index.php to
|
||||
// order and date entries in the RSS feed — illustrative dates here, not
|
||||
// derived from real history (this repo's posts all arrived in one batch
|
||||
// import, so there's no authentic per-post date to pull from).
|
||||
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.',
|
||||
'published' => '2026-07-11',
|
||||
],
|
||||
[
|
||||
'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.',
|
||||
'published' => '2026-07-11',
|
||||
],
|
||||
[
|
||||
'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.',
|
||||
'published' => '2026-07-13',
|
||||
],
|
||||
[
|
||||
'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.",
|
||||
'published' => '2026-07-13',
|
||||
],
|
||||
[
|
||||
'slug' => 'code-highlighting',
|
||||
'title' => 'Code Highlighting',
|
||||
'excerpt' => 'How syntax highlighting works on this site, with worked examples in bash, HTML, CSS, YAML, Python, JavaScript, JSON, and INI/env.',
|
||||
'published' => '2026-07-14',
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -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 }}…</p>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,27 @@
|
||||
{% 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 tags %}meta{% 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 %}
|
||||
@@ -0,0 +1,129 @@
|
||||
{% 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 tags %}css, reference{% 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 & 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())->send($old['name'], $old['email'], $old['message']);</code></p>
|
||||
<pre><code class="nohighlight">{% 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 %}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
// blog/tag/[tag]/feed/ — same [param] capture as blog/tag/[tag]/index.php
|
||||
// one level up ($params['tag'] is already populated by the time Router
|
||||
// resolves this deeper path — see that file's comments for how the
|
||||
// capture works). Project-owned, mirrors blog/tag/[tag]/index.php's
|
||||
// query almost exactly, just rendered as RSS instead of an HTML list.
|
||||
|
||||
use App\ContentIndexer;
|
||||
use App\Response;
|
||||
use Lib\Db;
|
||||
use Lib\Rss;
|
||||
|
||||
// Same two-step config load bootstrap.php/bin scripts use — this sidecar
|
||||
// isn't handed $config, so it loads its own copy to read
|
||||
// content_index_enabled before touching Lib\Db at all.
|
||||
$config = require __DIR__ . '/../../../../../../novaconium/config.php';
|
||||
$appConfigFile = __DIR__ . '/../../../../../../App/config.php';
|
||||
if (is_file($appConfigFile)) {
|
||||
$config = array_merge($config, require $appConfigFile);
|
||||
}
|
||||
|
||||
// Content index is off by default (depends on SQLite) — see
|
||||
// /admin/docs/content-index. Unlike App/pages/blog/feed/ (the main feed,
|
||||
// which has zero content-index dependency), this per-tag feed reads
|
||||
// content_tags/content_pages directly, so it 404s the same way
|
||||
// blog/tag/[tag]/index.php does when the index is off, and never
|
||||
// constructs a Lib\Db connection in that case.
|
||||
if (!$config['content_index_enabled']) {
|
||||
return Response::html('404 Not Found', 404);
|
||||
}
|
||||
|
||||
ContentIndexer::ensureFresh();
|
||||
|
||||
$tag = $params['tag'];
|
||||
|
||||
// Same query as blog/tag/[tag]/index.php, plus source_mtime — used below
|
||||
// as pubDate. This is the page's own source-file mtime, not a true
|
||||
// "published" date (the content index has no separate published concept
|
||||
// the way the hand-written main feed's $posts array does) — an honest
|
||||
// stand-in, not presented as more precise than it is.
|
||||
$posts = Db::query(
|
||||
'SELECT content_pages.route, content_pages.title, content_pages.description, content_pages.source_mtime ' .
|
||||
'FROM content_tags ' .
|
||||
'JOIN content_pages ON content_pages.route = content_tags.route ' .
|
||||
'WHERE content_tags.tag = ? ' .
|
||||
"AND content_pages.route LIKE '/blog/%' " .
|
||||
'ORDER BY content_pages.source_mtime DESC',
|
||||
[$tag]
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$items = array_map(
|
||||
fn (array $post) => [
|
||||
'title' => $post['title'],
|
||||
'link' => $post['route'],
|
||||
'guid' => $post['route'],
|
||||
'pubDateTimestamp' => (int) $post['source_mtime'],
|
||||
'description' => $post['description'],
|
||||
],
|
||||
$posts
|
||||
);
|
||||
|
||||
$xml = Rss::render(
|
||||
$config['site_name'] . ' Blog — tagged "' . $tag . '"',
|
||||
'/blog/tag/' . $tag,
|
||||
'Posts tagged "' . $tag . '" from ' . $config['site_name'] . '.',
|
||||
$items
|
||||
);
|
||||
|
||||
return Response::xml($xml);
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
// blog/tag/[tag]/ — the [param] segment captures anything after
|
||||
// /blog/tag/ into $params['tag'] (see /admin/docs/routing). This page is
|
||||
// project-owned (unlike sitemap.xml/search, which are framework defaults
|
||||
// under novaconium/pages/) because blog/ itself is project content, not
|
||||
// framework machinery.
|
||||
|
||||
use App\ContentIndexer;
|
||||
use App\Response;
|
||||
use Lib\Db;
|
||||
|
||||
// Same two-step config load bootstrap.php/bin scripts use — this sidecar
|
||||
// isn't handed $config, so it loads its own copy to read
|
||||
// content_index_enabled before touching Lib\Db at all.
|
||||
$config = require __DIR__ . '/../../../../../novaconium/config.php';
|
||||
$appConfigFile = __DIR__ . '/../../../../../App/config.php';
|
||||
if (is_file($appConfigFile)) {
|
||||
$config = array_merge($config, require $appConfigFile);
|
||||
}
|
||||
|
||||
// Content index is off by default (depends on SQLite) — see
|
||||
// /admin/docs/content-index. When it's off, this route must 404 exactly
|
||||
// like a page that doesn't exist, and never construct a Lib\Db connection
|
||||
// (which would otherwise create data/novaconium.sqlite just because this
|
||||
// file exists, even on a site that never opted in).
|
||||
if (!$config['content_index_enabled']) {
|
||||
return Response::html('404 Not Found', 404);
|
||||
}
|
||||
|
||||
// Lazy reindex-if-stale — a no-op on most requests (only actually
|
||||
// reindexes when a page's source file changed since the last index). Also
|
||||
// guards against reentrancy: ContentIndexer's own crawl renders every
|
||||
// page, but it never renders *this* route, since blog/tag/[tag] is a
|
||||
// wildcard directory and Overlay::listPageDirs() skips [param]-wildcard
|
||||
// dirs entirely (concrete tag values aren't knowable without a data
|
||||
// source — see ContentIndexer's docblock).
|
||||
ContentIndexer::ensureFresh();
|
||||
|
||||
$tag = $params['tag'];
|
||||
|
||||
// content_tags is a derived index built from each post's own
|
||||
// {% block tags %} (see /admin/docs/content-index) — it's populated
|
||||
// entirely by ContentIndexer::reindex(), never written to directly here.
|
||||
// The route LIKE '/blog/%' filter matters because content_tags isn't
|
||||
// blog-specific — any page anywhere on the site can declare tags, so this
|
||||
// scopes results to blog posts only, the same way App/pages/blog/index.php
|
||||
// itself only ever lists blog posts.
|
||||
$posts = Db::query(
|
||||
'SELECT content_pages.route, content_pages.title, content_pages.description ' .
|
||||
'FROM content_tags ' .
|
||||
'JOIN content_pages ON content_pages.route = content_tags.route ' .
|
||||
'WHERE content_tags.tag = ? ' .
|
||||
"AND content_pages.route LIKE '/blog/%' " .
|
||||
'ORDER BY content_pages.route',
|
||||
[$tag]
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// $posts is [] (not an error) when nothing matches — the twig template
|
||||
// renders a plain "no posts tagged ..." message for that case, same as
|
||||
// /search does for a query with no results.
|
||||
return [
|
||||
'tag' => $tag,
|
||||
'posts' => $posts,
|
||||
];
|
||||
@@ -0,0 +1,24 @@
|
||||
{% extends layout %}
|
||||
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
{% block title %}Posts tagged “{{ tag }}”{% endblock %}
|
||||
{% block description %}Blog posts tagged {{ tag }}.{% endblock %}
|
||||
{% block robots %}noindex, follow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="icon-heading">{{ icons.tag() }}Posts tagged “{{ tag }}”</h1>
|
||||
|
||||
{% if posts|length > 0 %}
|
||||
<ul class="post-list">
|
||||
{% for post in posts %}
|
||||
<li>
|
||||
<a href="{{ post.route }}">{{ post.title }}</a>
|
||||
{% if post.description %}<p>{{ post.description }}</p>{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>No posts tagged “{{ tag }}”.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,114 @@
|
||||
{% 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 tags %}twig, reference{% 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 & 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 class="nohighlight">{% 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 class="nohighlight">{% verbatim %}{% if sent %}
|
||||
<p>Thanks — your message has been sent.</p>
|
||||
{% endif %}{% endverbatim %}</code></pre>
|
||||
<pre><code class="nohighlight">{% verbatim %}{% for post in posts %}
|
||||
<li><a href="/blog/{{ post.slug }}">{{ post.title }}</a></li>
|
||||
{% 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 class="nohighlight">{% 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 & includes</h2>
|
||||
<p><code>{% verbatim %}{% extends %}{% endverbatim %}</code> is how every page on this site gets its <code><html></code>/<code><head></code>/nav/footer for free — a child template only fills in named <code>{% verbatim %}{% block %}{% endverbatim %}</code> slots the parent declares:</p>
|
||||
<pre><code class="nohighlight">{% 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 class="nohighlight">{% 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 %}
|
||||
@@ -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(),
|
||||
];
|
||||
@@ -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 %}
|
||||
@@ -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 & admin auth</h2>
|
||||
<p>Built-in analytics tracking and a multi-user session login 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 %}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -1,22 +1,122 @@
|
||||

|
||||
# 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 a session login with multi-user management: a SQLite-backed `users` table, `/admin/login`/`/admin/logout`, and an `/admin/users` page to create, disable, delete, group, promote/demote, and change the email or password of accounts (plus a `novaconium/bin/create-admin-user.php` CLI for the first user or deploy scripts). Two roles: the first user created is the admin; everyone after is a registered user with an optional group. Every account has a unique, normalized email address — groundwork for email verification later. Enabled with a single `admin_auth_enabled` flag in `App/config.php`; off by default, and reusable for any admin page a project adds later.
|
||||
- **Access control** — assign a page (or a section, one line per page) to a user or group from its sidecar: `Access::require('group:members')` returns `null` or a ready-made `Response` (login redirect with a return path, or a 404 for the wrong account). Public is the default — a sidecar that never calls it is untouched, and static (sidecar-less, cached) pages are always public by construction. Gated pages stay out of `/search` and `/sitemap.xml` automatically.
|
||||
- **Draft pages** — list a route under `draft_routes` in `App/config.php` to make it visible only to an authenticated admin; anyone else gets a plain 404, not a login prompt. Reuses the admin auth check directly, and is excluded from static caching so a cached copy can't leak the draft to the public. See `/admin/docs/drafts`.
|
||||
- **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`, `/admin/login`, and `/admin/users`.
|
||||
- **SQLite/MySQL database, zero setup** — `Lib\Db`, a thin PDO wrapper (no ORM) supporting multiple named connections open at once — e.g. this site's own SQLite data plus a MySQL connection to a legacy database, usable in the same sidecar — each with its own plain-SQL migration convention, applied automatically on first use or via `php novaconium/bin/migrate.php`. SQLite data lives in a project-owned top-level `data/` directory, outside both `public/` and `novaconium/`. See `/admin/docs/database`.
|
||||
- **Sessions with flash data** — `Lib\Session`, a thin wrapper around native PHP sessions with CodeIgniter-style flash values (set now, readable on exactly the next request) for post/redirect/GET flows without a query-string flag. Lazy-start, same mechanism `Lib\Csrf` already uses. See `/admin/docs/session`.
|
||||
- **Content index: sitemap, search, tags** — `/sitemap.xml`, full-text `/search` (SQLite FTS5), and blog tag browsing all share one crawler that renders every page and harvests `keywords`/`tags`/`changefreq`/`priority` Twig blocks via Twig's own `renderBlock()` — no front-matter, no separate metadata files. Off by default (depends on SQLite); reindexes lazily on demand or via `php novaconium/bin/index-content.php`. See `/admin/docs/content-index`.
|
||||
- **Blog RSS feed** — `/blog/feed`, built from the same hand-written post list `App/pages/blog/index.php` itself renders from, so it works with no database at all. `Lib\Rss` (a small RSS 2.0 envelope builder) also backs a per-tag feed, `/blog/tag/<tag>/feed`, once the content index above is enabled. Auto-discovered via a `<link rel="alternate">` on `/blog/*` pages.
|
||||
- **Syntax-highlighted code blocks** — vendored [highlight.js](https://highlightjs.org/) colors PHP/Bash/HTML code blocks site-wide, auto-detected with no per-block markup, swapping between dark (`ir-black`) and light (`github`) themes along with the existing dark/light toggle. Twig-syntax samples (which highlight.js can't parse) are left plain rather than colored wrong. See `/admin/docs/upgrading-highlightjs`.
|
||||
- **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
|
||||
Master Repo: https://git.4lt.ca/4lt/novaconium
|
||||
## Getting started
|
||||
|
||||
## Getting Started
|
||||
**Requirements:** PHP 8.1+ (uses `readonly` constructor-promoted properties) with the `pdo_sqlite` extension (bundled with PHP, just needs to be enabled — no separate install; add `pdo_mysql` too if using a MySQL connection), and, for production, Apache with `mod_rewrite` and `AllowOverride All`. The content index's search (`/admin/docs/content-index`) additionally needs SQLite's FTS5 extension, bundled with `pdo_sqlite` on virtually every modern PHP build — only relevant if `content_index_enabled` is turned on.
|
||||
|
||||
### Installation
|
||||
### Run it locally (no Apache needed)
|
||||
|
||||
```bash
|
||||
mkdir project_name;
|
||||
cd project_name;
|
||||
composer require 4lt/novaconium
|
||||
cp -R vendor/4lt/novaconium/examples/App/ .
|
||||
cp -R vendor/4lt/novaconium/examples/public/ .
|
||||
```
|
||||
php -S 127.0.0.1:8000 -t public public/router.php
|
||||
```
|
||||
|
||||
`public/router.php` is a dev-only script that mimics the `.htaccess` rules (canonical redirects + static cache lookup) so you can develop without Apache. It is never used in production — Apache reads `public/.htaccess` directly.
|
||||
|
||||
Visit `http://127.0.0.1:8000/` 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.
|
||||
|
||||
### Deploy on Apache
|
||||
|
||||
Point the vhost's document root at `public/`, make sure `mod_rewrite` is enabled and `AllowOverride All` is set for that directory so `public/.htaccess` takes effect, and it just works — no build step required.
|
||||
|
||||
### Starting a new project
|
||||
|
||||
Clone this repo and drop its Git history — no Composer scaffold or installer:
|
||||
|
||||
```
|
||||
git clone --depth 1 <novaconium-repo-url> my-new-project
|
||||
cd my-new-project
|
||||
rm -rf .git && git init && git add -A && git commit -m "Initial commit from novaconium template"
|
||||
```
|
||||
|
||||
Then replace the example content under `App/pages/` with your own; leave `novaconium/` and `public/` alone.
|
||||
|
||||
### Updating the framework
|
||||
|
||||
Since the framework core lives entirely under `novaconium/`, pick up a new release by overwriting just that directory against a tag and committing the diff:
|
||||
|
||||
```
|
||||
git clone --depth 1 --branch <release-tag> <novaconium-repo-url> /tmp/nova-update
|
||||
rm -rf novaconium && cp -r /tmp/nova-update/novaconium ./novaconium && rm -rf /tmp/nova-update
|
||||
git add novaconium && git commit -m "Update novaconium framework to <release-tag>"
|
||||
```
|
||||
|
||||
Safe by construction — `App/` always overrides `novaconium/`, so an update can't clobber project customizations. See [Getting started](http://127.0.0.1:8000/admin/docs/getting-started) for the full write-up.
|
||||
|
||||
### Add a page
|
||||
|
||||
Create a directory under `App/pages/` with an `index.twig` — the directory path *is* the URL:
|
||||
|
||||
```
|
||||
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
|
||||
|
||||
The full framework documentation — routing, sidecars, libraries, database, session, content index, XML sitemap, RSS feeds, layouts, static caching, SEO, Matomo analytics, admin authentication, draft pages, 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:
|
||||
|
||||
- [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)
|
||||
- [Database](http://127.0.0.1:8000/admin/docs/database)
|
||||
- [Session](http://127.0.0.1:8000/admin/docs/session)
|
||||
- [Content index](http://127.0.0.1:8000/admin/docs/content-index)
|
||||
- [XML sitemap](http://127.0.0.1:8000/admin/docs/sitemap)
|
||||
- [RSS feeds](http://127.0.0.1:8000/admin/docs/rss)
|
||||
- [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)
|
||||
- [Access control](http://127.0.0.1:8000/admin/docs/access-control)
|
||||
- [Draft pages](http://127.0.0.1:8000/admin/docs/drafts)
|
||||
- [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)
|
||||
|
||||
`AGENTS.md` is the short, agent-facing version for coding assistants working in this repo, and `novaconium/ISSUES.md` is the roadmap/backlog.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
See [Project layout](http://127.0.0.1:8000/admin/docs/project-layout) for the full tree with every file explained.
|
||||
|
||||
## Third-party
|
||||
|
||||
[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`.
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
<svg width="100%" height="200" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="100%" height="100%" fill="black"/>
|
||||
<defs>
|
||||
<radialGradient id="star-gradient" cx="50%" cy="50%" r="50%" fx="50%" fy="50%">
|
||||
<stop offset="0%" style="stop-color: white; stop-opacity: 1" />
|
||||
<stop offset="100%" style="stop-color: white; stop-opacity: 0" />
|
||||
</radialGradient>
|
||||
<g id="star">
|
||||
<circle cx="0" cy="0" r="2" fill="white" />
|
||||
</g>
|
||||
</defs>
|
||||
|
||||
<!-- Lots of stars moving outward slower -->
|
||||
<g>
|
||||
<use href="#star" x="50%" y="50%" transform="translate(-300,-150) scale(1)" />
|
||||
<use href="#star" x="50%" y="50%" transform="translate(300,-150) scale(1)" />
|
||||
<use href="#star" x="50%" y="50%" transform="translate(-300,150) scale(1)" />
|
||||
<use href="#star" x="50%" y="50%" transform="translate(300,150) scale(1)" />
|
||||
<use href="#star" x="50%" y="50%" transform="translate(-150,-75) scale(0.5)" />
|
||||
<use href="#star" x="50%" y="50%" transform="translate(150,-75) scale(0.5)" />
|
||||
<use href="#star" x="50%" y="50%" transform="translate(-150,75) scale(0.5)" />
|
||||
<use href="#star" x="50%" y="50%" transform="translate(150,75) scale(0.5)" />
|
||||
<use href="#star" x="50%" y="50%" transform="translate(-75,-37) scale(0.7)" />
|
||||
<use href="#star" x="50%" y="50%" transform="translate(75,-37) scale(0.7)" />
|
||||
<use href="#star" x="50%" y="50%" transform="translate(-75,37) scale(0.7)" />
|
||||
<use href="#star" x="50%" y="50%" transform="translate(75,37) scale(0.7)" />
|
||||
<use href="#star" x="50%" y="50%" transform="translate(-350,-175) scale(0.4)" />
|
||||
<use href="#star" x="50%" y="50%" transform="translate(350,-175) scale(0.4)" />
|
||||
<use href="#star" x="50%" y="50%" transform="translate(-350,175) scale(0.4)" />
|
||||
<use href="#star" x="50%" y="50%" transform="translate(350,175) scale(0.4)" />
|
||||
<animateTransform attributeName="transform" type="scale" from="1" to="10" dur="2s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" from="1" to="0" dur="2s" repeatCount="indefinite" />
|
||||
</g>
|
||||
|
||||
<!-- Centered Title -->
|
||||
<text x="50%" y="50%" fill="white" font-size="40" text-anchor="middle" font-family="Arial" dy=".3em">
|
||||
Novaconium PHP
|
||||
</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.2 KiB |
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"name": "4lt/novaconium",
|
||||
"description": "A high-performance PHP framework built from the past.",
|
||||
"version": "1.0.2",
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nick Yeoman",
|
||||
"email": "dev@4lt.ca",
|
||||
"homepage": "https://www.4lt.ca",
|
||||
"role": "Consultant"
|
||||
}
|
||||
],
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Novaconium\\\\": "src/"
|
||||
}
|
||||
},
|
||||
"require": {
|
||||
"twig/twig": "*"
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"extra": {
|
||||
"versioning": {
|
||||
"strategy": "semantic-versioning"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
$config = [
|
||||
'database' => [
|
||||
'host' => '',
|
||||
'name' => '',
|
||||
'user' => '',
|
||||
'pass' => '',
|
||||
'port' => 3306
|
||||
],
|
||||
'base_url' => 'http://localhost:8000'
|
||||
];
|
||||
@@ -1,15 +0,0 @@
|
||||
<?php
|
||||
|
||||
// Define our status code and message
|
||||
$status_code = 404;
|
||||
$status_message = 'The requested resource could not be found.';
|
||||
|
||||
// Set the HTTP response code and message
|
||||
http_response_code($status_code);
|
||||
header("Content-Type: text/html");
|
||||
?>
|
||||
|
||||
<h1>Error 404 Resource Not found</h1>
|
||||
<p><?php echo $status_message; ?></p>
|
||||
<p style="font-size:10px; margin-top:60px">Novaconium Default 404 page.</p>
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
<?php
|
||||
echo $twig->render('index.html.twig');
|
||||
@@ -1,9 +0,0 @@
|
||||
<?php
|
||||
$routes = [
|
||||
'/about' => [
|
||||
'get' => 'about'
|
||||
],
|
||||
'/' => [
|
||||
'get' => 'index'
|
||||
]
|
||||
];
|
||||
@@ -1 +0,0 @@
|
||||
{# Overrides go here #}
|
||||
@@ -1,7 +0,0 @@
|
||||
{% extends '@novaconium/master.html.twig' %}
|
||||
|
||||
{% block content %}
|
||||
<h1>This is twig</h1>
|
||||
<p>Content Here</p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
RewriteEngine On
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^(.*)$ index.php?_uri=$1 [QSA,L]
|
||||
@@ -1,6 +0,0 @@
|
||||
<?php
|
||||
// error_reporting(E_ALL);
|
||||
// ini_set('display_errors', 1);
|
||||
define('BASEPATH', dirname(__DIR__, 1));
|
||||
require_once(BASEPATH . '/vendor/4lt/novaconium/src/novaconium.php');
|
||||
?>
|
||||
@@ -0,0 +1,723 @@
|
||||
# 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):
|
||||
|
||||
1. **Media/file manager** — no hard dependency; usable standalone, and
|
||||
already gated behind admin login the moment it exists under `/admin/*`.
|
||||
2. **In-house comments** — admin login & user management (Done
|
||||
2026-07-14) unblocked this; comments are tied to real user accounts,
|
||||
not anonymous.
|
||||
3. **Ecommerce functionality** — its dependencies (SQLite groundwork,
|
||||
session handling for the cart, admin login for order/product admin)
|
||||
are all done now.
|
||||
4. **Paywall functionality** — needs everything Ecommerce needs, plus
|
||||
Ecommerce itself for the recurring-billing/payment-gateway plumbing;
|
||||
build after it rather than in parallel — also now has a concrete
|
||||
precedent to follow for the "gated content must skip the static cache"
|
||||
part of its design (see Draft pages (admin-only preview) in Done, and
|
||||
the caching/auth standing rule in `AGENTS.md`), which was still an open
|
||||
question when this entry was originally written.
|
||||
|
||||
MySQL support, Session handling (with flash sessions), Draft pages
|
||||
(admin-only preview), Blog tags/categories + Internal search + XML
|
||||
sitemap (shipped together as one content index — see Done), Blog RSS
|
||||
feed, Syntax highlighting on code blocks, and Admin login & user
|
||||
management all shipped 2026-07-14.
|
||||
|
||||
See **Won't Do** below for 404 tracking, dropped in favor of Matomo.
|
||||
|
||||
### Email verification for user accounts
|
||||
|
||||
- **Type:** Feature
|
||||
- **Status:** Backlog
|
||||
- **Priority:** Medium
|
||||
- **Depends on:** Admin login & user management (Done)
|
||||
- **Added:** 2026-07-14
|
||||
|
||||
Verify a user's email address by sending a confirmation link — the
|
||||
groundwork is already in place: every account has a required, unique,
|
||||
normalized email (`users.email`, added the same day as user deletion —
|
||||
see User deletion & email addresses under Done). Needs a `verified_at`
|
||||
(or token) column, a token-generation/expiry scheme, a send path
|
||||
(`Lib\Mailer` is currently a log-to-file stand-in — this feature is
|
||||
probably what forces it to grow a real mail transport), and a decision
|
||||
on what an unverified account may do (log in but fail `Lib\Access`
|
||||
rules? not log in at all?). Also the natural home for password-reset-
|
||||
by-email later, which shares all the same plumbing.
|
||||
|
||||
### 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.
|
||||
|
||||
### In-house comments
|
||||
|
||||
- **Type:** Feature
|
||||
- **Status:** Backlog
|
||||
- **Priority:** Medium
|
||||
- **Depends on:** Admin login & user management (Done), SQLite groundwork (Done)
|
||||
- **Added:** 2026-07-14
|
||||
|
||||
A self-hosted comments library — no third-party service (Disqus,
|
||||
Commento, etc.) — a `Lib\` class any sidecar can call to attach comments
|
||||
to any page, not just blog posts, the same way `Lib\SpamGuard`/
|
||||
`Lib\FormValidator` are reusable across any form rather than hardcoded to
|
||||
the contact page. Comments tied to a real user account rather than
|
||||
anonymous name/email fields, which is why this rides on Admin login &
|
||||
user management rather than SQLite groundwork alone — needs that
|
||||
feature's user store to exist first. Likely a `comments` table
|
||||
(route/user/body/created_at/approved or similar — a migration under
|
||||
`App/migrations/`, following the two-root convention documented in
|
||||
`/admin/docs/database`) plus a small set of sidecar-callable methods
|
||||
(list comments for a route, submit one, moderate one). Needs a decision
|
||||
on moderation model (auto-approve vs. admin-approval queue, reusing the
|
||||
`/admin/*` auth gate for the moderation UI) and on spam handling (reuse
|
||||
`Lib\SpamGuard`'s honeypot/timing approach rather than inventing a second
|
||||
mechanism, consistent with how CSRF protection already works — see
|
||||
`/admin/docs/sidecars`'s "Form security" section for the existing
|
||||
input-cleaning/CSRF/spam-prevention layers a comment form should compose
|
||||
the same way the contact form does).
|
||||
|
||||
### Ecommerce functionality
|
||||
|
||||
- **Type:** Feature
|
||||
- **Status:** Backlog
|
||||
- **Priority:** Low
|
||||
- **Depends on:** SQLite groundwork (Done), Session handling (with flash sessions) (Done), Admin login & user management (Done)
|
||||
- **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 (Done), Session handling (with flash sessions) (Done), Admin login & user management (Done)
|
||||
- **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) — the
|
||||
access-check half of this now has a shipped foundation to build on:
|
||||
`Lib\Access` (see User roles, groups & page access control in Done)
|
||||
already handles login-gated/group-gated sidecar content; a paywall
|
||||
mostly adds "does this account have an active subscription" as a rule
|
||||
source on top of it. 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
|
||||
|
||||
### User deletion & email addresses
|
||||
|
||||
- **Type:** Feature
|
||||
- **Status:** Done
|
||||
- **Priority:** Medium
|
||||
- **Depends on:** Admin login & user management (Done)
|
||||
- **Added:** 2026-07-14
|
||||
- **Shipped:** 2026-07-14 (b882c30)
|
||||
|
||||
Second same-day follow-up to Admin login & user management (below),
|
||||
also pre-commit — so the `email` column went into the existing
|
||||
`0002_create_users.sql` like the roles change before it. `/admin/users`
|
||||
gained a hard-delete action (username/email become reusable; any live
|
||||
session dies on its next request via the same per-request row re-check
|
||||
disabling uses; the last-active-admin guard covers delete as well as
|
||||
disable/demote) and a change-email action. Every account now requires a
|
||||
unique email address — validated and normalized (trim + lowercase) via
|
||||
the existing `Lib\Validate::isEmail()`, so uniqueness is
|
||||
case-insensitive by construction (verified: `BOB@example.com` collides
|
||||
with `bob@example.com`) — on the create form, the change-email action,
|
||||
and `bin/create-admin-user.php` (now `<username> <email>`). Not used
|
||||
for login or any mail yet; it exists so the planned email-verification
|
||||
flow (new Backlog entry above) has an address for every account that
|
||||
predates it. Delete stays deliberately distinct from disable in the UI
|
||||
(inside a confirm-style `<details>` with a warning): disable is
|
||||
keep-but-shut-out, delete is gone-for-good.
|
||||
|
||||
### User roles, groups & page access control
|
||||
|
||||
- **Type:** Feature
|
||||
- **Status:** Done
|
||||
- **Priority:** Medium
|
||||
- **Depends on:** Admin login & user management (Done)
|
||||
- **Added:** 2026-07-14
|
||||
- **Shipped:** 2026-07-14 (b882c30)
|
||||
|
||||
Follow-up to Admin login & user management (below), shipped the same day
|
||||
before any of it was committed — so the `users` schema change went into
|
||||
the existing `0002_create_users.sql` rather than a third migration. Two
|
||||
roles (`users.role`): the first user created is `'admin'`, everyone
|
||||
after is `'registered'` with an optional single group
|
||||
(`users.user_group`, a plain text label matched exactly — deliberately
|
||||
no groups table). `/admin/*` and draft preview are admin-only now — a
|
||||
logged-in registered user gets a plain 404 there (not a login redirect;
|
||||
they're authenticated, what they lack is the role) — and the
|
||||
last-active-user lockout guard became a last-active-*admin* guard,
|
||||
applied to both the disable and the new demote action (`/admin/users`
|
||||
also grew group-assignment and promote/demote).
|
||||
|
||||
`Lib\Access` (`novaconium/lib/Access.php`) is the sidecar-level content
|
||||
gate, per the "assign a page/section to a user or group" spec:
|
||||
`Access::require('group:members', 'user:bob')` at the top of a sidecar
|
||||
returns `null` or a ready-made `Response` (anonymous → login redirect
|
||||
carrying a `?return=` path, validated against open redirects;
|
||||
wrong-account → 404, drafts' hide-don't-tease posture). No rules = any
|
||||
logged-in user; admins pass everything. Public is the default twice
|
||||
over: sidecars that never call it are untouched, and static
|
||||
(sidecar-less) pages *can't* call it — always public, which also means
|
||||
a gated page necessarily has a sidecar and is therefore never written
|
||||
to the static HTML cache: the caching/auth standing rule satisfied by
|
||||
construction, no bootstrap exclusion needed. Sections are gated by
|
||||
composition (a shared `_access.php` file `require`'d by each sidecar in
|
||||
the section), not per-directory config. `Access::require()` has no side
|
||||
effects on deny, so the content-index crawl (anonymous GET per sidecar)
|
||||
both drops gated pages from `/search`/`/sitemap.xml` automatically and
|
||||
can't touch the visiting user's session. Verified end-to-end with three
|
||||
real accounts (admin / registered-with-group / registered-without) and
|
||||
a cookie jar each: rule matrix, return-path round trip,
|
||||
`//evil.com`-style return rejection, registered-user 404s on `/admin/*`
|
||||
and drafts, promote/demote + guards, group reassignment taking effect
|
||||
immediately, crawl exclusion, and flag-off zero-footprint posture.
|
||||
Documented at `/admin/docs/access-control` (new topic, linked from the
|
||||
docs nav/index), with supporting updates to `admin-auth`, `drafts`,
|
||||
`sidecars`, `config`, and `libraries`.
|
||||
|
||||
### Admin login & user management
|
||||
|
||||
- **Type:** Feature
|
||||
- **Status:** Done
|
||||
- **Priority:** Medium
|
||||
- **Depends on:** SQLite groundwork (Done), Session handling (with flash sessions) (Done)
|
||||
- **Added:** 2026-07-12
|
||||
- **Shipped:** 2026-07-14 (b882c30)
|
||||
|
||||
Shipped as specified: the single-user HTTP Basic Auth stopgap that gated
|
||||
`/admin/*` was **replaced, not layered on** — `AdminAuth` keeps its name
|
||||
and call sites (`bootstrap.php`'s admin gate and draft gate) but is now a
|
||||
session login (`Lib\Session`, with a new `Session::regenerate()` against
|
||||
session fixation) against a `users` table
|
||||
(`novaconium/migrations/0002_create_users.sql`, the second
|
||||
framework-shipped migration) with `password_hash()`/`password_verify()`
|
||||
and no external auth library. The `admin_username`/`admin_password_hash`
|
||||
config keys and the `/admin/password-hash` page are gone, superseded by a
|
||||
single `admin_auth_enabled` flag (default `false`) plus new
|
||||
`/admin/login`, `/admin/logout` (a real page now — the pre-router special
|
||||
case in `bootstrap.php` is gone too — and POST-only with a GET confirm
|
||||
form, because the content-index crawl runs every sidecar as a GET and a
|
||||
logout-on-GET would have ended the crawling admin's own session the first
|
||||
time a lazy reindex rendered it), and `/admin/users`
|
||||
(create/disable/enable/change-password) pages, and a
|
||||
`novaconium/bin/create-admin-user.php` CLI (password via stdin, for
|
||||
deploy scripts and lockout recovery). Documented at
|
||||
`/admin/docs/admin-auth`.
|
||||
|
||||
Decisions worth recording: same zero-footprint posture as the content
|
||||
index (flag off → the three auth routes 404 and `Lib\Db` is never
|
||||
touched, so no `data/novaconium.sqlite` appears — the route sidecars
|
||||
self-load config the same way `/search` does); first-user bootstrap keeps
|
||||
the gate open only while the `users` table is empty (creating the first
|
||||
user at `/admin/users` auto-logs you in as it and closes the gate —
|
||||
running the CLI *before* enabling the flag avoids the window entirely);
|
||||
`admin/login` is the one `/admin/*` route exempted from
|
||||
`requireLogin()`, or its redirect would loop; disabling a user kills any
|
||||
live session on its next request (`currentUser()` re-checks the row per
|
||||
request), and disabling the last active user is refused since the
|
||||
empty-table window never reopens — that would be a permanent lockout.
|
||||
Login/user passwords read `$_POST` directly, extending the documented
|
||||
`Lib\Input` exact-value exception (verified with a password containing
|
||||
`<>` end-to-end). Verified route-by-route with real HTTP requests and a
|
||||
cookie jar: flag off (404s, no DB file), setup window, first-user
|
||||
auto-login, fresh-client redirect, wrong/right password, logout,
|
||||
enable/disable/change-password, live-session lockout on disable,
|
||||
last-user guard, CSRF failure paths, CLI creation, and draft gating via
|
||||
the session cookie (including confirming a pre-existing static-cache copy
|
||||
of a page still serves after the page is marked a draft until the cache
|
||||
is cleared — the documented cache-clear step, not a new bug).
|
||||
|
||||
### Syntax highlighting on code blocks
|
||||
|
||||
- **Type:** Feature
|
||||
- **Status:** Done
|
||||
- **Priority:** Low
|
||||
- **Added:** 2026-07-13
|
||||
- **Shipped:** 2026-07-14
|
||||
|
||||
Colors `<pre><code>` blocks site-wide via vendored highlight.js v11.11.1
|
||||
(pinned to that stable tag, not `main`, which tracks an in-progress
|
||||
`11.0.0-beta1`), auto-detected and restricted to `configure({ languages:
|
||||
['php', 'bash', 'xml', 'css', 'python', 'javascript', 'yaml', 'json',
|
||||
'ini'] })` — no per-block markup needed for the ~60 code blocks across the
|
||||
site. `css`/`python`/`javascript` ship in the core bundle alongside the
|
||||
original `php`/`bash`/`xml`; `yaml`/`json`/`ini` (the last covers
|
||||
`.env`-style files too) don't and are vendored as three separate
|
||||
per-language files under `public/vendor/highlightjs/languages/` — added
|
||||
after initial shipment, once `/blog/code-highlighting` (a new reference
|
||||
post, worked example of each of the nine) needed them. Themes swap with the
|
||||
existing dark/light toggle: **ir-black** (dark, as originally specified)
|
||||
+ **github** (light, new — resolving the open question this entry
|
||||
originally left for "decide whether code blocks stay ir-black regardless
|
||||
of site theme"), via the same `data-theme`-driven mechanism as the main
|
||||
palette (`novaconium/pages/_layout/syntax-highlight-init.twig`/
|
||||
`syntax-highlight.twig`, mirroring `theme-init.twig`/`nav.twig`'s split —
|
||||
a `MutationObserver` on `data-theme` swaps the theme `<link>` live,
|
||||
without touching the existing toggle button's own click handler at all).
|
||||
|
||||
Twig-syntax code blocks (no highlight.js grammar exists for Twig) are
|
||||
marked `class="nohighlight"` by hand at the source — the entry's own
|
||||
suggested fallback — rather than force-matched into the restricted
|
||||
candidate set, which would color them *wrong* rather than leave them
|
||||
plain (auto-detection with a restricted language list always returns its
|
||||
best guess among the allowed set, never "gives up"). 15 blocks across 9
|
||||
files needed the marker; verified by finding every `<pre><code>`
|
||||
containing literal `{% %}`/`{{ }}` syntax rather than guessing, and
|
||||
confirmed two files matching that initial grep (`sidecars`, one block in
|
||||
`forms`) turned out to be `{% verbatim %}`-wrapped *PHP* snippets
|
||||
(verbatim just protecting a stray `{{ }}` mention), correctly left alone
|
||||
for auto-detection. Same class of gotcha hit again writing the bash
|
||||
example for `/blog/code-highlighting`: a command starting with the
|
||||
literal word `php` (`php -S 127.0.0.1:8000 ...`) auto-detects as PHP, not
|
||||
bash — not a bug, just a reminder that short/ambiguous snippets can
|
||||
mis-detect regardless of the restricted candidate set; the shipped bash
|
||||
example uses a `#!/bin/bash` shebang instead, a strong, reliable signal,
|
||||
verified against the real detector before committing to it.
|
||||
|
||||
**One correction to this entry's own suggested approach, found while
|
||||
implementing it:** vendoring highlight.js under `novaconium/vendor/` next
|
||||
to Twig (as originally suggested) would have been wrong and silently
|
||||
broken — Twig is server-side PHP, never fetched by a browser, but
|
||||
highlight.js's `.js`/`.css` files are, and only `public/` is web
|
||||
-reachable. Vendored to `public/vendor/highlightjs/` instead — see the
|
||||
standing rule added to `AGENTS.md` and `/admin/docs/upgrading-highlightjs`
|
||||
for the consequence: `public/` isn't touched by the usual
|
||||
`novaconium/`-swap framework-update workflow, so a future highlight.js
|
||||
version bump won't propagate to existing projects automatically the way
|
||||
everything else under `novaconium/` does.
|
||||
|
||||
**A real bug caught by testing, not review:** `hljs.highlightAll()`
|
||||
doesn't defer itself if called while `document.readyState` is still
|
||||
`"loading"` — it silently no-ops permanently rather than waiting and
|
||||
retrying, confirmed with an actual DOM test (jsdom) before it was
|
||||
noticed, not assumed safe just because the script tag sits near the end
|
||||
of `<body>`. Fixed by wrapping the call in the same `DOMContentLoaded`
|
||||
pattern `code-copy.twig` already uses. Verified end-to-end with a real
|
||||
`highlight.js` execution against real rendered page HTML (not a hand
|
||||
-rolled mock): correct language detection on real PHP/Bash blocks,
|
||||
`nohighlight` blocks left untouched, and `code.textContent` (what the
|
||||
copy-to-clipboard button reads) confirmed unchanged after `<span>`
|
||||
-wrapping — the specific regression this entry flagged as a risk.
|
||||
|
||||
### Blog RSS feed
|
||||
|
||||
- **Type:** Feature
|
||||
- **Status:** Done
|
||||
- **Priority:** Medium
|
||||
- **Added:** 2026-07-12
|
||||
- **Shipped:** 2026-07-14
|
||||
|
||||
`App/pages/blog/feed/index.php`, sidecar-only, `Response::xml(...)` — as
|
||||
originally scoped, no new mechanism needed. Deliberately independent of
|
||||
the content index above: it reads the same hand-written `$posts` array
|
||||
`App/pages/blog/index.php` itself renders from (now with a `published`
|
||||
date field added per entry, illustrative — this repo's posts all arrived
|
||||
in one batch import, no authentic per-post history to derive real dates
|
||||
from), so it works with `content_index_enabled` left at its shipped
|
||||
default of `false`. Sorted newest-first for the feed only; the array's
|
||||
own order (and the `/blog` listing page) is untouched. `<link>`/`<guid>`
|
||||
are site-relative paths, consistent with how `canonical`/`og:url` already
|
||||
work in this framework (no site-wide base-URL config exists to build
|
||||
absolute URLs from — not adding one for this alone); `<guid
|
||||
isPermaLink="false">` is the spec-correct way to mark a non-absolute
|
||||
identifier.
|
||||
|
||||
Shipped a per-tag feed too (`App/pages/blog/tag/[tag]/feed/index.php`),
|
||||
gated on `content_index_enabled` the same way `blog/tag/[tag]/index.php`
|
||||
is, `<pubDate>` from each page's `source_mtime` (a stand-in for a real
|
||||
publish date, which the content index doesn't track). Both feeds share
|
||||
`Lib\Rss::render()` (`novaconium/lib/Rss.php`, new — a generic RSS 2.0
|
||||
envelope builder, framework-default since only its two call sites are
|
||||
blog-specific, not the class itself) rather than duplicating the same XML
|
||||
-building logic twice.
|
||||
|
||||
Feed auto-discovery needed a new `head_extra` block in the root layout
|
||||
(`novaconium/pages/_layout/layout.twig`, empty by default, rendered right
|
||||
before `</head>`) — the root layout had no open-ended "extra head
|
||||
content" extension point before this; `App/pages/blog/_layout/layout.twig`
|
||||
overrides it with the `<link rel="alternate">`, so it only appears on
|
||||
`/blog/*` pages, not site-wide.
|
||||
|
||||
### Content index: keywords, tags/categories, search, XML sitemap
|
||||
|
||||
- **Type:** Feature
|
||||
- **Status:** Done
|
||||
- **Priority:** Medium
|
||||
- **Depends on:** SQLite groundwork (Done)
|
||||
- **Added:** 2026-07-12 (Blog tags/categories, Internal search, XML
|
||||
sitemap entries) / 2026-07-14 (keywords, combined)
|
||||
- **Shipped:** 2026-07-14
|
||||
|
||||
Shipped Blog tags/categories, Internal search, and XML sitemap together,
|
||||
plus a new meta-keywords request, as one feature rather than three —
|
||||
exactly the "worth deciding together when either is picked up" call this
|
||||
file made when XML sitemap was first written. All three (plus a fourth,
|
||||
new: `<meta name="keywords">`) turned out to be one shared crawler with
|
||||
thin consumers, not separate mechanisms.
|
||||
|
||||
Design: content stays in files (Twig pages, no CMS-style body-in-database
|
||||
— keeps the Hugo-style file-based-routing pitch intact). Per-page metadata
|
||||
is four Twig blocks in `novaconium/pages/_layout/layout.twig`, the same
|
||||
override mechanism already used for `title`/`description`/`og_*`:
|
||||
`keywords` (rendered, new `<meta>` tag), `tags` (comma-separated, not
|
||||
rendered), `changefreq`/`priority` (sitemap hints, not rendered). No
|
||||
front-matter, no separate metadata file convention. `App\ContentIndexer`
|
||||
(`novaconium/src/ContentIndexer.php`) crawls every routable page
|
||||
(`Overlay::listPageDirs()`, a new method — skips `_`/`404`/`[param]`
|
||||
directories, matching `Router::resolve()`'s reserved-segment rule and the
|
||||
original XML sitemap entry's stated V1 limitation that wildcard routes
|
||||
aren't crawled without a data source to resolve concrete values) and pulls
|
||||
each block's value via `Renderer::renderForIndex()` (new method) calling
|
||||
Twig's own `TemplateWrapper::renderBlock()` — not regex-parsing `.twig`
|
||||
source — so overrides and layout inheritance resolve exactly like a real
|
||||
render. Rendered HTML is `strip_tags()`-stripped into a SQLite FTS5 table
|
||||
for search. A page listed in `draft_routes` or whose `robots` block
|
||||
resolves to `noindex` is skipped entirely (never indexed), same convention
|
||||
`/admin/docs/seo` already documents for admin/internal pages.
|
||||
|
||||
**Off by default** (`content_index_enabled`, default `false`) — all three
|
||||
consumers depend on SQLite, a real dependency plenty of sites built on
|
||||
this framework won't want, same reasoning that already keeps Matomo/admin
|
||||
auth off by default. Verified end-to-end that disabling it is a true
|
||||
zero-footprint no-op: no `data/novaconium.sqlite` gets created just
|
||||
because the feature exists in the codebase, and all three consumer routes
|
||||
404 exactly as if they didn't exist.
|
||||
|
||||
Two trigger paths sharing one `reindex()`: lazy (`content_index_auto`,
|
||||
default `true` — a cheap mtime-staleness check on first touch of a
|
||||
consumer route, never on a normal page view) and explicit
|
||||
(`php novaconium/bin/index-content.php`, same shape as `bin/migrate.php`,
|
||||
ignores `content_index_auto`).
|
||||
|
||||
**Two real bugs caught by testing, not review:**
|
||||
1. **Reentrancy** — the crawl renders every page, including `/search`
|
||||
itself, whose own sidecar calls `ContentIndexer::ensureFresh()`;
|
||||
without a guard this triggered a nested `reindex()` mid-transaction and
|
||||
fataled on a second `PDO::beginTransaction()`. Fixed with a
|
||||
`private static bool $indexing` guard checked at the top of both
|
||||
`ensureFresh()` and `reindex()`.
|
||||
2. **Wrong PDO constant** (`PDO::KEY_PAIR` instead of
|
||||
`PDO::FETCH_KEY_PAIR`) in the search sidecar, caught immediately by
|
||||
actually hitting `/search` with a real query rather than trusting the
|
||||
code read correctly.
|
||||
|
||||
Also fixed two unrelated pre-existing bugs discovered while building this
|
||||
(both blocked/were adjacent to the crawler rendering every page for real):
|
||||
`novaconium/pages/admin/docs/sidecars/index.twig` had a literal
|
||||
un-escaped `{{ }}` in prose text that fataled Twig with a syntax error on
|
||||
any real render of that page (it had apparently never actually been
|
||||
visited before); and both that page and `Lib\Input`'s doc-comment still
|
||||
said "there's no database layer in this framework yet" despite `Lib\Db`
|
||||
having shipped weeks earlier.
|
||||
|
||||
**`migrations_dir` (`Lib\Db`) now accepts an ordered list of roots, not
|
||||
just one path** — needed so the content index's schema
|
||||
(`novaconium/migrations/0001_create_content_index.sql`) could ship as a
|
||||
framework migration without colliding with project migrations in
|
||||
`App/migrations/`. This is the first framework-shipped migration, and the
|
||||
two-root extension point `AGENTS.md` flagged as a future need when SQLite
|
||||
groundwork shipped. Migrations are now tracked by path relative to the
|
||||
repo root (not bare filename) specifically to prevent two roots each
|
||||
containing a same-named file from shadowing one another —
|
||||
`realpath()`-normalized so a `migrations_dir` containing `..` (like the
|
||||
default connection's own `__DIR__ . '/../App/migrations'`) doesn't produce
|
||||
an ugly, unstable tracked name.
|
||||
|
||||
New consumer routes: `novaconium/pages/sitemap.xml/index.php` (framework
|
||||
-default — confirmed a directory literally named `sitemap.xml` resolves
|
||||
correctly, since `Router` only splits on `/`), `novaconium/pages/search/`
|
||||
(framework-default, FTS5-backed, the search term wrapped as an escaped
|
||||
quoted phrase before binding — parameter binding stops SQL injection but
|
||||
not FTS5's own query-language parsing of the bound value, verified against
|
||||
a literal `"` and several FTS operator characters, not just assumed safe),
|
||||
and `App/pages/blog/tag/[tag]/` (project-owned, since `blog/` is project
|
||||
content — `App/pages/blog/index.php`'s hand-written post array is
|
||||
untouched, `content_tags` is a derived index on top of it, not a
|
||||
replacement). Added `{% block tags %}` to the 4 existing blog posts so tag
|
||||
browsing has real content to demonstrate against — an exception to the
|
||||
"ship mechanism only, no demo content" pattern of prior sessions, since
|
||||
here the target content already existed and tag browsing is meaningless
|
||||
to verify without it. Documented at `/admin/docs/content-index`, with
|
||||
supporting updates to `/admin/docs/seo`, `/admin/docs/database`, and
|
||||
`novaconium/bin/create-static-page.php`'s scaffolded template.
|
||||
|
||||
### Draft pages (admin-only preview)
|
||||
|
||||
- **Type:** Feature
|
||||
- **Status:** Done
|
||||
- **Priority:** Medium
|
||||
- **Added:** 2026-07-13
|
||||
- **Shipped:** 2026-07-14
|
||||
|
||||
Let a page under `App/pages/` be written and previewed by an admin without
|
||||
being visible to the public — a config-driven list, `draft_routes` in
|
||||
`App/config.php` (`Route::$dir`-format entries, e.g. `'blog/upcoming-post'`),
|
||||
checked in `novaconium/bootstrap.php` right alongside the existing
|
||||
`/admin/*` gate. Reuses `AdminAuth::isAuthenticated()` (a new method,
|
||||
extracted out of `requireLogin()` so the credential check could be reused
|
||||
with a different failure response) rather than a second auth mechanism:
|
||||
not authenticated → the same plain 404 an unmatched route gets (not a
|
||||
login prompt, so a draft's existence isn't revealed to anyone poking at
|
||||
the URL), authenticated → renders normally. No separate login flow needed
|
||||
— an admin authenticates once at `/admin`, and the browser then resends
|
||||
those same Basic Auth credentials to draft URLs automatically, since
|
||||
they're scoped to the whole origin/realm.
|
||||
|
||||
The gotcha flagged when this entry was written was designed around
|
||||
correctly: sidecar-less pages get written to the static HTML cache and
|
||||
served by `.htaccess` before PHP ever runs, so a draft without its own
|
||||
sidecar needed an explicit exclusion, not just the auth gate —
|
||||
`Renderer::render()` gained an `$excludeFromCache` param for this.
|
||||
**A second, real instance of the same bug was found while testing this
|
||||
feature, pre-dating it entirely:** `/admin` itself
|
||||
(`novaconium/pages/admin/index.twig`) has no sidecar, so it was already
|
||||
being written to the static cache — meaning once any admin visited
|
||||
`/admin` once, the admin panel was served to every subsequent visitor,
|
||||
unauthenticated, straight from `public/cache/admin/`, completely
|
||||
bypassing `AdminAuth`. Fixed in the same change by passing
|
||||
`$excludeFromCache = true` for every `/admin/*` route too, not just
|
||||
drafts. Documented as a standing rule in `AGENTS.md` (next to the
|
||||
`mb_substr`/`|slice` and `|escape('js')` notes) and at
|
||||
`/admin/docs/drafts`/`/admin/docs/caching`: any future mechanism that
|
||||
conditionally hides page content from the public has to make the same
|
||||
check, not just gate the initial request.
|
||||
|
||||
### Session handling (with flash sessions)
|
||||
|
||||
- **Type:** Feature
|
||||
- **Status:** Done
|
||||
- **Priority:** High
|
||||
- **Added:** 2026-07-12
|
||||
- **Shipped:** 2026-07-14
|
||||
|
||||
A `Lib\` wrapper around PHP's native session handling (`session_start()`,
|
||||
`$_SESSION`, not a custom session store) — `Lib\Session`
|
||||
(`novaconium/lib/Session.php`), all-static and lazy-start, same shape as
|
||||
the already-shipped `Lib\Csrf` (which also touches the native session; the
|
||||
two coexist in the same request without conflict). Ships
|
||||
`get()`/`set()`/`has()`/`remove()` plus CodeIgniter-style flash data
|
||||
(`flash()`/`getFlash()`) — a value set now that's readable on exactly the
|
||||
next request, then gone, for post/redirect/GET flows like
|
||||
`App/pages/contact/index.php`'s hand-rolled `?sent=1` (not refactored to
|
||||
use it in this change — cited in the original spec as the motivating
|
||||
example, not a mandate to touch working demo code). The flash mechanism is
|
||||
a single per-request swap (snapshot last request's flash bucket into an
|
||||
in-memory static on first touch, then clear the stored bucket so this
|
||||
request's `flash()` calls fill a fresh one for the request after), not a
|
||||
separate expiry/sweep pass — verified end-to-end across three real,
|
||||
separate HTTP requests sharing a cookie jar (not three calls in one PHP
|
||||
process), confirming a flashed value appears on exactly the next request
|
||||
and is gone on the one after. Foundational alongside SQLite groundwork —
|
||||
admin login (next up) depends on it for logged-in state. Documented at
|
||||
`/admin/docs/session` and in `AGENTS.md` next to the `Lib\Csrf`/`Lib\Db`
|
||||
sections.
|
||||
|
||||
### MySQL support
|
||||
|
||||
- **Type:** Feature
|
||||
- **Status:** Done
|
||||
- **Priority:** Medium
|
||||
- **Depends on:** SQLite groundwork (Done)
|
||||
- **Added:** 2026-07-12
|
||||
- **Shipped:** 2026-07-14
|
||||
|
||||
Let a project point `Lib\Db` at MySQL — but went further than the original
|
||||
spec ("point the `Db` wrapper at MySQL instead of SQLite"): a real
|
||||
requirement surfaced during implementation that a single request may need
|
||||
**both** at once (sidecars have full access to any `Lib\` class, so nothing
|
||||
stops one from querying this site's own SQLite data and a legacy MySQL
|
||||
database in the same request). So `Lib\Db` was redesigned around multiple,
|
||||
independently-configured, simultaneously-open named connections
|
||||
(`config['db_connections']`, keyed by name — `'default'` is the only
|
||||
required one) rather than one global connection switched by a single
|
||||
`db_driver` key. This superseded the flat `db_driver`/`db_path`/
|
||||
`db_migrations_dir` keys the SQLite groundwork entry above originally
|
||||
shipped with (which had no downstream consumers yet, so no migration path
|
||||
was needed). `Db::query(string $sql, array $params = [], string
|
||||
$connection = 'default')` and `Db::connection(string $name = 'default')`
|
||||
both default to `'default'` so the common single-database case reads the
|
||||
same as before; a third/first argument targets any other configured
|
||||
connection. Each connection has its own lazy PDO connect (`'sqlite'` and
|
||||
`'mysql'` drivers implemented), its own optional `migrations_dir`, and its
|
||||
own independent `schema_migrations` table — verified for real (not just by
|
||||
inspection) by running a local MariaDB instance alongside the existing
|
||||
SQLite connection and executing queries against both from the same script.
|
||||
`db_connections` needed one deliberate exception to the project's usual
|
||||
shallow config-merge rule — merged one level deeper, by connection name, so
|
||||
an `App/config.php` adding a `legacy` connection doesn't silently delete
|
||||
the framework's `default` one — documented in `AGENTS.md` and
|
||||
`/admin/docs/database`, including the exact "capture defaults before the
|
||||
top-level `array_merge()` overwrites them" ordering bug hit once while
|
||||
building this (caught by the end-to-end MySQL test, not by review).
|
||||
|
||||
### SQLite groundwork
|
||||
|
||||
- **Type:** Feature
|
||||
- **Status:** Done
|
||||
- **Priority:** High
|
||||
- **Added:** 2026-07-12
|
||||
- **Shipped:** 2026-07-14
|
||||
|
||||
Laid the groundwork for optional SQLite storage: `Lib\Db`
|
||||
(`novaconium/lib/Db.php`) is a thin, no-ORM PDO wrapper (prepared
|
||||
statements only, no string-interpolation helper ever, per `Lib\Input`'s
|
||||
existing documented security stance) so features that need persistence —
|
||||
404 tracking (see Won't Do; superseded by Matomo before this shipped),
|
||||
admin login, blog tags, internal search, and anything future — have a
|
||||
common place to store data instead of ad hoc flat files. Data lives in a
|
||||
new top-level `data/` directory — deliberately outside both `public/`
|
||||
(would be web-accessible) and `novaconium/` (gets wholesale-replaced by the
|
||||
"Updating the framework" workflow documented at
|
||||
`/admin/docs/getting-started`, so anything persisted there would be
|
||||
destroyed by the next update) — gitignored per-file, with a tracked
|
||||
`.gitkeep`. Designed driver-agnostic (no SQLite-only SQL in the mechanism
|
||||
itself) specifically so MySQL support wouldn't need a retrofit — see that
|
||||
entry below (shipped 2026-07-14) for the connection/config/migration API,
|
||||
which superseded the single-connection shape (`db_driver`/`db_path`/
|
||||
`db_migrations_dir` config keys) this entry originally shipped with.
|
||||
|
||||
### Copy-to-clipboard button on code blocks
|
||||
|
||||
- **Type:** Feature
|
||||
- **Status:** Done
|
||||
- **Priority:** Low
|
||||
- **Added:** 2026-07-13
|
||||
- **Shipped:** 2026-07-14
|
||||
|
||||
Every `<pre><code>` block across `/admin/docs/*` and the blog's reference
|
||||
posts (Twig Syntax Guide, Style Guide) is meant to be copy-pasted — added 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 event-delegation
|
||||
pattern as the dark/light theme toggle (`novaconium/pages/_layout/nav.twig`).
|
||||
Implemented as a single site-wide partial
|
||||
(`novaconium/pages/_layout/code-copy.twig`, included from
|
||||
`_layout/layout.twig`'s footer) that injects a button into every `<pre>`
|
||||
containing a `<code>` on `DOMContentLoaded`, rather than touching each doc
|
||||
page's markup individually. Added `copy`/`check` icons to
|
||||
`novaconium/pages/_layout/icons.twig` and matching styles in
|
||||
`novaconium/sass/main.sass` (hover/focus-revealed button, `.copied` state).
|
||||
Icon markup reaches JS via two `<template>` elements read through
|
||||
`.innerHTML`, not Twig's `|escape('js')` — that filter calls
|
||||
`Twig\Runtime\mb_ord()` and fatals without the `mbstring` extension, hit
|
||||
for real once on a bare-PHP install; see the standing rule added to
|
||||
`AGENTS.md` next to the existing `|slice`/`mb_substr` gotcha.
|
||||
Copies via `code.textContent`, not `innerHTML`, so HTML-entity-escaped
|
||||
samples (e.g. `<h1>` in the SEO starter template) come out as literal
|
||||
characters rather than escaped markup. Shows a "Copied!" label/checkmark
|
||||
for 1.5s after a successful copy.
|
||||
|
||||
## 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.
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
use Lib\Db;
|
||||
use Lib\Validate;
|
||||
|
||||
require __DIR__ . '/../autoload.php';
|
||||
|
||||
// Creates an admin user from the command line (e.g. from a deploy script,
|
||||
// or to fix a lockout) — the CLI counterpart to /admin/users, and the way
|
||||
// to create the first user *before* flipping admin_auth_enabled on, which
|
||||
// avoids the brief open-access setup window /admin/users otherwise relies
|
||||
// on (see /admin/docs/admin-auth).
|
||||
//
|
||||
// php novaconium/bin/create-admin-user.php <username> <email>
|
||||
//
|
||||
// The password is read from stdin — typed at the prompt (echo suppressed
|
||||
// where the terminal supports it), or piped:
|
||||
//
|
||||
// echo 'the-password' | php novaconium/bin/create-admin-user.php admin admin@example.com
|
||||
//
|
||||
// Deliberately not gated on admin_auth_enabled: creating the row is
|
||||
// harmless while the gate is off, and doing it first is the safer order.
|
||||
|
||||
$username = trim((string) ($argv[1] ?? ''));
|
||||
$email = Validate::isEmail((string) ($argv[2] ?? ''));
|
||||
if ($username === '' || strlen($username) > 64 || $email === false) {
|
||||
fwrite(STDERR, "Usage: php novaconium/bin/create-admin-user.php <username> <email>\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Db::connection() runs pending migrations on first touch, so the users
|
||||
// table exists after this even on a fresh clone.
|
||||
$exists = (bool) Db::query('SELECT EXISTS(SELECT 1 FROM users WHERE username = ?)', [$username])->fetchColumn();
|
||||
if ($exists) {
|
||||
fwrite(STDERR, "A user named '{$username}' already exists.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$emailExists = (bool) Db::query('SELECT EXISTS(SELECT 1 FROM users WHERE email = ?)', [$email])->fetchColumn();
|
||||
if ($emailExists) {
|
||||
fwrite(STDERR, "A user with the email '{$email}' already exists.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$interactive = stream_isatty(STDIN);
|
||||
if ($interactive) {
|
||||
fwrite(STDOUT, "Password for '{$username}': ");
|
||||
// Suppress echo while the password is typed; restore afterwards.
|
||||
// shell_exec() may be unavailable/no-op on some setups — then the
|
||||
// password just echoes, same as any basic CLI prompt.
|
||||
shell_exec('stty -echo 2> /dev/null');
|
||||
}
|
||||
|
||||
$password = rtrim((string) fgets(STDIN), "\r\n");
|
||||
|
||||
if ($interactive) {
|
||||
shell_exec('stty echo 2> /dev/null');
|
||||
fwrite(STDOUT, "\n");
|
||||
}
|
||||
|
||||
if (strlen($password) < 8) {
|
||||
fwrite(STDERR, "Use a password of at least 8 characters.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Always role 'admin', as the script name says — /admin/users is the
|
||||
// place to create registered users; this exists for first-user setup and
|
||||
// lockout recovery, both of which need an admin.
|
||||
Db::query(
|
||||
"INSERT INTO users (username, email, password_hash, role, user_group, is_disabled, created_at) VALUES (?, ?, ?, 'admin', '', 0, ?)",
|
||||
[$username, $email, password_hash($password, PASSWORD_DEFAULT), gmdate('Y-m-d\TH:i:s\Z')]
|
||||
);
|
||||
|
||||
echo "User '{$username}' created.\n";
|
||||
echo "If admin auth isn't enabled yet, set 'admin_auth_enabled' => true in App/config.php.\n";
|
||||
@@ -0,0 +1,102 @@
|
||||
<?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 keywords %}{% endblock %}
|
||||
{% block tags %}{% endblock %}
|
||||
{% block changefreq %}monthly{% endblock %}
|
||||
{% block priority %}0.5{% 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";
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use App\ContentIndexer;
|
||||
|
||||
require __DIR__ . '/../autoload.php';
|
||||
|
||||
$config = require __DIR__ . '/../config.php';
|
||||
|
||||
$appConfigFile = __DIR__ . '/../../App/config.php';
|
||||
if (is_file($appConfigFile)) {
|
||||
$config = array_merge($config, require $appConfigFile);
|
||||
}
|
||||
|
||||
if (!$config['content_index_enabled']) {
|
||||
echo "content_index_enabled is false — nothing to do. See /admin/docs/content-index.\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
// Ignores content_index_auto (the lazy-vs-CLI-only toggle) — this script
|
||||
// is the explicit-trigger path, so it always reindexes when run.
|
||||
ContentIndexer::reindex();
|
||||
|
||||
echo "Content index rebuilt.\n";
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Lib\Db;
|
||||
|
||||
require __DIR__ . '/../autoload.php';
|
||||
|
||||
// Db::connection() applies any pending migrations for that connection as a
|
||||
// side effect of opening it (see Lib\Db::migrate()) — this script triggers
|
||||
// that explicitly for every configured connection, e.g. from a deploy
|
||||
// script, without serving a request first.
|
||||
$config = require __DIR__ . '/../config.php';
|
||||
|
||||
$appConfigFile = __DIR__ . '/../../App/config.php';
|
||||
if (is_file($appConfigFile)) {
|
||||
$appConfig = require $appConfigFile;
|
||||
$defaultConnections = $config['db_connections'];
|
||||
$appConnections = $appConfig['db_connections'] ?? [];
|
||||
$config = array_merge($config, $appConfig);
|
||||
$config['db_connections'] = array_merge($defaultConnections, $appConnections);
|
||||
}
|
||||
|
||||
foreach (array_keys($config['db_connections']) as $name) {
|
||||
Db::connection($name);
|
||||
echo "Migrated connection '{$name}'.\n";
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?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. resolves the URL to a page directory (Router)
|
||||
* 3. gates /admin/* behind session login, if enabled (AdminAuth)
|
||||
* 4. 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');
|
||||
}
|
||||
|
||||
$requestUri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||
|
||||
// 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);
|
||||
|
||||
// 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 = (bool) $config['admin_auth_enabled'];
|
||||
|
||||
$cache = new Cache($config['cache_dir']);
|
||||
$renderer = new Renderer($config['pages_dirs'], $cache, $adminAuthEnabled, $matomoUrl, $config['matomo_site_id'], $config['site_name'], $config['content_index_enabled']);
|
||||
|
||||
// Every route under /admin/* — clear-cache, docs, users, 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. Two steps: nobody
|
||||
// logged in → redirect to the login form (requireLogin() exits); logged
|
||||
// in but not an admin (a 'registered' user — see /admin/docs/admin-auth)
|
||||
// → the same plain 404 an unmatched route gets, since bouncing an
|
||||
// already-authenticated user back to the login form would be a lie (what
|
||||
// they lack is the admin role, not a session). The one exemption is the
|
||||
// login form itself, which has to stay reachable logged-out or
|
||||
// requireLogin()'s redirect to it would loop forever. No-op (open access)
|
||||
// when admin_auth_enabled is false (the default), or while no users exist
|
||||
// yet (so the first user can be created at /admin/users). See
|
||||
// novaconium/src/AdminAuth.php and /admin/docs/admin-auth.
|
||||
$isAdminRoute = $route->found && ($route->dir === 'admin' || str_starts_with((string) $route->dir, 'admin/'));
|
||||
if ($isAdminRoute && $route->dir !== 'admin/login') {
|
||||
AdminAuth::requireLogin($config['admin_auth_enabled']);
|
||||
|
||||
if (!AdminAuth::isAdmin($config['admin_auth_enabled'])) {
|
||||
$renderer->renderNotFound($requestUri);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// A route listed in draft_routes is only visible to a logged-in admin —
|
||||
// anyone else (including logged-in registered users) gets treated exactly
|
||||
// like a route that doesn't exist at all (a plain 404, not a login
|
||||
// prompt), so a draft's existence isn't revealed to anyone poking at the
|
||||
// URL. See /admin/docs/drafts. Reuses the same access check /admin/* uses
|
||||
// (AdminAuth::isAdmin()) — an admin logs in once at /admin/login, and the
|
||||
// session cookie covers draft URLs too, since it's scoped to the whole
|
||||
// origin.
|
||||
$isDraftRoute = $route->found && in_array($route->dir, $config['draft_routes'], true);
|
||||
|
||||
// $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 — except for drafts and $isAdminRoute (see
|
||||
// Renderer::render()'s $excludeFromCache param). Every /admin/* route is
|
||||
// excluded from the cache for the same reason a draft is: a sidecar-less
|
||||
// admin page (e.g. novaconium/pages/admin/index.twig) would otherwise get
|
||||
// written to public/cache/ as plain HTML the first time an authenticated
|
||||
// admin visited it, and .htaccess serves a cached file before PHP (and
|
||||
// therefore AdminAuth::requireLogin()) ever runs again — permanently
|
||||
// serving the admin panel to anyone, unauthenticated, straight from the
|
||||
// static cache. See novaconium/src/Renderer.php.
|
||||
if (!$route->found || ($isDraftRoute && !AdminAuth::isAdmin($config['admin_auth_enabled']))) {
|
||||
$renderer->renderNotFound($requestUri);
|
||||
return;
|
||||
}
|
||||
|
||||
$renderer->render($route, $requestUri, $isDraftRoute || $isAdminRoute);
|
||||
@@ -0,0 +1,104 @@
|
||||
<?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, users, and any future
|
||||
// admin page) behind a session login against the `users` table on
|
||||
// Lib\Db's default connection — see /admin/docs/admin-auth. The first
|
||||
// user created is the admin; users after that are 'registered', each
|
||||
// with an optional group, and see whatever content sidecars grant via
|
||||
// Lib\Access (see /admin/docs/access-control) — /admin/* itself 404s
|
||||
// for them. Off by default because it depends on SQLite (same
|
||||
// reasoning as content_index_enabled below): when false, /admin/* is
|
||||
// wide open, /admin/login, /admin/logout, and /admin/users 404,
|
||||
// Access::require() allows everything, and nothing ever touches
|
||||
// Lib\Db because of this feature. After enabling it via
|
||||
// App/config.php, create the first user at /admin/users (open access
|
||||
// until at least one user exists) or with:
|
||||
// php novaconium/bin/create-admin-user.php <username>
|
||||
'admin_auth_enabled' => false,
|
||||
|
||||
// Lib\Db (see /admin/docs/database) — named, simultaneously-usable
|
||||
// connections, keyed by name; 'default' is the only one required. A
|
||||
// sidecar can use more than one at once, e.g. Db::query(...) (default)
|
||||
// alongside Db::query(..., 'legacy'). Supported drivers: 'sqlite',
|
||||
// 'mysql'. The default connection's path deliberately lives outside
|
||||
// both public/ (must never be web-accessible) and novaconium/ (gets
|
||||
// wholly replaced on a framework update — see
|
||||
// /admin/docs/getting-started's "Updating the framework" section) — a
|
||||
// top-level data/ directory, project-owned like App/, is the only safe
|
||||
// place for it. migrations_dir is optional per connection (omit it to
|
||||
// never run migrations against that connection, e.g. a read-only
|
||||
// legacy database) and accepts either one path or an ordered list of
|
||||
// roots — the default connection lists novaconium/migrations/ (framework
|
||||
// -shipped schema, e.g. the content index — see /admin/docs/content-index)
|
||||
// before App/migrations/ (project migrations), so framework migrations
|
||||
// always apply first. NOTE: unlike every other key here, App/config.php
|
||||
// merges into db_connections one level deeper than a normal shallow
|
||||
// override — see the comment on Lib\Db::config() — so adding a second
|
||||
// connection there doesn't require repeating 'default'.
|
||||
'db_connections' => [
|
||||
'default' => [
|
||||
'driver' => 'sqlite',
|
||||
'path' => __DIR__ . '/../data/novaconium.sqlite',
|
||||
'migrations_dir' => [
|
||||
__DIR__ . '/migrations',
|
||||
__DIR__ . '/../App/migrations',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
// Routes an admin can preview before the public can see them (see
|
||||
// /admin/docs/drafts) — a list of Route::$dir-format paths, no leading
|
||||
// slash, e.g. 'blog/upcoming-post'. Not authenticated as admin (per
|
||||
// AdminAuth::isAuthenticated()) → 404, same as a route that doesn't
|
||||
// exist at all, so a draft's existence isn't revealed to anyone
|
||||
// poking at the URL. Authenticated → renders normally, and — critically
|
||||
// — is never written to the static HTML cache regardless of whether
|
||||
// the page has a sidecar (see Renderer::render()'s $isDraft param),
|
||||
// since a world-readable cached copy would otherwise permanently leak
|
||||
// the draft the first time an admin previewed it.
|
||||
'draft_routes' => [],
|
||||
|
||||
// Content index (see /admin/docs/content-index) — backs /sitemap.xml,
|
||||
// /search, and blog tag browsing. Off by default: all three depend on
|
||||
// SQLite (Lib\Db), a real dependency plenty of sites built on this
|
||||
// framework won't want at all, the same reasoning that keeps Matomo
|
||||
// and admin auth off by default above. When false, all three routes
|
||||
// 404 exactly as if they didn't exist, and nothing ever touches
|
||||
// Lib\Db because of this feature — no data/novaconium.sqlite gets
|
||||
// created just because the code exists. content_index_auto only
|
||||
// matters once enabled: true (the default) reindexes lazily,
|
||||
// on-demand, the first time a stale index is actually needed (never on
|
||||
// a normal page view); false disables that and leaves indexing
|
||||
// entirely to `php novaconium/bin/index-content.php`, e.g. from a
|
||||
// deploy step.
|
||||
'content_index_enabled' => false,
|
||||
'content_index_auto' => true,
|
||||
];
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace Lib;
|
||||
|
||||
use App\AdminAuth;
|
||||
use App\Response;
|
||||
|
||||
/**
|
||||
* Sidecar-level access control for page content — the way a page (or a
|
||||
* whole section, one line per page) is assigned to a user, a group, or
|
||||
* just "anyone logged in". See /admin/docs/access-control. Usage, at the
|
||||
* top of a sidecar:
|
||||
*
|
||||
* if ($denied = Access::require('group:members')) {
|
||||
* return $denied;
|
||||
* }
|
||||
*
|
||||
* Rules: 'group:<name>' (the user's users.user_group matches), or
|
||||
* 'user:<username>' (that exact account); several rules mean "any of
|
||||
* these". No rules at all means any logged-in user. Admins always pass
|
||||
* every rule. Returns null when the request may proceed, or a Response
|
||||
* for the sidecar to return: a 303 to /admin/login (with a ?return= path
|
||||
* back here) when nobody is logged in, or a plain 404 when someone *is*
|
||||
* logged in but isn't allowed — same hide-don't-tease posture as draft
|
||||
* pages, and the same plain-text 404 /search returns when disabled.
|
||||
*
|
||||
* Public is the default, twice over: a sidecar that never calls this is
|
||||
* untouched, and a page with no sidecar at all *can't* call it — static
|
||||
* (cached) pages are always public. That's load-bearing, not incidental:
|
||||
* only sidecar-less pages are ever written to the static HTML cache
|
||||
* (which .htaccess serves before PHP runs — see /admin/docs/caching), so
|
||||
* a gated page, necessarily having a sidecar, is never cached. This
|
||||
* satisfies the caching/auth standing rule in AGENTS.md by construction
|
||||
* rather than by a bootstrap.php exclusion like drafts/admin need.
|
||||
*
|
||||
* Same open-until-configured posture as the rest of admin auth: with
|
||||
* admin_auth_enabled off, or while no users exist yet, require() allows
|
||||
* everything (there'd be nothing to log in as) — and never touches
|
||||
* Lib\Db, so a site that never opted in never gets a database file.
|
||||
*
|
||||
* The content-index crawl runs every sidecar as an anonymous GET, so a
|
||||
* gated page's sidecar short-circuits to the login redirect during a
|
||||
* crawl — Renderer::renderForIndex() discards Responses, meaning gated
|
||||
* pages are automatically absent from /search and /sitemap.xml, with no
|
||||
* extra wiring. require() has no side effects on deny (the return path
|
||||
* travels in the redirect URL, not the session) for the same reason: a
|
||||
* crawl must not scribble on the visitor's session.
|
||||
*/
|
||||
final class Access
|
||||
{
|
||||
private static ?bool $enabled = null;
|
||||
|
||||
public static function require(string ...$rules): ?Response
|
||||
{
|
||||
if (!self::enabled() || !AdminAuth::hasUsers()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = AdminAuth::currentUser();
|
||||
|
||||
if ($user === null) {
|
||||
$path = (string) (parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/');
|
||||
|
||||
return Response::redirect('/admin/login?return=' . rawurlencode($path), 303);
|
||||
}
|
||||
|
||||
if ($user['role'] === 'admin' || $rules === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($rules as $rule) {
|
||||
if (str_starts_with($rule, 'user:') && substr($rule, 5) === $user['username']) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($rule, 'group:') && $user['user_group'] !== '' && substr($rule, 6) === $user['user_group']) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return Response::html('404 Not Found', 404);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same two-step config load bootstrap.php/bin scripts and the
|
||||
* /search sidecar use — Lib\ classes aren't handed $config, so this
|
||||
* loads its own copy to read admin_auth_enabled (memoized per
|
||||
* request; static state never survives across requests).
|
||||
*/
|
||||
private static function enabled(): bool
|
||||
{
|
||||
if (self::$enabled === null) {
|
||||
$config = require __DIR__ . '/../config.php';
|
||||
|
||||
$appConfigFile = __DIR__ . '/../../App/config.php';
|
||||
if (is_file($appConfigFile)) {
|
||||
$config = array_merge($config, require $appConfigFile);
|
||||
}
|
||||
|
||||
self::$enabled = (bool) $config['admin_auth_enabled'];
|
||||
}
|
||||
|
||||
return self::$enabled;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?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 was the first thing in the framework to start 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. Lib\Session and
|
||||
* App\AdminAuth (session-based admin login) now touch the same native
|
||||
* session the same lazy way — safe in any order, since ensureSession()
|
||||
* no-ops when a session is already active.
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
<?php
|
||||
|
||||
namespace Lib;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* A thin PDO wrapper — the SQLite/MySQL groundwork tracked in
|
||||
* novaconium/ISSUES.md. No ORM, no query builder, consistent with this
|
||||
* project's no-Composer, no-build-step philosophy: just lazily-opened PDO
|
||||
* connections plus a minimal per-connection migration runner.
|
||||
*
|
||||
* Supports multiple, simultaneously-open, independently-configured named
|
||||
* connections (config['db_connections'], keyed by name) rather than one
|
||||
* global connection — because sidecars are plain PHP with full access to
|
||||
* any Lib\ class, a single request may legitimately need more than one
|
||||
* database at once (e.g. this site's own SQLite data plus a MySQL
|
||||
* connection to a legacy/external database). The common single-database
|
||||
* case still reads the same as a single-connection API would:
|
||||
* Db::query('SELECT ...', [...]) always targets the 'default' connection
|
||||
* unless a different connection name is passed explicitly.
|
||||
*
|
||||
* Db::query() is the only query-running helper, and it only ever accepts a
|
||||
* SQL string plus a params array for PDO to bind — there is deliberately no
|
||||
* string-interpolation convenience method. See Lib\Input's doc-comment: the
|
||||
* only real defense against SQL injection is parameterized queries, never
|
||||
* string concatenation or sanitize-then-interpolate, however "cleaned" input
|
||||
* looks. Call Db::connection() directly for anything Db::query() doesn't
|
||||
* cover (transactions, lastInsertId(), etc.) — it returns the raw PDO
|
||||
* instance for the named connection.
|
||||
*
|
||||
* Lazy-connect, same shape as Lib\Csrf's lazy session start: nothing opens
|
||||
* a database connection or runs a migration until the first real call to a
|
||||
* given connection name, so a request that never touches a particular
|
||||
* database never pays for it.
|
||||
*/
|
||||
final class Db
|
||||
{
|
||||
/** @var array<string, PDO> */
|
||||
private static array $connections = [];
|
||||
|
||||
public static function connection(string $name = 'default'): PDO
|
||||
{
|
||||
return self::$connections[$name] ??= self::connect($name);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string,mixed> $params
|
||||
*/
|
||||
public static function query(string $sql, array $params = [], string $connection = 'default'): \PDOStatement
|
||||
{
|
||||
$statement = self::connection($connection)->prepare($sql);
|
||||
$statement->execute($params);
|
||||
|
||||
return $statement;
|
||||
}
|
||||
|
||||
private static function connect(string $name): PDO
|
||||
{
|
||||
$connections = self::config()['db_connections'];
|
||||
|
||||
if (!isset($connections[$name])) {
|
||||
throw new RuntimeException(
|
||||
"No db_connections entry named '{$name}' in config — configured connections: " .
|
||||
(empty($connections) ? '(none)' : implode(', ', array_keys($connections)))
|
||||
);
|
||||
}
|
||||
|
||||
$connectionConfig = $connections[$name];
|
||||
$driver = $connectionConfig['driver'] ?? null;
|
||||
|
||||
$pdo = match ($driver) {
|
||||
'sqlite' => self::connectSqlite($connectionConfig),
|
||||
'mysql' => self::connectMysql($connectionConfig),
|
||||
default => throw new RuntimeException(
|
||||
"Connection '{$name}' has unsupported driver " .
|
||||
(is_string($driver) ? "'{$driver}'" : 'null') . " — only 'sqlite' and 'mysql' are implemented."
|
||||
),
|
||||
};
|
||||
|
||||
self::migrate($pdo, $connectionConfig['migrations_dir'] ?? null);
|
||||
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $config
|
||||
*/
|
||||
private static function connectSqlite(array $config): PDO
|
||||
{
|
||||
self::requireDriver('sqlite', 'pdo_sqlite',
|
||||
'bundled with PHP but sometimes not enabled — Debian/Ubuntu: `apt install php-sqlite3`; Arch: uncomment `extension=pdo_sqlite` in php.ini');
|
||||
|
||||
$path = $config['path'];
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0775, true);
|
||||
}
|
||||
|
||||
$pdo = new PDO('sqlite:' . $path, options: [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
$pdo->exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $config
|
||||
*/
|
||||
private static function connectMysql(array $config): PDO
|
||||
{
|
||||
self::requireDriver('mysql', 'pdo_mysql',
|
||||
'Debian/Ubuntu: `apt install php-mysql`; Arch: uncomment `extension=pdo_mysql` in php.ini');
|
||||
|
||||
$charset = $config['charset'] ?? 'utf8mb4';
|
||||
$dsn = "mysql:host={$config['host']};port={$config['port']};dbname={$config['database']};charset={$charset}";
|
||||
|
||||
return new PDO($dsn, $config['username'], $config['password'], [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A missing PDO driver otherwise surfaces as a bare PDOException
|
||||
* ("could not find driver") from deep inside a connect call — hit for
|
||||
* real the first time this ran on a PHP install without pdo_sqlite
|
||||
* enabled. Checking PDO::getAvailableDrivers() up front turns that
|
||||
* into an error that names the extension and how to install it.
|
||||
*/
|
||||
private static function requireDriver(string $driver, string $extension, string $installHint): void
|
||||
{
|
||||
if (!in_array($driver, PDO::getAvailableDrivers(), true)) {
|
||||
throw new RuntimeException(
|
||||
"PHP is missing the {$extension} extension, which this connection's '{$driver}' driver needs ({$installHint}). " .
|
||||
'Verify with `php -m`, and restart PHP after enabling it. See /admin/docs/database.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies any *.sql file under $migrationsDirs not yet recorded in this
|
||||
* connection's own schema_migrations table. $migrationsDirs is an
|
||||
* ordered list of roots (a single string is accepted too, wrapped
|
||||
* internally) — each root's own files are applied in filename order
|
||||
* within that root (numeric prefixes, e.g. 0001_create_x.sql,
|
||||
* 0002_add_y.sql, control order within a root), and roots are fully
|
||||
* processed one at a time in the order given, not interleaved by
|
||||
* filename across roots. This lets framework-shipped migrations (e.g.
|
||||
* novaconium/migrations/) always apply before a project's own
|
||||
* (App/migrations/) on the same connection — see
|
||||
* novaconium/config.php's db_connections.default.migrations_dir and
|
||||
* AGENTS.md.
|
||||
*
|
||||
* Each file is tracked once applied and never re-run, keyed by its path
|
||||
* relative to the repo root (e.g. novaconium/migrations/0001_x.sql) —
|
||||
* not bare filename, because two roots can each contain a same-named
|
||||
* file (a framework migration and an unrelated project migration both
|
||||
* numbered 0001_...); tracking by bare filename would make the second
|
||||
* one seen look "already applied" and silently skip it. A relative
|
||||
* path is also portable across environments, unlike a full absolute
|
||||
* path, which would make every migration look "new" again after a
|
||||
* clone/deploy to a different directory.
|
||||
*
|
||||
* A connection with no migrations_dir set skips this entirely — e.g. a
|
||||
* connection to a legacy database this project shouldn't manage schema
|
||||
* for. Runs automatically on every first connection() call per
|
||||
* process, per connection name — cheap (one query plus a directory
|
||||
* glob per root), so no separate "migrate" step is required, matching
|
||||
* the framework's zero-config philosophy elsewhere (e.g. static
|
||||
* caching). novaconium/bin/migrate.php exists to run it explicitly for
|
||||
* every configured connection (e.g. from a deploy script) without
|
||||
* serving a request first.
|
||||
*
|
||||
* @param string|string[]|null $migrationsDirs
|
||||
*/
|
||||
private static function migrate(PDO $pdo, string|array|null $migrationsDirs): void
|
||||
{
|
||||
if ($migrationsDirs === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$pdo->exec(
|
||||
'CREATE TABLE IF NOT EXISTS schema_migrations (' .
|
||||
'filename VARCHAR(255) PRIMARY KEY, ' .
|
||||
'applied_at VARCHAR(32) NOT NULL' .
|
||||
')'
|
||||
);
|
||||
|
||||
$applied = $pdo->query('SELECT filename FROM schema_migrations')->fetchAll(PDO::FETCH_COLUMN);
|
||||
$applied = array_flip($applied);
|
||||
|
||||
// novaconium/lib/ -> novaconium/ -> repo root, two levels up.
|
||||
$repoRoot = rtrim(realpath(dirname(__DIR__, 2)) ?: dirname(__DIR__, 2), '/') . '/';
|
||||
|
||||
foreach ((array) $migrationsDirs as $dir) {
|
||||
if (!is_dir($dir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$files = glob(rtrim($dir, '/') . '/*.sql') ?: [];
|
||||
sort($files);
|
||||
|
||||
foreach ($files as $file) {
|
||||
// realpath() resolves any ".." left over from a
|
||||
// migrations_dir like __DIR__ . '/../App/migrations' (glob()
|
||||
// doesn't normalize the paths it returns), so the tracked
|
||||
// name is clean, e.g. "App/migrations/0001_x.sql" rather
|
||||
// than "novaconium/../App/migrations/0001_x.sql".
|
||||
$resolved = realpath($file) ?: $file;
|
||||
$trackedName = str_starts_with($resolved, $repoRoot) ? substr($resolved, strlen($repoRoot)) : basename($file);
|
||||
|
||||
if (isset($applied[$trackedName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pdo->exec((string) file_get_contents($file));
|
||||
|
||||
$insert = $pdo->prepare('INSERT INTO schema_migrations (filename, applied_at) VALUES (?, ?)');
|
||||
$insert->execute([$trackedName, gmdate('Y-m-d\TH:i:s\Z')]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads config the same way bootstrap.php/bin scripts do (framework
|
||||
* defaults shallow-merged with App/config.php), except for
|
||||
* db_connections specifically: a shallow array_merge would let a
|
||||
* project's App/config.php silently drop the framework's 'default'
|
||||
* connection just by adding a second named connection (array_merge
|
||||
* replaces the whole key, it doesn't merge inside it). db_connections
|
||||
* is therefore merged one level deeper, by connection name, so adding
|
||||
* e.g. 'legacy' in App/config.php doesn't require repeating 'default'.
|
||||
* This is the one config key in the project that isn't plain
|
||||
* shallow-merge — see AGENTS.md.
|
||||
*
|
||||
* @return array{db_connections: array<string, array<string, mixed>>}
|
||||
*/
|
||||
private static function config(): array
|
||||
{
|
||||
$config = require __DIR__ . '/../config.php';
|
||||
|
||||
$appConfigFile = __DIR__ . '/../../App/config.php';
|
||||
if (is_file($appConfigFile)) {
|
||||
$appConfig = require $appConfigFile;
|
||||
$defaultConnections = $config['db_connections'];
|
||||
$appConnections = $appConfig['db_connections'] ?? [];
|
||||
$config = array_merge($config, $appConfig);
|
||||
$config['db_connections'] = array_merge($defaultConnections, $appConnections);
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?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. Lib\Db (see
|
||||
* /admin/docs/database) is the database layer — its query() method uses
|
||||
* PDO prepared statements exclusively for this reason. 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 or verified) 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 (or failing a login whose password actually matches). See
|
||||
* the password fields in novaconium/pages/admin/users/index.php and
|
||||
* novaconium/pages/admin/login/index.php for the places 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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace Lib;
|
||||
|
||||
/**
|
||||
* A minimal RSS 2.0 envelope builder — plain string concatenation, no
|
||||
* DOMDocument, same style as novaconium/pages/sitemap.xml/index.php.
|
||||
* Generic on purpose (title/link/description/items in, XML string out) —
|
||||
* it doesn't know about blog posts specifically; App/pages/blog/feed/ and
|
||||
* App/pages/blog/tag/[tag]/feed/ are the two call sites that supply
|
||||
* blog-shaped data to it.
|
||||
*
|
||||
* Links/guids are expected to be site-relative paths (e.g.
|
||||
* "/blog/hello-world"), consistent with how this framework already
|
||||
* handles canonical/og:url (see /admin/docs/seo) — there's no site-wide
|
||||
* base-URL config to build absolute URLs from. Every <guid> is emitted
|
||||
* with isPermaLink="false" for exactly this reason: it's a stable
|
||||
* identifier, not a real absolute permalink.
|
||||
*/
|
||||
final class Rss
|
||||
{
|
||||
/**
|
||||
* @param array<int, array{title: string, link: string, guid: string, pubDateTimestamp: int, description: string}> $items
|
||||
*/
|
||||
public static function render(string $channelTitle, string $channelLink, string $channelDescription, array $items): string
|
||||
{
|
||||
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
|
||||
$xml .= '<rss version="2.0">' . "\n";
|
||||
$xml .= ' <channel>' . "\n";
|
||||
$xml .= ' <title>' . htmlspecialchars($channelTitle, ENT_XML1) . '</title>' . "\n";
|
||||
$xml .= ' <link>' . htmlspecialchars($channelLink, ENT_XML1) . '</link>' . "\n";
|
||||
$xml .= ' <description>' . htmlspecialchars($channelDescription, ENT_XML1) . '</description>' . "\n";
|
||||
|
||||
foreach ($items as $item) {
|
||||
$xml .= ' <item>' . "\n";
|
||||
$xml .= ' <title>' . htmlspecialchars($item['title'], ENT_XML1) . '</title>' . "\n";
|
||||
$xml .= ' <link>' . htmlspecialchars($item['link'], ENT_XML1) . '</link>' . "\n";
|
||||
$xml .= ' <guid isPermaLink="false">' . htmlspecialchars($item['guid'], ENT_XML1) . '</guid>' . "\n";
|
||||
$xml .= ' <pubDate>' . date(DATE_RSS, $item['pubDateTimestamp']) . '</pubDate>' . "\n";
|
||||
$xml .= ' <description>' . htmlspecialchars($item['description'], ENT_XML1) . '</description>' . "\n";
|
||||
$xml .= ' </item>' . "\n";
|
||||
}
|
||||
|
||||
$xml .= ' </channel>' . "\n";
|
||||
$xml .= '</rss>' . "\n";
|
||||
|
||||
return $xml;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace Lib;
|
||||
|
||||
/**
|
||||
* A thin wrapper around PHP's native session handling — session_start()
|
||||
* etc., not a custom session store — so sidecars have a consistent
|
||||
* get/set/flash API instead of touching $_SESSION directly. All-static,
|
||||
* lazy-start like Lib\Csrf: nothing calls session_start() until the first
|
||||
* real call to any method here, so a page that never touches Session (or
|
||||
* Csrf, which starts a session the same way) never gets a session cookie.
|
||||
*
|
||||
* ensureSession()'s body is deliberately duplicated from Csrf::ensureSession()
|
||||
* rather than extracted into a shared helper — keeps Csrf standalone with
|
||||
* zero new dependencies rather than coupling it to a class that didn't
|
||||
* exist when it shipped, consistent with this project's tolerance for
|
||||
* small duplication over premature coupling (see the config-load block
|
||||
* duplicated across bootstrap.php/bin/clear-cache.php/Lib\Db::config()).
|
||||
* Both classes touching the same native session in the same request is
|
||||
* safe either way — session_status() guards against a double session_start().
|
||||
*
|
||||
* Flash data: a value set now via flash() is readable via getFlash() on
|
||||
* exactly the next request, then gone — for post/redirect/GET flows like
|
||||
* "message sent" banners, without a query-string flag. See
|
||||
* /admin/docs/session for the mechanism and a worked example.
|
||||
*/
|
||||
final class Session
|
||||
{
|
||||
private const FLASH_KEY = '_flash';
|
||||
|
||||
private static bool $flashLoaded = false;
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
private static array $currentFlash = [];
|
||||
|
||||
public static function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
self::ensureSession();
|
||||
|
||||
return $_SESSION[$key] ?? $default;
|
||||
}
|
||||
|
||||
public static function set(string $key, mixed $value): void
|
||||
{
|
||||
self::ensureSession();
|
||||
|
||||
$_SESSION[$key] = $value;
|
||||
}
|
||||
|
||||
public static function has(string $key): bool
|
||||
{
|
||||
self::ensureSession();
|
||||
|
||||
return isset($_SESSION[$key]);
|
||||
}
|
||||
|
||||
public static function remove(string $key): void
|
||||
{
|
||||
self::ensureSession();
|
||||
|
||||
unset($_SESSION[$key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Swaps the session id for a fresh one, keeping the session's data.
|
||||
* Call on any privilege change — after a successful login, and on
|
||||
* logout — so a session id an attacker planted or observed before the
|
||||
* change is worthless after it (session fixation). App\AdminAuth does
|
||||
* exactly this.
|
||||
*/
|
||||
public static function regenerate(): void
|
||||
{
|
||||
self::ensureSession();
|
||||
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores $value so it's readable via getFlash($key) on the next
|
||||
* request only, then gone — regardless of whether getFlash() was
|
||||
* actually called on that next request.
|
||||
*/
|
||||
public static function flash(string $key, mixed $value): void
|
||||
{
|
||||
self::ensureSession();
|
||||
|
||||
$_SESSION[self::FLASH_KEY][$key] = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a value flashed on the previous request. Never reflects a
|
||||
* value flashed during this same request — that value will be
|
||||
* readable on the next request instead.
|
||||
*/
|
||||
public static function getFlash(string $key, mixed $default = null): mixed
|
||||
{
|
||||
self::ensureSession();
|
||||
|
||||
return self::$currentFlash[$key] ?? $default;
|
||||
}
|
||||
|
||||
private static function ensureSession(): void
|
||||
{
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
// 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();
|
||||
}
|
||||
|
||||
// Runs once per request, on whichever Session method is called
|
||||
// first: snapshot last request's flash bucket for this request's
|
||||
// getFlash() reads, then immediately reset the session's bucket so
|
||||
// flash() calls made during this request go to a fresh bucket —
|
||||
// the one the *next* request will snapshot. This single swap is
|
||||
// the entire flash mechanism; no separate expiry/sweep step needed,
|
||||
// since static properties don't persist across requests.
|
||||
if (!self::$flashLoaded) {
|
||||
self::$currentFlash = $_SESSION[self::FLASH_KEY] ?? [];
|
||||
$_SESSION[self::FLASH_KEY] = [];
|
||||
self::$flashLoaded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE IF NOT EXISTS content_pages (
|
||||
route TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
keywords TEXT NOT NULL DEFAULT '',
|
||||
changefreq TEXT NOT NULL DEFAULT '',
|
||||
priority TEXT NOT NULL DEFAULT '',
|
||||
source_mtime INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS content_tags (
|
||||
route TEXT NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
PRIMARY KEY (route, tag)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_content_tags_tag ON content_tags(tag);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS content_search USING fts5(route UNINDEXED, title, body);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS content_index_meta (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
newest_source_mtime INTEGER NOT NULL,
|
||||
indexed_at TEXT NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'registered',
|
||||
user_group TEXT NOT NULL DEFAULT '',
|
||||
is_disabled INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
@@ -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 %}
|
||||
@@ -0,0 +1,58 @@
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
{# Adds a hover-revealed copy button to every <pre><code> block on the
|
||||
page, without touching any individual doc/blog page's markup. Reads
|
||||
textContent (not innerHTML) when copying, so HTML-entity-escaped
|
||||
samples (e.g. <h1> in the SEO starter template) come out as their
|
||||
literal, unescaped characters rather than the escaped markup.
|
||||
|
||||
The icon markup is passed to JS via <template> elements (plain HTML
|
||||
output, default autoescaping) rather than Twig's `|escape('js')`
|
||||
filter — that filter calls Twig\Runtime\mb_ord() under the hood, which
|
||||
hard-requires the mbstring extension and fatals
|
||||
(`Call to undefined function Twig\Runtime\mb_ord()`) without it, the
|
||||
same class of mbstring gotcha documented in AGENTS.md for `|slice` on
|
||||
strings. #}
|
||||
<template id="copy-code-icon-copy">{{ icons.copy() }}<span class="copy-code-label">Copy</span></template>
|
||||
<template id="copy-code-icon-copied">{{ icons.check() }}<span class="copy-code-label">Copied!</span></template>
|
||||
<script>
|
||||
(function () {
|
||||
var copyIconHtml = document.getElementById('copy-code-icon-copy').innerHTML;
|
||||
var checkIconHtml = document.getElementById('copy-code-icon-copied').innerHTML;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.querySelectorAll('pre').forEach(function (pre) {
|
||||
if (!pre.querySelector('code')) {
|
||||
return;
|
||||
}
|
||||
|
||||
var button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'copy-code-button icon-link';
|
||||
button.setAttribute('aria-label', 'Copy code to clipboard');
|
||||
button.innerHTML = copyIconHtml + '<span class="copy-code-label">Copy</span>';
|
||||
pre.appendChild(button);
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
var button = event.target.closest('.copy-code-button');
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
var code = button.closest('pre').querySelector('code');
|
||||
|
||||
navigator.clipboard.writeText(code.textContent).then(function () {
|
||||
var originalHtml = button.innerHTML;
|
||||
|
||||
button.innerHTML = checkIconHtml + '<span class="copy-code-label">Copied!</span>';
|
||||
button.classList.add('copied');
|
||||
|
||||
setTimeout(function () {
|
||||
button.innerHTML = originalHtml;
|
||||
button.classList.remove('copied');
|
||||
}, 1500);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,168 @@
|
||||
{# 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, copy, check.
|
||||
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 users(class) %}
|
||||
<svg class="icon icon-users {{ 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="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</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 %}
|
||||
|
||||
{% macro copy(class) %}
|
||||
<svg class="icon icon-copy {{ 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="9" y="9" width="11" height="11" rx="1.5" />
|
||||
<path d="M5.5 15H4.5A1.5 1.5 0 0 1 3 13.5v-9A1.5 1.5 0 0 1 4.5 3h9A1.5 1.5 0 0 1 15 4.5v1" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro check(class) %}
|
||||
<svg class="icon icon-check {{ 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.5 12.5 9.5 17.5 19.5 6.5" />
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
@@ -0,0 +1,76 @@
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
{% include '_layout/theme-init.twig' %}
|
||||
{% include '_layout/syntax-highlight-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 %}">
|
||||
<meta name="keywords" content="{% block keywords %}{% endblock %}">
|
||||
<link rel="canonical" href="{% block canonical %}{{ request_path|default('/') }}{% endblock %}">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
|
||||
{# tags/changefreq/priority (below) are metadata-only, not meant to be
|
||||
visible on the page. A block tag always emits its content wherever
|
||||
it's declared, though — and a Twig comment tag can't wrap a block
|
||||
tag (Twig comments are stripped before parsing, so a nested block
|
||||
tag inside one would never compile; don't put literal Twig
|
||||
delimiter syntax inside a Twig comment's text either, for the same
|
||||
reason — it terminates the comment early). So these three are
|
||||
wrapped in a real HTML comment instead: invisible to a
|
||||
reader/browser, but still genuine Twig blocks, overridable per-page
|
||||
and harvestable by ContentIndexer via Twig's renderBlock() API
|
||||
exactly like every SEO block above. See /admin/docs/content-index. #}
|
||||
<!--
|
||||
{% block tags %}{% endblock %}
|
||||
{% block changefreq %}monthly{% endblock %}
|
||||
{% block priority %}0.5{% endblock %}
|
||||
-->
|
||||
|
||||
|
||||
{# 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' %}
|
||||
|
||||
{# Open-ended extension point for anything a subtree's own layout
|
||||
needs in <head> that doesn't fit an existing named block — e.g.
|
||||
App/pages/blog/_layout/layout.twig overrides this with a
|
||||
<link rel="alternate" type="application/rss+xml"> for feed
|
||||
auto-discovery, scoped to /blog/* only since only that layout
|
||||
overrides it. Empty by default, so nothing changes for a page that
|
||||
doesn't need it. #}
|
||||
{% block head_extra %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
{% include '_layout/nav.twig' %}
|
||||
</header>
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<footer>
|
||||
<small>© {{ "now"|date("Y") }} {{ site_name }}</small>
|
||||
<nav class="footer-menu">
|
||||
{% if content_index_enabled %}<a class="icon-link" href="/sitemap.xml">{{ icons.sitemap() }}Sitemap</a>{% endif %}
|
||||
<a class="icon-link" href="/blog/feed">{{ icons.rss() }}RSS Feed</a>
|
||||
</nav>
|
||||
</footer>
|
||||
{% include '_layout/code-copy.twig' %}
|
||||
{% include '_layout/syntax-highlight.twig' %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,18 @@
|
||||
{# Creates the highlight.js theme <link> with the correct href before
|
||||
paint, so switching to light doesn't flash the dark (ir-black) code
|
||||
theme first — same FOUC-avoidance trick theme-init.twig uses for the
|
||||
main palette. Must run after theme-init.twig (data-theme needs to
|
||||
already be set on <html>) and before the stylesheet link — see
|
||||
novaconium/pages/_layout/layout.twig. The live swap (when the toggle
|
||||
button is clicked after page load) is handled separately by
|
||||
novaconium/pages/_layout/syntax-highlight.twig's MutationObserver. #}
|
||||
<script>
|
||||
(function () {
|
||||
var isLight = document.documentElement.getAttribute('data-theme') === 'light';
|
||||
var link = document.createElement('link');
|
||||
link.id = 'hljs-theme';
|
||||
link.rel = 'stylesheet';
|
||||
link.href = isLight ? '/vendor/highlightjs/styles/github.min.css' : '/vendor/highlightjs/styles/ir-black.min.css';
|
||||
document.head.appendChild(link);
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,52 @@
|
||||
{# Colors <pre><code> blocks site-wide via vendored highlight.js
|
||||
(public/vendor/highlightjs/ — see /admin/docs/upgrading-highlightjs),
|
||||
auto-detected and restricted to only the languages this site actually
|
||||
uses (hljs.configure below) so it doesn't waste cycles or misfire
|
||||
trying to match ~40 bundled languages against a handful of short
|
||||
snippets. php/bash/css/python/javascript/xml ship in the core
|
||||
highlight.min.js bundle; yaml/json/ini don't (checked, not assumed —
|
||||
see /admin/docs/upgrading-highlightjs) and are vendored as separate
|
||||
per-language files under languages/, loaded after the core bundle so
|
||||
their hljs.registerLanguage(...) self-registration calls have a global
|
||||
hljs to register against. Twig-syntax code blocks have no highlight.js
|
||||
grammar and are NOT auto-detected against — forcing one through the
|
||||
restricted candidate set above would still force-match it to whichever
|
||||
configured language scores highest, coloring it *wrong* rather than
|
||||
leaving it plain. Those blocks are marked class="nohighlight" by hand
|
||||
at the source (a real highlight.js convention meaning "skip this block
|
||||
entirely") — see AGENTS.md for which files have them and why.
|
||||
|
||||
The copy-to-clipboard button (code-copy.twig) needs no changes for
|
||||
this: it already reads code.textContent, not innerHTML, which stays
|
||||
the original plain text regardless of the <span> wrapping
|
||||
highlightAll() adds.
|
||||
|
||||
hljs.highlightAll() does NOT defer itself if called while the document
|
||||
is still parsing (document.readyState === "loading") — it just silently
|
||||
no-ops, permanently, rather than waiting and retrying. Confirmed this
|
||||
with a real DOM test, not assumed: calling it immediately (unwrapped)
|
||||
produced zero highlighted blocks even though this script tag sits near
|
||||
the end of <body>, since the document can still be mid-parse at that
|
||||
exact point. So this is wrapped in the same DOMContentLoaded pattern
|
||||
code-copy.twig already uses for its own button injection, rather than
|
||||
called directly. #}
|
||||
<script src="/vendor/highlightjs/highlight.min.js"></script>
|
||||
<script src="/vendor/highlightjs/languages/yaml.min.js"></script>
|
||||
<script src="/vendor/highlightjs/languages/json.min.js"></script>
|
||||
<script src="/vendor/highlightjs/languages/ini.min.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
hljs.configure({ languages: ['php', 'bash', 'xml', 'css', 'python', 'javascript', 'yaml', 'json', 'ini'] });
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
hljs.highlightAll();
|
||||
});
|
||||
|
||||
var themeLink = document.getElementById('hljs-theme');
|
||||
|
||||
new MutationObserver(function () {
|
||||
var isLight = document.documentElement.getAttribute('data-theme') === 'light';
|
||||
themeLink.href = isLight ? '/vendor/highlightjs/styles/github.min.css' : '/vendor/highlightjs/styles/ir-black.min.css';
|
||||
}).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
})();
|
||||
</script>
|
||||
@@ -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,40 @@
|
||||
{% 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/database">{{ icons.book() }}Database</a></li>
|
||||
<li><a class="icon-link" href="/admin/docs/session">{{ icons.book() }}Session</a></li>
|
||||
<li><a class="icon-link" href="/admin/docs/content-index">{{ icons.search() }}Content index</a></li>
|
||||
<li><a class="icon-link" href="/admin/docs/sitemap">{{ icons.sitemap() }}XML sitemap</a></li>
|
||||
<li><a class="icon-link" href="/admin/docs/rss">{{ icons.rss() }}RSS feeds</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/access-control">{{ icons.lock() }}Access control</a></li>
|
||||
<li><a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}Draft pages</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>
|
||||
<li><a class="icon-link" href="/admin/docs/upgrading-highlightjs">{{ icons.book() }}Upgrading highlight.js</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="docs-content">
|
||||
{% block docs_content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,70 @@
|
||||
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
{% block title %}Access control{% endblock %}
|
||||
|
||||
{% block description %}Assign a page or section to a user or group from its sidecar with Lib\Access — public by default, static pages always public.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block docs_content %}
|
||||
<h1>Access control</h1>
|
||||
|
||||
<p><code>Lib\Access</code> (<code>novaconium/lib/Access.php</code>) assigns a page to a user, a group, or just "anyone logged in" — from the page's own sidecar, using the accounts <a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a> manages at <a href="/admin/users">/admin/users</a>. One call at the top of <code>index.php</code>:</p>
|
||||
|
||||
<pre><code><?php
|
||||
|
||||
use Lib\Access;
|
||||
|
||||
if ($denied = Access::require('group:members')) {
|
||||
return $denied;
|
||||
}
|
||||
|
||||
return [
|
||||
// ...normal sidecar context...
|
||||
];</code></pre>
|
||||
|
||||
<p><code>Access::require()</code> returns <code>null</code> when the request may proceed, or a <code>Response</code> the sidecar returns as-is: nobody logged in → a <code>303</code> to <a href="/admin/login">/admin/login</a> carrying a <code>?return=</code> path so a successful login lands right back on the page they wanted; logged in but not allowed → a plain <code>404</code>, the same hide-don't-tease posture as <a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}draft pages</a>.</p>
|
||||
|
||||
<h2>Rules</h2>
|
||||
|
||||
<ul>
|
||||
<li><code>Access::require()</code> — no rules: any logged-in user (admin or registered).</li>
|
||||
<li><code>Access::require('group:members')</code> — users whose group (assigned at <a href="/admin/users">/admin/users</a>) is <code>members</code>. Each user has at most one group; a group is just a text label, matched exactly — there's no groups table to manage.</li>
|
||||
<li><code>Access::require('user:bob')</code> — exactly that account.</li>
|
||||
<li><code>Access::require('group:members', 'user:bob')</code> — several rules mean <em>any</em> of them grants access.</li>
|
||||
</ul>
|
||||
|
||||
<p><strong>Admins always pass every rule</strong> — the admin role exists to run the site, so there's no way to write a rule that locks an admin out of content.</p>
|
||||
|
||||
<h2>Public is the default — and static pages are always public</h2>
|
||||
|
||||
<p>A sidecar that never calls <code>Access::require()</code> is completely untouched by all of this, and a page with no sidecar at all <em>can't</em> call it — so sidecar-less pages are always public. That's load-bearing rather than incidental: only sidecar-less pages are ever written to the <a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}static HTML cache</a>, which Apache serves before PHP (and therefore any access check) runs. Because a gated page necessarily has a sidecar, it's never statically cached, so there's no way to leak a gated page through the cache — the caching/auth rule that drafts and <code>/admin/*</code> need explicit cache exclusions for is satisfied here by construction.</p>
|
||||
|
||||
<h2>Gating a section</h2>
|
||||
|
||||
<p>There's no per-directory config for this — a "section" is gated by giving each page in it a sidecar with the same check, which stays visible and greppable at the page level. To keep the rule itself in one place, put a <code>_access.php</code> file in the section directory (any file that isn't <code>index.php</code>/<code>index.twig</code> is invisible to the router) and <code>require</code> it from each sidecar — a PHP file can return a value, so it composes exactly like a direct call:</p>
|
||||
|
||||
<pre><code><?php
|
||||
// App/pages/members/_access.php — the section's one shared rule
|
||||
use Lib\Access;
|
||||
|
||||
return Access::require('group:members');</code></pre>
|
||||
|
||||
<pre><code><?php
|
||||
// App/pages/members/anything/index.php — each page in the section
|
||||
if ($denied = require dirname(__DIR__) . '/_access.php') {
|
||||
return $denied;
|
||||
}
|
||||
|
||||
return [];</code></pre>
|
||||
|
||||
<h2>Interactions worth knowing</h2>
|
||||
|
||||
<ul>
|
||||
<li><strong>Off means open.</strong> With <code>admin_auth_enabled</code> off (or while no users exist yet), <code>Access::require()</code> allows everything — there'd be nothing to log in as. Same open-until-configured posture as the rest of admin auth, and the same zero-footprint guarantee: it never touches <code>Lib\Db</code> in that state.</li>
|
||||
<li><strong>Gated pages stay out of <a class="icon-link" href="/admin/docs/content-index">{{ icons.search() }}search and the sitemap</a> automatically.</strong> The content-index crawl runs every sidecar as an anonymous GET, so a gated sidecar short-circuits to the login redirect and the crawler skips the page — nothing to configure, verified for real. <code>require()</code> also has no side effects on deny (the return path travels in the redirect URL, not the session), so a crawl can't scribble on the visiting user's session.</li>
|
||||
<li><strong>Who's logged in?</strong> A sidecar that wants to greet the user (or vary content by account) can read <code>App\AdminAuth::currentUser()</code> — <code>id</code>/<code>username</code>/<code>email</code>/<code>role</code>/<code>user_group</code>, or <code>null</code> — and pass what it needs into its template context.</li>
|
||||
</ul>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,70 @@
|
||||
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
{% block title %}Admin authentication{% endblock %}
|
||||
|
||||
{% block description %}Session-based login with admin/registered roles, groups, and user management, gating /admin/* and draft pages.{% 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 a session-based login against a <code>users</code> table in the <a class="icon-link" href="/admin/docs/database">{{ icons.book() }}database</a>'s <code>default</code> connection. It's off by default (same posture as the <a class="icon-link" href="/admin/docs/content-index">{{ icons.search() }}content index</a>, and for the same reason: it depends on SQLite, a real dependency plenty of sites won't want): with <code>admin_auth_enabled</code> left <code>false</code>, <code>/admin/*</code> is wide open, <code>/admin/login</code>, <code>/admin/logout</code>, and <code>/admin/users</code> all <code>404</code> as if they didn't exist, and nothing ever touches <code>Lib\Db</code> because of this feature — no <code>data/novaconium.sqlite</code> gets created just because the code exists.</p>
|
||||
|
||||
<p>This replaced the original single-user HTTP Basic Auth stopgap (an <code>admin_username</code>/<code>admin_password_hash</code> config pair — both keys, and the <code>/admin/password-hash</code> hash-generator page, are gone). Same call sites in <code>bootstrap.php</code>, new mechanism: multiple accounts, real server-side login state (riding on <a class="icon-link" href="/admin/docs/session">{{ icons.book() }}<code>Lib\Session</code></a>), and user management in the browser.</p>
|
||||
|
||||
<h2>Two roles: admin and registered</h2>
|
||||
|
||||
<p>The <strong>first user ever created is the admin</strong>; every user created after that is <strong>registered</strong>. An admin runs the site: full <code>/admin/*</code> access, sees <a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}draft pages</a>, and passes every <a class="icon-link" href="/admin/docs/access-control">{{ icons.lock() }}<code>Lib\Access</code></a> rule. A registered user can log in at the same <a href="/admin/login">/admin/login</a> and sees whatever content <code>Lib\Access</code> assigns to their account or their group — but <code>/admin/*</code> returns a plain <code>404</code> for them, not a login prompt (they <em>are</em> logged in; what they lack is the role). Each registered user can be assigned at most one <strong>group</strong> — a plain text label (e.g. <code>members</code>) that <code>Access::require('group:members')</code> matches exactly; there's no groups table to manage.</p>
|
||||
|
||||
<h2>Enabling it</h2>
|
||||
|
||||
<p>Turn it on in <code>App/config.php</code>:</p>
|
||||
|
||||
<pre><code><?php
|
||||
// App/config.php
|
||||
return [
|
||||
'admin_auth_enabled' => true,
|
||||
];</code></pre>
|
||||
|
||||
<p>Since this is the first SQLite-backed feature most sites turn on, make sure PHP has the <code>pdo_sqlite</code> extension enabled first (<code>php -m | grep -i sqlite</code>) — it's bundled with PHP but not always enabled; Debian/Ubuntu package it as <code>php-sqlite3</code>. Without it, the first request into <code>/admin/*</code> fails naming the missing extension. See <a href="/admin/docs">Overview</a>'s requirements list.</p>
|
||||
|
||||
<p>Enabling the flag alone protects nothing yet — <strong>while zero users exist, the whole admin area stays open</strong>, precisely so the first user (the admin) can be created. Do that either in the browser at <a href="/admin/users">/admin/users</a> (you're logged in as the first user automatically the moment it's created, and the gate closes), or from the command line:</p>
|
||||
|
||||
<pre><code>php novaconium/bin/create-admin-user.php admin admin@example.com</code></pre>
|
||||
|
||||
<p>The CLI reads the password from stdin (echo suppressed at an interactive prompt, and pipeable from a deploy script), and always creates an <em>admin</em> — it exists for first-user setup and lockout recovery, both of which need one; registered users are created at <code>/admin/users</code>. Running it <em>before</em> flipping <code>admin_auth_enabled</code> on is the safer order — the open setup window then never exists at all.</p>
|
||||
|
||||
<p>The <code>users</code> table ships as a framework migration (<code>novaconium/migrations/0002_create_users.sql</code>) and is created automatically the first time anything touches the <code>default</code> connection — no manual schema step. Passwords are stored as <code>password_hash()</code> hashes and checked with <code>password_verify()</code>; no plaintext, and nothing here ever selects the hash back out except to verify a login. Every account also has a <strong>unique email address</strong>, stored normalized (trimmed, lowercased, via <code>Lib\Validate::isEmail()</code>) — not used for login (that's the username) or for any mail yet, but required up front so the planned email-verification flow (see <code>novaconium/ISSUES.md</code>) has an address for every account that already exists by then.</p>
|
||||
|
||||
<h2>Managing users</h2>
|
||||
|
||||
<p><a href="/admin/users">/admin/users</a> lists every account and handles the rest: create a user (registered, with an email address and an optional group — only the very first is the admin), disable/enable one, assign or change a group, promote/demote between admin and registered, change an email address, change a password, and delete an account outright. Behaviors worth knowing:</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>Disabling is immediate.</strong> A disabled user can't log in, and any session they already had is re-checked against the table on its next request — there's no "still logged in until the session expires" window. The same immediate re-check applies to a role or group change.</li>
|
||||
<li><strong>The last active admin can't be disabled, demoted, or deleted.</strong> Any of the three would lock everyone out of <code>/admin/*</code> permanently (the zero-users setup window doesn't reopen — the table isn't empty), leaving only the CLI or hand-editing the database as recovery. The form refuses instead. Registered users carry no such guard.</li>
|
||||
<li><strong>Delete is a hard delete</strong> — the row is gone, the username and email become reusable, and any live session dies on its next request, same as disabling. Disable is the right tool for "shut this account out but keep it"; delete is for accounts that shouldn't exist at all.</li>
|
||||
<li><strong>More admins are allowed</strong> — "first user is the admin" is the default, not a cap; promote a registered user any time.</li>
|
||||
</ul>
|
||||
|
||||
<h2>How it's wired</h2>
|
||||
|
||||
<p><code>novaconium/src/AdminAuth.php</code> is a pair of reusable checks 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 redirect on an unrelated 404): <code>AdminAuth::requireLogin($enabled)</code> redirects anyone not logged in to the login form, then <code>AdminAuth::isAdmin($enabled)</code> 404s anyone who <em>is</em> logged in but isn't an admin — a registered user is authenticated, so bouncing them back to the login form would be a lie. Because the checks live 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. The one exemption is <code>admin/login</code> itself, which has to stay reachable logged-out or the redirect to it would loop.</p>
|
||||
|
||||
<p>The login form is a normal page with a normal form: CSRF-protected like every other form here (see <a class="icon-link" href="/admin/docs/forms">{{ icons.email() }}Forms</a>), reading the username via <code>Lib\Input</code> and the password straight from <code>$_POST</code> (the documented exact-value exception — cleaning would strip characters like <code><</code>/<code>></code> and fail a login whose password actually matches). A successful login regenerates the session id (<code>Session::regenerate()</code>) before storing the user id, so a session id planted or observed pre-login never becomes an authenticated one — then lands on <code>?return=</code> (how <code>Lib\Access</code> sends someone back to the gated page they wanted; validated to be a local path, never another site), or, with no return path, on <code>/admin</code> for admins and the homepage for registered users.</p>
|
||||
|
||||
<p><a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}Draft pages</a> reuse <code>isAdmin()</code> directly with a different failure response (a plain <code>404</code> for <em>everyone</em> unauthorized, never a login redirect), rather than duplicating the logic. The session cookie is scoped to the whole origin, so logging in once at <code>/admin/login</code> covers draft URLs and <code>Lib\Access</code>-gated pages too.</p>
|
||||
|
||||
<p>Templates can read the <code>admin_auth_enabled</code> Twig global (it mirrors the config flag) — <code>admin/index.twig</code> uses it to show the "Admin users" and "Logout" links only when the feature is on. It's a display flag only; enforcement never depends on Twig.</p>
|
||||
|
||||
<h2>Logging out</h2>
|
||||
|
||||
<p><a href="/admin/logout">/admin/logout</a> is a real server-side logout now (its Basic Auth predecessor could only trick the browser into forgetting cached credentials with a fresh <code>401</code>): a POST — with a small GET confirm form, same shape as <code>/admin/clear-cache</code> — that drops the logged-in user id from the session, regenerates the session id, and redirects to the login form. It's deliberately not logout-on-GET: sidecars must stay side-effect-free on GET, both as ordinary HTTP hygiene and because the <a class="icon-link" href="/admin/docs/content-index">{{ icons.search() }}content index</a>'s crawl invokes every page's sidecar the way a real GET would — a logout-on-GET would end the crawling admin's own session the moment a lazy reindex rendered this page.</p>
|
||||
|
||||
<h2>What this is (and isn't)</h2>
|
||||
|
||||
<p>This is authentication plus a deliberately small authorization model: two roles and one group label per user, matched by <a class="icon-link" href="/admin/docs/access-control">{{ icons.lock() }}<code>Lib\Access</code></a> rules in sidecars — no permissions matrix, no role hierarchy, no per-admin capability flags. Registration is admin-driven only; there's no self-serve sign-up form. Sessions are PHP's native ones via <code>Lib\Session</code>, with the same cookie hardening it always applies (<code>httponly</code>, <code>SameSite=Lax</code>, <code>secure</code> on HTTPS). As with any password form, serve <code>/admin</code> over HTTPS in production.</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,26 @@
|
||||
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
{% 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/<path>/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>Two kinds of route are excluded from the cache unconditionally, regardless of whether they have a sidecar: every <a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}draft page</a> and every <code>/admin/*</code> route. Both are gated by the <a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}admin login</a>, and a cached copy would bypass that check entirely — <code>.htaccess</code> serves a cached file before PHP (and therefore any auth check) ever runs, so a cached admin or draft page would be served to anyone, unauthenticated, forever after the first authenticated view. See <a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}Draft pages</a> for the full write-up.</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">admin login gate</a> as the rest of <code>/admin/*</code> once admin auth is enabled.</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,75 @@
|
||||
{% 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_auth_enabled</code>, <code>db_connections</code>, <code>draft_routes</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><?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><title></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><?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_auth_enabled</code> (default <code>false</code>) gates every <code>/admin/*</code> route behind a session login against the <code>users</code> table — see <a href="/admin/docs/admin-auth">Admin authentication</a> for the full write-up, including roles (the first user is the admin; the rest are registered) and how the first user gets created. The same flag powers <a href="/admin/docs/access-control">Access control</a> (<code>Lib\Access</code>) for member/group-gated content. When left off, <code>/admin/*</code> is open, the login/users routes <code>404</code>, and <code>Access::require()</code> allows everything.</p>
|
||||
|
||||
<h2>Draft pages</h2>
|
||||
|
||||
<p><code>draft_routes</code> (default <code>[]</code>) is a list of routes only an authenticated admin can see — everyone else gets a plain <code>404</code>. Requires <code>admin_auth_enabled</code> above (and at least one user) to actually gate anything. See <a href="/admin/docs/drafts">Draft pages</a> for the full write-up, including why a cached draft page would be a security problem and how that's avoided.</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->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->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->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,86 @@
|
||||
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
{% block title %}Content index{% endblock %}
|
||||
|
||||
{% block description %}The shared crawler behind /sitemap.xml, /search, and blog tag browsing.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block docs_content %}
|
||||
<h1>Content index</h1>
|
||||
|
||||
<p>Three features — <a class="icon-link" href="/sitemap.xml">{{ icons.sitemap() }}/sitemap.xml</a>, <a class="icon-link" href="/search">{{ icons.search() }}/search</a>, and blog <a class="icon-link" href="/admin/docs/seo">{{ icons.tag() }}tag browsing</a> — share one underlying mechanism rather than three separate ones: a crawler (<code>App\ContentIndexer</code>, <code>novaconium/src/ContentIndexer.php</code>) that renders every routable page, pulls its metadata, and stores it in SQLite.</p>
|
||||
|
||||
<p>Content itself stays in files — plain Twig pages, same as everywhere else in this framework. Nothing here moves a page's body into a database; only metadata is extracted and indexed.</p>
|
||||
|
||||
<h2>Off by default</h2>
|
||||
|
||||
<p>All three features depend on <a class="icon-link" href="/admin/docs/database">{{ icons.book() }}SQLite</a> — a real dependency plenty of sites built on this framework won't want at all, the same reasoning that keeps Matomo and admin authentication off until a project opts in. <code>content_index_enabled</code> (default <code>false</code>) gates the whole subsystem:</p>
|
||||
|
||||
<pre><code><?php
|
||||
// App/config.php
|
||||
return [
|
||||
'content_index_enabled' => true,
|
||||
];</code></pre>
|
||||
|
||||
<p>When it's off, <code>/sitemap.xml</code>, <code>/search</code>, and every <code>/blog/tag/<tag></code> route return a plain <code>404</code> — exactly as if they didn't exist — and nothing ever touches <code>Lib\Db</code> because of this feature. <code>data/novaconium.sqlite</code> isn't created just because the code is present in the codebase; each consumer checks the flag before constructing anything that would open a connection.</p>
|
||||
|
||||
<h2>Declaring metadata on a page</h2>
|
||||
|
||||
<p>Four Twig blocks, declared in <code>novaconium/pages/_layout/layout.twig</code> next to the rest of the <a href="/admin/docs/seo">SEO blocks</a> — same override mechanism, just harvested rather than always rendered:</p>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Block</th><th>Default</th><th>Used for</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>keywords</code></td><td>empty</td><td>rendered as <code><meta name="keywords"></code> on every page (see <a href="/admin/docs/seo">SEO</a>)</td></tr>
|
||||
<tr><td><code>tags</code></td><td>empty</td><td>comma-separated; indexed into <code>content_tags</code> for tag browsing</td></tr>
|
||||
<tr><td><code>changefreq</code></td><td><code>monthly</code></td><td><code>/sitemap.xml</code>'s <code><changefreq></code></td></tr>
|
||||
<tr><td><code>priority</code></td><td><code>0.5</code></td><td><code>/sitemap.xml</code>'s <code><priority></code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<pre><code class="nohighlight">{% verbatim %}{% block tags %}yellow, the best, summer, hot{% endblock %}
|
||||
{% block changefreq %}weekly{% endblock %}
|
||||
{% block priority %}1.0{% endblock %}{% endverbatim %}</code></pre>
|
||||
|
||||
<p>See any post under <code>App/pages/blog/</code> for a working <code>tags</code> example.</p>
|
||||
|
||||
<h2>How the crawl works</h2>
|
||||
|
||||
<p><code>ContentIndexer::reindex()</code> walks every page under both page roots (<code>Overlay::listPageDirs()</code>, skipping <code>_</code>-prefixed, <code>404</code>, and <code>[param]</code>-wildcard directories — a wildcard route's concrete values aren't knowable without a data source, so dynamic routes aren't crawled yet), renders each one via <code>Renderer::renderForIndex()</code> (the same sidecar-then-Twig path a real request takes, minus HTTP output and the static cache write), and pulls each metadata block via Twig's own <code>renderBlock()</code> API — not by parsing <code>.twig</code> source — so App-over-novaconium overrides and layout inheritance resolve exactly the way they do for a real visit.</p>
|
||||
|
||||
<p>A page is skipped entirely (not stored) if it's listed in <a href="/admin/docs/drafts">{{ icons.lock() }}draft_routes</a>, or if its resolved <code>robots</code> block contains <code>noindex</code> — the same convention <a href="/admin/docs/seo">SEO</a> already documents for admin/internal pages. The rendered HTML is stripped with <code>strip_tags()</code> for a plain-text copy stored in a SQLite <a href="https://sqlite.org/fts5.html">FTS5</a> virtual table for search.</p>
|
||||
|
||||
<p>Every reindex is a full rebuild, not incremental — all three tables are truncated and repopulated inside one transaction, simple and correct at this scale rather than trying to diff what changed. Because the crawl renders every page's sidecar as a plain <code>GET</code> (forcing <code>$_SERVER['REQUEST_METHOD']</code> to <code>'GET'</code> for the duration, regardless of what triggered the reindex, and restoring it afterward), a POST-guarded sidecar action is never accidentally triggered by indexing — the same HTTP-safe-method hygiene any GET handler is already expected to have.</p>
|
||||
|
||||
<h2>When it runs</h2>
|
||||
|
||||
<p>Two paths, both calling the same <code>reindex()</code>:</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>Lazy (default):</strong> <code>ContentIndexer::ensureFresh()</code>, called from the <code>/sitemap.xml</code>, <code>/search</code>, and tag-browsing sidecars themselves — never from a normal page view, so browsing the rest of the site never pays any indexing cost. It compares the newest source-file mtime across every page (a cheap stat pass, no rendering) against the last indexed time, and only reindexes if something actually changed. Set <code>content_index_auto</code> to <code>false</code> to disable this and rely only on the CLI below.</li>
|
||||
<li><strong>Explicit:</strong> <code>php novaconium/bin/index-content.php</code> — same shape as <code>bin/migrate.php</code>, for a deploy step. Always reindexes (ignores <code>content_index_auto</code>); exits immediately if <code>content_index_enabled</code> is <code>false</code>.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Search</h2>
|
||||
|
||||
<p><code>/search?q=...</code> runs an FTS5 <code>MATCH</code> query. The search term is wrapped as a quoted phrase (embedded <code>"</code> doubled) before binding — parameter binding stops SQL injection, but the bound value is still parsed as its own FTS5 query-language expression, so an unescaped <code>"</code> or FTS operator in user input could otherwise throw a syntax error or search for something unintended.</p>
|
||||
|
||||
<h2>Sitemap</h2>
|
||||
|
||||
<p>See <a href="/admin/docs/sitemap">{{ icons.sitemap() }}XML sitemap</a> for the full write-up — per-page <code>changefreq</code>/<code>priority</code>, what's included/excluded, and a known limitation around relative <code><loc></code> URLs.</p>
|
||||
|
||||
<h2>Blog tag browsing</h2>
|
||||
|
||||
<p><code>App/pages/blog/tag/[tag]/index.php</code> — project-owned, since <code>blog/</code> itself is project content — uses the <a href="/admin/docs/routing">{{ icons.link() }}<code>[param]</code></a> capture to read the tag from the URL and queries <code>content_tags</code> joined to <code>content_pages</code>. <code>App/pages/blog/index.php</code>'s own hand-written post list is untouched by any of this — it stays the source of truth for the main blog listing; <code>content_tags</code> is a derived index built from each post's own <code>tags</code> block, not a replacement for it.</p>
|
||||
|
||||
<p><code>App/pages/blog/tag/[tag]/feed/index.php</code> is the same query rendered as an RSS feed instead of an HTML list, one directory deeper — <code>/blog/tag/<tag>/feed</code>. Unlike the main blog feed, this one depends on the content index (gated the same way <code>blog/tag/[tag]/index.php</code> itself is), since tags only exist once it's enabled. See <a href="/admin/docs/rss">{{ icons.rss() }}RSS feeds</a> for the full write-up — <code>Lib\Rss</code>, the main blog feed, and how to add more feeds for other content collections.</p>
|
||||
|
||||
<h2>Migrations, and the two-root scan</h2>
|
||||
|
||||
<p>The schema (<code>content_pages</code>, <code>content_tags</code>, <code>content_search</code>, <code>content_index_meta</code>) ships as a framework migration, <code>novaconium/migrations/0001_create_content_index.sql</code> — the first framework-owned migration, and the reason <a href="/admin/docs/database">{{ icons.book() }}<code>migrations_dir</code></a> now accepts an ordered list of roots instead of a single path: the default connection's <code>migrations_dir</code> is <code>[novaconium/migrations, App/migrations]</code>, so framework migrations always apply before a project's own on the same connection.</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,92 @@
|
||||
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
{% block title %}Database{% endblock %}
|
||||
|
||||
{% block description %}Lib\Db — a thin PDO wrapper (SQLite and MySQL) with named, simultaneous connections and a plain-SQL migration convention.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block docs_content %}
|
||||
<h1>Database</h1>
|
||||
|
||||
<p><code>Lib\Db</code> (<code>novaconium/lib/Db.php</code>) is the SQLite/MySQL groundwork tracked in <code>novaconium/ISSUES.md</code> — a thin PDO wrapper plus a minimal migration runner, no ORM and no query builder, consistent with this project's no-Composer, no-build-step philosophy. It's a <a class="icon-link" href="/admin/docs/libraries">{{ icons.book() }}Lib\</a> class like <code>Input</code>/<code>Csrf</code>/<code>Mailer</code>, so a project can override it entirely by dropping its own <code>App/lib/Db.php</code>.</p>
|
||||
|
||||
<p>It supports multiple, independently-configured, <strong>simultaneously open</strong> named connections rather than a single global one — because a sidecar is plain PHP with full access to any <code>Lib\</code> class, a single request can legitimately need more than one database at once, e.g. this site's own SQLite data alongside a MySQL connection to a legacy or external database.</p>
|
||||
|
||||
<h2>Using it</h2>
|
||||
|
||||
<pre><code>use Lib\Db;
|
||||
|
||||
// Targets the 'default' connection — reads exactly like a single-database API.
|
||||
$rows = Db::query('SELECT * FROM posts WHERE published = ?', [1])->fetchAll();
|
||||
|
||||
// A third argument targets any other configured connection by name, and can
|
||||
// be used in the same request/script as the default connection above.
|
||||
$legacyRows = Db::query('SELECT * FROM widgets', [], 'legacy')->fetchAll();</code></pre>
|
||||
|
||||
<p><code>Db::query(string $sql, array $params = [], string $connection = 'default')</code> prepares and executes against the named connection in one call, returning the <code>PDOStatement</code>. It's the only query-running helper this class exposes — there is deliberately no string-interpolation convenience method. <code>Db::connection(string $name = 'default')</code> returns the raw <code>PDO</code> instance for anything <code>query()</code> doesn't cover (transactions, <code>lastInsertId()</code>, etc.).</p>
|
||||
|
||||
<p><strong>Always use parameter binding, never string-concatenate values into SQL</strong> — the same rule <a class="icon-link" href="/admin/docs/libraries">{{ icons.book() }}Lib\Input</a>'s own documentation already commits to: cleaning input is defense-in-depth against HTML/script injection, not SQL injection, and no string transform makes arbitrary input safe to concatenate into a query. Parameterized queries are the only real defense, so <code>Db</code> never grows an <code>sqlSafe()</code>-style shortcut.</p>
|
||||
|
||||
<p>Each connection is opened lazily and independently — nothing touches a given database or runs its migrations until the first real call naming that connection, so a request that only ever uses <code>default</code> never pays to open <code>legacy</code>.</p>
|
||||
|
||||
<h2>Configuration</h2>
|
||||
|
||||
<p>Connections are a named map under a single <code>db_connections</code> key. The framework default defines only <code>default</code> (SQLite):</p>
|
||||
|
||||
<pre><code>// novaconium/config.php (framework default)
|
||||
'db_connections' => [
|
||||
'default' => [
|
||||
'driver' => 'sqlite',
|
||||
'path' => __DIR__ . '/../data/novaconium.sqlite',
|
||||
'migrations_dir' => [
|
||||
__DIR__ . '/migrations',
|
||||
__DIR__ . '/../App/migrations',
|
||||
],
|
||||
],
|
||||
],</code></pre>
|
||||
|
||||
<p>Add a MySQL connection alongside it from <code>App/config.php</code>:</p>
|
||||
|
||||
<pre><code><?php
|
||||
// App/config.php
|
||||
return [
|
||||
'db_connections' => [
|
||||
'legacy' => [
|
||||
'driver' => 'mysql',
|
||||
'host' => 'localhost',
|
||||
'port' => 3306,
|
||||
'database' => 'legacy_app',
|
||||
'username' => 'root',
|
||||
'password' => '...',
|
||||
'charset' => 'utf8mb4', // optional, defaults to utf8mb4
|
||||
'migrations_dir' => __DIR__ . '/migrations/legacy', // optional
|
||||
],
|
||||
],
|
||||
];</code></pre>
|
||||
|
||||
<p><strong>This is the one config key in the project that doesn't follow the usual shallow-merge rule.</strong> Every other <code>App/config.php</code> key replaces the framework default outright (see <a class="icon-link" href="/admin/docs/config">{{ icons.book() }}Configuration</a>) — but a plain shallow merge on <code>db_connections</code> would let the snippet above silently delete the framework's <code>default</code> connection just by adding <code>legacy</code>. So <code>Lib\Db</code> merges <code>db_connections</code> one level deeper, by connection name: the example above ends up with both <code>default</code> (SQLite, from the framework) and <code>legacy</code> (MySQL, from <code>App/config.php</code>) configured at once. To actually replace <code>default</code>, redeclare a <code>default</code> key yourself.</p>
|
||||
|
||||
<p>Only <code>'sqlite'</code> and <code>'mysql'</code> are implemented as <code>driver</code> values. <code>migrations_dir</code> is optional per connection — omit it to never run migrations against that connection (e.g. a legacy database this project shouldn't manage schema for) — and accepts either a single path (<code>legacy</code>'s example above) or an ordered list of roots (the default connection's example above), each scanned for its own <code>*.sql</code> files.</p>
|
||||
|
||||
<p>The default connection's <code>path</code> lives in a top-level <code>data/</code> directory — a sibling of <code>App/</code>, <code>novaconium/</code>, and <code>public/</code>, not nested inside any of them. This is deliberate: it can't live under <code>public/</code> (would be directly web-accessible), and it can't live under <code>novaconium/</code> either, since <a class="icon-link" href="/admin/docs/getting-started">{{ icons.book() }}updating the framework</a> means overwriting that whole directory — anything persisted there would be destroyed by the next update. <code>data/</code> is project-owned, like <code>App/</code>, and untouched by a framework update. Its contents (<code>*.sqlite</code> and the SQLite journal/WAL/SHM sidecar files) are gitignored; only a <code>.gitkeep</code> is tracked so the directory exists in a fresh clone.</p>
|
||||
|
||||
<h2>Migrations</h2>
|
||||
|
||||
<p>Plain <code>.sql</code> files under each connection's own <code>migrations_dir</code> root(s), applied in filename order within each root — name them with a numeric prefix to control ordering:</p>
|
||||
|
||||
<pre><code>-- App/migrations/0001_create_posts.sql
|
||||
CREATE TABLE posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL
|
||||
);</code></pre>
|
||||
|
||||
<p>Each file is tracked by its path relative to the repo root (e.g. <code>novaconium/migrations/0001_x.sql</code>) in that connection's own <code>schema_migrations</code> table (created automatically in that connection's database) and only ever run once — <code>default</code> and <code>legacy</code> each track their own applied migrations independently. Tracking the repo-relative path rather than the bare filename matters once a connection has more than one <code>migrations_dir</code> root: two roots can each contain a same-named file (e.g. a framework <code>0001_...sql</code> and an unrelated project <code>0001_...sql</code>) without one being mistaken for the other already having run. Migrations for a given connection apply automatically the first time it's used in a process — zero-config, the same "just works" philosophy as static caching — or explicitly for every configured connection at once, without serving a request first:</p>
|
||||
|
||||
<pre><code>php novaconium/bin/migrate.php</code></pre>
|
||||
|
||||
<p>When a connection has multiple <code>migrations_dir</code> roots, each root is fully processed in the order given — the default connection's own framework root (<code>novaconium/migrations/</code>) always applies completely before its project root (<code>App/migrations/</code>), not interleaved by filename across the two. <code>novaconium/migrations/0001_create_content_index.sql</code> (see <a href="/admin/docs/content-index">{{ icons.search() }}Content index</a>) is the first framework-shipped migration — the reason this two-root support exists at all, extending the same App-over-novaconium override pattern used for pages and lib to migrations too. Point two connections' <code>migrations_dir</code> at different directories (e.g. <code>App/migrations/</code> for <code>default</code>, <code>App/migrations/legacy/</code> for <code>legacy</code>) if their SQL genuinely diverges between drivers; otherwise the same directory works for both as long as the SQL in it is portable.</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/<segments>/index.html</code>.</li>
|
||||
<li><code>.htaccess</code> checks <code>public/cache/<REQUEST_URI>/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><?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,36 @@
|
||||
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
{% block title %}Draft pages{% endblock %}
|
||||
|
||||
{% block description %}Let an admin preview a page before the public can see it, without a second login mechanism.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block docs_content %}
|
||||
<h1>Draft pages</h1>
|
||||
|
||||
<p>List a page's route under <code>draft_routes</code> in <code>App/config.php</code> to make it visible only to an authenticated admin — anyone else gets a plain <code>404</code>, exactly as if the page didn't exist at all:</p>
|
||||
|
||||
<pre><code><?php
|
||||
// App/config.php
|
||||
return [
|
||||
'admin_auth_enabled' => true,
|
||||
'draft_routes' => ['blog/upcoming-post'],
|
||||
];</code></pre>
|
||||
|
||||
<p>Each entry matches the same path format <a class="icon-link" href="/admin/docs/routing">{{ icons.link() }}Routing</a> resolves internally — no leading slash, directory segments joined with <code>/</code> (e.g. <code>App/pages/blog/upcoming-post/</code> is listed as <code>'blog/upcoming-post'</code>).</p>
|
||||
|
||||
<h2>Not a login prompt</h2>
|
||||
|
||||
<p>An unauthenticated visitor to a draft route gets the site's normal 404 page — not a redirect to the login form like <code>/admin/*</code> gives. This is deliberate: bouncing to a login would itself reveal that something is gated at that URL. A draft is indistinguishable from a URL that was never routable in the first place.</p>
|
||||
|
||||
<p>There's no separate login flow for drafts, and none is needed — <code>AdminAuth::isAdmin()</code> (the same check <a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a>'s admin gate uses) is reused directly, so drafts are admin-only: a logged-in <em>registered</em> user gets the same 404 as everyone else (previewing unpublished work is a site-running privilege, not a membership perk — use <a class="icon-link" href="/admin/docs/access-control">{{ icons.lock() }}<code>Lib\Access</code></a> for members-only content). In practice, an admin logs in once at <code>/admin/login</code>; the session cookie is scoped to the whole origin, not a single path, so it covers later requests to a draft URL too. With <code>admin_auth_enabled</code> left off (or while no users exist yet), drafts are open to everyone, consistent with the rest of <code>/admin/*</code>.</p>
|
||||
|
||||
<h2>The caching interaction</h2>
|
||||
|
||||
<p>Sidecar-less pages normally get pre-rendered once and served as static HTML straight from <code>public/cache/</code> on every later request (see <a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a>) — <code>.htaccess</code> checks for that cached file <strong>before PHP, and therefore any auth check, ever runs</strong>. A draft page without its own sidecar would otherwise take that exact path: the moment an authenticated admin previewed it, the rendered HTML would be written to the cache as a plain file, and every subsequent visitor — authenticated or not — would be served it directly by Apache, permanently bypassing the draft gate.</p>
|
||||
|
||||
<p>Draft routes are therefore excluded from the static cache unconditionally, regardless of whether the page has a sidecar — <code>novaconium/bootstrap.php</code> passes this down to <code>Renderer::render()</code>'s <code>$excludeFromCache</code> parameter. Every <code>/admin/*</code> route gets the same exclusion, for the identical reason (most admin pages have no sidecar either).</p>
|
||||
{% 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 %}<?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 class="nohighlight">{% verbatim %}{% extends layout %}
|
||||
|
||||
{% block title %}Newsletter{% endblock %}
|
||||
{% block description %}Sign up for occasional updates.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article>
|
||||
<h1>Newsletter</h1>
|
||||
|
||||
{% if sent %}
|
||||
<p><strong>Thanks — you're signed up.</strong></p>
|
||||
{% endif %}
|
||||
|
||||
{% if securityError %}
|
||||
<p><strong>Your session expired before submitting — please try again.</strong></p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/newsletter">
|
||||
<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="email">Email</label><br>
|
||||
<input type="text" id="email" name="email" value="{{ old.email }}">
|
||||
{% if errors.email %}<br><small>{{ errors.email }}</small>{% endif %}
|
||||
</p>
|
||||
<button type="submit">Sign up</button>
|
||||
</form>
|
||||
</article>
|
||||
{% 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,64 @@
|
||||
{% 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+ (uses <code>readonly</code> constructor-promoted properties) and, for production, Apache with <code>mod_rewrite</code> and <code>AllowOverride All</code>. The <a href="/admin/docs/database">Database</a>/<a href="/admin/docs/content-index">Content index</a>/<a href="/admin/docs/admin-auth">Admin authentication</a> features are optional and off by default — see <a href="/admin/docs">Overview</a> for the extensions they need (<code>pdo_sqlite</code>, optionally <code>pdo_mysql</code>/FTS5) if you turn them on.</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>Starting a new project</h2>
|
||||
|
||||
<p>Clone this repo and drop its Git history — that's it, there's no Composer scaffold or installer:</p>
|
||||
|
||||
<pre><code>git clone --depth 1 <novaconium-repo-url> my-new-project
|
||||
cd my-new-project
|
||||
rm -rf .git
|
||||
git init
|
||||
git add -A
|
||||
git commit -m "Initial commit from novaconium template"</code></pre>
|
||||
|
||||
<p>Then replace the example content that ships under <code>App/pages/</code> (the <code>about</code>/<code>blog</code>/<code>contact</code> sample pages) with your own pages, lib classes, and config. Leave <code>novaconium/</code> and <code>public/</code> as-is.</p>
|
||||
|
||||
<h2>Updating the framework</h2>
|
||||
|
||||
<p>Because the framework core lives entirely under <code>novaconium/</code> — separate from your project's <code>App/</code> — picking up a new release is a matter of overwriting that one directory and committing the diff:</p>
|
||||
|
||||
<pre><code>git clone --depth 1 --branch <release-tag> <novaconium-repo-url> /tmp/nova-update
|
||||
rm -rf novaconium
|
||||
cp -r /tmp/nova-update/novaconium ./novaconium
|
||||
rm -rf /tmp/nova-update
|
||||
git add novaconium
|
||||
git commit -m "Update novaconium framework to <release-tag>"</code></pre>
|
||||
|
||||
<p>This is safe by construction: the override-by-path design means <code>App/</code> always wins over <code>novaconium/</code> for pages, lib classes, and Sass colors (see <a href="/admin/docs/project-layout">Project layout</a>), so an update can't clobber your project's customizations. Diff before committing to see what changed, and run <code>php novaconium/bin/clear-cache.php</code> afterward since a framework update can change rendered output.</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 %}
|
||||
@@ -0,0 +1,55 @@
|
||||
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
{% block title %}Docs{% endblock %}
|
||||
|
||||
{% block description %}Framework documentation: routing, sidecars, forms, libraries, database, session, content index, XML sitemap, RSS feeds, admin authentication, draft pages, 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>
|
||||
|
||||
<h2>Requirements</h2>
|
||||
|
||||
<p><strong>Minimum:</strong> PHP 8.1+ (uses <code>readonly</code> constructor-promoted properties) and, for production, Apache with <code>mod_rewrite</code> and <code>AllowOverride All</code> — see <a href="/admin/docs/getting-started">Getting started</a>.</p>
|
||||
|
||||
<p><strong>Optional, for the database/content-index features</strong> (<a href="/admin/docs/database">Database</a>, <a href="/admin/docs/content-index">Content index</a> — both off by default):</p>
|
||||
|
||||
<ul>
|
||||
<li>The <code>pdo_sqlite</code> extension — bundled with PHP, just needs to be enabled, no separate install. Required for <code>Lib\Db</code>'s default (SQLite) connection.</li>
|
||||
<li><code>pdo_mysql</code> — only if using a MySQL <code>db_connections</code> entry alongside or instead of SQLite.</li>
|
||||
<li>SQLite's FTS5 extension — bundled with <code>pdo_sqlite</code> on virtually every modern PHP build. Only needed for <code>/search</code>, i.e. only relevant if <code>content_index_enabled</code> is turned on.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Documentation</h2>
|
||||
|
||||
<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/database">{{ icons.book() }}Database</a> — <code>Lib\Db</code>, a thin PDO wrapper (SQLite and MySQL) with named, simultaneous connections and a plain-SQL migration convention.</li>
|
||||
<li><a class="icon-link" href="/admin/docs/session">{{ icons.book() }}Session</a> — <code>Lib\Session</code>, a thin wrapper around native PHP sessions, with CodeIgniter-style flash data.</li>
|
||||
<li><a class="icon-link" href="/admin/docs/content-index">{{ icons.search() }}Content index</a> — the shared crawler behind <code>/sitemap.xml</code>, <code>/search</code>, and blog tag browsing. Off by default.</li>
|
||||
<li><a class="icon-link" href="/admin/docs/sitemap">{{ icons.sitemap() }}XML sitemap</a> — per-page <code>changefreq</code>/<code>priority</code>, what's included, and submitting it to search engines.</li>
|
||||
<li><a class="icon-link" href="/admin/docs/rss">{{ icons.rss() }}RSS feeds</a> — <code>Lib\Rss</code>, building one feed or several for any content collection.</li>
|
||||
<li><a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a> — gate <code>/admin/*</code> behind a session login, with admin/registered roles, groups, and user management.</li>
|
||||
<li><a class="icon-link" href="/admin/docs/access-control">{{ icons.lock() }}Access control</a> — assign a page or section to a user or group from its sidecar with <code>Lib\Access</code>; public by default, static pages always public.</li>
|
||||
<li><a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}Draft pages</a> — let an admin preview a page before the public can see it, reusing the same auth check.</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>
|
||||
<li><a class="icon-link" href="/admin/docs/upgrading-highlightjs">{{ icons.book() }}Upgrading highlight.js</a> — how to bump the vendored copy, and why it's not automatic on a framework update.</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 <- framework default, used unless overridden
|
||||
App/pages/_layout/layout.twig <- overrides the site-wide default (optional)
|
||||
App/pages/blog/_layout/layout.twig <- overrides it for everything under /blog</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 class="nohighlight">{% 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 class="nohighlight">{% verbatim %}{% extends layout %}
|
||||
{% block content %}...{% endblock %}{% endverbatim %}</code></pre>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,34 @@
|
||||
{% 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())->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>
|
||||
<li><code>Lib\Db</code> — the thin no-ORM PDO wrapper behind everything database-backed here. See <a href="/admin/docs/database">Database</a>.</li>
|
||||
<li><code>Lib\Session</code> — native PHP sessions with a consistent get/set/flash API. See <a href="/admin/docs/session">Session</a>.</li>
|
||||
<li><code>Lib\Access</code> — sidecar-level access control: assign a page to a user or group (<code>Access::require('group:members')</code>). See <a href="/admin/docs/access-control">Access control</a>.</li>
|
||||
<li><code>Lib\Rss</code> — a generic RSS 2.0 envelope builder. See <a href="/admin/docs/rss">RSS feeds</a>.</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><?php
|
||||
// App/config.php
|
||||
return [
|
||||
'matomo_url' => 'https://matomo.example.com/',
|
||||
'matomo_site_id' => '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><script></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 = <requested path>/From = <referrer></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 => 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,55 @@
|
||||
{% 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/<path>/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>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> (except <code>admin/login</code> itself), 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->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,72 @@
|
||||
{% 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/<anything> ($params['id'] = <anything>)
|
||||
index.php -> optional sidecar for that page</code></pre>
|
||||
|
||||
<ul>
|
||||
<li>Every route is <code>App/pages/<segments>/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><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->dir</code> is under <code>admin</code>/<code>admin/*</code> (except <code>admin/login</code>, which must stay reachable logged-out), <code>AdminAuth::requireLogin()</code> gates it — reading <code>$route->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->found</code> is <code>false</code>, otherwise <code>render($route, ...)</code> — using <code>$route->dir</code> to find the sidecar/layout/template and <code>$route->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<string,string> — captured [param] segments, e.g.
|
||||
// ['id' => '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,104 @@
|
||||
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
{% block title %}RSS feeds{% endblock %}
|
||||
|
||||
{% block description %}Lib\Rss — building one feed, or several, for any content collection.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block docs_content %}
|
||||
<h1 class="icon-heading">{{ icons.rss() }}RSS feeds</h1>
|
||||
|
||||
<p><code>Lib\Rss</code> (<code>novaconium/lib/Rss.php</code>) is a small, generic RSS 2.0 envelope builder — plain string concatenation, no <code>DOMDocument</code>, the same style as <code>/sitemap.xml</code> (see <a href="/admin/docs/content-index">{{ icons.search() }}Content index</a>). It doesn't know anything about blog posts, or content in general — it just turns a channel title/link/description plus a list of items into an XML string. Every feed on this site, and any feed a project adds, is a plain sidecar-only page (like <code>sitemap.xml</code>/<code>search</code>) that gathers its own items from wherever they live and hands them to it.</p>
|
||||
|
||||
<h2><code>Lib\Rss::render()</code></h2>
|
||||
|
||||
<pre><code>use Lib\Rss;
|
||||
|
||||
$xml = Rss::render(
|
||||
'My Site Blog', // channel title
|
||||
'/blog', // channel link
|
||||
'Posts from My Site.', // channel description
|
||||
[
|
||||
[
|
||||
'title' => 'Post title',
|
||||
'link' => '/blog/post-slug',
|
||||
'guid' => '/blog/post-slug',
|
||||
'pubDateTimestamp' => strtotime('2026-07-11'),
|
||||
'description' => 'A short excerpt or summary.',
|
||||
],
|
||||
// ...one array per item
|
||||
]
|
||||
);
|
||||
|
||||
return Response::xml($xml);</code></pre>
|
||||
|
||||
<p><code>link</code>/<code>guid</code> are expected to be site-relative paths (e.g. <code>/blog/post-slug</code>), consistent with how this framework already handles <code>canonical</code>/<code>og:url</code> (see <a href="/admin/docs/seo">{{ icons.book() }}SEO</a>) — there's no site-wide base-URL config to build absolute URLs from. Every <code><guid></code> is emitted with <code>isPermaLink="false"</code> for exactly this reason: it's a stable identifier, not a real absolute permalink.</p>
|
||||
|
||||
<h2>The two feeds shipped with this site</h2>
|
||||
|
||||
<ul>
|
||||
<li><code>/blog/feed</code> (<code>App/pages/blog/feed/index.php</code>) — the main blog feed. Reads the same hand-written <code>$posts</code> array <code>App/pages/blog/index.php</code> itself renders from, so it has <strong>zero dependency on the content index</strong> — it works even with <code>content_index_enabled</code> left at its default <code>false</code>.</li>
|
||||
<li><code>/blog/tag/<tag>/feed</code> (<code>App/pages/blog/tag/[tag]/feed/index.php</code>) — one feed per tag, generated dynamically via the <a href="/admin/docs/routing">{{ icons.link() }}<code>[param]</code></a> capture rather than a static file per tag. This one <em>does</em> need the content index (same gate as <code>blog/tag/[tag]/index.php</code>), since tags only exist there — see <a href="/admin/docs/content-index">{{ icons.search() }}Content index</a>. Its <code><pubDate></code> is each page's <code>source_mtime</code>, a stand-in for a real publish date the content index doesn't separately track.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Adding another feed</h2>
|
||||
|
||||
<p>Nothing is registered or limited to "one feed" — any sidecar-only page that builds an item list and calls <code>Rss::render()</code> is a feed. For a second content collection (say, <code>App/pages/products/</code>, with its own hand-written array like <code>App/pages/blog/index.php</code>'s), a new <code>App/pages/products/feed/index.php</code> looks almost identical to <code>blog/feed</code>:</p>
|
||||
|
||||
<pre><code><?php
|
||||
|
||||
use App\Response;
|
||||
use Lib\Rss;
|
||||
|
||||
$config = require __DIR__ . '/../../../../novaconium/config.php';
|
||||
$appConfigFile = __DIR__ . '/../../../../App/config.php';
|
||||
if (is_file($appConfigFile)) {
|
||||
$config = array_merge($config, require $appConfigFile);
|
||||
}
|
||||
|
||||
$products = (require __DIR__ . '/../index.php')['products'];
|
||||
|
||||
$items = array_map(
|
||||
fn (array $p) => [
|
||||
'title' => $p['title'],
|
||||
'link' => '/products/' . $p['slug'],
|
||||
'guid' => '/products/' . $p['slug'],
|
||||
'pubDateTimestamp' => strtotime($p['published']),
|
||||
'description' => $p['excerpt'],
|
||||
],
|
||||
$products
|
||||
);
|
||||
|
||||
return Response::xml(Rss::render(
|
||||
$config['site_name'] . ' Products',
|
||||
'/products',
|
||||
'New products from ' . $config['site_name'] . '.',
|
||||
$items
|
||||
));</code></pre>
|
||||
|
||||
<p>Two independent feeds now exist side by side — <code>/blog/feed</code> and <code>/products/feed</code> — with no shared state, registry, or configuration between them. A project can have as many as it has content collections.</p>
|
||||
|
||||
<h2>Auto-discovery for more than one feed</h2>
|
||||
|
||||
<p><a href="/admin/docs/seo">{{ icons.book() }}SEO</a>'s <code>head_extra</code> block is how a feed gets a <code><link rel="alternate" type="application/rss+xml"></code> tag in <code><head></code> so browsers/feed readers can discover it — <code>App/pages/blog/_layout/layout.twig</code> overrides it with exactly one such tag, scoped to <code>/blog/*</code> pages only since only that layout overrides the block. A layout isn't limited to one <code><link></code> in that override — list several, each with its own <code>title</code> attribute so a feed reader can tell them apart:</p>
|
||||
|
||||
<pre><code class="nohighlight">{% verbatim %}{% block head_extra %}
|
||||
<link rel="alternate" type="application/rss+xml" title="{{ site_name }} Blog" href="/blog/feed">
|
||||
<link rel="alternate" type="application/rss+xml" title="{{ site_name }} Products" href="/products/feed">
|
||||
{% endblock %}{% endverbatim %}</code></pre>
|
||||
|
||||
<p>Where that override lives determines which pages advertise which feeds — put it in the root layout (<code>novaconium/pages/_layout/layout.twig</code>) for a feed that should be discoverable site-wide, or in a subtree's own <code>_layout/layout.twig</code> (like <code>App/pages/blog/_layout/layout.twig</code> does today) to scope it to just that subtree. A page can also declare a feed link that isn't a listing of that page's own subtree at all — there's no rule tying a <code>head_extra</code> override to the feed(s) "belonging" to that directory, it's just the natural place to put it for the common case.</p>
|
||||
|
||||
<h2>Verifying a feed</h2>
|
||||
|
||||
<p>No test suite — check a feed is well-formed XML with matching item counts before trusting it, the same way this site's own feeds were verified while being built:</p>
|
||||
|
||||
<pre><code>curl -s http://127.0.0.1:8000/blog/feed | php -r '
|
||||
$xml = stream_get_contents(STDIN);
|
||||
$parsed = simplexml_load_string($xml);
|
||||
echo $parsed === false ? "INVALID XML\n" : "valid, " . count($parsed->channel->item) . " items\n";
|
||||
'</code></pre>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,103 @@
|
||||
{% 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><head></code>: viewport, description, robots, keywords, 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><head></code>. Three more blocks — <code>tags</code>, <code>changefreq</code>, <code>priority</code> — are declared the same way but never rendered into the page at all; see <a href="/admin/docs/content-index">Content index</a> for what reads them.</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><title></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><meta name="description"></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><meta name="robots"></code></td></tr>
|
||||
<tr><td><code>keywords</code></td><td>empty</td><td><code><meta name="keywords"></code></td></tr>
|
||||
<tr><td><code>tags</code></td><td>empty</td><td>not rendered — comma-separated, harvested by <a href="/admin/docs/content-index">the content index</a> for blog tag browsing</td></tr>
|
||||
<tr><td><code>changefreq</code></td><td><code>monthly</code></td><td>not rendered — harvested for <code>/sitemap.xml</code>'s <code><changefreq></code></td></tr>
|
||||
<tr><td><code>priority</code></td><td><code>0.5</code></td><td>not rendered — harvested for <code>/sitemap.xml</code>'s <code><priority></code></td></tr>
|
||||
<tr><td><code>canonical</code></td><td><code>{{ '{{ request_path }}' }}</code></td><td><code><link rel="canonical"></code>, and reused by <code>og:url</code></td></tr>
|
||||
<tr><td><code>og_type</code></td><td><code>website</code></td><td><code><meta property="og:type"></code></td></tr>
|
||||
<tr><td><code>og_title</code></td><td><code>{{ '{{ block(\'title\') }}' }}</code></td><td><code><meta property="og:title"></code></td></tr>
|
||||
<tr><td><code>og_description</code></td><td><code>{{ '{{ block(\'description\') }}' }}</code></td><td><code><meta property="og:description"></code></td></tr>
|
||||
<tr><td><code>og_url</code></td><td><code>{{ '{{ block(\'canonical\') }}' }}</code></td><td><code><meta property="og:url"></code></td></tr>
|
||||
<tr><td><code>twitter_card</code></td><td><code>summary</code></td><td><code><meta name="twitter:card"></code></td></tr>
|
||||
<tr><td><code>twitter_title</code></td><td><code>{{ '{{ block(\'title\') }}' }}</code></td><td><code><meta name="twitter:title"></code></td></tr>
|
||||
<tr><td><code>twitter_description</code></td><td><code>{{ '{{ block(\'description\') }}' }}</code></td><td><code><meta name="twitter:description"></code></td></tr>
|
||||
<tr><td><code>head_extra</code></td><td>empty</td><td>open-ended — anything a subtree's own layout needs in <code><head></code> that doesn't fit an existing block. <code>App/pages/blog/_layout/layout.twig</code> overrides it with the blog's RSS <code><link rel="alternate"></code>, scoped to <code>/blog/*</code> only since only that layout overrides it.</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 class="nohighlight">{% 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 <path></code> scaffolds this exact template for you — see <a href="/admin/docs/getting-started">Getting started</a>.</p>
|
||||
|
||||
<pre><code class="nohighlight">{% verbatim %}{% extends layout %}
|
||||
|
||||
{% block title %}Page title{% endblock %}
|
||||
{% block description %}One or two sentences describing this page.{% endblock %}
|
||||
|
||||
{% block robots %}index, follow{% endblock %}
|
||||
{% block keywords %}{% endblock %}
|
||||
{% block tags %}{% endblock %}
|
||||
{% block changefreq %}monthly{% endblock %}
|
||||
{% block priority %}0.5{% 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>Page title</h1>
|
||||
<p>...</p>
|
||||
</article>
|
||||
{% 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><link rel="icon" href="/favicon.ico"></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,48 @@
|
||||
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
{% block title %}Session{% endblock %}
|
||||
|
||||
{% block description %}Lib\Session — a thin wrapper around native PHP sessions, with CodeIgniter-style flash data.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block docs_content %}
|
||||
<h1>Session</h1>
|
||||
|
||||
<p><code>Lib\Session</code> (<code>novaconium/lib/Session.php</code>) is a thin wrapper around PHP's native session handling — plain <code>session_start()</code>/<code>$_SESSION</code>, not a custom session store — so sidecars have a consistent get/set API instead of touching <code>$_SESSION</code> directly. It's a <a class="icon-link" href="/admin/docs/libraries">{{ icons.book() }}Lib\</a> class like <code>Input</code>/<code>Csrf</code>/<code>Mailer</code>, so a project can override it entirely by dropping its own <code>App/lib/Session.php</code>.</p>
|
||||
|
||||
<h2>Using it</h2>
|
||||
|
||||
<pre><code>use Lib\Session;
|
||||
|
||||
Session::set('user_id', 42);
|
||||
$userId = Session::get('user_id'); // 42
|
||||
$loggedIn = Session::has('user_id'); // true
|
||||
Session::remove('user_id');</code></pre>
|
||||
|
||||
<p>The session is started lazily — nothing calls <code>session_start()</code> until the first real call to any <code>Session</code> method, so a page that never touches <code>Session</code> never gets a session cookie. <a class="icon-link" href="/admin/docs/libraries">{{ icons.book() }}Lib\Csrf</a> uses the exact same lazy-start mechanism to run its own session-token CSRF protection — both classes can touch the same native session in the same request without conflict, since <code>session_start()</code> is only ever actually called once (PHP no-ops a second call).</p>
|
||||
|
||||
<p><code>Session::regenerate()</code> swaps the session id for a fresh one while keeping the session's data — call it on any privilege change, so a session id an attacker planted or observed before the change is worthless after it (session fixation). <a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a> does exactly this on login and logout.</p>
|
||||
|
||||
<h2>Flash data</h2>
|
||||
|
||||
<p>A flashed value is readable on exactly the next request, then gone — useful for post/redirect/GET flows (a "message sent" banner after a redirect) without a query-string flag like <code>?sent=1</code>:</p>
|
||||
|
||||
<pre><code>use Lib\Session;
|
||||
use App\Response;
|
||||
|
||||
// In the sidecar handling the POST:
|
||||
Session::flash('message', 'Sent! We\'ll be in touch soon.');
|
||||
return Response::redirect('/contact');
|
||||
|
||||
// In the sidecar handling the following GET (the redirect target):
|
||||
return [
|
||||
'flashMessage' => Session::getFlash('message'),
|
||||
];</code></pre>
|
||||
|
||||
<p><code>Session::getFlash($key, $default = null)</code> returns the value on the request immediately after <code>flash()</code> was called, and the default on every request after that — regardless of whether <code>getFlash()</code> was actually called on that one request in between. A value flashed during the current request is never visible to <code>getFlash()</code> during that same request; it becomes visible on the next one.</p>
|
||||
|
||||
<p>Mechanically, this is a single swap rather than a separate expiry/sweep step: the first time any <code>Session</code> method runs in a request, it snapshots whatever was flashed on the previous request into an in-memory value for that request's <code>getFlash()</code> calls, then immediately clears the stored flash bucket so <code>flash()</code> calls made during the current request start filling a fresh bucket — the one the next request will snapshot in turn.</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,261 @@
|
||||
{% 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><?php
|
||||
// App/pages/contact/index.php
|
||||
use Lib\Input;
|
||||
use Lib\Mailer;
|
||||
|
||||
$errors = [];
|
||||
$old = ['name' => '', 'email' => '', 'message' => ''];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
// ...validate Input::post() into $old/$errors...
|
||||
|
||||
if (!$errors) {
|
||||
(new Mailer())->send($old['name'], $old['email'], $old['message']);
|
||||
return \App\Response::redirect('/contact?sent=1');
|
||||
}
|
||||
}
|
||||
|
||||
return ['errors' => $errors, 'old' => $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' => true]);
|
||||
Response::xml('<root/>');
|
||||
Response::html('<h1>raw</h1>');</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. <a href="/admin/docs/database">Lib\Db</a>'s <code>query()</code> method uses PDO prepared statements exclusively for exactly this reason. <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 or verified, say — should read <code>$_POST</code> directly instead of going through <code>Input::post()</code>. Cleaning would silently strip characters like <code><</code>/<code>></code> before hashing, producing a hash that doesn't match what's actually typed later (or failing a login whose password actually matches). See the password fields in <code>novaconium/pages/admin/users/index.php</code> and <code>novaconium/pages/admin/login/index.php</code> for the places this framework does that on purpose.</p>
|
||||
|
||||
<p>A sidecar is also where a page gets assigned to a user or group: one <code>Lib\Access</code> call at the top, returning its <code>Response</code> when access is denied — see <a href="/admin/docs/access-control">Access control</a>.</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><input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}"></code></pre>
|
||||
|
||||
<p>and two extra keys in the sidecar's returned context:</p>
|
||||
|
||||
<pre><code>'csrfField' => Csrf::fieldName(),
|
||||
'csrfToken' => 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->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())
|
||||
->required($old['name'], 'name', 'Name is required.')
|
||||
->email($old['email'], 'email', 'A valid email is required.')
|
||||
->required($old['message'], 'message', 'Message is required.')
|
||||
->maxLength($old['message'], 'message', 2000, 'Message is too long.');
|
||||
|
||||
if ($validator->passes()) {
|
||||
// ...
|
||||
}
|
||||
|
||||
$errors = $validator->errors();</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, four 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 %}<?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());
|
||||
|
||||
// 4. Require a login — gate the page to a group, a user, or any
|
||||
// logged-in account before doing anything else. Access::require()
|
||||
// returns null when the visitor may proceed, or a ready-made Response
|
||||
// (a login redirect that comes back here afterwards, or a 404 for the
|
||||
// wrong account) for you to return as-is. Needs admin_auth_enabled and
|
||||
// at least one user — see /admin/docs/access-control for the rules and
|
||||
// /admin/docs/admin-auth for accounts and groups.
|
||||
// use Lib\Access;
|
||||
//
|
||||
// if ($denied = Access::require('group:members')) {
|
||||
// return $denied;
|
||||
// }
|
||||
//
|
||||
// return [
|
||||
// 'message' => 'Hello, member!',
|
||||
// ];{% endverbatim %}</code></pre>
|
||||
|
||||
<p>Only one of the numbered <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. The login gate in example 4 isn't really an alternative to the other three — it's a first line that composes with any of them: gate first, then return whatever the page normally would. Swap the rule for <code>Access::require('user:bob')</code>, several rules (any one grants access), or no rules at all for "anyone logged in"; admins always pass.</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->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->status)</code>.</li>
|
||||
<li>Sends each header in <code>$this->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->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->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->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->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->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,56 @@
|
||||
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
{% block title %}XML sitemap{% endblock %}
|
||||
|
||||
{% block description %}/sitemap.xml — generated from the content index, with per-page changefreq/priority.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block docs_content %}
|
||||
<h1 class="icon-heading">{{ icons.sitemap() }}XML sitemap</h1>
|
||||
|
||||
<p><code>/sitemap.xml</code> (<code>novaconium/pages/sitemap.xml/index.php</code>) lists every indexed page for search engine discovery, following the <a href="https://www.sitemaps.org/protocol.html">sitemaps.org protocol</a>: one <code><url></code> entry per page with <code><loc></code>, <code><lastmod></code>, <code><changefreq></code>, and <code><priority></code>. It's a framework default — a directory literally named <code>sitemap.xml</code> under <code>novaconium/pages/</code>; <code>Router</code> only ever splits the request path on <code>/</code>, so that resolves the literal <code>/sitemap.xml</code> URL correctly, no special extension-routing involved.</p>
|
||||
|
||||
<p>Sidecar-only — no <code>index.twig</code>, since <code>Response::xml(...)</code> bypasses Twig entirely (see <a href="/admin/docs/sidecars">{{ icons.book() }}Sidecars</a>' JSON-only example for the same pattern). Built entirely from the <a href="/admin/docs/content-index">{{ icons.search() }}content index</a> — it's one of the three routes gated by <code>content_index_enabled</code> (default <code>false</code>), so it 404s exactly like a route that doesn't exist until that's turned on.</p>
|
||||
|
||||
<h2>Per-page <code>changefreq</code>/<code>priority</code></h2>
|
||||
|
||||
<p>Two Twig blocks, declared in <code>novaconium/pages/_layout/layout.twig</code> alongside the rest of the <a href="/admin/docs/seo">{{ icons.book() }}SEO</a> blocks — same override mechanism, just harvested by the content index rather than rendered into the page:</p>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Block</th><th>Default</th><th>Sitemap element</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>changefreq</code></td><td><code>monthly</code></td><td><code><changefreq></code> — how often the page is expected to change (<code>always</code>/<code>hourly</code>/<code>daily</code>/<code>weekly</code>/<code>monthly</code>/<code>yearly</code>/<code>never</code>, per the protocol)</td></tr>
|
||||
<tr><td><code>priority</code></td><td><code>0.5</code></td><td><code><priority></code> — relative priority against this site's own other pages, <code>0.0</code>–<code>1.0</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<pre><code class="nohighlight">{% verbatim %}{% block changefreq %}weekly{% endblock %}
|
||||
{% block priority %}1.0{% endblock %}{% endverbatim %}</code></pre>
|
||||
|
||||
<p>A page that doesn't override either just gets the layout's defaults — nothing to set for most pages. Bump <code>priority</code> on a handful of pages that matter most (the homepage, key landing pages) rather than trying to rank every page precisely; search engines treat this as a hint, not a strict ordering.</p>
|
||||
|
||||
<h2>What's included, what isn't</h2>
|
||||
|
||||
<ul>
|
||||
<li>Only pages the content index actually indexes — see <a href="/admin/docs/content-index">{{ icons.search() }}Content index</a> for the full crawl rules. In short: any page whose resolved <code>robots</code> block contains <code>noindex</code> (every <code>/admin/*</code> page already does) or that's listed in <a href="/admin/docs/drafts">{{ icons.lock() }}draft_routes</a> is skipped.</li>
|
||||
<li><code>[param]</code>-wildcard routes (e.g. <code>blog/tag/[tag]</code>) aren't crawled at all — a wildcard's concrete values aren't knowable without a data source, so dynamic routes like individual tag pages don't get their own sitemap entries. This is a known V1 limitation, not an oversight.</li>
|
||||
<li><code><lastmod></code> is the page's own source file's mtime (<code>content_pages.source_mtime</code>), not when the sitemap itself was last regenerated — it reflects when the content actually last changed.</li>
|
||||
</ul>
|
||||
|
||||
<h2>A known limitation: relative <code><loc></code> URLs</h2>
|
||||
|
||||
<p>The sitemaps.org protocol calls for <code><loc></code> to be a fully-qualified absolute URL. This framework's <code><loc></code> values are site-relative paths instead (e.g. <code>/about</code>, not <code>https://example.com/about</code>) — consistent with how <code>canonical</code>/<code>og:url</code> already work (see <a href="/admin/docs/seo">{{ icons.book() }}SEO</a>), since there's no site-wide base-URL config to build absolute URLs from. Most tooling tolerates this, but it isn't strictly spec-compliant, and a search engine or validator that enforces the letter of the protocol may reject entries. If that matters for a given deployment, override <code>novaconium/pages/sitemap.xml/index.php</code> with your own <code>App/pages/sitemap.xml/index.php</code> (same override-by-path mechanism as any other framework default) and prefix each <code><loc></code> with the site's real domain.</p>
|
||||
|
||||
<h2>How it's built</h2>
|
||||
|
||||
<p>Calls <code>ContentIndexer::ensureFresh()</code> (the lazy reindex-if-stale check — see <a href="/admin/docs/content-index">{{ icons.search() }}Content index</a>), queries <code>content_pages</code>, and builds the XML with plain string concatenation — no <code>DOMDocument</code>, this site's scale doesn't need one. Every value is still <code>htmlspecialchars(..., ENT_XML1)</code>-escaped, since <code>route</code>/<code>changefreq</code>/<code>priority</code> all ultimately come from page-author-controlled Twig blocks, not hardcoded constants.</p>
|
||||
|
||||
<h2>Submitting it to search engines</h2>
|
||||
|
||||
<p>This framework doesn't ship a <code>public/robots.txt</code> — add one yourself with a <code>Sitemap:</code> line pointing at wherever <code>/sitemap.xml</code> ends up being served, and/or submit the URL directly through each search engine's own webmaster tools (e.g. Google Search Console). Re-submission isn't needed on every change — crawlers revisit periodically on their own, and <code><lastmod></code> is the signal that tells them what's actually changed since their last visit.</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,70 @@
|
||||
{% 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 \
|
||||
&& apt-get install -y --no-install-recommends curl ca-certificates \
|
||||
&& 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 \
|
||||
&& tar -xzf /tmp/dart-sass.tar.gz -C /usr/local/lib \
|
||||
&& ln -s /usr/local/lib/dart-sass/sass /usr/local/bin/sass \
|
||||
&& rm /tmp/dart-sass.tar.gz \
|
||||
&& apt-get purge -y curl \
|
||||
&& apt-get autoremove -y \
|
||||
&& 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><html></code> at runtime and persists the choice to <code>localStorage</code>. <code>novaconium/pages/_layout/theme-init.twig</code>, included early in <code><head></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>
|
||||
|
||||
<h2>Syntax-highlighted code blocks follow the same toggle</h2>
|
||||
|
||||
<p><a href="/admin/docs/upgrading-highlightjs">highlight.js</a> colors PHP/Bash/HTML(XML) <code><pre><code></code> blocks site-wide, and swaps between two vendored themes — <strong>ir-black</strong> (dark) and <strong>github</strong> (light) — the same way the rest of the palette does, but as a separate mechanism from the Sass variables above, since these are two plain vendored CSS files, not compiled from this project's own <code>_colors.sass</code>. <code>novaconium/pages/_layout/syntax-highlight-init.twig</code> creates the correct theme <code><link></code> before paint (same FOUC-avoidance trick as <code>theme-init.twig</code>), and <code>novaconium/pages/_layout/syntax-highlight.twig</code> watches the <code>data-theme</code> attribute with a <code>MutationObserver</code> to swap it live when the toggle button is clicked — it doesn't need to know about the toggle button itself, only the attribute it already mutates.</p>
|
||||
|
||||
<p>A code block written in Twig syntax has no highlight.js grammar to match against — those are marked <code>class="nohighlight"</code> by hand at the source rather than highlighted incorrectly. See <code>AGENTS.md</code> for which files have them.</p>
|
||||
|
||||
<h2>Copy-to-clipboard on code blocks</h2>
|
||||
|
||||
<p><code>novaconium/pages/_layout/code-copy.twig</code>, included once from <code>_layout/layout.twig</code>'s footer, injects a hover-revealed copy button into every <code><pre></code> containing a <code><code></code> on the page — no per-page markup needed. It copies via <code>code.textContent</code> (not <code>innerHTML</code>), so HTML-entity-escaped samples like <code>&lt;h1&gt;</code> in the <a href="/admin/docs/seo">SEO</a> starter template come out as literal characters, not escaped markup. Uses the same event-delegation pattern as the theme toggle in <code>_layout/nav.twig</code>: one document-level click listener rather than a listener per button.</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,15 @@
|
||||
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||
|
||||
{% block title %}Third-party{% endblock %}
|
||||
|
||||
{% block description %}Vendored Twig and highlight.js, and their licenses.{% 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>
|
||||
|
||||
<p><a href="https://highlightjs.org/">highlight.js</a> (v11.11.1) is vendored as its built distribution under <code>public/vendor/highlightjs/</code> — not <code>novaconium/vendor/</code> like Twig, since it's fetched by the browser and only <code>public/</code> is web-reachable (see <a href="/admin/docs/upgrading-highlightjs">Upgrading highlight.js</a>). Also BSD-3-Clause; the license text ships at <code>public/vendor/highlightjs/LICENSE</code>.</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,44 @@
|
||||
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||
|
||||
{% block title %}Upgrading highlight.js{% endblock %}
|
||||
|
||||
{% block description %}How to bump the vendored copy of highlight.js.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block docs_content %}
|
||||
<h1>Upgrading vendored highlight.js</h1>
|
||||
|
||||
<p>Like Twig (see <a href="/admin/docs/upgrading-twig">Upgrading Twig</a>), highlight.js is vendored by hand — no Composer, no npm, no lockfile. Upgrading is a manual copy-and-verify process.</p>
|
||||
|
||||
<h2>The one thing that makes this different from every other vendored/framework file</h2>
|
||||
|
||||
<p>Everything else under <code>novaconium/</code> gets refreshed automatically when a project runs the <a href="/admin/docs/getting-started">"Updating the framework"</a> workflow (<code>rm -rf novaconium && cp -r <new-novaconium></code>). highlight.js is vendored under <code>public/vendor/highlightjs/</code> instead, because its files are fetched by the browser and only <code>public/</code> is the Apache document root — <code>novaconium/</code> isn't web-reachable at all. <code>public/</code> is project-owned and that update workflow never touches it. <strong>A future framework release that bumps the vendored highlight.js version will not update it on an existing project automatically</strong> — re-vendoring it is a separate, manual step, following this page, even after an otherwise-routine framework update.</p>
|
||||
|
||||
<h2>What's currently vendored</h2>
|
||||
|
||||
<ul>
|
||||
<li>Version: <strong>11.11.1</strong> (see the version comment at the top of <code>public/vendor/highlightjs/highlight.min.js</code> — that comment is always the source of truth, this doc can drift).</li>
|
||||
<li>The root <code>build/highlight.min.js</code> bundle from <a href="https://github.com/highlightjs/cdn-release">highlightjs/cdn-release</a> — includes <code>php</code>, <code>bash</code>, <code>css</code>, <code>python</code>, <code>javascript</code>, and <code>xml</code> (covers HTML) out of the box.</li>
|
||||
<li>Three separate per-language files under <code>public/vendor/highlightjs/languages/</code> — <code>yaml.min.js</code>, <code>json.min.js</code>, <code>ini.min.js</code> (covers <code>.env</code>-style key/value files too) — checked, not assumed, that these aren't part of the core bundle before vendoring them separately from <code>build/languages/</code> in the same <code>cdn-release</code> repo, at the same pinned tag.</li>
|
||||
<li>Two themes: <code>public/vendor/highlightjs/styles/ir-black.min.css</code> (dark) and <code>styles/github.min.css</code> (light), swapped via the site's existing <code>data-theme</code> toggle — see <a href="/admin/docs/styling">Styling</a>.</li>
|
||||
<li><code>public/vendor/highlightjs/LICENSE</code> (BSD-3-Clause) — covers the per-language files too, same license, same repo.</li>
|
||||
</ul>
|
||||
|
||||
<p>All nine languages are why <code>hljs.configure({ languages: [...] })</code> in <code>novaconium/pages/_layout/syntax-highlight.twig</code> lists <code>['php', 'bash', 'xml', 'css', 'python', 'javascript', 'yaml', 'json', 'ini']</code> — see <a href="/blog/code-highlighting">the Code Highlighting post</a> for a worked example of each.</p>
|
||||
|
||||
<h2>How to upgrade</h2>
|
||||
|
||||
<ol>
|
||||
<li>Pick a target version from the <a href="https://github.com/highlightjs/cdn-release/tags">cdn-release tags</a> — always a stable tag (e.g. <code>11.x.y</code>), never the <code>main</code> branch, which tracks an in-progress pre-release (this project's own vendored copy was pinned from tag <code>11.11.1</code> specifically for this reason, not <code>main</code>).</li>
|
||||
<li>Download, from that same tag: <code>build/highlight.min.js</code>, <code>build/languages/yaml.min.js</code>, <code>build/languages/json.min.js</code>, <code>build/languages/ini.min.js</code>, <code>build/styles/ir-black.min.css</code>, <code>build/styles/github.min.css</code>, and <code>build/LICENSE</code>.</li>
|
||||
<li>Replace the seven files under <code>public/vendor/highlightjs/</code> wholesale (<code>highlight.min.js</code>, the three files under <code>languages/</code>, the two under <code>styles/</code>, and <code>LICENSE</code>).</li>
|
||||
<li>Confirm <code>php</code>, <code>bash</code>, <code>css</code>, <code>python</code>, and <code>javascript</code> are still present in the new core bundle (e.g. <code>grep -o '"php"' highlight.min.js</code>) — the root bundle's included-language set can change between releases; if one of these ever drops out, either vendor that language's individual file from <code>build/languages/</code> the same way <code>yaml</code>/<code>json</code>/<code>ini</code> already are, or adjust <code>hljs.configure({ languages: [...] })</code> to match what's actually available.</li>
|
||||
<li>Run the app and check: code blocks still get colored on <a href="/blog/code-highlighting">the Code Highlighting post</a> (all nine languages, one worked example each) and the theme still swaps live with the dark/light toggle, and a <code>class="nohighlight"</code> Twig-syntax block (e.g. on <code>/admin/docs/seo</code>) still renders plain, uncolored. There's no automated test suite, so this is the real regression check.</li>
|
||||
<li>Update the version note at the top of this page.</li>
|
||||
</ol>
|
||||
|
||||
<h2>Adding a new highlighted language</h2>
|
||||
|
||||
<p>If a project adds code blocks in a language outside the nine above, vendor that language's file from <code>build/languages/<name>.min.js</code> in <code>cdn-release</code> (at the same pinned tag as everything else) into <code>public/vendor/highlightjs/languages/</code>, add a <code><script src="/vendor/highlightjs/languages/<name>.min.js"></script></code> tag after the core bundle (and after the other <code>languages/</code> scripts, order between them doesn't matter) in <code>novaconium/pages/_layout/syntax-highlight.twig</code>, and add the language's name to the <code>hljs.configure({ languages: [...] })</code> call there — each language file self-registers against the global <code>hljs</code> via its own <code>hljs.registerLanguage(...)</code> call once loaded, no other wiring needed.</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 %}
|
||||
@@ -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>
|
||||
{% if admin_auth_enabled %}<li><a class="icon-link" href="/admin/users">{{ icons.users() }}Users</a></li>{% endif %}
|
||||
{% 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,108 @@
|
||||
<?php
|
||||
|
||||
use App\AdminAuth;
|
||||
use App\Response;
|
||||
use Lib\Csrf;
|
||||
use Lib\Input;
|
||||
use Lib\Session;
|
||||
|
||||
// Same two-step config load bootstrap.php/bin scripts use — this sidecar
|
||||
// isn't handed $config, so it loads its own copy to read
|
||||
// admin_auth_enabled before touching Lib\Db at all (same pattern as
|
||||
// /search reading content_index_enabled).
|
||||
$config = require __DIR__ . '/../../../config.php';
|
||||
$appConfigFile = __DIR__ . '/../../../../App/config.php';
|
||||
if (is_file($appConfigFile)) {
|
||||
$config = array_merge($config, require $appConfigFile);
|
||||
}
|
||||
|
||||
// Admin auth is off by default (depends on SQLite) — see
|
||||
// /admin/docs/admin-auth. When it's off there's nothing to log in to:
|
||||
// this route must 404 exactly like a page that doesn't exist, and never
|
||||
// construct a Lib\Db connection (which would otherwise create
|
||||
// data/novaconium.sqlite just because this file exists, even on a site
|
||||
// that never opted in).
|
||||
if (!$config['admin_auth_enabled']) {
|
||||
return Response::html('404 Not Found', 404);
|
||||
}
|
||||
|
||||
// This is the one /admin/* route bootstrap.php exempts from the admin
|
||||
// gate — it has to be reachable logged-out, or the redirect here would
|
||||
// loop. It serves registered users too, not just admins: Lib\Access (see
|
||||
// /admin/docs/access-control) sends anyone hitting a gated page here.
|
||||
|
||||
// Where to go after a successful login. Lib\Access passes the gated
|
||||
// page's path along as ?return=, carried through the form as a hidden
|
||||
// field. Local paths only — must start with '/' but not '//' (a
|
||||
// protocol-relative URL) and contain no backslash — so a crafted login
|
||||
// link can never bounce someone to another site after they've typed
|
||||
// their password here.
|
||||
$sanitizeReturn = static function (?string $path): ?string {
|
||||
if ($path === null || $path === '' || $path[0] !== '/') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($path, '//') || str_contains($path, '\\')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $path;
|
||||
};
|
||||
|
||||
// Where a login (or an already-logged-in visit) lands when there's no
|
||||
// return path: admins on the admin panel, registered users on the
|
||||
// homepage (there's nothing for them under /admin — it 404s). During the
|
||||
// zero-users setup window, the admin panel — that's where the "create
|
||||
// the first user" path starts.
|
||||
$defaultTarget = static function (): string {
|
||||
if (!AdminAuth::hasUsers()) {
|
||||
return '/admin';
|
||||
}
|
||||
|
||||
$user = AdminAuth::currentUser();
|
||||
|
||||
return $user !== null && $user['role'] === 'admin' ? '/admin' : '/';
|
||||
};
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$return = $sanitizeReturn(Input::post('return'));
|
||||
$failureUrl = '/admin/login' . ($return !== null ? '?return=' . rawurlencode($return) : '');
|
||||
|
||||
if (!Csrf::verify(Input::post('csrf_token'))) {
|
||||
Session::flash('login_error', 'Your session expired before submitting — please try again.');
|
||||
|
||||
return Response::redirect($failureUrl, 303);
|
||||
}
|
||||
|
||||
// Password read directly, not via Input::post() — cleaning would
|
||||
// silently strip characters like < and > before password_verify(),
|
||||
// failing a login the password actually matches. Same documented
|
||||
// exception /admin/users makes when hashing — see
|
||||
// /admin/docs/sidecars' "Form security" section.
|
||||
$ok = AdminAuth::attempt(
|
||||
(string) Input::post('username', ''),
|
||||
(string) ($_POST['password'] ?? '')
|
||||
);
|
||||
|
||||
if ($ok) {
|
||||
return Response::redirect($return ?? $defaultTarget(), 303);
|
||||
}
|
||||
|
||||
Session::flash('login_error', 'Wrong username or password.');
|
||||
|
||||
return Response::redirect($failureUrl, 303);
|
||||
}
|
||||
|
||||
// Already logged in (or the gate is open because no users exist yet) —
|
||||
// the form is pointless, move along.
|
||||
if (AdminAuth::isLoggedIn(true)) {
|
||||
return Response::redirect($defaultTarget(), 303);
|
||||
}
|
||||
|
||||
return [
|
||||
'return' => $sanitizeReturn(Input::get('return')),
|
||||
'error' => Session::getFlash('login_error'),
|
||||
'notice' => Session::getFlash('admin_notice'),
|
||||
'csrfField' => Csrf::fieldName(),
|
||||
'csrfToken' => Csrf::token(),
|
||||
];
|
||||
@@ -0,0 +1,37 @@
|
||||
{% extends layout %}
|
||||
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
{% block title %}Log in{% endblock %}
|
||||
|
||||
{% block description %}Log in to your account.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article>
|
||||
<h1 class="icon-heading">{{ icons.lock() }}Log in</h1>
|
||||
|
||||
{% if notice %}
|
||||
<p><strong>{{ notice }}</strong></p>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<p><strong>{{ error }}</strong></p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/admin/login">
|
||||
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||
{% if return %}<input type="hidden" name="return" value="{{ return }}">{% endif %}
|
||||
<p>
|
||||
<label for="username">Username</label><br>
|
||||
<input type="text" id="username" name="username" autocomplete="username" required>
|
||||
</p>
|
||||
<p>
|
||||
<label for="password">Password</label><br>
|
||||
<input type="password" id="password" name="password" autocomplete="current-password" required>
|
||||
</p>
|
||||
<button type="submit">Log in</button>
|
||||
</form>
|
||||
</article>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
use App\AdminAuth;
|
||||
use App\Response;
|
||||
use Lib\Csrf;
|
||||
use Lib\Input;
|
||||
use Lib\Session;
|
||||
|
||||
// Same two-step config load as /admin/login — see that sidecar. 404 when
|
||||
// admin auth is off so this route has zero footprint (no session cookie,
|
||||
// no Lib\Db touch) on a site that never opted in.
|
||||
$config = require __DIR__ . '/../../../config.php';
|
||||
$appConfigFile = __DIR__ . '/../../../../App/config.php';
|
||||
if (is_file($appConfigFile)) {
|
||||
$config = array_merge($config, require $appConfigFile);
|
||||
}
|
||||
|
||||
if (!$config['admin_auth_enabled']) {
|
||||
return Response::html('404 Not Found', 404);
|
||||
}
|
||||
|
||||
// A real page now, replacing the hardcoded pre-router special case
|
||||
// bootstrap.php needed back when logout meant tricking the browser into
|
||||
// dropping cached Basic Auth credentials. Unlike then, this is a real
|
||||
// server-side logout: the session's user id is gone afterwards, whatever
|
||||
// the browser resends.
|
||||
//
|
||||
// POST-only, with a GET confirm form, same shape as /admin/clear-cache —
|
||||
// NOT a logout-on-GET link. Sidecars must be side-effect-free on GET
|
||||
// (ordinary HTTP hygiene, and the content-index crawl relies on it: it
|
||||
// invokes every page's sidecar the way a real GET would, so a
|
||||
// logout-on-GET here would silently end the crawling admin's own session
|
||||
// the first time a lazy reindex rendered this page).
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!Csrf::verify(Input::post('csrf_token'))) {
|
||||
return Response::redirect('/admin/logout?error=security', 303);
|
||||
}
|
||||
|
||||
AdminAuth::logout();
|
||||
Session::flash('admin_notice', 'You have been logged out.');
|
||||
|
||||
return Response::redirect('/admin/login', 303);
|
||||
}
|
||||
|
||||
return [
|
||||
'securityError' => Input::get('error') === 'security',
|
||||
'csrfField' => Csrf::fieldName(),
|
||||
'csrfToken' => Csrf::token(),
|
||||
];
|
||||
@@ -0,0 +1,23 @@
|
||||
{% extends layout %}
|
||||
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
{% block title %}Log out{% endblock %}
|
||||
|
||||
{% block description %}Log out of the admin area.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article>
|
||||
<h1 class="icon-heading">{{ icons.external_link() }}Log out</h1>
|
||||
<p>Ends your admin session on the server — you'll need to log in again at <a href="/admin/login">/admin/login</a> to get back in.</p>
|
||||
{% 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">Log out</button>
|
||||
</form>
|
||||
</article>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
use App\AdminAuth;
|
||||
use App\Response;
|
||||
use Lib\Csrf;
|
||||
use Lib\Db;
|
||||
use Lib\Input;
|
||||
use Lib\Session;
|
||||
use Lib\Validate;
|
||||
|
||||
// Same two-step config load as /admin/login — see that sidecar. 404 when
|
||||
// admin auth is off, before anything here touches Lib\Db, so the feature
|
||||
// has zero footprint on a site that never opted in.
|
||||
$config = require __DIR__ . '/../../../config.php';
|
||||
$appConfigFile = __DIR__ . '/../../../../App/config.php';
|
||||
if (is_file($appConfigFile)) {
|
||||
$config = array_merge($config, require $appConfigFile);
|
||||
}
|
||||
|
||||
if (!$config['admin_auth_enabled']) {
|
||||
return Response::html('404 Not Found', 404);
|
||||
}
|
||||
|
||||
// No auth check here — bootstrap.php's admin gate already covers this
|
||||
// route like every other /admin/* page (admins only; registered users get
|
||||
// a 404 there). While zero users exist that gate is deliberately open,
|
||||
// which is exactly what makes creating the *first* user here possible.
|
||||
|
||||
// Disabling or demoting the last active admin would lock everyone out of
|
||||
// /admin/* permanently (the zero-users setup window doesn't reopen — the
|
||||
// table isn't empty), recoverable only via the CLI or editing the
|
||||
// database by hand. Both actions below refuse when this returns 1 and
|
||||
// the target is an active admin.
|
||||
$activeAdminCount = static fn (): int => (int) Db::query(
|
||||
"SELECT COUNT(*) FROM users WHERE is_disabled = 0 AND role = 'admin'"
|
||||
)->fetchColumn();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!Csrf::verify(Input::post('csrf_token'))) {
|
||||
Session::flash('users_error', 'Your session expired before submitting — please try again.');
|
||||
|
||||
return Response::redirect('/admin/users', 303);
|
||||
}
|
||||
|
||||
$action = Input::post('action', '');
|
||||
|
||||
if ($action === 'create') {
|
||||
$username = trim((string) Input::post('username', ''));
|
||||
// Validate::isEmail() returns the normalized (trimmed, lowercased)
|
||||
// address, or false — stored normalized so the future
|
||||
// email-verification flow (see novaconium/ISSUES.md) can match
|
||||
// addresses case-insensitively for free.
|
||||
$email = Validate::isEmail((string) Input::post('email', ''));
|
||||
$group = trim((string) Input::post('group', ''));
|
||||
// Passwords read directly, not via Input::post() — cleaning would
|
||||
// silently strip characters like < and > before hashing, producing
|
||||
// a hash that doesn't match what's actually typed at /admin/login
|
||||
// later. See /admin/docs/sidecars' "Form security" section.
|
||||
$password = (string) ($_POST['password'] ?? '');
|
||||
|
||||
$exists = $username !== ''
|
||||
&& (bool) Db::query('SELECT EXISTS(SELECT 1 FROM users WHERE username = ?)', [$username])->fetchColumn();
|
||||
$emailExists = $email !== false
|
||||
&& (bool) Db::query('SELECT EXISTS(SELECT 1 FROM users WHERE email = ?)', [$email])->fetchColumn();
|
||||
|
||||
if ($username === '' || strlen($username) > 64) {
|
||||
Session::flash('users_error', 'Enter a username (64 characters max).');
|
||||
} elseif ($email === false) {
|
||||
Session::flash('users_error', 'Enter a valid email address.');
|
||||
} elseif ($emailExists) {
|
||||
Session::flash('users_error', 'That email address is already in use.');
|
||||
} elseif (strlen($group) > 64) {
|
||||
Session::flash('users_error', 'Group names are 64 characters max.');
|
||||
} elseif ($exists) {
|
||||
Session::flash('users_error', 'That username is already taken.');
|
||||
} elseif (strlen($password) < 8) {
|
||||
Session::flash('users_error', 'Use a password of at least 8 characters.');
|
||||
} else {
|
||||
// The first user ever created is the admin; everyone after is
|
||||
// a registered user (optionally in a group) — Lib\Access rules
|
||||
// decide what content they can see, and /admin/* 404s for
|
||||
// them. See /admin/docs/admin-auth and
|
||||
// /admin/docs/access-control.
|
||||
$wasFirstUser = !AdminAuth::hasUsers();
|
||||
$role = $wasFirstUser ? 'admin' : 'registered';
|
||||
|
||||
Db::query(
|
||||
'INSERT INTO users (username, email, password_hash, role, user_group, is_disabled, created_at) VALUES (?, ?, ?, ?, ?, 0, ?)',
|
||||
[$username, $email, password_hash($password, PASSWORD_DEFAULT), $role, $group, gmdate('Y-m-d\TH:i:s\Z')]
|
||||
);
|
||||
|
||||
// Creating the first user is what closes the open setup gate —
|
||||
// log its creator in as it, or their very next request would
|
||||
// bounce them to the login form they were just typing into.
|
||||
if ($wasFirstUser) {
|
||||
AdminAuth::attempt($username, $password);
|
||||
}
|
||||
|
||||
Session::flash('users_notice', "User \u{201C}{$username}\u{201D} created.");
|
||||
}
|
||||
} elseif ($action === 'disable' || $action === 'enable') {
|
||||
$id = (int) Input::post('id', '0');
|
||||
$target = Db::query('SELECT id, username, role, is_disabled FROM users WHERE id = ?', [$id])->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($target === false) {
|
||||
Session::flash('users_error', 'No such user.');
|
||||
} elseif ($action === 'disable' && (int) $target['is_disabled'] === 0 && $target['role'] === 'admin' && $activeAdminCount() <= 1) {
|
||||
Session::flash('users_error', 'Cannot disable the last active admin — that would lock everyone out of /admin.');
|
||||
} else {
|
||||
Db::query('UPDATE users SET is_disabled = ? WHERE id = ?', [$action === 'disable' ? 1 : 0, $id]);
|
||||
Session::flash('users_notice', sprintf("User \u{201C}%s\u{201D} %sd.", $target['username'], $action));
|
||||
}
|
||||
} elseif ($action === 'role') {
|
||||
$id = (int) Input::post('id', '0');
|
||||
$role = Input::post('role', '');
|
||||
$target = Db::query('SELECT id, username, role, is_disabled FROM users WHERE id = ?', [$id])->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($target === false) {
|
||||
Session::flash('users_error', 'No such user.');
|
||||
} elseif (!in_array($role, ['admin', 'registered'], true)) {
|
||||
Session::flash('users_error', 'No such role.');
|
||||
} elseif ($role === 'registered' && $target['role'] === 'admin' && (int) $target['is_disabled'] === 0 && $activeAdminCount() <= 1) {
|
||||
Session::flash('users_error', 'Cannot demote the last active admin — that would lock everyone out of /admin.');
|
||||
} else {
|
||||
Db::query('UPDATE users SET role = ? WHERE id = ?', [$role, $id]);
|
||||
Session::flash('users_notice', "User \u{201C}{$target['username']}\u{201D} is now {$role}.");
|
||||
}
|
||||
} elseif ($action === 'delete') {
|
||||
$id = (int) Input::post('id', '0');
|
||||
$target = Db::query('SELECT id, username, role, is_disabled FROM users WHERE id = ?', [$id])->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($target === false) {
|
||||
Session::flash('users_error', 'No such user.');
|
||||
} elseif ((int) $target['is_disabled'] === 0 && $target['role'] === 'admin' && $activeAdminCount() <= 1) {
|
||||
Session::flash('users_error', 'Cannot delete the last active admin — that would lock everyone out of /admin.');
|
||||
} else {
|
||||
// A hard delete, not a soft one — is_disabled already covers
|
||||
// "keep the account but shut it out", so delete is for
|
||||
// accounts that shouldn't exist at all. Any live session dies
|
||||
// on its next request (currentUser() re-checks the row).
|
||||
Db::query('DELETE FROM users WHERE id = ?', [$id]);
|
||||
Session::flash('users_notice', "User \u{201C}{$target['username']}\u{201D} deleted.");
|
||||
}
|
||||
} elseif ($action === 'email') {
|
||||
$id = (int) Input::post('id', '0');
|
||||
$email = Validate::isEmail((string) Input::post('email', ''));
|
||||
$target = Db::query('SELECT id, username FROM users WHERE id = ?', [$id])->fetch(PDO::FETCH_ASSOC);
|
||||
$emailExists = $email !== false
|
||||
&& (bool) Db::query('SELECT EXISTS(SELECT 1 FROM users WHERE email = ? AND id != ?)', [$email, $id])->fetchColumn();
|
||||
|
||||
if ($target === false) {
|
||||
Session::flash('users_error', 'No such user.');
|
||||
} elseif ($email === false) {
|
||||
Session::flash('users_error', 'Enter a valid email address.');
|
||||
} elseif ($emailExists) {
|
||||
Session::flash('users_error', 'That email address is already in use.');
|
||||
} else {
|
||||
Db::query('UPDATE users SET email = ? WHERE id = ?', [$email, $id]);
|
||||
Session::flash('users_notice', "Email changed for \u{201C}{$target['username']}\u{201D}.");
|
||||
}
|
||||
} elseif ($action === 'group') {
|
||||
$id = (int) Input::post('id', '0');
|
||||
$group = trim((string) Input::post('group', ''));
|
||||
$target = Db::query('SELECT id, username FROM users WHERE id = ?', [$id])->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($target === false) {
|
||||
Session::flash('users_error', 'No such user.');
|
||||
} elseif (strlen($group) > 64) {
|
||||
Session::flash('users_error', 'Group names are 64 characters max.');
|
||||
} else {
|
||||
Db::query('UPDATE users SET user_group = ? WHERE id = ?', [$group, $id]);
|
||||
Session::flash('users_notice', $group === ''
|
||||
? "User \u{201C}{$target['username']}\u{201D} removed from their group."
|
||||
: "User \u{201C}{$target['username']}\u{201D} assigned to group \u{201C}{$group}\u{201D}.");
|
||||
}
|
||||
} elseif ($action === 'password') {
|
||||
$id = (int) Input::post('id', '0');
|
||||
$password = (string) ($_POST['password'] ?? '');
|
||||
$target = Db::query('SELECT id, username FROM users WHERE id = ?', [$id])->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($target === false) {
|
||||
Session::flash('users_error', 'No such user.');
|
||||
} elseif (strlen($password) < 8) {
|
||||
Session::flash('users_error', 'Use a password of at least 8 characters.');
|
||||
} else {
|
||||
Db::query('UPDATE users SET password_hash = ? WHERE id = ?', [password_hash($password, PASSWORD_DEFAULT), $id]);
|
||||
Session::flash('users_notice', "Password changed for \u{201C}{$target['username']}\u{201D}.");
|
||||
}
|
||||
}
|
||||
|
||||
return Response::redirect('/admin/users', 303);
|
||||
}
|
||||
|
||||
return [
|
||||
'users' => Db::query('SELECT id, username, email, role, user_group, is_disabled, created_at FROM users ORDER BY username')->fetchAll(PDO::FETCH_ASSOC),
|
||||
'currentUserId' => AdminAuth::currentUser()['id'] ?? null,
|
||||
'notice' => Session::getFlash('users_notice'),
|
||||
'error' => Session::getFlash('users_error'),
|
||||
'csrfField' => Csrf::fieldName(),
|
||||
'csrfToken' => Csrf::token(),
|
||||
];
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user