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 %}