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.
//
// 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.
+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.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",