diff --git a/pkg/linking/linking.go b/pkg/linking/linking.go index 45012c82d..682c9fe47 100644 --- a/pkg/linking/linking.go +++ b/pkg/linking/linking.go @@ -423,6 +423,24 @@ func (l *Linker) GenerateDefaultCard(ctx context.Context, u *url.URL, sentryDSN }) } +// isLinkPreviewMeta reports whether a is one the cards own: the page +// description and every og: / twitter: tag. +func isLinkPreviewMeta(node *html.Node) bool { + for _, attr := range node.Attr { + switch attr.Key { + case "property": + if strings.HasPrefix(attr.Val, "og:") || strings.HasPrefix(attr.Val, "twitter:") { + return true + } + case "name": + if attr.Val == "description" || strings.HasPrefix(attr.Val, "twitter:") { + return true + } + } + } + return false +} + func (l *Linker) GenerateHTML(ctx context.Context, pc *PageConfig) ([]byte, error) { root, err := html.Parse(bytes.NewReader(l.BaseHTML)) @@ -452,17 +470,20 @@ func (l *Linker) GenerateHTML(ctx context.Context, pc *PageConfig) ([]byte, erro return nil, errors.New("head not found") } - // Title tag (handled separately as it's not a meta tag) - - var oldTitle *html.Node + // The template ships its own title, description and link-preview tags + // (the first-party brand, for a static host). Every card replaces them, + // and crawlers honour the first tag they meet, so the template's go. + var stale []*html.Node for node := range head.ChildNodes() { - if node.Type == html.ElementNode && node.Data == "title" { - oldTitle = node - break + if node.Type != html.ElementNode { + continue + } + if node.Data == "title" || (node.Data == "meta" && isLinkPreviewMeta(node)) { + stale = append(stale, node) } } - if oldTitle != nil { - head.RemoveChild(oldTitle) + for _, node := range stale { + head.RemoveChild(node) } title := &html.Node{ diff --git a/pkg/linking/linking_test.go b/pkg/linking/linking_test.go index 99edb6583..97d081c25 100644 --- a/pkg/linking/linking_test.go +++ b/pkg/linking/linking_test.go @@ -146,3 +146,31 @@ func TestGenerateDefaultCardAtMe(t *testing.T) { require.NotContains(t, linkStr, "at:canonical", "front page has no canonical record") require.NotContains(t, linkStr, "at:author", "front page has no single author") } + +// The app template ships first-party link-preview tags; a card must replace +// them rather than append after them, since crawlers take the first tag. +func TestGenerateHTMLReplacesTemplatePreviewTags(t *testing.T) { + base := []byte(` +Streamplace + + + + + + + +`) + linker, err := NewLinker(context.Background(), base, nil, &config.CLI{BroadcasterHost: "example.com"}) + require.NoError(t, err) + u, err := url.Parse("https://example.com/") + require.NoError(t, err) + out, err := linker.GenerateDefaultCard(context.Background(), u, "") + require.NoError(t, err) + page := string(out) + require.NotContains(t, page, "video layer for everything") + require.NotContains(t, page, `content="/linkbanner.png"`) + require.NotContains(t, page, "og:image:width") + require.Contains(t, page, `name="viewport"`) + require.Equal(t, 1, strings.Count(page, `property="og:title"`)) + require.Equal(t, 1, strings.Count(page, "")) +}