Why is my Bluesky link on the wrong text?
A short guide to rich-text facets: byte offsets, the two classic bugs, and the auto-detection quirks
(like readme.md turning into a link). Every output below was produced by the
facet-check code and matches the official @atproto/api library (v0.30.3) exactly.
Short answer
A Bluesky post stores its links, @mentions and #tags as facets: a list of ranges into the text plus what each
range means. The ranges are counted in UTF-8 bytes. If your code counted characters (Python len,
[...str]) or JavaScript string indices (UTF-16), every facet after the first emoji or accented letter is shifted.
One emoji, three different numbers
Take the post 🙂 see https://example.com. Where does the link start and end?
| Counted in | Link range | What that range covers |
|---|---|---|
| UTF-8 bytes (correct) | 9–28 | https://example.com |
Characters / code points (Python str) | 6–25 | ee https://example. |
UTF-16 units (JavaScript str.length, match.index) | 7–26 | e https://example.c |
🙂 is 1 character, 2 UTF-16 units and 4 bytes. So character offsets land 3 bytes early, UTF-16 offsets 2 bytes early, and
the highlighted link swallows part of the word before it. Accents do it too: in café #atproto the tag is at bytes
6–14, but at characters 5–13, because é is 2 bytes.
The correct facet for the example:
{"index": {"byteStart": 9, "byteEnd": 28},
"features": [{"$type": "app.bsky.richtext.facet#link", "uri": "https://example.com"}]}
The fix: measure the UTF-8 prefix
Find the match however you like, then convert both ends to byte offsets by encoding the text before each end.
Python (string indices are code points):
import re
text = "🙂 see https://example.com"
m = re.search(r"https?://\S+", text)
byte_start = len(text[:m.start()].encode("utf-8")) # 9
byte_end = len(text[:m.end()].encode("utf-8")) # 28
JavaScript (string indices are UTF-16 units):
const text = "🙂 see https://example.com"; const m = /https?:\/\/\S+/.exec(text); const enc = new TextEncoder(); const byteStart = enc.encode(text.slice(0, m.index)).length; // 9 const byteEnd = enc.encode(text.slice(0, m.index + m[0].length)).length; // 28
Or skip the arithmetic: in JS the official RichText class from @atproto/api does it for you, and in
Python bsky-tiny (one file, no dependencies) uses the same rules.
How to tell which mistake you made
Paste the post link into facet-check's Check a post tab. It shows what each facet really covers, and if the offsets would be right when counted in characters or in UTF-16, it says so. For the 6–25 range above it reports: "These offsets match the text if counted in characters (code points). Facets need UTF-8 bytes."
Why did readme.md become a link?
The official detector links bare domains (no https://) when the last part is a real top-level domain.
.md is Moldova and .py is Paraguay, so filenames with those endings look like websites to it:
| Text | Becomes a link? |
|---|---|
readme.md | yes, to https://readme.md |
bsky.py | yes, to https://bsky.py |
docs.rs | yes (and that one is a real site) |
notes.txt, index.html, main.go | no: txt, html and go aren't TLDs |
Example.COM | no: the TLD check is case-sensitive, only lowercase matches |
x@y.com | no, and it isn't a mention either |
Detection is done by the client that writes the post, not by Bluesky's servers: the record holds whatever facets were sent. So if you build facets in your own code, you can simply leave out a link you didn't mean.
Other things the detector does
- Trailing punctuation is trimmed: in
(https://example.com/a).the link ishttps://example.com/a, and#tag.is the tagtag. - Emoji can be tags:
#🙂becomes a tag facet with the tag🙂. $AAPL(a$, then 1–5 characters starting with a letter) becomes a tag facet whose tag is$AAPL.- Mentions need a DID, so a client has to resolve the handle first. facet-check leaves unresolved mentions out until you press Resolve @mentions.
- The 300-character limit is a third kind of count: graphemes (what a person sees as one character). A family emoji is 1 grapheme but 25 bytes.