Blog RSS feed (novaconium/ISSUES.md): - App/pages/blog/feed/index.php - main feed, built from the same hand-written $posts array App/pages/blog/index.php renders from, so it works with content_index_enabled left at its default false. Added a published date field per post entry. - App/pages/blog/tag/[tag]/feed/index.php - per-tag feed, gated on content_index_enabled the same way blog/tag/[tag]/index.php is. - novaconium/lib/Rss.php - shared RSS 2.0 envelope builder both feeds use, generic (title/link/description/items in, XML string out). - New head_extra block in the root layout (empty by default) so App/pages/blog/_layout/layout.twig can add feed auto-discovery scoped to /blog/* only. Footer: - New content_index_enabled Twig global (Renderer/bootstrap.php), same pattern as admin_auth_enabled. - Right-aligned footer menu linking to /sitemap.xml (only shown when content_index_enabled, so it never links to a 404) and /blog/feed (always shown, no content-index dependency). Docs: - New /admin/docs/rss page: Lib\Rss API, the two shipped feeds, a worked example of adding a feed for another content collection, and how to advertise multiple feeds via head_extra. - New /admin/docs/sitemap page: changefreq/priority blocks, what's included/excluded, an honest callout on the relative-<loc>-URL spec deviation (consistent with how canonical/og:url already work) with an override path for strict compliance, and submitting it to search engines. - /admin/docs (Overview) gains a Requirements section: minimum requirements, then optional requirements for the database/content-index features (pdo_sqlite, optional pdo_mysql, FTS5) - and a Documentation heading separating it from the doc links list. - /admin/docs/getting-started's Requirements line was stale (missing pdo_sqlite entirely, unlike README) - fixed and cross-linked to the Overview page's fuller list. - /admin/docs/content-index trimmed to point at the new RSS/sitemap pages instead of duplicating their detail. Also adds an "In-house comments" entry to novaconium/ISSUES.md's Backlog - a Lib\ class for sidecar-attached comments on any page, depending on the not-yet-built Admin login & user management for real user accounts. Closes the "Blog RSS feed" backlog item.
29 KiB
Backlog & Roadmap
Official issue tracker: https://git.4lt.ca/4lt/novaconium/issues — bug reports and feature requests are filed and discussed there, not here.
This file is the roadmap that sits above the tracker: a curated, lower-noise list of what's planned, in progress, or decided against, used to triage and clean up the tracker (grouping related issues, deciding priority/sequencing, deciding what's not going to happen) rather than to replace it. An entry here should generally reference the tracker issue(s) it corresponds to once one exists; an entry can also exist here before any tracker issue is filed, for things that are still just an idea.
How to use this file
- Add new items to Backlog using the template below. Don't build anything the moment it's added — Backlog is "known, not yet started."
- Link the tracker issue once one is filed (
Issue:line). Not every Backlog entry needs one yet — file the tracker issue when it's ready to be actionable/discussed, not necessarily when the idea is first written down here. - When work begins, move the item to In Progress.
- When shipped, move it to Done, keep the entry (don't delete), and add
a
Shipped:line with the date and, once committed, the commit/PR reference. - If something is decided against, move it to Won't Do with a
Reason:line rather than deleting it — the "why not" is worth keeping. Close the corresponding tracker issue with a link back to that entry. - Keep entries terse. This file is a map, not a design doc — link out to
/admin/docs/design-notes, the tracker issue, or a futuredocs/note for anything long enough to need one.
Entry template
### <Short title>
- **Type:** Feature | Bug
- **Status:** Backlog | In Progress | Done | Won't Do
- **Priority:** Low | Medium | High
- **Added:** YYYY-MM-DD
- **Depends on:** <other entry title(s)> — omit if none
- **Issue:** https://git.4lt.ca/4lt/novaconium/issues/N — once filed
- **Shipped:** YYYY-MM-DD (commit/PR ref) — only once Done
<1-3 sentence description: what and why. For bugs, include repro steps and
expected vs. actual behavior. For features, include the motivating use case.>
Backlog
Suggested build order (foundations first, since admin login builds on three of the others):
- Media/file manager — no hard dependency; usable standalone, though best gated behind admin login once that exists.
- Syntax highlighting on code blocks — no hard dependency on the
copy button (see Done — shipped 2026-07-14), but touches the same
<pre><code>markup it did. - Admin login & user management — SQLite groundwork and session handling it needed are both done; ready to build.
- In-house comments — needs admin login & user management (comments are tied to real user accounts, not anonymous); build right after it.
- Ecommerce functionality — needs admin login & user management (order/product admin, and customer accounts); SQLite groundwork and session handling (cart) it needed are both done.
- Paywall functionality — needs everything Ecommerce needs, plus
Ecommerce itself for the recurring-billing/payment-gateway plumbing;
build after it rather than in parallel — also now has a concrete
precedent to follow for the "gated content must skip the static cache"
part of its design (see Draft pages (admin-only preview) in Done, and
the caching/auth standing rule in
AGENTS.md), which was still an open question when this entry was originally written.
MySQL support, Session handling (with flash sessions), Draft pages (admin-only preview), Blog tags/categories + Internal search + XML sitemap (shipped together as one content index — see Done), and Blog RSS feed all shipped 2026-07-14.
See Won't Do below for 404 tracking, dropped in favor of Matomo.
Media/file manager
- Type: Feature
- Status: Backlog
- Priority: Medium
- Added: 2026-07-12
An upload/browse/delete UI for media (images, PDFs, etc.) so sidecars and
Twig templates have a consistent place to reference uploaded files from
— e.g. a blog post's header image — instead of authors manually copying
files into public/. Likely a new /admin/media page — already covered
by the admin authentication gate (every /admin/* route) the moment it's
added, no extra wiring needed — backed by a plain directory under
public/uploads/ rather than a database (files are already static
assets; no need for SQLite here unless metadata like alt text/captions is
wanted later, in which case that part could ride on SQLite groundwork).
Needs basic safety handling: extension allowlist, filename sanitization,
and a max upload size, since this is a file-write surface.
Syntax highlighting on code blocks
- Type: Feature
- Status: Backlog
- Priority: Low
- Added: 2026-07-13
Color the PHP/Twig/Bash/HTML snippets across /admin/docs/* and the
blog's reference posts instead of the current flat, single-color
<pre><code> rendering. PHP has a built-in highlight_string(), but it
only understands PHP — everything else on this site's code blocks (Twig
template syntax, Bash/Docker commands in /admin/docs/styling, plain
HTML) needs something else, so use highlight.js
client-side for everything uniformly, with the ir-black theme (dark,
high-contrast — fits this project's existing dark/teal default palette).
ir-black is a dark-only theme, though, and this site now has a light
theme too (see the dark/light toggle) — decide whether code blocks stay
ir-black regardless of site theme (simplest, and arguably fine since
code blocks are visually distinct boxes already), or whether a second,
light-appropriate highlight.js theme gets swapped in via the same
data-theme attribute the color-palette toggle already sets.
Staying consistent with the no-CDN, no-build-step philosophy (Twig itself
is vendored, not npm installed) means vendoring highlight.js's built
highlight.min.js + the ir-black.min.css theme file directly under
novaconium/vendor/ next to twig/, following the same
one-subdirectory-per-vendor convention established there — rather than
pulling from a CDN. highlight.js doesn't ship a Twig grammar out of the
box; either register a custom language definition for it (Twig's syntax
is close enough to Jinja2 that an existing community Jinja grammar may
mostly work) or leave Twig snippets using the plain/no-highlight class
and accept that only PHP/Bash/HTML/XML get colored initially.
No hard dependency on the copy-to-clipboard button above, but both touch
every <pre><code> block on the site, so doing them in the same pass
avoids visiting each doc page's code samples twice. If both ship, the
copy button must keep copying the plain, unhighlighted text (via
textContent, not innerHTML — see that entry) even after this adds
<span> wrappers around tokens, so a copied snippet doesn't come out
full of stray markup.
Admin login & user management
- Type: Feature
- Status: Backlog
- Priority: Medium
- Depends on: SQLite groundwork (Done), Session handling (with flash sessions) (Done)
- Added: 2026-07-12
A single-user HTTP Basic Auth stopgap now gates /admin/*
(novaconium/src/AdminAuth.php, admin_username/admin_password_hash in
App/config.php — see /admin/docs/admin-auth), so /admin is no longer
wide open by default choice. This entry is the real, larger replacement:
multiple accounts, a user store (the SQLite groundwork above — a users
table with hashed passwords via password_hash()/password_verify(), no
external auth library), proper sessions instead of Basic Auth (rides on
session handling above), and basic user management (create/disable a
user, change password). Ship this by replacing AdminAuth::requireLogin()
with the new mechanism, not layering on top of it.
In-house comments
- Type: Feature
- Status: Backlog
- Priority: Medium
- Depends on: Admin login & user management, SQLite groundwork (Done)
- Added: 2026-07-14
A self-hosted comments library — no third-party service (Disqus,
Commento, etc.) — a Lib\ class any sidecar can call to attach comments
to any page, not just blog posts, the same way Lib\SpamGuard/
Lib\FormValidator are reusable across any form rather than hardcoded to
the contact page. Comments tied to a real user account rather than
anonymous name/email fields, which is why this rides on Admin login &
user management rather than SQLite groundwork alone — needs that
feature's user store to exist first. Likely a comments table
(route/user/body/created_at/approved or similar — a migration under
App/migrations/, following the two-root convention documented in
/admin/docs/database) plus a small set of sidecar-callable methods
(list comments for a route, submit one, moderate one). Needs a decision
on moderation model (auto-approve vs. admin-approval queue, reusing the
/admin/* auth gate for the moderation UI) and on spam handling (reuse
Lib\SpamGuard's honeypot/timing approach rather than inventing a second
mechanism, consistent with how CSRF protection already works — see
/admin/docs/sidecars's "Form security" section for the existing
input-cleaning/CSRF/spam-prevention layers a comment form should compose
the same way the contact form does).
Ecommerce functionality
- Type: Feature
- Status: Backlog
- Priority: Low
- Depends on: SQLite groundwork (Done), Session handling (with flash sessions) (Done), Admin login & user management
- Added: 2026-07-12
Product catalog, cart, checkout, and order storage — a products /
orders table in SQLite, a session-based cart (rides on the flash-session
work above), and a payment gateway integration for actually taking money.
Given the project's no-Composer/no-vendored-SDK philosophy, prefer calling
a payment provider's HTTP API directly (e.g. Stripe's REST API via cURL)
over vendoring a full SDK, same reasoning as vendoring only Twig's src/
rather than pulling in a package manager. Needs a decision on which
provider(s) to support first. Order/product management rides on admin
login above. Large feature — likely worth its own sub-breakdown (catalog,
cart, checkout, order admin) once it's actually picked up rather than
planning it all up front here.
Paywall functionality
- Type: Feature
- Status: Backlog
- Priority: Low
- Depends on: Ecommerce functionality (recurring billing/payment plumbing), SQLite groundwork (Done), Session handling (with flash sessions) (Done), Admin login & user management
- Added: 2026-07-12
Subscription/membership content gating, similar to OnlyFans/Patreon:
recurring billing tied to a user account, content (posts, pages, media)
marked as gated behind an active subscription, and access checks in
sidecars ($_SESSION's logged-in user + subscription status, similar to
how admin login gates /admin/*). Reuses Ecommerce's payment-gateway
plumbing for the recurring-charge side rather than integrating a payment
provider a second time — build after Ecommerce rather than in parallel.
Also needs a decision on how gated content is authored (a gated: true
flag in a sidecar's returned context vs. a separate content root) and
what happens to cached pages once caching only applies to sidecar-less
pages — gated pages will need a sidecar to check access, so they're never
statically cached, which is consistent with the existing caching model
but worth being explicit about up front.
In Progress
Nothing yet.
Done
Blog RSS feed
- Type: Feature
- Status: Done
- Priority: Medium
- Added: 2026-07-12
- Shipped: 2026-07-14
App/pages/blog/feed/index.php, sidecar-only, Response::xml(...) — as
originally scoped, no new mechanism needed. Deliberately independent of
the content index above: it reads the same hand-written $posts array
App/pages/blog/index.php itself renders from (now with a published
date field added per entry, illustrative — this repo's posts all arrived
in one batch import, no authentic per-post history to derive real dates
from), so it works with content_index_enabled left at its shipped
default of false. Sorted newest-first for the feed only; the array's
own order (and the /blog listing page) is untouched. <link>/<guid>
are site-relative paths, consistent with how canonical/og:url already
work in this framework (no site-wide base-URL config exists to build
absolute URLs from — not adding one for this alone); <guid isPermaLink="false"> is the spec-correct way to mark a non-absolute
identifier.
Shipped a per-tag feed too (App/pages/blog/tag/[tag]/feed/index.php),
gated on content_index_enabled the same way blog/tag/[tag]/index.php
is, <pubDate> from each page's source_mtime (a stand-in for a real
publish date, which the content index doesn't track). Both feeds share
Lib\Rss::render() (novaconium/lib/Rss.php, new — a generic RSS 2.0
envelope builder, framework-default since only its two call sites are
blog-specific, not the class itself) rather than duplicating the same XML
-building logic twice.
Feed auto-discovery needed a new head_extra block in the root layout
(novaconium/pages/_layout/layout.twig, empty by default, rendered right
before </head>) — the root layout had no open-ended "extra head
content" extension point before this; App/pages/blog/_layout/layout.twig
overrides it with the <link rel="alternate">, so it only appears on
/blog/* pages, not site-wide.
Content index: keywords, tags/categories, search, XML sitemap
- Type: Feature
- Status: Done
- Priority: Medium
- Depends on: SQLite groundwork (Done)
- Added: 2026-07-12 (Blog tags/categories, Internal search, XML sitemap entries) / 2026-07-14 (keywords, combined)
- Shipped: 2026-07-14
Shipped Blog tags/categories, Internal search, and XML sitemap together,
plus a new meta-keywords request, as one feature rather than three —
exactly the "worth deciding together when either is picked up" call this
file made when XML sitemap was first written. All three (plus a fourth,
new: <meta name="keywords">) turned out to be one shared crawler with
thin consumers, not separate mechanisms.
Design: content stays in files (Twig pages, no CMS-style body-in-database
— keeps the Hugo-style file-based-routing pitch intact). Per-page metadata
is four Twig blocks in novaconium/pages/_layout/layout.twig, the same
override mechanism already used for title/description/og_*:
keywords (rendered, new <meta> tag), tags (comma-separated, not
rendered), changefreq/priority (sitemap hints, not rendered). No
front-matter, no separate metadata file convention. App\ContentIndexer
(novaconium/src/ContentIndexer.php) crawls every routable page
(Overlay::listPageDirs(), a new method — skips _/404/[param]
directories, matching Router::resolve()'s reserved-segment rule and the
original XML sitemap entry's stated V1 limitation that wildcard routes
aren't crawled without a data source to resolve concrete values) and pulls
each block's value via Renderer::renderForIndex() (new method) calling
Twig's own TemplateWrapper::renderBlock() — not regex-parsing .twig
source — so overrides and layout inheritance resolve exactly like a real
render. Rendered HTML is strip_tags()-stripped into a SQLite FTS5 table
for search. A page listed in draft_routes or whose robots block
resolves to noindex is skipped entirely (never indexed), same convention
/admin/docs/seo already documents for admin/internal pages.
Off by default (content_index_enabled, default false) — all three
consumers depend on SQLite, a real dependency plenty of sites built on
this framework won't want, same reasoning that already keeps Matomo/admin
auth off by default. Verified end-to-end that disabling it is a true
zero-footprint no-op: no data/novaconium.sqlite gets created just
because the feature exists in the codebase, and all three consumer routes
404 exactly as if they didn't exist.
Two trigger paths sharing one reindex(): lazy (content_index_auto,
default true — a cheap mtime-staleness check on first touch of a
consumer route, never on a normal page view) and explicit
(php novaconium/bin/index-content.php, same shape as bin/migrate.php,
ignores content_index_auto).
Two real bugs caught by testing, not review:
- Reentrancy — the crawl renders every page, including
/searchitself, whose own sidecar callsContentIndexer::ensureFresh(); without a guard this triggered a nestedreindex()mid-transaction and fataled on a secondPDO::beginTransaction(). Fixed with aprivate static bool $indexingguard checked at the top of bothensureFresh()andreindex(). - Wrong PDO constant (
PDO::KEY_PAIRinstead ofPDO::FETCH_KEY_PAIR) in the search sidecar, caught immediately by actually hitting/searchwith a real query rather than trusting the code read correctly.
Also fixed two unrelated pre-existing bugs discovered while building this
(both blocked/were adjacent to the crawler rendering every page for real):
novaconium/pages/admin/docs/sidecars/index.twig had a literal
un-escaped {{ }} in prose text that fataled Twig with a syntax error on
any real render of that page (it had apparently never actually been
visited before); and both that page and Lib\Input's doc-comment still
said "there's no database layer in this framework yet" despite Lib\Db
having shipped weeks earlier.
migrations_dir (Lib\Db) now accepts an ordered list of roots, not
just one path — needed so the content index's schema
(novaconium/migrations/0001_create_content_index.sql) could ship as a
framework migration without colliding with project migrations in
App/migrations/. This is the first framework-shipped migration, and the
two-root extension point AGENTS.md flagged as a future need when SQLite
groundwork shipped. Migrations are now tracked by path relative to the
repo root (not bare filename) specifically to prevent two roots each
containing a same-named file from shadowing one another —
realpath()-normalized so a migrations_dir containing .. (like the
default connection's own __DIR__ . '/../App/migrations') doesn't produce
an ugly, unstable tracked name.
New consumer routes: novaconium/pages/sitemap.xml/index.php (framework
-default — confirmed a directory literally named sitemap.xml resolves
correctly, since Router only splits on /), novaconium/pages/search/
(framework-default, FTS5-backed, the search term wrapped as an escaped
quoted phrase before binding — parameter binding stops SQL injection but
not FTS5's own query-language parsing of the bound value, verified against
a literal " and several FTS operator characters, not just assumed safe),
and App/pages/blog/tag/[tag]/ (project-owned, since blog/ is project
content — App/pages/blog/index.php's hand-written post array is
untouched, content_tags is a derived index on top of it, not a
replacement). Added {% block tags %} to the 4 existing blog posts so tag
browsing has real content to demonstrate against — an exception to the
"ship mechanism only, no demo content" pattern of prior sessions, since
here the target content already existed and tag browsing is meaningless
to verify without it. Documented at /admin/docs/content-index, with
supporting updates to /admin/docs/seo, /admin/docs/database, and
novaconium/bin/create-static-page.php's scaffolded template.
Draft pages (admin-only preview)
- Type: Feature
- Status: Done
- Priority: Medium
- Added: 2026-07-13
- Shipped: 2026-07-14
Let a page under App/pages/ be written and previewed by an admin without
being visible to the public — a config-driven list, draft_routes in
App/config.php (Route::$dir-format entries, e.g. 'blog/upcoming-post'),
checked in novaconium/bootstrap.php right alongside the existing
/admin/* gate. Reuses AdminAuth::isAuthenticated() (a new method,
extracted out of requireLogin() so the credential check could be reused
with a different failure response) rather than a second auth mechanism:
not authenticated → the same plain 404 an unmatched route gets (not a
login prompt, so a draft's existence isn't revealed to anyone poking at
the URL), authenticated → renders normally. No separate login flow needed
— an admin authenticates once at /admin, and the browser then resends
those same Basic Auth credentials to draft URLs automatically, since
they're scoped to the whole origin/realm.
The gotcha flagged when this entry was written was designed around
correctly: sidecar-less pages get written to the static HTML cache and
served by .htaccess before PHP ever runs, so a draft without its own
sidecar needed an explicit exclusion, not just the auth gate —
Renderer::render() gained an $excludeFromCache param for this.
A second, real instance of the same bug was found while testing this
feature, pre-dating it entirely: /admin itself
(novaconium/pages/admin/index.twig) has no sidecar, so it was already
being written to the static cache — meaning once any admin visited
/admin once, the admin panel was served to every subsequent visitor,
unauthenticated, straight from public/cache/admin/, completely
bypassing AdminAuth. Fixed in the same change by passing
$excludeFromCache = true for every /admin/* route too, not just
drafts. Documented as a standing rule in AGENTS.md (next to the
mb_substr/|slice and |escape('js') notes) and at
/admin/docs/drafts//admin/docs/caching: any future mechanism that
conditionally hides page content from the public has to make the same
check, not just gate the initial request.
Session handling (with flash sessions)
- Type: Feature
- Status: Done
- Priority: High
- Added: 2026-07-12
- Shipped: 2026-07-14
A Lib\ wrapper around PHP's native session handling (session_start(),
$_SESSION, not a custom session store) — Lib\Session
(novaconium/lib/Session.php), all-static and lazy-start, same shape as
the already-shipped Lib\Csrf (which also touches the native session; the
two coexist in the same request without conflict). Ships
get()/set()/has()/remove() plus CodeIgniter-style flash data
(flash()/getFlash()) — a value set now that's readable on exactly the
next request, then gone, for post/redirect/GET flows like
App/pages/contact/index.php's hand-rolled ?sent=1 (not refactored to
use it in this change — cited in the original spec as the motivating
example, not a mandate to touch working demo code). The flash mechanism is
a single per-request swap (snapshot last request's flash bucket into an
in-memory static on first touch, then clear the stored bucket so this
request's flash() calls fill a fresh one for the request after), not a
separate expiry/sweep pass — verified end-to-end across three real,
separate HTTP requests sharing a cookie jar (not three calls in one PHP
process), confirming a flashed value appears on exactly the next request
and is gone on the one after. Foundational alongside SQLite groundwork —
admin login (next up) depends on it for logged-in state. Documented at
/admin/docs/session and in AGENTS.md next to the Lib\Csrf/Lib\Db
sections.
MySQL support
- Type: Feature
- Status: Done
- Priority: Medium
- Depends on: SQLite groundwork (Done)
- Added: 2026-07-12
- Shipped: 2026-07-14
Let a project point Lib\Db at MySQL — but went further than the original
spec ("point the Db wrapper at MySQL instead of SQLite"): a real
requirement surfaced during implementation that a single request may need
both at once (sidecars have full access to any Lib\ class, so nothing
stops one from querying this site's own SQLite data and a legacy MySQL
database in the same request). So Lib\Db was redesigned around multiple,
independently-configured, simultaneously-open named connections
(config['db_connections'], keyed by name — 'default' is the only
required one) rather than one global connection switched by a single
db_driver key. This superseded the flat db_driver/db_path/
db_migrations_dir keys the SQLite groundwork entry above originally
shipped with (which had no downstream consumers yet, so no migration path
was needed). Db::query(string $sql, array $params = [], string $connection = 'default') and Db::connection(string $name = 'default')
both default to 'default' so the common single-database case reads the
same as before; a third/first argument targets any other configured
connection. Each connection has its own lazy PDO connect ('sqlite' and
'mysql' drivers implemented), its own optional migrations_dir, and its
own independent schema_migrations table — verified for real (not just by
inspection) by running a local MariaDB instance alongside the existing
SQLite connection and executing queries against both from the same script.
db_connections needed one deliberate exception to the project's usual
shallow config-merge rule — merged one level deeper, by connection name, so
an App/config.php adding a legacy connection doesn't silently delete
the framework's default one — documented in AGENTS.md and
/admin/docs/database, including the exact "capture defaults before the
top-level array_merge() overwrites them" ordering bug hit once while
building this (caught by the end-to-end MySQL test, not by review).
SQLite groundwork
- Type: Feature
- Status: Done
- Priority: High
- Added: 2026-07-12
- Shipped: 2026-07-14
Laid the groundwork for optional SQLite storage: Lib\Db
(novaconium/lib/Db.php) is a thin, no-ORM PDO wrapper (prepared
statements only, no string-interpolation helper ever, per Lib\Input's
existing documented security stance) so features that need persistence —
404 tracking (see Won't Do; superseded by Matomo before this shipped),
admin login, blog tags, internal search, and anything future — have a
common place to store data instead of ad hoc flat files. Data lives in a
new top-level data/ directory — deliberately outside both public/
(would be web-accessible) and novaconium/ (gets wholesale-replaced by the
"Updating the framework" workflow documented at
/admin/docs/getting-started, so anything persisted there would be
destroyed by the next update) — gitignored per-file, with a tracked
.gitkeep. Designed driver-agnostic (no SQLite-only SQL in the mechanism
itself) specifically so MySQL support wouldn't need a retrofit — see that
entry below (shipped 2026-07-14) for the connection/config/migration API,
which superseded the single-connection shape (db_driver/db_path/
db_migrations_dir config keys) this entry originally shipped with.
Copy-to-clipboard button on code blocks
- Type: Feature
- Status: Done
- Priority: Low
- Added: 2026-07-13
- Shipped: 2026-07-14
Every <pre><code> block across /admin/docs/* and the blog's reference
posts (Twig Syntax Guide, Style Guide) is meant to be copy-pasted — added a
small button on hover that copies the block's text via the
Clipboard API
(navigator.clipboard.writeText(...)), consistent with this project's
no-build-step philosophy: vanilla JS, no dependency, same event-delegation
pattern as the dark/light theme toggle (novaconium/pages/_layout/nav.twig).
Implemented as a single site-wide partial
(novaconium/pages/_layout/code-copy.twig, included from
_layout/layout.twig's footer) that injects a button into every <pre>
containing a <code> on DOMContentLoaded, rather than touching each doc
page's markup individually. Added copy/check icons to
novaconium/pages/_layout/icons.twig and matching styles in
novaconium/sass/main.sass (hover/focus-revealed button, .copied state).
Icon markup reaches JS via two <template> elements read through
.innerHTML, not Twig's |escape('js') — that filter calls
Twig\Runtime\mb_ord() and fatals without the mbstring extension, hit
for real once on a bare-PHP install; see the standing rule added to
AGENTS.md next to the existing |slice/mb_substr gotcha.
Copies via code.textContent, not innerHTML, so HTML-entity-escaped
samples (e.g. <h1> in the SEO starter template) come out as literal
characters rather than escaped markup. Shows a "Copied!" label/checkmark
for 1.5s after a successful copy.
Won't Do
404 tracking
- Type: Feature
- Status: Won't Do
- Priority: Medium
- Added: 2026-07-12
- Reason: Matomo (see
/admin/docs/matomo) already tracks 404 hits —Renderer::renderNotFound()setsis_404 => trueand the tracking snippet tags the document title as404/URL = <path>/From = <referrer>beforetrackPageView, so 404s are already filterable in Matomo under Behaviour → Page URLs. A separate in-app SQLite-backed 404 log would just duplicate what Matomo already captures, for no real benefit.
Originally proposed as: log requests that hit the 404 page (path,
timestamp, referrer, user agent) into SQLite, similar to Joomla's 404 log,
with an /admin page to view them.