Replace v1 with v2 codebase
Full rewrite: swap out the v1 framework (src/, controllers/, views/, twig/, sass/, skeleton/) for the working v2 codebase from phpproject (App/, novaconium/, public/).
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
{% extends '_layout/layout.twig' %}
|
||||
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
{% block content %}
|
||||
<div class="docs">
|
||||
<nav class="docs-nav">
|
||||
<ul>
|
||||
<li><a class="icon-link" href="/admin/docs">{{ icons.book() }}Overview</a></li>
|
||||
<li><a class="icon-link" href="/admin/docs/getting-started">{{ icons.book() }}Getting started</a></li>
|
||||
<li><a class="icon-link" href="/admin/docs/routing">{{ icons.link() }}Routing</a></li>
|
||||
<li><a class="icon-link" href="/admin/docs/sidecars">{{ icons.book() }}Sidecars</a></li>
|
||||
<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/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>
|
||||
<li><a class="icon-link" href="/admin/docs/seo">{{ icons.book() }}SEO</a></li>
|
||||
<li><a class="icon-link" href="/admin/docs/matomo">{{ icons.book() }}Matomo</a></li>
|
||||
<li><a class="icon-link" href="/admin/docs/styling">{{ icons.book() }}Styling</a></li>
|
||||
<li><a class="icon-link" href="/admin/docs/project-layout">{{ icons.sitemap() }}Project layout</a></li>
|
||||
<li><a class="icon-link" href="/admin/docs/third-party">{{ icons.external_link() }}Third-party</a></li>
|
||||
<li><a class="icon-link" href="/admin/docs/design-notes">{{ icons.book() }}Design notes</a></li>
|
||||
<li><a class="icon-link" href="/admin/docs/upgrading-twig">{{ icons.book() }}Upgrading Twig</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="docs-content">
|
||||
{% block docs_content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,50 @@
|
||||
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
{% block title %}Admin authentication{% endblock %}
|
||||
|
||||
{% block description %}Gating /admin/* behind HTTP Basic Auth, reusable for any future admin page.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block docs_content %}
|
||||
<h1>Admin authentication</h1>
|
||||
|
||||
<p>Every route under <code>/admin/*</code> — <code>/admin</code>, <code>/admin/clear-cache</code>, the docs section, and any admin page a project adds later — can be gated behind HTTP Basic Auth with two <code>App/config.php</code> keys. It's off by default (same posture as Matomo): an empty <code>admin_password_hash</code> means no gate at all, matching this project's original wide-open behavior.</p>
|
||||
|
||||
<p>This replaced the old <code>docs_enabled</code> config flag, which only hid the docs section specifically. Since this gate covers all of <code>/admin/*</code> — including docs — there's no need for a separate docs-only toggle anymore; set a password and the whole admin area (docs included) requires login.</p>
|
||||
|
||||
<h2>Enabling it</h2>
|
||||
|
||||
<p>Generate a password hash once — either on the command line:</p>
|
||||
|
||||
<pre><code>php -r "echo password_hash('yourpassword', PASSWORD_DEFAULT), PHP_EOL;"</code></pre>
|
||||
|
||||
<p>...or, if you'd rather not touch a terminal, at <a class="icon-link" href="/admin/password-hash">{{ icons.lock() }}/admin/password-hash</a> — a small built-in form that does the same <code>password_hash()</code> call and hands back a ready-to-paste config snippet. Nothing typed there is stored or logged. Since it lives under <code>/admin/*</code> like everything else here, it's automatically covered by this same gate once a password is set — reachable while <code>admin_password_hash</code> is still empty (so you can generate your first one), then protected like any other admin page afterward.</p>
|
||||
|
||||
<p>Then set both keys in <code>App/config.php</code>:</p>
|
||||
|
||||
<pre><code><?php
|
||||
// App/config.php
|
||||
return [
|
||||
'admin_username' => 'admin',
|
||||
'admin_password_hash' => '$2y$10$...',
|
||||
];</code></pre>
|
||||
|
||||
<p>Every request into <code>/admin/*</code> now requires that username/password via the browser's built-in Basic Auth prompt; anything outside <code>/admin</code> is unaffected.</p>
|
||||
|
||||
<h2>How it's wired</h2>
|
||||
|
||||
<p><code>novaconium/src/AdminAuth.php</code> is a single reusable check — <code>AdminAuth::requireLogin($username, $passwordHash)</code> — called once from <code>novaconium/bootstrap.php</code> for any resolved route whose path is <code>admin</code> or starts with <code>admin/</code>, and only for routes that actually resolved (no login prompt on an unrelated 404). Because the check lives in <code>bootstrap.php</code> rather than on each page, <strong>a new admin page needs zero extra wiring</strong> to be protected — dropping a new directory under <code>App/pages/admin/</code> or <code>novaconium/pages/admin/</code> is automatically gated the moment it exists.</p>
|
||||
|
||||
<h2>Logging out</h2>
|
||||
|
||||
<p>HTTP Basic Auth has no real server-side logout — the browser just keeps resending the same cached credentials on every request to that realm. Visiting <code>/admin/logout</code> works around this: <code>AdminAuth::logout()</code> always issues a fresh <code>401</code> challenge, regardless of what credentials were sent, which makes the browser discard what it had cached and prompt again the next time <code>/admin</code> is visited. Credentials themselves aren't invalidated server-side (there's nothing to invalidate — it's just a password check on every request), so this is a client-side-only logout, same as any Basic Auth site. The "Logout" link only appears on <code>/admin</code> when <code>admin_auth_enabled</code> is true (i.e. a password is actually set).</p>
|
||||
|
||||
<h2>What this is (and isn't)</h2>
|
||||
|
||||
<p>This is HTTP Basic Auth against a single username/password pair in config — no sessions, no user table, no password reset, no multiple accounts. It's a deliberate stopgap: see <code>novaconium/ISSUES.md</code>'s "Admin login & user management" entry for the planned real multi-user system (backed by SQLite, with proper sessions). That feature will <em>replace</em> this mechanism, not layer on top of it. Until then, this is enough to keep the general public out of <code>/admin</code> on a production site.</p>
|
||||
|
||||
<p>Basic Auth credentials are sent base64-encoded on every request (not encrypted) — always serve <code>/admin</code> over HTTPS in production, same as any password-protected page.</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,22 @@
|
||||
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||
|
||||
{% block title %}Static caching{% endblock %}
|
||||
|
||||
{% block description %}How sidecar-less pages are pre-rendered and served as static HTML.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block docs_content %}
|
||||
<h1>Static caching</h1>
|
||||
|
||||
<p>If a page has <strong>no</strong> sidecar, its rendered HTML is written to <code>public/cache/<path>/index.html</code> after the first request. <code>.htaccess</code> checks for that file before PHP ever runs, so repeat visits are served straight by Apache with zero PHP/Twig overhead. Pages with a sidecar are never cached this way, since their output can vary per request.</p>
|
||||
|
||||
<p>To force a single page to re-render, delete its file under <code>public/cache/</code>. To clear everything at once, there are two equivalent options:</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>CLI:</strong> <code>php novaconium/bin/clear-cache.php</code> — a standalone script for deploys, cron jobs, or anywhere you'd rather not go through a browser. Prints <code>Cache cleared.</code> and exits.</li>
|
||||
<li><strong>Web:</strong> <a href="/admin/clear-cache">/admin/clear-cache</a> — a POST form under <code>/admin</code>, covered by the same <a href="/admin/docs/admin-auth">HTTP Basic Auth gate</a> as the rest of <code>/admin/*</code> once a password is set.</li>
|
||||
</ul>
|
||||
|
||||
<p>Both end up calling the same underlying <code>Cache::clear()</code> — see <code>/admin/docs/config</code>'s "For developers: using <code>Cache.php</code> directly" section for how each entry point constructs it. Clearing the cache only deletes the generated static HTML; it doesn't affect <code>App/pages/</code> or any other source. Any project change that should show up on an already-cached page — a new <code>site_name</code>, a new Sass color, a new admin toggle — needs a cache clear before it's visible, since the old <code>index.html</code> would otherwise keep being served as-is.</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,71 @@
|
||||
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||
|
||||
{% block title %}Configuration{% endblock %}
|
||||
|
||||
{% block description %}How to override framework settings without editing novaconium/config.php.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block docs_content %}
|
||||
<h1>Configuration</h1>
|
||||
|
||||
<p><code>novaconium/config.php</code> holds the framework defaults — <code>pages_dirs</code>, <code>cache_dir</code>, <code>debug</code>, <code>site_name</code>, <code>matomo_url</code>, <code>matomo_site_id</code>, <code>admin_username</code>, <code>admin_password_hash</code> — and is not meant to be edited per-project, same as everything else under <code>novaconium/</code>.</p>
|
||||
|
||||
<p><code>App/config.php</code> ships with the skeleton as an empty, commented placeholder — uncomment (or add) whichever keys you want to change, returning an array of just those:</p>
|
||||
|
||||
<pre><code><?php
|
||||
// App/config.php
|
||||
return [
|
||||
'debug' => false,
|
||||
];</code></pre>
|
||||
|
||||
<p><code>novaconium/bootstrap.php</code> (and <code>novaconium/bin/clear-cache.php</code>) check whether <code>App/config.php</code> exists and, if so, shallow-merge it over <code>novaconium/config.php</code>'s defaults with <code>array_merge()</code> — the same App-overrides-novaconium pattern used for pages and <code>Lib\</code> classes elsewhere. You only need to list the keys you're changing; anything you omit keeps the framework default.</p>
|
||||
|
||||
<p>Deleting <code>App/config.php</code> entirely is just as valid as leaving it in place returning an empty array — either way, every setting falls back to <code>novaconium/config.php</code>'s defaults.</p>
|
||||
|
||||
<h2>Site name</h2>
|
||||
|
||||
<p><code>site_name</code> (default <code>'My Site'</code>) is used by the root layout as the default page <code><title></code> (when a page doesn't override the <code>title</code> block), <code>og:site_name</code>, and the footer copyright line:</p>
|
||||
|
||||
<pre><code><?php
|
||||
// App/config.php
|
||||
return [
|
||||
'site_name' => 'Nick Yeoman',
|
||||
];</code></pre>
|
||||
|
||||
<p>See <a href="/admin/docs/seo">SEO</a> for the full list of overridable meta blocks. Pages that were already statically cached before this changes need <code>php novaconium/bin/clear-cache.php</code> to pick it up.</p>
|
||||
|
||||
<h2>Admin authentication</h2>
|
||||
|
||||
<p><code>admin_username</code> / <code>admin_password_hash</code> gate every <code>/admin/*</code> route behind HTTP Basic Auth — see <a href="/admin/docs/admin-auth">Admin authentication</a> for the full write-up. Both are set via <code>App/config.php</code>; leaving <code>admin_password_hash</code> empty (the default) disables the gate.</p>
|
||||
|
||||
<h2>For developers: using <code>Cache.php</code> directly</h2>
|
||||
|
||||
<p><code>novaconium/src/Cache.php</code> is the class behind the <code>cache_dir</code> config key above — a small, dependency-free wrapper around writing/deleting the static HTML files under <code>public/cache/</code> that <a href="/admin/docs/caching">Static caching</a> describes. Like <code>Router</code> (see <code>/admin/docs/routing</code>'s "For developers" section), it's plain and easy to reason about in isolation: no Twig, no request state, just a path convention and some filesystem calls.</p>
|
||||
|
||||
<h3>How it fits in</h3>
|
||||
|
||||
<p><code>Cache</code> shows up in three places, all constructed the same way — <code>new Cache($config['cache_dir'])</code>:</p>
|
||||
|
||||
<ul>
|
||||
<li><code>novaconium/bootstrap.php</code> constructs one and hands it to <code>Renderer</code>, which calls <code>$cache->write()</code> after rendering any page that has <strong>no</strong> sidecar (see <code>/admin/docs/sidecars</code>'s <code>Renderer.php</code> section) — <code>Renderer</code> is the only thing that ever writes to the cache.</li>
|
||||
<li><code>novaconium/bin/clear-cache.php</code>, a standalone CLI script, constructs one and calls <code>$cache->clear()</code> — this is what <code>php novaconium/bin/clear-cache.php</code> runs.</li>
|
||||
<li><code>novaconium/pages/admin/clear-cache/index.php</code>'s sidecar calls <code>$cache->clear()</code> too, but doesn't construct it — <code>$cache</code> is already in scope automatically inside every sidecar, the same instance <code>Renderer</code> is using, injected by <code>Renderer::runSidecar()</code> alongside <code>$params</code>.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Constructing it and its three methods</h3>
|
||||
|
||||
<pre><code>use App\Cache;
|
||||
|
||||
$cache = new Cache($config['cache_dir']);
|
||||
|
||||
$cache->path($requestUri); // string — the .html file a URI maps to, without touching the filesystem
|
||||
$cache->write($requestUri, $html); // void — creates parent directories as needed, then writes the file
|
||||
$cache->clear(); // void — recursively deletes everything under cache_dir, leaving the directory itself</code></pre>
|
||||
|
||||
<p>The constructor takes just one thing — <code>cache_dir</code>, a single absolute path (<code>novaconium/config.php</code> sets it to <code>public/cache</code>) — unlike <code>Router</code>/<code>Renderer</code>, which take the whole ordered <code>pagesDirs</code> list. That's because caching isn't part of the App-over-novaconium override mechanism; there's exactly one cache directory, not a searched list of them.</p>
|
||||
|
||||
<p><code>path()</code> mirrors the public URL tree directly: <code>/blog/hello-world</code> maps to <code>{cache_dir}/blog/hello-world/index.html</code>, matching the <code>.htaccess</code> rule that checks for that exact file before letting any request reach PHP. <code>write()</code> calls <code>path()</code> internally and creates any missing parent directories with <code>mkdir(..., true)</code> before writing. <code>clear()</code> recurses over every entry under <code>cache_dir</code> deleting files and subdirectories, but never deletes <code>cache_dir</code> itself — so a stray <code>public/cache/.gitkeep</code> (see <code>.gitignore</code>) survives a clear.</p>
|
||||
|
||||
<p>Because every method here is a straightforward filesystem operation with no hidden state beyond the one constructor argument, testing <code>Cache</code> in isolation is just a matter of pointing it at a temporary directory and asserting on what ends up on disk.</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,84 @@
|
||||
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||
|
||||
{% block title %}Design notes{% endblock %}
|
||||
|
||||
{% block description %}The original design rationale for this framework.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block docs_content %}
|
||||
<h1>File-based PHP + Twig micro-router (Hugo-style, static-cached, Apache)</h1>
|
||||
|
||||
<h2>Context</h2>
|
||||
<p>User likes Hugo's file-based routing/pretty-URLs but wants real PHP capability and Twig instead of Markdown. Pages are files on disk (page-bundle style): a directory tree of <code>.twig</code> templates defines the URL structure, and any page needing PHP logic gets an optional sidecar <code>index.php</code> that supplies data or a full custom response (redirect/JSON/XML). Pages <em>without</em> a sidecar are pure/deterministic, so their rendered output is cached to a static <code>.html</code> file and served directly by Apache — PHP is skipped entirely on repeat hits, similar to Hugo's static output but generated lazily on first request instead of an upfront build. Deployment target is Apache only, so <code>.htaccess</code> handles both the cache short-circuit and canonical-URL redirects.</p>
|
||||
|
||||
<h2>Pages/layouts are overridable, same mechanism as Lib\ overrides</h2>
|
||||
<p>Routing and rendering key off an <em>ordered list</em> of roots (<code>App/pages/</code> then <code>novaconium/pages/</code>) and resolve every relative path (a page, a sidecar, a <code>_layout/layout.twig</code>) against that list via <code>Overlay::isFile</code>/<code>isDir</code>/<code>findFile</code>/<code>findWildcardDir</code>, first match wins. <code>novaconium/pages/</code> ships the framework defaults (<code>_layout/layout.twig</code>, <code>404/index.twig</code>); a project doesn't need to duplicate those to have a working site, but overrides either — or any page — just by placing a file at the same relative path under <code>App/pages/</code>. Twig's <code>FilesystemLoader</code> natively accepts an array of paths, so template rendering (including <code>{% verbatim %}{% extends %}{% endverbatim %}</code>) gets the same override-then-fallback resolution for free — no manual proxying required there.</p>
|
||||
|
||||
<h2>Router (novaconium/src/Router.php) — intentionally simple, no rendering</h2>
|
||||
<ol>
|
||||
<li>Take <code>$_SERVER['REQUEST_URI']</code>, strip query string, trim slashes, split into segments (root = <code>[]</code>).</li>
|
||||
<li>Walk the page roots via <code>Overlay</code>, skipping any directory starting with <code>_</code> when matching (those are reserved, e.g. <code>_layout/</code>, never routable). At each level: exact-name subdirectory first (found in <em>any</em> root), else a <code>[param]</code>-named subdirectory (capturing into <code>params[name]</code>), else no match.</li>
|
||||
<li>Returns a <code>Route</code> object: <code>{ dir: string|null (relative path, not absolute), params: array, found: bool }</code>. Never touches Twig, sidecars, or output — that's the Renderer's job. This keeps Router a pure "URL -> relative page path" lookup, easy to unit-test on its own.</li>
|
||||
</ol>
|
||||
|
||||
<h2>Sidecar PHP contract — can return more than an array</h2>
|
||||
<p><code>App/pages/**/index.php</code> (optional) returns one of:</p>
|
||||
<ul>
|
||||
<li><strong>array</strong> → treated as Twig context data (<code>$params</code> is in scope for slug etc.).</li>
|
||||
<li><strong>Response object</strong> → short-circuits Twig entirely. <code>novaconium/src/Response.php</code> provides factories: <code>Response::redirect($url, $code = 301)</code>, <code>Response::json($data)</code>, <code>Response::xml($string)</code>, <code>Response::html($string)</code> (raw HTML, bypass Twig but still gets cached like a normal page if desired).</li>
|
||||
</ul>
|
||||
<p>Renderer checks the sidecar's return type: array → render matched <code>index.twig</code> with that data; <code>Response</code> → emit directly (correct headers/status, no Twig, no layout).</p>
|
||||
|
||||
<h2>Layout inheritance — walk upward like Hugo</h2>
|
||||
<p>Reserved <code>_layout/layout.twig</code> directories are looked up by the Renderer, not baked into Router:</p>
|
||||
<ol>
|
||||
<li>Starting at the matched page's directory, check for <code>_layout/layout.twig</code> via <code>Overlay</code> (checking <code>App/pages/</code> before <code>novaconium/pages/</code> at every level). If absent, check parent directory, and so on up to the root <code>_layout/layout.twig</code> (<code>App/pages/_layout/layout.twig</code> if present, else <code>novaconium/pages/_layout/layout.twig</code>).</li>
|
||||
<li>The resolved layout path is injected into the Twig context as <code>layout</code>, and each <code>index.twig</code> does <code>{% verbatim %}{% extends layout %}{% endverbatim %}</code> — keeps the mechanism transparent in the template rather than magic in the engine.</li>
|
||||
</ol>
|
||||
|
||||
<h2>Static caching (sidecar-less pages only)</h2>
|
||||
<ol>
|
||||
<li>When Renderer serves a page whose directory has <strong>no</strong> <code>index.php</code>, after rendering the Twig output it also writes it to <code>public/cache/<segments>/index.html</code>.</li>
|
||||
<li><code>.htaccess</code> checks <code>public/cache/<REQUEST_URI>/index.html</code> first (<code>RewriteCond ... -f</code>) and serves it directly if present — PHP/Twig never runs again for that route until the cache file is cleared.</li>
|
||||
<li>Cache invalidation is manual for now: clearing <code>public/cache/</code> (or a specific subfolder) forces regeneration on next hit. (Simple, matches "no build step" philosophy; can add mtime-based invalidation later if needed.)</li>
|
||||
<li>Pages <em>with</em> a sidecar are never cached this way since their output can vary per-request (DB data, params, POST handling, etc.).</li>
|
||||
</ol>
|
||||
|
||||
<h2>Canonical URLs — no trailing slash (SEO)</h2>
|
||||
<ul>
|
||||
<li>Canonical form is <strong>without</strong> a trailing slash: <code>/about</code>, <code>/blog/hello-world</code>.</li>
|
||||
<li><code>.htaccess</code> 301-redirects any URL ending in <code>/</code> (except the bare root <code>/</code>) to the same path without it, before any routing/cache logic runs.</li>
|
||||
<li>Router/Renderer internally still key off directory-per-page (<code>pages/about/index.twig</code>), that's just the filesystem convention — it's independent of what the external canonical URL looks like.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Front controller (public/index.php) — kept intentionally light</h2>
|
||||
<pre><code><?php
|
||||
require __DIR__ . '/../novaconium/bootstrap.php';</code></pre>
|
||||
<p>All real logic — autoload registration, config load, <code>Router::resolve()</code>, <code>Renderer::render()</code>, emitting headers/body — lives in <code>novaconium/bootstrap.php</code>, not in <code>public/index.php</code>.</p>
|
||||
|
||||
<h2>Sass (indented syntax, not SCSS)</h2>
|
||||
<ul>
|
||||
<li>Source lives in <code>novaconium/sass/</code> (structure in <code>main.sass</code>, default colors in <code>defaults/_colors.sass</code>) using indented syntax. <code>App/sass/_colors.sass</code> overrides the color palette.</li>
|
||||
<li>Compiled output goes to <code>public/css/main.css</code>.</li>
|
||||
<li>Compilation is a build step (not a PHP runtime concern) — use the <code>sass</code> CLI (Dart Sass, which supports indented syntax) e.g. <code>sass --load-path=App/sass --load-path=novaconium/sass/defaults novaconium/sass/main.sass public/css/main.css</code>. No PHP Sass library is needed/assumed since PHP-based compilers (scssphp) target SCSS, not indented syntax.</li>
|
||||
<li><code>main.sass</code> does <code>@use 'colors' as *</code> but its own directory has no <code>_colors.sass</code> — on purpose, so resolution falls through to the load paths above (<code>App/sass</code> checked first) instead of resolving to a same-directory sibling, which Dart Sass would otherwise prefer regardless of load-path order. Same override-by-presence mechanism used for pages/lib, extended to a non-PHP asset.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Autoloading (no Composer)</h2>
|
||||
<p><code>novaconium/autoload.php</code>: <code>spl_autoload_register</code> mapping <code>Twig\</code> → <code>novaconium/vendor/twig/src/</code>, <code>App\</code> → <code>novaconium/src/</code>, and <code>Lib\</code> → checked against <code>App/lib/</code> first, falling back to <code>novaconium/lib/</code> — this is the override mechanism: a project drops a same-named class in <code>App/lib/</code> to replace a framework default without touching <code>novaconium/</code>. Twig vendored manually from a GitHub release zip into <code>novaconium/vendor/twig/</code>.</p>
|
||||
|
||||
<h2>Verification</h2>
|
||||
<ul>
|
||||
<li>Configure Apache (or equivalent local Apache setup) with docroot <code>public/</code> and the <code>.htaccess</code> rules active (<code>AllowOverride All</code> / mod_rewrite enabled).</li>
|
||||
<li>Visit <code>/about/</code> → confirms 301 redirect to <code>/about</code> (canonical, no trailing slash).</li>
|
||||
<li>Visit <code>/about</code> twice → first hit renders via PHP/Twig and writes <code>public/cache/about/index.html</code>; second hit confirms (e.g. via response headers/timing, or temporarily renaming <code>novaconium/bootstrap.php</code>) that Apache served the cached file directly.</li>
|
||||
<li>Visit <code>/contact</code> (has a sidecar) → confirms it is NOT cached (no sidecar-less caching), and that a <code>Lib\</code> class (<code>Lib\Mailer</code>) can be called from it. Every post under <code>App/pages/blog/</code> is sidecar-less by contrast — visiting one twice should show the same static-cache behavior as <code>/about</code> above.</li>
|
||||
<li>Add a sidecar that returns <code>Response::json([...])</code> → confirms JSON is emitted with correct <code>Content-Type</code>, bypassing Twig.</li>
|
||||
<li>Add a sidecar that returns <code>Response::redirect('/somewhere')</code> → confirms a real HTTP redirect happens.</li>
|
||||
<li>Confirm <code>App/pages/blog/_layout/layout.twig</code> overrides <code>App/pages/_layout/layout.twig</code> for pages under <code>blog/</code>, and that <code>_layout/</code> directories are never reachable as routes (404 if requested directly).</li>
|
||||
<li>Drop a same-named class into <code>App/lib/</code> and confirm it's used instead of the <code>novaconium/lib/</code> default (override mechanism works).</li>
|
||||
<li>Delete <code>App/pages/_layout/</code> and <code>App/pages/404/</code> (if present) and confirm the site still renders and 404s using <code>novaconium/pages/</code>'s defaults; then drop a <code>_layout/layout.twig</code> into <code>App/pages/</code> and confirm it takes precedence.</li>
|
||||
<li>Run <code>sass --load-path=App/sass --load-path=novaconium/sass/defaults novaconium/sass/main.sass public/css/main.css</code> and confirm compiled CSS loads on a page.</li>
|
||||
</ul>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,137 @@
|
||||
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
{% block title %}Forms{% endblock %}
|
||||
|
||||
{% block description %}Building a custom form with a sidecar, using Lib\FormValidator, Lib\SpamGuard, and Lib\Validate.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block docs_content %}
|
||||
<h1>Forms</h1>
|
||||
|
||||
<p>Every form on this site — the <a class="icon-link" href="/contact">{{ icons.email() }}contact form</a> included — is the same four ingredients: a page with a sidecar, the three validation/spam classes under <code>Lib\</code>, and the POST/redirect/GET pattern. This page builds a second, different form from scratch (a one-field newsletter signup) so the pattern is clear independent of the contact form's specific fields.</p>
|
||||
|
||||
<h2>1. Create the page</h2>
|
||||
|
||||
<p>A form needs a directory with both an <code>index.twig</code> and an <code>index.php</code> — the sidecar is what makes it a form rather than a static page. Scaffold the Twig half with <code>novaconium/bin/create-static-page.php</code> (see <a class="icon-link" href="/admin/docs/getting-started">{{ icons.book() }}Getting started</a>) and add the sidecar yourself, or just create both by hand:</p>
|
||||
|
||||
<pre><code>App/pages/newsletter/
|
||||
index.twig
|
||||
index.php</code></pre>
|
||||
|
||||
<h2>2. The sidecar</h2>
|
||||
|
||||
<p>This is the whole contract: validate <code>$_POST</code>, check for spam, do something with the result, redirect. Nothing here is specific to "newsletter" — swap the one field and the one line that does something with valid input, and this is the shape of any form on the site:</p>
|
||||
|
||||
<pre><code>{% verbatim %}<?php
|
||||
// App/pages/newsletter/index.php
|
||||
|
||||
use App\Response;
|
||||
use Lib\Csrf;
|
||||
use Lib\FormValidator;
|
||||
use Lib\Input;
|
||||
use Lib\SpamGuard;
|
||||
|
||||
$errors = [];
|
||||
$old = ['email' => ''];
|
||||
$spamGuard = new SpamGuard();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!Csrf::verify(Input::post('csrf_token'))) {
|
||||
return Response::redirect('/newsletter?error=security');
|
||||
}
|
||||
|
||||
$old = [
|
||||
'email' => Input::post('email', ''),
|
||||
];
|
||||
|
||||
$validator = (new FormValidator())
|
||||
->required($old['email'], 'email', 'Email is required.')
|
||||
->email($old['email'], 'email', 'A valid email is required.');
|
||||
|
||||
if ($validator->passes()) {
|
||||
if (!$spamGuard->isSpam(Input::post())) {
|
||||
// ...store $old['email'] somewhere: a file, SQLite once that
|
||||
// groundwork lands (see novaconium/ISSUES.md), a third-party API, etc.
|
||||
// This is the one line that's actually specific to this form.
|
||||
}
|
||||
|
||||
return Response::redirect('/newsletter?sent=1');
|
||||
}
|
||||
|
||||
$errors = $validator->errors();
|
||||
}
|
||||
|
||||
return [
|
||||
'errors' => $errors,
|
||||
'old' => $old,
|
||||
'sent' => Input::get('sent') !== null,
|
||||
'securityError' => Input::get('error') === 'security',
|
||||
'renderedAt' => $spamGuard->renderedAt(),
|
||||
'csrfField' => Csrf::fieldName(),
|
||||
'csrfToken' => Csrf::token(),
|
||||
];{% endverbatim %}</code></pre>
|
||||
|
||||
<p>Four things worth noticing, all covered in full in <a class="icon-link" href="/admin/docs/sidecars">{{ icons.book() }}Sidecars</a>' "Form security" section:</p>
|
||||
|
||||
<ul>
|
||||
<li><code>FormValidator</code> accumulates named field errors — <code>required()</code>, <code>email()</code>, and <code>maxLength()</code>/<code>minLength()</code> are all available; chain as many as the form needs, then check <code>$validator->passes()</code> once.</li>
|
||||
<li><code>SpamGuard::isSpam(Input::post())</code> is checked <em>after</em> validation passes, and the form redirects to the same success URL either way — a bot gets an identical response to a human, it just never reaches the "actually do something" line.</li>
|
||||
<li><code>$spamGuard->renderedAt()</code> goes into the sidecar's returned context on every request (GET <em>and</em> POST) — the template needs it either way, since a failed validation re-renders the form with a fresh timestamp for the next attempt.</li>
|
||||
<li><code>Csrf::verify()</code> is checked <em>before</em> anything else, and unlike a failed spam check, a failed CSRF check shows a real, visible error — a CSRF failure is usually a legitimate stale-tab case for a real visitor, not something worth hiding.</li>
|
||||
</ul>
|
||||
|
||||
<h2>3. The template</h2>
|
||||
|
||||
<p>The honeypot field and the hidden timestamp are the two pieces every form needs regardless of its actual fields — copy them as-is:</p>
|
||||
|
||||
<pre><code>{% verbatim %}{% extends layout %}
|
||||
|
||||
{% block title %}Newsletter{% endblock %}
|
||||
{% block description %}Sign up for occasional updates.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article>
|
||||
<h1>Newsletter</h1>
|
||||
|
||||
{% if sent %}
|
||||
<p><strong>Thanks — you're signed up.</strong></p>
|
||||
{% endif %}
|
||||
|
||||
{% if securityError %}
|
||||
<p><strong>Your session expired before submitting — please try again.</strong></p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/newsletter">
|
||||
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||
<div class="hp-field" aria-hidden="true">
|
||||
<label for="website">Leave this field blank</label>
|
||||
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off">
|
||||
</div>
|
||||
<input type="hidden" name="rendered_at" value="{{ renderedAt }}">
|
||||
<p>
|
||||
<label for="email">Email</label><br>
|
||||
<input type="text" id="email" name="email" value="{{ old.email }}">
|
||||
{% if errors.email %}<br><small>{{ errors.email }}</small>{% endif %}
|
||||
</p>
|
||||
<button type="submit">Sign up</button>
|
||||
</form>
|
||||
</article>
|
||||
{% endblock %}{% endverbatim %}</code></pre>
|
||||
|
||||
<p>The <code>.hp-field</code> class (defined once in <code>novaconium/sass/main.sass</code>) positions the honeypot off-screen rather than hiding it with <code>display: none</code>, since some spam bots specifically skip fields hidden that way — see <a class="icon-link" href="/admin/docs/sidecars">{{ icons.book() }}Sidecars</a> for why. Nothing about that field or the timestamp needs to change between forms; only the real fields (<code>email</code> here, <code>name</code>/<code>email</code>/<code>message</code> on the contact form) differ.</p>
|
||||
|
||||
<h2>Why it's never cached</h2>
|
||||
|
||||
<p>Because this page has a sidecar, it's excluded from the static-cache path entirely (see <a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a>) — every request re-runs the sidecar, which is exactly what a form needs: a fresh <code>rendered_at</code> timestamp, current validation errors, and an up-to-date <code>sent</code> flag from the query string. A form is the canonical example of a page that <em>shouldn't</em> be sidecar-less, even though nothing stops you from technically leaving the sidecar off — without one, there's nothing to receive <code>$_POST</code> at all.</p>
|
||||
|
||||
<h2>Going further</h2>
|
||||
|
||||
<ul>
|
||||
<li>Need a phone number, a postal/zip code, or a "confirm email" field? <code>Lib\Validate</code> has <code>isPhone()</code>, <code>isPostalCode()</code>/<code>isZipCode()</code>, and <code>isMatch()</code> — see <a class="icon-link" href="/admin/docs/libraries">{{ icons.book() }}Libraries</a>.</li>
|
||||
<li>Want the submission to actually go somewhere? Swap the comment in step 2 for a call to <code>Lib\Mailer::send()</code> (see the contact form), a new <code>Lib\</code> class of your own, or — once SQLite groundwork lands (see <code>novaconium/ISSUES.md</code>, not yet built) — a database write.</li>
|
||||
<li>Multiple forms on one site can each use their own honeypot/timestamp field names by passing constructor arguments to <code>SpamGuard</code>, e.g. <code>new SpamGuard('url', 'ts', 3)</code>, so they don't interfere with each other.</li>
|
||||
</ul>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,38 @@
|
||||
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||
|
||||
{% block title %}Getting started{% endblock %}
|
||||
|
||||
{% block description %}Requirements, running locally, and deploying on Apache.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block docs_content %}
|
||||
<h1>Getting started</h1>
|
||||
|
||||
<p><strong>Requirements:</strong> PHP 8.1+ and, for production, Apache with <code>mod_rewrite</code> and <code>AllowOverride All</code>.</p>
|
||||
|
||||
<h2>Run it locally (no Apache needed)</h2>
|
||||
|
||||
<pre><code>php -S 127.0.0.1:8000 -t public public/router.php</code></pre>
|
||||
|
||||
<p><code>public/router.php</code> is a dev-only script that mimics the <code>.htaccess</code> rules (canonical redirects + static cache lookup) so you can develop without Apache. It is never used in production — Apache reads <code>public/.htaccess</code> directly.</p>
|
||||
|
||||
<p>Visit:</p>
|
||||
<ul>
|
||||
<li><code>http://127.0.0.1:8000/</code> — static home page</li>
|
||||
</ul>
|
||||
|
||||
<h2>Deploy on Apache</h2>
|
||||
|
||||
<p>Point the vhost's document root at <code>public/</code>, make sure <code>mod_rewrite</code> is enabled and <code>AllowOverride All</code> is set for that directory so <code>public/.htaccess</code> takes effect, and it just works — no build step required.</p>
|
||||
|
||||
<h2>Adding a new page</h2>
|
||||
|
||||
<p>Create a directory under <code>App/pages/</code> with an <code>index.twig</code> — the directory path <em>is</em> the URL (see <a href="/admin/docs/routing">Routing</a>). <a href="/admin/docs/seo">SEO</a> has a ready-to-paste starter template with every overridable block (title, description, Open Graph, Twitter Card) plus a content stub — copy it in and fill in the blanks.</p>
|
||||
|
||||
<p>Or skip the copy-paste entirely:</p>
|
||||
|
||||
<pre><code>php novaconium/bin/create-static-page.php blog/my-new-post</code></pre>
|
||||
|
||||
<p>Scaffolds <code>App/pages/blog/my-new-post/index.twig</code> from that same starter template, with the title pre-filled from the last path segment ("my-new-post" → "My New Post"). The path can be given with or without a trailing <code>.twig</code> or <code>/index.twig</code> — refuses to run if the page already exists, or if any segment is reserved (starts with <code>_</code>, or is literally <code>404</code> — see <a href="/admin/docs/routing">Routing</a>).</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,32 @@
|
||||
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||
|
||||
{% import '_layout/icons.twig' as icons %}
|
||||
|
||||
{% block title %}Docs{% endblock %}
|
||||
|
||||
{% block description %}Framework documentation: routing, sidecars, forms, libraries, layouts, caching, styling.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block docs_content %}
|
||||
<h1 class="icon-heading">{{ icons.book() }}Project documentation</h1>
|
||||
<p>Framework docs, rendered as plain Twig pages — no internet connection needed.</p>
|
||||
<ul>
|
||||
<li><a class="icon-link" href="/admin/docs/getting-started">{{ icons.book() }}Getting started</a> — requirements, running locally, deploying on Apache.</li>
|
||||
<li><a class="icon-link" href="/admin/docs/routing">{{ icons.link() }}Routing</a> — how a URL maps to a directory under <code>App/pages/</code>.</li>
|
||||
<li><a class="icon-link" href="/admin/docs/sidecars">{{ icons.book() }}Sidecars</a> — where your PHP logic goes.</li>
|
||||
<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/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>
|
||||
<li><a class="icon-link" href="/admin/docs/seo">{{ icons.book() }}SEO</a> — the meta tags every page gets for free, and how to override them.</li>
|
||||
<li><a class="icon-link" href="/admin/docs/matomo">{{ icons.book() }}Matomo</a> — built-in analytics tracking, including 404 tracking.</li>
|
||||
<li><a class="icon-link" href="/admin/docs/styling">{{ icons.book() }}Styling</a> — Sass, indented syntax, with an overridable color palette.</li>
|
||||
<li><a class="icon-link" href="/admin/docs/project-layout">{{ icons.sitemap() }}Project layout</a> — a map of the whole tree.</li>
|
||||
<li><a class="icon-link" href="/admin/docs/third-party">{{ icons.external_link() }}Third-party</a> — vendored Twig and its license.</li>
|
||||
<li><a class="icon-link" href="/admin/docs/design-notes">{{ icons.book() }}Design notes</a> — the original design rationale.</li>
|
||||
<li><a class="icon-link" href="/admin/docs/upgrading-twig">{{ icons.book() }}Upgrading Twig</a> — how to bump the vendored copy.</li>
|
||||
</ul>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,28 @@
|
||||
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||
|
||||
{% block title %}Layouts{% endblock %}
|
||||
|
||||
{% block description %}How pages and layouts are overridable, just like Lib\.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block docs_content %}
|
||||
<h1>Pages and layouts are overridable, just like Lib\</h1>
|
||||
|
||||
<p>Routing doesn't resolve against a single <code>pages/</code> tree — it resolves against an ordered list of roots: <code>App/pages/</code> first, then <code>novaconium/pages/</code> as the framework's fallback default. The default <code>_layout/layout.twig</code> and <code>404/index.twig</code> ship in <code>novaconium/pages/</code>; a project doesn't need to duplicate them to have a working site, but can override either (or add any page) just by placing a file at the same relative path in <code>App/pages/</code>. Same mechanism as <code>Lib\</code> overrides in <code>App/lib/</code>, applied to pages.</p>
|
||||
|
||||
<p>Shared layout markup lives in <code>_layout/layout.twig</code> directories. The renderer walks upward from the matched page looking for the nearest one, checking <code>App/pages/</code> before <code>novaconium/pages/</code> at every level — so you can override the layout for a whole subtree just by dropping a new <code>_layout/</code> next to it:</p>
|
||||
|
||||
<pre><code>novaconium/pages/_layout/layout.twig <- framework default, used unless overridden
|
||||
App/pages/_layout/layout.twig <- overrides the site-wide default (optional)
|
||||
App/pages/blog/_layout/layout.twig <- overrides it for everything under /blog</code></pre>
|
||||
|
||||
<p>A nested layout can extend the parent one (paths are resolved against the page roots, not relative to the current file):</p>
|
||||
|
||||
<pre><code>{% verbatim %}{% extends 'admin/docs/_layout/layout.twig' %}{% endverbatim %}</code></pre>
|
||||
|
||||
<p>Every <code>index.twig</code> extends whichever layout was resolved for it, via the <code>layout</code> variable that's always injected into the context:</p>
|
||||
|
||||
<pre><code>{% verbatim %}{% extends layout %}
|
||||
{% block content %}...{% endblock %}{% endverbatim %}</code></pre>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,30 @@
|
||||
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||
|
||||
{% block title %}Libraries{% endblock %}
|
||||
|
||||
{% block description %}Plain PHP classes under the Lib\ namespace.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block docs_content %}
|
||||
<h1>Libraries</h1>
|
||||
|
||||
<p>Plain PHP classes live in <code>App/lib/</code> (or <code>novaconium/lib/</code> for defaults) under the <code>Lib\</code> namespace and are autoloaded automatically — call them from any sidecar:</p>
|
||||
|
||||
<pre><code>use Lib\Mailer;
|
||||
|
||||
(new Mailer())->send($old['name'], $old['email'], $old['message']);</code></pre>
|
||||
|
||||
<h2>Framework-default classes</h2>
|
||||
|
||||
<p><code>novaconium/lib/</code> ships a few ready-to-use classes any sidecar can call, same as a project's own <code>App/lib/</code> classes — override any of them by dropping a same-named file in <code>App/lib/</code>:</p>
|
||||
|
||||
<ul>
|
||||
<li><code>Lib\Mailer</code> — the contact form's mail stand-in (logs to a file instead of an external mail dependency; see its own source for the note on swapping in a real mail call).</li>
|
||||
<li><code>Lib\SpamGuard</code> — self-hosted honeypot + submission-timing spam detection, reusable on any form. See <a href="/admin/docs/sidecars">Sidecars</a>' "Spam prevention" section for the full write-up.</li>
|
||||
<li><code>Lib\FormValidator</code> — a small accumulating required-field/email/length validator, so a form sidecar doesn't hand-roll the same checks and <code>$errors</code> array every time. Also covered in <a href="/admin/docs/sidecars">Sidecars</a>' "Spam prevention" section.</li>
|
||||
<li><code>Lib\Validate</code> — the lower-level validation primitives <code>FormValidator</code> calls into (<code>isEmail()</code>, <code>minLength()</code>/<code>maxLength()</code>, <code>isMatch()</code>, <code>isPhone()</code>, <code>isPostalCode()</code>/<code>isZipCode()</code>) — call these directly from a sidecar when you just need a validated/normalized value back rather than an accumulated field-error. See <a href="/admin/docs/sidecars">Sidecars</a>' "Spam prevention" section.</li>
|
||||
<li><code>Lib\Input</code> — a cleaning accessor for <code>$_POST</code>/<code>$_GET</code> (<code>Input::post()</code>/<code>Input::get()</code>), used by every sidecar instead of the superglobals directly. Defense-in-depth against HTML/script injection, <strong>not</strong> a defense against SQL injection — see <a href="/admin/docs/sidecars">Sidecars</a>' "Form security" section for the full caveat.</li>
|
||||
<li><code>Lib\Csrf</code> — standalone session-token CSRF protection (<code>Csrf::token()</code>/<code>::verify()</code>), called directly from a sidecar rather than through <code>FormValidator</code>. See <a href="/admin/docs/sidecars">Sidecars</a>' "Form security" section.</li>
|
||||
</ul>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,46 @@
|
||||
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||
|
||||
{% block title %}Matomo{% endblock %}
|
||||
|
||||
{% block description %}Built-in Matomo analytics tracking, including 404 page tracking.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block docs_content %}
|
||||
<h1>Matomo analytics</h1>
|
||||
|
||||
<p>The root layout (<code>novaconium/pages/_layout/layout.twig</code>) includes <code>_layout/matomo.twig</code>, which emits the standard Matomo tracking snippet on every page — off by default, enabled by supplying two <code>App/config.php</code> keys.</p>
|
||||
|
||||
<h2>Enabling it</h2>
|
||||
|
||||
<pre><code><?php
|
||||
// App/config.php
|
||||
return [
|
||||
'matomo_url' => 'https://matomo.example.com/',
|
||||
'matomo_site_id' => '1',
|
||||
];</code></pre>
|
||||
|
||||
<p>Both <code>matomo_url</code> and <code>matomo_site_id</code> default to an empty string in <code>novaconium/config.php</code>. The layout only renders the tracking <code><script></code> when <strong>both</strong> are set — leaving either one out (e.g. in local development) disables tracking entirely, with zero script emitted. A missing trailing slash on <code>matomo_url</code> is normalized automatically in <code>novaconium/bootstrap.php</code>.</p>
|
||||
|
||||
<h2>404 tracking</h2>
|
||||
|
||||
<p>Matomo doesn't know a page is a 404 unless told — its documented convention is to set the document title to <code>404/URL = <requested path>/From = <referrer></code> immediately before <code>trackPageView</code>, so 404 hits are filterable under Behaviour → Page URLs by searching for <code>404</code>. The layout does this automatically: <code>novaconium/src/Renderer.php::renderNotFound()</code> passes <code>is_404 => true</code> into the 404 template's context (a Twig global elsewhere, defaulting to <code>false</code>), and the layout's tracking script checks that flag to decide whether to push <code>setDocumentTitle</code> before <code>trackPageView</code>.</p>
|
||||
|
||||
<h2>What's included</h2>
|
||||
|
||||
<ul>
|
||||
<li><code>trackPageView</code> on every request.</li>
|
||||
<li><code>enableLinkTracking</code> for outbound-link and download tracking.</li>
|
||||
<li>404-specific document title tagging as described above.</li>
|
||||
</ul>
|
||||
|
||||
<p>The snippet is the same one Matomo's own admin UI generates ("JS Tracking Code"), so anything from Matomo's docs that builds on <code>_paq.push([...])</code> calls (custom events, goal tracking, ecommerce, etc.) can be added directly to it.</p>
|
||||
|
||||
<h2>Overriding the snippet</h2>
|
||||
|
||||
<p>The snippet lives in its own file, <code>novaconium/pages/_layout/matomo.twig</code>, specifically so it's overridable — same App-over-novaconium mechanism used for every other page and layout, not a special case. Drop a same-named file at <code>App/pages/_layout/matomo.twig</code> to replace it entirely without touching <code>novaconium/</code>: point at a self-hosted tracker proxy, add extra <code>_paq.push</code> calls, gate it behind a consent check, or swap in a completely different analytics snippet. <code>matomo_url</code>, <code>matomo_site_id</code>, and <code>is_404</code> are all available in the override's context, same as in the original.</p>
|
||||
|
||||
<h2>Privacy note</h2>
|
||||
|
||||
<p>This only wires up the tracking snippet — it does not add a cookie/consent banner or handle Do Not Track. If your jurisdiction requires consent before loading analytics, gate the <code>matomo_url</code>/<code>matomo_site_id</code> config or override <code>_layout/matomo.twig</code> behind whatever consent mechanism the project uses.</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,56 @@
|
||||
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||
|
||||
{% block title %}Project layout{% endblock %}
|
||||
|
||||
{% block description %}A map of the whole project tree.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block docs_content %}
|
||||
<h1>Project layout</h1>
|
||||
|
||||
<pre><code>public/ Apache document root
|
||||
index.php thin front controller — just requires novaconium/bootstrap.php
|
||||
.htaccess cache short-circuit, canonical redirects, rewrite to index.php
|
||||
router.php dev-only helper for `php -S` (not used in production)
|
||||
cache/ generated static HTML (safe to delete anytime)
|
||||
css/ compiled CSS output
|
||||
App/ your project — the only directory you're expected to edit
|
||||
pages/ your routes — directory tree = URL tree, checked before novaconium/pages/ so you can add or override pages/layouts
|
||||
lib/ your PHP classes (Lib\), checked before novaconium/lib/ so you can override defaults
|
||||
sass/ your Sass overrides — _colors.sass, checked before novaconium/sass/defaults/
|
||||
config.php your config overrides — ships as an empty, commented placeholder; shallow-merged over novaconium/config.php
|
||||
novaconium/ the framework itself — boilerplate, not meant to be edited per-project
|
||||
pages/ default pages: _layout/layout.twig (root layout) and 404/index.twig (used when App/pages/ doesn't override them)
|
||||
sass/ Sass source: main.sass (structure) + defaults/_colors.sass (default palette, overridable from App/sass/)
|
||||
lib/ default Lib\ classes (used when App/lib/ doesn't override them)
|
||||
src/ Router, Route, Renderer, Response, Cache, Overlay (the App/-over-novaconium/ lookup used for both pages and lib)
|
||||
vendor/twig/ vendored Twig source (no Composer)
|
||||
bin/
|
||||
clear-cache.php standalone CLI entry point — `php novaconium/bin/clear-cache.php`
|
||||
create-static-page.php scaffolds a new page from the SEO starter template — `php novaconium/bin/create-static-page.php <path>`
|
||||
autoload.php manual PSR-4 autoloader
|
||||
config.php paths and settings
|
||||
bootstrap.php wires everything together for each request</code></pre>
|
||||
|
||||
<h2>How a request flows through these files</h2>
|
||||
|
||||
<p>There's no framework "kernel" class — just a plain chain of <code>require</code>s, each handing off to the next, deliberately kept flat enough to read top-to-bottom in one sitting:</p>
|
||||
|
||||
<ol>
|
||||
<li><strong><code>public/.htaccess</code></strong> runs first, before PHP does anything. If <code>public/cache/<path>/index.html</code> exists for the requested URL, Apache serves that file directly and nothing below this line ever executes — see <a href="/admin/docs/caching">Static caching</a>. Otherwise it strips a trailing slash (301 redirect) and rewrites everything else to <code>public/index.php</code>.</li>
|
||||
<li><strong><code>public/index.php</code></strong> is intentionally one line: <code>require __DIR__ . '/../novaconium/bootstrap.php';</code>. (Running locally via <code>php -S</code> instead of Apache? <code>public/router.php</code> mimics the same three <code>.htaccess</code> rules in PHP, then requires <code>index.php</code> the same way — see <a href="/admin/docs/getting-started">Getting started</a>.)</li>
|
||||
<li><strong><code>novaconium/bootstrap.php</code></strong> is where the real wiring happens, top-to-bottom:
|
||||
<ol>
|
||||
<li>Requires <strong><code>novaconium/autoload.php</code></strong>, registering the <code>Twig\</code>/<code>App\</code>/<code>Lib\</code> class autoloader (see <a href="/admin/docs/libraries">Libraries</a>) before anything below tries to instantiate a class.</li>
|
||||
<li>Requires <strong><code>novaconium/config.php</code></strong>, then shallow-merges <strong><code>App/config.php</code></strong> over it if that file exists — see <a href="/admin/docs/config">Configuration</a>.</li>
|
||||
<li>Special-cases <code>/admin/logout</code> directly against the request path — see <a href="/admin/docs/admin-auth">Admin authentication</a> — since it isn't a real page for the router to find.</li>
|
||||
<li>Constructs a <strong><code>Router</code></strong> (<code>novaconium/src/Router.php</code>) and calls <code>resolve()</code> to turn the URL into a <strong><code>Route</code></strong> (<code>novaconium/src/Route.php</code>) — see <a href="/admin/docs/routing">Routing</a>.</li>
|
||||
<li>If the resolved route is under <code>admin</code>/<code>admin/*</code>, calls <strong><code>AdminAuth::requireLogin()</code></strong> (<code>novaconium/src/AdminAuth.php</code>), reading that same <code>Route</code>.</li>
|
||||
<li>Constructs a <strong><code>Cache</code></strong> (<code>novaconium/src/Cache.php</code>) and a <strong><code>Renderer</code></strong> (<code>novaconium/src/Renderer.php</code>), then calls <code>renderNotFound()</code> or <code>render($route, ...)</code> depending on <code>$route->found</code> — see <a href="/admin/docs/sidecars">Sidecars</a> for what happens inside <code>Renderer</code> itself (running the matched directory's <code>index.php</code>, if any; resolving the nearest layout; rendering <code>index.twig</code>; writing the static cache for sidecar-less pages).</li>
|
||||
</ol>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<p>Every step after step 1 is plain PHP you can read start to finish in <code>novaconium/bootstrap.php</code> itself — the comments there walk through the same five sub-steps in more detail than this page does.</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,73 @@
|
||||
{% 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><code>/admin/logout</code> is special-cased directly against the raw request path, before routing even runs — it isn't a real page, so there'd be no <code>Route</code> for it anyway.</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>, <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 %}
|
||||
@@ -0,0 +1,94 @@
|
||||
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||
|
||||
{% block title %}SEO{% endblock %}
|
||||
|
||||
{% block description %}The SEO meta tags every page gets for free, and how to override them per-page.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block docs_content %}
|
||||
<h1>SEO boilerplate</h1>
|
||||
|
||||
<p><code>novaconium/pages/_layout/layout.twig</code> (the root layout every page extends, directly or via a nested layout) renders a full set of SEO meta tags in <code><head></code>: viewport, description, robots, canonical link, Open Graph, and Twitter Card. Each piece is a named Twig block with a sensible default, so any page can override just the piece it needs without touching the rest of <code><head></code>.</p>
|
||||
|
||||
<h2>Blocks you can override</h2>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Block</th><th>Default</th><th>Renders as</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>title</code></td><td><code>site_name</code> config value</td><td><code><title></code>, and reused by <code>og:title</code> / <code>twitter:title</code></td></tr>
|
||||
<tr><td><code>description</code></td><td>generic site description</td><td><code><meta name="description"></code>, and reused by <code>og:description</code> / <code>twitter:description</code></td></tr>
|
||||
<tr><td><code>robots</code></td><td><code>index, follow</code></td><td><code><meta name="robots"></code></td></tr>
|
||||
<tr><td><code>canonical</code></td><td><code>{{ '{{ request_path }}' }}</code></td><td><code><link rel="canonical"></code>, and reused by <code>og:url</code></td></tr>
|
||||
<tr><td><code>og_type</code></td><td><code>website</code></td><td><code><meta property="og:type"></code></td></tr>
|
||||
<tr><td><code>og_title</code></td><td><code>{{ '{{ block(\'title\') }}' }}</code></td><td><code><meta property="og:title"></code></td></tr>
|
||||
<tr><td><code>og_description</code></td><td><code>{{ '{{ block(\'description\') }}' }}</code></td><td><code><meta property="og:description"></code></td></tr>
|
||||
<tr><td><code>og_url</code></td><td><code>{{ '{{ block(\'canonical\') }}' }}</code></td><td><code><meta property="og:url"></code></td></tr>
|
||||
<tr><td><code>twitter_card</code></td><td><code>summary</code></td><td><code><meta name="twitter:card"></code></td></tr>
|
||||
<tr><td><code>twitter_title</code></td><td><code>{{ '{{ block(\'title\') }}' }}</code></td><td><code><meta name="twitter:title"></code></td></tr>
|
||||
<tr><td><code>twitter_description</code></td><td><code>{{ '{{ block(\'description\') }}' }}</code></td><td><code><meta name="twitter:description"></code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p>Blocks that piggyback on another block (e.g. <code>og_title</code> defaulting to <code>{{ '{{ block(\'title\') }}' }}</code>) use Twig's <code>block()</code> function, not inheritance — so setting <code>title</code> alone is enough to update <code>og:title</code> and <code>twitter:title</code> too, unless the page also overrides those blocks explicitly.</p>
|
||||
|
||||
<h2>Overriding on a page</h2>
|
||||
|
||||
<p>Any <code>index.twig</code> can override any subset of these blocks, same as <code>title</code> or <code>content</code>:</p>
|
||||
|
||||
<pre><code>{% verbatim %}{% extends layout %}
|
||||
|
||||
{% block title %}Pricing{% endblock %}
|
||||
{% block description %}Plans and pricing for the whole team.{% endblock %}
|
||||
{% block og_type %}product{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
...
|
||||
{% endblock %}{% endverbatim %}</code></pre>
|
||||
|
||||
<p>See any post under <code>App/pages/blog/</code> (e.g. <code>App/pages/blog/twig-syntax-guide/index.twig</code>) for a working example that sets <code>og_type</code> to <code>article</code> with a hand-written <code>description</code>. If you ever need to derive a description from a longer body string, don't reach for Twig's <code>|slice</code> filter — applied to a string, it calls <code>mb_substr()</code> unconditionally with no fallback, which hard-requires the <code>mbstring</code> extension. Truncate in PHP instead, guarded with <code>function_exists('mb_substr')</code>; see the footnote on <a href="/blog/twig-syntax-guide">the Twig Syntax Guide</a> for the story of why this project cares.</p>
|
||||
|
||||
<h2>Copy-paste starter: every block, explicitly</h2>
|
||||
|
||||
<p>Every <code>App/pages/*/index.twig</code> page in this project already includes the full block below as a reference — copy it into a new page and fill in the blanks. Nothing here is required (the layout's defaults are fine on their own), but having every knob visible up front makes it obvious what's available. Don't want to copy-paste by hand? <code>php novaconium/bin/create-static-page.php <path></code> scaffolds this exact template for you — see <a href="/admin/docs/getting-started">Getting started</a>.</p>
|
||||
|
||||
<pre><code>{% verbatim %}{% extends layout %}
|
||||
|
||||
{% block title %}Page title{% endblock %}
|
||||
{% block description %}One or two sentences describing this page.{% endblock %}
|
||||
|
||||
{% block robots %}index, follow{% endblock %}
|
||||
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
|
||||
|
||||
{% block og_type %}website{% endblock %}
|
||||
{% block og_title %}{{ block('title') }}{% endblock %}
|
||||
{% block og_description %}{{ block('description') }}{% endblock %}
|
||||
{% block og_url %}{{ block('canonical') }}{% endblock %}
|
||||
|
||||
{% block twitter_card %}summary{% endblock %}
|
||||
{% block twitter_title %}{{ block('title') }}{% endblock %}
|
||||
{% block twitter_description %}{{ block('description') }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article>
|
||||
<h1>Page title</h1>
|
||||
<p>...</p>
|
||||
</article>
|
||||
{% endblock %}{% endverbatim %}</code></pre>
|
||||
|
||||
<p>To keep a page out of search results, change only the <code>robots</code> line to <code>noindex, nofollow</code> and delete the rest — everything else can be left to the layout's defaults, same as the <code>/admin</code> pages do.</p>
|
||||
|
||||
<h2>Canonical URLs and <code>request_path</code></h2>
|
||||
|
||||
<p>The canonical link (and <code>og:url</code>) default to a <code>request_path</code> variable that <code>novaconium/src/Renderer.php</code> injects into every template's context — the request URI's path component, computed via <code>parse_url($requestUri, PHP_URL_PATH)</code>. You don't need to set this yourself; it's already correct for every route, including the 404 page. If you want an absolute canonical URL instead of a path (e.g. <code>https://example.com/about</code> rather than <code>/about</code>), override the <code>canonical</code> block per-page or add a site-wide base URL to <code>novaconium/config.php</code> and reference it in the layout.</p>
|
||||
|
||||
<h2>Keeping admin/internal pages out of search results</h2>
|
||||
|
||||
<p>Pages that shouldn't be indexed — <code>/admin</code>, <code>/admin/clear-cache</code>, every <code>/admin/docs/*</code> page, and the 404 page — override <code>robots</code> to <code>noindex, nofollow</code>. Follow the same pattern for any project-specific admin or internal tooling pages you add under <code>App/pages/</code>.</p>
|
||||
|
||||
<h2>Favicon</h2>
|
||||
|
||||
<p>The layout links <code><link rel="icon" href="/favicon.ico"></code> unconditionally — drop a <code>favicon.ico</code> into <code>public/</code> to have it picked up; there's no fallback or generation step.</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,242 @@
|
||||
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||
|
||||
{% block title %}Sidecars{% endblock %}
|
||||
|
||||
{% block description %}Where your PHP logic goes — the optional index.php sidecar.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block docs_content %}
|
||||
<h1>Sidecars — where your PHP logic goes</h1>
|
||||
|
||||
<p>Drop an <code>index.php</code> next to any <code>index.twig</code> and it becomes that page's data provider. It runs before the template and can return one of two things:</p>
|
||||
|
||||
<h2>An array — becomes the Twig context</h2>
|
||||
|
||||
<pre><code><?php
|
||||
// App/pages/contact/index.php
|
||||
use Lib\Input;
|
||||
use Lib\Mailer;
|
||||
|
||||
$errors = [];
|
||||
$old = ['name' => '', 'email' => '', 'message' => ''];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
// ...validate Input::post() into $old/$errors...
|
||||
|
||||
if (!$errors) {
|
||||
(new Mailer())->send($old['name'], $old['email'], $old['message']);
|
||||
return \App\Response::redirect('/contact?sent=1');
|
||||
}
|
||||
}
|
||||
|
||||
return ['errors' => $errors, 'old' => $old];</code></pre>
|
||||
|
||||
<p><code>$params</code> (the captured route segments, e.g. an <code>[id]</code> directory's <code>id</code>) is already in scope — no need to touch <code>$_GET</code>.</p>
|
||||
|
||||
<h2>A Response object — short-circuits Twig entirely</h2>
|
||||
|
||||
<pre><code>use App\Response;
|
||||
|
||||
Response::redirect('/somewhere');
|
||||
Response::json(['ok' => true]);
|
||||
Response::xml('<root/>');
|
||||
Response::html('<h1>raw</h1>');</code></pre>
|
||||
|
||||
<p>A sidecar-only page (no <code>index.twig</code> at all) is fine too — e.g. an <code>App/pages/api/posts/index.php</code> returning just <code>Response::json([...])</code> for a JSON-only endpoint. Twig never runs for that route at all; the sidecar's return value is the entire response.</p>
|
||||
|
||||
<p><strong>Form handling</strong> — a sidecar can branch on <code>$_SERVER['REQUEST_METHOD']</code>, validate <code>$_POST</code>, call a library, and only return <code>Response::redirect()</code> once it succeeds (the classic POST/redirect/GET pattern, so a page refresh doesn't resubmit the form). See <code>App/pages/contact/index.php</code> + <code>App/pages/contact/index.twig</code> for the full example — it validates name/email/message, calls <code>Lib\Mailer::send()</code>, and redirects to <code>/contact?sent=1</code> to show a success message.</p>
|
||||
|
||||
<h2>Form security</h2>
|
||||
|
||||
<p>Every form on this site combines three independent layers, all reusable <code>Lib\</code> classes: input cleaning, CSRF protection, and spam prevention. None of them depend on each other — a form can use any subset.</p>
|
||||
|
||||
<h3>Input cleaning</h3>
|
||||
|
||||
<p><strong><code>Lib\Input</code></strong> (<code>novaconium/lib/Input.php</code>) is a drop-in replacement for reading <code>$_POST</code>/<code>$_GET</code> directly — every sidecar on this site uses it instead of touching the superglobals:</p>
|
||||
|
||||
<pre><code>use Lib\Input;
|
||||
|
||||
$name = Input::post('name', ''); // trimmed, tags stripped, null bytes removed
|
||||
$sent = Input::get('sent') !== null; // was ?sent= present at all?
|
||||
$all = Input::post(); // the whole cleaned $_POST array</code></pre>
|
||||
|
||||
<p>Calling <code>post()</code>/<code>get()</code> with no key returns the entire cleaned array (handy for passing straight to <code>SpamGuard::isSpam()</code>, as below); calling it with a key returns that key's cleaned value, or the given default if it's absent. Nested arrays (e.g. a checkbox group posted as <code>tags[]</code>) are cleaned recursively. The result is memoized per request, so calling <code>Input::post()</code> repeatedly across a sidecar doesn't re-clean the superglobal each time.</p>
|
||||
|
||||
<p><strong>This is not SQL-injection protection.</strong> The cleaning <code>Input</code> does (trim + strip tags, via <code>Lib\Validate::clean()</code>, plus null-byte stripping) is defense-in-depth against HTML/script injection in output contexts — Twig already autoescapes <code>{{ }}</code> output by default (see <code>novaconium/src/Renderer.php</code>), so this is a second layer, not the only one. No string transform makes arbitrary input safe to concatenate into a SQL query; the real defense is parameterized queries (PDO prepared statements). There's no database layer in this framework yet (SQLite groundwork is Backlog — see <code>novaconium/ISSUES.md</code>); when one lands, use prepared statements exclusively. <code>Input</code> deliberately has no <code>sqlSafe()</code>-style method, since a method implying "cleaned = safe to interpolate into SQL" would be actively dangerous.</p>
|
||||
|
||||
<p>One documented exception: a field that needs an exact, unmodified value — a password about to be hashed, say — should read <code>$_POST</code> directly instead of going through <code>Input::post()</code>. Cleaning would silently strip characters like <code><</code>/<code>></code> before hashing, producing a hash that doesn't match what's actually typed later. See <code>novaconium/pages/admin/password-hash/index.php</code> for the one place this framework does that on purpose.</p>
|
||||
|
||||
<h3>CSRF protection</h3>
|
||||
|
||||
<p><strong><code>Lib\Csrf</code></strong> (<code>novaconium/lib/Csrf.php</code>) is a standalone session-token CSRF guard — standalone meaning it isn't wired into <code>FormValidator</code>'s chain, so a sidecar calls it directly, typically as the very first check on a POST:</p>
|
||||
|
||||
<pre><code>use Lib\Csrf;
|
||||
use Lib\Input;
|
||||
|
||||
if (!Csrf::verify(Input::post('csrf_token'))) {
|
||||
return \App\Response::redirect('/contact?error=security');
|
||||
}</code></pre>
|
||||
|
||||
<p>with a matching hidden field alongside the honeypot/timestamp fields:</p>
|
||||
|
||||
<pre><code><input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}"></code></pre>
|
||||
|
||||
<p>and two extra keys in the sidecar's returned context:</p>
|
||||
|
||||
<pre><code>'csrfField' => Csrf::fieldName(),
|
||||
'csrfToken' => Csrf::token(),</code></pre>
|
||||
|
||||
<p><code>Csrf::token()</code> is idempotent per session — it doesn't rotate on every call, so a form that re-renders after a validation error still verifies correctly on the next submit. It's the first thing in the framework that starts a native PHP session, and only lazily: a page that never calls <code>Csrf</code> never gets a session cookie, so the rest of the site stays session-free. The session cookie itself is hardened (<code>httponly</code>, <code>SameSite=Lax</code>, <code>secure</code> when the request is HTTPS) inside <code>Csrf</code>'s private <code>ensureSession()</code>.</p>
|
||||
|
||||
<p>A failed CSRF check shows a real, visible error — <strong>"Your session expired before submitting — please try again."</strong> — unlike a failed spam check, which redirects to the same success URL either way. That's deliberate: a CSRF failure is usually a legitimate stale-tab case for a real visitor, not something worth hiding, whereas hiding a spam block from a bot is the whole point of that check.</p>
|
||||
|
||||
<h3>Spam prevention</h3>
|
||||
|
||||
<p>The contact form fends off basic spam without an external CAPTCHA service (no CDN script, no site key/secret key, no outbound API call on every submission — consistent with this project's self-hosted-everything approach), using <strong><code>Lib\SpamGuard</code></strong> (<code>novaconium/lib/SpamGuard.php</code>) — a framework-default <code>Lib\</code> class, reusable on any form a project adds, the same way <code>Lib\Mailer</code> is:</p>
|
||||
|
||||
<pre><code>use Lib\Input;
|
||||
use Lib\SpamGuard;
|
||||
|
||||
$spamGuard = new SpamGuard(); // honeypot field 'website', timestamp field 'rendered_at', 2s minimum
|
||||
|
||||
if (!$spamGuard->isSpam(Input::post())) {
|
||||
// ...actually send the message...
|
||||
}
|
||||
|
||||
// In the sidecar's returned context, for the hidden timestamp field:
|
||||
'renderedAt' => $spamGuard->renderedAt(),</code></pre>
|
||||
|
||||
<p>Two checks run inside <code>isSpam()</code>, both purely server-side:</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>Honeypot field.</strong> The paired Twig template renders a <code>website</code> input inside a <code>.hp-field</code> wrapper, positioned off-screen with CSS (<code>position: absolute; left: -9999px</code> — deliberately <em>not</em> <code>display: none</code> or <code>visibility: hidden</code>, since some spam bots specifically skip fields hidden that way) and marked <code>aria-hidden="true"</code> with <code>tabindex="-1"</code> so it's invisible to real visitors and screen readers alike. A bot that auto-fills every input on a form fills this one too; any non-empty value counts as spam.</li>
|
||||
<li><strong>Timing check.</strong> A hidden field (rendered via <code>$spamGuard->renderedAt()</code>, read back as <code>rendered_at</code>) carries the Unix timestamp of when the form was rendered. On submit, anything completed in under the constructor's <code>$minSeconds</code> (2 by default) counts as spam — plausible for a script filling and submitting a form instantly, implausible for a human reading the form and typing a message. This value isn't cryptographically signed, so a determined bot could forge it; it's a deterrent against unsophisticated spam, not a security boundary.</li>
|
||||
</ul>
|
||||
|
||||
<p><code>isSpam()</code> tripping either check doesn't stop the sidecar from validating and redirecting normally — see <code>App/pages/contact/index.php</code>, which still returns <code>Response::redirect('/contact?sent=1')</code> regardless, and only skips <code>Lib\Mailer::send()</code> when spam is detected. That's deliberate: a bot gets the exact same success response a human would, with nothing revealing which check it tripped, or that a check exists at all. Both the honeypot field name, timestamp field name, and minimum seconds are constructor arguments (<code>new SpamGuard('website', 'rendered_at', 2)</code>), so a second form on the same site can use different field names without the two forms interfering with each other.</p>
|
||||
|
||||
<p>Field validation (required fields, email format, length limits) is its own reusable class, <strong><code>Lib\FormValidator</code></strong> (<code>novaconium/lib/FormValidator.php</code>) — an accumulating validator so a sidecar doesn't hand-roll the same checks and <code>$errors</code> array every time:</p>
|
||||
|
||||
<pre><code>use Lib\FormValidator;
|
||||
|
||||
$validator = (new FormValidator())
|
||||
->required($old['name'], 'name', 'Name is required.')
|
||||
->email($old['email'], 'email', 'A valid email is required.')
|
||||
->required($old['message'], 'message', 'Message is required.')
|
||||
->maxLength($old['message'], 'message', 2000, 'Message is too long.');
|
||||
|
||||
if ($validator->passes()) {
|
||||
// ...
|
||||
}
|
||||
|
||||
$errors = $validator->errors();</code></pre>
|
||||
|
||||
<p><code>FormValidator</code> doesn't implement validation logic itself — each check delegates to <strong><code>Lib\Validate</code></strong> (<code>novaconium/lib/Validate.php</code>), a set of stateless, static validation primitives modeled after <a href="https://github.com/nickyeoman/php-validation-class">the project author's own reusable validation class</a>: <code>clean()</code>, <code>isEmail()</code>, <code>minLength()</code>/<code>maxLength()</code>, <code>isMatch()</code> (e.g. a "confirm email" field), <code>isPhone()</code> (7- or 10-digit, with optional extension), and <code>isPostalCode()</code>/<code>isZipCode()</code>. Call <code>Validate</code> directly from a sidecar when you just need a validated/normalized value back — e.g. <code>Validate::isPhone($old['phone'], withExtension: true)</code> — rather than an accumulated field-error:</p>
|
||||
|
||||
<pre><code>use Lib\Validate;
|
||||
|
||||
$phone = Validate::isPhone($old['phone']); // '5551234567' or false</code></pre>
|
||||
|
||||
<p>All three classes live under <code>novaconium/lib/</code> as framework defaults — like any other <code>Lib\</code> class, a project can override any of them by dropping a same-named file in <code>App/lib/</code> (see <a href="/admin/docs/libraries">Libraries</a>).</p>
|
||||
|
||||
<h2>Copy-paste starter: a sidecar, three ways</h2>
|
||||
|
||||
<p>Drop this in as <code>App/pages/example/index.php</code> (next to an <code>App/pages/example/index.twig</code> using the <a href="/admin/docs/seo">SEO starter template</a>) and delete whichever example you don't need:</p>
|
||||
|
||||
<pre><code>{% verbatim %}<?php
|
||||
// App/pages/example/index.php
|
||||
|
||||
// 1. Say hello — becomes Twig context, so {{ message }} works in index.twig.
|
||||
return [
|
||||
'message' => 'Hello, World!',
|
||||
];
|
||||
|
||||
// 2. Show the visitor's IP — same idea, just another key in the array.
|
||||
// return [
|
||||
// 'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
|
||||
// ];
|
||||
|
||||
// 3. Run phpinfo() — a Response short-circuits Twig entirely, which
|
||||
// phpinfo() needs since it echoes its own complete HTML page. Capture
|
||||
// that output with ob_start()/ob_get_clean() and hand it to
|
||||
// Response::html() rather than letting phpinfo() echo directly (which
|
||||
// would print before whatever index.twig renders, out of order).
|
||||
// use App\Response;
|
||||
//
|
||||
// ob_start();
|
||||
// phpinfo();
|
||||
// return Response::html(ob_get_clean());{% endverbatim %}</code></pre>
|
||||
|
||||
<p>Only one of the three <code>return</code>s in a real sidecar ever runs, obviously — pick one, or branch between them with an <code>if</code>. The IP example works with or without a sidecar-only page (no <code>index.twig</code>); the <code>phpinfo()</code> example needs <em>no</em> <code>index.twig</code> at all, since <code>Response::html()</code> bypasses Twig — see the JSON-only example above for another sidecar-only page.</p>
|
||||
|
||||
<p><strong>Never ship <code>phpinfo()</code> to production</strong> — it dumps environment variables, file paths, loaded extensions, and configuration values that are useful to an attacker mapping your server. Delete the page after you're done with it, or at minimum gate it behind <a href="/admin/docs/admin-auth">admin authentication</a> the same way <code>/admin/*</code> already is, so it's never reachable by the public.</p>
|
||||
|
||||
<h2>For developers: using <code>Response.php</code> directly</h2>
|
||||
|
||||
<p><code>novaconium/src/Response.php</code> is the small value object behind <code>Response::redirect()</code>/<code>::json()</code>/<code>::xml()</code>/<code>::html()</code> above. Like <code>Route</code> (see <code>/admin/docs/routing</code>'s "How <code>Route.php</code> fits in" section), it's a dumb data carrier with one job: describe a response, without emitting anything itself, so it can be constructed in a sidecar and only actually acted on later, by <code>Renderer</code>.</p>
|
||||
|
||||
<h3>How it fits in</h3>
|
||||
|
||||
<p>Its constructor is <code>private</code> — you never call <code>new Response(...)</code> directly, only one of the four named factories, each of which fixes the <code>type</code>/<code>headers</code> that go with that kind of response:</p>
|
||||
|
||||
<pre><code>Response::redirect(string $url, int $status = 301): self // type 'redirect', no extra headers
|
||||
Response::json(mixed $data, int $status = 200): self // type 'json', Content-Type: application/json
|
||||
Response::xml(string $xml, int $status = 200): self // type 'xml', Content-Type: application/xml
|
||||
Response::html(string $html, int $status = 200): self // type 'html', Content-Type: text/html</code></pre>
|
||||
|
||||
<p>Every factory returns a fully-formed, readonly <code>Response</code> — <code>type</code>, <code>body</code>, <code>status</code>, <code>headers</code> — and nothing has happened yet. A sidecar just returns that object; it's <code>Renderer::render()</code> (see this page's <code>Renderer.php</code> section above) that checks <code>$result instanceof Response</code> and, if so, calls <code>$result->emit()</code> and stops, skipping Twig entirely. That's the entire contract between a sidecar and the framework: return an array for Twig context, or return a <code>Response</code> to bypass it.</p>
|
||||
|
||||
<h3>What <code>emit()</code> actually does</h3>
|
||||
|
||||
<p><code>emit()</code> is the one method with side effects, and it's only ever called from inside <code>Renderer</code>, never from a sidecar itself:</p>
|
||||
|
||||
<ol>
|
||||
<li>Sets the HTTP status code via <code>http_response_code($this->status)</code>.</li>
|
||||
<li>Sends each header in <code>$this->headers</code> (empty for <code>redirect</code>, a single <code>Content-Type</code> for the other three).</li>
|
||||
<li>For a <code>redirect</code>, sends a <code>Location</code> header (the URL passed to <code>Response::redirect()</code>) and returns — no body.</li>
|
||||
<li>For <code>json</code>, encodes <code>$this->body</code> with <code>json_encode(..., JSON_THROW_ON_ERROR)</code> and echoes it — the <code>JSON_THROW_ON_ERROR</code> flag means an unencodable value (e.g. a resource, or a value containing invalid UTF-8) throws a <code>JsonException</code> rather than silently emitting <code>false</code> as the body.</li>
|
||||
<li>For <code>xml</code>/<code>html</code>, <code>$this->body</code> is already a string (built by the caller), so it's echoed as-is — <code>Response</code> doesn't validate or escape it.</li>
|
||||
</ol>
|
||||
|
||||
<p>Because every property is <code>readonly</code> and the type/body/status/headers are fixed at construction, a <code>Response</code> is safe to build early in a sidecar and pass around (or return immediately) without worrying about it changing shape before <code>Renderer</code> gets to it — there's no setter to call by mistake.</p>
|
||||
|
||||
<h2>For developers: using <code>Renderer.php</code> directly</h2>
|
||||
|
||||
<p><code>novaconium/src/Renderer.php</code> is the class that actually runs a sidecar and turns its return value into a response — everything in this page so far (arrays becoming Twig context, <code>Response</code> objects short-circuiting) is <code>Renderer</code>'s doing. Unlike <code>Router</code> (see <code>/admin/docs/routing</code>'s "For developers" section), it isn't a pure lookup: <code>render()</code> and <code>renderNotFound()</code> both emit directly — <code>http_response_code()</code>, <code>header()</code>, <code>echo</code> — rather than returning a string, so it's a class you call once per request for its side effects, not one you inspect a return value from.</p>
|
||||
|
||||
<h3>How it fits in</h3>
|
||||
|
||||
<p><code>Renderer</code> is the last thing a request touches, in <code>novaconium/bootstrap.php</code>: <code>Router::resolve()</code> produces a <code>Route</code>, <code>AdminAuth</code> optionally gates it, and then <code>Renderer</code> reads that same <code>Route</code> to actually produce output. It never talks back to <code>Router</code> or <code>AdminAuth</code> — by the time it runs, routing and auth are already decided, and its only inputs are the <code>Route</code> and the raw request URI:</p>
|
||||
|
||||
<pre><code>$renderer = new Renderer($config['pages_dirs'], $cache, $adminAuthEnabled, $matomoUrl, $config['matomo_site_id'], $config['site_name']);
|
||||
|
||||
if (!$route->found) {
|
||||
$renderer->renderNotFound($requestUri);
|
||||
return;
|
||||
}
|
||||
|
||||
$renderer->render($route, $requestUri);</code></pre>
|
||||
|
||||
<h3>Constructing it</h3>
|
||||
|
||||
<p>The first two constructor arguments are the same <code>pagesDirs</code> override-root list every other class here takes, plus a <code>Cache</code> instance (<code>novaconium/src/Cache.php</code>) it writes sidecar-less pages' output to. The remaining four — admin-auth-enabled flag, Matomo URL/site ID, site name — aren't used for routing or rendering logic at all; they're registered as Twig globals (<code>$this->twig->addGlobal(...)</code>) purely so every template can read <code>matomo_url</code>, <code>site_name</code>, etc. without a sidecar having to pass them through manually. A fifth global, <code>is_404</code>, defaults to <code>false</code> here and is overridden per-render — see below.</p>
|
||||
|
||||
<h3>What <code>render()</code> actually does, in order</h3>
|
||||
|
||||
<ol>
|
||||
<li>Looks for <code>index.php</code> at <code>$route->dir</code> via <code>Overlay::findFile()</code> (checking <code>App/pages/</code> before <code>novaconium/pages/</code>, same as everywhere else) and, if one exists, requires it in a scope where <code>$params</code> and <code>$cache</code> are already defined — see <code>runSidecar()</code>.</li>
|
||||
<li>If the sidecar returned a <code>Response</code>, calls <code>$result->emit()</code> and returns immediately — Twig never runs, nothing gets cached.</li>
|
||||
<li>Otherwise treats the return value as Twig context, merging in <code>params</code>, the resolved <code>layout</code> path, and <code>request_path</code>.</li>
|
||||
<li>Renders <code>index.twig</code> at <code>$route->dir</code> with that context, emits <code>200</code> + the HTML.</li>
|
||||
<li>Only if there was <strong>no</strong> sidecar, writes the rendered HTML to the static cache (<code>Cache::write()</code>) — a page with a sidecar is never cached this way, since its output can vary per request.</li>
|
||||
</ol>
|
||||
|
||||
<p><code>renderNotFound()</code> is a smaller version of the same idea: no sidecar to run, always emits <code>404</code>, renders <code>404/index.twig</code> if one exists in either page root (with <code>is_404</code> forced to <code>true</code> in that render's context — see <code>/admin/docs/matomo</code> for what that flag is used for), and falls back to a bare <code>404 Not Found</code> string if even the default 404 template is missing.</p>
|
||||
|
||||
<h3>Layout resolution</h3>
|
||||
|
||||
<p>Both methods get their <code>layout</code> value from the private <code>relativeLayoutPath()</code>, which walks upward from the matched directory looking for the nearest <code>_layout/layout.twig</code> — checking both page roots at every level before going up one more directory — until it either finds one or runs out of directory to walk (returning <code>null</code>, which only happens if even the root <code>_layout/layout.twig</code> is missing from both roots). This is what lets <code>App/pages/blog/_layout/layout.twig</code> override the site-wide layout for just that subtree, per <a href="/admin/docs/layouts">Layouts</a>.</p>
|
||||
|
||||
<p>Because <code>render()</code>/<code>renderNotFound()</code> write straight to PHP's output buffer and response headers rather than returning anything, testing <code>Renderer</code> in isolation means capturing output (e.g. <code>ob_start()</code>) and inspecting headers, rather than asserting on a return value the way you can with <code>Router::resolve()</code>'s <code>Route</code>.</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,60 @@
|
||||
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||
|
||||
{% block title %}Styling{% endblock %}
|
||||
|
||||
{% block description %}Sass, indented syntax, compiled to public/css/main.css, with an overridable color palette.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block docs_content %}
|
||||
<h1>Styling (Sass, indented syntax)</h1>
|
||||
|
||||
<p>Source lives in <code>novaconium/sass/main.sass</code> (indented syntax, not SCSS). Compile it with <a href="https://sass-lang.com/dart-sass/">Dart Sass</a>, passing both Sass directories as load paths:</p>
|
||||
|
||||
<pre><code>sass --load-path=App/sass --load-path=novaconium/sass/defaults novaconium/sass/main.sass public/css/main.css</code></pre>
|
||||
|
||||
<p>There's no PHP-based Sass compiler in this project (PHP options like <code>scssphp</code> only understand SCSS syntax) — this is a manual/CI build step, not something the app does at runtime.</p>
|
||||
|
||||
<p>Don't have Dart Sass installed? Run it via Docker instead — copy-paste this Dockerfile, which installs the same official standalone Dart Sass release used in this environment (<code>1.101.0</code>, via <code>pacman -S dart-sass</code> on Arch), not the npm-wrapped build:</p>
|
||||
|
||||
<pre><code>FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl ca-certificates \
|
||||
&& curl -fsSLo /tmp/dart-sass.tar.gz \
|
||||
https://github.com/sass/dart-sass/releases/download/1.101.0/dart-sass-1.101.0-linux-x64.tar.gz \
|
||||
&& tar -xzf /tmp/dart-sass.tar.gz -C /usr/local/lib \
|
||||
&& ln -s /usr/local/lib/dart-sass/sass /usr/local/bin/sass \
|
||||
&& rm /tmp/dart-sass.tar.gz \
|
||||
&& apt-get purge -y curl \
|
||||
&& apt-get autoremove -y \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
ENTRYPOINT ["sass"]</code></pre>
|
||||
|
||||
<p>Check <a href="https://github.com/sass/dart-sass/releases">the dart-sass releases page</a> for a newer version and swap both occurrences of <code>1.101.0</code> in the download URL if you want to track latest instead of matching this environment.</p>
|
||||
|
||||
<p>Build it once, then run it the same way you'd run the local <code>sass</code> CLI (skip the leading <code>sass</code> in the command — the image's <code>ENTRYPOINT</code> already supplies it):</p>
|
||||
|
||||
<pre><code>docker build -t novaconium-sass -f Dockerfile .
|
||||
docker run --rm -v "$(pwd):/usr/src/app" -w /usr/src/app novaconium-sass \
|
||||
--load-path=App/sass --load-path=novaconium/sass/defaults novaconium/sass/main.sass public/css/main.css</code></pre>
|
||||
|
||||
<p>See <a href="https://git.4lt.ca/4lt/novaconium/src/branch/master/docs/Sass.md">git.4lt.ca/4lt/novaconium/docs/Sass.md</a> for this project's own write-up of the Docker approach in general; the Dockerfile and paths/flags above are this project's own, adjusted to use Dart Sass directly and this repo's current layout.</p>
|
||||
|
||||
<h2>Overriding just the colors</h2>
|
||||
|
||||
<p><code>novaconium/sass/main.sass</code> starts with <code>@use 'colors' as *</code>, but its own directory has no <code>_colors.sass</code> of its own — on purpose, so that <code>@use</code> falls through to the Sass load path above rather than resolving to a sibling file. <code>App/sass/_colors.sass</code> is checked first; <code>novaconium/sass/defaults/_colors.sass</code> (the framework's own palette) is the fallback. Same override-by-presence mechanism as <code>App/pages/</code> over <code>novaconium/pages/</code>, just applied to Sass instead of Twig/PHP.</p>
|
||||
|
||||
<p>To reskin the whole site, edit the seven variables in <code>App/sass/_colors.sass</code> — <code>$bg</code>, <code>$surface</code>, <code>$text-color</code>, <code>$muted-color</code>, <code>$border-color</code>, <code>$accent</code>, <code>$accent-hover</code> — and recompile. Nothing under <code>novaconium/</code> needs to change. Delete <code>App/sass/_colors.sass</code> entirely to fall back to the framework's default palette instead.</p>
|
||||
|
||||
<h2>Dark/light theme toggle</h2>
|
||||
|
||||
<p>Every color rule in <code>main.sass</code> reads a CSS custom property (<code>var(--bg)</code>, <code>var(--accent)</code>, etc.) instead of a Sass variable directly. The Sass variables only seed the initial <code>:root</code> values at compile time; a <code>:root[data-theme="light"]</code> block overrides all seven using <code>-light</code>-suffixed variables from the same <code>_colors.sass</code> files (<code>$bg-light</code>, <code>$surface-light</code>, etc.) — same override mechanism, same files, a second palette.</p>
|
||||
|
||||
<p>The toggle button in <code>novaconium/pages/_layout/nav.twig</code> flips a <code>data-theme</code> attribute on <code><html></code> at runtime and persists the choice to <code>localStorage</code>. <code>novaconium/pages/_layout/theme-init.twig</code>, included early in <code><head></code> before the stylesheet, re-applies a saved choice before first paint on every later page load, so switching to light doesn't flash dark first. The sun/moon icon swap inside the button is pure CSS reacting to the attribute — no JS involved there — so it works correctly even on sidecar-less pages that get statically cached.</p>
|
||||
|
||||
<p>To customize the light theme the same way you'd customize the dark one, edit the <code>-light</code> variables in <code>App/sass/_colors.sass</code> and recompile.</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,13 @@
|
||||
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||
|
||||
{% block title %}Third-party{% endblock %}
|
||||
|
||||
{% block description %}Vendored Twig and its license.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block docs_content %}
|
||||
<h1>Third-party</h1>
|
||||
|
||||
<p><a href="https://twig.symfony.com/">Twig</a> is vendored in source form under <code>novaconium/vendor/twig/</code> (no Composer — see <a href="/admin/docs/upgrading-twig">Upgrading Twig</a> for how to upgrade it). It's BSD-3-Clause licensed; the full license text ships alongside it at <code>novaconium/vendor/twig/LICENSE</code>.</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,44 @@
|
||||
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||
|
||||
{% block title %}Upgrading Twig{% endblock %}
|
||||
|
||||
{% block description %}How to bump the vendored copy of Twig.{% endblock %}
|
||||
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
|
||||
{% block docs_content %}
|
||||
<h1>Upgrading vendored Twig</h1>
|
||||
|
||||
<p>This project has no Composer — Twig is vendored by hand as source files under <code>novaconium/vendor/twig/</code>. There's no lockfile and no <code>composer.json</code>, so upgrading is a manual copy-and-verify process rather than <code>composer update</code>.</p>
|
||||
|
||||
<h2>What's currently vendored</h2>
|
||||
<ul>
|
||||
<li>Version: <strong>3.28.0</strong> (see <code>novaconium/vendor/twig/src/Environment.php</code>, the <code>Environment::VERSION</code> constant — that constant is always the source of truth, this doc can drift).</li>
|
||||
<li>Only the <code>src/</code> directory from the Twig package is vendored — no tests, no docs, no <code>composer.json</code>. <code>novaconium/autoload.php</code> maps the <code>Twig\</code> namespace straight at <code>novaconium/vendor/twig/src/</code>, so anything Twig autoloads has to live at the matching path under there.</li>
|
||||
<li><code>novaconium/autoload.php</code> also defines a <code>trigger_deprecation()</code> shim. Twig 3.x calls this function (normally supplied by <code>symfony/deprecation-contracts</code>, which isn't vendored here) whenever it hits a deprecated code path. Without the shim, upgrading to a Twig version that deprecates something this project uses would fatal instead of warn.</li>
|
||||
</ul>
|
||||
|
||||
<h2>How to upgrade</h2>
|
||||
<ol>
|
||||
<li>Pick the target version from Twig's GitHub releases: <code>https://github.com/twigphp/Twig/releases</code>. Read the changelog for breaking changes, in particular anything touching <code>Environment</code>, <code>Loader\FilesystemLoader</code>, <code>{% verbatim %}{% extends %}{% endverbatim %}</code>/<code>{% verbatim %}{% include %}{% endverbatim %}</code> resolution, or autoescaping — those are the parts of Twig this project actually exercises (see <code>novaconium/src/Renderer.php</code>).</li>
|
||||
<li>Download the release source (the "Source code (zip)" asset on the release page, or <code>git clone --branch vX.Y.Z --depth 1 https://github.com/twigphp/Twig</code> if you have network access to GitHub).</li>
|
||||
<li>From the downloaded copy, take only the <code>src/</code> directory.</li>
|
||||
<li>Replace <code>novaconium/vendor/twig/src/</code> wholesale with that new <code>src/</code> directory (delete the old one first so removed files don't linger).</li>
|
||||
<li>Copy the new <code>LICENSE</code> file over <code>novaconium/vendor/twig/LICENSE</code> too, in case it changed.</li>
|
||||
<li>Confirm the namespace layout is unchanged: <code>novaconium/autoload.php</code> assumes <code>Twig\Foo\Bar</code> lives at <code>src/Foo/Bar.php</code> relative to the vendor root. This has been stable across Twig 3.x, but double-check if jumping a major version.</li>
|
||||
<li>Run the app and click through every route (or run the manual checklist in the <a href="/admin/docs/design-notes">Design notes</a>' Verification section) — there's no automated test suite, so this is the actual regression check:
|
||||
<ul>
|
||||
<li><code>/</code>, <code>/about</code> — static pages, exercise <code>{% verbatim %}{% extends layout %}{% endverbatim %}</code>.</li>
|
||||
<li><code>/blog</code> and any post under it — a sidecar-driven listing plus sidecar-less posts.</li>
|
||||
<li>A <code>_layout/layout.twig</code> overriding the root layout — exercises nested <code>{% verbatim %}{% extends %}{% endverbatim %}</code> resolution across the <code>App/pages/</code> + <code>novaconium/pages/</code> overlay (see <code>novaconium/src/Overlay.php</code>).</li>
|
||||
<li><code>/contact</code> — form handling, <code>{% verbatim %}{% if %}{% endverbatim %}</code> blocks, loop-free but exercises variable escaping (<code>{% verbatim %}{{ old.name }}{% endverbatim %}</code> etc.) — good smoke test for autoescape behavior changes.</li>
|
||||
<li><code>/admin/docs</code> and its subpages — pure Twig, no <code>|raw</code> — confirm they still render as expected after the upgrade.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>If PHP now emits <code>E_USER_DEPRECATED</code> warnings that weren't there before (visible because <code>debug</code> is <code>true</code> in <code>novaconium/config.php</code>), that's the <code>trigger_deprecation()</code> shim doing its job — read the message and update the calling code in <code>novaconium/src/Renderer.php</code> before the next major Twig version removes the deprecated path entirely.</li>
|
||||
<li>Update the version note at the top of this page.</li>
|
||||
</ol>
|
||||
|
||||
<h2>Why not just use Composer?</h2>
|
||||
<p>The project intentionally has no build/install step — cloning the repo and pointing a web server at <code>public/</code> is enough. That's a deliberate trade-off: upgrades are manual, but there's nothing to install, no lockfile to drift, and no <code>vendor/</code> regeneration step for a fresh deploy.</p>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user