synd

package module
v0.11.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 21, 2026 License: MIT Imports: 30 Imported by: 0

README

axon-synd

Services · Part of the lamina workspace

Personal syndication engine: publish posts to a canonical static site and syndicate copies to social platforms including Bluesky, Mastodon, and Threads.

Getting started

go get github.com/benaskins/axon-synd
store := fact.NewMemoryStore()
ps := synd.NewPostStore(store)
store.RegisterProjector(ps.Projector())

// Create a short-form post
post, _ := ps.Create(ctx, synd.Short, "Hello from the syndication engine",
    synd.WithTags("intro", "test"),
)

// Approve and publish
ps.Approve(ctx, post.ID, "ben")
ps.Publish(ctx, post.ID, "https://example.com/posts/"+post.ID)

// Build the static site
builder := synd.NewSiteBuilder(synd.SiteConfig{
    Title:   "My Site",
    BaseURL: "https://example.com",
    Author:  "Ben",
})
builder.Build(ps.Projection().PublishedPosts(), "./public")

// Syndicate to Bluesky
bsky := synd.NewBlueskyClient(synd.BlueskyConfig{
    Handle:   "me.bsky.social",
    Password: os.Getenv("BLUESKY_APP_PASSWORD"),
})
bsky.Authenticate(ctx)
uri, _, _ := bsky.Post(ctx, post.Body)
ps.Syndicate(ctx, post.ID, synd.Bluesky, uri, synd.BlueskyPostURL("me.bsky.social", uri))

Key types

  • Post — canonical content record with kind (short/long/image), lifecycle status, and metadata
  • PostStore — event-sourced post management with create, revise, approve, publish, syndicate, and delete operations
  • PostProjection — read model built from events, queryable by status (drafts, approved, published, unsynced)
  • PostgresEventStore — persistent fact.EventStore backed by PostgreSQL, with projector and publisher support
  • Engagement — metrics (likes, reposts, replies, views) for a post on a single platform
  • SiteBuilder — static site generator producing index, post pages, RSS feed, and CSS from published posts
  • BlueskyClient — posts to Bluesky via the AT Protocol, with text, link, and image support
  • MastodonClient — posts to Mastodon via the REST API, with media uploads
  • ThreadsClient — posts to Threads via Meta's Graph API
  • CloudflareDeploy — uploads a built site to Cloudflare Pages via Direct Upload API
  • GitPublish — commits and pushes site changes to a git repo

CLI (cmd/synd)

The synd binary provides commands for managing posts: post, posts, drafts, revise, approve, synd, serve, and delete.

License

MIT

Documentation

Overview

Package synd provides a personal syndication engine: publish posts to a canonical static site and syndicate copies to social platforms including Bluesky, Mastodon, and Threads.

Class: experiment UseWhen: Blog/social publishing.

Index

Constants

View Source
const (
	EventPostCreated          = "post.created"
	EventPostRevised          = "post.revised"
	EventPostApproved         = "post.approved"
	EventPostPublished        = "post.published"
	EventPostSyndicated       = "post.syndicated"
	EventPostDeleted          = "post.deleted"
	EventPostEngagementUpdate = "post.engagement_updated"
)

Event types for post lifecycle.

Variables

View Source
var CanApprove = rule.AllOf(
	rule.New(PostCandidate.IsDraft),
)

CanApprove requires the post to be a draft.

View Source
var CanDelete = rule.AllOf(
	rule.New(PostCandidate.IsNotDeleted),
)

CanDelete allows any non-deleted post to be deleted.

View Source
var CanPublish = rule.AllOf(
	rule.New(PostCandidate.IsApproved),
)

CanPublish requires the post to be approved.

View Source
var CanRevise = rule.AllOf(
	rule.New(PostCandidate.IsDraft),
)

CanRevise requires the post to be a draft.

Functions

func BlueskyPostURL

func BlueskyPostURL(handle, atURI string) string

BlueskyPostURL converts an AT URI to a web URL. at://did:plc:abc/app.bsky.feed.post/xyz → https://bsky.app/profile/handle/post/xyz

