astro-loader-bluesky-posts
Version:
Astro loader for loading Bluesky posts and threads using post URLs or AT-URIs.
652 lines (648 loc) • 20.2 kB
JavaScript
import { RichText, AtpAgent, AppBskyFeedDefs } from '@atproto/api';
import { z } from 'astro/zod';
// src/index.ts
var defaultConfig = {
urlTextType: "post-text",
newlineHandling: "none",
fetchThread: false,
threadDepth: 1,
threadParentHeight: 1,
fetchOnlyAuthorReplies: false
};
var BlueskyPostsLoaderConfigSchema = z.object({
/**
* List of Bluesky post URLs or {@link https://atproto.com/specs/at-uri-scheme AT-URIs}.
*/
uris: z.array(z.string()),
/**
* The type of text to display for links when generating renderable HTML:
* - `'domain-path'`: Shows the link's domain and path.
* - `'post-text'`: Uses the link text as shown in the post.
*
* @default 'post-text'
*/
linkTextType: z.enum(["domain-path", "post-text"]).default(defaultConfig.urlTextType),
/**
* The way for processing `\n` when generating renderable HTML:
* - `'none'`: Keep as is.
* - `'break'`: Replace consecutive `\n` with `<br>`.
* - `'paragraph'`: Wrap paragraphs with `<p>` while removing standalone `\n`.
*
* @default 'none'
*/
newlineHandling: z.enum(["none", "break", "paragraph"]).default(defaultConfig.newlineHandling),
/**
* Whether to fetch the post's thread including replies and parents.
*
* @default false
*/
fetchThread: z.boolean().default(defaultConfig.fetchThread),
/**
* The depth of the descendant post tree to fetch if fetching the thread.
* Specifies how many levels of reply depth should be included.
*
* @default 1
*/
threadDepth: z.number().min(0).max(1e3).default(defaultConfig.threadDepth),
/**
* The height of the ancestor post tree to fetch if fetching the thread.
* Specifies how many levels of parent posts should be included.
*
* @default 1
*/
threadParentHeight: z.number().min(0).max(1e3).default(defaultConfig.threadParentHeight),
/**
* Fetches only the post author's replies at the specified `threadDepth`.
*
* - `false` (default): Includes all replies without filtering.
* - `true`: Returns only the author's replies as a flat array, ignoring `threadParentHeight`,
* with no `parent` field.
*
* @default false
*/
fetchOnlyAuthorReplies: z.boolean().default(defaultConfig.fetchOnlyAuthorReplies)
});
var EmbedVideoSchema = z.object({
$type: z.literal("app.bsky.embed.video"),
alt: z.string().optional(),
aspectRatio: z.object({
height: z.number(),
width: z.number()
}).optional(),
video: z.object({
$type: z.literal("blob"),
ref: z.object({ $link: z.string() }),
mimeType: z.string(),
size: z.number()
})
});
var EmbedVideoViewSchema = z.object({
$type: z.literal("app.bsky.embed.video#view"),
cid: z.string(),
playlist: z.string(),
thumbnail: z.string().optional(),
alt: z.string().optional(),
aspectRatio: z.object({
width: z.number(),
height: z.number()
}).optional()
});
var EmbedExternalSchema = z.object({
$type: z.literal("app.bsky.embed.external"),
external: z.object({
uri: z.string().url(),
title: z.string().optional(),
description: z.string().optional(),
thumb: z.object({
$type: z.literal("blob"),
ref: z.object({ $link: z.string() }),
mimeType: z.string(),
size: z.number()
}).optional()
})
});
var EmbedExternalViewSchema = z.object({
$type: z.literal("app.bsky.embed.external#view"),
external: z.object({
uri: z.string().url(),
title: z.string().optional(),
description: z.string().optional(),
thumb: z.string().url().optional()
})
});
var EmbedRecordSchema = z.object({
$type: z.literal("app.bsky.embed.record"),
record: z.object({
uri: z.string(),
cid: z.string()
})
});
var EmbedImagesSchema = z.object({
$type: z.literal("app.bsky.embed.images"),
images: z.array(
z.object({
alt: z.string(),
aspectRatio: z.object({
height: z.number(),
width: z.number()
}).optional(),
image: z.object({
$type: z.literal("blob"),
ref: z.object({ $link: z.string() }),
mimeType: z.string(),
size: z.number()
})
})
)
});
var EmbedImagesViewSchema = z.object({
$type: z.literal("app.bsky.embed.images#view"),
images: z.array(
z.object({
thumb: z.string().url(),
fullsize: z.string().url(),
alt: z.string().optional(),
aspectRatio: z.object({
width: z.number(),
height: z.number()
}).optional()
})
)
});
var EmbedListSchema = z.object({
$type: z.literal("app.bsky.embed.list"),
list: z.object({
title: z.string(),
items: z.array(
z.object({
uri: z.string(),
label: z.string()
})
)
})
});
var EmbedListViewSchema = z.object({
$type: z.literal("app.bsky.embed.list#view"),
list: z.object({
title: z.string(),
items: z.array(
z.object({
uri: z.string(),
label: z.string()
})
)
})
});
var EmbedStarterPackSchema = z.object({
$type: z.literal("app.bsky.embed.starterPack"),
starterPack: z.object({
title: z.string(),
description: z.string().optional(),
members: z.array(
z.object({
did: z.string(),
handle: z.string(),
displayName: z.string().optional(),
avatar: z.string().url().optional()
})
)
})
});
var EmbedRecordViewSchema = z.object({
$type: z.literal("app.bsky.embed.record#view"),
record: z.object({
uri: z.string(),
cid: z.string(),
author: z.object({
did: z.string(),
handle: z.string(),
displayName: z.string().optional(),
avatar: z.string().url().optional()
}),
value: z.object({
$type: z.string(),
createdAt: z.string().datetime(),
text: z.string().optional()
}),
replyCount: z.number().optional(),
repostCount: z.number().optional(),
likeCount: z.number().optional(),
quoteCount: z.number().optional(),
indexedAt: z.string()
})
});
var EmbedRecordWithMediaSchema = z.object({
$type: z.literal("app.bsky.embed.recordWithMedia"),
record: z.object({
uri: z.string(),
cid: z.string(),
author: z.object({
did: z.string(),
handle: z.string(),
displayName: z.string().optional(),
avatar: z.string().url().optional()
}),
value: z.object({
$type: z.string(),
createdAt: z.string().datetime(),
text: z.string().optional()
})
}),
media: z.union([EmbedExternalSchema, EmbedImagesSchema, EmbedVideoSchema])
});
var EmbedRecordWithMediaViewSchema = z.object({
$type: z.literal("app.bsky.embed.recordWithMedia#view"),
record: z.object({
uri: z.string(),
cid: z.string(),
author: z.object({
did: z.string(),
handle: z.string(),
displayName: z.string().optional(),
avatar: z.string().url().optional()
}),
value: z.object({
$type: z.string(),
createdAt: z.string().datetime(),
text: z.string().optional()
})
}),
media: z.union([
EmbedExternalViewSchema,
EmbedImagesViewSchema,
EmbedVideoViewSchema
])
});
var EmbedStarterPackViewSchema = z.object({
$type: z.literal("app.bsky.embed.starterPack#view"),
starterPack: z.object({
title: z.string(),
description: z.string().optional(),
members: z.array(
z.object({
did: z.string(),
handle: z.string(),
displayName: z.string().optional(),
avatar: z.string().url().optional()
})
)
})
});
var UnknownEmbedSchema = z.object({
$type: z.string()
}).catchall(z.unknown());
var EmbedSchema = z.union([
EmbedImagesSchema,
EmbedImagesViewSchema,
EmbedVideoSchema,
EmbedVideoViewSchema,
EmbedExternalSchema,
EmbedExternalViewSchema,
EmbedRecordSchema,
EmbedRecordWithMediaSchema,
EmbedRecordViewSchema,
EmbedRecordWithMediaViewSchema,
EmbedListSchema,
EmbedListViewSchema,
EmbedStarterPackSchema,
EmbedStarterPackViewSchema,
UnknownEmbedSchema
]);
var PostSchema = z.object({
uri: z.string(),
cid: z.string(),
author: z.object({
did: z.string(),
handle: z.string(),
displayName: z.string().optional(),
avatar: z.string().url().optional(),
associated: z.object({
chat: z.object({
allowIncoming: z.enum(["following", "all", "none"]).optional()
}).optional()
}).optional(),
labels: z.array(z.unknown()).optional(),
createdAt: z.string().datetime().optional()
}),
record: z.object({
$type: z.literal("app.bsky.feed.post"),
createdAt: z.string().datetime(),
langs: z.array(z.string()).optional(),
text: z.string().optional(),
reply: z.object({
parent: z.object({
cid: z.string(),
uri: z.string()
}),
root: z.object({
cid: z.string(),
uri: z.string()
})
}).optional(),
embed: EmbedSchema.optional(),
facets: z.array(
z.object({
index: z.object({
byteStart: z.number().nonnegative(),
byteEnd: z.number().nonnegative()
}),
features: z.array(
z.union([
z.object({
$type: z.literal("app.bsky.richtext.facet#mention"),
did: z.string()
}),
z.object({
$type: z.literal("app.bsky.richtext.facet#link"),
uri: z.string().url()
}),
z.object({
$type: z.literal("app.bsky.richtext.facet#tag"),
tag: z.string()
}),
z.object({}).catchall(z.unknown())
])
)
})
).optional()
}).catchall(z.unknown()),
embed: EmbedSchema.optional(),
replyCount: z.number().nonnegative(),
repostCount: z.number().nonnegative(),
likeCount: z.number().nonnegative(),
quoteCount: z.number().nonnegative(),
indexedAt: z.string().datetime(),
labels: z.array(z.unknown()).optional()
});
var PostViewExtendedSchema = PostSchema.extend({
link: z.string(),
html: z.string()
});
var NotFoundPostSchema = z.object({
$type: z.literal("app.bsky.feed.defs#notFoundPost").optional(),
uri: z.string(),
notFound: z.literal(true)
});
var BlockedPostSchema = z.object({
$type: z.literal("app.bsky.feed.defs#blockedPost").optional(),
uri: z.string(),
blocked: z.literal(true)
});
var PostWithThreadViewSchema = z.object({
uri: z.string(),
$type: z.literal("app.bsky.feed.defs#threadViewPost").optional(),
post: PostViewExtendedSchema,
parent: z.union([
z.lazy(() => ThreadViewPostSchema),
NotFoundPostSchema,
BlockedPostSchema
]).optional(),
replies: z.array(
z.union([
z.lazy(() => ThreadViewPostSchema),
NotFoundPostSchema,
BlockedPostSchema
])
).optional()
});
var ThreadViewPostSchema = z.object({
$type: z.literal("app.bsky.feed.defs#threadViewPost").optional(),
post: PostSchema,
parent: z.union([
z.lazy(() => ThreadViewPostSchema),
NotFoundPostSchema,
BlockedPostSchema
]).optional(),
replies: z.array(
z.union([
z.lazy(() => ThreadViewPostSchema),
NotFoundPostSchema,
BlockedPostSchema
])
).optional()
});
var PostWithThreadViewExtendedSchema = PostWithThreadViewSchema.extend({
replies: z.union([
z.array(
z.union([
z.lazy(() => ThreadViewPostSchema),
NotFoundPostSchema,
BlockedPostSchema
])
),
z.array(PostViewExtendedSchema)
]).optional()
});
var PostWithOnlyAuthorRepliesExtendedSchema = PostWithThreadViewSchema.omit({
parent: true
}).extend({
replies: z.array(PostViewExtendedSchema).optional()
});
async function getAtUri(uri, agent) {
try {
const parts = uri.startsWith("at:") ? uri.split("/") : new URL(uri).pathname.split("/");
let id = parts[2];
const postId = parts[4];
if (!id.startsWith("did:")) {
const handleResolution = await agent.resolveHandle({ handle: id });
id = handleResolution.data.did;
}
return `at://${id}/app.bsky.feed.post/${postId}`;
} catch {
throw new Error(`Invalid post URL: ${uri}.`);
}
}
function escapeHTML(str) {
const escapeMap = {
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'"
};
return str?.replace(/[&<>"']/g, (match) => escapeMap[match] || match) ?? "";
}
function getDomainAndPath(url) {
if (url) {
const parsedUrl = new URL(url);
return `${parsedUrl.hostname}${parsedUrl.pathname}`;
}
return "";
}
function renderPostAsHtml(richText, options) {
const { linkTextType, newlineHandling } = options;
const rt = new RichText(richText);
let html = "";
for (const segment of rt.segments()) {
if (segment.isLink()) {
html += `<a href="${escapeHTML(segment.link?.uri)}">${escapeHTML(linkTextType === "post-text" ? segment.text : getDomainAndPath(segment.link?.uri))}</a>`;
} else if (segment.isMention()) {
html += `<a href="https://bsky.app/profile/${escapeHTML(segment.mention?.did)}">${escapeHTML(segment.text)}</a>`;
} else if (segment.isTag()) {
html += `<a href="https://bsky.app/hastag/${escapeHTML(segment.tag?.tag)}">#${escapeHTML(segment.tag?.tag)}</a>`;
} else {
html += escapeHTML(segment.text);
}
}
if (newlineHandling !== "none") {
if (newlineHandling === "break") {
html = html.replace(/\n+/g, "<br/ >\n");
} else if (newlineHandling === "paragraph") {
html = html.split("\n").map((line) => {
const l = line.trim();
if (l.length > 0) return `<p>${l}</p>`;
return line;
}).join("");
}
}
return html;
}
function atUriToPostUri(atUri) {
const [, , did, , postId] = atUri.split("/");
return `https://bsky.app/profile/${did}/post/${postId}`;
}
function getOnlyAuthorReplies(replies, depth, authorDid) {
if (!replies || replies.length === 0 || depth <= 0) return [];
const filtered = replies.filter(AppBskyFeedDefs.isThreadViewPost).filter((item) => item.post.author.did === authorDid);
return filtered.reduce((acc, item) => {
acc.push(item.post);
const childPosts = getOnlyAuthorReplies(item.replies, depth - 1, authorDid);
acc.push(...childPosts);
return acc;
}, []);
}
// src/index.ts
function blueskyPostsLoader(userConfig) {
return {
name: "astro-loader-bluesky-posts",
schema: userConfig.fetchThread ? userConfig.fetchOnlyAuthorReplies ? PostWithOnlyAuthorRepliesExtendedSchema : PostWithThreadViewExtendedSchema : PostViewExtendedSchema,
async load({ logger, store, parseData, generateDigest, meta }) {
const parsedConfig = BlueskyPostsLoaderConfigSchema.safeParse(userConfig);
if (!parsedConfig.success) {
logger.error(
`The configuration provided is invalid. ${parsedConfig.error.issues.map((issue) => issue.message).join("\n")}. Check out the configuration: https://github.com/lin-stephanie/astro-loaders/blob/main/packages/astro-loader-bluesky-posts/README.md#configuration.`
);
return;
}
const {
uris,
fetchThread,
threadDepth,
threadParentHeight,
fetchOnlyAuthorReplies,
...renderPostAsHtmlConfig
} = parsedConfig.data;
if (uris.length === 0) {
logger.warn("No AT-URIs provided and no posts will be loaded");
return;
}
const preConfig = meta.get("config");
const configDigest = generateDigest(JSON.stringify(parsedConfig.data));
if (preConfig && preConfig === configDigest) {
logger.info("Configuration unchanged, skipping");
return;
}
try {
const agent = new AtpAgent({ service: "https://public.api.bsky.app" });
const atUris = await Promise.all(
uris.map((uri) => getAtUri(uri, agent))
);
if (!fetchThread) {
logger.info(`Loading ${atUris.length} posts`);
const chunkSize = 25;
const allPosts = [];
for (let i = 0; i < atUris.length; i += chunkSize) {
const chunk = atUris.slice(i, i + chunkSize);
const getPostsRes = await agent.getPosts({ uris: chunk });
if (getPostsRes.success) {
allPosts.push(...getPostsRes.data.posts);
} else {
throw new Error(
`Loading posts ${i + 1} to ${i + chunk.length} encountered unknown errors`
);
}
}
for (const item of allPosts) {
const link = atUriToPostUri(item.uri);
const html = renderPostAsHtml(
item.record,
renderPostAsHtmlConfig
);
const parsedItem = await parseData({
id: item.uri,
// convert `item` to a pure POJO by stripping non-serializable, non-enumerable, and inherited properties,
// preventing serialization errors 'Cannot stringify arbitrary non-POJOs' caused by devalue library.
data: JSON.parse(JSON.stringify({ ...item, link, html }))
});
store.set({
id: item.uri,
data: parsedItem,
digest: generateDigest(parsedItem),
rendered: { html }
});
}
logger.info("Successfully loaded all posts");
} else {
let count = atUris.length;
logger.info(
`Loading ${count} posts and ${fetchOnlyAuthorReplies ? "direct replies" : "threads"}`
);
for (const uri of atUris) {
const getPostThreadRes = await agent.getPostThread({
uri,
depth: threadDepth,
parentHeight: fetchOnlyAuthorReplies ? 0 : threadParentHeight
});
if (getPostThreadRes.success) {
const {
data: { thread }
} = getPostThreadRes;
if (AppBskyFeedDefs.isNotFoundPost(thread)) {
logger.warn(`Post with '${uri}' not found`);
count--;
continue;
}
if (AppBskyFeedDefs.isBlockedPost(thread)) {
logger.warn(`Post with '${uri}' is blocked`);
count--;
continue;
}
if (AppBskyFeedDefs.isThreadViewPost(thread)) {
const post = thread.post;
const replies = thread.replies;
const link = atUriToPostUri(post.uri);
const html = renderPostAsHtml(
post.record,
renderPostAsHtmlConfig
);
const data = JSON.parse(
JSON.stringify({
uri: post.uri,
post: { ...post, link, html },
replies: fetchOnlyAuthorReplies ? getOnlyAuthorReplies(
replies,
threadDepth,
post.author.did
).map((item) => ({
...item,
link: atUriToPostUri(item.uri),
html: renderPostAsHtml(
item.record,
renderPostAsHtmlConfig
)
})) : replies,
...fetchOnlyAuthorReplies ? {} : { parent: thread.parent }
})
);
const parsedDate = await parseData({
id: post.uri,
data
});
store.set({
id: parsedDate.uri,
data: parsedDate,
digest: generateDigest(parsedDate),
rendered: { html }
});
}
} else {
logger.warn(`Post with '${uri}' load failed`);
count--;
}
}
logger.info(
`Successfully loaded ${count === atUris.length ? "all" : `${count}`} posts`
);
}
meta.set("config", configDigest);
} catch (error) {
if (error instanceof Error) {
logger.error(error.stack ?? error.message);
} else {
logger.error("Failed to load posts.");
}
}
}
};
}
export { atUriToPostUri, blueskyPostsLoader, renderPostAsHtml };