Add SQLite groundwork: Lib\Db, migrations, and a project-owned data dir

Lib\Db (novaconium/lib/Db.php) is a thin, no-ORM PDO wrapper — lazy-connect
like Lib\Csrf, Db::query() as the only query-running helper (prepared
statements only, no interpolation shortcut, per Lib\Input's existing
security stance). Plain .sql migrations under App/migrations/, applied in
filename order and tracked in an auto-created schema_migrations table, run
automatically on first connection or via novaconium/bin/migrate.php.

Data lives in a new top-level data/ directory rather than novaconium/ or
public/ — outside public/ so it's never web-accessible, and outside
novaconium/ since that directory gets wholesale-replaced by the "Updating
the framework" workflow, which would otherwise destroy it on every update.

New config keys: db_driver (only 'sqlite' implemented), db_path,
db_migrations_dir. Documented at /admin/docs/database and in AGENTS.md.
Closes the "SQLite groundwork" backlog item in novaconium/ISSUES.md.
This commit is contained in:
code
2026-07-14 03:33:38 +00:00
parent 672f997d8b
commit a3b996719a
13 changed files with 314 additions and 47 deletions
+4
View File
@@ -1,4 +1,8 @@
/public/cache/*
!/public/cache/.gitkeep
/novaconium/contact-log.txt
/data/*.sqlite
/data/*.sqlite-journal
/data/*.sqlite-wal
/data/*.sqlite-shm
.claude/
+29
View File
@@ -130,6 +130,35 @@ management"); don't extend this class toward multi-user/session-based
auth — that's a separate, larger feature that
will replace it.
`Lib\Db` (`novaconium/lib/Db.php`) is the SQLite 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.
`Db::query(string $sql, array $params = [])` (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. Connection is
lazy (opened on first `Db::query()`/`Db::connection()` call, same shape as
`Lib\Csrf`'s lazy session start), and migrates automatically at that point:
plain `.sql` files under `App/migrations/` (`config['db_migrations_dir']`),
filename-ordered, tracked in an auto-created `schema_migrations` table, run
once each. `novaconium/bin/migrate.php` runs the same migration step
explicitly (e.g. from a deploy script) without serving a request first.
**`config['db_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. Only `App/migrations/` is scanned for now (the framework ships
no core tables of its own yet) — if a future framework feature needs a
shipped migration, extend this to the same App-over-novaconium two-root
scan `Overlay.php` already does for pages/lib, don't invent a second
mechanism.
## Running it
```
+5
View File
@@ -20,4 +20,9 @@ return [
// or use the built-in /admin/password-hash form.
// 'admin_username' => 'admin',
// 'admin_password_hash' => '$2y$10$...',
// Docs: /admin/docs/database
// 'db_driver' => 'sqlite',
// 'db_path' => __DIR__ . '/../data/novaconium.sqlite',
// 'db_migrations_dir' => __DIR__ . '/migrations',
];
View File
+2 -1
View File
@@ -16,11 +16,12 @@ A tiny, Hugo-flavored PHP micro-framework. Routes are directories on disk, pages
- **Dark/light theme toggle** — a nav button flips a `data-theme` attribute (persisted to `localStorage`) that swaps every color via CSS custom properties; both palettes live in `App/sass/_colors.sass`, same override mechanism as everything else.
- **Self-hosted spam prevention & form validation** — `Lib\SpamGuard`, a reusable class for any form: a CSS-hidden honeypot field plus a submission-timing check, no external CAPTCHA service, site key, or outbound API call. Pairs with `Lib\FormValidator` (accumulating required-field/email/length checks) and `Lib\Validate` (the underlying validation primitives — email, length, phone, postal/zip, spam-word checks). All three ship in `novaconium/lib/`, demonstrated on the contact form.
- **Form security by default** — `Lib\Input`, a cleaning accessor for `$_POST`/`$_GET` (defense-in-depth against HTML/script injection, not a substitute for parameterized queries), and `Lib\Csrf`, standalone session-token CSRF protection called directly from a sidecar. Both ship in `novaconium/lib/`, wired into the contact form, `/admin/clear-cache`, and `/admin/password-hash`.
- **SQLite database, zero setup** — `Lib\Db`, a thin PDO wrapper (no ORM) with a plain-SQL migration convention (`App/migrations/*.sql`, applied automatically on first use, or via `php novaconium/bin/migrate.php`). Data lives in a project-owned top-level `data/` directory, outside both `public/` and `novaconium/`. See `/admin/docs/database`.
- **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.
## Getting started
**Requirements:** PHP 8.1+ (uses `readonly` constructor-promoted properties) and, for production, Apache with `mod_rewrite` and `AllowOverride All`.
**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), and, for production, Apache with `mod_rewrite` and `AllowOverride All`.
### Run it locally (no Apache needed)
View File
+58 -45
View File
@@ -54,64 +54,45 @@ expected vs. actual behavior. For features, include the motivating use case.>
Suggested build order (foundations first, since admin login builds on two
of the others):
1. **SQLite groundwork**no dependencies.
2. **MySQL support** — builds directly on SQLite groundwork's `Db`
abstraction; do right after so the abstraction is driver-agnostic from
the start rather than retrofitted.
3. **Session handling** — no dependencies; can be built in parallel with 1.
4. **Blog tags/categories** — no dependencies, but now needs a metadata
1. **MySQL support**builds directly on SQLite groundwork's `Db`
abstraction (see Done — shipped 2026-07-14); do it soon since the
abstraction was designed driver-agnostic from the start specifically to
avoid a retrofit here.
2. **Session handling** — no dependencies.
3. **Blog tags/categories** — no dependencies, but now needs a metadata
source design decision first (`PostRepository` was removed when
`hello-world`/`second-post` became plain Twig pages — see the entry
below), so worth doing first or together with Blog RSS feed.
5. **Blog RSS feed** — no hard dependency, but a per-tag feed is easiest
4. **Blog RSS feed** — no hard dependency, but a per-tag feed is easiest
once tags/categories exist.
6. **Internal search** needs SQLite groundwork for storage.
7. **XML sitemap** — no hard dependency, but shares crawling logic with
5. **Internal search** — SQLite groundwork it needed is done; ready to
build.
6. **XML sitemap** — no hard dependency, but shares crawling logic with
Internal search, so easiest right after (or alongside) it.
8. **Media/file manager** — no hard dependency; usable standalone, though
7. **Media/file manager** — no hard dependency; usable standalone, though
best gated behind admin login once that exists.
9. **Draft pages (admin-only preview)** — no hard dependency; the admin
8. **Draft pages (admin-only preview)** — no hard dependency; the admin
authentication it reuses already shipped (see `/admin/docs/admin-auth`).
10. **Syntax highlighting on code blocks** — no hard dependency on the
copy button (see Done — shipped 2026-07-14), but touches the same
`<pre><code>` markup it did.
11. **Admin login & user management** needs both SQLite groundwork (user
store) and session handling (logged-in state).
12. **Ecommerce functionality** — needs SQLite groundwork, session handling
(cart), and admin login & user management (order/product admin, and
customer accounts).
13. **Paywall functionality** — needs everything Ecommerce needs, plus
9. **Syntax highlighting on code blocks** — no hard dependency on the
copy button (see Done — shipped 2026-07-14), but touches the same
`<pre><code>` markup it did.
10. **Admin login & user management** — SQLite groundwork it needed is
done; still needs session handling (logged-in state).
11. **Ecommerce functionality** — needs session handling (cart) and admin
login & user management (order/product admin, and customer accounts);
SQLite groundwork it needed is done.
12. **Paywall functionality** — needs everything Ecommerce needs, plus
Ecommerce itself for the recurring-billing/payment-gateway plumbing;
build after it rather than in parallel.
See **Won't Do** below for 404 tracking, dropped in favor of Matomo.
### SQLite groundwork
- **Type:** Feature
- **Status:** Backlog
- **Priority:** High
- **Added:** 2026-07-12
Lay the groundwork for optional SQLite storage (a `Db` or similar `Lib\`
wrapper around `PDO`/`sqlite3`, a data directory outside `public/`, a
migration/schema convention) so features that need persistence — 404
tracking and admin login below, and anything future — have a common place
to store data instead of ad hoc flat files. No ORM; stay consistent with
the project's no-Composer, no-build-step philosophy. Foundational — nothing
else here depends on this being skipped, but 404 tracking and admin login
both depend on it existing. Design the `Db` wrapper's interface with MySQL
support (below) in mind from the start — PDO already abstracts most of the
driver difference, so the schema/migration convention should avoid
SQLite-only syntax where a MySQL-compatible equivalent exists, to avoid a
retrofit.
### MySQL support
- **Type:** Feature
- **Status:** Backlog
- **Priority:** Medium
- **Depends on:** SQLite groundwork
- **Depends on:** SQLite groundwork (Done)
- **Added:** 2026-07-12
Let a project point the `Db` wrapper at MySQL instead of SQLite — via a
@@ -192,7 +173,7 @@ pattern, just with `Response::json()` instead of `Response::xml()`).
- **Type:** Feature
- **Status:** Backlog
- **Priority:** Medium
- **Depends on:** SQLite groundwork
- **Depends on:** SQLite groundwork (Done)
- **Added:** 2026-07-12
Crawl the site's own pages (likely via `App/pages/` + rendered output,
@@ -327,7 +308,7 @@ full of stray markup.
- **Type:** Feature
- **Status:** Backlog
- **Priority:** Medium
- **Depends on:** SQLite groundwork, Session handling (with flash sessions)
- **Depends on:** SQLite groundwork (Done), Session handling (with flash sessions)
- **Added:** 2026-07-12
A single-user HTTP Basic Auth stopgap now gates `/admin/*`
@@ -346,7 +327,7 @@ with the new mechanism, not layering on top of it.
- **Type:** Feature
- **Status:** Backlog
- **Priority:** Low
- **Depends on:** SQLite groundwork, Session handling (with flash sessions), Admin login & user management
- **Depends on:** SQLite groundwork (Done), Session handling (with flash sessions), Admin login & user management
- **Added:** 2026-07-12
Product catalog, cart, checkout, and order storage — a `products` /
@@ -366,7 +347,7 @@ planning it all up front here.
- **Type:** Feature
- **Status:** Backlog
- **Priority:** Low
- **Depends on:** Ecommerce functionality (recurring billing/payment plumbing), SQLite groundwork, Session handling (with flash sessions), Admin login & user management
- **Depends on:** Ecommerce functionality (recurring billing/payment plumbing), SQLite groundwork (Done), Session handling (with flash sessions), Admin login & user management
- **Added:** 2026-07-12
Subscription/membership content gating, similar to OnlyFans/Patreon:
@@ -389,6 +370,38 @@ _Nothing yet._
## Done
### 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 (`Db::query(string
$sql, array $params = [])` — 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. Connection is lazy (opens on first real
call, same shape as `Lib\Csrf`'s lazy session start). Migration convention:
plain numbered `.sql` files under `App/migrations/`
(`config['db_migrations_dir']`), applied in filename order and tracked in
an auto-created `schema_migrations` table, run automatically on first
connection or explicitly via `php novaconium/bin/migrate.php`. 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`. New config keys: `db_driver` (only `'sqlite'` implemented),
`db_path`, `db_migrations_dir`. Documented at `/admin/docs/database` and in
`AGENTS.md` next to the two-root-split section. Designed driver-agnostic
(no SQLite-only SQL in the mechanism itself) so MySQL support, next up in
the build order, doesn't need a retrofit.
### Copy-to-clipboard button on code blocks
- **Type:** Feature
+13
View File
@@ -0,0 +1,13 @@
<?php
use Lib\Db;
require __DIR__ . '/../autoload.php';
// Db::connection() applies any pending App/migrations/*.sql as a side
// effect of opening the connection (see Lib\Db::migrate()) — this script
// just triggers that explicitly, e.g. from a deploy script, without
// serving a request first.
Db::connection();
echo "Migrations applied.\n";
+11
View File
@@ -38,4 +38,15 @@ return [
// 'admin_password_hash' => '$2y$10$...',
'admin_username' => 'admin',
'admin_password_hash' => '',
// Lib\Db (see /admin/docs/database). Only 'sqlite' is implemented today
// — MySQL support is tracked as a separate Backlog item in
// novaconium/ISSUES.md. db_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.
'db_driver' => 'sqlite',
'db_path' => __DIR__ . '/../data/novaconium.sqlite',
'db_migrations_dir' => __DIR__ . '/../App/migrations',
];
+131
View File
@@ -0,0 +1,131 @@
<?php
namespace Lib;
use PDO;
use RuntimeException;
/**
* A thin PDO wrapper — the SQLite groundwork tracked in novaconium/ISSUES.md.
* No ORM, no query builder, consistent with this project's no-Composer,
* no-build-step philosophy: just a lazily-opened PDO connection plus a
* minimal migration runner.
*
* 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.
*
* Lazy-connect, same shape as Lib\Csrf's lazy session start: nothing opens
* a database file or runs a migration until the first real call, so a
* request that never touches the database never pays for it.
*/
final class Db
{
private static ?PDO $connection = null;
public static function connection(): PDO
{
return self::$connection ??= self::connect();
}
/**
* @param array<int|string,mixed> $params
*/
public static function query(string $sql, array $params = []): \PDOStatement
{
$statement = self::connection()->prepare($sql);
$statement->execute($params);
return $statement;
}
private static function connect(): PDO
{
$config = self::config();
if ($config['db_driver'] !== 'sqlite') {
throw new RuntimeException(
"Unsupported db_driver '{$config['db_driver']}' — only 'sqlite' is implemented so far " .
'(MySQL support is tracked separately in novaconium/ISSUES.md).'
);
}
$path = $config['db_path'];
$dir = dirname($path);
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
$pdo = new PDO('sqlite:' . $path);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
self::migrate($pdo, $config['db_migrations_dir']);
return $pdo;
}
/**
* Applies any *.sql file under $migrationsDir not yet recorded in
* schema_migrations, in filename order — so migrations are named with a
* numeric prefix (0001_create_x.sql, 0002_add_y.sql, ...) to control
* apply order. Each file is tracked by filename once applied and never
* re-run. Runs automatically on every first connection() call per
* process — cheap (one query plus a directory glob), so no separate
* "migrate" step is required, matching the framework's zero-config
* philosophy elsewhere (e.g. static caching). novaconium/bin/migrate.php
* exists for running it explicitly (e.g. from a deploy script) without
* serving a request first.
*/
private static function migrate(PDO $pdo, string $migrationsDir): void
{
$pdo->exec(
'CREATE TABLE IF NOT EXISTS schema_migrations (' .
'filename TEXT PRIMARY KEY, ' .
'applied_at TEXT NOT NULL' .
')'
);
if (!is_dir($migrationsDir)) {
return;
}
$applied = $pdo->query('SELECT filename FROM schema_migrations')->fetchAll(PDO::FETCH_COLUMN);
$applied = array_flip($applied);
$files = glob(rtrim($migrationsDir, '/') . '/*.sql') ?: [];
sort($files);
foreach ($files as $file) {
$filename = basename($file);
if (isset($applied[$filename])) {
continue;
}
$pdo->exec((string) file_get_contents($file));
$insert = $pdo->prepare('INSERT INTO schema_migrations (filename, applied_at) VALUES (?, ?)');
$insert->execute([$filename, gmdate('Y-m-d\TH:i:s\Z')]);
}
}
/**
* @return array{db_driver: string, db_path: string, db_migrations_dir: string}
*/
private static function config(): array
{
$config = require __DIR__ . '/../config.php';
$appConfigFile = __DIR__ . '/../../App/config.php';
if (is_file($appConfigFile)) {
$config = array_merge($config, require $appConfigFile);
}
return $config;
}
}
@@ -13,6 +13,7 @@
<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/admin-auth">{{ icons.lock() }}Admin authentication</a></li>
<li><a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a></li>
<li><a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a></li>
@@ -0,0 +1,58 @@
{% extends 'admin/docs/_layout/layout.twig' %}
{% import '_layout/icons.twig' as icons %}
{% block title %}Database{% endblock %}
{% block description %}Lib\Db — a thin PDO/SQLite wrapper with 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 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>
<h2>Using it</h2>
<pre><code>use Lib\Db;
$rows = Db::query('SELECT * FROM posts WHERE published = ?', [1])-&gt;fetchAll();</code></pre>
<p><code>Db::query(string $sql, array $params = [])</code> prepares and executes 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()</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>The connection is opened lazily — nothing touches the database file or runs a migration until the first real call to <code>Db::query()</code> or <code>Db::connection()</code>, so a request that never needs the database never pays for it.</p>
<h2>Configuration</h2>
<pre><code>&lt;?php
// App/config.php
return [
'db_driver' =&gt; 'sqlite',
'db_path' =&gt; __DIR__ . '/../data/novaconium.sqlite',
'db_migrations_dir' =&gt; __DIR__ . '/migrations',
];</code></pre>
<p>Only <code>'sqlite'</code> is implemented for <code>db_driver</code> today — MySQL support is tracked as a separate item in <code>novaconium/ISSUES.md</code>, and the wrapper avoids SQLite-only SQL where a MySQL-compatible equivalent exists so that lands without a retrofit.</p>
<p><code>db_path</code> defaults to 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 <code>App/migrations/</code> (<code>db_migrations_dir</code>), applied in filename order — 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 filename in a <code>schema_migrations</code> table (created automatically) and only ever run once. Migrations apply automatically the first time <code>Db::connection()</code> is called in a process — zero-config, the same "just works" philosophy as static caching — or explicitly, without serving a request first:</p>
<pre><code>php novaconium/bin/migrate.php</code></pre>
<p>Only <code>App/migrations/</code> is scanned — the framework itself ships no core tables yet, so there's no second <code>novaconium/migrations/</code> root to merge in. If a future framework feature needs a shipped migration (e.g. <a href="https://git.4lt.ca/4lt/novaconium/issues">admin login's user table</a>), this can extend to the same App-over-novaconium two-root scan used for pages and lib.</p>
{% endblock %}
+2 -1
View File
@@ -4,7 +4,7 @@
{% block title %}Docs{% endblock %}
{% block description %}Framework documentation: routing, sidecars, forms, libraries, layouts, caching, styling.{% endblock %}
{% block description %}Framework documentation: routing, sidecars, forms, libraries, database, layouts, caching, styling.{% endblock %}
{% block robots %}noindex, nofollow{% endblock %}
@@ -18,6 +18,7 @@
<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/SQLite wrapper with a plain-SQL migration convention.</li>
<li><a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a> — gate <code>/admin/*</code> behind HTTP Basic Auth, reusable for any future admin page.</li>
<li><a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a> — pages and layouts are overridable, just like <code>Lib\</code>.</li>
<li><a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a> — how sidecar-less pages get served as static HTML.</li>