Ctrl K
ComunidadGuías…

AGENTS.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 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 <your-password>
LAMB_WRITE_TEST_PASSWORD=1 php make-password.php <your-password>   # 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 <source> <path>`
└── 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/<id>` 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/<theme>/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/<template>.php  (home / status / tag / …)
     │   └─ Theme\part('_items')     → parts/_items.php
     ├─ Theme\part('_related')       → parts/_related.php    (status only)
     └─ Theme\part('_pagination')    → parts/_pagination.php
```

`$template` equals the first URL segment (`home`, `status`, `tag`, `search`, `edit`, `login`, `settings`, `404`, `drafts`). For a slugged post URL it is set to `'status'`.

### Global variables available in every part

| Variable | Type | Contents |
|----------|------|----------|
| `$config` | `array` | Full config: `site_title`, `author_name`, `author_email`, `menu_items`, `feeds`, `theme`, … |
| `$data` | `array` | Route-specific data (see table below) |
| `$template` | `string` | Current template name (`home`, `status`, `tag`, …) |

### `$data` keys by template

| Template | Keys always present | Keys sometimes present |
|----------|--------------------|-----------------------|
| `home` | `posts`, `pagination`, `title` | `home_post` (the published post slugged `home`, first page only; excluded from `posts`; the base theme renders it above the timeline, so a custom `parts/home.php` must render it itself) |
| `status` / slugged post | `posts` (single-item array), `title` | — |
| `tag` | `posts`, `pagination`, `title`, `intro`, `feed_url` | — |
| `search` | `posts`, `pagination`, `title`, `intro` | — |
| `drafts` | `posts`, `pagination`, `title` | — |
| `settings` | `title`, `ini_text` (the stored INI, which the form is prefilled from via `Config\settings_form_prefill()`), `feed_statuses`, `redirects` | `form_values` (the typed fields, on a refused save) |
| `404` | `action` | — |

**`$data['posts']`** is an array of RedBeanPHP `OODBBean` objects. Each bean exposes:

| Property | Description |
|----------|-------------|
| `$bean->id` | Integer primary key |
| `$bean->title` | Post title (may be empty for status posts) |
| `$bean->slug` | URL slug for page-style posts; created from front matter/title on first save and preserved on later edits on `main`/`release` |
| `$bean->body` | Raw Markdown source |
| `$bean->transformed` | Pre-rendered HTML (use this in templates — never re-render `body`) |
| `$bean->description` | Plain-text excerpt (auto-generated) |
| `$bean->created` | Datetime string (e.g. `2024-03-01 12:00:00`) |
| `$bean->updated` | Datetime string |
| `$bean->feed_name` | Source feed name (only present for ingested feed items) |
| `$bean->feeditem_uuid` | MD5 dedup key (only for feed items) |
| `$bean->import_uuid` | MD5 dedup key for posts brought in by an importer (WordPress, Known, lamb export) |
| `$bean->source_url` | Permalink of the original feed item (only for feed items; used by `link_source()`) |
| `$bean->is_menu_item` | Truthy if the post is pinned as a menu item |

**`$data['pagination']`** shape (when present):

```php
[
  'current'     => int,    // current page number
  'per_page'    => int,    // posts per page
  'total_posts' => int,
  'total_pages' => int,
  'prev_page'   => int|null,
  'next_page'   => int|null,
  'offset'      => int,
]
```

### Theme helper functions (`Lamb\Theme` namespace)

All helpers must be imported with `use function Lamb\Theme\<name>` before use.

