diff --git a/CHANGELOG.md b/CHANGELOG.md index e00807e..c7b2e14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,8 @@ ## [Unreleased] ### Added -- Seed a user/admin account at install time from the CLI (`admin create-user`), enabling automated provisioning with tools like Ansible (#1) +- Seed a user/admin account at install time from the CLI (`admin create-user`), enabling automated provisioning with tools like Ansible (#750) +- Open Graph and Twitter Card meta tags on gist pages for rich social sharing previews in Discord/Slack/IRC clients (#756) ## [1.13.1](https://github.com/thomiceli/opengist/compare/v1.13.0...v1.13.1) - 2026-06-10 See here how to [update](https://opengist.io/docs/update) Opengist. diff --git a/internal/web/handlers/gist/gist_test.go b/internal/web/handlers/gist/gist_test.go index af82114..d6d6d91 100644 --- a/internal/web/handlers/gist/gist_test.go +++ b/internal/web/handlers/gist/gist_test.go @@ -100,6 +100,49 @@ func TestGistIndex(t *testing.T) { }) } +func TestGistSocialMetaTags(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + s.Register(t, "thomas") + + t.Run("RendersOpenGraphAndTwitterCards", func(t *testing.T) { + _, gist, username, identifier := s.CreateGist(t, "0") + + resp := s.Request(t, "GET", "/"+username+"/"+identifier, nil, 200) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + html := string(body) + + // Open Graph tags + assert.Contains(t, html, `property="og:title"`) + assert.Contains(t, html, `property="og:description"`) + assert.Contains(t, html, `property="og:type" content="article"`) + assert.Contains(t, html, `property="og:url"`) + assert.Contains(t, html, `property="og:site_name" content="Opengist"`) + assert.Contains(t, html, `property="og:image"`) + + // Twitter Card tags + assert.Contains(t, html, `name="twitter:card" content="summary"`) + assert.Contains(t, html, `name="twitter:title"`) + assert.Contains(t, html, `name="twitter:description"`) + assert.Contains(t, html, `name="twitter:image"`) + + // The card reflects the gist owner/title and points at the gist URL + assert.Contains(t, html, gist.Title) + assert.Contains(t, html, username+"/"+identifier) + }) + + t.Run("UnlistedGistAlsoHasMetaTags", func(t *testing.T) { + _, _, username, identifier := s.CreateGist(t, "1") + + resp := s.Request(t, "GET", "/"+username+"/"+identifier, nil, 200) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Contains(t, string(body), `property="og:title"`) + }) +} + func TestPreview(t *testing.T) { s := webtest.Setup(t) defer webtest.Teardown(t) diff --git a/internal/web/server/renderer.go b/internal/web/server/renderer.go index 8d35ac7..bf73a3a 100644 --- a/internal/web/server/renderer.go +++ b/internal/web/server/renderer.go @@ -188,6 +188,18 @@ func (s *Server) setFuncMap() { } return str }, + // ogText collapses all whitespace (newlines, tabs, multiple spaces) into + // single spaces and truncates to maxRunes runes, appending an ellipsis when + // truncated. Used to build clean values for Open Graph / Twitter meta tags + // from multi-line gist descriptions or code previews. + "ogText": func(s string, maxRunes int) string { + collapsed := strings.Join(strings.Fields(s), " ") + runes := []rune(collapsed) + if len(runes) <= maxRunes { + return collapsed + } + return string(runes[:maxRunes]) + "\u2026" + }, "hexToRgb": func(hex string) string { h, _ := strconv.ParseUint(strings.TrimPrefix(hex, "#"), 16, 32) return fmt.Sprintf("%d, %d, %d,", (h>>16)&0xFF, (h>>8)&0xFF, h&0xFF) diff --git a/templates/base/base_header.html b/templates/base/base_header.html index fdc49ce..7e2f21a 100644 --- a/templates/base/base_header.html +++ b/templates/base/base_header.html @@ -12,6 +12,8 @@ {{ if .canonicalUrl }} {{ end }} + + {{ template "seo_meta" . }}