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 inLink rangeWhat that range covers
UTF-8 bytes (correct)9–28https://example.com
Characters / code points (Python str)6–25ee https://example.
UTF-16 units (JavaScript str.length, match.index)7–26e 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:

TextBecomes a link?
readme.mdyes, to https://readme.md
bsky.pyyes, to https://bsky.py
docs.rsyes (and that one is a real site)
notes.txt, index.html, main.gono: txt, html and go aren't TLDs
Example.COMno: the TLD check is case-sensitive, only lowercase matches
x@y.comno, 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