// pulls my bluesky posts from my pds and writes them into the static html.
// run with `mise run sync-posts`. no dependencies, node 18+.
//
// a page opts in with a pair of marker comments; everything between them is
// replaced on every run:
//
// ... every post
// ... the newest 3
// ... the newest one as
// a card (/, /blog/)
//
// my own top-level posts and my reposts are kept, newest first; replies are
// dropped. a repost shows the original post, the way bluesky does.
//
// my pds only holds my own records, so reposted posts, quoted posts and video
// thumbnails and streams are looked up on bluesky's public api. that happens
// here, at sync time, never when a page loads. if it fails, they fall back to
// plain links.
//
// if the pds
// can't be reached, or comes back empty while the site still has posts, the
// script exits non-zero and touches nothing (--force allows the empty case).
import { readFile, writeFile } from "node:fs/promises";
const PDS = "https://pds.danieldaum.net";
const DID = "did:plc:e3z5gj5iyuwgqao562bo4bdf";
const HANDLE = "danieldaum.net";
const APPVIEW = "https://public.api.bsky.app";
const TZ = "America/Los_Angeles";
const PAGES = ["posts/index.html", "blog/index.html", "index.html"];
// ---------------------------------------------------------------------------
// data
async function fetchPosts() {
const posts = (await listRecords("app.bsky.feed.post"))
.filter((r) => typeof r.value?.text === "string" && !r.value.reply)
.map((r) => ({ uri: r.uri, rkey: rkeyOf(r.uri), ...r.value }));
const reposts = (await listRecords("app.bsky.feed.repost"))
.filter((r) => typeof r.value?.subject?.uri === "string")
.map((r) => ({ rkey: rkeyOf(r.uri), createdAt: r.value.createdAt, subjectUri: r.value.subject.uri }));
const views = await fetchViews([...posts.map((p) => p.uri), ...reposts.map((r) => r.subjectUri)]);
for (const post of posts) post.view = views.get(post.uri)?.embed;
for (const repost of reposts) repost.subject = views.get(repost.subjectUri);
return [...posts, ...reposts].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
}
async function listRecords(collection) {
const records = [];
let cursor;
do {
const url = new URL("/xrpc/com.atproto.repo.listRecords", PDS);
url.searchParams.set("repo", DID);
url.searchParams.set("collection", collection);
url.searchParams.set("limit", "100");
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url);
if (!res.ok) throw new Error(`listRecords ${collection}: ${res.status} ${res.statusText}`);
const page = await res.json();
records.push(...page.records);
cursor = page.records.length > 0 ? page.cursor : undefined;
} while (cursor);
return records;
}
function rkeyOf(uri) {
return uri.split("/").pop();
}
// the appview's hydrated copy of each post: reposted and quoted post text,
// image and video thumbnails. getPosts takes 25 uris per call
async function fetchViews(uris) {
const views = new Map();
try {
for (let i = 0; i < uris.length; i += 25) {
const url = new URL("/xrpc/app.bsky.feed.getPosts", APPVIEW);
for (const uri of uris.slice(i, i + 25)) url.searchParams.append("uris", uri);
const res = await fetch(url);
if (!res.ok) throw new Error(`getPosts: ${res.status}`);
for (const view of (await res.json()).posts) views.set(view.uri, view);
}
} catch (err) {
console.warn(`appview lookup failed, reposts, quotes and videos will be plain links (${err.message})`);
}
return views;
}
// ---------------------------------------------------------------------------
// rendering
function renderItem(item) {
return item.subjectUri ? renderRepost(item) : renderPost(item);
}
function renderPost(post) {
const permalink = `/posts/#${post.rkey}`;
const bsky = `https://bsky.app/profile/${HANDLE}/post/${post.rkey}`;
const lines = [
``,
`
`);
}
lines.push(...renderEmbed(post.embed, post.view, DID, bsky).map((l) => ` ${l}`));
lines.push(``);
return lines.join("\n");
}
// the original post inside a card. the reply link goes to the original
function renderRepost(repost) {
const permalink = `/posts/#${repost.rkey}`;
const href = postHref(repost.subjectUri);
const subject = repost.subject;
const lines = [
``,
`
`);
}
lines.push(``);
return lines.join("\n");
}
// the newest post as a card, set apart from the lists around it. links to
// the post on /posts/
function renderHighlight(item) {
const subject = item.subjectUri ? item.subject : null;
const record = item.subjectUri ? subject?.record : item;
const author = subject ? `${subject.author?.displayName || subject.author?.handle || ""}: ` : "";
const extras = attachments(record?.embed);
const text = record?.text?.trim() || extras.join(", ") || "view post";
return [
``,
` `,
` ${item.subjectUri ? "↻ reposted" : "@" + HANDLE}`,
` `,
` `,
` ${escText(author + text)}`,
extras.length ? ` ${escText(extras.join(" ยท "))}` : null,
``,
].filter((l) => l !== null).join("\n");
}
// "2 images", "video", "gif", "link", "quote"
function attachments(embed) {
switch (embed?.$type) {
case "app.bsky.embed.images": {
const n = embed.images?.length || 0;
return n ? [n === 1 ? "1 image" : `${n} images`] : [];
}
case "app.bsky.embed.video":
return ["video"];
case "app.bsky.embed.external":
return [klipyGif(embed.external?.uri) ? "gif" : "link"];
case "app.bsky.embed.record":
return ["quote"];
case "app.bsky.embed.recordWithMedia":
return [...attachments(embed.media), "quote"];
default:
return [];
}
}
// facets index the text by utf-8 byte offset, not by js string index
function renderText(text, facets = []) {
const bytes = new TextEncoder().encode(text);
const decode = (from, to) => new TextDecoder().decode(bytes.slice(from, to));
const sorted = facets
.filter((f) => f.index && f.features?.length)
.sort((a, b) => a.index.byteStart - b.index.byteStart);
let out = "";
let pos = 0;
for (const facet of sorted) {
const { byteStart, byteEnd } = facet.index;
if (byteStart < pos) continue;
out += escText(decode(pos, byteStart));
const label = escText(decode(byteStart, byteEnd));
const href = facetHref(facet.features[0]);
out += href ? `${label}` : label;
pos = byteEnd;
}
return out + escText(decode(pos));
}
function facetHref(feature) {
switch (feature.$type) {
case "app.bsky.richtext.facet#link":
return /^https?:\/\//.test(feature.uri) ? feature.uri : null;
case "app.bsky.richtext.facet#mention":
return `https://bsky.app/profile/${feature.did}`;
case "app.bsky.richtext.facet#tag":
return `https://bsky.app/hashtag/${encodeURIComponent(feature.tag)}`;
default:
return null;
}
}
// `view` is the appview's copy of the same embed, possibly missing. `did` is
// the post author's: only my own images come straight from my pds. a quote
// with media carries the media one level down, in both
function renderEmbed(embed, view, did, bsky) {
if (!embed) return [];
switch (embed.$type) {
case "app.bsky.embed.images":
return renderImages(embed.images, view?.images, did);
case "app.bsky.embed.video":
return renderVideo(embed, view, did, bsky);
case "app.bsky.embed.external":
return renderGif(embed.external, view?.external, did) ?? renderCard(embed.external);
case "app.bsky.embed.record":
return renderQuote(embed.record, view?.record);
case "app.bsky.embed.recordWithMedia":
return [
...renderEmbed(embed.media, view?.media, did, bsky),
...renderQuote(embed.record?.record, view?.record?.record),
];
default:
return [];
}
}
// one image keeps its shape; two or more go in a cropped grid, like bluesky.
// someone else's images come from bluesky's cdn, via the appview
function renderImages(images = [], views = [], did) {
const imgs = images
.map((img, i) => ({ img, src: did === DID ? img.image?.ref?.$link && blobUrl(img.image.ref.$link) : views[i]?.thumb }))
.filter(({ src }) => typeof src === "string" && /^https:\/\//.test(src))
.map(({ img, src }) => ``);
if (imgs.length === 0) return [];
const cls = imgs.length === 1 ? "post-item-media" : "post-item-media post-item-media--grid";
return [`
`, ...imgs.map((i) => ` ${i}`), `
`];
}
// my own videos play here, nothing loads until play. the pds can't serve
// range requests, which safari needs, so safari streams bluesky's hls copy
// and other browsers skip it for the file on the pds (no type: they try it
// whatever the container). someone else's video is bluesky's thumbnail,
// linking there to watch
function renderVideo(embed, view, did, bsky) {
const thumb = view?.thumbnail;
if (!thumb || !/^https:\/\//.test(thumb)) {
return [`
`];
}
const ref = embed.video?.ref?.$link;
if (did === DID && ref && /^https:\/\//.test(view.playlist || "")) {
const label = embed.alt ? ` aria-label="${esc(embed.alt)}"` : "";
return [
``,
];
}
const alt = embed.alt ? `${embed.alt} (video, plays on bluesky)` : "video, plays on bluesky";
return [
``,
` `,
``,
];
}
// a gif from bluesky's picker is a link card to klipy. like bluesky, play its
// mp4 from bluesky's proxy, so visitors never reach klipy. the card's thumb is
// the still frame shown with reduced motion. alt text is the description,
// which bluesky saves as "Alt: ..."
function renderGif(external, view, did) {
const gif = klipyGif(external?.uri);
if (!gif) return null;
const ref = external.thumb?.ref?.$link;
const still = did === DID ? ref && blobUrl(ref) : view?.thumb;
const alt = (external.description || external.title || "gif").replace(/^Alt: /, "");
const size = sizeAttrs(gif.size);
const hasStill = typeof still === "string" && /^https:\/\//.test(still);
return [
`
`,
` `,
hasStill ? ` ` : null,
`
`,
].filter((l) => l !== null);
}
// static.klipy.com/ii//.gif?hh=&ww=&mp4=
// -> k.gifs.bsky.app/ii//.mp4, the way bluesky's app builds it
function klipyGif(uri) {
let url;
try {
url = new URL(uri);
} catch {
return null;
}
const slug = url.searchParams.get("mp4");
if (url.hostname !== "static.klipy.com" || !url.pathname.startsWith("/ii/") || !/^[\w-]+$/.test(slug || "")) return null;
const src = new URL(url.pathname.replace(/[^/]+$/, `${slug}.mp4`), "https://k.gifs.bsky.app");
return { src: src.href, size: { width: Number(url.searchParams.get("ww")), height: Number(url.searchParams.get("hh")) } };
}
function sizeAttrs(ratio) {
return ratio?.width && ratio?.height ? ` width="${ratio.width}" height="${ratio.height}"` : "";
}
function renderCard(external) {
if (!external?.uri || !/^https?:\/\//.test(external.uri)) return [];
const domain = new URL(external.uri).hostname.replace(/^www\./, "");
return [
``,
` ${escText(external.title || domain)}`,
external.description ? ` ${escText(external.description)}` : null,
` ${escText(domain)}`,
``,
].filter((l) => l !== null);
}
// with the appview's copy, the quoted text shows as a card
function renderQuote(ref, view) {
const href = postHref(ref?.uri);
if (!href) return [];
const text = view?.$type === "app.bsky.embed.record#viewRecord" ? view.value?.text : null;
if (typeof text !== "string") {
return [`