From 3fa06ac4dffba26419c7c07d72ede604273b7c5f Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Tue, 15 Sep 2026 19:44:35 -0700 Subject: [PATCH] link cards: each page gets its own OpenGraph card from templates link cards: each page of the app gets its own OpenGraph card, phrased by branding templates. A stream page is " is live on " over the stream's post text (the streamer's display name comes from their indexed profile record, the handle when there is none; it used to say "@handle's livestream on "); a video page is "'s video on " over the video's title; the front page (live tab), /video (on demand) and /live (go live) each carry their own title and description instead of the generic site card. Keys: cardSiteName ({site} in the templates, so a "W Social" site can say "on W"), cardLiveTitle, cardHomeTitle/Description (default: the site's own), cardVideosTitle/Description, cardVideoTitle, cardGoLiveTitle/Description; placeholders {site}, {name}, {handle}. The three card generators now share one branding fetch and one tag builder; a Link cards block in the branding admin edits the templates. Verified on a scratch node with the client's bundle plus the templates: all five pages read as specified Claude-Session: https://claude.ai/code/session_014aPQ5yqG9QFxKnbwQfCYKa (cherry picked from commit 6040e5ace78a50c6dcd4728b9ec2f0d8c026b820) --- js/app/components/settings/branding-admin.tsx | 118 ++++++ js/i18n/locales/en-US/settings.ftl | 2 + js/i18n/public/locales/en-US/settings.json | 2 + js/web/public/locales/en-US/settings.json | 2 + pkg/api/api.go | 36 ++ pkg/branding/vocab.go | 9 + pkg/linking/linking.go | 373 +++++++++--------- pkg/linking/linking_test.go | 125 +++++- 8 files changed, 487 insertions(+), 180 deletions(-) diff --git a/js/app/components/settings/branding-admin.tsx b/js/app/components/settings/branding-admin.tsx index 5f643f997..938610bef 100644 --- a/js/app/components/settings/branding-admin.tsx +++ b/js/app/components/settings/branding-admin.tsx @@ -60,6 +60,36 @@ function bytesToBase64(bytes: Uint8Array): string { return btoa(binary); } +// The link-card templates (pkg/branding vocab): {site} is cardSiteName over +// the site title; {name} and {handle} the account a page is about. The +// placeholders show each template's default. +const CARD_TEMPLATE_KEYS: { key: string; placeholder: string }[] = [ + { + key: "cardSiteName", + placeholder: "Site name in cards (default: site title)", + }, + { key: "cardLiveTitle", placeholder: "{name} is live on {site}" }, + { + key: "cardHomeTitle", + placeholder: "Front page title (default: site title)", + }, + { + key: "cardHomeDescription", + placeholder: "Front page description (default: site description)", + }, + { key: "cardVideosTitle", placeholder: "Videos on {site}" }, + { + key: "cardVideosDescription", + placeholder: "Watch replays of live streams on {site}.", + }, + { key: "cardVideoTitle", placeholder: "{name}'s video on {site}" }, + { key: "cardGoLiveTitle", placeholder: "Go live on {site}" }, + { + key: "cardGoLiveDescription", + placeholder: "Set up and start your live stream on {site}.", + }, +]; + export function BrandingAdmin() { const { t } = useTranslation("settings"); const { theme } = useTheme(); @@ -290,6 +320,15 @@ export function BrandingAdmin() { case "socialLinks": case "networkName": case "loginPlaceholder": + case "cardSiteName": + case "cardLiveTitle": + case "cardHomeTitle": + case "cardHomeDescription": + case "cardVideosTitle": + case "cardVideosDescription": + case "cardVideoTitle": + case "cardGoLiveTitle": + case "cardGoLiveDescription": setChromeInputs((prev) => ({ ...prev, [key]: "" })); break; case "defaultStreamer": @@ -624,6 +663,85 @@ export function BrandingAdmin() { + + + + + {t("branding-link-cards")} + + + {t("branding-link-cards-description")} + + {CARD_TEMPLATE_KEYS.map(({ key, placeholder }) => ( + + + + {key} + + + + + setChromeInputs((prev) => ({ + ...prev, + [key]: v, + })) + } + /> + + + ))} + + + + + + + + diff --git a/js/i18n/locales/en-US/settings.ftl b/js/i18n/locales/en-US/settings.ftl index 2aada08b3..3b4b5947a 100644 --- a/js/i18n/locales/en-US/settings.ftl +++ b/js/i18n/locales/en-US/settings.ftl @@ -270,6 +270,8 @@ branding-site-title = Site Title branding-site-title-placeholder = Enter new site title branding-site-description = Site Description branding-site-description-placeholder = Enter site description +branding-link-cards = Link cards +branding-link-cards-description = What a shared link shows for each page: the stream page, the front page (live tab), the videos tab, a video page and the go-live tab. Templates take {"{site}"} (the site name below, or the site title), and on pages about one account {"{name}"} (their display name, else handle) and {"{handle}"}. Descriptions of stream and video pages are their post text. branding-network-name = Network name branding-network-name-description = What the app calls the social network viewers sign in with and share to: "Sign in with your { $network } account", "Share to { $network }", the live-ring permission. Default Bluesky. branding-login-placeholder = Login handle example diff --git a/js/i18n/public/locales/en-US/settings.json b/js/i18n/public/locales/en-US/settings.json index 4cb8d1a42..3aafc267b 100644 --- a/js/i18n/public/locales/en-US/settings.json +++ b/js/i18n/public/locales/en-US/settings.json @@ -210,6 +210,8 @@ "branding-site-title-placeholder": "Enter new site title", "branding-site-description": "Site Description", "branding-site-description-placeholder": "Enter site description", + "branding-link-cards": "Link cards", + "branding-link-cards-description": "What a shared link shows for each page: the stream page, the front page (live tab), the videos tab, a video page and the go-live tab. Templates take {} (the site name below, or the site title), and on pages about one account {} (their display name, else handle) and {}. Descriptions of stream and video pages are their post text.", "branding-network-name": "Network name", "branding-network-name-description": "What the app calls the social network viewers sign in with and share to: \"Sign in with your {$network} account\", \"Share to {$network}\", the live-ring permission. Default Bluesky.", "branding-login-placeholder": "Login handle example", diff --git a/js/web/public/locales/en-US/settings.json b/js/web/public/locales/en-US/settings.json index 4cb8d1a42..3aafc267b 100644 --- a/js/web/public/locales/en-US/settings.json +++ b/js/web/public/locales/en-US/settings.json @@ -210,6 +210,8 @@ "branding-site-title-placeholder": "Enter new site title", "branding-site-description": "Site Description", "branding-site-description-placeholder": "Enter site description", + "branding-link-cards": "Link cards", + "branding-link-cards-description": "What a shared link shows for each page: the stream page, the front page (live tab), the videos tab, a video page and the go-live tab. Templates take {} (the site name below, or the site title), and on pages about one account {} (their display name, else handle) and {}. Descriptions of stream and video pages are their post text.", "branding-network-name": "Network name", "branding-network-name-description": "What the app calls the social network viewers sign in with and share to: \"Sign in with your {$network} account\", \"Share to {$network}\", the live-ring permission. Default Bluesky.", "branding-login-placeholder": "Login handle example", diff --git a/pkg/api/api.go b/pkg/api/api.go index 4a42ee1dc..3e27b2d7f 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -484,6 +484,22 @@ func (a *StreamplaceAPI) notFoundLinkingHandler(ctx context.Context, linker *lin req.URL.Host = req.Host req.URL.Scheme = proto + // The app's tabs each get their own card: the front page (live), + // /video (on demand) and /live (go live). + if page := linking.LandingPageFor(req.URL.Path); page != linking.LandingDefault { + bs, err := linker.GenerateLandingCard(ctx, req.URL, page, a.CLI.SentryDSN) + if err != nil { + log.Error(ctx, "error generating landing card", "path", req.URL.Path, "error", err) + defaultHandler.ServeHTTP(w, req) + return + } + w.Header().Set("Content-Type", "text/html") + if _, err := w.Write(bs); err != nil { + log.Error(ctx, "error writing response", "error", err) + } + return + } + // VOD link cards live at //video/. Everything else with a // slash in it falls through to static-file / default-card handling. parts := strings.Split(strings.Trim(req.URL.Path, "/"), "/") @@ -519,6 +535,7 @@ func (a *StreamplaceAPI) notFoundLinkingHandler(ctx context.Context, linker *lin defaultHandler.ServeHTTP(w, req) return } + a.hydrateAuthorName(ctx, &lsv.Author) bs, err := linker.GenerateStreamerCard(ctx, req.URL, lsv, a.CLI.SentryDSN) if err != nil { log.Error(ctx, "error generating html", "error", err) @@ -551,6 +568,7 @@ func (a *StreamplaceAPI) writeVideoCard(ctx context.Context, w http.ResponseWrit if vv == nil { return false } + a.hydrateAuthorName(ctx, &vv.Author) bs, err := linker.GenerateVideoCard(ctx, req.URL, vv, a.CLI.SentryDSN) if err != nil { log.Error(ctx, "error generating video card", "uri", uri, "error", err) @@ -563,6 +581,24 @@ func (a *StreamplaceAPI) writeVideoCard(ctx context.Context, w http.ResponseWrit return true } +// hydrateAuthorName fills a card author's display name from their indexed +// profile record (the same source the stream page's websocket names the +// streamer from), so cards can say "Ada Lovelace is live" rather than +// "@ada.example". Left empty when no profile is indexed; the card then +// falls back to the handle. +func (a *StreamplaceAPI) hydrateAuthorName(ctx context.Context, author *appbsky.ActorDefs_ProfileViewBasic) { + if author == nil || author.Did == "" || author.DisplayName != nil { + return + } + bp, err := a.Model.GetBskyProfile(ctx, author.Did, false) + if err != nil || bp == nil || bp.DisplayName == nil { + return + } + if name := strings.TrimSpace(*bp.DisplayName); name != "" { + author.DisplayName = &name + } +} + func (a *StreamplaceAPI) MistProxyHandler(ctx context.Context, tmpl string) httprouter.Handle { return func(w http.ResponseWriter, req *http.Request, params httprouter.Params) { if !a.CLI.HasMist() { diff --git a/pkg/branding/vocab.go b/pkg/branding/vocab.go index 69d3a13b7..c558d63db 100644 --- a/pkg/branding/vocab.go +++ b/pkg/branding/vocab.go @@ -54,6 +54,15 @@ var Specs = []Spec{ {Key: "siteTitle", Kind: KindText, MaxSize: textMax, Doc: "Name of the node, shown in the nav lockup, the browser tab and link cards."}, {Key: "siteDescription", Kind: KindText, MaxSize: textMax, Doc: "One-line description for link cards and the page meta description."}, {Key: "networkName", Kind: KindText, Default: "Bluesky", MaxSize: textMax, Doc: "What the app calls the social network viewers sign in with and share to."}, + {Key: "cardSiteName", Kind: KindText, MaxSize: textMax, Doc: "How link cards name the site in their titles: the {site} in the card templates below. Default siteTitle."}, + {Key: "cardLiveTitle", Kind: KindText, Default: "{name} is live on {site}", MaxSize: textMax, Doc: "Link-card title of a live stream page; {name} is the streamer's display name (their handle when unset), {handle} their handle, {site} cardSiteName. The description is the stream's post text."}, + {Key: "cardHomeTitle", Kind: KindText, MaxSize: textMax, Doc: "Link-card title of the front page (the live tab), e.g. \"Live streams on {site}\". Default siteTitle."}, + {Key: "cardHomeDescription", Kind: KindText, MaxSize: textMax, Doc: "Link-card description of the front page, e.g. \"Watch live streams from verified accounts on {site}.\" Default siteDescription."}, + {Key: "cardVideosTitle", Kind: KindText, Default: "Videos on {site}", MaxSize: textMax, Doc: "Link-card title of the on-demand videos tab (/video)."}, + {Key: "cardVideosDescription", Kind: KindText, Default: "Watch replays of live streams on {site}.", MaxSize: textMax, Doc: "Link-card description of the on-demand videos tab."}, + {Key: "cardVideoTitle", Kind: KindText, Default: "{name}'s video on {site}", MaxSize: textMax, Doc: "Link-card title of a video page; {name}, {handle} and {site} as in cardLiveTitle. The description is the video's title."}, + {Key: "cardGoLiveTitle", Kind: KindText, Default: "Go live on {site}", MaxSize: textMax, Doc: "Link-card title of the go-live tab (/live)."}, + {Key: "cardGoLiveDescription", Kind: KindText, Default: "Set up and start your live stream on {site}.", MaxSize: textMax, Doc: "Link-card description of the go-live tab."}, {Key: "loginPlaceholder", Kind: KindText, MaxSize: textMax, Doc: "Example handle shown in the login form's empty handle field."}, {Key: "defaultStreamer", Kind: KindText, MaxSize: textMax, Doc: "Handle or DID whose stream page is the node's front door (single-user nodes)."}, {Key: "primaryColor", Kind: KindColor, Default: "#6366f1", MaxSize: textMax, Doc: "Buttons, links, focus rings."}, diff --git a/pkg/linking/linking.go b/pkg/linking/linking.go index bd7b877a3..b3d16592b 100644 --- a/pkg/linking/linking.go +++ b/pkg/linking/linking.go @@ -14,6 +14,7 @@ import ( "stream.place/streamplace/pkg/log" "golang.org/x/net/html" + "stream.place/streamplace/pkg/appbsky" "stream.place/streamplace/pkg/branding" "stream.place/streamplace/pkg/config" "stream.place/streamplace/pkg/placestream" @@ -179,92 +180,153 @@ func (l *Linker) getBrandingAssets(broadcasterDid string) ([]placestream.Brandin return ret, nil } -func (l *Linker) GenerateStreamerCard(ctx context.Context, u *url.URL, lsv *placestream.Livestream_LivestreamView, sentryDSN string) ([]byte, error) { - if u == nil { - return nil, errors.New("url is nil") +// brandMetas fetches the node's branding once for a card: the +// internal-brand:* meta tags the app hydrates from, plus every text value by +// key so the card's own title and description can be branded. +func (l *Linker) brandMetas(ctx context.Context) ([]MetaTag, map[string]string) { + values := map[string]string{} + if l.sdb == nil || l.cli == nil { + return nil, values } - if lsv == nil { - return nil, errors.New("livestream view is nil") + assets, err := l.getBrandingAssets("did:web:" + l.cli.BroadcasterHost) + if err != nil { + // log but we should not block rendering + log.Error(ctx, "error fetching branding assets", "error", err) + return nil, values + } + var metas []MetaTag + for i := range assets { + val := assets[i] + if val.MimeType == branding.TextMime && val.Data != nil { + values[val.Key] = strings.TrimSpace(*val.Data) + } + marshalledJson, err := json.Marshal(val) + if err != nil { + log.Error(ctx, "error marshalling branding asset", "key", val.Key, "error", err) + continue + } + metas = append(metas, MetaTag{ + Type: "name", + Key: "internal-brand:" + val.Key, + Content: string(marshalledJson), + }) } - ls, ok := lsv.Record.Val.(*placestream.Livestream) - if !ok { - return nil, errors.New("livestream view is not a livestream") + return metas, values +} + +// The card text when a node has no siteTitle / siteDescription branding. +const ( + defaultSiteTitle = "streamplace node" + defaultSiteDescription = "Open-source livestreaming on the AT Protocol." +) + +// cardText resolves one of the card template keys: the node's value when +// set, else the vocabulary's default. +func cardText(values map[string]string, key string) string { + if v := values[key]; v != "" { + return v + } + if spec, ok := branding.Lookup(key); ok { + return spec.Default } + return "" +} - titleStr := fmt.Sprintf("@%s's livestream on ", lsv.Author.Handle) - outURL := u.String() +// siteTitle is the node's name for og:site_name and the page title fallback; +// siteName is how the card templates refer to it ({site}), which a node can +// shorten with cardSiteName ("W" for a "W Social" site). +func siteTitle(values map[string]string) string { + if v := values["siteTitle"]; v != "" { + return v + } + return defaultSiteTitle +} - thumbURL, _ := url.Parse(u.String()) - thumbURL.Path = "/xrpc/place.stream.live.getProfileCard" - thumbURL.RawQuery = fmt.Sprintf("id=%s", lsv.Author.Did) +func siteName(values map[string]string) string { + if v := values["cardSiteName"]; v != "" { + return v + } + return siteTitle(values) +} - // Define all meta tags - metaTags := []MetaTag{ +// expandCard fills a card template's placeholders: {site}, and for a page +// about one account {name} (display name, falling back to the handle) and +// {handle}. +func expandCard(tmpl string, values map[string]string, author *appbsky.ActorDefs_ProfileViewBasic) string { + r := []string{"{site}", siteName(values)} + if author != nil { + name := author.Handle + if author.DisplayName != nil && strings.TrimSpace(*author.DisplayName) != "" { + name = strings.TrimSpace(*author.DisplayName) + } + r = append(r, "{name}", name, "{handle}", author.Handle) + } + return strings.TrimSpace(strings.NewReplacer(r...).Replace(tmpl)) +} + +// cardTags builds the description, OpenGraph and Twitter tags every card +// shares. +func cardTags(u *url.URL, ogType, title, description, image, site string) []MetaTag { + tags := []MetaTag{ // Basic meta - {Type: "name", Key: "description", Content: ls.Title}, + {Type: "name", Key: "description", Content: description}, // Facebook Meta Tags {Type: "property", Key: "og:url", Content: u.String()}, - {Type: "property", Key: "og:type", Content: "website"}, - {Type: "property", Key: "og:description", Content: ls.Title}, - {Type: "property", Key: "og:image", Content: thumbURL.String()}, + {Type: "property", Key: "og:type", Content: ogType}, + {Type: "property", Key: "og:site_name", Content: site}, + {Type: "property", Key: "og:title", Content: title}, + {Type: "property", Key: "og:description", Content: description}, + {Type: "property", Key: "og:image", Content: image}, // Twitter Meta Tags {Type: "name", Key: "twitter:card", Content: "summary_large_image"}, {Type: "property", Key: "twitter:domain", Content: u.Host}, - {Type: "property", Key: "twitter:url", Content: outURL}, - {Type: "name", Key: "twitter:description", Content: ls.Title}, - {Type: "name", Key: "twitter:image", Content: thumbURL.String()}, - } - brandingTitle := "streamplace node" - if l.sdb != nil && l.cli != nil { - branding, err := l.getBrandingAssets("did:web:" + l.cli.BroadcasterHost) - if err == nil { - for i := range branding { - val := branding[i] - if val.Key == "siteTitle" && val.Data != nil { - brandingTitle = *val.Data - } - marshalledJson, err := json.Marshal(val) - if err != nil { - log.Error(ctx, "error marshalling branding asset", "key", val.Key, "error", err) - continue - } - metaTags = append(metaTags, MetaTag{ - Type: "name", - Key: "internal-brand:" + val.Key, - Content: string(marshalledJson), - }) - } - } else { - // log but we should not block rendering - log.Error(ctx, "error fetching branding assets", "error", err) - } + {Type: "property", Key: "twitter:url", Content: u.String()}, + {Type: "name", Key: "twitter:title", Content: title}, + {Type: "name", Key: "twitter:description", Content: description}, + {Type: "name", Key: "twitter:image", Content: image}, } + return tags +} - // do twitter/og title after - metaTags = append(metaTags, MetaTag{ - Type: "property", - Key: "og:title", - Content: fmt.Sprintf("%s%s", titleStr, brandingTitle), - }) - metaTags = append(metaTags, MetaTag{ - Type: "name", - Key: "twitter:title", - Content: fmt.Sprintf("%s%s", titleStr, brandingTitle), - }) +// GenerateStreamerCard is the card for a stream page: " is live on +// " (cardLiveTitle) over the stream's post text, with the streamer's +// profile card as the image. +func (l *Linker) GenerateStreamerCard(ctx context.Context, u *url.URL, lsv *placestream.Livestream_LivestreamView, sentryDSN string) ([]byte, error) { + if u == nil { + return nil, errors.New("url is nil") + } + if lsv == nil { + return nil, errors.New("livestream view is nil") + } + ls, ok := lsv.Record.Val.(*placestream.Livestream) + if !ok { + return nil, errors.New("livestream view is not a livestream") + } + + thumbURL, _ := url.Parse(u.String()) + thumbURL.Path = "/xrpc/place.stream.live.getProfileCard" + thumbURL.RawQuery = fmt.Sprintf("id=%s", lsv.Author.Did) + + brandMetas, values := l.brandMetas(ctx) + title := expandCard(cardText(values, "cardLiveTitle"), values, &lsv.Author) + metaTags := cardTags(u, "website", title, ls.Title, thumbURL.String(), siteTitle(values)) + metaTags = append(metaTags, brandMetas...) // at-tags: this page canonically maps to the livestream record, authored // by the streamer metaTags = append(metaTags, l.atTags(lsv.Uri, lsv.Author.Did)...) return l.GenerateHTML(ctx, &PageConfig{ - Title: fmt.Sprintf("%s%s", titleStr, brandingTitle), + Title: title, Metas: metaTags, SentryDSN: sentryDSN, }) } +// GenerateVideoCard is the card for a video page: "'s video on " +// (cardVideoTitle) over the video's own title. func (l *Linker) GenerateVideoCard(ctx context.Context, u *url.URL, vv *placestream.MediaGetVideo_VideoView, sentryDSN string) ([]byte, error) { if u == nil { return nil, errors.New("url is nil") @@ -277,14 +339,7 @@ func (l *Linker) GenerateVideoCard(ctx context.Context, u *url.URL, vv *placestr return nil, errors.New("video view record is not a video") } - outURL := u.String() - - authorDid := "" - authorHandle := "" - if !false { - authorDid = vv.Author.Did - authorHandle = vv.Author.Handle - } + authorDid := vv.Author.Did // og:image is the VOD's own thumbnail, served through the Bluesky image // CDN (which fetches the blob from the author's PDS — the same trick used @@ -303,60 +358,16 @@ func (l *Linker) GenerateVideoCard(ctx context.Context, u *url.URL, vv *placestr imageURL = cardURL.String() } - // Define all meta tags. The title/description pair is appended after we - // resolve the node's branding title below: the card headline is the - // video's own title, and the author + node go in the description. - metaTags := []MetaTag{ - // Facebook Meta Tags - {Type: "property", Key: "og:url", Content: outURL}, - {Type: "property", Key: "og:type", Content: "video.other"}, - {Type: "property", Key: "og:image", Content: imageURL}, - - // Twitter Meta Tags - {Type: "name", Key: "twitter:card", Content: "summary_large_image"}, - {Type: "property", Key: "twitter:domain", Content: u.Host}, - {Type: "property", Key: "twitter:url", Content: outURL}, - {Type: "name", Key: "twitter:image", Content: imageURL}, - } - - brandingTitle := "streamplace node" - if l.sdb != nil && l.cli != nil { - branding, err := l.getBrandingAssets("did:web:" + l.cli.BroadcasterHost) - if err == nil { - for i := range branding { - val := branding[i] - if val.Key == "siteTitle" && val.Data != nil { - brandingTitle = *val.Data - } - marshalledJson, err := json.Marshal(val) - if err != nil { - log.Error(ctx, "error marshalling branding asset %s", "key", val.Key, "error", err) - continue - } - metaTags = append(metaTags, MetaTag{ - Type: "name", - Key: "internal-brand:" + val.Key, - Content: string(marshalledJson), - }) - } - } else { - // log but we should not block rendering - log.Error(ctx, "error fetching branding assets", "error", err) - } + brandMetas, values := l.brandMetas(ctx) + title := expandCard(cardText(values, "cardVideoTitle"), values, &vv.Author) + // The description is the video's title, the post text it was published + // with; a video without one falls back to its description. + description := strings.TrimSpace(video.Title) + if description == "" && video.Description != nil { + description = strings.TrimSpace(*video.Description) } - - // The card headline is the video's own title; the author + node name go - // in the description. Appended after the branding loop so the description - // can reference the resolved branding title. - title := video.Title - description := fmt.Sprintf("@%s's video on %s", authorHandle, brandingTitle) - metaTags = append(metaTags, - MetaTag{Type: "name", Key: "description", Content: description}, - MetaTag{Type: "property", Key: "og:description", Content: description}, - MetaTag{Type: "property", Key: "og:title", Content: title}, - MetaTag{Type: "name", Key: "twitter:description", Content: description}, - MetaTag{Type: "name", Key: "twitter:title", Content: title}, - ) + metaTags := cardTags(u, "video.other", title, description, imageURL, siteTitle(values)) + metaTags = append(metaTags, brandMetas...) // at-tags: this page canonically maps to the place.stream.video record, // authored by the streamer @@ -369,72 +380,82 @@ func (l *Linker) GenerateVideoCard(ctx context.Context, u *url.URL, vv *placestr }) } +// LandingPage names the app's top-level tabs, each with its own link card. +type LandingPage int + +const ( + // LandingDefault is any page without a card of its own: the node's + // siteTitle and siteDescription. + LandingDefault LandingPage = iota + // LandingHome is the front page, the live tab (/): cardHomeTitle and + // cardHomeDescription, falling back to the site's own. + LandingHome + // LandingVideos is the on-demand tab (/video). + LandingVideos + // LandingGoLive is the go-live tab (/live, /go-live). + LandingGoLive +) + +// LandingPageFor maps a request path to the tab it shows, LandingDefault for +// anything else. +func LandingPageFor(path string) LandingPage { + switch strings.Trim(path, "/") { + case "": + return LandingHome + case "video": + return LandingVideos + case "live", "go-live": + return LandingGoLive + } + return LandingDefault +} + +// GenerateDefaultCard is the card for a page without one of its own: the +// node's siteTitle and siteDescription with its link banner. func (l *Linker) GenerateDefaultCard(ctx context.Context, u *url.URL, sentryDSN string) ([]byte, error) { + return l.GenerateLandingCard(ctx, u, LandingDefault, sentryDSN) +} + +// GenerateLandingCard is the card for one of the app's tabs: its own title +// and description from the card templates (cardHomeTitle, cardVideosTitle, +// cardGoLiveTitle and their descriptions), the node's link banner as the +// image. +func (l *Linker) GenerateLandingCard(ctx context.Context, u *url.URL, page LandingPage, sentryDSN string) ([]byte, error) { if u == nil { return nil, errors.New("url is nil") } - // The front-page card is the node's own: its siteTitle and - // siteDescription branding, and /linkbanner.png, which serves the - // uploaded linkBanner asset when there is one and the bundled brand - // banner otherwise. + // /linkbanner.png serves the uploaded linkBanner asset when there is one + // and the bundled brand banner otherwise. thumbURL, _ := url.Parse(u.String()) thumbURL.Path = "/linkbanner.png" - brandingTitle := "streamplace node" - brandingDescription := "Open-source livestreaming on the AT Protocol." - var brandMetas []MetaTag - if l.sdb != nil && l.cli != nil { - branding, err := l.getBrandingAssets("did:web:" + l.cli.BroadcasterHost) - if err == nil { - for i := range branding { - val := branding[i] - if val.Data != nil && strings.TrimSpace(*val.Data) != "" { - switch val.Key { - case "siteTitle": - brandingTitle = *val.Data - case "siteDescription": - brandingDescription = *val.Data - } - } - marshalledJson, err := json.Marshal(val) - if err != nil { - log.Error(ctx, "error marshalling branding asset", "key", val.Key, "error", err) - continue - } - brandMetas = append(brandMetas, MetaTag{ - Type: "name", - Key: "internal-brand:" + val.Key, - Content: string(marshalledJson), - }) - } - } else { - // log but we should not block rendering - log.Error(ctx, "error fetching branding assets", "error", err) + brandMetas, values := l.brandMetas(ctx) + title := siteTitle(values) + description := values["siteDescription"] + if description == "" { + description = defaultSiteDescription + } + switch page { + case LandingHome: + // The front page falls back to the site's own title and description; + // a node phrases its live tab ("Live streams on W") by setting the + // home templates. + if t := expandCard(cardText(values, "cardHomeTitle"), values, nil); t != "" { + title = t + } + if d := expandCard(cardText(values, "cardHomeDescription"), values, nil); d != "" { + description = d } + case LandingVideos: + title = expandCard(cardText(values, "cardVideosTitle"), values, nil) + description = expandCard(cardText(values, "cardVideosDescription"), values, nil) + case LandingGoLive: + title = expandCard(cardText(values, "cardGoLiveTitle"), values, nil) + description = expandCard(cardText(values, "cardGoLiveDescription"), values, nil) } - // Define all meta tags - metaTags := []MetaTag{ - // Basic meta - {Type: "name", Key: "description", Content: brandingDescription}, - - // Facebook Meta Tags - {Type: "property", Key: "og:url", Content: u.String()}, - {Type: "property", Key: "og:type", Content: "website"}, - {Type: "property", Key: "og:site_name", Content: brandingTitle}, - {Type: "property", Key: "og:title", Content: brandingTitle}, - {Type: "property", Key: "og:description", Content: brandingDescription}, - {Type: "property", Key: "og:image", Content: thumbURL.String()}, - - // Twitter Meta Tags - {Type: "name", Key: "twitter:card", Content: "summary_large_image"}, - {Type: "property", Key: "twitter:domain", Content: u.Host}, - {Type: "property", Key: "twitter:url", Content: u.String()}, - {Type: "name", Key: "twitter:title", Content: brandingTitle}, - {Type: "name", Key: "twitter:description", Content: brandingDescription}, - {Type: "name", Key: "twitter:image", Content: thumbURL.String()}, - } + metaTags := cardTags(u, "website", title, description, thumbURL.String(), siteTitle(values)) metaTags = append(metaTags, brandMetas...) // at-tags: the site itself is identified by this node's did:web; there's @@ -442,7 +463,7 @@ func (l *Linker) GenerateDefaultCard(ctx context.Context, u *url.URL, sentryDSN metaTags = append(metaTags, l.atTags("", "")...) return l.GenerateHTML(ctx, &PageConfig{ - Title: brandingTitle, + Title: title, Metas: metaTags, SentryDSN: sentryDSN, }) diff --git a/pkg/linking/linking_test.go b/pkg/linking/linking_test.go index 51d5c3851..c9b779f9a 100644 --- a/pkg/linking/linking_test.go +++ b/pkg/linking/linking_test.go @@ -16,6 +16,7 @@ import ( "stream.place/streamplace/pkg/comatproto" "stream.place/streamplace/pkg/config" "stream.place/streamplace/pkg/placestream" + "stream.place/streamplace/pkg/statedb" ) func IndexHTML(t *testing.T) []byte { @@ -115,10 +116,11 @@ func TestGenerateVideoCard(t *testing.T) { require.NoError(t, err) linkStr := string(linkCard) require.True(t, strings.Contains(linkStr, "iame.li"), "should contain the author handle") - require.True(t, strings.Contains(linkStr, ""+video.Title+""), - "page title should be the video's own title") - require.True(t, strings.Contains(linkStr, `content="`+video.Title+`"`), - "og:title/twitter:title should be the video's own title") + require.Contains(t, linkStr, "iame.li's video on streamplace node", + "page title names the author (handle without a display name) and the site") + require.Contains(t, linkStr, ``, + "og:description is the video's own title") + require.Contains(t, linkStr, ``) require.True(t, strings.Contains(linkStr, "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:2zmxikig2sj7gqaezl5gntae/"+thumbCID+"@jpeg"), "og:image should be the video thumbnail served via the bsky CDN") @@ -183,3 +185,118 @@ func TestGenerateHTMLReplacesTemplatePreviewTags(t *testing.T) { require.Equal(t, 1, strings.Count(page, `property="og:title"`)) require.Equal(t, 1, strings.Count(page, "")) } + +func TestHomeCardFallsBackToSite(t *testing.T) { + ctx := context.Background() + linker, err := NewLinker(ctx, IndexHTML(t), nil, nil) + require.NoError(t, err) + home, _ := url.Parse("https://stream.place/") + out, err := linker.GenerateLandingCard(ctx, home, LandingHome, "") + require.NoError(t, err) + page := string(out) + require.Contains(t, page, `<meta property="og:title" content="streamplace node"/>`) + require.Contains(t, page, `<meta property="og:description" content="Open-source livestreaming on the AT Protocol."/>`) + require.Equal(t, 1, strings.Count(page, `property="og:title"`)) +} + +func TestLandingPageFor(t *testing.T) { + require.Equal(t, LandingHome, LandingPageFor("/")) + require.Equal(t, LandingHome, LandingPageFor("")) + require.Equal(t, LandingVideos, LandingPageFor("/video")) + require.Equal(t, LandingVideos, LandingPageFor("/video/")) + require.Equal(t, LandingGoLive, LandingPageFor("/live")) + require.Equal(t, LandingGoLive, LandingPageFor("/go-live")) + require.Equal(t, LandingDefault, LandingPageFor("/settings")) + require.Equal(t, LandingDefault, LandingPageFor("/ada.example")) + require.Equal(t, LandingDefault, LandingPageFor("/ada.example/video")) +} + +// The card templates: {name} is the display name when the profile is +// indexed, the handle otherwise; {site} is cardSiteName over siteTitle; the +// tabs each carry their own title and description. +func TestCardTemplates(t *testing.T) { + ctx := context.Background() + index := IndexHTML(t) + cli := &config.CLI{BroadcasterHost: "example.com"} + sdb, err := statedb.MakeDB(ctx, &config.CLI{DBURL: ":memory:"}, nil, nil) + require.NoError(t, err) + linker, err := NewLinker(ctx, index, sdb, cli) + require.NoError(t, err) + put := func(key, value string) { + require.NoError(t, sdb.PutBrandingBlob("did:web:example.com", key, "text/plain", []byte(value), nil, nil)) + } + put("siteTitle", "Example Social") + put("siteDescription", "Trust your feed") + put("cardSiteName", "X") + put("cardHomeTitle", "Live streams on {site}") + put("cardHomeDescription", "Watch live streams from verified accounts on {site}.") + put("cardVideosTitle", "{site} play") + put("cardVideosDescription", "Watch replays of live streams from verified accounts on {site}.") + put("cardVideoTitle", "{name} on {site} play") + + name := "Ada Lovelace" + ls := &placestream.Livestream{CreatedAt: "2025-03-25T00:39:49.121Z", Title: "Going live from the lab"} + lsv := &placestream.Livestream_LivestreamView{ + Author: appbsky.ActorDefs_ProfileViewBasic{Handle: "ada.example", Did: "did:plc:ada", DisplayName: &name}, + Record: &glex.LexiconTypeDecoder{Val: ls}, + Uri: "at://did:plc:ada/place.stream.livestream/3ll5zuop2k22x", + } + u, _ := url.Parse("https://example.com/ada.example") + out, err := linker.GenerateStreamerCard(ctx, u, lsv, "") + require.NoError(t, err) + page := string(out) + require.Contains(t, page, `<meta property="og:title" content="Ada Lovelace is live on X"/>`) + require.Contains(t, page, `<meta property="og:description" content="Going live from the lab"/>`) + require.Contains(t, page, `<meta property="og:site_name" content="Example Social"/>`) + require.Contains(t, page, "<title>Ada Lovelace is live on X") + + lsv.Author.DisplayName = nil + out, err = linker.GenerateStreamerCard(ctx, u, lsv, "") + require.NoError(t, err) + require.Contains(t, string(out), `content="ada.example is live on X"`, "no display name: the handle") + + video := &placestream.Video{Title: "Lab replay"} + vv := &placestream.MediaGetVideo_VideoView{ + Author: appbsky.ActorDefs_ProfileViewBasic{Handle: "ada.example", Did: "did:plc:ada", DisplayName: &name}, + Record: &glex.LexiconTypeDecoder{Val: video}, + Uri: "at://did:plc:ada/place.stream.video/3mms3tfkcxstu", + } + vu, _ := url.Parse("https://example.com/ada.example/video/3mms3tfkcxstu") + out, err = linker.GenerateVideoCard(ctx, vu, vv, "") + require.NoError(t, err) + page = string(out) + require.Contains(t, page, ``) + require.Contains(t, page, ``) + + home, _ := url.Parse("https://example.com/") + out, err = linker.GenerateLandingCard(ctx, home, LandingHome, "") + require.NoError(t, err) + page = string(out) + require.Contains(t, page, ``) + require.Contains(t, page, ``) + require.Contains(t, page, ``) + + videos, _ := url.Parse("https://example.com/video") + out, err = linker.GenerateLandingCard(ctx, videos, LandingVideos, "") + require.NoError(t, err) + page = string(out) + require.Contains(t, page, ``) + require.Contains(t, page, `content="Watch replays of live streams from verified accounts on X."`) + + // The go-live templates were left at their defaults. + golive, _ := url.Parse("https://example.com/live") + out, err = linker.GenerateLandingCard(ctx, golive, LandingGoLive, "") + require.NoError(t, err) + page = string(out) + require.Contains(t, page, ``) + require.Contains(t, page, `content="Set up and start your live stream on X."`) + + // Any other page: the site's own title and description. + out, err = linker.GenerateLandingCard(ctx, home, LandingDefault, "") + require.NoError(t, err) + page = string(out) + require.Contains(t, page, ``) + require.Contains(t, page, ``) +} + +// Without the home templates the front page keeps the site's own card. -- knotmirror 0.0.1