@hedgerow/publish
v0.3.1
Published
Publish markdown posts as standard.site atproto records to a PDS.
Maintainers
Readme
@hedgerow/publish
Publish markdown posts as portable site.standard.*
records in your own AT Protocol repository, and read them
back. Your writing lives on the open network in a repo you control, rather than
inside one host's database.
Part of Hedgerow. This is the author's
side of the toolkit — see @hedgerow/comments and
@hedgerow/react for rendering the social layer on your site, and
@hedgerow/reader for a visitor's own identity.
New applications normally consume the curated hedgerow/site and
hedgerow/node entry points. This scoped package remains the lower-level
protocol and compatibility surface.
Install
npm install @hedgerow/publishNode ≥ 20. ESM-only. The generated Astro starter requires Node ≥ 22.12.
Entry points
This package splits deliberately, because half of it must be safe to bundle for a browser:
| Import | Contains | Safe in a browser |
| --- | --- | --- |
| @hedgerow/publish/site | Record shapes/builders, CID-safe document operations, and unauthenticated publication reads | Yes |
| @hedgerow/publish | Legacy compatibility surface, including frontmatter parsing and filesystem reconciliation | Do not use for new browser bundles |
| @hedgerow/publish/node | The compatibility surface plus oauthPublisher, openInBrowser, clearSession, and FileStore | No — uses node:http, node:fs |
A Node script or CLI should import @hedgerow/publish/node. A browser
integration should normally use hedgerow/site; lower-level browser code can
use @hedgerow/publish/site. The bare entry point stays intact for existing
consumers, but its historical frontmatter parser is intentionally not part of
the browser-focused subpath.
Publishing
Authentication is atproto OAuth with granular publication, document and
discussion-post permissions — there is no password or app-password to store.
The first publish opens a browser; the session is then cached (in
~/.config/hedgerow) and silently refreshed until you sign out.
import { oauthPublisher, publishSite, parsePost } from "@hedgerow/publish/node";
// Restores a cached session, or runs the browser login the first time.
// `identifier` is an optional hint — omit it to pick the account in the browser.
const publisher = await oauthPublisher({ identifier: "you.bsky.social" });
const posts = [parsePost(markdown, "my-first-post")];
const result = await publishSite(
publisher,
{ url: "https://you.com", name: "you" },
posts,
);Login uses the atproto loopback client flow: a native client id and a
throwaway callback server on 127.0.0.1. Nothing to host, no client secret.
Because a record write requires a real login, there is deliberately no
headless publish path — a human completes the browser step once, then reruns
are non-interactive off the cached session.
Sign out with clearSession() (aliased logout()); pass { identifier } to
drop just one account.
Persist the state, or you'll create duplicates
publishSite returns a PublishState recording which record key it used per
slug. Persist it between runs (e.g. .publish-state.json) — that is what
makes reruns idempotent, targeting the same record instead of creating a second
one.
import { readFile, writeFile } from "node:fs/promises";
import { emptyState } from "@hedgerow/publish/node";
const state = await readFile(".publish-state.json", "utf8")
.then(JSON.parse)
.catch(() => emptyState());
const result = await publishSite(publisher, config, posts, state);
await writeFile(".publish-state.json", JSON.stringify(result.state, null, 2));Reading it back in is the half that matters — publishSite without a state
argument starts from empty and will create a second record for every post.
Two related behaviours worth knowing:
updatedAtonly moves on a real change. Republishing compares against the existing record ignoringupdatedAt; if nothing else changed, the write is skipped entirely. It stays an honest "last edited", not "last ran the script".textContentis always written. Every document mirrors plaintext intotextContentalongside the richercontent, so a standard.site reader that knows nothing about Hedgerow's markdown member still renders something.
Verify what landed in your repo at pdsls.dev.
Editing safely in a browser
The recommended high-level API is a publication-bound capability:
import { author } from "hedgerow/site";
const writer = author(session, { ownerDid, publicationUri });
await writer.updateDocument({ snapshot: current, document: draft });It checks the signed-in DID, injects publication membership and exposes no raw collection/rkey selection. The lower-level operations below remain available for integrations that supply their own capability boundary.
createDocument, updateDocument, and deleteDocument own the protocol
details an editor should not reimplement. New records receive a TID key;
updates keep the same AT URI and use the CID loaded with the document as a
compare-and-swap token.
import { readSite, updateDocument } from "@hedgerow/publish";
const site = await readSite(authorDid, fetch, { publicationUri });
const current = site.documents[0];
if (!current?.uri || !current.cid) throw new Error("Document is not published");
const updated = await updateDocument(publisher, {
uri: current.uri,
cid: current.cid,
document: {
site: current.value.site,
path: current.value.path,
title: "Corrected title",
publishedAt: current.value.publishedAt,
markdown,
},
});The update replaces the value at the same record key; it does not create a
second article. If another tab or client changed the record after it was
loaded, the write throws RecordConflictError and preserves the newer value.
Always select a publication by publicationUri or publicationUrl when a DID
can own more than one site. Reads return the publication and document CIDs.
Hedgerow only edits pub.hedgerow.content.markdown; documentMarkdown()
throws for an unknown rich-content union member instead of converting and
destroying it silently.
Connecting a post to its comments
bskyPostRef is a strongRef on the document pointing at a real Bluesky post
that hosts the canonical thread. The document record is not itself the comment
target — the conversation lives on Bluesky, and the record names which post to
read replies and likes from. That is what lets @hedgerow/comments render a
live thread against a post you actually made.
Testing
The full publish → PDS → read loop is tested against an in-process PDS
from @atproto/dev-env
— no credentials, no Docker, no domain, no live account. What genuinely needs
the real world (OAuth login, custom-domain handle resolution) lives in a manual
checklist instead. See the repo README.
Stability
Pre-1.0. While this package is on 0.x, a minor version bump may contain
breaking changes — npm resolves ^0.1.0 as >=0.1.0 <0.2.0, so a caret range
is already safe. Pin exactly (0.1.0) or use ~0.1.0 if you want to be
conservative. See CONTRIBUTING.md.
