Files
novaconium/novaconium/pages/admin/docs/routing/index.twig
T
code b882c304b1 Replace Basic Auth with multi-user login, roles, groups, and Lib\Access
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>
2026-07-15 00:17:54 +00:00

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/&lt;anything&gt; ($params['id'] = &lt;anything&gt;)
index.php -> optional sidecar for that page</code></pre>
<ul>
<li>Every route is <code>App/pages/&lt;segments&gt;/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-&gt;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-&gt;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-&gt;found</code> is <code>false</code>, otherwise <code>render($route, ...)</code> — using <code>$route-&gt;dir</code> to find the sidecar/layout/template and <code>$route-&gt;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&lt;string,string&gt; — captured [param] segments, e.g.
// ['id' =&gt; '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 %}