light housekeeping

This commit is contained in:
2026-07-14 23:50:13 -07:00
parent b37b13120e
commit f6fb2f12c6
2 changed files with 11 additions and 292 deletions
+1
View File
@@ -6,3 +6,4 @@
/data/*.sqlite-wal
/data/*.sqlite-shm
.claude/
/graphify-out/
+10 -292
View File
@@ -20,9 +20,14 @@ tracker issue is filed, for things that are still just an idea.
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.
- When shipped, move it to **Done** and add a `Shipped:` line with the date
and, once committed, the commit/PR reference. Keep a Done entry only as
long as it's referenced by (a `Depends on:`, or otherwise relevant
context for) something still in Backlog/In Progress — once nothing
active points back to it, delete it rather than letting this file grow
without bound. This is a change from the file's earlier "never delete"
policy; if a stale Done entry's history is ever needed again, it's in
git history / the linked tracker issue.
- 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.
@@ -69,11 +74,8 @@ Suggested build order (foundations first):
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), Blog RSS
feed, Syntax highlighting on code blocks, and Admin login & user
management all shipped 2026-07-14.
Session handling (with flash sessions), Draft pages (admin-only preview),
and Admin login & user management all shipped 2026-07-14 (see Done).
See **Won't Do** below for 404 tracking, dropped in favor of Matomo.
@@ -322,220 +324,6 @@ the session cookie (including confirming a pre-existing static-cache copy
of a page still serves after the page is marked a draft until the cache
is cleared — the documented cache-clear step, not a new bug).
### Syntax highlighting on code blocks
- **Type:** Feature
- **Status:** Done
- **Priority:** Low
- **Added:** 2026-07-13
- **Shipped:** 2026-07-14
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 code blocks across the
site. `css`/`python`/`javascript` ship in the core bundle alongside the
original `php`/`bash`/`xml`; `yaml`/`json`/`ini` (the last covers
`.env`-style files too) don't and are vendored as three separate
per-language files under `public/vendor/highlightjs/languages/` — added
after initial shipment, once `/blog/code-highlighting` (a new reference
post, worked example of each of the nine) needed them. Themes swap with the
existing dark/light toggle: **ir-black** (dark, as originally specified)
+ **github** (light, new — resolving the open question this entry
originally left for "decide whether code blocks stay ir-black regardless
of site theme"), via the same `data-theme`-driven mechanism as the main
palette (`novaconium/pages/_layout/syntax-highlight-init.twig`/
`syntax-highlight.twig`, mirroring `theme-init.twig`/`nav.twig`'s split —
a `MutationObserver` on `data-theme` swaps the theme `<link>` live,
without touching the existing toggle button's own click handler at all).
Twig-syntax code blocks (no highlight.js grammar exists for Twig) are
marked `class="nohighlight"` by hand at the source — the entry's own
suggested fallback — rather than force-matched into the restricted
candidate set, which would color them *wrong* rather than leave them
plain (auto-detection with a restricted language list always returns its
best guess among the allowed set, never "gives up"). 15 blocks across 9
files needed the marker; verified by finding every `<pre><code>`
containing literal `{% %}`/`{{ }}` syntax rather than guessing, and
confirmed two files matching that initial grep (`sidecars`, one block in
`forms`) turned out to be `{% verbatim %}`-wrapped *PHP* snippets
(verbatim just protecting a stray `{{ }}` mention), correctly left alone
for auto-detection. Same class of gotcha hit again writing the bash
example for `/blog/code-highlighting`: a command starting with the
literal word `php` (`php -S 127.0.0.1:8000 ...`) auto-detects as PHP, not
bash — not a bug, just a reminder that short/ambiguous snippets can
mis-detect regardless of the restricted candidate set; the shipped bash
example uses a `#!/bin/bash` shebang instead, a strong, reliable signal,
verified against the real detector before committing to it.
**One correction to this entry's own suggested approach, found while
implementing it:** vendoring highlight.js under `novaconium/vendor/` next
to Twig (as originally suggested) would have been wrong and silently
broken — 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 — see the
standing rule added to `AGENTS.md` and `/admin/docs/upgrading-highlightjs`
for the consequence: `public/` isn't touched by the usual
`novaconium/`-swap framework-update workflow, so a future highlight.js
version bump won't propagate to existing projects automatically the way
everything else under `novaconium/` does.
**A real bug caught by testing, not review:** `hljs.highlightAll()`
doesn't defer itself if called while `document.readyState` is still
`"loading"` — it silently no-ops permanently rather than waiting and
retrying, confirmed with an actual DOM test (jsdom) before it was
noticed, not assumed safe just because the script tag sits near the end
of `<body>`. Fixed by wrapping the call in the same `DOMContentLoaded`
pattern `code-copy.twig` already uses. Verified end-to-end with a real
`highlight.js` execution against real rendered page HTML (not a hand
-rolled mock): correct language detection on real PHP/Bash blocks,
`nohighlight` blocks left untouched, and `code.textContent` (what the
copy-to-clipboard button reads) confirmed unchanged after `<span>`
-wrapping — the specific regression this entry flagged as a risk.
### 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:**
1. **Reentrancy** — the crawl renders every page, including `/search`
itself, whose own sidecar calls `ContentIndexer::ensureFresh()`;
without a guard this triggered a nested `reindex()` mid-transaction and
fataled on a second `PDO::beginTransaction()`. Fixed with a
`private static bool $indexing` guard checked at the top of both
`ensureFresh()` and `reindex()`.
2. **Wrong PDO constant** (`PDO::KEY_PAIR` instead of
`PDO::FETCH_KEY_PAIR`) in the search sidecar, caught immediately by
actually hitting `/search` with 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
@@ -607,44 +395,6 @@ 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
@@ -671,38 +421,6 @@ 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](https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/writeText)
(`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