diff --git a/appview/issues/opengraph.go b/appview/issues/opengraph.go
new file mode 100644
index 00000000..d09d547a
--- /dev/null
+++ b/appview/issues/opengraph.go
@@ -0,0 +1,267 @@
+package issues
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "image"
+ "image/color"
+ "image/png"
+ "log"
+ "net/http"
+
+ "tangled.org/core/appview/models"
+ "tangled.org/core/appview/ogcard"
+)
+
+func (rp *Issues) drawIssueSummaryCard(issue *models.Issue, repo *models.Repo, commentCount int, ownerHandle string) (*ogcard.Card, error) {
+ width, height := ogcard.DefaultSize()
+ mainCard, err := ogcard.NewCard(width, height)
+ if err != nil {
+ return nil, err
+ }
+
+ // Split: content area (75%) and status/stats area (25%)
+ contentCard, statsArea := mainCard.Split(false, 75)
+
+ // Add padding to content
+ contentCard.SetMargin(50)
+
+ // Split content horizontally: main content (80%) and avatar area (20%)
+ mainContent, avatarArea := contentCard.Split(true, 80)
+
+ // Add margin to main content like repo card
+ mainContent.SetMargin(10)
+
+ // Use full main content area for repo name and title
+ bounds := mainContent.Img.Bounds()
+ startX := bounds.Min.X + mainContent.Margin
+ startY := bounds.Min.Y + mainContent.Margin
+
+ // Draw full repository name at top (owner/repo format)
+ var repoOwner string
+ owner, err := rp.idResolver.ResolveIdent(context.Background(), repo.Did)
+ if err != nil {
+ repoOwner = repo.Did
+ } else {
+ repoOwner = "@" + owner.Handle.String()
+ }
+
+ fullRepoName := repoOwner + " / " + repo.Name
+ if len(fullRepoName) > 60 {
+ fullRepoName = fullRepoName[:60] + "…"
+ }
+
+ grayColor := color.RGBA{88, 96, 105, 255}
+ err = mainContent.DrawTextAt(fullRepoName, startX, startY, grayColor, 36, ogcard.Top, ogcard.Left)
+ if err != nil {
+ return nil, err
+ }
+
+ // Draw issue title below repo name with wrapping
+ titleY := startY + 60
+ titleX := startX
+
+ // Truncate title if too long
+ issueTitle := issue.Title
+ maxTitleLength := 80
+ if len(issueTitle) > maxTitleLength {
+ issueTitle = issueTitle[:maxTitleLength] + "…"
+ }
+
+ // Create a temporary card for the title area to enable wrapping
+ titleBounds := mainContent.Img.Bounds()
+ titleWidth := titleBounds.Dx() - (startX - titleBounds.Min.X) - 20 // Leave some margin
+ titleHeight := titleBounds.Dy() - (titleY - titleBounds.Min.Y) - 100 // Leave space for issue ID
+
+ titleRect := image.Rect(titleX, titleY, titleX+titleWidth, titleY+titleHeight)
+ titleCard := &ogcard.Card{
+ Img: mainContent.Img.SubImage(titleRect).(*image.RGBA),
+ Font: mainContent.Font,
+ Margin: 0,
+ }
+
+ // Draw wrapped title
+ lines, err := titleCard.DrawText(issueTitle, color.Black, 54, ogcard.Top, ogcard.Left)
+ if err != nil {
+ return nil, err
+ }
+
+ // Calculate where title ends (number of lines * line height)
+ lineHeight := 60 // Approximate line height for 54pt font
+ titleEndY := titleY + (len(lines) * lineHeight) + 10
+
+ // Draw issue ID in gray below the title
+ issueIdText := fmt.Sprintf("#%d", issue.IssueId)
+ err = mainContent.DrawTextAt(issueIdText, startX, titleEndY, grayColor, 54, ogcard.Top, ogcard.Left)
+ if err != nil {
+ return nil, err
+ }
+
+ // Get issue author handle (needed for avatar and metadata)
+ var authorHandle string
+ author, err := rp.idResolver.ResolveIdent(context.Background(), issue.Did)
+ if err != nil {
+ authorHandle = issue.Did
+ } else {
+ authorHandle = "@" + author.Handle.String()
+ }
+
+ // Draw avatar circle on the right side
+ avatarBounds := avatarArea.Img.Bounds()
+ avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin
+ if avatarSize > 220 {
+ avatarSize = 220
+ }
+ avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2)
+ avatarY := avatarBounds.Min.Y + 20
+
+ // Get avatar URL for issue author
+ avatarURL := rp.pages.AvatarUrl(authorHandle, "256")
+ err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize)
+ if err != nil {
+ log.Printf("failed to draw avatar (non-fatal): %v", err)
+ }
+
+ // Split stats area: left side for status/comments (80%), right side for dolly (20%)
+ statusCommentsArea, dollyArea := statsArea.Split(true, 80)
+
+ // Draw status and comment count in status/comments area
+ statsBounds := statusCommentsArea.Img.Bounds()
+ statsX := statsBounds.Min.X + 60 // left padding
+ statsY := statsBounds.Min.Y
+
+ iconColor := color.RGBA{88, 96, 105, 255}
+ iconSize := 36
+ textSize := 36.0
+ labelSize := 28.0
+ iconBaselineOffset := int(textSize) / 2
+
+ // Draw status (open/closed) with colored icon and text
+ var statusIcon string
+ var statusText string
+ var statusBgColor color.RGBA
+
+ if issue.Open {
+ statusIcon = "static/icons/circle-dot.svg"
+ statusText = "open"
+ statusBgColor = color.RGBA{34, 139, 34, 255} // green
+ } else {
+ statusIcon = "static/icons/circle-dot.svg"
+ statusText = "closed"
+ statusBgColor = color.RGBA{52, 58, 64, 255} // dark gray
+ }
+
+ badgeIconSize := 36
+
+ // Draw icon with status color (no background)
+ err = statusCommentsArea.DrawSVGIcon(statusIcon, statsX, statsY+iconBaselineOffset-badgeIconSize/2+5, badgeIconSize, statusBgColor)
+ if err != nil {
+ log.Printf("failed to draw status icon: %v", err)
+ }
+
+ // Draw text with status color (no background)
+ textX := statsX + badgeIconSize + 12
+ badgeTextSize := 32.0
+ err = statusCommentsArea.DrawTextAt(statusText, textX, statsY+iconBaselineOffset, statusBgColor, badgeTextSize, ogcard.Middle, ogcard.Left)
+ if err != nil {
+ log.Printf("failed to draw status text: %v", err)
+ }
+
+ statusTextWidth := len(statusText) * 20
+ currentX := statsX + badgeIconSize + 12 + statusTextWidth + 50
+
+ // Draw comment count
+ err = statusCommentsArea.DrawSVGIcon("static/icons/message-square.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
+ if err != nil {
+ log.Printf("failed to draw comment icon: %v", err)
+ }
+
+ currentX += iconSize + 15
+ commentText := fmt.Sprintf("%d comments", commentCount)
+ if commentCount == 1 {
+ commentText = "1 comment"
+ }
+ err = statusCommentsArea.DrawTextAt(commentText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left)
+ if err != nil {
+ log.Printf("failed to draw comment text: %v", err)
+ }
+
+ // Draw dolly logo on the right side
+ dollyBounds := dollyArea.Img.Bounds()
+ dollySize := 90
+ dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
+ dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
+ dollyColor := color.RGBA{180, 180, 180, 255} // light gray
+ err = dollyArea.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", dollyX, dollyY, dollySize, dollyColor)
+ if err != nil {
+ log.Printf("dolly silhouette not available (this is ok): %v", err)
+ }
+
+ // Draw "opened by @author" and date at the bottom with more spacing
+ labelY := statsY + iconSize + 30
+
+ // Format the opened date
+ openedDate := issue.Created.Format("Jan 2, 2006")
+ metaText := fmt.Sprintf("opened by %s · %s", authorHandle, openedDate)
+
+ err = statusCommentsArea.DrawTextAt(metaText, statsX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Left)
+ if err != nil {
+ log.Printf("failed to draw metadata: %v", err)
+ }
+
+ return mainCard, nil
+}
+
+func (rp *Issues) IssueOpenGraphSummary(w http.ResponseWriter, r *http.Request) {
+ f, err := rp.repoResolver.Resolve(r)
+ if err != nil {
+ log.Println("failed to get repo and knot", err)
+ return
+ }
+
+ issue, ok := r.Context().Value("issue").(*models.Issue)
+ if !ok {
+ log.Println("issue not found in context")
+ http.Error(w, "issue not found", http.StatusNotFound)
+ return
+ }
+
+ // Get comment count
+ commentCount := len(issue.Comments)
+
+ // Get owner handle for avatar
+ var ownerHandle string
+ owner, err := rp.idResolver.ResolveIdent(r.Context(), f.Repo.Did)
+ if err != nil {
+ ownerHandle = f.Repo.Did
+ } else {
+ ownerHandle = "@" + owner.Handle.String()
+ }
+
+ card, err := rp.drawIssueSummaryCard(issue, &f.Repo, commentCount, ownerHandle)
+ if err != nil {
+ log.Println("failed to draw issue summary card", err)
+ http.Error(w, "failed to draw issue summary card", http.StatusInternalServerError)
+ return
+ }
+
+ var imageBuffer bytes.Buffer
+ err = png.Encode(&imageBuffer, card.Img)
+ if err != nil {
+ log.Println("failed to encode issue summary card", err)
+ http.Error(w, "failed to encode issue summary card", http.StatusInternalServerError)
+ return
+ }
+
+ imageBytes := imageBuffer.Bytes()
+
+ w.Header().Set("Content-Type", "image/png")
+ w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour
+ w.WriteHeader(http.StatusOK)
+ _, err = w.Write(imageBytes)
+ if err != nil {
+ log.Println("failed to write issue summary card", err)
+ return
+ }
+}
diff --git a/appview/issues/router.go b/appview/issues/router.go
index dc34c67a..e0e5dba4 100644
--- a/appview/issues/router.go
+++ b/appview/issues/router.go
@@ -16,6 +16,7 @@ func (i *Issues) Router(mw *middleware.Middleware) http.Handler {
r.Route("/{issue}", func(r chi.Router) {
r.Use(mw.ResolveIssue)
r.Get("/", i.RepoSingleIssue)
+ r.Get("/opengraph", i.IssueOpenGraphSummary)
// authenticated routes
r.Group(func(r chi.Router) {
diff --git a/appview/repo/ogcard/card.go b/appview/ogcard/card.go
similarity index 93%
rename from appview/repo/ogcard/card.go
rename to appview/ogcard/card.go
index 569ad4e3..6fd1692e 100644
--- a/appview/repo/ogcard/card.go
+++ b/appview/ogcard/card.go
@@ -394,11 +394,6 @@ func (c *Card) fetchExternalImage(url string) (image.Image, bool) {
}
contentType := resp.Header.Get("Content-Type")
- // Support content types are in-sync with the allowed custom avatar file types
- if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/gif" && contentType != "image/webp" {
- log.Printf("fetching external image returned unsupported Content-Type which was ignored: %s", contentType)
- return nil, false
- }
body := resp.Body
bodyBytes, err := io.ReadAll(body)
@@ -407,6 +402,17 @@ func (c *Card) fetchExternalImage(url string) (image.Image, bool) {
return nil, false
}
+ // Handle SVG separately
+ if contentType == "image/svg+xml" || strings.HasSuffix(url, ".svg") {
+ return c.convertSVGToPNG(bodyBytes)
+ }
+
+ // Support content types are in-sync with the allowed custom avatar file types
+ if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/gif" && contentType != "image/webp" {
+ log.Printf("fetching external image returned unsupported Content-Type which was ignored: %s", contentType)
+ return nil, false
+ }
+
bodyBuffer := bytes.NewReader(bodyBytes)
_, imgType, err := image.DecodeConfig(bodyBuffer)
if err != nil {
@@ -437,6 +443,35 @@ func (c *Card) fetchExternalImage(url string) (image.Image, bool) {
return img, true
}
+// convertSVGToPNG converts SVG data to a PNG image
+func (c *Card) convertSVGToPNG(svgData []byte) (image.Image, bool) {
+ // Parse the SVG
+ icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData))
+ if err != nil {
+ log.Printf("error parsing SVG: %v", err)
+ return nil, false
+ }
+
+ // Set a reasonable size for the rasterized image
+ width := 256
+ height := 256
+ icon.SetTarget(0, 0, float64(width), float64(height))
+
+ // Create an image to draw on
+ rgba := image.NewRGBA(image.Rect(0, 0, width, height))
+
+ // Fill with white background
+ draw.Draw(rgba, rgba.Bounds(), &image.Uniform{color.White}, image.Point{}, draw.Src)
+
+ // Create a scanner and rasterize the SVG
+ scanner := rasterx.NewScannerGV(width, height, rgba, rgba.Bounds())
+ raster := rasterx.NewDasher(width, height, scanner)
+
+ icon.Draw(raster, 1.0)
+
+ return rgba, true
+}
+
func (c *Card) DrawExternalImage(url string) {
image, ok := c.fetchExternalImage(url)
if !ok {
diff --git a/appview/pages/templates/repo/issues/fragments/og.html b/appview/pages/templates/repo/issues/fragments/og.html
new file mode 100644
index 00000000..93f62ef6
--- /dev/null
+++ b/appview/pages/templates/repo/issues/fragments/og.html
@@ -0,0 +1,19 @@
+{{ define "issues/fragments/og" }}
+ {{ $title := printf "%s #%d" .Issue.Title .Issue.IssueId }}
+ {{ $description := or .Issue.Body .RepoInfo.Description }}
+ {{ $url := printf "https://tangled.org/%s/issues/%d" .RepoInfo.FullName .Issue.IssueId }}
+ {{ $imageUrl := printf "https://tangled.org/%s/issues/%d/opengraph" .RepoInfo.FullName .Issue.IssueId }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+{{ end }}
diff --git a/appview/repo/opengraph.go b/appview/repo/opengraph.go
index 03b49f50..e5cc2fc0 100644
--- a/appview/repo/opengraph.go
+++ b/appview/repo/opengraph.go
@@ -15,7 +15,7 @@ import (
"github.com/go-enry/go-enry/v2"
"tangled.org/core/appview/db"
"tangled.org/core/appview/models"
- "tangled.org/core/appview/repo/ogcard"
+ "tangled.org/core/appview/ogcard"
"tangled.org/core/types"
)
@@ -158,7 +158,7 @@ func (rp *Repo) drawRepoSummaryCard(repo *models.Repo, languageStats []types.Rep
// Draw star icon, count, and label
// Align icon baseline with text baseline
iconBaselineOffset := int(textSize) / 2
- err = statsArea.DrawSVGIcon("static/icons/star.svg", currentX, statsY+iconBaselineOffset-iconSize/2, iconSize, iconColor)
+ err = statsArea.DrawSVGIcon("static/icons/star.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
if err != nil {
log.Printf("failed to draw star icon: %v", err)
}
@@ -185,7 +185,7 @@ func (rp *Repo) drawRepoSummaryCard(repo *models.Repo, languageStats []types.Rep
// Draw issues icon, count, and label
issueStartX := currentX
- err = statsArea.DrawSVGIcon("static/icons/circle-dot.svg", currentX, statsY+iconBaselineOffset-iconSize/2, iconSize, iconColor)
+ err = statsArea.DrawSVGIcon("static/icons/circle-dot.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
if err != nil {
log.Printf("failed to draw circle-dot icon: %v", err)
}
@@ -210,7 +210,7 @@ func (rp *Repo) drawRepoSummaryCard(repo *models.Repo, languageStats []types.Rep
// Draw pull request icon, count, and label
prStartX := currentX
- err = statsArea.DrawSVGIcon("static/icons/git-pull-request.svg", currentX, statsY+iconBaselineOffset-iconSize/2, iconSize, iconColor)
+ err = statsArea.DrawSVGIcon("static/icons/git-pull-request.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
if err != nil {
log.Printf("failed to draw git-pull-request icon: %v", err)
}