func CloudflareDeploy added in v0.2.0

func CloudflareDeploy(cfg CloudflareConfig, siteDir string) error

CloudflareDeploy uploads the contents of siteDir to a Cloudflare Pages project using the Direct Upload API. It hashes all files with BLAKE3, checks which are missing, uploads them, and creates a deployment.

func GitPublish

func GitPublish(repoDir, message string) (bool, error)

GitPublish commits and pushes changes in a site repo directory. Returns true if changes were committed, false if there was nothing to commit.

func MarshalData

func MarshalData(v any) json.RawMessage

MarshalData serialises an event payload to JSON.

func MastodonPostURL

func MastodonPostURL(instance, username, statusID string) string

MastodonPostURL constructs the web URL for a Mastodon status.

func TestGit

func TestGit(t interface {
	Helper()
	Fatalf(string, ...any)
}, dir string, args ...string)

TestGit runs a git command in the given directory. For use in tests only.

func ThreadsPostURL

func ThreadsPostURL(username, mediaID string) string

ThreadsPostURL constructs the web URL for a Threads post.

Types

type AlreadyDeleted added in v0.4.0

type AlreadyDeleted struct{}

type BlueskyClient

type BlueskyClient struct {
	// contains filtered or unexported fields
}

BlueskyClient posts to Bluesky via the AT Protocol.

func NewBlueskyClient

func NewBlueskyClient(config BlueskyConfig) *BlueskyClient

NewBlueskyClient creates a client with the given config.

func (*BlueskyClient) Authenticate

func (c *BlueskyClient) Authenticate(ctx context.Context) error

Authenticate creates a session with the PDS.

func (*BlueskyClient) Post

func (c *BlueskyClient) Post(ctx context.Context, text string) (uri string, cid string, err error)

Post creates a post on Bluesky. Returns the AT URI and CID.

func (*BlueskyClient) PostWithImage

func (c *BlueskyClient) PostWithImage(ctx context.Context, text, imagePath, altText string) (uri string, cid string, err error)

PostWithImage creates a post with an attached image.

func (c *BlueskyClient) PostWithLink(ctx context.Context, text, linkURL, linkText string) (uri string, cid string, err error)

PostWithLink creates a post with a clickable link appended to the text.

type BlueskyConfig

type BlueskyConfig struct {
	Handle   string // e.g. "baskins.bsky.social"
	Password string // app password
	PDS      string // PDS host, defaults to https://bsky.social
}

BlueskyConfig holds credentials for the Bluesky AT Protocol API.

type CloudflareConfig added in v0.2.0

type CloudflareConfig struct {
	AccountID   string
	APIToken    string
	ProjectName string
}

CloudflareConfig holds credentials for Cloudflare Pages direct upload.

type Engagement

type Engagement struct {
	PostID    string    `json:"post_id"`
	Platform  string    `json:"platform"`
	Likes     int       `json:"likes"`
	Reposts   int       `json:"reposts"`
	Replies   int       `json:"replies"`
	Views     int       `json:"views,omitempty"`
	FetchedAt time.Time `json:"fetched_at"`
}

Engagement holds metrics for a single post on a single platform.

type Link struct {
	Text  string
	URL   string
	Start int // byte offset in plain text
	End   int // byte offset in plain text
}

Link represents a hyperlink extracted from markdown text.

func ExtractMarkdownLinks(s string) (string, []Link)

ExtractMarkdownLinks converts markdown link syntax to plain text and returns the positions of each link in the resulting text. Inline code spans are preserved as-is (backtick-wrapped content is not treated as links).

type MastodonClient

type MastodonClient struct {
	// contains filtered or unexported fields
}

MastodonClient posts to Mastodon via the REST API.

func NewMastodonClient

func NewMastodonClient(config MastodonConfig) *MastodonClient

NewMastodonClient creates a client with the given config.

func (*MastodonClient) Post

func (c *MastodonClient) Post(ctx context.Context, text string) (id string, statusURL string, err error)

Post creates a status on Mastodon. Returns the status ID and URL.

func (*MastodonClient) PostWithImage

