fix discussionForumPosting.comment field type

This commit is contained in:
Michael Black
2026-05-26 16:07:28 -05:00
parent 0e250e7ddb
commit 20bbceeee9
2 changed files with 67 additions and 45 deletions
+38 -40
View File
@@ -9,15 +9,10 @@ import (
) )
// schema.org structured-data types emitted on post and profile pages. // schema.org structured-data types emitted on post and profile pages.
// // Building these as Go structs (rather than inline JSON in templates) gives
// These mirror the shapes recommended by Google Search for // us correct JSON escaping via encoding/json and a single place to test the
// DiscussionForumPosting (post pages) and ProfilePage (profile pages). The // schema. Optional fields use omitempty so empty values don't reach Google's
// goal of building these in Go (rather than writing JSON inline in Pongo2 // validator as null/empty strings.
// 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.
const schemaOrgContext = "https://schema.org" const schemaOrgContext = "https://schema.org"
@@ -45,27 +40,35 @@ type sharedContent struct {
} }
type discussionForumPosting struct { type discussionForumPosting struct {
Context string `json:"@context,omitempty"` Context string `json:"@context,omitempty"`
Type string `json:"@type"` Type string `json:"@type"`
URL string `json:"url,omitempty"` URL string `json:"url,omitempty"`
Identifier string `json:"identifier,omitempty"` Identifier string `json:"identifier,omitempty"`
Author *personOrOrg `json:"author,omitempty"` Author *personOrOrg `json:"author,omitempty"`
Text string `json:"text,omitempty"` Text string `json:"text,omitempty"`
Image []string `json:"image,omitempty"` Image []string `json:"image,omitempty"`
ThumbnailURL string `json:"thumbnailUrl,omitempty"` ThumbnailURL string `json:"thumbnailUrl,omitempty"`
DatePublished string `json:"datePublished,omitempty"` DatePublished string `json:"datePublished,omitempty"`
InteractionStat []interactionStat `json:"interactionStatistic,omitempty"` InteractionStat []interactionStat `json:"interactionStatistic,omitempty"`
CommentCount *int64 `json:"commentCount,omitempty"` CommentCount *int64 `json:"commentCount,omitempty"`
Comment []discussionForumPosting `json:"comment,omitempty"` Comment []comment `json:"comment,omitempty"`
IsBasedOn string `json:"isBasedOn,omitempty"` IsBasedOn string `json:"isBasedOn,omitempty"`
SharedContent *sharedContent `json:"sharedContent,omitempty"` SharedContent *sharedContent `json:"sharedContent,omitempty"`
} }
// Replies reuse discussionForumPosting via buildReplyNode, which produces a // comment is the schema.org Comment shape used inside
// "shallow" form: it includes author, text, datePublished, url, identifier, // DiscussionForumPosting.comment[]. The comment property does not accept
// and media (image, thumbnailUrl) but does NOT recurse into nested // DiscussionForumPosting, so replies map to Comment.
// comment[], isBasedOn, or sharedContent. This bounds the size of the type comment struct {
// emitted JSON-LD on posts with deep reply trees. 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 { type webPage struct {
Context string `json:"@context"` 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) reply := buildReplyNode(r.FeedDefs_ThreadViewPost.Post, hideLabels)
if reply.Type == "" { if reply.Type == "" {
// nil-Author guard tripped; skip rather than emit a malformed entry. // nil-Author guard tripped; skip.
continue continue
} }
node.Comment = append(node.Comment, reply) node.Comment = append(node.Comment, reply)
@@ -355,14 +358,11 @@ func buildPostNode(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_Th
return node return node
} }
// buildReplyNode builds a "shallow" DiscussionForumPosting for a reply // buildReplyNode builds a schema.org Comment for a reply. Returns the zero
// comment: includes media but skips nested comment[], isBasedOn, and // value if pv or pv.Author is nil; callers should treat that as "skip".
// sharedContent (per project decision to bound payload size). Returns the func buildReplyNode(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) comment {
// 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 {
if pv == nil || pv.Author == nil { if pv == nil || pv.Author == nil {
return discussionForumPosting{} return comment{}
} }
embedHidden := postEmbedHidden(pv, hideLabels) embedHidden := postEmbedHidden(pv, hideLabels)
images := extractPostMedia(pv, embedHidden) images := extractPostMedia(pv, embedHidden)
@@ -370,9 +370,8 @@ func buildReplyNode(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) d
if len(images) > 0 { if len(images) > 0 {
thumb = images[0] thumb = images[0]
} }
return comment{
node := discussionForumPosting{ Type: "Comment",
Type: "DiscussionForumPosting",
URL: bskyPostURLFromATURI(pv.Author.Handle, pv.Uri), URL: bskyPostURLFromATURI(pv.Author.Handle, pv.Uri),
Identifier: pv.Uri, Identifier: pv.Uri,
Author: buildAuthor(pv.Author), Author: buildAuthor(pv.Author),
@@ -381,7 +380,6 @@ func buildReplyNode(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) d
ThumbnailURL: thumb, ThumbnailURL: thumb,
DatePublished: pv.IndexedAt, DatePublished: pv.IndexedAt,
} }
return node
} }
// buildPostJSONLD marshals the top-level WebPage envelope for a post page. // buildPostJSONLD marshals the top-level WebPage envelope for a post page.
+29 -5
View File
@@ -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 := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
*pv.ReplyCount = 14 *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" { if first["identifier"] != "at://did:plc:rep00/app.bsky.feed.post/reply00" {
t.Errorf("first comment should be reply00, got %v", first["identifier"]) 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 { for i, c := range comments {
cm := c.(map[string]any) 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 { if _, present := cm["comment"]; present {
t.Errorf("reply %d should not have nested comment[]", i) 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 { if _, present := cm["sharedContent"]; present {
t.Errorf("reply %d should not have sharedContent", i) 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 { if cm["url"] == nil || cm["identifier"] == nil {
t.Errorf("reply %d missing url/identifier", i) 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) { func TestBuildProfileJSONLD_Basic(t *testing.T) {
pv := &appbsky.ActorDefs_ProfileViewDetailed{ pv := &appbsky.ActorDefs_ProfileViewDetailed{
Did: "did:plc:alice", Did: "did:plc:alice",