a minimal blog cms for your pds
13
fork

Configure Feed

Select the types of activity you want to include in your feed.

fair: comments, --announce, responsive images, security hardening

Builds on the initial publish/build pipeline:

- Comments: post pages render an inline-JS comments section that fetches
app.bsky.feed.getPostThread from the public Bluesky AppView at view
time. No-JS users get a "reply on Bluesky" link; empty-thread state
shows "be the first to reply"; replies render with avatar, profile
link, timestamp, body text, and a permalink ↗ to the specific reply.

- --announce flag on publish: optional auto-create of an
app.bsky.feed.post on the user's repo, pinned via bskyPostRef as the
comment-thread root. Adds repo:app.bsky.feed.post scope (existing
sessions need one-time re-login). Idempotent — writes the new at-uri
back to frontmatter as bsky_post: so re-runs short-circuit.

- bskyPostRef lexicon compliance: the field is a
com.atproto.repo.strongRef per the canonical standard.site lexicon —
fair now reads/writes both uri+cid. Frontmatter `bsky_post:` resolves
the CID via own-repo authenticated GetRecord (sandbox-friendly) or
the public AppView (federated posts).

- Responsive images: publish generates 480/960/1440/1920 variants
alongside each inline image (golang.org/x/image/draw CatmullRom);
build emits srcset/sizes/width/height + loading=lazy + decoding=async
on every <img>. New internal/images package.

- Home page: _index.md frontmatter gains description/subtitle/pinned/
hide_years for home-only customization; _outro.md is below-fold
chrome; year-grouped post index via <details>; tag cloud + h2
section headers; reading time per post; pdsls.dev link in the post
meta line.

- Polish: viewport meta, color-scheme light/dark, theme-color light/
dark, speculation-rules prerender, system font stack, line-height,
body width cap, centered prev/home/next nav, prev/next rel links.

- Security review fixes: path-traversal validation on PDS-controlled
path and tag fields; SSRF mitigation via no-redirect HTTP client
policy on the build's getRecord/listRecords calls; XSS escaping in
OAuth loopback callback page; at-uri prefix gate on htmltemplate.URL
casts; favicon download bounded with io.LimitReader (5 MiB); all
XRPC URL params now built via url.Values.Encode rather than
fmt.Sprintf.

