a collection of lightweight TypeScript packages for AT Protocol, the protocol powering Bluesky
atproto bluesky typescript npm

@atcute/bluesky-richtext-builder#

fluent builder for constructing Bluesky rich text with facets.

npm install @atcute/bluesky-richtext-builder

Bluesky posts support rich text with mentions, links, and hashtags. these are represented as "facets" - byte ranges with attached features. this package provides a builder that handles the byte offset calculations for you.

why use a builder?#

Bluesky facets use byte offsets, not character positions. this matters for non-ASCII text:

// "café" is 5 bytes in UTF-8 (c=1, a=1, f=1, é=2)
const text = 'café ☕';

// the coffee emoji starts at byte 5, not character 5

the builder handles these calculations automatically, so you don't have to think about byte offsets.

usage#

basic usage#

import RichtextBuilder from '@atcute/bluesky-richtext-builder';

const rt = new RichtextBuilder()
	.addText('hello, ')
	.addMention('@alice', 'did:plc:abc123')
	.addText('! check out ')
	.addLink('my website', 'https://example.com');

console.log(rt.text);
// -> "hello, @alice! check out my website"

console.log(rt.facets);
// -> [
//   { index: { byteStart: 7, byteEnd: 13 }, features: [{ $type: 'app.bsky.richtext.facet#mention', did: '...' }] },
//   { index: { byteStart: 25, byteEnd: 35 }, features: [{ $type: 'app.bsky.richtext.facet#link', uri: '...' }] }
// ]

creating a post#

use with @atcute/client to create a post:

import { Client, CredentialManager, ok } from '@atcute/client';
import RichtextBuilder from '@atcute/bluesky-richtext-builder';

import type {} from '@atcute/bluesky';

const manager = new CredentialManager({ service: 'https://bsky.social' });
const rpc = new Client({ handler: manager });

await manager.login({ identifier: 'you.bsky.social', password: 'your-app-password' });

const rt = new RichtextBuilder()
	.addText('hello ')
	.addMention('@bsky.app', 'did:plc:z72i7hdynmk6r22z27h6tvur')
	.addText('! ')
	.addTag('atproto');

await ok(
	rpc.post('com.atproto.repo.createRecord', {
		input: {
			repo: manager.session!.did,
			collection: 'app.bsky.feed.post',
			record: {
				$type: 'app.bsky.feed.post',
				text: rt.text,
				facets: rt.facets,
				createdAt: new Date().toISOString(),
			},
		},
	}),
);
const rt = new RichtextBuilder()
	.addText('read the ')
	.addLink('documentation', 'https://atproto.com/docs')
	.addText(' for more info');

the link text can be anything - it doesn't have to be the URL:

const rt = new RichtextBuilder().addLink('click here', 'https://example.com');

adding mentions#

mentions require both the display text and the user's DID:

const rt = new RichtextBuilder().addMention('@alice.bsky.social', 'did:plc:abc123');

you'll typically resolve the handle to a DID first:

import {
	CompositeHandleResolver,
	DohJsonHandleResolver,
	WellKnownHandleResolver,
} from '@atcute/identity-resolver';

const handleResolver = new CompositeHandleResolver({
	methods: {
		dns: new DohJsonHandleResolver({ dohUrl: 'https://mozilla.cloudflare-dns.com/dns-query' }),
		http: new WellKnownHandleResolver(),
	},
});

const handle = 'alice.bsky.social';
const did = await handleResolver.resolve(handle);

const rt = new RichtextBuilder().addMention(`@${handle}`, did);

adding hashtags#

hashtags are added without the # prefix - it's added automatically:

const rt = new RichtextBuilder().addText('loving ').addTag('atproto').addText(' development!');

// text: "loving #atproto development!"

custom facet features#

use addDecoratedText() for custom facet features:

import type { FacetFeature } from '@atcute/bluesky-richtext-builder';

const feature: FacetFeature = {
	$type: 'app.bsky.richtext.facet#link',
	uri: 'https://example.com',
};

const rt = new RichtextBuilder().addDecoratedText('custom link', feature);

getting the result#

there are multiple ways to get the composed rich text:

const rt = new RichtextBuilder().addText('hello ').addTag('world');

// via getters
const text = rt.text;
const facets = rt.facets;

// via build() method
const { text, facets } = rt.build();

cloning the builder#

clone a builder to create variations:

const base = new RichtextBuilder().addText('hello ');

const withMention = base.clone().addMention('@alice', 'did:plc:abc');
const withLink = base.clone().addLink('world', 'https://example.com');