Sidecar-less page bundles whose directory ends in a MediaType extension
(.txt/.css/.xml/.json/.js) now render raw (no HTML layout, autoescape off),
are served with that extension's Content-Type, and cache as a flat file
(public/cache/<path>) so Apache serves them directly via a new .htaccess
rule. They're excluded from the content index.
CSS becomes one of these: novaconium/pages/css/main.css/ holds index.twig
({{ sass('main.sass') }}), main.sass, and _colors.sass. App\SassExtension's
sass() Twig function resolves the entry file through the overlay and shells
out to Dart Sass on a cache miss; Lib\SassCompiler wraps the CLI. This
drops the committed App/css/main.css build artifact and the public/css
bind mount. Override styling by copying the whole bundle to App/pages/.
Also ship robots.txt and humans.txt as framework bundles, and drop the
php -S dev router (public/router.php) - Docker/Apache only now.
New: novaconium/src/MediaType.php, SassExtension.php, lib/SassCompiler.php.
Moved: novaconium/sass/ -> novaconium/pages/css/main.css/ (+ App/sass merged).
Docs: AGENTS.md, new /admin/docs/text-pages, and styling/caching/docker/
design-notes/getting-started/project-layout/config docs updated.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Q9cAXC9xcXYnmP7qjsnw7
224 lines
12 KiB
Markdown
224 lines
12 KiB
Markdown
# AGENTS.md
|
|
|
|
Context for any coding agent working in this repo — Claude, DeepSeek, or
|
|
otherwise. Full narrative docs live at `/admin/docs` when the app is
|
|
running. `README.md` is the GitHub-facing pitch, `novaconium/ISSUES.md` is
|
|
the roadmap/backlog, and this file is the short, agent-facing version:
|
|
load-bearing gotchas and conventions only, not narrative history.
|
|
|
|
**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,
|
|
admin auth, styling, Docker, project layout, third-party) has exactly one
|
|
canonical writeup: a page under `novaconium/pages/admin/docs/<topic>/index.twig`.
|
|
`README.md` deliberately does **not** mirror this content — it's a short
|
|
GitHub-facing pitch (what this is, minimal steps to get it running, a
|
|
pointer into `/admin/docs`) plus the Third-party section, nothing more. The
|
|
full feature list lives as a blog post, `App/pages/blog/novaconium-features/`
|
|
(sample content, replaceable like any other post), not in the README. This
|
|
was a deliberate change (2026-07-15) away from an earlier "keep README and
|
|
docs in sync" convention that had made the README long and hard to scan —
|
|
don't re-add a feature list or per-topic bullet list to README.md.
|
|
|
|
Any change to framework behavior or a new feature:
|
|
|
|
1. Update/add the docs page, and if new, link it from both
|
|
`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
|
|
|
|
A dependency-light PHP + Twig micro-framework: directories under `pages/`
|
|
map directly to URLs (Hugo-style page bundles), optional `index.php`
|
|
sidecars supply data or short-circuit to a `Response`, and sidecar-less
|
|
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
|
|
vendored as plain source files.
|
|
|
|
## The two-root split
|
|
|
|
- **`App/`** — the project: `App/pages/`, `App/lib/` (`Lib\` classes),
|
|
`App/config.php`, `App/migrations/`. The only directory a site author is
|
|
expected to touch.
|
|
- **`novaconium/`** — the framework: router/renderer core
|
|
(`novaconium/src/`), default pages/libs (including the `css/main.css/`,
|
|
`robots.txt/`, `humans.txt/` bundles), vendored Twig, autoloader, config,
|
|
bootstrap.
|
|
|
|
Routing/rendering resolve against **both roots, `App/` first** (via
|
|
`novaconium/src/Overlay.php` for pages, `novaconium/autoload.php` for
|
|
`Lib\` classes) — same override-by-presence mechanism used for
|
|
`config.php` and Twig's `FilesystemLoader`. A project only lists the config
|
|
keys it's changing in `App/config.php`; never edit `novaconium/config.php`
|
|
directly.
|
|
|
|
**`db_connections` is the one config key that isn't a plain shallow-merge.**
|
|
`Lib\Db::config()` (and the duplicate in `bin/migrate.php`) merges it one
|
|
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`.
|
|
|
|
`Lib\Db` supports multiple, simultaneously-open named connections
|
|
(`'sqlite'`/`'mysql'` drivers only). Each connection migrates lazily on
|
|
first use, tracked by path **relative to the repo root** (not bare
|
|
filename — two roots can share a filename). `migrations_dir` accepts an
|
|
ordered list of roots, each fully processed before the next.
|
|
|
|
**The default DB path (`data/novaconium.sqlite`) lives outside `public/`
|
|
(web-accessible) and `novaconium/`** (wholesale-replaced by framework
|
|
updates) — it's a project-owned top-level dir, gitignored per-content with
|
|
a tracked `.gitkeep`. Uploaded files (see Media manager,
|
|
`/admin/docs/media-manager`) live under `public/uploads/` instead, since
|
|
they need to be web-reachable directly — a separate, plain static
|
|
directory on its own volume, not coupled to the SQLite path, since a
|
|
project may run MySQL or no DB at all.
|
|
|
|
## Standing rule: caching vs. any content-hiding mechanism
|
|
|
|
**Any mechanism that conditionally hides page content from the public
|
|
must be threaded into `Renderer::render()`'s `$excludeFromCache` param, not
|
|
just a pre-render auth gate.** `Renderer::render()` writes a sidecar-less
|
|
page's output to the static HTML cache, and `.htaccess` serves a cached
|
|
file *before PHP (and therefore any auth check) ever runs again*. A route
|
|
gated only at the auth-check level still leaks to the public the moment an
|
|
authorized user views it once, if the page has no sidecar. `draft_routes`
|
|
and every `/admin/*` route already pass `true` for this reason. Any new
|
|
feature that gates a route by anything other than a sidecar check needs the
|
|
same treatment — this has caused a real bug before, twice.
|
|
|
|
Corollary: `Lib\Access` (the sidecar-level content gate, see
|
|
`/admin/docs/access-control`) is safe by construction — a page with no
|
|
sidecar can't call `Access`, and only sidecar-less pages get cached, so a
|
|
gated page can never leak through the cache with no extra wiring needed.
|
|
|
|
## Reentrancy hazard: ContentIndexer
|
|
|
|
`ContentIndexer::reindex()` renders every routable page, including
|
|
`/search` itself, which also calls `ContentIndexer::ensureFresh()`.
|
|
Guarded by a `private static bool $indexing` flag checked at the top of
|
|
both methods — don't remove it, any new consumer route inherits the same
|
|
hazard automatically. `reindex()` also forces
|
|
`$_SERVER['REQUEST_METHOD']` to `'GET'` for the duration of the crawl
|
|
(restored in a `finally`) so a lazy reindex triggered from a POST can't
|
|
leak that POST into an unrelated page's sidecar.
|
|
|
|
## Vendored dependency placement
|
|
|
|
**Server-side-only (PHP, autoloaded) → `novaconium/vendor/`. Anything a
|
|
browser fetches (`.js`, `.css`, images) → `public/vendor/`** — `novaconium/`
|
|
is never web-reachable. This matters beyond correctness: `public/` is
|
|
project-owned and untouched by a framework update, so a `public/vendor/`
|
|
dependency bump does **not** propagate automatically the way a
|
|
`novaconium/vendor/` bump would — re-vendoring is a manual step per
|
|
dependency (see `/admin/docs/upgrading-highlightjs`).
|
|
|
|
## Twig gotchas that will fatal without `mbstring`
|
|
|
|
Don't use `|slice` on a **string** (calls `mb_substr()` unconditionally) or
|
|
`|escape('js')`/`'js'` arg to `|e` (calls `mb_ord()`) — both hard-require
|
|
`mbstring` and fatal without it; this project deliberately avoids that
|
|
dependency. Truncate strings in PHP with an `mb_substr`/`substr` fallback
|
|
instead. For markup destined for inline `<script>`, render into a
|
|
`<template>` element and read `.innerHTML` in JS rather than
|
|
`|escape('js')`.
|
|
|
|
`class="nohighlight"` marks a `<pre><code>` block containing literal Twig
|
|
syntax (`{% %}`/`{{ }}`) — highlight.js has no Twig grammar and a
|
|
restricted auto-detect still always guesses wrong without this class. Any
|
|
new Twig-syntax code sample needs it; PHP/Bash samples don't.
|
|
|
|
## Typed text pages (CSS, robots.txt, humans.txt, …)
|
|
|
|
A sidecar-less page bundle whose **directory segment ends in an extension**
|
|
in `App\MediaType::MAP` (`.txt` `.css` `.xml` `.json` `.js`) is a *typed
|
|
text page*: `Renderer` renders its `index.twig` with **no** HTML layout and
|
|
**autoescaping off**, serves it with that extension's Content-Type, and
|
|
`Cache` writes it as a flat file (`public/cache/css/main.css`, not
|
|
`.../index.html`). A rewrite in `public/.htaccess` (rule 2b) then serves
|
|
that file straight from Apache on every later request — so the extension
|
|
list is duplicated there; keep it in sync with `MediaType::MAP`. These
|
|
pages are excluded from the content index (`ContentIndexer::isCrawlable()`).
|
|
See `/admin/docs/text-pages`.
|
|
|
|
The framework ships three: `novaconium/pages/css/main.css/` (route
|
|
`/css/main.css`), `novaconium/pages/robots.txt/`, `novaconium/pages/humans.txt/`.
|
|
A project overrides one the normal App-over-novaconium way — put its own
|
|
bundle at the same relative path under `App/pages/`.
|
|
|
|
CSS is the interesting one: `novaconium/pages/css/main.css/` holds
|
|
`index.twig` (`{{- sass('main.sass') -}}`), `main.sass`, and `_colors.sass`.
|
|
The `sass()` Twig function (`App\SassExtension` → `Lib\SassCompiler`)
|
|
resolves the entry file through the overlay (App first) and shells out to
|
|
Dart Sass with that file's own directory as the sole `--load-path`, so
|
|
`@use 'colors'` resolves to the sibling `_colors.sass` in whichever copy of
|
|
the bundle won. To customise styling, copy the **whole** `css/main.css/`
|
|
bundle to `App/pages/css/main.css/` — there's no partial "just
|
|
`_colors.sass`" override.
|
|
Output is cached like any typed page; run
|
|
`php novaconium/bin/clear-cache.php` after editing Sass. Every color rule
|
|
reads a CSS custom property (`var(--bg)` …), never a Sass variable directly
|
|
— required for the runtime dark/light toggle; adding a color means adding
|
|
its plain and `-light` variable in `_colors.sass` and wiring both into the
|
|
two `:root` blocks in `main.sass`.
|
|
|
|
## Input handling
|
|
|
|
Sidecars read request data via `Lib\Input::post()`/`::get()`, not
|
|
`$_POST`/`$_GET` directly (trims, strips tags/null bytes — XSS
|
|
defense-in-depth, **not** SQL-injection protection; use PDO prepared
|
|
statements via `Lib\Db::query()` for that, never string-interpolated SQL).
|
|
Exception: fields needing an exact unmodified value (e.g. a password about
|
|
to be hashed) read `$_POST` directly — see login/users sidecars.
|
|
`Lib\Csrf::verify()` is called directly by a sidecar, not wired into
|
|
`FormValidator`.
|
|
|
|
## Running it
|
|
|
|
Only via the Docker image built from `Dockerfile` (Apache reads
|
|
`public/.htaccess` directly — there is no `php -S` dev router). Typically:
|
|
|
|
```
|
|
docker compose up # image + volumes from docker-compose.yml
|
|
```
|
|
|
|
There is no test suite — verification is manual route-by-route (see
|
|
`/admin/docs/design-notes`'s Verification section). After testing, clear
|
|
stray cache with `php novaconium/bin/clear-cache.php` and remove any
|
|
test-only debris from `App/lib/`/`App/pages/` — nothing there is gitignored
|
|
except `public/cache/*` and `novaconium/contact-log.txt`.
|
|
|
|
## Conventions worth knowing
|
|
|
|
- Reserved segments: any path segment starting with `_` or literally named
|
|
`404` is never routable — `Router::resolve()` 404s on sight.
|
|
- Sidecars (`index.php`) return an array (Twig context) or a `Response`.
|
|
`$params` and `$cache` are in scope automatically — see
|
|
`novaconium/src/Renderer.php::runSidecar()`.
|
|
- No Composer — `novaconium/autoload.php` is a hand-rolled PSR-4 loader. A
|
|
new framework-core class goes under `App\` in `novaconium/src/`; a new
|
|
`Lib\` class goes in `App/lib/` or `novaconium/lib/`.
|
|
- `novaconium/bin/` holds standalone CLI entry points
|
|
(`php novaconium/bin/<script>.php`) — distinct from
|
|
`bootstrap.php`/`autoload.php`/`config.php`, which are only `require`'d.
|
|
- CSS is a **typed text page** (see that section above), not a build
|
|
artifact: the Sass lives in `novaconium/pages/css/main.css/` (override at
|
|
`App/pages/css/main.css/`) and is compiled on demand by the `sass()` Twig
|
|
function, cached to `public/cache/css/main.css`. There is nothing to
|
|
pre-build or commit. The Docker image ships Dart Sass standalone (pinned
|
|
`DART_SASS_VERSION` in the `Dockerfile`, symlinked to
|
|
`/usr/local/bin/sass`) as a runtime dependency. See `/admin/docs/styling`
|
|
and `/admin/docs/text-pages`.
|