Emit og:image:alt / twitter:image:alt on bskyweb post pages

Post embeds already carry author-provided alt text in the appview
response (images, gallery items, and video), but bskyweb dropped it when
extracting og:image thumbnails, so link previews and crawlers never saw
an image description.

Extend extractPostMedia to return thumb+alt pairs and emit
og:image:alt / twitter:image:alt after each og:image / twitter:image
when the alt text is non-empty. Video posts use the video's alt text for
the poster thumbnail. JSON-LD callers flatten via a new imageURLs helper
so image[] strings stay byte-identical to og:image.

Related to the spirit of #8033 (alt text on link card images), but on
the bskyweb OpenGraph output side.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ki4kET2FKHBfNWR6jx6ooW
This commit is contained in:
Claude
2026-07-22 19:44:21 +00:00
parent f62e54ec82
commit 0820788d6f
5 changed files with 139 additions and 32 deletions
+54 -18
View File
@@ -178,10 +178,19 @@ func bskyProfileURL(handle string) string {
return fmt.Sprintf("https://bsky.app/profile/%s", handle)
}
// extractPostMedia returns thumbnail URLs for the post's image, gallery,
// or video embed, byte-identical to what we put in og:image. Callers
// derive thumbnailUrl from urls[0].
func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string {
// postImage pairs an og:image thumbnail URL with the author-provided alt
// text ("" when none), so templates can emit og:image:alt alongside
// og:image.
type postImage struct {
Thumb string
Alt string
}
// extractPostMedia returns thumbnails (with alt text) for the post's image,
// gallery, or video embed. Thumb values are byte-identical to what we put
// in og:image; JSON-LD callers flatten via imageURLs. Callers derive
// thumbnailUrl from the first entry.
func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []postImage {
if pv == nil || pv.Embed == nil || embedHidden {
return nil
}
@@ -193,7 +202,7 @@ func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string
return galleryThumbs(pv.Embed.EmbedGallery_View.Items)
}
if pv.Embed.EmbedVideo_View != nil && pv.Embed.EmbedVideo_View.Thumbnail != nil {
return []string{*pv.Embed.EmbedVideo_View.Thumbnail}
return []postImage{videoThumb(pv.Embed.EmbedVideo_View)}
}
if pv.Embed.EmbedRecordWithMedia_View != nil && pv.Embed.EmbedRecordWithMedia_View.Media != nil {
media := pv.Embed.EmbedRecordWithMedia_View.Media
@@ -204,14 +213,16 @@ func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string
return galleryThumbs(media.EmbedGallery_View.Items)
}
if media.EmbedVideo_View != nil && media.EmbedVideo_View.Thumbnail != nil {
return []string{*media.EmbedVideo_View.Thumbnail}
return []postImage{videoThumb(media.EmbedVideo_View)}
}
}
return nil
}
// imageThumbs returns the thumb URLs, or nil if empty.
func imageThumbs(images []*appbsky.EmbedImages_ViewImage) []string {
// imageURLs flattens postImages to their thumb URLs. JSON-LD image[] uses
// this so its strings stay byte-identical to og:image (per Google's
// requirement).
func imageURLs(images []postImage) []string {
if len(images) == 0 {
return nil
}
@@ -222,16 +233,38 @@ func imageThumbs(images []*appbsky.EmbedImages_ViewImage) []string {
return urls
}
// galleryThumbs returns the thumbnail URLs of image items in a gallery
// embed, or nil if empty. Items_Elem is a union; non-image variants and
// nil entries are skipped so future gallery item types don't break SEO
// videoThumb pairs a video embed's poster thumbnail with the video's alt
// text. Callers must ensure v.Thumbnail is non-nil.
func videoThumb(v *appbsky.EmbedVideo_View) postImage {
img := postImage{Thumb: *v.Thumbnail}
if v.Alt != nil {
img.Alt = *v.Alt
}
return img
}
// imageThumbs returns the thumb URLs and alt text, or nil if empty.
func imageThumbs(images []*appbsky.EmbedImages_ViewImage) []postImage {
if len(images) == 0 {
return nil
}
out := make([]postImage, 0, len(images))
for _, img := range images {
out = append(out, postImage{Thumb: img.Thumb, Alt: img.Alt})
}
return out
}
// galleryThumbs returns the thumbnails (with alt text) of image items in a
// gallery embed, or nil if empty. Items_Elem is a union; non-image variants
// and nil entries are skipped so future gallery item types don't break SEO
// extraction. Empty Thumbnail strings are also skipped to avoid emitting
// <meta property="og:image" content=""> if the appview ever returns one.
func galleryThumbs(items []*appbsky.EmbedGallery_View_Items_Elem) []string {
func galleryThumbs(items []*appbsky.EmbedGallery_View_Items_Elem) []postImage {
if len(items) == 0 {
return nil
}
urls := make([]string, 0, len(items))
out := make([]postImage, 0, len(items))
for _, item := range items {
if item == nil || item.EmbedGallery_ViewImage == nil {
continue
@@ -239,12 +272,15 @@ func galleryThumbs(items []*appbsky.EmbedGallery_View_Items_Elem) []string {
if item.EmbedGallery_ViewImage.Thumbnail == "" {
continue
}
urls = append(urls, item.EmbedGallery_ViewImage.Thumbnail)
out = append(out, postImage{
Thumb: item.EmbedGallery_ViewImage.Thumbnail,
Alt: item.EmbedGallery_ViewImage.Alt,
})
}
if len(urls) == 0 {
if len(out) == 0 {
return nil
}
return urls
return out
}
// findVideoEmbed returns the post's video embed view, or nil if there is
@@ -525,7 +561,7 @@ func buildPostNode(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_Th
return discussionForumPosting{}
}
embedHidden := postEmbedHidden(pv, hideLabels)
images := extractPostMedia(pv, embedHidden)
images := imageURLs(extractPostMedia(pv, embedHidden))
var thumb string
if len(images) > 0 {
thumb = images[0]
@@ -600,7 +636,7 @@ func buildReplyNode(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) c
return comment{}
}
embedHidden := postEmbedHidden(pv, hideLabels)
images := extractPostMedia(pv, embedHidden)
images := imageURLs(extractPostMedia(pv, embedHidden))
var thumb string
if len(images) > 0 {
thumb = images[0]
+51 -1
View File
@@ -433,7 +433,7 @@ func TestExtractPostMedia_GallerySkipsUnknownItems(t *testing.T) {
},
}
got := extractPostMedia(pv, false)
if len(got) != 1 || got[0] != thumb {
if len(got) != 1 || got[0].Thumb != thumb {
t.Errorf("expected single thumb, got %v", got)
}
@@ -448,6 +448,56 @@ func TestExtractPostMedia_GallerySkipsUnknownItems(t *testing.T) {
}
}
// Alt text must ride along with the thumb for every embed shape so
// og:image:alt can be emitted (issue #8033 adjacent: describe images to
// screen readers and link-preview consumers).
func TestExtractPostMedia_IncludesAlt(t *testing.T) {
thumb := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/a@jpeg"
// images embed
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "pic")
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
EmbedImages_View: &appbsky.EmbedImages_View{
Images: []*appbsky.EmbedImages_ViewImage{{Thumb: thumb, Alt: "a red bird"}},
},
}
got := extractPostMedia(pv, false)
if len(got) != 1 || got[0].Alt != "a red bird" {
t.Errorf("images embed: expected alt to be extracted, got %v", got)
}
// gallery embed
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
EmbedGallery_View: &appbsky.EmbedGallery_View{
Items: []*appbsky.EmbedGallery_View_Items_Elem{
{EmbedGallery_ViewImage: &appbsky.EmbedGallery_ViewImage{Thumbnail: thumb, Alt: "a blue bird"}},
},
},
}
got = extractPostMedia(pv, false)
if len(got) != 1 || got[0].Alt != "a blue bird" {
t.Errorf("gallery embed: expected alt to be extracted, got %v", got)
}
// video embed: poster thumb carries the video's alt text
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
EmbedVideo_View: &appbsky.EmbedVideo_View{Thumbnail: strPtr(thumb), Alt: strPtr("a bird singing")},
}
got = extractPostMedia(pv, false)
if len(got) != 1 || got[0].Alt != "a bird singing" {
t.Errorf("video embed: expected alt to be extracted, got %v", got)
}
// video embed without alt: empty string, not a panic
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
EmbedVideo_View: &appbsky.EmbedVideo_View{Thumbnail: strPtr(thumb)},
}
got = extractPostMedia(pv, false)
if len(got) != 1 || got[0].Alt != "" {
t.Errorf("video embed without alt: expected empty alt, got %v", got)
}
}
func TestBuildPostJSONLD_WithVideo(t *testing.T) {
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch", withVideo(thumb))
+22 -7
View File
@@ -87,13 +87,27 @@ func TestRenderPost_OGImageMatchesJSONLD(t *testing.T) {
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"canonicalURL": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"postJSONLD": ld,
"imgThumbUrls": []string{thumb1, thumb2},
"postImages": []postImage{
{Thumb: thumb1, Alt: `a "cool" cat`},
{Thumb: thumb2},
},
})
// og:image and JSON-LD image[] must be byte-identical.
if !strings.Contains(html, `<meta property="og:image" content="`+thumb1+`">`) {
t.Errorf("og:image[0] not found in rendered HTML")
}
// Alt text emits as og:image:alt / twitter:image:alt, HTML-escaped.
if !strings.Contains(html, `<meta property="og:image:alt" content="a &quot;cool&quot; cat">`) {
t.Errorf("og:image:alt not found or not escaped in rendered HTML:\n%s", html)
}
if !strings.Contains(html, `<meta property="twitter:image:alt" content="a &quot;cool&quot; cat">`) {
t.Errorf("twitter:image:alt not found or not escaped in rendered HTML")
}
// Images without alt text must not emit an empty og:image:alt.
if strings.Count(html, `og:image:alt`) != 1 {
t.Errorf("expected exactly one og:image:alt (second image has no alt); got:\n%s", html)
}
body := extractJSONLD(t, html)
var parsed map[string]any
_ = json.Unmarshal([]byte(body), &parsed)
@@ -112,14 +126,14 @@ func TestRenderPost_OGImageMatchesJSONLD_Gallery(t *testing.T) {
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g1@jpeg"
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g2@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery", withGallery(thumb1, thumb2))
thumbs := extractPostMedia(pv, false)
postImages := extractPostMedia(pv, false)
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"canonicalURL": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"postJSONLD": ld,
"imgThumbUrls": thumbs,
"postImages": postImages,
})
if !strings.Contains(html, `<meta property="og:image" content="`+thumb1+`">`) {
@@ -229,8 +243,9 @@ func TestRenderPost_OGUrlMatchesCanonical(t *testing.T) {
}
// og:video must emit even when there is no thumbnail. Previously the
// {% if videoUrl %} block was nested inside {% if imgThumbUrls %}, so a
// video without a thumbnail dropped og:video entirely.
// {% if videoUrl %} block was nested inside the image block (then keyed on
// imgThumbUrls, now postImages), so a video without a thumbnail dropped
// og:video entirely.
func TestRenderPost_VideoWithoutThumbnailEmitsOGVideo(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch")
ld, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
@@ -244,10 +259,10 @@ func TestRenderPost_VideoWithoutThumbnailEmitsOGVideo(t *testing.T) {
"videoType": "application/x-mpegURL",
})
if !strings.Contains(html, `<meta property="og:video" content="`+videoURL+`">`) {
t.Errorf("og:video should emit even without imgThumbUrls; got:\n%s", html)
t.Errorf("og:video should emit even without postImages; got:\n%s", html)
}
if !strings.Contains(html, `<meta property="og:video:type" content="application/x-mpegURL">`) {
t.Errorf("og:video:type should emit even without imgThumbUrls; got:\n%s", html)
t.Errorf("og:video:type should emit even without postImages; got:\n%s", html)
}
}
+2 -2
View File
@@ -680,8 +680,8 @@ func (srv *Server) WebPost(c echo.Context) error {
isEmbedHidden := postEmbedHidden(postView, hideEmbedLabels)
data["postText"] = postRecordText(postView)
if thumbs := extractPostMedia(postView, isEmbedHidden); len(thumbs) > 0 {
data["imgThumbUrls"] = thumbs
if imgs := extractPostMedia(postView, isEmbedHidden); len(imgs) > 0 {
data["postImages"] = imgs
}
if vm := extractVideoMeta(postView, isEmbedHidden); vm.URL != "" {
data["videoUrl"] = vm.URL
+10 -4
View File
@@ -33,10 +33,16 @@
<meta property="og:description" content="{{ postText }}">
<meta property="twitter:description" content="{{ postText }}">
{% endif -%}
{%- if imgThumbUrls %}
{% for imgThumbUrl in imgThumbUrls %}
<meta property="og:image" content="{{ imgThumbUrl }}">
<meta property="twitter:image" content="{{ imgThumbUrl }}">
{%- if postImages %}
{% for img in postImages %}
<meta property="og:image" content="{{ img.Thumb }}">
{%- if img.Alt %}
<meta property="og:image:alt" content="{{ img.Alt }}">
{% endif -%}
<meta property="twitter:image" content="{{ img.Thumb }}">
{%- if img.Alt %}
<meta property="twitter:image:alt" content="{{ img.Alt }}">
{% endif -%}
{% endfor %}
<meta name="twitter:card" content="summary_large_image">
{% else %}