Formatting is an important tool for emphasizing your point. We use it to highlight key parts of our work every day, including when developing services.
WYSIWYG editors, HTML tags, Markdown symbols. These are convenient tools that let users see text exactly as we intended to convey it. Under the hood, however, this is often markup embedded directly within the text itself. What we see isn’t the same as what we store.
But there’s another way - not through markup inside the text, but through a layer on top of it. One example is Facets - a formatting approach in atproto.
I started thinking about this back when I was developing atsky.app (an appview for Bluesky). One of the first improvements was polls, which were displayed only in atsky, while Bluesky showed the post as usual.
Later, this same approach became the basis for displaying code and mathematical expressions. However, in Bluesky, such elements are still not displayed directly - instead, the user sees a link saying “code not supported, open in Atsky.”
https://bsky.app/profile/alexdln.com/post/3mbybcmnh522o
It was an interesting experience, but the possibilities I saw in it were far more intriguing. And I asked myself - just how much can be done with them? And as you’ve already gathered from the title - quite a lot. This article will be dedicated to that journey and the results obtained.
Formatting
First, a little about the problem facets solve - formatting.
Formatting is the process of giving data (such as text) structure, semantics, and/or visual representation according to specified rules.
The main formatting methods in development are HTML and Markdown. Both add structure through inline markup, using key expressions to define formatting boundaries within the text itself.
<p>Hello <b>World!</b></p>
But if we don’t understand this language, it looks like strange, incorrect text to us.
To address the issue of accessibility, some atproto standards, such as standard.site, recommend storing textContent separately, thereby allowing any user or service to read the record’s content, while the content blocks themselves, with all formatting, are typically stored within the “content” key.
{
"$type": "site.standard.document",
"site": "at://did:plc:abc123/site.standard.publication/3lwafzkjqm25s",
"path": "/blog/getting-started",
"title": "Getting Started with Standard.site",
"description": "Learn how to use Standard.site lexicons in your project",
"textContent": "Full text of the article...",
"content": { "$type": "com.example.blog", "blocks": ["..."] },
"publishedAt": "2024-01-20T14:30:00.000Z"
}
An alternative formatting method is the so-called annotation layer. This approach involves defining formatting boundaries not within the text itself, but in a separate data layer. One example is the facets feature in atproto.
{
"features": [
{
"$type": "app.bsky.richtext.facet#link",
"uri": "<https://alexdln.com/blog/facets>"
}
],
"index": {
"byteEnd": 10,
"byteStart": 16
}
}
Currently, atproto itself uses them only in links within Bluesky. Writing-focused services like pckt.blog, leaflet.pub, and offprint.app also use them for standard formatting elements - bold, italics, underlining, etc.
How it works
As mentioned earlier, facets are an annotation layer that stores formatting instructions in a separate layer without altering the text. Let’s break down what a facet consists of:
range - the text range where formatting begins and ends. This range is specified in bytes. RichText sequentially passes through each facet and applies features to the specified range.
"index": {
"byteEnd": 10,
"byteStart": 16
}
features - a set of formatting instructions themselves - type (bold, italic, link) and additional data for a specific type (f.e. link URL). This is an array that formally allows you to specify multiple formatting types within a given range.
"features": [
{
"$type": "app.bsky.richtext.facet#link",
"uri": "<https://alexdln.com/blog/facets>"
}
]
And if you look at these two parts, you can see that they allow for embedding any data. Here’s an example of how the math works in atsky.app
[{
"features": [
{
"$type": "app.bsky.richtext.facet#link",
"uri": "<https://atsky.app/profile/alexdln.com/post/3mbybcmnh522o>"
}
],
"index": {
"byteEnd": 98,
"byteStart": 56
}
}, {
"features": [
{
"$type": "app.bsky.richtext.facet#code.latex",
"code": "T(n) = a \\cdot n + b \\cdot \\log n + c"
}
],
"index": {
"byteEnd": 98,
"byteStart": 56
}
}]
You can see overlapping facets, and this is a common feature of current implementations: if formatting has already been applied to this range, subsequent ones are ignored (since it is more likely that it was specified as a second feature within the first facet). Therefore, in bluesky the code is ignored, and in atsky the link is ignored.
It is important to note that facets operate specifically in bytes. In the most basic version, their implementation would look something like this:
if (!facets.length) return text;
return facets.reduce((acc, facet, index) => {
const nextFacetStart = facets[index + 1]
? bytePositionToCharPosition(text, facets[index + 1].index.byteStart)
: undefined;
acc.push(
<RichTextFeature key={facet.index.byteStart} features={facet.features}>
{text.substring(
bytePositionToCharPosition(text, facet.index.byteStart),
bytePositionToCharPosition(text, facet.index.byteEnd),
)}
</RichTextFeature>,
<Fragment key={`${facet.index.byteStart}_next`}>
{text.substring(bytePositionToCharPosition(text, facet.index.byteEnd), nextFacetStart)}
</Fragment>,
);
return acc;
}, [
text.substring(0, bytePositionToCharPosition(text, facets[0].index.byteStart)),
]);
Flexibility
In the example above, another clear benefit of this approach is its high cross-platform compatibility. If you support only the link - you process only the link, if you support the full set of features - you process all specified facets, if you’re unfamiliar with the system or just starting to build a tool - you simply display the text.
The service author has access to the content regardless of language, tools, environment, or runtime. You can start with text and gradually add support for more elements.
Iterative development where you can get results immediately and then gradually improve them. One of the many things we love about atproto.
Scalability
As mentioned earlier, the only standardized use of facets in atproto itself right now is links in bluesky. This is perhaps the most underrated feature in the entire protocol.
It’s a powerful tool for inline formatting, but as you’ve seen from the code and links, it’s also suitable for other elements. More precisely, for absolutely any element.
One example of its use is articles. Current services store text separately and formatting in special blocks
{
"content": {
"$type": "com.example.blog",
"blocks": [{
"$type": "com.example.blog.blockquote",
"content": "Everything is a facet (except plain text)"
}]
},
"textContent": "Everything is a facet (except plain text)"
}
But if you look at this block, you’ll notice that its content is essentially no different from the facet mentioned above. So let’s implement it as a facet
{
"facets": [{
"features": [
{
"$type": "com.example.richtext.facet#blockquote"
}
],
"index": {
"byteEnd": 0,
"byteStart": 41
}
},
"textContent": "Everything is a facet (except plain text)"
}
And this is a perfectly valid facet, all that’s left is to integrate it if you want to support it. This way, we avoid duplicating text, and the main logic of the analysis is shifted to the rendering engine.
Types and lexicon
After a series of experiments and comparisons, I concluded that a facet can be used not only to display an expanded post on a social network, but also to generate literally any text. To make this possible, I created a lexicon, which you can view on pdsls. Here are its types as of this writing:
net.atview.richtext.facet#b,
net.atview.richtext.facet#i,
net.atview.richtext.facet#u,
net.atview.richtext.facet#code,
net.atview.richtext.facet#strikethrough,
net.atview.richtext.facet#highlight,
net.atview.richtext.facet#link,
net.atview.richtext.facet#mention,
net.atview.richtext.facet#h2,
net.atview.richtext.facet#h3,
net.atview.richtext.facet#h4,
net.atview.richtext.facet#h5,
net.atview.richtext.facet#h6,
net.atview.richtext.facet#blockquote,
net.atview.richtext.facet#codeBlock,
net.atview.richtext.facet#media,
net.atview.richtext.facet#bskyPost,
net.atview.richtext.facet#ul,
net.atview.richtext.facet#ol,
net.atview.richtext.facet#website,
net.atview.richtext.facet#horizontalRule,
net.atview.richtext.facet#iframe,
net.atview.richtext.facet#math,
net.atview.richtext.facet#hardBreak
If you’re reading this article in its original form (at alexdln.com/blog/facets), you can inspect its data by clicking the button below or on pdsls. This article is written using the lexicon and approaches described above, as well as some interesting sugar from other implementations. For example, two line breaks in the text create a new block, similar to markdown engines.
Of course, this is an experiment, not a final proposal. This approach adds significant complexity to the rendering tools themselves, whereas current implementations are easier to integrate. Nevertheless, I hope you found at least some of these ideas interesting.