diff --git a/appview/config/config.go b/appview/config/config.go --- a/appview/config/config.go +++ b/appview/config/config.go @@ -139,6 +139,10 @@ UpdateInterval time.Duration `env:"UPDATE_INTERVAL, default=1h"` } +type OgcardConfig struct { + Host string `env:"HOST, default=https://og.tangled.org"` +} + func (cfg RedisConfig) ToURL() string { u := &url.URL{ Scheme: "redis", @@ -171,6 +175,7 @@ Bluesky BlueskyConfig `env:",prefix=TANGLED_BLUESKY_"` Sites SitesConfig `env:",prefix=TANGLED_SITES_"` KnotMirror KnotMirrorConfig `env:",prefix=TANGLED_KNOTMIRROR_"` + Ogcard OgcardConfig `env:",prefix=TANGLED_OGCARD_"` } func LoadConfig(ctx context.Context) (*Config, error) { diff --git a/appview/issues/issues.go b/appview/issues/issues.go --- a/appview/issues/issues.go +++ b/appview/issues/issues.go @@ -23,6 +23,7 @@ "tangled.org/core/appview/models" "tangled.org/core/appview/notify" "tangled.org/core/appview/oauth" + "tangled.org/core/appview/ogcard" "tangled.org/core/appview/pages" "tangled.org/core/appview/pages/repoinfo" "tangled.org/core/appview/pagination" @@ -48,6 +49,7 @@ logger *slog.Logger validator *validator.Validator indexer *issues_indexer.Indexer + ogcardClient *ogcard.Client } func New( @@ -77,6 +79,7 @@ logger: logger, validator: validator, indexer: indexer, + ogcardClient: ogcard.NewClient(config.Ogcard.Host), } } diff --git a/appview/issues/opengraph.go b/appview/issues/opengraph.go --- a/appview/issues/opengraph.go +++ b/appview/issues/opengraph.go @@ -1,225 +1,14 @@ package issues import ( - "bytes" "context" - "fmt" - "image" - "image/color" - "image/png" "log" "net/http" + "time" "tangled.org/core/appview/models" "tangled.org/core/appview/ogcard" ) - -func (rp *Issues) drawIssueSummaryCard(issue *models.Issue, repo *models.Repo, commentCount int, ownerHandle string) (*ogcard.Card, error) { - width, height := ogcard.DefaultSize() - mainCard, err := ogcard.NewCard(width, height) - if err != nil { - return nil, err - } - - // Split: content area (75%) and status/stats area (25%) - contentCard, statsArea := mainCard.Split(false, 75) - - // Add padding to content - contentCard.SetMargin(50) - - // Split content horizontally: main content (80%) and avatar area (20%) - mainContent, avatarArea := contentCard.Split(true, 80) - - // Add margin to main content like repo card - mainContent.SetMargin(10) - - // Use full main content area for repo name and title - bounds := mainContent.Img.Bounds() - startX := bounds.Min.X + mainContent.Margin - startY := bounds.Min.Y + mainContent.Margin - - // Draw full repository name at top (owner/repo format) - var repoOwner string - owner, err := rp.idResolver.ResolveIdent(context.Background(), repo.Did) - if err != nil { - repoOwner = repo.Did - } else { - repoOwner = "@" + owner.Handle.String() - } - - fullRepoName := repoOwner + " / " + repo.Name - if len(fullRepoName) > 60 { - fullRepoName = fullRepoName[:60] + "…" - } - - grayColor := color.RGBA{88, 96, 105, 255} - err = mainContent.DrawTextAt(fullRepoName, startX, startY, grayColor, 36, ogcard.Top, ogcard.Left) - if err != nil { - return nil, err - } - - // Draw issue title below repo name with wrapping - titleY := startY + 60 - titleX := startX - - // Truncate title if too long - issueTitle := issue.Title - maxTitleLength := 80 - if len(issueTitle) > maxTitleLength { - issueTitle = issueTitle[:maxTitleLength] + "…" - } - - // Create a temporary card for the title area to enable wrapping - titleBounds := mainContent.Img.Bounds() - titleWidth := titleBounds.Dx() - (startX - titleBounds.Min.X) - 20 // Leave some margin - titleHeight := titleBounds.Dy() - (titleY - titleBounds.Min.Y) - 100 // Leave space for issue ID - - titleRect := image.Rect(titleX, titleY, titleX+titleWidth, titleY+titleHeight) - titleCard := &ogcard.Card{ - Img: mainContent.Img.SubImage(titleRect).(*image.RGBA), - Font: mainContent.Font, - Margin: 0, - } - - // Draw wrapped title - lines, err := titleCard.DrawText(issueTitle, color.Black, 54, ogcard.Top, ogcard.Left) - if err != nil { - return nil, err - } - - // Calculate where title ends (number of lines * line height) - lineHeight := 60 // Approximate line height for 54pt font - titleEndY := titleY + (len(lines) * lineHeight) + 10 - - // Draw issue ID in gray below the title - issueIdText := fmt.Sprintf("#%d", issue.IssueId) - err = mainContent.DrawTextAt(issueIdText, startX, titleEndY, grayColor, 54, ogcard.Top, ogcard.Left) - if err != nil { - return nil, err - } - - // Get issue author handle (needed for avatar and metadata) - var authorHandle string - author, err := rp.idResolver.ResolveIdent(context.Background(), issue.Did) - if err != nil { - authorHandle = issue.Did - } else { - authorHandle = "@" + author.Handle.String() - } - - // Draw avatar circle on the right side - avatarBounds := avatarArea.Img.Bounds() - avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin - if avatarSize > 220 { - avatarSize = 220 - } - avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2) - avatarY := avatarBounds.Min.Y + 20 - - // Get avatar URL for issue author - avatarURL := rp.pages.AvatarUrl(authorHandle, "256") - err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize) - if err != nil { - log.Printf("failed to draw avatar (non-fatal): %v", err) - } - - // Split stats area: left side for status/comments (80%), right side for dolly (20%) - statusArea, dollyArea := statsArea.Split(true, 80) - - // Draw status and comment count in status/comments area - statsBounds := statusArea.Img.Bounds() - statsX := statsBounds.Min.X + 60 // left padding - statsY := statsBounds.Min.Y - - iconColor := color.RGBA{88, 96, 105, 255} - iconSize := 36 - textSize := 36.0 - labelSize := 28.0 - iconBaselineOffset := int(textSize) / 2 - - // Draw status (open/closed) with colored icon and text - var statusIcon string - var statusText string - var statusColor color.RGBA - - if issue.Open { - statusIcon = "circle-dot" - statusText = "open" - statusColor = color.RGBA{34, 139, 34, 255} // green - } else { - statusIcon = "ban" - statusText = "closed" - statusColor = color.RGBA{52, 58, 64, 255} // dark gray - } - - statusTextWidth := statusArea.TextWidth(statusText, textSize) - badgePadding := 12 - badgeHeight := int(textSize) + (badgePadding * 2) - badgeWidth := iconSize + badgePadding + statusTextWidth + (badgePadding * 2) - cornerRadius := 8 - badgeX := 60 - badgeY := 0 - - statusArea.DrawRoundedRect(badgeX, badgeY, badgeWidth, badgeHeight, cornerRadius, statusColor) - - whiteColor := color.RGBA{255, 255, 255, 255} - iconX := statsX + badgePadding - iconY := statsY + (badgeHeight-iconSize)/2 - err = statusArea.DrawLucideIcon(statusIcon, iconX, iconY, iconSize, whiteColor) - if err != nil { - log.Printf("failed to draw status icon: %v", err) - } - - textX := statsX + badgePadding + iconSize + badgePadding - textY := statsY + (badgeHeight-int(textSize))/2 - 5 - err = statusArea.DrawTextAt(statusText, textX, textY, whiteColor, textSize, ogcard.Top, ogcard.Left) - if err != nil { - log.Printf("failed to draw status text: %v", err) - } - - currentX := statsX + badgeWidth + 50 - - // Draw comment count - err = statusArea.DrawLucideIcon("message-square", currentX, iconY, iconSize, iconColor) - if err != nil { - log.Printf("failed to draw comment icon: %v", err) - } - - currentX += iconSize + 15 - commentText := fmt.Sprintf("%d comments", commentCount) - if commentCount == 1 { - commentText = "1 comment" - } - err = statusArea.DrawTextAt(commentText, currentX, textY, iconColor, textSize, ogcard.Top, ogcard.Left) - if err != nil { - log.Printf("failed to draw comment text: %v", err) - } - - // Draw dolly logo on the right side - dollyBounds := dollyArea.Img.Bounds() - dollySize := 90 - dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) - dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 - dollyColor := color.RGBA{180, 180, 180, 255} // light gray - err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor) - if err != nil { - log.Printf("dolly not available (this is ok): %v", err) - } - - // Draw "opened by @author" and date at the bottom with more spacing - labelY := statsY + iconSize + 30 - - // Format the opened date - openedDate := issue.Created.Format("Jan 2, 2006") - metaText := fmt.Sprintf("opened by %s · %s", authorHandle, openedDate) - - err = statusArea.DrawTextAt(metaText, statsX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Left) - if err != nil { - log.Printf("failed to draw metadata: %v", err) - } - - return mainCard, nil -} func (rp *Issues) IssueOpenGraphSummary(w http.ResponseWriter, r *http.Request) { f, err := rp.repoResolver.Resolve(r) @@ -235,41 +24,58 @@ return } - // Get comment count - commentCount := len(issue.Comments) - - // Get owner handle for avatar var ownerHandle string - owner, err := rp.idResolver.ResolveIdent(r.Context(), f.Did) + owner, err := rp.idResolver.ResolveIdent(context.Background(), f.Did) if err != nil { ownerHandle = f.Did } else { ownerHandle = "@" + owner.Handle.String() } - card, err := rp.drawIssueSummaryCard(issue, f, commentCount, ownerHandle) + var authorHandle string + author, err := rp.idResolver.ResolveIdent(context.Background(), issue.Did) if err != nil { - log.Println("failed to draw issue summary card", err) - http.Error(w, "failed to draw issue summary card", http.StatusInternalServerError) - return + authorHandle = issue.Did + } else { + authorHandle = "@" + author.Handle.String() } - var imageBuffer bytes.Buffer - err = png.Encode(&imageBuffer, card.Img) - if err != nil { - log.Println("failed to encode issue summary card", err) - http.Error(w, "failed to encode issue summary card", http.StatusInternalServerError) - return + avatarUrl := rp.pages.AvatarUrl(authorHandle, "256") + + status := "closed" + if issue.Open { + status = "open" } - imageBytes := imageBuffer.Bytes() + commentCount := len(issue.Comments) + + payload := ogcard.IssueCardPayload{ + Type: "issue", + RepoName: f.Name, + OwnerHandle: ownerHandle, + AvatarUrl: avatarUrl, + Title: issue.Title, + IssueNumber: issue.IssueId, + Status: status, + Labels: []ogcard.LabelData{}, + CommentCount: commentCount, + ReactionCount: 0, + CreatedAt: issue.Created.Format(time.RFC3339), + } + + imageBytes, err := rp.ogcardClient.RenderIssueCard(r.Context(), payload) + if err != nil { + log.Println("failed to render issue card", err) + http.Error(w, "failed to render issue card", http.StatusInternalServerError) + return + } w.Header().Set("Content-Type", "image/png") - w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour + w.Header().Set("Cache-Control", "public, max-age=3600") w.WriteHeader(http.StatusOK) _, err = w.Write(imageBytes) if err != nil { - log.Println("failed to write issue summary card", err) + log.Println("failed to write issue card", err) return } } diff --git a/appview/ogcard/.gitignore b/appview/ogcard/.gitignore new file mode 100644 --- /dev/null +++ b/appview/ogcard/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +output/ +.wrangler/ +.DS_Store diff --git a/appview/ogcard/bun.lock b/appview/ogcard/bun.lock new file mode 100644 --- /dev/null +++ b/appview/ogcard/bun.lock @@ -0,0 +1,492 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@tangled/ogcard-worker", + "dependencies": { + "@fontsource/inter": "^5.2.8", + "@resvg/resvg-wasm": "^2.6.2", + "@tangled/ogcard-runtime": "*", + "lucide-static": "^0.577.0", + "preact": "^10.29.0", + "satori": "0.25.0", + "zod": "^4.3.6", + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@types/bun": "^1.3.11", + "@types/node": "^25.5.0", + "knip": "^6.0.1", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "wrangler": "^4.75.0", + }, + }, + "packages/runtime": { + "name": "@tangled/ogcard-runtime", + "version": "1.0.0", + }, + }, + "packages": { + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="], + + "@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=="], + + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260317.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-8hjh3sPMwY8M/zedq3/sXoA2Q4BedlGufn3KOOleIG+5a4ReQKLlUah140D7J6zlKmYZAFMJ4tWC7hCuI/s79g=="], + + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260317.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-M/MnNyvO5HMgoIdr3QHjdCj2T1ki9gt0vIUnxYxBu9ISXS/jgtMl6chUVPJ7zHYBn9MyYr8ByeN6frjYxj0MGg=="], + + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260317.1", "", { "os": "linux", "cpu": "x64" }, "sha512-1ltuEjkRcS3fsVF7CxsKlWiRmzq2ZqMfqDN0qUOgbUwkpXsLVJsXmoblaLf5OP00ELlcgF0QsN0p2xPEua4Uug=="], + + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260317.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-3QrNnPF1xlaNwkHpasvRvAMidOvQs2NhXQmALJrEfpIJ/IDL2la8g499yXp3eqhG3hVMCB07XVY149GTs42Xtw=="], + + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260317.1", "", { "os": "win32", "cpu": "x64" }, "sha512-MfZTz+7LfuIpMGTa3RLXHX8Z/pnycZLItn94WRdHr8LPVet+C5/1Nzei399w/jr3+kzT4pDKk26JF/tlI5elpQ=="], + + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260317.1", "", {}, "sha512-+G4eVwyCpm8Au1ex8vQBCuA9wnwqetz4tPNRoB/53qvktERWBRMQnrtvC1k584yRE3emMThtuY0gWshvSJ++PQ=="], + + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], + + "@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.9.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.4", "", { "os": "android", "cpu": "arm" }, "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.4", "", { "os": "android", "cpu": "arm64" }, "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.4", "", { "os": "android", "cpu": "x64" }, "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.4", "", { "os": "linux", "cpu": "arm" }, "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.4", "", { "os": "linux", "cpu": "x64" }, "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.4", "", { "os": "none", "cpu": "x64" }, "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="], + + "@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="], + + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@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=="], + + "@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=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@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=="], + + "@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=="], + + "@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=="], + + "@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=="], + + "@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=="], + + "@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=="], + + "@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=="], + + "@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=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], + + "@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=="], + + "@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=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@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=="], + + "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.120.0", "", { "os": "android", "cpu": "arm" }, "sha512-WU3qtINx802wOl8RxAF1v0VvmC2O4D9M8Sv486nLeQ7iPHVmncYZrtBhB4SYyX+XZxj2PNnCcN+PW21jHgiOxg=="], + + "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.120.0", "", { "os": "android", "cpu": "arm64" }, "sha512-SEf80EHdhlbjZEgzeWm0ZA/br4GKMenDW3QB/gtyeTV1gStvvZeFi40ioHDZvds2m4Z9J1bUAUL8yn1/+A6iGg=="], + + "@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.120.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xVrrbCai8R8CUIBu3CjryutQnEYhZqs1maIqDvtUCFZb8vY33H7uh9mHpL3a0JBIKoBUKjPH8+rzyAeXnS2d6A=="], + + "@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.120.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-xyHBbnJ6mydnQUH7MAcafOkkrNzQC6T+LXgDH/3InEq2BWl/g424IMRiJVSpVqGjB+p2bd0h0WRR8iIwzjU7rw=="], + + "@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.120.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMnVRllquXUYTeNfFKmxTTEdZ/ix1nLl0ducDzMSREoWYGVIHnOOxoKMWlCOvRr9Wk/HZqo2rh1jeumbPGPV9A=="], + + "@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.120.0", "", { "os": "linux", "cpu": "arm" }, "sha512-tkvn2CQ7QdcsMnpfiX3fd3wA3EFsWKYlcQzq9cFw/xc89Al7W6Y4O0FgLVkVQpo0Tnq/qtE1XfkJOnRRA9S/NA=="], + + "@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.120.0", "", { "os": "linux", "cpu": "arm" }, "sha512-WN5y135Ic42gQDk9grbwY9++fDhqf8knN6fnP+0WALlAUh4odY/BDK1nfTJRSfpJD9P3r1BwU0m3pW2DU89whQ=="], + + "@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.120.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-1GgQBCcXvFMw99EPdMy+4NZ3aYyXsxjf9kbUUg8HuAy3ZBXzOry5KfFEzT9nqmgZI1cuetvApkiJBZLAPo8uaw=="], + + "@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.120.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-gmMQ70gsPdDBgpcErvJEoWNBr7bJooSLlvOBVBSGfOzlP5NvJ3bFvnUeZZ9d+dPrqSngtonf7nyzWUTUj/U+lw=="], + + "@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=="], + + "@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.120.0", "", { "os": "linux", "cpu": "none" }, "sha512-vn21KXLAXzaI3N5CZWlBr1iWeXLl9QFIMor7S1hUjUGTeUuWCoE6JZB040/ZNDwf+JXPX8Ao9KbmJq9FMC2iGw=="], + + "@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.120.0", "", { "os": "linux", "cpu": "none" }, "sha512-SUbUxlar007LTGmSLGIC5x/WJvwhdX+PwNzFJ9f/nOzZOrCFbOT4ikt7pJIRg1tXVsEfzk5mWpGO1NFiSs4PIw=="], + + "@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.120.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-hYiPJTxyfJY2+lMBFk3p2bo0R9GN+TtpPFlRqVchL1qvLG+pznstramHNvJlw9AjaoRUHwp9IKR7UZQnRPGjgQ=="], + + "@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.120.0", "", { "os": "linux", "cpu": "x64" }, "sha512-q+5jSVZkprJCIy3dzJpApat0InJaoxQLsJuD6DkX8hrUS61z2lHQ1Fe9L2+TYbKHXCLWbL0zXe7ovkIdopBGMQ=="], + + "@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.120.0", "", { "os": "linux", "cpu": "x64" }, "sha512-D9QDDZNnH24e7X4ftSa6ar/2hCavETfW3uk0zgcMIrZNy459O5deTbWrjGzZiVrSWigGtlQwzs2McBP0QsfV1w=="], + + "@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.120.0", "", { "os": "none", "cpu": "arm64" }, "sha512-TBU8ZwOUWAOUWVfmI16CYWbvh4uQb9zHnGBHsw5Cp2JUVG044OIY1CSHODLifqzQIMTXvDvLzcL89GGdUIqNrA=="], + + "@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=="], + + "@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=="], + + "@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.120.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-L7vfLzbOXsjBXV0rv/6Y3Jd9BRjPeCivINZAqrSyAOZN3moCopDN+Psq9ZrGNZtJzP8946MtlRFZ0Als0wBCOw=="], + + "@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.120.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ys+upfqNtSu58huAhJMBKl3XCkGzyVFBlMlGPzHeFKgpFF/OdgNs1MMf8oaJIbgMH8ZxgGF7qfue39eJohmKIg=="], + + "@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="], + + "@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.19.1", "", { "os": "android", "cpu": "arm" }, "sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg=="], + + "@oxc-resolver/binding-android-arm64": ["@oxc-resolver/binding-android-arm64@11.19.1", "", { "os": "android", "cpu": "arm64" }, "sha512-oolbkRX+m7Pq2LNjr/kKgYeC7bRDMVTWPgxBGMjSpZi/+UskVo4jsMU3MLheZV55jL6c3rNelPl4oD60ggYmqA=="], + + "@oxc-resolver/binding-darwin-arm64": ["@oxc-resolver/binding-darwin-arm64@11.19.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-nUC6d2i3R5B12sUW4O646qD5cnMXf2oBGPLIIeaRfU9doJRORAbE2SGv4eW6rMqhD+G7nf2Y8TTJTLiiO3Q/dQ=="], + + "@oxc-resolver/binding-darwin-x64": ["@oxc-resolver/binding-darwin-x64@11.19.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-cV50vE5+uAgNcFa3QY1JOeKDSkM/9ReIcc/9wn4TavhW/itkDGrXhw9jaKnkQnGbjJ198Yh5nbX/Gr2mr4Z5jQ=="], + + "@oxc-resolver/binding-freebsd-x64": ["@oxc-resolver/binding-freebsd-x64@11.19.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-xZOQiYGFxtk48PBKff+Zwoym7ScPAIVp4c14lfLxizO2LTTTJe5sx9vQNGrBymrf/vatSPNMD4FgsaaRigPkqw=="], + + "@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=="], + + "@oxc-resolver/binding-linux-arm-musleabihf": ["@oxc-resolver/binding-linux-arm-musleabihf@11.19.1", "", { "os": "linux", "cpu": "arm" }, "sha512-veG1kKsuK5+t2IsO9q0DErYVSw2azvCVvWHnfTOS73WE0STdLLB7Q1bB9WR+yHPQM76ASkFyRbogWo1GR1+WbQ=="], + + "@oxc-resolver/binding-linux-arm64-gnu": ["@oxc-resolver/binding-linux-arm64-gnu@11.19.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-heV2+jmXyYnUrpUXSPugqWDRpnsQcDm2AX4wzTuvgdlZfoNYO0O3W2AVpJYaDn9AG4JdM6Kxom8+foE7/BcSig=="], + + "@oxc-resolver/binding-linux-arm64-musl": ["@oxc-resolver/binding-linux-arm64-musl@11.19.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jvo2Pjs1c9KPxMuMPIeQsgu0mOJF9rEb3y3TdpsrqwxRM+AN6/nDDwv45n5ZrUnQMsdBy5gIabioMKnQfWo9ew=="], + + "@oxc-resolver/binding-linux-ppc64-gnu": ["@oxc-resolver/binding-linux-ppc64-gnu@11.19.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-vLmdNxWCdN7Uo5suays6A/+ywBby2PWBBPXctWPg5V0+eVuzsJxgAn6MMB4mPlshskYbppjpN2Zg83ArHze9gQ=="], + + "@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=="], + + "@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=="], + + "@oxc-resolver/binding-linux-s390x-gnu": ["@oxc-resolver/binding-linux-s390x-gnu@11.19.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-EDpafVOQWF8/MJynsjOGFThcqhRHy417sRyLfQmeiamJ8qVhSKAn2Dn2VVKUGCjVB9C46VGjhNo7nOPUi1x6uA=="], + + "@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=="], + + "@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=="], + + "@oxc-resolver/binding-openharmony-arm64": ["@oxc-resolver/binding-openharmony-arm64@11.19.1", "", { "os": "none", "cpu": "arm64" }, "sha512-QF080IowFB0+9Rh6RcD19bdgh49BpQHUW5TajG1qvWHvmrQznTZZjYlgE2ltLXyKY+qs4F/v5xuX1XS7Is+3qA=="], + + "@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=="], + + "@oxc-resolver/binding-win32-arm64-msvc": ["@oxc-resolver/binding-win32-arm64-msvc@11.19.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-nJ4AsUVZrVKwnU/QRdzPCCrO0TrabBqgJ8pJhXITdZGYOV28TIYystV1VFLbQ7DtAcaBHpocT5/ZJnF78YJPtQ=="], + + "@oxc-resolver/binding-win32-ia32-msvc": ["@oxc-resolver/binding-win32-ia32-msvc@11.19.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-EW+ND5q2Tl+a3pH81l1QbfgbF3HmqgwLfDfVithRFheac8OTcnbXt/JxqD2GbDkb7xYEqy1zNaVFRr3oeG8npA=="], + + "@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=="], + + "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], + + "@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=="], + + "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], + + "@resvg/resvg-wasm": ["@resvg/resvg-wasm@2.6.2", "", {}, "sha512-FqALmHI8D4o6lk/LRWDnhw95z5eO+eAa6ORjVg09YRR7BkcM6oPHU9uyC0gtQG5vpFLvgpeU4+zEAz2H8APHNw=="], + + "@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=="], + + "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], + + "@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="], + + "@tangled/ogcard-runtime": ["@tangled/ogcard-runtime@workspace:packages/runtime"], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + + "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + + "base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="], + + "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + + "camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "css-background-parser": ["css-background-parser@0.1.0", "", {}, "sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA=="], + + "css-box-shadow": ["css-box-shadow@1.0.0-3", "", {}, "sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg=="], + + "css-color-keywords": ["css-color-keywords@1.0.0", "", {}, "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg=="], + + "css-gradient-parser": ["css-gradient-parser@0.0.17", "", {}, "sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg=="], + + "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=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "emoji-regex-xs": ["emoji-regex-xs@2.0.1", "", {}, "sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g=="], + + "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], + + "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=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "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=="], + + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + + "fd-package-json": ["fd-package-json@2.0.0", "", { "dependencies": { "walk-up-path": "^4.0.0" } }, "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ=="], + + "fflate": ["fflate@0.7.4", "", {}, "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "formatly": ["formatly@0.3.0", "", { "dependencies": { "fd-package-json": "^2.0.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], + + "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "hex-rgb": ["hex-rgb@4.3.0", "", {}, "sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "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=="], + + "linebreak": ["linebreak@1.1.0", "", { "dependencies": { "base64-js": "0.0.8", "unicode-trie": "^2.0.0" } }, "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ=="], + + "lucide-static": ["lucide-static@0.577.0", "", {}, "sha512-hx39J5Tq4JWF2ALY+5YRg+SxQLpeAmLJDXNcqiBJH/UuVwp43it9fyki/onZO7AVFgG5ZbB+fWwZR9mwGHE2XQ=="], + + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "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=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "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=="], + + "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=="], + + "pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="], + + "parse-css-color": ["parse-css-color@0.2.1", "", { "dependencies": { "color-name": "^1.1.4", "hex-rgb": "^4.1.0" } }, "sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg=="], + + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + + "preact": ["preact@10.29.0", "", {}, "sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "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=="], + + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "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=="], + + "smol-toml": ["smol-toml@1.6.0", "", {}, "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw=="], + + "string.prototype.codepointat": ["string.prototype.codepointat@0.2.1", "", {}, "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg=="], + + "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], + + "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + + "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "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=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "unbash": ["unbash@2.2.0", "", {}, "sha512-X2wH19RAPZE3+ldGicOkoj/SIA83OIxcJ6Cuaw23hf8Xc6fQpvZXY0SftE2JgS0QhYLUG4uwodSI3R53keyh7w=="], + + "undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], + + "unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="], + + "walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="], + + "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=="], + + "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=="], + + "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=="], + + "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + + "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + + "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=="], + + "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "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=="], + + "wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + + "wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + + "wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + + "wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + + "wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + + "wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + + "wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + + "wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + + "wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + + "wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + + "wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + + "wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + + "wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + + "wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + + "wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + + "wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + + "wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + + "wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + + "wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + + "wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + + "wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + + "wrangler/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + + "wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + + "wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + + "wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + + "wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + } +} diff --git a/appview/ogcard/card.go b/appview/ogcard/card.go deleted file mode 100644 --- a/appview/ogcard/card.go +++ /dev/null @@ -1,640 +0,0 @@ -// Copyright 2024 The Forgejo Authors. All rights reserved. -// Copyright 2025 The Tangled Authors -- repurposed for Tangled use. -// SPDX-License-Identifier: MIT - -package ogcard - -import ( - "bytes" - "fmt" - "html/template" - "image" - "image/color" - "io" - "log" - "math" - "net/http" - "strings" - "sync" - "time" - - "github.com/goki/freetype" - "github.com/goki/freetype/truetype" - "github.com/srwiley/oksvg" - "github.com/srwiley/rasterx" - "golang.org/x/image/draw" - "golang.org/x/image/font" - "tangled.org/core/appview/pages" - - _ "golang.org/x/image/webp" // for processing webp images -) - -type Card struct { - Img *image.RGBA - Font *truetype.Font - Margin int - Width int - Height int -} - -var fontCache = sync.OnceValues(func() (*truetype.Font, error) { - interVar, err := pages.Files.ReadFile("static/fonts/InterVariable.ttf") - if err != nil { - return nil, err - } - return truetype.Parse(interVar) -}) - -// DefaultSize returns the default size for a card -func DefaultSize() (int, int) { - return 1200, 630 -} - -// NewCard creates a new card with the given dimensions in pixels -func NewCard(width, height int) (*Card, error) { - img := image.NewRGBA(image.Rect(0, 0, width, height)) - draw.Draw(img, img.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) - - font, err := fontCache() - if err != nil { - return nil, err - } - - return &Card{ - Img: img, - Font: font, - Margin: 0, - Width: width, - Height: height, - }, nil -} - -// Split splits the card horizontally or vertically by a given percentage; the first card returned has the percentage -// size, and the second card has the remainder. Both cards draw to a subsection of the same image buffer. -func (c *Card) Split(vertical bool, percentage int) (*Card, *Card) { - bounds := c.Img.Bounds() - bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) - if vertical { - mid := (bounds.Dx() * percentage / 100) + bounds.Min.X - subleft := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, mid, bounds.Max.Y)).(*image.RGBA) - subright := c.Img.SubImage(image.Rect(mid, bounds.Min.Y, bounds.Max.X, bounds.Max.Y)).(*image.RGBA) - return &Card{Img: subleft, Font: c.Font, Width: subleft.Bounds().Dx(), Height: subleft.Bounds().Dy()}, - &Card{Img: subright, Font: c.Font, Width: subright.Bounds().Dx(), Height: subright.Bounds().Dy()} - } - mid := (bounds.Dy() * percentage / 100) + bounds.Min.Y - subtop := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, mid)).(*image.RGBA) - subbottom := c.Img.SubImage(image.Rect(bounds.Min.X, mid, bounds.Max.X, bounds.Max.Y)).(*image.RGBA) - return &Card{Img: subtop, Font: c.Font, Width: subtop.Bounds().Dx(), Height: subtop.Bounds().Dy()}, - &Card{Img: subbottom, Font: c.Font, Width: subbottom.Bounds().Dx(), Height: subbottom.Bounds().Dy()} -} - -// SetMargin sets the margins for the card -func (c *Card) SetMargin(margin int) { - c.Margin = margin -} - -type ( - VAlign int64 - HAlign int64 -) - -const ( - Top VAlign = iota - Middle - Bottom -) - -const ( - Left HAlign = iota - Center - Right -) - -// DrawText draws text within the card, respecting margins and alignment -func (c *Card) DrawText(text string, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) ([]string, error) { - ft := freetype.NewContext() - ft.SetDPI(72) - ft.SetFont(c.Font) - ft.SetFontSize(sizePt) - ft.SetClip(c.Img.Bounds()) - ft.SetDst(c.Img) - ft.SetSrc(image.NewUniform(textColor)) - - face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72}) - fontHeight := ft.PointToFixed(sizePt).Ceil() - - bounds := c.Img.Bounds() - bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) - boxWidth, boxHeight := bounds.Size().X, bounds.Size().Y - // draw.Draw(c.Img, bounds, image.NewUniform(color.Gray{128}), image.Point{}, draw.Src) // Debug draw box - - // Try to apply wrapping to this text; we'll find the most text that will fit into one line, record that line, move - // on. We precalculate each line before drawing so that we can support valign="middle" correctly which requires - // knowing the total height, which is related to how many lines we'll have. - lines := make([]string, 0) - textWords := strings.Split(text, " ") - currentLine := "" - heightTotal := 0 - - for { - if len(textWords) == 0 { - // Ran out of words. - if currentLine != "" { - heightTotal += fontHeight - lines = append(lines, currentLine) - } - break - } - - nextWord := textWords[0] - proposedLine := currentLine - if proposedLine != "" { - proposedLine += " " - } - proposedLine += nextWord - - proposedLineWidth := font.MeasureString(face, proposedLine) - if proposedLineWidth.Ceil() > boxWidth { - // no, proposed line is too big; we'll use the last "currentLine" - heightTotal += fontHeight - if currentLine != "" { - lines = append(lines, currentLine) - currentLine = "" - // leave nextWord in textWords and keep going - } else { - // just nextWord by itself doesn't fit on a line; well, we can't skip it, but we'll consume it - // regardless as a line by itself. It will be clipped by the drawing routine. - lines = append(lines, nextWord) - textWords = textWords[1:] - } - } else { - // yes, it will fit - currentLine = proposedLine - textWords = textWords[1:] - } - } - - textY := 0 - switch valign { - case Top: - textY = fontHeight - case Bottom: - textY = boxHeight - heightTotal + fontHeight - case Middle: - textY = ((boxHeight - heightTotal) / 2) + fontHeight - } - - for _, line := range lines { - lineWidth := font.MeasureString(face, line) - - textX := 0 - switch halign { - case Left: - textX = 0 - case Right: - textX = boxWidth - lineWidth.Ceil() - case Center: - textX = (boxWidth - lineWidth.Ceil()) / 2 - } - - pt := freetype.Pt(bounds.Min.X+textX, bounds.Min.Y+textY) - _, err := ft.DrawString(line, pt) - if err != nil { - return nil, err - } - - textY += fontHeight - } - - return lines, nil -} - -// DrawTextAt draws text at a specific position with the given alignment -func (c *Card) DrawTextAt(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) error { - _, err := c.DrawTextAtWithWidth(text, x, y, textColor, sizePt, valign, halign) - return err -} - -// DrawTextAtWithWidth draws text at a specific position and returns the text width -func (c *Card) DrawTextAtWithWidth(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) { - ft := freetype.NewContext() - ft.SetDPI(72) - ft.SetFont(c.Font) - ft.SetFontSize(sizePt) - ft.SetClip(c.Img.Bounds()) - ft.SetDst(c.Img) - ft.SetSrc(image.NewUniform(textColor)) - - face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72}) - fontHeight := ft.PointToFixed(sizePt).Ceil() - lineWidth := font.MeasureString(face, text) - textWidth := lineWidth.Ceil() - - // Adjust position based on alignment - adjustedX := x - adjustedY := y - - switch halign { - case Left: - // x is already at the left position - case Right: - adjustedX = x - textWidth - case Center: - adjustedX = x - textWidth/2 - } - - switch valign { - case Top: - adjustedY = y + fontHeight - case Bottom: - adjustedY = y - case Middle: - adjustedY = y + fontHeight/2 - } - - pt := freetype.Pt(adjustedX, adjustedY) - _, err := ft.DrawString(text, pt) - return textWidth, err -} - -func (c *Card) FontHeight(sizePt float64) int { - ft := freetype.NewContext() - ft.SetDPI(72) - ft.SetFont(c.Font) - ft.SetFontSize(sizePt) - return ft.PointToFixed(sizePt).Ceil() -} - -func (c *Card) TextWidth(text string, sizePt float64) int { - face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72}) - lineWidth := font.MeasureString(face, text) - textWidth := lineWidth.Ceil() - return textWidth -} - -// DrawBoldText draws bold text by rendering multiple times with slight offsets -func (c *Card) DrawBoldText(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) { - // Draw the text multiple times with slight offsets to create bold effect - offsets := []struct{ dx, dy int }{ - {0, 0}, // original - {1, 0}, // right - {0, 1}, // down - {1, 1}, // diagonal - } - - var width int - for _, offset := range offsets { - w, err := c.DrawTextAtWithWidth(text, x+offset.dx, y+offset.dy, textColor, sizePt, valign, halign) - if err != nil { - return 0, err - } - if width == 0 { - width = w - } - } - return width, nil -} - -func BuildSVGIconFromData(svgData []byte, iconColor color.Color) (*oksvg.SvgIcon, error) { - // Convert color to hex string for SVG - rgba, isRGBA := iconColor.(color.RGBA) - if !isRGBA { - r, g, b, a := iconColor.RGBA() - rgba = color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), uint8(a >> 8)} - } - colorHex := fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B) - - // Replace currentColor with our desired color in the SVG - svgString := string(svgData) - svgString = strings.ReplaceAll(svgString, "currentColor", colorHex) - - // Make the stroke thicker - svgString = strings.ReplaceAll(svgString, `stroke-width="2"`, `stroke-width="3"`) - - // Parse SVG - icon, err := oksvg.ReadIconStream(strings.NewReader(svgString)) - if err != nil { - return nil, fmt.Errorf("failed to parse SVG: %w", err) - } - - return icon, nil -} - -func BuildSVGIconFromPath(svgPath string, iconColor color.Color) (*oksvg.SvgIcon, error) { - svgData, err := pages.Files.ReadFile(svgPath) - if err != nil { - return nil, fmt.Errorf("failed to read SVG file %s: %w", svgPath, err) - } - - icon, err := BuildSVGIconFromData(svgData, iconColor) - if err != nil { - return nil, fmt.Errorf("failed to build SVG icon %s: %w", svgPath, err) - } - - return icon, nil -} - -func BuildLucideIcon(name string, iconColor color.Color) (*oksvg.SvgIcon, error) { - return BuildSVGIconFromPath(fmt.Sprintf("static/icons/%s.svg", name), iconColor) -} - -func (c *Card) DrawLucideIcon(name string, x, y, size int, iconColor color.Color) error { - icon, err := BuildSVGIconFromPath(fmt.Sprintf("static/icons/%s.svg", name), iconColor) - if err != nil { - return err - } - - c.DrawSVGIcon(icon, x, y, size) - - return nil -} - -func (c *Card) DrawDolly(x, y, size int, iconColor color.Color) error { - tpl, err := template.New("dolly"). - ParseFS(pages.Files, "templates/fragments/dolly/logo.html") - if err != nil { - return fmt.Errorf("failed to read dolly template: %w", err) - } - - var svgData bytes.Buffer - if err = tpl.ExecuteTemplate(&svgData, "fragments/dolly/logo", nil); err != nil { - return fmt.Errorf("failed to execute dolly template: %w", err) - } - - icon, err := BuildSVGIconFromData(svgData.Bytes(), iconColor) - if err != nil { - return err - } - - c.DrawSVGIcon(icon, x, y, size) - - return nil -} - -// DrawSVGIcon draws an SVG icon from the embedded files at the specified position -func (c *Card) DrawSVGIcon(icon *oksvg.SvgIcon, x, y, size int) { - // Set the icon size - w, h := float64(size), float64(size) - icon.SetTarget(0, 0, w, h) - - // Create a temporary RGBA image for the icon - iconImg := image.NewRGBA(image.Rect(0, 0, size, size)) - - // Create scanner and rasterizer - scanner := rasterx.NewScannerGV(size, size, iconImg, iconImg.Bounds()) - raster := rasterx.NewDasher(size, size, scanner) - - // Draw the icon - icon.Draw(raster, 1.0) - - // Draw the icon onto the card at the specified position - bounds := c.Img.Bounds() - destRect := image.Rect(x, y, x+size, y+size) - - // Make sure we don't draw outside the card bounds - if destRect.Max.X > bounds.Max.X { - destRect.Max.X = bounds.Max.X - } - if destRect.Max.Y > bounds.Max.Y { - destRect.Max.Y = bounds.Max.Y - } - - draw.Draw(c.Img, destRect, iconImg, image.Point{}, draw.Over) -} - -// DrawImage fills the card with an image, scaled to maintain the original aspect ratio and centered with respect to the non-filled dimension -func (c *Card) DrawImage(img image.Image) { - bounds := c.Img.Bounds() - targetRect := image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) - srcBounds := img.Bounds() - srcAspect := float64(srcBounds.Dx()) / float64(srcBounds.Dy()) - targetAspect := float64(targetRect.Dx()) / float64(targetRect.Dy()) - - var scale float64 - if srcAspect > targetAspect { - // Image is wider than target, scale by width - scale = float64(targetRect.Dx()) / float64(srcBounds.Dx()) - } else { - // Image is taller or equal, scale by height - scale = float64(targetRect.Dy()) / float64(srcBounds.Dy()) - } - - newWidth := int(math.Round(float64(srcBounds.Dx()) * scale)) - newHeight := int(math.Round(float64(srcBounds.Dy()) * scale)) - - // Center the image within the target rectangle - offsetX := (targetRect.Dx() - newWidth) / 2 - offsetY := (targetRect.Dy() - newHeight) / 2 - - scaledRect := image.Rect(targetRect.Min.X+offsetX, targetRect.Min.Y+offsetY, targetRect.Min.X+offsetX+newWidth, targetRect.Min.Y+offsetY+newHeight) - draw.CatmullRom.Scale(c.Img, scaledRect, img, srcBounds, draw.Over, nil) -} - -func fallbackImage() image.Image { - // can't usage image.Uniform(color.White) because it's infinitely sized causing a panic in the scaler in DrawImage - img := image.NewRGBA(image.Rect(0, 0, 1, 1)) - img.Set(0, 0, color.White) - return img -} - -// As defensively as possible, attempt to load an image from a presumed external and untrusted URL -func (c *Card) fetchExternalImage(url string) (image.Image, bool) { - // Use a short timeout; in the event of any failure we'll be logging and returning a placeholder, but we don't want - // this rendering process to be slowed down - client := &http.Client{ - Timeout: 1 * time.Second, // 1 second timeout - } - - resp, err := client.Get(url) - if err != nil { - log.Printf("error when fetching external image from %s: %v", url, err) - return nil, false - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - log.Printf("non-OK error code when fetching external image from %s: %s", url, resp.Status) - return nil, false - } - - contentType := resp.Header.Get("Content-Type") - - body := resp.Body - bodyBytes, err := io.ReadAll(body) - if err != nil { - log.Printf("error when fetching external image from %s: %v", url, err) - return nil, false - } - - // Handle SVG separately - if contentType == "image/svg+xml" || strings.HasSuffix(url, ".svg") { - return convertSVGToPNG(bodyBytes) - } - - // Support content types are in-sync with the allowed custom avatar file types - if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/gif" && contentType != "image/webp" { - log.Printf("fetching external image returned unsupported Content-Type which was ignored: %s", contentType) - return nil, false - } - - bodyBuffer := bytes.NewReader(bodyBytes) - _, imgType, err := image.DecodeConfig(bodyBuffer) - if err != nil { - log.Printf("error when decoding external image from %s: %v", url, err) - return nil, false - } - - // Verify that we have a match between actual data understood in the image body and the reported Content-Type - if (contentType == "image/png" && imgType != "png") || - (contentType == "image/jpeg" && imgType != "jpeg") || - (contentType == "image/gif" && imgType != "gif") || - (contentType == "image/webp" && imgType != "webp") { - log.Printf("while fetching external image, mismatched image body (%s) and Content-Type (%s)", imgType, contentType) - return nil, false - } - - _, err = bodyBuffer.Seek(0, io.SeekStart) // reset for actual decode - if err != nil { - log.Printf("error w/ bodyBuffer.Seek") - return nil, false - } - img, _, err := image.Decode(bodyBuffer) - if err != nil { - log.Printf("error when decoding external image from %s: %v", url, err) - return nil, false - } - - return img, true -} - -// convertSVGToPNG converts SVG data to a PNG image -func convertSVGToPNG(svgData []byte) (image.Image, bool) { - // Parse the SVG - icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData)) - if err != nil { - log.Printf("error parsing SVG: %v", err) - return nil, false - } - - // Set a reasonable size for the rasterized image - width := 256 - height := 256 - icon.SetTarget(0, 0, float64(width), float64(height)) - - // Create an image to draw on - rgba := image.NewRGBA(image.Rect(0, 0, width, height)) - - // Fill with white background - draw.Draw(rgba, rgba.Bounds(), &image.Uniform{color.White}, image.Point{}, draw.Src) - - // Create a scanner and rasterize the SVG - scanner := rasterx.NewScannerGV(width, height, rgba, rgba.Bounds()) - raster := rasterx.NewDasher(width, height, scanner) - - icon.Draw(raster, 1.0) - - return rgba, true -} - -func (c *Card) DrawExternalImage(url string) { - image, ok := c.fetchExternalImage(url) - if !ok { - image = fallbackImage() - } - c.DrawImage(image) -} - -// DrawCircularExternalImage draws an external image as a circle at the specified position -func (c *Card) DrawCircularExternalImage(url string, x, y, size int) error { - img, ok := c.fetchExternalImage(url) - if !ok { - img = fallbackImage() - } - - // Create a circular mask - circle := image.NewRGBA(image.Rect(0, 0, size, size)) - center := size / 2 - radius := float64(size / 2) - - // Scale the source image to fit the circle - srcBounds := img.Bounds() - scaledImg := image.NewRGBA(image.Rect(0, 0, size, size)) - draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil) - - // Draw the image with circular clipping - for cy := range size { - for cx := range size { - // Calculate distance from center - dx := float64(cx - center) - dy := float64(cy - center) - distance := math.Sqrt(dx*dx + dy*dy) - - // Only draw pixels within the circle - if distance <= radius { - circle.Set(cx, cy, scaledImg.At(cx, cy)) - } - } - } - - // Draw the circle onto the card - bounds := c.Img.Bounds() - destRect := image.Rect(x, y, x+size, y+size) - - // Make sure we don't draw outside the card bounds - if destRect.Max.X > bounds.Max.X { - destRect.Max.X = bounds.Max.X - } - if destRect.Max.Y > bounds.Max.Y { - destRect.Max.Y = bounds.Max.Y - } - - draw.Draw(c.Img, destRect, circle, image.Point{}, draw.Over) - - return nil -} - -// DrawRect draws a rect with the given color -func (c *Card) DrawRect(startX, startY, endX, endY int, color color.Color) { - draw.Draw(c.Img, image.Rect(startX, startY, endX, endY), &image.Uniform{color}, image.Point{}, draw.Src) -} - -// drawRoundedRect draws a filled rounded rectangle on the given card -func (card *Card) DrawRoundedRect(x, y, width, height, cornerRadius int, fillColor color.RGBA) { - cardBounds := card.Img.Bounds() - for py := y; py < y+height; py++ { - for px := x; px < x+width; px++ { - // calculate distance from corners - dx := 0 - dy := 0 - - // check which corner region we're in - if px < x+cornerRadius && py < y+cornerRadius { - // top-left corner - dx = x + cornerRadius - px - dy = y + cornerRadius - py - } else if px >= x+width-cornerRadius && py < y+cornerRadius { - // top-right corner - dx = px - (x + width - cornerRadius - 1) - dy = y + cornerRadius - py - } else if px < x+cornerRadius && py >= y+height-cornerRadius { - // bottom-left corner - dx = x + cornerRadius - px - dy = py - (y + height - cornerRadius - 1) - } else if px >= x+width-cornerRadius && py >= y+height-cornerRadius { - // Bottom-right corner - dx = px - (x + width - cornerRadius - 1) - dy = py - (y + height - cornerRadius - 1) - } - - // if we're in a corner, check if we're within the radius - inCorner := (dx > 0 || dy > 0) - withinRadius := dx*dx+dy*dy <= cornerRadius*cornerRadius - - // draw pixel if not in corner, or in corner and within radius - // check bounds relative to the card's image bounds - if (!inCorner || withinRadius) && px >= 0 && px < cardBounds.Dx() && py >= 0 && py < cardBounds.Dy() { - card.Img.Set(px+cardBounds.Min.X, py+cardBounds.Min.Y, fillColor) - } - } - } -} diff --git a/appview/ogcard/client.go b/appview/ogcard/client.go new file mode 100644 --- /dev/null +++ b/appview/ogcard/client.go @@ -0,0 +1,117 @@ +package ogcard + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +type Client struct { + host string + client *http.Client +} + +func NewClient(host string) *Client { + return &Client{ + host: host, + client: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +type LabelData struct { + Name string `json:"name"` + Color string `json:"color"` +} + +type LanguageData struct { + Color string `json:"color"` + Percentage float32 `json:"percentage"` +} + +type RepositoryCardPayload struct { + Type string `json:"type"` + RepoName string `json:"repoName"` + OwnerHandle string `json:"ownerHandle"` + Stars int `json:"stars"` + Pulls int `json:"pulls"` + Issues int `json:"issues"` + CreatedAt string `json:"createdAt"` + AvatarUrl string `json:"avatarUrl"` + Languages []LanguageData `json:"languages"` +} + +type IssueCardPayload struct { + Type string `json:"type"` + RepoName string `json:"repoName"` + OwnerHandle string `json:"ownerHandle"` + AvatarUrl string `json:"avatarUrl"` + Title string `json:"title"` + IssueNumber int `json:"issueNumber"` + Status string `json:"status"` + Labels []LabelData `json:"labels"` + CommentCount int `json:"commentCount"` + ReactionCount int `json:"reactionCount"` + CreatedAt string `json:"createdAt"` +} + +type PullRequestCardPayload struct { + Type string `json:"type"` + RepoName string `json:"repoName"` + OwnerHandle string `json:"ownerHandle"` + AvatarUrl string `json:"avatarUrl"` + Title string `json:"title"` + PullRequestNumber int `json:"pullRequestNumber"` + Status string `json:"status"` + FilesChanged int `json:"filesChanged"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + Rounds int `json:"rounds"` + CommentCount int `json:"commentCount"` + ReactionCount int `json:"reactionCount"` + CreatedAt string `json:"createdAt"` +} + +func (c *Client) doRequest(ctx context.Context, path string, payload any) ([]byte, error) { + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshal payload: %w", err) + } + + url := fmt.Sprintf("%s/%s", c.host, path) + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("do request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("unexpected status: %d, body: %s", resp.StatusCode, string(respBody)) + } + + return io.ReadAll(resp.Body) +} + +func (c *Client) RenderRepositoryCard(ctx context.Context, payload RepositoryCardPayload) ([]byte, error) { + return c.doRequest(ctx, "repository", payload) +} + +func (c *Client) RenderIssueCard(ctx context.Context, payload IssueCardPayload) ([]byte, error) { + return c.doRequest(ctx, "issue", payload) +} + +func (c *Client) RenderPullRequestCard(ctx context.Context, payload PullRequestCardPayload) ([]byte, error) { + return c.doRequest(ctx, "pullRequest", payload) +} diff --git a/appview/ogcard/knip.json b/appview/ogcard/knip.json new file mode 100644 --- /dev/null +++ b/appview/ogcard/knip.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://unpkg.com/knip@5/schema.json", + "tags": ["-lintignore"] +} diff --git a/appview/ogcard/package.json b/appview/ogcard/package.json new file mode 100644 --- /dev/null +++ b/appview/ogcard/package.json @@ -0,0 +1,34 @@ +{ + "name": "@tangled/ogcard-worker", + "version": "1.0.0", + "private": true, + "type": "module", + "workspaces": [ + "packages/runtime" + ], + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit", + "test": "bun test", + "knip": "knip" + }, + "dependencies": { + "@fontsource/inter": "^5.2.8", + "@resvg/resvg-wasm": "^2.6.2", + "@tangled/ogcard-runtime": "*", + "lucide-static": "^0.577.0", + "preact": "^10.29.0", + "satori": "0.25.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@types/bun": "^1.3.11", + "@types/node": "^25.5.0", + "knip": "^6.0.1", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "wrangler": "^4.75.0" + } +} diff --git a/appview/ogcard/tsconfig.json b/appview/ogcard/tsconfig.json new file mode 100644 --- /dev/null +++ b/appview/ogcard/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "lib": ["ES2022"], + "moduleResolution": "bundler", + "types": ["@cloudflare/workers-types", "node", "bun"], + "jsx": "react-jsx", + "jsxImportSource": "preact", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "noEmit": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/appview/ogcard/wrangler.jsonc b/appview/ogcard/wrangler.jsonc new file mode 100644 --- /dev/null +++ b/appview/ogcard/wrangler.jsonc @@ -0,0 +1,25 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "tangled-ogcard-worker", + "main": "src/index.tsx", + "compatibility_date": "2026-03-07", + "observability": { + "enabled": true, + }, + "routes": [ + { + "pattern": "og.tangled.org/*", + "zone_name": "tangled.org", + }, + ], + "vars": { + "ENVIRONMENT": "production", + }, + "rules": [ + { + "type": "Data", + "globs": ["**/*.woff"], + "fallthrough": true, + }, + ], +} diff --git a/appview/pulls/opengraph.go b/appview/pulls/opengraph.go --- a/appview/pulls/opengraph.go +++ b/appview/pulls/opengraph.go @@ -1,273 +1,15 @@ package pulls import ( - "bytes" "context" - "fmt" - "image" - "image/color" - "image/png" "log" "net/http" + "time" "tangled.org/core/appview/models" "tangled.org/core/appview/ogcard" "tangled.org/core/patchutil" - "tangled.org/core/types" ) - -func (s *Pulls) drawPullSummaryCard(pull *models.Pull, repo *models.Repo, diffStats types.DiffFileStat, filesChanged int) (*ogcard.Card, error) { - width, height := ogcard.DefaultSize() - mainCard, err := ogcard.NewCard(width, height) - if err != nil { - return nil, err - } - - // Split: content area (75%) and status/stats area (25%) - contentCard, statsArea := mainCard.Split(false, 75) - - // Add padding to content - contentCard.SetMargin(50) - - // Split content horizontally: main content (80%) and avatar area (20%) - mainContent, avatarArea := contentCard.Split(true, 80) - - // Add margin to main content - mainContent.SetMargin(10) - - // Use full main content area for repo name and title - bounds := mainContent.Img.Bounds() - startX := bounds.Min.X + mainContent.Margin - startY := bounds.Min.Y + mainContent.Margin - - // Draw full repository name at top (owner/repo format) - var repoOwner string - owner, err := s.idResolver.ResolveIdent(context.Background(), repo.Did) - if err != nil { - repoOwner = repo.Did - } else { - repoOwner = "@" + owner.Handle.String() - } - - fullRepoName := repoOwner + " / " + repo.Name - if len(fullRepoName) > 60 { - fullRepoName = fullRepoName[:60] + "…" - } - - grayColor := color.RGBA{88, 96, 105, 255} - err = mainContent.DrawTextAt(fullRepoName, startX, startY, grayColor, 36, ogcard.Top, ogcard.Left) - if err != nil { - return nil, err - } - - // Draw pull request title below repo name with wrapping - titleY := startY + 60 - titleX := startX - - // Truncate title if too long - pullTitle := pull.Title - maxTitleLength := 80 - if len(pullTitle) > maxTitleLength { - pullTitle = pullTitle[:maxTitleLength] + "…" - } - - // Create a temporary card for the title area to enable wrapping - titleBounds := mainContent.Img.Bounds() - titleWidth := titleBounds.Dx() - (startX - titleBounds.Min.X) - 20 // Leave some margin - titleHeight := titleBounds.Dy() - (titleY - titleBounds.Min.Y) - 100 // Leave space for pull ID - - titleRect := image.Rect(titleX, titleY, titleX+titleWidth, titleY+titleHeight) - titleCard := &ogcard.Card{ - Img: mainContent.Img.SubImage(titleRect).(*image.RGBA), - Font: mainContent.Font, - Margin: 0, - } - - // Draw wrapped title - lines, err := titleCard.DrawText(pullTitle, color.Black, 54, ogcard.Top, ogcard.Left) - if err != nil { - return nil, err - } - - // Calculate where title ends (number of lines * line height) - lineHeight := 60 // Approximate line height for 54pt font - titleEndY := titleY + (len(lines) * lineHeight) + 10 - - // Draw pull ID in gray below the title - pullIdText := fmt.Sprintf("#%d", pull.PullId) - err = mainContent.DrawTextAt(pullIdText, startX, titleEndY, grayColor, 54, ogcard.Top, ogcard.Left) - if err != nil { - return nil, err - } - - // Get pull author handle (needed for avatar and metadata) - var authorHandle string - author, err := s.idResolver.ResolveIdent(context.Background(), pull.OwnerDid) - if err != nil { - authorHandle = pull.OwnerDid - } else { - authorHandle = "@" + author.Handle.String() - } - - // Draw avatar circle on the right side - avatarBounds := avatarArea.Img.Bounds() - avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin - if avatarSize > 220 { - avatarSize = 220 - } - avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2) - avatarY := avatarBounds.Min.Y + 20 - - // Get avatar URL for pull author - avatarURL := s.pages.AvatarUrl(authorHandle, "256") - err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize) - if err != nil { - log.Printf("failed to draw avatar (non-fatal): %v", err) - } - - // Split stats area: left side for status/stats (80%), right side for dolly (20%) - statusArea, dollyArea := statsArea.Split(true, 80) - - // Draw status and stats - statsBounds := statusArea.Img.Bounds() - statsX := statsBounds.Min.X + 60 // left padding - statsY := statsBounds.Min.Y - - iconColor := color.RGBA{88, 96, 105, 255} - iconSize := 36 - textSize := 36.0 - labelSize := 28.0 - iconBaselineOffset := int(textSize) / 2 - - // Draw status (open/merged/closed) with colored icon and text - var statusIcon string - var statusText string - var statusColor color.RGBA - - if pull.State.IsOpen() { - statusIcon = "git-pull-request" - statusText = "open" - statusColor = color.RGBA{34, 139, 34, 255} // green - } else if pull.State.IsMerged() { - statusIcon = "git-merge" - statusText = "merged" - statusColor = color.RGBA{138, 43, 226, 255} // purple - } else { - statusIcon = "git-pull-request-closed" - statusText = "closed" - statusColor = color.RGBA{52, 58, 64, 255} // dark gray - } - - statusTextWidth := statusArea.TextWidth(statusText, textSize) - badgePadding := 12 - badgeHeight := int(textSize) + (badgePadding * 2) - badgeWidth := iconSize + badgePadding + statusTextWidth + (badgePadding * 2) - cornerRadius := 8 - badgeX := 60 - badgeY := 0 - - statusArea.DrawRoundedRect(badgeX, badgeY, badgeWidth, badgeHeight, cornerRadius, statusColor) - - whiteColor := color.RGBA{255, 255, 255, 255} - iconX := statsX + badgePadding - iconY := statsY + (badgeHeight-iconSize)/2 - err = statusArea.DrawLucideIcon(statusIcon, iconX, iconY, iconSize, whiteColor) - if err != nil { - log.Printf("failed to draw status icon: %v", err) - } - - textX := statsX + badgePadding + iconSize + badgePadding - textY := statsY + (badgeHeight-int(textSize))/2 - 5 - err = statusArea.DrawTextAt(statusText, textX, textY, whiteColor, textSize, ogcard.Top, ogcard.Left) - if err != nil { - log.Printf("failed to draw status text: %v", err) - } - - currentX := statsX + badgeWidth + 50 - - // Draw comment count - err = statusArea.DrawLucideIcon("message-square", currentX, iconY, iconSize, iconColor) - if err != nil { - log.Printf("failed to draw comment icon: %v", err) - } - - currentX += iconSize + 15 - commentCount := pull.TotalComments() - commentText := fmt.Sprintf("%d comments", commentCount) - if commentCount == 1 { - commentText = "1 comment" - } - err = statusArea.DrawTextAt(commentText, currentX, textY, iconColor, textSize, ogcard.Top, ogcard.Left) - if err != nil { - log.Printf("failed to draw comment text: %v", err) - } - - commentTextWidth := len(commentText) * 20 - currentX += commentTextWidth + 40 - - // Draw files changed - err = statusArea.DrawLucideIcon("file-diff", currentX, iconY, iconSize, iconColor) - if err != nil { - log.Printf("failed to draw file diff icon: %v", err) - } - - currentX += iconSize + 15 - filesText := fmt.Sprintf("%d files", filesChanged) - if filesChanged == 1 { - filesText = "1 file" - } - err = statusArea.DrawTextAt(filesText, currentX, textY, iconColor, textSize, ogcard.Top, ogcard.Left) - if err != nil { - log.Printf("failed to draw files text: %v", err) - } - - filesTextWidth := len(filesText) * 20 - currentX += filesTextWidth - - // Draw additions (green +) - greenColor := color.RGBA{34, 139, 34, 255} - additionsText := fmt.Sprintf("+%d", diffStats.Insertions) - err = statusArea.DrawTextAt(additionsText, currentX, textY, greenColor, textSize, ogcard.Top, ogcard.Left) - if err != nil { - log.Printf("failed to draw additions text: %v", err) - } - - additionsTextWidth := len(additionsText) * 20 - currentX += additionsTextWidth + 30 - - // Draw deletions (red -) right next to additions - redColor := color.RGBA{220, 20, 60, 255} - deletionsText := fmt.Sprintf("-%d", diffStats.Deletions) - err = statusArea.DrawTextAt(deletionsText, currentX, textY, redColor, textSize, ogcard.Top, ogcard.Left) - if err != nil { - log.Printf("failed to draw deletions text: %v", err) - } - - // Draw dolly logo on the right side - dollyBounds := dollyArea.Img.Bounds() - dollySize := 90 - dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) - dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 - dollyColor := color.RGBA{180, 180, 180, 255} // light gray - err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor) - if err != nil { - log.Printf("dolly silhouette not available (this is ok): %v", err) - } - - // Draw "opened by @author" and date at the bottom with more spacing - labelY := statsY + iconSize + 30 - - // Format the opened date - openedDate := pull.Created.Format("Jan 2, 2006") - metaText := fmt.Sprintf("opened by %s · %s", authorHandle, openedDate) - - err = statusArea.DrawTextAt(metaText, statsX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Left) - if err != nil { - log.Printf("failed to draw metadata: %v", err) - } - - return mainCard, nil -} func (s *Pulls) PullOpenGraphSummary(w http.ResponseWriter, r *http.Request) { f, err := s.repoResolver.Resolve(r) @@ -283,40 +25,82 @@ return } - // Calculate diff stats from latest submission using patchutil - var diffStats types.DiffFileStat - filesChanged := 0 + var ownerHandle string + owner, err := s.idResolver.ResolveIdent(context.Background(), f.Did) + if err != nil { + ownerHandle = f.Did + } else { + ownerHandle = "@" + owner.Handle.String() + } + + var authorHandle string + author, err := s.idResolver.ResolveIdent(context.Background(), pull.OwnerDid) + if err != nil { + authorHandle = pull.OwnerDid + } else { + authorHandle = "@" + author.Handle.String() + } + + avatarUrl := s.pages.AvatarUrl(authorHandle, "256") + + var status string + if pull.State.IsOpen() { + status = "open" + } else if pull.State.IsMerged() { + status = "merged" + } else { + status = "closed" + } + + var filesChanged int + var additions int64 + var deletions int64 + if len(pull.Submissions) > 0 { latestSubmission := pull.LatestSubmission() niceDiff := patchutil.AsNiceDiff(latestSubmission.Patch, pull.TargetBranch) - diffStats.Insertions = int64(niceDiff.Stat.Insertions) - diffStats.Deletions = int64(niceDiff.Stat.Deletions) filesChanged = niceDiff.Stat.FilesChanged + additions = int64(niceDiff.Stat.Insertions) + deletions = int64(niceDiff.Stat.Deletions) } - card, err := s.drawPullSummaryCard(pull, f, diffStats, filesChanged) + commentCount := pull.TotalComments() + + rounds := len(pull.Submissions) + if rounds == 0 { + rounds = 1 + } + + payload := ogcard.PullRequestCardPayload{ + Type: "pullRequest", + RepoName: f.Name, + OwnerHandle: ownerHandle, + AvatarUrl: avatarUrl, + Title: pull.Title, + PullRequestNumber: pull.PullId, + Status: status, + FilesChanged: filesChanged, + Additions: int(additions), + Deletions: int(deletions), + Rounds: rounds, + CommentCount: commentCount, + ReactionCount: 0, + CreatedAt: pull.Created.Format(time.RFC3339), + } + + imageBytes, err := s.ogcardClient.RenderPullRequestCard(r.Context(), payload) if err != nil { - log.Println("failed to draw pull summary card", err) - http.Error(w, "failed to draw pull summary card", http.StatusInternalServerError) + log.Println("failed to render pull request card", err) + http.Error(w, "failed to render pull request card", http.StatusInternalServerError) return } - - var imageBuffer bytes.Buffer - err = png.Encode(&imageBuffer, card.Img) - if err != nil { - log.Println("failed to encode pull summary card", err) - http.Error(w, "failed to encode pull summary card", http.StatusInternalServerError) - return - } - - imageBytes := imageBuffer.Bytes() w.Header().Set("Content-Type", "image/png") - w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour + w.Header().Set("Cache-Control", "public, max-age=3600") w.WriteHeader(http.StatusOK) _, err = w.Write(imageBytes) if err != nil { - log.Println("failed to write pull summary card", err) + log.Println("failed to write pull request card", err) return } } diff --git a/appview/pulls/pulls.go b/appview/pulls/pulls.go --- a/appview/pulls/pulls.go +++ b/appview/pulls/pulls.go @@ -26,6 +26,7 @@ "tangled.org/core/appview/models" "tangled.org/core/appview/notify" "tangled.org/core/appview/oauth" + "tangled.org/core/appview/ogcard" "tangled.org/core/appview/pages" "tangled.org/core/appview/pages/markup" "tangled.org/core/appview/pages/repoinfo" @@ -65,6 +66,7 @@ logger *slog.Logger validator *validator.Validator indexer *pulls_indexer.Indexer + ogcardClient *ogcard.Client } func New( @@ -94,6 +96,7 @@ logger: logger, validator: validator, indexer: indexer, + ogcardClient: ogcard.NewClient(config.Ogcard.Host), } } diff --git a/appview/repo/opengraph.go b/appview/repo/opengraph.go --- a/appview/repo/opengraph.go +++ b/appview/repo/opengraph.go @@ -1,332 +1,18 @@ package repo import ( - "bytes" "context" - "encoding/hex" - "fmt" - "image/color" - "image/png" "log" "net/http" "sort" - "strings" + "time" "github.com/go-enry/go-enry/v2" "tangled.org/core/appview/db" - "tangled.org/core/appview/models" "tangled.org/core/appview/ogcard" "tangled.org/core/orm" "tangled.org/core/types" ) - -func (rp *Repo) drawRepoSummaryCard(repo *models.Repo, languageStats []types.RepoLanguageDetails) (*ogcard.Card, error) { - width, height := ogcard.DefaultSize() - mainCard, err := ogcard.NewCard(width, height) - if err != nil { - return nil, err - } - - // Split: content area (75%) and language bar + icons (25%) - contentCard, bottomArea := mainCard.Split(false, 75) - - // Add padding to content - contentCard.SetMargin(50) - - // Split content horizontally: main content (80%) and avatar area (20%) - mainContent, avatarArea := contentCard.Split(true, 80) - - // Use main content area for both repo name and description to allow dynamic wrapping. - mainContent.SetMargin(10) - - var ownerHandle string - owner, err := rp.idResolver.ResolveIdent(context.Background(), repo.Did) - if err != nil { - ownerHandle = repo.Did - } else { - ownerHandle = "@" + owner.Handle.String() - } - - bounds := mainContent.Img.Bounds() - startX := bounds.Min.X + mainContent.Margin - startY := bounds.Min.Y + mainContent.Margin - currentX := startX - currentY := startY - lineHeight := 64 // Font size 54 + padding - textColor := color.RGBA{88, 96, 105, 255} - - // Draw owner handle - ownerWidth, err := mainContent.DrawTextAtWithWidth(ownerHandle, currentX, currentY, textColor, 54, ogcard.Top, ogcard.Left) - if err != nil { - return nil, err - } - currentX += ownerWidth - - // Draw separator - sepWidth, err := mainContent.DrawTextAtWithWidth(" / ", currentX, currentY, textColor, 54, ogcard.Top, ogcard.Left) - if err != nil { - return nil, err - } - currentX += sepWidth - - words := strings.Fields(repo.Name) - spaceWidth, _ := mainContent.DrawTextAtWithWidth(" ", -1000, -1000, color.Black, 54, ogcard.Top, ogcard.Left) - if spaceWidth == 0 { - spaceWidth = 15 - } - - for _, word := range words { - // estimate bold width by measuring regular width and adding a multiplier - regularWidth, _ := mainContent.DrawTextAtWithWidth(word, -1000, -1000, color.Black, 54, ogcard.Top, ogcard.Left) - estimatedBoldWidth := int(float64(regularWidth) * 1.15) // Heuristic for bold text - - if currentX+estimatedBoldWidth > (bounds.Max.X - mainContent.Margin) { - currentX = startX - currentY += lineHeight - } - - _, err := mainContent.DrawBoldText(word, currentX, currentY, color.Black, 54, ogcard.Top, ogcard.Left) - if err != nil { - return nil, err - } - currentX += estimatedBoldWidth + spaceWidth - } - - // update Y position for the description - currentY += lineHeight - - // draw description - if currentY < bounds.Max.Y-mainContent.Margin { - totalHeight := float64(bounds.Dy()) - repoNameHeight := float64(currentY - bounds.Min.Y) - - if totalHeight > 0 && repoNameHeight < totalHeight { - repoNamePercent := (repoNameHeight / totalHeight) * 100 - if repoNamePercent < 95 { // Ensure there's space left for description - _, descriptionCard := mainContent.Split(false, int(repoNamePercent)) - descriptionCard.SetMargin(8) - - description := repo.Description - if len(description) > 70 { - description = description[:70] + "…" - } - - _, err = descriptionCard.DrawText(description, color.RGBA{88, 96, 105, 255}, 36, ogcard.Top, ogcard.Left) - if err != nil { - log.Printf("failed to draw description: %v", err) - } - } - } - } - - // Draw avatar circle on the right side - avatarBounds := avatarArea.Img.Bounds() - avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin - if avatarSize > 220 { - avatarSize = 220 - } - avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2) - avatarY := avatarBounds.Min.Y + 20 - - // Get avatar URL and draw it - avatarURL := rp.pages.AvatarUrl(ownerHandle, "256") - err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize) - if err != nil { - log.Printf("failed to draw avatar (non-fatal): %v", err) - } - - // Split bottom area: icons area (65%) and language bar (35%) - iconsArea, languageBarCard := bottomArea.Split(false, 75) - - // Split icons area: left side for stats (80%), right side for dolly (20%) - statsArea, dollyArea := iconsArea.Split(true, 80) - - // Draw stats with icons in the stats area - starsText := repo.RepoStats.StarCount - issuesText := repo.RepoStats.IssueCount.Open - pullRequestsText := repo.RepoStats.PullCount.Open - - iconColor := color.RGBA{88, 96, 105, 255} - iconSize := 36 - textSize := 36.0 - - // Position stats in the middle of the stats area - statsBounds := statsArea.Img.Bounds() - statsX := statsBounds.Min.X + 60 // left padding - statsY := statsBounds.Min.Y - currentX = statsX - labelSize := 22.0 - // Draw star icon, count, and label - // Align icon baseline with text baseline - iconBaselineOffset := int(textSize) / 2 - err = statsArea.DrawLucideIcon("star", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) - if err != nil { - log.Printf("failed to draw star icon: %v", err) - } - starIconX := currentX - currentX += iconSize + 15 - - starText := fmt.Sprintf("%d", starsText) - err = statsArea.DrawTextAt(starText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) - if err != nil { - log.Printf("failed to draw star text: %v", err) - } - starTextWidth := len(starText) * 20 - starGroupWidth := iconSize + 15 + starTextWidth - - // Draw "stars" label below and centered under the icon+text group - labelY := statsY + iconSize + 15 - labelX := starIconX + starGroupWidth/2 - err = iconsArea.DrawTextAt("stars", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center) - if err != nil { - log.Printf("failed to draw stars label: %v", err) - } - - currentX += starTextWidth + 50 - - // Draw issues icon, count, and label - issueStartX := currentX - err = statsArea.DrawLucideIcon("circle-dot", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) - if err != nil { - log.Printf("failed to draw circle-dot icon: %v", err) - } - currentX += iconSize + 15 - - issueText := fmt.Sprintf("%d", issuesText) - err = statsArea.DrawTextAt(issueText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) - if err != nil { - log.Printf("failed to draw issue text: %v", err) - } - issueTextWidth := len(issueText) * 20 - issueGroupWidth := iconSize + 15 + issueTextWidth - - // Draw "issues" label below and centered under the icon+text group - labelX = issueStartX + issueGroupWidth/2 - err = iconsArea.DrawTextAt("issues", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center) - if err != nil { - log.Printf("failed to draw issues label: %v", err) - } - - currentX += issueTextWidth + 50 - - // Draw pull request icon, count, and label - prStartX := currentX - err = statsArea.DrawLucideIcon("git-pull-request", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) - if err != nil { - log.Printf("failed to draw git-pull-request icon: %v", err) - } - currentX += iconSize + 15 - - prText := fmt.Sprintf("%d", pullRequestsText) - err = statsArea.DrawTextAt(prText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) - if err != nil { - log.Printf("failed to draw PR text: %v", err) - } - prTextWidth := len(prText) * 20 - prGroupWidth := iconSize + 15 + prTextWidth - - // Draw "pulls" label below and centered under the icon+text group - labelX = prStartX + prGroupWidth/2 - err = iconsArea.DrawTextAt("pulls", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center) - if err != nil { - log.Printf("failed to draw pulls label: %v", err) - } - - dollyBounds := dollyArea.Img.Bounds() - dollySize := 90 - dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) - dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 - dollyColor := color.RGBA{180, 180, 180, 255} // light gray - err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor) - if err != nil { - log.Printf("dolly silhouette not available (this is ok): %v", err) - } - - // Draw language bar at bottom - err = drawLanguagesCard(languageBarCard, languageStats) - if err != nil { - log.Printf("failed to draw language bar: %v", err) - return nil, err - } - - return mainCard, nil -} - -// hexToColor converts a hex color to a go color -func hexToColor(colorStr string) (*color.RGBA, error) { - colorStr = strings.TrimLeft(colorStr, "#") - - b, err := hex.DecodeString(colorStr) - if err != nil { - return nil, err - } - - if len(b) < 3 { - return nil, fmt.Errorf("expected at least 3 bytes from DecodeString, got %d", len(b)) - } - - clr := color.RGBA{b[0], b[1], b[2], 255} - - return &clr, nil -} - -func drawLanguagesCard(card *ogcard.Card, languageStats []types.RepoLanguageDetails) error { - bounds := card.Img.Bounds() - cardWidth := bounds.Dx() - - if len(languageStats) == 0 { - // Draw a light gray bar if no languages detected - card.DrawRect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, bounds.Max.Y, color.RGBA{225, 228, 232, 255}) - return nil - } - - // Limit to top 5 languages for the visual bar - displayLanguages := languageStats - if len(displayLanguages) > 5 { - displayLanguages = displayLanguages[:5] - } - - currentX := bounds.Min.X - - for _, lang := range displayLanguages { - var langColor *color.RGBA - var err error - - if lang.Color != "" { - langColor, err = hexToColor(lang.Color) - if err != nil { - // Fallback to a default color - langColor = &color.RGBA{149, 157, 165, 255} - } - } else { - // Default color if no color specified - langColor = &color.RGBA{149, 157, 165, 255} - } - - langWidth := float32(cardWidth) * (lang.Percentage / 100) - card.DrawRect(currentX, bounds.Min.Y, currentX+int(langWidth), bounds.Max.Y, langColor) - currentX += int(langWidth) - } - - // Fill remaining space with the last color (if any gap due to rounding) - if currentX < bounds.Max.X && len(displayLanguages) > 0 { - lastLang := displayLanguages[len(displayLanguages)-1] - var lastColor *color.RGBA - var err error - - if lastLang.Color != "" { - lastColor, err = hexToColor(lastLang.Color) - if err != nil { - lastColor = &color.RGBA{149, 157, 165, 255} - } - } else { - lastColor = &color.RGBA{149, 157, 165, 255} - } - card.DrawRect(currentX, bounds.Min.Y, bounds.Max.X, bounds.Max.Y, lastColor) - } - - return nil -} func (rp *Repo) Opengraph(w http.ResponseWriter, r *http.Request) { f, err := rp.repoResolver.Resolve(r) @@ -335,7 +21,16 @@ return } - // Get language stats directly from database + var ownerHandle string + owner, err := rp.idResolver.ResolveIdent(context.Background(), f.Did) + if err != nil { + ownerHandle = f.Did + } else { + ownerHandle = "@" + owner.Handle.String() + } + + avatarUrl := rp.pages.AvatarUrl(ownerHandle, "256") + var languageStats []types.RepoLanguageDetails langs, err := db.GetRepoLanguages( rp.db, @@ -344,7 +39,6 @@ ) if err != nil { log.Printf("failed to get language stats from db: %v", err) - // non-fatal, continue without language stats } else if len(langs) > 0 { var total int64 for _, l := range langs { @@ -375,29 +69,42 @@ }) } - card, err := rp.drawRepoSummaryCard(f, languageStats) - if err != nil { - log.Println("failed to draw repo summary card", err) - http.Error(w, "failed to draw repo summary card", http.StatusInternalServerError) - return + var ogLanguages []ogcard.LanguageData + for _, lang := range languageStats { + if len(ogLanguages) >= 5 { + break + } + ogLanguages = append(ogLanguages, ogcard.LanguageData{ + Color: lang.Color, + Percentage: lang.Percentage, + }) } - var imageBuffer bytes.Buffer - err = png.Encode(&imageBuffer, card.Img) - if err != nil { - log.Println("failed to encode repo summary card", err) - http.Error(w, "failed to encode repo summary card", http.StatusInternalServerError) - return + payload := ogcard.RepositoryCardPayload{ + Type: "repository", + RepoName: f.Name, + OwnerHandle: ownerHandle, + Stars: f.RepoStats.StarCount, + Pulls: f.RepoStats.PullCount.Open, + Issues: f.RepoStats.IssueCount.Open, + CreatedAt: f.Created.Format(time.RFC3339), + AvatarUrl: avatarUrl, + Languages: ogLanguages, } - imageBytes := imageBuffer.Bytes() + imageBytes, err := rp.ogcardClient.RenderRepositoryCard(r.Context(), payload) + if err != nil { + log.Println("failed to render repository card", err) + http.Error(w, "failed to render repository card", http.StatusInternalServerError) + return + } w.Header().Set("Content-Type", "image/png") - w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour + w.Header().Set("Cache-Control", "public, max-age=3600") w.WriteHeader(http.StatusOK) _, err = w.Write(imageBytes) if err != nil { - log.Println("failed to write repo summary card", err) + log.Println("failed to write repository card", err) return } } diff --git a/appview/repo/repo.go b/appview/repo/repo.go --- a/appview/repo/repo.go +++ b/appview/repo/repo.go @@ -20,6 +20,7 @@ "tangled.org/core/appview/models" "tangled.org/core/appview/notify" "tangled.org/core/appview/oauth" + "tangled.org/core/appview/ogcard" "tangled.org/core/appview/pages" "tangled.org/core/appview/reporesolver" "tangled.org/core/appview/validator" @@ -53,6 +54,7 @@ serviceAuth *serviceauth.ServiceAuth validator *validator.Validator cfClient *cloudflare.Client + ogcardClient *ogcard.Client } func New( @@ -82,6 +84,7 @@ logger: logger, validator: validator, cfClient: cfClient, + ogcardClient: ogcard.NewClient(config.Ogcard.Host), } } diff --git a/avatar/src/index.js b/avatar/src/index.js --- a/avatar/src/index.js +++ b/avatar/src/index.js @@ -150,6 +150,15 @@ const size = searchParams.get("size"); const resizeToTiny = size === "tiny"; + const format = searchParams.get("format") || "webp"; + const validFormats = ["webp", "jpeg", "png"]; + const outputFormat = validFormats.includes(format) ? format : "webp"; + + const contentTypes = { + webp: "image/webp", + jpeg: "image/jpeg", + png: "image/png", + }; const cache = caches.default; let cacheKey = request.url; @@ -242,20 +251,16 @@ // Fetch and optionally resize the avatar let avatarResponse; - if (resizeToTiny) { - avatarResponse = await fetch(avatarUrl, { - // cf: { - // image: { - // width: 32, - // height: 32, - // fit: "cover", - // format: "webp", - // }, - // }, - }); - } else { - avatarResponse = await fetch(avatarUrl); - } + const cfOptions = outputFormat !== "webp" || resizeToTiny ? { + cf: { + image: { + format: outputFormat, + ...(resizeToTiny ? { width: 32, height: 32, fit: "cover" } : {}), + }, + }, + }: {}; + + avatarResponse = await fetch(avatarUrl, cfOptions); if (!avatarResponse.ok) { return new Response(`failed to fetch avatar for ${actor}.`, { @@ -264,12 +269,10 @@ } const avatarData = await avatarResponse.arrayBuffer(); - const contentType = - avatarResponse.headers.get("content-type") || "image/jpeg"; response = new Response(avatarData, { headers: { - "Content-Type": contentType, + "Content-Type": contentTypes[outputFormat], "Cache-Control": "public, max-age=43200", }, }); diff --git a/appview/ogcard/src/index.tsx b/appview/ogcard/src/index.tsx new file mode 100644 --- /dev/null +++ b/appview/ogcard/src/index.tsx @@ -0,0 +1,96 @@ +import { cardPayloadSchema } from "./validation"; +import { renderCard } from "./lib/render"; +import { RepositoryCard } from "./components/cards/repository"; +import { IssueCard } from "./components/cards/issue"; +import { PullRequestCard } from "./components/cards/pull-request"; +import { z } from "zod"; + +declare global { + interface CacheStorage { + default: Cache; + } +} + +interface Env { + ENVIRONMENT: string; +} + +export default { + async fetch(request: Request, env: Env): Promise { + if (request.method !== "POST") { + return new Response("Method not allowed", { status: 405 }); + } + + const url = new URL(request.url); + const cardType = url.pathname.split("/").pop(); + + try { + const body = await request.json(); + const payload = cardPayloadSchema.parse(body); + + let component; + switch (payload.type) { + case "repository": + component = ; + break; + case "issue": + component = ; + break; + case "pullRequest": + component = ; + break; + default: + return new Response("Unknown card type", { status: 400 }); + } + + const cacheKeyUrl = new URL(request.url); + cacheKeyUrl.searchParams.set("payload", JSON.stringify(payload)); + const cacheKey = new Request(cacheKeyUrl.toString(), { method: "GET" }); + const cache = caches.default; + const cached = await cache.match(cacheKey); + + if (cached) { + return cached; + } + + const { png: pngBuffer } = await renderCard(component); + + const response = new Response(pngBuffer as any, { + headers: { + "Content-Type": "image/png", + "Cache-Control": "public, max-age=3600", + }, + }); + + await cache.put(cacheKey, response.clone()); + + return response; + } catch (error) { + if (error instanceof z.ZodError) { + return new Response( + JSON.stringify({ errors: (error as z.ZodError).issues }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + console.error("Error generating card:", error); + const errorMessage = + error instanceof Error ? error.message : String(error); + const errorStack = error instanceof Error ? error.stack : ""; + console.error("Error stack:", errorStack); + return new Response( + JSON.stringify({ + error: errorMessage, + stack: errorStack, + }), + { + status: 500, + headers: { "Content-Type": "application/json" }, + }, + ); + } + }, +}; diff --git a/appview/ogcard/src/types.d.ts b/appview/ogcard/src/types.d.ts new file mode 100644 --- /dev/null +++ b/appview/ogcard/src/types.d.ts @@ -0,0 +1,9 @@ +declare module "*.wasm?module" { + const value: WebAssembly.Module; + export default value; +} + +declare module "*.woff" { + const value: ArrayBuffer; + export default value; +} diff --git a/appview/ogcard/src/validation.ts b/appview/ogcard/src/validation.ts new file mode 100644 --- /dev/null +++ b/appview/ogcard/src/validation.ts @@ -0,0 +1,70 @@ +import { z } from "zod"; + +const hexColor = /^#[0-9A-Fa-f]{6}$/; + +const languageSchema = z.object({ + color: z.string().regex(hexColor), + percentage: z.number().min(0).max(100), +}); + +export const repositoryCardSchema = z.object({ + type: z.literal("repository"), + repoName: z.string().min(1).max(100), + ownerHandle: z.string().min(1).max(100), + stars: z.number().int().min(0).max(1000000), + pulls: z.number().int().min(0).max(100000), + issues: z.number().int().min(0).max(100000), + createdAt: z.string().max(100), + avatarUrl: z.string().url(), + languages: z.array(languageSchema).max(5), +}); + +export const issueCardSchema = z.object({ + type: z.literal("issue"), + repoName: z.string().min(1).max(100), + ownerHandle: z.string().min(1).max(100), + avatarUrl: z.string().url(), + title: z.string().min(1).max(500), + issueNumber: z.number().int().positive(), + status: z.enum(["open", "closed"]), + labels: z + .array( + z.object({ + name: z.string().max(50), + color: z.string().regex(hexColor), + }), + ) + .max(10), + commentCount: z.number().int().min(0), + reactionCount: z.number().int().min(0), + createdAt: z.string(), +}); + +export const pullRequestCardSchema = z.object({ + type: z.literal("pullRequest"), + repoName: z.string().min(1).max(100), + ownerHandle: z.string().min(1).max(100), + avatarUrl: z.string().url(), + title: z.string().min(1).max(500), + pullRequestNumber: z.number().int().positive(), + status: z.enum(["open", "closed", "merged"]), + filesChanged: z.number().int().min(0), + additions: z.number().int().min(0), + deletions: z.number().int().min(0), + rounds: z.number().int().min(1), + // reviews: z.number().int().min(0), // TODO: implement review tracking + commentCount: z.number().int().min(0), + reactionCount: z.number().int().min(0), + createdAt: z.string(), +}); + +export const cardPayloadSchema = z.discriminatedUnion("type", [ + repositoryCardSchema, + issueCardSchema, + pullRequestCardSchema, +]); + +export type Language = z.infer; +export type RepositoryCardData = z.infer; +export type IssueCardData = z.infer; +export type PullRequestCardData = z.infer; diff --git a/appview/ogcard/packages/runtime/index.ts b/appview/ogcard/packages/runtime/index.ts new file mode 100644 --- /dev/null +++ b/appview/ogcard/packages/runtime/index.ts @@ -0,0 +1,88 @@ +/** + * Bun/Node.js runtime implementation + * Uses filesystem APIs to load WASM and fonts + */ +import { readFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import type { FontData, SatoriFn, ResvgClass } from "./types"; + +const require = createRequire(import.meta.url); + +let satoriFn: SatoriFn | null = null; +let resvgInitialized = false; +let Resvg: ResvgClass | null = null; + +export async function initSatori(): Promise { + if (satoriFn) return satoriFn; + + const { default: satori } = await import("satori"); + satoriFn = satori; + + return satoriFn; +} + +export async function initResvg(): Promise { + if (resvgInitialized) return Resvg!; + + const { Resvg: ResvgClass, initWasm } = await import("@resvg/resvg-wasm"); + const wasmPath = require.resolve("@resvg/resvg-wasm/index_bg.wasm"); + const wasmBuffer = await readFile(wasmPath); + await initWasm(wasmBuffer); + + Resvg = ResvgClass; + resvgInitialized = true; + return Resvg; +} + +export async function loadFonts(): Promise { + // In Bun, .woff imports return a Module object with `default` being the file path + const inter400Module = await import( + "@fontsource/inter/files/inter-latin-400-normal.woff" + ); + const inter500Module = await import( + "@fontsource/inter/files/inter-latin-500-normal.woff" + ); + const inter600Module = await import( + "@fontsource/inter/files/inter-latin-600-normal.woff" + ); + + const inter400Path = (inter400Module as { default: string }).default; + const inter500Path = (inter500Module as { default: string }).default; + const inter600Path = (inter600Module as { default: string }).default; + + const [buf400, buf500, buf600] = await Promise.all([ + readFile(inter400Path), + readFile(inter500Path), + readFile(inter600Path), + ]); + + return [ + { + name: "Inter", + data: buf400.buffer.slice( + buf400.byteOffset, + buf400.byteOffset + buf400.byteLength, + ), + weight: 400, + style: "normal", + }, + { + name: "Inter", + data: buf500.buffer.slice( + buf500.byteOffset, + buf500.byteOffset + buf500.byteLength, + ), + weight: 500, + style: "normal", + }, + { + name: "Inter", + data: buf600.buffer.slice( + buf600.byteOffset, + buf600.byteOffset + buf600.byteLength, + ), + weight: 600, + style: "normal", + }, + ]; +} diff --git a/appview/ogcard/packages/runtime/package.json b/appview/ogcard/packages/runtime/package.json new file mode 100644 --- /dev/null +++ b/appview/ogcard/packages/runtime/package.json @@ -0,0 +1,12 @@ +{ + "name": "@tangled/ogcard-runtime", + "version": "1.0.0", + "private": true, + "type": "module", + "exports": { + "workerd": "./workerd.ts", + "bun": "./index.ts", + "default": "./index.ts" + }, + "types": "./types.ts" +} diff --git a/appview/ogcard/packages/runtime/types.ts b/appview/ogcard/packages/runtime/types.ts new file mode 100644 --- /dev/null +++ b/appview/ogcard/packages/runtime/types.ts @@ -0,0 +1,10 @@ +export interface FontData { + name: string; + data: ArrayBuffer; + weight: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900; + style: "normal" | "italic"; +} + +export type SatoriFn = typeof import("satori").default; + +export type ResvgClass = typeof import("@resvg/resvg-wasm").Resvg; diff --git a/appview/ogcard/packages/runtime/workerd.ts b/appview/ogcard/packages/runtime/workerd.ts new file mode 100644 --- /dev/null +++ b/appview/ogcard/packages/runtime/workerd.ts @@ -0,0 +1,60 @@ +/** + * Cloudflare Workers runtime implementation + * Uses ?module suffix for WASM imports as required by Wrangler + */ +import type { FontData, SatoriFn, ResvgClass } from "./types"; + +import inter400 from "@fontsource/inter/files/inter-latin-400-normal.woff"; +import inter500 from "@fontsource/inter/files/inter-latin-500-normal.woff"; +import inter600 from "@fontsource/inter/files/inter-latin-600-normal.woff"; + +let satoriFn: SatoriFn | null = null; +let resvgInitialized = false; +let Resvg: ResvgClass | null = null; + +export async function initSatori(): Promise { + if (satoriFn) return satoriFn; + + const { default: satori, init } = await import("satori/standalone"); + const wasmModule = (await import("satori/yoga.wasm?module")).default; + await init(wasmModule); + satoriFn = satori; + + return satoriFn; +} + +export async function initResvg(): Promise { + if (resvgInitialized) return Resvg!; + + const { Resvg: ResvgClass, initWasm } = await import("@resvg/resvg-wasm"); + const wasmModule = (await import("@resvg/resvg-wasm/index_bg.wasm?module")) + .default; + await initWasm(wasmModule); + + Resvg = ResvgClass; + resvgInitialized = true; + return Resvg; +} + +export async function loadFonts(): Promise { + return [ + { + name: "Inter", + data: inter400 as ArrayBuffer, + weight: 400, + style: "normal", + }, + { + name: "Inter", + data: inter500 as ArrayBuffer, + weight: 500, + style: "normal", + }, + { + name: "Inter", + data: inter600 as ArrayBuffer, + weight: 600, + style: "normal", + }, + ]; +} diff --git a/appview/ogcard/src/__tests__/fixtures.ts b/appview/ogcard/src/__tests__/fixtures.ts new file mode 100644 --- /dev/null +++ b/appview/ogcard/src/__tests__/fixtures.ts @@ -0,0 +1,87 @@ +import type { + RepositoryCardData, + IssueCardData, + PullRequestCardData, +} from "../validation"; + +const LONG_TITLE = + "fix critical memory leak in WebSocket connection handler that causes server crashes under high load conditions in production environments"; + +export const createRepoData = (avatarUrl: string): RepositoryCardData => ({ + type: "repository", + repoName: "core", + ownerHandle: "tangled.org", + stars: 746, + pulls: 82, + issues: 176, + createdAt: "2026-01-29T00:00:00Z", + avatarUrl, + languages: [ + { color: "#00ADD8", percentage: 50 }, + { color: "#e34c26", percentage: 30 }, + { color: "#7e7eff", percentage: 10 }, + { color: "#663399", percentage: 5 }, + { color: "#f1e05a", percentage: 5 }, + ], +}); + +export const createIssueData = ( + avatarUrl: string, + overrides?: Partial, +): IssueCardData => ({ + type: "issue", + repoName: "core", + ownerHandle: "tangled.org", + avatarUrl, + title: "feature request: sync fork button", + issueNumber: 8, + status: "open", + labels: [ + { name: "feature", color: "#4639d6" }, + { name: "help-wanted", color: "#008672" }, + { name: "enhancement", color: "#0052cc" }, + ], + commentCount: 12, + reactionCount: 5, + createdAt: "2026-01-29T00:00:00Z", + ...overrides, +}); + +export const createPullRequestData = ( + avatarUrl: string, + overrides?: Partial, +): PullRequestCardData => ({ + type: "pullRequest", + repoName: "core", + ownerHandle: "tangled.org", + avatarUrl, + title: "add author description to README.md", + pullRequestNumber: 1, + status: "open", + filesChanged: 2, + additions: 116, + deletions: 59, + rounds: 3, + commentCount: 12, + reactionCount: 31, + createdAt: "2026-01-29T00:00:00Z", + ...overrides, +}); + +export const createLongTitleIssueData = ( + avatarUrl: string, + overrides?: Partial, +): IssueCardData => ({ + ...createIssueData(avatarUrl), + title: LONG_TITLE, + ...overrides, +}); + +export const createLongTitlePullRequestData = ( + avatarUrl: string, + overrides?: Partial, +): PullRequestCardData => ({ + ...createPullRequestData(avatarUrl), + title: LONG_TITLE, + ...overrides, +}); diff --git a/appview/ogcard/src/__tests__/render.test.ts b/appview/ogcard/src/__tests__/render.test.ts new file mode 100644 --- /dev/null +++ b/appview/ogcard/src/__tests__/render.test.ts @@ -0,0 +1,132 @@ +import { test, describe, beforeAll } from "bun:test"; +import { writeFileSync, mkdirSync, readFileSync } from "fs"; +import { join } from "path"; +import { h, type VNode } from "preact"; +import { renderCard } from "../lib/render"; +import { RepositoryCard } from "../components/cards/repository"; +import { IssueCard } from "../components/cards/issue"; +import { PullRequestCard } from "../components/cards/pull-request"; +import { + repositoryCardSchema, + issueCardSchema, + pullRequestCardSchema, +} from "../validation"; +import { + createRepoData, + createIssueData, + createPullRequestData, + createLongTitleIssueData, + createLongTitlePullRequestData, +} from "./fixtures"; + +const outputDir = join(process.cwd(), "output"); +let avatarDataUri: string; + +const loadAvatar = (): string => { + const avatarPath = join( + process.cwd(), + "src", + "__tests__", + "assets", + "avatar.jpg", + ); + const avatarBase64 = readFileSync(avatarPath).toString("base64"); + return `data:image/jpeg;base64,${avatarBase64}`; +}; + +beforeAll(() => { + mkdirSync(outputDir, { recursive: true }); + avatarDataUri = loadAvatar(); +}); + +const savePng = (filename: string, buffer: Uint8Array) => { + writeFileSync(join(outputDir, filename), buffer); +}; + +const renderAndSave = async

(component: VNode

, filename: string) => { + const { png } = await renderCard(component as VNode); + savePng(filename, png); +}; + +describe("repository card", () => { + test("renders repository card", async () => { + const data = createRepoData(avatarDataUri); + const validated = repositoryCardSchema.parse(data); + await renderAndSave(h(RepositoryCard, validated), "repository-card.png"); + }); +}); + +describe("issue cards", () => { + test("renders open issue", async () => { + const data = createIssueData(avatarDataUri); + const validated = issueCardSchema.parse(data); + await renderAndSave(h(IssueCard, validated), "issue-card.png"); + }); + + test("renders closed issue", async () => { + const data = createIssueData(avatarDataUri, { + issueNumber: 5, + status: "closed", + labels: [{ name: "wontfix", color: "#6a737d" }], + reactionCount: 2, + }); + const validated = issueCardSchema.parse(data); + await renderAndSave(h(IssueCard, validated), "issue-card-closed.png"); + }); + + test("renders issue with long title", async () => { + const data = createLongTitleIssueData(avatarDataUri, { + issueNumber: 42, + }); + const validated = issueCardSchema.parse(data); + await renderAndSave(h(IssueCard, validated), "issue-card-long-title.png"); + }); +}); + +describe("pull request cards", () => { + test("renders open pull request", async () => { + const data = createPullRequestData(avatarDataUri); + const validated = pullRequestCardSchema.parse(data); + await renderAndSave(h(PullRequestCard, validated), "pull-request-card.png"); + }); + + test("renders merged pull request", async () => { + const data = createPullRequestData(avatarDataUri, { + pullRequestNumber: 2, + status: "merged", + title: "Implement OAuth2 authentication flow", + filesChanged: 5, + additions: 342, + deletions: 28, + }); + const validated = pullRequestCardSchema.parse(data); + await renderAndSave( + h(PullRequestCard, validated), + "pull-request-card-merged.png", + ); + }); + + test("renders closed pull request", async () => { + const data = createPullRequestData(avatarDataUri, { + pullRequestNumber: 3, + status: "closed", + title: "WIP: Experimental feature", + }); + const validated = pullRequestCardSchema.parse(data); + await renderAndSave( + h(PullRequestCard, validated), + "pull-request-card-closed.png", + ); + }); + + test("renders pull request with long title", async () => { + const data = createLongTitlePullRequestData(avatarDataUri, { + pullRequestNumber: 42, + }); + const validated = pullRequestCardSchema.parse(data); + await renderAndSave( + h(PullRequestCard, validated), + "pull-request-card-long-title.png", + ); + }); +}); diff --git a/appview/ogcard/src/icons/lucide.tsx b/appview/ogcard/src/icons/lucide.tsx new file mode 100644 --- /dev/null +++ b/appview/ogcard/src/icons/lucide.tsx @@ -0,0 +1,52 @@ +import { h } from "preact"; +import iconNodes from "lucide-static/icon-nodes.json"; + +interface IconProps { + size?: number; + color?: string; + strokeWidth?: number; +} + +type IconNodeEntry = [string, Record]; + +function createIcon(name: string) { + const nodes = (iconNodes as unknown as Record)[name]; + if (!nodes) throw new Error(`Icon "${name}" not found`); + + return function Icon({ + size = 24, + color = "currentColor", + strokeWidth = 2, + }: IconProps = {}) { + return h( + "svg", + { + xmlns: "http://www.w3.org/2000/svg", + width: size, + height: size, + viewBox: "0 0 24 24", + fill: "none", + stroke: color, + strokeWidth, + strokeLinecap: "round" as const, + strokeLinejoin: "round" as const, + }, + nodes.map(([tag, attrs], i) => h(tag, { key: i, ...attrs })), + ); + }; +} + +export const Star = createIcon("star"); +export const GitPullRequest = createIcon("git-pull-request"); +export const GitPullRequestClosed = createIcon("git-pull-request-closed"); +export const GitMerge = createIcon("git-merge"); +export const CircleDot = createIcon("circle-dot"); +export const Calendar = createIcon("calendar"); +export const MessageSquare = createIcon("message-square"); +export const MessageSquareCode = createIcon("message-square-code"); +export const Ban = createIcon("ban"); +export const SmilePlus = createIcon("smile-plus"); +export const FileDiff = createIcon("file-diff"); +export const RefreshCw = createIcon("refresh-cw"); + +export type LucideIcon = typeof Star; diff --git a/appview/ogcard/src/lib/render.ts b/appview/ogcard/src/lib/render.ts new file mode 100644 --- /dev/null +++ b/appview/ogcard/src/lib/render.ts @@ -0,0 +1,46 @@ +import type { VNode } from "preact"; +import { initSatori, initResvg, loadFonts } from "@tangled/ogcard-runtime"; +import type { ResvgClass } from "@tangled/ogcard-runtime/types"; + +let satoriFn: typeof import("satori").default | null = null; +let Resvg: ResvgClass | null = null; +let fontsLoaded = false; +let cachedFonts: Awaited> | null = null; + +export interface RenderResult { + svg: string; + png: Uint8Array; +} + +export async function renderCard(component: VNode): Promise { + if (!satoriFn) { + satoriFn = await initSatori(); + } + + if (!Resvg) { + Resvg = await initResvg(); + } + + if (!fontsLoaded) { + cachedFonts = await loadFonts(); + fontsLoaded = true; + } + + const svg = await satoriFn(component as any, { + width: 1200, + height: 630, + fonts: cachedFonts!, + embedFont: true, + }); + + const resvg = new Resvg!(svg, { + fitTo: { mode: "width", value: 1200 }, + }); + + const pngData = resvg.render(); + + return { + svg, + png: pngData.asPng(), + }; +} diff --git a/appview/ogcard/src/__tests__/assets/avatar.jpg b/appview/ogcard/src/__tests__/assets/avatar.jpg new file mode 100644 --- /dev/null +++ b/appview/ogcard/src/__tests__/assets/avatar.jpg diff --git a/appview/ogcard/src/components/cards/issue.tsx b/appview/ogcard/src/components/cards/issue.tsx new file mode 100644 --- /dev/null +++ b/appview/ogcard/src/components/cards/issue.tsx @@ -0,0 +1,52 @@ +import { Card, Row, Col } from "../shared/layout"; +import { TangledLogo } from "../shared/logo"; +import { IssueStatusBadge } from "../shared/status-badge"; +import { CardHeader } from "../shared/card-header"; +import { LabelList } from "../shared/label-pill"; +import { FooterStats } from "../shared/footer-stats"; +import { TYPOGRAPHY } from "../shared/constants"; +import type { IssueCardData } from "../../validation"; + +export function IssueCard(data: IssueCardData) { + return ( + + + + + + + + +

+ {data.title} +
+ + + + + + + + + + + ); +} diff --git a/appview/ogcard/src/components/cards/pull-request.tsx b/appview/ogcard/src/components/cards/pull-request.tsx new file mode 100644 --- /dev/null +++ b/appview/ogcard/src/components/cards/pull-request.tsx @@ -0,0 +1,137 @@ +import { Card, Row, Col } from "../shared/layout"; +import { TangledLogo } from "../shared/logo"; +import { StatusBadge } from "../shared/status-badge"; +import { CardHeader } from "../shared/card-header"; +import { FooterStats } from "../shared/footer-stats"; +import { FileDiff, RefreshCw } from "../../icons/lucide"; +import { COLORS, TYPOGRAPHY } from "../shared/constants"; +import type { PullRequestCardData } from "../../validation"; + +interface FilesChangedPillProps { + filesChanged: number; + additions: number; + deletions: number; +} + +function FilesChangedPill({ + filesChanged, + additions, + deletions, +}: FilesChangedPillProps) { + return ( + + + + + {filesChanged} files + + + + + + +{additions} + + + + + -{deletions} + + + + + ); +} + +interface MetricPillProps { + value: number; + label: string; +} + +function RoundsPill({ value, label }: MetricPillProps) { + return ( + + + + {value} {label} + + + ); +} + +export function PullRequestCard(data: PullRequestCardData) { + return ( + + + + + + + + + + {data.title} + + + + + + + + + + + + + + + ); +} diff --git a/appview/ogcard/src/components/cards/repository.tsx b/appview/ogcard/src/components/cards/repository.tsx new file mode 100644 --- /dev/null +++ b/appview/ogcard/src/components/cards/repository.tsx @@ -0,0 +1,44 @@ +import { Card, Row, Col } from "../shared/layout"; +import { Avatar } from "../shared/avatar"; +import { LanguageCircles } from "../shared/language-circles"; +import { Metrics } from "../shared/metrics"; +import { TangledLogo } from "../shared/logo"; +import { FooterStats } from "../shared/footer-stats"; +import { TYPOGRAPHY } from "../shared/constants"; +import type { RepositoryCardData } from "../../validation"; + +export function RepositoryCard(data: RepositoryCardData) { + return ( + + + + + + + {data.repoName} + + + + + + {data.ownerHandle} + + + + + + + + + + + + + + ); +} diff --git a/appview/ogcard/src/components/shared/avatar.tsx b/appview/ogcard/src/components/shared/avatar.tsx new file mode 100644 --- /dev/null +++ b/appview/ogcard/src/components/shared/avatar.tsx @@ -0,0 +1,31 @@ +interface AvatarProps { + src: string; + size?: number; +} + +export function Avatar({ src, size = 64 }: AvatarProps) { + const avatarSrc = + src.includes("avatar.tangled.sh") && !src.includes("format=") + ? `${src}${src.includes("?") ? "&" : "?"}format=jpeg` + : src; + + return ( +
+ +
+ ); +} diff --git a/appview/ogcard/src/components/shared/card-header.tsx b/appview/ogcard/src/components/shared/card-header.tsx new file mode 100644 --- /dev/null +++ b/appview/ogcard/src/components/shared/card-header.tsx @@ -0,0 +1,24 @@ +import { Row } from "./layout"; +import { Avatar } from "./avatar"; +import { TYPOGRAPHY } from "./constants"; + +interface CardHeaderProps { + avatarUrl: string; + ownerHandle: string; + repoName: string; +} + +export function CardHeader({ + avatarUrl, + ownerHandle, + repoName, +}: CardHeaderProps) { + return ( + + + + {ownerHandle} / {repoName} + + + ); +} diff --git a/appview/ogcard/src/components/shared/constants.ts b/appview/ogcard/src/components/shared/constants.ts new file mode 100644 --- /dev/null +++ b/appview/ogcard/src/components/shared/constants.ts @@ -0,0 +1,30 @@ +export const COLORS = { + text: "#000000", + textSecondary: "#7D7D7D", + icon: "#404040", + status: { + open: { bg: "#16A34A", text: "#ffffff" }, + closed: { bg: "#1f2937", text: "#ffffff" }, + merged: { bg: "#7C3AED", text: "#ffffff" }, + }, + label: { + text: "#202020", + border: "#E6E6E6", + }, + diff: { + additions: { bg: "#dcfce7", text: "#15803d" }, + deletions: { bg: "#fee2e2", text: "#b91c1c" }, + }, +} as const; + +export const TYPOGRAPHY = { + title: { fontFamily: "Inter", fontSize: 64, fontWeight: 600 }, + repoName: { fontFamily: "Inter", fontSize: 144, fontWeight: 600 }, + ownerHandle: { fontFamily: "Inter", fontSize: 48, fontWeight: 500 }, + cardHeader: { fontFamily: "Inter", fontSize: 48, fontWeight: 500 }, + status: { fontFamily: "Inter", fontSize: 48, fontWeight: 500 }, + metricValue: { fontFamily: "Inter", fontSize: 48, fontWeight: 500 }, + body: { fontFamily: "Inter", fontSize: 36, fontWeight: 400 }, + meta: { fontFamily: "Inter", fontSize: 32, fontWeight: 400 }, + label: { fontFamily: "Inter", fontSize: 24, fontWeight: 400 }, +} as const; diff --git a/appview/ogcard/src/components/shared/footer-stats.tsx b/appview/ogcard/src/components/shared/footer-stats.tsx new file mode 100644 --- /dev/null +++ b/appview/ogcard/src/components/shared/footer-stats.tsx @@ -0,0 +1,33 @@ +import { Row } from "./layout"; +import { Calendar, MessageSquare, SmilePlus } from "../../icons/lucide"; +import { StatItem } from "./stat-item"; + +interface FooterStatsProps { + createdAt: string; + reactionCount?: number; + commentCount?: number; +} + +export function FooterStats({ + createdAt, + reactionCount, + commentCount, +}: FooterStatsProps) { + const formattedDate = new Intl.DateTimeFormat("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + }).format(new Date(createdAt)); + + return ( + + + {reactionCount ? ( + + ) : null} + {commentCount ? ( + + ) : null} + + ); +} diff --git a/appview/ogcard/src/components/shared/label-pill.tsx b/appview/ogcard/src/components/shared/label-pill.tsx new file mode 100644 --- /dev/null +++ b/appview/ogcard/src/components/shared/label-pill.tsx @@ -0,0 +1,49 @@ +import { Row } from "./layout"; +import { COLORS, TYPOGRAPHY } from "./constants"; + +interface LabelPillProps { + name: string; + color: string; +} + +function LabelPill({ name, color }: LabelPillProps) { + return ( + +
+ + {name} + + + ); +} + +interface LabelListProps { + labels: Array<{ name: string; color: string }>; + max?: number; +} + +export function LabelList({ labels, max = 5 }: LabelListProps) { + if (labels.length === 0) return null; + + return ( + + {labels.slice(0, max).map((label, i) => ( + + ))} + + ); +} diff --git a/appview/ogcard/src/components/shared/language-circles.tsx b/appview/ogcard/src/components/shared/language-circles.tsx new file mode 100644 --- /dev/null +++ b/appview/ogcard/src/components/shared/language-circles.tsx @@ -0,0 +1,56 @@ +import type { Language } from "../../validation"; + +interface LanguageCirclesProps { + languages: Language[]; +} + +const MAX_RADIUS = 380; + +function percentageToThickness(percentage: number): number { + return (percentage / 100) * MAX_RADIUS; +} + +export function LanguageCircles({ languages }: LanguageCirclesProps) { + const sortedLanguages = [...languages] + .sort((a, b) => b.percentage - a.percentage) + .slice(0, 5) + .reverse(); + + let cumulativeRadius = 0; + + return ( +
+ {sortedLanguages.map((lang, i) => { + const thickness = percentageToThickness(lang.percentage); + const contentSize = cumulativeRadius * 2; + + cumulativeRadius += thickness; + + return ( +
+ ); + })} +
+ ); +} diff --git a/appview/ogcard/src/components/shared/layout.tsx b/appview/ogcard/src/components/shared/layout.tsx new file mode 100644 --- /dev/null +++ b/appview/ogcard/src/components/shared/layout.tsx @@ -0,0 +1,45 @@ +import type { ComponentChildren } from "preact"; + +interface StyleProps { + style?: Record; + children?: ComponentChildren; +} + +export function Card({ children, style }: StyleProps) { + return ( +
+ {children} +
+ ); +} + +export function Row({ children, style }: StyleProps) { + return ( +
+ {children} +
+ ); +} + +export function Col({ children, style }: StyleProps) { + return ( +
+ {children} +
+ ); +} diff --git a/appview/ogcard/src/components/shared/logo.tsx b/appview/ogcard/src/components/shared/logo.tsx new file mode 100644 --- /dev/null +++ b/appview/ogcard/src/components/shared/logo.tsx @@ -0,0 +1,68 @@ +export function TangledLogo() { + return ( +
+ + + + + + + + + + + + +
+ ); +} diff --git a/appview/ogcard/src/components/shared/metrics.tsx b/appview/ogcard/src/components/shared/metrics.tsx new file mode 100644 --- /dev/null +++ b/appview/ogcard/src/components/shared/metrics.tsx @@ -0,0 +1,43 @@ +import { Row, Col } from "./layout"; +import { TYPOGRAPHY } from "./constants"; +import { + Star, + GitPullRequest, + CircleDot, + type LucideIcon, +} from "../../icons/lucide"; + +interface MetricsProps { + stars: number; + pulls: number; + issues: number; +} + +// Display stars, pulls, issues with Lucide icons +export function Metrics({ stars, pulls, issues }: MetricsProps) { + return ( + + + + + + ); +} + +interface MetricItemProps { + value: number; + label: string; + Icon: LucideIcon; +} + +function MetricItem({ value, label, Icon }: MetricItemProps) { + return ( + + + {value} + + + {label} + + ); +} diff --git a/appview/ogcard/src/components/shared/stat-item.tsx b/appview/ogcard/src/components/shared/stat-item.tsx new file mode 100644 --- /dev/null +++ b/appview/ogcard/src/components/shared/stat-item.tsx @@ -0,0 +1,17 @@ +import { Row } from "./layout"; +import { TYPOGRAPHY } from "./constants"; +import type { LucideIcon } from "../../icons/lucide"; + +interface StatItemProps { + Icon: LucideIcon; + value: string | number; +} + +export function StatItem({ Icon, value }: StatItemProps) { + return ( + + + {value} + + ); +} diff --git a/appview/ogcard/src/components/shared/status-badge.tsx b/appview/ogcard/src/components/shared/status-badge.tsx new file mode 100644 --- /dev/null +++ b/appview/ogcard/src/components/shared/status-badge.tsx @@ -0,0 +1,73 @@ +import { Row } from "./layout"; +import { + CircleDot, + Ban, + GitPullRequest, + GitPullRequestClosed, + GitMerge, +} from "../../icons/lucide"; +import { COLORS, TYPOGRAPHY } from "./constants"; + +const STATUS_CONFIG = { + open: { + Icon: CircleDot, + bg: COLORS.status.open.bg, + text: COLORS.status.open.text, + }, + closed: { + Icon: Ban, + bg: COLORS.status.closed.bg, + text: COLORS.status.closed.text, + }, + merged: { + Icon: GitMerge, + bg: COLORS.status.merged.bg, + text: COLORS.status.merged.text, + }, +} as const; + +interface StatusBadgeProps { + status: "open" | "closed" | "merged"; +} + +export function StatusBadge({ status }: StatusBadgeProps) { + const config = + status === "merged" + ? STATUS_CONFIG.merged + : status === "closed" + ? STATUS_CONFIG.closed + : STATUS_CONFIG.open; + const Icon = config.Icon; + + return ( + + + {status} + + ); +} + +export function IssueStatusBadge({ status }: { status: "open" | "closed" }) { + const config = + status === "closed" ? STATUS_CONFIG.closed : STATUS_CONFIG.open; + const Icon = config.Icon; + + return ( + + + {status} + + ); +}