func (c *MastodonClient) PostWithImage(ctx context.Context, text, imagePath, altText string) (id string, statusURL string, err error)

PostWithImage uploads an image and creates a status with it attached.

func (c *MastodonClient) PostWithLink(ctx context.Context, text, linkURL string) (id string, statusURL string, err error)

PostWithLink creates a status with a URL appended.

func (*MastodonClient) VerifyCredentials

func (c *MastodonClient) VerifyCredentials(ctx context.Context) error

VerifyCredentials checks the access token and retrieves the account username.

type MastodonConfig

type MastodonConfig struct {
	Instance    string // e.g. "https://aus.social"
	AccessToken string // OAuth access token
}

MastodonConfig holds credentials for the Mastodon API.

type MissingBody added in v0.4.0

type MissingBody struct{}

type NotApproved added in v0.4.0

type NotApproved struct{ Status PostStatus }

type NotDraft added in v0.4.0

type NotDraft struct{ Status PostStatus }

type Platform

type Platform string

Platform identifies a syndication target.

const (
	Bluesky  Platform = "bluesky"
	Mastodon Platform = "mastodon"
	Threads  Platform = "threads"
)

type Post

type Post struct {
	ID        string     `json:"id"`
	Kind      PostKind   `json:"kind"`
	Status    PostStatus `json:"status"`
	Title     string     `json:"title,omitempty"`
	Abstract  string     `json:"abstract,omitempty"`
	Body      string     `json:"body"`
	ImagePath string     `json:"image_path,omitempty"`
	Tags      []string   `json:"tags,omitempty"`

	// ImportedFrom records the source platform for archived posts.
	// Empty for posts authored locally.
	ImportedFrom string `json:"imported_from,omitempty"`

	// ApprovalToken is a one-time token for the approval gate URL.
	ApprovalToken string `json:"approval_token,omitempty"`

	CreatedAt   time.Time `json:"created_at"`
	ApprovedAt  time.Time `json:"approved_at,omitempty"`
	ApprovedBy  string    `json:"approved_by,omitempty"`
	PublishedAt time.Time `json:"published_at,omitempty"`
}

Post is the canonical representation of a piece of content.

type PostApproved

type PostApproved struct {
	PostID     string    `json:"post_id"`
	ApprovedAt time.Time `json:"approved_at"`
	ApprovedBy string    `json:"approved_by"`
}

PostApproved is emitted when a human approves a draft for publishing.

type PostCandidate added in v0.4.0

type PostCandidate struct {
	Post Post
}

PostCandidate holds the data needed to evaluate post lifecycle rules.

func (PostCandidate) HasBody added in v0.4.0

func (c PostCandidate) HasBody() rule.Verdict

func (PostCandidate) IsApproved added in v0.4.0

func (c PostCandidate) IsApproved() rule.Verdict

func (PostCandidate) IsDraft added in v0.4.0

func (c PostCandidate) IsDraft() rule.Verdict

func (PostCandidate) IsNotDeleted added in v0.4.0

func (c PostCandidate) IsNotDeleted() rule.Verdict

type PostCreated

type PostCreated struct {
	ID            string    `json:"id"`
	Kind          PostKind  `json:"kind"`
	Title         string    `json:"title,omitempty"`
	Abstract      string    `json:"abstract,omitempty"`
	Body          string    `json:"body"`
	ImagePath     string    `json:"image_path,omitempty"`
	Tags          []string  `json:"tags,omitempty"`
	ImportedFrom  string    `json:"imported_from,omitempty"`
	ApprovalToken string    `json:"approval_token,omitempty"`
	CreatedAt     time.Time `json:"created_at"`
}

PostCreated is emitted when a new post is authored.

type PostDeleted

type PostDeleted struct {
	PostID    string    `json:"post_id"`
	DeletedAt time.Time `json:"deleted_at"`
	DeletedBy string    `json:"deleted_by"`
}

PostDeleted is emitted when a post is removed from the site.

type PostEngagementUpdated

