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:
@@ -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]
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 "cool" 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 "cool" 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user