From 0820788d6fe656be16b170922448c6e43bb902b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 19:44:21 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Ki4kET2FKHBfNWR6jx6ooW --- bskyweb/cmd/bskyweb/jsonld.go | 72 ++++++++++++++++++++++-------- bskyweb/cmd/bskyweb/jsonld_test.go | 52 ++++++++++++++++++++- bskyweb/cmd/bskyweb/render_test.go | 29 +++++++++--- bskyweb/cmd/bskyweb/server.go | 4 +- bskyweb/templates/post.html | 14 ++++-- 5 files changed, 139 insertions(+), 32 deletions(-) diff --git a/bskyweb/cmd/bskyweb/jsonld.go b/bskyweb/cmd/bskyweb/jsonld.go index a5ab849c35..c3ac638e1a 100644 --- a/bskyweb/cmd/bskyweb/jsonld.go +++ b/bskyweb/cmd/bskyweb/jsonld.go @@ -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 // 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] diff --git a/bskyweb/cmd/bskyweb/jsonld_test.go b/bskyweb/cmd/bskyweb/jsonld_test.go index d3da1a0570..920bb32aaf 100644 --- a/bskyweb/cmd/bskyweb/jsonld_test.go +++ b/bskyweb/cmd/bskyweb/jsonld_test.go @@ -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)) diff --git a/bskyweb/cmd/bskyweb/render_test.go b/bskyweb/cmd/bskyweb/render_test.go index c4ba22090a..ba00ba7aad 100644 --- a/bskyweb/cmd/bskyweb/render_test.go +++ b/bskyweb/cmd/bskyweb/render_test.go @@ -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, ``) { 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, ``) { + t.Errorf("og:image:alt not found or not escaped in rendered HTML:\n%s", html) + } + if !strings.Contains(html, ``) { + 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, ``) { @@ -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, ``) { - 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, ``) { - 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) } } diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index eb59adf40f..f4c8f9fedd 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -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 diff --git a/bskyweb/templates/post.html b/bskyweb/templates/post.html index 72ac4664e7..bbd2d4259b 100644 --- a/bskyweb/templates/post.html +++ b/bskyweb/templates/post.html @@ -33,10 +33,16 @@ {% endif -%} - {%- if imgThumbUrls %} - {% for imgThumbUrl in imgThumbUrls %} - - + {%- if postImages %} + {% for img in postImages %} + + {%- if img.Alt %} + + {% endif -%} + + {%- if img.Alt %} + + {% endif -%} {% endfor %} {% else %}