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" . }}