this repo has no description

appview/ogcard: split rendering into external worker service using satori and resvg-wasm

Signed-off-by: eti <eti@eti.tf>

authored by

eti and committed by tangled.org 5d2f08b3 053117e5

Waiting for spindle ...
+2214 -1481
+5
appview/config/config.go
··· 139 139 UpdateInterval time.Duration `env:"UPDATE_INTERVAL, default=1h"` 140 140 } 141 141 142 + type OgcardConfig struct { 143 + Host string `env:"HOST, default=https://og.tangled.org"` 144 + } 145 + 142 146 func (cfg RedisConfig) ToURL() string { 143 147 u := &url.URL{ 144 148 Scheme: "redis", ··· 171 175 Bluesky BlueskyConfig `env:",prefix=TANGLED_BLUESKY_"` 172 176 Sites SitesConfig `env:",prefix=TANGLED_SITES_"` 173 177 KnotMirror KnotMirrorConfig `env:",prefix=TANGLED_KNOTMIRROR_"` 178 + Ogcard OgcardConfig `env:",prefix=TANGLED_OGCARD_"` 174 179 } 175 180 176 181 func LoadConfig(ctx context.Context) (*Config, error) {
+3
appview/issues/issues.go
··· 23 23 "tangled.org/core/appview/models" 24 24 "tangled.org/core/appview/notify" 25 25 "tangled.org/core/appview/oauth" 26 + "tangled.org/core/appview/ogcard" 26 27 "tangled.org/core/appview/pages" 27 28 "tangled.org/core/appview/pages/repoinfo" 28 29 "tangled.org/core/appview/pagination" ··· 48 49 logger *slog.Logger 49 50 validator *validator.Validator 50 51 indexer *issues_indexer.Indexer 52 + ogcardClient *ogcard.Client 51 53 } 52 54 53 55 func New( ··· 77 79 logger: logger, 78 80 validator: validator, 79 81 indexer: indexer, 82 + ogcardClient: ogcard.NewClient(config.Ogcard.Host), 80 83 } 81 84 } 82 85
+35 -229
appview/issues/opengraph.go
··· 1 1 package issues 2 2 3 3 import ( 4 - "bytes" 5 4 "context" 6 - "fmt" 7 - "image" 8 - "image/color" 9 - "image/png" 10 5 "log" 11 6 "net/http" 7 + "time" 12 8 13 9 "tangled.org/core/appview/models" 14 10 "tangled.org/core/appview/ogcard" 15 11 ) 16 12 17 - func (rp *Issues) drawIssueSummaryCard(issue *models.Issue, repo *models.Repo, commentCount int, ownerHandle string) (*ogcard.Card, error) { 18 - width, height := ogcard.DefaultSize() 19 - mainCard, err := ogcard.NewCard(width, height) 20 - if err != nil { 21 - return nil, err 22 - } 23 - 24 - // Split: content area (75%) and status/stats area (25%) 25 - contentCard, statsArea := mainCard.Split(false, 75) 26 - 27 - // Add padding to content 28 - contentCard.SetMargin(50) 29 - 30 - // Split content horizontally: main content (80%) and avatar area (20%) 31 - mainContent, avatarArea := contentCard.Split(true, 80) 32 - 33 - // Add margin to main content like repo card 34 - mainContent.SetMargin(10) 35 - 36 - // Use full main content area for repo name and title 37 - bounds := mainContent.Img.Bounds() 38 - startX := bounds.Min.X + mainContent.Margin 39 - startY := bounds.Min.Y + mainContent.Margin 40 - 41 - // Draw full repository name at top (owner/repo format) 42 - var repoOwner string 43 - owner, err := rp.idResolver.ResolveIdent(context.Background(), repo.Did) 44 - if err != nil { 45 - repoOwner = repo.Did 46 - } else { 47 - repoOwner = "@" + owner.Handle.String() 48 - } 49 - 50 - fullRepoName := repoOwner + " / " + repo.Name 51 - if len(fullRepoName) > 60 { 52 - fullRepoName = fullRepoName[:60] + "…" 53 - } 54 - 55 - grayColor := color.RGBA{88, 96, 105, 255} 56 - err = mainContent.DrawTextAt(fullRepoName, startX, startY, grayColor, 36, ogcard.Top, ogcard.Left) 57 - if err != nil { 58 - return nil, err 59 - } 60 - 61 - // Draw issue title below repo name with wrapping 62 - titleY := startY + 60 63 - titleX := startX 64 - 65 - // Truncate title if too long 66 - issueTitle := issue.Title 67 - maxTitleLength := 80 68 - if len(issueTitle) > maxTitleLength { 69 - issueTitle = issueTitle[:maxTitleLength] + "…" 70 - } 71 - 72 - // Create a temporary card for the title area to enable wrapping 73 - titleBounds := mainContent.Img.Bounds() 74 - titleWidth := titleBounds.Dx() - (startX - titleBounds.Min.X) - 20 // Leave some margin 75 - titleHeight := titleBounds.Dy() - (titleY - titleBounds.Min.Y) - 100 // Leave space for issue ID 76 - 77 - titleRect := image.Rect(titleX, titleY, titleX+titleWidth, titleY+titleHeight) 78 - titleCard := &ogcard.Card{ 79 - Img: mainContent.Img.SubImage(titleRect).(*image.RGBA), 80 - Font: mainContent.Font, 81 - Margin: 0, 82 - } 83 - 84 - // Draw wrapped title 85 - lines, err := titleCard.DrawText(issueTitle, color.Black, 54, ogcard.Top, ogcard.Left) 86 - if err != nil { 87 - return nil, err 88 - } 89 - 90 - // Calculate where title ends (number of lines * line height) 91 - lineHeight := 60 // Approximate line height for 54pt font 92 - titleEndY := titleY + (len(lines) * lineHeight) + 10 93 - 94 - // Draw issue ID in gray below the title 95 - issueIdText := fmt.Sprintf("#%d", issue.IssueId) 96 - err = mainContent.DrawTextAt(issueIdText, startX, titleEndY, grayColor, 54, ogcard.Top, ogcard.Left) 97 - if err != nil { 98 - return nil, err 99 - } 100 - 101 - // Get issue author handle (needed for avatar and metadata) 102 - var authorHandle string 103 - author, err := rp.idResolver.ResolveIdent(context.Background(), issue.Did) 104 - if err != nil { 105 - authorHandle = issue.Did 106 - } else { 107 - authorHandle = "@" + author.Handle.String() 108 - } 109 - 110 - // Draw avatar circle on the right side 111 - avatarBounds := avatarArea.Img.Bounds() 112 - avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin 113 - if avatarSize > 220 { 114 - avatarSize = 220 115 - } 116 - avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2) 117 - avatarY := avatarBounds.Min.Y + 20 118 - 119 - // Get avatar URL for issue author 120 - avatarURL := rp.pages.AvatarUrl(authorHandle, "256") 121 - err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize) 122 - if err != nil { 123 - log.Printf("failed to draw avatar (non-fatal): %v", err) 124 - } 125 - 126 - // Split stats area: left side for status/comments (80%), right side for dolly (20%) 127 - statusArea, dollyArea := statsArea.Split(true, 80) 128 - 129 - // Draw status and comment count in status/comments area 130 - statsBounds := statusArea.Img.Bounds() 131 - statsX := statsBounds.Min.X + 60 // left padding 132 - statsY := statsBounds.Min.Y 133 - 134 - iconColor := color.RGBA{88, 96, 105, 255} 135 - iconSize := 36 136 - textSize := 36.0 137 - labelSize := 28.0 138 - iconBaselineOffset := int(textSize) / 2 139 - 140 - // Draw status (open/closed) with colored icon and text 141 - var statusIcon string 142 - var statusText string 143 - var statusColor color.RGBA 144 - 145 - if issue.Open { 146 - statusIcon = "circle-dot" 147 - statusText = "open" 148 - statusColor = color.RGBA{34, 139, 34, 255} // green 149 - } else { 150 - statusIcon = "ban" 151 - statusText = "closed" 152 - statusColor = color.RGBA{52, 58, 64, 255} // dark gray 153 - } 154 - 155 - statusTextWidth := statusArea.TextWidth(statusText, textSize) 156 - badgePadding := 12 157 - badgeHeight := int(textSize) + (badgePadding * 2) 158 - badgeWidth := iconSize + badgePadding + statusTextWidth + (badgePadding * 2) 159 - cornerRadius := 8 160 - badgeX := 60 161 - badgeY := 0 162 - 163 - statusArea.DrawRoundedRect(badgeX, badgeY, badgeWidth, badgeHeight, cornerRadius, statusColor) 164 - 165 - whiteColor := color.RGBA{255, 255, 255, 255} 166 - iconX := statsX + badgePadding 167 - iconY := statsY + (badgeHeight-iconSize)/2 168 - err = statusArea.DrawLucideIcon(statusIcon, iconX, iconY, iconSize, whiteColor) 169 - if err != nil { 170 - log.Printf("failed to draw status icon: %v", err) 171 - } 172 - 173 - textX := statsX + badgePadding + iconSize + badgePadding 174 - textY := statsY + (badgeHeight-int(textSize))/2 - 5 175 - err = statusArea.DrawTextAt(statusText, textX, textY, whiteColor, textSize, ogcard.Top, ogcard.Left) 176 - if err != nil { 177 - log.Printf("failed to draw status text: %v", err) 178 - } 179 - 180 - currentX := statsX + badgeWidth + 50 181 - 182 - // Draw comment count 183 - err = statusArea.DrawLucideIcon("message-square", currentX, iconY, iconSize, iconColor) 184 - if err != nil { 185 - log.Printf("failed to draw comment icon: %v", err) 186 - } 187 - 188 - currentX += iconSize + 15 189 - commentText := fmt.Sprintf("%d comments", commentCount) 190 - if commentCount == 1 { 191 - commentText = "1 comment" 192 - } 193 - err = statusArea.DrawTextAt(commentText, currentX, textY, iconColor, textSize, ogcard.Top, ogcard.Left) 194 - if err != nil { 195 - log.Printf("failed to draw comment text: %v", err) 196 - } 197 - 198 - // Draw dolly logo on the right side 199 - dollyBounds := dollyArea.Img.Bounds() 200 - dollySize := 90 201 - dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 202 - dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 203 - dollyColor := color.RGBA{180, 180, 180, 255} // light gray 204 - err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor) 205 - if err != nil { 206 - log.Printf("dolly not available (this is ok): %v", err) 207 - } 208 - 209 - // Draw "opened by @author" and date at the bottom with more spacing 210 - labelY := statsY + iconSize + 30 211 - 212 - // Format the opened date 213 - openedDate := issue.Created.Format("Jan 2, 2006") 214 - metaText := fmt.Sprintf("opened by %s · %s", authorHandle, openedDate) 215 - 216 - err = statusArea.DrawTextAt(metaText, statsX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Left) 217 - if err != nil { 218 - log.Printf("failed to draw metadata: %v", err) 219 - } 220 - 221 - return mainCard, nil 222 - } 223 - 224 13 func (rp *Issues) IssueOpenGraphSummary(w http.ResponseWriter, r *http.Request) { 225 14 f, err := rp.repoResolver.Resolve(r) 226 15 if err != nil { ··· 235 24 return 236 25 } 237 26 238 - // Get comment count 239 - commentCount := len(issue.Comments) 240 - 241 - // Get owner handle for avatar 242 27 var ownerHandle string 243 - owner, err := rp.idResolver.ResolveIdent(r.Context(), f.Did) 28 + owner, err := rp.idResolver.ResolveIdent(context.Background(), f.Did) 244 29 if err != nil { 245 30 ownerHandle = f.Did 246 31 } else { 247 32 ownerHandle = "@" + owner.Handle.String() 248 33 } 249 34 250 - card, err := rp.drawIssueSummaryCard(issue, f, commentCount, ownerHandle) 35 + var authorHandle string 36 + author, err := rp.idResolver.ResolveIdent(context.Background(), issue.Did) 251 37 if err != nil { 252 - log.Println("failed to draw issue summary card", err) 253 - http.Error(w, "failed to draw issue summary card", http.StatusInternalServerError) 254 - return 38 + authorHandle = issue.Did 39 + } else { 40 + authorHandle = "@" + author.Handle.String() 255 41 } 256 42 257 - var imageBuffer bytes.Buffer 258 - err = png.Encode(&imageBuffer, card.Img) 43 + avatarUrl := rp.pages.AvatarUrl(authorHandle, "256") 44 + 45 + status := "closed" 46 + if issue.Open { 47 + status = "open" 48 + } 49 + 50 + commentCount := len(issue.Comments) 51 + 52 + payload := ogcard.IssueCardPayload{ 53 + Type: "issue", 54 + RepoName: f.Name, 55 + OwnerHandle: ownerHandle, 56 + AvatarUrl: avatarUrl, 57 + Title: issue.Title, 58 + IssueNumber: issue.IssueId, 59 + Status: status, 60 + Labels: []ogcard.LabelData{}, 61 + CommentCount: commentCount, 62 + ReactionCount: 0, 63 + CreatedAt: issue.Created.Format(time.RFC3339), 64 + } 65 + 66 + imageBytes, err := rp.ogcardClient.RenderIssueCard(r.Context(), payload) 259 67 if err != nil { 260 - log.Println("failed to encode issue summary card", err) 261 - http.Error(w, "failed to encode issue summary card", http.StatusInternalServerError) 68 + log.Println("failed to render issue card", err) 69 + http.Error(w, "failed to render issue card", http.StatusInternalServerError) 262 70 return 263 71 } 264 - 265 - imageBytes := imageBuffer.Bytes() 266 72 267 73 w.Header().Set("Content-Type", "image/png") 268 - w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour 74 + w.Header().Set("Cache-Control", "public, max-age=3600") 269 75 w.WriteHeader(http.StatusOK) 270 76 _, err = w.Write(imageBytes) 271 77 if err != nil { 272 - log.Println("failed to write issue summary card", err) 78 + log.Println("failed to write issue card", err) 273 79 return 274 80 } 275 81 }
+4
appview/ogcard/.gitignore
··· 1 + node_modules/ 2 + output/ 3 + .wrangler/ 4 + .DS_Store
+492
appview/ogcard/bun.lock
··· 1 + { 2 + "lockfileVersion": 1, 3 + "configVersion": 1, 4 + "workspaces": { 5 + "": { 6 + "name": "@tangled/ogcard-worker", 7 + "dependencies": { 8 + "@fontsource/inter": "^5.2.8", 9 + "@resvg/resvg-wasm": "^2.6.2", 10 + "@tangled/ogcard-runtime": "*", 11 + "lucide-static": "^0.577.0", 12 + "preact": "^10.29.0", 13 + "satori": "0.25.0", 14 + "zod": "^4.3.6", 15 + }, 16 + "devDependencies": { 17 + "@cloudflare/workers-types": "^4.20260317.1", 18 + "@types/bun": "^1.3.11", 19 + "@types/node": "^25.5.0", 20 + "knip": "^6.0.1", 21 + "tsx": "^4.21.0", 22 + "typescript": "^5.9.3", 23 + "wrangler": "^4.75.0", 24 + }, 25 + }, 26 + "packages/runtime": { 27 + "name": "@tangled/ogcard-runtime", 28 + "version": "1.0.0", 29 + }, 30 + }, 31 + "packages": { 32 + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="], 33 + 34 + "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.15.0", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-EGYmJaGZKWl+X8tXxcnx4v2bOZSjQeNI5dWFeXivgX9+YCT69AkzHHwlNbVpqtEUTbew8eQurpyOpeN8fg00nw=="], 35 + 36 + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260317.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-8hjh3sPMwY8M/zedq3/sXoA2Q4BedlGufn3KOOleIG+5a4ReQKLlUah140D7J6zlKmYZAFMJ4tWC7hCuI/s79g=="], 37 + 38 + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260317.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-M/MnNyvO5HMgoIdr3QHjdCj2T1ki9gt0vIUnxYxBu9ISXS/jgtMl6chUVPJ7zHYBn9MyYr8ByeN6frjYxj0MGg=="], 39 + 40 + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260317.1", "", { "os": "linux", "cpu": "x64" }, "sha512-1ltuEjkRcS3fsVF7CxsKlWiRmzq2ZqMfqDN0qUOgbUwkpXsLVJsXmoblaLf5OP00ELlcgF0QsN0p2xPEua4Uug=="], 41 + 42 + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260317.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-3QrNnPF1xlaNwkHpasvRvAMidOvQs2NhXQmALJrEfpIJ/IDL2la8g499yXp3eqhG3hVMCB07XVY149GTs42Xtw=="], 43 + 44 + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260317.1", "", { "os": "win32", "cpu": "x64" }, "sha512-MfZTz+7LfuIpMGTa3RLXHX8Z/pnycZLItn94WRdHr8LPVet+C5/1Nzei399w/jr3+kzT4pDKk26JF/tlI5elpQ=="], 45 + 46 + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260317.1", "", {}, "sha512-+G4eVwyCpm8Au1ex8vQBCuA9wnwqetz4tPNRoB/53qvktERWBRMQnrtvC1k584yRE3emMThtuY0gWshvSJ++PQ=="], 47 + 48 + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], 49 + 50 + "@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="], 51 + 52 + "@emnapi/runtime": ["@emnapi/runtime@1.9.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="], 53 + 54 + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="], 55 + 56 + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="], 57 + 58 + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.4", "", { "os": "android", "cpu": "arm" }, "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ=="], 59 + 60 + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.4", "", { "os": "android", "cpu": "arm64" }, "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw=="], 61 + 62 + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.4", "", { "os": "android", "cpu": "x64" }, "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw=="], 63 + 64 + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ=="], 65 + 66 + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw=="], 67 + 68 + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw=="], 69 + 70 + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ=="], 71 + 72 + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.4", "", { "os": "linux", "cpu": "arm" }, "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg=="], 73 + 74 + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA=="], 75 + 76 + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA=="], 77 + 78 + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA=="], 79 + 80 + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw=="], 81 + 82 + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA=="], 83 + 84 + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw=="], 85 + 86 + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA=="], 87 + 88 + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.4", "", { "os": "linux", "cpu": "x64" }, "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA=="], 89 + 90 + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q=="], 91 + 92 + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.4", "", { "os": "none", "cpu": "x64" }, "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg=="], 93 + 94 + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow=="], 95 + 96 + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ=="], 97 + 98 + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg=="], 99 + 100 + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g=="], 101 + 102 + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg=="], 103 + 104 + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw=="], 105 + 106 + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="], 107 + 108 + "@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="], 109 + 110 + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], 111 + 112 + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], 113 + 114 + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], 115 + 116 + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], 117 + 118 + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], 119 + 120 + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], 121 + 122 + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], 123 + 124 + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], 125 + 126 + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], 127 + 128 + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], 129 + 130 + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], 131 + 132 + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], 133 + 134 + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], 135 + 136 + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], 137 + 138 + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], 139 + 140 + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], 141 + 142 + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], 143 + 144 + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], 145 + 146 + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], 147 + 148 + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], 149 + 150 + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], 151 + 152 + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], 153 + 154 + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], 155 + 156 + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], 157 + 158 + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], 159 + 160 + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], 161 + 162 + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], 163 + 164 + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], 165 + 166 + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], 167 + 168 + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], 169 + 170 + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], 171 + 172 + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], 173 + 174 + "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.120.0", "", { "os": "android", "cpu": "arm" }, "sha512-WU3qtINx802wOl8RxAF1v0VvmC2O4D9M8Sv486nLeQ7iPHVmncYZrtBhB4SYyX+XZxj2PNnCcN+PW21jHgiOxg=="], 175 + 176 + "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.120.0", "", { "os": "android", "cpu": "arm64" }, "sha512-SEf80EHdhlbjZEgzeWm0ZA/br4GKMenDW3QB/gtyeTV1gStvvZeFi40ioHDZvds2m4Z9J1bUAUL8yn1/+A6iGg=="], 177 + 178 + "@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.120.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xVrrbCai8R8CUIBu3CjryutQnEYhZqs1maIqDvtUCFZb8vY33H7uh9mHpL3a0JBIKoBUKjPH8+rzyAeXnS2d6A=="], 179 + 180 + "@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.120.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-xyHBbnJ6mydnQUH7MAcafOkkrNzQC6T+LXgDH/3InEq2BWl/g424IMRiJVSpVqGjB+p2bd0h0WRR8iIwzjU7rw=="], 181 + 182 + "@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.120.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMnVRllquXUYTeNfFKmxTTEdZ/ix1nLl0ducDzMSREoWYGVIHnOOxoKMWlCOvRr9Wk/HZqo2rh1jeumbPGPV9A=="], 183 + 184 + "@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.120.0", "", { "os": "linux", "cpu": "arm" }, "sha512-tkvn2CQ7QdcsMnpfiX3fd3wA3EFsWKYlcQzq9cFw/xc89Al7W6Y4O0FgLVkVQpo0Tnq/qtE1XfkJOnRRA9S/NA=="], 185 + 186 + "@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.120.0", "", { "os": "linux", "cpu": "arm" }, "sha512-WN5y135Ic42gQDk9grbwY9++fDhqf8knN6fnP+0WALlAUh4odY/BDK1nfTJRSfpJD9P3r1BwU0m3pW2DU89whQ=="], 187 + 188 + "@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.120.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-1GgQBCcXvFMw99EPdMy+4NZ3aYyXsxjf9kbUUg8HuAy3ZBXzOry5KfFEzT9nqmgZI1cuetvApkiJBZLAPo8uaw=="], 189 + 190 + "@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.120.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-gmMQ70gsPdDBgpcErvJEoWNBr7bJooSLlvOBVBSGfOzlP5NvJ3bFvnUeZZ9d+dPrqSngtonf7nyzWUTUj/U+lw=="], 191 + 192 + "@oxc-parser/binding-linux-ppc64-gnu": ["@oxc-parser/binding-linux-ppc64-gnu@0.120.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-T/kZuU0ajop0xhzVMwH5r3srC9Nqup5HaIo+3uFjIN5uPxa0LvSxC1ZqP4aQGJVW5G0z8/nCkjIfSMS91P/wzw=="], 193 + 194 + "@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.120.0", "", { "os": "linux", "cpu": "none" }, "sha512-vn21KXLAXzaI3N5CZWlBr1iWeXLl9QFIMor7S1hUjUGTeUuWCoE6JZB040/ZNDwf+JXPX8Ao9KbmJq9FMC2iGw=="], 195 + 196 + "@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.120.0", "", { "os": "linux", "cpu": "none" }, "sha512-SUbUxlar007LTGmSLGIC5x/WJvwhdX+PwNzFJ9f/nOzZOrCFbOT4ikt7pJIRg1tXVsEfzk5mWpGO1NFiSs4PIw=="], 197 + 198 + "@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.120.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-hYiPJTxyfJY2+lMBFk3p2bo0R9GN+TtpPFlRqVchL1qvLG+pznstramHNvJlw9AjaoRUHwp9IKR7UZQnRPGjgQ=="], 199 + 200 + "@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.120.0", "", { "os": "linux", "cpu": "x64" }, "sha512-q+5jSVZkprJCIy3dzJpApat0InJaoxQLsJuD6DkX8hrUS61z2lHQ1Fe9L2+TYbKHXCLWbL0zXe7ovkIdopBGMQ=="], 201 + 202 + "@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.120.0", "", { "os": "linux", "cpu": "x64" }, "sha512-D9QDDZNnH24e7X4ftSa6ar/2hCavETfW3uk0zgcMIrZNy459O5deTbWrjGzZiVrSWigGtlQwzs2McBP0QsfV1w=="], 203 + 204 + "@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.120.0", "", { "os": "none", "cpu": "arm64" }, "sha512-TBU8ZwOUWAOUWVfmI16CYWbvh4uQb9zHnGBHsw5Cp2JUVG044OIY1CSHODLifqzQIMTXvDvLzcL89GGdUIqNrA=="], 205 + 206 + "@oxc-parser/binding-wasm32-wasi": ["@oxc-parser/binding-wasm32-wasi@0.120.0", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-WG/FOZgDJCpJnuF3ToG/K28rcOmSY7FmFmfBKYb2fmLyhDzPpUldFGV7/Fz4ru0Iz/v4KPmf8xVgO8N3lO4KHA=="], 207 + 208 + "@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.120.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1T0HKGcsz/BKo77t7+89L8Qvu4f9DoleKWHp3C5sJEcbCjDOLx3m9m722bWZTY+hANlUEs+yjlK+lBFsA+vrVQ=="], 209 + 210 + "@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.120.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-L7vfLzbOXsjBXV0rv/6Y3Jd9BRjPeCivINZAqrSyAOZN3moCopDN+Psq9ZrGNZtJzP8946MtlRFZ0Als0wBCOw=="], 211 + 212 + "@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.120.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ys+upfqNtSu58huAhJMBKl3XCkGzyVFBlMlGPzHeFKgpFF/OdgNs1MMf8oaJIbgMH8ZxgGF7qfue39eJohmKIg=="], 213 + 214 + "@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="], 215 + 216 + "@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.19.1", "", { "os": "android", "cpu": "arm" }, "sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg=="], 217 + 218 + "@oxc-resolver/binding-android-arm64": ["@oxc-resolver/binding-android-arm64@11.19.1", "", { "os": "android", "cpu": "arm64" }, "sha512-oolbkRX+m7Pq2LNjr/kKgYeC7bRDMVTWPgxBGMjSpZi/+UskVo4jsMU3MLheZV55jL6c3rNelPl4oD60ggYmqA=="], 219 + 220 + "@oxc-resolver/binding-darwin-arm64": ["@oxc-resolver/binding-darwin-arm64@11.19.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-nUC6d2i3R5B12sUW4O646qD5cnMXf2oBGPLIIeaRfU9doJRORAbE2SGv4eW6rMqhD+G7nf2Y8TTJTLiiO3Q/dQ=="], 221 + 222 + "@oxc-resolver/binding-darwin-x64": ["@oxc-resolver/binding-darwin-x64@11.19.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-cV50vE5+uAgNcFa3QY1JOeKDSkM/9ReIcc/9wn4TavhW/itkDGrXhw9jaKnkQnGbjJ198Yh5nbX/Gr2mr4Z5jQ=="], 223 + 224 + "@oxc-resolver/binding-freebsd-x64": ["@oxc-resolver/binding-freebsd-x64@11.19.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-xZOQiYGFxtk48PBKff+Zwoym7ScPAIVp4c14lfLxizO2LTTTJe5sx9vQNGrBymrf/vatSPNMD4FgsaaRigPkqw=="], 225 + 226 + "@oxc-resolver/binding-linux-arm-gnueabihf": ["@oxc-resolver/binding-linux-arm-gnueabihf@11.19.1", "", { "os": "linux", "cpu": "arm" }, "sha512-lXZYWAC6kaGe/ky2su94e9jN9t6M0/6c+GrSlCqL//XO1cxi5lpAhnJYdyrKfm0ZEr/c7RNyAx3P7FSBcBd5+A=="], 227 + 228 + "@oxc-resolver/binding-linux-arm-musleabihf": ["@oxc-resolver/binding-linux-arm-musleabihf@11.19.1", "", { "os": "linux", "cpu": "arm" }, "sha512-veG1kKsuK5+t2IsO9q0DErYVSw2azvCVvWHnfTOS73WE0STdLLB7Q1bB9WR+yHPQM76ASkFyRbogWo1GR1+WbQ=="], 229 + 230 + "@oxc-resolver/binding-linux-arm64-gnu": ["@oxc-resolver/binding-linux-arm64-gnu@11.19.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-heV2+jmXyYnUrpUXSPugqWDRpnsQcDm2AX4wzTuvgdlZfoNYO0O3W2AVpJYaDn9AG4JdM6Kxom8+foE7/BcSig=="], 231 + 232 + "@oxc-resolver/binding-linux-arm64-musl": ["@oxc-resolver/binding-linux-arm64-musl@11.19.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jvo2Pjs1c9KPxMuMPIeQsgu0mOJF9rEb3y3TdpsrqwxRM+AN6/nDDwv45n5ZrUnQMsdBy5gIabioMKnQfWo9ew=="], 233 + 234 + "@oxc-resolver/binding-linux-ppc64-gnu": ["@oxc-resolver/binding-linux-ppc64-gnu@11.19.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-vLmdNxWCdN7Uo5suays6A/+ywBby2PWBBPXctWPg5V0+eVuzsJxgAn6MMB4mPlshskYbppjpN2Zg83ArHze9gQ=="], 235 + 236 + "@oxc-resolver/binding-linux-riscv64-gnu": ["@oxc-resolver/binding-linux-riscv64-gnu@11.19.1", "", { "os": "linux", "cpu": "none" }, "sha512-/b+WgR+VTSBxzgOhDO7TlMXC1ufPIMR6Vj1zN+/x+MnyXGW7prTLzU9eW85Aj7Th7CCEG9ArCbTeqxCzFWdg2w=="], 237 + 238 + "@oxc-resolver/binding-linux-riscv64-musl": ["@oxc-resolver/binding-linux-riscv64-musl@11.19.1", "", { "os": "linux", "cpu": "none" }, "sha512-YlRdeWb9j42p29ROh+h4eg/OQ3dTJlpHSa+84pUM9+p6i3djtPz1q55yLJhgW9XfDch7FN1pQ/Vd6YP+xfRIuw=="], 239 + 240 + "@oxc-resolver/binding-linux-s390x-gnu": ["@oxc-resolver/binding-linux-s390x-gnu@11.19.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-EDpafVOQWF8/MJynsjOGFThcqhRHy417sRyLfQmeiamJ8qVhSKAn2Dn2VVKUGCjVB9C46VGjhNo7nOPUi1x6uA=="], 241 + 242 + "@oxc-resolver/binding-linux-x64-gnu": ["@oxc-resolver/binding-linux-x64-gnu@11.19.1", "", { "os": "linux", "cpu": "x64" }, "sha512-NxjZe+rqWhr+RT8/Ik+5ptA3oz7tUw361Wa5RWQXKnfqwSSHdHyrw6IdcTfYuml9dM856AlKWZIUXDmA9kkiBQ=="], 243 + 244 + "@oxc-resolver/binding-linux-x64-musl": ["@oxc-resolver/binding-linux-x64-musl@11.19.1", "", { "os": "linux", "cpu": "x64" }, "sha512-cM/hQwsO3ReJg5kR+SpI69DMfvNCp+A/eVR4b4YClE5bVZwz8rh2Nh05InhwI5HR/9cArbEkzMjcKgTHS6UaNw=="], 245 + 246 + "@oxc-resolver/binding-openharmony-arm64": ["@oxc-resolver/binding-openharmony-arm64@11.19.1", "", { "os": "none", "cpu": "arm64" }, "sha512-QF080IowFB0+9Rh6RcD19bdgh49BpQHUW5TajG1qvWHvmrQznTZZjYlgE2ltLXyKY+qs4F/v5xuX1XS7Is+3qA=="], 247 + 248 + "@oxc-resolver/binding-wasm32-wasi": ["@oxc-resolver/binding-wasm32-wasi@11.19.1", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-w8UCKhX826cP/ZLokXDS6+milN8y4X7zidsAttEdWlVoamTNf6lhBJldaWr3ukTDiye7s4HRcuPEPOXNC432Vg=="], 249 + 250 + "@oxc-resolver/binding-win32-arm64-msvc": ["@oxc-resolver/binding-win32-arm64-msvc@11.19.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-nJ4AsUVZrVKwnU/QRdzPCCrO0TrabBqgJ8pJhXITdZGYOV28TIYystV1VFLbQ7DtAcaBHpocT5/ZJnF78YJPtQ=="], 251 + 252 + "@oxc-resolver/binding-win32-ia32-msvc": ["@oxc-resolver/binding-win32-ia32-msvc@11.19.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-EW+ND5q2Tl+a3pH81l1QbfgbF3HmqgwLfDfVithRFheac8OTcnbXt/JxqD2GbDkb7xYEqy1zNaVFRr3oeG8npA=="], 253 + 254 + "@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.19.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw=="], 255 + 256 + "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], 257 + 258 + "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], 259 + 260 + "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], 261 + 262 + "@resvg/resvg-wasm": ["@resvg/resvg-wasm@2.6.2", "", {}, "sha512-FqALmHI8D4o6lk/LRWDnhw95z5eO+eAa6ORjVg09YRR7BkcM6oPHU9uyC0gtQG5vpFLvgpeU4+zEAz2H8APHNw=="], 263 + 264 + "@shuding/opentype.js": ["@shuding/opentype.js@1.4.0-beta.0", "", { "dependencies": { "fflate": "^0.7.3", "string.prototype.codepointat": "^0.2.1" }, "bin": { "ot": "bin/ot" } }, "sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA=="], 265 + 266 + "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], 267 + 268 + "@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="], 269 + 270 + "@tangled/ogcard-runtime": ["@tangled/ogcard-runtime@workspace:packages/runtime"], 271 + 272 + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], 273 + 274 + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], 275 + 276 + "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], 277 + 278 + "base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="], 279 + 280 + "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], 281 + 282 + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], 283 + 284 + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], 285 + 286 + "camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="], 287 + 288 + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], 289 + 290 + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], 291 + 292 + "css-background-parser": ["css-background-parser@0.1.0", "", {}, "sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA=="], 293 + 294 + "css-box-shadow": ["css-box-shadow@1.0.0-3", "", {}, "sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg=="], 295 + 296 + "css-color-keywords": ["css-color-keywords@1.0.0", "", {}, "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg=="], 297 + 298 + "css-gradient-parser": ["css-gradient-parser@0.0.17", "", {}, "sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg=="], 299 + 300 + "css-to-react-native": ["css-to-react-native@3.2.0", "", { "dependencies": { "camelize": "^1.0.0", "css-color-keywords": "^1.0.0", "postcss-value-parser": "^4.0.2" } }, "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ=="], 301 + 302 + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], 303 + 304 + "emoji-regex-xs": ["emoji-regex-xs@2.0.1", "", {}, "sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g=="], 305 + 306 + "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], 307 + 308 + "esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="], 309 + 310 + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], 311 + 312 + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], 313 + 314 + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], 315 + 316 + "fd-package-json": ["fd-package-json@2.0.0", "", { "dependencies": { "walk-up-path": "^4.0.0" } }, "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ=="], 317 + 318 + "fflate": ["fflate@0.7.4", "", {}, "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw=="], 319 + 320 + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], 321 + 322 + "formatly": ["formatly@0.3.0", "", { "dependencies": { "fd-package-json": "^2.0.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w=="], 323 + 324 + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], 325 + 326 + "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], 327 + 328 + "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], 329 + 330 + "hex-rgb": ["hex-rgb@4.3.0", "", {}, "sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw=="], 331 + 332 + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], 333 + 334 + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], 335 + 336 + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], 337 + 338 + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], 339 + 340 + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], 341 + 342 + "knip": ["knip@6.0.1", "", { "dependencies": { "@nodelib/fs.walk": "^1.2.3", "fast-glob": "^3.3.3", "formatly": "^0.3.0", "get-tsconfig": "4.13.6", "jiti": "^2.6.0", "minimist": "^1.2.8", "oxc-parser": "^0.120.0", "oxc-resolver": "^11.19.1", "picocolors": "^1.1.1", "picomatch": "^4.0.1", "smol-toml": "^1.5.2", "strip-json-comments": "5.0.3", "unbash": "^2.2.0", "yaml": "^2.8.2", "zod": "^4.1.11" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-qk5m+w6IYEqfRG5546DXZJYl5AXsgFfDD6ULaDvkubqNtLye79sokBg3usURrWFjASMeQtvX19TfldU3jHkMNA=="], 343 + 344 + "linebreak": ["linebreak@1.1.0", "", { "dependencies": { "base64-js": "0.0.8", "unicode-trie": "^2.0.0" } }, "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ=="], 345 + 346 + "lucide-static": ["lucide-static@0.577.0", "", {}, "sha512-hx39J5Tq4JWF2ALY+5YRg+SxQLpeAmLJDXNcqiBJH/UuVwp43it9fyki/onZO7AVFgG5ZbB+fWwZR9mwGHE2XQ=="], 347 + 348 + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], 349 + 350 + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], 351 + 352 + "miniflare": ["miniflare@4.20260317.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.4", "workerd": "1.20260317.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-xuwk5Kjv+shi5iUBAdCrRl9IaWSGnTU8WuTQzsUS2GlSDIMCJuu8DiF/d9ExjMXYiQG5ml+k9SVKnMj8cRkq0w=="], 353 + 354 + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], 355 + 356 + "oxc-parser": ["oxc-parser@0.120.0", "", { "dependencies": { "@oxc-project/types": "^0.120.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.120.0", "@oxc-parser/binding-android-arm64": "0.120.0", "@oxc-parser/binding-darwin-arm64": "0.120.0", "@oxc-parser/binding-darwin-x64": "0.120.0", "@oxc-parser/binding-freebsd-x64": "0.120.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.120.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.120.0", "@oxc-parser/binding-linux-arm64-gnu": "0.120.0", "@oxc-parser/binding-linux-arm64-musl": "0.120.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.120.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.120.0", "@oxc-parser/binding-linux-riscv64-musl": "0.120.0", "@oxc-parser/binding-linux-s390x-gnu": "0.120.0", "@oxc-parser/binding-linux-x64-gnu": "0.120.0", "@oxc-parser/binding-linux-x64-musl": "0.120.0", "@oxc-parser/binding-openharmony-arm64": "0.120.0", "@oxc-parser/binding-wasm32-wasi": "0.120.0", "@oxc-parser/binding-win32-arm64-msvc": "0.120.0", "@oxc-parser/binding-win32-ia32-msvc": "0.120.0", "@oxc-parser/binding-win32-x64-msvc": "0.120.0" } }, "sha512-WyPWZlcIm+Fkte63FGfgFB8mAAk33aH9h5N9lphXVOHSXEBFFsmYdOBedVKly363aWABjZdaj/m9lBfEY4wt+w=="], 357 + 358 + "oxc-resolver": ["oxc-resolver@11.19.1", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.19.1", "@oxc-resolver/binding-android-arm64": "11.19.1", "@oxc-resolver/binding-darwin-arm64": "11.19.1", "@oxc-resolver/binding-darwin-x64": "11.19.1", "@oxc-resolver/binding-freebsd-x64": "11.19.1", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.19.1", "@oxc-resolver/binding-linux-arm-musleabihf": "11.19.1", "@oxc-resolver/binding-linux-arm64-gnu": "11.19.1", "@oxc-resolver/binding-linux-arm64-musl": "11.19.1", "@oxc-resolver/binding-linux-ppc64-gnu": "11.19.1", "@oxc-resolver/binding-linux-riscv64-gnu": "11.19.1", "@oxc-resolver/binding-linux-riscv64-musl": "11.19.1", "@oxc-resolver/binding-linux-s390x-gnu": "11.19.1", "@oxc-resolver/binding-linux-x64-gnu": "11.19.1", "@oxc-resolver/binding-linux-x64-musl": "11.19.1", "@oxc-resolver/binding-openharmony-arm64": "11.19.1", "@oxc-resolver/binding-wasm32-wasi": "11.19.1", "@oxc-resolver/binding-win32-arm64-msvc": "11.19.1", "@oxc-resolver/binding-win32-ia32-msvc": "11.19.1", "@oxc-resolver/binding-win32-x64-msvc": "11.19.1" } }, "sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg=="], 359 + 360 + "pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="], 361 + 362 + "parse-css-color": ["parse-css-color@0.2.1", "", { "dependencies": { "color-name": "^1.1.4", "hex-rgb": "^4.1.0" } }, "sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg=="], 363 + 364 + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], 365 + 366 + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], 367 + 368 + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], 369 + 370 + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], 371 + 372 + "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], 373 + 374 + "preact": ["preact@10.29.0", "", {}, "sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg=="], 375 + 376 + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], 377 + 378 + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], 379 + 380 + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], 381 + 382 + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], 383 + 384 + "satori": ["satori@0.25.0", "", { "dependencies": { "@shuding/opentype.js": "1.4.0-beta.0", "css-background-parser": "^0.1.0", "css-box-shadow": "1.0.0-3", "css-gradient-parser": "^0.0.17", "css-to-react-native": "^3.0.0", "emoji-regex-xs": "^2.0.1", "escape-html": "^1.0.3", "linebreak": "^1.1.0", "parse-css-color": "^0.2.1", "postcss-value-parser": "^4.2.0", "yoga-layout": "^3.2.1" } }, "sha512-utINfLxrYrmSnLvxFT4ZwgwWa8KOjrz7ans32V5wItgHVmzESl/9i33nE38uG0miycab8hUqQtDlOpqrIpB/iw=="], 385 + 386 + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], 387 + 388 + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], 389 + 390 + "smol-toml": ["smol-toml@1.6.0", "", {}, "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw=="], 391 + 392 + "string.prototype.codepointat": ["string.prototype.codepointat@0.2.1", "", {}, "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg=="], 393 + 394 + "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], 395 + 396 + "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], 397 + 398 + "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], 399 + 400 + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], 401 + 402 + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 403 + 404 + "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], 405 + 406 + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], 407 + 408 + "unbash": ["unbash@2.2.0", "", {}, "sha512-X2wH19RAPZE3+ldGicOkoj/SIA83OIxcJ6Cuaw23hf8Xc6fQpvZXY0SftE2JgS0QhYLUG4uwodSI3R53keyh7w=="], 409 + 410 + "undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="], 411 + 412 + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], 413 + 414 + "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], 415 + 416 + "unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="], 417 + 418 + "walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="], 419 + 420 + "workerd": ["workerd@1.20260317.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260317.1", "@cloudflare/workerd-darwin-arm64": "1.20260317.1", "@cloudflare/workerd-linux-64": "1.20260317.1", "@cloudflare/workerd-linux-arm64": "1.20260317.1", "@cloudflare/workerd-windows-64": "1.20260317.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-ZuEq1OdrJBS+NV+L5HMYPCzVn49a2O60slQiiLpG44jqtlOo+S167fWC76kEXteXLLLydeuRrluRel7WdOUa4g=="], 421 + 422 + "wrangler": ["wrangler@4.75.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.15.0", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260317.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260317.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260317.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-Efk1tcnm4eduBYpH1sSjMYydXMnIFPns/qABI3+fsbDrUk5GksNYX8nYGVP4sFygvGPO7kJc36YJKB5ooA7JAg=="], 423 + 424 + "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], 425 + 426 + "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], 427 + 428 + "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], 429 + 430 + "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], 431 + 432 + "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], 433 + 434 + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], 435 + 436 + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], 437 + 438 + "wrangler/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], 439 + 440 + "wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], 441 + 442 + "wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], 443 + 444 + "wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], 445 + 446 + "wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], 447 + 448 + "wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], 449 + 450 + "wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], 451 + 452 + "wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], 453 + 454 + "wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], 455 + 456 + "wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], 457 + 458 + "wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], 459 + 460 + "wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], 461 + 462 + "wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], 463 + 464 + "wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], 465 + 466 + "wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], 467 + 468 + "wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], 469 + 470 + "wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], 471 + 472 + "wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], 473 + 474 + "wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], 475 + 476 + "wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], 477 + 478 + "wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], 479 + 480 + "wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], 481 + 482 + "wrangler/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], 483 + 484 + "wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], 485 + 486 + "wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], 487 + 488 + "wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], 489 + 490 + "wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], 491 + } 492 + }
-640
appview/ogcard/card.go
··· 1 - // Copyright 2024 The Forgejo Authors. All rights reserved. 2 - // Copyright 2025 The Tangled Authors -- repurposed for Tangled use. 3 - // SPDX-License-Identifier: MIT 4 - 5 - package ogcard 6 - 7 - import ( 8 - "bytes" 9 - "fmt" 10 - "html/template" 11 - "image" 12 - "image/color" 13 - "io" 14 - "log" 15 - "math" 16 - "net/http" 17 - "strings" 18 - "sync" 19 - "time" 20 - 21 - "github.com/goki/freetype" 22 - "github.com/goki/freetype/truetype" 23 - "github.com/srwiley/oksvg" 24 - "github.com/srwiley/rasterx" 25 - "golang.org/x/image/draw" 26 - "golang.org/x/image/font" 27 - "tangled.org/core/appview/pages" 28 - 29 - _ "golang.org/x/image/webp" // for processing webp images 30 - ) 31 - 32 - type Card struct { 33 - Img *image.RGBA 34 - Font *truetype.Font 35 - Margin int 36 - Width int 37 - Height int 38 - } 39 - 40 - var fontCache = sync.OnceValues(func() (*truetype.Font, error) { 41 - interVar, err := pages.Files.ReadFile("static/fonts/InterVariable.ttf") 42 - if err != nil { 43 - return nil, err 44 - } 45 - return truetype.Parse(interVar) 46 - }) 47 - 48 - // DefaultSize returns the default size for a card 49 - func DefaultSize() (int, int) { 50 - return 1200, 630 51 - } 52 - 53 - // NewCard creates a new card with the given dimensions in pixels 54 - func NewCard(width, height int) (*Card, error) { 55 - img := image.NewRGBA(image.Rect(0, 0, width, height)) 56 - draw.Draw(img, img.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) 57 - 58 - font, err := fontCache() 59 - if err != nil { 60 - return nil, err 61 - } 62 - 63 - return &Card{ 64 - Img: img, 65 - Font: font, 66 - Margin: 0, 67 - Width: width, 68 - Height: height, 69 - }, nil 70 - } 71 - 72 - // Split splits the card horizontally or vertically by a given percentage; the first card returned has the percentage 73 - // size, and the second card has the remainder. Both cards draw to a subsection of the same image buffer. 74 - func (c *Card) Split(vertical bool, percentage int) (*Card, *Card) { 75 - bounds := c.Img.Bounds() 76 - bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 77 - if vertical { 78 - mid := (bounds.Dx() * percentage / 100) + bounds.Min.X 79 - subleft := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, mid, bounds.Max.Y)).(*image.RGBA) 80 - subright := c.Img.SubImage(image.Rect(mid, bounds.Min.Y, bounds.Max.X, bounds.Max.Y)).(*image.RGBA) 81 - return &Card{Img: subleft, Font: c.Font, Width: subleft.Bounds().Dx(), Height: subleft.Bounds().Dy()}, 82 - &Card{Img: subright, Font: c.Font, Width: subright.Bounds().Dx(), Height: subright.Bounds().Dy()} 83 - } 84 - mid := (bounds.Dy() * percentage / 100) + bounds.Min.Y 85 - subtop := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, mid)).(*image.RGBA) 86 - subbottom := c.Img.SubImage(image.Rect(bounds.Min.X, mid, bounds.Max.X, bounds.Max.Y)).(*image.RGBA) 87 - return &Card{Img: subtop, Font: c.Font, Width: subtop.Bounds().Dx(), Height: subtop.Bounds().Dy()}, 88 - &Card{Img: subbottom, Font: c.Font, Width: subbottom.Bounds().Dx(), Height: subbottom.Bounds().Dy()} 89 - } 90 - 91 - // SetMargin sets the margins for the card 92 - func (c *Card) SetMargin(margin int) { 93 - c.Margin = margin 94 - } 95 - 96 - type ( 97 - VAlign int64 98 - HAlign int64 99 - ) 100 - 101 - const ( 102 - Top VAlign = iota 103 - Middle 104 - Bottom 105 - ) 106 - 107 - const ( 108 - Left HAlign = iota 109 - Center 110 - Right 111 - ) 112 - 113 - // DrawText draws text within the card, respecting margins and alignment 114 - func (c *Card) DrawText(text string, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) ([]string, error) { 115 - ft := freetype.NewContext() 116 - ft.SetDPI(72) 117 - ft.SetFont(c.Font) 118 - ft.SetFontSize(sizePt) 119 - ft.SetClip(c.Img.Bounds()) 120 - ft.SetDst(c.Img) 121 - ft.SetSrc(image.NewUniform(textColor)) 122 - 123 - face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72}) 124 - fontHeight := ft.PointToFixed(sizePt).Ceil() 125 - 126 - bounds := c.Img.Bounds() 127 - bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 128 - boxWidth, boxHeight := bounds.Size().X, bounds.Size().Y 129 - // draw.Draw(c.Img, bounds, image.NewUniform(color.Gray{128}), image.Point{}, draw.Src) // Debug draw box 130 - 131 - // Try to apply wrapping to this text; we'll find the most text that will fit into one line, record that line, move 132 - // on. We precalculate each line before drawing so that we can support valign="middle" correctly which requires 133 - // knowing the total height, which is related to how many lines we'll have. 134 - lines := make([]string, 0) 135 - textWords := strings.Split(text, " ") 136 - currentLine := "" 137 - heightTotal := 0 138 - 139 - for { 140 - if len(textWords) == 0 { 141 - // Ran out of words. 142 - if currentLine != "" { 143 - heightTotal += fontHeight 144 - lines = append(lines, currentLine) 145 - } 146 - break 147 - } 148 - 149 - nextWord := textWords[0] 150 - proposedLine := currentLine 151 - if proposedLine != "" { 152 - proposedLine += " " 153 - } 154 - proposedLine += nextWord 155 - 156 - proposedLineWidth := font.MeasureString(face, proposedLine) 157 - if proposedLineWidth.Ceil() > boxWidth { 158 - // no, proposed line is too big; we'll use the last "currentLine" 159 - heightTotal += fontHeight 160 - if currentLine != "" { 161 - lines = append(lines, currentLine) 162 - currentLine = "" 163 - // leave nextWord in textWords and keep going 164 - } else { 165 - // just nextWord by itself doesn't fit on a line; well, we can't skip it, but we'll consume it 166 - // regardless as a line by itself. It will be clipped by the drawing routine. 167 - lines = append(lines, nextWord) 168 - textWords = textWords[1:] 169 - } 170 - } else { 171 - // yes, it will fit 172 - currentLine = proposedLine 173 - textWords = textWords[1:] 174 - } 175 - } 176 - 177 - textY := 0 178 - switch valign { 179 - case Top: 180 - textY = fontHeight 181 - case Bottom: 182 - textY = boxHeight - heightTotal + fontHeight 183 - case Middle: 184 - textY = ((boxHeight - heightTotal) / 2) + fontHeight 185 - } 186 - 187 - for _, line := range lines { 188 - lineWidth := font.MeasureString(face, line) 189 - 190 - textX := 0 191 - switch halign { 192 - case Left: 193 - textX = 0 194 - case Right: 195 - textX = boxWidth - lineWidth.Ceil() 196 - case Center: 197 - textX = (boxWidth - lineWidth.Ceil()) / 2 198 - } 199 - 200 - pt := freetype.Pt(bounds.Min.X+textX, bounds.Min.Y+textY) 201 - _, err := ft.DrawString(line, pt) 202 - if err != nil { 203 - return nil, err 204 - } 205 - 206 - textY += fontHeight 207 - } 208 - 209 - return lines, nil 210 - } 211 - 212 - // DrawTextAt draws text at a specific position with the given alignment 213 - func (c *Card) DrawTextAt(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) error { 214 - _, err := c.DrawTextAtWithWidth(text, x, y, textColor, sizePt, valign, halign) 215 - return err 216 - } 217 - 218 - // DrawTextAtWithWidth draws text at a specific position and returns the text width 219 - func (c *Card) DrawTextAtWithWidth(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) { 220 - ft := freetype.NewContext() 221 - ft.SetDPI(72) 222 - ft.SetFont(c.Font) 223 - ft.SetFontSize(sizePt) 224 - ft.SetClip(c.Img.Bounds()) 225 - ft.SetDst(c.Img) 226 - ft.SetSrc(image.NewUniform(textColor)) 227 - 228 - face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72}) 229 - fontHeight := ft.PointToFixed(sizePt).Ceil() 230 - lineWidth := font.MeasureString(face, text) 231 - textWidth := lineWidth.Ceil() 232 - 233 - // Adjust position based on alignment 234 - adjustedX := x 235 - adjustedY := y 236 - 237 - switch halign { 238 - case Left: 239 - // x is already at the left position 240 - case Right: 241 - adjustedX = x - textWidth 242 - case Center: 243 - adjustedX = x - textWidth/2 244 - } 245 - 246 - switch valign { 247 - case Top: 248 - adjustedY = y + fontHeight 249 - case Bottom: 250 - adjustedY = y 251 - case Middle: 252 - adjustedY = y + fontHeight/2 253 - } 254 - 255 - pt := freetype.Pt(adjustedX, adjustedY) 256 - _, err := ft.DrawString(text, pt) 257 - return textWidth, err 258 - } 259 - 260 - func (c *Card) FontHeight(sizePt float64) int { 261 - ft := freetype.NewContext() 262 - ft.SetDPI(72) 263 - ft.SetFont(c.Font) 264 - ft.SetFontSize(sizePt) 265 - return ft.PointToFixed(sizePt).Ceil() 266 - } 267 - 268 - func (c *Card) TextWidth(text string, sizePt float64) int { 269 - face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72}) 270 - lineWidth := font.MeasureString(face, text) 271 - textWidth := lineWidth.Ceil() 272 - return textWidth 273 - } 274 - 275 - // DrawBoldText draws bold text by rendering multiple times with slight offsets 276 - func (c *Card) DrawBoldText(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) { 277 - // Draw the text multiple times with slight offsets to create bold effect 278 - offsets := []struct{ dx, dy int }{ 279 - {0, 0}, // original 280 - {1, 0}, // right 281 - {0, 1}, // down 282 - {1, 1}, // diagonal 283 - } 284 - 285 - var width int 286 - for _, offset := range offsets { 287 - w, err := c.DrawTextAtWithWidth(text, x+offset.dx, y+offset.dy, textColor, sizePt, valign, halign) 288 - if err != nil { 289 - return 0, err 290 - } 291 - if width == 0 { 292 - width = w 293 - } 294 - } 295 - return width, nil 296 - } 297 - 298 - func BuildSVGIconFromData(svgData []byte, iconColor color.Color) (*oksvg.SvgIcon, error) { 299 - // Convert color to hex string for SVG 300 - rgba, isRGBA := iconColor.(color.RGBA) 301 - if !isRGBA { 302 - r, g, b, a := iconColor.RGBA() 303 - rgba = color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), uint8(a >> 8)} 304 - } 305 - colorHex := fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B) 306 - 307 - // Replace currentColor with our desired color in the SVG 308 - svgString := string(svgData) 309 - svgString = strings.ReplaceAll(svgString, "currentColor", colorHex) 310 - 311 - // Make the stroke thicker 312 - svgString = strings.ReplaceAll(svgString, `stroke-width="2"`, `stroke-width="3"`) 313 - 314 - // Parse SVG 315 - icon, err := oksvg.ReadIconStream(strings.NewReader(svgString)) 316 - if err != nil { 317 - return nil, fmt.Errorf("failed to parse SVG: %w", err) 318 - } 319 - 320 - return icon, nil 321 - } 322 - 323 - func BuildSVGIconFromPath(svgPath string, iconColor color.Color) (*oksvg.SvgIcon, error) { 324 - svgData, err := pages.Files.ReadFile(svgPath) 325 - if err != nil { 326 - return nil, fmt.Errorf("failed to read SVG file %s: %w", svgPath, err) 327 - } 328 - 329 - icon, err := BuildSVGIconFromData(svgData, iconColor) 330 - if err != nil { 331 - return nil, fmt.Errorf("failed to build SVG icon %s: %w", svgPath, err) 332 - } 333 - 334 - return icon, nil 335 - } 336 - 337 - func BuildLucideIcon(name string, iconColor color.Color) (*oksvg.SvgIcon, error) { 338 - return BuildSVGIconFromPath(fmt.Sprintf("static/icons/%s.svg", name), iconColor) 339 - } 340 - 341 - func (c *Card) DrawLucideIcon(name string, x, y, size int, iconColor color.Color) error { 342 - icon, err := BuildSVGIconFromPath(fmt.Sprintf("static/icons/%s.svg", name), iconColor) 343 - if err != nil { 344 - return err 345 - } 346 - 347 - c.DrawSVGIcon(icon, x, y, size) 348 - 349 - return nil 350 - } 351 - 352 - func (c *Card) DrawDolly(x, y, size int, iconColor color.Color) error { 353 - tpl, err := template.New("dolly"). 354 - ParseFS(pages.Files, "templates/fragments/dolly/logo.html") 355 - if err != nil { 356 - return fmt.Errorf("failed to read dolly template: %w", err) 357 - } 358 - 359 - var svgData bytes.Buffer 360 - if err = tpl.ExecuteTemplate(&svgData, "fragments/dolly/logo", nil); err != nil { 361 - return fmt.Errorf("failed to execute dolly template: %w", err) 362 - } 363 - 364 - icon, err := BuildSVGIconFromData(svgData.Bytes(), iconColor) 365 - if err != nil { 366 - return err 367 - } 368 - 369 - c.DrawSVGIcon(icon, x, y, size) 370 - 371 - return nil 372 - } 373 - 374 - // DrawSVGIcon draws an SVG icon from the embedded files at the specified position 375 - func (c *Card) DrawSVGIcon(icon *oksvg.SvgIcon, x, y, size int) { 376 - // Set the icon size 377 - w, h := float64(size), float64(size) 378 - icon.SetTarget(0, 0, w, h) 379 - 380 - // Create a temporary RGBA image for the icon 381 - iconImg := image.NewRGBA(image.Rect(0, 0, size, size)) 382 - 383 - // Create scanner and rasterizer 384 - scanner := rasterx.NewScannerGV(size, size, iconImg, iconImg.Bounds()) 385 - raster := rasterx.NewDasher(size, size, scanner) 386 - 387 - // Draw the icon 388 - icon.Draw(raster, 1.0) 389 - 390 - // Draw the icon onto the card at the specified position 391 - bounds := c.Img.Bounds() 392 - destRect := image.Rect(x, y, x+size, y+size) 393 - 394 - // Make sure we don't draw outside the card bounds 395 - if destRect.Max.X > bounds.Max.X { 396 - destRect.Max.X = bounds.Max.X 397 - } 398 - if destRect.Max.Y > bounds.Max.Y { 399 - destRect.Max.Y = bounds.Max.Y 400 - } 401 - 402 - draw.Draw(c.Img, destRect, iconImg, image.Point{}, draw.Over) 403 - } 404 - 405 - // DrawImage fills the card with an image, scaled to maintain the original aspect ratio and centered with respect to the non-filled dimension 406 - func (c *Card) DrawImage(img image.Image) { 407 - bounds := c.Img.Bounds() 408 - targetRect := image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 409 - srcBounds := img.Bounds() 410 - srcAspect := float64(srcBounds.Dx()) / float64(srcBounds.Dy()) 411 - targetAspect := float64(targetRect.Dx()) / float64(targetRect.Dy()) 412 - 413 - var scale float64 414 - if srcAspect > targetAspect { 415 - // Image is wider than target, scale by width 416 - scale = float64(targetRect.Dx()) / float64(srcBounds.Dx()) 417 - } else { 418 - // Image is taller or equal, scale by height 419 - scale = float64(targetRect.Dy()) / float64(srcBounds.Dy()) 420 - } 421 - 422 - newWidth := int(math.Round(float64(srcBounds.Dx()) * scale)) 423 - newHeight := int(math.Round(float64(srcBounds.Dy()) * scale)) 424 - 425 - // Center the image within the target rectangle 426 - offsetX := (targetRect.Dx() - newWidth) / 2 427 - offsetY := (targetRect.Dy() - newHeight) / 2 428 - 429 - scaledRect := image.Rect(targetRect.Min.X+offsetX, targetRect.Min.Y+offsetY, targetRect.Min.X+offsetX+newWidth, targetRect.Min.Y+offsetY+newHeight) 430 - draw.CatmullRom.Scale(c.Img, scaledRect, img, srcBounds, draw.Over, nil) 431 - } 432 - 433 - func fallbackImage() image.Image { 434 - // can't usage image.Uniform(color.White) because it's infinitely sized causing a panic in the scaler in DrawImage 435 - img := image.NewRGBA(image.Rect(0, 0, 1, 1)) 436 - img.Set(0, 0, color.White) 437 - return img 438 - } 439 - 440 - // As defensively as possible, attempt to load an image from a presumed external and untrusted URL 441 - func (c *Card) fetchExternalImage(url string) (image.Image, bool) { 442 - // Use a short timeout; in the event of any failure we'll be logging and returning a placeholder, but we don't want 443 - // this rendering process to be slowed down 444 - client := &http.Client{ 445 - Timeout: 1 * time.Second, // 1 second timeout 446 - } 447 - 448 - resp, err := client.Get(url) 449 - if err != nil { 450 - log.Printf("error when fetching external image from %s: %v", url, err) 451 - return nil, false 452 - } 453 - defer resp.Body.Close() 454 - 455 - if resp.StatusCode != http.StatusOK { 456 - log.Printf("non-OK error code when fetching external image from %s: %s", url, resp.Status) 457 - return nil, false 458 - } 459 - 460 - contentType := resp.Header.Get("Content-Type") 461 - 462 - body := resp.Body 463 - bodyBytes, err := io.ReadAll(body) 464 - if err != nil { 465 - log.Printf("error when fetching external image from %s: %v", url, err) 466 - return nil, false 467 - } 468 - 469 - // Handle SVG separately 470 - if contentType == "image/svg+xml" || strings.HasSuffix(url, ".svg") { 471 - return convertSVGToPNG(bodyBytes) 472 - } 473 - 474 - // Support content types are in-sync with the allowed custom avatar file types 475 - if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/gif" && contentType != "image/webp" { 476 - log.Printf("fetching external image returned unsupported Content-Type which was ignored: %s", contentType) 477 - return nil, false 478 - } 479 - 480 - bodyBuffer := bytes.NewReader(bodyBytes) 481 - _, imgType, err := image.DecodeConfig(bodyBuffer) 482 - if err != nil { 483 - log.Printf("error when decoding external image from %s: %v", url, err) 484 - return nil, false 485 - } 486 - 487 - // Verify that we have a match between actual data understood in the image body and the reported Content-Type 488 - if (contentType == "image/png" && imgType != "png") || 489 - (contentType == "image/jpeg" && imgType != "jpeg") || 490 - (contentType == "image/gif" && imgType != "gif") || 491 - (contentType == "image/webp" && imgType != "webp") { 492 - log.Printf("while fetching external image, mismatched image body (%s) and Content-Type (%s)", imgType, contentType) 493 - return nil, false 494 - } 495 - 496 - _, err = bodyBuffer.Seek(0, io.SeekStart) // reset for actual decode 497 - if err != nil { 498 - log.Printf("error w/ bodyBuffer.Seek") 499 - return nil, false 500 - } 501 - img, _, err := image.Decode(bodyBuffer) 502 - if err != nil { 503 - log.Printf("error when decoding external image from %s: %v", url, err) 504 - return nil, false 505 - } 506 - 507 - return img, true 508 - } 509 - 510 - // convertSVGToPNG converts SVG data to a PNG image 511 - func convertSVGToPNG(svgData []byte) (image.Image, bool) { 512 - // Parse the SVG 513 - icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData)) 514 - if err != nil { 515 - log.Printf("error parsing SVG: %v", err) 516 - return nil, false 517 - } 518 - 519 - // Set a reasonable size for the rasterized image 520 - width := 256 521 - height := 256 522 - icon.SetTarget(0, 0, float64(width), float64(height)) 523 - 524 - // Create an image to draw on 525 - rgba := image.NewRGBA(image.Rect(0, 0, width, height)) 526 - 527 - // Fill with white background 528 - draw.Draw(rgba, rgba.Bounds(), &image.Uniform{color.White}, image.Point{}, draw.Src) 529 - 530 - // Create a scanner and rasterize the SVG 531 - scanner := rasterx.NewScannerGV(width, height, rgba, rgba.Bounds()) 532 - raster := rasterx.NewDasher(width, height, scanner) 533 - 534 - icon.Draw(raster, 1.0) 535 - 536 - return rgba, true 537 - } 538 - 539 - func (c *Card) DrawExternalImage(url string) { 540 - image, ok := c.fetchExternalImage(url) 541 - if !ok { 542 - image = fallbackImage() 543 - } 544 - c.DrawImage(image) 545 - } 546 - 547 - // DrawCircularExternalImage draws an external image as a circle at the specified position 548 - func (c *Card) DrawCircularExternalImage(url string, x, y, size int) error { 549 - img, ok := c.fetchExternalImage(url) 550 - if !ok { 551 - img = fallbackImage() 552 - } 553 - 554 - // Create a circular mask 555 - circle := image.NewRGBA(image.Rect(0, 0, size, size)) 556 - center := size / 2 557 - radius := float64(size / 2) 558 - 559 - // Scale the source image to fit the circle 560 - srcBounds := img.Bounds() 561 - scaledImg := image.NewRGBA(image.Rect(0, 0, size, size)) 562 - draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil) 563 - 564 - // Draw the image with circular clipping 565 - for cy := range size { 566 - for cx := range size { 567 - // Calculate distance from center 568 - dx := float64(cx - center) 569 - dy := float64(cy - center) 570 - distance := math.Sqrt(dx*dx + dy*dy) 571 - 572 - // Only draw pixels within the circle 573 - if distance <= radius { 574 - circle.Set(cx, cy, scaledImg.At(cx, cy)) 575 - } 576 - } 577 - } 578 - 579 - // Draw the circle onto the card 580 - bounds := c.Img.Bounds() 581 - destRect := image.Rect(x, y, x+size, y+size) 582 - 583 - // Make sure we don't draw outside the card bounds 584 - if destRect.Max.X > bounds.Max.X { 585 - destRect.Max.X = bounds.Max.X 586 - } 587 - if destRect.Max.Y > bounds.Max.Y { 588 - destRect.Max.Y = bounds.Max.Y 589 - } 590 - 591 - draw.Draw(c.Img, destRect, circle, image.Point{}, draw.Over) 592 - 593 - return nil 594 - } 595 - 596 - // DrawRect draws a rect with the given color 597 - func (c *Card) DrawRect(startX, startY, endX, endY int, color color.Color) { 598 - draw.Draw(c.Img, image.Rect(startX, startY, endX, endY), &image.Uniform{color}, image.Point{}, draw.Src) 599 - } 600 - 601 - // drawRoundedRect draws a filled rounded rectangle on the given card 602 - func (card *Card) DrawRoundedRect(x, y, width, height, cornerRadius int, fillColor color.RGBA) { 603 - cardBounds := card.Img.Bounds() 604 - for py := y; py < y+height; py++ { 605 - for px := x; px < x+width; px++ { 606 - // calculate distance from corners 607 - dx := 0 608 - dy := 0 609 - 610 - // check which corner region we're in 611 - if px < x+cornerRadius && py < y+cornerRadius { 612 - // top-left corner 613 - dx = x + cornerRadius - px 614 - dy = y + cornerRadius - py 615 - } else if px >= x+width-cornerRadius && py < y+cornerRadius { 616 - // top-right corner 617 - dx = px - (x + width - cornerRadius - 1) 618 - dy = y + cornerRadius - py 619 - } else if px < x+cornerRadius && py >= y+height-cornerRadius { 620 - // bottom-left corner 621 - dx = x + cornerRadius - px 622 - dy = py - (y + height - cornerRadius - 1) 623 - } else if px >= x+width-cornerRadius && py >= y+height-cornerRadius { 624 - // Bottom-right corner 625 - dx = px - (x + width - cornerRadius - 1) 626 - dy = py - (y + height - cornerRadius - 1) 627 - } 628 - 629 - // if we're in a corner, check if we're within the radius 630 - inCorner := (dx > 0 || dy > 0) 631 - withinRadius := dx*dx+dy*dy <= cornerRadius*cornerRadius 632 - 633 - // draw pixel if not in corner, or in corner and within radius 634 - // check bounds relative to the card's image bounds 635 - if (!inCorner || withinRadius) && px >= 0 && px < cardBounds.Dx() && py >= 0 && py < cardBounds.Dy() { 636 - card.Img.Set(px+cardBounds.Min.X, py+cardBounds.Min.Y, fillColor) 637 - } 638 - } 639 - } 640 - }
+117
appview/ogcard/client.go
··· 1 + package ogcard 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "io" 9 + "net/http" 10 + "time" 11 + ) 12 + 13 + type Client struct { 14 + host string 15 + client *http.Client 16 + } 17 + 18 + func NewClient(host string) *Client { 19 + return &Client{ 20 + host: host, 21 + client: &http.Client{ 22 + Timeout: 10 * time.Second, 23 + }, 24 + } 25 + } 26 + 27 + type LabelData struct { 28 + Name string `json:"name"` 29 + Color string `json:"color"` 30 + } 31 + 32 + type LanguageData struct { 33 + Color string `json:"color"` 34 + Percentage float32 `json:"percentage"` 35 + } 36 + 37 + type RepositoryCardPayload struct { 38 + Type string `json:"type"` 39 + RepoName string `json:"repoName"` 40 + OwnerHandle string `json:"ownerHandle"` 41 + Stars int `json:"stars"` 42 + Pulls int `json:"pulls"` 43 + Issues int `json:"issues"` 44 + CreatedAt string `json:"createdAt"` 45 + AvatarUrl string `json:"avatarUrl"` 46 + Languages []LanguageData `json:"languages"` 47 + } 48 + 49 + type IssueCardPayload struct { 50 + Type string `json:"type"` 51 + RepoName string `json:"repoName"` 52 + OwnerHandle string `json:"ownerHandle"` 53 + AvatarUrl string `json:"avatarUrl"` 54 + Title string `json:"title"` 55 + IssueNumber int `json:"issueNumber"` 56 + Status string `json:"status"` 57 + Labels []LabelData `json:"labels"` 58 + CommentCount int `json:"commentCount"` 59 + ReactionCount int `json:"reactionCount"` 60 + CreatedAt string `json:"createdAt"` 61 + } 62 + 63 + type PullRequestCardPayload struct { 64 + Type string `json:"type"` 65 + RepoName string `json:"repoName"` 66 + OwnerHandle string `json:"ownerHandle"` 67 + AvatarUrl string `json:"avatarUrl"` 68 + Title string `json:"title"` 69 + PullRequestNumber int `json:"pullRequestNumber"` 70 + Status string `json:"status"` 71 + FilesChanged int `json:"filesChanged"` 72 + Additions int `json:"additions"` 73 + Deletions int `json:"deletions"` 74 + Rounds int `json:"rounds"` 75 + CommentCount int `json:"commentCount"` 76 + ReactionCount int `json:"reactionCount"` 77 + CreatedAt string `json:"createdAt"` 78 + } 79 + 80 + func (c *Client) doRequest(ctx context.Context, path string, payload any) ([]byte, error) { 81 + body, err := json.Marshal(payload) 82 + if err != nil { 83 + return nil, fmt.Errorf("marshal payload: %w", err) 84 + } 85 + 86 + url := fmt.Sprintf("%s/%s", c.host, path) 87 + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) 88 + if err != nil { 89 + return nil, fmt.Errorf("create request: %w", err) 90 + } 91 + req.Header.Set("Content-Type", "application/json") 92 + 93 + resp, err := c.client.Do(req) 94 + if err != nil { 95 + return nil, fmt.Errorf("do request: %w", err) 96 + } 97 + defer resp.Body.Close() 98 + 99 + if resp.StatusCode != http.StatusOK { 100 + respBody, _ := io.ReadAll(resp.Body) 101 + return nil, fmt.Errorf("unexpected status: %d, body: %s", resp.StatusCode, string(respBody)) 102 + } 103 + 104 + return io.ReadAll(resp.Body) 105 + } 106 + 107 + func (c *Client) RenderRepositoryCard(ctx context.Context, payload RepositoryCardPayload) ([]byte, error) { 108 + return c.doRequest(ctx, "repository", payload) 109 + } 110 + 111 + func (c *Client) RenderIssueCard(ctx context.Context, payload IssueCardPayload) ([]byte, error) { 112 + return c.doRequest(ctx, "issue", payload) 113 + } 114 + 115 + func (c *Client) RenderPullRequestCard(ctx context.Context, payload PullRequestCardPayload) ([]byte, error) { 116 + return c.doRequest(ctx, "pullRequest", payload) 117 + }
+4
appview/ogcard/knip.json
··· 1 + { 2 + "$schema": "https://unpkg.com/knip@5/schema.json", 3 + "tags": ["-lintignore"] 4 + }
+34
appview/ogcard/package.json
··· 1 + { 2 + "name": "@tangled/ogcard-worker", 3 + "version": "1.0.0", 4 + "private": true, 5 + "type": "module", 6 + "workspaces": [ 7 + "packages/runtime" 8 + ], 9 + "scripts": { 10 + "dev": "wrangler dev", 11 + "deploy": "wrangler deploy", 12 + "typecheck": "tsc --noEmit", 13 + "test": "bun test", 14 + "knip": "knip" 15 + }, 16 + "dependencies": { 17 + "@fontsource/inter": "^5.2.8", 18 + "@resvg/resvg-wasm": "^2.6.2", 19 + "@tangled/ogcard-runtime": "*", 20 + "lucide-static": "^0.577.0", 21 + "preact": "^10.29.0", 22 + "satori": "0.25.0", 23 + "zod": "^4.3.6" 24 + }, 25 + "devDependencies": { 26 + "@cloudflare/workers-types": "^4.20260317.1", 27 + "@types/bun": "^1.3.11", 28 + "@types/node": "^25.5.0", 29 + "knip": "^6.0.1", 30 + "tsx": "^4.21.0", 31 + "typescript": "^5.9.3", 32 + "wrangler": "^4.75.0" 33 + } 34 + }
+88
appview/ogcard/packages/runtime/index.ts
··· 1 + /** 2 + * Bun/Node.js runtime implementation 3 + * Uses filesystem APIs to load WASM and fonts 4 + */ 5 + import { readFile } from "node:fs/promises"; 6 + import { createRequire } from "node:module"; 7 + import type { FontData, SatoriFn, ResvgClass } from "./types"; 8 + 9 + const require = createRequire(import.meta.url); 10 + 11 + let satoriFn: SatoriFn | null = null; 12 + let resvgInitialized = false; 13 + let Resvg: ResvgClass | null = null; 14 + 15 + export async function initSatori(): Promise<SatoriFn> { 16 + if (satoriFn) return satoriFn; 17 + 18 + const { default: satori } = await import("satori"); 19 + satoriFn = satori; 20 + 21 + return satoriFn; 22 + } 23 + 24 + export async function initResvg(): Promise<ResvgClass> { 25 + if (resvgInitialized) return Resvg!; 26 + 27 + const { Resvg: ResvgClass, initWasm } = await import("@resvg/resvg-wasm"); 28 + const wasmPath = require.resolve("@resvg/resvg-wasm/index_bg.wasm"); 29 + const wasmBuffer = await readFile(wasmPath); 30 + await initWasm(wasmBuffer); 31 + 32 + Resvg = ResvgClass; 33 + resvgInitialized = true; 34 + return Resvg; 35 + } 36 + 37 + export async function loadFonts(): Promise<FontData[]> { 38 + // In Bun, .woff imports return a Module object with `default` being the file path 39 + const inter400Module = await import( 40 + "@fontsource/inter/files/inter-latin-400-normal.woff" 41 + ); 42 + const inter500Module = await import( 43 + "@fontsource/inter/files/inter-latin-500-normal.woff" 44 + ); 45 + const inter600Module = await import( 46 + "@fontsource/inter/files/inter-latin-600-normal.woff" 47 + ); 48 + 49 + const inter400Path = (inter400Module as { default: string }).default; 50 + const inter500Path = (inter500Module as { default: string }).default; 51 + const inter600Path = (inter600Module as { default: string }).default; 52 + 53 + const [buf400, buf500, buf600] = await Promise.all([ 54 + readFile(inter400Path), 55 + readFile(inter500Path), 56 + readFile(inter600Path), 57 + ]); 58 + 59 + return [ 60 + { 61 + name: "Inter", 62 + data: buf400.buffer.slice( 63 + buf400.byteOffset, 64 + buf400.byteOffset + buf400.byteLength, 65 + ), 66 + weight: 400, 67 + style: "normal", 68 + }, 69 + { 70 + name: "Inter", 71 + data: buf500.buffer.slice( 72 + buf500.byteOffset, 73 + buf500.byteOffset + buf500.byteLength, 74 + ), 75 + weight: 500, 76 + style: "normal", 77 + }, 78 + { 79 + name: "Inter", 80 + data: buf600.buffer.slice( 81 + buf600.byteOffset, 82 + buf600.byteOffset + buf600.byteLength, 83 + ), 84 + weight: 600, 85 + style: "normal", 86 + }, 87 + ]; 88 + }
+12
appview/ogcard/packages/runtime/package.json
··· 1 + { 2 + "name": "@tangled/ogcard-runtime", 3 + "version": "1.0.0", 4 + "private": true, 5 + "type": "module", 6 + "exports": { 7 + "workerd": "./workerd.ts", 8 + "bun": "./index.ts", 9 + "default": "./index.ts" 10 + }, 11 + "types": "./types.ts" 12 + }
+10
appview/ogcard/packages/runtime/types.ts
··· 1 + export interface FontData { 2 + name: string; 3 + data: ArrayBuffer; 4 + weight: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900; 5 + style: "normal" | "italic"; 6 + } 7 + 8 + export type SatoriFn = typeof import("satori").default; 9 + 10 + export type ResvgClass = typeof import("@resvg/resvg-wasm").Resvg;
+60
appview/ogcard/packages/runtime/workerd.ts
··· 1 + /** 2 + * Cloudflare Workers runtime implementation 3 + * Uses ?module suffix for WASM imports as required by Wrangler 4 + */ 5 + import type { FontData, SatoriFn, ResvgClass } from "./types"; 6 + 7 + import inter400 from "@fontsource/inter/files/inter-latin-400-normal.woff"; 8 + import inter500 from "@fontsource/inter/files/inter-latin-500-normal.woff"; 9 + import inter600 from "@fontsource/inter/files/inter-latin-600-normal.woff"; 10 + 11 + let satoriFn: SatoriFn | null = null; 12 + let resvgInitialized = false; 13 + let Resvg: ResvgClass | null = null; 14 + 15 + export async function initSatori(): Promise<SatoriFn> { 16 + if (satoriFn) return satoriFn; 17 + 18 + const { default: satori, init } = await import("satori/standalone"); 19 + const wasmModule = (await import("satori/yoga.wasm?module")).default; 20 + await init(wasmModule); 21 + satoriFn = satori; 22 + 23 + return satoriFn; 24 + } 25 + 26 + export async function initResvg(): Promise<ResvgClass> { 27 + if (resvgInitialized) return Resvg!; 28 + 29 + const { Resvg: ResvgClass, initWasm } = await import("@resvg/resvg-wasm"); 30 + const wasmModule = (await import("@resvg/resvg-wasm/index_bg.wasm?module")) 31 + .default; 32 + await initWasm(wasmModule); 33 + 34 + Resvg = ResvgClass; 35 + resvgInitialized = true; 36 + return Resvg; 37 + } 38 + 39 + export async function loadFonts(): Promise<FontData[]> { 40 + return [ 41 + { 42 + name: "Inter", 43 + data: inter400 as ArrayBuffer, 44 + weight: 400, 45 + style: "normal", 46 + }, 47 + { 48 + name: "Inter", 49 + data: inter500 as ArrayBuffer, 50 + weight: 500, 51 + style: "normal", 52 + }, 53 + { 54 + name: "Inter", 55 + data: inter600 as ArrayBuffer, 56 + weight: 600, 57 + style: "normal", 58 + }, 59 + ]; 60 + }
appview/ogcard/src/__tests__/assets/avatar.jpg

This is a binary file and will not be displayed.

+87
appview/ogcard/src/__tests__/fixtures.ts
··· 1 + import type { 2 + RepositoryCardData, 3 + IssueCardData, 4 + PullRequestCardData, 5 + } from "../validation"; 6 + 7 + const LONG_TITLE = 8 + "fix critical memory leak in WebSocket connection handler that causes server crashes under high load conditions in production environments"; 9 + 10 + export const createRepoData = (avatarUrl: string): RepositoryCardData => ({ 11 + type: "repository", 12 + repoName: "core", 13 + ownerHandle: "tangled.org", 14 + stars: 746, 15 + pulls: 82, 16 + issues: 176, 17 + createdAt: "2026-01-29T00:00:00Z", 18 + avatarUrl, 19 + languages: [ 20 + { color: "#00ADD8", percentage: 50 }, 21 + { color: "#e34c26", percentage: 30 }, 22 + { color: "#7e7eff", percentage: 10 }, 23 + { color: "#663399", percentage: 5 }, 24 + { color: "#f1e05a", percentage: 5 }, 25 + ], 26 + }); 27 + 28 + export const createIssueData = ( 29 + avatarUrl: string, 30 + overrides?: Partial<IssueCardData>, 31 + ): IssueCardData => ({ 32 + type: "issue", 33 + repoName: "core", 34 + ownerHandle: "tangled.org", 35 + avatarUrl, 36 + title: "feature request: sync fork button", 37 + issueNumber: 8, 38 + status: "open", 39 + labels: [ 40 + { name: "feature", color: "#4639d6" }, 41 + { name: "help-wanted", color: "#008672" }, 42 + { name: "enhancement", color: "#0052cc" }, 43 + ], 44 + commentCount: 12, 45 + reactionCount: 5, 46 + createdAt: "2026-01-29T00:00:00Z", 47 + ...overrides, 48 + }); 49 + 50 + export const createPullRequestData = ( 51 + avatarUrl: string, 52 + overrides?: Partial<PullRequestCardData>, 53 + ): PullRequestCardData => ({ 54 + type: "pullRequest", 55 + repoName: "core", 56 + ownerHandle: "tangled.org", 57 + avatarUrl, 58 + title: "add author description to README.md", 59 + pullRequestNumber: 1, 60 + status: "open", 61 + filesChanged: 2, 62 + additions: 116, 63 + deletions: 59, 64 + rounds: 3, 65 + commentCount: 12, 66 + reactionCount: 31, 67 + createdAt: "2026-01-29T00:00:00Z", 68 + ...overrides, 69 + }); 70 + 71 + export const createLongTitleIssueData = ( 72 + avatarUrl: string, 73 + overrides?: Partial<IssueCardData>, 74 + ): IssueCardData => ({ 75 + ...createIssueData(avatarUrl), 76 + title: LONG_TITLE, 77 + ...overrides, 78 + }); 79 + 80 + export const createLongTitlePullRequestData = ( 81 + avatarUrl: string, 82 + overrides?: Partial<PullRequestCardData>, 83 + ): PullRequestCardData => ({ 84 + ...createPullRequestData(avatarUrl), 85 + title: LONG_TITLE, 86 + ...overrides, 87 + });
+132
appview/ogcard/src/__tests__/render.test.ts
··· 1 + import { test, describe, beforeAll } from "bun:test"; 2 + import { writeFileSync, mkdirSync, readFileSync } from "fs"; 3 + import { join } from "path"; 4 + import { h, type VNode } from "preact"; 5 + import { renderCard } from "../lib/render"; 6 + import { RepositoryCard } from "../components/cards/repository"; 7 + import { IssueCard } from "../components/cards/issue"; 8 + import { PullRequestCard } from "../components/cards/pull-request"; 9 + import { 10 + repositoryCardSchema, 11 + issueCardSchema, 12 + pullRequestCardSchema, 13 + } from "../validation"; 14 + import { 15 + createRepoData, 16 + createIssueData, 17 + createPullRequestData, 18 + createLongTitleIssueData, 19 + createLongTitlePullRequestData, 20 + } from "./fixtures"; 21 + 22 + const outputDir = join(process.cwd(), "output"); 23 + let avatarDataUri: string; 24 + 25 + const loadAvatar = (): string => { 26 + const avatarPath = join( 27 + process.cwd(), 28 + "src", 29 + "__tests__", 30 + "assets", 31 + "avatar.jpg", 32 + ); 33 + const avatarBase64 = readFileSync(avatarPath).toString("base64"); 34 + return `data:image/jpeg;base64,${avatarBase64}`; 35 + }; 36 + 37 + beforeAll(() => { 38 + mkdirSync(outputDir, { recursive: true }); 39 + avatarDataUri = loadAvatar(); 40 + }); 41 + 42 + const savePng = (filename: string, buffer: Uint8Array) => { 43 + writeFileSync(join(outputDir, filename), buffer); 44 + }; 45 + 46 + const renderAndSave = async <P>(component: VNode<P>, filename: string) => { 47 + const { png } = await renderCard(component as VNode); 48 + savePng(filename, png); 49 + }; 50 + 51 + describe("repository card", () => { 52 + test("renders repository card", async () => { 53 + const data = createRepoData(avatarDataUri); 54 + const validated = repositoryCardSchema.parse(data); 55 + await renderAndSave(h(RepositoryCard, validated), "repository-card.png"); 56 + }); 57 + }); 58 + 59 + describe("issue cards", () => { 60 + test("renders open issue", async () => { 61 + const data = createIssueData(avatarDataUri); 62 + const validated = issueCardSchema.parse(data); 63 + await renderAndSave(h(IssueCard, validated), "issue-card.png"); 64 + }); 65 + 66 + test("renders closed issue", async () => { 67 + const data = createIssueData(avatarDataUri, { 68 + issueNumber: 5, 69 + status: "closed", 70 + labels: [{ name: "wontfix", color: "#6a737d" }], 71 + reactionCount: 2, 72 + }); 73 + const validated = issueCardSchema.parse(data); 74 + await renderAndSave(h(IssueCard, validated), "issue-card-closed.png"); 75 + }); 76 + 77 + test("renders issue with long title", async () => { 78 + const data = createLongTitleIssueData(avatarDataUri, { 79 + issueNumber: 42, 80 + }); 81 + const validated = issueCardSchema.parse(data); 82 + await renderAndSave(h(IssueCard, validated), "issue-card-long-title.png"); 83 + }); 84 + }); 85 + 86 + describe("pull request cards", () => { 87 + test("renders open pull request", async () => { 88 + const data = createPullRequestData(avatarDataUri); 89 + const validated = pullRequestCardSchema.parse(data); 90 + await renderAndSave(h(PullRequestCard, validated), "pull-request-card.png"); 91 + }); 92 + 93 + test("renders merged pull request", async () => { 94 + const data = createPullRequestData(avatarDataUri, { 95 + pullRequestNumber: 2, 96 + status: "merged", 97 + title: "Implement OAuth2 authentication flow", 98 + filesChanged: 5, 99 + additions: 342, 100 + deletions: 28, 101 + }); 102 + const validated = pullRequestCardSchema.parse(data); 103 + await renderAndSave( 104 + h(PullRequestCard, validated), 105 + "pull-request-card-merged.png", 106 + ); 107 + }); 108 + 109 + test("renders closed pull request", async () => { 110 + const data = createPullRequestData(avatarDataUri, { 111 + pullRequestNumber: 3, 112 + status: "closed", 113 + title: "WIP: Experimental feature", 114 + }); 115 + const validated = pullRequestCardSchema.parse(data); 116 + await renderAndSave( 117 + h(PullRequestCard, validated), 118 + "pull-request-card-closed.png", 119 + ); 120 + }); 121 + 122 + test("renders pull request with long title", async () => { 123 + const data = createLongTitlePullRequestData(avatarDataUri, { 124 + pullRequestNumber: 42, 125 + }); 126 + const validated = pullRequestCardSchema.parse(data); 127 + await renderAndSave( 128 + h(PullRequestCard, validated), 129 + "pull-request-card-long-title.png", 130 + ); 131 + }); 132 + });
+52
appview/ogcard/src/components/cards/issue.tsx
··· 1 + import { Card, Row, Col } from "../shared/layout"; 2 + import { TangledLogo } from "../shared/logo"; 3 + import { IssueStatusBadge } from "../shared/status-badge"; 4 + import { CardHeader } from "../shared/card-header"; 5 + import { LabelList } from "../shared/label-pill"; 6 + import { FooterStats } from "../shared/footer-stats"; 7 + import { TYPOGRAPHY } from "../shared/constants"; 8 + import type { IssueCardData } from "../../validation"; 9 + 10 + export function IssueCard(data: IssueCardData) { 11 + return ( 12 + <Card style={{ justifyContent: "space-between" }}> 13 + <Col style={{ gap: 48 }}> 14 + <Col style={{ gap: 32 }}> 15 + <Row style={{ justifyContent: "space-between" }}> 16 + <CardHeader 17 + avatarUrl={data.avatarUrl} 18 + ownerHandle={data.ownerHandle} 19 + repoName={data.repoName} 20 + /> 21 + <IssueStatusBadge status={data.status} /> 22 + </Row> 23 + 24 + <div 25 + style={{ 26 + ...TYPOGRAPHY.title, 27 + color: "#000000", 28 + display: "block", 29 + lineClamp: `2 "... #${data.issueNumber}"`, 30 + }}> 31 + {data.title} 32 + </div> 33 + </Col> 34 + 35 + <LabelList labels={data.labels} /> 36 + </Col> 37 + 38 + <Row 39 + style={{ 40 + alignItems: "flex-end", 41 + justifyContent: "space-between", 42 + }}> 43 + <FooterStats 44 + createdAt={data.createdAt} 45 + reactionCount={data.reactionCount} 46 + commentCount={data.commentCount} 47 + /> 48 + <TangledLogo /> 49 + </Row> 50 + </Card> 51 + ); 52 + }
+137
appview/ogcard/src/components/cards/pull-request.tsx
··· 1 + import { Card, Row, Col } from "../shared/layout"; 2 + import { TangledLogo } from "../shared/logo"; 3 + import { StatusBadge } from "../shared/status-badge"; 4 + import { CardHeader } from "../shared/card-header"; 5 + import { FooterStats } from "../shared/footer-stats"; 6 + import { FileDiff, RefreshCw } from "../../icons/lucide"; 7 + import { COLORS, TYPOGRAPHY } from "../shared/constants"; 8 + import type { PullRequestCardData } from "../../validation"; 9 + 10 + interface FilesChangedPillProps { 11 + filesChanged: number; 12 + additions: number; 13 + deletions: number; 14 + } 15 + 16 + function FilesChangedPill({ 17 + filesChanged, 18 + additions, 19 + deletions, 20 + }: FilesChangedPillProps) { 21 + return ( 22 + <Row 23 + style={{ 24 + overflow: "hidden", 25 + borderRadius: 18, 26 + backgroundColor: "#fff", 27 + border: `4px solid ${COLORS.label.border}`, 28 + }}> 29 + <Row 30 + style={{ 31 + gap: 16, 32 + padding: "16px 28px", 33 + }}> 34 + <FileDiff size={34} color="#202020" /> 35 + <span style={{ ...TYPOGRAPHY.body, color: "#202020" }}> 36 + {filesChanged} files 37 + </span> 38 + </Row> 39 + <Row style={{ gap: 0 }}> 40 + <Row 41 + style={{ 42 + padding: "16px 10px 16px 11px", 43 + backgroundColor: COLORS.diff.additions.bg, 44 + }}> 45 + <span 46 + style={{ ...TYPOGRAPHY.body, color: COLORS.diff.additions.text }}> 47 + +{additions} 48 + </span> 49 + </Row> 50 + <Row 51 + style={{ 52 + padding: "16px 16px 16px 11px", 53 + backgroundColor: COLORS.diff.deletions.bg, 54 + }}> 55 + <span 56 + style={{ ...TYPOGRAPHY.body, color: COLORS.diff.deletions.text }}> 57 + -{deletions} 58 + </span> 59 + </Row> 60 + </Row> 61 + </Row> 62 + ); 63 + } 64 + 65 + interface MetricPillProps { 66 + value: number; 67 + label: string; 68 + } 69 + 70 + function RoundsPill({ value, label }: MetricPillProps) { 71 + return ( 72 + <Row 73 + style={{ 74 + gap: 16, 75 + padding: "16px 28px", 76 + borderRadius: 18, 77 + backgroundColor: "#fff", 78 + border: `4px solid ${COLORS.label.border}`, 79 + }}> 80 + <RefreshCw size={36} color="#202020" /> 81 + <span style={{ ...TYPOGRAPHY.body, color: "#202020" }}> 82 + {value} {label} 83 + </span> 84 + </Row> 85 + ); 86 + } 87 + 88 + export function PullRequestCard(data: PullRequestCardData) { 89 + return ( 90 + <Card style={{ justifyContent: "space-between" }}> 91 + <Col style={{ gap: 48 }}> 92 + <Col style={{ gap: 32 }}> 93 + <Row style={{ justifyContent: "space-between" }}> 94 + <CardHeader 95 + avatarUrl={data.avatarUrl} 96 + ownerHandle={data.ownerHandle} 97 + repoName={data.repoName} 98 + /> 99 + <StatusBadge status={data.status} /> 100 + </Row> 101 + 102 + <span 103 + style={{ 104 + ...TYPOGRAPHY.title, 105 + color: "#000000", 106 + display: "block", 107 + lineClamp: `2 "... #${data.pullRequestNumber}"`, 108 + }}> 109 + {data.title} 110 + </span> 111 + </Col> 112 + 113 + <Row style={{ gap: 16 }}> 114 + <FilesChangedPill 115 + filesChanged={data.filesChanged} 116 + additions={data.additions} 117 + deletions={data.deletions} 118 + /> 119 + <RoundsPill value={data.rounds} label="rounds" /> 120 + </Row> 121 + </Col> 122 + 123 + <Row 124 + style={{ 125 + alignItems: "flex-end", 126 + justifyContent: "space-between", 127 + }}> 128 + <FooterStats 129 + createdAt={data.createdAt} 130 + reactionCount={data.reactionCount} 131 + commentCount={data.commentCount} 132 + /> 133 + <TangledLogo /> 134 + </Row> 135 + </Card> 136 + ); 137 + }
+44
appview/ogcard/src/components/cards/repository.tsx
··· 1 + import { Card, Row, Col } from "../shared/layout"; 2 + import { Avatar } from "../shared/avatar"; 3 + import { LanguageCircles } from "../shared/language-circles"; 4 + import { Metrics } from "../shared/metrics"; 5 + import { TangledLogo } from "../shared/logo"; 6 + import { FooterStats } from "../shared/footer-stats"; 7 + import { TYPOGRAPHY } from "../shared/constants"; 8 + import type { RepositoryCardData } from "../../validation"; 9 + 10 + export function RepositoryCard(data: RepositoryCardData) { 11 + return ( 12 + <Card> 13 + <LanguageCircles languages={data.languages} /> 14 + 15 + <Col style={{ gap: 64 }}> 16 + <Col style={{ gap: 24 }}> 17 + <span style={{ ...TYPOGRAPHY.repoName, color: "#000000" }}> 18 + {data.repoName} 19 + </span> 20 + 21 + <Row style={{ gap: 16 }}> 22 + <Avatar src={data.avatarUrl} size={64} /> 23 + <span style={{ ...TYPOGRAPHY.ownerHandle, color: "#000000" }}> 24 + {data.ownerHandle} 25 + </span> 26 + </Row> 27 + </Col> 28 + 29 + <Metrics stars={data.stars} pulls={data.pulls} issues={data.issues} /> 30 + </Col> 31 + 32 + <Row 33 + style={{ 34 + alignItems: "flex-end", 35 + justifyContent: "space-between", 36 + flexGrow: 1, 37 + }}> 38 + <FooterStats createdAt={data.createdAt} /> 39 + 40 + <TangledLogo /> 41 + </Row> 42 + </Card> 43 + ); 44 + }
+31
appview/ogcard/src/components/shared/avatar.tsx
··· 1 + interface AvatarProps { 2 + src: string; 3 + size?: number; 4 + } 5 + 6 + export function Avatar({ src, size = 64 }: AvatarProps) { 7 + const avatarSrc = 8 + src.includes("avatar.tangled.sh") && !src.includes("format=") 9 + ? `${src}${src.includes("?") ? "&" : "?"}format=jpeg` 10 + : src; 11 + 12 + return ( 13 + <div 14 + style={{ 15 + width: size, 16 + height: size, 17 + borderRadius: size / 2, 18 + overflow: "hidden", 19 + display: "flex", 20 + alignItems: "center", 21 + justifyContent: "center", 22 + }}> 23 + <img 24 + src={avatarSrc} 25 + width={size} 26 + height={size} 27 + style={{ objectFit: "cover" }} 28 + /> 29 + </div> 30 + ); 31 + }
+24
appview/ogcard/src/components/shared/card-header.tsx
··· 1 + import { Row } from "./layout"; 2 + import { Avatar } from "./avatar"; 3 + import { TYPOGRAPHY } from "./constants"; 4 + 5 + interface CardHeaderProps { 6 + avatarUrl: string; 7 + ownerHandle: string; 8 + repoName: string; 9 + } 10 + 11 + export function CardHeader({ 12 + avatarUrl, 13 + ownerHandle, 14 + repoName, 15 + }: CardHeaderProps) { 16 + return ( 17 + <Row style={{ gap: 16 }}> 18 + <Avatar src={avatarUrl} size={64} /> 19 + <span style={{ ...TYPOGRAPHY.cardHeader, color: "#000000" }}> 20 + {ownerHandle} / {repoName} 21 + </span> 22 + </Row> 23 + ); 24 + }
+30
appview/ogcard/src/components/shared/constants.ts
··· 1 + export const COLORS = { 2 + text: "#000000", 3 + textSecondary: "#7D7D7D", 4 + icon: "#404040", 5 + status: { 6 + open: { bg: "#16A34A", text: "#ffffff" }, 7 + closed: { bg: "#1f2937", text: "#ffffff" }, 8 + merged: { bg: "#7C3AED", text: "#ffffff" }, 9 + }, 10 + label: { 11 + text: "#202020", 12 + border: "#E6E6E6", 13 + }, 14 + diff: { 15 + additions: { bg: "#dcfce7", text: "#15803d" }, 16 + deletions: { bg: "#fee2e2", text: "#b91c1c" }, 17 + }, 18 + } as const; 19 + 20 + export const TYPOGRAPHY = { 21 + title: { fontFamily: "Inter", fontSize: 64, fontWeight: 600 }, 22 + repoName: { fontFamily: "Inter", fontSize: 144, fontWeight: 600 }, 23 + ownerHandle: { fontFamily: "Inter", fontSize: 48, fontWeight: 500 }, 24 + cardHeader: { fontFamily: "Inter", fontSize: 48, fontWeight: 500 }, 25 + status: { fontFamily: "Inter", fontSize: 48, fontWeight: 500 }, 26 + metricValue: { fontFamily: "Inter", fontSize: 48, fontWeight: 500 }, 27 + body: { fontFamily: "Inter", fontSize: 36, fontWeight: 400 }, 28 + meta: { fontFamily: "Inter", fontSize: 32, fontWeight: 400 }, 29 + label: { fontFamily: "Inter", fontSize: 24, fontWeight: 400 }, 30 + } as const;
+33
appview/ogcard/src/components/shared/footer-stats.tsx
··· 1 + import { Row } from "./layout"; 2 + import { Calendar, MessageSquare, SmilePlus } from "../../icons/lucide"; 3 + import { StatItem } from "./stat-item"; 4 + 5 + interface FooterStatsProps { 6 + createdAt: string; 7 + reactionCount?: number; 8 + commentCount?: number; 9 + } 10 + 11 + export function FooterStats({ 12 + createdAt, 13 + reactionCount, 14 + commentCount, 15 + }: FooterStatsProps) { 16 + const formattedDate = new Intl.DateTimeFormat("en-GB", { 17 + day: "numeric", 18 + month: "short", 19 + year: "numeric", 20 + }).format(new Date(createdAt)); 21 + 22 + return ( 23 + <Row style={{ gap: 64 }}> 24 + <StatItem Icon={Calendar} value={formattedDate} /> 25 + {reactionCount ? ( 26 + <StatItem Icon={SmilePlus} value={reactionCount} /> 27 + ) : null} 28 + {commentCount ? ( 29 + <StatItem Icon={MessageSquare} value={commentCount} /> 30 + ) : null} 31 + </Row> 32 + ); 33 + }
+49
appview/ogcard/src/components/shared/label-pill.tsx
··· 1 + import { Row } from "./layout"; 2 + import { COLORS, TYPOGRAPHY } from "./constants"; 3 + 4 + interface LabelPillProps { 5 + name: string; 6 + color: string; 7 + } 8 + 9 + function LabelPill({ name, color }: LabelPillProps) { 10 + return ( 11 + <Row 12 + style={{ 13 + gap: 16, 14 + padding: "16px 28px", 15 + borderRadius: 18, 16 + backgroundColor: "#fff", 17 + border: `4px solid ${COLORS.label.border}`, 18 + }}> 19 + <div 20 + style={{ 21 + width: 24, 22 + height: 24, 23 + borderRadius: "50%", 24 + backgroundColor: color, 25 + }} 26 + /> 27 + <span style={{ ...TYPOGRAPHY.body, color: COLORS.label.text }}> 28 + {name} 29 + </span> 30 + </Row> 31 + ); 32 + } 33 + 34 + interface LabelListProps { 35 + labels: Array<{ name: string; color: string }>; 36 + max?: number; 37 + } 38 + 39 + export function LabelList({ labels, max = 5 }: LabelListProps) { 40 + if (labels.length === 0) return null; 41 + 42 + return ( 43 + <Row style={{ gap: 12 }}> 44 + {labels.slice(0, max).map((label, i) => ( 45 + <LabelPill key={i} name={label.name} color={label.color} /> 46 + ))} 47 + </Row> 48 + ); 49 + }
+56
appview/ogcard/src/components/shared/language-circles.tsx
··· 1 + import type { Language } from "../../validation"; 2 + 3 + interface LanguageCirclesProps { 4 + languages: Language[]; 5 + } 6 + 7 + const MAX_RADIUS = 380; 8 + 9 + function percentageToThickness(percentage: number): number { 10 + return (percentage / 100) * MAX_RADIUS; 11 + } 12 + 13 + export function LanguageCircles({ languages }: LanguageCirclesProps) { 14 + const sortedLanguages = [...languages] 15 + .sort((a, b) => b.percentage - a.percentage) 16 + .slice(0, 5) 17 + .reverse(); 18 + 19 + let cumulativeRadius = 0; 20 + 21 + return ( 22 + <div 23 + style={{ 24 + position: "absolute", 25 + right: -MAX_RADIUS, 26 + top: -MAX_RADIUS, 27 + width: MAX_RADIUS * 2, 28 + height: MAX_RADIUS * 2, 29 + display: "flex", 30 + }}> 31 + {sortedLanguages.map((lang, i) => { 32 + const thickness = percentageToThickness(lang.percentage); 33 + const contentSize = cumulativeRadius * 2; 34 + 35 + cumulativeRadius += thickness; 36 + 37 + return ( 38 + <div 39 + key={i} 40 + style={{ 41 + position: "absolute", 42 + left: "50%", 43 + top: "50%", 44 + transform: "translate(-50%, -50%)", 45 + width: contentSize, 46 + height: contentSize, 47 + borderRadius: "50%", 48 + border: `${thickness}px solid ${lang.color}`, 49 + boxSizing: "content-box", 50 + }} 51 + /> 52 + ); 53 + })} 54 + </div> 55 + ); 56 + }
+45
appview/ogcard/src/components/shared/layout.tsx
··· 1 + import type { ComponentChildren } from "preact"; 2 + 3 + interface StyleProps { 4 + style?: Record<string, string | number>; 5 + children?: ComponentChildren; 6 + } 7 + 8 + export function Card({ children, style }: StyleProps) { 9 + return ( 10 + <div 11 + style={{ 12 + width: 1200, 13 + height: 630, 14 + background: "white", 15 + display: "flex", 16 + flexDirection: "column", 17 + padding: 48, 18 + ...style, 19 + }}> 20 + {children} 21 + </div> 22 + ); 23 + } 24 + 25 + export function Row({ children, style }: StyleProps) { 26 + return ( 27 + <div 28 + style={{ 29 + display: "flex", 30 + flexDirection: "row", 31 + alignItems: "center", 32 + ...style, 33 + }}> 34 + {children} 35 + </div> 36 + ); 37 + } 38 + 39 + export function Col({ children, style }: StyleProps) { 40 + return ( 41 + <div style={{ display: "flex", flexDirection: "column", ...style }}> 42 + {children} 43 + </div> 44 + ); 45 + }
+68
appview/ogcard/src/components/shared/logo.tsx
··· 1 + export function TangledLogo() { 2 + return ( 3 + <div 4 + style={{ 5 + width: 256, 6 + height: 70, 7 + display: "contents", 8 + }}> 9 + <svg 10 + width="256" 11 + height="70" 12 + viewBox="0 0 256 70" 13 + fill="none" 14 + xmlns="http://www.w3.org/2000/svg"> 15 + <path 16 + d="M38.6562 30.0449L39.168 30.2402L39.6807 30.4346L40.0234 30.8076L40.3672 31.1807L40.4922 31.7363L40.6162 32.292L39.9473 35.0566L39.7441 36.6553L39.5049 41.5752L39.3906 42.0312L39.2773 42.4863L38.9443 42.8193L38.6104 43.1514L37.9229 43.4463L37.3574 43.4414L36.793 43.4375L36.4531 43.2549L36.1143 43.0723L35.7949 42.6689L35.4756 42.2666L35.3438 41.7139L35.2109 41.1621L35.3564 37.832L35.5674 35.2646L36.002 33.0186L36.165 32.3838L36.3271 31.748L36.7324 30.9541L37.5293 30.2979L38.0928 30.1719L38.6562 30.0449Z" 17 + fill="black" 18 + /> 19 + <path 20 + d="M30.5889 31.1201L30.8682 31.4277L31.1484 31.7354L31.2695 32.0986L31.3916 32.4619V33.3789L31.1904 33.9082L30.8477 35.5654L30.8486 38.1523L31.1074 40.5127L30.5547 41.7197L30.1074 42.0156L29.6611 42.3115L28.4502 42.3838L28.0098 42.1787L27.5693 41.9727L27.04 41.2539L26.9248 40.9336L26.8086 40.6123L26.6738 39.8008L26.4814 36.9756L26.6992 34.3018L26.8467 33.6553L26.9932 33.0078L27.4502 31.7852L28.0137 31.1162L28.8779 30.7236H29.7334L30.5889 31.1201Z" 21 + fill="black" 22 + /> 23 + <path 24 + fill-rule="evenodd" 25 + clip-rule="evenodd" 26 + d="M45.4551 0L48.0215 0.143555L50.1611 0.571289L51.8721 1.12793L53.5869 1.91602L55.0186 2.78613L56.5781 3.96191L58.3262 5.74609L59.2383 6.91309L59.6455 7.55957L60.0518 8.20605L60.5176 9.16797L60.9824 10.1309L61.2656 10.9355L61.5479 11.7402L61.9658 13.7363L61.999 13.7607L62.0332 13.7852L64.707 15.0918L66.5674 16.3906L68.4297 18.1523L69.5566 19.5645L70.5576 21.1123L71.4766 23.0723L72.0156 24.6768L72.5146 27.0293L72.5107 30.9873L72.1279 32.8848L71.8564 33.7539L71.584 34.623L70.8457 36.3145L69.9648 37.832L68.8193 39.3867L67.2871 41L65.5625 42.3418L63.6367 43.4707L63.5068 43.5762L63.376 43.6816L63.5449 44.0723L63.7148 44.4629L63.9775 45.1045L64.2393 45.7461L65.3809 50.1318L65.5762 51.041L65.7725 51.9492L65.7617 56.0137L65.2803 58.2598L64.7393 59.8643L63.5947 62.2168L62.2529 64.1426L61.3398 65.0898L60.4277 66.0381L58.7158 67.3643L58.0547 67.7627L57.3926 68.1602L56.6641 68.4814L55.9355 68.8018L55.668 68.9443L55.4004 69.0859L53.9033 69.5225L51.9668 69.8672L50.2666 69.8496L49.8389 69.8301L48.7695 69.8105L48.3955 69.8877L48.0205 69.9639L47.8271 69.8506L47.6328 69.7373L46.418 69.6748L45.5889 69.5967L44.7607 69.5176V69.3584L44.1455 69.2383L43.5303 69.1172L43.0264 68.876L42.5225 68.6338L42.5146 68.3857L40.9121 67.5215L39.252 66.2988L37.6768 64.7842L36.3486 63.0723L35.3242 61.3613L35.2842 61.3184L35.2441 61.2744L34.3906 62.2441L33.0488 63.5322L31.9307 64.4111L31.1709 64.9014L30.4121 65.3926L28.915 66.0859L28.9307 66.1836L28.9473 66.2812L28.4307 66.4189L27.915 66.5576L25.8828 67.165L24.8135 67.4365L22.46 67.5928L20.3213 67.3818L20.209 67.29L20.0967 67.1992L19.6338 67.3203L19.4824 67.168L19.3301 67.0166L18.7031 66.8779L18.0752 66.7383L16.2891 66.1172L14.332 65.1357L12.3652 63.7656L10.5332 62.0312L9.18457 60.292L8.41992 59.0078L7.78125 57.7246L7.02539 55.6924L6.52441 53.3398L6.52734 49.1689L6.91406 47.251L7.56738 45.2109L8.51465 43.292L8.72559 42.9502L8.93652 42.6094L8.34277 42.2012L7.80762 41.8965L7.27246 41.5928L5.66895 40.416L4.09082 38.9014L2.95703 37.4893L2.47852 36.748L1.99902 36.0078L1.61035 35.208L1.2207 34.4092L0.859375 33.3926L0.49707 32.377L0 30.0244L0.0126953 25.8525L0.610352 23.291L1.40625 21.2383L1.9082 20.3164L2.41113 19.3955L3.06738 18.5059L3.72461 17.6172L5.24121 16.04L5.93555 15.4834L6.63086 14.9277L8.02148 14.0195L9.95117 13.0859L11.0166 12.6934L12.0859 10.665L13.3633 8.84766L15.252 6.89062L16.0215 6.30273L16.792 5.71582L18.6221 4.63867L20.5225 3.85156L21.7842 3.44922L24.1719 2.97266L27.7012 2.99023L30.375 3.5459L32.1934 4.21094L33.1846 4.70605L34.1768 5.2002L35.6152 3.80762L36.5781 3.05176L37.0596 2.74512L37.541 2.4375L37.9688 2.19043L38.3965 1.94238L39.0918 1.61523L39.7861 1.28711L41.3906 0.693359L43.3164 0.251953L45.4551 0ZM39.8984 21.0156L39.7324 21.3662L38.7773 22.457L37.7549 23.1094L37.0898 23.3311L36.4248 23.5537H34.7402L33.5654 23.1494L33.2246 22.9727L32.8848 22.7969L32.1631 22.1328L31.4424 21.4678L31.1543 21.2139L30.8672 20.959L29.5518 22.1494L29.0537 22.3848L28.5566 22.6211L28.0225 22.7646L27.4873 22.9092L26.8281 22.9102L26.1699 22.9121L25.4941 22.7012L24.8184 22.4912L24.5127 22.7812L24.207 23.0723L23.8438 23.5645L23.4814 24.0576L22.9707 24.248L22.4609 24.4375L21.7676 24.9531L21.4385 25.2959L21.1104 25.6387L18.9746 28.8174L16.3428 34.3018L15.4658 36.334L15.1318 37.6572L15.084 38.2793L15.0371 38.9014L15.1396 39.6318L15.2432 40.3613L15.5322 40.9531L15.8223 41.5439L16.2539 41.9209L16.6855 42.2969L17.8115 42.8223L19.4658 42.834L20.1611 42.54L20.8564 42.2471L21.498 41.7891L23.71 40.0234L23.791 40.0732L23.8711 40.123L24.0127 42.9297L24.5439 46.0146L25.0791 48.2061L25.8701 50.0244L26.1865 50.5049L26.502 50.9863L27.167 51.7002L28.8779 52.9375L29.7461 53.377L31.0381 53.7109L32.5146 53.8965L33.1289 53.8584L33.7441 53.8213L34.3896 53.7686L35.0361 53.7168L36.2783 53.3086L37.0127 52.9541L37.7471 52.6006L39.3975 51.3564L40.7764 49.8105L42.0938 48.0986L43.2275 46.3877L44.5176 44.1416L45.3223 42.2656L45.4082 42.2129L45.4941 42.1592L46.6807 43.7354L47.6641 44.6426L48.2715 44.9209L48.8779 45.2002L49.7871 45.2432L50.6963 45.2852L51.9795 44.8027L53.1855 43.5723L53.4639 42.7344L53.7412 41.8955L54.0879 40.0781L53.9785 37.5107L53.5176 34.9492L53.001 33.0186L52.1523 30.5996L51.1514 28.7402L50.4463 27.7783L50.0723 27.418L49.6982 27.0586L49.8115 25.96L49.4424 24.4629L48.6377 22.751L47.5391 21.5381L46.4971 20.7734L45.8828 21.1426L45.2686 21.5127L43.6631 21.8701L43.0449 21.8047L42.4268 21.7402L41.0137 21.209L40.6572 20.9375L40.3018 20.666H40.0645L39.8984 21.0156Z" 27 + fill="black" 28 + /> 29 + <path 30 + fill-rule="evenodd" 31 + clip-rule="evenodd" 32 + d="M171.79 22.4316C173.259 22.4316 174.489 22.6826 175.479 23.1836C176.47 23.6732 177.268 24.2882 177.871 25.0283C178.486 25.7568 178.958 26.474 179.288 27.1797H179.562V22.7734H186.787V49.2646C186.787 51.4507 186.24 53.2782 185.147 54.7471C184.054 56.2273 182.539 57.3432 180.604 58.0947C178.679 58.8462 176.465 59.2226 173.96 59.2227C171.603 59.2227 169.582 58.9032 167.896 58.2656C166.223 57.628 164.89 56.7683 163.899 55.6865C162.909 54.6049 162.266 53.4037 161.97 52.083L168.699 51.1777C168.904 51.6559 169.229 52.1061 169.673 52.5273C170.117 52.9598 170.703 53.3127 171.432 53.5859C172.172 53.8592 173.071 53.9961 174.13 53.9961C175.713 53.9961 177.017 53.6197 178.042 52.8682C179.078 52.1166 179.596 50.8813 179.596 49.1621V44.3623H179.288C178.969 45.0911 178.491 45.7806 177.854 46.4297C177.216 47.0785 176.396 47.6077 175.395 48.0176C174.393 48.4275 173.197 48.6328 171.808 48.6328C169.838 48.6328 168.044 48.1775 166.427 47.2666C164.821 46.3443 163.54 44.9379 162.584 43.0479C161.639 41.1463 161.167 38.7434 161.167 35.8398C161.167 32.8679 161.65 30.3853 162.618 28.3926C163.586 26.3999 164.873 24.9086 166.479 23.918C168.095 22.9274 169.866 22.4317 171.79 22.4316ZM174.113 28.2217C172.918 28.2217 171.91 28.5463 171.09 29.1953C170.27 29.833 169.65 30.7217 169.229 31.8604C168.807 32.999 168.597 34.3141 168.597 35.8057C168.597 37.32 168.807 38.6299 169.229 39.7344C169.661 40.8273 170.281 41.6759 171.09 42.2793C171.91 42.8714 172.918 43.167 174.113 43.167C175.286 43.167 176.277 42.8765 177.085 42.2959C177.905 41.7039 178.531 40.8615 178.964 39.7686C179.408 38.6641 179.63 37.3428 179.63 35.8057C179.63 34.2685 179.414 32.9359 178.981 31.8086C178.549 30.67 177.922 29.7874 177.103 29.1611C176.283 28.5349 175.286 28.2217 174.113 28.2217Z" 33 + fill="black" 34 + /> 35 + <path 36 + fill-rule="evenodd" 37 + clip-rule="evenodd" 38 + d="M215.798 22.4316C217.528 22.4317 219.139 22.7107 220.631 23.2686C222.134 23.8151 223.444 24.6407 224.56 25.7451C225.687 26.8496 226.564 28.2392 227.19 29.9131C227.817 31.5754 228.13 33.5224 228.13 35.7539V37.7529H210.264V37.7695C210.264 39.0676 210.503 40.1897 210.981 41.1348C211.471 42.0796 212.16 42.808 213.048 43.3203C213.936 43.8327 214.99 44.0889 216.208 44.0889C217.016 44.0888 217.756 43.9757 218.428 43.748C219.1 43.5203 219.675 43.1781 220.153 42.7227C220.631 42.2672 220.996 41.7091 221.246 41.0488L227.976 41.4932C227.634 43.1101 226.934 44.5225 225.875 45.7295C224.827 46.925 223.472 47.8585 221.81 48.5303C220.159 49.1906 218.251 49.5205 216.088 49.5205C213.389 49.5205 211.066 48.974 209.119 47.8809C207.184 46.7764 205.692 45.2165 204.645 43.2012C203.597 41.1744 203.073 38.7776 203.073 36.0107C203.073 33.3121 203.597 30.9435 204.645 28.9053C205.692 26.867 207.167 25.2783 209.068 24.1396C210.981 23.0011 213.225 22.4316 215.798 22.4316ZM215.917 27.8633C214.813 27.8633 213.833 28.1195 212.979 28.6318C212.137 29.1328 211.476 29.8102 210.998 30.6641C210.557 31.4413 210.317 32.3013 210.273 33.2432H221.28C221.28 32.1957 221.052 31.2674 220.597 30.459C220.141 29.6508 219.509 29.0189 218.701 28.5635C217.904 28.0967 216.976 27.8633 215.917 27.8633Z" 39 + fill="black" 40 + /> 41 + <path 42 + fill-rule="evenodd" 43 + clip-rule="evenodd" 44 + d="M118.389 22.4316C119.846 22.4316 121.241 22.6028 122.573 22.9443C123.917 23.2859 125.107 23.8149 126.144 24.5322C127.191 25.2496 128.017 26.1725 128.62 27.2998C129.224 28.4157 129.525 29.7536 129.525 31.3135V49.0088H122.625V45.3701H122.42C121.999 46.1898 121.435 46.9129 120.729 47.5391C120.024 48.1539 119.175 48.6382 118.185 48.9912C117.194 49.3328 116.049 49.5039 114.751 49.5039C113.077 49.5039 111.586 49.2134 110.276 48.6328C108.967 48.0407 107.93 47.1696 107.167 46.0195C106.416 44.8581 106.04 43.4114 106.04 41.6807C106.04 40.2233 106.308 38.9993 106.843 38.0088C107.378 37.0181 108.107 36.2207 109.029 35.6172C109.952 35.0138 110.999 34.5584 112.172 34.251C113.356 33.9435 114.597 33.7278 115.896 33.6025C117.421 33.4431 118.651 33.2948 119.585 33.1582C120.519 33.0102 121.196 32.7934 121.617 32.5088C122.038 32.2241 122.249 31.803 122.249 31.2451V31.1426C122.249 30.0609 121.908 29.2239 121.225 28.6318C120.553 28.0397 119.596 27.7441 118.354 27.7441C117.045 27.7442 116.004 28.0346 115.229 28.6152C114.455 29.1845 113.943 29.9014 113.692 30.7666L106.963 30.2207C107.304 28.6266 107.976 27.2483 108.978 26.0869C109.98 24.9141 111.273 24.0149 112.855 23.3887C114.449 22.7511 116.294 22.4317 118.389 22.4316ZM122.301 36.8477C122.073 36.9956 121.76 37.1316 121.361 37.2568C120.974 37.3707 120.536 37.4796 120.046 37.582C119.556 37.6731 119.067 37.7582 118.577 37.8379C118.088 37.9062 117.644 37.9694 117.245 38.0264C116.391 38.1516 115.644 38.3507 115.007 38.624C114.369 38.8973 113.874 39.2676 113.521 39.7344C113.169 40.1898 112.992 40.7593 112.992 41.4424C112.992 42.4327 113.35 43.1902 114.067 43.7139C114.796 44.2263 115.719 44.4824 116.835 44.4824C117.905 44.4824 118.85 44.2718 119.67 43.8506C120.49 43.4179 121.134 42.8371 121.601 42.1084C122.067 41.3798 122.301 40.554 122.301 39.6318V36.8477Z" 45 + fill="black" 46 + /> 47 + <path 48 + fill-rule="evenodd" 49 + clip-rule="evenodd" 50 + d="M256 14.0283V49.0088H248.826V44.8066H248.52C248.178 45.5353 247.694 46.2583 247.067 46.9756C246.453 47.6815 245.65 48.2685 244.659 48.7354C243.68 49.2022 242.484 49.4355 241.072 49.4355C239.08 49.4355 237.275 48.9231 235.658 47.8984C234.053 46.8623 232.777 45.3419 231.832 43.3379C230.898 41.3224 230.432 38.8512 230.432 35.9248C230.432 32.9188 230.915 30.4194 231.883 28.4268C232.851 26.4227 234.138 24.9252 235.743 23.9346C237.36 22.9326 239.131 22.4317 241.055 22.4316C242.524 22.4316 243.748 22.6826 244.728 23.1836C245.718 23.6732 246.515 24.2883 247.118 25.0283C247.733 25.757 248.201 26.4738 248.52 27.1797H248.741V14.0283H256ZM243.378 28.2217C242.182 28.2217 241.174 28.5463 240.354 29.1953C239.535 29.8444 238.914 30.7445 238.493 31.8945C238.072 33.0445 237.861 34.3764 237.861 35.8906C237.861 37.4163 238.072 38.7657 238.493 39.9385C238.926 41.0999 239.546 42.0114 240.354 42.6719C241.174 43.3209 242.182 43.6455 243.378 43.6455C244.551 43.6455 245.541 43.3261 246.35 42.6885C247.169 42.0394 247.796 41.1341 248.229 39.9727C248.673 38.8113 248.895 37.4505 248.895 35.8906C248.895 34.3309 248.679 32.9761 248.246 31.8262C247.813 30.6761 247.187 29.7874 246.367 29.1611C245.547 28.5349 244.551 28.2217 243.378 28.2217Z" 51 + fill="black" 52 + /> 53 + <path 54 + d="M99.0752 16.4883V22.7734H104.012V28.2393H99.0752V40.9463C99.0752 41.6179 99.178 42.1418 99.3828 42.5176C99.5878 42.882 99.8729 43.1381 100.237 43.2861C100.613 43.4341 101.046 43.5088 101.535 43.5088C101.877 43.5088 102.218 43.4798 102.56 43.4229C102.901 43.3545 103.164 43.3037 103.346 43.2695L104.49 48.6836C104.126 48.7974 103.613 48.9292 102.953 49.0771C102.293 49.2366 101.489 49.333 100.544 49.3672C98.7906 49.4354 97.2534 49.2021 95.9326 48.667C94.6232 48.1318 93.6037 47.3001 92.875 46.1729C92.1464 45.0457 91.7885 43.6224 91.7998 41.9033V28.2393H88.2129V22.7734H91.7998V16.4883H99.0752Z" 55 + fill="black" 56 + /> 57 + <path 58 + d="M198.397 41.1514C198.409 41.9824 198.556 42.5861 198.841 42.9619C199.137 43.3263 199.639 43.5088 200.345 43.5088C200.709 43.4974 200.993 43.4745 201.198 43.4404C201.403 43.4063 201.574 43.3607 201.711 43.3037L202.872 48.5986C202.496 48.7125 202.035 48.8318 201.488 48.957C200.953 49.0709 200.23 49.1446 199.319 49.1787C196.53 49.2812 194.469 48.7575 193.137 47.6074C191.804 46.446 191.132 44.6187 191.121 42.125V14.0283H198.397V41.1514Z" 59 + fill="black" 60 + /> 61 + <path 62 + d="M148.839 22.4316C150.661 22.4316 152.249 22.8299 153.604 23.627C154.959 24.4239 156.012 25.5629 156.764 27.043C157.515 28.5118 157.892 30.2656 157.892 32.3037V49.0088H150.615V33.6025C150.627 31.9972 150.217 30.7443 149.386 29.8447C148.555 28.9338 147.41 28.4785 145.952 28.4785C144.973 28.4785 144.108 28.6891 143.356 29.1104C142.616 29.5317 142.036 30.1466 141.614 30.9551C141.204 31.752 140.994 32.7138 140.982 33.8408V49.0088H133.706V22.7734H140.641V27.4023H140.948C141.529 25.8766 142.502 24.6694 143.868 23.7812C145.235 22.8817 146.892 22.4316 148.839 22.4316Z" 63 + fill="black" 64 + /> 65 + </svg> 66 + </div> 67 + ); 68 + }
+43
appview/ogcard/src/components/shared/metrics.tsx
··· 1 + import { Row, Col } from "./layout"; 2 + import { TYPOGRAPHY } from "./constants"; 3 + import { 4 + Star, 5 + GitPullRequest, 6 + CircleDot, 7 + type LucideIcon, 8 + } from "../../icons/lucide"; 9 + 10 + interface MetricsProps { 11 + stars: number; 12 + pulls: number; 13 + issues: number; 14 + } 15 + 16 + // Display stars, pulls, issues with Lucide icons 17 + export function Metrics({ stars, pulls, issues }: MetricsProps) { 18 + return ( 19 + <Row style={{ gap: 56, alignItems: "flex-start" }}> 20 + <MetricItem value={stars} label="stars" Icon={Star} /> 21 + <MetricItem value={pulls} label="pulls" Icon={GitPullRequest} /> 22 + <MetricItem value={issues} label="issues" Icon={CircleDot} /> 23 + </Row> 24 + ); 25 + } 26 + 27 + interface MetricItemProps { 28 + value: number; 29 + label: string; 30 + Icon: LucideIcon; 31 + } 32 + 33 + function MetricItem({ value, label, Icon }: MetricItemProps) { 34 + return ( 35 + <Col style={{ gap: 12 }}> 36 + <Row style={{ gap: 12, alignItems: "center" }}> 37 + <span style={TYPOGRAPHY.metricValue}>{value}</span> 38 + <Icon size={48} /> 39 + </Row> 40 + <span style={{ ...TYPOGRAPHY.label, opacity: 0.75 }}>{label}</span> 41 + </Col> 42 + ); 43 + }
+17
appview/ogcard/src/components/shared/stat-item.tsx
··· 1 + import { Row } from "./layout"; 2 + import { TYPOGRAPHY } from "./constants"; 3 + import type { LucideIcon } from "../../icons/lucide"; 4 + 5 + interface StatItemProps { 6 + Icon: LucideIcon; 7 + value: string | number; 8 + } 9 + 10 + export function StatItem({ Icon, value }: StatItemProps) { 11 + return ( 12 + <Row style={{ gap: 16 }}> 13 + <Icon size={36} color="#404040" /> 14 + <span style={{ ...TYPOGRAPHY.body, color: "#404040" }}>{value}</span> 15 + </Row> 16 + ); 17 + }
+73
appview/ogcard/src/components/shared/status-badge.tsx
··· 1 + import { Row } from "./layout"; 2 + import { 3 + CircleDot, 4 + Ban, 5 + GitPullRequest, 6 + GitPullRequestClosed, 7 + GitMerge, 8 + } from "../../icons/lucide"; 9 + import { COLORS, TYPOGRAPHY } from "./constants"; 10 + 11 + const STATUS_CONFIG = { 12 + open: { 13 + Icon: CircleDot, 14 + bg: COLORS.status.open.bg, 15 + text: COLORS.status.open.text, 16 + }, 17 + closed: { 18 + Icon: Ban, 19 + bg: COLORS.status.closed.bg, 20 + text: COLORS.status.closed.text, 21 + }, 22 + merged: { 23 + Icon: GitMerge, 24 + bg: COLORS.status.merged.bg, 25 + text: COLORS.status.merged.text, 26 + }, 27 + } as const; 28 + 29 + interface StatusBadgeProps { 30 + status: "open" | "closed" | "merged"; 31 + } 32 + 33 + export function StatusBadge({ status }: StatusBadgeProps) { 34 + const config = 35 + status === "merged" 36 + ? STATUS_CONFIG.merged 37 + : status === "closed" 38 + ? STATUS_CONFIG.closed 39 + : STATUS_CONFIG.open; 40 + const Icon = config.Icon; 41 + 42 + return ( 43 + <Row 44 + style={{ 45 + gap: 12, 46 + padding: "14px 26px 14px 24px", 47 + borderRadius: 18, 48 + backgroundColor: config.bg, 49 + }}> 50 + <Icon size={48} color={config.text} /> 51 + <span style={{ ...TYPOGRAPHY.status, color: config.text }}>{status}</span> 52 + </Row> 53 + ); 54 + } 55 + 56 + export function IssueStatusBadge({ status }: { status: "open" | "closed" }) { 57 + const config = 58 + status === "closed" ? STATUS_CONFIG.closed : STATUS_CONFIG.open; 59 + const Icon = config.Icon; 60 + 61 + return ( 62 + <Row 63 + style={{ 64 + gap: 12, 65 + padding: "14px 26px 14px 24px", 66 + borderRadius: 18, 67 + backgroundColor: config.bg, 68 + }}> 69 + <Icon size={48} color={config.text} /> 70 + <span style={{ ...TYPOGRAPHY.status, color: config.text }}>{status}</span> 71 + </Row> 72 + ); 73 + }
+52
appview/ogcard/src/icons/lucide.tsx
··· 1 + import { h } from "preact"; 2 + import iconNodes from "lucide-static/icon-nodes.json"; 3 + 4 + interface IconProps { 5 + size?: number; 6 + color?: string; 7 + strokeWidth?: number; 8 + } 9 + 10 + type IconNodeEntry = [string, Record<string, string | number>]; 11 + 12 + function createIcon(name: string) { 13 + const nodes = (iconNodes as unknown as Record<string, IconNodeEntry[]>)[name]; 14 + if (!nodes) throw new Error(`Icon "${name}" not found`); 15 + 16 + return function Icon({ 17 + size = 24, 18 + color = "currentColor", 19 + strokeWidth = 2, 20 + }: IconProps = {}) { 21 + return h( 22 + "svg", 23 + { 24 + xmlns: "http://www.w3.org/2000/svg", 25 + width: size, 26 + height: size, 27 + viewBox: "0 0 24 24", 28 + fill: "none", 29 + stroke: color, 30 + strokeWidth, 31 + strokeLinecap: "round" as const, 32 + strokeLinejoin: "round" as const, 33 + }, 34 + nodes.map(([tag, attrs], i) => h(tag, { key: i, ...attrs })), 35 + ); 36 + }; 37 + } 38 + 39 + export const Star = createIcon("star"); 40 + export const GitPullRequest = createIcon("git-pull-request"); 41 + export const GitPullRequestClosed = createIcon("git-pull-request-closed"); 42 + export const GitMerge = createIcon("git-merge"); 43 + export const CircleDot = createIcon("circle-dot"); 44 + export const Calendar = createIcon("calendar"); 45 + export const MessageSquare = createIcon("message-square"); 46 + export const MessageSquareCode = createIcon("message-square-code"); 47 + export const Ban = createIcon("ban"); 48 + export const SmilePlus = createIcon("smile-plus"); 49 + export const FileDiff = createIcon("file-diff"); 50 + export const RefreshCw = createIcon("refresh-cw"); 51 + 52 + export type LucideIcon = typeof Star;
+96
appview/ogcard/src/index.tsx
··· 1 + import { cardPayloadSchema } from "./validation"; 2 + import { renderCard } from "./lib/render"; 3 + import { RepositoryCard } from "./components/cards/repository"; 4 + import { IssueCard } from "./components/cards/issue"; 5 + import { PullRequestCard } from "./components/cards/pull-request"; 6 + import { z } from "zod"; 7 + 8 + declare global { 9 + interface CacheStorage { 10 + default: Cache; 11 + } 12 + } 13 + 14 + interface Env { 15 + ENVIRONMENT: string; 16 + } 17 + 18 + export default { 19 + async fetch(request: Request, env: Env): Promise<Response> { 20 + if (request.method !== "POST") { 21 + return new Response("Method not allowed", { status: 405 }); 22 + } 23 + 24 + const url = new URL(request.url); 25 + const cardType = url.pathname.split("/").pop(); 26 + 27 + try { 28 + const body = await request.json(); 29 + const payload = cardPayloadSchema.parse(body); 30 + 31 + let component; 32 + switch (payload.type) { 33 + case "repository": 34 + component = <RepositoryCard {...payload} />; 35 + break; 36 + case "issue": 37 + component = <IssueCard {...payload} />; 38 + break; 39 + case "pullRequest": 40 + component = <PullRequestCard {...payload} />; 41 + break; 42 + default: 43 + return new Response("Unknown card type", { status: 400 }); 44 + } 45 + 46 + const cacheKeyUrl = new URL(request.url); 47 + cacheKeyUrl.searchParams.set("payload", JSON.stringify(payload)); 48 + const cacheKey = new Request(cacheKeyUrl.toString(), { method: "GET" }); 49 + const cache = caches.default; 50 + const cached = await cache.match(cacheKey); 51 + 52 + if (cached) { 53 + return cached; 54 + } 55 + 56 + const { png: pngBuffer } = await renderCard(component); 57 + 58 + const response = new Response(pngBuffer as any, { 59 + headers: { 60 + "Content-Type": "image/png", 61 + "Cache-Control": "public, max-age=3600", 62 + }, 63 + }); 64 + 65 + await cache.put(cacheKey, response.clone()); 66 + 67 + return response; 68 + } catch (error) { 69 + if (error instanceof z.ZodError) { 70 + return new Response( 71 + JSON.stringify({ errors: (error as z.ZodError).issues }), 72 + { 73 + status: 400, 74 + headers: { "Content-Type": "application/json" }, 75 + }, 76 + ); 77 + } 78 + 79 + console.error("Error generating card:", error); 80 + const errorMessage = 81 + error instanceof Error ? error.message : String(error); 82 + const errorStack = error instanceof Error ? error.stack : ""; 83 + console.error("Error stack:", errorStack); 84 + return new Response( 85 + JSON.stringify({ 86 + error: errorMessage, 87 + stack: errorStack, 88 + }), 89 + { 90 + status: 500, 91 + headers: { "Content-Type": "application/json" }, 92 + }, 93 + ); 94 + } 95 + }, 96 + };
+46
appview/ogcard/src/lib/render.ts
··· 1 + import type { VNode } from "preact"; 2 + import { initSatori, initResvg, loadFonts } from "@tangled/ogcard-runtime"; 3 + import type { ResvgClass } from "@tangled/ogcard-runtime/types"; 4 + 5 + let satoriFn: typeof import("satori").default | null = null; 6 + let Resvg: ResvgClass | null = null; 7 + let fontsLoaded = false; 8 + let cachedFonts: Awaited<ReturnType<typeof loadFonts>> | null = null; 9 + 10 + export interface RenderResult { 11 + svg: string; 12 + png: Uint8Array; 13 + } 14 + 15 + export async function renderCard(component: VNode): Promise<RenderResult> { 16 + if (!satoriFn) { 17 + satoriFn = await initSatori(); 18 + } 19 + 20 + if (!Resvg) { 21 + Resvg = await initResvg(); 22 + } 23 + 24 + if (!fontsLoaded) { 25 + cachedFonts = await loadFonts(); 26 + fontsLoaded = true; 27 + } 28 + 29 + const svg = await satoriFn(component as any, { 30 + width: 1200, 31 + height: 630, 32 + fonts: cachedFonts!, 33 + embedFont: true, 34 + }); 35 + 36 + const resvg = new Resvg!(svg, { 37 + fitTo: { mode: "width", value: 1200 }, 38 + }); 39 + 40 + const pngData = resvg.render(); 41 + 42 + return { 43 + svg, 44 + png: pngData.asPng(), 45 + }; 46 + }
+9
appview/ogcard/src/types.d.ts
··· 1 + declare module "*.wasm?module" { 2 + const value: WebAssembly.Module; 3 + export default value; 4 + } 5 + 6 + declare module "*.woff" { 7 + const value: ArrayBuffer; 8 + export default value; 9 + }
+70
appview/ogcard/src/validation.ts
··· 1 + import { z } from "zod"; 2 + 3 + const hexColor = /^#[0-9A-Fa-f]{6}$/; 4 + 5 + const languageSchema = z.object({ 6 + color: z.string().regex(hexColor), 7 + percentage: z.number().min(0).max(100), 8 + }); 9 + 10 + export const repositoryCardSchema = z.object({ 11 + type: z.literal("repository"), 12 + repoName: z.string().min(1).max(100), 13 + ownerHandle: z.string().min(1).max(100), 14 + stars: z.number().int().min(0).max(1000000), 15 + pulls: z.number().int().min(0).max(100000), 16 + issues: z.number().int().min(0).max(100000), 17 + createdAt: z.string().max(100), 18 + avatarUrl: z.string().url(), 19 + languages: z.array(languageSchema).max(5), 20 + }); 21 + 22 + export const issueCardSchema = z.object({ 23 + type: z.literal("issue"), 24 + repoName: z.string().min(1).max(100), 25 + ownerHandle: z.string().min(1).max(100), 26 + avatarUrl: z.string().url(), 27 + title: z.string().min(1).max(500), 28 + issueNumber: z.number().int().positive(), 29 + status: z.enum(["open", "closed"]), 30 + labels: z 31 + .array( 32 + z.object({ 33 + name: z.string().max(50), 34 + color: z.string().regex(hexColor), 35 + }), 36 + ) 37 + .max(10), 38 + commentCount: z.number().int().min(0), 39 + reactionCount: z.number().int().min(0), 40 + createdAt: z.string(), 41 + }); 42 + 43 + export const pullRequestCardSchema = z.object({ 44 + type: z.literal("pullRequest"), 45 + repoName: z.string().min(1).max(100), 46 + ownerHandle: z.string().min(1).max(100), 47 + avatarUrl: z.string().url(), 48 + title: z.string().min(1).max(500), 49 + pullRequestNumber: z.number().int().positive(), 50 + status: z.enum(["open", "closed", "merged"]), 51 + filesChanged: z.number().int().min(0), 52 + additions: z.number().int().min(0), 53 + deletions: z.number().int().min(0), 54 + rounds: z.number().int().min(1), 55 + // reviews: z.number().int().min(0), // TODO: implement review tracking 56 + commentCount: z.number().int().min(0), 57 + reactionCount: z.number().int().min(0), 58 + createdAt: z.string(), 59 + }); 60 + 61 + export const cardPayloadSchema = z.discriminatedUnion("type", [ 62 + repositoryCardSchema, 63 + issueCardSchema, 64 + pullRequestCardSchema, 65 + ]); 66 + 67 + export type Language = z.infer<typeof languageSchema>; 68 + export type RepositoryCardData = z.infer<typeof repositoryCardSchema>; 69 + export type IssueCardData = z.infer<typeof issueCardSchema>; 70 + export type PullRequestCardData = z.infer<typeof pullRequestCardSchema>;
+19
appview/ogcard/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "target": "ES2022", 4 + "module": "ES2022", 5 + "lib": ["ES2022"], 6 + "moduleResolution": "bundler", 7 + "types": ["@cloudflare/workers-types", "node", "bun"], 8 + "jsx": "react-jsx", 9 + "jsxImportSource": "preact", 10 + "strict": true, 11 + "esModuleInterop": true, 12 + "skipLibCheck": true, 13 + "forceConsistentCasingInFileNames": true, 14 + "resolveJsonModule": true, 15 + "noEmit": true 16 + }, 17 + "include": ["src/**/*"], 18 + "exclude": ["node_modules"] 19 + }
+25
appview/ogcard/wrangler.jsonc
··· 1 + { 2 + "$schema": "node_modules/wrangler/config-schema.json", 3 + "name": "tangled-ogcard-worker", 4 + "main": "src/index.tsx", 5 + "compatibility_date": "2026-03-07", 6 + "observability": { 7 + "enabled": true, 8 + }, 9 + "routes": [ 10 + { 11 + "pattern": "og.tangled.org/*", 12 + "zone_name": "tangled.org", 13 + }, 14 + ], 15 + "vars": { 16 + "ENVIRONMENT": "production", 17 + }, 18 + "rules": [ 19 + { 20 + "type": "Data", 21 + "globs": ["**/*.woff"], 22 + "fallthrough": true, 23 + }, 24 + ], 25 + }
+52 -268
appview/pulls/opengraph.go
··· 1 1 package pulls 2 2 3 3 import ( 4 - "bytes" 5 4 "context" 6 - "fmt" 7 - "image" 8 - "image/color" 9 - "image/png" 10 5 "log" 11 6 "net/http" 7 + "time" 12 8 13 9 "tangled.org/core/appview/models" 14 10 "tangled.org/core/appview/ogcard" 15 11 "tangled.org/core/patchutil" 16 - "tangled.org/core/types" 17 12 ) 18 13 19 - func (s *Pulls) drawPullSummaryCard(pull *models.Pull, repo *models.Repo, diffStats types.DiffFileStat, filesChanged int) (*ogcard.Card, error) { 20 - width, height := ogcard.DefaultSize() 21 - mainCard, err := ogcard.NewCard(width, height) 22 - if err != nil { 23 - return nil, err 24 - } 25 - 26 - // Split: content area (75%) and status/stats area (25%) 27 - contentCard, statsArea := mainCard.Split(false, 75) 28 - 29 - // Add padding to content 30 - contentCard.SetMargin(50) 31 - 32 - // Split content horizontally: main content (80%) and avatar area (20%) 33 - mainContent, avatarArea := contentCard.Split(true, 80) 34 - 35 - // Add margin to main content 36 - mainContent.SetMargin(10) 37 - 38 - // Use full main content area for repo name and title 39 - bounds := mainContent.Img.Bounds() 40 - startX := bounds.Min.X + mainContent.Margin 41 - startY := bounds.Min.Y + mainContent.Margin 42 - 43 - // Draw full repository name at top (owner/repo format) 44 - var repoOwner string 45 - owner, err := s.idResolver.ResolveIdent(context.Background(), repo.Did) 46 - if err != nil { 47 - repoOwner = repo.Did 48 - } else { 49 - repoOwner = "@" + owner.Handle.String() 50 - } 51 - 52 - fullRepoName := repoOwner + " / " + repo.Name 53 - if len(fullRepoName) > 60 { 54 - fullRepoName = fullRepoName[:60] + "…" 55 - } 56 - 57 - grayColor := color.RGBA{88, 96, 105, 255} 58 - err = mainContent.DrawTextAt(fullRepoName, startX, startY, grayColor, 36, ogcard.Top, ogcard.Left) 14 + func (s *Pulls) PullOpenGraphSummary(w http.ResponseWriter, r *http.Request) { 15 + f, err := s.repoResolver.Resolve(r) 59 16 if err != nil { 60 - return nil, err 61 - } 62 - 63 - // Draw pull request title below repo name with wrapping 64 - titleY := startY + 60 65 - titleX := startX 66 - 67 - // Truncate title if too long 68 - pullTitle := pull.Title 69 - maxTitleLength := 80 70 - if len(pullTitle) > maxTitleLength { 71 - pullTitle = pullTitle[:maxTitleLength] + "…" 72 - } 73 - 74 - // Create a temporary card for the title area to enable wrapping 75 - titleBounds := mainContent.Img.Bounds() 76 - titleWidth := titleBounds.Dx() - (startX - titleBounds.Min.X) - 20 // Leave some margin 77 - titleHeight := titleBounds.Dy() - (titleY - titleBounds.Min.Y) - 100 // Leave space for pull ID 78 - 79 - titleRect := image.Rect(titleX, titleY, titleX+titleWidth, titleY+titleHeight) 80 - titleCard := &ogcard.Card{ 81 - Img: mainContent.Img.SubImage(titleRect).(*image.RGBA), 82 - Font: mainContent.Font, 83 - Margin: 0, 17 + log.Println("failed to get repo and knot", err) 18 + return 84 19 } 85 20 86 - // Draw wrapped title 87 - lines, err := titleCard.DrawText(pullTitle, color.Black, 54, ogcard.Top, ogcard.Left) 88 - if err != nil { 89 - return nil, err 21 + pull, ok := r.Context().Value("pull").(*models.Pull) 22 + if !ok { 23 + log.Println("pull not found in context") 24 + http.Error(w, "pull not found", http.StatusNotFound) 25 + return 90 26 } 91 27 92 - // Calculate where title ends (number of lines * line height) 93 - lineHeight := 60 // Approximate line height for 54pt font 94 - titleEndY := titleY + (len(lines) * lineHeight) + 10 95 - 96 - // Draw pull ID in gray below the title 97 - pullIdText := fmt.Sprintf("#%d", pull.PullId) 98 - err = mainContent.DrawTextAt(pullIdText, startX, titleEndY, grayColor, 54, ogcard.Top, ogcard.Left) 28 + var ownerHandle string 29 + owner, err := s.idResolver.ResolveIdent(context.Background(), f.Did) 99 30 if err != nil { 100 - return nil, err 31 + ownerHandle = f.Did 32 + } else { 33 + ownerHandle = "@" + owner.Handle.String() 101 34 } 102 35 103 - // Get pull author handle (needed for avatar and metadata) 104 36 var authorHandle string 105 37 author, err := s.idResolver.ResolveIdent(context.Background(), pull.OwnerDid) 106 38 if err != nil { ··· 109 41 authorHandle = "@" + author.Handle.String() 110 42 } 111 43 112 - // Draw avatar circle on the right side 113 - avatarBounds := avatarArea.Img.Bounds() 114 - avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin 115 - if avatarSize > 220 { 116 - avatarSize = 220 117 - } 118 - avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2) 119 - avatarY := avatarBounds.Min.Y + 20 120 - 121 - // Get avatar URL for pull author 122 - avatarURL := s.pages.AvatarUrl(authorHandle, "256") 123 - err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize) 124 - if err != nil { 125 - log.Printf("failed to draw avatar (non-fatal): %v", err) 126 - } 127 - 128 - // Split stats area: left side for status/stats (80%), right side for dolly (20%) 129 - statusArea, dollyArea := statsArea.Split(true, 80) 130 - 131 - // Draw status and stats 132 - statsBounds := statusArea.Img.Bounds() 133 - statsX := statsBounds.Min.X + 60 // left padding 134 - statsY := statsBounds.Min.Y 135 - 136 - iconColor := color.RGBA{88, 96, 105, 255} 137 - iconSize := 36 138 - textSize := 36.0 139 - labelSize := 28.0 140 - iconBaselineOffset := int(textSize) / 2 141 - 142 - // Draw status (open/merged/closed) with colored icon and text 143 - var statusIcon string 144 - var statusText string 145 - var statusColor color.RGBA 44 + avatarUrl := s.pages.AvatarUrl(authorHandle, "256") 146 45 46 + var status string 147 47 if pull.State.IsOpen() { 148 - statusIcon = "git-pull-request" 149 - statusText = "open" 150 - statusColor = color.RGBA{34, 139, 34, 255} // green 48 + status = "open" 151 49 } else if pull.State.IsMerged() { 152 - statusIcon = "git-merge" 153 - statusText = "merged" 154 - statusColor = color.RGBA{138, 43, 226, 255} // purple 50 + status = "merged" 155 51 } else { 156 - statusIcon = "git-pull-request-closed" 157 - statusText = "closed" 158 - statusColor = color.RGBA{52, 58, 64, 255} // dark gray 159 - } 160 - 161 - statusTextWidth := statusArea.TextWidth(statusText, textSize) 162 - badgePadding := 12 163 - badgeHeight := int(textSize) + (badgePadding * 2) 164 - badgeWidth := iconSize + badgePadding + statusTextWidth + (badgePadding * 2) 165 - cornerRadius := 8 166 - badgeX := 60 167 - badgeY := 0 168 - 169 - statusArea.DrawRoundedRect(badgeX, badgeY, badgeWidth, badgeHeight, cornerRadius, statusColor) 170 - 171 - whiteColor := color.RGBA{255, 255, 255, 255} 172 - iconX := statsX + badgePadding 173 - iconY := statsY + (badgeHeight-iconSize)/2 174 - err = statusArea.DrawLucideIcon(statusIcon, iconX, iconY, iconSize, whiteColor) 175 - if err != nil { 176 - log.Printf("failed to draw status icon: %v", err) 177 - } 178 - 179 - textX := statsX + badgePadding + iconSize + badgePadding 180 - textY := statsY + (badgeHeight-int(textSize))/2 - 5 181 - err = statusArea.DrawTextAt(statusText, textX, textY, whiteColor, textSize, ogcard.Top, ogcard.Left) 182 - if err != nil { 183 - log.Printf("failed to draw status text: %v", err) 184 - } 185 - 186 - currentX := statsX + badgeWidth + 50 187 - 188 - // Draw comment count 189 - err = statusArea.DrawLucideIcon("message-square", currentX, iconY, iconSize, iconColor) 190 - if err != nil { 191 - log.Printf("failed to draw comment icon: %v", err) 192 - } 193 - 194 - currentX += iconSize + 15 195 - commentCount := pull.TotalComments() 196 - commentText := fmt.Sprintf("%d comments", commentCount) 197 - if commentCount == 1 { 198 - commentText = "1 comment" 199 - } 200 - err = statusArea.DrawTextAt(commentText, currentX, textY, iconColor, textSize, ogcard.Top, ogcard.Left) 201 - if err != nil { 202 - log.Printf("failed to draw comment text: %v", err) 203 - } 204 - 205 - commentTextWidth := len(commentText) * 20 206 - currentX += commentTextWidth + 40 207 - 208 - // Draw files changed 209 - err = statusArea.DrawLucideIcon("file-diff", currentX, iconY, iconSize, iconColor) 210 - if err != nil { 211 - log.Printf("failed to draw file diff icon: %v", err) 212 - } 213 - 214 - currentX += iconSize + 15 215 - filesText := fmt.Sprintf("%d files", filesChanged) 216 - if filesChanged == 1 { 217 - filesText = "1 file" 218 - } 219 - err = statusArea.DrawTextAt(filesText, currentX, textY, iconColor, textSize, ogcard.Top, ogcard.Left) 220 - if err != nil { 221 - log.Printf("failed to draw files text: %v", err) 222 - } 223 - 224 - filesTextWidth := len(filesText) * 20 225 - currentX += filesTextWidth 226 - 227 - // Draw additions (green +) 228 - greenColor := color.RGBA{34, 139, 34, 255} 229 - additionsText := fmt.Sprintf("+%d", diffStats.Insertions) 230 - err = statusArea.DrawTextAt(additionsText, currentX, textY, greenColor, textSize, ogcard.Top, ogcard.Left) 231 - if err != nil { 232 - log.Printf("failed to draw additions text: %v", err) 233 - } 234 - 235 - additionsTextWidth := len(additionsText) * 20 236 - currentX += additionsTextWidth + 30 237 - 238 - // Draw deletions (red -) right next to additions 239 - redColor := color.RGBA{220, 20, 60, 255} 240 - deletionsText := fmt.Sprintf("-%d", diffStats.Deletions) 241 - err = statusArea.DrawTextAt(deletionsText, currentX, textY, redColor, textSize, ogcard.Top, ogcard.Left) 242 - if err != nil { 243 - log.Printf("failed to draw deletions text: %v", err) 244 - } 245 - 246 - // Draw dolly logo on the right side 247 - dollyBounds := dollyArea.Img.Bounds() 248 - dollySize := 90 249 - dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 250 - dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 251 - dollyColor := color.RGBA{180, 180, 180, 255} // light gray 252 - err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor) 253 - if err != nil { 254 - log.Printf("dolly silhouette not available (this is ok): %v", err) 255 - } 256 - 257 - // Draw "opened by @author" and date at the bottom with more spacing 258 - labelY := statsY + iconSize + 30 259 - 260 - // Format the opened date 261 - openedDate := pull.Created.Format("Jan 2, 2006") 262 - metaText := fmt.Sprintf("opened by %s · %s", authorHandle, openedDate) 263 - 264 - err = statusArea.DrawTextAt(metaText, statsX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Left) 265 - if err != nil { 266 - log.Printf("failed to draw metadata: %v", err) 267 - } 268 - 269 - return mainCard, nil 270 - } 271 - 272 - func (s *Pulls) PullOpenGraphSummary(w http.ResponseWriter, r *http.Request) { 273 - f, err := s.repoResolver.Resolve(r) 274 - if err != nil { 275 - log.Println("failed to get repo and knot", err) 276 - return 52 + status = "closed" 277 53 } 278 54 279 - pull, ok := r.Context().Value("pull").(*models.Pull) 280 - if !ok { 281 - log.Println("pull not found in context") 282 - http.Error(w, "pull not found", http.StatusNotFound) 283 - return 284 - } 55 + var filesChanged int 56 + var additions int64 57 + var deletions int64 285 58 286 - // Calculate diff stats from latest submission using patchutil 287 - var diffStats types.DiffFileStat 288 - filesChanged := 0 289 59 if len(pull.Submissions) > 0 { 290 60 latestSubmission := pull.LatestSubmission() 291 61 niceDiff := patchutil.AsNiceDiff(latestSubmission.Patch, pull.TargetBranch) 292 - diffStats.Insertions = int64(niceDiff.Stat.Insertions) 293 - diffStats.Deletions = int64(niceDiff.Stat.Deletions) 294 62 filesChanged = niceDiff.Stat.FilesChanged 63 + additions = int64(niceDiff.Stat.Insertions) 64 + deletions = int64(niceDiff.Stat.Deletions) 295 65 } 296 66 297 - card, err := s.drawPullSummaryCard(pull, f, diffStats, filesChanged) 298 - if err != nil { 299 - log.Println("failed to draw pull summary card", err) 300 - http.Error(w, "failed to draw pull summary card", http.StatusInternalServerError) 301 - return 67 + commentCount := pull.TotalComments() 68 + 69 + rounds := len(pull.Submissions) 70 + if rounds == 0 { 71 + rounds = 1 302 72 } 303 73 304 - var imageBuffer bytes.Buffer 305 - err = png.Encode(&imageBuffer, card.Img) 74 + payload := ogcard.PullRequestCardPayload{ 75 + Type: "pullRequest", 76 + RepoName: f.Name, 77 + OwnerHandle: ownerHandle, 78 + AvatarUrl: avatarUrl, 79 + Title: pull.Title, 80 + PullRequestNumber: pull.PullId, 81 + Status: status, 82 + FilesChanged: filesChanged, 83 + Additions: int(additions), 84 + Deletions: int(deletions), 85 + Rounds: rounds, 86 + CommentCount: commentCount, 87 + ReactionCount: 0, 88 + CreatedAt: pull.Created.Format(time.RFC3339), 89 + } 90 + 91 + imageBytes, err := s.ogcardClient.RenderPullRequestCard(r.Context(), payload) 306 92 if err != nil { 307 - log.Println("failed to encode pull summary card", err) 308 - http.Error(w, "failed to encode pull summary card", http.StatusInternalServerError) 93 + log.Println("failed to render pull request card", err) 94 + http.Error(w, "failed to render pull request card", http.StatusInternalServerError) 309 95 return 310 96 } 311 97 312 - imageBytes := imageBuffer.Bytes() 313 - 314 98 w.Header().Set("Content-Type", "image/png") 315 - w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour 99 + w.Header().Set("Cache-Control", "public, max-age=3600") 316 100 w.WriteHeader(http.StatusOK) 317 101 _, err = w.Write(imageBytes) 318 102 if err != nil { 319 - log.Println("failed to write pull summary card", err) 103 + log.Println("failed to write pull request card", err) 320 104 return 321 105 } 322 106 }
+3
appview/pulls/pulls.go
··· 26 26 "tangled.org/core/appview/models" 27 27 "tangled.org/core/appview/notify" 28 28 "tangled.org/core/appview/oauth" 29 + "tangled.org/core/appview/ogcard" 29 30 "tangled.org/core/appview/pages" 30 31 "tangled.org/core/appview/pages/markup" 31 32 "tangled.org/core/appview/pages/repoinfo" ··· 65 66 logger *slog.Logger 66 67 validator *validator.Validator 67 68 indexer *pulls_indexer.Indexer 69 + ogcardClient *ogcard.Client 68 70 } 69 71 70 72 func New( ··· 94 96 logger: logger, 95 97 validator: validator, 96 98 indexer: indexer, 99 + ogcardClient: ogcard.NewClient(config.Ogcard.Host), 97 100 } 98 101 } 99 102
+34 -327
appview/repo/opengraph.go
··· 1 1 package repo 2 2 3 3 import ( 4 - "bytes" 5 4 "context" 6 - "encoding/hex" 7 - "fmt" 8 - "image/color" 9 - "image/png" 10 5 "log" 11 6 "net/http" 12 7 "sort" 13 - "strings" 8 + "time" 14 9 15 10 "github.com/go-enry/go-enry/v2" 16 11 "tangled.org/core/appview/db" 17 - "tangled.org/core/appview/models" 18 12 "tangled.org/core/appview/ogcard" 19 13 "tangled.org/core/orm" 20 14 "tangled.org/core/types" 21 15 ) 22 16 23 - func (rp *Repo) drawRepoSummaryCard(repo *models.Repo, languageStats []types.RepoLanguageDetails) (*ogcard.Card, error) { 24 - width, height := ogcard.DefaultSize() 25 - mainCard, err := ogcard.NewCard(width, height) 17 + func (rp *Repo) Opengraph(w http.ResponseWriter, r *http.Request) { 18 + f, err := rp.repoResolver.Resolve(r) 26 19 if err != nil { 27 - return nil, err 20 + log.Println("failed to get repo and knot", err) 21 + return 28 22 } 29 23 30 - // Split: content area (75%) and language bar + icons (25%) 31 - contentCard, bottomArea := mainCard.Split(false, 75) 32 - 33 - // Add padding to content 34 - contentCard.SetMargin(50) 35 - 36 - // Split content horizontally: main content (80%) and avatar area (20%) 37 - mainContent, avatarArea := contentCard.Split(true, 80) 38 - 39 - // Use main content area for both repo name and description to allow dynamic wrapping. 40 - mainContent.SetMargin(10) 41 - 42 24 var ownerHandle string 43 - owner, err := rp.idResolver.ResolveIdent(context.Background(), repo.Did) 25 + owner, err := rp.idResolver.ResolveIdent(context.Background(), f.Did) 44 26 if err != nil { 45 - ownerHandle = repo.Did 27 + ownerHandle = f.Did 46 28 } else { 47 29 ownerHandle = "@" + owner.Handle.String() 48 30 } 49 31 50 - bounds := mainContent.Img.Bounds() 51 - startX := bounds.Min.X + mainContent.Margin 52 - startY := bounds.Min.Y + mainContent.Margin 53 - currentX := startX 54 - currentY := startY 55 - lineHeight := 64 // Font size 54 + padding 56 - textColor := color.RGBA{88, 96, 105, 255} 57 - 58 - // Draw owner handle 59 - ownerWidth, err := mainContent.DrawTextAtWithWidth(ownerHandle, currentX, currentY, textColor, 54, ogcard.Top, ogcard.Left) 60 - if err != nil { 61 - return nil, err 62 - } 63 - currentX += ownerWidth 64 - 65 - // Draw separator 66 - sepWidth, err := mainContent.DrawTextAtWithWidth(" / ", currentX, currentY, textColor, 54, ogcard.Top, ogcard.Left) 67 - if err != nil { 68 - return nil, err 69 - } 70 - currentX += sepWidth 71 - 72 - words := strings.Fields(repo.Name) 73 - spaceWidth, _ := mainContent.DrawTextAtWithWidth(" ", -1000, -1000, color.Black, 54, ogcard.Top, ogcard.Left) 74 - if spaceWidth == 0 { 75 - spaceWidth = 15 76 - } 77 - 78 - for _, word := range words { 79 - // estimate bold width by measuring regular width and adding a multiplier 80 - regularWidth, _ := mainContent.DrawTextAtWithWidth(word, -1000, -1000, color.Black, 54, ogcard.Top, ogcard.Left) 81 - estimatedBoldWidth := int(float64(regularWidth) * 1.15) // Heuristic for bold text 82 - 83 - if currentX+estimatedBoldWidth > (bounds.Max.X - mainContent.Margin) { 84 - currentX = startX 85 - currentY += lineHeight 86 - } 87 - 88 - _, err := mainContent.DrawBoldText(word, currentX, currentY, color.Black, 54, ogcard.Top, ogcard.Left) 89 - if err != nil { 90 - return nil, err 91 - } 92 - currentX += estimatedBoldWidth + spaceWidth 93 - } 94 - 95 - // update Y position for the description 96 - currentY += lineHeight 97 - 98 - // draw description 99 - if currentY < bounds.Max.Y-mainContent.Margin { 100 - totalHeight := float64(bounds.Dy()) 101 - repoNameHeight := float64(currentY - bounds.Min.Y) 102 - 103 - if totalHeight > 0 && repoNameHeight < totalHeight { 104 - repoNamePercent := (repoNameHeight / totalHeight) * 100 105 - if repoNamePercent < 95 { // Ensure there's space left for description 106 - _, descriptionCard := mainContent.Split(false, int(repoNamePercent)) 107 - descriptionCard.SetMargin(8) 32 + avatarUrl := rp.pages.AvatarUrl(ownerHandle, "256") 108 33 109 - description := repo.Description 110 - if len(description) > 70 { 111 - description = description[:70] + "…" 112 - } 113 - 114 - _, err = descriptionCard.DrawText(description, color.RGBA{88, 96, 105, 255}, 36, ogcard.Top, ogcard.Left) 115 - if err != nil { 116 - log.Printf("failed to draw description: %v", err) 117 - } 118 - } 119 - } 120 - } 121 - 122 - // Draw avatar circle on the right side 123 - avatarBounds := avatarArea.Img.Bounds() 124 - avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin 125 - if avatarSize > 220 { 126 - avatarSize = 220 127 - } 128 - avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2) 129 - avatarY := avatarBounds.Min.Y + 20 130 - 131 - // Get avatar URL and draw it 132 - avatarURL := rp.pages.AvatarUrl(ownerHandle, "256") 133 - err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize) 134 - if err != nil { 135 - log.Printf("failed to draw avatar (non-fatal): %v", err) 136 - } 137 - 138 - // Split bottom area: icons area (65%) and language bar (35%) 139 - iconsArea, languageBarCard := bottomArea.Split(false, 75) 140 - 141 - // Split icons area: left side for stats (80%), right side for dolly (20%) 142 - statsArea, dollyArea := iconsArea.Split(true, 80) 143 - 144 - // Draw stats with icons in the stats area 145 - starsText := repo.RepoStats.StarCount 146 - issuesText := repo.RepoStats.IssueCount.Open 147 - pullRequestsText := repo.RepoStats.PullCount.Open 148 - 149 - iconColor := color.RGBA{88, 96, 105, 255} 150 - iconSize := 36 151 - textSize := 36.0 152 - 153 - // Position stats in the middle of the stats area 154 - statsBounds := statsArea.Img.Bounds() 155 - statsX := statsBounds.Min.X + 60 // left padding 156 - statsY := statsBounds.Min.Y 157 - currentX = statsX 158 - labelSize := 22.0 159 - // Draw star icon, count, and label 160 - // Align icon baseline with text baseline 161 - iconBaselineOffset := int(textSize) / 2 162 - err = statsArea.DrawLucideIcon("star", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 163 - if err != nil { 164 - log.Printf("failed to draw star icon: %v", err) 165 - } 166 - starIconX := currentX 167 - currentX += iconSize + 15 168 - 169 - starText := fmt.Sprintf("%d", starsText) 170 - err = statsArea.DrawTextAt(starText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) 171 - if err != nil { 172 - log.Printf("failed to draw star text: %v", err) 173 - } 174 - starTextWidth := len(starText) * 20 175 - starGroupWidth := iconSize + 15 + starTextWidth 176 - 177 - // Draw "stars" label below and centered under the icon+text group 178 - labelY := statsY + iconSize + 15 179 - labelX := starIconX + starGroupWidth/2 180 - err = iconsArea.DrawTextAt("stars", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center) 181 - if err != nil { 182 - log.Printf("failed to draw stars label: %v", err) 183 - } 184 - 185 - currentX += starTextWidth + 50 186 - 187 - // Draw issues icon, count, and label 188 - issueStartX := currentX 189 - err = statsArea.DrawLucideIcon("circle-dot", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 190 - if err != nil { 191 - log.Printf("failed to draw circle-dot icon: %v", err) 192 - } 193 - currentX += iconSize + 15 194 - 195 - issueText := fmt.Sprintf("%d", issuesText) 196 - err = statsArea.DrawTextAt(issueText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) 197 - if err != nil { 198 - log.Printf("failed to draw issue text: %v", err) 199 - } 200 - issueTextWidth := len(issueText) * 20 201 - issueGroupWidth := iconSize + 15 + issueTextWidth 202 - 203 - // Draw "issues" label below and centered under the icon+text group 204 - labelX = issueStartX + issueGroupWidth/2 205 - err = iconsArea.DrawTextAt("issues", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center) 206 - if err != nil { 207 - log.Printf("failed to draw issues label: %v", err) 208 - } 209 - 210 - currentX += issueTextWidth + 50 211 - 212 - // Draw pull request icon, count, and label 213 - prStartX := currentX 214 - err = statsArea.DrawLucideIcon("git-pull-request", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 215 - if err != nil { 216 - log.Printf("failed to draw git-pull-request icon: %v", err) 217 - } 218 - currentX += iconSize + 15 219 - 220 - prText := fmt.Sprintf("%d", pullRequestsText) 221 - err = statsArea.DrawTextAt(prText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) 222 - if err != nil { 223 - log.Printf("failed to draw PR text: %v", err) 224 - } 225 - prTextWidth := len(prText) * 20 226 - prGroupWidth := iconSize + 15 + prTextWidth 227 - 228 - // Draw "pulls" label below and centered under the icon+text group 229 - labelX = prStartX + prGroupWidth/2 230 - err = iconsArea.DrawTextAt("pulls", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center) 231 - if err != nil { 232 - log.Printf("failed to draw pulls label: %v", err) 233 - } 234 - 235 - dollyBounds := dollyArea.Img.Bounds() 236 - dollySize := 90 237 - dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 238 - dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 239 - dollyColor := color.RGBA{180, 180, 180, 255} // light gray 240 - err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor) 241 - if err != nil { 242 - log.Printf("dolly silhouette not available (this is ok): %v", err) 243 - } 244 - 245 - // Draw language bar at bottom 246 - err = drawLanguagesCard(languageBarCard, languageStats) 247 - if err != nil { 248 - log.Printf("failed to draw language bar: %v", err) 249 - return nil, err 250 - } 251 - 252 - return mainCard, nil 253 - } 254 - 255 - // hexToColor converts a hex color to a go color 256 - func hexToColor(colorStr string) (*color.RGBA, error) { 257 - colorStr = strings.TrimLeft(colorStr, "#") 258 - 259 - b, err := hex.DecodeString(colorStr) 260 - if err != nil { 261 - return nil, err 262 - } 263 - 264 - if len(b) < 3 { 265 - return nil, fmt.Errorf("expected at least 3 bytes from DecodeString, got %d", len(b)) 266 - } 267 - 268 - clr := color.RGBA{b[0], b[1], b[2], 255} 269 - 270 - return &clr, nil 271 - } 272 - 273 - func drawLanguagesCard(card *ogcard.Card, languageStats []types.RepoLanguageDetails) error { 274 - bounds := card.Img.Bounds() 275 - cardWidth := bounds.Dx() 276 - 277 - if len(languageStats) == 0 { 278 - // Draw a light gray bar if no languages detected 279 - card.DrawRect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, bounds.Max.Y, color.RGBA{225, 228, 232, 255}) 280 - return nil 281 - } 282 - 283 - // Limit to top 5 languages for the visual bar 284 - displayLanguages := languageStats 285 - if len(displayLanguages) > 5 { 286 - displayLanguages = displayLanguages[:5] 287 - } 288 - 289 - currentX := bounds.Min.X 290 - 291 - for _, lang := range displayLanguages { 292 - var langColor *color.RGBA 293 - var err error 294 - 295 - if lang.Color != "" { 296 - langColor, err = hexToColor(lang.Color) 297 - if err != nil { 298 - // Fallback to a default color 299 - langColor = &color.RGBA{149, 157, 165, 255} 300 - } 301 - } else { 302 - // Default color if no color specified 303 - langColor = &color.RGBA{149, 157, 165, 255} 304 - } 305 - 306 - langWidth := float32(cardWidth) * (lang.Percentage / 100) 307 - card.DrawRect(currentX, bounds.Min.Y, currentX+int(langWidth), bounds.Max.Y, langColor) 308 - currentX += int(langWidth) 309 - } 310 - 311 - // Fill remaining space with the last color (if any gap due to rounding) 312 - if currentX < bounds.Max.X && len(displayLanguages) > 0 { 313 - lastLang := displayLanguages[len(displayLanguages)-1] 314 - var lastColor *color.RGBA 315 - var err error 316 - 317 - if lastLang.Color != "" { 318 - lastColor, err = hexToColor(lastLang.Color) 319 - if err != nil { 320 - lastColor = &color.RGBA{149, 157, 165, 255} 321 - } 322 - } else { 323 - lastColor = &color.RGBA{149, 157, 165, 255} 324 - } 325 - card.DrawRect(currentX, bounds.Min.Y, bounds.Max.X, bounds.Max.Y, lastColor) 326 - } 327 - 328 - return nil 329 - } 330 - 331 - func (rp *Repo) Opengraph(w http.ResponseWriter, r *http.Request) { 332 - f, err := rp.repoResolver.Resolve(r) 333 - if err != nil { 334 - log.Println("failed to get repo and knot", err) 335 - return 336 - } 337 - 338 - // Get language stats directly from database 339 34 var languageStats []types.RepoLanguageDetails 340 35 langs, err := db.GetRepoLanguages( 341 36 rp.db, ··· 344 39 ) 345 40 if err != nil { 346 41 log.Printf("failed to get language stats from db: %v", err) 347 - // non-fatal, continue without language stats 348 42 } else if len(langs) > 0 { 349 43 var total int64 350 44 for _, l := range langs { ··· 375 69 }) 376 70 } 377 71 378 - card, err := rp.drawRepoSummaryCard(f, languageStats) 379 - if err != nil { 380 - log.Println("failed to draw repo summary card", err) 381 - http.Error(w, "failed to draw repo summary card", http.StatusInternalServerError) 382 - return 72 + var ogLanguages []ogcard.LanguageData 73 + for _, lang := range languageStats { 74 + if len(ogLanguages) >= 5 { 75 + break 76 + } 77 + ogLanguages = append(ogLanguages, ogcard.LanguageData{ 78 + Color: lang.Color, 79 + Percentage: lang.Percentage, 80 + }) 81 + } 82 + 83 + payload := ogcard.RepositoryCardPayload{ 84 + Type: "repository", 85 + RepoName: f.Name, 86 + OwnerHandle: ownerHandle, 87 + Stars: f.RepoStats.StarCount, 88 + Pulls: f.RepoStats.PullCount.Open, 89 + Issues: f.RepoStats.IssueCount.Open, 90 + CreatedAt: f.Created.Format(time.RFC3339), 91 + AvatarUrl: avatarUrl, 92 + Languages: ogLanguages, 383 93 } 384 94 385 - var imageBuffer bytes.Buffer 386 - err = png.Encode(&imageBuffer, card.Img) 95 + imageBytes, err := rp.ogcardClient.RenderRepositoryCard(r.Context(), payload) 387 96 if err != nil { 388 - log.Println("failed to encode repo summary card", err) 389 - http.Error(w, "failed to encode repo summary card", http.StatusInternalServerError) 97 + log.Println("failed to render repository card", err) 98 + http.Error(w, "failed to render repository card", http.StatusInternalServerError) 390 99 return 391 100 } 392 101 393 - imageBytes := imageBuffer.Bytes() 394 - 395 102 w.Header().Set("Content-Type", "image/png") 396 - w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour 103 + w.Header().Set("Cache-Control", "public, max-age=3600") 397 104 w.WriteHeader(http.StatusOK) 398 105 _, err = w.Write(imageBytes) 399 106 if err != nil { 400 - log.Println("failed to write repo summary card", err) 107 + log.Println("failed to write repository card", err) 401 108 return 402 109 } 403 110 }
+3
appview/repo/repo.go
··· 20 20 "tangled.org/core/appview/models" 21 21 "tangled.org/core/appview/notify" 22 22 "tangled.org/core/appview/oauth" 23 + "tangled.org/core/appview/ogcard" 23 24 "tangled.org/core/appview/pages" 24 25 "tangled.org/core/appview/reporesolver" 25 26 "tangled.org/core/appview/validator" ··· 53 54 serviceAuth *serviceauth.ServiceAuth 54 55 validator *validator.Validator 55 56 cfClient *cloudflare.Client 57 + ogcardClient *ogcard.Client 56 58 } 57 59 58 60 func New( ··· 82 84 logger: logger, 83 85 validator: validator, 84 86 cfClient: cfClient, 87 + ogcardClient: ogcard.NewClient(config.Ogcard.Host), 85 88 } 86 89 } 87 90
+20 -17
avatar/src/index.js
··· 150 150 151 151 const size = searchParams.get("size"); 152 152 const resizeToTiny = size === "tiny"; 153 + const format = searchParams.get("format") || "webp"; 154 + const validFormats = ["webp", "jpeg", "png"]; 155 + const outputFormat = validFormats.includes(format) ? format : "webp"; 156 + 157 + const contentTypes = { 158 + webp: "image/webp", 159 + jpeg: "image/jpeg", 160 + png: "image/png", 161 + }; 153 162 154 163 const cache = caches.default; 155 164 let cacheKey = request.url; ··· 242 251 243 252 // Fetch and optionally resize the avatar 244 253 let avatarResponse; 245 - if (resizeToTiny) { 246 - avatarResponse = await fetch(avatarUrl, { 247 - // cf: { 248 - // image: { 249 - // width: 32, 250 - // height: 32, 251 - // fit: "cover", 252 - // format: "webp", 253 - // }, 254 - // }, 255 - }); 256 - } else { 257 - avatarResponse = await fetch(avatarUrl); 258 - } 254 + const cfOptions = outputFormat !== "webp" || resizeToTiny ? { 255 + cf: { 256 + image: { 257 + format: outputFormat, 258 + ...(resizeToTiny ? { width: 32, height: 32, fit: "cover" } : {}), 259 + }, 260 + }, 261 + }: {}; 262 + 263 + avatarResponse = await fetch(avatarUrl, cfOptions); 259 264 260 265 if (!avatarResponse.ok) { 261 266 return new Response(`failed to fetch avatar for ${actor}.`, { ··· 264 269 } 265 270 266 271 const avatarData = await avatarResponse.arrayBuffer(); 267 - const contentType = 268 - avatarResponse.headers.get("content-type") || "image/jpeg"; 269 272 270 273 response = new Response(avatarData, { 271 274 headers: { 272 - "Content-Type": contentType, 275 + "Content-Type": contentTypes[outputFormat], 273 276 "Cache-Control": "public, max-age=43200", 274 277 }, 275 278 });