keep comments concise
This commit is contained in:
@@ -4,15 +4,12 @@ import (
|
|||||||
appbsky "github.com/bluesky-social/indigo/api/bsky"
|
appbsky "github.com/bluesky-social/indigo/api/bsky"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Helpers for extracting Open Graph / Twitter Card metadata from post
|
// Helpers for extracting Open Graph metadata from post embeds. These feed
|
||||||
// embeds. These produce data for og:* and twitter:* meta tags only — they
|
// og:* / twitter:* meta tags only; the schema.org JSON-LD output lives in
|
||||||
// are not used by the schema.org JSON-LD output (which lives in jsonld.go).
|
// jsonld.go. og:video has its own path because JSON-LD does not yet emit
|
||||||
//
|
// VideoObject (deferred — needs `duration` from the appview).
|
||||||
// The og:video tags exist as a separate code path because JSON-LD does not
|
|
||||||
// currently emit a VideoObject (deferred — requires `duration` from the
|
|
||||||
// appview that is not exposed today).
|
|
||||||
|
|
||||||
// videoMeta is the metadata needed for og:video meta tags.
|
// videoMeta holds og:video meta tag data.
|
||||||
type videoMeta struct {
|
type videoMeta struct {
|
||||||
URL string
|
URL string
|
||||||
Type string
|
Type string
|
||||||
@@ -21,10 +18,8 @@ type videoMeta struct {
|
|||||||
HasSize bool
|
HasSize bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractVideoMeta returns og:video metadata for the post if it has a video
|
// extractVideoMeta returns og:video metadata, or the zero value if there's
|
||||||
// embed, otherwise the zero value. Respects the same embedHidden gate as
|
// no video embed. Respects embedHidden so og:video and og:image stay in sync.
|
||||||
// extractPostMedia (in jsonld.go) so og:video and og:image suppression stay
|
|
||||||
// in sync.
|
|
||||||
func extractVideoMeta(pv *appbsky.FeedDefs_PostView, embedHidden bool) videoMeta {
|
func extractVideoMeta(pv *appbsky.FeedDefs_PostView, embedHidden bool) videoMeta {
|
||||||
if pv == nil || pv.Embed == nil || embedHidden {
|
if pv == nil || pv.Embed == nil || embedHidden {
|
||||||
return videoMeta{}
|
return videoMeta{}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ type discussionForumPosting struct {
|
|||||||
SharedContent *sharedContent `json:"sharedContent,omitempty"`
|
SharedContent *sharedContent `json:"sharedContent,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// comment is the schema.org Comment shape used inside
|
// comment is the schema.org Comment shape used in
|
||||||
// DiscussionForumPosting.comment[]. The comment property does not accept
|
// DiscussionForumPosting.comment[]. The comment property does not accept
|
||||||
// DiscussionForumPosting, so replies map to Comment.
|
// DiscussionForumPosting, so replies map to Comment.
|
||||||
type comment struct {
|
type comment struct {
|
||||||
@@ -85,24 +85,18 @@ type profilePage struct {
|
|||||||
HasPart []discussionForumPosting `json:"hasPart,omitempty"`
|
HasPart []discussionForumPosting `json:"hasPart,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// maxComments is the maximum number of top-level replies emitted in
|
// maxComments caps DiscussionForumPosting.comment[] to keep SSR HTML small.
|
||||||
// DiscussionForumPosting.comment[]. Bounded to keep SSR HTML payload small.
|
|
||||||
const maxComments = 10
|
const maxComments = 10
|
||||||
|
|
||||||
// maxRecentPosts is the maximum number of recent posts emitted on a profile
|
// maxRecentPosts caps ProfilePage.hasPart[].
|
||||||
// page in ProfilePage.hasPart[].
|
|
||||||
const maxRecentPosts = 10
|
const maxRecentPosts = 10
|
||||||
|
|
||||||
// authorFeedFetchLimit is how many entries to request from getAuthorFeed
|
// authorFeedFetchLimit oversamples getAuthorFeed because posts_no_replies
|
||||||
// when populating ProfilePage.hasPart. We oversample because the
|
// still returns reposts; we drop those client-side. 3x is a safe margin.
|
||||||
// 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
|
const authorFeedFetchLimit = 3 * maxRecentPosts
|
||||||
|
|
||||||
// bskyPostURL returns the canonical handle-form URL for a post, given the
|
// bskyPostURL returns the canonical handle-form post URL, or "" if handle
|
||||||
// post's author handle and record key. Returns "" if the handle is unusable
|
// or rkey is unusable.
|
||||||
// (empty or handle.invalid) or rkey is empty.
|
|
||||||
func bskyPostURL(handle, rkey string) string {
|
func bskyPostURL(handle, rkey string) string {
|
||||||
if handle == "" || handle == "handle.invalid" || rkey == "" {
|
if handle == "" || handle == "handle.invalid" || rkey == "" {
|
||||||
return ""
|
return ""
|
||||||
@@ -110,9 +104,7 @@ func bskyPostURL(handle, rkey string) string {
|
|||||||
return fmt.Sprintf("https://bsky.app/profile/%s/post/%s", handle, rkey)
|
return fmt.Sprintf("https://bsky.app/profile/%s/post/%s", handle, rkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// bskyPostURLFromATURI is a convenience wrapper for callers that hold an
|
// bskyPostURLFromATURI is bskyPostURL for callers holding an at-uri.
|
||||||
// at-uri rather than a record key directly. Returns "" if the URI cannot be
|
|
||||||
// parsed.
|
|
||||||
func bskyPostURLFromATURI(handle, atURI string) string {
|
func bskyPostURLFromATURI(handle, atURI string) string {
|
||||||
parsed, err := syntax.ParseATURI(atURI)
|
parsed, err := syntax.ParseATURI(atURI)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -121,8 +113,8 @@ func bskyPostURLFromATURI(handle, atURI string) string {
|
|||||||
return bskyPostURL(handle, parsed.RecordKey().String())
|
return bskyPostURL(handle, parsed.RecordKey().String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// bskyProfileURL returns the canonical handle-form URL for a profile.
|
// bskyProfileURL returns the canonical handle-form profile URL, or "" if
|
||||||
// Returns "" if the handle is unusable.
|
// the handle is unusable.
|
||||||
func bskyProfileURL(handle string) string {
|
func bskyProfileURL(handle string) string {
|
||||||
if handle == "" || handle == "handle.invalid" {
|
if handle == "" || handle == "handle.invalid" {
|
||||||
return ""
|
return ""
|
||||||
@@ -130,10 +122,9 @@ func bskyProfileURL(handle string) string {
|
|||||||
return fmt.Sprintf("https://bsky.app/profile/%s", handle)
|
return fmt.Sprintf("https://bsky.app/profile/%s", handle)
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractPostMedia returns the image thumbnail URLs for the post (if any).
|
// extractPostMedia returns thumbnail URLs for the post's image or video
|
||||||
// All URLs are reused verbatim from the appview response — the same strings
|
// embed, byte-identical to what we put in og:image. Callers derive
|
||||||
// that go into og:image meta tags — so Google sees byte-identical media
|
// thumbnailUrl from urls[0].
|
||||||
// references. Callers derive thumbnailUrl from urls[0].
|
|
||||||
func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string {
|
func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string {
|
||||||
if pv == nil || pv.Embed == nil || embedHidden {
|
if pv == nil || pv.Embed == nil || embedHidden {
|
||||||
return nil
|
return nil
|
||||||
@@ -157,8 +148,7 @@ func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// imageThumbs returns the thumb URLs for a slice of image embed views, or
|
// imageThumbs returns the thumb URLs, or nil if empty.
|
||||||
// nil if the slice is empty.
|
|
||||||
func imageThumbs(images []*appbsky.EmbedImages_ViewImage) []string {
|
func imageThumbs(images []*appbsky.EmbedImages_ViewImage) []string {
|
||||||
if len(images) == 0 {
|
if len(images) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -170,9 +160,8 @@ func imageThumbs(images []*appbsky.EmbedImages_ViewImage) []string {
|
|||||||
return urls
|
return urls
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractQuotedPostURL returns the canonical handle-form URL of a quoted
|
// extractQuotedPostURL returns the canonical URL of a quoted post, or ""
|
||||||
// post, if the embed is a viewable record (not blocked / detached / not
|
// if the embed is blocked / not-found / detached / a non-post record.
|
||||||
// found / non-post record like a feed generator or list).
|
|
||||||
func extractQuotedPostURL(pv *appbsky.FeedDefs_PostView) string {
|
func extractQuotedPostURL(pv *appbsky.FeedDefs_PostView) string {
|
||||||
if pv == nil || pv.Embed == nil {
|
if pv == nil || pv.Embed == nil {
|
||||||
return ""
|
return ""
|
||||||
@@ -188,14 +177,13 @@ func extractQuotedPostURL(pv *appbsky.FeedDefs_PostView) string {
|
|||||||
}
|
}
|
||||||
vr := rec.Record.EmbedRecord_ViewRecord
|
vr := rec.Record.EmbedRecord_ViewRecord
|
||||||
if vr == nil || vr.Author == nil {
|
if vr == nil || vr.Author == nil {
|
||||||
// Skip _ViewBlocked, _ViewNotFound, _ViewDetached, and non-post records.
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return bskyPostURLFromATURI(vr.Author.Handle, vr.Uri)
|
return bskyPostURLFromATURI(vr.Author.Handle, vr.Uri)
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractSharedContentURL returns the URL of an external link embedded in
|
// extractSharedContentURL returns the URL of an external link embed (also
|
||||||
// the post (or in the media slot of a record-with-media embed).
|
// from the media slot of a record-with-media embed).
|
||||||
func extractSharedContentURL(pv *appbsky.FeedDefs_PostView) string {
|
func extractSharedContentURL(pv *appbsky.FeedDefs_PostView) string {
|
||||||
if pv == nil || pv.Embed == nil {
|
if pv == nil || pv.Embed == nil {
|
||||||
return ""
|
return ""
|
||||||
@@ -212,9 +200,8 @@ func extractSharedContentURL(pv *appbsky.FeedDefs_PostView) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildAuthor constructs a Person object. Organization classification for
|
// buildAuthor constructs a Person. Organization classification for
|
||||||
// custom-domain or organization-style accounts is a future enhancement; for
|
// custom-domain accounts is a future enhancement.
|
||||||
// now, every author is emitted as Person.
|
|
||||||
func buildAuthor(author *appbsky.ActorDefs_ProfileViewBasic) *personOrOrg {
|
func buildAuthor(author *appbsky.ActorDefs_ProfileViewBasic) *personOrOrg {
|
||||||
if author == nil {
|
if author == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -236,9 +223,9 @@ func buildAuthor(author *appbsky.ActorDefs_ProfileViewBasic) *personOrOrg {
|
|||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
// postEmbedHidden checks self-labels and post-view labels for any label that
|
// postEmbedHidden reports whether any post-view label or self-label asks
|
||||||
// causes embeds to be omitted. Mirrors logic in WebPost handler so the
|
// embeds to be omitted. WebPost uses this so og:image and JSON-LD media
|
||||||
// JSON-LD shape stays consistent with og:image emission.
|
// suppression stay in sync.
|
||||||
func postEmbedHidden(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) bool {
|
func postEmbedHidden(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) bool {
|
||||||
if pv == nil {
|
if pv == nil {
|
||||||
return false
|
return false
|
||||||
@@ -263,8 +250,8 @@ func postEmbedHidden(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool)
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// postRecordText returns the expanded post text (with shortened links
|
// postRecordText returns the post's expanded text, or "" if the record is
|
||||||
// expanded back to full URLs) or "" if the record is missing or malformed.
|
// missing or malformed.
|
||||||
func postRecordText(pv *appbsky.FeedDefs_PostView) string {
|
func postRecordText(pv *appbsky.FeedDefs_PostView) string {
|
||||||
if pv == nil || pv.Record == nil {
|
if pv == nil || pv.Record == nil {
|
||||||
return ""
|
return ""
|
||||||
@@ -276,10 +263,8 @@ func postRecordText(pv *appbsky.FeedDefs_PostView) string {
|
|||||||
return ExpandPostText(rec)
|
return ExpandPostText(rec)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildPostStats returns the standard like/comment/share interaction stat
|
// buildPostStats returns the like / comment / share interaction triple.
|
||||||
// triple. CommentAction count uses ReplyCount to match what Google expects
|
// commentCount is emitted separately on DiscussionForumPosting.
|
||||||
// in InteractionCounter; commentCount is emitted separately on
|
|
||||||
// DiscussionForumPosting.
|
|
||||||
func buildPostStats(pv *appbsky.FeedDefs_PostView) []interactionStat {
|
func buildPostStats(pv *appbsky.FeedDefs_PostView) []interactionStat {
|
||||||
if pv == nil {
|
if pv == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -297,10 +282,10 @@ func buildPostStats(pv *appbsky.FeedDefs_PostView) []interactionStat {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildPostNode constructs a DiscussionForumPosting (in nested form, no
|
// buildPostNode constructs a DiscussionForumPosting in nested form (no
|
||||||
// @context, no envelope). Used both for top-level posts and for entries in
|
// envelope, no @context). Used for top-level posts and for entries in
|
||||||
// hasPart / comment arrays. Returns the zero value if pv or pv.Author is nil
|
// hasPart / comment arrays. Returns the zero value if pv or pv.Author is
|
||||||
// — callers should treat that as "skip this entry".
|
// nil; callers should treat that as "skip".
|
||||||
func buildPostNode(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem, hideLabels map[string]bool) discussionForumPosting {
|
func buildPostNode(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem, hideLabels map[string]bool) discussionForumPosting {
|
||||||
if pv == nil || pv.Author == nil {
|
if pv == nil || pv.Author == nil {
|
||||||
return discussionForumPosting{}
|
return discussionForumPosting{}
|
||||||
@@ -349,7 +334,6 @@ func buildPostNode(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_Th
|
|||||||
}
|
}
|
||||||
reply := buildReplyNode(r.FeedDefs_ThreadViewPost.Post, hideLabels)
|
reply := buildReplyNode(r.FeedDefs_ThreadViewPost.Post, hideLabels)
|
||||||
if reply.Type == "" {
|
if reply.Type == "" {
|
||||||
// nil-Author guard tripped; skip.
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
node.Comment = append(node.Comment, reply)
|
node.Comment = append(node.Comment, reply)
|
||||||
@@ -358,8 +342,8 @@ func buildPostNode(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_Th
|
|||||||
return node
|
return node
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildReplyNode builds a schema.org Comment for a reply. Returns the zero
|
// buildReplyNode builds a schema.org Comment for a reply. Returns the
|
||||||
// value if pv or pv.Author is nil; callers should treat that as "skip".
|
// zero value if pv or pv.Author is nil.
|
||||||
func buildReplyNode(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) comment {
|
func buildReplyNode(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) comment {
|
||||||
if pv == nil || pv.Author == nil {
|
if pv == nil || pv.Author == nil {
|
||||||
return comment{}
|
return comment{}
|
||||||
@@ -382,28 +366,21 @@ func buildReplyNode(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) c
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildPostJSONLD marshals the top-level WebPage envelope for a post page.
|
// buildPostJSONLD marshals the WebPage envelope wrapping a
|
||||||
// This is what gets injected into <script type="application/ld+json">.
|
// DiscussionForumPosting. canonicalURL is used for both envelope.url and
|
||||||
//
|
// (as a fallback) mainEntity.url so they always agree.
|
||||||
// canonicalURL is the public URL for the page. Callers (WebPost) prefer the
|
|
||||||
// handle-form URL when the author has a usable handle, otherwise fall back
|
|
||||||
// to the request URI (DID form). The same string is used for both
|
|
||||||
// envelope.url and mainEntity.url so search engines see a single
|
|
||||||
// authoritative URL for the post.
|
|
||||||
func buildPostJSONLD(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem, canonicalURL string, hideLabels map[string]bool) (string, error) {
|
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 {
|
if pv == nil || pv.Author == nil {
|
||||||
return "", fmt.Errorf("nil post view or author")
|
return "", fmt.Errorf("nil post view or author")
|
||||||
}
|
}
|
||||||
node := buildPostNode(pv, replies, hideLabels)
|
node := buildPostNode(pv, replies, hideLabels)
|
||||||
|
|
||||||
// buildPostNode derives mainEntity.url from the author handle alone, so
|
// mainEntity.url is empty when the author handle is unusable; fall back
|
||||||
// it ends up empty when the handle is unusable (handle.invalid). Fall
|
// to canonicalURL so it agrees with envelope.url.
|
||||||
// back to canonicalURL so envelope.url and mainEntity.url always agree.
|
|
||||||
if node.URL == "" {
|
if node.URL == "" {
|
||||||
node.URL = canonicalURL
|
node.URL = canonicalURL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Top-level entity: wrap in WebPage envelope per Google's recommendation.
|
|
||||||
envelope := webPage{
|
envelope := webPage{
|
||||||
Context: schemaOrgContext,
|
Context: schemaOrgContext,
|
||||||
Type: "WebPage",
|
Type: "WebPage",
|
||||||
@@ -417,8 +394,7 @@ func buildPostJSONLD(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_
|
|||||||
return string(b), nil
|
return string(b), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildProfileJSONLD marshals the ProfilePage object (including hasPart
|
// buildProfileJSONLD marshals a ProfilePage (with hasPart recent posts).
|
||||||
// recent posts) for a profile page.
|
|
||||||
func buildProfileJSONLD(pv *appbsky.ActorDefs_ProfileViewDetailed, recentPosts []*appbsky.FeedDefs_PostView, hideLabels map[string]bool) (string, error) {
|
func buildProfileJSONLD(pv *appbsky.ActorDefs_ProfileViewDetailed, recentPosts []*appbsky.FeedDefs_PostView, hideLabels map[string]bool) (string, error) {
|
||||||
if pv == nil {
|
if pv == nil {
|
||||||
return "", fmt.Errorf("nil profile view")
|
return "", fmt.Errorf("nil profile view")
|
||||||
@@ -471,10 +447,9 @@ func buildProfileJSONLD(pv *appbsky.ActorDefs_ProfileViewDetailed, recentPosts [
|
|||||||
if len(page.HasPart) >= maxRecentPosts {
|
if len(page.HasPart) >= maxRecentPosts {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
// Recent posts go in nested form (no replies, no envelope).
|
// Recent posts go in nested form.
|
||||||
node := buildPostNode(rp, nil, hideLabels)
|
node := buildPostNode(rp, nil, hideLabels)
|
||||||
if node.Type == "" {
|
if node.Type == "" {
|
||||||
// nil-Author guard tripped; skip.
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
page.HasPart = append(page.HasPart, node)
|
page.HasPart = append(page.HasPart, node)
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ import (
|
|||||||
lexutil "github.com/bluesky-social/indigo/lex/util"
|
lexutil "github.com/bluesky-social/indigo/lex/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// strPtr / intPtr / boolPtr - small helpers for the optional appbsky fields.
|
// Pointer helpers for optional appbsky fields.
|
||||||
func strPtr(s string) *string { return &s }
|
func strPtr(s string) *string { return &s }
|
||||||
func intPtr(i int64) *int64 { return &i }
|
func intPtr(i int64) *int64 { return &i }
|
||||||
|
|
||||||
// newProfileViewDetailed returns a populated ProfileViewDetailed for tests.
|
// newProfileViewDetailed returns a populated profile for tests.
|
||||||
func newProfileViewDetailed() *appbsky.ActorDefs_ProfileViewDetailed {
|
func newProfileViewDetailed() *appbsky.ActorDefs_ProfileViewDetailed {
|
||||||
return &appbsky.ActorDefs_ProfileViewDetailed{
|
return &appbsky.ActorDefs_ProfileViewDetailed{
|
||||||
Did: "did:plc:alice",
|
Did: "did:plc:alice",
|
||||||
@@ -298,21 +298,18 @@ func TestBuildPostJSONLD_HiddenEmbed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildPostJSONLD_TextEscaping(t *testing.T) {
|
func TestBuildPostJSONLD_TextEscaping(t *testing.T) {
|
||||||
// Crafted to break naive string concatenation: includes ", \, newline,
|
// Includes ", \, newline, </script>, and a unicode char.
|
||||||
// </script>, and a unicode character.
|
|
||||||
tricky := "hello \"world\" \\ <\\>\n</script> 🎉"
|
tricky := "hello \"world\" \\ <\\>\n</script> 🎉"
|
||||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", tricky)
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", tricky)
|
||||||
out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels)
|
out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// Must round-trip through the JSON parser.
|
|
||||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||||
if main["text"] != tricky {
|
if main["text"] != tricky {
|
||||||
t.Errorf("text round-trip failed: got %q want %q", 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
|
// Literal </script> would break out of the script tag.
|
||||||
// that would break out of <script type="application/ld+json">.
|
|
||||||
if strings.Contains(out, "</script>") {
|
if strings.Contains(out, "</script>") {
|
||||||
t.Errorf("output contains literal </script>, would break HTML embedding")
|
t.Errorf("output contains literal </script>, would break HTML embedding")
|
||||||
}
|
}
|
||||||
@@ -322,9 +319,7 @@ func TestBuildPostJSONLD_Comments(t *testing.T) {
|
|||||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
|
||||||
*pv.ReplyCount = 14
|
*pv.ReplyCount = 14
|
||||||
|
|
||||||
// 14 replies: 12 valid, 1 not-found, 1 blocked. The cap is maxComments=10
|
// 12 valid replies + 1 not-found + 1 blocked. Cap is maxComments=10.
|
||||||
// so we expect exactly 10 valid entries in comment[], with the
|
|
||||||
// non-thread variants filtered out.
|
|
||||||
const validReplies = 12
|
const validReplies = 12
|
||||||
var replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem
|
var replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem
|
||||||
for i := 0; i < validReplies; i++ {
|
for i := 0; i < validReplies; i++ {
|
||||||
@@ -355,15 +350,12 @@ func TestBuildPostJSONLD_Comments(t *testing.T) {
|
|||||||
if len(comments) != maxComments {
|
if len(comments) != maxComments {
|
||||||
t.Errorf("expected %d comments (capped from %d valid), got %d", maxComments, validReplies, len(comments))
|
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
|
// FIFO order: first valid reply, not the not-found at the end.
|
||||||
// one which sits at the end).
|
|
||||||
first := comments[0].(map[string]any)
|
first := comments[0].(map[string]any)
|
||||||
if first["identifier"] != "at://did:plc:rep00/app.bsky.feed.post/reply00" {
|
if first["identifier"] != "at://did:plc:rep00/app.bsky.feed.post/reply00" {
|
||||||
t.Errorf("first comment should be reply00, got %v", first["identifier"])
|
t.Errorf("first comment should be reply00, got %v", first["identifier"])
|
||||||
}
|
}
|
||||||
// Each comment must be schema.org Comment (not DiscussionForumPosting —
|
// Comment type, no nested comment[] / isBasedOn / sharedContent.
|
||||||
// that type is invalid for the comment property) and must NOT have
|
|
||||||
// nested comment[] / isBasedOn / sharedContent.
|
|
||||||
for i, c := range comments {
|
for i, c := range comments {
|
||||||
cm := c.(map[string]any)
|
cm := c.(map[string]any)
|
||||||
if cm["@type"] != "Comment" {
|
if cm["@type"] != "Comment" {
|
||||||
@@ -390,9 +382,8 @@ func TestBuildPostJSONLD_HandleInvalidAuthor(t *testing.T) {
|
|||||||
out, _ := buildPostJSONLD(pv, nil, fallback, hideEmbedLabels)
|
out, _ := buildPostJSONLD(pv, nil, fallback, hideEmbedLabels)
|
||||||
envelope := unmarshalLD(t, out)
|
envelope := unmarshalLD(t, out)
|
||||||
main := envelope["mainEntity"].(map[string]any)
|
main := envelope["mainEntity"].(map[string]any)
|
||||||
// With no usable handle, mainEntity.url falls back to the canonical URL
|
// mainEntity.url falls back to the caller's canonical URL so envelope
|
||||||
// the caller provided (DID-form request URI), so the envelope URL and
|
// and post URLs always agree.
|
||||||
// the post URL agree on a single string.
|
|
||||||
if main["url"] != fallback {
|
if main["url"] != fallback {
|
||||||
t.Errorf("mainEntity.url should fall back to canonical URL, got %v", main["url"])
|
t.Errorf("mainEntity.url should fall back to canonical URL, got %v", main["url"])
|
||||||
}
|
}
|
||||||
@@ -403,21 +394,18 @@ func TestBuildPostJSONLD_HandleInvalidAuthor(t *testing.T) {
|
|||||||
t.Errorf("envelope.url and mainEntity.url disagree: %v vs %v",
|
t.Errorf("envelope.url and mainEntity.url disagree: %v vs %v",
|
||||||
envelope["url"], main["url"])
|
envelope["url"], main["url"])
|
||||||
}
|
}
|
||||||
// identifier (AT-URI) is still present and stable across handle changes.
|
// identifier (AT-URI) stays stable across handle changes.
|
||||||
if main["identifier"] != pv.Uri {
|
if main["identifier"] != pv.Uri {
|
||||||
t.Errorf("identifier should still be the AT-URI, got %v", main["identifier"])
|
t.Errorf("identifier should still be the AT-URI, got %v", main["identifier"])
|
||||||
}
|
}
|
||||||
// Author URL is omitted (no usable handle to construct a profile URL).
|
// Author URL omitted (no usable handle).
|
||||||
author := main["author"].(map[string]any)
|
author := main["author"].(map[string]any)
|
||||||
if _, present := author["url"]; present {
|
if _, present := author["url"]; present {
|
||||||
t.Errorf("handle.invalid author should not produce author.url")
|
t.Errorf("handle.invalid author should not produce author.url")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestBuildPostJSONLD_EnvelopeURLMatchesMainEntity asserts the P0.3
|
// envelope.url and mainEntity.url must always agree.
|
||||||
// invariant that the WebPage envelope and the inner DiscussionForumPosting
|
|
||||||
// always carry the same URL — both for happy-path (handle-form canonical)
|
|
||||||
// and the handle.invalid fallback case.
|
|
||||||
func TestBuildPostJSONLD_EnvelopeURLMatchesMainEntity(t *testing.T) {
|
func TestBuildPostJSONLD_EnvelopeURLMatchesMainEntity(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name, handle, did, rkey, canonical string
|
name, handle, did, rkey, canonical string
|
||||||
@@ -454,8 +442,7 @@ func TestBuildPostJSONLD_EnvelopeURLMatchesMainEntity(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildPostJSONLD_NilAuthor(t *testing.T) {
|
func TestBuildPostJSONLD_NilAuthor(t *testing.T) {
|
||||||
// Defensive: appview is contractually required to send an Author, but we
|
// Defensive: don't panic if Author is nil.
|
||||||
// shouldn't panic if a malformed payload sneaks through.
|
|
||||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
|
||||||
pv.Author = nil
|
pv.Author = nil
|
||||||
if _, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels); err == nil {
|
if _, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels); err == nil {
|
||||||
@@ -464,8 +451,7 @@ func TestBuildPostJSONLD_NilAuthor(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildPostJSONLD_NilAuthorReply(t *testing.T) {
|
func TestBuildPostJSONLD_NilAuthorReply(t *testing.T) {
|
||||||
// A reply with a nil author should be silently dropped from comment[]
|
// Reply with nil Author should be dropped, not emitted with empty @type.
|
||||||
// rather than producing an entry with no @type.
|
|
||||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
|
||||||
*pv.ReplyCount = 2
|
*pv.ReplyCount = 2
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ import (
|
|||||||
// Helpers for inspecting profile / post labels.
|
// Helpers for inspecting profile / post labels.
|
||||||
|
|
||||||
// profileRequiresAuth reports whether the profile has self-applied the
|
// profileRequiresAuth reports whether the profile has self-applied the
|
||||||
// `!no-unauthenticated` label, indicating the user only wants their content
|
// !no-unauthenticated label. SSR responses for these profiles omit post
|
||||||
// shown to signed-in viewers. SSR responses for these profiles must omit
|
// content beyond minimal identity.
|
||||||
// post text, descriptions, and other content beyond minimal identity.
|
|
||||||
func profileRequiresAuth(pv *appbsky.ActorDefs_ProfileViewDetailed) bool {
|
func profileRequiresAuth(pv *appbsky.ActorDefs_ProfileViewDetailed) bool {
|
||||||
if pv == nil {
|
if pv == nil {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -56,10 +56,8 @@ func TestProfileRequiresAuth(t *testing.T) {
|
|||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Helper does not currently honor a Neg flag — matches existing
|
// Negation isn't honored — matches prior inline behavior in
|
||||||
// inline behavior in WebPost / WebProfile / WebProfileRSS. If
|
// WebPost / WebProfile / WebProfileRSS.
|
||||||
// negation semantics are needed for self-labels, all four call
|
|
||||||
// sites need to change together.
|
|
||||||
name: "negated label still triggers (matches prior behavior)",
|
name: "negated label still triggers (matches prior behavior)",
|
||||||
pv: &appbsky.ActorDefs_ProfileViewDetailed{
|
pv: &appbsky.ActorDefs_ProfileViewDetailed{
|
||||||
Did: "did:plc:alice",
|
Did: "did:plc:alice",
|
||||||
|
|||||||
@@ -11,19 +11,13 @@ import (
|
|||||||
"github.com/flosch/pongo2/v6"
|
"github.com/flosch/pongo2/v6"
|
||||||
)
|
)
|
||||||
|
|
||||||
// renderTemplate executes a template by name with the given context and
|
// renderTemplate executes a template using the live renderer and returns
|
||||||
// returns the rendered output. Uses the same renderer plumbing as the live
|
// the output. base.html includes templates/scripts.html, which is generated
|
||||||
// server so we exercise the real template loader.
|
// by `yarn build-web`; skip the test if it's missing.
|
||||||
//
|
|
||||||
// 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 {
|
func renderTemplate(t *testing.T, name string, ctx pongo2.Context) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if _, err := bskyweb.TemplateFS.ReadFile("templates/scripts.html"); err != nil {
|
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")
|
t.Skip("templates/scripts.html not present (run yarn build-web first)")
|
||||||
}
|
}
|
||||||
r := NewRenderer("templates/", &bskyweb.TemplateFS, false)
|
r := NewRenderer("templates/", &bskyweb.TemplateFS, false)
|
||||||
tmpl, err := r.TemplateSet.FromCache(name)
|
tmpl, err := r.TemplateSet.FromCache(name)
|
||||||
@@ -39,8 +33,7 @@ func renderTemplate(t *testing.T, name string, ctx pongo2.Context) string {
|
|||||||
|
|
||||||
var jsonLDRe = regexp.MustCompile(`(?s)<script type="application/ld\+json">(.*?)</script>`)
|
var jsonLDRe = regexp.MustCompile(`(?s)<script type="application/ld\+json">(.*?)</script>`)
|
||||||
|
|
||||||
// extractJSONLD pulls out the body of the <script type="application/ld+json">
|
// extractJSONLD pulls out the body of the application/ld+json script tag.
|
||||||
// block from rendered HTML. Asserts the block exists.
|
|
||||||
func extractJSONLD(t *testing.T, html string) string {
|
func extractJSONLD(t *testing.T, html string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
m := jsonLDRe.FindStringSubmatch(html)
|
m := jsonLDRe.FindStringSubmatch(html)
|
||||||
@@ -71,8 +64,7 @@ func TestRenderPost_EmitsJSONLD(t *testing.T) {
|
|||||||
if parsed["@type"] != "WebPage" {
|
if parsed["@type"] != "WebPage" {
|
||||||
t.Errorf("expected WebPage envelope, got %v", parsed["@type"])
|
t.Errorf("expected WebPage envelope, got %v", parsed["@type"])
|
||||||
}
|
}
|
||||||
// Verify canonical link is the handle-form URL (not the request URI
|
// Canonical link should be the handle-form URL.
|
||||||
// canonicalized).
|
|
||||||
if !strings.Contains(html, `<link rel="canonical" href="https://bsky.app/profile/alice.bsky.social/post/abc123" />`) {
|
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)
|
t.Errorf("canonical link missing or wrong:\n%s", html)
|
||||||
}
|
}
|
||||||
@@ -88,11 +80,10 @@ func TestRenderPost_OGImageMatchesJSONLD(t *testing.T) {
|
|||||||
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
||||||
"canonicalURL": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
"canonicalURL": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
||||||
"postJSONLD": ld,
|
"postJSONLD": ld,
|
||||||
// Mirrors what server.go puts in: og:image meta tags use imgThumbUrls.
|
|
||||||
"imgThumbUrls": []string{thumb1, thumb2},
|
"imgThumbUrls": []string{thumb1, thumb2},
|
||||||
})
|
})
|
||||||
|
|
||||||
// og:image must use the same URLs as JSON-LD's image[] (Google byte-equality requirement).
|
// og:image and JSON-LD image[] must be byte-identical.
|
||||||
if !strings.Contains(html, `<meta property="og:image" content="`+thumb1+`">`) {
|
if !strings.Contains(html, `<meta property="og:image" content="`+thumb1+`">`) {
|
||||||
t.Errorf("og:image[0] not found in rendered HTML")
|
t.Errorf("og:image[0] not found in rendered HTML")
|
||||||
}
|
}
|
||||||
@@ -108,8 +99,7 @@ func TestRenderPost_OGImageMatchesJSONLD(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRenderPost_FallsBackToCanonicalizeFilter(t *testing.T) {
|
func TestRenderPost_FallsBackToCanonicalizeFilter(t *testing.T) {
|
||||||
// When canonicalURL is not set, template should fall back to
|
// Without canonicalURL, the template falls back to requestURI|canonicalize_url.
|
||||||
// requestURI|canonicalize_url.
|
|
||||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
|
||||||
ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels)
|
ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels)
|
||||||
html := renderTemplate(t, "post.html", pongo2.Context{
|
html := renderTemplate(t, "post.html", pongo2.Context{
|
||||||
@@ -145,11 +135,9 @@ func TestRenderProfile_EmitsJSONLD(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRenderProfile_AuthRequiredEmitsJSONLD confirms that auth-required
|
// Regression: auth-required profiles must still emit ProfilePage JSON-LD
|
||||||
// profiles still emit ProfilePage / Person structured data (without
|
// (without hasPart). Previously regressed when WebProfile short-circuited
|
||||||
// hasPart). Previously this regressed when WebProfile was refactored to
|
// before buildProfileJSONLD.
|
||||||
// short-circuit before buildProfileJSONLD; the regression test guards
|
|
||||||
// against re-introducing that.
|
|
||||||
func TestRenderProfile_AuthRequiredEmitsJSONLD(t *testing.T) {
|
func TestRenderProfile_AuthRequiredEmitsJSONLD(t *testing.T) {
|
||||||
pv := newProfileViewDetailed()
|
pv := newProfileViewDetailed()
|
||||||
ld, err := buildProfileJSONLD(pv, nil, hideEmbedLabels)
|
ld, err := buildProfileJSONLD(pv, nil, hideEmbedLabels)
|
||||||
@@ -176,9 +164,7 @@ func TestRenderProfile_AuthRequiredEmitsJSONLD(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRenderPost_OGUrlMatchesCanonical confirms that og:url and
|
// og:url and <link rel="canonical"> must emit the same URL.
|
||||||
// <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) {
|
func TestRenderPost_OGUrlMatchesCanonical(t *testing.T) {
|
||||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
|
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
|
||||||
ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels)
|
ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels)
|
||||||
@@ -195,7 +181,7 @@ func TestRenderPost_OGUrlMatchesCanonical(t *testing.T) {
|
|||||||
if !strings.Contains(html, `<link rel="canonical" href="`+canonical+`" />`) {
|
if !strings.Contains(html, `<link rel="canonical" href="`+canonical+`" />`) {
|
||||||
t.Errorf("canonical link missing or wrong:\n%s", html)
|
t.Errorf("canonical link missing or wrong:\n%s", html)
|
||||||
}
|
}
|
||||||
// Inverse: the request URI (DID form) should NOT appear as og:url.
|
// DID-form request URI must not leak into og:url.
|
||||||
if strings.Contains(html, `<meta property="og:url" content="https://bsky.app/profile/did:plc:alice/post/abc123">`) {
|
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")
|
t.Errorf("og:url should not echo DID-form request URI when canonical is set")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -529,10 +529,9 @@ func (srv *Server) WebPost(c echo.Context) error {
|
|||||||
req := c.Request()
|
req := c.Request()
|
||||||
requestURI := fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
|
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.
|
// Always prefer the handle-form URL so JSON-LD `url` and
|
||||||
// This both handles DID-form requests and normalizes handle-form requests against stale handles in the URL,
|
// <link rel="canonical"> match. Falls back to requestURI when the
|
||||||
// guaranteeing JSON-LD `url` and <link rel="canonical"> match exactly.
|
// handle is unusable (template strips query/fragment).
|
||||||
// If the handle is unusable (handle.invalid or empty), fall back to the request URI with query/fragment stripped.
|
|
||||||
canonicalURL := bskyPostURL(pv.Handle, rkey.String())
|
canonicalURL := bskyPostURL(pv.Handle, rkey.String())
|
||||||
|
|
||||||
if !unauthedViewingOkay {
|
if !unauthedViewingOkay {
|
||||||
@@ -568,10 +567,8 @@ func (srv *Server) WebPost(c echo.Context) error {
|
|||||||
data["canonicalURL"] = canonicalURL
|
data["canonicalURL"] = canonicalURL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embed-hidden gate, post text, image thumbs, and video metadata are all
|
// Share extraction helpers with jsonld.go so og:image and JSON-LD
|
||||||
// derived from helpers in jsonld.go so the og:* / twitter:* meta tags
|
// image[] are byte-identical (per Google's requirement).
|
||||||
// and the JSON-LD payload stay in lockstep — Google's Rich Results
|
|
||||||
// validator requires og:image and JSON-LD image[] to be byte-identical.
|
|
||||||
isEmbedHidden := postEmbedHidden(postView, hideEmbedLabels)
|
isEmbedHidden := postEmbedHidden(postView, hideEmbedLabels)
|
||||||
data["postText"] = postRecordText(postView)
|
data["postText"] = postRecordText(postView)
|
||||||
|
|
||||||
@@ -587,9 +584,8 @@ func (srv *Server) WebPost(c echo.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build schema.org JSON-LD for SEO. canonicalURL is what we want as the
|
// Build JSON-LD. Fall back to requestURI when handle is unusable so the
|
||||||
// public URL; if it's empty (handle.invalid edge case), fall back to the
|
// envelope url is never empty.
|
||||||
// request URI so the field still gets emitted with something sensible.
|
|
||||||
jsonldURL := canonicalURL
|
jsonldURL := canonicalURL
|
||||||
if jsonldURL == "" {
|
if jsonldURL == "" {
|
||||||
jsonldURL = requestURI
|
jsonldURL = requestURI
|
||||||
@@ -666,27 +662,19 @@ func (srv *Server) WebProfile(c echo.Context) error {
|
|||||||
data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
|
data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
|
||||||
data["requestHost"] = req.Host
|
data["requestHost"] = req.Host
|
||||||
|
|
||||||
// Canonical URL: always prefer the handle-form URL when we have a usable
|
// Prefer the handle-form URL so JSON-LD `url` and
|
||||||
// handle, regardless of how the request was looked up. This handles
|
// <link rel="canonical"> match. Template falls back to requestURI
|
||||||
// DID-form requests and normalizes handle-form requests against stale
|
// when the handle is unusable.
|
||||||
// handles, guaranteeing JSON-LD `url` and <link rel="canonical"> match.
|
|
||||||
// Falls back to requestURI (with query/fragment stripped by the
|
|
||||||
// canonicalize_url filter) when the handle is unusable.
|
|
||||||
if url := bskyProfileURL(pv.Handle); url != "" {
|
if url := bskyProfileURL(pv.Handle); url != "" {
|
||||||
data["canonicalURL"] = url
|
data["canonicalURL"] = url
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch recent posts to embed as ProfilePage.hasPart so search engines
|
// Fetch recent posts for ProfilePage.hasPart. Skipped for auth-required
|
||||||
// can connect a profile to its recent content. Failures here degrade
|
// profiles (posts aren't publicly indexable anyway). Failures degrade
|
||||||
// gracefully — we still render the profile without hasPart.
|
// gracefully — the profile still renders without hasPart.
|
||||||
//
|
//
|
||||||
// Skipped for auth-required profiles (their posts aren't publicly
|
// NOTE: extra XRPC call on every public profile render; consider
|
||||||
// indexable anyway), but the rest of the ProfilePage / Person markup
|
// caching per-profile if upstream load becomes a concern.
|
||||||
// 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
|
var recentPosts []*appbsky.FeedDefs_PostView
|
||||||
if unauthedViewingOkay {
|
if unauthedViewingOkay {
|
||||||
af, err := appbsky.FeedGetAuthorFeed(ctx, srv.xrpcc, pv.Did, "", "posts_no_replies", false, authorFeedFetchLimit)
|
af, err := appbsky.FeedGetAuthorFeed(ctx, srv.xrpcc, pv.Did, "", "posts_no_replies", false, authorFeedFetchLimit)
|
||||||
@@ -697,7 +685,7 @@ func (srv *Server) WebProfile(c echo.Context) error {
|
|||||||
if p == nil || p.Post == nil {
|
if p == nil || p.Post == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Only the author's own posts (matches RSS handler behavior).
|
// Only the author's own posts (matches RSS handler).
|
||||||
if p.Post.Author == nil || p.Post.Author.Did != pv.Did {
|
if p.Post.Author == nil || p.Post.Author.Did != pv.Did {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user