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 %}