---
name: standard-site-hugo-integration
description: Use when adding standard.site (AT Protocol / Bluesky publishing lexicons) to a Hugo blog so posts sync to the site owner's PDS and Bluesky shows rich link previews. Triggers on "standard.site", "AT Protocol", "atproto", "PDS", "site.standard.publication", "site.standard.document", "Bluesky rich preview", "publish Hugo to the ATmosphere".
metadata: 
  - version: 1.1.0
  - license: MIT
---

# Integrating standard.site into a Hugo Blog

## Overview

standard.site is two AT Protocol lexicons — `site.standard.publication` (one
record describing the site) and `site.standard.document` (one record per
post) — stored on the site owner's PDS. Bluesky's AppView crawls shared
links, finds `<link rel="site.standard.publication">` /
`<link rel="site.standard.document">` tags in the page head, resolves them
against the PDS, and verifies ownership via
`/.well-known/site.standard.publication`. Full lexicon docs:
https://standard.site/docs/lexicons/publication and `/document`.

## When to Use

- Request to integrate standard.site, publish a Hugo blog to atproto, or get
  Bluesky rich previews for a self-hosted blog.
- You're given: a domain, a Bluesky handle matching it, and a DID (or handle
  to resolve one from).

## Prerequisites

- Confirm handle verification already works independently of this (DNS TXT
  `_atproto.<domain>` or `/.well-known/atproto-did`). Don't conflate it with
  the `site.standard.publication` well-known file — separate mechanism.
- The user must generate an app password themselves and put it in a
  gitignored `.env`. Never ask them to paste a credential into chat.

## Process

1. **Resolve DID → PDS endpoint**: `curl https://plc.directory/<did>` →
   read `service[]` where `type == "AtprotoPersonalDataServer"` →
   `serviceEndpoint`. For `did:web`, fetch
   `https://<domain>/.well-known/did.json` instead.
2. **Survey the target repo**: find the shared `<head>` partial (grep for
   other SEO/verification partials as an anchor point), the config file to
   add a params block to, the posts content directory, and the permalink
   pattern (check a built page under `public/<section>/...`, or the
   `[permalinks]` config) — needed to build canonical post URLs.
3. **Wire up Hugo**:
   - Config: `[standard_site]` → `enable`, `did`, `publication_uri`
     (`at://<did>/site.standard.publication/self`).
   - New partial, called from the head partial: emits
     `<link rel="site.standard.publication" href="{{ site.Params.standard_site.publication_uri }}">`
     sitewide, and
     `<link rel="site.standard.document" href="{{ .Params.standard_site_uri }}">`
     only when that frontmatter field is set on the current page.
   - Two frontmatter fields the publish script writes back per post:
     `standard_site_uri`, `standard_site_hash` (content hash, for idempotent
     re-publish).
   - `/.well-known/site.standard.publication` in the static dir — plain text
     containing the publication AT-URI, written by the script once that
     record exists.
4. **Publish script**: copy `publish-template.js` (in this skill's scripts directory)
   into the target repo as `scripts/standard-site-publish.js`, adapt the
   `CONFIG` block (content dir, permalink→path function, site name /
   description / URL) to the target theme. Add `dotenv` + `gray-matter` as
   devDependencies, add an npm script. Credentials via gitignored `.env`
   (copy this skill's `scripts/.env.example` alongside the template and
   commit it with blank values). The template handles:
   session auth, verifying the returned DID matches, ensuring the
   publication record exists + writing the well-known file, and per-post
   document records using TID rkeys with hash-based skip logic. Each
   document record includes `textContent` (full post body, stripped of
   markdown, capped at 300KB) and `updatedAt` timestamps.
   - **Logo**: set `SITE_LOGO_PATH` to a square image (≥256x256) to publish
     it as the publication's `icon`.
   - **Theme**: set `SITE_THEME_BACKGROUND` / `_FOREGROUND` / `_ACCENT` /
     `_ACCENT_FOREGROUND` (hex colors) to publish a `basicTheme`.
   - **Cover image per post**: add `cover: <filename>` to a post's
     frontmatter (path relative to that post's own file) to publish it as
     the document's `coverImage`. Files ≥1MB are skipped with a warning —
     the lexicon caps `coverImage` under 1MB.
   - Icon/theme changes and cover images are uploaded via
     `com.atproto.repo.uploadBlob` before being referenced in a record.
     The publication record's hash is cached in `.standard-site-state.json`
     so unchanged icon/theme don't trigger a redundant blob re-upload on
     every run; cover images are covered by the existing per-post content
     hash instead.
5. Add `.env` and `.standard-site-state.json` to `.gitignore` if they
   aren't already there.
6. Run manually (`npm run standard-site:publish`) after writing/editing
   posts. Don't wire this into CI — it needs a repo-write-capable app
   password, best kept off shared infrastructure.

## Common Mistakes

| Mistake | Why it breaks |
|---|---|
| Document rkey = post slug | Some validators silently reject non-TID rkeys; generate a real TID (see template). |
| Missing `$type` on nested objects (e.g. `basicTheme`) | Bluesky silently drops the whole nested block — no error surfaced. |
| Writing to `bsky.social` / `api.bsky.app` | Repo writes must go to the account's actual PDS endpoint, resolved from its DID — never assumed. |
| Treating this as the same thing as handle verification | standard.site's well-known file is separate from `atproto-did`/DNS TXT handle verification; both may already exist independently. |
| Deriving `path` from the filename alone | Posts with a `slug:` frontmatter override produce a different real URL — check frontmatter before falling back to the filename. |
| `href="{{ atURI }}"` without `\| safeURL` in Hugo templates | Go's `html/template` doesn't recognize the `at://` scheme and silently rewrites the href to `#ZgotmplZ`. Always pipe atproto URIs through `safeURL`. |
| Globbing the content dir with `*.md` unfiltered | Sweeps in `_index.md` section pages, which have no title/date and crash naive frontmatter handling. Exclude `_index*`. |
| Assuming `gray-matter` parses frontmatter exactly like Hugo | Hugo tolerates a leading blank line before the `---` delimiter; `gray-matter` doesn't and silently returns `{}`. `trimStart()` the raw file before parsing, and validate required fields instead of crashing on the first bad post. |

## Files Touched (typical)

- Config file — new `[standard_site]` block
- Theme's shared head partial + one new partial for the `<link>` tags
- `static/.well-known/site.standard.publication`
- `scripts/standard-site-publish.js`, `.env.example`, `.gitignore`
- `package.json` — new script + `dotenv`/`gray-matter` devDeps
- Post frontmatter — `standard_site_uri` / `standard_site_hash` added
  incrementally as posts are published, `cover` set manually to opt a post
  into a `coverImage`
- `.standard-site-state.json` (gitignored) — caches the publication
  record's hash so unchanged icon/theme aren't re-uploaded every run

## Lexicon Quick Reference

`site.standard.publication` — required: `url`, `name`. Optional: `icon`
(blob), `description`, `basicTheme`, `labels`, `preferences.showInDiscover`.

`site.standard.document` — required: `site` (at:// or https:// pointing to
the publication), `title`, `publishedAt`. Optional: `path`, `description`,
`coverImage` (blob, <1MB), `content`, `textContent`, `bskyPostRef`, `tags[]`,
`links`, `labels`, `contributors[]` (`did` required, `role`/`displayName`
optional), `updatedAt`.
