From 20bbceeee981e35ae338dad7dbb8f4deb5609e7f Mon Sep 17 00:00:00 2001 From: Michael Black Date: Tue, 26 May 2026 16:07:28 -0500 Subject: [PATCH] fix discussionForumPosting.comment field type --- bskyweb/cmd/bskyweb/jsonld.go | 78 +++++++++++++++--------------- bskyweb/cmd/bskyweb/jsonld_test.go | 34 +++++++++++-- 2 files changed, 67 insertions(+), 45 deletions(-) diff --git a/bskyweb/cmd/bskyweb/jsonld.go b/bskyweb/cmd/bskyweb/jsonld.go index 3eb27079c2..79d5d4ad40 100644 --- a/bskyweb/cmd/bskyweb/jsonld.go +++ b/bskyweb/cmd/bskyweb/jsonld.go @@ -9,15 +9,10 @@ import ( ) // schema.org structured-data types emitted on post and profile pages. -// -// These mirror the shapes recommended by Google Search for -// DiscussionForumPosting (post pages) and ProfilePage (profile pages). The -// goal of building these in Go (rather than writing JSON inline in Pongo2 -// templates) is to get correct JSON escaping for free via encoding/json, and -// to keep the schema in one place that is easy to unit-test. -// -// All fields use omitempty aggressively so optional fields don't emit empty -// strings or null values that would otherwise confuse Google's validator. +// Building these as Go structs (rather than inline JSON in templates) gives +// us correct JSON escaping via encoding/json and a single place to test the +// schema. Optional fields use omitempty so empty values don't reach Google's +// validator as null/empty strings. const schemaOrgContext = "https://schema.org" @@ -45,27 +40,35 @@ type sharedContent struct { } type discussionForumPosting struct { - Context string `json:"@context,omitempty"` - Type string `json:"@type"` - URL string `json:"url,omitempty"` - Identifier string `json:"identifier,omitempty"` - Author *personOrOrg `json:"author,omitempty"` - Text string `json:"text,omitempty"` - Image []string `json:"image,omitempty"` - ThumbnailURL string `json:"thumbnailUrl,omitempty"` - DatePublished string `json:"datePublished,omitempty"` - InteractionStat []interactionStat `json:"interactionStatistic,omitempty"` - CommentCount *int64 `json:"commentCount,omitempty"` - Comment []discussionForumPosting `json:"comment,omitempty"` - IsBasedOn string `json:"isBasedOn,omitempty"` - SharedContent *sharedContent `json:"sharedContent,omitempty"` + Context string `json:"@context,omitempty"` + Type string `json:"@type"` + URL string `json:"url,omitempty"` + Identifier string `json:"identifier,omitempty"` + Author *personOrOrg `json:"author,omitempty"` + Text string `json:"text,omitempty"` + Image []string `json:"image,omitempty"` + ThumbnailURL string `json:"thumbnailUrl,omitempty"` + DatePublished string `json:"datePublished,omitempty"` + InteractionStat []interactionStat `json:"interactionStatistic,omitempty"` + CommentCount *int64 `json:"commentCount,omitempty"` + Comment []comment `json:"comment,omitempty"` + IsBasedOn string `json:"isBasedOn,omitempty"` + SharedContent *sharedContent `json:"sharedContent,omitempty"` } -// Replies reuse discussionForumPosting via buildReplyNode, which produces a -// "shallow" form: it includes author, text, datePublished, url, identifier, -// and media (image, thumbnailUrl) but does NOT recurse into nested -// comment[], isBasedOn, or sharedContent. This bounds the size of the -// emitted JSON-LD on posts with deep reply trees. +// comment is the schema.org Comment shape used inside +// DiscussionForumPosting.comment[]. The comment property does not accept +// DiscussionForumPosting, so replies map to Comment. +type comment struct { + Type string `json:"@type"` + URL string `json:"url,omitempty"` + Identifier string `json:"identifier,omitempty"` + Author *personOrOrg `json:"author,omitempty"` + Text string `json:"text,omitempty"` + Image []string `json:"image,omitempty"` + ThumbnailURL string `json:"thumbnailUrl,omitempty"` + DatePublished string `json:"datePublished,omitempty"` +} type webPage struct { Context string `json:"@context"` @@ -346,7 +349,7 @@ func buildPostNode(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_Th } reply := buildReplyNode(r.FeedDefs_ThreadViewPost.Post, hideLabels) if reply.Type == "" { - // nil-Author guard tripped; skip rather than emit a malformed entry. + // nil-Author guard tripped; skip. continue } node.Comment = append(node.Comment, reply) @@ -355,14 +358,11 @@ func buildPostNode(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_Th return node } -// buildReplyNode builds a "shallow" DiscussionForumPosting for a reply -// comment: includes media but skips nested comment[], isBasedOn, and -// sharedContent (per project decision to bound payload size). Returns the -// zero value if pv or pv.Author is nil — callers should treat that as -// "skip this entry". -func buildReplyNode(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) discussionForumPosting { +// buildReplyNode builds a schema.org Comment for a reply. Returns the zero +// value if pv or pv.Author is nil; callers should treat that as "skip". +func buildReplyNode(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) comment { if pv == nil || pv.Author == nil { - return discussionForumPosting{} + return comment{} } embedHidden := postEmbedHidden(pv, hideLabels) images := extractPostMedia(pv, embedHidden) @@ -370,9 +370,8 @@ func buildReplyNode(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) d if len(images) > 0 { thumb = images[0] } - - node := discussionForumPosting{ - Type: "DiscussionForumPosting", + return comment{ + Type: "Comment", URL: bskyPostURLFromATURI(pv.Author.Handle, pv.Uri), Identifier: pv.Uri, Author: buildAuthor(pv.Author), @@ -381,7 +380,6 @@ func buildReplyNode(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) d ThumbnailURL: thumb, DatePublished: pv.IndexedAt, } - return node } // buildPostJSONLD marshals the top-level WebPage envelope for a post page. diff --git a/bskyweb/cmd/bskyweb/jsonld_test.go b/bskyweb/cmd/bskyweb/jsonld_test.go index abad405d34..55875c609d 100644 --- a/bskyweb/cmd/bskyweb/jsonld_test.go +++ b/bskyweb/cmd/bskyweb/jsonld_test.go @@ -318,7 +318,7 @@ func TestBuildPostJSONLD_TextEscaping(t *testing.T) { } } -func TestBuildPostJSONLD_Replies(t *testing.T) { +func TestBuildPostJSONLD_Comments(t *testing.T) { pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main") *pv.ReplyCount = 14 @@ -361,9 +361,14 @@ func TestBuildPostJSONLD_Replies(t *testing.T) { if first["identifier"] != "at://did:plc:rep00/app.bsky.feed.post/reply00" { t.Errorf("first comment should be reply00, got %v", first["identifier"]) } - // Each comment must NOT have nested comment[]/isBasedOn/sharedContent. + // Each comment must be schema.org Comment (not DiscussionForumPosting — + // that type is invalid for the comment property) and must NOT have + // nested comment[] / isBasedOn / sharedContent. for i, c := range comments { cm := c.(map[string]any) + if cm["@type"] != "Comment" { + t.Errorf("reply %d wrong type: got %v, want Comment", i, cm["@type"]) + } if _, present := cm["comment"]; present { t.Errorf("reply %d should not have nested comment[]", i) } @@ -373,9 +378,6 @@ func TestBuildPostJSONLD_Replies(t *testing.T) { if _, present := cm["sharedContent"]; present { t.Errorf("reply %d should not have sharedContent", i) } - if cm["@type"] != "DiscussionForumPosting" { - t.Errorf("reply %d wrong type: %v", i, cm["@type"]) - } if cm["url"] == nil || cm["identifier"] == nil { t.Errorf("reply %d missing url/identifier", i) } @@ -486,6 +488,28 @@ func TestBuildPostJSONLD_NilAuthorReply(t *testing.T) { } } +func TestBuildPostJSONLD_CommentMedia(t *testing.T) { + // Comments with media embeds should expose image[] and thumbnailUrl. + pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main") + *pv.ReplyCount = 1 + thumb := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:bob/x@jpg" + reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "with image", + withImages(thumb)) + replies := []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem{ + {FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: reply}}, + } + out, _ := buildPostJSONLD(pv, replies, "u", hideEmbedLabels) + main := unmarshalLD(t, out)["mainEntity"].(map[string]any) + c := main["comment"].([]any)[0].(map[string]any) + imgs, ok := c["image"].([]any) + if !ok || len(imgs) != 1 || imgs[0] != thumb { + t.Errorf("comment image[] wrong: %v", c["image"]) + } + if c["thumbnailUrl"] != thumb { + t.Errorf("comment thumbnailUrl wrong: %v", c["thumbnailUrl"]) + } +} + func TestBuildProfileJSONLD_Basic(t *testing.T) { pv := &appbsky.ActorDefs_ProfileViewDetailed{ Did: "did:plc:alice",