b882c304b1
Admin login & user management (novaconium/ISSUES.md): session-based
login against a SQLite users table replaces the single-user HTTP Basic
Auth stopgap (admin_username/admin_password_hash and /admin/password-hash
are gone; one admin_auth_enabled flag, off by default with zero DB
footprint). New /admin/login, /admin/logout (POST-only, real page), and
/admin/users pages plus bin/create-admin-user.php.
First user created is the admin; everyone after is registered with a
unique normalized email and an optional group. /admin/* and drafts are
admin-only; Lib\Access gates page content from sidecars
(Access::require('group:members')) with login-redirect/404 responses —
public by default, static pages always public by construction. User
management covers disable/enable, delete, promote/demote, group, email,
and password, with last-active-admin lockout guards.
Also: Session::regenerate() against fixation, friendly missing-PDO-driver
errors in Lib\Db, docs at /admin/docs/access-control and updates across
admin-auth/drafts/sidecars/config/libraries and README/AGENTS.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
73 lines
7.2 KiB
Twig
73 lines
7.2 KiB
Twig
{% 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 %}
|