Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0455241ea | |||
| a0ae58c29e | |||
| 6624c4fdc3 | |||
| f6fb2f12c6 |
@@ -0,0 +1,12 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
graphify-out/
|
||||||
|
.claude/
|
||||||
|
public/cache/*
|
||||||
|
!public/cache/.gitkeep
|
||||||
|
data/*.sqlite*
|
||||||
|
images/*
|
||||||
|
!images/.gitkeep
|
||||||
|
docker-compose.yml
|
||||||
|
Dockerfile
|
||||||
|
Dockerfile.sass
|
||||||
@@ -6,3 +6,6 @@
|
|||||||
/data/*.sqlite-wal
|
/data/*.sqlite-wal
|
||||||
/data/*.sqlite-shm
|
/data/*.sqlite-shm
|
||||||
.claude/
|
.claude/
|
||||||
|
/graphify-out/
|
||||||
|
/public/uploads/*
|
||||||
|
!/public/uploads/.gitkeep
|
||||||
|
|||||||
@@ -1,45 +1,42 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
Context for any coding agent working in this repo — Claude, DeepSeek, or
|
Context for any coding agent working in this repo — Claude, DeepSeek, or
|
||||||
otherwise; this file (and the maintenance rule below) applies regardless of
|
otherwise. Full narrative docs live at `/admin/docs` when the app is
|
||||||
which model or CLI is driving. Full narrative docs live at `/admin/docs`
|
running. `README.md` is the GitHub-facing pitch, `novaconium/ISSUES.md` is
|
||||||
when the app is running (also the *only* place Twig upgrade instructions
|
the roadmap/backlog, and this file is the short, agent-facing version:
|
||||||
live now — see `/admin/docs/upgrading-twig`; there's no separate
|
load-bearing gotchas and conventions only, not narrative history.
|
||||||
MAINTENANCE.md, keeping one copy in the docs page avoids drift). `README.md`
|
|
||||||
is the GitHub-facing pitch, `novaconium/ISSUES.md` is the roadmap/backlog,
|
|
||||||
and this file is the short, agent-facing version. The original design
|
|
||||||
rationale used to live in a standalone `plan.md`; it's now folded into
|
|
||||||
`/admin/docs/design-notes` (everything in it shipped) and the file was
|
|
||||||
deleted. There used to also be a
|
|
||||||
`GUIDE.md` mirroring `/admin/docs` for offline reading — it was removed to
|
|
||||||
cut a doc copy that had to be kept in sync; `/admin/docs` is the only
|
|
||||||
narrative reference now.
|
|
||||||
|
|
||||||
## Documentation is duplicated on purpose — keep all copies in sync
|
**This repo has a graphify knowledge graph (`graphify-out/`).** For design
|
||||||
|
rationale, "why was it built this way," or exploring how components relate,
|
||||||
|
query the graph instead of expecting this file to carry that context — this
|
||||||
|
file is kept intentionally short and only lists things that will cause a
|
||||||
|
bug or a broken convention if you don't know them going in.
|
||||||
|
|
||||||
|
## Docs live in one place: `/admin/docs` — README stays thin
|
||||||
|
|
||||||
Every topic (routing, sidecars, libraries, layouts, caching, SEO, Matomo,
|
Every topic (routing, sidecars, libraries, layouts, caching, SEO, Matomo,
|
||||||
admin authentication, styling, project layout, third-party) exists in
|
admin auth, styling, Docker, project layout, third-party) has exactly one
|
||||||
**two** places: a page under `novaconium/pages/admin/docs/<topic>/index.twig`
|
canonical writeup: a page under `novaconium/pages/admin/docs/<topic>/index.twig`.
|
||||||
(the canonical reference), and (for anything a README-reading human needs
|
`README.md` deliberately does **not** mirror this content — it's a short
|
||||||
up front) a mention in `README.md`. This is intentional — `/admin/docs` is
|
GitHub-facing pitch (what this is, minimal steps to get it running, a
|
||||||
for reading against a running instance with no internet needed, and
|
pointer into `/admin/docs`) plus the Third-party section, nothing more. The
|
||||||
`README.md` is the GitHub-facing pitch — but it means **any agent that
|
full feature list lives as a blog post, `App/pages/blog/novaconium-features/`
|
||||||
changes framework behavior or adds a feature must update both copies in
|
(sample content, replaceable like any other post), not in the README. This
|
||||||
the same change**, not just the one that was open. Concretely, after
|
was a deliberate change (2026-07-15) away from an earlier "keep README and
|
||||||
touching routing/rendering/caching/SEO behavior or adding a new top-level
|
docs in sync" convention that had made the README long and hard to scan —
|
||||||
docs topic:
|
don't re-add a feature list or per-topic bullet list to README.md.
|
||||||
|
|
||||||
1. Update (or add) the matching page under
|
Any change to framework behavior or a new feature:
|
||||||
`novaconium/pages/admin/docs/<topic>/index.twig`, and if it's a new
|
|
||||||
topic, link it from both `admin/docs/index.twig` and the nav in
|
|
||||||
`admin/docs/_layout/layout.twig`.
|
|
||||||
2. Update `README.md` if the change affects the feature list, getting
|
|
||||||
started steps, or the docs index there.
|
|
||||||
3. Update this file if the change affects a convention an agent needs to
|
|
||||||
know before editing code (not just narrative docs).
|
|
||||||
|
|
||||||
A doc change that only touches one of these copies is incomplete —
|
1. Update/add the docs page, and if new, link it from both
|
||||||
verify the other copy before considering the task done.
|
`admin/docs/index.twig` and the nav in `admin/docs/_layout/layout.twig`.
|
||||||
|
2. Update `App/pages/blog/novaconium-features/index.twig` (and its entry in
|
||||||
|
`App/pages/blog/index.php`) if it affects the feature tour.
|
||||||
|
3. Update `README.md` only if it affects the one-paragraph pitch, the
|
||||||
|
minimal getting-started steps, or the Third-party section — not a
|
||||||
|
per-feature bullet.
|
||||||
|
4. Update this file only if it affects a convention an agent needs to know
|
||||||
|
before editing code.
|
||||||
|
|
||||||
## What this is
|
## What this is
|
||||||
|
|
||||||
@@ -50,375 +47,122 @@ pages get pre-rendered to static HTML on first request and served straight
|
|||||||
from Apache after that. No Composer, no build step to install — Twig is
|
from Apache after that. No Composer, no build step to install — Twig is
|
||||||
vendored as plain source files.
|
vendored as plain source files.
|
||||||
|
|
||||||
## The two-root split — read this before editing anything under `pages/` or `lib/`
|
## The two-root split
|
||||||
|
|
||||||
Everything lives in one of two places:
|
- **`App/`** — the project: `App/pages/`, `App/lib/` (`Lib\` classes),
|
||||||
|
`App/config.php`, `App/migrations/`, `App/sass/`. The only directory a
|
||||||
- **`App/`** — the actual project: `App/pages/` (routes/content) and
|
|
||||||
`App/lib/` (project's own `Lib\` classes). This is the only directory a
|
|
||||||
site author is expected to touch.
|
site author is expected to touch.
|
||||||
- **`novaconium/`** — the framework itself: router/renderer core
|
- **`novaconium/`** — the framework: router/renderer core
|
||||||
(`novaconium/src/`), default pages (`novaconium/pages/` — root layout,
|
(`novaconium/src/`), default pages/libs, vendored Twig, autoloader,
|
||||||
404, the `/admin` tools), default `Lib\` classes (`novaconium/lib/`),
|
config, bootstrap.
|
||||||
vendored Twig, autoloader, config, bootstrap.
|
|
||||||
|
|
||||||
Routing and rendering resolve against **both roots, in order** —
|
Routing/rendering resolve against **both roots, `App/` first** (via
|
||||||
`App/pages/` first, `novaconium/pages/` as fallback — via
|
`novaconium/src/Overlay.php` for pages, `novaconium/autoload.php` for
|
||||||
`novaconium/src/Overlay.php`. Same mechanism for `Lib\` classes:
|
`Lib\` classes) — same override-by-presence mechanism used for
|
||||||
`App/lib/` is checked before `novaconium/lib/` in `novaconium/autoload.php`.
|
`config.php`, Twig's `FilesystemLoader`, and Sass (see below). A project
|
||||||
Concretely: dropping a file at the same relative path in `App/` overrides
|
only lists the config keys it's changing in `App/config.php`; never edit
|
||||||
the `novaconium/` default; nothing needs to be duplicated for the site to
|
`novaconium/config.php` directly.
|
||||||
work, since `novaconium/pages/` already supplies a working layout and 404.
|
|
||||||
|
|
||||||
Twig's `FilesystemLoader` is constructed with both paths as an array, so
|
**`db_connections` is the one config key that isn't a plain shallow-merge.**
|
||||||
`{% extends %}` / `{% include %}` get this override-then-fallback
|
`Lib\Db::config()` (and the duplicate in `bin/migrate.php`) merges it one
|
||||||
resolution for free — no custom logic needed there.
|
level deeper, by connection name, so adding a second connection in
|
||||||
|
`App/config.php` can't silently delete the framework's `default`
|
||||||
|
connection. Capture the defaults *before* the top-level `array_merge()`
|
||||||
|
overwrites `$config['db_connections']`, not after. See `/admin/docs/database`.
|
||||||
|
|
||||||
The same override-by-presence pattern applies to `novaconium/config.php`:
|
`Lib\Db` supports multiple, simultaneously-open named connections
|
||||||
if `App/config.php` exists, `novaconium/bootstrap.php` and
|
(`'sqlite'`/`'mysql'` drivers only). Each connection migrates lazily on
|
||||||
`novaconium/bin/clear-cache.php` shallow-merge it over the framework defaults
|
first use, tracked by path **relative to the repo root** (not bare
|
||||||
with `array_merge()`. A project only needs to list the keys it's changing
|
filename — two roots can share a filename). `migrations_dir` accepts an
|
||||||
— never edit `novaconium/config.php` directly.
|
ordered list of roots, each fully processed before the next.
|
||||||
|
|
||||||
`config['matomo_url']` / `config['matomo_site_id']` (both default `''`)
|
**The default DB path (`data/novaconium.sqlite`) lives outside `public/`
|
||||||
gate the Matomo tracking script emitted by the root layout — set both via
|
(web-accessible) and `novaconium/`** (wholesale-replaced by framework
|
||||||
`App/config.php` to enable it, since either being empty disables tracking
|
updates) — it's a project-owned top-level dir, gitignored per-content with
|
||||||
entirely. `bootstrap.php` normalizes a missing trailing slash on
|
a tracked `.gitkeep`. Uploaded files (see Media manager,
|
||||||
`matomo_url` before passing it to `Renderer`, which exposes `matomo_url`,
|
`/admin/docs/media-manager`) live under `public/uploads/` instead, since
|
||||||
`matomo_site_id`, and `is_404` as Twig globals (`is_404` is overridden to
|
they need to be web-reachable directly — a separate, plain static
|
||||||
`true` in the 404 template's local render context by
|
directory on its own volume, not coupled to the SQLite path, since a
|
||||||
`Renderer::renderNotFound()`, per Twig's local-context-over-global
|
project may run MySQL or no DB at all.
|
||||||
precedence). Any new Twig global added to `Renderer`'s constructor should
|
|
||||||
follow this same pattern: default value, `addGlobal()` call, documented
|
|
||||||
here and in `/admin/docs`.
|
|
||||||
|
|
||||||
`config['site_name']` (default `'My Site'`) is the same pattern — passed to
|
## Standing rule: caching vs. any content-hiding mechanism
|
||||||
`Renderer` and exposed as the `site_name` Twig global, used by
|
|
||||||
`novaconium/pages/_layout/layout.twig` for the default `title` block,
|
|
||||||
`og:site_name`, and the footer copyright line. Any other hardcoded
|
|
||||||
site-identity string that shows up in a shared template (as opposed to a
|
|
||||||
per-page override) should become a `config.php` key the same way, not stay
|
|
||||||
hardcoded in the template.
|
|
||||||
|
|
||||||
`config['admin_auth_enabled']` (default `false`) gates every `/admin/*`
|
**Any mechanism that conditionally hides page content from the public
|
||||||
route behind a session login against the `users` table on `Lib\Db`'s
|
must be threaded into `Renderer::render()`'s `$excludeFromCache` param, not
|
||||||
default connection (`novaconium/migrations/0002_create_users.sql`) — the
|
just a pre-render auth gate.** `Renderer::render()` writes a sidecar-less
|
||||||
multi-user system from `novaconium/ISSUES.md`'s "Admin login & user
|
page's output to the static HTML cache, and `.htaccess` serves a cached
|
||||||
management" entry, which **replaced** the old single-user HTTP Basic Auth
|
file *before PHP (and therefore any auth check) ever runs again*. A route
|
||||||
stopgap (the `admin_username`/`admin_password_hash` config keys and the
|
gated only at the auth-check level still leaks to the public the moment an
|
||||||
`/admin/password-hash` page are gone; that stopgap had itself replaced
|
authorized user views it once, if the page has no sidecar. `draft_routes`
|
||||||
the even older `docs_enabled` flag). Same off-by-default posture as
|
and every `/admin/*` route already pass `true` for this reason. Any new
|
||||||
`content_index_enabled`, for the same reason: it depends on SQLite, so
|
feature that gates a route by anything other than a sidecar check needs the
|
||||||
when the flag is false, `/admin/*` is wide open, the three auth routes
|
same treatment — this has caused a real bug before, twice.
|
||||||
(`/admin/login`, `/admin/logout`, `/admin/users`) 404 as if they didn't
|
|
||||||
exist, their sidecars check the flag (via the same self-loaded two-step
|
|
||||||
config read `/search` uses) *before* touching `Lib\Db`, and no
|
|
||||||
`data/novaconium.sqlite` is ever created by this feature — verified
|
|
||||||
end-to-end. **Two roles** (`users.role`): the **first user ever created
|
|
||||||
is `'admin'`; everyone created after is `'registered'`**, each with an
|
|
||||||
optional single group (`users.user_group`, a plain text label — no
|
|
||||||
groups table). Admins run the site: `/admin/*`, drafts, and every
|
|
||||||
`Lib\Access` rule passes for them. Registered users log in at the same
|
|
||||||
`/admin/login` and see whatever content `Lib\Access` (below) grants
|
|
||||||
their account or group — but `/admin/*` renders a plain 404 for them
|
|
||||||
(they're authenticated; what they lack is the role, so bouncing them to
|
|
||||||
the login form would be wrong). Unlike the Twig-global pattern above,
|
|
||||||
the gate itself is enforced in `bootstrap.php`, before rendering, in two
|
|
||||||
steps: `AdminAuth::requireLogin($config['admin_auth_enabled'])`
|
|
||||||
(`novaconium/src/AdminAuth.php`) redirects anyone not logged in, then
|
|
||||||
`AdminAuth::isAdmin(...)` 404s logged-in non-admins — for any resolved
|
|
||||||
route whose path is `admin` or starts with `admin/`, **except
|
|
||||||
`admin/login` itself, which must stay reachable logged-out or the
|
|
||||||
redirect to it would loop.** **Any new admin page dropped under
|
|
||||||
`App/pages/admin/` or `novaconium/pages/admin/` is automatically
|
|
||||||
protected — no per-page wiring needed.** Bootstrapping the first user:
|
|
||||||
while the `users` table is empty, the gate deliberately returns open
|
|
||||||
access so the first user (the admin) can be created at `/admin/users`
|
|
||||||
(which auto-logs its creator in, closing the gate), mirroring the old
|
|
||||||
"wide open until a password is configured" posture;
|
|
||||||
`novaconium/bin/create-admin-user.php` creates an admin from the CLI
|
|
||||||
instead (password via stdin), which lets a deploy create the user
|
|
||||||
*before* flipping the flag so the open window never exists — it's also
|
|
||||||
the lockout-recovery path (usage: `<username> <email>`, password via
|
|
||||||
stdin). Every account has a **unique email address** (`users.email`),
|
|
||||||
stored normalized via `Lib\Validate::isEmail()` (trim + lowercase) so
|
|
||||||
the planned email-verification feature (see `novaconium/ISSUES.md`
|
|
||||||
Backlog) can match case-insensitively — not used for login (username)
|
|
||||||
or any mail yet. `/admin/users` is the management UI (create — always
|
|
||||||
`'registered'` except the first / disable / enable / delete / change
|
|
||||||
group / promote-demote / change email / change password); a disabled
|
|
||||||
**or deleted** user fails login and any existing session dies on its
|
|
||||||
next request (`currentUser()` re-checks the row per request), and
|
|
||||||
disabling, demoting, *or deleting* the last **active admin** is refused
|
|
||||||
— the empty-table setup window doesn't reopen once users exist, so that
|
|
||||||
would be a permanent lockout. Delete is a hard delete (username/email
|
|
||||||
become reusable); disable is the keep-but-shut-out option. `/admin/login` accepts a
|
|
||||||
`?return=` path (how `Lib\Access` sends someone back to the gated page
|
|
||||||
after login), validated to a local path (must start `/`, not `//`, no
|
|
||||||
`\`) so a crafted login link can't bounce a fresh login to another
|
|
||||||
site; with no return path, admins land on `/admin`, registered users on
|
|
||||||
`/`. Login
|
|
||||||
regenerates the session id (`Lib\Session::regenerate()`, added for this)
|
|
||||||
against session fixation; logout is a real page now — the pre-router
|
|
||||||
`/admin/logout` special case in `bootstrap.php` is gone — and it is
|
|
||||||
**POST-only with a GET confirm form** (same shape as
|
|
||||||
`/admin/clear-cache`), not logout-on-GET: the content-index crawl runs
|
|
||||||
every page's sidecar as a GET, so a GET side effect there would end the
|
|
||||||
crawling admin's own session mid-reindex. Passwords are read from `$_POST` directly, not
|
|
||||||
`Lib\Input` (the documented exact-value exception — see `Lib\Input`'s
|
|
||||||
doc-comment, which now points at the login/users sidecars). `Renderer`
|
|
||||||
still exposes the `admin_auth_enabled` Twig global (now mirroring the
|
|
||||||
config flag) so `admin/index.twig` can conditionally show the
|
|
||||||
"Admin users"/"Logout" links — a derived display flag, not the
|
|
||||||
enforcement mechanism itself, which never depends on Twig.
|
|
||||||
|
|
||||||
`Lib\Db` (`novaconium/lib/Db.php`) is the SQLite/MySQL groundwork tracked
|
Corollary: `Lib\Access` (the sidecar-level content gate, see
|
||||||
in `novaconium/ISSUES.md` — a thin, no-ORM PDO wrapper, `Lib\` (not `App\`)
|
`/admin/docs/access-control`) is safe by construction — a page with no
|
||||||
so a project can override it via `App/lib/Db.php` like any other `Lib\`
|
sidecar can't call `Access`, and only sidecar-less pages get cached, so a
|
||||||
class. It supports multiple, independently-configured, **simultaneously
|
gated page can never leak through the cache with no extra wiring needed.
|
||||||
open** named connections (`config['db_connections']`, keyed by name) rather
|
|
||||||
than one global connection — because sidecars are plain PHP with full
|
|
||||||
access to any `Lib\` class, a single request can legitimately need more
|
|
||||||
than one database at once (e.g. this site's own SQLite data alongside a
|
|
||||||
MySQL connection to a legacy database). `Db::query(string $sql, array
|
|
||||||
$params = [], string $connection = 'default')` (prepare+execute) is the
|
|
||||||
only query-running helper — never add a string-interpolation shortcut; see
|
|
||||||
`Lib\Input`'s doc-comment, which already commits this project to
|
|
||||||
parameterized queries as the sole SQL-injection defense. Each connection is
|
|
||||||
lazy and independent (opened on first `Db::query()`/`Db::connection()` call
|
|
||||||
naming it, same shape as `Lib\Csrf`'s lazy session start), and migrates
|
|
||||||
automatically at that point: plain `.sql` files under that connection's own
|
|
||||||
`migrations_dir` — a single path, or (since the content index shipped) an
|
|
||||||
**ordered list of roots**, each root's own files filename-ordered and
|
|
||||||
roots processed fully in the order given, not interleaved by filename
|
|
||||||
across roots (so a framework root always finishes before a project root on
|
|
||||||
the same connection). Tracked by path **relative to the repo root** (e.g.
|
|
||||||
`novaconium/migrations/0001_x.sql`), not bare filename — two roots can
|
|
||||||
each contain a same-named file, and tracking by bare filename would make
|
|
||||||
the second one seen look "already applied" and silently skip it; a
|
|
||||||
repo-relative path is also portable across environments, unlike a full
|
|
||||||
absolute path, which would make every migration look new again after a
|
|
||||||
clone/deploy to a different directory. `realpath()` normalizes any `..` a
|
|
||||||
`migrations_dir` like `__DIR__ . '/../App/migrations'` would otherwise
|
|
||||||
leave in the tracked name. Each connection's own auto-created
|
|
||||||
`schema_migrations` table tracks its migrations independently of any other
|
|
||||||
connection's; each is only ever run once. `novaconium/bin/migrate.php`
|
|
||||||
loops every configured connection and runs the same migration step
|
|
||||||
explicitly (e.g. from a deploy script) without serving a request first.
|
|
||||||
Only `'sqlite'` and `'mysql'` drivers are implemented; a connection's
|
|
||||||
`migrations_dir` is optional — omit it to never run migrations against
|
|
||||||
that connection (e.g. a legacy database this project shouldn't manage
|
|
||||||
schema for).
|
|
||||||
|
|
||||||
**`db_connections` is the one config key in the project that isn't plain
|
## Reentrancy hazard: ContentIndexer
|
||||||
shallow-merge** — `bootstrap.php`/`bin/*.php`'s usual `array_merge($config,
|
|
||||||
$appConfig)` would let a project's `App/config.php` silently delete the
|
|
||||||
framework's `default` connection just by adding a second named connection
|
|
||||||
(a shallow merge replaces the whole key, it doesn't merge inside it). So
|
|
||||||
`Lib\Db::config()` (and the copy of this logic duplicated in
|
|
||||||
`bin/migrate.php`, same duplication precedent as the two-step config load
|
|
||||||
already duplicated across `bootstrap.php`/`bin/clear-cache.php`) merges
|
|
||||||
`db_connections` one level deeper, by connection name, **after** capturing
|
|
||||||
the framework defaults — capture the defaults *before* the top-level
|
|
||||||
`array_merge()` overwrites `$config['db_connections']`, not after, or the
|
|
||||||
deeper merge silently operates on the already-overwritten value and the
|
|
||||||
`default` connection vanishes anyway. (This exact bug was hit once while
|
|
||||||
building this feature — verified by testing a real `App/config.php`
|
|
||||||
override end-to-end, not just reading the code — so it's worth re-checking
|
|
||||||
by hand if this logic is ever touched again.) See
|
|
||||||
`/admin/docs/database` for the worked example.
|
|
||||||
|
|
||||||
**`config['db_connections']['default']['path']` must stay outside both
|
`ContentIndexer::reindex()` renders every routable page, including
|
||||||
`public/` (would be web-accessible) and `novaconium/`** — unlike
|
`/search` itself, which also calls `ContentIndexer::ensureFresh()`.
|
||||||
`cache_dir`/`contact-log.txt`, which are disposable, a SQLite file is data a
|
Guarded by a `private static bool $indexing` flag checked at the top of
|
||||||
project can't afford to lose, and `novaconium/` gets wholesale-replaced by
|
both methods — don't remove it, any new consumer route inherits the same
|
||||||
the "Updating the framework" workflow (`/admin/docs/getting-started`: `rm
|
hazard automatically. `reindex()` also forces
|
||||||
-rf novaconium && cp -r <new-novaconium>`). The default
|
`$_SERVER['REQUEST_METHOD']` to `'GET'` for the duration of the crawl
|
||||||
(`data/novaconium.sqlite`) lives in a new top-level `data/` directory
|
(restored in a `finally`) so a lazy reindex triggered from a POST can't
|
||||||
instead — project-owned like `App/`, gitignored per-file
|
leak that POST into an unrelated page's sidecar.
|
||||||
(`*.sqlite`/`-journal`/`-wal`/`-shm`, with a tracked `.gitkeep` so the
|
|
||||||
directory exists in a fresh clone) rather than wholesale like
|
|
||||||
`public/cache/`, since a project might reasonably want other non-DB files
|
|
||||||
there later. The default connection's `migrations_dir` scans
|
|
||||||
`novaconium/migrations/` (framework-shipped schema, e.g. the content
|
|
||||||
index below) before `App/migrations/` (project schema) — see the
|
|
||||||
`migrations_dir` array-form paragraph above. Any *other* connection a
|
|
||||||
project adds still defaults to no `migrations_dir` at all unless it sets
|
|
||||||
one; the two-root default is specific to `default`.
|
|
||||||
|
|
||||||
`Lib\Session` (`novaconium/lib/Session.php`) is a thin wrapper around
|
## Vendored dependency placement
|
||||||
native PHP sessions (`session_start()`/`$_SESSION`, not a custom store),
|
|
||||||
all-static and lazy-start like `Lib\Csrf` — nothing calls `session_start()`
|
|
||||||
until the first real call to a `Session` method. Its `ensureSession()` is a
|
|
||||||
**deliberate duplicate** of `Csrf::ensureSession()` (same cookie params,
|
|
||||||
same `session_status()` guard) rather than a shared helper — keeps `Csrf`
|
|
||||||
standalone with zero new dependencies on a class that didn't exist when it
|
|
||||||
shipped, same tolerance for small duplication already established by the
|
|
||||||
config-load block duplicated across `bootstrap.php`/`bin/clear-cache.php`/
|
|
||||||
`Lib\Db::config()`. Both classes touching the same native session in the
|
|
||||||
same request is safe either way, since `session_start()` silently no-ops
|
|
||||||
if a session is already active — there's no ordering requirement between
|
|
||||||
`Csrf::token()`/`::verify()` and any `Session` method.
|
|
||||||
|
|
||||||
Flash data (`Session::flash()`/`::getFlash()`) is one swap, not a
|
**Server-side-only (PHP, autoloaded) → `novaconium/vendor/`. Anything a
|
||||||
sweep/expiry pass: the first `Session` method call in a request snapshots
|
browser fetches (`.js`, `.css`, images) → `public/vendor/`** — `novaconium/`
|
||||||
whatever was flashed on the *previous* request into an in-memory static
|
is never web-reachable. This matters beyond correctness: `public/` is
|
||||||
(`self::$currentFlash`) for that request's `getFlash()` reads, then
|
project-owned and untouched by a framework update, so a `public/vendor/`
|
||||||
immediately empties the stored flash bucket so `flash()` calls made
|
dependency bump does **not** propagate automatically the way a
|
||||||
*during* the current request start filling a fresh bucket for the request
|
`novaconium/vendor/` bump would — re-vendoring is a manual step per
|
||||||
after this one. This relies on static properties not persisting across
|
dependency (see `/admin/docs/upgrading-highlightjs`).
|
||||||
requests (true under `php -S`, mod_php, and PHP-FPM alike — each request
|
|
||||||
gets fresh PHP state regardless of worker-process reuse) — don't add any
|
|
||||||
caching/memoization to `Session` that assumes static state survives
|
|
||||||
between requests, since none of it does. See `/admin/docs/session` for a
|
|
||||||
worked flash example.
|
|
||||||
|
|
||||||
**Standing rule: any mechanism that conditionally hides page content from
|
## Twig gotchas that will fatal without `mbstring`
|
||||||
the public must also be threaded into `Renderer::render()`'s
|
|
||||||
`$excludeFromCache` decision, not just a pre-render auth gate.** This
|
|
||||||
bit twice already — once as a designed-around gotcha (draft pages), once
|
|
||||||
as a real pre-existing bug found while testing that feature (`/admin/*`
|
|
||||||
itself). The reason: `Renderer::render()` writes a sidecar-less page's
|
|
||||||
output to the static HTML cache (`novaconium/src/Cache.php`), and
|
|
||||||
`.htaccess` serves a cached file *before PHP, and therefore any auth
|
|
||||||
check, ever runs again* (see `/admin/docs/caching`). A route can be
|
|
||||||
gated by `AdminAuth::requireLogin()`/`::isAuthenticated()` and still leak
|
|
||||||
completely to the public the moment it's viewed once by someone
|
|
||||||
authorized, if the page has no sidecar and nothing tells `Renderer` to
|
|
||||||
skip the cache write for that route. `draft_routes` (see
|
|
||||||
`/admin/docs/drafts`, `novaconium/config.php`) and every `/admin/*` route
|
|
||||||
both pass `true` for `Renderer::render()`'s `$excludeFromCache` param
|
|
||||||
from `novaconium/bootstrap.php` for exactly this reason — most pages
|
|
||||||
under `novaconium/pages/admin/` (e.g. `admin/index.twig`) have no
|
|
||||||
sidecar, so before this was wired up, visiting `/admin` once as an
|
|
||||||
authenticated admin would cache the admin panel and serve it to every
|
|
||||||
subsequent visitor, unauthenticated, straight from `public/cache/admin/`.
|
|
||||||
Any future feature that gates a route by anything other than a sidecar
|
|
||||||
check (paywall content is the next one on the roadmap likely to hit this)
|
|
||||||
needs to make the same check here, not just at the point where the
|
|
||||||
request is first authorized.
|
|
||||||
|
|
||||||
`AdminAuth::isAdmin(bool $enabled): bool` / `::isLoggedIn(bool
|
Don't use `|slice` on a **string** (calls `mb_substr()` unconditionally) or
|
||||||
$enabled): bool` (`novaconium/src/AdminAuth.php`) are the access checks
|
`|escape('js')`/`'js'` arg to `|e` (calls `mb_ord()`) — both hard-require
|
||||||
on their own, with no response side effects — `requireLogin()` is
|
`mbstring` and fatal without it; this project deliberately avoids that
|
||||||
`isLoggedIn()` plus a 303 redirect to `/admin/login` on failure, and a
|
dependency. Truncate strings in PHP with an `mb_substr`/`substr` fallback
|
||||||
different caller can react to failure differently. The draft-page gate
|
instead. For markup destined for inline `<script>`, render into a
|
||||||
in `bootstrap.php` is the first such caller, and it uses `isAdmin()`
|
`<template>` element and read `.innerHTML` in JS rather than
|
||||||
(drafts are admin-only — a logged-in registered user gets the same 404
|
`|escape('js')`.
|
||||||
as an anonymous visitor): on failure it renders a plain 404 via the same
|
|
||||||
path an unmatched route takes, not a login redirect — bouncing to a
|
|
||||||
login at a draft URL would itself reveal that something is gated there,
|
|
||||||
which defeats the point of hiding it. Both return `true` (open access)
|
|
||||||
when `$enabled` is false or while the `users` table is empty, mirroring
|
|
||||||
`requireLogin()`'s posture, so a draft behaves consistently with the
|
|
||||||
rest of `/admin/*`: wide open until the feature is enabled and a first
|
|
||||||
user exists, gated after that.
|
|
||||||
|
|
||||||
`Lib\Access` (`novaconium/lib/Access.php`) is the sidecar-level content
|
`class="nohighlight"` marks a `<pre><code>` block containing literal Twig
|
||||||
gate — how a page (or a section, one line per page; a shared
|
syntax (`{% %}`/`{{ }}`) — highlight.js has no Twig grammar and a
|
||||||
`_access.php` in the section directory is the documented pattern, since
|
restricted auto-detect still always guesses wrong without this class. Any
|
||||||
non-`index.*` files are invisible to the router) is assigned to a user
|
new Twig-syntax code sample needs it; PHP/Bash samples don't.
|
||||||
or group: `Access::require('group:members', 'user:bob')` returns `null`
|
|
||||||
(allowed — no rules at all means any logged-in user, and **admins pass
|
|
||||||
every rule**) or a `Response` the sidecar returns as-is (anonymous → 303
|
|
||||||
to `/admin/login?return=<path>`; logged-in-but-not-allowed → plain-text
|
|
||||||
404, same hide-don't-tease posture as drafts). See
|
|
||||||
`/admin/docs/access-control`. **Public is the default, and static pages
|
|
||||||
are always public**: a page with no sidecar can't call `Access` — and
|
|
||||||
that's load-bearing, since only sidecar-less pages are written to the
|
|
||||||
static HTML cache; a gated page necessarily has a sidecar, so it can
|
|
||||||
never leak through the cache — the caching/auth standing rule below is
|
|
||||||
satisfied by construction, with no bootstrap exclusion needed. Same
|
|
||||||
open-until-configured / zero-DB-footprint posture as the rest of admin
|
|
||||||
auth when the flag is off or no users exist. Deliberately **no side
|
|
||||||
effects on deny** (the return path travels in the redirect URL, not the
|
|
||||||
session): the content-index crawl runs every sidecar as an anonymous
|
|
||||||
GET, so gated pages drop out of `/search`/`/sitemap.xml` automatically
|
|
||||||
(the crawler discards `Response`s) and a crawl must never scribble on
|
|
||||||
the visiting user's session — the same reasoning that made
|
|
||||||
`/admin/logout` POST-only.
|
|
||||||
|
|
||||||
`App\ContentIndexer` (`novaconium/src/ContentIndexer.php`) is the shared
|
## Sass override quirk
|
||||||
crawler behind `/sitemap.xml`, `/search`, and blog tag browsing (see
|
|
||||||
`/admin/docs/content-index`) — `App\`, not `Lib\`, since it's rendering
|
|
||||||
infrastructure akin to `Renderer`/`Router`, not project-overridable
|
|
||||||
content. Content stays in files; only metadata is indexed. Per-page
|
|
||||||
metadata is four Twig blocks declared in the root layout next to the SEO
|
|
||||||
blocks (`keywords`, `tags`, `changefreq`, `priority` — the last three
|
|
||||||
never rendered into the page, only harvested) and pulled via
|
|
||||||
`Renderer::renderForIndex()`, which calls Twig's own
|
|
||||||
`TemplateWrapper::renderBlock()` per block rather than parsing `.twig`
|
|
||||||
source — this is deliberate: it gets App-over-novaconium override and
|
|
||||||
layout-inheritance resolution for free, the same way a real render does.
|
|
||||||
**`config['content_index_enabled']` defaults to `false`** — same posture
|
|
||||||
as `matomo_url`/`admin_password_hash`, since this is a real SQLite
|
|
||||||
dependency plenty of sites won't want. Every consumer route checks the
|
|
||||||
flag *before* touching `Lib\Db` and 404s if it's off, so the feature has
|
|
||||||
zero filesystem footprint (no `data/novaconium.sqlite`) when disabled —
|
|
||||||
verified end-to-end, not assumed, since "off" silently still creating a
|
|
||||||
database file would defeat the point.
|
|
||||||
|
|
||||||
**Reentrancy hazard, already hit once:** `ContentIndexer::reindex()`
|
`novaconium/sass/main.sass` does `@use 'colors' as *` with **no**
|
||||||
renders every routable page as part of the crawl — including `/search`
|
`_colors.sass` sibling in `novaconium/sass/` — on purpose. Dart Sass
|
||||||
itself, which is also a real page and also calls
|
resolves a bare `@use` relative to the importing file's own directory
|
||||||
`ContentIndexer::ensureFresh()` from its own sidecar. Without a guard,
|
*before* `--load-path`, so a sibling file would always win and silently
|
||||||
crawling `/search` would trigger a nested `reindex()` call mid-transaction
|
defeat the `App/sass/_colors.sass` override. The framework default lives
|
||||||
and fatal on a second `PDO::beginTransaction()`. `ContentIndexer` guards
|
at `novaconium/sass/defaults/_colors.sass` instead. Don't move it back.
|
||||||
this with a `private static bool $indexing` flag, checked at the top of
|
|
||||||
both `ensureFresh()` and `reindex()` — either no-ops while a reindex is
|
|
||||||
already running on the call stack. Any future consumer route added under
|
|
||||||
this mechanism inherits the same hazard for free (it'll also get crawled,
|
|
||||||
and if its sidecar also calls `ensureFresh()`, the guard already covers
|
|
||||||
it) — don't remove the flag thinking it's unnecessary.
|
|
||||||
|
|
||||||
`reindex()` also forces `$_SERVER['REQUEST_METHOD']` to `'GET'` for the
|
Every color rule in `main.sass` reads a CSS custom property (`var(--bg)`,
|
||||||
duration of the crawl (restoring whatever it was before, in a `finally`)
|
etc.), never a Sass variable directly — required for the runtime dark/light
|
||||||
— sidecars are expected to be side-effect-free for non-POST requests
|
toggle. Adding a color means adding both the plain and `-light` variable in
|
||||||
anyway (ordinary HTTP-safe-method hygiene), but this guarantees a lazy
|
**both** `_colors.sass` files and wiring it into both `:root` blocks.
|
||||||
reindex triggered from within a POST request can never leak that POST
|
|
||||||
into an unrelated page's sidecar purely because the crawler happened to
|
|
||||||
render it. A crawl is a full truncate-and-rebuild inside one transaction,
|
|
||||||
not incremental — simple and correct at this site's scale; don't add
|
|
||||||
incremental/diffing logic without a real need for it.
|
|
||||||
|
|
||||||
**Standing rule: a vendored dependency's files go under `novaconium/vendor/`
|
## Input handling
|
||||||
only if they're server-side (PHP, autoloaded, never fetched by a browser)
|
|
||||||
— anything the browser has to fetch (`.js`, `.css`, images) has to live
|
|
||||||
under `public/vendor/` instead, since `public/` is the only web-reachable
|
|
||||||
directory (`novaconium/` isn't reachable at all — see `public/.htaccess`).**
|
|
||||||
Twig lives under `novaconium/vendor/twig/` correctly, since it's pure PHP
|
|
||||||
source. highlight.js (`/admin/docs/upgrading-highlightjs`,
|
|
||||||
`public/vendor/highlightjs/`) is the first vendored dependency that's
|
|
||||||
actually browser-servable, and originally almost got vendored to
|
|
||||||
`novaconium/vendor/` too, following Twig's precedent blindly — that would
|
|
||||||
have silently 404ed on every request, since nothing under `novaconium/`
|
|
||||||
is ever served to a browser. This has a real consequence beyond just
|
|
||||||
placement: `public/` is project-owned and untouched by the "Updating the
|
|
||||||
framework" workflow (`rm -rf novaconium && cp -r <new-novaconium>` — see
|
|
||||||
`/admin/docs/getting-started`), so a future framework release that bumps
|
|
||||||
a `public/vendor/`-placed dependency will **not** carry that upgrade to
|
|
||||||
an existing project automatically the way a `novaconium/vendor/` bump
|
|
||||||
would — re-vendoring it is a separate manual step every time, documented
|
|
||||||
per-dependency (see `/admin/docs/upgrading-highlightjs`).
|
|
||||||
|
|
||||||
**`class="nohighlight"` marks a `<pre><code>` block whose content is
|
Sidecars read request data via `Lib\Input::post()`/`::get()`, not
|
||||||
literal Twig template syntax** (`{% %}`/`{{ }}`), so highlight.js's
|
`$_POST`/`$_GET` directly (trims, strips tags/null bytes — XSS
|
||||||
auto-detection (`novaconium/pages/_layout/syntax-highlight.twig`,
|
defense-in-depth, **not** SQL-injection protection; use PDO prepared
|
||||||
restricted to `configure({ languages: ['php', 'bash', 'xml', 'css',
|
statements via `Lib\Db::query()` for that, never string-interpolated SQL).
|
||||||
'python', 'javascript', 'yaml', 'json', 'ini'] })` — `yaml`/`json`/`ini`
|
Exception: fields needing an exact unmodified value (e.g. a password about
|
||||||
are vendored as separate per-language files under
|
to be hashed) read `$_POST` directly — see login/users sidecars.
|
||||||
`public/vendor/highlightjs/languages/`, not part of the core bundle like
|
`Lib\Csrf::verify()` is called directly by a sidecar, not wired into
|
||||||
the other six; see `/admin/docs/upgrading-highlightjs`) doesn't
|
`FormValidator`.
|
||||||
force-match it to whichever configured language scores highest — Twig has
|
|
||||||
no highlight.js grammar, and a restricted auto-detect still always
|
|
||||||
returns its best guess among the allowed set, never "give up," so an
|
|
||||||
unmarked Twig block would get colored *wrong*, not just left plain.
|
|
||||||
Currently on:
|
|
||||||
`novaconium/pages/admin/docs/{layouts,content-index,rss,sitemap,forms,seo}/index.twig`
|
|
||||||
and `App/pages/blog/{style-guide,twig-syntax-guide}/index.twig`. A new
|
|
||||||
Twig-syntax code sample added anywhere needs the same class — a PHP or
|
|
||||||
Bash sample doesn't (auto-detection handles those reliably on its own,
|
|
||||||
via strong signals like a leading `<?php`).
|
|
||||||
|
|
||||||
## Running it
|
## Running it
|
||||||
|
|
||||||
@@ -428,118 +172,26 @@ php -S 127.0.0.1:8000 -t public public/router.php
|
|||||||
|
|
||||||
`public/router.php` is dev-only, mimics `public/.htaccess`. There is no
|
`public/router.php` is dev-only, mimics `public/.htaccess`. There is no
|
||||||
test suite — verification is manual route-by-route (see
|
test suite — verification is manual route-by-route (see
|
||||||
`/admin/docs/design-notes`'s Verification section for the checklist used
|
`/admin/docs/design-notes`'s Verification section). After testing, clear
|
||||||
after any framework change).
|
stray cache with `php novaconium/bin/clear-cache.php` and remove any
|
||||||
After testing, clear stray cache with `php novaconium/bin/clear-cache.php` or
|
test-only debris from `App/lib/`/`App/pages/` — nothing there is gitignored
|
||||||
POST `/admin/clear-cache`, and remove anything written to `App/lib/` or
|
except `public/cache/*` and `novaconium/contact-log.txt`.
|
||||||
`App/pages/` that was only for testing an override — nothing here is
|
|
||||||
gitignored except `public/cache/*` and `novaconium/contact-log.txt`, so
|
|
||||||
test debris left in `App/` will otherwise get committed or silently change
|
|
||||||
site behavior for the next person.
|
|
||||||
|
|
||||||
## Conventions worth knowing
|
## Conventions worth knowing
|
||||||
|
|
||||||
- Reserved segments: any path segment starting with `_` (e.g. `_layout/`)
|
- Reserved segments: any path segment starting with `_` or literally named
|
||||||
or literally named `404` is never routable — `Router::resolve()` 404s on
|
`404` is never routable — `Router::resolve()` 404s on sight.
|
||||||
sight, don't try to serve content directly at those paths.
|
- Sidecars (`index.php`) return an array (Twig context) or a `Response`.
|
||||||
- Sidecars (`index.php`) return either an array (Twig context) or a
|
`$params` and `$cache` are in scope automatically — see
|
||||||
`Response` (redirect/json/xml/html — `novaconium/src/Response.php`).
|
|
||||||
`$params` (route captures) and `$cache` (the `Cache` instance, e.g. for
|
|
||||||
`$cache->clear()`) are both in scope automatically — see
|
|
||||||
`novaconium/src/Renderer.php::runSidecar()`.
|
`novaconium/src/Renderer.php::runSidecar()`.
|
||||||
- No Composer — `novaconium/autoload.php` is a hand-rolled PSR-4 loader.
|
- No Composer — `novaconium/autoload.php` is a hand-rolled PSR-4 loader. A
|
||||||
Adding a new framework-core class means adding it under `App\` in
|
new framework-core class goes under `App\` in `novaconium/src/`; a new
|
||||||
`novaconium/src/`; a new `Lib\` class goes in `App/lib/` or
|
`Lib\` class goes in `App/lib/` or `novaconium/lib/`.
|
||||||
`novaconium/lib/` depending on whether it's project- or
|
- `novaconium/bin/` holds standalone CLI entry points
|
||||||
framework-specific.
|
(`php novaconium/bin/<script>.php`) — distinct from
|
||||||
- `novaconium/bin/` holds standalone CLI entry points meant to be run
|
`bootstrap.php`/`autoload.php`/`config.php`, which are only `require`'d.
|
||||||
directly (`php novaconium/bin/<script>.php`) — distinct from
|
- CSS compiles from `novaconium/sass/main.sass` (indented syntax) to
|
||||||
`bootstrap.php`/`autoload.php`/`config.php`, which are only ever
|
`public/css/main.css`:
|
||||||
`require`'d, never invoked directly. `clear-cache.php` and
|
|
||||||
`create-static-page.php` (scaffolds a new page from the `/admin/docs/seo`
|
|
||||||
starter template) both live there; a new CLI tool goes there too.
|
|
||||||
- CSS is compiled from `novaconium/sass/main.sass` (indented syntax) to
|
|
||||||
`public/css/main.css`. `dart-sass` is installed in this environment
|
|
||||||
(Arch: `pacman -S dart-sass`) — after editing Sass source, run:
|
|
||||||
`sass --load-path=App/sass --load-path=novaconium/sass/defaults --no-source-map novaconium/sass/main.sass public/css/main.css`
|
`sass --load-path=App/sass --load-path=novaconium/sass/defaults --no-source-map novaconium/sass/main.sass public/css/main.css`
|
||||||
and commit the regenerated `public/css/main.css` (`--no-source-map`
|
— commit the regenerated CSS. See `/admin/docs/styling` for a Docker
|
||||||
avoids a stray `main.css.map` the project doesn't otherwise use). If
|
fallback if `sass` isn't installed locally.
|
||||||
`sass` isn't available in whatever environment you're in, either run it
|
|
||||||
via Docker — `/admin/docs/styling` has a copy-pasteable
|
|
||||||
Dockerfile that installs the same standalone Dart Sass release used in
|
|
||||||
this environment (`1.101.0`) directly from GitHub, not via npm, plus
|
|
||||||
the `docker build`/`docker run` commands adjusted to this repo's paths
|
|
||||||
— or hand-edit both files in parallel and keep them in sync — that's
|
|
||||||
how the dark/teal theme and the homepage hero/animation
|
|
||||||
styling were originally written before `sass` was installed here.
|
|
||||||
- The Sass color palette follows the same App-over-novaconium override
|
|
||||||
pattern as pages/lib, but with a twist worth understanding before
|
|
||||||
touching it: `novaconium/sass/main.sass` does `@use 'colors' as *`, and
|
|
||||||
its own directory (`novaconium/sass/`) deliberately has **no**
|
|
||||||
`_colors.sass` sibling. Dart Sass resolves a bare `@use` relative to the
|
|
||||||
importing file's own directory *before* consulting `--load-path`
|
|
||||||
entries, so if `novaconium/sass/_colors.sass` existed next to
|
|
||||||
`main.sass`, it would always win regardless of load-path order —
|
|
||||||
silently defeating the override. Keeping the framework default at
|
|
||||||
`novaconium/sass/defaults/_colors.sass` (a different directory) forces
|
|
||||||
resolution through the load path, where `App/sass` (checked first) can
|
|
||||||
actually override it with `App/sass/_colors.sass`. Don't move
|
|
||||||
`defaults/_colors.sass` back next to `main.sass` — it was moved out on
|
|
||||||
purpose, and doing so reintroduces this bug.
|
|
||||||
- Every color rule in `main.sass` reads a CSS custom property
|
|
||||||
(`var(--bg)`, `var(--accent)`, etc.), never a Sass variable directly —
|
|
||||||
that indirection is what makes the dark/light theme toggle possible,
|
|
||||||
since Sass only runs at compile time and can't react to a runtime
|
|
||||||
choice on its own. The two `_colors.sass` files seed `:root` (dark,
|
|
||||||
the default) and `:root[data-theme="light"]` (via `-light`-suffixed
|
|
||||||
variables — `$bg-light`, `$accent-light`, etc., same files, same
|
|
||||||
override mechanism) once at compile time; the toggle button in
|
|
||||||
`novaconium/pages/_layout/nav.twig` flips the `data-theme` attribute on
|
|
||||||
`<html>` at runtime and persists it to `localStorage`.
|
|
||||||
`novaconium/pages/_layout/theme-init.twig` re-applies a saved choice
|
|
||||||
early in `<head>` (before the stylesheet link) to avoid a flash of the
|
|
||||||
wrong theme on load. If you add a new color to the palette, add both
|
|
||||||
the plain and `-light` variable in **both** `_colors.sass` files and
|
|
||||||
wire it into both `:root` blocks in `main.sass` — a color that's only
|
|
||||||
themed in one direction will look wrong after a toggle.
|
|
||||||
- Sidecars should read request data via `Lib\Input::post()`/`::get()`
|
|
||||||
(`novaconium/lib/Input.php`) rather than `$_POST`/`$_GET` directly — it
|
|
||||||
trims, strips tags, and strips null bytes automatically. This is
|
|
||||||
defense-in-depth against HTML/script injection, **not** SQL-injection
|
|
||||||
protection (no string transform makes input safe to concatenate into a
|
|
||||||
query — use PDO prepared statements once a database layer exists); don't
|
|
||||||
add an `sqlSafe()`-style method to `Input`. One documented exception: a
|
|
||||||
field needing an exact, unmodified value (e.g. a password about to be
|
|
||||||
hashed or verified) should read `$_POST` directly instead — see the
|
|
||||||
password fields in `novaconium/pages/admin/users/index.php` and
|
|
||||||
`novaconium/pages/admin/login/index.php`. `Lib\Csrf`
|
|
||||||
(`novaconium/lib/Csrf.php`) is standalone session-token CSRF protection, not
|
|
||||||
wired into `FormValidator` — a sidecar calls `Csrf::verify()` directly.
|
|
||||||
It was the first thing in the framework to start a native PHP session
|
|
||||||
(only lazily, when a form actually calls it); `Lib\Session` and
|
|
||||||
`AdminAuth`'s session login now share that same native session, safely
|
|
||||||
in any order.
|
|
||||||
- Don't use Twig's `|slice` filter on a **string** (as opposed to an
|
|
||||||
array) — it unconditionally calls PHP's `mb_substr()` with no fallback
|
|
||||||
(`novaconium/vendor/twig/src/Extension/CoreExtension.php`), which
|
|
||||||
hard-requires the `mbstring` extension and will fatal
|
|
||||||
(`Call to undefined function Twig\Extension\mb_substr()`) on a PHP
|
|
||||||
install without it — a real regression this project hit once already,
|
|
||||||
back when `/blog/hello-world` had a sidecar computing an excerpt this
|
|
||||||
way (see the footnote on `App/pages/blog/twig-syntax-guide/index.twig`
|
|
||||||
for the full story). Truncate strings in PHP instead, guarded with
|
|
||||||
`function_exists('mb_substr')` falling back to `substr()`, and pass the
|
|
||||||
already-truncated value into the template.
|
|
||||||
- Same class of bug, different filter: don't use Twig's `|escape('js')` (or
|
|
||||||
the `'js'` arg to `|e`) either — it calls `Twig\Runtime\mb_ord()`
|
|
||||||
(`novaconium/vendor/twig/src/Extension/EscaperExtension.php`), which
|
|
||||||
hard-requires `mbstring` the same way `|slice` does, and fatals
|
|
||||||
identically without it. Hit for real when
|
|
||||||
`novaconium/pages/_layout/code-copy.twig` used it to pass SVG icon
|
|
||||||
markup into an inline `<script>` as a JS string literal. Fixed by not
|
|
||||||
needing string-escaped markup in JS at all: render the markup as plain
|
|
||||||
HTML into a `<template>` element (default autoescaping, no mbstring
|
|
||||||
dependency) and read it in JS via that template element's `.innerHTML`
|
|
||||||
getter instead. Prefer that pattern — or a `data-*` attribute if the
|
|
||||||
value is plain text, not markup — over `|escape('js')` any time a Twig
|
|
||||||
value needs to reach JS.
|
|
||||||
|
|||||||
@@ -40,5 +40,11 @@ return [
|
|||||||
'excerpt' => 'How syntax highlighting works on this site, with worked examples in bash, HTML, CSS, YAML, Python, JavaScript, JSON, and INI/env.',
|
'excerpt' => 'How syntax highlighting works on this site, with worked examples in bash, HTML, CSS, YAML, Python, JavaScript, JSON, and INI/env.',
|
||||||
'published' => '2026-07-14',
|
'published' => '2026-07-14',
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'slug' => 'novaconium-features',
|
||||||
|
'title' => 'Novaconium Features',
|
||||||
|
'excerpt' => 'A tour of what ships with novaconium out of the box: routing, sidecars, caching, admin auth, access control, media manager, database, search, RSS, and more.',
|
||||||
|
'published' => '2026-07-15',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{% extends layout %}
|
||||||
|
|
||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
|
||||||
|
{% block title %}Novaconium Features{% endblock %}
|
||||||
|
{% block description %}A tour of what ships with novaconium out of the box: routing, sidecars, caching, admin auth, access control, media manager, database, search, RSS, and more.{% endblock %}
|
||||||
|
|
||||||
|
{% block robots %}index, follow{% endblock %}
|
||||||
|
{% block tags %}features, meta{% endblock %}
|
||||||
|
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block og_type %}article{% endblock %}
|
||||||
|
{% block og_title %}{{ block('title') }}{% endblock %}
|
||||||
|
{% block og_description %}{{ block('description') }}{% endblock %}
|
||||||
|
{% block og_url %}{{ block('canonical') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block twitter_card %}summary{% endblock %}
|
||||||
|
{% block twitter_title %}{{ block('title') }}{% endblock %}
|
||||||
|
{% block twitter_description %}{{ block('description') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block blog_content %}
|
||||||
|
<h1>Novaconium Features</h1>
|
||||||
|
|
||||||
|
<p>A tour of what ships with novaconium out of the box. Every topic below has a full writeup at <a class="icon-link" href="/admin/docs">{{ icons.book() }}/admin/docs</a> on any running instance — this post is the overview.</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><strong>File-based routing</strong> — a directory under <code>App/pages/</code> <em>is</em> a route (Hugo-style page bundles). No route table to maintain.</li>
|
||||||
|
<li><strong><code>[param]</code> segments</strong> — a directory literally named <code>[param]</code> (e.g. <code>App/pages/products/[id]/</code>) captures any single URL segment into <code>$params['param']</code> for clean URLs, no query strings.</li>
|
||||||
|
<li><strong>Optional PHP "sidecars"</strong> — drop an <code>index.php</code> next to any <code>index.twig</code> to supply Twig context data, or return a <code>Response</code> (redirect/JSON/XML/HTML) to short-circuit templating entirely.</li>
|
||||||
|
<li><strong>Static caching, zero config</strong> — sidecar-less pages render once and are written to <code>public/cache/</code>; <code>.htaccess</code> serves the cached file directly on every later hit, skipping PHP and Twig entirely.</li>
|
||||||
|
<li><strong>Override-by-path</strong> — <code>App/</code> (your project) is checked before <code>novaconium/</code> (the framework defaults) for every page, layout, <code>Lib\</code> class, and even the Sass color palette (<code>App/sass/_colors.sass</code>). Drop a file at the same relative path to override it; nothing needs duplicating to get a working site.</li>
|
||||||
|
<li><strong>Layout inheritance</strong> — <code>_layout/layout.twig</code> directories are resolved by walking upward from the matched page, so you can override the layout for a whole subtree.</li>
|
||||||
|
<li><strong>SEO boilerplate out of the box</strong> — the default layout ships meta description, canonical link, robots, Open Graph, and Twitter Card tags, all overridable per-page via Twig blocks.</li>
|
||||||
|
<li><strong>Built-in Matomo analytics</strong> — set <code>matomo_url</code> and <code>matomo_site_id</code> in <code>App/config.php</code> to enable tracking site-wide, including automatic 404 tracking. Off by default.</li>
|
||||||
|
<li><strong>Admin authentication</strong> — gate every <code>/admin/*</code> route behind a session login with multi-user management: a SQLite-backed <code>users</code> table, <code>/admin/login</code>/<code>/admin/logout</code>, and an <code>/admin/users</code> page to create, disable, delete, group, promote/demote, and change the email or password of accounts (plus a <code>novaconium/bin/create-admin-user.php</code> CLI for the first user or deploy scripts). Two roles: the first user created is the admin; everyone after is a registered user with an optional group. Enabled with a single <code>admin_auth_enabled</code> flag in <code>App/config.php</code>; off by default.</li>
|
||||||
|
<li><strong>Access control</strong> — assign a page (or a section, one line per page) to a user or group from its sidecar: <code>Access::require('group:members')</code> returns <code>null</code> or a ready-made <code>Response</code> (login redirect with a return path, or a 404 for the wrong account). Public is the default — a sidecar that never calls it is untouched, and static (sidecar-less, cached) pages are always public by construction.</li>
|
||||||
|
<li><strong>Draft pages</strong> — list a route under <code>draft_routes</code> in <code>App/config.php</code> to make it visible only to an authenticated admin; anyone else gets a plain 404, not a login prompt.</li>
|
||||||
|
<li><strong>Media manager</strong> — <code>/admin/media</code>, an upload/browse/delete UI for files under <code>public/uploads/</code>, covered by the existing <code>/admin/*</code> auth gate with no separate flag needed. Extension allowlist and max upload size are configurable.</li>
|
||||||
|
<li><strong>Dark/light theme toggle</strong> — a nav button flips a <code>data-theme</code> attribute (persisted to <code>localStorage</code>) that swaps every color via CSS custom properties.</li>
|
||||||
|
<li><strong>Self-hosted spam prevention & form validation</strong> — <code>Lib\SpamGuard</code> (honeypot + submission-timing check, no external CAPTCHA), <code>Lib\FormValidator</code>, and <code>Lib\Validate</code>, demonstrated on the contact form.</li>
|
||||||
|
<li><strong>Form security by default</strong> — <code>Lib\Input</code> (cleaning accessor for <code>$_POST</code>/<code>$_GET</code>) and <code>Lib\Csrf</code> (standalone session-token CSRF protection), wired into the contact form and every admin form.</li>
|
||||||
|
<li><strong>SQLite/MySQL database, zero setup</strong> — <code>Lib\Db</code>, a thin PDO wrapper (no ORM) supporting multiple named connections open at once, each with its own plain-SQL migration convention, applied automatically on first use or via <code>php novaconium/bin/migrate.php</code>.</li>
|
||||||
|
<li><strong>Sessions with flash data</strong> — <code>Lib\Session</code>, a thin wrapper around native PHP sessions with CodeIgniter-style flash values for post/redirect/GET flows.</li>
|
||||||
|
<li><strong>Content index: sitemap, search, tags</strong> — <code>/sitemap.xml</code>, full-text <code>/search</code> (SQLite FTS5), and blog tag browsing all share one crawler. Off by default; reindexes lazily on demand or via <code>php novaconium/bin/index-content.php</code>.</li>
|
||||||
|
<li><strong>Blog RSS feed</strong> — <code>/blog/feed</code>, built from the same hand-written post list <code>App/pages/blog/index.php</code> itself renders from, so it works with no database at all.</li>
|
||||||
|
<li><strong>Syntax-highlighted code blocks</strong> — vendored <a href="https://highlightjs.org/">highlight.js</a> colors PHP/Bash/HTML code blocks site-wide, auto-detected with no per-block markup.</li>
|
||||||
|
<li><strong>No build step, no Composer</strong> — clone it, point Apache (or <code>php -S</code>) at <code>public/</code>, and it runs. Twig is vendored as source.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>Full details on every one of these live at <a class="icon-link" href="/admin/docs">{{ icons.book() }}/admin/docs</a>, rendered live from this same running instance — routing, sidecars, libraries, database, session, content index, XML sitemap, RSS feeds, layouts, static caching, SEO, Matomo, admin authentication, access control, draft pages, media manager, styling, project layout, and third-party notices.</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -1 +1,6 @@
|
|||||||
|
# Agent permissions
|
||||||
|
|
||||||
|
- Only run `git` commands with the user's explicit permission for that specific command/action.
|
||||||
|
- Never run `docker` commands (build, compose up, run, etc.) — leave all Docker execution to the user.
|
||||||
|
|
||||||
@AGENTS.md
|
@AGENTS.md
|
||||||
|
|||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
# Arch Linux + Apache + PHP image for running novaconium in production.
|
||||||
|
# See /admin/docs/docker for volumes, overriding App/ without a rebuild,
|
||||||
|
# and optional MySQL wiring.
|
||||||
|
FROM archlinux:base
|
||||||
|
|
||||||
|
RUN pacman -Syu --noconfirm --needed apache php php-apache sqlite \
|
||||||
|
&& pacman -Scc --noconfirm
|
||||||
|
|
||||||
|
# php-apache on Arch is built against mpm_prefork, not httpd's default
|
||||||
|
# mpm_event — swap MPMs, enable mod_rewrite, point DocumentRoot at
|
||||||
|
# public/, and wire in mod_php.
|
||||||
|
RUN sed -i \
|
||||||
|
-e 's/^LoadModule mpm_event_module/#LoadModule mpm_event_module/' \
|
||||||
|
-e 's/^#LoadModule mpm_prefork_module/LoadModule mpm_prefork_module/' \
|
||||||
|
-e '/^#LoadModule rewrite_module/s/^#//' \
|
||||||
|
-e 's#DocumentRoot "/srv/http"#DocumentRoot "/var/www/html/public"#' \
|
||||||
|
-e 's#<Directory "/srv/http">#<Directory "/var/www/html/public">#' \
|
||||||
|
-e 's/^AllowOverride None/AllowOverride All/' \
|
||||||
|
/etc/httpd/conf/httpd.conf \
|
||||||
|
&& printf '\nLoadModule php_module modules/libphp.so\nAddHandler php-script .php\nDirectoryIndex index.php index.html\nServerName localhost\n' \
|
||||||
|
>> /etc/httpd/conf/httpd.conf \
|
||||||
|
&& sed -i \
|
||||||
|
-e 's/^;extension=pdo_sqlite/extension=pdo_sqlite/' \
|
||||||
|
-e 's/^;extension=pdo_mysql/extension=pdo_mysql/' \
|
||||||
|
/etc/php/php.ini
|
||||||
|
|
||||||
|
WORKDIR /var/www/html
|
||||||
|
|
||||||
|
COPY novaconium/ ./novaconium/
|
||||||
|
COPY public/ ./public/
|
||||||
|
COPY App/ ./App/
|
||||||
|
|
||||||
|
# Runtime-writable paths not covered by named volumes in docker-compose.yml.
|
||||||
|
RUN mkdir -p public/cache public/uploads data \
|
||||||
|
&& touch novaconium/contact-log.txt \
|
||||||
|
&& chown -R http:http public/cache public/uploads data App novaconium/contact-log.txt
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["httpd", "-D", "FOREGROUND"]
|
||||||
@@ -1,122 +1,34 @@
|
|||||||
|
```
|
||||||
|
_ __ _____ ____ _ ___ ___ _ __ (_)_ _ _ __ ___
|
||||||
|
| '_ \ / _ \ \ / / _` |/ __/ _ \| '_ \| | | | | '_ ` _ \
|
||||||
|
| | | | (_) \ V / (_| | (_| (_) | | | | | |_| | | | | | |
|
||||||
|
|_| |_|\___/ \_/ \__,_|\___\___/|_| |_|_|\__,_|_| |_| |_|
|
||||||
|
```
|
||||||
|
|
||||||
# novaconium
|
# novaconium
|
||||||
|
|
||||||
A tiny, Hugo-flavored PHP micro-framework. Routes are directories on disk, pages render with [Twig](https://twig.symfony.com/), and any page that needs real logic gets an optional PHP "sidecar" file. Pages without a sidecar are pre-rendered once and served as static HTML straight from Apache afterwards. No Composer — Twig is vendored directly into the repo as plain source files.
|
A tiny, Hugo-flavored PHP micro-framework. Routes are directories on disk, pages render with [Twig](https://twig.symfony.com/), and any page that needs real logic gets an optional PHP "sidecar" file. Pages without a sidecar are pre-rendered once and served as static HTML straight from Apache afterwards. No Composer — Twig is vendored directly into the repo as plain source files.
|
||||||
|
|
||||||
## Features
|
For a full tour of what's included — routing, sidecars, caching, admin auth, access control, media manager, database, search, RSS, and more — see the [Novaconium Features](http://127.0.0.1:8000/blog/novaconium-features) post once the site is running, or `/admin/docs` (see Documentation below).
|
||||||
|
|
||||||
- **File-based routing** — a directory under `App/pages/` *is* a route (Hugo-style page bundles). No route table to maintain.
|
|
||||||
- **`[param]` segments** — a directory literally named `[param]` (e.g. `App/pages/products/[id]/`) captures any single URL segment into `$params['param']` for clean URLs, no query strings.
|
|
||||||
- **Optional PHP "sidecars"** — drop an `index.php` next to any `index.twig` to supply Twig context data, or return a `Response` (redirect/JSON/XML/HTML) to short-circuit templating entirely.
|
|
||||||
- **Static caching, zero config** — sidecar-less pages render once and are written to `public/cache/`; `.htaccess` serves the cached file directly on every later hit, skipping PHP and Twig entirely.
|
|
||||||
- **Override-by-path** — `App/` (your project) is checked before `novaconium/` (the framework defaults) for every page, layout, `Lib\` class, and even the Sass color palette (`App/sass/_colors.sass`). Drop a file at the same relative path to override it; nothing needs duplicating to get a working site.
|
|
||||||
- **Layout inheritance** — `_layout/layout.twig` directories are resolved by walking upward from the matched page, so you can override the layout for a whole subtree.
|
|
||||||
- **SEO boilerplate out of the box** — the default layout ships meta description, canonical link, robots, Open Graph, and Twitter Card tags, all overridable per-page via Twig blocks.
|
|
||||||
- **Built-in Matomo analytics** — set `matomo_url` and `matomo_site_id` in `App/config.php` to enable tracking site-wide, including automatic 404 tracking. Off by default.
|
|
||||||
- **Admin authentication** — gate every `/admin/*` route behind a session login with multi-user management: a SQLite-backed `users` table, `/admin/login`/`/admin/logout`, and an `/admin/users` page to create, disable, delete, group, promote/demote, and change the email or password of accounts (plus a `novaconium/bin/create-admin-user.php` CLI for the first user or deploy scripts). Two roles: the first user created is the admin; everyone after is a registered user with an optional group. Every account has a unique, normalized email address — groundwork for email verification later. Enabled with a single `admin_auth_enabled` flag in `App/config.php`; off by default, and reusable for any admin page a project adds later.
|
|
||||||
- **Access control** — assign a page (or a section, one line per page) to a user or group from its sidecar: `Access::require('group:members')` returns `null` or a ready-made `Response` (login redirect with a return path, or a 404 for the wrong account). Public is the default — a sidecar that never calls it is untouched, and static (sidecar-less, cached) pages are always public by construction. Gated pages stay out of `/search` and `/sitemap.xml` automatically.
|
|
||||||
- **Draft pages** — list a route under `draft_routes` in `App/config.php` to make it visible only to an authenticated admin; anyone else gets a plain 404, not a login prompt. Reuses the admin auth check directly, and is excluded from static caching so a cached copy can't leak the draft to the public. See `/admin/docs/drafts`.
|
|
||||||
- **Dark/light theme toggle** — a nav button flips a `data-theme` attribute (persisted to `localStorage`) that swaps every color via CSS custom properties; both palettes live in `App/sass/_colors.sass`, same override mechanism as everything else.
|
|
||||||
- **Self-hosted spam prevention & form validation** — `Lib\SpamGuard`, a reusable class for any form: a CSS-hidden honeypot field plus a submission-timing check, no external CAPTCHA service, site key, or outbound API call. Pairs with `Lib\FormValidator` (accumulating required-field/email/length checks) and `Lib\Validate` (the underlying validation primitives — email, length, phone, postal/zip, spam-word checks). All three ship in `novaconium/lib/`, demonstrated on the contact form.
|
|
||||||
- **Form security by default** — `Lib\Input`, a cleaning accessor for `$_POST`/`$_GET` (defense-in-depth against HTML/script injection, not a substitute for parameterized queries), and `Lib\Csrf`, standalone session-token CSRF protection called directly from a sidecar. Both ship in `novaconium/lib/`, wired into the contact form, `/admin/clear-cache`, `/admin/login`, and `/admin/users`.
|
|
||||||
- **SQLite/MySQL database, zero setup** — `Lib\Db`, a thin PDO wrapper (no ORM) supporting multiple named connections open at once — e.g. this site's own SQLite data plus a MySQL connection to a legacy database, usable in the same sidecar — each with its own plain-SQL migration convention, applied automatically on first use or via `php novaconium/bin/migrate.php`. SQLite data lives in a project-owned top-level `data/` directory, outside both `public/` and `novaconium/`. See `/admin/docs/database`.
|
|
||||||
- **Sessions with flash data** — `Lib\Session`, a thin wrapper around native PHP sessions with CodeIgniter-style flash values (set now, readable on exactly the next request) for post/redirect/GET flows without a query-string flag. Lazy-start, same mechanism `Lib\Csrf` already uses. See `/admin/docs/session`.
|
|
||||||
- **Content index: sitemap, search, tags** — `/sitemap.xml`, full-text `/search` (SQLite FTS5), and blog tag browsing all share one crawler that renders every page and harvests `keywords`/`tags`/`changefreq`/`priority` Twig blocks via Twig's own `renderBlock()` — no front-matter, no separate metadata files. Off by default (depends on SQLite); reindexes lazily on demand or via `php novaconium/bin/index-content.php`. See `/admin/docs/content-index`.
|
|
||||||
- **Blog RSS feed** — `/blog/feed`, built from the same hand-written post list `App/pages/blog/index.php` itself renders from, so it works with no database at all. `Lib\Rss` (a small RSS 2.0 envelope builder) also backs a per-tag feed, `/blog/tag/<tag>/feed`, once the content index above is enabled. Auto-discovered via a `<link rel="alternate">` on `/blog/*` pages.
|
|
||||||
- **Syntax-highlighted code blocks** — vendored [highlight.js](https://highlightjs.org/) colors PHP/Bash/HTML code blocks site-wide, auto-detected with no per-block markup, swapping between dark (`ir-black`) and light (`github`) themes along with the existing dark/light toggle. Twig-syntax samples (which highlight.js can't parse) are left plain rather than colored wrong. See `/admin/docs/upgrading-highlightjs`.
|
|
||||||
- **No build step, no Composer** — clone it, point Apache (or `php -S`) at `public/`, and it runs. Twig is vendored as source; see `/admin/docs/upgrading-twig` for upgrading it.
|
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
**Requirements:** PHP 8.1+ (uses `readonly` constructor-promoted properties) with the `pdo_sqlite` extension (bundled with PHP, just needs to be enabled — no separate install; add `pdo_mysql` too if using a MySQL connection), and, for production, Apache with `mod_rewrite` and `AllowOverride All`. The content index's search (`/admin/docs/content-index`) additionally needs SQLite's FTS5 extension, bundled with `pdo_sqlite` on virtually every modern PHP build — only relevant if `content_index_enabled` is turned on.
|
**Requirements:** PHP 8.1+ (uses `readonly` constructor-promoted properties) and, for production, Apache with `mod_rewrite` and `AllowOverride All`. A few optional features (database, content index/search, admin authentication) need the `pdo_sqlite` extension — see `/admin/docs` for details once running.
|
||||||
|
|
||||||
### Run it locally (no Apache needed)
|
Run it locally, no Apache needed:
|
||||||
|
|
||||||
```
|
```
|
||||||
php -S 127.0.0.1:8000 -t public public/router.php
|
php -S 127.0.0.1:8000 -t public public/router.php
|
||||||
```
|
```
|
||||||
|
|
||||||
`public/router.php` is a dev-only script that mimics the `.htaccess` rules (canonical redirects + static cache lookup) so you can develop without Apache. It is never used in production — Apache reads `public/.htaccess` directly.
|
Visit `http://127.0.0.1:8000/` — click around the example pages, then open `http://127.0.0.1:8000/admin/docs` for the complete documentation, rendered live from this same instance.
|
||||||
|
|
||||||
Visit `http://127.0.0.1:8000/` for the static home page, then click around — `/about`, `/blog/hello-world`, `/contact`, and `/admin` (cache clearing + these same docs, rendered live) are all included as working examples.
|
|
||||||
|
|
||||||
### Deploy on Apache
|
|
||||||
|
|
||||||
Point the vhost's document root at `public/`, make sure `mod_rewrite` is enabled and `AllowOverride All` is set for that directory so `public/.htaccess` takes effect, and it just works — no build step required.
|
|
||||||
|
|
||||||
### Starting a new project
|
|
||||||
|
|
||||||
Clone this repo and drop its Git history — no Composer scaffold or installer:
|
|
||||||
|
|
||||||
```
|
|
||||||
git clone --depth 1 <novaconium-repo-url> my-new-project
|
|
||||||
cd my-new-project
|
|
||||||
rm -rf .git && git init && git add -A && git commit -m "Initial commit from novaconium template"
|
|
||||||
```
|
|
||||||
|
|
||||||
Then replace the example content under `App/pages/` with your own; leave `novaconium/` and `public/` alone.
|
|
||||||
|
|
||||||
### Updating the framework
|
|
||||||
|
|
||||||
Since the framework core lives entirely under `novaconium/`, pick up a new release by overwriting just that directory against a tag and committing the diff:
|
|
||||||
|
|
||||||
```
|
|
||||||
git clone --depth 1 --branch <release-tag> <novaconium-repo-url> /tmp/nova-update
|
|
||||||
rm -rf novaconium && cp -r /tmp/nova-update/novaconium ./novaconium && rm -rf /tmp/nova-update
|
|
||||||
git add novaconium && git commit -m "Update novaconium framework to <release-tag>"
|
|
||||||
```
|
|
||||||
|
|
||||||
Safe by construction — `App/` always overrides `novaconium/`, so an update can't clobber project customizations. See [Getting started](http://127.0.0.1:8000/admin/docs/getting-started) for the full write-up.
|
|
||||||
|
|
||||||
### Add a page
|
|
||||||
|
|
||||||
Create a directory under `App/pages/` with an `index.twig` — the directory path *is* the URL:
|
|
||||||
|
|
||||||
```
|
|
||||||
App/pages/pricing/index.twig -> /pricing
|
|
||||||
```
|
|
||||||
|
|
||||||
Add an `index.php` next to it if the page needs data or logic. See [Sidecars](http://127.0.0.1:8000/admin/docs/sidecars) in the docs for the full contract, or [SEO](http://127.0.0.1:8000/admin/docs/seo) for a ready-to-paste starter template with every overridable block — or skip the copy-paste and scaffold it:
|
|
||||||
|
|
||||||
```
|
|
||||||
php novaconium/bin/create-static-page.php blog/my-new-post
|
|
||||||
```
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
The full framework documentation — routing, sidecars, libraries, database, session, content index, XML sitemap, RSS feeds, layouts, static caching, SEO, Matomo analytics, admin authentication, draft pages, styling, project layout, and third-party notices — lives at `/admin/docs` on any running instance (so it travels with the code, no internet connection needed). Highlights:
|
The full framework documentation lives inside the framework itself, at `/admin/docs` on any running instance — so it travels with the code, no internet connection needed. That's the canonical reference for everything: requirements, running locally, deploying on Apache or Docker, starting a new project, updating the framework, adding a page, routing, sidecars, libraries, database, session, content index, XML sitemap, RSS feeds, layouts, static caching, SEO, Matomo analytics, admin authentication, access control, draft pages, media manager, styling, and project layout.
|
||||||
|
|
||||||
- [Getting started](http://127.0.0.1:8000/admin/docs/getting-started)
|
|
||||||
- [Routing](http://127.0.0.1:8000/admin/docs/routing)
|
|
||||||
- [Sidecars](http://127.0.0.1:8000/admin/docs/sidecars)
|
|
||||||
- [Libraries](http://127.0.0.1:8000/admin/docs/libraries)
|
|
||||||
- [Database](http://127.0.0.1:8000/admin/docs/database)
|
|
||||||
- [Session](http://127.0.0.1:8000/admin/docs/session)
|
|
||||||
- [Content index](http://127.0.0.1:8000/admin/docs/content-index)
|
|
||||||
- [XML sitemap](http://127.0.0.1:8000/admin/docs/sitemap)
|
|
||||||
- [RSS feeds](http://127.0.0.1:8000/admin/docs/rss)
|
|
||||||
- [Layouts](http://127.0.0.1:8000/admin/docs/layouts)
|
|
||||||
- [Static caching](http://127.0.0.1:8000/admin/docs/caching)
|
|
||||||
- [SEO](http://127.0.0.1:8000/admin/docs/seo)
|
|
||||||
- [Matomo](http://127.0.0.1:8000/admin/docs/matomo)
|
|
||||||
- [Admin authentication](http://127.0.0.1:8000/admin/docs/admin-auth)
|
|
||||||
- [Access control](http://127.0.0.1:8000/admin/docs/access-control)
|
|
||||||
- [Draft pages](http://127.0.0.1:8000/admin/docs/drafts)
|
|
||||||
- [Styling](http://127.0.0.1:8000/admin/docs/styling)
|
|
||||||
- [Project layout](http://127.0.0.1:8000/admin/docs/project-layout)
|
|
||||||
- [Third-party](http://127.0.0.1:8000/admin/docs/third-party)
|
|
||||||
|
|
||||||
`AGENTS.md` is the short, agent-facing version for coding assistants working in this repo, and `novaconium/ISSUES.md` is the roadmap/backlog.
|
`AGENTS.md` is the short, agent-facing version for coding assistants working in this repo, and `novaconium/ISSUES.md` is the roadmap/backlog.
|
||||||
|
|
||||||
## Project layout
|
|
||||||
|
|
||||||
```
|
|
||||||
App/ your project — pages/ (routes), lib/ (Lib\ classes), sass/ (color overrides) — the only directory you're expected to edit
|
|
||||||
public/ Apache document root — front controller, .htaccess, static cache, compiled CSS
|
|
||||||
novaconium/ the framework itself — router, renderer, vendored Twig, default pages/lib/sass — not edited per-project
|
|
||||||
```
|
|
||||||
|
|
||||||
See [Project layout](http://127.0.0.1:8000/admin/docs/project-layout) for the full tree with every file explained.
|
|
||||||
|
|
||||||
## Third-party
|
## Third-party
|
||||||
|
|
||||||
[Twig](https://twig.symfony.com/) is vendored in source form under `novaconium/vendor/twig/` (no Composer — see `/admin/docs/upgrading-twig` for how to upgrade it). It's BSD-3-Clause licensed; the full license text ships alongside it at `novaconium/vendor/twig/LICENSE`.
|
[Twig](https://twig.symfony.com/) is vendored in source form under `novaconium/vendor/twig/` (no Composer — see `/admin/docs/upgrading-twig` for how to upgrade it). It's BSD-3-Clause licensed; the full license text ships alongside it at `novaconium/vendor/twig/LICENSE`.
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
services:
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
volumes:
|
||||||
|
- cache:/var/www/html/public/cache
|
||||||
|
- uploads:/var/www/html/public/uploads
|
||||||
|
- data:/var/www/html/data
|
||||||
|
# Uncomment to override the baked-in App/ with a host copy, no rebuild:
|
||||||
|
# - ./App:/var/www/html/App
|
||||||
|
|
||||||
|
# Optional — only needed if App/config.php adds a db_connections entry
|
||||||
|
# with driver: mysql. See /admin/docs/database.
|
||||||
|
# db:
|
||||||
|
# image: mysql:8
|
||||||
|
# environment:
|
||||||
|
# MYSQL_DATABASE: novaconium
|
||||||
|
# MYSQL_USER: novaconium
|
||||||
|
# MYSQL_PASSWORD: change-me
|
||||||
|
# MYSQL_ROOT_PASSWORD: change-me
|
||||||
|
# volumes:
|
||||||
|
# - mysql-data:/var/lib/mysql
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
cache:
|
||||||
|
uploads:
|
||||||
|
data:
|
||||||
|
# mysql-data:
|
||||||
+46
-316
@@ -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
|
be actionable/discussed, not necessarily when the idea is first written
|
||||||
down here.
|
down here.
|
||||||
- When work begins, move the item to **In Progress**.
|
- When work begins, move the item to **In Progress**.
|
||||||
- When shipped, move it to **Done**, keep the entry (don't delete), and add
|
- When shipped, move it to **Done** and add a `Shipped:` line with the date
|
||||||
a `Shipped:` line with the date and, once committed, the commit/PR
|
and, once committed, the commit/PR reference. Keep a Done entry only as
|
||||||
reference.
|
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:`
|
- 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
|
line rather than deleting it — the "why not" is worth keeping. Close the
|
||||||
corresponding tracker issue with a link back to that entry.
|
corresponding tracker issue with a link back to that entry.
|
||||||
@@ -53,15 +58,13 @@ expected vs. actual behavior. For features, include the motivating use case.>
|
|||||||
|
|
||||||
Suggested build order (foundations first):
|
Suggested build order (foundations first):
|
||||||
|
|
||||||
1. **Media/file manager** — no hard dependency; usable standalone, and
|
1. **In-house comments** — admin login & user management (Done
|
||||||
already gated behind admin login the moment it exists under `/admin/*`.
|
|
||||||
2. **In-house comments** — admin login & user management (Done
|
|
||||||
2026-07-14) unblocked this; comments are tied to real user accounts,
|
2026-07-14) unblocked this; comments are tied to real user accounts,
|
||||||
not anonymous.
|
not anonymous.
|
||||||
3. **Ecommerce functionality** — its dependencies (SQLite groundwork,
|
2. **Ecommerce functionality** — its dependencies (SQLite groundwork,
|
||||||
session handling for the cart, admin login for order/product admin)
|
session handling for the cart, admin login for order/product admin)
|
||||||
are all done now.
|
are all done now.
|
||||||
4. **Paywall functionality** — needs everything Ecommerce needs, plus
|
3. **Paywall functionality** — needs everything Ecommerce needs, plus
|
||||||
Ecommerce itself for the recurring-billing/payment-gateway plumbing;
|
Ecommerce itself for the recurring-billing/payment-gateway plumbing;
|
||||||
build after it rather than in parallel — also now has a concrete
|
build after it rather than in parallel — also now has a concrete
|
||||||
precedent to follow for the "gated content must skip the static cache"
|
precedent to follow for the "gated content must skip the static cache"
|
||||||
@@ -69,11 +72,9 @@ Suggested build order (foundations first):
|
|||||||
the caching/auth standing rule in `AGENTS.md`), which was still an open
|
the caching/auth standing rule in `AGENTS.md`), which was still an open
|
||||||
question when this entry was originally written.
|
question when this entry was originally written.
|
||||||
|
|
||||||
MySQL support, Session handling (with flash sessions), Draft pages
|
Session handling (with flash sessions), Draft pages (admin-only preview),
|
||||||
(admin-only preview), Blog tags/categories + Internal search + XML
|
Admin login & user management, and Media/file manager all shipped (see
|
||||||
sitemap (shipped together as one content index — see Done), Blog RSS
|
Done) — 2026-07-14 for the first three, 2026-07-15 for Media/file manager.
|
||||||
feed, Syntax highlighting on code blocks, and Admin login & user
|
|
||||||
management all shipped 2026-07-14.
|
|
||||||
|
|
||||||
See **Won't Do** below for 404 tracking, dropped in favor of Matomo.
|
See **Won't Do** below for 404 tracking, dropped in favor of Matomo.
|
||||||
|
|
||||||
@@ -96,25 +97,6 @@ on what an unverified account may do (log in but fail `Lib\Access`
|
|||||||
rules? not log in at all?). Also the natural home for password-reset-
|
rules? not log in at all?). Also the natural home for password-reset-
|
||||||
by-email later, which shares all the same plumbing.
|
by-email later, which shares all the same plumbing.
|
||||||
|
|
||||||
### Media/file manager
|
|
||||||
|
|
||||||
- **Type:** Feature
|
|
||||||
- **Status:** Backlog
|
|
||||||
- **Priority:** Medium
|
|
||||||
- **Added:** 2026-07-12
|
|
||||||
|
|
||||||
An upload/browse/delete UI for media (images, PDFs, etc.) so sidecars and
|
|
||||||
Twig templates have a consistent place to reference uploaded files from
|
|
||||||
— e.g. a blog post's header image — instead of authors manually copying
|
|
||||||
files into `public/`. Likely a new `/admin/media` page — already covered
|
|
||||||
by the admin authentication gate (every `/admin/*` route) the moment it's
|
|
||||||
added, no extra wiring needed — backed by a plain directory under
|
|
||||||
`public/uploads/` rather than a database (files are already static
|
|
||||||
assets; no need for SQLite here unless metadata like alt text/captions is
|
|
||||||
wanted later, in which case that part could ride on SQLite groundwork).
|
|
||||||
Needs basic safety handling: extension allowlist, filename sanitization,
|
|
||||||
and a max upload size, since this is a file-write surface.
|
|
||||||
|
|
||||||
### In-house comments
|
### In-house comments
|
||||||
|
|
||||||
- **Type:** Feature
|
- **Type:** Feature
|
||||||
@@ -195,6 +177,38 @@ _Nothing yet._
|
|||||||
|
|
||||||
## Done
|
## Done
|
||||||
|
|
||||||
|
### Media/file manager
|
||||||
|
|
||||||
|
- **Type:** Feature
|
||||||
|
- **Status:** Done
|
||||||
|
- **Priority:** Medium
|
||||||
|
- **Added:** 2026-07-12
|
||||||
|
- **Shipped:** 2026-07-15
|
||||||
|
|
||||||
|
An upload/browse/delete UI for media (images, PDFs, etc.) at `/admin/media`
|
||||||
|
so sidecars and Twig templates have a consistent place to reference
|
||||||
|
uploaded files from — e.g. a blog post's header image — instead of authors
|
||||||
|
manually copying files into `public/`. Covered by the existing
|
||||||
|
`/admin/*` auth gate the moment the page was added, no extra wiring
|
||||||
|
needed — as planned, there's no separate `*_enabled` flag (unlike admin
|
||||||
|
auth/content index) since it has no SQLite dependency to gate. Backed by a
|
||||||
|
plain directory, `public/uploads/` (gitignored per-file with a tracked
|
||||||
|
`.gitkeep`, same convention as `data/`), rather than a database — no
|
||||||
|
metadata store (alt text, captions) yet; that's flagged as a natural
|
||||||
|
SQLite fit if wanted later, not built now. Also removed the top-level
|
||||||
|
`images/` scaffolding directory/volume (reserved for a future
|
||||||
|
image-upload feature, per the prior `AGENTS.md` wording): nothing had
|
||||||
|
ever consumed it, and `public/uploads/` now covers the use case it was
|
||||||
|
held open for. Safety handling: an extension
|
||||||
|
allowlist and max upload size, both configurable
|
||||||
|
(`media_upload_extensions`/`media_upload_max_bytes` in `App/config.php`),
|
||||||
|
plus filename sanitization (`basename()` + safe-charset reduction,
|
||||||
|
collision-safe via a `-1`/`-2`/... suffix) and a `realpath()` re-check on
|
||||||
|
delete to confirm the resolved path still lands inside `public/uploads/`
|
||||||
|
before unlinking. Documented at `/admin/docs/media-manager` (new topic,
|
||||||
|
linked from the docs nav/index and the admin dashboard), with a matching
|
||||||
|
README feature/docs-index entry.
|
||||||
|
|
||||||
### User deletion & email addresses
|
### User deletion & email addresses
|
||||||
|
|
||||||
- **Type:** Feature
|
- **Type:** Feature
|
||||||
@@ -322,220 +336,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
|
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).
|
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)
|
### Draft pages (admin-only preview)
|
||||||
|
|
||||||
- **Type:** Feature
|
- **Type:** Feature
|
||||||
@@ -607,44 +407,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`
|
`/admin/docs/session` and in `AGENTS.md` next to the `Lib\Csrf`/`Lib\Db`
|
||||||
sections.
|
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
|
### SQLite groundwork
|
||||||
|
|
||||||
- **Type:** Feature
|
- **Type:** Feature
|
||||||
@@ -671,38 +433,6 @@ entry below (shipped 2026-07-14) for the connection/config/migration API,
|
|||||||
which superseded the single-connection shape (`db_driver`/`db_path`/
|
which superseded the single-connection shape (`db_driver`/`db_path`/
|
||||||
`db_migrations_dir` config keys) this entry originally shipped with.
|
`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
|
## Won't Do
|
||||||
|
|
||||||
### 404 tracking
|
### 404 tracking
|
||||||
|
|||||||
@@ -101,4 +101,17 @@ return [
|
|||||||
// deploy step.
|
// deploy step.
|
||||||
'content_index_enabled' => false,
|
'content_index_enabled' => false,
|
||||||
'content_index_auto' => true,
|
'content_index_auto' => true,
|
||||||
|
|
||||||
|
// Media manager (/admin/media — see /admin/docs/media-manager): an
|
||||||
|
// upload/browse/delete UI for files under public/uploads/, covered by
|
||||||
|
// the existing /admin/* auth gate the moment the page exists, so
|
||||||
|
// there's no separate *_enabled flag here (unlike admin_auth_enabled/
|
||||||
|
// content_index_enabled above, it has no SQLite dependency to gate).
|
||||||
|
// media_upload_extensions is an allowlist, matched case-insensitively
|
||||||
|
// against the uploaded filename's extension; media_upload_max_bytes
|
||||||
|
// caps a single file's size (checked against both $_FILES' reported
|
||||||
|
// size and PHP's own upload_max_filesize/post_max_size ini limits,
|
||||||
|
// see /admin/docs/media-manager).
|
||||||
|
'media_upload_extensions' => ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf', 'txt', 'zip'],
|
||||||
|
'media_upload_max_bytes' => 10 * 1024 * 1024,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<li><a class="icon-link" href="/admin/docs">{{ icons.book() }}Overview</a></li>
|
<li><a class="icon-link" href="/admin/docs">{{ icons.book() }}Overview</a></li>
|
||||||
<li><a class="icon-link" href="/admin/docs/getting-started">{{ icons.book() }}Getting started</a></li>
|
<li><a class="icon-link" href="/admin/docs/getting-started">{{ icons.book() }}Getting started</a></li>
|
||||||
|
<li><a class="icon-link" href="/admin/docs/docker">{{ icons.book() }}Docker</a></li>
|
||||||
<li><a class="icon-link" href="/admin/docs/routing">{{ icons.link() }}Routing</a></li>
|
<li><a class="icon-link" href="/admin/docs/routing">{{ icons.link() }}Routing</a></li>
|
||||||
<li><a class="icon-link" href="/admin/docs/sidecars">{{ icons.book() }}Sidecars</a></li>
|
<li><a class="icon-link" href="/admin/docs/sidecars">{{ icons.book() }}Sidecars</a></li>
|
||||||
<li><a class="icon-link" href="/admin/docs/forms">{{ icons.email() }}Forms</a></li>
|
<li><a class="icon-link" href="/admin/docs/forms">{{ icons.email() }}Forms</a></li>
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
<li><a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a></li>
|
<li><a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a></li>
|
||||||
<li><a class="icon-link" href="/admin/docs/access-control">{{ icons.lock() }}Access control</a></li>
|
<li><a class="icon-link" href="/admin/docs/access-control">{{ icons.lock() }}Access control</a></li>
|
||||||
<li><a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}Draft pages</a></li>
|
<li><a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}Draft pages</a></li>
|
||||||
|
<li><a class="icon-link" href="/admin/docs/media-manager">{{ icons.tag() }}Media manager</a></li>
|
||||||
<li><a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a></li>
|
<li><a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a></li>
|
||||||
<li><a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a></li>
|
<li><a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a></li>
|
||||||
<li><a class="icon-link" href="/admin/docs/seo">{{ icons.book() }}SEO</a></li>
|
<li><a class="icon-link" href="/admin/docs/seo">{{ icons.book() }}SEO</a></li>
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Docker{% endblock %}
|
||||||
|
|
||||||
|
{% block description %}Running novaconium in a container: the Apache/PHP image, its three volumes, and overriding App/ without a rebuild.{% endblock %}
|
||||||
|
|
||||||
|
{% block robots %}noindex, nofollow{% endblock %}
|
||||||
|
|
||||||
|
{% block docs_content %}
|
||||||
|
<h1>Docker</h1>
|
||||||
|
|
||||||
|
<p>The root <code>Dockerfile</code> builds an Apache + PHP image on an <a href="https://archlinux.org/">Arch Linux</a> base, with <code>mod_rewrite</code>, <code>AllowOverride All</code>, and <code>pdo_sqlite</code>/<code>pdo_mysql</code> already enabled — nothing else in <a href="/admin/docs/getting-started">Getting started</a>'s "Deploy on Apache" section needs configuring by hand. <code>docker-compose.yml</code> wires it up with three named volumes so a project's own cache, uploads, and database never live inside a path that gets wiped by the <a href="/admin/docs/getting-started">"Updating the framework"</a> workflow.</p>
|
||||||
|
|
||||||
|
<pre><code>docker compose up --build</code></pre>
|
||||||
|
|
||||||
|
<p>Visit <code>http://localhost:8080/</code>.</p>
|
||||||
|
|
||||||
|
<h2>The volumes</h2>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><code>cache</code> → <code>public/cache/</code> — the static HTML page cache (see <a href="/admin/docs/caching">Static caching</a>). Disposable; clear it from inside the container with <code>docker compose exec web php novaconium/bin/clear-cache.php</code>.</li>
|
||||||
|
<li><code>uploads</code> → <code>public/uploads/</code> — files uploaded through <a class="icon-link" href="/admin/docs/media-manager">Media manager</a> (<code>/admin/media</code>). Needs its own volume specifically because <code>public/</code> is otherwise baked into the image with <code>COPY</code> at build time — without this, an upload would only survive until the next <code>docker compose up --build</code>. Kept separate from the <code>data</code> volume below deliberately, since a project might use MySQL or no database at all and shouldn't have upload storage coupled to the SQLite volume.</li>
|
||||||
|
<li><code>data</code> → <code>data/</code> — holds <code>data/novaconium.sqlite</code> if <a href="/admin/docs/database">Database</a>-backed features (admin auth, content index) are enabled. Persists across <code>docker compose down</code>/<code>up</code> as long as you don't pass <code>-v</code>.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p><code>App/</code> itself is <strong>not</strong> a named volume — it's baked into the image with <code>COPY</code> at build time, so <code>docker compose up --build</code> alone produces a working site with no extra steps. A named volume seeded from <code>COPY App/</code> would only populate once, on first container creation, and would silently go stale on every later rebuild. To edit content without rebuilding, uncomment the bind mount in <code>docker-compose.yml</code>:</p>
|
||||||
|
|
||||||
|
<pre><code>volumes:
|
||||||
|
- cache:/var/www/html/public/cache
|
||||||
|
- uploads:/var/www/html/public/uploads
|
||||||
|
- data:/var/www/html/data
|
||||||
|
- ./App:/var/www/html/App</code></pre>
|
||||||
|
|
||||||
|
<p>A bind mount at the same path as a <code>COPY</code>'d directory shadows the image layer at container start, so a host-side edit under <code>App/pages/</code> shows up after <code>docker compose restart web</code> — no rebuild, no custom entrypoint logic.</p>
|
||||||
|
|
||||||
|
<h2>MySQL</h2>
|
||||||
|
|
||||||
|
<p><code>Lib\Db</code> supports MySQL alongside or instead of SQLite (see <a href="/admin/docs/database">Database</a>). <code>docker-compose.yml</code> has a commented-out <code>db</code> service and <code>mysql-data</code> volume — uncomment both, add a <code>db_connections</code> entry with <code>driver: mysql</code> and matching credentials in <code>App/config.php</code>, and the app container can reach it at hostname <code>db</code>.</p>
|
||||||
|
|
||||||
|
<h2>Arch-specific notes</h2>
|
||||||
|
|
||||||
|
<p>Arch's <code>php-apache</code> package is built against <code>mpm_prefork</code>, not <code>httpd</code>'s default <code>mpm_event</code> — the Dockerfile swaps MPMs as part of the build. The Apache/PHP worker user on Arch is <code>http</code>, not Debian's <code>www-data</code>; the Dockerfile <code>chown</code>s the cache/uploads/data/App directories and <code>novaconium/contact-log.txt</code> to <code>http:http</code> at build time so a freshly created named volume (which inherits the image mountpoint's ownership) is writable immediately. If you swap a named volume for a bind mount pointing at a host directory with different ownership, that automatic chown doesn't apply — you may need to adjust permissions on the host side.</p>
|
||||||
|
|
||||||
|
<p>This is a separate Dockerfile from the one-off Dart Sass build tool described in <a href="/admin/docs/styling">Styling</a> — that one lives at <code>Dockerfile.sass</code>, a Debian-based image whose only job is running the <code>sass</code> CLI, not serving the app.</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
{% block title %}Docs{% endblock %}
|
{% block title %}Docs{% endblock %}
|
||||||
|
|
||||||
{% block description %}Framework documentation: routing, sidecars, forms, libraries, database, session, content index, XML sitemap, RSS feeds, admin authentication, draft pages, layouts, caching, styling.{% endblock %}
|
{% block description %}Framework documentation: routing, sidecars, forms, libraries, database, session, content index, XML sitemap, RSS feeds, admin authentication, draft pages, media manager, layouts, caching, styling.{% endblock %}
|
||||||
|
|
||||||
{% block robots %}noindex, nofollow{% endblock %}
|
{% block robots %}noindex, nofollow{% endblock %}
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
<li><a class="icon-link" href="/admin/docs/getting-started">{{ icons.book() }}Getting started</a> — requirements, running locally, deploying on Apache.</li>
|
<li><a class="icon-link" href="/admin/docs/getting-started">{{ icons.book() }}Getting started</a> — requirements, running locally, deploying on Apache.</li>
|
||||||
|
<li><a class="icon-link" href="/admin/docs/docker">{{ icons.book() }}Docker</a> — the Apache/PHP image, its three volumes, and overriding <code>App/</code> without a rebuild.</li>
|
||||||
<li><a class="icon-link" href="/admin/docs/routing">{{ icons.link() }}Routing</a> — how a URL maps to a directory under <code>App/pages/</code>.</li>
|
<li><a class="icon-link" href="/admin/docs/routing">{{ icons.link() }}Routing</a> — how a URL maps to a directory under <code>App/pages/</code>.</li>
|
||||||
<li><a class="icon-link" href="/admin/docs/sidecars">{{ icons.book() }}Sidecars</a> — where your PHP logic goes.</li>
|
<li><a class="icon-link" href="/admin/docs/sidecars">{{ icons.book() }}Sidecars</a> — where your PHP logic goes.</li>
|
||||||
<li><a class="icon-link" href="/admin/docs/forms">{{ icons.email() }}Forms</a> — building a custom form with a sidecar, using the novaconium validation/spam libraries.</li>
|
<li><a class="icon-link" href="/admin/docs/forms">{{ icons.email() }}Forms</a> — building a custom form with a sidecar, using the novaconium validation/spam libraries.</li>
|
||||||
@@ -41,6 +42,7 @@
|
|||||||
<li><a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a> — gate <code>/admin/*</code> behind a session login, with admin/registered roles, groups, and user management.</li>
|
<li><a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a> — gate <code>/admin/*</code> behind a session login, with admin/registered roles, groups, and user management.</li>
|
||||||
<li><a class="icon-link" href="/admin/docs/access-control">{{ icons.lock() }}Access control</a> — assign a page or section to a user or group from its sidecar with <code>Lib\Access</code>; public by default, static pages always public.</li>
|
<li><a class="icon-link" href="/admin/docs/access-control">{{ icons.lock() }}Access control</a> — assign a page or section to a user or group from its sidecar with <code>Lib\Access</code>; public by default, static pages always public.</li>
|
||||||
<li><a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}Draft pages</a> — let an admin preview a page before the public can see it, reusing the same auth check.</li>
|
<li><a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}Draft pages</a> — let an admin preview a page before the public can see it, reusing the same auth check.</li>
|
||||||
|
<li><a class="icon-link" href="/admin/docs/media-manager">{{ icons.tag() }}Media manager</a> — upload, browse, and delete files under <code>public/uploads/</code> from <code>/admin/media</code>.</li>
|
||||||
<li><a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a> — pages and layouts are overridable, just like <code>Lib\</code>.</li>
|
<li><a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a> — pages and layouts are overridable, just like <code>Lib\</code>.</li>
|
||||||
<li><a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a> — how sidecar-less pages get served as static HTML.</li>
|
<li><a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a> — how sidecar-less pages get served as static HTML.</li>
|
||||||
<li><a class="icon-link" href="/admin/docs/seo">{{ icons.book() }}SEO</a> — the meta tags every page gets for free, and how to override them.</li>
|
<li><a class="icon-link" href="/admin/docs/seo">{{ icons.book() }}SEO</a> — the meta tags every page gets for free, and how to override them.</li>
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||||
|
|
||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
|
||||||
|
{% block title %}Media manager{% endblock %}
|
||||||
|
|
||||||
|
{% block description %}Upload, browse, and delete files under public/uploads/ from /admin/media — extension allowlist, filename sanitization, max upload size.{% endblock %}
|
||||||
|
|
||||||
|
{% block robots %}noindex, nofollow{% endblock %}
|
||||||
|
|
||||||
|
{% block docs_content %}
|
||||||
|
<h1 class="icon-heading">{{ icons.tag() }}Media manager</h1>
|
||||||
|
|
||||||
|
<p><a href="/admin/media">/admin/media</a> is an upload/browse/delete UI for files (images, PDFs, and whatever else you allow) so sidecars and Twig templates have a consistent place to reference uploaded assets from — e.g. a blog post's header image — instead of authors manually copying files into <code>public/</code>. It's a plain directory, <code>public/uploads/</code>, not a database-backed feature: files are already static assets, so there's nothing for <code>Lib\Db</code> to do here.</p>
|
||||||
|
|
||||||
|
<p>Covered by the existing <code>/admin/*</code> auth gate the moment the page exists — unlike <a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}admin authentication</a> or <a class="icon-link" href="/admin/docs/content-index">{{ icons.search() }}the content index</a>, it has no SQLite dependency to gate behind its own flag, so there's no <code>media_manager_enabled</code> key — it's simply on wherever <code>/admin/*</code> is reachable.</p>
|
||||||
|
|
||||||
|
<h2>Configuration</h2>
|
||||||
|
|
||||||
|
<p>Two keys in <code>App/config.php</code> (framework defaults in <code>novaconium/config.php</code>):</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><code>media_upload_extensions</code> — an allowlist of lowercase extensions (no leading dot), matched case-insensitively against the uploaded filename. Defaults to <code>['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf', 'txt', 'zip']</code>.</li>
|
||||||
|
<li><code>media_upload_max_bytes</code> — caps a single file's size. Defaults to 10 MB. A file larger than PHP's own <code>upload_max_filesize</code>/<code>post_max_size</code> ini limits is rejected by PHP itself before this check ever runs (reported via <code>UPLOAD_ERR_INI_SIZE</code>/<code>UPLOAD_ERR_FORM_SIZE</code>) — raise those ini limits too if you raise <code>media_upload_max_bytes</code> past them.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Safety handling</h2>
|
||||||
|
|
||||||
|
<p>Three things every upload goes through, in <code>novaconium/pages/admin/media/index.php</code>:</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><strong>Extension allowlist</strong> — rejected before the file ever touches disk if its extension isn't in <code>media_upload_extensions</code>.</li>
|
||||||
|
<li><strong>Filename sanitization</strong> — the uploaded filename is run through <code>basename()</code> (strips directory components and <code>..</code>) and then reduced to a safe character set (<code>A-Za-z0-9._-</code>). A name collision gets a <code>-1</code>, <code>-2</code>, etc. suffix rather than overwriting the existing file. Deletes re-derive the same safe name from the request and re-verify with <code>realpath()</code> that the resolved path still lands inside <code>public/uploads/</code> before unlinking anything.</li>
|
||||||
|
<li><strong>Max upload size</strong> — checked against both <code>$_FILES</code>' reported size and PHP's own ini limits (see above).</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>Uploaded files are served directly by Apache at <code>/uploads/<filename></code> — <code>public/uploads/</code> is a plain static directory, not routed through the framework, the same as <code>public/cache/</code>. It's gitignored per-file with a tracked <code>.gitkeep</code>, the same convention as <code>data/</code> (see <a class="icon-link" href="/admin/docs/project-layout">{{ icons.sitemap() }}Project layout</a>) — uploads are runtime content, not something a fresh checkout should ship with.</p>
|
||||||
|
|
||||||
|
<p>There's no metadata store (alt text, captions) — if you need that later, it's a natural fit for <a class="icon-link" href="/admin/docs/database">{{ icons.book() }}SQLite</a> rather than encoding it into filenames.</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
<p>There's no PHP-based Sass compiler in this project (PHP options like <code>scssphp</code> only understand SCSS syntax) — this is a manual/CI build step, not something the app does at runtime.</p>
|
<p>There's no PHP-based Sass compiler in this project (PHP options like <code>scssphp</code> only understand SCSS syntax) — this is a manual/CI build step, not something the app does at runtime.</p>
|
||||||
|
|
||||||
<p>Don't have Dart Sass installed? Run it via Docker instead — copy-paste this Dockerfile, which installs the same official standalone Dart Sass release used in this environment (<code>1.101.0</code>, via <code>pacman -S dart-sass</code> on Arch), not the npm-wrapped build:</p>
|
<p>Don't have Dart Sass installed? Run it via Docker instead — copy-paste this into a file named <code>Dockerfile.sass</code> (the project's own root <code>Dockerfile</code> is the app container, see <a href="/admin/docs/docker">Docker</a> — this is a separate, one-off build tool, so it gets its own filename), which installs the same official standalone Dart Sass release used in this environment (<code>1.101.0</code>, via <code>pacman -S dart-sass</code> on Arch), not the npm-wrapped build:</p>
|
||||||
|
|
||||||
<pre><code>FROM debian:bookworm-slim
|
<pre><code>FROM debian:bookworm-slim
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ ENTRYPOINT ["sass"]</code></pre>
|
|||||||
|
|
||||||
<p>Build it once, then run it the same way you'd run the local <code>sass</code> CLI (skip the leading <code>sass</code> in the command — the image's <code>ENTRYPOINT</code> already supplies it):</p>
|
<p>Build it once, then run it the same way you'd run the local <code>sass</code> CLI (skip the leading <code>sass</code> in the command — the image's <code>ENTRYPOINT</code> already supplies it):</p>
|
||||||
|
|
||||||
<pre><code>docker build -t novaconium-sass -f Dockerfile .
|
<pre><code>docker build -t novaconium-sass -f Dockerfile.sass .
|
||||||
docker run --rm -v "$(pwd):/usr/src/app" -w /usr/src/app novaconium-sass \
|
docker run --rm -v "$(pwd):/usr/src/app" -w /usr/src/app novaconium-sass \
|
||||||
--load-path=App/sass --load-path=novaconium/sass/defaults novaconium/sass/main.sass public/css/main.css</code></pre>
|
--load-path=App/sass --load-path=novaconium/sass/defaults novaconium/sass/main.sass public/css/main.css</code></pre>
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<li><a class="icon-link" href="/admin/clear-cache">{{ icons.trash() }}Clear cache</a></li>
|
<li><a class="icon-link" href="/admin/clear-cache">{{ icons.trash() }}Clear cache</a></li>
|
||||||
<li><a class="icon-link" href="/admin/docs">{{ icons.book() }}Project docs</a></li>
|
<li><a class="icon-link" href="/admin/docs">{{ icons.book() }}Project docs</a></li>
|
||||||
|
<li><a class="icon-link" href="/admin/media">{{ icons.tag() }}Media</a></li>
|
||||||
{% if admin_auth_enabled %}<li><a class="icon-link" href="/admin/users">{{ icons.users() }}Users</a></li>{% endif %}
|
{% if admin_auth_enabled %}<li><a class="icon-link" href="/admin/users">{{ icons.users() }}Users</a></li>{% endif %}
|
||||||
{% if admin_auth_enabled %}<li><a class="icon-link" href="/admin/logout">{{ icons.external_link() }}Logout</a></li>{% endif %}
|
{% if admin_auth_enabled %}<li><a class="icon-link" href="/admin/logout">{{ icons.external_link() }}Logout</a></li>{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Response;
|
||||||
|
use Lib\Csrf;
|
||||||
|
use Lib\Input;
|
||||||
|
use Lib\Session;
|
||||||
|
|
||||||
|
// Same two-step config load as other /admin/* sidecars that read config
|
||||||
|
// directly (e.g. admin/users) — no *_enabled flag to check here, this
|
||||||
|
// feature has no SQLite dependency, and bootstrap.php's admin gate already
|
||||||
|
// covers /admin/media like every other /admin/* route.
|
||||||
|
$config = require __DIR__ . '/../../../config.php';
|
||||||
|
$appConfigFile = __DIR__ . '/../../../../App/config.php';
|
||||||
|
if (is_file($appConfigFile)) {
|
||||||
|
$config = array_merge($config, require $appConfigFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
$uploadDir = __DIR__ . '/../../../../public/uploads';
|
||||||
|
$allowedExtensions = $config['media_upload_extensions'];
|
||||||
|
$maxBytes = $config['media_upload_max_bytes'];
|
||||||
|
|
||||||
|
// Filenames are user input (from the browser's original filename or a
|
||||||
|
// delete request) and end up in filesystem calls, so every path built from
|
||||||
|
// one is basename()'d first (strips directory components/`..`) and then
|
||||||
|
// re-verified with realpath() to land inside $uploadDir before any
|
||||||
|
// read/write/delete touches disk.
|
||||||
|
$safeName = static function (string $name): string {
|
||||||
|
$name = basename($name);
|
||||||
|
$name = preg_replace('/[^A-Za-z0-9._-]/', '_', $name) ?? '';
|
||||||
|
$name = ltrim($name, '.');
|
||||||
|
|
||||||
|
return $name === '' ? 'file' : $name;
|
||||||
|
};
|
||||||
|
|
||||||
|
$resolveInUploadDir = static function (string $filename) use ($uploadDir): string|false {
|
||||||
|
$path = $uploadDir . '/' . $filename;
|
||||||
|
$real = realpath($path);
|
||||||
|
$realDir = realpath($uploadDir);
|
||||||
|
|
||||||
|
if ($real === false || $realDir === false || !str_starts_with($real, $realDir . DIRECTORY_SEPARATOR)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $real;
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
if (!Csrf::verify(Input::post('csrf_token'))) {
|
||||||
|
Session::flash('media_error', 'Your session expired before submitting — please try again.');
|
||||||
|
|
||||||
|
return Response::redirect('/admin/media', 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
$action = Input::post('action', '');
|
||||||
|
|
||||||
|
if ($action === 'upload') {
|
||||||
|
$file = $_FILES['file'] ?? null;
|
||||||
|
|
||||||
|
if ($file === null || !is_uploaded_file($file['tmp_name'] ?? '')) {
|
||||||
|
Session::flash('media_error', 'Choose a file to upload.');
|
||||||
|
} elseif ($file['error'] === UPLOAD_ERR_INI_SIZE || $file['error'] === UPLOAD_ERR_FORM_SIZE) {
|
||||||
|
Session::flash('media_error', 'That file is too large.');
|
||||||
|
} elseif ($file['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
Session::flash('media_error', 'Upload failed — please try again.');
|
||||||
|
} elseif ($file['size'] > $maxBytes) {
|
||||||
|
Session::flash('media_error', sprintf('That file is too large — %s max.', number_format($maxBytes / 1024 / 1024, 1) . ' MB'));
|
||||||
|
} else {
|
||||||
|
$extension = strtolower(pathinfo((string) $file['name'], PATHINFO_EXTENSION));
|
||||||
|
|
||||||
|
if (!in_array($extension, $allowedExtensions, true)) {
|
||||||
|
Session::flash('media_error', sprintf("That file type isn\u{2019}t allowed — allowed types: %s.", implode(', ', $allowedExtensions)));
|
||||||
|
} else {
|
||||||
|
$name = $safeName((string) $file['name']);
|
||||||
|
|
||||||
|
// Avoid clobbering an existing file of the same name —
|
||||||
|
// append -1, -2, etc. before the extension until free.
|
||||||
|
$base = pathinfo($name, PATHINFO_FILENAME);
|
||||||
|
$ext = pathinfo($name, PATHINFO_EXTENSION);
|
||||||
|
$candidate = $name;
|
||||||
|
$i = 1;
|
||||||
|
|
||||||
|
while (is_file($uploadDir . '/' . $candidate)) {
|
||||||
|
$candidate = $ext !== '' ? "{$base}-{$i}.{$ext}" : "{$base}-{$i}";
|
||||||
|
$i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_dir($uploadDir)) {
|
||||||
|
mkdir($uploadDir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (move_uploaded_file($file['tmp_name'], $uploadDir . '/' . $candidate)) {
|
||||||
|
Session::flash('media_notice', "Uploaded \u{201C}{$candidate}\u{201D}.");
|
||||||
|
} else {
|
||||||
|
Session::flash('media_error', 'Upload failed — please try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} elseif ($action === 'delete') {
|
||||||
|
$filename = $safeName((string) Input::post('filename', ''));
|
||||||
|
$real = $resolveInUploadDir($filename);
|
||||||
|
|
||||||
|
if ($real === false || !is_file($real)) {
|
||||||
|
Session::flash('media_error', 'No such file.');
|
||||||
|
} else {
|
||||||
|
unlink($real);
|
||||||
|
Session::flash('media_notice', "Deleted \u{201C}{$filename}\u{201D}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response::redirect('/admin/media', 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = [];
|
||||||
|
|
||||||
|
if (is_dir($uploadDir)) {
|
||||||
|
foreach (scandir($uploadDir) as $entry) {
|
||||||
|
// .gitkeep keeps the otherwise-gitignored directory tracked in git
|
||||||
|
// (see .gitignore's /public/uploads/* rule) — not a real upload.
|
||||||
|
if ($entry === '.' || $entry === '..' || $entry === '.gitkeep' || str_starts_with($entry, '.')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $uploadDir . '/' . $entry;
|
||||||
|
|
||||||
|
if (!is_file($path)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$files[] = [
|
||||||
|
'name' => $entry,
|
||||||
|
'size' => filesize($path),
|
||||||
|
'modified' => gmdate('Y-m-d H:i', filemtime($path)),
|
||||||
|
'url' => '/uploads/' . rawurlencode($entry),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($files, static fn (array $a, array $b): int => strcmp($a['name'], $b['name']));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'files' => $files,
|
||||||
|
'allowedExtensions' => $allowedExtensions,
|
||||||
|
'maxBytes' => $maxBytes,
|
||||||
|
'notice' => Session::getFlash('media_notice'),
|
||||||
|
'error' => Session::getFlash('media_error'),
|
||||||
|
'csrfField' => Csrf::fieldName(),
|
||||||
|
'csrfToken' => Csrf::token(),
|
||||||
|
];
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
{% extends layout %}
|
||||||
|
|
||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
|
||||||
|
{% block title %}Media{% endblock %}
|
||||||
|
|
||||||
|
{% block description %}Upload, browse, and delete files under public/uploads/.{% endblock %}
|
||||||
|
|
||||||
|
{% block robots %}noindex, nofollow{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<article>
|
||||||
|
<h1 class="icon-heading">{{ icons.book() }}Media</h1>
|
||||||
|
|
||||||
|
<p>Files uploaded here live under <code>public/uploads/</code> and are served directly at the URL shown below each file — reference one from a sidecar or Twig template the same way you'd link any other static asset. Allowed types: {% for ext in allowedExtensions %}<code>.{{ ext }}</code>{% if not loop.last %}, {% endif %}{% endfor %}. Max size: {{ (maxBytes / 1024 / 1024)|round(1, 'floor') }} MB.</p>
|
||||||
|
|
||||||
|
{% if notice %}
|
||||||
|
<p><strong>{{ notice }}</strong></p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<p><strong>{{ error }}</strong></p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h2>Upload a file</h2>
|
||||||
|
|
||||||
|
<form method="post" action="/admin/media" enctype="multipart/form-data">
|
||||||
|
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||||
|
<input type="hidden" name="action" value="upload">
|
||||||
|
<p>
|
||||||
|
<label for="file">File</label><br>
|
||||||
|
<input type="file" id="file" name="file" required>
|
||||||
|
</p>
|
||||||
|
<button type="submit">Upload</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h2>Files</h2>
|
||||||
|
|
||||||
|
{% if files is empty %}
|
||||||
|
<p>No files uploaded yet.</p>
|
||||||
|
{% else %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>File</th>
|
||||||
|
<th>Size</th>
|
||||||
|
<th>Modified</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for file in files %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ file.url }}">{{ file.name }}</a></td>
|
||||||
|
<td>{{ (file.size / 1024)|round(1, 'floor') }} KB</td>
|
||||||
|
<td>{{ file.modified }}</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/admin/media">
|
||||||
|
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||||
|
<input type="hidden" name="filename" value="{{ file.name }}">
|
||||||
|
<input type="hidden" name="action" value="delete">
|
||||||
|
<button type="submit">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user