Files
novaconium/novaconium/ISSUES.md
T
code 4862526fa1 Add draft pages (admin-only preview); fix admin panel cache leak
Lets a page under App/pages/ be previewed by an admin before the public
can see it: list its route in draft_routes (App/config.php), checked in
bootstrap.php alongside the existing /admin/* gate. Not authenticated ->
same plain 404 an unmatched route gets, not a login prompt, so a draft's
existence isn't revealed. Authenticated -> renders normally. No separate
login flow needed - Basic Auth credentials are scoped to the whole
origin/realm, so authenticating once at /admin covers draft URLs too.

AdminAuth::isAuthenticated() is extracted out of requireLogin() so the
draft gate can reuse the same credential check with a different failure
response (404 vs. a 401 challenge).

Renderer::render() gains an $excludeFromCache param so a draft without
its own sidecar can't get written to the static HTML cache - .htaccess
serves a cached file before PHP, and therefore any auth check, ever runs
again, so an uncached exception is required, not just the auth gate.

While testing this, found the same bug already existed for /admin itself:
novaconium/pages/admin/index.twig has no sidecar, so it was already being
cached - meaning any admin visiting /admin once caused the panel to be
served to everyone, unauthenticated, straight from
public/cache/admin/. Fixed in this change by excluding every /admin/*
route from the cache the same way, and documented as a standing rule in
AGENTS.md: any future mechanism that conditionally hides page content
from the public has to make the same check, not just gate the initial
request.

Closes the "Draft pages (admin-only preview)" backlog item in
novaconium/ISSUES.md.
2026-07-14 16:35:31 +00:00

24 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 future docs/ 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 two of the others):

  1. Blog tags/categories — no dependencies, but now needs a metadata source design decision first (PostRepository was removed when hello-world/second-post became plain Twig pages — see the entry below), so worth doing first or together with Blog RSS feed.
  2. Blog RSS feed — no hard dependency, but a per-tag feed is easiest once tags/categories exist.
  3. Internal search — SQLite groundwork it needed is done; ready to build.
  4. XML sitemap — no hard dependency, but shares crawling logic with Internal search, so easiest right after (or alongside) it.
  5. Media/file manager — no hard dependency; usable standalone, though best gated behind admin login once that exists.
  6. 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.
  7. Admin login & user management — SQLite groundwork and session handling it needed are both done; ready to build.
  8. Ecommerce functionality — needs admin login & user management (order/product admin, and customer accounts); SQLite groundwork and session handling (cart) it needed are both done.
  9. 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), and Draft pages (admin-only preview) all shipped 2026-07-14 — see Done.

See Won't Do below for 404 tracking, dropped in favor of Matomo.

Blog tags/categories

  • Type: Feature
  • Status: Backlog
  • Priority: Medium
  • Added: 2026-07-12

Tag (or category) each blog post so posts can be browsed/filtered by topic — e.g. App/pages/blog/tag/[tag]/index.php listing matching posts, using the [param] route-capture mechanism (see /admin/docs/routing; this project's own App/pages/blog/ doesn't currently use [param] for posts, every post is its own plain directory named after its slug). Lib\PostRepository (which used to back hello-world/second-post) was removed when those two posts became plain Twig pages, like twig-syntax-guide and style-guide — so there's no shared metadata store anymore. App/pages/blog/index.php now just hand-lists each post's slug/title/excerpt in a plain array; adding tags means adding a tags field to each entry there and a lookup by tag over that same array. Fine at current scale (4 posts); if the array grows unwieldy or tags need real querying, that's a SQLite-groundwork question — no need to block on it now. Once a feed exists, consider a per-tag feed too (e.g. App/pages/blog/tag/[tag]/feed/index.php).

Blog RSS feed

  • Type: Feature
  • Status: Backlog
  • Priority: Medium
  • Added: 2026-07-12

An RSS (or Atom) feed for the blog, e.g. App/pages/blog/feed/index.php returning Response::xml(...) — the sidecar contract already supports this, no new mechanism needed. App/pages/blog/index.php already hand-lists every post's slug/title/excerpt in a plain array (no PostRepository anymore — see the Blog tags/categories entry above for why); the remaining gap is a published-date field per entry so a feed can sort them, regardless of whether that array stays hardcoded or moves onto SQLite later. Should link from <link rel="alternate" type="application/rss+xml"> in the blog layout (or the root layout) for feed auto-discovery, and probably wants its own App/pages/blog/_layout/ or a sidecar-only page — no index.twig needed since the sidecar returns XML directly (see /admin/docs/sidecars's JSON-only example for the same pattern, just with Response::json() instead of Response::xml()).

  • Type: Feature
  • Status: Backlog
  • Priority: Medium
  • Depends on: SQLite groundwork (Done)
  • Added: 2026-07-12

Crawl the site's own pages (likely via App/pages/ + rendered output, rather than an external HTTP crawl, to avoid hitting the static cache/ .htaccess layer) and index the content into SQLite once the groundwork above exists, so a search box can query it — a search sidecar (e.g. App/pages/search/index.php) that reads the index and returns matching pages, no external search service. Needs a decision on crawl trigger (on-demand via an admin action vs. a cron-like re-crawl) and on indexing granularity (whole-page vs. per-section). Consider reusing the SQLite FTS5 extension if available, rather than hand-rolling text search.

XML sitemap

  • Type: Feature
  • Status: Backlog
  • Priority: Medium
  • Added: 2026-07-12

Generate a /sitemap.xml listing every routable page (App/pages/ + novaconium/pages/ overlay, same resolution Router/Overlay already do) for search engine discovery — one more piece of the SEO groundwork already laid (/admin/docs/seo, canonical links, etc.). No hard dependency on SQLite: a first version can be built by walking pages_dirs directly (skipping reserved _/404 segments, resolving [param] routes only if their concrete values are knowable from some data source — moot for App/pages/blog/ today, since every post there is now a plain directory rather than a [param] route) the same way Router::resolve() walks it, with no separate crawl step. That said, it may be worth sharing a crawler with Internal search rather than building two separate page-enumeration mechanisms — if Internal search's crawler already walks and records every real URL (including resolved [param] values), the sitemap can just be a different serialization of that same data instead of its own logic. Worth deciding together when either is picked up. Should also exclude /admin/docs/* if it isn't publicly reachable (see admin authentication) and the noindex pages already marked via the robots block.

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.

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

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. &lt;h1&gt; 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() sets is_404 => true and the tracking snippet tags the document title as 404/URL = <path>/From = <referrer> before trackPageView, 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.