make links in bluesky posts clickable

Bluesky linkifies nothing by itself. A URL in the text of a record is
plain text unless a richtext facet says which bytes of the post are a
link and where they point, so our posts carried URLs no one could press.

Each link now travels on the Post as an output.Link with the offsets of
the text it occupies, and the bluesky output turns those into
app.bsky.richtext.facet#link. The offsets are UTF-8 byte offsets, not
character counts: a facet measured in characters slides off the URL as
soon as any Japanese text precedes it, and underlines the wrong words.

Scanning moved from preview.go to findLinks in main.go, which now
reports every link with its offsets rather than just the first one; the
preview card still goes to the first, which is the one a reader meets
first. X is unaffected, as it linkifies URLs itself.

The facet feature only gains its lexicon type when marshalled, so the
test checks the encoded record rather than the struct.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 09:35:59 +09:00
parent 953664ff1c
commit d794f65673
7 changed files with 153 additions and 37 deletions

View File

@@ -67,6 +67,8 @@ func (bo *blueskyoutput) Write(post Post) (Ref, error) {
Langs: []string{"ja"},
}
feedpost.Facets = linkFacets(post.Links)
root := Ref{}
if post.ReplyTo != nil && post.ReplyTo.URI != "" {
parent := &atproto.RepoStrongRef{
@@ -146,6 +148,29 @@ func (bo *blueskyoutput) Write(post Post) (Ref, error) {
}, nil
}
// linkFacets marks up the links in a post's text. Bluesky linkifies nothing
// by itself: a URL stays plain text until a facet says which bytes of the
// post are a link and where they point.
func linkFacets(links []Link) []*bsky.RichtextFacet {
if len(links) == 0 {
return nil
}
facets := make([]*bsky.RichtextFacet, 0, len(links))
for _, link := range links {
facets = append(facets, &bsky.RichtextFacet{
Index: &bsky.RichtextFacet_ByteSlice{
ByteStart: int64(link.ByteStart),
ByteEnd: int64(link.ByteEnd),
},
Features: []*bsky.RichtextFacet_Features_Elem{{
RichtextFacet_Link: &bsky.RichtextFacet_Link{Uri: link.URL},
}},
})
}
return facets
}
func (bo *blueskyoutput) Delete(ref Ref) error {
rkey, err := recordKey(ref.URI)
if err != nil {

View File

@@ -1,6 +1,51 @@
package output
import "testing"
import (
"encoding/json"
"strings"
"testing"
"time"
"github.com/bluesky-social/indigo/api/bsky"
"github.com/bluesky-social/indigo/util"
)
func TestLinkFacets(t *testing.T) {
if facets := linkFacets(nil); facets != nil {
t.Errorf("a post with no links got %d facets, want none", len(facets))
}
text := "みてね https://example.com/x"
post := &bsky.FeedPost{
Text: text,
CreatedAt: time.Now().Format(util.ISO8601),
Facets: linkFacets([]Link{{URL: "https://example.com/x", ByteStart: 10, ByteEnd: 31}}),
}
// The feature carries its lexicon type only once marshalled, so the
// record as it goes over the wire is what has to be checked.
encoded, err := json.Marshal(post)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
`"$type":"app.bsky.richtext.facet#link"`,
`"uri":"https://example.com/x"`,
`"byteStart":10`,
`"byteEnd":31`,
} {
if !strings.Contains(string(encoded), want) {
t.Errorf("record does not contain %s\ngot: %s", want, encoded)
}
}
// A facet that points at the wrong bytes underlines the wrong text.
index := post.Facets[0].Index
if slice := text[index.ByteStart:index.ByteEnd]; slice != "https://example.com/x" {
t.Errorf("facet covers %q, want the URL", slice)
}
}
func TestRecordKey(t *testing.T) {
rkey, err := recordKey("at://did:plc:abc123/app.bsky.feed.post/3kqz7xyz")

View File

@@ -14,6 +14,15 @@ type Preview struct {
Thumb *Image
}
// Link is a URL inside a post's text, located by the UTF-8 byte offsets
// bluesky needs to mark it up: it does not linkify text on its own, so a URL
// no one points at stays unclickable.
type Link struct {
URL string
ByteStart int
ByteEnd int
}
// Ref identifies a post an output has already published so it can later be
// replied to or deleted. The fields are output specific; only the ones the
// publishing output filled in are meaningful to it.
@@ -34,6 +43,7 @@ func (r Ref) IsZero() bool {
type Post struct {
Text string
Images []Image
Links []Link
Preview *Preview
// ReplyTo is the Ref this same output returned for the post being
// replied to, or nil for a top level post.