forked from tangled.org/core
Monorepo for Tangled — https://tangled.org

Compare changes

Choose any two refs to compare.

Changed files
+1184 -98
appview
knotserver
nix
+38 -10
appview/db/timeline.go
··· 9 10 // TODO: this gathers heterogenous events from different sources and aggregates 11 // them in code; if we did this entirely in sql, we could order and limit and paginate easily 12 - func MakeTimeline(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 13 var events []models.TimelineEvent 14 15 - repos, err := getTimelineRepos(e, limit, loggedInUserDid) 16 if err != nil { 17 return nil, err 18 } 19 20 - stars, err := getTimelineStars(e, limit, loggedInUserDid) 21 if err != nil { 22 return nil, err 23 } 24 25 - follows, err := getTimelineFollows(e, limit, loggedInUserDid) 26 if err != nil { 27 return nil, err 28 } ··· 70 return isStarred, starCount 71 } 72 73 - func getTimelineRepos(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 74 - repos, err := GetRepos(e, limit) 75 if err != nil { 76 return nil, err 77 } ··· 125 return events, nil 126 } 127 128 - func getTimelineStars(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 129 - stars, err := GetStars(e, limit) 130 if err != nil { 131 return nil, err 132 } ··· 166 return events, nil 167 } 168 169 - func getTimelineFollows(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 170 - follows, err := GetFollows(e, limit) 171 if err != nil { 172 return nil, err 173 }
··· 9 10 // TODO: this gathers heterogenous events from different sources and aggregates 11 // them in code; if we did this entirely in sql, we could order and limit and paginate easily 12 + func MakeTimeline(e Execer, limit int, loggedInUserDid string, limitToUsersIsFollowing bool) ([]models.TimelineEvent, error) { 13 var events []models.TimelineEvent 14 15 + var userIsFollowing []string 16 + if limitToUsersIsFollowing { 17 + following, err := GetFollowing(e, loggedInUserDid) 18 + if err != nil { 19 + return nil, err 20 + } 21 + 22 + userIsFollowing = make([]string, 0, len(following)) 23 + for _, follow := range following { 24 + userIsFollowing = append(userIsFollowing, follow.SubjectDid) 25 + } 26 + } 27 + 28 + repos, err := getTimelineRepos(e, limit, loggedInUserDid, userIsFollowing) 29 if err != nil { 30 return nil, err 31 } 32 33 + stars, err := getTimelineStars(e, limit, loggedInUserDid, userIsFollowing) 34 if err != nil { 35 return nil, err 36 } 37 38 + follows, err := getTimelineFollows(e, limit, loggedInUserDid, userIsFollowing) 39 if err != nil { 40 return nil, err 41 } ··· 83 return isStarred, starCount 84 } 85 86 + func getTimelineRepos(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 87 + filters := make([]filter, 0) 88 + if userIsFollowing != nil { 89 + filters = append(filters, FilterIn("did", userIsFollowing)) 90 + } 91 + 92 + repos, err := GetRepos(e, limit, filters...) 93 if err != nil { 94 return nil, err 95 } ··· 143 return events, nil 144 } 145 146 + func getTimelineStars(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 147 + filters := make([]filter, 0) 148 + if userIsFollowing != nil { 149 + filters = append(filters, FilterIn("starred_by_did", userIsFollowing)) 150 + } 151 + 152 + stars, err := GetStars(e, limit, filters...) 153 if err != nil { 154 return nil, err 155 } ··· 189 return events, nil 190 } 191 192 + func getTimelineFollows(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 193 + filters := make([]filter, 0) 194 + if userIsFollowing != nil { 195 + filters = append(filters, FilterIn("user_did", userIsFollowing)) 196 + } 197 + 198 + follows, err := GetFollows(e, limit, filters...) 199 if err != nil { 200 return nil, err 201 }
+7 -7
appview/pages/funcmap.go
··· 265 return nil 266 }, 267 "i": func(name string, classes ...string) template.HTML { 268 - data, err := icon(name, classes) 269 if err != nil { 270 log.Printf("icon %s does not exist", name) 271 - data, _ = icon("airplay", classes) 272 } 273 return template.HTML(data) 274 }, 275 - "cssContentHash": CssContentHash, 276 "fileTree": filetree.FileTree, 277 "pathEscape": func(s string) string { 278 return url.PathEscape(s) ··· 283 }, 284 285 "tinyAvatar": func(handle string) string { 286 - return p.avatarUri(handle, "tiny") 287 }, 288 "fullAvatar": func(handle string) string { 289 - return p.avatarUri(handle, "") 290 }, 291 "langColor": enry.GetColor, 292 "layoutSide": func() string { ··· 310 } 311 } 312 313 - func (p *Pages) avatarUri(handle, size string) string { 314 handle = strings.TrimPrefix(handle, "@") 315 316 secret := p.avatar.SharedSecret ··· 325 return fmt.Sprintf("%s/%s/%s?%s", p.avatar.Host, signature, handle, sizeArg) 326 } 327 328 - func icon(name string, classes []string) (template.HTML, error) { 329 iconPath := filepath.Join("static", "icons", name) 330 331 if filepath.Ext(name) == "" {
··· 265 return nil 266 }, 267 "i": func(name string, classes ...string) template.HTML { 268 + data, err := p.icon(name, classes) 269 if err != nil { 270 log.Printf("icon %s does not exist", name) 271 + data, _ = p.icon("airplay", classes) 272 } 273 return template.HTML(data) 274 }, 275 + "cssContentHash": p.CssContentHash, 276 "fileTree": filetree.FileTree, 277 "pathEscape": func(s string) string { 278 return url.PathEscape(s) ··· 283 }, 284 285 "tinyAvatar": func(handle string) string { 286 + return p.AvatarUrl(handle, "tiny") 287 }, 288 "fullAvatar": func(handle string) string { 289 + return p.AvatarUrl(handle, "") 290 }, 291 "langColor": enry.GetColor, 292 "layoutSide": func() string { ··· 310 } 311 } 312 313 + func (p *Pages) AvatarUrl(handle, size string) string { 314 handle = strings.TrimPrefix(handle, "@") 315 316 secret := p.avatar.SharedSecret ··· 325 return fmt.Sprintf("%s/%s/%s?%s", p.avatar.Host, signature, handle, sizeArg) 326 } 327 328 + func (p *Pages) icon(name string, classes []string) (template.HTML, error) { 329 iconPath := filepath.Join("static", "icons", name) 330 331 if filepath.Ext(name) == "" {
+6 -1
appview/pages/markup/markdown.go
··· 5 "bytes" 6 "fmt" 7 "io" 8 "net/url" 9 "path" 10 "strings" ··· 20 "github.com/yuin/goldmark/renderer/html" 21 "github.com/yuin/goldmark/text" 22 "github.com/yuin/goldmark/util" 23 htmlparse "golang.org/x/net/html" 24 25 "tangled.org/core/api/tangled" ··· 45 IsDev bool 46 RendererType RendererType 47 Sanitizer Sanitizer 48 } 49 50 func (rctx *RenderContext) RenderMarkdown(source string) string { ··· 62 extension.WithFootnoteIDPrefix([]byte("footnote")), 63 ), 64 treeblood.MathML(), 65 ), 66 goldmark.WithParserOptions( 67 parser.WithAutoHeadingID(), ··· 140 func visitNode(ctx *RenderContext, node *htmlparse.Node) { 141 switch node.Type { 142 case htmlparse.ElementNode: 143 - if node.Data == "img" || node.Data == "source" { 144 for i, attr := range node.Attr { 145 if attr.Key != "src" { 146 continue
··· 5 "bytes" 6 "fmt" 7 "io" 8 + "io/fs" 9 "net/url" 10 "path" 11 "strings" ··· 21 "github.com/yuin/goldmark/renderer/html" 22 "github.com/yuin/goldmark/text" 23 "github.com/yuin/goldmark/util" 24 + callout "gitlab.com/staticnoise/goldmark-callout" 25 htmlparse "golang.org/x/net/html" 26 27 "tangled.org/core/api/tangled" ··· 47 IsDev bool 48 RendererType RendererType 49 Sanitizer Sanitizer 50 + Files fs.FS 51 } 52 53 func (rctx *RenderContext) RenderMarkdown(source string) string { ··· 65 extension.WithFootnoteIDPrefix([]byte("footnote")), 66 ), 67 treeblood.MathML(), 68 + callout.CalloutExtention, 69 ), 70 goldmark.WithParserOptions( 71 parser.WithAutoHeadingID(), ··· 144 func visitNode(ctx *RenderContext, node *htmlparse.Node) { 145 switch node.Type { 146 case htmlparse.ElementNode: 147 + switch node.Data { 148 + case "img", "source": 149 for i, attr := range node.Attr { 150 if attr.Key != "src" { 151 continue
+3
appview/pages/markup/sanitizer.go
··· 114 policy.AllowNoAttrs().OnElements(mathElements...) 115 policy.AllowAttrs(mathAttrs...).OnElements(mathElements...) 116 117 return policy 118 } 119
··· 114 policy.AllowNoAttrs().OnElements(mathElements...) 115 policy.AllowAttrs(mathAttrs...).OnElements(mathElements...) 116 117 + // goldmark-callout 118 + policy.AllowAttrs("data-callout").OnElements("details") 119 + 120 return policy 121 } 122
+4 -3
appview/pages/pages.go
··· 61 CamoUrl: config.Camo.Host, 62 CamoSecret: config.Camo.SharedSecret, 63 Sanitizer: markup.NewSanitizer(), 64 } 65 66 p := &Pages{ ··· 1477 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) 1478 } 1479 1480 - sub, err := fs.Sub(Files, "static") 1481 if err != nil { 1482 p.logger.Error("no static dir found? that's crazy", "err", err) 1483 panic(err) ··· 1500 }) 1501 } 1502 1503 - func CssContentHash() string { 1504 - cssFile, err := Files.Open("static/tw.css") 1505 if err != nil { 1506 slog.Debug("Error opening CSS file", "err", err) 1507 return ""
··· 61 CamoUrl: config.Camo.Host, 62 CamoSecret: config.Camo.SharedSecret, 63 Sanitizer: markup.NewSanitizer(), 64 + Files: Files, 65 } 66 67 p := &Pages{ ··· 1478 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) 1479 } 1480 1481 + sub, err := fs.Sub(p.embedFS, "static") 1482 if err != nil { 1483 p.logger.Error("no static dir found? that's crazy", "err", err) 1484 panic(err) ··· 1501 }) 1502 } 1503 1504 + func (p *Pages) CssContentHash() string { 1505 + cssFile, err := p.embedFS.Open("static/tw.css") 1506 if err != nil { 1507 slog.Debug("Error opening CSS file", "err", err) 1508 return ""
+44
appview/pages/templates/fragments/dolly/silhouette.svg
···
··· 1 + <svg 2 + version="1.1" 3 + id="svg1" 4 + width="32" 5 + height="32" 6 + viewBox="0 0 25 25" 7 + sodipodi:docname="tangled_dolly_silhouette.png" 8 + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 9 + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 10 + xmlns="http://www.w3.org/2000/svg" 11 + xmlns:svg="http://www.w3.org/2000/svg"> 12 + <title>Dolly</title> 13 + <defs 14 + id="defs1" /> 15 + <sodipodi:namedview 16 + id="namedview1" 17 + pagecolor="#ffffff" 18 + bordercolor="#000000" 19 + borderopacity="0.25" 20 + inkscape:showpageshadow="2" 21 + inkscape:pageopacity="0.0" 22 + inkscape:pagecheckerboard="true" 23 + inkscape:deskcolor="#d1d1d1"> 24 + <inkscape:page 25 + x="0" 26 + y="0" 27 + width="25" 28 + height="25" 29 + id="page2" 30 + margin="0" 31 + bleed="0" /> 32 + </sodipodi:namedview> 33 + <g 34 + inkscape:groupmode="layer" 35 + inkscape:label="Image" 36 + id="g1"> 37 + <path 38 + class="dolly" 39 + fill="currentColor" 40 + style="stroke-width:1.12248" 41 + d="m 16.208435,23.914069 c -0.06147,-0.02273 -0.147027,-0.03034 -0.190158,-0.01691 -0.197279,0.06145 -1.31068,-0.230493 -1.388819,-0.364153 -0.01956,-0.03344 -0.163274,-0.134049 -0.319377,-0.223561 -0.550395,-0.315603 -1.010951,-0.696643 -1.428383,-1.181771 -0.264598,-0.307509 -0.597257,-0.785384 -0.597257,-0.857979 0,-0.0216 -0.02841,-0.06243 -0.06313,-0.0907 -0.04977,-0.04053 -0.160873,0.0436 -0.52488,0.397463 -0.479803,0.466432 -0.78924,0.689475 -1.355603,0.977118 -0.183693,0.0933 -0.323426,0.179989 -0.310516,0.192658 0.02801,0.02748 -0.7656391,0.270031 -1.209129,0.369517 -0.5378332,0.120647 -1.6341809,0.08626 -1.9721503,-0.06186 C 6.7977157,23.031391 6.56735,22.957551 6.3371134,22.889782 4.9717169,22.487902 3.7511914,21.481518 3.1172396,20.234838 2.6890391,19.392772 2.5582276,18.827446 2.5610489,17.831154 2.5639589,16.802192 2.7366641,16.125844 3.2142117,15.273187 3.3040457,15.112788 3.3713143,14.976533 3.3636956,14.9704 3.3560756,14.9643 3.2459634,14.90305 3.1189994,14.834381 1.7582586,14.098312 0.77760984,12.777439 0.44909837,11.23818 0.33531456,10.705039 0.33670119,9.7067968 0.45195381,9.1778795 0.72259241,7.9359287 1.3827188,6.8888436 2.4297498,6.0407205 2.6856126,5.8334648 3.2975489,5.4910878 3.6885849,5.3364049 L 4.0584319,5.190106 4.2333984,4.860432 C 4.8393906,3.7186139 5.8908314,2.7968028 7.1056396,2.3423025 7.7690673,2.0940921 8.2290216,2.0150935 9.01853,2.0137575 c 0.9625627,-0.00163 1.629181,0.1532762 2.485864,0.5776514 l 0.271744,0.1346134 0.42911,-0.3607688 c 1.082666,-0.9102346 2.185531,-1.3136811 3.578383,-1.3090327 0.916696,0.00306 1.573918,0.1517893 2.356121,0.5331927 1.465948,0.7148 2.54506,2.0625628 2.865177,3.57848 l 0.07653,0.362429 0.515095,0.2556611 c 1.022872,0.5076874 1.756122,1.1690944 2.288361,2.0641468 0.401896,0.6758594 0.537303,1.0442682 0.675505,1.8378683 0.288575,1.6570823 -0.266229,3.3548023 -1.490464,4.5608743 -0.371074,0.36557 -0.840205,0.718265 -1.203442,0.904754 -0.144112,0.07398 -0.271303,0.15826 -0.282647,0.187269 -0.01134,0.02901 0.02121,0.142764 0.07234,0.25279 0.184248,0.396467 0.451371,1.331823 0.619371,2.168779 0.463493,2.30908 -0.754646,4.693707 -2.92278,5.721632 -0.479538,0.227352 -0.717629,0.309322 -1.144194,0.39393 -0.321869,0.06383 -1.850573,0.09139 -2.000174,0.03604 z M 12.25443,18.636956 c 0.739923,-0.24652 1.382521,-0.718922 1.874623,-1.37812 0.0752,-0.100718 0.213883,-0.275851 0.308198,-0.389167 0.09432,-0.113318 0.210136,-0.271056 0.257381,-0.350531 0.416347,-0.700389 0.680936,-1.176102 0.766454,-1.378041 0.05594,-0.132087 0.114653,-0.239607 0.130477,-0.238929 0.01583,6.79e-4 0.08126,0.08531 0.145412,0.188069 0.178029,0.285173 0.614305,0.658998 0.868158,0.743878 0.259802,0.08686 0.656158,0.09598 0.911369,0.02095 0.213812,-0.06285 0.507296,-0.298016 0.645179,-0.516947 0.155165,-0.246374 0.327989,-0.989595 0.327989,-1.410501 0,-1.26718 -0.610975,-3.143405 -1.237774,-3.801045 -0.198483,-0.2082486 -0.208557,-0.2319396 -0.208557,-0.4904655 0,-0.2517771 -0.08774,-0.5704927 -0.258476,-0.938956 C 16.694963,8.50313 16.375697,8.1377479 16.135846,7.9543702 L 15.932296,7.7987471 15.683004,7.9356529 C 15.131767,8.2383821 14.435638,8.1945733 13.943459,7.8261812 L 13.782862,7.7059758 13.686773,7.8908012 C 13.338849,8.5600578 12.487087,8.8811064 11.743178,8.6233891 11.487199,8.5347109 11.358897,8.4505994 11.063189,8.1776138 L 10.69871,7.8411436 10.453484,8.0579255 C 10.318608,8.1771557 10.113778,8.3156283 9.9983037,8.3656417 9.7041488,8.4930449 9.1808299,8.5227884 8.8979004,8.4281886 8.7754792,8.3872574 8.6687415,8.3537661 8.6607053,8.3537661 c -0.03426,0 -0.3092864,0.3066098 -0.3791974,0.42275 -0.041935,0.069664 -0.1040482,0.1266636 -0.1380294,0.1266636 -0.1316419,0 -0.4197402,0.1843928 -0.6257041,0.4004735 -0.1923125,0.2017571 -0.6853701,0.9036038 -0.8926582,1.2706578 -0.042662,0.07554 -0.1803555,0.353687 -0.3059848,0.618091 -0.1256293,0.264406 -0.3270073,0.686768 -0.4475067,0.938581 -0.1204992,0.251816 -0.2469926,0.519654 -0.2810961,0.595199 -0.2592829,0.574347 -0.285919,1.391094 -0.057822,1.77304 0.1690683,0.283105 0.4224039,0.480895 0.7285507,0.568809 0.487122,0.139885 0.9109638,-0.004 1.6013422,-0.543768 l 0.4560939,-0.356568 0.0036,0.172041 c 0.01635,0.781837 0.1831084,1.813183 0.4016641,2.484154 0.1160449,0.356262 0.3781448,0.83968 0.5614081,1.035462 0.2171883,0.232025 0.7140951,0.577268 1.0100284,0.701749 0.121485,0.0511 0.351032,0.110795 0.510105,0.132647 0.396966,0.05452 1.2105,0.02265 1.448934,-0.05679 z" 42 + id="path1" /> 43 + </g> 44 + </svg>
+2 -2
appview/pages/templates/layouts/base.html
··· 26 </head> 27 <body class="min-h-screen flex flex-col gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 28 {{ block "topbarLayout" . }} 29 - <header class="w-full bg-white dark:bg-gray-800 col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;"> 30 31 {{ if .LoggedInUser }} 32 <div id="upgrade-banner" ··· 58 {{ end }} 59 60 {{ block "footerLayout" . }} 61 - <footer class="bg-white dark:bg-gray-800 mt-12"> 62 {{ template "layouts/fragments/footer" . }} 63 </footer> 64 {{ end }}
··· 26 </head> 27 <body class="min-h-screen flex flex-col gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 28 {{ block "topbarLayout" . }} 29 + <header class="w-full col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;"> 30 31 {{ if .LoggedInUser }} 32 <div id="upgrade-banner" ··· 58 {{ end }} 59 60 {{ block "footerLayout" . }} 61 + <footer class="mt-12"> 62 {{ template "layouts/fragments/footer" . }} 63 </footer> 64 {{ end }}
+1 -1
appview/pages/templates/layouts/fragments/footer.html
··· 1 {{ define "layouts/fragments/footer" }} 2 - <div class="w-full p-8"> 3 <div class="mx-auto px-4"> 4 <div class="flex flex-col text-gray-600 dark:text-gray-400 gap-8"> 5 <!-- Desktop layout: grid with 3 columns -->
··· 1 {{ define "layouts/fragments/footer" }} 2 + <div class="w-full p-8 bg-white dark:bg-gray-800"> 3 <div class="mx-auto px-4"> 4 <div class="flex flex-col text-gray-600 dark:text-gray-400 gap-8"> 5 <!-- Desktop layout: grid with 3 columns -->
+1 -1
appview/pages/templates/layouts/fragments/topbar.html
··· 1 {{ define "layouts/fragments/topbar" }} 2 - <nav class="mx-auto space-x-4 px-6 py-2 rounded-b dark:text-white drop-shadow-sm"> 3 <div class="flex justify-between p-0 items-center"> 4 <div id="left-items"> 5 <a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2">
··· 1 {{ define "layouts/fragments/topbar" }} 2 + <nav class="mx-auto space-x-4 px-6 py-2 rounded-b dark:text-white drop-shadow-sm bg-white dark:bg-gray-800"> 3 <div class="flex justify-between p-0 items-center"> 4 <div id="left-items"> 5 <a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2">
+3 -3
appview/pages/templates/repo/commit.html
··· 80 {{end}} 81 82 {{ define "topbarLayout" }} 83 - <header class="px-1 col-span-full" style="z-index: 20;"> 84 {{ template "layouts/fragments/topbar" . }} 85 </header> 86 {{ end }} 87 88 {{ define "mainLayout" }} 89 - <div class="px-1 col-span-full flex flex-col gap-4"> 90 {{ block "contentLayout" . }} 91 {{ block "content" . }}{{ end }} 92 {{ end }} ··· 105 {{ end }} 106 107 {{ define "footerLayout" }} 108 - <footer class="px-1 col-span-full mt-12"> 109 {{ template "layouts/fragments/footer" . }} 110 </footer> 111 {{ end }}
··· 80 {{end}} 81 82 {{ define "topbarLayout" }} 83 + <header class="col-span-full" style="z-index: 20;"> 84 {{ template "layouts/fragments/topbar" . }} 85 </header> 86 {{ end }} 87 88 {{ define "mainLayout" }} 89 + <div class="px-1 flex-grow col-span-full flex flex-col gap-4"> 90 {{ block "contentLayout" . }} 91 {{ block "content" . }}{{ end }} 92 {{ end }} ··· 105 {{ end }} 106 107 {{ define "footerLayout" }} 108 + <footer class="col-span-full mt-12"> 109 {{ template "layouts/fragments/footer" . }} 110 </footer> 111 {{ end }}
+9 -1
appview/pages/templates/repo/fragments/og.html
··· 2 {{ $title := or .Title .RepoInfo.FullName }} 3 {{ $description := or .Description .RepoInfo.Description }} 4 {{ $url := or .Url (printf "https://tangled.org/%s" .RepoInfo.FullName) }} 5 - 6 7 <meta property="og:title" content="{{ unescapeHtml $title }}" /> 8 <meta property="og:type" content="object" /> 9 <meta property="og:url" content="{{ $url }}" /> 10 <meta property="og:description" content="{{ $description }}" /> 11 {{ end }}
··· 2 {{ $title := or .Title .RepoInfo.FullName }} 3 {{ $description := or .Description .RepoInfo.Description }} 4 {{ $url := or .Url (printf "https://tangled.org/%s" .RepoInfo.FullName) }} 5 + {{ $imageUrl := printf "https://tangled.org/%s/opengraph" .RepoInfo.FullName }} 6 7 <meta property="og:title" content="{{ unescapeHtml $title }}" /> 8 <meta property="og:type" content="object" /> 9 <meta property="og:url" content="{{ $url }}" /> 10 <meta property="og:description" content="{{ $description }}" /> 11 + <meta property="og:image" content="{{ $imageUrl }}" /> 12 + <meta property="og:image:width" content="1200" /> 13 + <meta property="og:image:height" content="600" /> 14 + 15 + <meta name="twitter:card" content="summary_large_image" /> 16 + <meta name="twitter:title" content="{{ unescapeHtml $title }}" /> 17 + <meta name="twitter:description" content="{{ $description }}" /> 18 + <meta name="twitter:image" content="{{ $imageUrl }}" /> 19 {{ end }}
+1 -14
appview/pages/templates/repo/pulls/interdiff.html
··· 28 29 {{ end }} 30 31 - {{ define "topbarLayout" }} 32 - <header class="px-1 col-span-full" style="z-index: 20;"> 33 - {{ template "layouts/fragments/topbar" . }} 34 - </header> 35 - {{ end }} 36 - 37 {{ define "mainLayout" }} 38 - <div class="px-1 col-span-full flex flex-col gap-4"> 39 {{ block "contentLayout" . }} 40 {{ block "content" . }}{{ end }} 41 {{ end }} ··· 52 {{ end }} 53 </div> 54 {{ end }} 55 - 56 - {{ define "footerLayout" }} 57 - <footer class="px-1 col-span-full mt-12"> 58 - {{ template "layouts/fragments/footer" . }} 59 - </footer> 60 - {{ end }} 61 - 62 63 {{ define "contentAfter" }} 64 {{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff .DiffOpts) }}
··· 28 29 {{ end }} 30 31 {{ define "mainLayout" }} 32 + <div class="px-1 col-span-full flex-grow flex flex-col gap-4"> 33 {{ block "contentLayout" . }} 34 {{ block "content" . }}{{ end }} 35 {{ end }} ··· 46 {{ end }} 47 </div> 48 {{ end }} 49 50 {{ define "contentAfter" }} 51 {{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff .DiffOpts) }}
+1 -13
appview/pages/templates/repo/pulls/patch.html
··· 34 </section> 35 {{ end }} 36 37 - {{ define "topbarLayout" }} 38 - <header class="px-1 col-span-full" style="z-index: 20;"> 39 - {{ template "layouts/fragments/topbar" . }} 40 - </header> 41 - {{ end }} 42 - 43 {{ define "mainLayout" }} 44 - <div class="px-1 col-span-full flex flex-col gap-4"> 45 {{ block "contentLayout" . }} 46 {{ block "content" . }}{{ end }} 47 {{ end }} ··· 57 </div> 58 {{ end }} 59 </div> 60 - {{ end }} 61 - 62 - {{ define "footerLayout" }} 63 - <footer class="px-1 col-span-full mt-12"> 64 - {{ template "layouts/fragments/footer" . }} 65 - </footer> 66 {{ end }} 67 68 {{ define "contentAfter" }}
··· 34 </section> 35 {{ end }} 36 37 {{ define "mainLayout" }} 38 + <div class="px-1 col-span-full flex-grow flex flex-col gap-4"> 39 {{ block "contentLayout" . }} 40 {{ block "content" . }}{{ end }} 41 {{ end }} ··· 51 </div> 52 {{ end }} 53 </div> 54 {{ end }} 55 56 {{ define "contentAfter" }}
+6 -4
appview/pulls/pulls.go
··· 1201 Repo: string(f.RepoAt()), 1202 Branch: targetBranch, 1203 }, 1204 - Patch: patch, 1205 - Source: recordPullSource, 1206 }, 1207 }, 1208 }) ··· 1853 Repo: string(f.RepoAt()), 1854 Branch: pull.TargetBranch, 1855 }, 1856 - Patch: patch, // new patch 1857 - Source: recordPullSource, 1858 }, 1859 }, 1860 })
··· 1201 Repo: string(f.RepoAt()), 1202 Branch: targetBranch, 1203 }, 1204 + Patch: patch, 1205 + Source: recordPullSource, 1206 + CreatedAt: time.Now().Format(time.RFC3339), 1207 }, 1208 }, 1209 }) ··· 1854 Repo: string(f.RepoAt()), 1855 Branch: pull.TargetBranch, 1856 }, 1857 + Patch: patch, // new patch 1858 + Source: recordPullSource, 1859 + CreatedAt: time.Now().Format(time.RFC3339), 1860 }, 1861 }, 1862 })
+500
appview/repo/ogcard/card.go
···
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // Copyright 2025 The Tangled Authors -- repurposed for Tangled use. 3 + // SPDX-License-Identifier: MIT 4 + 5 + package ogcard 6 + 7 + import ( 8 + "bytes" 9 + "fmt" 10 + "image" 11 + "image/color" 12 + "io" 13 + "log" 14 + "math" 15 + "net/http" 16 + "strings" 17 + "sync" 18 + "time" 19 + 20 + "github.com/goki/freetype" 21 + "github.com/goki/freetype/truetype" 22 + "github.com/srwiley/oksvg" 23 + "github.com/srwiley/rasterx" 24 + "golang.org/x/image/draw" 25 + "golang.org/x/image/font" 26 + "tangled.org/core/appview/pages" 27 + 28 + _ "golang.org/x/image/webp" // for processing webp images 29 + ) 30 + 31 + type Card struct { 32 + Img *image.RGBA 33 + Font *truetype.Font 34 + Margin int 35 + Width int 36 + Height int 37 + } 38 + 39 + var fontCache = sync.OnceValues(func() (*truetype.Font, error) { 40 + interVar, err := pages.Files.ReadFile("static/fonts/InterVariable.ttf") 41 + if err != nil { 42 + return nil, err 43 + } 44 + return truetype.Parse(interVar) 45 + }) 46 + 47 + // DefaultSize returns the default size for a card 48 + func DefaultSize() (int, int) { 49 + return 1200, 600 50 + } 51 + 52 + // NewCard creates a new card with the given dimensions in pixels 53 + func NewCard(width, height int) (*Card, error) { 54 + img := image.NewRGBA(image.Rect(0, 0, width, height)) 55 + draw.Draw(img, img.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) 56 + 57 + font, err := fontCache() 58 + if err != nil { 59 + return nil, err 60 + } 61 + 62 + return &Card{ 63 + Img: img, 64 + Font: font, 65 + Margin: 0, 66 + Width: width, 67 + Height: height, 68 + }, nil 69 + } 70 + 71 + // Split splits the card horizontally or vertically by a given percentage; the first card returned has the percentage 72 + // size, and the second card has the remainder. Both cards draw to a subsection of the same image buffer. 73 + func (c *Card) Split(vertical bool, percentage int) (*Card, *Card) { 74 + bounds := c.Img.Bounds() 75 + bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 76 + if vertical { 77 + mid := (bounds.Dx() * percentage / 100) + bounds.Min.X 78 + subleft := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, mid, bounds.Max.Y)).(*image.RGBA) 79 + subright := c.Img.SubImage(image.Rect(mid, bounds.Min.Y, bounds.Max.X, bounds.Max.Y)).(*image.RGBA) 80 + return &Card{Img: subleft, Font: c.Font, Width: subleft.Bounds().Dx(), Height: subleft.Bounds().Dy()}, 81 + &Card{Img: subright, Font: c.Font, Width: subright.Bounds().Dx(), Height: subright.Bounds().Dy()} 82 + } 83 + mid := (bounds.Dy() * percentage / 100) + bounds.Min.Y 84 + subtop := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, mid)).(*image.RGBA) 85 + subbottom := c.Img.SubImage(image.Rect(bounds.Min.X, mid, bounds.Max.X, bounds.Max.Y)).(*image.RGBA) 86 + return &Card{Img: subtop, Font: c.Font, Width: subtop.Bounds().Dx(), Height: subtop.Bounds().Dy()}, 87 + &Card{Img: subbottom, Font: c.Font, Width: subbottom.Bounds().Dx(), Height: subbottom.Bounds().Dy()} 88 + } 89 + 90 + // SetMargin sets the margins for the card 91 + func (c *Card) SetMargin(margin int) { 92 + c.Margin = margin 93 + } 94 + 95 + type ( 96 + VAlign int64 97 + HAlign int64 98 + ) 99 + 100 + const ( 101 + Top VAlign = iota 102 + Middle 103 + Bottom 104 + ) 105 + 106 + const ( 107 + Left HAlign = iota 108 + Center 109 + Right 110 + ) 111 + 112 + // DrawText draws text within the card, respecting margins and alignment 113 + func (c *Card) DrawText(text string, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) ([]string, error) { 114 + ft := freetype.NewContext() 115 + ft.SetDPI(72) 116 + ft.SetFont(c.Font) 117 + ft.SetFontSize(sizePt) 118 + ft.SetClip(c.Img.Bounds()) 119 + ft.SetDst(c.Img) 120 + ft.SetSrc(image.NewUniform(textColor)) 121 + 122 + face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72}) 123 + fontHeight := ft.PointToFixed(sizePt).Ceil() 124 + 125 + bounds := c.Img.Bounds() 126 + bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 127 + boxWidth, boxHeight := bounds.Size().X, bounds.Size().Y 128 + // draw.Draw(c.Img, bounds, image.NewUniform(color.Gray{128}), image.Point{}, draw.Src) // Debug draw box 129 + 130 + // Try to apply wrapping to this text; we'll find the most text that will fit into one line, record that line, move 131 + // on. We precalculate each line before drawing so that we can support valign="middle" correctly which requires 132 + // knowing the total height, which is related to how many lines we'll have. 133 + lines := make([]string, 0) 134 + textWords := strings.Split(text, " ") 135 + currentLine := "" 136 + heightTotal := 0 137 + 138 + for { 139 + if len(textWords) == 0 { 140 + // Ran out of words. 141 + if currentLine != "" { 142 + heightTotal += fontHeight 143 + lines = append(lines, currentLine) 144 + } 145 + break 146 + } 147 + 148 + nextWord := textWords[0] 149 + proposedLine := currentLine 150 + if proposedLine != "" { 151 + proposedLine += " " 152 + } 153 + proposedLine += nextWord 154 + 155 + proposedLineWidth := font.MeasureString(face, proposedLine) 156 + if proposedLineWidth.Ceil() > boxWidth { 157 + // no, proposed line is too big; we'll use the last "currentLine" 158 + heightTotal += fontHeight 159 + if currentLine != "" { 160 + lines = append(lines, currentLine) 161 + currentLine = "" 162 + // leave nextWord in textWords and keep going 163 + } else { 164 + // just nextWord by itself doesn't fit on a line; well, we can't skip it, but we'll consume it 165 + // regardless as a line by itself. It will be clipped by the drawing routine. 166 + lines = append(lines, nextWord) 167 + textWords = textWords[1:] 168 + } 169 + } else { 170 + // yes, it will fit 171 + currentLine = proposedLine 172 + textWords = textWords[1:] 173 + } 174 + } 175 + 176 + textY := 0 177 + switch valign { 178 + case Top: 179 + textY = fontHeight 180 + case Bottom: 181 + textY = boxHeight - heightTotal + fontHeight 182 + case Middle: 183 + textY = ((boxHeight - heightTotal) / 2) + fontHeight 184 + } 185 + 186 + for _, line := range lines { 187 + lineWidth := font.MeasureString(face, line) 188 + 189 + textX := 0 190 + switch halign { 191 + case Left: 192 + textX = 0 193 + case Right: 194 + textX = boxWidth - lineWidth.Ceil() 195 + case Center: 196 + textX = (boxWidth - lineWidth.Ceil()) / 2 197 + } 198 + 199 + pt := freetype.Pt(bounds.Min.X+textX, bounds.Min.Y+textY) 200 + _, err := ft.DrawString(line, pt) 201 + if err != nil { 202 + return nil, err 203 + } 204 + 205 + textY += fontHeight 206 + } 207 + 208 + return lines, nil 209 + } 210 + 211 + // DrawTextAt draws text at a specific position with the given alignment 212 + func (c *Card) DrawTextAt(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) error { 213 + _, err := c.DrawTextAtWithWidth(text, x, y, textColor, sizePt, valign, halign) 214 + return err 215 + } 216 + 217 + // DrawTextAtWithWidth draws text at a specific position and returns the text width 218 + func (c *Card) DrawTextAtWithWidth(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) { 219 + ft := freetype.NewContext() 220 + ft.SetDPI(72) 221 + ft.SetFont(c.Font) 222 + ft.SetFontSize(sizePt) 223 + ft.SetClip(c.Img.Bounds()) 224 + ft.SetDst(c.Img) 225 + ft.SetSrc(image.NewUniform(textColor)) 226 + 227 + face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72}) 228 + fontHeight := ft.PointToFixed(sizePt).Ceil() 229 + lineWidth := font.MeasureString(face, text) 230 + textWidth := lineWidth.Ceil() 231 + 232 + // Adjust position based on alignment 233 + adjustedX := x 234 + adjustedY := y 235 + 236 + switch halign { 237 + case Left: 238 + // x is already at the left position 239 + case Right: 240 + adjustedX = x - textWidth 241 + case Center: 242 + adjustedX = x - textWidth/2 243 + } 244 + 245 + switch valign { 246 + case Top: 247 + adjustedY = y + fontHeight 248 + case Bottom: 249 + adjustedY = y 250 + case Middle: 251 + adjustedY = y + fontHeight/2 252 + } 253 + 254 + pt := freetype.Pt(adjustedX, adjustedY) 255 + _, err := ft.DrawString(text, pt) 256 + return textWidth, err 257 + } 258 + 259 + // DrawBoldText draws bold text by rendering multiple times with slight offsets 260 + func (c *Card) DrawBoldText(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) { 261 + // Draw the text multiple times with slight offsets to create bold effect 262 + offsets := []struct{ dx, dy int }{ 263 + {0, 0}, // original 264 + {1, 0}, // right 265 + {0, 1}, // down 266 + {1, 1}, // diagonal 267 + } 268 + 269 + var width int 270 + for _, offset := range offsets { 271 + w, err := c.DrawTextAtWithWidth(text, x+offset.dx, y+offset.dy, textColor, sizePt, valign, halign) 272 + if err != nil { 273 + return 0, err 274 + } 275 + if width == 0 { 276 + width = w 277 + } 278 + } 279 + return width, nil 280 + } 281 + 282 + // DrawSVGIcon draws an SVG icon from the embedded files at the specified position 283 + func (c *Card) DrawSVGIcon(svgPath string, x, y, size int, iconColor color.Color) error { 284 + svgData, err := pages.Files.ReadFile(svgPath) 285 + if err != nil { 286 + return fmt.Errorf("failed to read SVG file %s: %w", svgPath, err) 287 + } 288 + 289 + // Convert color to hex string for SVG 290 + rgba, isRGBA := iconColor.(color.RGBA) 291 + if !isRGBA { 292 + r, g, b, a := iconColor.RGBA() 293 + rgba = color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), uint8(a >> 8)} 294 + } 295 + colorHex := fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B) 296 + 297 + // Replace currentColor with our desired color in the SVG 298 + svgString := string(svgData) 299 + svgString = strings.ReplaceAll(svgString, "currentColor", colorHex) 300 + 301 + // Make the stroke thicker 302 + svgString = strings.ReplaceAll(svgString, `stroke-width="2"`, `stroke-width="3"`) 303 + 304 + // Parse SVG 305 + icon, err := oksvg.ReadIconStream(strings.NewReader(svgString)) 306 + if err != nil { 307 + return fmt.Errorf("failed to parse SVG %s: %w", svgPath, err) 308 + } 309 + 310 + // Set the icon size 311 + w, h := float64(size), float64(size) 312 + icon.SetTarget(0, 0, w, h) 313 + 314 + // Create a temporary RGBA image for the icon 315 + iconImg := image.NewRGBA(image.Rect(0, 0, size, size)) 316 + 317 + // Create scanner and rasterizer 318 + scanner := rasterx.NewScannerGV(size, size, iconImg, iconImg.Bounds()) 319 + raster := rasterx.NewDasher(size, size, scanner) 320 + 321 + // Draw the icon 322 + icon.Draw(raster, 1.0) 323 + 324 + // Draw the icon onto the card at the specified position 325 + bounds := c.Img.Bounds() 326 + destRect := image.Rect(x, y, x+size, y+size) 327 + 328 + // Make sure we don't draw outside the card bounds 329 + if destRect.Max.X > bounds.Max.X { 330 + destRect.Max.X = bounds.Max.X 331 + } 332 + if destRect.Max.Y > bounds.Max.Y { 333 + destRect.Max.Y = bounds.Max.Y 334 + } 335 + 336 + draw.Draw(c.Img, destRect, iconImg, image.Point{}, draw.Over) 337 + 338 + return nil 339 + } 340 + 341 + // DrawImage fills the card with an image, scaled to maintain the original aspect ratio and centered with respect to the non-filled dimension 342 + func (c *Card) DrawImage(img image.Image) { 343 + bounds := c.Img.Bounds() 344 + targetRect := image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 345 + srcBounds := img.Bounds() 346 + srcAspect := float64(srcBounds.Dx()) / float64(srcBounds.Dy()) 347 + targetAspect := float64(targetRect.Dx()) / float64(targetRect.Dy()) 348 + 349 + var scale float64 350 + if srcAspect > targetAspect { 351 + // Image is wider than target, scale by width 352 + scale = float64(targetRect.Dx()) / float64(srcBounds.Dx()) 353 + } else { 354 + // Image is taller or equal, scale by height 355 + scale = float64(targetRect.Dy()) / float64(srcBounds.Dy()) 356 + } 357 + 358 + newWidth := int(math.Round(float64(srcBounds.Dx()) * scale)) 359 + newHeight := int(math.Round(float64(srcBounds.Dy()) * scale)) 360 + 361 + // Center the image within the target rectangle 362 + offsetX := (targetRect.Dx() - newWidth) / 2 363 + offsetY := (targetRect.Dy() - newHeight) / 2 364 + 365 + scaledRect := image.Rect(targetRect.Min.X+offsetX, targetRect.Min.Y+offsetY, targetRect.Min.X+offsetX+newWidth, targetRect.Min.Y+offsetY+newHeight) 366 + draw.CatmullRom.Scale(c.Img, scaledRect, img, srcBounds, draw.Over, nil) 367 + } 368 + 369 + func fallbackImage() image.Image { 370 + // can't usage image.Uniform(color.White) because it's infinitely sized causing a panic in the scaler in DrawImage 371 + img := image.NewRGBA(image.Rect(0, 0, 1, 1)) 372 + img.Set(0, 0, color.White) 373 + return img 374 + } 375 + 376 + // As defensively as possible, attempt to load an image from a presumed external and untrusted URL 377 + func (c *Card) fetchExternalImage(url string) (image.Image, bool) { 378 + // Use a short timeout; in the event of any failure we'll be logging and returning a placeholder, but we don't want 379 + // this rendering process to be slowed down 380 + client := &http.Client{ 381 + Timeout: 1 * time.Second, // 1 second timeout 382 + } 383 + 384 + resp, err := client.Get(url) 385 + if err != nil { 386 + log.Printf("error when fetching external image from %s: %v", url, err) 387 + return nil, false 388 + } 389 + defer resp.Body.Close() 390 + 391 + if resp.StatusCode != http.StatusOK { 392 + log.Printf("non-OK error code when fetching external image from %s: %s", url, resp.Status) 393 + return nil, false 394 + } 395 + 396 + contentType := resp.Header.Get("Content-Type") 397 + // Support content types are in-sync with the allowed custom avatar file types 398 + if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/gif" && contentType != "image/webp" { 399 + log.Printf("fetching external image returned unsupported Content-Type which was ignored: %s", contentType) 400 + return nil, false 401 + } 402 + 403 + body := resp.Body 404 + bodyBytes, err := io.ReadAll(body) 405 + if err != nil { 406 + log.Printf("error when fetching external image from %s: %v", url, err) 407 + return nil, false 408 + } 409 + 410 + bodyBuffer := bytes.NewReader(bodyBytes) 411 + _, imgType, err := image.DecodeConfig(bodyBuffer) 412 + if err != nil { 413 + log.Printf("error when decoding external image from %s: %v", url, err) 414 + return nil, false 415 + } 416 + 417 + // Verify that we have a match between actual data understood in the image body and the reported Content-Type 418 + if (contentType == "image/png" && imgType != "png") || 419 + (contentType == "image/jpeg" && imgType != "jpeg") || 420 + (contentType == "image/gif" && imgType != "gif") || 421 + (contentType == "image/webp" && imgType != "webp") { 422 + log.Printf("while fetching external image, mismatched image body (%s) and Content-Type (%s)", imgType, contentType) 423 + return nil, false 424 + } 425 + 426 + _, err = bodyBuffer.Seek(0, io.SeekStart) // reset for actual decode 427 + if err != nil { 428 + log.Printf("error w/ bodyBuffer.Seek") 429 + return nil, false 430 + } 431 + img, _, err := image.Decode(bodyBuffer) 432 + if err != nil { 433 + log.Printf("error when decoding external image from %s: %v", url, err) 434 + return nil, false 435 + } 436 + 437 + return img, true 438 + } 439 + 440 + func (c *Card) DrawExternalImage(url string) { 441 + image, ok := c.fetchExternalImage(url) 442 + if !ok { 443 + image = fallbackImage() 444 + } 445 + c.DrawImage(image) 446 + } 447 + 448 + // DrawCircularExternalImage draws an external image as a circle at the specified position 449 + func (c *Card) DrawCircularExternalImage(url string, x, y, size int) error { 450 + img, ok := c.fetchExternalImage(url) 451 + if !ok { 452 + img = fallbackImage() 453 + } 454 + 455 + // Create a circular mask 456 + circle := image.NewRGBA(image.Rect(0, 0, size, size)) 457 + center := size / 2 458 + radius := float64(size / 2) 459 + 460 + // Scale the source image to fit the circle 461 + srcBounds := img.Bounds() 462 + scaledImg := image.NewRGBA(image.Rect(0, 0, size, size)) 463 + draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil) 464 + 465 + // Draw the image with circular clipping 466 + for cy := 0; cy < size; cy++ { 467 + for cx := 0; cx < size; cx++ { 468 + // Calculate distance from center 469 + dx := float64(cx - center) 470 + dy := float64(cy - center) 471 + distance := math.Sqrt(dx*dx + dy*dy) 472 + 473 + // Only draw pixels within the circle 474 + if distance <= radius { 475 + circle.Set(cx, cy, scaledImg.At(cx, cy)) 476 + } 477 + } 478 + } 479 + 480 + // Draw the circle onto the card 481 + bounds := c.Img.Bounds() 482 + destRect := image.Rect(x, y, x+size, y+size) 483 + 484 + // Make sure we don't draw outside the card bounds 485 + if destRect.Max.X > bounds.Max.X { 486 + destRect.Max.X = bounds.Max.X 487 + } 488 + if destRect.Max.Y > bounds.Max.Y { 489 + destRect.Max.Y = bounds.Max.Y 490 + } 491 + 492 + draw.Draw(c.Img, destRect, circle, image.Point{}, draw.Over) 493 + 494 + return nil 495 + } 496 + 497 + // DrawRect draws a rect with the given color 498 + func (c *Card) DrawRect(startX, startY, endX, endY int, color color.Color) { 499 + draw.Draw(c.Img, image.Rect(startX, startY, endX, endY), &image.Uniform{color}, image.Point{}, draw.Src) 500 + }
+376
appview/repo/opengraph.go
···
··· 1 + package repo 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/hex" 7 + "fmt" 8 + "image/color" 9 + "image/png" 10 + "log" 11 + "net/http" 12 + "sort" 13 + "strings" 14 + 15 + "github.com/go-enry/go-enry/v2" 16 + "tangled.org/core/appview/db" 17 + "tangled.org/core/appview/models" 18 + "tangled.org/core/appview/repo/ogcard" 19 + "tangled.org/core/types" 20 + ) 21 + 22 + func (rp *Repo) drawRepoSummaryCard(repo *models.Repo, languageStats []types.RepoLanguageDetails) (*ogcard.Card, error) { 23 + width, height := ogcard.DefaultSize() 24 + mainCard, err := ogcard.NewCard(width, height) 25 + if err != nil { 26 + return nil, err 27 + } 28 + 29 + // Split: content area (75%) and language bar + icons (25%) 30 + contentCard, bottomArea := mainCard.Split(false, 75) 31 + 32 + // Add padding to content 33 + contentCard.SetMargin(50) 34 + 35 + // Split content horizontally: main content (80%) and avatar area (20%) 36 + mainContent, avatarArea := contentCard.Split(true, 80) 37 + 38 + // Split main content: 50% for name/description, 50% for spacing 39 + topSection, _ := mainContent.Split(false, 50) 40 + 41 + // Split top section: 40% for repo name, 60% for description 42 + repoNameCard, descriptionCard := topSection.Split(false, 50) 43 + 44 + // Draw repo name with owner in regular and repo name in bold 45 + repoNameCard.SetMargin(10) 46 + var ownerHandle string 47 + owner, err := rp.idResolver.ResolveIdent(context.Background(), repo.Did) 48 + if err != nil { 49 + ownerHandle = repo.Did 50 + } else { 51 + ownerHandle = "@" + owner.Handle.String() 52 + } 53 + 54 + // Draw repo name with wrapping support 55 + repoNameCard.SetMargin(10) 56 + bounds := repoNameCard.Img.Bounds() 57 + startX := bounds.Min.X + repoNameCard.Margin 58 + startY := bounds.Min.Y + repoNameCard.Margin 59 + currentX := startX 60 + textColor := color.RGBA{88, 96, 105, 255} 61 + 62 + // Draw owner handle in gray 63 + ownerWidth, err := repoNameCard.DrawTextAtWithWidth(ownerHandle, currentX, startY, textColor, 54, ogcard.Top, ogcard.Left) 64 + if err != nil { 65 + return nil, err 66 + } 67 + currentX += ownerWidth 68 + 69 + // Draw separator 70 + sepWidth, err := repoNameCard.DrawTextAtWithWidth(" / ", currentX, startY, textColor, 54, ogcard.Top, ogcard.Left) 71 + if err != nil { 72 + return nil, err 73 + } 74 + currentX += sepWidth 75 + 76 + // Draw repo name in bold 77 + _, err = repoNameCard.DrawBoldText(repo.Name, currentX, startY, color.Black, 54, ogcard.Top, ogcard.Left) 78 + if err != nil { 79 + return nil, err 80 + } 81 + 82 + // Draw description (DrawText handles multi-line wrapping automatically) 83 + descriptionCard.SetMargin(10) 84 + description := repo.Description 85 + if len(description) > 70 { 86 + description = description[:70] + "…" 87 + } 88 + 89 + _, err = descriptionCard.DrawText(description, color.RGBA{88, 96, 105, 255}, 36, ogcard.Top, ogcard.Left) 90 + if err != nil { 91 + log.Printf("failed to draw description: %v", err) 92 + return nil, err 93 + } 94 + 95 + // Draw avatar circle on the right side 96 + avatarBounds := avatarArea.Img.Bounds() 97 + avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin 98 + if avatarSize > 220 { 99 + avatarSize = 220 100 + } 101 + avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2) 102 + avatarY := avatarBounds.Min.Y + 20 103 + 104 + // Get avatar URL and draw it 105 + avatarURL := rp.pages.AvatarUrl(ownerHandle, "256") 106 + err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize) 107 + if err != nil { 108 + log.Printf("failed to draw avatar (non-fatal): %v", err) 109 + } 110 + 111 + // Split bottom area: icons area (65%) and language bar (35%) 112 + iconsArea, languageBarCard := bottomArea.Split(false, 75) 113 + 114 + // Split icons area: left side for stats (80%), right side for dolly (20%) 115 + statsArea, dollyArea := iconsArea.Split(true, 80) 116 + 117 + // Draw stats with icons in the stats area 118 + starsText := repo.RepoStats.StarCount 119 + issuesText := repo.RepoStats.IssueCount.Open 120 + pullRequestsText := repo.RepoStats.PullCount.Open 121 + 122 + iconColor := color.RGBA{88, 96, 105, 255} 123 + iconSize := 36 124 + textSize := 36.0 125 + 126 + // Position stats in the middle of the stats area 127 + statsBounds := statsArea.Img.Bounds() 128 + statsX := statsBounds.Min.X + 60 // left padding 129 + statsY := statsBounds.Min.Y 130 + currentX = statsX 131 + labelSize := 22.0 132 + // Draw star icon, count, and label 133 + // Align icon baseline with text baseline 134 + iconBaselineOffset := int(textSize) / 2 135 + err = statsArea.DrawSVGIcon("static/icons/star.svg", currentX, statsY+iconBaselineOffset-iconSize/2, iconSize, iconColor) 136 + if err != nil { 137 + log.Printf("failed to draw star icon: %v", err) 138 + } 139 + starIconX := currentX 140 + currentX += iconSize + 15 141 + 142 + starText := fmt.Sprintf("%d", starsText) 143 + err = statsArea.DrawTextAt(starText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) 144 + if err != nil { 145 + log.Printf("failed to draw star text: %v", err) 146 + } 147 + starTextWidth := len(starText) * 20 148 + starGroupWidth := iconSize + 15 + starTextWidth 149 + 150 + // Draw "stars" label below and centered under the icon+text group 151 + labelY := statsY + iconSize + 15 152 + labelX := starIconX + starGroupWidth/2 153 + err = iconsArea.DrawTextAt("stars", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center) 154 + if err != nil { 155 + log.Printf("failed to draw stars label: %v", err) 156 + } 157 + 158 + currentX += starTextWidth + 50 159 + 160 + // Draw issues icon, count, and label 161 + issueStartX := currentX 162 + err = statsArea.DrawSVGIcon("static/icons/circle-dot.svg", currentX, statsY+iconBaselineOffset-iconSize/2, iconSize, iconColor) 163 + if err != nil { 164 + log.Printf("failed to draw circle-dot icon: %v", err) 165 + } 166 + currentX += iconSize + 15 167 + 168 + issueText := fmt.Sprintf("%d", issuesText) 169 + err = statsArea.DrawTextAt(issueText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) 170 + if err != nil { 171 + log.Printf("failed to draw issue text: %v", err) 172 + } 173 + issueTextWidth := len(issueText) * 20 174 + issueGroupWidth := iconSize + 15 + issueTextWidth 175 + 176 + // Draw "issues" label below and centered under the icon+text group 177 + labelX = issueStartX + issueGroupWidth/2 178 + err = iconsArea.DrawTextAt("issues", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center) 179 + if err != nil { 180 + log.Printf("failed to draw issues label: %v", err) 181 + } 182 + 183 + currentX += issueTextWidth + 50 184 + 185 + // Draw pull request icon, count, and label 186 + prStartX := currentX 187 + err = statsArea.DrawSVGIcon("static/icons/git-pull-request.svg", currentX, statsY+iconBaselineOffset-iconSize/2, iconSize, iconColor) 188 + if err != nil { 189 + log.Printf("failed to draw git-pull-request icon: %v", err) 190 + } 191 + currentX += iconSize + 15 192 + 193 + prText := fmt.Sprintf("%d", pullRequestsText) 194 + err = statsArea.DrawTextAt(prText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) 195 + if err != nil { 196 + log.Printf("failed to draw PR text: %v", err) 197 + } 198 + prTextWidth := len(prText) * 20 199 + prGroupWidth := iconSize + 15 + prTextWidth 200 + 201 + // Draw "pulls" label below and centered under the icon+text group 202 + labelX = prStartX + prGroupWidth/2 203 + err = iconsArea.DrawTextAt("pulls", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center) 204 + if err != nil { 205 + log.Printf("failed to draw pulls label: %v", err) 206 + } 207 + 208 + dollyBounds := dollyArea.Img.Bounds() 209 + dollySize := 90 210 + dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 211 + dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 212 + dollyColor := color.RGBA{180, 180, 180, 255} // light gray 213 + err = dollyArea.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", dollyX, dollyY, dollySize, dollyColor) 214 + if err != nil { 215 + log.Printf("dolly silhouette not available (this is ok): %v", err) 216 + } 217 + 218 + // Draw language bar at bottom 219 + err = drawLanguagesCard(languageBarCard, languageStats) 220 + if err != nil { 221 + log.Printf("failed to draw language bar: %v", err) 222 + return nil, err 223 + } 224 + 225 + return mainCard, nil 226 + } 227 + 228 + // hexToColor converts a hex color to a go color 229 + func hexToColor(colorStr string) (*color.RGBA, error) { 230 + colorStr = strings.TrimLeft(colorStr, "#") 231 + 232 + b, err := hex.DecodeString(colorStr) 233 + if err != nil { 234 + return nil, err 235 + } 236 + 237 + if len(b) < 3 { 238 + return nil, fmt.Errorf("expected at least 3 bytes from DecodeString, got %d", len(b)) 239 + } 240 + 241 + clr := color.RGBA{b[0], b[1], b[2], 255} 242 + 243 + return &clr, nil 244 + } 245 + 246 + func drawLanguagesCard(card *ogcard.Card, languageStats []types.RepoLanguageDetails) error { 247 + bounds := card.Img.Bounds() 248 + cardWidth := bounds.Dx() 249 + 250 + if len(languageStats) == 0 { 251 + // Draw a light gray bar if no languages detected 252 + card.DrawRect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, bounds.Max.Y, color.RGBA{225, 228, 232, 255}) 253 + return nil 254 + } 255 + 256 + // Limit to top 5 languages for the visual bar 257 + displayLanguages := languageStats 258 + if len(displayLanguages) > 5 { 259 + displayLanguages = displayLanguages[:5] 260 + } 261 + 262 + currentX := bounds.Min.X 263 + 264 + for _, lang := range displayLanguages { 265 + var langColor *color.RGBA 266 + var err error 267 + 268 + if lang.Color != "" { 269 + langColor, err = hexToColor(lang.Color) 270 + if err != nil { 271 + // Fallback to a default color 272 + langColor = &color.RGBA{149, 157, 165, 255} 273 + } 274 + } else { 275 + // Default color if no color specified 276 + langColor = &color.RGBA{149, 157, 165, 255} 277 + } 278 + 279 + langWidth := float32(cardWidth) * (lang.Percentage / 100) 280 + card.DrawRect(currentX, bounds.Min.Y, currentX+int(langWidth), bounds.Max.Y, langColor) 281 + currentX += int(langWidth) 282 + } 283 + 284 + // Fill remaining space with the last color (if any gap due to rounding) 285 + if currentX < bounds.Max.X && len(displayLanguages) > 0 { 286 + lastLang := displayLanguages[len(displayLanguages)-1] 287 + var lastColor *color.RGBA 288 + var err error 289 + 290 + if lastLang.Color != "" { 291 + lastColor, err = hexToColor(lastLang.Color) 292 + if err != nil { 293 + lastColor = &color.RGBA{149, 157, 165, 255} 294 + } 295 + } else { 296 + lastColor = &color.RGBA{149, 157, 165, 255} 297 + } 298 + card.DrawRect(currentX, bounds.Min.Y, bounds.Max.X, bounds.Max.Y, lastColor) 299 + } 300 + 301 + return nil 302 + } 303 + 304 + func (rp *Repo) RepoOpenGraphSummary(w http.ResponseWriter, r *http.Request) { 305 + f, err := rp.repoResolver.Resolve(r) 306 + if err != nil { 307 + log.Println("failed to get repo and knot", err) 308 + return 309 + } 310 + 311 + // Get language stats directly from database 312 + var languageStats []types.RepoLanguageDetails 313 + langs, err := db.GetRepoLanguages( 314 + rp.db, 315 + db.FilterEq("repo_at", f.RepoAt()), 316 + db.FilterEq("is_default_ref", 1), 317 + ) 318 + if err != nil { 319 + log.Printf("failed to get language stats from db: %v", err) 320 + // non-fatal, continue without language stats 321 + } else if len(langs) > 0 { 322 + var total int64 323 + for _, l := range langs { 324 + total += l.Bytes 325 + } 326 + 327 + for _, l := range langs { 328 + percentage := float32(l.Bytes) / float32(total) * 100 329 + color := enry.GetColor(l.Language) 330 + languageStats = append(languageStats, types.RepoLanguageDetails{ 331 + Name: l.Language, 332 + Percentage: percentage, 333 + Color: color, 334 + }) 335 + } 336 + 337 + sort.Slice(languageStats, func(i, j int) bool { 338 + if languageStats[i].Name == enry.OtherLanguage { 339 + return false 340 + } 341 + if languageStats[j].Name == enry.OtherLanguage { 342 + return true 343 + } 344 + if languageStats[i].Percentage != languageStats[j].Percentage { 345 + return languageStats[i].Percentage > languageStats[j].Percentage 346 + } 347 + return languageStats[i].Name < languageStats[j].Name 348 + }) 349 + } 350 + 351 + card, err := rp.drawRepoSummaryCard(&f.Repo, languageStats) 352 + if err != nil { 353 + log.Println("failed to draw repo summary card", err) 354 + http.Error(w, "failed to draw repo summary card", http.StatusInternalServerError) 355 + return 356 + } 357 + 358 + var imageBuffer bytes.Buffer 359 + err = png.Encode(&imageBuffer, card.Img) 360 + if err != nil { 361 + log.Println("failed to encode repo summary card", err) 362 + http.Error(w, "failed to encode repo summary card", http.StatusInternalServerError) 363 + return 364 + } 365 + 366 + imageBytes := imageBuffer.Bytes() 367 + 368 + w.Header().Set("Content-Type", "image/png") 369 + w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour 370 + w.WriteHeader(http.StatusOK) 371 + _, err = w.Write(imageBytes) 372 + if err != nil { 373 + log.Println("failed to write repo summary card", err) 374 + return 375 + } 376 + }
+1
appview/repo/router.go
··· 10 func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { 11 r := chi.NewRouter() 12 r.Get("/", rp.RepoIndex) 13 r.Get("/feed.atom", rp.RepoAtomFeed) 14 r.Get("/commits/{ref}", rp.RepoLog) 15 r.Route("/tree/{ref}", func(r chi.Router) {
··· 10 func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { 11 r := chi.NewRouter() 12 r.Get("/", rp.RepoIndex) 13 + r.Get("/opengraph", rp.RepoOpenGraphSummary) 14 r.Get("/feed.atom", rp.RepoAtomFeed) 15 r.Get("/commits/{ref}", rp.RepoLog) 16 r.Route("/tree/{ref}", func(r chi.Router) {
+1
appview/state/router.go
··· 35 router.Get("/favicon.svg", s.Favicon) 36 router.Get("/favicon.ico", s.Favicon) 37 router.Get("/pwa-manifest.json", s.PWAManifest) 38 39 userRouter := s.UserRouter(&middleware) 40 standardRouter := s.StandardRouter(&middleware)
··· 35 router.Get("/favicon.svg", s.Favicon) 36 router.Get("/favicon.ico", s.Favicon) 37 router.Get("/pwa-manifest.json", s.PWAManifest) 38 + router.Get("/robots.txt", s.RobotsTxt) 39 40 userRouter := s.UserRouter(&middleware) 41 standardRouter := s.StandardRouter(&middleware)
+18 -2
appview/state/state.go
··· 203 s.pages.Favicon(w) 204 } 205 206 // https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest 207 const manifestJson = `{ 208 "name": "tangled", ··· 258 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 259 user := s.oauth.GetUser(r) 260 261 var userDid string 262 if user != nil { 263 userDid = user.Did 264 } 265 - timeline, err := db.MakeTimeline(s.db, 50, userDid) 266 if err != nil { 267 log.Println(err) 268 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") ··· 326 } 327 328 func (s *State) Home(w http.ResponseWriter, r *http.Request) { 329 - timeline, err := db.MakeTimeline(s.db, 5, "") 330 if err != nil { 331 log.Println(err) 332 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
··· 203 s.pages.Favicon(w) 204 } 205 206 + func (s *State) RobotsTxt(w http.ResponseWriter, r *http.Request) { 207 + w.Header().Set("Content-Type", "text/plain") 208 + w.Header().Set("Cache-Control", "public, max-age=86400") // one day 209 + 210 + robotsTxt := `User-agent: * 211 + Allow: / 212 + ` 213 + w.Write([]byte(robotsTxt)) 214 + } 215 + 216 // https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest 217 const manifestJson = `{ 218 "name": "tangled", ··· 268 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 269 user := s.oauth.GetUser(r) 270 271 + // TODO: set this flag based on the UI 272 + filtered := false 273 + 274 var userDid string 275 if user != nil { 276 userDid = user.Did 277 } 278 + timeline, err := db.MakeTimeline(s.db, 50, userDid, filtered) 279 if err != nil { 280 log.Println(err) 281 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") ··· 339 } 340 341 func (s *State) Home(w http.ResponseWriter, r *http.Request) { 342 + // TODO: set this flag based on the UI 343 + filtered := false 344 + 345 + timeline, err := db.MakeTimeline(s.db, 5, "", filtered) 346 if err != nil { 347 log.Println(err) 348 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
+7 -3
go.mod
··· 21 github.com/go-chi/chi/v5 v5.2.0 22 github.com/go-enry/go-enry/v2 v2.9.2 23 github.com/go-git/go-git/v5 v5.14.0 24 github.com/google/uuid v1.6.0 25 github.com/gorilla/feeds v1.2.0 26 github.com/gorilla/sessions v1.4.0 ··· 36 github.com/redis/go-redis/v9 v9.7.3 37 github.com/resend/resend-go/v2 v2.15.0 38 github.com/sethvargo/go-envconfig v1.1.0 39 github.com/stretchr/testify v1.10.0 40 github.com/urfave/cli/v3 v3.3.3 41 github.com/whyrusleeping/cbor-gen v0.3.1 ··· 44 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 45 golang.org/x/crypto v0.40.0 46 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b 47 golang.org/x/net v0.42.0 48 - golang.org/x/sync v0.16.0 49 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 50 gopkg.in/yaml.v3 v3.0.1 51 - tangled.org/anirudh.fi/atproto-oauth v0.0.0-20251004062652-69f4561572b5 52 ) 53 54 require ( ··· 157 github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 158 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 159 github.com/wyatt915/treeblood v0.1.15 // indirect 160 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 161 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 162 go.opentelemetry.io/auto/sdk v1.1.0 // indirect ··· 170 go.uber.org/multierr v1.11.0 // indirect 171 go.uber.org/zap v1.27.0 // indirect 172 golang.org/x/sys v0.34.0 // indirect 173 - golang.org/x/text v0.27.0 // indirect 174 golang.org/x/time v0.12.0 // indirect 175 google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect 176 google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
··· 21 github.com/go-chi/chi/v5 v5.2.0 22 github.com/go-enry/go-enry/v2 v2.9.2 23 github.com/go-git/go-git/v5 v5.14.0 24 + github.com/goki/freetype v1.0.5 25 github.com/google/uuid v1.6.0 26 github.com/gorilla/feeds v1.2.0 27 github.com/gorilla/sessions v1.4.0 ··· 37 github.com/redis/go-redis/v9 v9.7.3 38 github.com/resend/resend-go/v2 v2.15.0 39 github.com/sethvargo/go-envconfig v1.1.0 40 + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c 41 + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef 42 github.com/stretchr/testify v1.10.0 43 github.com/urfave/cli/v3 v3.3.3 44 github.com/whyrusleeping/cbor-gen v0.3.1 ··· 47 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 48 golang.org/x/crypto v0.40.0 49 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b 50 + golang.org/x/image v0.31.0 51 golang.org/x/net v0.42.0 52 + golang.org/x/sync v0.17.0 53 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 54 gopkg.in/yaml.v3 v3.0.1 55 ) 56 57 require ( ··· 160 github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 161 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 162 github.com/wyatt915/treeblood v0.1.15 // indirect 163 + gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab // indirect 164 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 165 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 166 go.opentelemetry.io/auto/sdk v1.1.0 // indirect ··· 174 go.uber.org/multierr v1.11.0 // indirect 175 go.uber.org/zap v1.27.0 // indirect 176 golang.org/x/sys v0.34.0 // indirect 177 + golang.org/x/text v0.29.0 // indirect 178 golang.org/x/time v0.12.0 // indirect 179 google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect 180 google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
+14 -10
go.sum
··· 23 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 24 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 25 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 26 - github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb h1:BqMNDZMfXwiRTJ6NvQotJ0qInn37JH5U8E+TF01CFHQ= 27 - github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb/go.mod h1:0XUyOCRtL4/OiyeqMTmr6RlVHQMDgw3LS7CfibuZR5Q= 28 github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e h1:IutKPwmbU0LrYqw03EuwJtMdAe67rDTrL1U8S8dicRU= 29 github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8= 30 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= ··· 138 github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 139 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 140 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 141 github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= 142 github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 143 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= ··· 245 github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8= 246 github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= 247 github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY= 248 - github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 249 - github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 250 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 251 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 252 github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= ··· 401 github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= 402 github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 403 github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 404 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 405 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 406 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= ··· 442 github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 443 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 444 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 445 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 446 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 447 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= ··· 491 golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= 492 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= 493 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= 494 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 495 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 496 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= ··· 530 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 531 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 532 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 533 - golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= 534 - golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 535 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 536 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 537 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= ··· 585 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 586 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 587 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 588 - golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= 589 - golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= 590 golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= 591 golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 592 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= ··· 654 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 655 lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= 656 lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= 657 - tangled.org/anirudh.fi/atproto-oauth v0.0.0-20251004062652-69f4561572b5 h1:EpQ9MT09jSf4Zjs1+yFvB4CD/fBkFdx8UaDJDwO1Jk8= 658 - tangled.org/anirudh.fi/atproto-oauth v0.0.0-20251004062652-69f4561572b5/go.mod h1:BQFGoN2V+h5KtgKsQgWU73R55ILdDy/R5RZTrZi6wog= 659 tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc= 660 tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
··· 23 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 24 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 25 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 26 github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e h1:IutKPwmbU0LrYqw03EuwJtMdAe67rDTrL1U8S8dicRU= 27 github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8= 28 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= ··· 136 github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 137 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 138 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 139 + github.com/goki/freetype v1.0.5 h1:yi2lQeUhXnBgSMqYd0vVmPw6RnnfIeTP3N4uvaJXd7A= 140 + github.com/goki/freetype v1.0.5/go.mod h1:wKmKxddbzKmeci9K96Wknn5kjTWLyfC8tKOqAFbEX8E= 141 github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= 142 github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 143 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= ··· 245 github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8= 246 github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= 247 github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY= 248 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 249 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 250 github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= ··· 399 github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= 400 github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 401 github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 402 + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= 403 + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= 404 + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= 405 + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= 406 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 407 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 408 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= ··· 444 github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 445 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 446 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 447 + gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab h1:gK9tS6QJw5F0SIhYJnGG2P83kuabOdmWBbSmZhJkz2A= 448 + gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab/go.mod h1:SPu13/NPe1kMrbGoJldQwqtpNhXsmIuHCfm/aaGjU0c= 449 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 450 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 451 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= ··· 495 golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= 496 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= 497 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= 498 + golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA= 499 + golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA= 500 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 501 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 502 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= ··· 536 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 537 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 538 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 539 + golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= 540 + golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 541 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 542 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 543 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= ··· 591 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 592 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 593 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 594 + golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= 595 + golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= 596 golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= 597 golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 598 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= ··· 660 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 661 lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= 662 lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= 663 tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc= 664 tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
+71 -12
input.css
··· 134 } 135 136 .prose hr { 137 - @apply my-2; 138 } 139 140 .prose li:has(input) { 141 - @apply list-none; 142 } 143 144 .prose ul:has(input) { 145 - @apply pl-2; 146 } 147 148 .prose .heading .anchor { 149 - @apply no-underline mx-2 opacity-0; 150 } 151 152 .prose .heading:hover .anchor { 153 - @apply opacity-70; 154 } 155 156 .prose .heading .anchor:hover { 157 - @apply opacity-70; 158 } 159 160 .prose a.footnote-backref { 161 - @apply no-underline; 162 } 163 164 .prose li { 165 - @apply my-0 py-0; 166 } 167 168 - .prose ul, .prose ol { 169 - @apply my-1 py-0; 170 } 171 172 .prose img { ··· 176 } 177 178 .prose input { 179 - @apply inline-block my-0 mb-1 mx-1; 180 } 181 182 .prose input[type="checkbox"] { 183 @apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500; 184 } 185 } 186 @layer utilities { 187 .error { ··· 228 } 229 /* LineHighlight */ 230 .chroma .hl { 231 - @apply bg-amber-400/30 dark:bg-amber-500/20; 232 } 233 234 /* LineNumbersTable */
··· 134 } 135 136 .prose hr { 137 + @apply my-2; 138 } 139 140 .prose li:has(input) { 141 + @apply list-none; 142 } 143 144 .prose ul:has(input) { 145 + @apply pl-2; 146 } 147 148 .prose .heading .anchor { 149 + @apply no-underline mx-2 opacity-0; 150 } 151 152 .prose .heading:hover .anchor { 153 + @apply opacity-70; 154 } 155 156 .prose .heading .anchor:hover { 157 + @apply opacity-70; 158 } 159 160 .prose a.footnote-backref { 161 + @apply no-underline; 162 } 163 164 .prose li { 165 + @apply my-0 py-0; 166 } 167 168 + .prose ul, 169 + .prose ol { 170 + @apply my-1 py-0; 171 } 172 173 .prose img { ··· 177 } 178 179 .prose input { 180 + @apply inline-block my-0 mb-1 mx-1; 181 } 182 183 .prose input[type="checkbox"] { 184 @apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500; 185 } 186 + 187 + /* Base callout */ 188 + details[data-callout] { 189 + @apply border-l-4 pl-3 py-2 text-gray-800 dark:text-gray-200 my-4; 190 + } 191 + 192 + details[data-callout] > summary { 193 + @apply font-bold cursor-pointer mb-1; 194 + } 195 + 196 + details[data-callout] > .callout-content { 197 + @apply text-sm leading-snug; 198 + } 199 + 200 + /* Note (blue) */ 201 + details[data-callout="note" i] { 202 + @apply border-blue-400 dark:border-blue-500; 203 + } 204 + details[data-callout="note" i] > summary { 205 + @apply text-blue-700 dark:text-blue-400; 206 + } 207 + 208 + /* Important (purple) */ 209 + details[data-callout="important" i] { 210 + @apply border-purple-400 dark:border-purple-500; 211 + } 212 + details[data-callout="important" i] > summary { 213 + @apply text-purple-700 dark:text-purple-400; 214 + } 215 + 216 + /* Warning (yellow) */ 217 + details[data-callout="warning" i] { 218 + @apply border-yellow-400 dark:border-yellow-500; 219 + } 220 + details[data-callout="warning" i] > summary { 221 + @apply text-yellow-700 dark:text-yellow-400; 222 + } 223 + 224 + /* Caution (red) */ 225 + details[data-callout="caution" i] { 226 + @apply border-red-400 dark:border-red-500; 227 + } 228 + details[data-callout="caution" i] > summary { 229 + @apply text-red-700 dark:text-red-400; 230 + } 231 + 232 + /* Tip (green) */ 233 + details[data-callout="tip" i] { 234 + @apply border-green-400 dark:border-green-500; 235 + } 236 + details[data-callout="tip" i] > summary { 237 + @apply text-green-700 dark:text-green-400; 238 + } 239 + 240 + /* Optional: hide the disclosure arrow like GitHub */ 241 + details[data-callout] > summary::-webkit-details-marker { 242 + display: none; 243 + } 244 } 245 @layer utilities { 246 .error { ··· 287 } 288 /* LineHighlight */ 289 .chroma .hl { 290 + @apply bg-amber-400/30 dark:bg-amber-500/20; 291 } 292 293 /* LineNumbersTable */
+46
knotserver/internal.go
··· 13 securejoin "github.com/cyphar/filepath-securejoin" 14 "github.com/go-chi/chi/v5" 15 "github.com/go-chi/chi/v5/middleware" 16 "tangled.org/core/api/tangled" 17 "tangled.org/core/hook" 18 "tangled.org/core/knotserver/config" 19 "tangled.org/core/knotserver/db" 20 "tangled.org/core/knotserver/git" ··· 118 // non-fatal 119 } 120 121 err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions) 122 if err != nil { 123 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) ··· 126 } 127 128 writeJSON(w, resp) 129 } 130 131 func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
··· 13 securejoin "github.com/cyphar/filepath-securejoin" 14 "github.com/go-chi/chi/v5" 15 "github.com/go-chi/chi/v5/middleware" 16 + "github.com/go-git/go-git/v5/plumbing" 17 "tangled.org/core/api/tangled" 18 "tangled.org/core/hook" 19 + "tangled.org/core/idresolver" 20 "tangled.org/core/knotserver/config" 21 "tangled.org/core/knotserver/db" 22 "tangled.org/core/knotserver/git" ··· 120 // non-fatal 121 } 122 123 + if (line.NewSha.String() != line.OldSha.String()) && line.OldSha.IsZero() { 124 + msg, err := h.replyCompare(line, gitUserDid, gitRelativeDir, repoName, r.Context()) 125 + if err != nil { 126 + l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 127 + // non-fatal 128 + } else { 129 + for msgLine := range msg { 130 + resp.Messages = append(resp.Messages, msg[msgLine]) 131 + } 132 + } 133 + } 134 + 135 err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions) 136 if err != nil { 137 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) ··· 140 } 141 142 writeJSON(w, resp) 143 + } 144 + 145 + func (h *InternalHandle) replyCompare(line git.PostReceiveLine, gitUserDid string, gitRelativeDir string, repoName string, ctx context.Context) ([]string, error) { 146 + l := h.l.With("handler", "replyCompare") 147 + userIdent, err := idresolver.DefaultResolver().ResolveIdent(ctx, gitUserDid) 148 + user := gitUserDid 149 + if err != nil { 150 + l.Error("Failed to fetch user identity", "err", err) 151 + // non-fatal 152 + } else { 153 + user = userIdent.Handle.String() 154 + } 155 + gr, err := git.PlainOpen(gitRelativeDir) 156 + if err != nil { 157 + l.Error("Failed to open git repository", "err", err) 158 + return []string{}, err 159 + } 160 + defaultBranch, err := gr.FindMainBranch() 161 + if err != nil { 162 + l.Error("Failed to fetch default branch", "err", err) 163 + return []string{}, err 164 + } 165 + if line.Ref == plumbing.NewBranchReferenceName(defaultBranch).String() { 166 + return []string{}, nil 167 + } 168 + ZWS := "\u200B" 169 + var msg []string 170 + msg = append(msg, ZWS) 171 + msg = append(msg, fmt.Sprintf("Create a PR pointing to %s", defaultBranch)) 172 + msg = append(msg, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/"))) 173 + msg = append(msg, ZWS) 174 + return msg, nil 175 } 176 177 func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
+23 -11
nix/gomod2nix.toml
··· 40 hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ=" 41 replaced = "tangled.sh/oppi.li/go-gitdiff" 42 [mod."github.com/bluesky-social/indigo"] 43 - version = "v0.0.0-20250724221105-5827c8fb61bb" 44 - hash = "sha256-uDYmzP4/mT7xP62LIL4QIOlkaKWS/IT1uho+udyVOAI=" 45 [mod."github.com/bluesky-social/jetstream"] 46 version = "v0.0.0-20241210005130-ea96859b93d1" 47 hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0=" ··· 163 [mod."github.com/gogo/protobuf"] 164 version = "v1.3.2" 165 hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c=" 166 [mod."github.com/golang-jwt/jwt/v5"] 167 version = "v5.2.3" 168 hash = "sha256-dY2avNPPS3xokn5E+VCLxXcQk7DsM7err2QGrG0nXKo=" ··· 407 [mod."github.com/spaolacci/murmur3"] 408 version = "v1.1.0" 409 hash = "sha256-RWD4PPrlAsZZ8Xy356MBxpj+/NZI7w2XOU14Ob7/Y9M=" 410 [mod."github.com/stretchr/testify"] 411 version = "v1.10.0" 412 hash = "sha256-fJ4gnPr0vnrOhjQYQwJ3ARDKPsOtA7d4olQmQWR+wpI=" ··· 432 version = "v0.1.15" 433 hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g=" 434 [mod."github.com/yuin/goldmark"] 435 - version = "v1.7.12" 436 - hash = "sha256-thLYBS4woL2X5qRdo7vP+xCvjlGRDU0jXtDCUt6vvWM=" 437 [mod."github.com/yuin/goldmark-highlighting/v2"] 438 version = "v2.0.0-20230729083705-37449abec8cc" 439 hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg=" 440 [mod."gitlab.com/yawning/secp256k1-voi"] 441 version = "v0.0.0-20230925100816-f2616030848b" 442 hash = "sha256-X8INg01LTg13iOuwPI3uOhPN7r01sPZtmtwJ2sudjCA=" ··· 479 [mod."golang.org/x/exp"] 480 version = "v0.0.0-20250620022241-b7579e27df2b" 481 hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig=" 482 [mod."golang.org/x/net"] 483 version = "v0.42.0" 484 hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s=" 485 [mod."golang.org/x/sync"] 486 - version = "v0.16.0" 487 - hash = "sha256-sqKDRESeMzLe0jWGWltLZL/JIgrn0XaIeBWCzVN3Bks=" 488 [mod."golang.org/x/sys"] 489 version = "v0.34.0" 490 hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50=" 491 [mod."golang.org/x/text"] 492 - version = "v0.27.0" 493 - hash = "sha256-VX0rOh6L3qIvquKSGjfZQFU8URNtGvkNvxE7OZtboW8=" 494 [mod."golang.org/x/time"] 495 version = "v0.12.0" 496 hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw=" ··· 527 [mod."lukechampine.com/blake3"] 528 version = "v1.4.1" 529 hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc=" 530 - [mod."tangled.org/anirudh.fi/atproto-oauth"] 531 - version = "v0.0.0-20250724194903-28e660378cb1" 532 - hash = "sha256-z7huwCTTHqLb1hxQW62lz9GQ3Orqt4URfeOVhQVd1f8="
··· 40 hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ=" 41 replaced = "tangled.sh/oppi.li/go-gitdiff" 42 [mod."github.com/bluesky-social/indigo"] 43 + version = "v0.0.0-20251003000214-3259b215110e" 44 + hash = "sha256-qi/GrquJznbLnnHVpd7IqoryCESbi6xE4X1SiEM2qlo=" 45 [mod."github.com/bluesky-social/jetstream"] 46 version = "v0.0.0-20241210005130-ea96859b93d1" 47 hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0=" ··· 163 [mod."github.com/gogo/protobuf"] 164 version = "v1.3.2" 165 hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c=" 166 + [mod."github.com/goki/freetype"] 167 + version = "v1.0.5" 168 + hash = "sha256-8ILVMx5w1/nV88RZPoG45QJ0jH1YEPJGLpZQdBJFqIs=" 169 [mod."github.com/golang-jwt/jwt/v5"] 170 version = "v5.2.3" 171 hash = "sha256-dY2avNPPS3xokn5E+VCLxXcQk7DsM7err2QGrG0nXKo=" ··· 410 [mod."github.com/spaolacci/murmur3"] 411 version = "v1.1.0" 412 hash = "sha256-RWD4PPrlAsZZ8Xy356MBxpj+/NZI7w2XOU14Ob7/Y9M=" 413 + [mod."github.com/srwiley/oksvg"] 414 + version = "v0.0.0-20221011165216-be6e8873101c" 415 + hash = "sha256-lZb6Y8HkrDpx9pxS+QQTcXI2MDSSv9pUyVTat59OrSk=" 416 + [mod."github.com/srwiley/rasterx"] 417 + version = "v0.0.0-20220730225603-2ab79fcdd4ef" 418 + hash = "sha256-/XmSE/J+f6FLWXGvljh6uBK71uoCAK3h82XQEQ1Ki68=" 419 [mod."github.com/stretchr/testify"] 420 version = "v1.10.0" 421 hash = "sha256-fJ4gnPr0vnrOhjQYQwJ3ARDKPsOtA7d4olQmQWR+wpI=" ··· 441 version = "v0.1.15" 442 hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g=" 443 [mod."github.com/yuin/goldmark"] 444 + version = "v1.7.13" 445 + hash = "sha256-vBCxZrPYPc8x/nvAAv3Au59dCCyfS80Vw3/a9EXK7TE=" 446 [mod."github.com/yuin/goldmark-highlighting/v2"] 447 version = "v2.0.0-20230729083705-37449abec8cc" 448 hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg=" 449 + [mod."gitlab.com/staticnoise/goldmark-callout"] 450 + version = "v0.0.0-20240609120641-6366b799e4ab" 451 + hash = "sha256-CgqBIYAuSmL2hcFu5OW18nWWaSy3pp3CNp5jlWzBX44=" 452 [mod."gitlab.com/yawning/secp256k1-voi"] 453 version = "v0.0.0-20230925100816-f2616030848b" 454 hash = "sha256-X8INg01LTg13iOuwPI3uOhPN7r01sPZtmtwJ2sudjCA=" ··· 491 [mod."golang.org/x/exp"] 492 version = "v0.0.0-20250620022241-b7579e27df2b" 493 hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig=" 494 + [mod."golang.org/x/image"] 495 + version = "v0.31.0" 496 + hash = "sha256-ZFTlu9+4QToPPLA8C5UcG2eq/lQylq81RoG/WtYo9rg=" 497 [mod."golang.org/x/net"] 498 version = "v0.42.0" 499 hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s=" 500 [mod."golang.org/x/sync"] 501 + version = "v0.17.0" 502 + hash = "sha256-M85lz4hK3/fzmcUViAp/CowHSxnr3BHSO7pjHp1O6i0=" 503 [mod."golang.org/x/sys"] 504 version = "v0.34.0" 505 hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50=" 506 [mod."golang.org/x/text"] 507 + version = "v0.29.0" 508 + hash = "sha256-2cWBtJje+Yc+AnSgCANqBlIwnOMZEGkpQ2cFI45VfLI=" 509 [mod."golang.org/x/time"] 510 version = "v0.12.0" 511 hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw=" ··· 542 [mod."lukechampine.com/blake3"] 543 version = "v1.4.1" 544 hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc="
+1
nix/pkgs/appview-static-files.nix
··· 22 cp -rf ${lucide-src}/*.svg icons/ 23 cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/ 24 cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/ 25 cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/ 26 # tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work 27 # for whatever reason (produces broken css), so we are doing this instead
··· 22 cp -rf ${lucide-src}/*.svg icons/ 23 cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/ 24 cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/ 25 + cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/ 26 cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/ 27 # tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work 28 # for whatever reason (produces broken css), so we are doing this instead