type PostEngagementUpdated struct {
	PostID    string    `json:"post_id"`
	Platform  Platform  `json:"platform"`
	Likes     int       `json:"likes"`
	Reposts   int       `json:"reposts"`
	Replies   int       `json:"replies"`
	Views     int       `json:"views,omitempty"`
	FetchedAt time.Time `json:"fetched_at"`
}

PostEngagementUpdated is emitted when metrics are polled from a platform.

type PostKind

type PostKind string

PostKind distinguishes the shape of a post.

const (
	Short PostKind = "short"
	Long  PostKind = "long"
	Image PostKind = "image"
)

type PostOption

type PostOption func(*postConfig)

PostOption configures optional fields when creating a post.

func WithAbstract

func WithAbstract(a string) PostOption

func WithApprovalToken

func WithApprovalToken(t string) PostOption

func WithImagePath

func WithImagePath(p string) PostOption

func WithImportedFrom

func WithImportedFrom(p string) PostOption

func WithTags

func WithTags(t ...string) PostOption

func WithTitle

func WithTitle(t string) PostOption

type PostProjection

type PostProjection struct {
	// contains filtered or unexported fields
}

PostProjection is a read model built from post events.

func (*PostProjection) ApprovedPosts

func (p *PostProjection) ApprovedPosts() []Post

ApprovedPosts returns posts that are approved but not yet published.

func (*PostProjection) Drafts

func (p *PostProjection) Drafts() []Post

Drafts returns posts with status == draft.

func (*PostProjection) EngagementFor

func (p *PostProjection) EngagementFor(postID string) []Engagement

EngagementFor returns engagement metrics for a post across all platforms.

func (*PostProjection) Get

func (p *PostProjection) Get(id string) *Post

Get returns a post by ID, or nil if not found.

func (*PostProjection) Handle

func (p *PostProjection) Handle(_ context.Context, event fact.Event) error

Handle processes a single event to update the projection.

func (*PostProjection) List

func (p *PostProjection) List() []Post

List returns all posts sorted by creation time, newest first.

func (*PostProjection) PublishedPosts added in v0.2.0

func (p *PostProjection) PublishedPosts() []Post

PublishedPosts returns posts with status == published, newest first.

func (*PostProjection) Syndications

func (p *PostProjection) Syndications(postID string) []SyndicationRecord

Syndications returns the syndication records for a post.

func (*PostProjection) UnsyncedPosts

func (p *PostProjection) UnsyncedPosts(platform Platform) []Post

UnsyncedPosts returns posts that haven't been syndicated to the given platform.

type PostPublished

type PostPublished struct {
	ID          string    `json:"id"`
	URL         string    `json:"url"`
	PublishedAt time.Time `json:"published_at"`
}

PostPublished is emitted when the static site is rebuilt and pushed.

type PostRevised

type PostRevised struct {
	PostID    string    `json:"post_id"`
	Title     string    `json:"title,omitempty"`
	Abstract  string    `json:"abstract,omitempty"`
	Body      string    `json:"body"`
	Tags      []string  `json:"tags,omitempty"`
	RevisedAt time.Time `json:"revised_at"`
	RevisedBy string    `json:"revised_by,omitempty"`
}

PostRevised is emitted when a draft post is edited.

type PostStatus

type PostStatus string

PostStatus tracks where a post is in its lifecycle.

const (
	StatusDraft     PostStatus = "draft"
	StatusApproved  PostStatus = "approved"
	StatusPublished PostStatus = "published"
	StatusDeleted   PostStatus = "deleted"
)

type PostStore

type PostStore struct {
	// contains filtered or unexported fields
}

PostStore manages posts through an event-sourced append-only log.

func NewPostStore

func NewPostStore(events fact.EventStore) *PostStore

NewPostStore creates a post store backed by the given event store. The projection is registered as a projector so it stays in sync.

func (*PostStore) Approve

func (s *PostStore) Approve(ctx context.Context, postID, approvedBy string) error

Approve marks a draft post as approved for publishing.

func (*PostStore) Create

func (s *PostStore) Create(ctx context.Context, kind PostKind, body string, opts ...PostOption) (*Post, error)

Create persists a new post as a PostCreated event.

func (*PostStore) Delete

