Build and deploy

Decisions — part 3 of 6

Static output, rebuilt incrementally -- only changed bytes are ever written or uploaded. emit compares before writing, the deploy manifest diffs by hash (with a size+mtime fast path), and RSS avoids embedding "now". A one-character edit deploys a handful of files, which also keeps cloud-synced and content-hashed targets calm.

Since 1.6 there is a layer above that: the build records what went into each page and does not render the ones whose inputs have not moved, because comparing bytes still costs rendering them first. The record is never an authority -- anything it cannot vouch for is rendered and compared as before -- and it is thrown away whole whenever a template, a locale, the configuration or the engine changes, since any of those can reach every page.

Pagination is anchored to the oldest post. Slicing from the newest end -- the obvious way -- shifts every page boundary each time a post is published, rewriting the whole archive on every deploy. Anchoring to the oldest makes old pages immutable; the landing page absorbs new posts and splits only when full. Same reason there's no "page X of Y" label: the total would put a changing byte on every page. What the total was wanted for -- where in the archive a page is, when the page right behind the front one carries the highest number there is -- a page says with the days it covers, beside its number. Those are read off the page's own posts, so they are exactly as fixed as the page.

Attributes live in the frontmatter, actions live in a dialog. A post's type and tags are edited where the text is -- prefilled in the header of edit, so the current state is visible before it's changed. Operations with consequences (publish, unpublish, rename, delete, the announcement) each get a guarded prompt in the props dialog instead. The wizard menu then lists activities, not operations: five entries, with everything post-shaped reached through the post. The CLI commands all remain -- scripts don't navigate menus.

The pin is the deliberate exception, and it moved on first contact with reality: it started as an attribute, and the very first person to unpin a post read "pinned: yes" in the dialog and found no way to act on it -- the dialog sent them into an editor session to flip one boolean. Type and tags are values you write; the pin is a switch, its consequence lives on the front page, and a switch behind an editor round-trip is exactly the friction the dialog exists to remove. So [c] toggles it in place, and the frontmatter keeps accepting pinned: as before -- two doors, one state.

A slug rename is an action with a permanent redirect, not an attribute. A published slug is an address other people hold; editing it like a tag would break every copy of that address silently. So renaming is a guarded action that records the old address in the post (former_slugs) and the build keeps a one-page redirect standing there -- for as long as the post itself is published. The address book lives in the post's own JSON rather than a separate registry, so it moves, backs up and restores with the post and can never orphan.

Deploys are paranoid by default. A large swing in file count or total bytes aborts (a broken build must never be mirrored), deletion is opt-in (--prune), manifests are per-backend so switching targets can't inherit foreign state, and every manifest is disposable -- deleting one costs one full re-upload, never correctness. The guards themselves -- what trips them and how to prove them by hand -- are in Running it.

The guards measure the build against the build. They used to compare it against the manifest, which is the state of the target -- so any failed upload knocked their reference out of true, and the patch for that was a marker that stood them down until a clean run came along. When the failure was permanent, that was never. The reference is now the shape of the last build the guards themselves accepted, written before the first byte moves, so there is nothing left to switch off. The manifest is still allowed to serve as a floor for the drop direction, because a partial manifest can only ever understate the site: as a floor it can hide a drop, never invent one. It is never a reference for growth, which is precisely the reading that caused the original defect. Percentages carry absolute floors as well, asymmetric on purpose -- a missed increase costs transferred bytes that the next --prune reclaims, a missed drop deletes live pages.

One file-size limit for every backend, and no key to loosen it. The hosts differ wildly -- a git pages host refuses large files outright, a plain rsync target refuses nothing -- but a per-backend limit would mean a post that saves today becomes undeployable the day the site moves. The strictest supported target therefore sets the rule for all of them, with the engine's line drawn just under that host's so it refuses before the host does (the limit itself is in Running it). Refusal happens at save time, where the author can still act, as well as at deploy time; a config key would only restate the question every installation would then answer differently, the same reasoning as the fixed JPEG quality in the HEIC converter.

Six deploy backends behind one small contract. The manifest logic is target-independent; backends only move bytes. Self-diffing targets (rsync, rclone, git) get a single batch call; dumb ones (sftp) execute the manifest's precomputed lists; git is a forced single-commit snapshot because site history already lives in the source repo.