fair#
Last reviewed: 2026-04-30 (images-mirror lives in content repo for prod)
A Go CLI that publishes markdown posts to an ATProto PDS as standard.site
lexicon records, then renders them as plain HTML for static deploy.
Source of truth for architecture: docs/design-plans/2026-04-29-blog-v2026.md
(historical, captures the original design intent).
Operator/user surface: README.md. This file captures only the
non-obvious rules and the things that bite when forgotten.
Status: Feature-complete for single-author personal use. The deployment
is whatever the operator wires up — see docs/runbook.md for the
recommended first-deploy path.
Stack#
- Go 1.26+, single static binary (
go build -o fair ./cmd/fair) - Module path:
aparker.io/fair - CLI:
spf13/cobra - Markdown:
github.com/yuin/goldmark(extensions are HASH-SAFE — see "Hash determinism" — but they DO change rendered HTML for future builds) - Frontmatter:
gopkg.in/yaml.v3 - Config:
github.com/BurntSushi/toml - ATProto OAuth:
github.com/haileyok/atproto-oauth-golang@v0.0.3(pinned — upstream pre-1.0; mind the version when bumping) - License: MIT (see
LICENSE)
Domain#
- Lexicons:
site.standard.publication,site.standard.document, withsite.standard.content.markdowncontent variant. - Records on the PDS are canonical. Markdown files in the configured
blog_postsdirectory are working copies;fair publishis idempotent (content-hash skip;--forceoverrides). - Each blog has its own authoring repo + operator notes — those live
with the content, not here. See the operator's
CLAUDE.mdin their blog-posts directory for the deploy-side specifics.
Subcommands#
fair --profile <name> <verb> — see README for full usage. One-line reminder:
auth login|logout|status, init publication, publish <path>,
unpublish <rkey>, build, ls, doctor, config show,
emit-client-metadata <out>.
Profile mechanism (mandatory, no default)#
- Every invocation needs
--profile <name>orFAIR_PROFILE. There is no default. - Profiles are filesystem-discovered:
config.<name>.tomlin--config-dir(default~/.config/fair). Unknown profile → error with Levenshtein-≤2 "did you mean" suggestion. - Profile-scoped state, dist, images-mirror, and keychain entries prevent any cross-contamination between sandbox and prod. Keep that isolation when adding new state.
Auto-token-refresh (every authenticated subcommand)#
cmd/fair/profile.go:newAuthedClient is the single entry point for any
subcommand that needs authenticated XRPC. It loads the persisted session,
the persistent ECDSA client signing key, computes client_id (loopback for
sandbox, metadata-URL for prod), and calls Client.RefreshIfNeeded — no-op
when fresh, silent token rotation otherwise. Failures surface as
"please re-login" errors that name the exact command to run.
Do not call atproto.NewClient directly from a subcommand — go through
newAuthedClient so refresh stays automatic. cmd/fair/auth.go is the only
caller of atproto.Login (it creates the session rather than using one).
Hash determinism (do not break casually)#
internal/markdown/Hash drives fair publish idempotency. Any change to
its inputs invalidates every stored record's hash, forcing full republish.
Hash is over the markdown SOURCE, not goldmark's rendered HTML.
Specifically: Hash(NormalizedBody, Canonical(Frontmatter), CoverSourceHash),
sections separated by \x00. This is the load-bearing correction —
adding/removing goldmark extensions or rendering options does NOT
change stored hashes. It only changes future rendered HTML.
- Body normalized: LF endings, BOM stripped, exactly one trailing newline.
- Frontmatter canonicalized via
Canonical(): sorted keys, no comments,id/updated/updatedAtexcluded. Encoded as rigid line-per-key bytes, not YAML, soyaml.v3upgrades don't shift the hash. - Tags lowercased, trimmed, deduped, sorted.
- Cover hashed by source-file SHA-256 (not blob CID) so we can short-circuit before any upload.
Markdown rendering (goldmark)#
internal/markdown/Parser() returns a singleton goldmark configured with:
- GFM (tables, strikethrough, task lists, autolinks)
- Footnote (
[^1]syntax →<sup>+ footnote list) - DefinitionList (
Term\n: Definition→<dl>) WithAutoHeadingID(stable anchor IDs)WithUnsafe(HTML passthrough — required for hand-authored<blockquote data-bluesky-uri>markers the build flow rewrites)
Extension changes are hash-safe (see above) but will alter rendered HTML on
the next fair build. Consumers like Leaflet/pckt re-render from source
with their own goldmark config and may produce different HTML than us — that
is expected.
HTML post-processing (kept out of goldmark deliberately)#
internal/build/render.go runs three text passes on goldmark's output
inside RenderMarkdown, in order. They live outside the parser
specifically so hash stays stable:
resolveRelativeURLs— joinspublication.urlwith any/...href/src so rendered HTML works on any origin.transformBlueskyEmbeds— rewrites<blockquote data-bluesky-uri>into<blockquote>...<footer>Discuss on Bluesky</footer></blockquote>(no JS).wrapStandaloneImagesAsFigures— promotes<p><img></p>blocks to<figure><img><figcaption>alt</figcaption></figure>. Inline images are left alone.
A fourth pass — EnrichResponsiveImages in internal/build/srcset.go
— runs in Build after RenderMarkdown returns (not inside it,
because it needs ImagesMirror which the markdown package shouldn't
know about). Adds srcset/sizes/width/height to <img> tags
whose source has resized variants on disk.
Each pass is a regex on rendered HTML, not a goldmark parser/renderer extension. Adding new transforms here is safe; modifying the goldmark parser config to do the same thing is not.
Domain-portable records#
Inline image URLs in PDS record bodies are relative (/images/<slug>/foo.png).
The build flow joins them with publication.url to produce absolute URLs in
rendered HTML. This is a deliberate design choice — keep it relative or
readers like Leaflet/pckt break.
Inline images: filesystem-staged, not PDS blobs#
Inline image bytes are not uploaded as PDS blobs (only cover images are).
fair publish rewrites  to a relative /images/<slug>/<basename>
URL in the record body, and copies the source file into the directory
configured as images_mirror. Alongside the source, it also generates
resized variants (<basename>-480.<ext>, -960, -1440, -1920)
when the source is wider than each target — see
internal/images/variants.go. Variants for unsupported formats (gif,
svg, etc.) are skipped silently; that image just gets a plain <img>.
fair build reads from the mirror and copies into dist/images/. A
build-time HTML pass (internal/build/srcset.go::EnrichResponsiveImages)
walks <img> tags whose src points into <publicationURL>/images/...
and rewrites them with srcset/sizes/width/height attributes
when variants are present on disk. The pass is a no-op when
ImagesMirror is empty or when an image has no variants.
The browser needs two CSS rules for the rendered layout to behave —
they live in one inline <style> block in every template <head>:
body{max-width:720px;margin:0 auto;padding:1rem}
img{max-width:100%;height:auto}
The first caps the reading column to ~720px on desktop and centers it, which gives a typographically reasonable line length and naturally constrains images (since they live inside the body). The second makes images shrink to fit the column on narrow viewports.
The "no CSS" claim in the README is that we don't ship a stylesheet,
framework, or design system — not that we emit zero style attributes.
Body width can't be constrained without CSS for the "narrow on mobile,
narrow on desktop, centered everywhere" case (HTML only supports fixed
width="N" on tables, which overflows mobile). The picture-with-media-
queries pattern can size images alone without CSS but doesn't help
text, costs more publish-side complexity, and doesn't center the result
— so we keep the two-rule block instead.
The operator decides where images_mirror lives. Two common shapes:
- Inside the content repo (e.g.,
<blog-posts>/images-mirror/) — the mirror travels with the markdown, deploy CI reads it from a git clone, no PDS blob fetch needed. - Outside the content repo (e.g.,
~/.config/fair/images-mirror.<profile>/) — fine for sandbox/test profiles where the bytes are throwaway between resets.
Why not blobs on the PDS: would need a sidecar collection in our own NSID
to record path↔CID mapping (site.standard.document is external — not
ours to extend), plus a hash-format bump to fold inline image source
hashes into Hash(). Punted to a future phase if/when remote-publish
becomes a real flow. Until then, the operator's chosen mirror location
is the durability story for inline image bytes.
Build flow (cold-start contract)#
internal/build.Build is the orchestrator. Key invariant: it works with no
auth, no records, even before any publication exists. It always emits
.well-known/oauth-client-metadata.json first (auth needs it on virgin
deploys); on missing publication it emits a minimal cold-start site
(well-known + empty feed/sitemap + stub index/404), atomic-promotes,
returns. Otherwise it paginates listRecords (unauthenticated), renders
per-post pages plus index, feed.xml, rss.xml, sitemap.xml, robots.txt,
JSON Feed, index.json, tag pages, archive, and copies ImagesMirror
into dist.tmp/images/ before atomic-promoting dist.tmp → dist.
Templates live in internal/build/templates/, embedded via embed.FS.
HTML templates use html/template (auto-escaping); XML/JSON templates use
text/template so XML declarations and JSON braces survive intact.
OAuth scopes (changing this list can invalidate sessions)#
internal/atproto.BlogScopes is the single source of truth:
atproto repo:site.standard.publication repo:site.standard.document repo:app.bsky.feed.post blob.
Adding a new collection here means existing sessions are missing the new
scope and need re-login (subset removals are fine — existing tokens
gracefully retain access to scopes they were issued under). When adding
a one-time migration scope, drop it from this list once the migration is
complete.
repo:app.bsky.feed.post is for fair publish --announce — the optional
flow that creates a Bluesky announce post per published article and pins
it as bskyPostRef. Drop this scope only if you remove the --announce
feature entirely; otherwise leave it.
Cobra wiring pattern#
Subcommands use newXxxCmd() constructors registered via root.AddCommand
in newRootCmd(). Do NOT use init() blocks — cmd/fair/main_test.go
relies on building a fresh tree per test (runCLI calls newRootCmd() each
invocation). New subcommands follow the same pattern.
Sandbox: pds.localtest.me + mkcert + Caddy#
The bluesky-social/pds image hardcodes https:// for OAuth issuer URLs and
rejects loopback hostnames. Workaround: pds.localtest.me (public DNS that
always resolves to 127.0.0.1, non-loopback per the PDS validator), mkcert
(OS-trusted local CA — run sudo mkcert -install once for browser trust;
the Go CLI trusts the CA explicitly regardless), and Caddy (terminates TLS
in front of the PDS). PDS_SERVICE_HANDLE_DOMAINS=".pds.localtest.me"
enables handle creation; the seed handle is aparker.pds.localtest.me
because test/admin/etc. are on the PDS's reserved-handle list.
Don't try to "simplify" sandbox to bare localhost — it's been tried and the PDS rejects the resulting OAuth issuer URL.
Local preview#
.claude/launch.json defines two run configs: sandbox-pds (docker compose
up) and dist-preview (python http.server on port 8000 serving
dist.sandbox/). Use the latter to browse the rendered site locally
without a webserver setup.
Test layout#
- Unit tests live next to the code (
*_test.goin the same package). - Integration tests against the sandbox PDS are gated behind
//go:build sandbox. They requiresandbox/seed.shto have run first. - Cobra tests build a fresh root via
newRootCmd()per invocation; preserve that.
Home-page customization (_index.md, _outro.md)#
Two optional files at the root of the authoring directory drive home-page-only customization. Neither gets published to the PDS — both are build-time chrome. Standard.site readers (Leaflet, pckt) see only the records on the PDS, not these files.
_index.md — body markdown rendered as the intro section between
<header> and the year disclosures. Optional YAML frontmatter at the
top drives further home-page-only behaviors. All fields optional:
---
description: "Override Publication.Description for the home page only"
subtitle: "Extra <p> between <h1> and the publication description"
pinned:
- small-software
- keep-the-internet-weird
hide_years: false
---
[intro markdown body…]
description— overridespublication.descriptionin the home page's<meta name="description">, OG tag, twitter card. Useful when the publication-record description is too short or is being kept generic for standard.site readers.subtitle— extra<p>rendered between the<h1>title and the publication description.pinned: [slug, ...]— renders a "Featured"<section>between the intro and the year disclosures, listing those posts in the order given. Slugs that don't match any published post log a warning and are skipped.hide_years: true— suppresses the year-group disclosures (e.g., for a fully-curated home page that only shows pinned posts + outro).
The body of _index.md (everything after the closing ---) is the
intro markdown. Goes through the same goldmark pipeline as posts,
including the EnrichResponsiveImages pass — though the intro doesn't
get image variants the way post bodies do, since intro images aren't
pre-staged into the mirror.
_outro.md — plain markdown (no frontmatter), rendered below the
year groups and above the home-page footer. Useful for "subscribe / how
this site works / contact" content that doesn't fit at the top of the
page. Missing file is not an error.
internal/build.loadHomeContent parses _index.md (frontmatter +
body), loadOutro reads _outro.md. Both are no-ops when the file
doesn't exist. See internal/build/build.go for the wiring.
Boundaries#
- Safe to edit:
cmd/,internal/,sandbox/,docs/. internal/markdown/testdata/posts are golden inputs — changing them invalidates hash tests; do it deliberately.- The authoring directory (configured
blog_posts) is the operator's repo, separate from this one. Within it,_index.mdand_outro.md(if present) are read at build time as home-page chrome (intro + customization frontmatter; outro); every other*.mdunder a post directory is content.
Gotchas#
FAIR_PROFILE=""(empty string) is treated as "unset" byResolveProfile, so test setup that doest.Setenv("FAIR_PROFILE", "")to clear inherited env works as intended.FileStore.Saveis atomic via temp-file + rename in the same directory. Don't change the temp location without preserving same-fs invariant.EmitClientMetadataderivesclient_idfrom the profile'sdomainfield; half-configured (empty domain) returns an error rather than silently writing a bad doc.publish.EnsureIDwrites the ULIDidback into the source markdown frontmatter. The source IS mutated in--dry-runfor that one field — rkeys must be stable across runs even when nothing else gets uploaded.- Windows is unsupported for sandbox; prod-side commands are fine. Use WSL2.