+38
-10
appview/db/timeline.go
+38
-10
appview/db/timeline.go
···
9
9
10
10
// TODO: this gathers heterogenous events from different sources and aggregates
11
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) {
12
+
func MakeTimeline(e Execer, limit int, loggedInUserDid string, limitToUsersIsFollowing bool) ([]models.TimelineEvent, error) {
13
13
var events []models.TimelineEvent
14
14
15
-
repos, err := getTimelineRepos(e, limit, loggedInUserDid)
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)
16
29
if err != nil {
17
30
return nil, err
18
31
}
19
32
20
-
stars, err := getTimelineStars(e, limit, loggedInUserDid)
33
+
stars, err := getTimelineStars(e, limit, loggedInUserDid, userIsFollowing)
21
34
if err != nil {
22
35
return nil, err
23
36
}
24
37
25
-
follows, err := getTimelineFollows(e, limit, loggedInUserDid)
38
+
follows, err := getTimelineFollows(e, limit, loggedInUserDid, userIsFollowing)
26
39
if err != nil {
27
40
return nil, err
28
41
}
···
70
83
return isStarred, starCount
71
84
}
72
85
73
-
func getTimelineRepos(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
74
-
repos, err := GetRepos(e, limit)
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...)
75
93
if err != nil {
76
94
return nil, err
77
95
}
···
125
143
return events, nil
126
144
}
127
145
128
-
func getTimelineStars(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
129
-
stars, err := GetStars(e, limit)
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...)
130
153
if err != nil {
131
154
return nil, err
132
155
}
···
166
189
return events, nil
167
190
}
168
191
169
-
func getTimelineFollows(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
170
-
follows, err := GetFollows(e, limit)
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...)
171
199
if err != nil {
172
200
return nil, err
173
201
}
+7
-7
appview/pages/funcmap.go
+7
-7
appview/pages/funcmap.go
···
265
265
return nil
266
266
},
267
267
"i": func(name string, classes ...string) template.HTML {
268
-
data, err := icon(name, classes)
268
+
data, err := p.icon(name, classes)
269
269
if err != nil {
270
270
log.Printf("icon %s does not exist", name)
271
-
data, _ = icon("airplay", classes)
271
+
data, _ = p.icon("airplay", classes)
272
272
}
273
273
return template.HTML(data)
274
274
},
275
-
"cssContentHash": CssContentHash,
275
+
"cssContentHash": p.CssContentHash,
276
276
"fileTree": filetree.FileTree,
277
277
"pathEscape": func(s string) string {
278
278
return url.PathEscape(s)
···
283
283
},
284
284
285
285
"tinyAvatar": func(handle string) string {
286
-
return p.avatarUri(handle, "tiny")
286
+
return p.AvatarUrl(handle, "tiny")
287
287
},
288
288
"fullAvatar": func(handle string) string {
289
-
return p.avatarUri(handle, "")
289
+
return p.AvatarUrl(handle, "")
290
290
},
291
291
"langColor": enry.GetColor,
292
292
"layoutSide": func() string {
···
310
310
}
311
311
}
312
312
313
-
func (p *Pages) avatarUri(handle, size string) string {
313
+
func (p *Pages) AvatarUrl(handle, size string) string {
314
314
handle = strings.TrimPrefix(handle, "@")
315
315
316
316
secret := p.avatar.SharedSecret
···
325
325
return fmt.Sprintf("%s/%s/%s?%s", p.avatar.Host, signature, handle, sizeArg)
326
326
}
327
327
328
-
func icon(name string, classes []string) (template.HTML, error) {
328
+
func (p *Pages) icon(name string, classes []string) (template.HTML, error) {
329
329
iconPath := filepath.Join("static", "icons", name)
330
330
331
331
if filepath.Ext(name) == "" {
+6
-1
appview/pages/markup/markdown.go
+6
-1
appview/pages/markup/markdown.go
···
5
5
"bytes"
6
6
"fmt"
7
7
"io"
8
+
"io/fs"
8
9
"net/url"
9
10
"path"
10
11
"strings"
···
20
21
"github.com/yuin/goldmark/renderer/html"
21
22
"github.com/yuin/goldmark/text"
22
23
"github.com/yuin/goldmark/util"
24
+
callout "gitlab.com/staticnoise/goldmark-callout"
23
25
htmlparse "golang.org/x/net/html"
24
26
25
27
"tangled.org/core/api/tangled"
···
45
47
IsDev bool
46
48
RendererType RendererType
47
49
Sanitizer Sanitizer
50
+
Files fs.FS
48
51
}
49
52
50
53
func (rctx *RenderContext) RenderMarkdown(source string) string {
···
62
65
extension.WithFootnoteIDPrefix([]byte("footnote")),
63
66
),
64
67
treeblood.MathML(),
68
+
callout.CalloutExtention,
65
69
),
66
70
goldmark.WithParserOptions(
67
71
parser.WithAutoHeadingID(),
···
140
144
func visitNode(ctx *RenderContext, node *htmlparse.Node) {
141
145
switch node.Type {
142
146
case htmlparse.ElementNode:
143
-
if node.Data == "img" || node.Data == "source" {
147
+
switch node.Data {
148
+
case "img", "source":
144
149
for i, attr := range node.Attr {
145
150
if attr.Key != "src" {
146
151
continue
+3
appview/pages/markup/sanitizer.go
+3
appview/pages/markup/sanitizer.go
+4
-3
appview/pages/pages.go
+4
-3
appview/pages/pages.go
···
61
61
CamoUrl: config.Camo.Host,
62
62
CamoSecret: config.Camo.SharedSecret,
63
63
Sanitizer: markup.NewSanitizer(),
64
+
Files: Files,
64
65
}
65
66
66
67
p := &Pages{
···
1477
1478
return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static")))
1478
1479
}
1479
1480
1480
-
sub, err := fs.Sub(Files, "static")
1481
+
sub, err := fs.Sub(p.embedFS, "static")
1481
1482
if err != nil {
1482
1483
p.logger.Error("no static dir found? that's crazy", "err", err)
1483
1484
panic(err)
···
1500
1501
})
1501
1502
}
1502
1503
1503
-
func CssContentHash() string {
1504
-
cssFile, err := Files.Open("static/tw.css")
1504
+
func (p *Pages) CssContentHash() string {
1505
+
cssFile, err := p.embedFS.Open("static/tw.css")
1505
1506
if err != nil {
1506
1507
slog.Debug("Error opening CSS file", "err", err)
1507
1508
return ""
+44
appview/pages/templates/fragments/dolly/silhouette.svg
+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
+2
-2
appview/pages/templates/layouts/base.html
···
26
26
</head>
27
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
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;">
29
+
<header class="w-full col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;">
30
30
31
31
{{ if .LoggedInUser }}
32
32
<div id="upgrade-banner"
···
58
58
{{ end }}
59
59
60
60
{{ block "footerLayout" . }}
61
-
<footer class="bg-white dark:bg-gray-800 mt-12">
61
+
<footer class="mt-12">
62
62
{{ template "layouts/fragments/footer" . }}
63
63
</footer>
64
64
{{ end }}
+1
-1
appview/pages/templates/layouts/fragments/topbar.html
+1
-1
appview/pages/templates/layouts/fragments/topbar.html
···
1
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">
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
3
<div class="flex justify-between p-0 items-center">
4
4
<div id="left-items">
5
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
+3
-3
appview/pages/templates/repo/commit.html
···
80
80
{{end}}
81
81
82
82
{{ define "topbarLayout" }}
83
-
<header class="px-1 col-span-full" style="z-index: 20;">
83
+
<header class="col-span-full" style="z-index: 20;">
84
84
{{ template "layouts/fragments/topbar" . }}
85
85
</header>
86
86
{{ end }}
87
87
88
88
{{ define "mainLayout" }}
89
-
<div class="px-1 col-span-full flex flex-col gap-4">
89
+
<div class="px-1 flex-grow col-span-full flex flex-col gap-4">
90
90
{{ block "contentLayout" . }}
91
91
{{ block "content" . }}{{ end }}
92
92
{{ end }}
···
105
105
{{ end }}
106
106
107
107
{{ define "footerLayout" }}
108
-
<footer class="px-1 col-span-full mt-12">
108
+
<footer class="col-span-full mt-12">
109
109
{{ template "layouts/fragments/footer" . }}
110
110
</footer>
111
111
{{ end }}
+9
-1
appview/pages/templates/repo/fragments/og.html
+9
-1
appview/pages/templates/repo/fragments/og.html
···
2
2
{{ $title := or .Title .RepoInfo.FullName }}
3
3
{{ $description := or .Description .RepoInfo.Description }}
4
4
{{ $url := or .Url (printf "https://tangled.org/%s" .RepoInfo.FullName) }}
5
-
5
+
{{ $imageUrl := printf "https://tangled.org/%s/opengraph" .RepoInfo.FullName }}
6
6
7
7
<meta property="og:title" content="{{ unescapeHtml $title }}" />
8
8
<meta property="og:type" content="object" />
9
9
<meta property="og:url" content="{{ $url }}" />
10
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 }}" />
11
19
{{ end }}
+1
-14
appview/pages/templates/repo/pulls/interdiff.html
+1
-14
appview/pages/templates/repo/pulls/interdiff.html
···
28
28
29
29
{{ end }}
30
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
31
{{ define "mainLayout" }}
38
-
<div class="px-1 col-span-full flex flex-col gap-4">
32
+
<div class="px-1 col-span-full flex-grow flex flex-col gap-4">
39
33
{{ block "contentLayout" . }}
40
34
{{ block "content" . }}{{ end }}
41
35
{{ end }}
···
52
46
{{ end }}
53
47
</div>
54
48
{{ 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
49
63
50
{{ define "contentAfter" }}
64
51
{{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff .DiffOpts) }}
+1
-13
appview/pages/templates/repo/pulls/patch.html
+1
-13
appview/pages/templates/repo/pulls/patch.html
···
34
34
</section>
35
35
{{ end }}
36
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
37
{{ define "mainLayout" }}
44
-
<div class="px-1 col-span-full flex flex-col gap-4">
38
+
<div class="px-1 col-span-full flex-grow flex flex-col gap-4">
45
39
{{ block "contentLayout" . }}
46
40
{{ block "content" . }}{{ end }}
47
41
{{ end }}
···
57
51
</div>
58
52
{{ end }}
59
53
</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
54
{{ end }}
67
55
68
56
{{ define "contentAfter" }}
+6
-4
appview/pulls/pulls.go
+6
-4
appview/pulls/pulls.go
···
1201
1201
Repo: string(f.RepoAt()),
1202
1202
Branch: targetBranch,
1203
1203
},
1204
-
Patch: patch,
1205
-
Source: recordPullSource,
1204
+
Patch: patch,
1205
+
Source: recordPullSource,
1206
+
CreatedAt: time.Now().Format(time.RFC3339),
1206
1207
},
1207
1208
},
1208
1209
})
···
1853
1854
Repo: string(f.RepoAt()),
1854
1855
Branch: pull.TargetBranch,
1855
1856
},
1856
-
Patch: patch, // new patch
1857
-
Source: recordPullSource,
1857
+
Patch: patch, // new patch
1858
+
Source: recordPullSource,
1859
+
CreatedAt: time.Now().Format(time.RFC3339),
1858
1860
},
1859
1861
},
1860
1862
})
+500
appview/repo/ogcard/card.go
+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
+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
+1
appview/repo/router.go
···
10
10
func (rp *Repo) Router(mw *middleware.Middleware) http.Handler {
11
11
r := chi.NewRouter()
12
12
r.Get("/", rp.RepoIndex)
13
+
r.Get("/opengraph", rp.RepoOpenGraphSummary)
13
14
r.Get("/feed.atom", rp.RepoAtomFeed)
14
15
r.Get("/commits/{ref}", rp.RepoLog)
15
16
r.Route("/tree/{ref}", func(r chi.Router) {
+1
appview/state/router.go
+1
appview/state/router.go
+18
-2
appview/state/state.go
+18
-2
appview/state/state.go
···
203
203
s.pages.Favicon(w)
204
204
}
205
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
+
206
216
// https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest
207
217
const manifestJson = `{
208
218
"name": "tangled",
···
258
268
func (s *State) Timeline(w http.ResponseWriter, r *http.Request) {
259
269
user := s.oauth.GetUser(r)
260
270
271
+
// TODO: set this flag based on the UI
272
+
filtered := false
273
+
261
274
var userDid string
262
275
if user != nil {
263
276
userDid = user.Did
264
277
}
265
-
timeline, err := db.MakeTimeline(s.db, 50, userDid)
278
+
timeline, err := db.MakeTimeline(s.db, 50, userDid, filtered)
266
279
if err != nil {
267
280
log.Println(err)
268
281
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
···
326
339
}
327
340
328
341
func (s *State) Home(w http.ResponseWriter, r *http.Request) {
329
-
timeline, err := db.MakeTimeline(s.db, 5, "")
342
+
// TODO: set this flag based on the UI
343
+
filtered := false
344
+
345
+
timeline, err := db.MakeTimeline(s.db, 5, "", filtered)
330
346
if err != nil {
331
347
log.Println(err)
332
348
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
+7
-3
go.mod
+7
-3
go.mod
···
21
21
github.com/go-chi/chi/v5 v5.2.0
22
22
github.com/go-enry/go-enry/v2 v2.9.2
23
23
github.com/go-git/go-git/v5 v5.14.0
24
+
github.com/goki/freetype v1.0.5
24
25
github.com/google/uuid v1.6.0
25
26
github.com/gorilla/feeds v1.2.0
26
27
github.com/gorilla/sessions v1.4.0
···
36
37
github.com/redis/go-redis/v9 v9.7.3
37
38
github.com/resend/resend-go/v2 v2.15.0
38
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
39
42
github.com/stretchr/testify v1.10.0
40
43
github.com/urfave/cli/v3 v3.3.3
41
44
github.com/whyrusleeping/cbor-gen v0.3.1
···
44
47
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
45
48
golang.org/x/crypto v0.40.0
46
49
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
50
+
golang.org/x/image v0.31.0
47
51
golang.org/x/net v0.42.0
48
-
golang.org/x/sync v0.16.0
52
+
golang.org/x/sync v0.17.0
49
53
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
50
54
gopkg.in/yaml.v3 v3.0.1
51
-
tangled.org/anirudh.fi/atproto-oauth v0.0.0-20251004062652-69f4561572b5
52
55
)
53
56
54
57
require (
···
157
160
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
158
161
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
159
162
github.com/wyatt915/treeblood v0.1.15 // indirect
163
+
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab // indirect
160
164
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
161
165
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
162
166
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
···
170
174
go.uber.org/multierr v1.11.0 // indirect
171
175
go.uber.org/zap v1.27.0 // indirect
172
176
golang.org/x/sys v0.34.0 // indirect
173
-
golang.org/x/text v0.27.0 // indirect
177
+
golang.org/x/text v0.29.0 // indirect
174
178
golang.org/x/time v0.12.0 // indirect
175
179
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect
176
180
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
+14
-10
go.sum
+14
-10
go.sum
···
23
23
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
24
24
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
25
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
26
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e h1:IutKPwmbU0LrYqw03EuwJtMdAe67rDTrL1U8S8dicRU=
29
27
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8=
30
28
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA=
···
138
136
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
139
137
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
140
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
141
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
142
142
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
143
143
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
···
245
245
github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8=
246
246
github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU=
247
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
248
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
251
249
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
252
250
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
···
401
399
github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM=
402
400
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
403
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=
404
406
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
405
407
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
406
408
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
···
442
444
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
443
445
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
444
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=
445
449
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA=
446
450
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8=
447
451
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q=
···
491
495
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
492
496
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
493
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=
494
500
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
495
501
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
496
502
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
···
530
536
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
531
537
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
532
538
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=
539
+
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
540
+
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
535
541
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
536
542
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
537
543
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
···
585
591
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
586
592
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
587
593
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=
594
+
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
595
+
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
590
596
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
591
597
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
592
598
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
···
654
660
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
655
661
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
656
662
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
663
tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc=
660
664
tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
+71
-12
input.css
+71
-12
input.css
···
134
134
}
135
135
136
136
.prose hr {
137
-
@apply my-2;
137
+
@apply my-2;
138
138
}
139
139
140
140
.prose li:has(input) {
141
-
@apply list-none;
141
+
@apply list-none;
142
142
}
143
143
144
144
.prose ul:has(input) {
145
-
@apply pl-2;
145
+
@apply pl-2;
146
146
}
147
147
148
148
.prose .heading .anchor {
149
-
@apply no-underline mx-2 opacity-0;
149
+
@apply no-underline mx-2 opacity-0;
150
150
}
151
151
152
152
.prose .heading:hover .anchor {
153
-
@apply opacity-70;
153
+
@apply opacity-70;
154
154
}
155
155
156
156
.prose .heading .anchor:hover {
157
-
@apply opacity-70;
157
+
@apply opacity-70;
158
158
}
159
159
160
160
.prose a.footnote-backref {
161
-
@apply no-underline;
161
+
@apply no-underline;
162
162
}
163
163
164
164
.prose li {
165
-
@apply my-0 py-0;
165
+
@apply my-0 py-0;
166
166
}
167
167
168
-
.prose ul, .prose ol {
169
-
@apply my-1 py-0;
168
+
.prose ul,
169
+
.prose ol {
170
+
@apply my-1 py-0;
170
171
}
171
172
172
173
.prose img {
···
176
177
}
177
178
178
179
.prose input {
179
-
@apply inline-block my-0 mb-1 mx-1;
180
+
@apply inline-block my-0 mb-1 mx-1;
180
181
}
181
182
182
183
.prose input[type="checkbox"] {
183
184
@apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500;
184
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
+
}
185
244
}
186
245
@layer utilities {
187
246
.error {
···
228
287
}
229
288
/* LineHighlight */
230
289
.chroma .hl {
231
-
@apply bg-amber-400/30 dark:bg-amber-500/20;
290
+
@apply bg-amber-400/30 dark:bg-amber-500/20;
232
291
}
233
292
234
293
/* LineNumbersTable */
+46
knotserver/internal.go
+46
knotserver/internal.go
···
13
13
securejoin "github.com/cyphar/filepath-securejoin"
14
14
"github.com/go-chi/chi/v5"
15
15
"github.com/go-chi/chi/v5/middleware"
16
+
"github.com/go-git/go-git/v5/plumbing"
16
17
"tangled.org/core/api/tangled"
17
18
"tangled.org/core/hook"
19
+
"tangled.org/core/idresolver"
18
20
"tangled.org/core/knotserver/config"
19
21
"tangled.org/core/knotserver/db"
20
22
"tangled.org/core/knotserver/git"
···
118
120
// non-fatal
119
121
}
120
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
+
121
135
err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions)
122
136
if err != nil {
123
137
l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
···
126
140
}
127
141
128
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
129
175
}
130
176
131
177
func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
+23
-11
nix/gomod2nix.toml
+23
-11
nix/gomod2nix.toml
···
40
40
hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ="
41
41
replaced = "tangled.sh/oppi.li/go-gitdiff"
42
42
[mod."github.com/bluesky-social/indigo"]
43
-
version = "v0.0.0-20250724221105-5827c8fb61bb"
44
-
hash = "sha256-uDYmzP4/mT7xP62LIL4QIOlkaKWS/IT1uho+udyVOAI="
43
+
version = "v0.0.0-20251003000214-3259b215110e"
44
+
hash = "sha256-qi/GrquJznbLnnHVpd7IqoryCESbi6xE4X1SiEM2qlo="
45
45
[mod."github.com/bluesky-social/jetstream"]
46
46
version = "v0.0.0-20241210005130-ea96859b93d1"
47
47
hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0="
···
163
163
[mod."github.com/gogo/protobuf"]
164
164
version = "v1.3.2"
165
165
hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c="
166
+
[mod."github.com/goki/freetype"]
167
+
version = "v1.0.5"
168
+
hash = "sha256-8ILVMx5w1/nV88RZPoG45QJ0jH1YEPJGLpZQdBJFqIs="
166
169
[mod."github.com/golang-jwt/jwt/v5"]
167
170
version = "v5.2.3"
168
171
hash = "sha256-dY2avNPPS3xokn5E+VCLxXcQk7DsM7err2QGrG0nXKo="
···
407
410
[mod."github.com/spaolacci/murmur3"]
408
411
version = "v1.1.0"
409
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="
410
419
[mod."github.com/stretchr/testify"]
411
420
version = "v1.10.0"
412
421
hash = "sha256-fJ4gnPr0vnrOhjQYQwJ3ARDKPsOtA7d4olQmQWR+wpI="
···
432
441
version = "v0.1.15"
433
442
hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g="
434
443
[mod."github.com/yuin/goldmark"]
435
-
version = "v1.7.12"
436
-
hash = "sha256-thLYBS4woL2X5qRdo7vP+xCvjlGRDU0jXtDCUt6vvWM="
444
+
version = "v1.7.13"
445
+
hash = "sha256-vBCxZrPYPc8x/nvAAv3Au59dCCyfS80Vw3/a9EXK7TE="
437
446
[mod."github.com/yuin/goldmark-highlighting/v2"]
438
447
version = "v2.0.0-20230729083705-37449abec8cc"
439
448
hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
449
+
[mod."gitlab.com/staticnoise/goldmark-callout"]
450
+
version = "v0.0.0-20240609120641-6366b799e4ab"
451
+
hash = "sha256-CgqBIYAuSmL2hcFu5OW18nWWaSy3pp3CNp5jlWzBX44="
440
452
[mod."gitlab.com/yawning/secp256k1-voi"]
441
453
version = "v0.0.0-20230925100816-f2616030848b"
442
454
hash = "sha256-X8INg01LTg13iOuwPI3uOhPN7r01sPZtmtwJ2sudjCA="
···
479
491
[mod."golang.org/x/exp"]
480
492
version = "v0.0.0-20250620022241-b7579e27df2b"
481
493
hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig="
494
+
[mod."golang.org/x/image"]
495
+
version = "v0.31.0"
496
+
hash = "sha256-ZFTlu9+4QToPPLA8C5UcG2eq/lQylq81RoG/WtYo9rg="
482
497
[mod."golang.org/x/net"]
483
498
version = "v0.42.0"
484
499
hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s="
485
500
[mod."golang.org/x/sync"]
486
-
version = "v0.16.0"
487
-
hash = "sha256-sqKDRESeMzLe0jWGWltLZL/JIgrn0XaIeBWCzVN3Bks="
501
+
version = "v0.17.0"
502
+
hash = "sha256-M85lz4hK3/fzmcUViAp/CowHSxnr3BHSO7pjHp1O6i0="
488
503
[mod."golang.org/x/sys"]
489
504
version = "v0.34.0"
490
505
hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50="
491
506
[mod."golang.org/x/text"]
492
-
version = "v0.27.0"
493
-
hash = "sha256-VX0rOh6L3qIvquKSGjfZQFU8URNtGvkNvxE7OZtboW8="
507
+
version = "v0.29.0"
508
+
hash = "sha256-2cWBtJje+Yc+AnSgCANqBlIwnOMZEGkpQ2cFI45VfLI="
494
509
[mod."golang.org/x/time"]
495
510
version = "v0.12.0"
496
511
hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw="
···
527
542
[mod."lukechampine.com/blake3"]
528
543
version = "v1.4.1"
529
544
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="
+1
nix/pkgs/appview-static-files.nix
+1
nix/pkgs/appview-static-files.nix
···
22
22
cp -rf ${lucide-src}/*.svg icons/
23
23
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/
24
24
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/
25
+
cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/
25
26
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/
26
27
# tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work
27
28
# for whatever reason (produces broken css), so we are doing this instead