# AGENTS.md — Lamb Codebase Guide Lamb is a self-hosted, single-author microblog. It uses PHP 8.4+, SQLite (via RedBeanPHP ORM), and a procedural-with-namespaces architecture. There is no MVC framework — routing, responses, and views are handled by small namespaced PHP files. ## Documentation (End-User) The end-user documentation lives in `docs/` (tracked in the repository, served via GitHub Pages). It is the end-user manual. When working on user-facing features: - Check whether a docs page exists for the feature and update it if needed. - When adding new user-facing behaviour, consider whether a new docs page is warranted. - Ensure docs pages that are topically related link to each other via a "Related" section. GitHub Pages publishes the `release` branch's `docs/` folder, so the live site always matches the latest released version. Docs changes merged to `main` go live when `main` is merged into `release`. Preview the in-development docs locally with `make docs` (serves at http://localhost:4000/lamb/). ## Key Commands ```bash # Install dependencies viv install # Start dev server (PHP built-in, port 8747) viv run serve # Lint (PSR-2/PSR-12 + PHPCompatibility) viv run lint # Run all tests vendor/bin/codecept run # Run only unit tests vendor/bin/codecept run Unit # Run acceptance tests (requires SITE_URL in .env) vendor/bin/codecept run Acceptance # Run client-side JavaScript unit tests (node:test + jsdom) pnpm test # Generate password hash and write .env (warns on STDERR if the password is weak). # By default the cleartext password is NOT written to .env; the acceptance suite # opts in via LAMB_WRITE_TEST_PASSWORD=1 to get LAMB_TEST_PASSWORD. php make-password.php LAMB_WRITE_TEST_PASSWORD=1 php make-password.php # for acceptance tests # Static analysis viv run analyse # Auto-fix coding standard violations viv run fix # Install pre-commit hook (one-time, after cloning) printf '#!/bin/sh\nset -e\nviv run lint\nviv run analyse\n' > .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit # Take screenshots at mobile/tablet/desktop (requires viv run serve to be running) PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=$HOME/.cache/ms-playwright/chromium-1208/chrome-linux64/chrome \ pnpm run screenshot [/path] [outdir] # Before/after: git stash → screenshot → git stash pop → screenshot ``` ### Screenshot notes - JS deps: `pnpm install` (not npm); Playwright is `@playwright/test` - The PHP dev server must be started with `php -S 0.0.0.0:8747 -t src` (no router script argument) - Chromium executable: `~/.cache/ms-playwright/chromium-1208/chrome-linux64/chrome` (note `chrome-linux64`; version dir may differ per machine — check `ls ~/.cache/ms-playwright/`) - Script: `scripts/screenshot.mjs [path] [outdir]` ### Worktrees Run `viv install` inside each git worktree. Don't share or symlink one checkout's `vendor/` into another: the generated autoloader resolves to the checkout that created it, so a worktree pointed at another checkout's `vendor/` silently runs and tests that checkout's `src/`, not the worktree's — green tests against the wrong code. A per-worktree `viv install` also registers the PHPCompatibility standard for that checkout (handled on install by the `phpcodesniffer-composer-installer` plugin), so `viv run lint` works without any global phpcs config. ### Cloud sessions If the SessionStart hook warns that `viv install` is incomplete (a network that blocks GitHub downloads but allows git), run `bash .claude/hooks/cloud-fallback.sh`. ## Dependencies Dependabot (`.github/dependabot.yml`) watches all five ecosystems weekly — composer, npm, github-actions, docker, devcontainers — and `dependabot-auto-merge.yml` merges patch and minor bumps once CI is green. Dependabot parses `composer.json`/`composer.lock` directly, so the composer ecosystem is unaffected by installing dependencies with viv instead of Composer. Vulnerabilities are caught by `viv audit` (quality job) and `pnpm audit` (js-test job). Lamb does not publish a prebuilt Docker image. Docker is a documented self-build path (`docs/docker.md`), so base-image CVEs are the operator's to manage on their own build; Dependabot's `docker` ecosystem keeps the base image in `.docker/` current. `release-verify.yml` still builds and acceptance-tests the release image before each release. Two things that automation deliberately does **not** handle, so they need a human: - **Major bumps never auto-merge.** They arrive as individual PRs and will sit until someone reviews them, which is how a project quietly ends up on an unsupported branch. Check for outstanding majors when cutting a release — it's a checklist item in `RELEASING.md` step 1. `viv outdated` and `pnpm outdated` list them without needing an install. - **Hand-written pins and overrides are invisible to Dependabot.** It does not update pnpm `overrides`, so a pin added to dodge an advisory becomes a permanent *ceiling* that silently holds a package below what its parent declares. This already bit us once: an `undici: ^7.28.0` override kept resolving undici 7.x after jsdom 30 moved to `^8.7.0`. If you must add one, say in a comment why it exists and what would let it go — then remove it as soon as the upstream range covers the fix. New dependencies are subject to a 3-day supply-chain cooldown (`cooldown` in `dependabot.yml`, `min-release-age` in `.npmrc`). To pull something newer by hand — e.g. a same-day security patch — use `pnpm install --config.minimum-release-age=0`. ### PHP versions Lamb supports every PHP version in `composer.json`'s `require` (currently 8.4.1 or newer, with 8.5 the newest tested) and defaults to the newest. Two kinds of setting, kept apart: - **Floor (oldest supported):** `composer.json` `require.php` and `config.platform.php` (so the lock never picks a dependency the floor can't run), `phpcs.xml` `php_version`/`testVersion`, `phpstan.neon` `phpVersion`, and the CI `test` matrix's lowest entry. - **Default (newest):** Docker images, devbox, devcontainer, DDEV, `.workshop`, `.nginx` snippet, single-version CI jobs, and doc examples. When a new PHP ships, add it to the CI matrix and `testVersion`, then move the defaults. Raise the floor only when that version reaches end of life. ## Project Structure ``` lamb/ ├── src/ # Application source (web root) │ ├── index.php # Entry point: bootstrap, routing, view dispatch │ ├── bootstrap.php # DB init (SQLite via RedBean) + session setup │ ├── config.php # INI-based config stored in DB; load/save/validate │ ├── constants.php # Static app-wide constants (POST_VERSION, SESSION_LOGIN, …) │ ├── export.php # Site export: archive layout, manifest, zip assembly │ ├── routes.php # register_route() / call_route() helpers │ ├── lamb.php # Core helpers: parse_bean, parse_tags, permalink, visibility clauses, redirects │ ├── post.php # Post helpers: populate_bean, parse_matter, slugify, finalize_slug │ ├── restore.php # Lamb export importer: archive reader, manifest validation, post/asset restore │ ├── response.php # Response helpers: pagination, conditional GET/304, 404, upgrade_posts │ ├── response/ # Route handlers (respond_*, redirect_*), split by area │ │ ├── auth.php # Login/logout/settings │ │ ├── export.php # /export download (login required) │ │ ├── feeds.php # Home, drafts/trash/scheduled, search, tag, Atom + JSON feeds │ │ ├── posts.php # Status/slug pages, create/edit/delete/restore │ │ └── upload.php # Image upload + WebP conversion │ ├── security.php # require_login(), require_csrf() │ ├── theme.php # Template helpers, part(); more helpers in theme/ │ ├── theme/ # Theme helper split: assets.php (styles/scripts), formatting.php (escape, human_time), meta.php (opengraph, titles) │ ├── network.php # Feed ingestion cron orchestrator (_cron route); helpers split into network/ │ ├── network/ # Feed ingestion split: sources.php (config/SimplePie/RSS crawl), json_feed.php (JSON Feed parse + adapter), ingest.php (item→post pipeline), status.php (feedstatus health bean) │ ├── http.php # get_request_uri(); shared HTTP client: fetch(), parse_status_line() │ ├── micropub.php # Micropub endpoint (taproot/micropub-adapter subclass) + media endpoint │ ├── webmention.php # Webmention receiving + outbound sending queue (via /_cron) │ ├── websub.php # WebSub hub pings on publish │ ├── highlight.php # Syntax highlighting of fenced code blocks (Phiki) │ ├── LambDown.php # Parsedown subclass (restricts heading levels) │ ├── assets/ # Runtime upload storage (created under YYYY/MM) │ ├── images/ # Shipped static images (default social embed image) │ ├── themes/ │ │ ├── base/ # Base theme + fallback library (HTML, parts, feeds, CSS) │ │ ├── 2024/ # Alternative theme (overrides parts as needed) │ │ └── 2026/ # "Notes" theme — default for new installs │ └── scripts/ # JS: shorthand.js + logged_in/ (admin-only) ├── tests/ │ ├── Unit/ # PHPUnit tests (ConfigTest.php) │ ├── Acceptance/ # Codeception browser tests │ ├── Functional/ # Codeception functional tests │ └── Support/ # Codeception support files ├── composer.json ├── phpcs.xml # Coding standard config ├── codeception.yml # Test runner config ├── bin/lamb # CLI: unified importer driver — `bin/lamb import ` └── make-password.php # CLI utility: hash password → .env ``` ## Architecture Overview ### Entry Point & Routing (`src/index.php`) `index.php` is the single entry point (PHP built-in server or web server rewrite). It: 1. Calls `bootstrap_db('../data')` to set up SQLite at `../data/lamb.db` 2. Calls `bootstrap_session()` to configure a hardened session 3. Loads config via `Config\load()` 4. Defines constants: `ROOT_DIR`, `ROOT_URL`, `THEME`, `THEME_DIR`, etc. 5. Registers routes using `Route\register_route($action, $callback, ...$args)` 6. Dispatches via `Route\call_route($action)` where `$action` is the first path segment 7. Falls through to `Theme\part('html', '')` to render the outer shell Routes are plain strings (`'home'`, `'feed'`, `'tag'`, etc.). Slugged posts get a dynamic route registered when their slug matches the current action. ### Namespaces Each file declares a namespace; functions are called with the namespace prefix: | File | Namespace | |------|-----------| | `bootstrap.php` | `Lamb\Bootstrap` | | `config.php` | `Lamb\Config` | | `export.php` | `Lamb\Export` | | `highlight.php` | `Lamb\Highlight` | | `http.php` | `Lamb\Http` | | `lamb.php` | `Lamb` | | `micropub.php` | `Lamb\Micropub` | | `network.php` + `network/*.php` | `Lamb\Network` | | `post.php` | `Lamb\Post` | | `response.php` + `response/*.php` | `Lamb\Response` | | `restore.php` | `Lamb\Restore` | | `routes.php` | `Lamb\Route` | | `security.php` | `Lamb\Security` | | `theme.php` + `theme/*.php` | `Lamb\Theme` | | `webmention.php` | `Lamb\Webmention` | | `websub.php` | `Lamb\Websub` | `response/`, `theme/` and `network/` are namespace splits, not new namespaces: each file inside declares the parent's namespace, so callers don't care which file a function lives in. `known.php` and `wordpress.php` are thin sources over `Lamb\Import\import_item()`, which holds the one shared pipeline body (dedup, HTML prep, markdown conversion, save, redirects). A new importer adds a source array of callables (`uuid`, `allowed_types`, `title`, `slug`, `dom_pass`, `markdown`, `tags`, `finalize_markdown`, `store_redirects`) passed to that shared function — never a copy of `import_item()` itself. ### Database RedBeanPHP (fluid mode) on SQLite. Beans are dispensed/loaded with `R::dispense`, `R::load`, `R::findOne`, `R::find`, `R::findAll`. Schema evolves automatically. **Tables used:** - `post` — blog posts; columns include `body`, `slug`, `title`, `description`, `transformed`, `created`, `updated`, `version`, `feed_name`, `feeditem_uuid`, `import_uuid`, `source_url` - `option` — key/value store (e.g. `site_config_ini`, `last_processed_date`, `login_fail_*` throttle counters) - `redirect` — automatic 301 redirects created when a post slug changes; columns: `from_slug`, `to_url` - `webmention` — received (inbound) webmentions; columns: `source`, `target`, `post_id`, `type`, `author`, `content`, `status`, `created`, `verified_at` - `webmentionoutbox` — outbound webmention queue processed by `/_cron`; columns: `post_id`, `source`, `target`, `endpoint`, `status`, `attempts`, `created`, `processed_at` **Post versioning:** startup bootstrapping in `bootstrap_db()` stamps legacy rows with `version = 1` via SQL (`UPDATE post SET version = 1 WHERE version IS NULL`). `upgrade_posts()` in `response.php` is still called on read paths, but it now mainly acts as a safety net for unexpected old rows loaded into memory rather than the primary migration mechanism. ### Configuration Config is stored as raw INI text in the `option` table under key `site_config_ini`. On first run it bootstraps from built-in defaults. Edit it at `/settings` (login required). Config keys: `author_email`, `author_name`, `site_title`, `theme`, `404_fallback`, `posts_per_page`, `[menu_items]`, `[feeds]`, `feeds_draft`, `[preconnect]`, `[redirections]`. ### Post Content Format Post bodies are Markdown with optional YAML front matter separated by `---`: ``` --- title: My Post Title --- Post content here. Use #hashtags inline. ``` `parse_matter()` extracts YAML and normalises keys before matching (`normalize_matter_keys()`: lower-cased, underscores → dashes), so `Title`/`title` and `in_reply_to`/`in-reply-to` collapse onto canonical keys — this smooths over mobile auto-capitalisation and the underscore/dash ambiguity. If `title` is present and `slug` is absent, it derives `slug` from `title` via `slugify()`. If `slug` is explicitly present in front matter, that value is used. `parse_bean()` runs Markdown → HTML, extracts tags, stores `transformed`, `description`, and front-matter-derived fields on the bean. `LambDown` extends Parsedown with safe mode on and restricts `#` headings (must be `# ` with a space). On `main`/`release`, slugs are effectively immutable after creation: editing a post will not overwrite an existing slug from changed front matter or title. New page-like posts get their slug at creation time; status posts keep the numeric `/status/` permalink. ### Theming `Theme\part($name, $dir)` includes `THEME_DIR/$dir/$name.php`, falling back to `src/themes/base/`. Custom themes only need to override specific parts. **Theme parts (base):** - `html.php` — outer HTML shell (includes `parts/home.php`, etc.) - The Atom and JSON feeds are **not** theme parts — they are rendered in code by `Lamb\Response\render_atom_feed()` / `render_json_feed()` (`response/feeds.php`). A theme cannot override them: a `feed.php` / `feed_json.php` in a theme directory is simply never read. - `parts/home.php`, `status.php`, `edit.php`, `search.php`, `tag.php`, `login.php`, `settings.php`, `404.php`, `drafts.php`, `scheduled.php`, `trash.php` - `parts/_items.php` — post list partial - `parts/_pagination.php` — pagination partial - `parts/_related.php` — related posts partial - `parts/_webmentions.php` — received-webmentions partial (status pages) --- ## Theme System — Complete Reference ### How themes are selected `index.php` reads `$config['theme']` (set via the INI config at `/settings`) and resolves it through `Theme\resolve_theme()`, which sanitises the value and falls back to `'base'` when it names no directory under `src/themes/` — a missing key, a malformed `[theme]` section, a typo, or a deleted custom theme all resolve the same way: ```php define("THEME", Theme\resolve_theme($config['theme'] ?? null)); define("THEME_DIR", ROOT_DIR . '/themes/' . THEME . '/'); // absolute FS path define("THEME_URL", 'themes/' . THEME . '/'); // URL prefix (relative) ``` `ROOT_DIR` is `src/`. So `THEME_DIR` for a theme named `news` resolves to `src/themes/news/`. To activate a theme, pick it in the Theme field at `/settings` (the list is the directories under `src/themes/`), or set `theme = news` in the stored INI. New installs are seeded with `theme = 2026` (the "Notes" theme) via `Config\get_default_ini_text()`. An install with no `theme` key set falls back to `theme = base` via `Theme\resolve_theme()` at read time — nothing is rewritten in storage. ### Part resolution (`Theme\part`) ```php Theme\part($name, $dir = 'parts') ``` 1. Looks for `THEME_DIR . $dir . '/' . $name . '.php'` 2. Falls back to `src/themes/base/$dir/$name.php` 3. Throws `RuntimeException` if neither exists `$name` and `$dir` are sanitised (only `[a-zA-Z0-9-_]` allowed — dots and slashes are stripped). `html.php` is the only file called with an empty `$dir`: ```php Theme\part('html', ''); // → src/themes//html.php ``` Everything else is called as `part($template)` from inside `html.php`, which uses the default `$dir = 'parts'`. ### Render flow ``` index.php └─ Theme\part('html', '') → html.php ├─ Theme\part($template) → parts/