| Function | Returns | Description |
|----------|---------|-------------|
| `escape($str)` | `string` | `htmlspecialchars` for HTML5 output — use on every user-supplied value |
| `site_title($type='html')` | `string` | `<h1>` wrapping `$config['site_title']`, or plain text if `$type !== 'html'` |
| `page_title($type='html')` | `string` | `<h1>` wrapping `$data['title']` (falls back to `site_title`) |
| `page_intro()` | `string` | `<p>` wrapping `$data['intro']`, or `''` |
| `li_menu_items()` | `string` | `<li><a>` tags from `$config['menu_items']` |
| `date_created($bean)` | `string` | `<a><time>` linking to the post permalink with human-readable timestamp |
| `link_source($bean)` | `string` | "Via <a>" attribution link for feed-ingested posts, or `''`. Uses `$bean->source_url` when set, falling back to the feed URL from config |
| `action_edit($bean)` | `string` | Edit button (logged-in only), or `''` |
| `action_delete($bean)` | `string` | Delete form (logged-in only), or `''` |
| `the_entry_form()` | `void` | Renders the quick-post `<form>` (logged-in only) |
| `the_styles()` | `void` | Emits `<link rel="stylesheet">` for `styles/styles.css` in the active theme |
| `the_scripts()` | `void` | Emits `<script defer>` tags for application scripts in `src/scripts/`; logged-in users also get `src/scripts/logged_in/*.js` |
| `the_opengraph()` | `void` | Emits `<meta>` OG/Twitter tags (status template only) |
| `the_robots()` | `void` | Emits `<meta name="robots" content="noindex, nofollow">` when the response is marked noindex (private routes, `?preview=` links); nothing otherwise |
| `the_preconnect()` | `void` | Emits `<link rel="preconnect">` and `dns-prefetch` for up to 3 distinct origins in total: deprecated `$config['preconnect']` origins first, then the remote origins in the `src`/`srcset`/`poster` URLs of `$data['posts']` bodies |
| `part($name, $dir='parts')` | `void` | Includes a theme part (see resolution rules above) |
| `csrf_token()` | `string` | Returns (and creates if needed) the current session CSRF token |
| `related_posts($body)` | `array` | Posts sharing hashtags with `$body`; returns `['posts' => OODBBean[]]` |
| `human_time($timestamp)` | `string` | Human-readable relative time ("3 hours ago", "Monday at 2:15 pm", …) |
| `redirect_to()` | `string` | Sanitised `?redirect_to=` query param value |

Additional helper from `Lamb\Config`:

| Function | Returns | Description |
|----------|---------|-------------|
| `is_menu_item($slugOrId)` | `bool` | True if the value matches a configured menu-item slug/id — use to hide menu posts from feed lists |

### Constants available in parts

| Constant | Value / Description |
|----------|---------------------|
| `ROOT_URL` | Full base URL, e.g. `https://example.com` |
| `ROOT_DIR` | Absolute path to `src/` |
| `THEME` | Active theme name string |
| `THEME_DIR` | Absolute path to active theme directory (trailing `/`) |
| `THEME_URL` | Relative URL to active theme directory, e.g. `themes/news/` |
| `SESSION_LOGIN` | Session key `'logged_in'` — check `isset($_SESSION[SESSION_LOGIN])` for auth |
| `HIDDEN_CSRF_NAME` | CSRF field name `'csrf'` |
| `SUBMIT_CREATE` | Submit button label `'Create post'` |
| `SUBMIT_EDIT` | Submit button label `'Update post'` |

### CSS asset loading

`the_styles()` takes no arguments and always loads `styles/styles.css` from the active theme. There is no multi-file or concatenation mechanism — put everything in one CSS file per theme.

`Theme\styles_markup()` decides how it is served. When the minified stylesheet is ≤ 20 KB it is **inlined** as a `<style>` tag (minified via `Theme\minify_css()`, with relative `url()` font references rewritten to absolute via `Theme\rewrite_css_urls()`) so first paint isn't blocked on a separate CSS request — the main mobile-PageSpeed win for a single-file theme. Larger or unreadable stylesheets fall back to an external `<link rel="stylesheet">` whose URL is `THEME_URL . 'styles/styles.css'` with a content-hash cache-buster (`?ver=<md5>`). The two preloaded fonts in `html.php` keep their absolute URLs, which match the rewritten `url()` targets, so inlining does not double-fetch them.

### JavaScript asset loading

`the_scripts()` takes no arguments. It does not load scripts from the active theme directory.

It always loads:

