light housekeeping
This commit is contained in:
@@ -6,3 +6,4 @@
|
||||
/data/*.sqlite-wal
|
||||
/data/*.sqlite-shm
|
||||
.claude/
|
||||
/graphify-out/
|
||||
|
||||
+10
-292
@@ -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. `<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
|
||||
|
||||
Reference in New Issue
Block a user