func (s *PostStore) Delete(ctx context.Context, postID, deletedBy string) error

Delete marks a post as deleted so it is excluded from listings and site builds.

func (*PostStore) Get

func (s *PostStore) Get(id string) *Post

Get returns a post by ID.

func (*PostStore) List

func (s *PostStore) List() []Post

List returns all posts in reverse chronological order.

func (*PostStore) Projection

func (s *PostStore) Projection() *PostProjection

Projection returns the read model for direct query access.

func (*PostStore) Projector

func (s *PostStore) Projector() fact.Projector

Projector returns the projector for registration with the event store.

func (*PostStore) Publish

func (s *PostStore) Publish(ctx context.Context, postID, url string) error

Publish marks a post as published at the given URL.

func (*PostStore) Revise

func (s *PostStore) Revise(ctx context.Context, postID, body, title, abstract string, tags []string, revisedBy string) error

Revise updates a draft post's content.

func (*PostStore) SetEventStore

func (s *PostStore) SetEventStore(es fact.EventStore)

SetEventStore replaces the backing event store. Used when the store is constructed before the event store is available.

func (*PostStore) Syndicate

func (s *PostStore) Syndicate(ctx context.Context, postID string, platform Platform, remoteID, remoteURL string) error

Syndicate records that a post was sent to a platform.

func (*PostStore) UpdateEngagement

func (s *PostStore) UpdateEngagement(ctx context.Context, postID string, platform Platform, likes, reposts, replies, views int) error

UpdateEngagement records polled metrics for a post on a platform.

type PostSyndicated

type PostSyndicated struct {
	PostID    string    `json:"post_id"`
	Platform  Platform  `json:"platform"`
	RemoteID  string    `json:"remote_id"`
	RemoteURL string    `json:"remote_url,omitempty"`
	CreatedAt time.Time `json:"created_at"`
}

PostSyndicated is emitted when a copy is sent to an external platform.

type SiteBuilder

type SiteBuilder struct {
	// contains filtered or unexported fields
}

SiteBuilder generates a static site from posts.

func NewSiteBuilder

func NewSiteBuilder(config SiteConfig) *SiteBuilder

NewSiteBuilder creates a builder with the given config.

func (*SiteBuilder) Build

func (b *SiteBuilder) Build(posts []Post, outputDir string) error

Build generates the full static site into outputDir.

type SiteConfig

type SiteConfig struct {
	Title       string
	BaseURL     string
	Author      string
	Description string
}

SiteConfig holds settings for static site generation.

type SyndicationRecord

type SyndicationRecord struct {
	PostID    string    `json:"post_id"`
	Platform  string    `json:"platform"`
	RemoteID  string    `json:"remote_id"`
	RemoteURL string    `json:"remote_url,omitempty"`
	CreatedAt time.Time `json:"created_at"`
}

SyndicationRecord tracks a post's presence on an external platform.

type ThreadsClient

type ThreadsClient struct {
	// contains filtered or unexported fields
}

ThreadsClient posts to Threads via Meta's Graph API.

func NewThreadsClient

func NewThreadsClient(config ThreadsConfig) *ThreadsClient

NewThreadsClient creates a client with the given config.

func (*ThreadsClient) Post

func (c *ThreadsClient) Post(ctx context.Context, text string) (string, error)

Post creates a text post on Threads. Returns the published media ID.

func (*ThreadsClient) PostWithImage

func (c *ThreadsClient) PostWithImage(ctx context.Context, text, imageURL string) (string, error)

PostWithImage creates a post with an attached image.

func (c *ThreadsClient) PostWithLink(ctx context.Context, text, linkURL string) (string, error)

PostWithLink creates a text post with a link appended.

func (*ThreadsClient) VerifyCredentials

func (c *ThreadsClient) VerifyCredentials(ctx context.Context) error

VerifyCredentials fetches the authenticated user's ID.

type ThreadsConfig

type ThreadsConfig struct {
	AccessToken string // long-lived OAuth token
}

ThreadsConfig holds credentials for the Threads API.

Directories

Path Synopsis
cmd
synd command

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL