post jsonld canonical url
This commit is contained in:
@@ -386,12 +386,25 @@ func buildReplyNode(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) d
|
|||||||
|
|
||||||
// buildPostJSONLD marshals the top-level WebPage envelope for a post page.
|
// buildPostJSONLD marshals the top-level WebPage envelope for a post page.
|
||||||
// This is what gets injected into <script type="application/ld+json">.
|
// This is what gets injected into <script type="application/ld+json">.
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
// it ends up empty when the handle is unusable (handle.invalid). Fall
|
||||||
|
// back to canonicalURL so envelope.url and mainEntity.url always agree.
|
||||||
|
if node.URL == "" {
|
||||||
|
node.URL = canonicalURL
|
||||||
|
}
|
||||||
|
|
||||||
// Top-level entity: wrap in WebPage envelope per Google's recommendation.
|
// Top-level entity: wrap in WebPage envelope per Google's recommendation.
|
||||||
envelope := webPage{
|
envelope := webPage{
|
||||||
Context: schemaOrgContext,
|
Context: schemaOrgContext,
|
||||||
|
|||||||
@@ -384,19 +384,73 @@ func TestBuildPostJSONLD_Replies(t *testing.T) {
|
|||||||
|
|
||||||
func TestBuildPostJSONLD_HandleInvalidAuthor(t *testing.T) {
|
func TestBuildPostJSONLD_HandleInvalidAuthor(t *testing.T) {
|
||||||
pv := makePostView("handle.invalid", "did:plc:alice", "abc123", "hello")
|
pv := makePostView("handle.invalid", "did:plc:alice", "abc123", "hello")
|
||||||
out, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/did:plc:alice/post/abc123", hideEmbedLabels)
|
fallback := "https://bsky.app/profile/did:plc:alice/post/abc123"
|
||||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
out, _ := buildPostJSONLD(pv, nil, fallback, hideEmbedLabels)
|
||||||
// With no usable handle, post-level url should be omitted.
|
envelope := unmarshalLD(t, out)
|
||||||
if _, present := main["url"]; present {
|
main := envelope["mainEntity"].(map[string]any)
|
||||||
t.Errorf("handle.invalid author should not produce post url")
|
// With no usable handle, mainEntity.url falls back to the canonical URL
|
||||||
|
// the caller provided (DID-form request URI), so the envelope URL and
|
||||||
|
// the post URL agree on a single string.
|
||||||
|
if main["url"] != fallback {
|
||||||
|
t.Errorf("mainEntity.url should fall back to canonical URL, got %v", main["url"])
|
||||||
}
|
}
|
||||||
// Author URL also omitted.
|
if envelope["url"] != fallback {
|
||||||
|
t.Errorf("envelope.url should equal canonical URL, got %v", envelope["url"])
|
||||||
|
}
|
||||||
|
if main["url"] != envelope["url"] {
|
||||||
|
t.Errorf("envelope.url and mainEntity.url disagree: %v vs %v",
|
||||||
|
envelope["url"], main["url"])
|
||||||
|
}
|
||||||
|
// identifier (AT-URI) is still present and stable across handle changes.
|
||||||
|
if main["identifier"] != pv.Uri {
|
||||||
|
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 := 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
|
||||||
|
// 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) {
|
||||||
|
cases := []struct {
|
||||||
|
name, handle, did, rkey, canonical string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "handle form",
|
||||||
|
handle: "alice.bsky.social",
|
||||||
|
did: "did:plc:alice",
|
||||||
|
rkey: "abc",
|
||||||
|
canonical: "https://bsky.app/profile/alice.bsky.social/post/abc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "handle.invalid falls back to canonical",
|
||||||
|
handle: "handle.invalid",
|
||||||
|
did: "did:plc:alice",
|
||||||
|
rkey: "abc",
|
||||||
|
canonical: "https://bsky.app/profile/did:plc:alice/post/abc",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
pv := makePostView(tc.handle, tc.did, tc.rkey, "hi")
|
||||||
|
out, _ := buildPostJSONLD(pv, nil, tc.canonical, hideEmbedLabels)
|
||||||
|
env := unmarshalLD(t, out)
|
||||||
|
main := env["mainEntity"].(map[string]any)
|
||||||
|
if env["url"] != tc.canonical {
|
||||||
|
t.Errorf("envelope.url = %v, want %v", env["url"], tc.canonical)
|
||||||
|
}
|
||||||
|
if main["url"] != tc.canonical {
|
||||||
|
t.Errorf("mainEntity.url = %v, want %v", main["url"], tc.canonical)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildPostJSONLD_NilAuthor(t *testing.T) {
|
func TestBuildPostJSONLD_NilAuthor(t *testing.T) {
|
||||||
// Defensive: appview is contractually required to send an Author, but we
|
// Defensive: appview is contractually required to send an Author, but we
|
||||||
// shouldn't panic if a malformed payload sneaks through.
|
// shouldn't panic if a malformed payload sneaks through.
|
||||||
|
|||||||
Reference in New Issue
Block a user