From 7ee47a0b1e82afef11b570f4d1c7625be04930e0 Mon Sep 17 00:00:00 2001 From: Nick Yeoman Date: Fri, 3 Jul 2026 21:05:39 -0700 Subject: [PATCH] moving away from gross globals --- CLAUDE.md | 66 ++++++++++++++++++++++++++++++++++++++++++ src/Context.php | 26 +++++++++++++++++ src/Database.php | 10 +++---- src/MessageHandler.php | 2 +- src/functions.php | 15 ++++++++-- src/novaconium.php | 18 ++++++++++++ src/twig.php | 11 +++++-- 7 files changed, 138 insertions(+), 10 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/Context.php diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3120fa6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,66 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +Novaconium is a PHP 8.1+ micro-framework distributed as a Composer package (`4lt/novaconium`, PSR-4 autoloaded under the `Novaconium\` namespace from `src/`). This repo *is* the framework itself — there is no "app" here to run directly. Consuming projects `composer require` it and copy `skeleton/novaconium/` into their own project to get an `App/` directory (config, controllers, views, routes) plus a `public/` entrypoint. + +Because of this, most of the interesting behavior lives in two places: +- `src/` — the framework runtime (bootstrap, router, session, db, etc.) +- `controllers/` + `views/` + `twig/` — the framework's own built-in admin panel ("NOVACONIUM" control panel routes), shipped so consuming apps get login/dashboard/page-editing/messages for free. + +`skeleton/` is what gets copied into a new consuming project — treat it as a template, not live framework code. + +## Development + +There is no local PHP/Composer install expected — Composer, PHP/Apache, and Sass all run in Docker containers (see `docs/docker.md`, `docs/Composer.md`, `docs/Sass.md`). There is no test suite or linter configured in this repo. + +Compiling Sass (from repo root): +```bash +docker run --rm -v "$(pwd):/usr/src/app" -w /usr/src/app sass-container --style=compressed sass/novaconium.sass skeleton/novaconium/public/css/novaconium.css +``` + +For iterating against a real consuming project without re-running `composer require` on every change, use the fake autoloader dev pattern in `docs/Dev-Fake_autoload.md` (registers a `spl_autoload_register` shim pointing at a manually cloned copy of this repo under the app's `vendor/4lt/novaconium`). + +## Request lifecycle + +Entry point in a consuming app is `App/public/index.php` (see `skeleton/novaconium/public/index.php`), which: +1. Defines `BASEPATH` (app root) and `FRAMEWORKPATH` (`BASEPATH/vendor/4lt/novaconium`) — these two constants are used everywhere in `src/` to resolve app vs. framework paths, and are assumed to already be defined by the time any framework code runs. +2. Requires Composer's autoloader, then `FRAMEWORKPATH/src/novaconium.php` (the bootstrap). +3. Calls the global `makeitso()` at the very end. + +`src/novaconium.php` (the bootstrap) runs in this order and wires up several **globals** that framework code and app controllers rely on implicitly (`$config`, `$log`, `$data`, `$session`, `$messages`, `$db`, `$post`, `$redirect`, `$router`): +1. Loads `App/config.php` (falls back to the skeleton's default config if the app hasn't created one). +2. Loads `src/functions.php` (global helpers: `dd()`, `makeitso()`) and `src/twig.php` (global `view()` helper). +3. Creates `$log` (`Novaconium\Logger`), `$session` (`Novaconium\Session`), `$messages` (`Novaconium\MessageHandler`, seeded from flashed session messages), `$db` (`Novaconium\Database`, only if `config['database']['host']` is set), `$post` (`Novaconium\Post`, only if `$_POST` is non-empty), `$redirect` (`Novaconium\Redirect`). +4. Builds `$router` (`Novaconium\Router`), whose constructor resolves the matched controller file, then immediately `require`s it — so the matched controller file executes inline, still inside bootstrap, with all the globals above already in scope. + +Controllers are plain PHP scripts (not classes) that use the ambient globals directly (e.g. `$session->get(...)`, `$messages->error(...)`, `$post->get(...)`, `$db->getRow(...)`) and typically end by calling the global `view($templateName, $data)` or setting `$redirect->url(...)` followed by `makeitso()` to end the request (writes session, closes DB, performs any pending redirect, flashes messages, then `exit`). + +## Router (`src/Router.php`) + +Full behavioral spec is in `docs/Router.md` — read it before touching routing logic, since several quirks are **intentionally preserved for backward compatibility**, not bugs: +- Only the *first* `{param}` in a route pattern is ever extracted; subsequent params in the same pattern are ignored. +- For parameterized routes, the first route whose static prefix + segment count matches wins, even if that route has no handler for the current HTTP method — the router does not keep searching for a better match. +- No wildcard/optional-segment support; segment counts must match exactly. + +Routes are plain associative arrays keyed by path, mapping `get`/`post` to a controller string (see `config/routes.php` for the framework's own admin routes, `skeleton/novaconium/App/routes.php` for the app-level shape). `App/routes.php` routes are merged with (and can override) framework routes. Controller strings prefixed with `NOVACONIUM` resolve against `FRAMEWORKPATH/controllers/`; everything else resolves against `BASEPATH/App/controllers/`. Unresolved routes/files fall back to the app's `404.php`, then the framework's `controllers/404.php`. + +## Views (Twig) + +`view($name, $data)` (`src/twig.php`) loads templates from `App/views/` by default, with two additional Twig namespaces registered: `@novaconium` → `FRAMEWORKPATH/twig` (layout partials: `master.html.twig`, `nav.html.twig`, `head.html.twig`, etc.) and `@novacore` → `FRAMEWORKPATH/views` (the framework's built-in admin pages: dashboard, pages, messages, settings). Apps can override any framework template by placing a same-named file under `App/templates/` (`override` namespace). The framework's own controllers render via the `@novacore/...` namespace (e.g. `view('@novacore/dashboard', $data)`). + +## Other framework primitives (all under `src/`, namespace `Novaconium`) + +- `Database` — thin `mysqli` wrapper (`query`, `getRow`, `getRows`); always uses prepared statements but binds every parameter as a string type. +- `Session` — thin `$_SESSION` wrapper; auto-initializes `token`/`messages`/`formData`/`errors` keys; `flash()` reads-then-deletes. +- `MessageHandler` — categorized flash messages (`error`/`warning`/`notice`/`success`), persisted across requests via `$session`. +- `Post` — sanitizes `$_POST` via `FILTER_SANITIZE_FULL_SPECIAL_CHARS` on construction; access via `$post->get($key)`. +- `Redirect` — buffers a target URL/status; only the *last* `url()` call before `makeitso()` takes effect; actual `header()` redirect happens inside `makeitso()`. +- `Logger` — level-gated file logger (`DEBUG`/`INFO`/`WARNING`/`ERROR`/`NONE`), configured via `config['logfile']`/`config['loglevel']`. +- `src/Services/` — newer, thinner service classes (`Auth`, `TagManager`) alongside the older top-level `src/` classes; check here first before adding new cross-cutting logic. + +## Configuration + +App-level config lives in `App/config.php` (see `docs/ConfigurationFile.md` and `skeleton/novaconium/App/config.php` for the shape): `database` (mysqli connection), `base_url`, `secure_key` (used to pepper password hashes via `hash_hmac('sha3-512', ...)`, see `controllers/authenticate.php`), `logfile`, `loglevel`, plus optional `matomo_url`/`matomo_id`/`fonts` passed straight into Twig's global `$data`. diff --git a/src/Context.php b/src/Context.php new file mode 100644 index 0000000..c0f1df5 --- /dev/null +++ b/src/Context.php @@ -0,0 +1,26 @@ +data = array_merge($this->data, $more); + return $this; + } +} diff --git a/src/Database.php b/src/Database.php index 7689080..0f3261c 100644 --- a/src/Database.php +++ b/src/Database.php @@ -25,7 +25,7 @@ class Database { // Prepare the SQL statement $stmt = $this->conn->prepare($query); if (!$stmt) { - throw new Exception("Query preparation failed: " . $this->conn->error); + throw new \Exception("Query preparation failed: " . $this->conn->error); } // Bind parameters if needed @@ -37,7 +37,7 @@ class Database { // Execute the statement if (!$stmt->execute()) { $stmt->close(); - throw new Exception("Query execution failed: " . $stmt->error); + throw new \Exception("Query execution failed: " . $stmt->error); } // Save last insert id if it's an INSERT query @@ -70,7 +70,7 @@ class Database { // Prepare the SQL statement $stmt = $this->conn->prepare($query); if (!$stmt) { - throw new Exception("Query preparation failed: " . $this->conn->error); + throw new \Exception("Query preparation failed: " . $this->conn->error); } // Bind parameters @@ -82,7 +82,7 @@ class Database { // Execute the statement if (!$stmt->execute()) { $stmt->close(); - throw new Exception("Query execution failed: " . $stmt->error); + throw new \Exception("Query execution failed: " . $stmt->error); } // Get result @@ -92,7 +92,7 @@ class Database { $stmt->close(); return $row; - } catch (Exception $e) { + } catch (\Exception $e) { echo "An error occurred: " . $e->getMessage(); return null; } diff --git a/src/MessageHandler.php b/src/MessageHandler.php index b28cd68..496a715 100644 --- a/src/MessageHandler.php +++ b/src/MessageHandler.php @@ -21,7 +21,7 @@ class MessageHandler { // Add a message of a specific type public function addMessage($type, $message) { if (!isset($this->messages[$type])) { - throw new Exception("Invalid message type: $type"); + throw new \Exception("Invalid message type: $type"); } $this->messages[$type][] = $message; } diff --git a/src/functions.php b/src/functions.php index a7743ab..06fc78c 100644 --- a/src/functions.php +++ b/src/functions.php @@ -24,7 +24,18 @@ function dd(...$vars): void { * connection if configured, and performs a redirect. */ function makeitso(): void { - global $session, $db, $redirect, $config, $messages, $log; + global $session, $db, $redirect, $config, $messages, $log, $ctx; + + // Prefer $ctx when available; its service properties are the same + // object instances as the bare globals, so this is behavior-neutral. + if (isset($ctx)) { + $session = $ctx->session; + $db = $ctx->db; + $redirect = $ctx->redirect; + $config = $ctx->config; + $messages = $ctx->messages; + $log = $ctx->log; + } // ------------------------------ // Close database if configured @@ -44,7 +55,7 @@ function makeitso(): void { // Set all messages in the session $session->set('messages', $messages->getAllMessages()); - + // Write any buffered session data to persistent storage $session->write(); diff --git a/src/novaconium.php b/src/novaconium.php index 5f2c5a9..f456d9c 100644 --- a/src/novaconium.php +++ b/src/novaconium.php @@ -7,6 +7,7 @@ use Novaconium\Database; use Novaconium\Post; use Novaconium\Redirect; use Novaconium\Router; +use Novaconium\Context; $db = null; $post = null; @@ -56,4 +57,21 @@ $redirect = new Redirect(); // --- Router --- $router = new Router(); + +// --- Typed Context --- +// Bundles the globals above into one typed object. The bare globals are +// left in place (same object instances) so existing plain-script +// controllers keep working unmodified; new controllers can use $ctx. +$ctx = new Context( + config: $config, + log: $log, + session: $session, + messages: $messages, + db: $db, + post: $post, + redirect: $redirect, + router: $router, + data: $data, +); + require_once $router->controllerPath; diff --git a/src/twig.php b/src/twig.php index 1611aea..3c5d333 100644 --- a/src/twig.php +++ b/src/twig.php @@ -15,13 +15,20 @@ use Twig\Loader\FilesystemLoader; */ function view(string $name = '', array $moreData = []): bool { - global $config, $data; + global $config, $data, $ctx; if (!is_array($data)) { $data = []; } - if (!empty($moreData)) { + // Reconcile the bare $data global with $ctx->data: either style of + // controller ($data = array_merge($data, [...]) or $ctx->withData([...])) + // ends up folded in here, regardless of which one was used. + if (isset($ctx)) { + $data = array_merge($data, $ctx->data, $moreData); + $ctx->data = $data; + $config = $ctx->config; + } elseif (!empty($moreData)) { $data = array_merge($data, $moreData); }