[feature] Preserve whitespace in opengraph meta description elements (#4658)

# Description

> If this is a code change, please include a summary of what you've coded, and link to the issue(s) it closes/implements.
>
> If this is a documentation change, please briefly describe what you've changed and why.

More dicking about with opengraph meta stuff. This one preserves whitespace and newlines and indentation and stuff inside of ogmeta description tags. Should make multi-line posts look a LOT better when linked in discord etc.

![image](/attachments/8c8721c9-af00-4b4b-afd4-ad8044e6af7a)

Also closes https://codeberg.org/superseriousbusiness/gotosocial/issues/2452 for all intents + purposes, since text inside description tags is now converted to text, and go templating escapes them instead of them being escaped and *then* truncated.

## Checklist

Please put an x inside each checkbox to indicate that you've read and followed it: `[ ]` -> `[x]`

If this is a documentation change, only the first two checkboxes must be filled (you can delete the others if you want).

- [x] I/we have read the [GoToSocial contribution guidelines](https://codeberg.org/superseriousbusiness/gotosocial/src/branch/main/CONTRIBUTING.md).
- [x] I/we have not used so-called 'AI' to create the proposed changes.
- [x] I/we have discussed the proposed changes already, either in an issue on the repository, or in the Matrix chat.
- [x] I/we have performed a self-review of added code.
- [x] I/we have written code that is legible and maintainable by others.
- [x] I/we have commented the added code, particularly in hard-to-understand areas.
- [x] I/we have made any necessary changes to documentation.
- [x] I/we have added tests that cover new code.
- [x] I/we have run tests and they pass locally with the changes.
- [x] I/we have run `go fmt ./...` and `golangci-lint run`.

Reviewed-on: https://codeberg.org/superseriousbusiness/gotosocial/pulls/4658
Co-authored-by: tobi <tobi.smethurst@protonmail.com>
Co-committed-by: tobi <tobi.smethurst@protonmail.com>
This commit is contained in:
tobi
2026-01-29 11:43:44 +01:00
committed by tobi
parent 6151130843
commit 696fc66619
8 changed files with 334 additions and 223 deletions
+189 -124
View File
@@ -18,10 +18,9 @@
package util
import (
"html"
"math"
"regexp"
"strconv"
"strings"
apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model"
"code.superseriousbusiness.org/gotosocial/internal/text"
@@ -81,6 +80,8 @@ type OGMedia struct {
// the base root of an instance. It also serves as a
// foundation for building account / status ogMeta.
func OGBase(instance *apimodel.InstanceV1) *OGMeta {
// Take first
// lang as locale.
var locale string
if len(instance.Languages) > 0 {
locale = instance.Languages[0]
@@ -92,7 +93,7 @@ func OGBase(instance *apimodel.InstanceV1) *OGMeta {
Locale: locale,
URL: instance.URI,
SiteName: instance.AccountDomain,
Description: ParseDescription(instance.ShortDescription),
Description: toDescription(instance.ShortDescription),
Media: []OGMedia{
{
OGType: "image",
@@ -106,24 +107,51 @@ func OGBase(instance *apimodel.InstanceV1) *OGMeta {
return og
}
// WithAccount uses the given account to build
// an ogMeta struct specific to that account.
// OGAccount builds an ogMeta struct for the given account.
// It's suitable for serving at account profile pages.
func (o *OGMeta) WithAccount(acct *apimodel.WebAccount) *OGMeta {
o.Title = AccountTitle(acct, o.SiteName)
o.ProfileUsername = acct.Username + "@" + o.SiteName
o.Type = "profile"
o.URL = acct.URL
if acct.Note != "" {
o.Description = ParseDescription(acct.Note)
} else {
const desc = "This GoToSocial user hasn't written a bio yet!"
o.Description = desc
func OGAccount(
instance *apimodel.InstanceV1,
acct *apimodel.WebAccount,
) *OGMeta {
// Set title to something like
// "Display Name (@username@account.domain)"
accountdomain := instance.AccountDomain
title := AccountTitle(acct, accountdomain)
// Take first
// lang as locale.
var locale string
if len(instance.Languages) > 0 {
locale = instance.Languages[0]
}
// Set avatar image.
o.Media = []OGMedia{ogImgForAcct(acct)}
return o
// Create description
// from note (if set).
var description string
if acct.Note != "" {
description = toDescription(acct.Note)
} else {
const emptyDesc = "This GoToSocial user hasn't written a bio yet!"
description = emptyDesc
}
// Parse image from
// account avatar (if set).
media := []OGMedia{ogImgForAcct(acct)}
// ProfileUsername in format `someone@example.org`.
profileUsername := acct.Username + "@" + accountdomain
return &OGMeta{
Title: title,
Type: "profile",
Locale: locale,
URL: acct.URL,
SiteName: accountdomain,
Description: truncate(description),
Media: media,
ProfileUsername: profileUsername,
}
}
// util funct to return OGImage using account.
@@ -176,30 +204,35 @@ func ogImgForAcct(account *apimodel.WebAccount) OGMedia {
return ogMedia
}
// WithStatus uses the given status to build
// and ogMeta struct specific to that status.
// It's suitable for serving at status pages.
func (o *OGMeta) WithStatus(status *apimodel.WebStatus) *OGMeta {
// OGStatus builds an ogMeta struct for
// the given status by the given account.
// It's suitable for serving at thread pages.
func OGStatus(
instance *apimodel.InstanceV1,
acct *apimodel.WebAccount,
status *apimodel.WebStatus,
) *OGMeta {
// Set title to something like
// "Display Name (@username@account.domain)"
o.Title = AccountTitle(status.Account, o.SiteName)
accountdomain := instance.AccountDomain
title := AccountTitle(acct, accountdomain)
// It's a post not an article
// but this is all we have.
o.Type = "article"
if status.Language != nil {
o.Locale = *status.Language
// Take first
// lang as locale.
var locale string
if len(instance.Languages) > 0 {
locale = instance.Languages[0]
}
// Self-explanatory.
o.URL = status.URL
// Derive description based on
// sensitivity + media attachments.
attachLen := len(status.MediaAttachments)
attachSet := attachLen != 0
cwSet := status.SpoilerText != ""
contentSet := status.Text != ""
var (
description string
attachLen = len(status.MediaAttachments)
attachSet = attachLen != 0
cwSet = status.SpoilerContent != ""
contentSet = status.Content != ""
)
switch {
@@ -208,10 +241,11 @@ func (o *OGMeta) WithStatus(status *apimodel.WebStatus) *OGMeta {
// we should not use the post content
// at all in the description.
case cwSet:
content := toDescription(status.SpoilerContent)
if attachSet {
o.Description = ParseDescription("Sensitive content [" + mediaCount(attachLen) + "]" + ": " + status.SpoilerText)
description = "Sensitive content [" + mediaCount(attachLen) + "]" + ": " + content
} else {
o.Description = ParseDescription("Sensitive content: " + status.SpoilerText)
description = "Sensitive content: " + content
}
// There's no content warning set but
@@ -220,120 +254,136 @@ func (o *OGMeta) WithStatus(status *apimodel.WebStatus) *OGMeta {
// status content in the description
// but warn that it's sensitive.
case status.Sensitive && contentSet:
content := toDescription(status.Content)
if attachSet {
o.Description = ParseDescription("Sensitive content [" + mediaCount(attachLen) + "]" + ": " + status.Text)
description = "Sensitive content [" + mediaCount(attachLen) + "]" + ": " + content
} else {
o.Description = ParseDescription("Sensitive content: " + status.Text)
description = "Sensitive content: " + content
}
// There's no content warning set
// and no text content set, but
// there are sensitive attachments.
case status.Sensitive && attachSet:
o.Description = "Sensitive media: " + mediaCount(attachLen)
description = "Sensitive media [" + mediaCount(attachLen) + "]"
// Status isn't sensitive and there's
// no content warning set, use the
// post content in the description.
case !status.Sensitive && contentSet:
content := toDescription(status.Content)
if attachSet {
o.Description = ParseDescription("[" + mediaCount(attachLen) + "] " + status.Text)
description = "[" + mediaCount(attachLen) + "] " + content
} else {
o.Description = ParseDescription(status.Text)
description = content
}
// Status isn't sensitive and there's
// no content warning or content set.
case !status.Sensitive && !contentSet:
if attachSet {
o.Description = mediaCount(attachLen)
description = mediaCount(attachLen)
} else {
o.Description = ParseDescription("Post by " + o.Title)
description = "Post by " + title
}
// Fall back to
// account title.
default:
o.Description = o.Title
description = title
}
o.ArticlePublisher = status.Account.URL
o.ArticleAuthor = status.Account.URL
o.ArticlePublishedTime = status.CreatedAt
o.ArticleModifiedTime = util.PtrOrValue(status.EditedAt, status.CreatedAt)
// Prepare status media.
var (
media []OGMedia
twitterSummaryLargeImage string
twitterImageAlt string
)
// Clear any existing medias.
o.Media = []OGMedia{}
// If media is sensitive then
// don't append it to preview.
if status.Sensitive {
return o
}
// Add image / media previews.
for _, a := range status.MediaAttachments {
if a.Type == "unknown" {
// Skip unknown.
continue
}
// Start building entry
// with common media tags.
desc := util.PtrOrZero(a.Description)
ogMedia := OGMedia{
URL: *a.URL,
MIMEType: a.MIMEType,
Alt: desc,
}
// Add further tags
// depending on type.
switch a.Type {
case "image":
ogMedia.OGType = "image"
ogMedia.Width = strconv.Itoa(a.Meta.Original.Width)
ogMedia.Height = strconv.Itoa(a.Meta.Original.Height)
// If this image is the only piece of media,
// set TwitterSummaryLargeImage to indicate
// that a large image summary is preferred.
if attachLen == 1 {
o.TwitterSummaryLargeImage = *a.URL
o.TwitterImageAlt = desc
// Only append media to
// preview if not sensitive.
if !status.Sensitive {
for _, a := range status.MediaAttachments {
if a.Type == "unknown" {
// Skip unknown.
continue
}
case "audio":
ogMedia.OGType = "audio"
// Start building entry
// with common media tags.
desc := util.PtrOrZero(a.Description)
ogMedia := OGMedia{
URL: *a.URL,
MIMEType: a.MIMEType,
Alt: desc,
}
case "video", "gifv":
ogMedia.OGType = "video"
ogMedia.Width = strconv.Itoa(a.Meta.Original.Width)
ogMedia.Height = strconv.Itoa(a.Meta.Original.Height)
}
// Add further tags
// depending on type.
switch a.Type {
// Add this to our gathered entries.
o.Media = append(o.Media, ogMedia)
case "image":
ogMedia.OGType = "image"
ogMedia.Width = strconv.Itoa(a.Meta.Original.Width)
ogMedia.Height = strconv.Itoa(a.Meta.Original.Height)
// Include static/thumb for non-visual files
// (eg., audios) if they have a preview url set.
if a.Type != "image" && a.PreviewURL != nil {
o.Media = append(
o.Media,
OGMedia{
OGType: "image",
URL: *a.PreviewURL,
MIMEType: a.PreviewMIMEType,
Width: strconv.Itoa(a.Meta.Small.Width),
Height: strconv.Itoa(a.Meta.Small.Height),
Alt: util.PtrOrZero(a.Description),
},
)
// If this image is the only piece of media,
// set TwitterSummaryLargeImage to indicate
// that a large image summary is preferred.
if attachLen == 1 {
twitterSummaryLargeImage = *a.URL
twitterImageAlt = desc
}
case "audio":
ogMedia.OGType = "audio"
case "video", "gifv":
ogMedia.OGType = "video"
ogMedia.Width = strconv.Itoa(a.Meta.Original.Width)
ogMedia.Height = strconv.Itoa(a.Meta.Original.Height)
}
// Add this to our gathered entries.
media = append(media, ogMedia)
// Include static/thumb for non-visual files
// (eg., audios) if they have a preview url set.
if a.Type != "image" && a.PreviewURL != nil {
media = append(
media,
OGMedia{
OGType: "image",
URL: *a.PreviewURL,
MIMEType: a.PreviewMIMEType,
Width: strconv.Itoa(a.Meta.Small.Width),
Height: strconv.Itoa(a.Meta.Small.Height),
Alt: util.PtrOrZero(a.Description),
},
)
}
}
}
return o
// ProfileUsername in format `someone@example.org`.
profileUsername := acct.Username + "@" + accountdomain
return &OGMeta{
Title: title,
Type: "article",
Locale: locale,
URL: status.URL,
SiteName: accountdomain,
Description: truncate(description),
Media: media,
ArticlePublisher: status.Account.URL,
ArticleAuthor: status.Account.URL,
ArticlePublishedTime: status.CreatedAt,
ArticleModifiedTime: util.PtrOrValue(status.EditedAt, status.CreatedAt),
ProfileUsername: profileUsername,
TwitterSummaryLargeImage: twitterSummaryLargeImage,
TwitterImageAlt: twitterImageAlt,
}
}
// AccountTitle parses a page title
@@ -352,21 +402,36 @@ func AccountTitle(
return displayName + " (" + nameString + ")"
}
// ParseDescription returns a string description which is
// safe to use as the content of a `content="..."` attribute.
func ParseDescription(in string) string {
i := text.StripHTMLFromText(in)
i = strings.ReplaceAll(i, "\n", " ")
i = strings.Join(strings.Fields(i), " ")
i = html.EscapeString(i)
i = strings.ReplaceAll(i, `\`, "&bsol;")
return truncate(i)
// Finds any links unnested
// by text.ParseHTMLToPlain.
var unnestedURLsRegexp = regexp.MustCompile(`(?U) <(?:http|https):\/\/.+\..+>`)
// toDescription converts given HTML string to
// an appropriate string to use as "description"
// content inside an opengraph <meta> tag.
func toDescription(html string) string {
// Parse html string to plaintext.
plain := text.ParseHTMLToPlain(html)
// Remove any unnested URLs as they look ugly
// when rendered inside an opengraph description.
//
// Eg., replace
// `#boobs <https://example.org/tags/boobs>`
// with just
// `#boobs`.
plain = unnestedURLsRegexp.ReplaceAllString(plain, "")
// Truncate to 2000 chars,
// anything longer than
// that is a bloody essay.
return truncate(plain)
}
// truncate trims string
// to maximum 300 runes.
// to maximum 2000 runes.
func truncate(s string) string {
const truncateLen = 300
const truncateLen = 2000
r := []rune(s)
if len(r) < truncateLen {
+37 -94
View File
@@ -22,7 +22,6 @@ import (
apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model"
"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
"code.superseriousbusiness.org/gotosocial/internal/typeutils"
"code.superseriousbusiness.org/gotosocial/internal/util"
"github.com/stretchr/testify/suite"
)
@@ -31,40 +30,24 @@ type OpenGraphTestSuite struct {
suite.Suite
}
func (suite *OpenGraphTestSuite) TestParseDescription() {
tests := []struct {
name, in, exp string
}{
{name: "shellcmd", in: `echo '\e]8;;http://example.com\e\This is a link\e]8;;\e'`, exp: `echo &#39;&bsol;e]8;;http://example.com&bsol;e&bsol;This is a link&bsol;e]8;;&bsol;e&#39;`},
{name: "newlines", in: "test\n\ntest\ntest", exp: "test test test"},
}
for _, tt := range tests {
tt := tt
suite.Run(tt.name, func() {
suite.Equal(tt.exp, ParseDescription(tt.in))
})
}
}
func (suite *OpenGraphTestSuite) TestWithAccountWithNote() {
baseMeta := OGBase(&apimodel.InstanceV1{
instance := &apimodel.InstanceV1{
AccountDomain: "example.org",
Languages: []string{"en"},
Thumbnail: "https://example.org/instance-avatar.webp",
ThumbnailType: "image/webp",
})
}
acct := &apimodel.Account{
Acct: "example_account",
DisplayName: "example person!!",
URL: "https://example.org/@example_account",
Note: "<p>This is my profile, read it and weep! Weep then!</p>",
Note: "<p>This is my profile, read it and weep!<br/>Weep then!</p>",
Username: "example_account",
Avatar: "https://example.org/avatar.jpg",
}
accountMeta := baseMeta.WithAccount(&apimodel.WebAccount{Account: acct})
accountMeta := OGAccount(instance, &apimodel.WebAccount{Account: acct})
suite.EqualValues(OGMeta{
Title: "example person!! (@example_account@example.org)",
@@ -72,7 +55,7 @@ func (suite *OpenGraphTestSuite) TestWithAccountWithNote() {
Locale: "en",
URL: "https://example.org/@example_account",
SiteName: "example.org",
Description: "This is my profile, read it and weep! Weep then!",
Description: "This is my profile, read it and weep!\nWeep then!",
Media: []OGMedia{
{
OGType: "image",
@@ -89,12 +72,12 @@ func (suite *OpenGraphTestSuite) TestWithAccountWithNote() {
}
func (suite *OpenGraphTestSuite) TestWithAccountNoNote() {
baseMeta := OGBase(&apimodel.InstanceV1{
instance := &apimodel.InstanceV1{
AccountDomain: "example.org",
Languages: []string{"en"},
Thumbnail: "https://example.org/instance-avatar.webp",
ThumbnailType: "image/webp",
})
}
acct := &apimodel.Account{
Acct: "example_account",
@@ -105,7 +88,7 @@ func (suite *OpenGraphTestSuite) TestWithAccountNoNote() {
Avatar: "https://example.org/avatar.jpg",
}
accountMeta := baseMeta.WithAccount(&apimodel.WebAccount{Account: acct})
accountMeta := OGAccount(instance, &apimodel.WebAccount{Account: acct})
suite.EqualValues(OGMeta{
Title: "example person!! (@example_account@example.org)",
@@ -130,38 +113,29 @@ func (suite *OpenGraphTestSuite) TestWithAccountNoNote() {
}
func (suite *OpenGraphTestSuite) TestWithStatus() {
baseMeta := OGBase(&apimodel.InstanceV1{
instance := &apimodel.InstanceV1{
AccountDomain: "example.org",
Languages: []string{"en"},
Thumbnail: "https://example.org/instance-avatar.webp",
ThumbnailType: "image/webp",
})
}
acct := &apimodel.Account{
Acct: "example_account",
DisplayName: "example person!!",
URL: "https://example.org/@example_account",
Note: "", // <- empty
Username: "example_account",
Avatar: "https://example.org/avatar.jpg",
}
apiStatus := &apimodel.Status{
ID: "12345",
CreatedAt: "2025-01-18T00:00:00+00:00",
EditedAt: util.Ptr("2025-01-18T11:00:00+00:00"),
Sensitive: false,
SpoilerText: "",
Visibility: typeutils.VisToAPIVis(gtsmodel.VisibilityPublic),
LocalOnly: false,
Language: util.Ptr("en"),
URI: "https://example.org/statuses/12345",
URL: "https://example.org/@example_account/12345",
Content: "<b>test status</b>",
Account: acct,
MediaAttachments: []*apimodel.Attachment{},
Text: "**test status**",
ContentType: apimodel.StatusContentTypeMarkdown,
ID: "12345",
CreatedAt: "2025-01-18T00:00:00+00:00",
EditedAt: util.Ptr("2025-01-18T11:00:00+00:00"),
URI: "https://example.org/statuses/12345",
URL: "https://example.org/@example_account/12345",
Content: "<p><b>test status</b><p><p>here's another line</p>",
Account: acct,
}
status := &apimodel.WebStatus{
@@ -175,7 +149,7 @@ func (suite *OpenGraphTestSuite) TestWithStatus() {
},
}
statusMeta := baseMeta.WithStatus(status)
statusMeta := OGStatus(instance, status.Account, status)
suite.EqualValues(OGMeta{
Title: "example person!! (@example_account@example.org)",
@@ -183,103 +157,78 @@ func (suite *OpenGraphTestSuite) TestWithStatus() {
Locale: "en",
URL: "https://example.org/@example_account/12345",
SiteName: "example.org",
Description: "**test status**",
Media: []OGMedia{},
Description: "test status\n\nhere's another line",
ArticlePublisher: "https://example.org/@example_account",
ArticleAuthor: "https://example.org/@example_account",
ArticleModifiedTime: "2025-01-18T11:00:00+00:00",
ArticlePublishedTime: "2025-01-18T00:00:00+00:00",
ProfileUsername: "",
ProfileUsername: "example_account@example.org",
}, *statusMeta)
}
func (suite *OpenGraphTestSuite) TestWithStatusWithImage() {
baseMeta := OGBase(&apimodel.InstanceV1{
instance := &apimodel.InstanceV1{
AccountDomain: "example.org",
Languages: []string{"en"},
Thumbnail: "https://example.org/instance-avatar.webp",
ThumbnailType: "image/webp",
})
}
acct := &apimodel.Account{
Acct: "example_account",
DisplayName: "example person!!",
URL: "https://example.org/@example_account",
Note: "", // <- empty
Username: "example_account",
Avatar: "https://example.org/avatar.jpg",
}
imageAttachment := &apimodel.Attachment{
ID: "00IMAGE00",
Type: "image",
URL: util.Ptr("https://example.org/@example_account/12345/example.png"),
TextURL: util.Ptr("https://example.org/@example_account/12345/example.png"),
PreviewURL: util.Ptr("https://example.org/@example_account/12345/small/example.png"),
RemoteURL: nil,
PreviewRemoteURL: nil,
ID: "00IMAGE00",
Type: "image",
URL: util.Ptr("https://example.org/@example_account/12345/example.png"),
TextURL: util.Ptr("https://example.org/@example_account/12345/example.png"),
PreviewURL: util.Ptr("https://example.org/@example_account/12345/small/example.png"),
Meta: &apimodel.MediaMeta{
Original: apimodel.MediaDimensions{
Width: 1920,
Height: 1080,
Size: "1920x1080",
Aspect: 1920.0 / 1080,
},
Small: apimodel.MediaDimensions{
Width: 320,
Height: 240,
Size: "320x240",
Aspect: 320.0 / 240,
},
Focus: nil,
},
Description: util.Ptr("an example image"),
Blurhash: util.Ptr("LKE3VIw}0KD%a2o{M|t7NFWps:t7"), // <- from testmodels
}
anotherImageAttachment := &apimodel.Attachment{
ID: "00IMAGE11",
Type: "image",
URL: util.Ptr("https://example.org/@example_account/12345/example2.png"),
TextURL: util.Ptr("https://example.org/@example_account/12345/example2.png"),
PreviewURL: util.Ptr("https://example.org/@example_account/12345/small/example2.png"),
RemoteURL: nil,
PreviewRemoteURL: nil,
ID: "00IMAGE11",
Type: "image",
URL: util.Ptr("https://example.org/@example_account/12345/example2.png"),
TextURL: util.Ptr("https://example.org/@example_account/12345/example2.png"),
PreviewURL: util.Ptr("https://example.org/@example_account/12345/small/example2.png"),
Meta: &apimodel.MediaMeta{
Original: apimodel.MediaDimensions{
Width: 1000,
Height: 1000,
Size: "1000x1000",
Aspect: 1,
},
Small: apimodel.MediaDimensions{
Width: 200,
Height: 200,
Size: "200x200",
Aspect: 1,
},
Focus: nil,
},
Description: util.Ptr("another example image"),
Blurhash: util.Ptr("LNABP8o#Dge,S6M}axxVEQjYxWbH"), // <- from testmodels
}
apiStatus := &apimodel.Status{
ID: "12345",
CreatedAt: "2025-01-18T00:00:00+00:00",
EditedAt: util.Ptr("2025-01-18T11:00:00+00:00"),
Sensitive: false,
SpoilerText: "",
Visibility: typeutils.VisToAPIVis(gtsmodel.VisibilityPublic),
LocalOnly: false,
Language: util.Ptr("en"),
URI: "https://example.org/statuses/12345",
URL: "https://example.org/@example_account/12345",
Content: "<b>test status</b>",
Content: "<p>test status <span class=\"h-card\"><a href=\"https://example.org/c/mutual_aid\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>mutual_aid@example.org</span></a></span> <a href=\"https://example.org/tags/MutualAidRequest\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>MutualAidRequest</span></a></p>",
Account: acct,
MediaAttachments: []*apimodel.Attachment{imageAttachment, anotherImageAttachment},
Text: "**test status**",
ContentType: apimodel.StatusContentTypeMarkdown,
}
webAttachment := &apimodel.WebAttachment{
@@ -300,17 +249,11 @@ func (suite *OpenGraphTestSuite) TestWithStatusWithImage() {
status := &apimodel.WebStatus{
Status: apiStatus,
SpoilerContent: "", // <- empty
MediaAttachments: []*apimodel.WebAttachment{webAttachment, anotherWebAttachment},
Account: &apimodel.WebAccount{
Account: acct,
AvatarAttachment: nil,
HeaderAttachment: nil,
WebLayout: gtsmodel.WebLayoutMicroblog.String(),
},
Account: &apimodel.WebAccount{Account: acct},
}
statusMeta := baseMeta.WithStatus(status)
statusMeta := OGStatus(instance, status.Account, status)
suite.EqualValues(OGMeta{
Title: "example person!! (@example_account@example.org)",
@@ -318,7 +261,7 @@ func (suite *OpenGraphTestSuite) TestWithStatusWithImage() {
Locale: "en",
URL: "https://example.org/@example_account/12345",
SiteName: "example.org",
Description: "[2 media attachments] **test status**",
Description: "[2 media attachments] test status @mutual_aid@example.org #MutualAidRequest",
Media: []OGMedia{
{
OGType: "image",
@@ -341,7 +284,7 @@ func (suite *OpenGraphTestSuite) TestWithStatusWithImage() {
ArticleAuthor: "https://example.org/@example_account",
ArticleModifiedTime: "2025-01-18T11:00:00+00:00",
ArticlePublishedTime: "2025-01-18T00:00:00+00:00",
ProfileUsername: "",
ProfileUsername: "example_account@example.org",
}, *statusMeta)
}
+69
View File
@@ -148,6 +148,7 @@ var funcMap = template.FuncMap{
"indentAttr": indentAttr,
"isNil": isNil,
"outdentPreformatted": outdentPreformatted,
"outdentOGMeta": outdentOGMeta,
"noescapeAttr": noescapeAttr,
"noescape": noescape,
"oddOrEven": oddOrEven,
@@ -312,6 +313,23 @@ var (
// Find content of alt or title attributes.
indentAltOrTitle = regexp.MustCompile(`(?Ums)\b(?:alt|title)="(.*)"(?:\b|>|$)`)
// One indent level is four spaces,
// so the start of an element inside
// <head> will be indented 8 spaces, eg:
//
// <!DOCTYPE html>
// <html lang="en">
// <head>
// <meta property="og:description"> <-- the thing we're looking for
headContentIndent = strings.Repeat(indentStr, 2)
// Find content of <meta> elements for
// `description` and `og:description`.
indentHeadOGDescription = regexp.MustCompile(
`(?Ums)^` +
headContentIndent +
`<meta (?:property="og:description"|name="description") content="(.*)">$`,
)
// Map of lazily-compiled replaceIndent
// regexes, keyed by the indent they
// replace, to avoid recompilation.
@@ -340,6 +358,57 @@ func indentAttr(n int, html template.HTMLAttr) template.HTMLAttr {
return noescapeAttr(out)
}
// outdentOGMeta outdents all preformatted text
// inside of "description" and "og:description"
// <meta> elements in the given html fragment.
func outdentOGMeta(html template.HTML) template.HTML {
output := regexes.ReplaceAllStringFunc(indentHeadOGDescription, string(html),
func(match string, buf *bytes.Buffer) string {
// Reuse the regex to pull out submatches.
matches := indentHeadOGDescription.FindAllStringSubmatch(match, -1)
// Ensure matches
// expected length.
if len(matches) != 1 {
return match
}
// Ensure inner matches
// expected length.
innerMatches := matches[0]
if len(innerMatches) != 2 {
return match
}
// We know the length of indent
// before elements inside head>
// beforehand, it's two levels.
indent := headContentIndent
// Load or create + store the
// regex to replace this indent,
// avoiding recompilation.
var replaceIndent *regexp.Regexp
if replaceIndentI, ok := replaceIndents.Load(indent); ok {
// Got regex for this indent.
replaceIndent = replaceIndentI.(*regexp.Regexp)
} else {
// No regex stored for
// this indent yet, store it.
replaceIndent = regexp.MustCompile(`(?m)^` + indent)
replaceIndents.Store(indent, replaceIndent)
}
// Keep the initial indent before the element,
// but remove all occurrences of the indent
// at the start of each line inside the match.
return indent + replaceIndent.ReplaceAllString(match, "")
},
)
return noescape(output)
}
// outdentPreformatted outdents all preformatted text in
// the given HTML, ie., in `alt` and `title` attributes,
// and between `<pre>` tags, so that it renders correctly,
+31
View File
@@ -236,3 +236,34 @@ With her hands on her hips looking annoyed she says &#34;That sign won&#39;t sto
t.Fatalf("unexpected output:\n`%s`\n", out)
}
}
func TestOutdentOGMeta(t *testing.T) {
const html = template.HTML(`<html lang="en">
<head>
<meta property="og:description" content="here is
a
multiline toot
with some
significant whitespace!
&lt;3 &lt;3 &lt;3">
</head>`)
const expected = template.HTML(`<html lang="en">
<head>
<meta property="og:description" content="here is
a
multiline toot
with some
significant whitespace!
&lt;3 &lt;3 &lt;3">
</head>`)
out := outdentOGMeta(html)
if out != expected {
t.Fatalf("unexpected output:\n`%s`\n", out)
}
}
+2 -2
View File
@@ -243,7 +243,7 @@ func (m *Module) profileMicroblog(c *gin.Context, p *profile) {
page := apiutil.WebPage{
Template: "profile.tmpl",
Instance: p.instance,
OGMeta: apiutil.OGBase(p.instance).WithAccount(p.account),
OGMeta: apiutil.OGAccount(p.instance, p.account),
Stylesheets: stylesheets,
Javascript: []apiutil.JavascriptEntry{
{
@@ -317,7 +317,7 @@ func (m *Module) profileGallery(c *gin.Context, p *profile) {
page := apiutil.WebPage{
Template: "profile-gallery.tmpl",
Instance: p.instance,
OGMeta: apiutil.OGBase(p.instance).WithAccount(p.account),
OGMeta: apiutil.OGAccount(p.instance, p.account),
Stylesheets: stylesheets,
Javascript: []apiutil.JavascriptEntry{
{
+1 -1
View File
@@ -143,7 +143,7 @@ func (m *Module) threadGETHandler(c *gin.Context) {
page := apiutil.WebPage{
Template: "thread.tmpl",
Instance: instance,
OGMeta: apiutil.OGBase(instance).WithStatus(context.Status),
OGMeta: apiutil.OGStatus(instance, acct, context.Status),
Stylesheets: stylesheets,
Javascript: []apiutil.JavascriptEntry{
{
+1 -1
View File
@@ -49,7 +49,7 @@ image/webp
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="{{- if .robotsMeta -}}{{- .robotsMeta -}}{{- else -}}noindex, nofollow, noai, noimageai{{- end -}}">
{{- if .ogMeta }}
{{- include "page_ogmeta.tmpl" . | indent 2 }}
{{- include "page_ogmeta.tmpl" . | indent 2 | outdentOGMeta }}
{{- else }}
{{- end }}
{{- if .rssFeed }}
+4 -1
View File
@@ -32,11 +32,14 @@
<meta property="og:title" content="{{- demojify .Title | noescape -}}">
<meta property="og:url" content="{{- .URL -}}">
<meta property="og:site_name" content="{{- .SiteName -}}">
<meta property="og:description" content="{{- demojify .Description | noescape -}}">
<meta property="og:description" content="{{- .Description -}}">
<meta name="description" content="{{- .Description -}}">
{{- if .ArticlePublisher }}
<meta property="og:article:publisher" content="{{ .ArticlePublisher }}">
<meta property="og:article:author" content="{{ .ArticleAuthor }}">
<meta property="og:modified_time" content="{{ .ArticleModifiedTime }}">
<meta property="og:article:modified_time" content="{{ .ArticleModifiedTime }}">
<meta property="og:published_time" content="{{ .ArticlePublishedTime }}">
<meta property="og:article:published_time" content="{{ .ArticlePublishedTime }}">
{{- else }}
{{- end }}