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:
code
2026-07-14 01:58:25 +00:00
parent d9c4b64d9c
commit fbd4e92e9f
460 changed files with 30107 additions and 4661 deletions
+38
View File
@@ -0,0 +1,38 @@
{% extends layout %}
{% import '_layout/icons.twig' as icons %}
{% block title %}About{% endblock %}
{% block description %}How this site is built: Novaconium, a tiny PHP micro-framework with file-based routing, Twig templates, and static caching.{% endblock %}
{# Every block below is optional — the layout already supplies a sensible
default for each (see /admin/docs/seo). Shown here uncommented as a
reference for every override a page can make; delete what you don't
need. #}
{% 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>About</h1>
<p>This site runs on <strong>Novaconium</strong>, a tiny PHP micro-framework built around one idea: a directory on disk <em>is</em> a route. There's no router configuration to maintain, no ORM, and no Composer — <a href="https://twig.symfony.com/">Twig</a> is the only dependency it has, and it's vendored directly into the repo as plain source files rather than pulled in through a package manager.</p>
<p>Pages render with Twig and are optionally backed by a PHP "sidecar" — a plain <code>index.php</code> file that supplies template data, or short-circuits templating entirely by returning a redirect, JSON, or raw HTML. Pages with no sidecar are pure and deterministic, so they're rendered once and served straight from Apache as static HTML on every later request, with no PHP or Twig overhead at all.</p>
<p>Everything the framework itself ships — default pages, default library classes, the root layout — lives under <code>novaconium/</code>, and can be overridden by placing a same-named file under <code>App/</code>, the only directory a site author is expected to touch. The same override mechanism covers configuration, too: any key in <code>novaconium/config.php</code> can be replaced piecemeal from <code>App/config.php</code>.</p>
<p>Beyond routing and templating, Novaconium ships with SEO meta tags (canonical links, Open Graph, Twitter Card), optional Matomo analytics with automatic 404 tracking, and an HTTP Basic Auth gate reusable across every <code>/admin/*</code> page — all off or sensible by default, and all documented at <a class="icon-link" href="/admin/docs">{{ icons.book() }}/admin/docs</a>, rendered live from this same running instance rather than a separate website.</p>
<p>It's a good fit for small marketing sites, blogs, and internal tools where a full framework would be overkill but a flat-file site generator alone falls short of real server-side logic. The source is on Git if you want to see how it's put together: <a class="icon-link" href="https://git.4lt.ca/4lt/novaconium">{{ icons.git() }}git.4lt.ca/4lt/novaconium</a>.</p>
</article>
{% endblock %}
+15
View File
@@ -0,0 +1,15 @@
{% extends '_layout/layout.twig' %}
{% import '_layout/icons.twig' as icons %}
{% block content %}
<div class="blog-layout">
<aside>
<p>This sidebar exists because <code>App/pages/blog/_layout/layout.twig</code> overrides the site-wide root layout for everything under <code>/blog</code> — the nearest <code>_layout/layout.twig</code> wins, so a subtree can look different without touching the pages themselves.</p>
<p><a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts docs</a></p>
</aside>
<article>
{% block blog_content %}{% endblock %}
</article>
</div>
{% endblock %}
+26
View File
@@ -0,0 +1,26 @@
{% extends layout %}
{% import '_layout/icons.twig' as icons %}
{% block title %}Hello, World!{% endblock %}
{% block description %}The first post on this blog — a plain sidecar-less page, like every other post here now.{% endblock %}
{% block robots %}index, follow{% endblock %}
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
{% block og_type %}article{% 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 blog_content %}
<h1>Hello, World!</h1>
<p>The first post on this blog — a plain page at <code>App/pages/blog/hello-world/index.twig</code>, sidecar-less like <a class="icon-link" href="/blog/twig-syntax-guide">{{ icons.book() }}the Twig Syntax Guide</a> and the <a class="icon-link" href="/blog/style-guide">{{ icons.book() }}Style Guide</a>. Since it has no sidecar, it's rendered once and served as static HTML from <code>public/cache/blog/hello-world/index.html</code> on every later request — see <a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a>.</p>
<p>This URL used to be backed by a wildcard <code>App/pages/blog/[slug]/</code> route pulling from a <code>Lib\PostRepository</code> class — a live demo of route-param capture. That mechanism (any single URL segment captured into <code>$params</code>) is still fully supported by the framework; see <a class="icon-link" href="/admin/docs/routing">{{ icons.link() }}Routing</a>. It just isn't what renders this particular post anymore, now that this post is fixed content rather than a stand-in for arbitrary slugs.</p>
{% endblock %}
+30
View File
@@ -0,0 +1,30 @@
<?php
// Every post under App/pages/blog/ is now a plain, sidecar-less directory
// with its own index.twig — none of them are driven by a repository or
// database, so this listing is just a hand-maintained array pointing at
// each one. Add a new entry here whenever a new post directory is added.
return [
'posts' => [
[
'slug' => 'hello-world',
'title' => 'Hello, World!',
'excerpt' => 'The first post on this blog — a plain sidecar-less page, like every other post here now.',
],
[
'slug' => 'second-post',
'title' => 'A Second Post',
'excerpt' => 'A second post at its own URL, showing that adding a new page under App/pages/blog/ needs nothing but a new directory.',
],
[
'slug' => 'twig-syntax-guide',
'title' => 'Twig Syntax Guide',
'excerpt' => 'A tour of the Twig syntax used throughout this site — output, filters, control structures, template inheritance, and a few gotchas worth knowing.',
],
[
'slug' => 'style-guide',
'title' => 'Style Guide',
'excerpt' => "A showcase of this theme's default styling for headings, lists, tables, code, and other common HTML elements.",
],
],
];
+30
View File
@@ -0,0 +1,30 @@
{% extends layout %}
{% block title %}Blog{% endblock %}
{% block description %}All posts on this blog — a working example of a sidecar-driven listing 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 blog_content %}
<h1>Blog</h1>
<p>Rendered by <code>App/pages/blog/index.php</code> and <code>index.twig</code> — a sidecar that returns a hand-maintained array pointing at each post directory under <code>App/pages/blog/</code>. Because this page has a sidecar, it's never served from the static cache; the list is rebuilt on every request, even though the array itself only changes when a post is added.</p>
<ul class="post-list">
{% for post in posts %}
<li>
<h2><a href="/blog/{{ post.slug }}">{{ post.title }}</a></h2>
<p>{{ post.excerpt }}&hellip;</p>
</li>
{% endfor %}
</ul>
{% endblock %}
+26
View File
@@ -0,0 +1,26 @@
{% extends layout %}
{% import '_layout/icons.twig' as icons %}
{% block title %}A Second Post{% endblock %}
{% block description %}A second post at its own URL, showing that adding a new page under App/pages/blog/ needs nothing but a new directory.{% endblock %}
{% block robots %}index, follow{% endblock %}
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
{% block og_type %}article{% 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 blog_content %}
<h1>A Second Post</h1>
<p>A second post at its own URL — <code>App/pages/blog/second-post/index.twig</code>. Just another plain, sidecar-less directory under <code>App/pages/blog/</code>, same as <a class="icon-link" href="/blog/hello-world">{{ icons.book() }}Hello, World!</a> next to it. Adding a new post is nothing more than a new directory with an <code>index.twig</code> — no route to register, no repository entry to add.</p>
<p>Both posts are listed on <a class="icon-link" href="/blog">{{ icons.book() }}the blog index</a>, built by <code>App/pages/blog/index.php</code> — see <a class="icon-link" href="/admin/docs/routing">{{ icons.link() }}Routing</a> for how a URL maps to a directory in the first place.</p>
{% endblock %}
+128
View File
@@ -0,0 +1,128 @@
{% extends layout %}
{% import '_layout/icons.twig' as icons %}
{% block title %}Style Guide{% endblock %}
{% block description %}A showcase of this theme's default styling for headings, lists, tables, code, and other common HTML elements.{% endblock %}
{% block robots %}index, follow{% endblock %}
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
{% block og_type %}article{% 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 blog_content %}
<h1>Style Guide</h1>
<p>A plain page, like <a class="icon-link" href="/blog/twig-syntax-guide">{{ icons.book() }}the Twig Syntax Guide</a>, this time showing off the default styling every element on this site gets for free from <code>novaconium/sass/main.sass</code> — no per-page CSS involved. If you've changed <code>App/sass/_colors.sass</code> (see <a class="icon-link" href="/admin/docs/styling">{{ icons.book() }}Styling</a>), this page is the fastest way to see the new palette applied across everything at once.</p>
<h2>Headings</h2>
<h1>Heading level 1</h1>
<h2>Heading level 2</h2>
<h3>Heading level 3</h3>
<h4>Heading level 4</h4>
<h5>Heading level 5</h5>
<h6>Heading level 6</h6>
<h2>Paragraph &amp; inline text</h2>
<p>A normal paragraph, with <strong>bold</strong>, <em>italic</em>, <code>inline code</code>, and <a href="/">a link</a> mixed in. <small>Small print, like this, is used for captions and secondary detail throughout the site.</small></p>
<h2>Blockquote</h2>
<blockquote>
<p>Design is not just what it looks like and feels like. Design is how it works.</p>
</blockquote>
<h2>Lists</h2>
<h3>Unordered</h3>
<ul>
<li>File-based routing</li>
<li>Optional sidecars</li>
<li>Static caching</li>
</ul>
<h3>Ordered</h3>
<ol>
<li>Write a Twig template</li>
<li>Optionally add a sidecar</li>
<li>Visit the URL</li>
</ol>
<h3>Nested</h3>
<ul>
<li>App/
<ul>
<li>pages/</li>
<li>lib/</li>
<li>sass/</li>
</ul>
</li>
<li>novaconium/
<ul>
<li>pages/</li>
<li>src/</li>
</ul>
</li>
</ul>
<h2>Table</h2>
<table>
<thead>
<tr><th>Element</th><th>Styled by</th></tr>
</thead>
<tbody>
<tr><td>Headings</td><td><code>h1, h2, h3, h4, h5, h6</code></td></tr>
<tr><td>Links</td><td><code>a</code>, <code>a:hover</code></td></tr>
<tr><td>Code</td><td><code>code</code>, <code>pre</code></td></tr>
<tr><td>Tables</td><td><code>table</code>, <code>th</code>, <code>td</code></td></tr>
</tbody>
</table>
<h2>Code</h2>
<p>Inline: <code>(new Mailer())-&gt;send($old['name'], $old['email'], $old['message']);</code></p>
<pre><code>{% verbatim %}{% extends layout %}
{% block content %}
...
{% endblock %}{% endverbatim %}</code></pre>
<h2>Horizontal rule</h2>
<p>Above this line:</p>
<hr>
<p>Below this line.</p>
<h2>Form elements</h2>
<form>
<p>
<label for="style-guide-example">A label</label><br>
<input type="text" id="style-guide-example" placeholder="A text input">
</p>
<p>
<label for="style-guide-textarea">A textarea</label><br>
<textarea id="style-guide-textarea" rows="3" placeholder="Some longer text"></textarea>
</p>
<p>
<label for="style-guide-select">A dropdown</label><br>
<select id="style-guide-select">
<option>Option one</option>
<option>Option two</option>
<option>Option three</option>
</select>
</p>
<p>
Radio buttons<br>
<label><input type="radio" name="style-guide-radio" checked> First choice</label><br>
<label><input type="radio" name="style-guide-radio"> Second choice</label><br>
<label><input type="radio" name="style-guide-radio"> Third choice</label>
</p>
<p>
<label><input type="checkbox" checked> A checkbox, too, while we're here</label>
</p>
<button type="button">A button</button>
</form>
<h2>Icons</h2>
<p>{{ icons.home() }} {{ icons.git() }} {{ icons.book() }} {{ icons.link() }} {{ icons.sitemap() }} {{ icons.email() }} {{ icons.search() }} {{ icons.rss() }} {{ icons.tag() }} {{ icons.lock() }} {{ icons.trash() }} {{ icons.external_link() }} {{ icons.menu() }} {{ icons.back_to_top() }} — every inline SVG icon in <code>novaconium/pages/_layout/icons.twig</code>, all inheriting the surrounding text color via <code>currentColor</code>.</p>
{% endblock %}
+113
View File
@@ -0,0 +1,113 @@
{% extends layout %}
{% import '_layout/icons.twig' as icons %}
{% block title %}Twig Syntax Guide{% endblock %}
{% block description %}A tour of the Twig syntax used throughout this site — output, filters, control structures, inheritance, and a few gotchas.{% endblock %}
{% block robots %}index, follow{% endblock %}
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
{% block og_type %}article{% 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 blog_content %}
<h1>Twig Syntax Guide</h1>
<p>Every page on this site is a Twig template. This post is a quick tour of the syntax that shows up throughout <code>App/pages/</code> and <code>novaconium/pages/</code> — not a full Twig reference (see <a class="icon-link" href="https://twig.symfony.com/doc/3.x/templates.html">{{ icons.external_link() }}the official docs</a> for that), just the parts this framework actually leans on, plus a couple of gotchas learned the hard way<sup><a href="#fn-1">1</a></sup>.</p>
<h2>Output &amp; variables</h2>
<p>Twig prints an expression with <code>{{ '{{ ... }}' }}</code>. Sidecar data, route params, and a handful of framework-provided variables (<code>request_path</code>, <code>layout</code>, <code>site_name</code>) are all just variables in scope:</p>
<pre><code>{% verbatim %}{{ title }}
{{ params.slug }}
{{ post.title }}{% endverbatim %}</code></pre>
<p>Dot notation (<code>post.title</code>) works whether <code>post</code> is an array key or an object property — Twig tries both, so templates don't need to care which.</p>
<h2>Filters</h2>
<p>Filters transform a value with a pipe: <code>{{ '{{ value|filter }}' }}</code>, and chain left to right. A few used on this site:</p>
<table>
<thead>
<tr><th>Filter</th><th>Example</th><th>Does</th></tr>
</thead>
<tbody>
<tr><td><code>default</code></td><td><code>{{ '{{ request_path|default(\'/\') }}' }}</code></td><td>Falls back when a variable is undefined or empty.</td></tr>
<tr><td><code>date</code></td><td><code>{{ '{{ "now"|date("Y") }}' }}</code></td><td>Formats a date — used for the footer's copyright year.</td></tr>
<tr><td><code>e</code> (escape)</td><td><code>{{ '{{ matomo_url|e(\'js\') }}' }}</code></td><td>Escapes for a context other than HTML, here JavaScript string literals.</td></tr>
<tr><td><code>raw</code></td><td><code>{{ '{{ html_string|raw }}' }}</code></td><td>Opts out of autoescaping — see Other elements below.</td></tr>
</tbody>
</table>
<p><strong>One filter to avoid:</strong> <code>|slice</code> on a <em>string</em> (not an array) calls PHP's <code>mb_substr()</code> internally with no fallback — see the footnote<sup><a href="#fn-1">1</a></sup>.</p>
<h2>Control structures</h2>
<p>The two workhorses are <code>{% verbatim %}{% if %}{% endverbatim %}</code> and <code>{% verbatim %}{% for %}{% endverbatim %}</code>:</p>
<pre><code>{% verbatim %}{% if sent %}
&lt;p&gt;Thanks — your message has been sent.&lt;/p&gt;
{% endif %}{% endverbatim %}</code></pre>
<pre><code>{% verbatim %}{% for post in posts %}
&lt;li&gt;&lt;a href="/blog/{{ post.slug }}"&gt;{{ post.title }}&lt;/a&gt;&lt;/li&gt;
{% endfor %}{% endverbatim %}</code></pre>
<p>This exact loop is what renders the <a href="/blog">blog listing page</a> you probably followed a link from to get here.</p>
<h2>Comments</h2>
<p>Anything between <code>{% verbatim %}{# and #}{% endverbatim %}</code> is stripped entirely from the output — unlike an HTML comment, it never reaches the browser:</p>
<pre><code>{% verbatim %}{# Open Graph / Facebook #}{% endverbatim %}</code></pre>
<p>That one's real — it's the comment sitting above the Open Graph block in <code>novaconium/pages/_layout/layout.twig</code>.</p>
<h2>Template inheritance &amp; includes</h2>
<p><code>{% verbatim %}{% extends %}{% endverbatim %}</code> is how every page on this site gets its <code>&lt;html&gt;</code>/<code>&lt;head&gt;</code>/nav/footer for free — a child template only fills in named <code>{% verbatim %}{% block %}{% endverbatim %}</code> slots the parent declares:</p>
<pre><code>{% verbatim %}{% extends layout %}
{% block title %}Blog{% endblock %}
{% block blog_content %}
...
{% endblock %}{% endverbatim %}</code></pre>
<p><code>{% verbatim %}{% include %}{% endverbatim %}</code> pulls in a whole template inline (used for <code>_layout/nav.twig</code> and <code>_layout/matomo.twig</code>), while <code>{% verbatim %}{% import %}{% endverbatim %}</code> pulls in reusable <strong>macros</strong> — parameterized snippets like the icons used throughout this page:</p>
<pre><code>{% verbatim %}{% import '_layout/icons.twig' as icons %}
{{ icons.book() }}{% endverbatim %}</code></pre>
<p>See <a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a> for how <code>{% verbatim %}{% extends %}{% endverbatim %}</code> resolution walks the <code>App/</code>-over-<code>novaconium/</code> override chain.</p>
<h2>Lists, three ways</h2>
<p>Ordered:</p>
<ol>
<li>Parse the template source into an AST.</li>
<li>Compile the AST into a plain PHP class.</li>
<li>Cache and execute that compiled class (caching is disabled in this project's <code>Renderer</code>, since <code>novaconium/vendor/twig/</code> — the source — is already about as fast to re-parse as anything else here).</li>
</ol>
<p>Unordered:</p>
<ul>
<li><code>{{ '{{ }}' }}</code> — output</li>
<li><code>{% verbatim %}{% %}{% endverbatim %}</code> — tags/control structures</li>
<li><code>{# #}</code> — comments</li>
</ul>
<p>Nested — the three page kinds this framework recognizes:</p>
<ul>
<li>Sidecar-less pages
<ul>
<li>Rendered once, then cached as static HTML</li>
<li>e.g. this page's siblings <code>/</code>, <code>/about</code></li>
</ul>
</li>
<li>Pages with a sidecar
<ul>
<li>Return array (Twig context) or a <code>Response</code></li>
<li>Never cached, since output can vary per request</li>
</ul>
</li>
</ul>
<h2>Other elements</h2>
<p><strong>Whitespace control:</strong> a hyphen inside a tag delimiter — <code>{% verbatim %}{%- -%}{% endverbatim %}</code> or <code>{{ '{{- -}}' }}</code> — trims adjacent whitespace/newlines from the output. Not heavily used in this project, since the HTML here isn't whitespace-sensitive, but useful when generating something like JSON or plain text from a template.</p>
<p><strong>Autoescaping:</strong> Twig HTML-escapes every <code>{{ '{{ }}' }}</code> output by default, so a sidecar can safely return user input (like the contact form's <code>old.name</code>) without it being able to inject markup. The <code>|raw</code> filter opts out where the content is trusted and intentionally HTML — used nowhere on the public site, but by the <code>|raw</code>-free docs pages under <code>/admin/docs</code>.</p>
<p><strong>Functions vs. filters:</strong> <code>block('title')</code> (used throughout this site's SEO blocks to reuse the <code>title</code> block's content for <code>og:title</code>) is a <em>function</em>, called with parentheses, not piped like a filter.</p>
<div class="footnotes">
<ol>
<li id="fn-1">Twig's <code>|slice</code> filter, applied to a string, calls PHP's <code>mb_substr()</code> with no fallback — a hard dependency on the <code>mbstring</code> extension that isn't guaranteed on every PHP install. This project hit that exact fatal error on <code>/blog/hello-world</code> once already, back when that page had a sidecar computing an excerpt this way. The fix: truncate in PHP instead, guarded with <code>function_exists('mb_substr')</code> falling back to plain <code>substr()</code> — see <code>AGENTS.md</code> for the standing rule.</li>
</ol>
</div>
{% endblock %}
+54
View File
@@ -0,0 +1,54 @@
<?php
use App\Response;
use Lib\Csrf;
use Lib\FormValidator;
use Lib\Input;
use Lib\Mailer;
use Lib\SpamGuard;
$errors = [];
$old = ['name' => '', 'email' => '', 'message' => ''];
$spamGuard = new SpamGuard();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!Csrf::verify(Input::post('csrf_token'))) {
return Response::redirect('/contact?error=security');
}
$old = [
'name' => Input::post('name', ''),
'email' => Input::post('email', ''),
'message' => Input::post('message', ''),
];
$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.');
if ($validator->passes()) {
// $spamGuard checks the honeypot field + submission timing (see
// Lib\SpamGuard and index.twig's hidden fields) — a bot gets the
// exact same redirect a human would either way, with nothing in
// the response revealing which check it tripped, or that a check
// exists at all. Only Mailer::send() is skipped for spam.
if (!$spamGuard->isSpam(Input::post())) {
(new Mailer())->send($old['name'], $old['email'], $old['message']);
}
return Response::redirect('/contact?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(),
];
+63
View File
@@ -0,0 +1,63 @@
{% extends layout %}
{% block title %}Contact{% endblock %}
{% block description %}Get in touch — send a message using the contact form.{% endblock %}
{# Every block below is optional — the layout already supplies a sensible
default for each (see /admin/docs/seo). Shown here uncommented as a
reference for every override a page can make; delete what you don't
need. #}
{% 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 %}
{% import '_layout/icons.twig' as icons %}
{% block content %}
<article>
<h1 class="icon-heading">{{ icons.email() }}Contact</h1>
<p>This form is handled entirely by <code>App/pages/contact/index.php</code>, a sidecar demonstrating the classic POST/redirect/GET pattern: it validates <code>$_POST</code>, calls <code>Lib\Mailer::send()</code> on success, and redirects to <code>/contact?sent=1</code> — so refreshing the page after submitting never resubmits the form. Because this page has a sidecar, it's never served from the static cache; see <a class="icon-link" href="/admin/docs/sidecars">{{ icons.book() }}Sidecars</a> and <a class="icon-link" href="/admin/docs/libraries">{{ icons.book() }}Libraries</a> for the full contract. It's also a working example of self-hosted spam prevention (honeypot field + submission-timing check, no external CAPTCHA service) — see <a class="icon-link" href="/admin/docs/sidecars">{{ icons.book() }}Sidecars</a>' "Spam prevention" section.</p>
{% if sent %}
<p><strong>Thanks — your message has been sent.</strong></p>
{% endif %}
{% if securityError %}
<p><strong>Your session expired before submitting — please try again.</strong></p>
{% endif %}
<form method="post" action="/contact">
<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="name">Name</label><br>
<input type="text" id="name" name="name" value="{{ old.name }}">
{% if errors.name %}<br><small>{{ errors.name }}</small>{% endif %}
</p>
<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>
<p>
<label for="message">Message</label><br>
<textarea id="message" name="message" rows="5">{{ old.message }}</textarea>
{% if errors.message %}<br><small>{{ errors.message }}</small>{% endif %}
</p>
<button type="submit">Send</button>
</form>
</article>
{% endblock %}
+77
View File
@@ -0,0 +1,77 @@
{% extends layout %}
{% block title %}Home{% endblock %}
{% block description %}A tiny, Hugo-flavored PHP micro-framework: file-based routing, Twig templates, and static caching.{% endblock %}
{# Every block below is optional — the layout already supplies a sensible
default for each (see /admin/docs/seo). Shown here uncommented as a
reference for every override a page can make; delete what you don't
need. #}
{% 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 %}
{% import '_layout/icons.twig' as icons %}
{% block content %}
<section class="hero">
<span class="hero-eyebrow">novaconium</span>
<h1>You're up and running.</h1>
<p class="hero-lede">A tiny, Hugo-flavored PHP micro-framework: file-based routing, Twig templates, and static caching — no Composer, no build step. This page lives at <code>App/pages/index.twig</code>; start editing there.</p>
<div class="hero-actions">
<a class="button-link" href="/admin/docs">Read the docs</a>
<a class="button-link button-link--ghost" href="https://git.4lt.ca/4lt/novaconium">{{ icons.git() }}View source</a>
</div>
<ul class="hero-badges">
<li class="badge">PHP 8.1+</li>
<li class="badge">No Composer</li>
<li class="badge">Twig vendored</li>
<li class="badge">Static caching</li>
</ul>
</section>
<section class="feature-grid">
<article class="feature-card">
<h2>File-based routing</h2>
<p>A directory under <code>App/pages/</code> <em>is</em> a route. <code>[param]</code> segments capture into <code>$params</code>. No route table to maintain.</p>
</article>
<article class="feature-card">
<h2>Optional sidecars</h2>
<p>Drop an <code>index.php</code> next to any <code>index.twig</code> for real logic, or return a <code>Response</code> to short-circuit templating entirely.</p>
</article>
<article class="feature-card">
<h2>Static caching</h2>
<p>Sidecar-less pages render once and get served straight from Apache afterwards — this page included.</p>
</article>
<article class="feature-card">
<h2>SEO out of the box</h2>
<p>Meta description, canonical links, Open Graph, and Twitter Card tags ship by default, all overridable per page.</p>
</article>
<article class="feature-card">
<h2>Matomo &amp; admin auth</h2>
<p>Built-in analytics tracking and an HTTP Basic Auth gate for <code>/admin/*</code> — both off until you turn them on in <code>App/config.php</code>.</p>
</article>
<article class="feature-card">
<h2>Override anything</h2>
<p><code>App/</code> is checked before <code>novaconium/</code> for every page, layout, and <code>Lib\</code> class — override by dropping a file at the same relative path.</p>
</article>
</section>
<section class="next-steps">
<h2>Where to next</h2>
<ul>
<li><a href="/admin/docs/getting-started">Getting started</a> — requirements, running locally, deploying on Apache.</li>
<li><a href="/admin/docs/routing">Routing</a> and <a href="/admin/docs/sidecars">sidecars</a> — how pages and logic fit together.</li>
<li><a href="/blog">The blog example</a> — a listing sidecar, individual posts, and a layout override.</li>
<li><a href="/admin">Admin</a> — clear the cache and browse these docs live.</li>
</ul>
</section>
{% endblock %}