refactor and improve seo schema.org data
This commit is contained in:
@@ -0,0 +1,474 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
appbsky "github.com/bluesky-social/indigo/api/bsky"
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
)
|
||||
|
||||
// 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.
|
||||
|
||||
const schemaOrgContext = "https://schema.org"
|
||||
|
||||
type interactionStat struct {
|
||||
Type string `json:"@type"`
|
||||
InteractionType string `json:"interactionType"`
|
||||
UserInteractionCount int64 `json:"userInteractionCount"`
|
||||
}
|
||||
|
||||
type personOrOrg struct {
|
||||
Type string `json:"@type"`
|
||||
Name string `json:"name,omitempty"`
|
||||
AlternateName string `json:"alternateName,omitempty"`
|
||||
Identifier string `json:"identifier,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
InteractionStat []interactionStat `json:"interactionStatistic,omitempty"`
|
||||
AgentInteractionStat []interactionStat `json:"agentInteractionStatistic,omitempty"`
|
||||
}
|
||||
|
||||
type sharedContent struct {
|
||||
Type string `json:"@type"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
type webPage struct {
|
||||
Context string `json:"@context"`
|
||||
Type string `json:"@type"`
|
||||
URL string `json:"url,omitempty"`
|
||||
MainEntity discussionForumPosting `json:"mainEntity"`
|
||||
}
|
||||
|
||||
type profilePage struct {
|
||||
Context string `json:"@context"`
|
||||
Type string `json:"@type"`
|
||||
DateCreated string `json:"dateCreated,omitempty"`
|
||||
MainEntity *personOrOrg `json:"mainEntity"`
|
||||
HasPart []discussionForumPosting `json:"hasPart,omitempty"`
|
||||
}
|
||||
|
||||
// maxComments is the maximum number of top-level replies emitted in
|
||||
// DiscussionForumPosting.comment[]. Bounded to keep SSR HTML payload small.
|
||||
const maxComments = 10
|
||||
|
||||
// maxRecentPosts is the maximum number of recent posts emitted on a profile
|
||||
// page in ProfilePage.hasPart[].
|
||||
const maxRecentPosts = 10
|
||||
|
||||
// authorFeedFetchLimit is how many entries to request from getAuthorFeed
|
||||
// when populating ProfilePage.hasPart. We oversample because the
|
||||
// posts_no_replies filter still returns reposts (which we drop client-side
|
||||
// — only the author's own posts go in hasPart). A 3x oversample is a safe
|
||||
// margin even for profiles that repost frequently.
|
||||
const authorFeedFetchLimit = 3 * maxRecentPosts
|
||||
|
||||
// bskyPostURL returns the canonical handle-form URL for a post, given the
|
||||
// post's author handle and at-uri. Returns "" if the URI cannot be parsed or
|
||||
// the handle is unusable (handle.invalid).
|
||||
func bskyPostURL(handle, atURI string) string {
|
||||
if handle == "" || handle == "handle.invalid" {
|
||||
return ""
|
||||
}
|
||||
parsed, err := syntax.ParseATURI(atURI)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
rkey := parsed.RecordKey()
|
||||
if rkey == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("https://bsky.app/profile/%s/post/%s", handle, rkey.String())
|
||||
}
|
||||
|
||||
// bskyProfileURL returns the canonical handle-form URL for a profile.
|
||||
// Returns "" if the handle is unusable.
|
||||
func bskyProfileURL(handle string) string {
|
||||
if handle == "" || handle == "handle.invalid" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("https://bsky.app/profile/%s", handle)
|
||||
}
|
||||
|
||||
// extractPostMedia returns (image thumbnail URLs, single thumbnailUrl).
|
||||
// thumbnailUrl is the first image's thumb (or the video thumbnail). All URLs
|
||||
// are reused verbatim from the appview response — the same strings that go
|
||||
// into og:image meta tags — so Google sees byte-identical media references.
|
||||
func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) ([]string, string) {
|
||||
if pv == nil || pv.Embed == nil || embedHidden {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
if pv.Embed.EmbedImages_View != nil {
|
||||
images := pv.Embed.EmbedImages_View.Images
|
||||
if len(images) == 0 {
|
||||
return nil, ""
|
||||
}
|
||||
urls := make([]string, 0, len(images))
|
||||
for _, img := range images {
|
||||
urls = append(urls, img.Thumb)
|
||||
}
|
||||
return urls, urls[0]
|
||||
}
|
||||
if pv.Embed.EmbedVideo_View != nil {
|
||||
if pv.Embed.EmbedVideo_View.Thumbnail != nil {
|
||||
t := *pv.Embed.EmbedVideo_View.Thumbnail
|
||||
return []string{t}, t
|
||||
}
|
||||
return nil, ""
|
||||
}
|
||||
if pv.Embed.EmbedRecordWithMedia_View != nil && pv.Embed.EmbedRecordWithMedia_View.Media != nil {
|
||||
media := pv.Embed.EmbedRecordWithMedia_View.Media
|
||||
if media.EmbedImages_View != nil {
|
||||
images := media.EmbedImages_View.Images
|
||||
if len(images) == 0 {
|
||||
return nil, ""
|
||||
}
|
||||
urls := make([]string, 0, len(images))
|
||||
for _, img := range images {
|
||||
urls = append(urls, img.Thumb)
|
||||
}
|
||||
return urls, urls[0]
|
||||
}
|
||||
if media.EmbedVideo_View != nil && media.EmbedVideo_View.Thumbnail != nil {
|
||||
t := *media.EmbedVideo_View.Thumbnail
|
||||
return []string{t}, t
|
||||
}
|
||||
}
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
// extractQuotedPostURL returns the canonical handle-form URL of a quoted
|
||||
// post, if the embed is a viewable record (not blocked / detached / not
|
||||
// found / non-post record like a feed generator or list).
|
||||
func extractQuotedPostURL(pv *appbsky.FeedDefs_PostView) string {
|
||||
if pv == nil || pv.Embed == nil {
|
||||
return ""
|
||||
}
|
||||
var rec *appbsky.EmbedRecord_View
|
||||
if pv.Embed.EmbedRecord_View != nil {
|
||||
rec = pv.Embed.EmbedRecord_View
|
||||
} else if pv.Embed.EmbedRecordWithMedia_View != nil {
|
||||
rec = pv.Embed.EmbedRecordWithMedia_View.Record
|
||||
}
|
||||
if rec == nil || rec.Record == nil {
|
||||
return ""
|
||||
}
|
||||
vr := rec.Record.EmbedRecord_ViewRecord
|
||||
if vr == nil || vr.Author == nil {
|
||||
// Skip _ViewBlocked, _ViewNotFound, _ViewDetached, and non-post records.
|
||||
return ""
|
||||
}
|
||||
return bskyPostURL(vr.Author.Handle, vr.Uri)
|
||||
}
|
||||
|
||||
// extractSharedContentURL returns the URL of an external link embedded in
|
||||
// the post (or in the media slot of a record-with-media embed).
|
||||
func extractSharedContentURL(pv *appbsky.FeedDefs_PostView) string {
|
||||
if pv == nil || pv.Embed == nil {
|
||||
return ""
|
||||
}
|
||||
if pv.Embed.EmbedExternal_View != nil && pv.Embed.EmbedExternal_View.External != nil {
|
||||
return pv.Embed.EmbedExternal_View.External.Uri
|
||||
}
|
||||
if pv.Embed.EmbedRecordWithMedia_View != nil &&
|
||||
pv.Embed.EmbedRecordWithMedia_View.Media != nil &&
|
||||
pv.Embed.EmbedRecordWithMedia_View.Media.EmbedExternal_View != nil &&
|
||||
pv.Embed.EmbedRecordWithMedia_View.Media.EmbedExternal_View.External != nil {
|
||||
return pv.Embed.EmbedRecordWithMedia_View.Media.EmbedExternal_View.External.Uri
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// buildAuthor constructs a Person object. Organization classification for
|
||||
// custom-domain or organization-style accounts is a future enhancement; for
|
||||
// now, every author is emitted as Person.
|
||||
func buildAuthor(author *appbsky.ActorDefs_ProfileViewBasic) *personOrOrg {
|
||||
if author == nil {
|
||||
return nil
|
||||
}
|
||||
p := &personOrOrg{
|
||||
Type: "Person",
|
||||
}
|
||||
if author.DisplayName != nil && *author.DisplayName != "" {
|
||||
p.Name = *author.DisplayName
|
||||
if author.Handle != "" {
|
||||
p.AlternateName = "@" + author.Handle
|
||||
}
|
||||
} else if author.Handle != "" {
|
||||
p.Name = "@" + author.Handle
|
||||
}
|
||||
if url := bskyProfileURL(author.Handle); url != "" {
|
||||
p.URL = url
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// postEmbedHidden checks self-labels and post-view labels for any label that
|
||||
// causes embeds to be omitted. Mirrors logic in WebPost handler so the
|
||||
// JSON-LD shape stays consistent with og:image emission.
|
||||
func postEmbedHidden(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) bool {
|
||||
if pv == nil {
|
||||
return false
|
||||
}
|
||||
for _, label := range pv.Labels {
|
||||
isNeg := label.Neg != nil && *label.Neg
|
||||
if hideLabels[label.Val] && !isNeg {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if pv.Record != nil {
|
||||
if rec, ok := pv.Record.Val.(*appbsky.FeedPost); ok {
|
||||
if rec.Labels != nil && rec.Labels.LabelDefs_SelfLabels != nil {
|
||||
for _, label := range rec.Labels.LabelDefs_SelfLabels.Values {
|
||||
if hideLabels[label.Val] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// postRecordText returns the expanded post text (with shortened links
|
||||
// expanded back to full URLs) or "" if the record is missing or malformed.
|
||||
func postRecordText(pv *appbsky.FeedDefs_PostView) string {
|
||||
if pv == nil || pv.Record == nil {
|
||||
return ""
|
||||
}
|
||||
rec, ok := pv.Record.Val.(*appbsky.FeedPost)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return ExpandPostText(rec)
|
||||
}
|
||||
|
||||
// buildPostStats returns the standard like/comment/share interaction stat
|
||||
// triple. CommentAction count uses ReplyCount to match what Google expects
|
||||
// in InteractionCounter; commentCount is emitted separately on
|
||||
// DiscussionForumPosting.
|
||||
func buildPostStats(pv *appbsky.FeedDefs_PostView) []interactionStat {
|
||||
if pv == nil {
|
||||
return nil
|
||||
}
|
||||
deref := func(p *int64) int64 {
|
||||
if p == nil {
|
||||
return 0
|
||||
}
|
||||
return *p
|
||||
}
|
||||
return []interactionStat{
|
||||
{Type: "InteractionCounter", InteractionType: "https://schema.org/LikeAction", UserInteractionCount: deref(pv.LikeCount)},
|
||||
{Type: "InteractionCounter", InteractionType: "https://schema.org/CommentAction", UserInteractionCount: deref(pv.ReplyCount)},
|
||||
{Type: "InteractionCounter", InteractionType: "https://schema.org/ShareAction", UserInteractionCount: deref(pv.RepostCount) + deref(pv.QuoteCount)},
|
||||
}
|
||||
}
|
||||
|
||||
// buildPostNode constructs a DiscussionForumPosting (in nested form, no
|
||||
// @context, no envelope). Used both for top-level posts and for entries in
|
||||
// hasPart / comment arrays. Returns the zero value if pv or pv.Author is nil
|
||||
// — callers should treat that as "skip this entry".
|
||||
func buildPostNode(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem, hideLabels map[string]bool) discussionForumPosting {
|
||||
if pv == nil || pv.Author == nil {
|
||||
return discussionForumPosting{}
|
||||
}
|
||||
embedHidden := postEmbedHidden(pv, hideLabels)
|
||||
images, thumb := extractPostMedia(pv, embedHidden)
|
||||
|
||||
node := discussionForumPosting{
|
||||
Type: "DiscussionForumPosting",
|
||||
URL: bskyPostURL(pv.Author.Handle, pv.Uri),
|
||||
Identifier: pv.Uri,
|
||||
Author: buildAuthor(pv.Author),
|
||||
Text: postRecordText(pv),
|
||||
Image: images,
|
||||
ThumbnailURL: thumb,
|
||||
DatePublished: pv.IndexedAt,
|
||||
InteractionStat: buildPostStats(pv),
|
||||
}
|
||||
|
||||
if pv.ReplyCount != nil {
|
||||
node.CommentCount = pv.ReplyCount
|
||||
} else {
|
||||
zero := int64(0)
|
||||
node.CommentCount = &zero
|
||||
}
|
||||
|
||||
if !embedHidden {
|
||||
if quoted := extractQuotedPostURL(pv); quoted != "" {
|
||||
node.IsBasedOn = quoted
|
||||
}
|
||||
if shared := extractSharedContentURL(pv); shared != "" {
|
||||
node.SharedContent = &sharedContent{Type: "WebPage", URL: shared}
|
||||
}
|
||||
}
|
||||
|
||||
for _, r := range replies {
|
||||
if r == nil || r.FeedDefs_ThreadViewPost == nil || r.FeedDefs_ThreadViewPost.Post == nil {
|
||||
continue
|
||||
}
|
||||
if len(node.Comment) >= maxComments {
|
||||
break
|
||||
}
|
||||
reply := buildReplyNode(r.FeedDefs_ThreadViewPost.Post, hideLabels)
|
||||
if reply.Type == "" {
|
||||
// nil-Author guard tripped; skip rather than emit a malformed entry.
|
||||
continue
|
||||
}
|
||||
node.Comment = append(node.Comment, reply)
|
||||
}
|
||||
|
||||
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 {
|
||||
if pv == nil || pv.Author == nil {
|
||||
return discussionForumPosting{}
|
||||
}
|
||||
embedHidden := postEmbedHidden(pv, hideLabels)
|
||||
images, thumb := extractPostMedia(pv, embedHidden)
|
||||
|
||||
node := discussionForumPosting{
|
||||
Type: "DiscussionForumPosting",
|
||||
URL: bskyPostURL(pv.Author.Handle, pv.Uri),
|
||||
Identifier: pv.Uri,
|
||||
Author: buildAuthor(pv.Author),
|
||||
Text: postRecordText(pv),
|
||||
Image: images,
|
||||
ThumbnailURL: thumb,
|
||||
DatePublished: pv.IndexedAt,
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
// buildPostJSONLD marshals the top-level WebPage envelope for a post page.
|
||||
// This is what gets injected into <script type="application/ld+json">.
|
||||
func buildPostJSONLD(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem, canonicalURL string, hideLabels map[string]bool) (string, error) {
|
||||
if pv == nil || pv.Author == nil {
|
||||
return "", fmt.Errorf("nil post view or author")
|
||||
}
|
||||
node := buildPostNode(pv, replies, hideLabels)
|
||||
|
||||
// Top-level entity: wrap in WebPage envelope per Google's recommendation.
|
||||
envelope := webPage{
|
||||
Context: schemaOrgContext,
|
||||
Type: "WebPage",
|
||||
URL: canonicalURL,
|
||||
MainEntity: node,
|
||||
}
|
||||
b, err := json.Marshal(envelope)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// buildProfileJSONLD marshals the ProfilePage object (including hasPart
|
||||
// recent posts) for a profile page.
|
||||
func buildProfileJSONLD(pv *appbsky.ActorDefs_ProfileViewDetailed, recentPosts []*appbsky.FeedDefs_PostView, hideLabels map[string]bool) (string, error) {
|
||||
if pv == nil {
|
||||
return "", fmt.Errorf("nil profile view")
|
||||
}
|
||||
|
||||
person := &personOrOrg{
|
||||
Type: "Person",
|
||||
Identifier: pv.Did,
|
||||
}
|
||||
if pv.DisplayName != nil && *pv.DisplayName != "" {
|
||||
person.Name = *pv.DisplayName
|
||||
person.AlternateName = "@" + pv.Handle
|
||||
} else {
|
||||
person.Name = "@" + pv.Handle
|
||||
}
|
||||
if pv.Description != nil {
|
||||
person.Description = *pv.Description
|
||||
}
|
||||
if pv.Avatar != nil {
|
||||
person.Image = *pv.Avatar
|
||||
}
|
||||
|
||||
deref := func(p *int64) int64 {
|
||||
if p == nil {
|
||||
return 0
|
||||
}
|
||||
return *p
|
||||
}
|
||||
person.InteractionStat = []interactionStat{
|
||||
{Type: "InteractionCounter", InteractionType: "https://schema.org/FollowAction", UserInteractionCount: deref(pv.FollowersCount)},
|
||||
}
|
||||
person.AgentInteractionStat = []interactionStat{
|
||||
{Type: "InteractionCounter", InteractionType: "https://schema.org/FollowAction", UserInteractionCount: deref(pv.FollowsCount)},
|
||||
{Type: "InteractionCounter", InteractionType: "https://schema.org/WriteAction", UserInteractionCount: deref(pv.PostsCount)},
|
||||
}
|
||||
|
||||
page := profilePage{
|
||||
Context: schemaOrgContext,
|
||||
Type: "ProfilePage",
|
||||
MainEntity: person,
|
||||
}
|
||||
if pv.CreatedAt != nil {
|
||||
page.DateCreated = *pv.CreatedAt
|
||||
}
|
||||
|
||||
for _, rp := range recentPosts {
|
||||
if rp == nil {
|
||||
continue
|
||||
}
|
||||
if len(page.HasPart) >= maxRecentPosts {
|
||||
break
|
||||
}
|
||||
// Recent posts go in nested form (no replies, no envelope).
|
||||
node := buildPostNode(rp, nil, hideLabels)
|
||||
if node.Type == "" {
|
||||
// nil-Author guard tripped; skip.
|
||||
continue
|
||||
}
|
||||
page.HasPart = append(page.HasPart, node)
|
||||
}
|
||||
|
||||
b, err := json.Marshal(page)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
comatprototypes "github.com/bluesky-social/indigo/api/atproto"
|
||||
appbsky "github.com/bluesky-social/indigo/api/bsky"
|
||||
lexutil "github.com/bluesky-social/indigo/lex/util"
|
||||
)
|
||||
|
||||
// strPtr / intPtr / boolPtr - small helpers for the optional appbsky fields.
|
||||
func strPtr(s string) *string { return &s }
|
||||
func intPtr(i int64) *int64 { return &i }
|
||||
|
||||
// newProfileViewDetailed returns a populated ProfileViewDetailed for tests.
|
||||
func newProfileViewDetailed() *appbsky.ActorDefs_ProfileViewDetailed {
|
||||
return &appbsky.ActorDefs_ProfileViewDetailed{
|
||||
Did: "did:plc:alice",
|
||||
Handle: "alice.bsky.social",
|
||||
DisplayName: strPtr("Alice"),
|
||||
Description: strPtr("just a person"),
|
||||
Avatar: strPtr("https://cdn.bsky.app/img/avatar/plain/a@jpeg"),
|
||||
FollowersCount: intPtr(100),
|
||||
FollowsCount: intPtr(50),
|
||||
PostsCount: intPtr(200),
|
||||
CreatedAt: strPtr("2023-01-01T00:00:00Z"),
|
||||
}
|
||||
}
|
||||
|
||||
// makePostView builds a minimal valid post view for tests.
|
||||
func makePostView(handle, did, rkey, text string, opts ...func(*appbsky.FeedDefs_PostView)) *appbsky.FeedDefs_PostView {
|
||||
uri := "at://" + did + "/app.bsky.feed.post/" + rkey
|
||||
pv := &appbsky.FeedDefs_PostView{
|
||||
Uri: uri,
|
||||
Cid: "bafy-test",
|
||||
IndexedAt: "2024-01-02T03:04:05Z",
|
||||
LikeCount: intPtr(7),
|
||||
ReplyCount: intPtr(3),
|
||||
RepostCount: intPtr(2),
|
||||
QuoteCount: intPtr(1),
|
||||
Author: &appbsky.ActorDefs_ProfileViewBasic{
|
||||
Did: did,
|
||||
Handle: handle,
|
||||
DisplayName: strPtr("Test User"),
|
||||
},
|
||||
Record: &lexutil.LexiconTypeDecoder{
|
||||
Val: &appbsky.FeedPost{
|
||||
Text: text,
|
||||
CreatedAt: "2024-01-02T03:04:05Z",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(pv)
|
||||
}
|
||||
return pv
|
||||
}
|
||||
|
||||
// withImages adds a simple images embed.
|
||||
func withImages(thumbs ...string) func(*appbsky.FeedDefs_PostView) {
|
||||
return func(pv *appbsky.FeedDefs_PostView) {
|
||||
var images []*appbsky.EmbedImages_ViewImage
|
||||
for _, t := range thumbs {
|
||||
images = append(images, &appbsky.EmbedImages_ViewImage{Thumb: t, Fullsize: t + "_full"})
|
||||
}
|
||||
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
|
||||
EmbedImages_View: &appbsky.EmbedImages_View{Images: images},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// withVideo adds a video embed with a thumbnail.
|
||||
func withVideo(thumb string) func(*appbsky.FeedDefs_PostView) {
|
||||
return func(pv *appbsky.FeedDefs_PostView) {
|
||||
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
|
||||
EmbedVideo_View: &appbsky.EmbedVideo_View{Thumbnail: strPtr(thumb)},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// withExternalEmbed adds an external link embed.
|
||||
func withExternalEmbed(uri, title string) func(*appbsky.FeedDefs_PostView) {
|
||||
return func(pv *appbsky.FeedDefs_PostView) {
|
||||
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
|
||||
EmbedExternal_View: &appbsky.EmbedExternal_View{
|
||||
External: &appbsky.EmbedExternal_ViewExternal{Uri: uri, Title: title, Description: "desc"},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// withQuotePost adds a record (quote-post) embed of the given target post.
|
||||
func withQuotePost(qHandle, qDid, qRkey string) func(*appbsky.FeedDefs_PostView) {
|
||||
return func(pv *appbsky.FeedDefs_PostView) {
|
||||
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
|
||||
EmbedRecord_View: &appbsky.EmbedRecord_View{
|
||||
Record: &appbsky.EmbedRecord_View_Record{
|
||||
EmbedRecord_ViewRecord: &appbsky.EmbedRecord_ViewRecord{
|
||||
Uri: "at://" + qDid + "/app.bsky.feed.post/" + qRkey,
|
||||
Cid: "bafy-quoted",
|
||||
Author: &appbsky.ActorDefs_ProfileViewBasic{
|
||||
Did: qDid,
|
||||
Handle: qHandle,
|
||||
},
|
||||
IndexedAt: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// withQuotePostBlocked adds a blocked-record embed (should NOT produce isBasedOn).
|
||||
func withQuotePostBlocked() func(*appbsky.FeedDefs_PostView) {
|
||||
return func(pv *appbsky.FeedDefs_PostView) {
|
||||
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
|
||||
EmbedRecord_View: &appbsky.EmbedRecord_View{
|
||||
Record: &appbsky.EmbedRecord_View_Record{
|
||||
EmbedRecord_ViewBlocked: &appbsky.EmbedRecord_ViewBlocked{
|
||||
Uri: "at://did:plc:blocked/app.bsky.feed.post/x",
|
||||
Blocked: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// withSelfLabel adds a self-label that should hide embeds.
|
||||
func withSelfLabel(val string) func(*appbsky.FeedDefs_PostView) {
|
||||
return func(pv *appbsky.FeedDefs_PostView) {
|
||||
rec, _ := pv.Record.Val.(*appbsky.FeedPost)
|
||||
rec.Labels = &appbsky.FeedPost_Labels{
|
||||
LabelDefs_SelfLabels: &comatprototypes.LabelDefs_SelfLabels{
|
||||
Values: []*comatprototypes.LabelDefs_SelfLabel{{Val: val}},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// unmarshalLD parses the JSON-LD blob produced by buildPostJSONLD.
|
||||
func unmarshalLD(t *testing.T, s string) map[string]any {
|
||||
t.Helper()
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal([]byte(s), &out); err != nil {
|
||||
t.Fatalf("invalid JSON-LD: %v\n%s", err, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_Bare(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
|
||||
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
|
||||
out, err := buildPostJSONLD(pv, nil, canonical, hideEmbedLabels)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
envelope := unmarshalLD(t, out)
|
||||
if envelope["@context"] != "https://schema.org" {
|
||||
t.Errorf("@context wrong: %v", envelope["@context"])
|
||||
}
|
||||
if envelope["@type"] != "WebPage" {
|
||||
t.Errorf("@type should be WebPage, got %v", envelope["@type"])
|
||||
}
|
||||
if envelope["url"] != canonical {
|
||||
t.Errorf("url should be canonical, got %v", envelope["url"])
|
||||
}
|
||||
main, ok := envelope["mainEntity"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("mainEntity missing")
|
||||
}
|
||||
if main["@type"] != "DiscussionForumPosting" {
|
||||
t.Errorf("mainEntity @type wrong: %v", main["@type"])
|
||||
}
|
||||
// nested entity should NOT have @context (Google's preferred shape).
|
||||
if _, present := main["@context"]; present {
|
||||
t.Errorf("nested mainEntity should not have @context")
|
||||
}
|
||||
if main["url"] != canonical {
|
||||
t.Errorf("post url wrong: %v", main["url"])
|
||||
}
|
||||
if main["identifier"] != pv.Uri {
|
||||
t.Errorf("identifier should be at-uri: %v", main["identifier"])
|
||||
}
|
||||
if main["text"] != "hello" {
|
||||
t.Errorf("text wrong: %v", main["text"])
|
||||
}
|
||||
if main["datePublished"] != "2024-01-02T03:04:05Z" {
|
||||
t.Errorf("datePublished wrong: %v", main["datePublished"])
|
||||
}
|
||||
// commentCount should always be emitted, even at zero.
|
||||
cc, ok := main["commentCount"].(float64)
|
||||
if !ok || int64(cc) != 3 {
|
||||
t.Errorf("commentCount wrong: %v", main["commentCount"])
|
||||
}
|
||||
// no images on bare post
|
||||
if _, present := main["image"]; present {
|
||||
t.Errorf("bare post should not have image")
|
||||
}
|
||||
if _, present := main["thumbnailUrl"]; present {
|
||||
t.Errorf("bare post should not have thumbnailUrl")
|
||||
}
|
||||
if _, present := main["isBasedOn"]; present {
|
||||
t.Errorf("bare post should not have isBasedOn")
|
||||
}
|
||||
if _, present := main["sharedContent"]; present {
|
||||
t.Errorf("bare post should not have sharedContent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_WithImages(t *testing.T) {
|
||||
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/abc@jpeg"
|
||||
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/def@jpeg"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "look", withImages(thumb1, thumb2))
|
||||
out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
imgs, ok := main["image"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("image should be array, got %T", main["image"])
|
||||
}
|
||||
if len(imgs) != 2 {
|
||||
t.Errorf("expected 2 images, got %d", len(imgs))
|
||||
}
|
||||
if imgs[0] != thumb1 {
|
||||
t.Errorf("image[0] should equal first thumb, got %v", imgs[0])
|
||||
}
|
||||
if main["thumbnailUrl"] != thumb1 {
|
||||
t.Errorf("thumbnailUrl should equal image[0] (Google byte-equality requirement), got %v", main["thumbnailUrl"])
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
if main["thumbnailUrl"] != thumb {
|
||||
t.Errorf("video thumbnailUrl wrong: %v", main["thumbnailUrl"])
|
||||
}
|
||||
imgs := main["image"].([]any)
|
||||
if len(imgs) != 1 || imgs[0] != thumb {
|
||||
t.Errorf("image[0] should be video thumb, got %v", imgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_QuotePost(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quoting!", withQuotePost("bob.example.com", "did:plc:bob", "xyz"))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
if main["isBasedOn"] != "https://bsky.app/profile/bob.example.com/post/xyz" {
|
||||
t.Errorf("isBasedOn wrong: %v", main["isBasedOn"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_QuoteBlocked(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quoting blocked", withQuotePostBlocked())
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
if _, present := main["isBasedOn"]; present {
|
||||
t.Errorf("blocked quote should not produce isBasedOn")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_ExternalEmbed(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "check this out", withExternalEmbed("https://www.spiegel.de/article", "Title"))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
sc, ok := main["sharedContent"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("sharedContent missing")
|
||||
}
|
||||
if sc["@type"] != "WebPage" {
|
||||
t.Errorf("sharedContent type wrong: %v", sc["@type"])
|
||||
}
|
||||
if sc["url"] != "https://www.spiegel.de/article" {
|
||||
t.Errorf("sharedContent url wrong: %v", sc["url"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_HiddenEmbed(t *testing.T) {
|
||||
thumb := "https://cdn.bsky.app/img/x@jpeg"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw",
|
||||
withImages(thumb), withSelfLabel("porn"))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
if _, present := main["image"]; present {
|
||||
t.Errorf("hidden-embed post should not emit image")
|
||||
}
|
||||
if _, present := main["thumbnailUrl"]; present {
|
||||
t.Errorf("hidden-embed post should not emit thumbnailUrl")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_TextEscaping(t *testing.T) {
|
||||
// Crafted to break naive string concatenation: includes ", \, newline,
|
||||
// </script>, and a unicode character.
|
||||
tricky := "hello \"world\" \\ <\\>\n</script> 🎉"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", tricky)
|
||||
out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Must round-trip through the JSON parser.
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
if main["text"] != tricky {
|
||||
t.Errorf("text round-trip failed: got %q want %q", main["text"], tricky)
|
||||
}
|
||||
// Defensive: literal "</script>" must not appear in the output, since
|
||||
// that would break out of <script type="application/ld+json">.
|
||||
if strings.Contains(out, "</script>") {
|
||||
t.Errorf("output contains literal </script>, would break HTML embedding")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_Replies(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
|
||||
*pv.ReplyCount = 14
|
||||
|
||||
// 14 replies: 12 valid, 1 not-found, 1 blocked. The cap is maxComments=10
|
||||
// so we expect exactly 10 valid entries in comment[], with the
|
||||
// non-thread variants filtered out.
|
||||
const validReplies = 12
|
||||
var replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem
|
||||
for i := 0; i < validReplies; i++ {
|
||||
rkey := fmt.Sprintf("reply%02d", i)
|
||||
reply := makePostView(fmt.Sprintf("rep%02d.bsky.social", i),
|
||||
fmt.Sprintf("did:plc:rep%02d", i), rkey, "reply text")
|
||||
replies = append(replies, &appbsky.FeedDefs_ThreadViewPost_Replies_Elem{
|
||||
FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: reply},
|
||||
})
|
||||
}
|
||||
replies = append(replies, &appbsky.FeedDefs_ThreadViewPost_Replies_Elem{
|
||||
FeedDefs_NotFoundPost: &appbsky.FeedDefs_NotFoundPost{Uri: "at://x/y/z"},
|
||||
})
|
||||
replies = append(replies, &appbsky.FeedDefs_ThreadViewPost_Replies_Elem{
|
||||
FeedDefs_BlockedPost: &appbsky.FeedDefs_BlockedPost{Uri: "at://x/y/z"},
|
||||
})
|
||||
|
||||
out, _ := buildPostJSONLD(pv, replies, "u", hideEmbedLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
|
||||
if cc := main["commentCount"].(float64); int64(cc) != 14 {
|
||||
t.Errorf("commentCount should reflect ReplyCount, got %v", cc)
|
||||
}
|
||||
comments, ok := main["comment"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("comment array missing")
|
||||
}
|
||||
if len(comments) != maxComments {
|
||||
t.Errorf("expected %d comments (capped from %d valid), got %d", maxComments, validReplies, len(comments))
|
||||
}
|
||||
// First comment must be the first reply (FIFO order, not the not-found
|
||||
// one which sits at the end).
|
||||
first := comments[0].(map[string]any)
|
||||
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.
|
||||
for i, c := range comments {
|
||||
cm := c.(map[string]any)
|
||||
if _, present := cm["comment"]; present {
|
||||
t.Errorf("reply %d should not have nested comment[]", i)
|
||||
}
|
||||
if _, present := cm["isBasedOn"]; present {
|
||||
t.Errorf("reply %d should not have isBasedOn", i)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_HandleInvalidAuthor(t *testing.T) {
|
||||
pv := makePostView("handle.invalid", "did:plc:alice", "abc123", "hello")
|
||||
out, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/did:plc:alice/post/abc123", hideEmbedLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
// With no usable handle, post-level url should be omitted.
|
||||
if _, present := main["url"]; present {
|
||||
t.Errorf("handle.invalid author should not produce post url")
|
||||
}
|
||||
// Author URL also omitted.
|
||||
author := main["author"].(map[string]any)
|
||||
if _, present := author["url"]; present {
|
||||
t.Errorf("handle.invalid author should not produce author.url")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_NilAuthor(t *testing.T) {
|
||||
// Defensive: appview is contractually required to send an Author, but we
|
||||
// shouldn't panic if a malformed payload sneaks through.
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
|
||||
pv.Author = nil
|
||||
if _, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels); err == nil {
|
||||
t.Errorf("expected error for nil-author post, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_NilAuthorReply(t *testing.T) {
|
||||
// A reply with a nil author should be silently dropped from comment[]
|
||||
// rather than producing an entry with no @type.
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
|
||||
*pv.ReplyCount = 2
|
||||
|
||||
goodReply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "good")
|
||||
badReply := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "bad")
|
||||
badReply.Author = nil
|
||||
|
||||
replies := []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem{
|
||||
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: goodReply}},
|
||||
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: badReply}},
|
||||
}
|
||||
out, err := buildPostJSONLD(pv, replies, "u", hideEmbedLabels)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
comments, _ := main["comment"].([]any)
|
||||
if len(comments) != 1 {
|
||||
t.Errorf("expected 1 comment (nil-Author dropped), got %d", len(comments))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProfileJSONLD_Basic(t *testing.T) {
|
||||
pv := &appbsky.ActorDefs_ProfileViewDetailed{
|
||||
Did: "did:plc:alice",
|
||||
Handle: "alice.bsky.social",
|
||||
DisplayName: strPtr("Alice"),
|
||||
Description: strPtr("just a person"),
|
||||
Avatar: strPtr("https://cdn.bsky.app/img/avatar/plain/a@jpeg"),
|
||||
FollowersCount: intPtr(100),
|
||||
FollowsCount: intPtr(50),
|
||||
PostsCount: intPtr(200),
|
||||
CreatedAt: strPtr("2023-01-01T00:00:00Z"),
|
||||
}
|
||||
out, err := buildProfileJSONLD(pv, nil, hideEmbedLabels)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page := unmarshalLD(t, out)
|
||||
if page["@context"] != "https://schema.org" {
|
||||
t.Errorf("@context wrong")
|
||||
}
|
||||
if page["@type"] != "ProfilePage" {
|
||||
t.Errorf("@type wrong: %v", page["@type"])
|
||||
}
|
||||
main := page["mainEntity"].(map[string]any)
|
||||
if main["@type"] != "Person" {
|
||||
t.Errorf("mainEntity @type wrong: %v", main["@type"])
|
||||
}
|
||||
if main["name"] != "Alice" {
|
||||
t.Errorf("name wrong: %v", main["name"])
|
||||
}
|
||||
if main["alternateName"] != "@alice.bsky.social" {
|
||||
t.Errorf("alternateName wrong: %v", main["alternateName"])
|
||||
}
|
||||
if main["identifier"] != "did:plc:alice" {
|
||||
t.Errorf("identifier wrong: %v", main["identifier"])
|
||||
}
|
||||
if _, present := page["hasPart"]; present {
|
||||
t.Errorf("empty recentPosts should not produce hasPart")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProfileJSONLD_HasPart(t *testing.T) {
|
||||
pv := &appbsky.ActorDefs_ProfileViewDetailed{
|
||||
Did: "did:plc:alice", Handle: "alice.bsky.social",
|
||||
DisplayName: strPtr("Alice"),
|
||||
CreatedAt: strPtr("2023-01-01T00:00:00Z"),
|
||||
}
|
||||
var posts []*appbsky.FeedDefs_PostView
|
||||
for i := 0; i < 12; i++ {
|
||||
posts = append(posts, makePostView("alice.bsky.social", "did:plc:alice",
|
||||
"r"+string(rune('0'+i)), "post"))
|
||||
}
|
||||
out, _ := buildProfileJSONLD(pv, posts, hideEmbedLabels)
|
||||
page := unmarshalLD(t, out)
|
||||
hp, ok := page["hasPart"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("hasPart missing")
|
||||
}
|
||||
if len(hp) != maxRecentPosts {
|
||||
t.Errorf("hasPart should be capped at %d, got %d", maxRecentPosts, len(hp))
|
||||
}
|
||||
first := hp[0].(map[string]any)
|
||||
if first["@type"] != "DiscussionForumPosting" {
|
||||
t.Errorf("hasPart entries should be DiscussionForumPosting, got %v", first["@type"])
|
||||
}
|
||||
if _, present := first["@context"]; present {
|
||||
t.Errorf("nested hasPart entries should not have @context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBskyPostURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, handle, atURI, want string
|
||||
}{
|
||||
{"valid", "alice.bsky.social", "at://did:plc:alice/app.bsky.feed.post/abc", "https://bsky.app/profile/alice.bsky.social/post/abc"},
|
||||
{"empty handle", "", "at://did:plc:alice/app.bsky.feed.post/abc", ""},
|
||||
{"handle.invalid", "handle.invalid", "at://did:plc:alice/app.bsky.feed.post/abc", ""},
|
||||
{"bad uri", "alice.bsky.social", "not-an-aturi", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := bskyPostURL(tt.handle, tt.atURI); got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBskyProfileURL(t *testing.T) {
|
||||
if bskyProfileURL("alice.bsky.social") != "https://bsky.app/profile/alice.bsky.social" {
|
||||
t.Errorf("valid handle wrong")
|
||||
}
|
||||
if bskyProfileURL("handle.invalid") != "" {
|
||||
t.Errorf("handle.invalid should produce empty")
|
||||
}
|
||||
if bskyProfileURL("") != "" {
|
||||
t.Errorf("empty handle should produce empty")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/bluesky-social/social-app/bskyweb"
|
||||
"github.com/flosch/pongo2/v6"
|
||||
)
|
||||
|
||||
// renderTemplate executes a template by name with the given context and
|
||||
// returns the rendered output. Uses the same renderer plumbing as the live
|
||||
// server so we exercise the real template loader.
|
||||
//
|
||||
// post.html and profile.html both extend base.html, which {% include %}s
|
||||
// templates/scripts.html — a file generated by the React/Vite web build.
|
||||
// When that file is absent (e.g. running `go test` in a fresh checkout
|
||||
// without a prior `yarn build-web`), template loading fails. Skip rather
|
||||
// than fail in that case.
|
||||
func renderTemplate(t *testing.T, name string, ctx pongo2.Context) string {
|
||||
t.Helper()
|
||||
if _, err := bskyweb.TemplateFS.ReadFile("templates/scripts.html"); err != nil {
|
||||
t.Skip("templates/scripts.html not present (run yarn build-web first); skipping render tests")
|
||||
}
|
||||
r := NewRenderer("templates/", &bskyweb.TemplateFS, false)
|
||||
tmpl, err := r.TemplateSet.FromCache(name)
|
||||
if err != nil {
|
||||
t.Fatalf("template load %q: %v", name, err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.ExecuteWriter(ctx, &buf); err != nil {
|
||||
t.Fatalf("template execute %q: %v", name, err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
var jsonLDRe = regexp.MustCompile(`(?s)<script type="application/ld\+json">(.*?)</script>`)
|
||||
|
||||
// extractJSONLD pulls out the body of the <script type="application/ld+json">
|
||||
// block from rendered HTML. Asserts the block exists.
|
||||
func extractJSONLD(t *testing.T, html string) string {
|
||||
t.Helper()
|
||||
m := jsonLDRe.FindStringSubmatch(html)
|
||||
if m == nil {
|
||||
t.Fatalf("no application/ld+json script tag in rendered HTML:\n%s", html)
|
||||
}
|
||||
return strings.TrimSpace(m[1])
|
||||
}
|
||||
|
||||
func TestRenderPost_EmitsJSONLD(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
|
||||
ld, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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,
|
||||
})
|
||||
|
||||
body := extractJSONLD(t, html)
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(body), &parsed); err != nil {
|
||||
t.Fatalf("rendered JSON-LD does not parse: %v\n%s", err, body)
|
||||
}
|
||||
if parsed["@type"] != "WebPage" {
|
||||
t.Errorf("expected WebPage envelope, got %v", parsed["@type"])
|
||||
}
|
||||
// Verify canonical link is the handle-form URL (not the request URI
|
||||
// canonicalized).
|
||||
if !strings.Contains(html, `<link rel="canonical" href="https://bsky.app/profile/alice.bsky.social/post/abc123" />`) {
|
||||
t.Errorf("canonical link missing or wrong:\n%s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPost_OGImageMatchesJSONLD(t *testing.T) {
|
||||
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/abc@jpeg"
|
||||
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/def@jpeg"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "look", withImages(thumb1, thumb2))
|
||||
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels)
|
||||
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,
|
||||
// Mirrors what server.go puts in: og:image meta tags use imgThumbUrls.
|
||||
"imgThumbUrls": []string{thumb1, thumb2},
|
||||
})
|
||||
|
||||
// og:image must use the same URLs as JSON-LD's image[] (Google byte-equality requirement).
|
||||
if !strings.Contains(html, `<meta property="og:image" content="`+thumb1+`">`) {
|
||||
t.Errorf("og:image[0] not found in rendered HTML")
|
||||
}
|
||||
body := extractJSONLD(t, html)
|
||||
var parsed map[string]any
|
||||
_ = json.Unmarshal([]byte(body), &parsed)
|
||||
main := parsed["mainEntity"].(map[string]any)
|
||||
imgs := main["image"].([]any)
|
||||
if imgs[0] != thumb1 || main["thumbnailUrl"] != thumb1 {
|
||||
t.Errorf("JSON-LD image strings drifted from og:image; image[0]=%v thumbnailUrl=%v",
|
||||
imgs[0], main["thumbnailUrl"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPost_FallsBackToCanonicalizeFilter(t *testing.T) {
|
||||
// When canonicalURL is not set, template should fall back to
|
||||
// requestURI|canonicalize_url.
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
|
||||
ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels)
|
||||
html := renderTemplate(t, "post.html", pongo2.Context{
|
||||
"postView": pv,
|
||||
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123?utm=foo",
|
||||
"postJSONLD": ld,
|
||||
})
|
||||
if !strings.Contains(html, `<link rel="canonical" href="https://bsky.app/profile/alice.bsky.social/post/abc123" />`) {
|
||||
t.Errorf("expected canonicalize_url filter to strip query; got:\n%s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderProfile_EmitsJSONLD(t *testing.T) {
|
||||
pv := newProfileViewDetailed()
|
||||
ld, err := buildProfileJSONLD(pv, nil, hideEmbedLabels)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
html := renderTemplate(t, "profile.html", pongo2.Context{
|
||||
"profileView": pv,
|
||||
"requestURI": "https://bsky.app/profile/alice.bsky.social",
|
||||
"canonicalURL": "https://bsky.app/profile/alice.bsky.social",
|
||||
"profileJSONLD": ld,
|
||||
})
|
||||
|
||||
body := extractJSONLD(t, html)
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(body), &parsed); err != nil {
|
||||
t.Fatalf("rendered JSON-LD does not parse: %v\n%s", err, body)
|
||||
}
|
||||
if parsed["@type"] != "ProfilePage" {
|
||||
t.Errorf("expected ProfilePage, got %v", parsed["@type"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderProfile_AuthRequiredEmitsJSONLD confirms that auth-required
|
||||
// profiles still emit ProfilePage / Person structured data (without
|
||||
// hasPart). Previously this regressed when WebProfile was refactored to
|
||||
// short-circuit before buildProfileJSONLD; the regression test guards
|
||||
// against re-introducing that.
|
||||
func TestRenderProfile_AuthRequiredEmitsJSONLD(t *testing.T) {
|
||||
pv := newProfileViewDetailed()
|
||||
ld, err := buildProfileJSONLD(pv, nil, hideEmbedLabels)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
html := renderTemplate(t, "profile.html", pongo2.Context{
|
||||
"profileView": pv,
|
||||
"requestURI": "https://bsky.app/profile/alice.bsky.social",
|
||||
"requiresAuth": true,
|
||||
"profileJSONLD": ld,
|
||||
})
|
||||
|
||||
body := extractJSONLD(t, html)
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(body), &parsed); err != nil {
|
||||
t.Fatalf("rendered JSON-LD does not parse: %v\n%s", err, body)
|
||||
}
|
||||
if parsed["@type"] != "ProfilePage" {
|
||||
t.Errorf("expected ProfilePage, got %v", parsed["@type"])
|
||||
}
|
||||
if _, present := parsed["hasPart"]; present {
|
||||
t.Errorf("auth-required profile should not have hasPart")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderPost_OGUrlMatchesCanonical confirms that og:url and
|
||||
// <link rel="canonical"> emit the same URL when canonicalURL is set, so
|
||||
// social cards and search engines see a consistent reference.
|
||||
func TestRenderPost_OGUrlMatchesCanonical(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
|
||||
ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels)
|
||||
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
|
||||
html := renderTemplate(t, "post.html", pongo2.Context{
|
||||
"postView": pv,
|
||||
"requestURI": "https://bsky.app/profile/did:plc:alice/post/abc123",
|
||||
"canonicalURL": canonical,
|
||||
"postJSONLD": ld,
|
||||
})
|
||||
if !strings.Contains(html, `<meta property="og:url" content="`+canonical+`">`) {
|
||||
t.Errorf("og:url should equal canonical URL when set; got:\n%s", html)
|
||||
}
|
||||
if !strings.Contains(html, `<link rel="canonical" href="`+canonical+`" />`) {
|
||||
t.Errorf("canonical link missing or wrong:\n%s", html)
|
||||
}
|
||||
// Inverse: the request URI (DID form) should NOT appear as og:url.
|
||||
if strings.Contains(html, `<meta property="og:url" content="https://bsky.app/profile/did:plc:alice/post/abc123">`) {
|
||||
t.Errorf("og:url should not echo DID-form request URI when canonical is set")
|
||||
}
|
||||
}
|
||||
@@ -532,9 +532,23 @@ func (srv *Server) WebPost(c echo.Context) error {
|
||||
}
|
||||
|
||||
req := c.Request()
|
||||
requestURI := fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
|
||||
|
||||
// always prefer the handle-form URL when we have a usable handle, regardless of how the request was looked up.
|
||||
// This both handles DID-form requests and normalizes handle-form requests against stale handles in the URL,
|
||||
// guaranteeing JSON-LD `url` and <link rel="canonical"> match exactly.
|
||||
// If the handle is unusable (handle.invalid or empty), fall back to the request URI with query/fragment stripped.
|
||||
canonicalURL := ""
|
||||
if pv.Handle != "" && pv.Handle != "handle.invalid" {
|
||||
canonicalURL = fmt.Sprintf("https://bsky.app/profile/%s/post/%s", pv.Handle, rkey)
|
||||
}
|
||||
|
||||
if !unauthedViewingOkay {
|
||||
// Provide minimal OpenGraph data for auth-required posts
|
||||
data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
|
||||
data["requestURI"] = requestURI
|
||||
if canonicalURL != "" {
|
||||
data["canonicalURL"] = canonicalURL
|
||||
}
|
||||
data["requiresAuth"] = true
|
||||
data["profileHandle"] = pv.Handle
|
||||
if pv.DisplayName != nil {
|
||||
@@ -551,9 +565,16 @@ func (srv *Server) WebPost(c echo.Context) error {
|
||||
return c.Render(http.StatusOK, "post.html", data)
|
||||
}
|
||||
|
||||
postView := tpv.Thread.FeedDefs_ThreadViewPost.Post
|
||||
threadView := tpv.Thread.FeedDefs_ThreadViewPost
|
||||
if threadView == nil || threadView.Post == nil {
|
||||
return c.Render(http.StatusOK, "post.html", data)
|
||||
}
|
||||
postView := threadView.Post
|
||||
data["postView"] = postView
|
||||
data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
|
||||
data["requestURI"] = requestURI
|
||||
if canonicalURL != "" {
|
||||
data["canonicalURL"] = canonicalURL
|
||||
}
|
||||
|
||||
// If any undesirable labels are set, the embed will not be included in
|
||||
// metadata
|
||||
@@ -628,6 +649,19 @@ func (srv *Server) WebPost(c echo.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Build schema.org JSON-LD for SEO. canonicalURL is what we want as the
|
||||
// public URL; if it's empty (handle.invalid edge case), fall back to the
|
||||
// request URI so the field still gets emitted with something sensible.
|
||||
jsonldURL := canonicalURL
|
||||
if jsonldURL == "" {
|
||||
jsonldURL = requestURI
|
||||
}
|
||||
if jsonld, err := buildPostJSONLD(postView, threadView.Replies, jsonldURL, hideEmbedLabels); err == nil {
|
||||
data["postJSONLD"] = jsonld
|
||||
} else {
|
||||
log.Warnf("failed to build post JSON-LD for %s: %v", uri, err)
|
||||
}
|
||||
|
||||
return c.Render(http.StatusOK, "post.html", data)
|
||||
}
|
||||
|
||||
@@ -681,6 +715,7 @@ func (srv *Server) WebProfile(c echo.Context) error {
|
||||
return c.Render(http.StatusOK, "profile.html", data)
|
||||
}
|
||||
identifier := handleOrDID.Normalize().String()
|
||||
isDIDInput := handleOrDID.IsDID()
|
||||
|
||||
pv, err := appbsky.ActorGetProfile(ctx, srv.xrpcc, identifier)
|
||||
if err != nil {
|
||||
@@ -699,10 +734,53 @@ func (srv *Server) WebProfile(c echo.Context) error {
|
||||
data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
|
||||
data["requestHost"] = req.Host
|
||||
|
||||
if !unauthedViewingOkay {
|
||||
// Canonical URL: when looked up by DID and we have a usable handle,
|
||||
// redirect search engines to the handle-form URL.
|
||||
if isDIDInput && pv.Handle != "" && pv.Handle != "handle.invalid" {
|
||||
data["canonicalURL"] = fmt.Sprintf("https://bsky.app/profile/%s", pv.Handle)
|
||||
}
|
||||
|
||||
// Fetch recent posts to embed as ProfilePage.hasPart so search engines
|
||||
// can connect a profile to its recent content. Failures here degrade
|
||||
// gracefully — we still render the profile without hasPart.
|
||||
//
|
||||
// Skipped for auth-required profiles (their posts aren't publicly
|
||||
// indexable anyway), but the rest of the ProfilePage / Person markup
|
||||
// is still emitted so search engines see basic identity.
|
||||
//
|
||||
// NOTE: this adds an extra XRPC call on every public profile page
|
||||
// render. If upstream load becomes a concern, consider caching
|
||||
// per-profile (recent posts change slowly relative to profile views).
|
||||
var recentPosts []*appbsky.FeedDefs_PostView
|
||||
if unauthedViewingOkay {
|
||||
af, err := appbsky.FeedGetAuthorFeed(ctx, srv.xrpcc, pv.Did, "", "posts_no_replies", false, authorFeedFetchLimit)
|
||||
if err != nil {
|
||||
log.Warnf("failed to fetch author feed for: %s\t%v", pv.Did, err)
|
||||
} else {
|
||||
for _, p := range af.Feed {
|
||||
if p == nil || p.Post == nil {
|
||||
continue
|
||||
}
|
||||
// Only the author's own posts (matches RSS handler behavior).
|
||||
if p.Post.Author == nil || p.Post.Author.Did != pv.Did {
|
||||
continue
|
||||
}
|
||||
recentPosts = append(recentPosts, p.Post)
|
||||
if len(recentPosts) >= maxRecentPosts {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
data["requiresAuth"] = true
|
||||
}
|
||||
|
||||
if jsonld, err := buildProfileJSONLD(pv, recentPosts, hideEmbedLabels); err == nil {
|
||||
data["profileJSONLD"] = jsonld
|
||||
} else {
|
||||
log.Warnf("failed to build profile JSON-LD for %s: %v", pv.Did, err)
|
||||
}
|
||||
|
||||
return c.Render(http.StatusOK, "profile.html", data)
|
||||
}
|
||||
|
||||
|
||||
+13
-40
@@ -15,8 +15,13 @@
|
||||
<meta property="og:type" content="article">
|
||||
<meta property="profile:username" content="{{ postView.Author.Handle }}">
|
||||
{%- if requestURI %}
|
||||
{% if canonicalURL %}
|
||||
<meta property="og:url" content="{{ canonicalURL }}">
|
||||
<link rel="canonical" href="{{ canonicalURL }}" />
|
||||
{% else %}
|
||||
<meta property="og:url" content="{{ requestURI }}">
|
||||
<link rel="canonical" href="{{ requestURI|canonicalize_url }}" />
|
||||
{% endif %}
|
||||
{% endif -%}
|
||||
{%- if postView.Author.DisplayName %}
|
||||
<meta property="og:title" content="{{ postView.Author.DisplayName }} (@{{ postView.Author.Handle }})">
|
||||
@@ -64,52 +69,20 @@
|
||||
<meta property="article:published_time" content="{{ postView.IndexedAt }}">
|
||||
<link rel="alternate" type="application/json+oembed" href="https://embed.bsky.app/oembed?format=json&url={{ postView.Uri | urlencode }}" />
|
||||
<link rel="alternate" href="{{ postView.Uri }}" />
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "DiscussionForumPosting",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
{%- if postView.Author.DisplayName %}
|
||||
"name": "{{ postView.Author.DisplayName }}",
|
||||
"alternateName": "@{{ postView.Author.Handle }}",
|
||||
{% else %}
|
||||
"name": "@{{ postView.Author.Handle }}",
|
||||
{% endif -%}
|
||||
"url": "https://bsky.app/profile/{{ postView.Author.Handle }}"
|
||||
},
|
||||
{%- if postText %}
|
||||
"text": "{{ postText }}",
|
||||
{% endif %}
|
||||
{%- if imageThumbUrls %}
|
||||
"image": "{{ imageThumbUrls[0] }}",
|
||||
{% endif %}
|
||||
"datePublished": "{{ postView.IndexedAt }}",
|
||||
"interactionStatistic": [
|
||||
{
|
||||
"@type": "InteractionCounter",
|
||||
"interactionType": "https://schema.org/LikeAction",
|
||||
"userInteractionCount": {{ postView.LikeCount }}
|
||||
},
|
||||
{
|
||||
"@type": "InteractionCounter",
|
||||
"interactionType": "https://schema.org/CommentAction",
|
||||
"userInteractionCount": {{ postView.ReplyCount }}
|
||||
},
|
||||
{
|
||||
"@type": "InteractionCounter",
|
||||
"interactionType": "https://schema.org/ShareAction",
|
||||
"userInteractionCount": {{ postView.RepostCount + postView.QuoteCount }}
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
{%- if postJSONLD %}
|
||||
<script type="application/ld+json">{{ postJSONLD|safe }}</script>
|
||||
{% endif -%}
|
||||
{%- elif requiresAuth and profileHandle -%}
|
||||
<meta property="og:type" content="article">
|
||||
<meta property="profile:username" content="{{ profileHandle }}">
|
||||
{%- if requestURI %}
|
||||
{% if canonicalURL %}
|
||||
<meta property="og:url" content="{{ canonicalURL }}">
|
||||
<link rel="canonical" href="{{ canonicalURL }}" />
|
||||
{% else %}
|
||||
<meta property="og:url" content="{{ requestURI }}">
|
||||
<link rel="canonical" href="{{ requestURI|canonicalize_url }}" />
|
||||
{% endif %}
|
||||
{% endif -%}
|
||||
{%- if profileDisplayName %}
|
||||
<meta property="og:title" content="{{ profileDisplayName }} (@{{ profileHandle }})">
|
||||
|
||||
@@ -12,8 +12,13 @@
|
||||
<meta property="og:site_name" content="Bluesky Social">
|
||||
<meta property="og:type" content="profile">
|
||||
{%- if requestURI %}
|
||||
{% if canonicalURL %}
|
||||
<meta property="og:url" content="{{ canonicalURL }}">
|
||||
<link rel="canonical" href="{{ canonicalURL }}" />
|
||||
{% else %}
|
||||
<meta property="og:url" content="{{ requestURI }}">
|
||||
<link rel="canonical" href="{{ requestURI|canonicalize_url }}" />
|
||||
{% endif %}
|
||||
{% endif -%}
|
||||
|
||||
{%- if profileView -%}
|
||||
@@ -52,44 +57,9 @@
|
||||
<meta property="twitter:description" content="This profile requires authentication to view.">
|
||||
{% endif %}
|
||||
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "ProfilePage",
|
||||
"dateCreated": "{{ profileView.CreatedAt }}",
|
||||
"mainEntity": {
|
||||
"@type": "Person",
|
||||
{%- if profileView.DisplayName %}
|
||||
"name": "{{ profileView.DisplayName }}",
|
||||
"alternateName": "@{{ profileView.Handle }}",
|
||||
{% else %}
|
||||
"name": "@{{ profileView.Handle }}",
|
||||
{% endif -%}
|
||||
"identifier": "{{ profileView.Did }}",
|
||||
"description": "{{ profileView.Description }}",
|
||||
"image": "{{ profileView.Avatar }}",
|
||||
"interactionStatistic": [
|
||||
{
|
||||
"@type": "InteractionCounter",
|
||||
"interactionType": "https://schema.org/FollowAction",
|
||||
"userInteractionCount": {{ profileView.FollowersCount }}
|
||||
}
|
||||
],
|
||||
"agentInteractionStatistic": [
|
||||
{
|
||||
"@type": "InteractionCounter",
|
||||
"interactionType": "https://schema.org/FollowAction",
|
||||
"userInteractionCount": {{ profileView.FollowsCount }}
|
||||
},
|
||||
{
|
||||
"@type": "InteractionCounter",
|
||||
"interactionType": "https://schema.org/WriteAction",
|
||||
"userInteractionCount": {{ profileView.PostsCount }}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{%- if profileJSONLD %}
|
||||
<script type="application/ld+json">{{ profileJSONLD|safe }}</script>
|
||||
{% endif -%}
|
||||
{% endif -%}
|
||||
{%- endblock %}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user