Files
novaconium/AGENTS.md
T
code c0455241ea Slim down README: features to a blog post, docs pointer, ASCII banner
Features section becomes a "Novaconium Features" blog post
(App/pages/blog/novaconium-features/), demonstrating the framework's own
content model rather than living as a long bullet list in README.md.
Getting started is trimmed to the minimum needed to run the site and
reach /admin/docs, which is now the single canonical source for every
topic (Docker, deploying, project layout, etc.) instead of being
mirrored into README.md. Third-party section kept as-is. AGENTS.md's
documentation-duplication rule updated to describe this new split so
future changes don't re-bloat the README. Also adds an ASCII art banner
above the title.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 07:44:51 +00:00

198 lines
10 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/`, `App/sass/`. The only directory a
site author is expected to touch.
- **`novaconium/`** — the framework: router/renderer core
(`novaconium/src/`), default pages/libs, 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`, Twig's `FilesystemLoader`, and Sass (see below). 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.
## Sass override quirk
`novaconium/sass/main.sass` does `@use 'colors' as *` with **no**
`_colors.sass` sibling in `novaconium/sass/` — on purpose. Dart Sass
resolves a bare `@use` relative to the importing file's own directory
*before* `--load-path`, so a sibling file would always win and silently
defeat the `App/sass/_colors.sass` override. The framework default lives
at `novaconium/sass/defaults/_colors.sass` instead. Don't move it back.
Every color rule in `main.sass` reads a CSS custom property (`var(--bg)`,
etc.), never a Sass variable directly — required for the runtime dark/light
toggle. Adding a color means adding both the plain and `-light` variable in
**both** `_colors.sass` files and wiring it into both `:root` blocks.
## 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
```
php -S 127.0.0.1:8000 -t public public/router.php
```
`public/router.php` is dev-only, mimics `public/.htaccess`. 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 compiles from `novaconium/sass/main.sass` (indented syntax) to
`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`
— commit the regenerated CSS. See `/admin/docs/styling` for a Docker
fallback if `sass` isn't installed locally.