refactor and improve seo schema.org data

This commit is contained in:
Michael Black
2026-05-26 09:58:27 -05:00
parent 45597de6e0
commit eddfb9f6cf
6 changed files with 1312 additions and 82 deletions
+474
View File
@@ -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
}
+533
View File
@@ -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")
}
}
+202
View File
@@ -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")
}
}
+82 -4
View File
@@ -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
View File
@@ -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 }})">
+8 -38
View File
@@ -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 %}