diff --git a/.gitignore b/.gitignore index 2899117..20e923f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ /data/*.sqlite-wal /data/*.sqlite-shm .claude/ +/graphify-out/ diff --git a/novaconium/ISSUES.md b/novaconium/ISSUES.md index c8aa20d..f560926 100644 --- a/novaconium/ISSUES.md +++ b/novaconium/ISSUES.md @@ -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 `
` 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 `` 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 ``
-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 ``. 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 ``
--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. ``/``
-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); `` 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, `` 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 ``) — the root layout had no open-ended "extra head
-content" extension point before this; `App/pages/blog/_layout/layout.twig`
-overrides it with the ``, 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: ``) 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 `` 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 `` 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 ``
-containing a `` 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 `` 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