diff --git a/bskyweb/cmd/bskyweb/jsonld.go b/bskyweb/cmd/bskyweb/jsonld.go
new file mode 100644
index 0000000000..e466ecf6c1
--- /dev/null
+++ b/bskyweb/cmd/bskyweb/jsonld.go
@@ -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 , and a unicode character.
+ tricky := "hello \"world\" \\ <\\>\n 🎉"
+ 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 "" must not appear in the output, since
+ // that would break out of ") {
+ t.Errorf("output contains literal , 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")
+ }
+}
diff --git a/bskyweb/cmd/bskyweb/render_test.go b/bskyweb/cmd/bskyweb/render_test.go
new file mode 100644
index 0000000000..d4bb1464a6
--- /dev/null
+++ b/bskyweb/cmd/bskyweb/render_test.go
@@ -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)`)
+
+// extractJSONLD pulls out the body of the
+ {%- if postJSONLD %}
+
+ {% endif -%}
{%- elif requiresAuth and profileHandle -%}
{%- if requestURI %}
+ {% if canonicalURL %}
+
+
+ {% else %}
+ {% endif %}
{% endif -%}
{%- if profileDisplayName %}
diff --git a/bskyweb/templates/profile.html b/bskyweb/templates/profile.html
index b4be429289..804ee7a92b 100644
--- a/bskyweb/templates/profile.html
+++ b/bskyweb/templates/profile.html
@@ -12,8 +12,13 @@
{%- if requestURI %}
+ {% if canonicalURL %}
+
+
+ {% else %}
+ {% endif %}
{% endif -%}
{%- if profileView -%}
@@ -52,44 +57,9 @@
{% endif %}
-
+ {%- if profileJSONLD %}
+
+ {% endif -%}
{% endif -%}
{%- endblock %}