From a3b996719a603e508a2401593f1795f2c84a94c4 Mon Sep 17 00:00:00 2001 From: code Date: Tue, 14 Jul 2026 03:33:38 +0000 Subject: [PATCH] Add SQLite groundwork: Lib\Db, migrations, and a project-owned data dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 4 + AGENTS.md | 29 ++++ App/config.php | 5 + App/migrations/.gitkeep | 0 README.md | 3 +- data/.gitkeep | 0 novaconium/ISSUES.md | 103 ++++++++------ novaconium/bin/migrate.php | 13 ++ novaconium/config.php | 11 ++ novaconium/lib/Db.php | 131 ++++++++++++++++++ .../pages/admin/docs/_layout/layout.twig | 1 + .../pages/admin/docs/database/index.twig | 58 ++++++++ novaconium/pages/admin/docs/index.twig | 3 +- 13 files changed, 314 insertions(+), 47 deletions(-) create mode 100644 App/migrations/.gitkeep create mode 100644 data/.gitkeep create mode 100644 novaconium/bin/migrate.php create mode 100644 novaconium/lib/Db.php create mode 100644 novaconium/pages/admin/docs/database/index.twig diff --git a/.gitignore b/.gitignore index 58b6df3..2899117 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/AGENTS.md b/AGENTS.md index b9c7e0f..daf2e70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 +`). 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 ``` diff --git a/App/config.php b/App/config.php index 64da674..00a4c8c 100644 --- a/App/config.php +++ b/App/config.php @@ -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', ]; diff --git a/App/migrations/.gitkeep b/App/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md index 6fdd932..155dc55 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/novaconium/ISSUES.md b/novaconium/ISSUES.md index 156b2c6..749e2cb 100644 --- a/novaconium/ISSUES.md +++ b/novaconium/ISSUES.md @@ -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 - `
` 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
+   `
` 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
diff --git a/novaconium/bin/migrate.php b/novaconium/bin/migrate.php
new file mode 100644
index 0000000..4302646
--- /dev/null
+++ b/novaconium/bin/migrate.php
@@ -0,0 +1,13 @@
+ '$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',
 ];
diff --git a/novaconium/lib/Db.php b/novaconium/lib/Db.php
new file mode 100644
index 0000000..bb2674b
--- /dev/null
+++ b/novaconium/lib/Db.php
@@ -0,0 +1,131 @@
+ $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;
+    }
+}
diff --git a/novaconium/pages/admin/docs/_layout/layout.twig b/novaconium/pages/admin/docs/_layout/layout.twig
index fe14aad..ce9686d 100644
--- a/novaconium/pages/admin/docs/_layout/layout.twig
+++ b/novaconium/pages/admin/docs/_layout/layout.twig
@@ -13,6 +13,7 @@
                 
  • {{ icons.email() }}Forms
  • {{ icons.book() }}Libraries
  • {{ icons.book() }}Configuration
  • +
  • {{ icons.book() }}Database
  • {{ icons.lock() }}Admin authentication
  • {{ icons.book() }}Layouts
  • {{ icons.book() }}Static caching
  • diff --git a/novaconium/pages/admin/docs/database/index.twig b/novaconium/pages/admin/docs/database/index.twig new file mode 100644 index 0000000..d525c26 --- /dev/null +++ b/novaconium/pages/admin/docs/database/index.twig @@ -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 %} +

    Database

    + +

    Lib\Db (novaconium/lib/Db.php) is the SQLite groundwork tracked in novaconium/ISSUES.md — 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 {{ icons.book() }}Lib\ class like Input/Csrf/Mailer, so a project can override it entirely by dropping its own App/lib/Db.php.

    + +

    Using it

    + +
    use Lib\Db;
    +
    +$rows = Db::query('SELECT * FROM posts WHERE published = ?', [1])->fetchAll();
    + +

    Db::query(string $sql, array $params = []) prepares and executes in one call, returning the PDOStatement. It's the only query-running helper this class exposes — there is deliberately no string-interpolation convenience method. Db::connection() returns the raw PDO instance for anything query() doesn't cover (transactions, lastInsertId(), etc.).

    + +

    Always use parameter binding, never string-concatenate values into SQL — the same rule {{ icons.book() }}Lib\Input'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 Db never grows an sqlSafe()-style shortcut.

    + +

    The connection is opened lazily — nothing touches the database file or runs a migration until the first real call to Db::query() or Db::connection(), so a request that never needs the database never pays for it.

    + +

    Configuration

    + +
    <?php
    +// App/config.php
    +return [
    +    'db_driver'         => 'sqlite',
    +    'db_path'           => __DIR__ . '/../data/novaconium.sqlite',
    +    'db_migrations_dir' => __DIR__ . '/migrations',
    +];
    + +

    Only 'sqlite' is implemented for db_driver today — MySQL support is tracked as a separate item in novaconium/ISSUES.md, and the wrapper avoids SQLite-only SQL where a MySQL-compatible equivalent exists so that lands without a retrofit.

    + +

    db_path defaults to a top-level data/ directory — a sibling of App/, novaconium/, and public/, not nested inside any of them. This is deliberate: it can't live under public/ (would be directly web-accessible), and it can't live under novaconium/ either, since {{ icons.book() }}updating the framework means overwriting that whole directory — anything persisted there would be destroyed by the next update. data/ is project-owned, like App/, and untouched by a framework update. Its contents (*.sqlite and the SQLite journal/WAL/SHM sidecar files) are gitignored; only a .gitkeep is tracked so the directory exists in a fresh clone.

    + +

    Migrations

    + +

    Plain .sql files under App/migrations/ (db_migrations_dir), applied in filename order — name them with a numeric prefix to control ordering:

    + +
    -- App/migrations/0001_create_posts.sql
    +CREATE TABLE posts (
    +    id INTEGER PRIMARY KEY AUTOINCREMENT,
    +    title TEXT NOT NULL,
    +    body TEXT NOT NULL
    +);
    + +

    Each file is tracked by filename in a schema_migrations table (created automatically) and only ever run once. Migrations apply automatically the first time Db::connection() is called in a process — zero-config, the same "just works" philosophy as static caching — or explicitly, without serving a request first:

    + +
    php novaconium/bin/migrate.php
    + +

    Only App/migrations/ is scanned — the framework itself ships no core tables yet, so there's no second novaconium/migrations/ root to merge in. If a future framework feature needs a shipped migration (e.g. admin login's user table), this can extend to the same App-over-novaconium two-root scan used for pages and lib.

    +{% endblock %} diff --git a/novaconium/pages/admin/docs/index.twig b/novaconium/pages/admin/docs/index.twig index 502c6cd..13b6e4a 100644 --- a/novaconium/pages/admin/docs/index.twig +++ b/novaconium/pages/admin/docs/index.twig @@ -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 @@
  • {{ icons.email() }}Forms — building a custom form with a sidecar, using the novaconium validation/spam libraries.
  • {{ icons.book() }}Libraries — plain PHP classes under Lib\.
  • {{ icons.book() }}Configuration — override framework settings from App/config.php.
  • +
  • {{ icons.book() }}DatabaseLib\Db, a thin PDO/SQLite wrapper with a plain-SQL migration convention.
  • {{ icons.lock() }}Admin authentication — gate /admin/* behind HTTP Basic Auth, reusable for any future admin page.
  • {{ icons.book() }}Layouts — pages and layouts are overridable, just like Lib\.
  • {{ icons.book() }}Static caching — how sidecar-less pages get served as static HTML.