- `src/scripts/shorthand.js`

When the user is logged in, it also loads:

- `src/scripts/logged_in/growing-input.js`
- `src/scripts/logged_in/confirm-delete.js`
- `src/scripts/logged_in/link-edit-buttons.js`
- `src/scripts/logged_in/upload-image.js`
- `src/scripts/logged_in/paste-link.js`

### Upload asset storage

User-uploaded files live under `src/assets/`, not under theme directories.

`respond_upload()` stores files in `src/assets/YYYY/MM/` and returns Markdown image links pointing at those uploaded files. JPEG/PNG uploads are re-encoded to WebP via GD (`Response\convert_to_webp()`, gated by `Response\should_convert_to_webp()`) and downscaled so their longest edge is at most 1600px (`Response\scaled_dimensions()`) — large screenshots are not served at full resolution to phones; GIF/WebP/AVIF are stored unchanged, and any conversion failure falls back to storing the original bytes. The same conversion is applied to Micropub uploads (inline `photo` files and the media endpoint) in `micropub.php`. Deployment setups that support uploads must ensure `src/assets/` is writable by the web server or PHP-FPM user.

### `.gitignore` exemption

Unknown subdirectories under `src/themes/` are ignored so users can install a custom theme alongside their clone of the repo without it appearing in `git status`. The shipped themes (`base`, `2024`, `2026`) are explicitly un-ignored at the directory level, so `git add` on their files works normally without `-f`.

To ship a new theme as part of the repo, add one line to `.gitignore`:

```
# in .gitignore
!src/themes/news/
```

After that, `git add` works normally for all files inside the theme.

### Minimal new theme checklist

A theme only needs to override the files that differ from `base`. The absolute minimum is a stylesheet:

```
src/themes/<name>/
└── styles/
    └── styles.css     ← required (the_styles() always loads this path)
```

Add `html.php` only if the HTML shell (nav, header, footer) changes. Add individual `parts/*.php` files only for the page templates that differ visually. All other parts fall back to `base` automatically. (The feeds are rendered in code, not a theme part — see above; a `feed.php` in a theme is never read.)

### Typical file set (for a full redesign)

```
src/themes/<name>/
├── html.php                  # header, nav, outer shell, footer
├── styles/
│   └── styles.css
└── parts/
    ├── home.php              # homepage (calls the_entry_form, site_title, part('_items'))
    ├── _items.php            # post list / card grid
    ├── status.php            # single post (usually just delegates to _items)
    ├── tag.php               # tag archive
    └── search.php            # search results
```

Parts you rarely need to override: `edit.php`, `login.php`, `settings.php`, `404.php`, `drafts.php`, `_related.php`, `_pagination.php`.

### Security

- CSRF: token stored in session, verified in `Security\require_csrf()`, consumed after use
- `respond_upload()` (`src/response/upload.php`) and `respond_checkbox()` (`src/response/posts.php`) call `Security\require_login()` but deliberately skip `require_csrf()` — both fire mid-composition/mid-edit, and consuming the single-use token there would break the subsequent form submit's own CSRF check. Their protection rests entirely on `LAMBSESSID`/`lamb_logged_in` being `SameSite=Strict` (a cross-site POST carries neither cookie, so no session, so `require_login()` redirects). That invariant is pinned by `testSessionCookieIsSameSiteStrict()` / `testGetCookieOptionsSameSiteIsStrict()` rather than assumed — see #536
- Login brute-force throttle (#443): after `LOGIN_THROTTLE_MAX_FAILURES` failed `/login` attempts from one client address, further attempts are refused for `LOGIN_THROTTLE_WINDOW` with `429` + `Retry-After`, checked *before* `password_verify()` so a blocked client costs no bcrypt. The counter is a `login_fail_<hmac(ip)>` row in the `option` table (no session exists on `/login` — see #462), cleared on a successful login and lazily pruned on the failure write path. Refused attempts are not counted, so retrying can't extend a block. `client_ip()` reads `REMOTE_ADDR` only — `X-Forwarded-For` is attacker-controlled on a directly-exposed install and would hand out a fresh bucket per request
- Session hardening: `httponly`, `secure` (HTTPS), `SameSite=Strict`, user-agent validation
- Auth: password stored as bcrypt hash in env var `LAMB_LOGIN_PASSWORD` (base64-encoded)
- Login sets `$_SESSION[SESSION_LOGIN]` and a `lamb_logged_in` cookie; logout destroys the session and expires both cookies
- Sessions are only started for (previously) logged-in users: `Bootstrap\should_start_session()` checks for the `lamb_logged_in` or `LAMBSESSID` cookie, so anonymous visitors get no `Set-Cookie` and no no-cache headers and their pages stay cacheable. Routes that need a session for an otherwise-anonymous request (the login page, CSRF POSTs, flash-before-redirect) call `Bootstrap\start_session()` explicitly. `Bootstrap\cache_headers()` emits `max-age=300` + `Vary: Cookie` for anonymous responses and private/no-store for logged-in ones (session cache limiter is disabled so it never fights these)
- Conditional GET: anonymous content pages and feeds carry `ETag`/`Last-Modified` validators derived from the most recently updated post **and the last config edit** (`Response\latest_content_timestamp()` takes the `max()` of the latest post `updated` and `Config\config_modified_timestamp()`), and `Response\send_304_if_current()` short-circuits with `304 Not Modified`. `Last-Modified` is the second-resolution `max()` of the two timestamps, but the `ETag` (`Bootstrap\content_etag()`) keeps them as **distinct components** (`"<content>-<config>"`) so a settings edit that lands in the same whole second as the latest post still changes the validator and invalidates caches. The login page and 404 responses are excluded. Feeds use a longer `max-age` (`Response\feed_cache()`). `Config\save_ini_text()` stamps `updated` on the config row so settings changes invalidate cached pages immediately
- Admin-only JS files are conditionally loaded via `asset_loader()`. Asset URLs are cache-busted with a content hash (`Theme\asset_version()` → `md5_file`), so editing a CSS/JS file invalidates the `?ver=` query

### Feed Ingestion (Cron)

`GET /_cron` triggers `Network\process_feeds()`. It reads `[feeds]` from config, fetches each RSS/Atom feed via SimplePie, and creates/updates posts. Rate-limited: minimum 1 minute between full runs, 30 minutes per feed. Feed items get a `feeditem_uuid` (md5 of feed name + item ID) for deduplication. The same run also drains the outbound webmention queue via `Webmention\process_outbound()`.

### IndieWeb Endpoints

- `/micropub` + `/micropub-media` (`micropub.php`) — Micropub create/update/delete/undelete and media uploads, built on `taproot/micropub-adapter` (`LambMicropubAdapter`); bearer tokens are introspected against the configured `token_endpoint`
- `/webmention` (`webmention.php`) — receives and verifies inbound webmentions (stored in the `webmention` table, rendered by `parts/_webmentions.php`); publishing/editing a post enqueues outbound webmentions in `webmentionoutbox`, sent by `/_cron`
- WebSub (`websub.php`) — pings the `websub_hubs` configured hubs when a post publishes
- `index.php` advertises `micropub`, `webmention`, `authorization_endpoint` and `token_endpoint` via `Link` headers

### Pagination

`paginate_posts($source, ...)` in `response.php` handles both array-based and DB-based pagination. It reads `posts_per_page` from config (default 10) and `?page=` from query string.

## Coding Standards

- **PSR-12** with PHPCompatibility checks for PHP 8.4+
- Line length limit disabled (`Generic.Files.LineLength.TooLong` excluded)
- Side effects with symbols allowed (`PSR1.Files.SideEffects.FoundWithSymbols` excluded)
- Underscore method names allowed in tests
- Every user-visible change adds a fragment file in `changelog.d/` in the same
  commit as the code, named `<issue>.<type>.md` (or `<issue>-<slug>.<type>.md`,
  or `<slug>.<type>.md` with no issue). Don't edit `CHANGELOG.md`: the release
  assembles the fragments into it. Each fragment holds bullets that say what
  the user sees, in plain language, and link the issue as `([#N](url))` when
  there is one. Use the Keep a Changelog types: added, changed, deprecated,
  removed, fixed, security. Internal-only changes (tests, tooling, refactors)
  don't get a fragment. A PR that changes `src/` or `bin/` without one fails CI
  unless it has the `no-changelog` label. See `changelog.d/README.md`.
- Run `viv run lint` before committing

### Comments

**Comment the constraint, not the changelog.**

An inline comment earns its place when reverting the line would look *reasonable*
to a future editor: a coupling across files, a counter-intuitive choice, a guard
whose removal breaks something distant. Write what breaks if the line changes.

Do not narrate the bug a line used to have. That belongs in the commit message,
where `git blame` will find it. Once the code reads correctly on its own, the
comment is noise:

```php
// Noise — archaeology. The line already states this.
// `created` is when the post was published and `updated` when it was last
// changed; the two were wired to the opposite properties, so every scraper
// read a post's edit time as its publication date.
'og:published_time' => $bean->created,

// Earns its place — a coupling the next editor cannot see from here.
// The status is load-bearing: upload-image.js keys on response.ok to tell
// markdown-to-insert from error-to-report.
header('HTTP/1.1 400 Bad Request');
```

Docblocks carry a function's **contract** — `@param`, `@return` and `@throws`
are required (they feed PHPStan and IDE hover) — plus the *local* why: an
invariant, a coupling, a ceiling the next editor will try to remove. Keep that
on the symbol, where it is read at the point of change.

A rationale that runs to a paragraph and explains a *subsystem* rather than a
single function is documentation. Put it in the module's README
(`src/<module>/README.md`) and leave a one-line pointer on the symbol
(`// SSRF model: see network/README.md`), so a design essay lives in one place
instead of being restated across every function it touches. `src/network/` is
the first module to follow this; see its `README.md` and the `2026-08-21`
decision log entry.

Inline `//` rationale stays scarce — around **8% of non-docblock lines**.

## Testing

Tests use **Codeception 5** with PHPUnit underneath.

- **Unit** (`tests/Unit/`): pure PHP, no HTTP.
- **Acceptance** (`tests/Acceptance/`): browser-level via PhpBrowser. Requires `SITE_URL` and `LAMB_TEST_PASSWORD` in `.env`. Generate it with `LAMB_WRITE_TEST_PASSWORD=1 php make-password.php <pw>` — `make-password.php` omits the cleartext `LAMB_TEST_PASSWORD` unless that flag is set.
- **Functional** (`tests/Functional/`): Codeception functional tests.

Config in `codeception.yml` reads env from `.env`.

### JavaScript unit tests

Client-side scripts in `src/scripts/` are unit-tested with Node's built-in test runner (`node:test`) plus `jsdom` — no extra framework. Run them with `pnpm test` (`node --test "tests/js/**/*.test.mjs"`); CI runs the same via the `js-test` job.

The scripts ship as plain `<script>`-tag globals (no module exports), so tests don't import them. Instead `tests/js/helpers.mjs` (`loadScripts()`) concatenates the source(s), evaluates them inside a `jsdom` window (`runScripts: 'outside-only'`), and returns the requested globals — keeping the shipped files untouched. Handlers registered via `onLoaded` (DOMContentLoaded) attach only after you dispatch a `DOMContentLoaded` event, since jsdom finishes parsing before the eval. See `tests/js/paste-link.test.mjs` for the pattern (faking a `paste` event with `clipboardData.getData`).

### Credential-shaped test fixtures

GitGuardian scans every commit in a pull request. A fixture written as a literal
`KEY='value'` line, or a password assigned a literal string, reads to it as a
committed credential and fails the `GitGuardian Security Checks` run. The check
reports nothing useful — just a link to a dashboard you may not be able to open.

Compose those lines instead of spelling them out:

```php
// Trips the scanner
file_put_contents($dir . '/.env', "LAMB_LOGIN_PASSWORD='the-live-one'\n");

// Does not
$line = sprintf("LAMB_LOGIN_PASSWORD='%s'%s", 'live-install-marker', "\n");
file_put_contents($dir . '/.env', $line);
```

If a scan fails on a commit you have already pushed, editing the file is not
enough: the earlier commit stays in the PR's range and keeps failing. Squash the
branch to a single commit so the literal leaves the scanned history.

### Red-Green TDD

Always follow red-green TDD when adding or changing behaviour:

1. **Write the test first** — it must fail (`vendor/bin/codecept run Unit`) before any implementation is written.
2. **Write the minimum implementation** to make the test pass.
3. **Run again** to confirm green.

Never write implementation code before a failing test exists for it.

### After a bugfix

After fixing any bug, always — without being asked:

1. **Sweep related functionality for the same class of bug.** Identify the other call sites, sibling functions, and code paths that share the faulty pattern (the same helper, the same assumption, the same parsing/encoding step) and check each for the same defect. A bug rarely lives alone — e.g. a fix in one save path usually has twins in the other save paths. Add red-green tests for any further bugs found and fix them.
2. **Identify refactoring opportunities.** Note duplication the fix exposed (e.g. the same logic copy-pasted across sites) and consolidate it behind a single shared helper where doing so removes the duplication without adding complexity. Prefer fixes that collapse the duplication that allowed the bug to diverge in the first place; leave well enough alone when sharing would complicate more than it simplifies.

## Environment Setup

Authentication password is stored hashed in the `LAMB_LOGIN_PASSWORD` environment variable:

```bash
php make-password.php mysecretpassword
# writes LAMB_LOGIN_PASSWORD, SITE_URL to .env
```

The app reads `LAMB_LOGIN_PASSWORD` via `getenv()` at runtime. Production deployments
(FrankenPHP, nginx+php-fpm, Docker) set it as a real environment variable. The PHP
built-in dev server (`viv run serve`) does not load `.env`, so `Bootstrap\load_dotenv()`
reads it in — but **only under the `cli-server` SAPI** and **non-overriding** (a real
env var always wins), so production is unaffected. `phpdotenv` is a dev dependency, so
this no-ops on a `--no-dev` install.

### Claude Code on the web

`.claude/hooks/session-start.sh` provisions the toolchain (viv, pnpm, `.env`,
phpcs's `installed_paths`) at session start, so `viv run lint`, `viv run analyse`
and `vendor/bin/codecept run` work without any manual setup. It exits immediately
unless `CLAUDE_CODE_REMOTE=true`, so devbox, the devcontainer and Workshop keep
their own setup paths.

One remote-only quirk it cannot paper over: **`api.github.com`/`codeload.github.com`
may be blocked** by the environment's network policy, which 403s every dist
download. viv is dist-only — unlike Composer it has no `--prefer-source` fallback
to retry with — so a blocked network here leaves `vendor/` incomplete and the hook
logs a warning rather than working around it. Allowing those two hosts in the
environment's network policy is the only fix; see
<https://code.claude.com/docs/en/claude-code-on-the-web>.

## Branching

- `release` — stable branch for end users running a blog; check out this branch if you want the latest released version
- `main` — active development branch for contributors; branch from this for new feature or fix work
- `next` — next major version targeting
- `*-pinned` — pinned reference branches

For contributors: always branch from `main` for new features. Open an issue first; get agreement from maintainers before building features.

## Pull Requests

When a task's work is complete and pushed, open a pull request for it by default — you do not need to be asked first. This overrides the default "do not open a pull request unless explicitly asked" behaviour.

After opening a pull request, watch its activity and automatically fix failing CI checks — diagnose the failure, push a fix, and repeat until the checks pass — without waiting to be asked. Address clear-cut review feedback the same way; check in before acting only when a fix is ambiguous or architecturally significant.

Keep the PR body short enough to read in a minute, about 200 words. Lead with the symptom and the fix in one or two sentences. In **Why this shape**, give the option you rejected and why, in one to three plain sentences: "Considered streaming to disk; dropped it, 50 MB fits in memory." Leave out function names, line numbers and mechanism there; the diff carries those. List evidence as the command and its result, and keep **Not checked** to one bullet per gap.

Before any bulk PR operation — merging, rebasing, or force-pushing across several PRs — check whether the branches are stacked. Branches can be built on each other even when every PR targets `main`, so a diff or a squash can carry a lower branch's content, and a PR's real base may be another feature branch (check `baseRefName`). Merge stacked PRs bottom-up, re-check each remaining PR's mergeable state after every merge (earlier merges turn later ones `DIRTY`), and never force-push a shared branch without confirming the remote tip is what you expect — another session or GitHub's base-retargeting may have moved it. Prefer merging `main` into the branch over rebasing, so the push stays a fast-forward. When resolving the merge, keep the content the stacked branch legitimately owns rather than blindly taking `main`'s side, and run the tests locally before pushing.

## Deprecations

Lamb removes deprecated work in the **next published major version**. Pre-1.0
that means the next `0.y`: something deprecated in 0.14.0 comes out in 0.15.0.
SemVer itself promises nothing under `0.y.z` ("anything MAY change at any
time"), but `0.y` is the boundary package managers encode and the one this
project holds to.

One published version of grace, then it goes. Deliberately short: Lamb is
pre-1.0 with few users, all of whom can be assumed to be on the most recent
published version.

When adding a deprecation:

- Say the version it is removed in, not "a future release" — a notice nobody
  can date is a notice nobody acts on. Name the replacement in the same breath.
- Emit something at runtime where there is somewhere to emit it: `E_USER_DEPRECATED`
  for library-level callers, STDERR for a CLI.
- Update `docs/` to document the replacement as the way to do the thing. A
  deprecated path should never be the primary documented one.
- Removing it is a **breaking change**, so it leads the release notes (see
  `RELEASING.md` step 3) and shapes the version (step 2).

## Philosophy (from README)

- Simple over complex
- Opinionated defaults over settings
- Assume success, communicate failure

When adding features, prefer the minimal implementation. Avoid adding configuration options where a sensible default is sufficient.

<!-- headroom:learn:start -->
## Headroom Learned Patterns
*Auto-generated by `headroom learn` on 2026-06-04 — do not edit manually*

### Shell Command Gotchas
*~2,500 tokens/session saved*
- A foreground `sleep N` followed by another command is BLOCKED by the harness (e.g. `sleep 30 && gh pr checks`). To wait on a condition use an until-loop `until <check>; do sleep 2; done`, run it in the background, or use Monitor. This was hit repeatedly across sessions.
- `grep` is proxied to ripgrep (rtk): BRE alternation `\|` and unescaped parens `(` cause `regex parse error: unclosed group`. Use ERE: `grep -E 'a|b'` and escape literal parens as `\(`. `rtk find` also rejects `-exec`/`-not` — call `find` directly for those.

### Running Acceptance Tests
*~1,500 tokens/session saved*
- `vendor/bin/codecept run Acceptance` needs `LAMB_LOGIN_PASSWORD` and `SITE_URL` in the environment. Source `.env` first: `set -a; . ./.env; set +a` then run. CI passes it via the acceptance suite server env (tests/Acceptance.suite.yml).
- Kill stale dev servers on port 8747 before re-running (`pkill -f 'php -S 0.0.0.0:8747'`) or the run hangs/fails.

### Editing Files
*~1,200 tokens/session saved*
- Edit/Write fail with "File has not been read yet" unless the file was Read in this session — this fired 15+ times, especially on MEMORY.md, docs/*.md, .gitignore, composer.json, and src files. Read the exact target path before the first Edit/Write to it; don't assume a `git mv`/`sed`-touched file is "read".

### Theme Directory Names
*~600 tokens/session saved*
- The base theme directory is `src/themes/base/` (the old `default` was renamed in PR #289). Greps for `src/themes/default` return nothing — search `base`, `2024`, or `2026`.

<!-- headroom:learn:end -->