UNPKG

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
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 = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }; 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 };