@scribe-atp/core
Functions
Section titled “Functions”fetchSite
Section titled “fetchSite”function fetchSite( author: string, publicationUrl: string, signal?: AbortSignal): Promise<Site>Fetches a site record from the author’s PDS. Resolves the author handle to a DID, discovers the PDS, and returns the full Site with embedded group and article metadata.
| Parameter | Type | Description |
|---|---|---|
author |
string |
Author handle (alice.bsky.social) or DID (did:plc:…) |
publicationUrl |
string |
The site’s canonical HTTPS URL, e.g. "https://alice.bsky.social" |
signal |
AbortSignal |
Optional. Cancel the request when the signal fires |
fetchArticleBySlug
Section titled “fetchArticleBySlug”function fetchArticleBySlug( author: string, publicationUrl: string, articleSlug: string, signal?: AbortSignal): Promise<ArticleResult>Fetches a full article record and its AT URI in a single call. Resolves the site record first (using the publication URL cache if already fetched), then locates the article ref by slug and fetches the full article.
| Parameter | Type | Description |
|---|---|---|
author |
string |
Author handle or DID |
publicationUrl |
string |
The site’s canonical HTTPS URL, e.g. "https://alice.bsky.social" |
articleSlug |
string |
The article’s rkey / slug |
signal |
AbortSignal |
Optional. Cancel the request when the signal fires |
Returns { article: Article, uri: string }. The uri is the full AT URI of the site.standard.document record — pass it to @scribe-atp/social’s LikeButton component.
This is the preferred function for article pages in server-rendered frameworks. Use fetchArticle only when you don’t need the AT URI.
fetchArticle
Section titled “fetchArticle”function fetchArticle( author: string, articleSlug: string, signal?: AbortSignal): Promise<Article>Fetches a single article record (including full HTML content) directly by slug. Does not return the AT URI.
| Parameter | Type | Description |
|---|---|---|
author |
string |
Author handle or DID |
articleSlug |
string |
The article’s rkey / slug |
signal |
AbortSignal |
Optional. Cancel the request when the signal fires |
fetchProfile
Section titled “fetchProfile”function fetchProfile( handleOrDid: string, signal?: AbortSignal): Promise<Profile>Fetches a Bluesky profile — display name, avatar, bio, and related profile data — for a handle or DID. Calls the public, unauthenticated app.bsky.actor.getProfile endpoint directly; unlike fetchSite and fetchArticle, no PDS resolution step is needed since Bluesky’s AppView resolves either a handle or a DID itself.
| Parameter | Type | Description |
|---|---|---|
handleOrDid |
string |
Bluesky handle (alice.bsky.social) or DID (did:plc:…) |
signal |
AbortSignal |
Optional. Cancel the request when the signal fires |
Use this to resolve the profile of an ArticleContributor for display — e.g. rendering an author byline card per Writer-role contributor on an article, falling back to the site owner’s own profile when there are none.
const writers = article.contributors?.filter((c) => c.role === "Writer") ?? [];const profiles = writers.length > 0 ? await Promise.all(writers.map((w) => fetchProfile(w.did, signal))) : [await fetchProfile(SITE_AUTHOR, signal)];resolvePublicationUri
Section titled “resolvePublicationUri”function resolvePublicationUri( author: string, publicationUrl: string, signal?: AbortSignal): Promise<string>Resolves the AT URI of a site record given the author handle and publication URL. Results are cached for the lifetime of the module — repeated calls with the same arguments make no additional network requests.
| Parameter | Type | Description |
|---|---|---|
author |
string |
Author handle or DID |
publicationUrl |
string |
The site’s canonical HTTPS URL |
signal |
AbortSignal |
Optional. Cancel the request when the signal fires |
Returns a string AT URI, e.g. "at://did:plc:.../site.standard.publication/3mp4nd46xwr2h". Pass this to @scribe-atp/social’s SubscribeButton component.
withRetry
Section titled “withRetry”function withRetry<T>( fn: () => Promise<T>, options?: { attempts?: number; delaysMs?: number[]; signal?: AbortSignal; }): Promise<T>Retries a failing async call with backoff. Generic — wraps fetchSite, fetchArticle, fetchArticleBySlug, resolvePublicationUri, or any other async function.
| Parameter | Type | Description |
|---|---|---|
fn |
() => Promise<T> |
The call to retry, e.g. () => fetchSite(author, publicationUrl, signal) |
options.attempts |
number |
Total attempts including the first. Default 5 |
options.delaysMs |
number[] |
Delay in ms before each retry. Default [300, 600, 1200, 2400] |
options.signal |
AbortSignal |
Optional. Stops retrying immediately once aborted |
Never retries NotFoundError or an aborted signal. Retries everything else, including plain Errors. Opt-in only — see Errors and retries for the full pattern, including pairing this with a loading state in server-rendered frameworks.
listSites
Section titled “listSites”function listSites( author: string, signal?: AbortSignal): Promise<SiteRecord[]>Returns all site records for the given author. Calls com.atproto.repo.listRecords for the site.standard.publication collection and follows cursor pagination automatically.
| Parameter | Type | Description |
|---|---|---|
author |
string |
Author handle (alice.bsky.social) or DID (did:plc:…) |
signal |
AbortSignal |
Optional. Cancel the request when the signal fires |
Each SiteRecord is a Site with an additional uri field (the full AT URI of the record). Use slugFromUri(record.uri) to extract the rkey when you need to construct URLs.
listArticles
Section titled “listArticles”function listArticles( author: string, signal?: AbortSignal): Promise<ArticleRef[]>Returns all published article records for the given author as lightweight ArticleRef objects (no content field). Calls com.atproto.repo.listRecords for the site.standard.document collection and follows cursor pagination automatically.
| Parameter | Type | Description |
|---|---|---|
author |
string |
Author handle or DID |
signal |
AbortSignal |
Optional. Cancel the request when the signal fires |
Call fetchArticle to retrieve the full content for any article.
toSlug
Section titled “toSlug”function toSlug(domain: string): stringDerives a slug from a domain name by replacing . with - and removing non-alphanumeric characters.
toSlug('norobots.blog') // → "norobots-blog"toSlug('anthonycregan.co.uk') // → "anthonycregan-co-uk"toSlug is no longer needed for SDK calls — fetchSite, fetchArticleBySlug, and resolvePublicationUri now take a full HTTPS URL instead of a slug. It remains exported for any other use where slug-style strings are useful.
slugFromUri
Section titled “slugFromUri”function slugFromUri(uri: string): stringExtracts the rkey (slug) from an AT URI.
slugFromUri('at://did:plc:abc/site.standard.document/my-post') // → "my-post"flattenArticles
Section titled “flattenArticles”function flattenArticles( groups: Array<{ articles: ArticleRef[] }>): ArticleRef[]Flattens all ArticleRef objects from all groups into a single ordered array.
const allArticles = flattenArticles(site.groups);generateFeed
Section titled “generateFeed”function generateFeed(site: Site, options: FeedOptions): stringReturns a complete RSS 2.0 XML string for all published articles in the site.
FeedOptions
| Property | Type | Required | Description |
|---|---|---|---|
baseUrl |
string |
Yes | Your site’s origin, e.g. "https://alice.example.com" |
feedUrl |
string |
No | Canonical URL of the feed — used for the <atom:link> self-reference |
language |
string |
No | RSS <language> tag. Default: "en" |
limit |
number |
No | Maximum number of items to include |
See the RSS feeds guide for usage examples.
getSitemapEntries
Section titled “getSitemapEntries”function getSitemapEntries( site: Site, options: GetSitemapEntriesOptions): SitemapEntry[]Returns an array of sitemap entries for all published articles (plus the site root and group index pages).
GetSitemapEntriesOptions
| Property | Type | Required | Description |
|---|---|---|---|
baseUrl |
string |
Yes | Your site’s origin, e.g. "https://alice.example.com" |
SitemapEntry
interface SitemapEntry { url: string; lastmod?: string; // ISO 8601 date, e.g. "2024-03-15"}See the Sitemaps guide for usage examples.
generateArticleMeta
Section titled “generateArticleMeta”function generateArticleMeta(article: Article, site: Site): ScribeMetaTag[]Returns a framework-neutral array of meta tag descriptors for an article page. Covers og:type, og:title, og:url, og:site_name, og:description, og:image, twitter:card, twitter:title, twitter:description, and twitter:image.
| Parameter | Type | Description |
|---|---|---|
article |
Article |
The full article object, as returned by fetchArticleBySlug or fetchArticle |
site |
Site |
The site object, used for og:site_name and canonical URL derivation |
Pass the output to a framework adapter (articleMeta in @scribe-atp/react-router-framework, articleMetadata in @scribe-atp/next, articleSeoMeta in @scribe-atp/nuxt) or convert to your framework’s format manually.
Also includes a { "script:ld+json": ... } tag generated internally via generateArticleJsonLd — you get JSON-LD structured data for free without calling it yourself. Use generateArticleJsonLd directly only if your framework’s metadata API has no room for a raw <script> tag (e.g. Next.js’s Metadata object) — see below.
See the Open Graph meta tags guide for framework-specific usage.
generateSiteMeta
Section titled “generateSiteMeta”function generateSiteMeta(site: Site): ScribeMetaTag[]Returns meta tag descriptors for a site index or group page — covers title, og:type (website), and optionally description and splash image.
| Parameter | Type | Description |
|---|---|---|
site |
Site |
The site object |
buildCanonicalUrl
Section titled “buildCanonicalUrl”function buildCanonicalUrl(article: Article, site: Site): stringDerives the fully-qualified canonical URL for an article. Uses article.canonicalUrl if set; otherwise combines site.url (the site’s domain, e.g. "myblog.com"), site.urlPrefix, and article.path to produce an https:// URL.
buildCanonicalUrl(article, site);// → "https://alice.bsky.social/blog/my-first-post"buildSiteUrl
Section titled “buildSiteUrl”function buildSiteUrl(site: Site): stringDerives the fully-qualified URL for a site’s index page from site.url and site.urlPrefix.
| Parameter | Type | Description |
|---|---|---|
site |
Site |
The site object |
buildSiteUrl(site);// → "https://alice.bsky.social/blog"generateArticleJsonLd
Section titled “generateArticleJsonLd”function generateArticleJsonLd(article: Article, site: Site): JsonLdObjectReturns a schema.org BlogPosting object as a plain, JSON-serializable value — headline, publish/modified dates, author, publisher, and (when present) description, image, and keywords. generateArticleMeta already calls this internally and includes the result as a script:ld+json tag, so most consumers never need to call it directly.
| Parameter | Type | Description |
|---|---|---|
article |
Article |
The full article object |
site |
Site |
The site object |
Call this directly when your framework’s metadata API has no place for a raw <script> tag and you need to render the JSON-LD <script type="application/ld+json"> element yourself. @scribe-atp/next’s adapter needs this for exactly that reason — but note that generateArticleJsonLd isn’t currently re-exported from @scribe-atp/next itself, so import it from @scribe-atp/core directly even in a Next.js project:
import { generateArticleJsonLd } from '@scribe-atp/core';
// in your page component<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(generateArticleJsonLd(article, site)) }}/>generateSiteJsonLd
Section titled “generateSiteJsonLd”function generateSiteJsonLd(site: Site): JsonLdObjectReturns a schema.org WebSite object — name, URL, and description if present. Same standalone-use case as generateArticleJsonLd above; not called automatically by generateSiteMeta’s WebSite type (only articles get automatic JSON-LD via generateArticleMeta).
| Parameter | Type | Description |
|---|---|---|
site |
Site |
The site object |
Writing content
Section titled “Writing content”Every function above is a read — no authentication required. crossPostToBluesky is the one exception: it writes a record to the AT Protocol, so it needs an authenticated agent.
crossPostToBluesky
Section titled “crossPostToBluesky”function crossPostToBluesky( agent: AtpAgentLike, params: CrossPostParams): Promise<StrongRef>Creates an app.bsky.feed.post with a rich link-card embed pointing at the article, plus associatedRefs linking the post back to the underlying site.standard.document and site.standard.publication records. This is what produces the Bluesky embed card and lets the bskyPostRef field be populated on the article record.
| Parameter | Type | Description |
|---|---|---|
agent |
AtpAgentLike |
An authenticated AT Protocol agent exposing com.atproto.repo.createRecord — e.g. an @atproto/api Agent instance with a valid OAuth session for the author’s own account |
params |
CrossPostParams |
See below |
Returns a StrongRef ({ uri, cid }) pointing at the newly created post — write this to the article’s bskyPostRef field yourself if you want readers of your own tooling to see the cross-post link.
This is a low-level primitive for building your own publishing tool against the SDK — Scribe CMS already calls the equivalent logic server-side when an author uses its own “Share to Bluesky” action. Most SDK consumers (sites that only display Scribe content) will never call this.
import { crossPostToBluesky } from '@scribe-atp/core';
const ref = await crossPostToBluesky(agent, { did: authorDid, documentUri: article.uri, documentCid: article.cid, publicationUri: site.uri, publicationCid: site.cid, canonicalUrl: buildCanonicalUrl(article, site), title: article.title, text: `New post: ${article.title}`, description: article.description,});// ref → { uri: "at://did:plc:.../app.bsky.feed.post/3mp...", cid: "..." }Errors
Section titled “Errors”NotFoundError
Section titled “NotFoundError”class NotFoundError extends Error {}Thrown when a fetch succeeded but the site, publication, or article genuinely doesn’t exist. Retrying will not help — treat it the same as an HTTP 404.
PdsFetchError
Section titled “PdsFetchError”class PdsFetchError extends Error { constructor(message: string, options?: { cause?: unknown });}Thrown when a request reached the PDS but it responded with a non-ok HTTP status. The service is up — this specific operation failed. Safe to retry — see withRetry and the Errors and retries guide.
PdsUnreachableError
Section titled “PdsUnreachableError”class PdsUnreachableError extends PdsFetchError { constructor(message: string, options?: { cause?: unknown });}Thrown when a request never got a response at all — DNS failure, connection refused, timeout. Extends PdsFetchError, so an instanceof PdsFetchError check matches this too; check instanceof PdsUnreachableError first if your UI distinguishes “this record had trouble loading” from “the service is down.” Safe to retry.
interface Site { title: string; url: string; // Domain without protocol, e.g. "alice.bsky.social" urlPrefix: string; // Path prefix, e.g. "blog" — empty string if content is at root description?: string; splashImageUrl?: string; logoImageUrl?: string; groups: SiteGroup[]; ungroupedArticles: ArticleRef[]; // legacy — always empty for current content, see Core Concepts}SiteGroup
Section titled “SiteGroup”interface SiteGroup { slug: string; title: string; articles: ArticleRef[];}ArticleRef
Section titled “ArticleRef”A lightweight snapshot of article metadata, embedded in the site record to avoid N+1 fetch patterns. Does not include content.
interface ArticleRef { uri: string; // AT URI, e.g. "at://did:plc:…/site.standard.document/my-post" title: string; slug?: string; // Article slug / rkey splashImageUrl: string | null; description?: string | null; tags?: string[]; createdAt: string; // ISO 8601 publishedAt?: string; // ISO 8601 updatedAt?: string; // ISO 8601}Article
Section titled “Article”The full article record, including HTML content. Returned by fetchArticle.
interface Article { title: string; content: string; // Sanitised HTML — safe to render directly textContent?: string; // Plain-text version of content (HTML tags stripped) path: string; // e.g. "/creative-writing/my-post" site: string; // AT URI of the publication, e.g. "at://did:plc:…/site.standard.publication/3abc" canonicalUrl?: string; // Fully-qualified article URL, e.g. "https://myblog.com/blog/my-article" coverImageUrl?: string; // URL of the cover/splash image description?: string; tags?: string[]; contributors?: ArticleContributor[]; bskyPostRef?: { uri: string; cid: string }; // Bluesky post reference if the article was cross-posted createdAt?: string; // ISO 8601 publishedAt: string; // ISO 8601 updatedAt: string; // ISO 8601}ArticleContributor
Section titled “ArticleContributor”Additional credited contributors on an article’s byline — added manually per-article from the article editor (e.g. crediting an editor or illustrator alongside the author). Distinct from Scribe CMS’s separate site-level Contributor feature (an author invited to submit articles to someone else’s site) — this array is not populated by that flow. An empty array is written on first publish if no contributors were added.
interface ArticleContributor { did: string; // AT Protocol DID of the contributor role?: string; // e.g. "author", "editor", "illustrator" displayName?: string; // Human-readable name}Profile
Section titled “Profile”Returned by fetchProfile. Mirrors Bluesky’s app.bsky.actor.defs#profileViewDetailed shape.
interface Profile { did: string; handle: string; displayName?: string; description?: string; avatar?: string; banner?: string; pronouns?: string; website?: string; followersCount?: number; followsCount?: number; postsCount?: number; createdAt?: string; // ISO 8601 associated?: ProfileAssociated; pinnedPost?: { uri: string; cid: string }; verification?: ProfileVerification; status?: ProfileStatus;}ProfileAssociated
Section titled “ProfileAssociated”interface ProfileAssociated { lists?: number; feedgens?: number; starterPacks?: number; labeler?: boolean;}ProfileVerification
Section titled “ProfileVerification”interface ProfileVerification { verifiedStatus: string; trustedVerifierStatus: string; verifications: Array<{ issuer: string; uri: string; isValid: boolean; createdAt: string; }>;}ProfileStatus
Section titled “ProfileStatus”interface ProfileStatus { status: string; expiresAt?: string; isActive?: boolean; isDisabled?: boolean;}SiteRecord
Section titled “SiteRecord”A Site with the AT URI of its underlying record included. Returned by listSites.
interface SiteRecord extends Site { uri: string; // AT URI, e.g. "at://did:plc:…/site.standard.publication/3mp4nd46xwr2h"}Use slugFromUri(record.uri) to extract the rkey when constructing URLs.
ArticleResult
Section titled “ArticleResult”Returned by fetchArticleBySlug. Contains the full article and its AT URI.
interface ArticleResult { article: Article; uri: string; // AT URI, e.g. "at://did:plc:…/site.standard.document/3abc123"}ScribeMetaTag
Section titled “ScribeMetaTag”The output element type of generateArticleMeta and generateSiteMeta. A union of five tag shapes:
type ScribeMetaTag = | { title: string } | { name: string; content: string } | { property: string; content: string } | { tagName: "link"; rel: string; href: string } | { "script:ld+json": JsonLdObject };Framework adapters convert this to their own meta format. Consume it directly only if you’re writing your own adapter or using a framework not covered by the existing packages. The script:ld+json variant is how generateArticleMeta delivers the JSON-LD structured data described under generateArticleJsonLd above.
JsonLdObject
Section titled “JsonLdObject”A plain, JSON-serializable object — matches the shape consuming frameworks’ own JSON-LD types expect (e.g. React Router’s LdJsonObject) without this package depending on any framework’s types directly.
type JsonLdObject = { [key: string]: string | number | boolean | null | JsonLdObject | JsonLdObject[];};CrossPostParams
Section titled “CrossPostParams”Input to crossPostToBluesky.
interface CrossPostParams { did: string; // The author's own DID — the repo the post is written to documentUri: string; // AT URI of the site.standard.document being cross-posted documentCid: string; publicationUri: string; // AT URI of the owning site.standard.publication publicationCid: string; canonicalUrl: string; // The article's canonical URL — becomes the link-card target title: string; text: string; // The post's own text content description?: string; thumbBlob?: unknown; // A pre-uploaded blob ref for the link-card thumbnail, if any}StrongRef
Section titled “StrongRef”An AT Protocol content-addressed reference — a URI plus the CID pinning it to a specific version of the record. Returned by crossPostToBluesky; also the shape of Article.bskyPostRef.
interface StrongRef { uri: string; cid: string;}