Improving Bluesky Syndication: Notes, Images, and State
The first version of the Bluesky syndication script posted articles. It worked, but several things needed fixing: notes weren’t syndicated, large images hit Bluesky’s blob size limit, same-day articles collapsed into one visible post, and the state file didn’t store the Bluesky post URL — making it impossible to link back to the syndicated copy.
Multiple content sources
The initial script targeted a single RSS feed. The refactored version uses a SOURCES array that maps each content type to its feed and a function that produces the post text:
const SOURCES = [
{
rssUrl: "https://adrian-altner.de/rss/articles.xml",
label: "article",
postText: (item) => item.title,
},
{
rssUrl: "https://adrian-altner.de/rss/notes.xml",
label: "note",
postText: (item) => item.description || item.title,
},
];
Articles post with their title. Notes post with their description — since notes don’t have long titles that stand alone, the description serves as the post body.
Adding a new content type later means appending one object to this array.
State file: from array to record
The original state file was a flat array of GUIDs:
["https://adrian-altner.de/articles/...", "..."]
This worked for tracking what had been posted, but threw away the Bluesky post URL. The new format is an object mapping each GUID to its Bluesky post URL:
{
"https://adrian-altner.de/articles/2026/03/23/joining-the-indieweb/": "https://bsky.app/profile/adrian-altner.de/post/3mhqb4t6ceu2w"
}
After a successful post, the script extracts the post key (rkey) from the AT Protocol URI and constructs the Bluesky URL:
const { uri } = await postRes.json();
const rkey = uri.split("/").pop();
posted[item.guid] = `https://bsky.app/profile/${IDENTIFIER}/post/${rkey}`;
This URL is now available to other parts of the build system — specifically the syndication links on each article and note page.
Image compression with Sharp
Bluesky’s blob upload limit is 976 KB. The OG image for a photo-heavy note came in at 1.56 MB and failed with BlobTooLarge. The fix is to compress the image before uploading:
import sharp from "sharp";
const compressed = await sharp(Buffer.from(rawBuffer))
.resize(1200, 630, { fit: "cover", position: "centre" })
.jpeg({ quality: 80 })
.toBuffer();
The image is resized to 1200×630 (the standard OG image ratio) and re-encoded as JPEG at 80% quality. The result is consistently under 200 KB — well within the limit — with no visible quality loss at Bluesky’s display size.
Publish dates, not deploy dates
The first version used new Date().toISOString() for createdAt. This placed everything at the deploy timestamp, so a batch of articles published weeks apart all appeared as posted today.
The fix is to read pubDate from the RSS item:
const baseDate = item.pubDate ? new Date(item.pubDate) : new Date();
const createdAt = new Date(baseDate.getTime() + i * 1000).toISOString();
The index offset (+ i * 1000) adds one second per item. Without it, articles sharing the same publish date get identical createdAt values, and Bluesky deduplicates them — showing only one post in the feed. The offset keeps them distinct while preserving the original date.
Persisting state after syndication
The state file lives in the repository root and is committed after each syndication run. The deploy script checks whether the file changed and commits it if so:
if ! git diff --quiet .bluesky-posted.json; then
git add .bluesky-posted.json
git commit -m "chore: update bluesky posted state"
git push
fi
Without this, the state file would reset on the next checkout and every article would be re-posted. With it, the file is always current and in sync with what’s actually on Bluesky.