Upload/browse/delete UI for files under public/uploads/, covered by the
existing /admin/* auth gate with no separate feature flag needed (no
SQLite dependency to gate). Extension allowlist and max upload size are
configurable; filenames are sanitized and de-duplicated on upload, and
deletes re-verify the resolved path lands inside the upload directory
before touching disk. Docker gains a fourth-turned-third named volume
for public/uploads/ so uploads survive a rebuild.
images/ (reserved scaffolding for a future image feature) is removed —
nothing ever consumed it, and public/uploads/ now covers that use case.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a root Dockerfile (Arch Linux + Apache + PHP) and docker-compose.yml
with separate cache/data/images volumes, so a project's own content,
page cache, and future uploaded images stay out of paths a framework
update would wipe. App/ is baked into the image but can be bind-mounted
to override without a rebuild. Renames the Sass build-tool Dockerfile
example in the styling doc to Dockerfile.sass to avoid colliding with
the new app Dockerfile, adds a new /admin/docs/docker page (linked from
the docs index and nav), and documents the reserved images/ directory
in AGENTS.md and README.
Also records the user's git/docker execution permission boundaries in
CLAUDE.md for future sessions.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Admin login & user management (novaconium/ISSUES.md): session-based
login against a SQLite users table replaces the single-user HTTP Basic
Auth stopgap (admin_username/admin_password_hash and /admin/password-hash
are gone; one admin_auth_enabled flag, off by default with zero DB
footprint). New /admin/login, /admin/logout (POST-only, real page), and
/admin/users pages plus bin/create-admin-user.php.
First user created is the admin; everyone after is registered with a
unique normalized email and an optional group. /admin/* and drafts are
admin-only; Lib\Access gates page content from sidecars
(Access::require('group:members')) with login-redirect/404 responses —
public by default, static pages always public by construction. User
management covers disable/enable, delete, promote/demote, group, email,
and password, with last-active-admin lockout guards.
Also: Session::regenerate() against fixation, friendly missing-PDO-driver
errors in Lib\Db, docs at /admin/docs/access-control and updates across
admin-auth/drafts/sidecars/config/libraries and README/AGENTS.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Colors <pre><code> blocks site-wide via vendored highlight.js v11.11.1
(pinned to that stable tag, not main, which tracks an in-progress
11.0.0-beta1), auto-detected and restricted to
configure({ languages: ['php', 'bash', 'xml', 'css', 'python',
'javascript', 'yaml', 'json', 'ini'] }) - no per-block markup needed for
the ~60 existing code blocks across the site. css/python/javascript ship
in the core bundle; yaml/json/ini (ini covers .env-style files too)
don't and are vendored as separate per-language files.
Themes swap with the existing dark/light toggle: ir-black (dark) +
github (light, resolving the entry's own open question), via the same
data-theme-driven mechanism as the main palette
(syntax-highlight-init.twig/syntax-highlight.twig, mirroring
theme-init.twig/nav.twig's split) - a MutationObserver swaps the theme
link live without touching the existing toggle button's click handler.
Twig-syntax code blocks have no highlight.js grammar and are marked
class="nohighlight" by hand (15 blocks across 9 files, found by grepping
for literal {% %}/{{ }} syntax rather than guessing) rather than
force-matched into the restricted candidate set, which would color them
wrong instead of leaving them plain.
One correction to the original backlog entry's suggested approach: it
suggested vendoring highlight.js under novaconium/vendor/ next to Twig.
That would have silently 404ed on every request - Twig is server-side
PHP, never fetched by a browser, but highlight.js's .js/.css files are,
and only public/ is web-reachable. Vendored to public/vendor/highlightjs/
instead; documented in AGENTS.md and a new upgrading-highlightjs doc,
since public/ isn't touched by the usual novaconium/-swap framework
update workflow, so a future highlight.js bump won't propagate to
existing projects automatically the way it does for everything else
under novaconium/.
Caught two real bugs via testing rather than review: hljs.highlightAll()
silently no-ops if called before the document finishes parsing rather
than deferring itself, and a bash example starting with the word "php"
auto-detects as PHP, not bash.
Also adds App/pages/blog/code-highlighting/ - a new blog post
demonstrating the feature with a verified worked example in each of the
nine languages, plus how to force a language via an explicit
language-<name> class when auto-detection isn't enough.
Closes the "Syntax highlighting on code blocks" backlog item in
novaconium/ISSUES.md.
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.
A Twig {% block %} always emits its content wherever it's declared -
wrapping novaconium/pages/_layout/layout.twig's metadata-only blocks
(tags/changefreq/priority) in a plain {# #} comment doesn't suppress
that, and a literal {% block %} tag inside a {# #} comment doesn't even
compile (comments are stripped before parsing). Both bugs were present:
the first caused "monthly 0.5" to appear as visible text in <head> on
every page; fixing that by wrapping the blocks in a real HTML comment
tripped the second, a Twig SyntaxError, because the explanatory comment
above them contained literal {% %}/{# #} sequences that terminated the
outer Twig comment early.
Fixed by wrapping the three blocks in an actual HTML comment (invisible
to a reader/browser, still genuine Twig blocks - overridable and
harvestable via renderBlock() exactly as before) and rewriting the
explanatory comment without embedding literal Twig delimiter syntax.
Verified the fix doesn't regress ContentIndexer's metadata extraction.
Ships Blog tags/categories, Internal search, and XML sitemap together as
one shared mechanism, plus a new meta keywords request, rather than three
separate ones - the "worth deciding together" call ISSUES.md made when
XML sitemap was first written.
Content stays in files. Per-page metadata is four Twig blocks in the
root layout, the same override mechanism already used for
title/description/og_* - keywords (rendered), tags/changefreq/priority
(not rendered, harvested only). App\ContentIndexer crawls every routable
page (Overlay::listPageDirs(), new) and pulls each block via
Renderer::renderForIndex() (new) calling Twig's own renderBlock() API,
not regex-parsing .twig source, so overrides and layout inheritance
resolve exactly like a real render. Rendered HTML is stripped and
indexed into a SQLite FTS5 table for search.
Off by default (content_index_enabled) - same posture as Matomo/admin
auth, since this is a real SQLite dependency plenty of sites won't want.
Verified true zero footprint when disabled: no data/novaconium.sqlite
gets created, all three consumer routes 404 like they don't exist.
content_index_auto (default true) reindexes lazily on first stale touch
of a consumer route, never on a normal page view; both that path and the
explicit `php novaconium/bin/index-content.php` share one reindex().
Two real bugs caught by testing, not review: a reentrancy bug where
/search's own sidecar calling ensureFresh() during the crawl triggered a
nested reindex() mid-transaction (fixed with a static re-entrancy guard),
and a wrong PDO constant in the search sidecar. Also fixed two unrelated
pre-existing bugs found while building this: an unescaped {{ }} in
admin/docs/sidecars that fataled Twig on any real render of that page,
and a stale "no database layer yet" claim there and in Lib\Input's
docblock, left over from before Lib\Db shipped.
Lib\Db's migrations_dir now accepts an ordered list of roots, not just
one path, so the content index's schema could ship as a framework
migration (novaconium/migrations/) without colliding with project
migrations in App/migrations/ - the two-root extension point flagged in
AGENTS.md when SQLite groundwork shipped. Migrations are tracked by path
relative to the repo root rather than bare filename so two roots with a
same-named file can't shadow each other.
New consumer routes: novaconium/pages/sitemap.xml/ and
novaconium/pages/search/ (framework defaults), App/pages/blog/tag/[tag]/
(project-owned, since blog/ is project content - the existing hand
-written post array in App/pages/blog/index.php is untouched). Added
tags to the 4 existing blog posts as a real demonstration.
Closes the Blog tags/categories, Internal search, and XML sitemap
backlog items in novaconium/ISSUES.md.
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.
Lib\Session (novaconium/lib/Session.php) is a thin, all-static wrapper
around PHP's native session handling — get/set/has/remove plus
CodeIgniter-style flash data (flash()/getFlash()): a value set now is
readable on exactly the next request, then gone, for post/redirect/GET
flows like the contact form's hand-rolled ?sent=1 (not refactored here —
the original spec cites it as a motivating example, not a mandate).
Lazy-start, same shape as the already-shipped Lib\Csrf, which the two
classes can share a native session with in the same request without
conflict. Flash data is a single per-request swap (snapshot last
request's bucket, clear the stored one) rather than a sweep/expiry pass.
Verified end-to-end across three separate HTTP requests sharing a cookie
jar (not just in-process calls), confirming a flashed value survives
exactly one subsequent request.
Closes the "Session handling (with flash sessions)" backlog item in
novaconium/ISSUES.md.
Redesigns Lib\Db from a single global connection to a config-driven map
of named connections (config['db_connections']), each independently
lazy-connected, each with its own optional migrations_dir and its own
schema_migrations table. A sidecar can use more than one connection in
the same request (e.g. Db::query(...) against SQLite alongside
Db::query(..., 'legacy') against MySQL) — sidecars have full access to
any Lib\ class, so nothing stops a request from needing two databases
at once, which the original single-driver spec didn't account for.
Db::query()/Db::connection() both default to the 'default' connection
name so the common single-database case is unchanged at the call site.
Supersedes the flat db_driver/db_path/db_migrations_dir keys shipped in
the SQLite groundwork commit (a3b9967) — no downstream consumers yet,
so no migration path needed.
db_connections needed one deliberate exception to the project's usual
shallow config-merge rule: merged one level deeper, by connection name,
so App/config.php adding a connection doesn't delete 'default'. Verified
end-to-end against a real local MariaDB instance running alongside the
existing SQLite connection, which caught a real ordering bug in the
initial merge implementation (capturing defaults after they'd already
been overwritten) before it shipped.
Closes the "MySQL support" backlog item in novaconium/ISSUES.md.
Lib\Db (novaconium/lib/Db.php) is a thin, no-ORM PDO wrapper — lazy-connect
like Lib\Csrf, Db::query() as the only query-running helper (prepared
statements only, no interpolation shortcut, per Lib\Input's existing
security stance). Plain .sql migrations under App/migrations/, applied in
filename order and tracked in an auto-created schema_migrations table, run
automatically on first connection or via novaconium/bin/migrate.php.
Data lives in a new top-level data/ directory rather than novaconium/ or
public/ — outside public/ so it's never web-accessible, and outside
novaconium/ since that directory gets wholesale-replaced by the "Updating
the framework" workflow, which would otherwise destroy it on every update.
New config keys: db_driver (only 'sqlite' implemented), db_path,
db_migrations_dir. Documented at /admin/docs/database and in AGENTS.md.
Closes the "SQLite groundwork" backlog item in novaconium/ISSUES.md.
Injects a hover-revealed copy button into every <pre><code> block
site-wide via a single layout partial, reading textContent so escaped
samples copy as literal text. Ships copy/check icons and matching Sass.
Passes icon markup to JS via <template> elements instead of Twig's
|escape('js'), which calls mb_ord() and fatals without mbstring — same
class of bug as the existing |slice/mb_substr gotcha, now documented in
AGENTS.md.
Closes the "Copy-to-clipboard button on code blocks" backlog item in
novaconium/ISSUES.md.
Add clone-and-drop-.git setup steps and a tag-and-overwrite recipe for
updating just novaconium/, mirrored in README.md and /admin/docs/getting-started.
Full rewrite: swap out the v1 framework (src/, controllers/, views/,
twig/, sass/, skeleton/) for the working v2 codebase from phpproject
(App/, novaconium/, public/).
Documents project structure, Docker-only build/run commands, and
conventions so any coding agent (Claude Code, DeepSeek, etc.) can
orient quickly. CLAUDE.md imports AGENTS.md as the single source of
truth.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>