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.
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.
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>