- Cleanup: removed migrate cleanup-whtwnd subcommand and scope; new
docs/runbook.md replaces the migration-flavored runbook-cutover.md;
CLAUDE.md split between fair (project doc) and the operator's
blog-posts repo (deploy notebook).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+1796 -401
-14
.claude/launch.json
··· 2 2 "version": "0.0.1", 3 3 "configurations": [ 4 4 { 5 - "name": "sandbox-pds", 6 - "runtimeExecutable": "docker", 7 - "runtimeArgs": [ 8 - "compose", 9 - "-f", 10 - "sandbox/compose.yaml", 11 - "--env-file", 12 - "sandbox/.env", 13 - "up" 14 - ], 15 - "port": 443, 16 - "autoPort": false 17 - }, 18 - { 19 5 "name": "dist-preview", 20 6 "runtimeExecutable": "python3", 21 7 "runtimeArgs": [
+149 -21
CLAUDE.md
··· 1 1 # fair 2 2 3 - Last reviewed: 2026-04-30 3 + Last reviewed: 2026-04-30 (images-mirror lives in content repo for prod) 4 4 5 5 A Go CLI that publishes markdown posts to an ATProto PDS as `standard.site` 6 - lexicon records, then renders them as plain HTML for static deploy to 7 - `aparker.io`. Replaces the Next.js + WhiteWind stack at `~/code/pds-blog`. 6 + lexicon records, then renders them as plain HTML for static deploy. 8 7 9 - **Source of truth for architecture:** `docs/design-plans/2026-04-29-blog-v2026.md`. 8 + **Source of truth for architecture:** `docs/design-plans/2026-04-29-blog-v2026.md` 9 + (historical, captures the original design intent). 10 10 **Operator/user surface:** `README.md`. This file captures only the 11 11 non-obvious rules and the things that bite when forgotten. 12 12 13 - **Status:** Phases 1–7 of the design plan are complete; the codebase is 14 - feature-complete for single-author personal use. The actual prod cutover 15 - (domain DNS flip, first push to Tangled) is the operator's to drive. 13 + **Status:** Feature-complete for single-author personal use. The deployment 14 + is whatever the operator wires up — see `docs/runbook.md` for the 15 + recommended first-deploy path. 16 16 17 17 ## Stack 18 18 ··· 29 29 30 30 ## Domain 31 31 32 - - Site domain is **`aparker.io`** (not austinparker.me — common autocomplete trap). 33 32 - Lexicons: `site.standard.publication`, `site.standard.document`, 34 33 with `site.standard.content.markdown` content variant. 35 - - Records are canonical. Markdown files in `~/code/blog-posts` are working 36 - copies; `fair publish` is idempotent (content-hash skip; `--force` overrides). 34 + - Records on the PDS are canonical. Markdown files in the configured 35 + `blog_posts` directory are working copies; `fair publish` is idempotent 36 + (content-hash skip; `--force` overrides). 37 + - Each blog has its own authoring repo + operator notes — those live 38 + with the content, not here. See the operator's `CLAUDE.md` in their 39 + blog-posts directory for the deploy-side specifics. 37 40 38 41 ## Subcommands 39 42 40 43 `fair --profile <name> <verb>` — see README for full usage. One-line reminder: 41 44 `auth login|logout|status`, `init publication`, `publish <path>`, 42 - `unpublish <rkey>`, `build`, `ls`, `doctor`, `migrate cleanup-whtwnd`, 43 - `config show`, `emit-client-metadata <out>`. 45 + `unpublish <rkey>`, `build`, `ls`, `doctor`, `config show`, 46 + `emit-client-metadata <out>`. 44 47 45 48 ## Profile mechanism (mandatory, no default) 46 49 ··· 102 105 103 106 ## HTML post-processing (kept out of goldmark deliberately) 104 107 105 - `internal/build/render.go` runs three text passes on goldmark's output, in 106 - order. They live outside the parser specifically so hash stays stable: 108 + `internal/build/render.go` runs three text passes on goldmark's output 109 + inside `RenderMarkdown`, in order. They live outside the parser 110 + specifically so hash stays stable: 107 111 108 112 1. `resolveRelativeURLs` — joins `publication.url` with any `/...` href/src 109 113 so rendered HTML works on any origin. ··· 113 117 `<figure><img><figcaption>alt</figcaption></figure>`. Inline images 114 118 are left alone. 115 119 120 + A fourth pass — `EnrichResponsiveImages` in `internal/build/srcset.go` 121 + — runs in `Build` after `RenderMarkdown` returns (not inside it, 122 + because it needs `ImagesMirror` which the markdown package shouldn't 123 + know about). Adds `srcset`/`sizes`/`width`/`height` to `<img>` tags 124 + whose source has resized variants on disk. 125 + 116 126 Each pass is a regex on rendered HTML, not a goldmark parser/renderer 117 127 extension. Adding new transforms here is safe; modifying the goldmark parser 118 128 config to do the same thing is not. ··· 124 134 rendered HTML. This is a deliberate design choice — keep it relative or 125 135 readers like Leaflet/pckt break. 126 136 137 + ## Inline images: filesystem-staged, not PDS blobs 138 + 139 + Inline image bytes are **not** uploaded as PDS blobs (only cover images are). 140 + `fair publish` rewrites `![alt](./foo.png)` to a relative `/images/<slug>/<basename>` 141 + URL in the record body, and copies the source file into the directory 142 + configured as `images_mirror`. Alongside the source, it also generates 143 + resized variants (`<basename>-480.<ext>`, `-960`, `-1440`, `-1920`) 144 + when the source is wider than each target — see 145 + `internal/images/variants.go`. Variants for unsupported formats (gif, 146 + svg, etc.) are skipped silently; that image just gets a plain `<img>`. 147 + 148 + `fair build` reads from the mirror and copies into `dist/images/`. A 149 + build-time HTML pass (`internal/build/srcset.go::EnrichResponsiveImages`) 150 + walks `<img>` tags whose src points into `<publicationURL>/images/...` 151 + and rewrites them with `srcset`/`sizes`/`width`/`height` attributes 152 + when variants are present on disk. The pass is a no-op when 153 + `ImagesMirror` is empty or when an image has no variants. 154 + 155 + The browser needs two CSS rules for the rendered layout to behave — 156 + they live in one inline `<style>` block in every template `<head>`: 157 + 158 + ```css 159 + body{max-width:720px;margin:0 auto;padding:1rem} 160 + img{max-width:100%;height:auto} 161 + ``` 162 + 163 + The first caps the reading column to ~720px on desktop and centers it, 164 + which gives a typographically reasonable line length and naturally 165 + constrains images (since they live inside the body). The second makes 166 + images shrink to fit the column on narrow viewports. 167 + 168 + The "no CSS" claim in the README is that we don't ship a stylesheet, 169 + framework, or design system — not that we emit zero style attributes. 170 + Body width can't be constrained without CSS for the "narrow on mobile, 171 + narrow on desktop, centered everywhere" case (HTML only supports fixed 172 + `width="N"` on tables, which overflows mobile). The picture-with-media- 173 + queries pattern *can* size images alone without CSS but doesn't help 174 + text, costs more publish-side complexity, and doesn't center the result 175 + — so we keep the two-rule block instead. 176 + 177 + The operator decides where `images_mirror` lives. Two common shapes: 178 + - **Inside the content repo** (e.g., `<blog-posts>/images-mirror/`) — the 179 + mirror travels with the markdown, deploy CI reads it from a git clone, 180 + no PDS blob fetch needed. 181 + - **Outside the content repo** (e.g., `~/.config/fair/images-mirror.<profile>/`) 182 + — fine for sandbox/test profiles where the bytes are throwaway between 183 + resets. 184 + 185 + Why not blobs on the PDS: would need a sidecar collection in our own NSID 186 + to record path↔CID mapping (`site.standard.document` is external — not 187 + ours to extend), plus a hash-format bump to fold inline image source 188 + hashes into `Hash()`. Punted to a future phase if/when remote-publish 189 + becomes a real flow. Until then, the operator's chosen mirror location 190 + is the durability story for inline image bytes. 191 + 127 192 ## Build flow (cold-start contract) 128 193 129 194 `internal/build.Build` is the orchestrator. Key invariant: it works with no ··· 140 205 HTML templates use `html/template` (auto-escaping); XML/JSON templates use 141 206 `text/template` so XML declarations and JSON braces survive intact. 142 207 143 - ## OAuth scopes (changing this list invalidates sessions) 208 + ## OAuth scopes (changing this list can invalidate sessions) 144 209 145 210 `internal/atproto.BlogScopes` is the single source of truth: 146 - `atproto repo:site.standard.publication repo:site.standard.document repo:com.whtwnd.blog.entry blob`. 147 - Adding/removing collections here means existing sessions are missing the 148 - new scope and need re-login. The `repo:com.whtwnd.blog.entry` grant is for 149 - the one-time `migrate cleanup-whtwnd` flow — drop it once no PDS holds 150 - legacy whitewind records. 211 + `atproto repo:site.standard.publication repo:site.standard.document repo:app.bsky.feed.post blob`. 212 + Adding a new collection here means existing sessions are missing the new 213 + scope and need re-login (subset removals are fine — existing tokens 214 + gracefully retain access to scopes they were issued under). When adding 215 + a one-time migration scope, drop it from this list once the migration is 216 + complete. 217 + 218 + `repo:app.bsky.feed.post` is for `fair publish --announce` — the optional 219 + flow that creates a Bluesky announce post per published article and pins 220 + it as `bskyPostRef`. Drop this scope only if you remove the --announce 221 + feature entirely; otherwise leave it. 151 222 152 223 ## Cobra wiring pattern 153 224 ··· 184 255 They require `sandbox/seed.sh` to have run first. 185 256 - Cobra tests build a fresh root via `newRootCmd()` per invocation; preserve that. 186 257 258 + ## Home-page customization (`_index.md`, `_outro.md`) 259 + 260 + Two optional files at the root of the authoring directory drive 261 + home-page-only customization. Neither gets published to the PDS — both 262 + are build-time chrome. Standard.site readers (Leaflet, pckt) see only 263 + the records on the PDS, not these files. 264 + 265 + **`_index.md`** — body markdown rendered as the intro section between 266 + `<header>` and the year disclosures. Optional YAML frontmatter at the 267 + top drives further home-page-only behaviors. All fields optional: 268 + 269 + ```yaml 270 + --- 271 + description: "Override Publication.Description for the home page only" 272 + subtitle: "Extra <p> between <h1> and the publication description" 273 + pinned: 274 + - small-software 275 + - keep-the-internet-weird 276 + hide_years: false 277 + --- 278 + 279 + [intro markdown body…] 280 + ``` 281 + 282 + - `description` — overrides `publication.description` in the home 283 + page's `<meta name="description">`, OG tag, twitter card. Useful when 284 + the publication-record description is too short or is being kept 285 + generic for standard.site readers. 286 + - `subtitle` — extra `<p>` rendered between the `<h1>` title and the 287 + publication description. 288 + - `pinned: [slug, ...]` — renders a "Featured" `<section>` between the 289 + intro and the year disclosures, listing those posts in the order 290 + given. Slugs that don't match any published post log a warning and 291 + are skipped. 292 + - `hide_years: true` — suppresses the year-group disclosures (e.g., 293 + for a fully-curated home page that only shows pinned posts + 294 + outro). 295 + 296 + The body of `_index.md` (everything after the closing `---`) is the 297 + intro markdown. Goes through the same goldmark pipeline as posts, 298 + including the `EnrichResponsiveImages` pass — though the intro doesn't 299 + get image variants the way post bodies do, since intro images aren't 300 + pre-staged into the mirror. 301 + 302 + **`_outro.md`** — plain markdown (no frontmatter), rendered below the 303 + year groups and above the home-page footer. Useful for "subscribe / how 304 + this site works / contact" content that doesn't fit at the top of the 305 + page. Missing file is not an error. 306 + 307 + `internal/build.loadHomeContent` parses `_index.md` (frontmatter + 308 + body), `loadOutro` reads `_outro.md`. Both are no-ops when the file 309 + doesn't exist. See `internal/build/build.go` for the wiring. 310 + 187 311 ## Boundaries 188 312 189 313 - Safe to edit: `cmd/`, `internal/`, `sandbox/`, `docs/`. 190 314 - `internal/markdown/testdata/` posts are golden inputs — changing them 191 315 invalidates hash tests; do it deliberately. 192 - - `~/code/blog-posts/` is the authoring repo, separate from this one. 316 + - The authoring directory (configured `blog_posts`) is the operator's repo, 317 + separate from this one. Within it, `_index.md` and `_outro.md` (if 318 + present) are read at build time as home-page chrome (intro + 319 + customization frontmatter; outro); every other `*.md` under a post 320 + directory is content. 193 321 194 322 ## Gotchas 195 323
+24 -10
README.md
··· 22 22 available. 23 23 - **Pure HTML output.** Semantic markup, native widgets (`<details>`, 24 24 `<progress>`, `<meter>`, `<figure>`, etc.), browser default styling. 25 + The only CSS shipped is a two-rule inline block — one constrains the 26 + reading column to ~720px on desktop, one keeps images from overflowing 27 + it. No stylesheet, no framework, no design system; everything else 28 + uses browser defaults. 29 + - **Responsive images.** `fair publish` generates resized variants 30 + (480/960/1440/1920) alongside each inline image; rendered HTML emits 31 + `srcset` + `sizes` so phones fetch a small variant and big screens 32 + fetch a sharp one. 25 33 - **Native widgets:** 26 34 [demo article](https://aparker.io/html-feature-demo/) shows 27 35 what's possible with no CSS or JS. ··· 30 38 - **Alternate formats:** Atom + RSS 2.0 + JSON Feed + `index.json` + 31 39 `sitemap.xml` + `robots.txt`, all generated. 32 40 - **Tag pages, archive page, prev/next navigation** between posts. 41 + - **Year-grouped home page** with native `<details>` disclosures; current 42 + year expanded by default. 43 + - **Optional home-page intro:** drop `_index.md` in your blog-posts 44 + directory and it renders above the year disclosures. 33 45 - **Local sandbox** for end-to-end testing without touching prod 34 46 (Caddy + mkcert + `pds.localtest.me` + a real PDS Docker container). 35 47 - **Operational tools:** `ls` (PDS-vs-local diff), `doctor` (health 36 - checks), `unpublish`, `migrate cleanup-whtwnd`. 48 + checks), `unpublish`. 37 49 38 50 ## Status 39 51 40 - Alpha but feature-complete for single-author personal use. Replaces 41 - the Next.js + WhiteWind stack at `~/code/pds-blog`. See 52 + Alpha but feature-complete for single-author personal use. See 42 53 [`docs/design-plans/2026-04-29-blog-v2026.md`](docs/design-plans/2026-04-29-blog-v2026.md) 43 - for the validated architecture and 44 - [`docs/runbook-cutover.md`](docs/runbook-cutover.md) for the 45 - production cutover steps. 54 + for the validated architecture (historical) and 55 + [`docs/runbook.md`](docs/runbook.md) for the first-deploy walkthrough. 46 56 47 57 ## Quick start 48 58 ··· 60 70 description = "Tagline" 61 71 blog_posts = "/path/to/blog-posts" 62 72 state_file = ".fair/state.prod.json" 63 - images_mirror = "images-mirror.prod/" 73 + # For prod, mirror lives inside the content repo so deploy CI can read 74 + # inline image bytes from a git clone (no PDS blob fetch for inline images). 75 + images_mirror = "/path/to/blog-posts/images-mirror" 64 76 dist_dir = "dist/" 65 77 social_links = ["https://bsky.app/profile/handle.bsky.social"] 66 78 EOF ··· 77 89 # Publish a post 78 90 ./fair --profile prod publish blog-posts/my-post/index.md 79 91 92 + # Persist the frontmatter id-write + any new images in images-mirror/ 93 + ( cd /path/to/blog-posts && git add . && git commit -m 'publish: my-post' && git push ) 94 + 80 95 # Render & deploy 81 96 ./fair --profile prod build 82 97 wrangler pages deploy dist/ ··· 96 111 | `ls` | Diff PDS records against local markdown | 97 112 | `doctor` | Five-check health report | 98 113 | `unpublish <rkey>` | Delete a `site.standard.document` record | 99 - | `migrate cleanup-whtwnd --yes` | Delete legacy WhiteWind records (one-time post-cutover) | 100 114 | `config show` | Print loaded config for the active profile | 101 115 102 116 Profile is **mandatory** — pass `--profile <name>` or set ··· 145 159 internal/build/ # PDS records → dist/ HTML rendering 146 160 internal/config/ # TOML config + profile resolution 147 161 internal/markdown/ # Normalize + frontmatter + hash + goldmark parser 148 - internal/ops/ # ls, doctor, unpublish, migrate 162 + internal/ops/ # ls, doctor, unpublish 149 163 internal/publish/ # 13-step publish orchestrator + building blocks 150 164 sandbox/ # Local PDS Docker compose + Caddy + scripts 151 165 docs/design-plans/ # Validated architecture 152 - docs/runbook-cutover.md # Production migration steps 166 + docs/runbook.md # First-deploy runbook 153 167 ``` 154 168 155 169 ## License
+1
cmd/fair/build.go
··· 41 41 Description: cfg.Description, 42 42 SocialLinks: cfg.SocialLinks, 43 43 ImagesMirror: cfg.ImagesMirror, 44 + BlogPosts: cfg.BlogPosts, 44 45 DistDir: cfg.DistDir, 45 46 CAPool: rootCAs, 46 47 })
-1
cmd/fair/main.go
··· 39 39 root.AddCommand(newLsCmd()) 40 40 root.AddCommand(newUnpublishCmd()) 41 41 root.AddCommand(newDoctorCmd()) 42 - root.AddCommand(newMigrateCmd()) 43 42 return root 44 43 }
-68
cmd/fair/migrate.go
··· 1 - package main 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - "os" 7 - "os/signal" 8 - 9 - "aparker.io/fair/internal/atproto" 10 - "aparker.io/fair/internal/ops" 11 - "github.com/spf13/cobra" 12 - ) 13 - 14 - // newMigrateCmd builds `fair migrate` and its cleanup-whtwnd 15 - // subcommand. Lives separately from `init` because migrate is 16 - // inherently destructive (deletes records). 17 - func newMigrateCmd() *cobra.Command { 18 - cmd := &cobra.Command{ 19 - Use: "migrate", 20 - Short: "One-time migration helpers (legacy lexicon cleanup, etc.)", 21 - } 22 - cmd.AddCommand(newMigrateCleanupWhtwndCmd()) 23 - return cmd 24 - } 25 - 26 - func newMigrateCleanupWhtwndCmd() *cobra.Command { 27 - cmd := &cobra.Command{ 28 - Use: "cleanup-whtwnd", 29 - Short: "Delete all com.whtwnd.blog.entry records (post-cutover cleanup)", 30 - Args: cobra.NoArgs, 31 - RunE: func(cmd *cobra.Command, _ []string) error { 32 - confirm, _ := cmd.Flags().GetBool("yes") 33 - if !confirm { 34 - fmt.Fprintln(cmd.OutOrStderr(), "destructive: deletes every com.whtwnd.blog.entry record in your repo.") 35 - fmt.Fprintln(cmd.OutOrStderr(), "re-run with --yes to proceed.") 36 - return fmt.Errorf("confirmation required") 37 - } 38 - 39 - profile, cfg, err := resolveProfileAndLoad(cmd) 40 - if err != nil { 41 - return err 42 - } 43 - caBundle, _ := cmd.Flags().GetString("ca-bundle") 44 - rootCAs, err := atproto.LoadExtraRoots(caBundle) 45 - if err != nil { 46 - return err 47 - } 48 - 49 - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) 50 - defer stop() 51 - 52 - client, err := newAuthedClient(ctx, cmd, cfg, profile, rootCAs) 53 - if err != nil { 54 - return err 55 - } 56 - 57 - count, err := ops.CleanupWhtwnd(ctx, client) 58 - if err != nil { 59 - return err 60 - } 61 - fmt.Fprintf(cmd.OutOrStdout(), "deleted %d com.whtwnd.blog.entry record(s)\n", count) 62 - return nil 63 - }, 64 - } 65 - cmd.Flags().Bool("yes", false, "confirm deletion") 66 - cmd.Flags().String("ca-bundle", "", "PEM file containing extra trusted CAs (sandbox: mkcert root)") 67 - return cmd 68 - }
+6 -2
cmd/fair/publish.go
··· 65 65 66 66 dryRun, _ := cmd.Flags().GetBool("dry-run") 67 67 force, _ := cmd.Flags().GetBool("force") 68 + announce, _ := cmd.Flags().GetBool("announce") 68 69 caBundle, _ := cmd.Flags().GetString("ca-bundle") 69 70 70 71 rootCAs, err := atproto.LoadExtraRoots(caBundle) ··· 92 93 StateFile: cfg.StateFile, 93 94 ImagesMirror: cfg.ImagesMirror, 94 95 PublicationURI: pubURI, 96 + PublicationURL: cfg.Domain, 95 97 MaxCoverSize: 1 << 20, // 1 MiB 96 98 }, publish.Options{ 97 - DryRun: dryRun, 98 - Force: force, 99 + DryRun: dryRun, 100 + Force: force, 101 + Announce: announce, 99 102 Logger: func(format string, a ...any) { 100 103 fmt.Fprintf(cmd.OutOrStderr(), " "+format+"\n", a...) 101 104 }, ··· 125 128 } 126 129 cmd.Flags().Bool("dry-run", false, "report what would happen without uploading or putting") 127 130 cmd.Flags().Bool("force", false, "re-put even when content hash is unchanged") 131 + cmd.Flags().Bool("announce", false, "create an app.bsky.feed.post on first publish and pin it as bskyPostRef (the comment-thread root)") 128 132 cmd.Flags().String("ca-bundle", "", "PEM file containing extra trusted CAs (sandbox: mkcert root)") 129 133 return cmd 130 134 }
-170
docs/runbook-cutover.md
··· 1 - # Cutover runbook: pds-blog (Next.js + WhiteWind) → blog-v2026 (Go + standard.site) 2 - 3 - Single-author production migration. Total wall-clock: ~30 min if nothing 4 - goes sideways. Safer to do this on a slow afternoon than on a Friday. 5 - 6 - ## Pre-flight 7 - 8 - - [ ] All Phase 1–6 tests passing: `go test ./...` green 9 - - [ ] Sandbox verified end-to-end at least once: `fair --profile sandbox auth login` → `init publication` → `publish` → `build` → eyeball 10 - - [ ] `~/code/blog-posts` clean and committed (this CLI mutates frontmatter on first publish to write back ULIDs) 11 - - [ ] You're prepared to deploy a near-empty `dist/` to aparker.io once before first auth login (cold-start contract; the OAuth client metadata document needs to live at the prod domain before the AS can fetch it) 12 - 13 - ## 1. Set up the prod profile 14 - 15 - Create `~/.config/fair/config.prod.toml`: 16 - 17 - ```toml 18 - pds_url = "https://bsky.social" # or your actual PDS endpoint 19 - did = "did:plc:..." # your real DID (resolve via bsky) 20 - domain = "https://aparker.io" 21 - publication_name = "Austin Parker" 22 - description = "..." # optional tagline 23 - blog_posts = "/Users/austinparker/code/blog-posts" 24 - state_file = ".fair/state.prod.json" 25 - images_mirror = "images-mirror.prod/" 26 - dist_dir = "dist/" 27 - cloudflare_project = "aparker-blog" # if you use fair deploy later 28 - loopback_client = false # prod fetches the metadata doc 29 - social_links = [ 30 - "https://bsky.app/profile/<your-handle>", 31 - # add github, mastodon, etc. 32 - ] 33 - # loopback_client deliberately omitted (defaults to false). Prod uses 34 - # the published metadata document, not the localhost client_id form. 35 - ``` 36 - 37 - Resolve your DID with: 38 - 39 - ```bash 40 - curl -s "https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=<your-handle>" | jq .did 41 - ``` 42 - 43 - ## 2. Cold-start build + first deploy 44 - 45 - Build with no auth, no records — emits the empty site + the OAuth client 46 - metadata that `auth login` will need to fetch. 47 - 48 - ```bash 49 - ./fair --profile prod build 50 - ls dist/ 51 - # Should include: .well-known/oauth-client-metadata.json 52 - ``` 53 - 54 - Deploy: 55 - 56 - ```bash 57 - wrangler pages deploy dist/ --project-name=aparker-blog 58 - # Or: configure git-integrated deploys and push 59 - ``` 60 - 61 - Verify: 62 - 63 - ```bash 64 - curl -fsS https://aparker.io/.well-known/oauth-client-metadata.json | jq .client_id 65 - # Should echo: https://aparker.io/.well-known/oauth-client-metadata.json 66 - ``` 67 - 68 - ## 3. Auth login (browser hop) 69 - 70 - ```bash 71 - ./fair --profile prod auth login --handle <your-handle> 72 - ``` 73 - 74 - The browser opens to your PDS's OAuth consent page; review the requested 75 - scopes (atproto + repo:site.standard.* + blob), approve, redirect lands 76 - on the loopback. `auth status` should now show your DID. 77 - 78 - ## 4. Create the publication record 79 - 80 - ```bash 81 - ./fair --profile prod init publication 82 - # > publication record at at://did:plc:.../site.standard.publication/self 83 - ``` 84 - 85 - ## 5. Publish all 11 posts 86 - 87 - ```bash 88 - for d in ~/code/blog-posts/*/; do 89 - ./fair --profile prod publish "$d/index.md" 90 - done 91 - ``` 92 - 93 - Each first publish writes a ULID back to the markdown frontmatter — commit 94 - those changes back to ~/code/blog-posts after this loop. Re-running is 95 - idempotent (content-hash skip). 96 - 97 - Watch for: 98 - - **Cover too large**: rejected client-side at 1MB. Convert/downscale and 99 - retry the affected post. 100 - - **Slug grammar errors**: directory names must match `[a-z0-9-]+`. Rename 101 - directories (or set explicit `slug:` in frontmatter) before publishing. 102 - - **Reserved slugs**: `self`, `index`, `feed`, `sitemap`, `.well-known` are 103 - rejected. 104 - 105 - ## 6. Final build + deploy 106 - 107 - ```bash 108 - ./fair --profile prod build 109 - wrangler pages deploy dist/ --project-name=aparker-blog 110 - ``` 111 - 112 - ## 7. Verify 113 - 114 - ```bash 115 - ./fair --profile prod ls 116 - # Expect: 11 'synced' rows 117 - ./fair --profile prod doctor 118 - # Expect: all 5 checks ✓ 119 - curl -fsS https://aparker.io/feed.xml | head -10 120 - curl -fsS https://aparker.io/.well-known/site.standard.publication 121 - ``` 122 - 123 - Open https://aparker.io/ in a browser. Click through one or two posts. 124 - Eyeball OG meta tags via View Source. 125 - 126 - Cross-render in an external standard.site reader (e.g., Leaflet) to 127 - confirm interop: 128 - - Navigate to your DID in their UI 129 - - Verify your posts appear with correct metadata 130 - 131 - ## 8. (Optional) DNS / project bindings 132 - 133 - Whatever name you use for the Cloudflare Pages project, make sure the 134 - custom domain `aparker.io` is bound to it and DNS points there. 135 - 136 - ## 9. (After ~1 week of stable operation) Cleanup 137 - 138 - When you've confirmed nothing depends on the legacy WhiteWind records: 139 - 140 - ```bash 141 - ./fair --profile prod migrate cleanup-whtwnd --yes 142 - ``` 143 - 144 - Deletes every `com.whtwnd.blog.entry` record in your repo. Idempotent 145 - on missing. 146 - 147 - ## 10. Archive the old repo 148 - 149 - ```bash 150 - cd ~/code/pds-blog 151 - git tag archived-2026-04-30 152 - # Then archive the GitHub repo via the web UI (Settings → Archive). 153 - ``` 154 - 155 - Don't delete it — keep it around in case you ever want to look back at 156 - the old render. 157 - 158 - ## Rollback (if something goes wrong) 159 - 160 - The standard.site records and the WhiteWind records coexist on the PDS 161 - until step 9 is run. To roll back: 162 - 163 - 1. Re-deploy the old Next.js site to aparker.io (it still reads 164 - `com.whtwnd.blog.entry` records). 165 - 2. Optionally `./fair --profile prod ls`, then `./fair unpublish <rkey>` 166 - each `site.standard.document` record to remove the new lexicon. 167 - 3. Don't run `migrate cleanup-whtwnd` until you're sure. 168 - 169 - The blog-posts repo got ULIDs written back; those are harmless but you 170 - can manually remove the `id:` lines if you want a clean slate.
+176
docs/runbook.md
··· 1 + # First-deploy runbook 2 + 3 + How to take a freshly-built `fair` binary, your PDS account, and a 4 + domain, and end up with a live `standard.site`-rendered blog. Total 5 + wall-clock: ~30 min if nothing goes sideways. 6 + 7 + ## Pre-flight 8 + 9 + - [ ] All tests passing: `go test ./...` green 10 + - [ ] Sandbox verified end-to-end at least once: `fair --profile sandbox auth login` → `init publication` → `publish` → `build` → eyeball 11 + - [ ] Your authoring directory (e.g., `~/code/blog-posts`) is clean and committed somewhere — `fair publish` mutates frontmatter on first publish to write back ULIDs, plus copies inline images into the configured `images_mirror` 12 + - [ ] You're prepared to deploy a near-empty `dist/` to your domain once before first auth login (cold-start contract; the OAuth client metadata document needs to live at the prod domain before the auth server can fetch it) 13 + 14 + ## 1. Set up the prod profile 15 + 16 + Create `~/.config/fair/config.prod.toml`: 17 + 18 + ```toml 19 + pds_url = "https://bsky.social" # or your actual PDS endpoint 20 + did = "did:plc:..." # your real DID (resolve via DNS or your PDS) 21 + domain = "https://example.com" 22 + publication_name = "Your Name" 23 + description = "..." # optional tagline 24 + blog_posts = "/path/to/blog-posts" 25 + state_file = ".fair/state.prod.json" 26 + # Mirror lives in the content repo so the deploy CI can read images 27 + # from a git clone (no PDS blob fetch for inline images). 28 + images_mirror = "/path/to/blog-posts/images-mirror" 29 + dist_dir = "dist/" 30 + cloudflare_project = "your-pages-project" # if you use a fair deploy wrapper later 31 + loopback_client = false # prod fetches the metadata doc 32 + social_links = [ 33 + "https://bsky.app/profile/<your-handle>", 34 + # add github, mastodon, etc. 35 + ] 36 + ``` 37 + 38 + Resolve your DID via the PDS-independent paths (in preference order): 39 + 40 + ```bash 41 + # 1. DNS TXT — canonical, no infrastructure dependency 42 + dig +short TXT _atproto.<your-handle> | tr -d '"' | sed 's/^did=//' 43 + 44 + # 2. HTTPS well-known — works when your handle is a domain you control 45 + curl -fsS https://<your-handle>/.well-known/atproto-did 46 + 47 + # 3. PDS XRPC fallback — trust your PDS to resolve. Use *your* PDS, not 48 + # bsky.social, unless your handle actually lives on bsky.social. 49 + curl -fsS "https://<your-pds>/xrpc/com.atproto.identity.resolveHandle?handle=<your-handle>" | jq -r .did 50 + ``` 51 + 52 + For a domain handle you own, (1) or (2) is the right answer; both work 53 + even if every PDS in the network is down. 54 + 55 + ## 2. Cold-start build + first deploy 56 + 57 + Build with no auth, no records — emits the empty site + the OAuth client 58 + metadata that `auth login` will need to fetch. 59 + 60 + ```bash 61 + ./fair --profile prod build 62 + ls dist/ 63 + # Should include: .well-known/oauth-client-metadata.json 64 + ``` 65 + 66 + Deploy: 67 + 68 + ```bash 69 + wrangler pages deploy dist/ --project-name=<your-pages-project> 70 + # Or: configure git-integrated deploys and push 71 + ``` 72 + 73 + Verify: 74 + 75 + ```bash 76 + curl -fsS https://example.com/.well-known/oauth-client-metadata.json | jq .client_id 77 + # Should echo: https://example.com/.well-known/oauth-client-metadata.json 78 + ``` 79 + 80 + ## 3. Auth login (browser hop) 81 + 82 + ```bash 83 + ./fair --profile prod auth login --handle <your-handle> 84 + ``` 85 + 86 + The browser opens to your PDS's OAuth consent page; review the requested 87 + scopes (atproto + repo:site.standard.* + blob), approve, redirect lands 88 + on the loopback. `auth status` should now show your DID. 89 + 90 + ## 4. Create the publication record 91 + 92 + ```bash 93 + ./fair --profile prod init publication 94 + # > publication record at at://did:plc:.../site.standard.publication/self 95 + ``` 96 + 97 + ## 5. Publish your posts 98 + 99 + ```bash 100 + for d in /path/to/blog-posts/*/; do 101 + ./fair --profile prod publish "$d/index.md" 102 + done 103 + ``` 104 + 105 + Each first publish writes a ULID back to the markdown frontmatter AND 106 + copies inline image bytes into `<blog-posts>/images-mirror/<slug>/`. 107 + Commit both back to the blog-posts repo after this loop: 108 + 109 + ```bash 110 + cd /path/to/blog-posts 111 + git add . && git status # eyeball what changed 112 + git commit -m "publish: initial standard.site posts" 113 + git push 114 + ``` 115 + 116 + The `git push` gives inline image bytes durability outside your 117 + laptop. Re-running `fair publish` is idempotent (content-hash skip). 118 + 119 + Watch for: 120 + - **Cover too large**: rejected client-side at 1MB. Convert/downscale and 121 + retry the affected post. 122 + - **Slug grammar errors**: directory names must match `[a-z0-9-]+`. Rename 123 + directories (or set explicit `slug:` in frontmatter) before publishing. 124 + - **Reserved slugs**: `self`, `index`, `feed`, `sitemap`, `.well-known` are 125 + rejected. 126 + 127 + ## 6. Final build + deploy 128 + 129 + ```bash 130 + ./fair --profile prod build 131 + wrangler pages deploy dist/ --project-name=<your-pages-project> 132 + ``` 133 + 134 + Build runs entirely locally — `fair build` reads public records from 135 + the PDS (no auth needed) and writes `dist/`. The deploy step is 136 + `wrangler` from your laptop. Two reasons we don't ship CI for this: 137 + publishing already requires the laptop (OAuth session lives there), 138 + and the build is fast enough that `build && deploy` is one shell 139 + command. If you eventually want hands-off rebuilds (e.g., to pick up 140 + out-of-band PDS edits), see the dedicated cron-Worker / Jetstream 141 + options in the design plan. 142 + 143 + ## 7. Verify 144 + 145 + ```bash 146 + ./fair --profile prod ls 147 + # Expect: every post 'synced' 148 + ./fair --profile prod doctor 149 + # Expect: all checks ✓ 150 + curl -fsS https://example.com/feed.xml | head -10 151 + curl -fsS https://example.com/.well-known/site.standard.publication 152 + ``` 153 + 154 + Open `https://example.com/` in a browser. Click through one or two posts. 155 + Eyeball OG meta tags via View Source. 156 + 157 + Cross-render in an external standard.site reader (e.g., Leaflet) to 158 + confirm interop: 159 + - Navigate to your DID in their UI 160 + - Verify your posts appear with correct metadata 161 + 162 + ## 8. (Optional) DNS / project bindings 163 + 164 + Make sure your custom domain is bound to the Pages project and DNS 165 + points there. 166 + 167 + ## Rollback (if something goes wrong) 168 + 169 + The standard.site records on the PDS are entirely independent of your 170 + deployed site. To roll back the deployment, redeploy the previous 171 + `dist/` (Cloudflare Pages keeps deployment history). To roll back 172 + records, `./fair --profile prod ls`, then `./fair unpublish <rkey>` for 173 + each `site.standard.document` record you want to remove. 174 + 175 + If frontmatter ULIDs got written back during a failed run, they're 176 + harmless — re-running `fair publish` will reuse them as rkeys.
+12 -9
go.mod
··· 2 2 3 3 go 1.26.2 4 4 5 - require github.com/spf13/cobra v1.10.2 5 + require ( 6 + github.com/BurntSushi/toml v1.6.0 7 + github.com/bluesky-social/indigo v0.0.0-20250616202859-d4516ea1d6cf 8 + github.com/haileyok/atproto-oauth-golang v0.0.3 9 + github.com/lestrrat-go/jwx/v2 v2.0.12 10 + github.com/oklog/ulid/v2 v2.1.1 11 + github.com/rivo/uniseg v0.4.7 12 + github.com/spf13/cobra v1.10.2 13 + github.com/yuin/goldmark v1.8.2 14 + golang.org/x/image v0.39.0 15 + gopkg.in/yaml.v3 v3.0.1 16 + ) 6 17 7 18 require ( 8 - github.com/BurntSushi/toml v1.6.0 // indirect 9 - github.com/bluesky-social/indigo v0.0.0-20250616202859-d4516ea1d6cf // indirect 10 19 github.com/carlmjohnson/versioninfo v0.22.5 // indirect 11 20 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect 12 21 github.com/felixge/httpsnoop v1.0.4 // indirect ··· 16 25 github.com/gogo/protobuf v1.3.2 // indirect 17 26 github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 18 27 github.com/google/uuid v1.6.0 // indirect 19 - github.com/haileyok/atproto-oauth-golang v0.0.3 // indirect 20 28 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 21 29 github.com/hashicorp/go-retryablehttp v0.7.5 // indirect 22 30 github.com/hashicorp/golang-lru v1.0.2 // indirect ··· 39 47 github.com/lestrrat-go/httpcc v1.0.1 // indirect 40 48 github.com/lestrrat-go/httprc v1.0.4 // indirect 41 49 github.com/lestrrat-go/iter v1.0.2 // indirect 42 - github.com/lestrrat-go/jwx/v2 v2.0.12 // indirect 43 50 github.com/lestrrat-go/option v1.0.1 // indirect 44 51 github.com/mattn/go-isatty v0.0.20 // indirect 45 52 github.com/minio/sha256-simd v1.0.1 // indirect ··· 49 56 github.com/multiformats/go-multibase v0.2.0 // indirect 50 57 github.com/multiformats/go-multihash v0.2.3 // indirect 51 58 github.com/multiformats/go-varint v0.0.7 // indirect 52 - github.com/oklog/ulid/v2 v2.1.1 // indirect 53 59 github.com/opentracing/opentracing-go v1.2.0 // indirect 54 60 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect 55 - github.com/rivo/uniseg v0.4.7 // indirect 56 61 github.com/segmentio/asm v1.2.0 // indirect 57 62 github.com/spaolacci/murmur3 v1.1.0 // indirect 58 63 github.com/spf13/pflag v1.0.9 // indirect 59 64 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect 60 - github.com/yuin/goldmark v1.8.2 // indirect 61 65 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect 62 66 go.opentelemetry.io/otel v1.29.0 // indirect 63 67 go.opentelemetry.io/otel/metric v1.29.0 // indirect ··· 68 72 golang.org/x/crypto v0.31.0 // indirect 69 73 golang.org/x/sys v0.28.0 // indirect 70 74 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect 71 - gopkg.in/yaml.v3 v3.0.1 // indirect 72 75 lukechampine.com/blake3 v1.2.1 // indirect 73 76 )
+30
go.sum
··· 10 10 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 11 11 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 12 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 14 + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 15 github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= 14 16 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= 15 17 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= ··· 27 29 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 28 30 github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= 29 31 github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 32 + github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 33 + github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 30 34 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 31 35 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 32 36 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 37 + github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= 33 38 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 34 39 github.com/haileyok/atproto-oauth-golang v0.0.3 h1:LdYSl6sgz11wnv8YD5e9WtopANEg4bCfMIXHMMnkOiI= 35 40 github.com/haileyok/atproto-oauth-golang v0.0.3/go.mod h1:vVRo6BPEmWOZnYk9LtXLzBPzfkY63fUaBahA+o4h55Q= 36 41 github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 37 42 github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 43 + github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI= 38 44 github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= 39 45 github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M= 40 46 github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= ··· 50 56 github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk= 51 57 github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk= 52 58 github.com/ipfs/go-datastore v0.6.0/go.mod h1:rt5M3nNbSO/8q1t4LNkLyUwRs8HupMeN/8O4Vn9YAT8= 59 + github.com/ipfs/go-detect-race v0.0.1 h1:qX/xay2W3E4Q1U7d9lNs1sU9nvguX0a7319XbyQ6cOk= 60 + github.com/ipfs/go-detect-race v0.0.1/go.mod h1:8BNT7shDZPo99Q74BpGMK+4D8Mn4j46UU0LZ723meps= 53 61 github.com/ipfs/go-ipfs-blockstore v1.3.1 h1:cEI9ci7V0sRNivqaOr0elDsamxXFxJMMMy7PTTDQNsQ= 54 62 github.com/ipfs/go-ipfs-blockstore v1.3.1/go.mod h1:KgtZyc9fq+P2xJUiCAzbRdhhqJHvsw8u2Dlqy2MyRTE= 55 63 github.com/ipfs/go-ipfs-ds-help v1.1.1 h1:B5UJOH52IbcfS56+Ul+sv8jnIV10lbjLF5eOO0C66Nw= ··· 70 78 github.com/jbenet/go-cienv v0.1.0/go.mod h1:TqNnHUmJgXau0nCzC7kXWeotg3J9W34CUv5Djy1+FlA= 71 79 github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o= 72 80 github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4= 81 + github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 82 + github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 83 + github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 73 84 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 74 85 github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 75 86 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 76 87 github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= 77 88 github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 78 89 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 90 + github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 91 + github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 79 92 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 80 93 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 94 + github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 95 + github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 81 96 github.com/lestrrat-go/blackmagic v1.0.1/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= 82 97 github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= 83 98 github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= ··· 116 131 github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= 117 132 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 118 133 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 134 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 135 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 119 136 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= 120 137 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= 121 138 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 122 139 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 123 140 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 141 + github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 142 + github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 124 143 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 125 144 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 126 145 github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= 127 146 github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 128 147 github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 148 + github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs= 129 149 github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= 150 + github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg= 130 151 github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= 131 152 github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 132 153 github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= ··· 145 166 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 146 167 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 147 168 github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 169 + github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 170 + github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 148 171 github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 172 + github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ= 149 173 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 150 174 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4= 151 175 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= ··· 168 192 go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= 169 193 go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 170 194 go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= 195 + go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= 196 + go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= 171 197 go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= 172 198 go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= 173 199 go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= ··· 186 212 golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= 187 213 golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= 188 214 golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 215 + golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= 216 + golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= 189 217 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 190 218 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 191 219 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= ··· 257 285 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 258 286 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 259 287 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 288 + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 289 + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 260 290 gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 261 291 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 262 292 gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+31
internal/atproto/client.go
··· 118 118 return &out, nil 119 119 } 120 120 121 + // CreateRecordInput models com.atproto.repo.createRecord input. Like 122 + // putRecord but the rkey is server-assigned (a TID). Used for records 123 + // where the operator doesn't have a stable identifier — most notably 124 + // app.bsky.feed.post when announcing a blog post. 125 + type CreateRecordInput struct { 126 + Repo string `json:"repo"` 127 + Collection string `json:"collection"` 128 + Rkey string `json:"rkey,omitempty"` 129 + Record any `json:"record"` 130 + Validate *bool `json:"validate,omitempty"` 131 + } 132 + 133 + // CreateRecord creates a new record with a server-assigned rkey. 134 + // Returns the assigned URI+CID. 135 + func (c *Client) CreateRecord(ctx context.Context, collection string, record any) (*RecordRef, error) { 136 + in := CreateRecordInput{ 137 + Repo: c.session.DID, 138 + Collection: collection, 139 + Record: record, 140 + } 141 + var out RecordRef 142 + err := c.xrpc.Do(ctx, c.authedArgs(), 143 + xrpc.Procedure, "application/json", 144 + "com.atproto.repo.createRecord", nil, in, &out, 145 + ) 146 + if err != nil { 147 + return nil, fmt.Errorf("createRecord: %w", err) 148 + } 149 + return &out, nil 150 + } 151 + 121 152 // GetRecordOutput is the shape of com.atproto.repo.getRecord output. 122 153 type GetRecordOutput struct { 123 154 URI string `json:"uri"`
+3 -2
internal/atproto/login.go
··· 34 34 // - atproto — base scope (always required) 35 35 // - repo:site.standard.publication — read/write the publication singleton 36 36 // - repo:site.standard.document — read/write/delete posts 37 - // - repo:com.whtwnd.blog.entry — for migration cleanup of legacy records 37 + // - repo:app.bsky.feed.post — create the optional announce post (--announce) 38 + // for the bsky_post / bskyPostRef discussion thread on each document 38 39 // - blob — upload cover images 39 - const BlogScopes = "atproto repo:site.standard.publication repo:site.standard.document repo:com.whtwnd.blog.entry blob" 40 + const BlogScopes = "atproto repo:site.standard.publication repo:site.standard.document repo:app.bsky.feed.post blob" 40 41 41 42 // LoopbackClientID builds an ATProto-compliant loopback client_id of the 42 43 // form http://localhost?scope=...&redirect_uri=http%3A%2F%2F127.0.0.1%2F<path>.
+10 -7
internal/atproto/loopback.go
··· 4 4 "context" 5 5 "errors" 6 6 "fmt" 7 + "html" 7 8 "net" 8 9 "net/http" 9 10 "sync" ··· 119 120 // to the terminal to see what happens next. 120 121 w.Header().Set("Content-Type", "text/html; charset=utf-8") 121 122 if res.Error != "" { 123 + // HTML-escape both fields — they come from the auth server's 124 + // redirect query params and could otherwise reflect a script 125 + // payload in the local browser tab. 126 + desc := "" 127 + if res.ErrorDescription != "" { 128 + desc = ": " + html.EscapeString(res.ErrorDescription) 129 + } 122 130 fmt.Fprintf(w, 123 131 `<!doctype html><title>Sign-in failed</title> 124 132 <h1>Sign-in failed</h1> 125 133 <p>%s%s</p> 126 134 <p>You can close this tab and check the terminal.</p>`, 127 - res.Error, 128 - func() string { 129 - if res.ErrorDescription != "" { 130 - return ": " + res.ErrorDescription 131 - } 132 - return "" 133 - }(), 135 + html.EscapeString(res.Error), 136 + desc, 134 137 ) 135 138 } else { 136 139 fmt.Fprint(w, `<!doctype html><title>Signed in</title>
+231 -47
internal/build/build.go
··· 12 12 "io/fs" 13 13 texttemplate "text/template" 14 14 "net/http" 15 + "net/url" 15 16 "os" 16 17 "path/filepath" 17 18 "sort" ··· 20 21 21 22 "aparker.io/fair/internal/atproto" 22 23 "aparker.io/fair/internal/markdown" 24 + "gopkg.in/yaml.v3" 23 25 ) 24 26 25 27 // Embedded templates. These ship inside the binary so the build flow ··· 57 59 // ImagesMirror: directory containing the staged inline images 58 60 // (populated by the publish flow). Copied into <DistDir>/images/. 59 61 ImagesMirror string 62 + 63 + // BlogPosts: directory containing the markdown source. The build 64 + // flow only reads one optional file from here — _index.md, used as 65 + // a free-form intro on the home page. Posts themselves come from 66 + // the PDS, not from this directory. 67 + BlogPosts string 60 68 61 69 // DistDir: target output directory. Build writes to DistDir+".tmp" 62 70 // then atomically renames. ··· 115 123 return nil, fmt.Errorf("emit client metadata: %w", err) 116 124 } 117 125 118 - httpClient := &http.Client{Timeout: 30 * time.Second} 126 + httpClient := &http.Client{ 127 + Timeout: 30 * time.Second, 128 + // Refuse to follow redirects from the PDS — XRPC endpoints don't 129 + // redirect normally, and a hostile PDS could otherwise bounce us 130 + // to an internal/metadata service on the build host (SSRF). 131 + CheckRedirect: noRedirectPolicy, 132 + } 119 133 if cfg.CAPool != nil { 120 134 httpClient.Transport = caTransport(cfg.CAPool) 121 135 } ··· 179 193 fmt.Fprintf(os.Stderr, " skipping %s: %v\n", d.URI, err) 180 194 continue 181 195 } 196 + // Defense in depth: PDS-controlled `path` field gets joined into 197 + // the dist directory below. Reject anything that doesn't match 198 + // the canonical slug shape rather than trust filepath.Join's 199 + // .. cleaning to contain the path. 200 + if !validRecordPath(pv.Path) { 201 + fmt.Fprintf(os.Stderr, " skipping %s: invalid path %q\n", d.URI, pv.Path) 202 + continue 203 + } 204 + // Filter out tags whose names would escape tags/<name>/. Drop the 205 + // invalid entries and keep the post. 206 + if len(pv.Tags) > 0 { 207 + kept := make([]string, 0, len(pv.Tags)) 208 + for _, t := range pv.Tags { 209 + if validTagName(t) { 210 + kept = append(kept, t) 211 + } else { 212 + fmt.Fprintf(os.Stderr, " %s: dropping invalid tag %q\n", d.URI, t) 213 + } 214 + } 215 + pv.Tags = kept 216 + } 217 + // Add srcset/sizes/width/height to <img> tags whose source has 218 + // resized variants in the mirror. No-op when mirror is empty 219 + // or when an image has no variants on disk. 220 + body := EnrichResponsiveImages(string(pv.HTMLBody), cfg.ImagesMirror, cfg.PublicationURL) 221 + // Add native lazy-loading + async decoding to all <img>. 222 + body = EnhanceImages(body) 223 + pv.HTMLBody = htmltemplate.HTML(body) 182 224 posts = append(posts, pv) 183 225 } 184 226 ··· 199 241 } 200 242 } 201 243 siteData.Posts = posts 244 + siteData.Years = groupByYear(posts) 245 + intro, homeMetaFM, err := loadHomeContent(cfg.BlogPosts, siteData.Publication.URL) 246 + if err != nil { 247 + return nil, fmt.Errorf("load home content: %w", err) 248 + } 249 + siteData.Intro = intro 250 + siteData.HomeMeta = homeMetaFM 251 + siteData.HideYears = homeMetaFM.HideYears 252 + // Resolve pinned slugs against posts for the Featured section. 253 + if len(homeMetaFM.Pinned) > 0 { 254 + bySlug := make(map[string]postView, len(posts)) 255 + for _, p := range posts { 256 + bySlug[strings.TrimPrefix(p.Path, "/")] = p 257 + } 258 + for _, slug := range homeMetaFM.Pinned { 259 + if p, ok := bySlug[slug]; ok { 260 + siteData.Featured = append(siteData.Featured, p) 261 + } else { 262 + fmt.Fprintf(os.Stderr, " warn: _index.md pinned slug %q not found among posts\n", slug) 263 + } 264 + } 265 + } 266 + outro, err := loadOutro(cfg.BlogPosts, siteData.Publication.URL) 267 + if err != nil { 268 + return nil, fmt.Errorf("load outro: %w", err) 269 + } 270 + siteData.Outro = outro 271 + 272 + // Pre-compute tag summaries so they're available for the home-page 273 + // template's tag cloud as well as the /tags/ index later in step 7. 274 + tagsByName := groupByTag(posts) 275 + tagNames := make([]string, 0, len(tagsByName)) 276 + for name := range tagsByName { 277 + tagNames = append(tagNames, name) 278 + } 279 + sort.Strings(tagNames) 280 + tagSummaries := make([]tagSummary, 0, len(tagNames)) 281 + for _, name := range tagNames { 282 + tagSummaries = append(tagSummaries, tagSummary{Name: name, Count: len(tagsByName[name])}) 283 + } 284 + siteData.Tags = tagSummaries 202 285 203 286 // Step 5: render per-post pages. documentToPostView leaves 204 287 // p.Publication.Name empty (it doesn't have access to the ··· 250 333 return nil, err 251 334 } 252 335 253 - // Tag pages: one per distinct tag plus a /tags/ index. Tags are 254 - // already canonicalized (lowercased, deduped) by the publish flow. 255 - tagsByName := groupByTag(posts) 336 + // Tag pages: one per distinct tag plus a /tags/ index. Summaries 337 + // were precomputed earlier so siteData.Tags could be used by the 338 + // home-page template; reuse them here. 256 339 if len(tagsByName) > 0 { 257 - // /tags/ index 258 - tagSummaries := make([]tagSummary, 0, len(tagsByName)) 259 - tagNames := make([]string, 0, len(tagsByName)) 260 - for name := range tagsByName { 261 - tagNames = append(tagNames, name) 262 - } 263 - sort.Strings(tagNames) 264 - for _, name := range tagNames { 265 - tagSummaries = append(tagSummaries, tagSummary{Name: name, Count: len(tagsByName[name])}) 266 - } 267 340 if err := writeRendered(tmpOut, "tags/index.html", "tags-index.html", struct { 268 341 Publication publicationView 269 342 Tags []tagSummary ··· 404 477 URL string 405 478 Name string 406 479 Description string 407 - SocialLinks []string // <link rel="me"> entries 480 + SocialLinks []string // raw URLs; emit as <link rel="me"> tags in the document head 408 481 FaviconPath string // e.g. "/favicon.png"; empty when no icon blob 409 482 } 410 483 ··· 414 487 } 415 488 416 489 type postView struct { 417 - Title string 418 - Path string // leading slash, no trailing slash 419 - AbsoluteURL string // publication.URL + path + "/" 420 - ATURI htmltemplate.URL // at://did/site.standard.document/<rkey> (marked safe so html/template doesn't rewrite to #ZgotmplZ) 421 - PublishedAt time.Time 422 - UpdatedAt time.Time 423 - Lastmod time.Time // = UpdatedAt or PublishedAt; precomputed for sitemap.xml 424 - Description string 425 - HTMLBody htmltemplate.HTML 426 - Tags []string 427 - BskyPostURL string // bsky.app web URL derived from bskyPostRef 428 - Prev *postNeighbor 429 - Next *postNeighbor 430 - Publication publicationView // copied so post template can reach it 490 + Title string 491 + Path string // leading slash, no trailing slash 492 + AbsoluteURL string // publication.URL + path + "/" 493 + ATURI htmltemplate.URL // at://did/site.standard.document/<rkey> (marked safe so html/template doesn't rewrite to #ZgotmplZ) 494 + PdsLsURL string // https://pdsls.dev/at://... — web viewer for the canonical record 495 + PublishedAt time.Time 496 + UpdatedAt time.Time 497 + Lastmod time.Time // = UpdatedAt or PublishedAt; precomputed for sitemap.xml 498 + Description string 499 + HTMLBody htmltemplate.HTML 500 + Tags []string 501 + BskyPostURL string // bsky.app web URL derived from bskyPostRef 502 + BskyPostAtURI string // at-uri of the bsky discussion thread; used by the inline-JS comments fetch 503 + ReadingMinutes int // wordcount/250 rounded up, computed from markdown source body 504 + Prev *postNeighbor 505 + Next *postNeighbor 506 + Publication publicationView // copied so post template can reach it 431 507 } 432 508 433 509 type siteContext struct { 434 510 Publication publicationView 435 511 Posts []postView 512 + Years []yearSection // posts bucketed by publishedAt year, descending; used by the home page 513 + Intro htmltemplate.HTML // optional rendered _index.md body content shown above the year disclosures 514 + Outro htmltemplate.HTML // optional rendered _outro.md content shown below the year disclosures 515 + Tags []tagSummary // alphabetical tag list; rendered as a footer cloud on the home page 516 + HomeMeta homeMeta // optional frontmatter from _index.md driving home-only customization 517 + Featured []postView // posts pinned via _index.md frontmatter, in declared order 518 + HideYears bool // true when _index.md frontmatter sets `hide_years: true` 436 519 GeneratedAt time.Time 437 520 } 438 521 522 + // homeMeta is the optional YAML frontmatter on _index.md. None of these 523 + // fields are required; missing file or missing fields fall back to the 524 + // publication record (description) or to default rendering. 525 + type homeMeta struct { 526 + Description string `yaml:"description"` // overrides Publication.Description on the home page only 527 + Subtitle string `yaml:"subtitle"` // extra <p> between <h1> and the intro body 528 + Pinned []string `yaml:"pinned"` // slugs to render as a Featured section above year groups 529 + HideYears bool `yaml:"hide_years"` // suppress the year-group disclosures (e.g., for a fully-curated home) 530 + } 531 + 532 + // loadHomeContent reads <blogPostsDir>/_index.md if it exists, splits 533 + // optional YAML frontmatter from the markdown body, and renders the 534 + // body through the same goldmark + post-process pipeline used for posts. 535 + // Missing file → zero values, no error. Used by the home page for the 536 + // intro section + frontmatter-driven customization (subtitle, pinned, 537 + // description override, hide_years). 538 + func loadHomeContent(blogPostsDir, publicationURL string) (htmltemplate.HTML, homeMeta, error) { 539 + if blogPostsDir == "" { 540 + return "", homeMeta{}, nil 541 + } 542 + src, err := os.ReadFile(filepath.Join(blogPostsDir, "_index.md")) 543 + if err != nil { 544 + if os.IsNotExist(err) { 545 + return "", homeMeta{}, nil 546 + } 547 + return "", homeMeta{}, err 548 + } 549 + fmRaw, body, err := markdown.Split(string(src)) 550 + if err != nil { 551 + return "", homeMeta{}, fmt.Errorf("_index.md frontmatter: %w", err) 552 + } 553 + var meta homeMeta 554 + if fmRaw != "" { 555 + if err := yaml.Unmarshal([]byte(fmRaw), &meta); err != nil { 556 + return "", homeMeta{}, fmt.Errorf("_index.md frontmatter: %w", err) 557 + } 558 + } 559 + html, err := RenderMarkdown(body, publicationURL) 560 + if err != nil { 561 + return "", homeMeta{}, err 562 + } 563 + return htmltemplate.HTML(html), meta, nil 564 + } 565 + 566 + // loadOutro reads <blogPostsDir>/_outro.md if it exists and renders it 567 + // through the markdown pipeline. Missing file → empty string, no error. 568 + // Rendered below the year groups + above the home-page footer. 569 + func loadOutro(blogPostsDir, publicationURL string) (htmltemplate.HTML, error) { 570 + if blogPostsDir == "" { 571 + return "", nil 572 + } 573 + body, err := os.ReadFile(filepath.Join(blogPostsDir, "_outro.md")) 574 + if err != nil { 575 + if os.IsNotExist(err) { 576 + return "", nil 577 + } 578 + return "", err 579 + } 580 + html, err := RenderMarkdown(string(body), publicationURL) 581 + if err != nil { 582 + return "", err 583 + } 584 + return htmltemplate.HTML(html), nil 585 + } 586 + 439 587 // --- record fetching -------------------------------------------------------- 440 588 441 589 type rawDocument struct { ··· 453 601 // the record is absent (404 from getRecord). Other errors are returned 454 602 // as-is. 455 603 func fetchPublication(ctx context.Context, hc *http.Client, pdsURL, did string) (*publicationRecord, error) { 456 - u := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=site.standard.publication&rkey=self", 457 - strings.TrimRight(pdsURL, "/"), did) 604 + q := url.Values{} 605 + q.Set("repo", did) 606 + q.Set("collection", "site.standard.publication") 607 + q.Set("rkey", "self") 608 + u := strings.TrimRight(pdsURL, "/") + "/xrpc/com.atproto.repo.getRecord?" + q.Encode() 458 609 req, err := http.NewRequestWithContext(ctx, "GET", u, nil) 459 610 if err != nil { 460 611 return nil, err ··· 504 655 var all []rawDocument 505 656 cursor := "" 506 657 for { 507 - u := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=site.standard.document&limit=100", 508 - strings.TrimRight(pdsURL, "/"), did) 658 + q := url.Values{} 659 + q.Set("repo", did) 660 + q.Set("collection", "site.standard.document") 661 + q.Set("limit", "100") 509 662 if cursor != "" { 510 - u += "&cursor=" + cursor 663 + q.Set("cursor", cursor) 511 664 } 665 + u := strings.TrimRight(pdsURL, "/") + "/xrpc/com.atproto.repo.listRecords?" + q.Encode() 512 666 req, err := http.NewRequestWithContext(ctx, "GET", u, nil) 513 667 if err != nil { 514 668 return nil, err ··· 550 704 } `json:"content"` 551 705 BskyPostRef *struct { 552 706 URI string `json:"uri"` 707 + CID string `json:"cid"` 553 708 } `json:"bskyPostRef,omitempty"` 554 709 } 555 710 ··· 596 751 atURI := d.URI 597 752 598 753 bskyPostURL := "" 599 - if v.BskyPostRef != nil && v.BskyPostRef.URI != "" { 754 + bskyPostAtURI := "" 755 + if v.BskyPostRef != nil && v.BskyPostRef.URI != "" && validATURI(v.BskyPostRef.URI) { 600 756 if w, err := bskyWebURL(v.BskyPostRef.URI); err == nil { 601 757 bskyPostURL = w 602 758 } 759 + bskyPostAtURI = v.BskyPostRef.URI 603 760 } 604 761 762 + // at-uris pass through to <link rel="alternate"> via htmltemplate.URL 763 + // (which normally allows arbitrary schemes). A compromised PDS could 764 + // return uri: "javascript:..." otherwise — gate on the at:// prefix. 765 + if !validATURI(atURI) { 766 + return postView{}, fmt.Errorf("invalid at-uri %q", atURI) 767 + } 605 768 return postView{ 606 - Title: v.Title, 607 - Path: path, 608 - AbsoluteURL: absoluteURL, 609 - ATURI: htmltemplate.URL(atURI), 610 - PublishedAt: publishedAt, 611 - UpdatedAt: updatedAt, 612 - Lastmod: lastmod, 613 - Description: v.Description, 614 - HTMLBody: htmltemplate.HTML(html), 615 - Tags: v.Tags, 616 - BskyPostURL: bskyPostURL, 617 - Publication: pub, 769 + Title: v.Title, 770 + Path: path, 771 + AbsoluteURL: absoluteURL, 772 + ATURI: htmltemplate.URL(atURI), 773 + PdsLsURL: "https://pdsls.dev/" + atURI, 774 + PublishedAt: publishedAt, 775 + UpdatedAt: updatedAt, 776 + Lastmod: lastmod, 777 + Description: v.Description, 778 + HTMLBody: htmltemplate.HTML(html), 779 + Tags: v.Tags, 780 + BskyPostURL: bskyPostURL, 781 + BskyPostAtURI: bskyPostAtURI, 782 + ReadingMinutes: estimateReadingMinutes(v.Content.Text), 783 + Publication: pub, 618 784 }, nil 785 + } 786 + 787 + // estimateReadingMinutes returns ceil(wordcount / 250). 250 wpm is the 788 + // common middle-ground for adult silent reading; useful as a "how long 789 + // is this post going to take" hint, not an exact figure. 790 + func estimateReadingMinutes(body string) int { 791 + words := len(strings.Fields(body)) 792 + if words == 0 { 793 + return 0 794 + } 795 + mins := words / 250 796 + if words%250 != 0 { 797 + mins++ 798 + } 799 + if mins < 1 { 800 + mins = 1 801 + } 802 + return mins 619 803 } 620 804 621 805 // --- helpers ----------------------------------------------------------------
+12 -3
internal/build/favicon.go
··· 7 7 "io" 8 8 "mime" 9 9 "net/http" 10 + "net/url" 10 11 "os" 11 12 "path/filepath" 12 13 "strings" 13 14 ) 15 + 16 + // maxFaviconBytes caps the favicon download. A compromised or malicious 17 + // PDS could otherwise stream gigabytes for a "tiny" icon and exhaust 18 + // disk or memory on the build host. Real favicons are typically <100KB. 19 + const maxFaviconBytes = 5 << 20 // 5 MiB 14 20 15 21 // fetchAndSaveFavicon pulls the publication's icon blob from the PDS 16 22 // and writes it to <tmpOut>/favicon.<ext>. Returns the public path ··· 25 31 if icon == nil || icon.Ref.Link == "" { 26 32 return "", errors.New("icon ref empty") 27 33 } 28 - u := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", 29 - strings.TrimRight(cfg.PDSURL, "/"), cfg.DID, icon.Ref.Link) 34 + q := url.Values{} 35 + q.Set("did", cfg.DID) 36 + q.Set("cid", icon.Ref.Link) 37 + u := strings.TrimRight(cfg.PDSURL, "/") + "/xrpc/com.atproto.sync.getBlob?" + q.Encode() 30 38 req, err := http.NewRequestWithContext(ctx, "GET", u, nil) 31 39 if err != nil { 32 40 return "", err ··· 50 58 return "", err 51 59 } 52 60 defer out.Close() 53 - if _, err := io.Copy(out, resp.Body); err != nil { 61 + // Cap the downloaded body so a hostile PDS can't fill the disk. 62 + if _, err := io.Copy(out, io.LimitReader(resp.Body, maxFaviconBytes)); err != nil { 54 63 return "", err 55 64 } 56 65 return "/favicon" + ext, nil
+55
internal/build/safety.go
··· 1 + package build 2 + 3 + import ( 4 + "net/http" 5 + "regexp" 6 + "strings" 7 + ) 8 + 9 + // Slug-grammar regexes used to validate record fields that get 10 + // interpolated into filesystem paths during the build. The publish flow 11 + // enforces a strict slug grammar via internal/publish/slug.go, but the 12 + // build reads records from a PDS that fair does not necessarily trust 13 + // — a compromised PDS could return a record with `path: "../../tmp/x"` 14 + // or `tags: ["../../etc/passwd"]`. These guards keep PDS-controlled 15 + // strings from escaping the build output directory. 16 + 17 + // pathFieldRE accepts the shape produced by publish.go (`/<slug>` — 18 + // single component, lowercase ASCII alphanumeric and hyphen). 19 + var pathFieldRE = regexp.MustCompile(`^/[a-z0-9-]+$`) 20 + 21 + // tagNameRE accepts the same character set as a slug. Tags get 22 + // interpolated into `<dist>/tags/<name>/index.html`, so the same 23 + // path-component constraints apply. 24 + var tagNameRE = regexp.MustCompile(`^[a-z0-9-]+$`) 25 + 26 + // validRecordPath returns true for paths produced by the canonical 27 + // publish flow (one slug-shaped component with a leading slash). 28 + func validRecordPath(p string) bool { 29 + return pathFieldRE.MatchString(p) 30 + } 31 + 32 + // validTagName returns true for strings safe to use as a single 33 + // directory component under tags/. 34 + func validTagName(t string) bool { 35 + return tagNameRE.MatchString(t) 36 + } 37 + 38 + // validATURI returns true when u parses as a syntactically plausible 39 + // at-uri (`at://...`). Used to gate htmltemplate.URL casts on 40 + // PDS-controlled values; without this gate a hostile record could set 41 + // `uri: "javascript:..."` and have it carry through to a clickable 42 + // rendered link. 43 + func validATURI(u string) bool { 44 + return strings.HasPrefix(u, "at://") && len(u) > len("at://") 45 + } 46 + 47 + // noRedirectPolicy is a CheckRedirect callback that refuses to follow 48 + // any HTTP redirects. Used on the build's HTTP client and the OAuth 49 + // loopback flow's HTTP client so a malicious PDS can't redirect us at 50 + // metadata-IP services on the build host (cloud-metadata SSRF, internal 51 + // CI runner exposure). XRPC endpoints don't redirect in normal 52 + // operation. 53 + func noRedirectPolicy(req *http.Request, via []*http.Request) error { 54 + return http.ErrUseLastResponse 55 + }
+115
internal/build/srcset.go
··· 1 + package build 2 + 3 + import ( 4 + "fmt" 5 + "path/filepath" 6 + "regexp" 7 + "sort" 8 + "strings" 9 + 10 + "aparker.io/fair/internal/images" 11 + ) 12 + 13 + // imgTagRe captures whole <img ...> tags. Group 1 is the inner attributes. 14 + var imgTagRe = regexp.MustCompile(`(?s)<img\b([^>]*)>`) 15 + 16 + // srcAttrRe captures the value of src= within an attribute string. 17 + var srcAttrRe = regexp.MustCompile(`\bsrc="([^"]+)"`) 18 + 19 + // EnrichResponsiveImages walks every <img> in html, looks for sibling 20 + // resized variants under mirrorDir, and rewrites the tag with srcset/ 21 + // sizes/width/height attributes when variants are present. Tags whose 22 + // src is not under publicationURL+/images/, or whose source can't be 23 + // decoded, are left untouched. 24 + // 25 + // The expected pipeline order is: 26 + // 1. RenderMarkdown produces HTML with `<img src="<absolute>/images/<slug>/<base>.<ext>">`. 27 + // 2. This pass enriches those tags using on-disk mirror state. 28 + // 29 + // mirrorDir empty disables enrichment (tests, sandbox-without-mirror). 30 + func EnrichResponsiveImages(html, mirrorDir, publicationURL string) string { 31 + if mirrorDir == "" || publicationURL == "" { 32 + return html 33 + } 34 + prefix := strings.TrimRight(publicationURL, "/") + "/images/" 35 + return imgTagRe.ReplaceAllStringFunc(html, func(tag string) string { 36 + attrs := imgTagRe.FindStringSubmatch(tag)[1] 37 + srcMatch := srcAttrRe.FindStringSubmatch(attrs) 38 + if srcMatch == nil { 39 + return tag 40 + } 41 + src := srcMatch[1] 42 + if !strings.HasPrefix(src, prefix) { 43 + return tag 44 + } 45 + rel := strings.TrimPrefix(src, prefix) // "<slug>/<base>.<ext>" 46 + srcPath := filepath.Join(mirrorDir, rel) 47 + 48 + srcW, srcH, variants, err := images.FindVariants(srcPath) 49 + if err != nil || (len(variants) == 0 && srcW == 0) { 50 + return tag 51 + } 52 + 53 + // Build srcset entries: each variant + the original at its 54 + // natural width. Sort by width ascending so the smallest is 55 + // listed first (cosmetic — browser doesn't care). 56 + type entry struct { 57 + url string 58 + w int 59 + } 60 + entries := make([]entry, 0, len(variants)+1) 61 + for _, v := range variants { 62 + variantURL := strings.TrimSuffix(src, filepath.Ext(rel)) 63 + variantURL = fmt.Sprintf("%s-%d%s", variantURL, v.Width, filepath.Ext(rel)) 64 + entries = append(entries, entry{url: variantURL, w: v.Width}) 65 + } 66 + entries = append(entries, entry{url: src, w: srcW}) 67 + sort.Slice(entries, func(i, j int) bool { return entries[i].w < entries[j].w }) 68 + 69 + var srcset strings.Builder 70 + for i, e := range entries { 71 + if i > 0 { 72 + srcset.WriteString(", ") 73 + } 74 + fmt.Fprintf(&srcset, "%s %dw", e.url, e.w) 75 + } 76 + 77 + // Strip any pre-existing srcset/sizes/width/height to avoid 78 + // duplicates if this pass runs twice. 79 + cleaned := stripAttrs(attrs, "srcset", "sizes", "width", "height") 80 + return fmt.Sprintf( 81 + `<img%s srcset="%s" sizes="100vw" width="%d" height="%d">`, 82 + cleaned, srcset.String(), srcW, srcH, 83 + ) 84 + }) 85 + } 86 + 87 + // stripAttrs returns attrs with the listed attribute=value pairs 88 + // removed. Preserves leading whitespace and quoting style. 89 + func stripAttrs(attrs string, names ...string) string { 90 + for _, name := range names { 91 + re := regexp.MustCompile(`\s+` + regexp.QuoteMeta(name) + `="[^"]*"`) 92 + attrs = re.ReplaceAllString(attrs, "") 93 + } 94 + return attrs 95 + } 96 + 97 + // EnhanceImages adds loading="lazy" decoding="async" to every <img> tag 98 + // in html that doesn't already have those attributes. Free perf wins: 99 + // browser-driven lazy loading defers off-screen image fetches, async 100 + // decoding moves the decode off the main thread. 101 + // 102 + // Idempotent — re-running over already-enhanced HTML is a no-op for 103 + // tags that already carry both attributes. 104 + func EnhanceImages(html string) string { 105 + return imgTagRe.ReplaceAllStringFunc(html, func(tag string) string { 106 + attrs := imgTagRe.FindStringSubmatch(tag)[1] 107 + if !strings.Contains(attrs, "loading=") { 108 + attrs += ` loading="lazy"` 109 + } 110 + if !strings.Contains(attrs, "decoding=") { 111 + attrs += ` decoding="async"` 112 + } 113 + return "<img" + attrs + ">" 114 + }) 115 + }
+90
internal/build/srcset_test.go
··· 1 + package build_test 2 + 3 + import ( 4 + "image" 5 + "image/color" 6 + "image/png" 7 + "os" 8 + "path/filepath" 9 + "strings" 10 + "testing" 11 + 12 + "aparker.io/fair/internal/build" 13 + "aparker.io/fair/internal/images" 14 + ) 15 + 16 + func makePNG(t *testing.T, path string, w, h int) { 17 + t.Helper() 18 + img := image.NewRGBA(image.Rect(0, 0, w, h)) 19 + for y := range h { 20 + for x := range w { 21 + img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), B: 200, A: 255}) 22 + } 23 + } 24 + f, err := os.Create(path) 25 + if err != nil { 26 + t.Fatalf("create %s: %v", path, err) 27 + } 28 + defer f.Close() 29 + if err := png.Encode(f, img); err != nil { 30 + t.Fatalf("encode: %v", err) 31 + } 32 + } 33 + 34 + func TestEnrichResponsiveImages_AddsSrcsetWhenVariantsExist(t *testing.T) { 35 + mirror := t.TempDir() 36 + slugDir := filepath.Join(mirror, "post-a") 37 + if err := os.MkdirAll(slugDir, 0o755); err != nil { 38 + t.Fatalf("mkdir: %v", err) 39 + } 40 + src := filepath.Join(slugDir, "fig.png") 41 + makePNG(t, src, 1600, 900) 42 + if _, err := images.GenerateVariants(src); err != nil { 43 + t.Fatalf("GenerateVariants: %v", err) 44 + } 45 + 46 + html := `<p>before <img src="https://example.com/images/post-a/fig.png" alt="caption"> after</p>` 47 + got := build.EnrichResponsiveImages(html, mirror, "https://example.com") 48 + 49 + for _, want := range []string{ 50 + `srcset="`, 51 + `sizes="100vw"`, 52 + `width="1600"`, 53 + `height="900"`, 54 + `fig-480.png 480w`, 55 + `fig-960.png 960w`, 56 + `fig-1440.png 1440w`, 57 + `https://example.com/images/post-a/fig.png 1600w`, 58 + `alt="caption"`, 59 + } { 60 + if !strings.Contains(got, want) { 61 + t.Errorf("missing %q in:\n%s", want, got) 62 + } 63 + } 64 + } 65 + 66 + func TestEnrichResponsiveImages_LeavesImageAloneWhenNoVariants(t *testing.T) { 67 + mirror := t.TempDir() 68 + html := `<img src="https://example.com/images/missing/x.png" alt="x">` 69 + got := build.EnrichResponsiveImages(html, mirror, "https://example.com") 70 + if got != html { 71 + t.Errorf("expected unchanged, got:\n%s", got) 72 + } 73 + } 74 + 75 + func TestEnrichResponsiveImages_NoOpWhenMirrorEmpty(t *testing.T) { 76 + html := `<img src="https://example.com/images/x/y.png" alt="x">` 77 + got := build.EnrichResponsiveImages(html, "", "https://example.com") 78 + if got != html { 79 + t.Errorf("expected unchanged when mirror empty, got:\n%s", got) 80 + } 81 + } 82 + 83 + func TestEnrichResponsiveImages_LeavesAbsoluteForeignSrcAlone(t *testing.T) { 84 + mirror := t.TempDir() 85 + html := `<img src="https://other.example.com/foo.png">` 86 + got := build.EnrichResponsiveImages(html, mirror, "https://example.com") 87 + if got != html { 88 + t.Errorf("expected foreign-host image unchanged, got:\n%s", got) 89 + } 90 + }
+1 -1
internal/build/templates/404.html
··· 1 1 <!doctype html> 2 2 <html lang="en"> 3 - <head><meta charset="utf-8"><title>Not found — {{.Publication.Name}}</title></head> 3 + <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="color-scheme" content="light dark"><meta name="theme-color" media="(prefers-color-scheme: light)" content="#ffffff"><meta name="theme-color" media="(prefers-color-scheme: dark)" content="#1a1a1a"><style>body{max-width:720px;margin:0 auto;padding:1rem;font-family:system-ui,sans-serif;line-height:1.5}nav{text-align:center;font-size:0.875em}img{max-width:100%;height:auto}</style><title>Not found — {{.Publication.Name}}</title></head> 4 4 <body> 5 5 <h1>Not found.</h1> 6 6 <p><a href="{{.Publication.URL}}/">← {{.Publication.Name}}</a></p>
+6
internal/build/templates/archive.html
··· 2 2 <html lang="en"> 3 3 <head> 4 4 <meta charset="utf-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1"> 6 + <meta name="color-scheme" content="light dark"> 7 + <meta name="theme-color" media="(prefers-color-scheme: light)" content="#ffffff"> 8 + <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#1a1a1a"> 9 + <style>body{max-width:720px;margin:0 auto;padding:1rem;font-family:system-ui,sans-serif;line-height:1.5}nav{text-align:center;font-size:0.875em}img{max-width:100%;height:auto}</style> 10 + <script type="speculationrules">{"prerender":[{"where":{"href_matches":"/*"},"eagerness":"moderate"}]}</script> 5 11 <title>Archive — {{.Publication.Name}}</title> 6 12 <link rel="canonical" href="{{.Publication.URL}}/archive/"> 7 13
+31 -7
internal/build/templates/index.html
··· 2 2 <html lang="en"> 3 3 <head> 4 4 <meta charset="utf-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1"> 6 + <meta name="color-scheme" content="light dark"> 7 + <meta name="theme-color" media="(prefers-color-scheme: light)" content="#ffffff"> 8 + <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#1a1a1a"> 9 + <style>body{max-width:720px;margin:0 auto;padding:1rem;font-family:system-ui,sans-serif;line-height:1.5}nav{text-align:center;font-size:0.875em}img{max-width:100%;height:auto}</style> 10 + <script type="speculationrules">{"prerender":[{"where":{"href_matches":"/*"},"eagerness":"moderate"}]}</script> 5 11 <title>{{.Publication.Name}}</title> 6 - {{if .Publication.Description}}<meta name="description" content="{{.Publication.Description}}">{{end}} 12 + {{if or .HomeMeta.Description .Publication.Description}}<meta name="description" content="{{if .HomeMeta.Description}}{{.HomeMeta.Description}}{{else}}{{.Publication.Description}}{{end}}">{{end}} 7 13 <link rel="canonical" href="{{.Publication.URL}}/"> 8 14 9 15 <meta property="og:title" content="{{.Publication.Name}}"> 10 16 <meta property="og:type" content="website"> 11 17 <meta property="og:url" content="{{.Publication.URL}}/"> 12 18 <meta property="og:site_name" content="{{.Publication.Name}}"> 13 - {{if .Publication.Description}}<meta property="og:description" content="{{.Publication.Description}}">{{end}} 19 + {{if or .HomeMeta.Description .Publication.Description}}<meta property="og:description" content="{{if .HomeMeta.Description}}{{.HomeMeta.Description}}{{else}}{{.Publication.Description}}{{end}}">{{end}} 14 20 <meta name="twitter:card" content="summary"> 15 21 <meta name="twitter:title" content="{{.Publication.Name}}"> 16 - {{if .Publication.Description}}<meta name="twitter:description" content="{{.Publication.Description}}">{{end}} 22 + {{if or .HomeMeta.Description .Publication.Description}}<meta name="twitter:description" content="{{if .HomeMeta.Description}}{{.HomeMeta.Description}}{{else}}{{.Publication.Description}}{{end}}">{{end}} 17 23 18 24 {{range .Publication.SocialLinks}}<link rel="me" href="{{.}}"> 19 25 {{end}}{{if .Publication.FaviconPath}}<link rel="icon" href="{{.Publication.FaviconPath}}">{{end}} ··· 25 31 <body> 26 32 <header> 27 33 <h1>{{.Publication.Name}}</h1> 34 + {{if .HomeMeta.Subtitle}}<p>{{.HomeMeta.Subtitle}}</p>{{end}} 28 35 {{if .Publication.Description}}<p>{{.Publication.Description}}</p>{{end}} 29 36 </header> 30 - {{if .Posts}} 37 + {{if .Intro}}<section> 38 + {{.Intro}} 39 + </section> 40 + {{end}}{{if .Featured}}<section> 41 + <h2>Featured</h2> 31 42 <ul> 32 - {{range .Posts}}<li><time datetime="{{.PublishedAt.Format "2006-01-02"}}">{{.PublishedAt.Format "Jan 2 2006"}}</time> &mdash; <a href="{{.Path}}/">{{.Title}}</a></li> 43 + {{range .Featured}}<li><time datetime="{{.PublishedAt.Format "2006-01-02"}}">{{.PublishedAt.Format "Jan 2 2006"}}</time> &mdash; <a href="{{.Path}}/">{{.Title}}</a></li> 33 44 {{end}}</ul> 34 - {{else}} 45 + </section> 46 + {{end}}{{if and (not .HideYears) .Years}}<h2>Posts</h2> 47 + {{range $i, $y := .Years}}<details{{if eq $i 0}} open{{end}}> 48 + <summary>{{$y.Year}} ({{len $y.Posts}} post{{if ne (len $y.Posts) 1}}s{{end}})</summary> 49 + <ul> 50 + {{range $y.Posts}}<li><time datetime="{{.PublishedAt.Format "2006-01-02"}}">{{.PublishedAt.Format "Jan 2"}}</time> &mdash; <a href="{{.Path}}/">{{.Title}}</a></li> 51 + {{end}}</ul> 52 + </details> 53 + {{end}}{{else if not .Years}} 35 54 <p>No posts yet.</p> 55 + {{end}}{{if .Outro}}<section> 56 + {{.Outro}} 57 + </section> 36 58 {{end}} 37 59 <footer> 38 - <p><a href="{{.Publication.URL}}/feed.xml">Atom</a> &middot; <a href="{{.Publication.URL}}/feed.json">JSON Feed</a> &middot; <a href="{{.Publication.URL}}/rss.xml">RSS</a></p> 60 + {{if .Tags}}<h2>Tags</h2> 61 + <p>{{range $i, $t := .Tags}}{{if $i}} · {{end}}<a href="{{$.Publication.URL}}/tags/{{$t.Name}}/">{{$t.Name}}</a> ({{$t.Count}}){{end}}</p>{{end}} 62 + <nav><a href="{{.Publication.URL}}/archive/">Archive</a> · <a href="{{.Publication.URL}}/tags/">Tags</a> · <a href="{{.Publication.URL}}/feed.xml">Atom</a> · <a href="{{.Publication.URL}}/feed.json">JSON Feed</a> · <a href="{{.Publication.URL}}/rss.xml">RSS</a></nav> 39 63 </footer> 40 64 </body> 41 65 </html>
+88 -7
internal/build/templates/post.html
··· 2 2 <html lang="en"> 3 3 <head> 4 4 <meta charset="utf-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1"> 6 + <meta name="color-scheme" content="light dark"> 7 + <meta name="theme-color" media="(prefers-color-scheme: light)" content="#ffffff"> 8 + <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#1a1a1a"> 9 + <style>body{max-width:720px;margin:0 auto;padding:1rem;font-family:system-ui,sans-serif;line-height:1.5}nav{text-align:center;font-size:0.875em}img{max-width:100%;height:auto}</style> 10 + <script type="speculationrules">{"prerender":[{"where":{"href_matches":"/*"},"eagerness":"moderate"}]}</script> 5 11 <title>{{.Title}} — {{.Publication.Name}}</title> 6 12 {{if .Description}}<meta name="description" content="{{.Description}}">{{end}} 7 13 <link rel="canonical" href="{{.AbsoluteURL}}"> ··· 34 40 <article> 35 41 <header> 36 42 <h1>{{.Title}}</h1> 37 - <p><time datetime="{{.PublishedAt.Format "2006-01-02T15:04:05Z07:00"}}">{{.PublishedAt.Format "January 2, 2006"}}</time>{{if not .UpdatedAt.IsZero}} (updated {{.UpdatedAt.Format "January 2, 2006"}}){{end}}{{if .Tags}} &middot; {{range $i, $t := .Tags}}{{if $i}}, {{end}}<a href="{{$.Publication.URL}}/tags/{{$t}}/">{{$t}}</a>{{end}}{{end}}</p> 43 + <p><small><time datetime="{{.PublishedAt.Format "2006-01-02T15:04:05Z07:00"}}">{{.PublishedAt.Format "January 2, 2006"}}</time>{{if not .UpdatedAt.IsZero}} (updated {{.UpdatedAt.Format "January 2, 2006"}}){{end}}{{if .ReadingMinutes}} &middot; {{.ReadingMinutes}} min read{{end}}{{if .Tags}} &middot; {{range $i, $t := .Tags}}{{if $i}}, {{end}}<a href="{{$.Publication.URL}}/tags/{{$t}}/">{{$t}}</a>{{end}}{{end}}{{if .PdsLsURL}} &middot; <a href="{{.PdsLsURL}}" title="View source record on pdsls.dev"><code>at://</code></a>{{end}}</small></p> 38 44 </header> 39 45 {{.HTMLBody}} 46 + {{if .BskyPostAtURI}}<section id="comments"> 47 + <h2>Comments</h2> 48 + <p><small>Replies from the <a href="{{.BskyPostURL}}">Bluesky thread</a>.<noscript> (Enable JavaScript to load replies inline; the link works without it.)</noscript></small></p> 49 + <ol id="comments-list"></ol> 50 + <p id="comments-empty"><small>No replies yet — <a href="{{.BskyPostURL}}">be the first to reply on Bluesky</a>.</small></p> 51 + </section> 52 + <script> 53 + (function() { 54 + var atUri = {{.BskyPostAtURI}}; 55 + var url = "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?depth=6&uri=" + encodeURIComponent(atUri); 56 + var list = document.getElementById("comments-list"); 57 + if (!list) return; 58 + function permalinkFor(uri, handle) { 59 + var m = /^at:\/\/[^/]+\/app\.bsky\.feed\.post\/([^/]+)$/.exec(uri || ""); 60 + if (!m) return null; 61 + return "https://bsky.app/profile/" + encodeURIComponent(handle) + "/post/" + encodeURIComponent(m[1]); 62 + } 63 + fetch(url).then(function(r) { return r.ok ? r.json() : null; }).then(function(data) { 64 + if (!data || !data.thread || !data.thread.replies || !data.thread.replies.length) return; 65 + var empty = document.getElementById("comments-empty"); 66 + if (empty) empty.style.display = "none"; 67 + function render(replies, parent) { 68 + replies.forEach(function(reply) { 69 + if (!reply || !reply.post || !reply.post.author || !reply.post.record) return; 70 + var post = reply.post; 71 + var author = post.author; 72 + var li = document.createElement("li"); 73 + var meta = document.createElement("p"); 74 + var small = document.createElement("small"); 75 + if (author.avatar) { 76 + var img = document.createElement("img"); 77 + img.src = author.avatar; 78 + img.alt = ""; 79 + img.width = 20; 80 + img.height = 20; 81 + img.loading = "lazy"; 82 + img.style.borderRadius = "50%"; 83 + small.appendChild(img); 84 + small.appendChild(document.createTextNode(" ")); 85 + } 86 + var profile = document.createElement("a"); 87 + profile.href = "https://bsky.app/profile/" + encodeURIComponent(author.handle); 88 + profile.textContent = "@" + author.handle; 89 + small.appendChild(profile); 90 + if (post.record.createdAt) { 91 + small.appendChild(document.createTextNode(" · ")); 92 + var d = new Date(post.record.createdAt); 93 + var t = document.createElement("time"); 94 + t.dateTime = post.record.createdAt; 95 + t.textContent = isNaN(d) ? post.record.createdAt : d.toLocaleDateString(); 96 + small.appendChild(t); 97 + } 98 + var permalink = permalinkFor(post.uri, author.handle); 99 + if (permalink) { 100 + small.appendChild(document.createTextNode(" · ")); 101 + var plink = document.createElement("a"); 102 + plink.href = permalink; 103 + plink.title = "View on Bluesky"; 104 + plink.setAttribute("aria-label", "View this reply on Bluesky"); 105 + plink.textContent = "↗"; 106 + small.appendChild(plink); 107 + } 108 + meta.appendChild(small); 109 + li.appendChild(meta); 110 + var body = document.createElement("p"); 111 + body.textContent = post.record.text || ""; 112 + li.appendChild(body); 113 + if (reply.replies && reply.replies.length) { 114 + var ol = document.createElement("ol"); 115 + render(reply.replies, ol); 116 + li.appendChild(ol); 117 + } 118 + parent.appendChild(li); 119 + }); 120 + } 121 + render(data.thread.replies, list); 122 + }).catch(function() { /* silent fail; the link in the heading still works */ }); 123 + })(); 124 + </script> 125 + {{end}} 40 126 <footer> 41 - {{if or .Prev .Next}}<nav> 42 - {{if .Prev}}<p>← Previous: <a href="{{.Prev.Path}}/">{{.Prev.Title}}</a></p>{{end}} 43 - {{if .Next}}<p>Next: <a href="{{.Next.Path}}/">{{.Next.Title}}</a> →</p>{{end}} 44 - </nav>{{end}} 45 - {{if .BskyPostURL}}<p><a href="{{.BskyPostURL}}">Discuss on Bluesky</a></p>{{end}} 46 - <p><a href="{{.Publication.URL}}/">← {{.Publication.Name}}</a></p> 127 + <nav>{{if .Prev}}<a href="{{.Prev.Path}}/" rel="prev">← Previous</a> · {{end}}<a href="{{.Publication.URL}}/">{{.Publication.Name}}</a>{{if .Next}} · <a href="{{.Next.Path}}/" rel="next">Next →</a>{{end}}</nav> 47 128 </footer> 48 129 </article> 49 130 </body>
+6
internal/build/templates/tag.html
··· 2 2 <html lang="en"> 3 3 <head> 4 4 <meta charset="utf-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1"> 6 + <meta name="color-scheme" content="light dark"> 7 + <meta name="theme-color" media="(prefers-color-scheme: light)" content="#ffffff"> 8 + <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#1a1a1a"> 9 + <style>body{max-width:720px;margin:0 auto;padding:1rem;font-family:system-ui,sans-serif;line-height:1.5}nav{text-align:center;font-size:0.875em}img{max-width:100%;height:auto}</style> 10 + <script type="speculationrules">{"prerender":[{"where":{"href_matches":"/*"},"eagerness":"moderate"}]}</script> 5 11 <title>Posts tagged "{{.Tag}}" — {{.Publication.Name}}</title> 6 12 <link rel="canonical" href="{{.Publication.URL}}/tags/{{.Tag}}/"> 7 13
+6
internal/build/templates/tags-index.html
··· 2 2 <html lang="en"> 3 3 <head> 4 4 <meta charset="utf-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1"> 6 + <meta name="color-scheme" content="light dark"> 7 + <meta name="theme-color" media="(prefers-color-scheme: light)" content="#ffffff"> 8 + <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#1a1a1a"> 9 + <style>body{max-width:720px;margin:0 auto;padding:1rem;font-family:system-ui,sans-serif;line-height:1.5}nav{text-align:center;font-size:0.875em}img{max-width:100%;height:auto}</style> 10 + <script type="speculationrules">{"prerender":[{"where":{"href_matches":"/*"},"eagerness":"moderate"}]}</script> 5 11 <title>Tags — {{.Publication.Name}}</title> 6 12 <link rel="canonical" href="{{.Publication.URL}}/tags/"> 7 13
+191
internal/images/variants.go
··· 1 + // Package images generates resized variants of source images so the 2 + // build flow can emit srcset/sizes attributes for responsive images. 3 + // 4 + // Without CSS, srcset/sizes alone doesn't make images visually scale 5 + // to the viewport — the build flow pairs this with one inline `<style>` 6 + // rule (`img{max-width:100%;height:auto}`) to actually constrain 7 + // rendered size. Variants exist so that a phone fetches a 480-pixel 8 + // image instead of a 2000-pixel one. 9 + package images 10 + 11 + import ( 12 + "fmt" 13 + "image" 14 + "image/jpeg" 15 + "image/png" 16 + "os" 17 + "path/filepath" 18 + "strings" 19 + 20 + "golang.org/x/image/draw" //nolint:goimports 21 + // Force decoder registration for jpeg/png on image.Decode below. 22 + _ "image/jpeg" 23 + _ "image/png" 24 + ) 25 + 26 + // VariantWidths is the set of pixel widths variants are generated at. 27 + // Variants are only generated when the source is wider than the target 28 + // (no upscaling). A copy of the original is always retained as the 29 + // canonical fallback. 30 + var VariantWidths = []int{480, 960, 1440, 1920} 31 + 32 + // Variant is one resized image written under the same directory as 33 + // the source. 34 + type Variant struct { 35 + Width int // pixel width of this variant 36 + Path string // absolute path on disk 37 + Height int // pixel height (proportional to source) 38 + } 39 + 40 + // Decoded carries the source image plus its dimensions, returned by 41 + // Decode. Build code uses Width/Height to populate <img width height> 42 + // for layout stability. 43 + type Decoded struct { 44 + Img image.Image 45 + Width int 46 + Height int 47 + Format string // "jpeg" or "png" 48 + } 49 + 50 + // Decode opens path and decodes it as JPEG or PNG. Other formats return 51 + // an error so callers can skip them gracefully. 52 + func Decode(path string) (*Decoded, error) { 53 + f, err := os.Open(path) 54 + if err != nil { 55 + return nil, err 56 + } 57 + defer f.Close() 58 + img, format, err := image.Decode(f) 59 + if err != nil { 60 + return nil, fmt.Errorf("decode %s: %w", path, err) 61 + } 62 + if format != "jpeg" && format != "png" { 63 + return nil, fmt.Errorf("unsupported image format %q for %s", format, path) 64 + } 65 + b := img.Bounds() 66 + return &Decoded{Img: img, Width: b.Dx(), Height: b.Dy(), Format: format}, nil 67 + } 68 + 69 + // GenerateVariants resizes srcPath into the standard set of widths and 70 + // writes each as <basename-without-ext>-<width>.<ext> next to the 71 + // source. Widths >= source width are skipped (no upscaling). Variants 72 + // already on disk with a matching mtime are skipped (idempotent). 73 + // 74 + // Returns the variants that exist on disk after this call (including 75 + // pre-existing ones), each tagged with its actual width and height. 76 + // 77 + // Unsupported or undecodable images (gif, webp, svg, corrupt, fake test 78 + // fixtures, etc.) return nil, nil — caller treats the image as non- 79 + // responsive and emits a plain <img>. Decode errors are intentionally 80 + // swallowed here because the publish flow should not fail just because 81 + // one image happens to be in a format we can't resize; the rendered 82 + // site still works, just without srcset for that one image. 83 + func GenerateVariants(srcPath string) ([]Variant, error) { 84 + dec, err := Decode(srcPath) 85 + if err != nil { 86 + return nil, nil 87 + } 88 + 89 + dir := filepath.Dir(srcPath) 90 + base := filepath.Base(srcPath) 91 + ext := filepath.Ext(base) 92 + stem := strings.TrimSuffix(base, ext) 93 + 94 + var out []Variant 95 + for _, w := range VariantWidths { 96 + if w >= dec.Width { 97 + continue 98 + } 99 + variantPath := filepath.Join(dir, fmt.Sprintf("%s-%d%s", stem, w, ext)) 100 + h := dec.Height * w / dec.Width 101 + 102 + // Skip if already on disk and newer than the source. 103 + if upToDate(srcPath, variantPath) { 104 + out = append(out, Variant{Width: w, Path: variantPath, Height: h}) 105 + continue 106 + } 107 + 108 + dst := image.NewRGBA(image.Rect(0, 0, w, h)) 109 + draw.CatmullRom.Scale(dst, dst.Bounds(), dec.Img, dec.Img.Bounds(), draw.Over, nil) 110 + 111 + if err := writeImage(variantPath, dst, dec.Format); err != nil { 112 + return nil, err 113 + } 114 + out = append(out, Variant{Width: w, Path: variantPath, Height: h}) 115 + } 116 + return out, nil 117 + } 118 + 119 + // upToDate reports whether dst exists and is at least as new as src. 120 + func upToDate(src, dst string) bool { 121 + srcInfo, err := os.Stat(src) 122 + if err != nil { 123 + return false 124 + } 125 + dstInfo, err := os.Stat(dst) 126 + if err != nil { 127 + return false 128 + } 129 + return !dstInfo.ModTime().Before(srcInfo.ModTime()) 130 + } 131 + 132 + // writeImage encodes img to path in the given format. Caller controls 133 + // path/ext alignment — this function trusts what it's given. 134 + func writeImage(path string, img image.Image, format string) error { 135 + tmp := path + ".tmp" 136 + f, err := os.Create(tmp) 137 + if err != nil { 138 + return err 139 + } 140 + defer func() { _ = os.Remove(tmp) }() 141 + 142 + switch format { 143 + case "jpeg": 144 + if err := jpeg.Encode(f, img, &jpeg.Options{Quality: 85}); err != nil { 145 + f.Close() 146 + return err 147 + } 148 + case "png": 149 + enc := &png.Encoder{CompressionLevel: png.BestCompression} 150 + if err := enc.Encode(f, img); err != nil { 151 + f.Close() 152 + return err 153 + } 154 + default: 155 + f.Close() 156 + return fmt.Errorf("writeImage: unsupported format %q", format) 157 + } 158 + if err := f.Close(); err != nil { 159 + return err 160 + } 161 + return os.Rename(tmp, path) 162 + } 163 + 164 + // FindVariants returns variants present on disk for srcPath, derived 165 + // from the canonical naming convention. Used by the build flow to 166 + // build srcset attributes without re-running the (slow) decode/resize 167 + // path. Returns the source's intrinsic dimensions for width/height 168 + // attributes. 169 + func FindVariants(srcPath string) (sourceWidth, sourceHeight int, variants []Variant, err error) { 170 + dec, err := Decode(srcPath) 171 + if err != nil { 172 + return 0, 0, nil, err 173 + } 174 + dir := filepath.Dir(srcPath) 175 + base := filepath.Base(srcPath) 176 + ext := filepath.Ext(base) 177 + stem := strings.TrimSuffix(base, ext) 178 + 179 + for _, w := range VariantWidths { 180 + if w >= dec.Width { 181 + continue 182 + } 183 + variantPath := filepath.Join(dir, fmt.Sprintf("%s-%d%s", stem, w, ext)) 184 + if _, err := os.Stat(variantPath); err != nil { 185 + continue 186 + } 187 + h := dec.Height * w / dec.Width 188 + variants = append(variants, Variant{Width: w, Path: variantPath, Height: h}) 189 + } 190 + return dec.Width, dec.Height, variants, nil 191 + }
+187
internal/images/variants_test.go
··· 1 + package images_test 2 + 3 + import ( 4 + "image" 5 + "image/color" 6 + "image/jpeg" 7 + "image/png" 8 + "os" 9 + "path/filepath" 10 + "testing" 11 + 12 + "aparker.io/fair/internal/images" 13 + ) 14 + 15 + // makeTestPNG writes a solid-color PNG of width×height and returns the path. 16 + func makeTestPNG(t *testing.T, dir, name string, width, height int) string { 17 + t.Helper() 18 + img := image.NewRGBA(image.Rect(0, 0, width, height)) 19 + for y := 0; y < height; y++ { 20 + for x := 0; x < width; x++ { 21 + img.Set(x, y, color.RGBA{R: uint8(x % 255), G: uint8(y % 255), B: 128, A: 255}) 22 + } 23 + } 24 + path := filepath.Join(dir, name) 25 + f, err := os.Create(path) 26 + if err != nil { 27 + t.Fatalf("create %s: %v", path, err) 28 + } 29 + defer f.Close() 30 + if err := png.Encode(f, img); err != nil { 31 + t.Fatalf("encode %s: %v", path, err) 32 + } 33 + return path 34 + } 35 + 36 + // makeTestJPEG writes a solid-color JPEG of width×height and returns the path. 37 + func makeTestJPEG(t *testing.T, dir, name string, width, height int) string { 38 + t.Helper() 39 + img := image.NewRGBA(image.Rect(0, 0, width, height)) 40 + for y := 0; y < height; y++ { 41 + for x := 0; x < width; x++ { 42 + img.Set(x, y, color.RGBA{R: 200, G: 100, B: 50, A: 255}) 43 + } 44 + } 45 + path := filepath.Join(dir, name) 46 + f, err := os.Create(path) 47 + if err != nil { 48 + t.Fatalf("create %s: %v", path, err) 49 + } 50 + defer f.Close() 51 + if err := jpeg.Encode(f, img, &jpeg.Options{Quality: 80}); err != nil { 52 + t.Fatalf("encode %s: %v", path, err) 53 + } 54 + return path 55 + } 56 + 57 + func TestGenerateVariants_PNG_GeneratesSmallerWidthsOnly(t *testing.T) { 58 + dir := t.TempDir() 59 + src := makeTestPNG(t, dir, "wide.png", 1600, 1200) 60 + 61 + got, err := images.GenerateVariants(src) 62 + if err != nil { 63 + t.Fatalf("GenerateVariants: %v", err) 64 + } 65 + 66 + // Source is 1600 wide; expect variants at 480, 960, 1440 (skip 1920). 67 + wantWidths := []int{480, 960, 1440} 68 + if len(got) != len(wantWidths) { 69 + t.Fatalf("variant count: got %d, want %d (%v)", len(got), len(wantWidths), got) 70 + } 71 + for i, v := range got { 72 + if v.Width != wantWidths[i] { 73 + t.Errorf("variant %d width: got %d, want %d", i, v.Width, wantWidths[i]) 74 + } 75 + // Height should be proportional: 1200/1600 = 0.75 76 + wantH := wantWidths[i] * 1200 / 1600 77 + if v.Height != wantH { 78 + t.Errorf("variant %d height: got %d, want %d", i, v.Height, wantH) 79 + } 80 + if _, err := os.Stat(v.Path); err != nil { 81 + t.Errorf("variant %d path %s: %v", i, v.Path, err) 82 + } 83 + } 84 + } 85 + 86 + func TestGenerateVariants_JPEG(t *testing.T) { 87 + dir := t.TempDir() 88 + src := makeTestJPEG(t, dir, "photo.jpg", 1000, 750) 89 + 90 + got, err := images.GenerateVariants(src) 91 + if err != nil { 92 + t.Fatalf("GenerateVariants: %v", err) 93 + } 94 + // 1000 wide → variants at 480, 960 only. 95 + if len(got) != 2 { 96 + t.Fatalf("variant count: got %d, want 2 (%v)", len(got), got) 97 + } 98 + if got[0].Width != 480 || got[1].Width != 960 { 99 + t.Errorf("widths: got %v, want [480 960]", []int{got[0].Width, got[1].Width}) 100 + } 101 + // Variant filenames carry the width and original extension. 102 + wantPaths := []string{ 103 + filepath.Join(dir, "photo-480.jpg"), 104 + filepath.Join(dir, "photo-960.jpg"), 105 + } 106 + for i, v := range got { 107 + if v.Path != wantPaths[i] { 108 + t.Errorf("variant %d path: got %s, want %s", i, v.Path, wantPaths[i]) 109 + } 110 + } 111 + } 112 + 113 + func TestGenerateVariants_NoUpscale(t *testing.T) { 114 + dir := t.TempDir() 115 + src := makeTestPNG(t, dir, "small.png", 300, 200) 116 + 117 + got, err := images.GenerateVariants(src) 118 + if err != nil { 119 + t.Fatalf("GenerateVariants: %v", err) 120 + } 121 + if len(got) != 0 { 122 + t.Errorf("source smaller than smallest variant should produce nothing, got %v", got) 123 + } 124 + } 125 + 126 + func TestGenerateVariants_Idempotent(t *testing.T) { 127 + dir := t.TempDir() 128 + src := makeTestPNG(t, dir, "img.png", 1000, 800) 129 + 130 + first, err := images.GenerateVariants(src) 131 + if err != nil { 132 + t.Fatalf("first run: %v", err) 133 + } 134 + if len(first) == 0 { 135 + t.Fatal("expected variants on first run") 136 + } 137 + mtime0 := mustModTime(t, first[0].Path) 138 + 139 + // Second call should not rewrite (mtime unchanged). 140 + second, err := images.GenerateVariants(src) 141 + if err != nil { 142 + t.Fatalf("second run: %v", err) 143 + } 144 + if len(second) != len(first) { 145 + t.Errorf("variant count changed across runs: %d → %d", len(first), len(second)) 146 + } 147 + mtime1 := mustModTime(t, first[0].Path) 148 + if !mtime0.Equal(mtime1) { 149 + t.Errorf("variant mtime changed on idempotent re-run: %v → %v", mtime0, mtime1) 150 + } 151 + } 152 + 153 + func TestFindVariants_AfterGenerate(t *testing.T) { 154 + dir := t.TempDir() 155 + src := makeTestPNG(t, dir, "doc.png", 1500, 1000) 156 + if _, err := images.GenerateVariants(src); err != nil { 157 + t.Fatalf("GenerateVariants: %v", err) 158 + } 159 + 160 + w, h, vs, err := images.FindVariants(src) 161 + if err != nil { 162 + t.Fatalf("FindVariants: %v", err) 163 + } 164 + if w != 1500 || h != 1000 { 165 + t.Errorf("source dims: got %dx%d, want 1500x1000", w, h) 166 + } 167 + // 1500-wide source: variants generated at 480, 960, 1440 (1920 skipped). 168 + if len(vs) != 3 { 169 + t.Errorf("variant count: got %d, want 3 (480, 960, 1440)", len(vs)) 170 + } 171 + } 172 + 173 + func mustModTime(t *testing.T, path string) (mtime interface{ Equal(any) bool }) { 174 + t.Helper() 175 + info, err := os.Stat(path) 176 + if err != nil { 177 + t.Fatalf("stat %s: %v", path, err) 178 + } 179 + return modTime{info.ModTime().UnixNano()} 180 + } 181 + 182 + type modTime struct{ ns int64 } 183 + 184 + func (m modTime) Equal(other any) bool { 185 + o, ok := other.(modTime) 186 + return ok && m.ns == o.ns 187 + }
+3
internal/markdown/canonical.go
··· 21 21 22 22 // Keys appear alphabetically; missing/empty fields are omitted so the 23 23 // canonical output of `{Title: "X"}` doesn't carry empty defaults. 24 + if fm.BskyPost != "" { 25 + fmt.Fprintf(&b, "bsky_post: %s\n", fm.BskyPost) 26 + } 24 27 if fm.Cover != "" { 25 28 fmt.Fprintf(&b, "cover: %s\n", fm.Cover) 26 29 }
+1
internal/markdown/frontmatter.go
··· 23 23 Tags []string `yaml:"tags,omitempty"` 24 24 Slug string `yaml:"slug,omitempty"` 25 25 Updated string `yaml:"updated,omitempty"` 26 + BskyPost string `yaml:"bsky_post,omitempty"` // at-uri of an app.bsky.feed.post record; published as bskyPostRef StrongRef 26 27 } 27 28 28 29 // Split separates the optional YAML frontmatter (delimited by "---" lines)
-31
internal/ops/migrate.go
··· 1 - package ops 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - 7 - "aparker.io/fair/internal/atproto" 8 - ) 9 - 10 - // CleanupWhtwnd deletes all com.whtwnd.blog.entry records in the 11 - // authenticated user's repo. Used after the cutover to standard.site 12 - // is verified — leaves the PDS clean of the prior lexicon. 13 - // 14 - // Safe to run multiple times; deleteRecord is idempotent on missing. 15 - // Returns the count of records that were deleted. 16 - func CleanupWhtwnd(ctx context.Context, client *atproto.Client) (int, error) { 17 - listing, err := client.ListRecords(ctx, "com.whtwnd.blog.entry", "", 100) 18 - if err != nil { 19 - return 0, fmt.Errorf("listRecords: %w", err) 20 - } 21 - 22 - count := 0 23 - for _, item := range listing.Records { 24 - rkey := rkeyFromAtURI(item.URI) 25 - if err := client.DeleteRecord(ctx, "com.whtwnd.blog.entry", rkey); err != nil { 26 - return count, fmt.Errorf("delete %s: %w", rkey, err) 27 - } 28 - count++ 29 - } 30 - return count, nil 31 - }
+82
internal/publish/announce.go
··· 1 + package publish 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "strings" 7 + "time" 8 + 9 + "aparker.io/fair/internal/atproto" 10 + ) 11 + 12 + // AnnounceInput is the data the announce flow needs to create an 13 + // app.bsky.feed.post. Pulled out of the main publish flow so it can be 14 + // unit-tested without touching the full document pipeline. 15 + type AnnounceInput struct { 16 + Title string // post title — used as the embed card title 17 + Description string // post description — used as the embed card description 18 + URL string // canonical absolute URL of the article on the rendered site 19 + CoverBlob *atproto.BlobRef // optional; used as the embed card thumbnail 20 + } 21 + 22 + // maxBskyPostText is the Bluesky post graphemes limit. We measure in 23 + // runes here, which is a slight under-count for posts containing emoji 24 + // composed of multiple codepoints, but conservative — undershooting 25 + // this constraint is fine, overshooting causes the PDS to reject. 26 + const maxBskyPostText = 300 27 + 28 + // Announce creates an app.bsky.feed.post record on the user's repo 29 + // announcing a published article, and returns the new post's at-uri 30 + // and CID. The result becomes the StrongRef stored as bskyPostRef on 31 + // the document so readers can pull replies as comments. 32 + // 33 + // The announce post body is "<title>\n\n<URL>" with a link card embed 34 + // pointing at the article URL. The embed reuses CoverBlob as its thumb 35 + // when one is supplied. 36 + func Announce(ctx context.Context, client *atproto.Client, in AnnounceInput) (uri, cid string, err error) { 37 + text := buildAnnounceText(in.Title, in.URL) 38 + 39 + external := map[string]any{ 40 + "uri": in.URL, 41 + "title": in.Title, 42 + "description": in.Description, 43 + } 44 + if in.CoverBlob != nil { 45 + external["thumb"] = in.CoverBlob 46 + } 47 + 48 + record := map[string]any{ 49 + "$type": "app.bsky.feed.post", 50 + "text": text, 51 + "createdAt": time.Now().UTC().Format(time.RFC3339), 52 + "langs": []string{"en"}, 53 + "embed": map[string]any{ 54 + "$type": "app.bsky.embed.external", 55 + "external": external, 56 + }, 57 + } 58 + 59 + ref, err := client.CreateRecord(ctx, "app.bsky.feed.post", record) 60 + if err != nil { 61 + return "", "", fmt.Errorf("create app.bsky.feed.post: %w", err) 62 + } 63 + return ref.URI, ref.CID, nil 64 + } 65 + 66 + // buildAnnounceText composes the post body. Format: 67 + // 68 + // <title> 69 + // <URL> 70 + // 71 + // If the title alone (plus newline plus URL) exceeds 300 runes we 72 + // truncate the title and add an ellipsis. URL is preserved verbatim 73 + // since shortening it would break the embed card resolution. 74 + func buildAnnounceText(title, articleURL string) string { 75 + title = strings.TrimSpace(title) 76 + urlPart := "\n\n" + articleURL 77 + maxTitle := maxBskyPostText - len([]rune(urlPart)) 78 + if r := []rune(title); len(r) > maxTitle { 79 + title = string(r[:maxTitle-1]) + "…" 80 + } 81 + return title + urlPart 82 + }
+125
internal/publish/bsky.go
··· 1 + package publish 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "net/http" 8 + "net/url" 9 + "strings" 10 + "time" 11 + 12 + "aparker.io/fair/internal/atproto" 13 + ) 14 + 15 + // publicBskyAppView is the unauthenticated AppView the publish flow 16 + // queries to resolve a Bluesky post at-uri to its current CID. We use 17 + // it for one specific job: building bskyPostRef StrongRefs at publish 18 + // time. The endpoint accepts unauthenticated reads of public records. 19 + const publicBskyAppView = "https://public.api.bsky.app" 20 + 21 + // bskyAppViewClient is the HTTP client used for resolveBskyPostCID. 22 + // Short timeout — this is one request per published post that uses 23 + // bsky_post; we don't need to be patient. 24 + var bskyAppViewClient = &http.Client{Timeout: 10 * time.Second} 25 + 26 + // resolveBskyPostCID parses an at-uri of the form 27 + // at://<did-or-handle>/app.bsky.feed.post/<rkey> 28 + // and fetches the matching record's CID. When the at-uri references 29 + // the publishing user's own repo, we use the authenticated client's 30 + // GetRecord (which already trusts the user's PDS — important for 31 + // sandbox/self-hosted setups where the public AppView can't see the 32 + // post). Otherwise we hit the public Bluesky AppView, which works for 33 + // any federated bsky post. 34 + // 35 + // site.standard.document.bskyPostRef is typed as a strongRef per the 36 + // canonical lexicon at 37 + // https://tangled.sh/standard.site/lexicons/blob/main/src/lexicons/site.standard.document.ts 38 + func resolveBskyPostCID(ctx context.Context, atURI string, client *atproto.Client) (cid string, err error) { 39 + repo, collection, rkey, err := parseATURI(atURI) 40 + if err != nil { 41 + return "", fmt.Errorf("bsky_post %q: %w", atURI, err) 42 + } 43 + if collection != "app.bsky.feed.post" { 44 + return "", fmt.Errorf("bsky_post %q: expected app.bsky.feed.post collection, got %s", atURI, collection) 45 + } 46 + 47 + // Own-repo lookup uses the authenticated client (which trusts the 48 + // session's PDS — covers sandbox/mkcert). 49 + if client != nil && repo == client.Session().DID { 50 + out, err := client.GetRecord(ctx, collection, rkey) 51 + if err != nil { 52 + return "", fmt.Errorf("getRecord on own PDS for %s: %w", atURI, err) 53 + } 54 + if out.CID == "" { 55 + return "", fmt.Errorf("own PDS returned no cid for %s", atURI) 56 + } 57 + return out.CID, nil 58 + } 59 + 60 + // External-repo lookup goes to the public Bluesky AppView. Default 61 + // HTTP client is fine here — public.api.bsky.app uses a normal 62 + // publicly-trusted certificate. 63 + q := url.Values{} 64 + q.Set("repo", repo) 65 + q.Set("collection", collection) 66 + q.Set("rkey", rkey) 67 + endpoint := publicBskyAppView + "/xrpc/com.atproto.repo.getRecord?" + q.Encode() 68 + body, err := tryGetRecord(ctx, endpoint) 69 + if err != nil { 70 + return "", fmt.Errorf("public AppView for %s: %w", atURI, err) 71 + } 72 + if body.CID == "" { 73 + return "", fmt.Errorf("public AppView returned no cid for %s", atURI) 74 + } 75 + return body.CID, nil 76 + } 77 + 78 + // tryGetRecord performs one getRecord lookup and decodes the URI+CID 79 + // envelope. Caller is responsible for trying alternative endpoints on 80 + // failure. 81 + func tryGetRecord(ctx context.Context, endpoint string) (struct { 82 + URI string `json:"uri"` 83 + CID string `json:"cid"` 84 + }, error) { 85 + var body struct { 86 + URI string `json:"uri"` 87 + CID string `json:"cid"` 88 + } 89 + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) 90 + if err != nil { 91 + return body, err 92 + } 93 + resp, err := bskyAppViewClient.Do(req) 94 + if err != nil { 95 + return body, fmt.Errorf("fetch %s: %w", endpoint, err) 96 + } 97 + defer resp.Body.Close() 98 + if resp.StatusCode != http.StatusOK { 99 + return body, fmt.Errorf("getRecord returned %d for %s", resp.StatusCode, endpoint) 100 + } 101 + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { 102 + return body, fmt.Errorf("decode getRecord response: %w", err) 103 + } 104 + return body, nil 105 + } 106 + 107 + // parseATURI splits an at-uri into its three components. Expected shape: 108 + // at://<repo>/<collection>/<rkey> 109 + // where repo is a DID or handle, collection is an NSID, rkey is an 110 + // arbitrary record key. Returns (repo, collection, rkey, nil) on 111 + // success. 112 + func parseATURI(atURI string) (repo, collection, rkey string, err error) { 113 + const prefix = "at://" 114 + if !strings.HasPrefix(atURI, prefix) { 115 + return "", "", "", fmt.Errorf("not an at-uri") 116 + } 117 + parts := strings.SplitN(atURI[len(prefix):], "/", 3) 118 + if len(parts) != 3 { 119 + return "", "", "", fmt.Errorf("expected at://<repo>/<collection>/<rkey>") 120 + } 121 + if parts[0] == "" || parts[1] == "" || parts[2] == "" { 122 + return "", "", "", fmt.Errorf("expected at://<repo>/<collection>/<rkey>") 123 + } 124 + return parts[0], parts[1], parts[2], nil 125 + }
+45
internal/publish/identity.go
··· 65 65 return id.String(), nil 66 66 } 67 67 68 + // writeBackBskyPost inserts a `bsky_post: <at-uri>` line into the YAML 69 + // frontmatter of the file at path. Used after a successful --announce 70 + // to record the announce post's at-uri so future runs reuse it 71 + // (idempotency). If the file already has a bsky_post line, this is a 72 + // no-op (the announce flow shouldn't have run in that case anyway). 73 + func writeBackBskyPost(path, atURI string) error { 74 + src, err := os.ReadFile(path) 75 + if err != nil { 76 + return fmt.Errorf("read %s: %w", path, err) 77 + } 78 + if strings.Contains(string(src), "bsky_post:") { 79 + return nil 80 + } 81 + const fence = "---\n" 82 + rest, ok := strings.CutPrefix(string(src), fence) 83 + if !ok { 84 + rest, ok = strings.CutPrefix(string(src), "---\r\n") 85 + if !ok { 86 + return fmt.Errorf("source does not start with --- frontmatter fence") 87 + } 88 + } 89 + closingIdx := strings.Index(rest, "\n---") 90 + if closingIdx < 0 { 91 + return fmt.Errorf("missing closing frontmatter fence") 92 + } 93 + fmYAML := rest[:closingIdx] 94 + tail := rest[closingIdx:] 95 + // Append bsky_post as the last key (before the closing fence) so it 96 + // sits with the other operator-supplied fields without disturbing 97 + // the order of existing keys. 98 + if !strings.HasSuffix(fmYAML, "\n") { 99 + fmYAML += "\n" 100 + } 101 + updated := fence + fmYAML + "bsky_post: \"" + atURI + "\"\n" + tail + "\n" 102 + tmp := path + ".tmp" 103 + if err := os.WriteFile(tmp, []byte(updated), 0o644); err != nil { 104 + return fmt.Errorf("write %s: %w", tmp, err) 105 + } 106 + if err := os.Rename(tmp, path); err != nil { 107 + _ = os.Remove(tmp) 108 + return fmt.Errorf("rename %s -> %s: %w", tmp, path, err) 109 + } 110 + return nil 111 + } 112 + 68 113 // writeBackID inserts an `id:` line into the YAML frontmatter of src. 69 114 // The body is the previously-parsed body slice so we can reconstruct 70 115 // the file deterministically.
+8
internal/publish/images.go
··· 7 7 "os" 8 8 "path/filepath" 9 9 "strings" 10 + 11 + "aparker.io/fair/internal/images" 10 12 ) 11 13 12 14 // RewriteImages finds inline images in the markdown body, copies their ··· 104 106 dest := filepath.Join(destDir, base) 105 107 if err := copyFileIdempotent(src, dest); err != nil { 106 108 return "", err 109 + } 110 + // Generate resized variants alongside the source so the build flow 111 + // can emit srcset/sizes. Unsupported formats (gif, svg, etc.) come 112 + // back as (nil, nil) — image becomes a plain non-responsive <img>. 113 + if _, err := images.GenerateVariants(dest); err != nil { 114 + return "", fmt.Errorf("generate variants for %s: %w", dest, err) 107 115 } 108 116 return fmt.Sprintf("![%s](/images/%s/%s)", altText, slug, base), nil 109 117 }
+71 -1
internal/publish/publish.go
··· 29 29 // record even when nothing changed. Advances updatedAt. 30 30 Force bool 31 31 32 + // Announce: when true and the post has no existing bsky_post 33 + // frontmatter, create an app.bsky.feed.post on the user's repo 34 + // linking to the article and store its at-uri+cid as bskyPostRef 35 + // on the document record. The new at-uri also gets written back 36 + // to the source frontmatter as bsky_post: so subsequent runs are 37 + // idempotent (no duplicate announce posts). 38 + Announce bool 39 + 32 40 // Logger receives operator-facing progress strings. nil silences output. 33 41 Logger func(format string, args ...any) 34 42 } ··· 49 57 // PublicationURI: the publication record's AT-URI, e.g. 50 58 // at://did:plc:.../site.standard.publication/self. 51 59 PublicationURI string 60 + 61 + // PublicationURL: the rendered site's absolute URL (no trailing 62 + // slash), e.g. https://aparker.io. Used to construct article URLs 63 + // for the --announce embed card. 64 + PublicationURL string 52 65 53 66 // MaxCoverSize: byte cap on cover images. The site.standard.document 54 67 // spec recommends 1MB; we enforce it at the client because PDS may ··· 158 171 CoverSourceHash: coverHash, 159 172 }) 160 173 prevEntry, hadEntry := state.Entries[slug] 161 - if hadEntry && prevEntry.LastHash == hash && !opts.Force { 174 + // --announce on a hash-unchanged post that's never been announced 175 + // must NOT short-circuit: the announce flow needs to run, the 176 + // document needs to be re-put with the new bskyPostRef, and the 177 + // frontmatter needs to gain bsky_post:. 178 + needsAnnounce := opts.Announce && fm.BskyPost == "" && !opts.DryRun 179 + if hadEntry && prevEntry.LastHash == hash && !opts.Force && !needsAnnounce { 162 180 log("skip %s (hash unchanged)", slug) 163 181 return Result{ 164 182 Rkey: slug, ··· 212 230 } 213 231 if len(fm.Tags) > 0 { 214 232 record["tags"] = canonicalTags(fm.Tags) 233 + } 234 + if fm.BskyPost != "" { 235 + // Resolve the at-uri to a CID and emit a strongRef per the 236 + // standard.site lexicon. Own-repo lookups use the authed 237 + // client (trusts our PDS, important for sandbox); external 238 + // lookups hit the public Bluesky AppView. 239 + cid, err := resolveBskyPostCID(ctx, fm.BskyPost, client) 240 + if err != nil { 241 + return Result{}, fmt.Errorf("bsky_post: %w", err) 242 + } 243 + record["bskyPostRef"] = map[string]any{ 244 + "uri": fm.BskyPost, 245 + "cid": cid, 246 + } 247 + } else if opts.Announce && !opts.DryRun { 248 + // No prior announce post; create one and pin it as the comment 249 + // root. The new at-uri gets written back to frontmatter so 250 + // re-runs short-circuit on this branch. 251 + articleURL := strings.TrimRight(cfg.PublicationURL, "/") + "/" + slug + "/" 252 + announceDesc := description 253 + if announceDesc == "" { 254 + announceDesc = fm.Title 255 + } 256 + log("announce app.bsky.feed.post → %s", articleURL) 257 + announceURI, announceCID, err := Announce(ctx, client, AnnounceInput{ 258 + Title: fm.Title, 259 + Description: announceDesc, 260 + URL: articleURL, 261 + CoverBlob: coverBlob, 262 + }) 263 + if err != nil { 264 + return Result{}, fmt.Errorf("announce: %w", err) 265 + } 266 + record["bskyPostRef"] = map[string]any{ 267 + "uri": announceURI, 268 + "cid": announceCID, 269 + } 270 + // Persist the new at-uri to the source markdown so subsequent 271 + // publishes reuse it (idempotency) and so it appears in the 272 + // authored frontmatter for everyone to see. Also update fm and 273 + // recompute hash so the state save below stores the 274 + // post-writeback hash; otherwise a no-flag re-publish would 275 + // see "frontmatter changed" and unnecessarily re-put. 276 + if err := writeBackBskyPost(sourcePath, announceURI); err != nil { 277 + return Result{}, fmt.Errorf("write back bsky_post: %w", err) 278 + } 279 + fm.BskyPost = announceURI 280 + hash = markdown.Hash(markdown.HashInput{ 281 + NormalizedBody: rewrittenBody, 282 + Frontmatter: fm, 283 + CoverSourceHash: coverHash, 284 + }) 215 285 } 216 286 updatedAtAdvanced := false 217 287 if hadEntry && prevEntry.LastHash != hash {