+79
-20
api/tangled/cbor_gen.go
+79
-20
api/tangled/cbor_gen.go
···
7934
}
7935
7936
cw := cbg.NewCborWriter(w)
7937
-
fieldCount := 9
7938
7939
if t.Body == nil {
7940
fieldCount--
7941
}
7942
7943
if t.Mentions == nil {
7944
fieldCount--
7945
}
7946
···
8008
}
8009
8010
// t.Patch (string) (string)
8011
-
if len("patch") > 1000000 {
8012
-
return xerrors.Errorf("Value in field \"patch\" was too long")
8013
-
}
8014
8015
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patch"))); err != nil {
8016
-
return err
8017
-
}
8018
-
if _, err := cw.WriteString(string("patch")); err != nil {
8019
-
return err
8020
-
}
8021
8022
-
if len(t.Patch) > 1000000 {
8023
-
return xerrors.Errorf("Value in field t.Patch was too long")
8024
-
}
8025
8026
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Patch))); err != nil {
8027
-
return err
8028
-
}
8029
-
if _, err := cw.WriteString(string(t.Patch)); err != nil {
8030
-
return err
8031
}
8032
8033
// t.Title (string) (string)
···
8147
return err
8148
}
8149
8150
// t.References ([]string) (slice)
8151
if t.References != nil {
8152
···
8262
case "patch":
8263
8264
{
8265
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8266
if err != nil {
8267
return err
8268
}
8269
8270
-
t.Patch = string(sval)
8271
}
8272
// t.Title (string) (string)
8273
case "title":
···
8370
}
8371
8372
t.CreatedAt = string(sval)
8373
}
8374
// t.References ([]string) (slice)
8375
case "references":
···
7934
}
7935
7936
cw := cbg.NewCborWriter(w)
7937
+
fieldCount := 10
7938
7939
if t.Body == nil {
7940
fieldCount--
7941
}
7942
7943
if t.Mentions == nil {
7944
+
fieldCount--
7945
+
}
7946
+
7947
+
if t.Patch == nil {
7948
fieldCount--
7949
}
7950
···
8012
}
8013
8014
// t.Patch (string) (string)
8015
+
if t.Patch != nil {
8016
8017
+
if len("patch") > 1000000 {
8018
+
return xerrors.Errorf("Value in field \"patch\" was too long")
8019
+
}
8020
8021
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patch"))); err != nil {
8022
+
return err
8023
+
}
8024
+
if _, err := cw.WriteString(string("patch")); err != nil {
8025
+
return err
8026
+
}
8027
+
8028
+
if t.Patch == nil {
8029
+
if _, err := cw.Write(cbg.CborNull); err != nil {
8030
+
return err
8031
+
}
8032
+
} else {
8033
+
if len(*t.Patch) > 1000000 {
8034
+
return xerrors.Errorf("Value in field t.Patch was too long")
8035
+
}
8036
8037
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Patch))); err != nil {
8038
+
return err
8039
+
}
8040
+
if _, err := cw.WriteString(string(*t.Patch)); err != nil {
8041
+
return err
8042
+
}
8043
+
}
8044
}
8045
8046
// t.Title (string) (string)
···
8160
return err
8161
}
8162
8163
+
// t.PatchBlob (util.LexBlob) (struct)
8164
+
if len("patchBlob") > 1000000 {
8165
+
return xerrors.Errorf("Value in field \"patchBlob\" was too long")
8166
+
}
8167
+
8168
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patchBlob"))); err != nil {
8169
+
return err
8170
+
}
8171
+
if _, err := cw.WriteString(string("patchBlob")); err != nil {
8172
+
return err
8173
+
}
8174
+
8175
+
if err := t.PatchBlob.MarshalCBOR(cw); err != nil {
8176
+
return err
8177
+
}
8178
+
8179
// t.References ([]string) (slice)
8180
if t.References != nil {
8181
···
8291
case "patch":
8292
8293
{
8294
+
b, err := cr.ReadByte()
8295
if err != nil {
8296
return err
8297
}
8298
+
if b != cbg.CborNull[0] {
8299
+
if err := cr.UnreadByte(); err != nil {
8300
+
return err
8301
+
}
8302
8303
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8304
+
if err != nil {
8305
+
return err
8306
+
}
8307
+
8308
+
t.Patch = (*string)(&sval)
8309
+
}
8310
}
8311
// t.Title (string) (string)
8312
case "title":
···
8409
}
8410
8411
t.CreatedAt = string(sval)
8412
+
}
8413
+
// t.PatchBlob (util.LexBlob) (struct)
8414
+
case "patchBlob":
8415
+
8416
+
{
8417
+
8418
+
b, err := cr.ReadByte()
8419
+
if err != nil {
8420
+
return err
8421
+
}
8422
+
if b != cbg.CborNull[0] {
8423
+
if err := cr.UnreadByte(); err != nil {
8424
+
return err
8425
+
}
8426
+
t.PatchBlob = new(util.LexBlob)
8427
+
if err := t.PatchBlob.UnmarshalCBOR(cr); err != nil {
8428
+
return xerrors.Errorf("unmarshaling t.PatchBlob pointer: %w", err)
8429
+
}
8430
+
}
8431
+
8432
}
8433
// t.References ([]string) (slice)
8434
case "references":
+12
-9
api/tangled/repopull.go
+12
-9
api/tangled/repopull.go
···
17
} //
18
// RECORDTYPE: RepoPull
19
type RepoPull struct {
20
-
LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"`
21
-
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
22
-
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
-
Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"`
24
-
Patch string `json:"patch" cborgen:"patch"`
25
-
References []string `json:"references,omitempty" cborgen:"references,omitempty"`
26
-
Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"`
27
-
Target *RepoPull_Target `json:"target" cborgen:"target"`
28
-
Title string `json:"title" cborgen:"title"`
29
}
30
31
// RepoPull_Source is a "source" in the sh.tangled.repo.pull schema.
···
17
} //
18
// RECORDTYPE: RepoPull
19
type RepoPull struct {
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"`
21
+
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
22
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
+
Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"`
24
+
// patch: (deprecated) use patchBlob instead
25
+
Patch *string `json:"patch,omitempty" cborgen:"patch,omitempty"`
26
+
// patchBlob: patch content
27
+
PatchBlob *util.LexBlob `json:"patchBlob" cborgen:"patchBlob"`
28
+
References []string `json:"references,omitempty" cborgen:"references,omitempty"`
29
+
Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"`
30
+
Target *RepoPull_Target `json:"target" cborgen:"target"`
31
+
Title string `json:"title" cborgen:"title"`
32
}
33
34
// RepoPull_Source is a "source" in the sh.tangled.repo.pull schema.
+18
-11
appview/db/profile.go
+18
-11
appview/db/profile.go
···
20
timeline := models.ProfileTimeline{
21
ByMonth: make([]models.ByMonth, TimeframeMonths),
22
}
23
-
currentMonth := time.Now().Month()
24
timeframe := fmt.Sprintf("-%d months", TimeframeMonths)
25
26
pulls, err := GetPullsByOwnerDid(e, forDid, timeframe)
···
30
31
// group pulls by month
32
for _, pull := range pulls {
33
-
pullMonth := pull.Created.Month()
34
35
-
if currentMonth-pullMonth >= TimeframeMonths {
36
// shouldn't happen; but times are weird
37
continue
38
}
39
40
-
idx := currentMonth - pullMonth
41
items := &timeline.ByMonth[idx].PullEvents.Items
42
43
*items = append(*items, &pull)
···
53
}
54
55
for _, issue := range issues {
56
-
issueMonth := issue.Created.Month()
57
58
-
if currentMonth-issueMonth >= TimeframeMonths {
59
// shouldn't happen; but times are weird
60
continue
61
}
62
63
-
idx := currentMonth - issueMonth
64
items := &timeline.ByMonth[idx].IssueEvents.Items
65
66
*items = append(*items, &issue)
···
77
if repo.Source != "" {
78
sourceRepo, err = GetRepoByAtUri(e, repo.Source)
79
if err != nil {
80
-
return nil, err
81
}
82
}
83
84
-
repoMonth := repo.Created.Month()
85
86
-
if currentMonth-repoMonth >= TimeframeMonths {
87
// shouldn't happen; but times are weird
88
continue
89
}
90
91
-
idx := currentMonth - repoMonth
92
93
items := &timeline.ByMonth[idx].RepoEvents
94
*items = append(*items, models.RepoEvent{
···
98
}
99
100
return &timeline, nil
101
}
102
103
func UpsertProfile(tx *sql.Tx, profile *models.Profile) error {
···
20
timeline := models.ProfileTimeline{
21
ByMonth: make([]models.ByMonth, TimeframeMonths),
22
}
23
+
now := time.Now()
24
timeframe := fmt.Sprintf("-%d months", TimeframeMonths)
25
26
pulls, err := GetPullsByOwnerDid(e, forDid, timeframe)
···
30
31
// group pulls by month
32
for _, pull := range pulls {
33
+
monthsAgo := monthsBetween(pull.Created, now)
34
35
+
if monthsAgo >= TimeframeMonths {
36
// shouldn't happen; but times are weird
37
continue
38
}
39
40
+
idx := monthsAgo
41
items := &timeline.ByMonth[idx].PullEvents.Items
42
43
*items = append(*items, &pull)
···
53
}
54
55
for _, issue := range issues {
56
+
monthsAgo := monthsBetween(issue.Created, now)
57
58
+
if monthsAgo >= TimeframeMonths {
59
// shouldn't happen; but times are weird
60
continue
61
}
62
63
+
idx := monthsAgo
64
items := &timeline.ByMonth[idx].IssueEvents.Items
65
66
*items = append(*items, &issue)
···
77
if repo.Source != "" {
78
sourceRepo, err = GetRepoByAtUri(e, repo.Source)
79
if err != nil {
80
+
// the source repo was not found, skip this bit
81
+
log.Println("profile", "err", err)
82
}
83
}
84
85
+
monthsAgo := monthsBetween(repo.Created, now)
86
87
+
if monthsAgo >= TimeframeMonths {
88
// shouldn't happen; but times are weird
89
continue
90
}
91
92
+
idx := monthsAgo
93
94
items := &timeline.ByMonth[idx].RepoEvents
95
*items = append(*items, models.RepoEvent{
···
99
}
100
101
return &timeline, nil
102
+
}
103
+
104
+
func monthsBetween(from, to time.Time) int {
105
+
years := to.Year() - from.Year()
106
+
months := int(to.Month() - from.Month())
107
+
return years*12 + months
108
}
109
110
func UpsertProfile(tx *sql.Tx, profile *models.Profile) error {
+1
-1
appview/db/punchcard.go
+1
-1
appview/db/punchcard.go
+2
-2
appview/issues/opengraph.go
+2
-2
appview/issues/opengraph.go
···
193
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
194
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
195
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
196
-
err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor)
197
if err != nil {
198
-
log.Printf("dolly silhouette not available (this is ok): %v", err)
199
}
200
201
// Draw "opened by @author" and date at the bottom with more spacing
···
193
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
194
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
195
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
196
+
err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor)
197
if err != nil {
198
+
log.Printf("dolly not available (this is ok): %v", err)
199
}
200
201
// Draw "opened by @author" and date at the bottom with more spacing
-5
appview/knots/knots.go
-5
appview/knots/knots.go
···
666
k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
667
return
668
}
669
-
if memberId.Handle.IsInvalidHandle() {
670
-
l.Error("failed to resolve member identity to handle")
671
-
k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
672
-
return
673
-
}
674
675
// remove from enforcer
676
err = k.Enforcer.RemoveKnotMember(domain, memberId.DID.String())
+4
appview/middleware/middleware.go
+4
appview/middleware/middleware.go
···
223
)
224
if err != nil {
225
log.Println("failed to resolve repo", "err", err)
226
mw.pages.ErrorKnot404(w)
227
return
228
}
···
240
f, err := mw.repoResolver.Resolve(r)
241
if err != nil {
242
log.Println("failed to fully resolve repo", err)
243
mw.pages.ErrorKnot404(w)
244
return
245
}
···
288
f, err := mw.repoResolver.Resolve(r)
289
if err != nil {
290
log.Println("failed to fully resolve repo", err)
291
mw.pages.ErrorKnot404(w)
292
return
293
}
···
324
f, err := mw.repoResolver.Resolve(r)
325
if err != nil {
326
log.Println("failed to fully resolve repo", err)
327
mw.pages.ErrorKnot404(w)
328
return
329
}
···
223
)
224
if err != nil {
225
log.Println("failed to resolve repo", "err", err)
226
+
w.WriteHeader(http.StatusNotFound)
227
mw.pages.ErrorKnot404(w)
228
return
229
}
···
241
f, err := mw.repoResolver.Resolve(r)
242
if err != nil {
243
log.Println("failed to fully resolve repo", err)
244
+
w.WriteHeader(http.StatusNotFound)
245
mw.pages.ErrorKnot404(w)
246
return
247
}
···
290
f, err := mw.repoResolver.Resolve(r)
291
if err != nil {
292
log.Println("failed to fully resolve repo", err)
293
+
w.WriteHeader(http.StatusNotFound)
294
mw.pages.ErrorKnot404(w)
295
return
296
}
···
327
f, err := mw.repoResolver.Resolve(r)
328
if err != nil {
329
log.Println("failed to fully resolve repo", err)
330
+
w.WriteHeader(http.StatusNotFound)
331
mw.pages.ErrorKnot404(w)
332
return
333
}
+1
-1
appview/models/pull.go
+1
-1
appview/models/pull.go
···
83
Repo *Repo
84
}
85
86
+
// NOTE: This method does not include patch blob in returned atproto record
87
func (p Pull) AsRecord() tangled.RepoPull {
88
var source *tangled.RepoPull_Source
89
if p.PullSource != nil {
···
114
Repo: p.RepoAt.String(),
115
Branch: p.TargetBranch,
116
},
117
Source: source,
118
}
119
return record
+9
-9
appview/ogcard/card.go
+9
-9
appview/ogcard/card.go
···
334
return nil
335
}
336
337
-
func (c *Card) DrawDollySilhouette(x, y, size int, iconColor color.Color) error {
338
tpl, err := template.New("dolly").
339
-
ParseFS(pages.Files, "templates/fragments/dolly/silhouette.html")
340
if err != nil {
341
-
return fmt.Errorf("failed to read dolly silhouette template: %w", err)
342
}
343
344
var svgData bytes.Buffer
345
-
if err = tpl.ExecuteTemplate(&svgData, "fragments/dolly/silhouette", nil); err != nil {
346
-
return fmt.Errorf("failed to execute dolly silhouette template: %w", err)
347
}
348
349
icon, err := BuildSVGIconFromData(svgData.Bytes(), iconColor)
···
453
454
// Handle SVG separately
455
if contentType == "image/svg+xml" || strings.HasSuffix(url, ".svg") {
456
-
return c.convertSVGToPNG(bodyBytes)
457
}
458
459
// Support content types are in-sync with the allowed custom avatar file types
···
493
}
494
495
// convertSVGToPNG converts SVG data to a PNG image
496
-
func (c *Card) convertSVGToPNG(svgData []byte) (image.Image, bool) {
497
// Parse the SVG
498
icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData))
499
if err != nil {
···
547
draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil)
548
549
// Draw the image with circular clipping
550
-
for cy := 0; cy < size; cy++ {
551
-
for cx := 0; cx < size; cx++ {
552
// Calculate distance from center
553
dx := float64(cx - center)
554
dy := float64(cy - center)
···
334
return nil
335
}
336
337
+
func (c *Card) DrawDolly(x, y, size int, iconColor color.Color) error {
338
tpl, err := template.New("dolly").
339
+
ParseFS(pages.Files, "templates/fragments/dolly/logo.html")
340
if err != nil {
341
+
return fmt.Errorf("failed to read dolly template: %w", err)
342
}
343
344
var svgData bytes.Buffer
345
+
if err = tpl.ExecuteTemplate(&svgData, "fragments/dolly/logo", nil); err != nil {
346
+
return fmt.Errorf("failed to execute dolly template: %w", err)
347
}
348
349
icon, err := BuildSVGIconFromData(svgData.Bytes(), iconColor)
···
453
454
// Handle SVG separately
455
if contentType == "image/svg+xml" || strings.HasSuffix(url, ".svg") {
456
+
return convertSVGToPNG(bodyBytes)
457
}
458
459
// Support content types are in-sync with the allowed custom avatar file types
···
493
}
494
495
// convertSVGToPNG converts SVG data to a PNG image
496
+
func convertSVGToPNG(svgData []byte) (image.Image, bool) {
497
// Parse the SVG
498
icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData))
499
if err != nil {
···
547
draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil)
548
549
// Draw the image with circular clipping
550
+
for cy := range size {
551
+
for cx := range size {
552
// Calculate distance from center
553
dx := float64(cx - center)
554
dy := float64(cy - center)
+12
-1
appview/pages/pages.go
+12
-1
appview/pages/pages.go
···
210
return tpl.ExecuteTemplate(w, "layouts/base", params)
211
}
212
213
+
type DollyParams struct {
214
+
Classes string
215
+
FillColor string
216
+
}
217
+
218
+
func (p *Pages) Dolly(w io.Writer, params DollyParams) error {
219
+
return p.executePlain("fragments/dolly/logo", w, params)
220
+
}
221
+
222
func (p *Pages) Favicon(w io.Writer) error {
223
+
return p.Dolly(w, DollyParams{
224
+
Classes: "text-black dark:text-white",
225
+
})
226
}
227
228
type LoginParams struct {
+9
-29
appview/pages/templates/brand/brand.html
+9
-29
appview/pages/templates/brand/brand.html
···
4
<div class="grid grid-cols-10">
5
<header class="col-span-full md:col-span-10 px-6 py-2 mb-4">
6
<h1 class="text-2xl font-bold dark:text-white mb-1">Brand</h1>
7
-
<p class="text-gray-600 dark:text-gray-400 mb-1">
8
Assets and guidelines for using Tangled's logo and brand elements.
9
</p>
10
</header>
···
14
15
<!-- Introduction Section -->
16
<section>
17
-
<p class="text-gray-600 dark:text-gray-400 mb-2">
18
Tangled's logo and mascot is <strong>Dolly</strong>, the first ever <em>cloned</em> mammal. Please
19
follow the below guidelines when using Dolly and the logotype.
20
</p>
21
-
<p class="text-gray-600 dark:text-gray-400 mb-2">
22
All assets are served as SVGs, and can be downloaded by right-clicking and clicking "Save image as".
23
</p>
24
</section>
···
34
</div>
35
<div class="order-1 lg:order-2">
36
<h2 class="text-xl font-semibold dark:text-white mb-3">Black logotype</h2>
37
-
<p class="text-gray-600 dark:text-gray-400 mb-4">For use on light-colored backgrounds.</p>
38
<p class="text-gray-700 dark:text-gray-300">
39
This is the preferred version of the logotype, featuring dark text and elements, ideal for light
40
backgrounds and designs.
···
53
</div>
54
<div class="order-1 lg:order-2">
55
<h2 class="text-xl font-semibold dark:text-white mb-3">White logotype</h2>
56
-
<p class="text-gray-600 dark:text-gray-400 mb-4">For use on dark-colored backgrounds.</p>
57
<p class="text-gray-700 dark:text-gray-300">
58
This version features white text and elements, ideal for dark backgrounds
59
and inverted designs.
···
81
</div>
82
<div class="order-1 lg:order-2">
83
<h2 class="text-xl font-semibold dark:text-white mb-3">Mark only</h2>
84
-
<p class="text-gray-600 dark:text-gray-400 mb-4">
85
When a smaller 1:1 logo or icon is needed, Dolly's face may be used on its own.
86
</p>
87
<p class="text-gray-700 dark:text-gray-300 mb-4">
···
123
</div>
124
<div class="order-1 lg:order-2">
125
<h2 class="text-xl font-semibold dark:text-white mb-3">Colored backgrounds</h2>
126
-
<p class="text-gray-600 dark:text-gray-400 mb-4">
127
White logo mark on colored backgrounds.
128
</p>
129
<p class="text-gray-700 dark:text-gray-300 mb-4">
···
165
</div>
166
<div class="order-1 lg:order-2">
167
<h2 class="text-xl font-semibold dark:text-white mb-3">Lighter backgrounds</h2>
168
-
<p class="text-gray-600 dark:text-gray-400 mb-4">
169
Dark logo mark on lighter, pastel backgrounds.
170
</p>
171
<p class="text-gray-700 dark:text-gray-300 mb-4">
···
186
</div>
187
<div class="order-1 lg:order-2">
188
<h2 class="text-xl font-semibold dark:text-white mb-3">Recoloring</h2>
189
-
<p class="text-gray-600 dark:text-gray-400 mb-4">
190
Custom coloring of the logotype is permitted.
191
</p>
192
<p class="text-gray-700 dark:text-gray-300 mb-4">
···
194
</p>
195
<p class="text-gray-700 dark:text-gray-300 text-sm">
196
<strong>Example:</strong> Gray/sand colored logotype on a light yellow/tan background.
197
-
</p>
198
-
</div>
199
-
</section>
200
-
201
-
<!-- Silhouette Section -->
202
-
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
203
-
<div class="order-2 lg:order-1">
204
-
<div class="border border-gray-200 dark:border-gray-700 p-8 sm:p-16 bg-gray-50 dark:bg-gray-100 rounded">
205
-
<img src="https://assets.tangled.network/tangled_dolly_silhouette.svg"
206
-
alt="Dolly silhouette"
207
-
class="w-full max-w-32 mx-auto" />
208
-
</div>
209
-
</div>
210
-
<div class="order-1 lg:order-2">
211
-
<h2 class="text-xl font-semibold dark:text-white mb-3">Dolly silhouette</h2>
212
-
<p class="text-gray-600 dark:text-gray-400 mb-4">A minimalist version of Dolly.</p>
213
-
<p class="text-gray-700 dark:text-gray-300">
214
-
The silhouette can be used where a subtle brand presence is needed,
215
-
or as a background element. Works on any background color with proper contrast.
216
-
For example, we use this as the site's favicon.
217
</p>
218
</div>
219
</section>
···
4
<div class="grid grid-cols-10">
5
<header class="col-span-full md:col-span-10 px-6 py-2 mb-4">
6
<h1 class="text-2xl font-bold dark:text-white mb-1">Brand</h1>
7
+
<p class="text-gray-500 dark:text-gray-300 mb-1">
8
Assets and guidelines for using Tangled's logo and brand elements.
9
</p>
10
</header>
···
14
15
<!-- Introduction Section -->
16
<section>
17
+
<p class="text-gray-500 dark:text-gray-300 mb-2">
18
Tangled's logo and mascot is <strong>Dolly</strong>, the first ever <em>cloned</em> mammal. Please
19
follow the below guidelines when using Dolly and the logotype.
20
</p>
21
+
<p class="text-gray-500 dark:text-gray-300 mb-2">
22
All assets are served as SVGs, and can be downloaded by right-clicking and clicking "Save image as".
23
</p>
24
</section>
···
34
</div>
35
<div class="order-1 lg:order-2">
36
<h2 class="text-xl font-semibold dark:text-white mb-3">Black logotype</h2>
37
+
<p class="text-gray-500 dark:text-gray-300 mb-4">For use on light-colored backgrounds.</p>
38
<p class="text-gray-700 dark:text-gray-300">
39
This is the preferred version of the logotype, featuring dark text and elements, ideal for light
40
backgrounds and designs.
···
53
</div>
54
<div class="order-1 lg:order-2">
55
<h2 class="text-xl font-semibold dark:text-white mb-3">White logotype</h2>
56
+
<p class="text-gray-500 dark:text-gray-300 mb-4">For use on dark-colored backgrounds.</p>
57
<p class="text-gray-700 dark:text-gray-300">
58
This version features white text and elements, ideal for dark backgrounds
59
and inverted designs.
···
81
</div>
82
<div class="order-1 lg:order-2">
83
<h2 class="text-xl font-semibold dark:text-white mb-3">Mark only</h2>
84
+
<p class="text-gray-500 dark:text-gray-300 mb-4">
85
When a smaller 1:1 logo or icon is needed, Dolly's face may be used on its own.
86
</p>
87
<p class="text-gray-700 dark:text-gray-300 mb-4">
···
123
</div>
124
<div class="order-1 lg:order-2">
125
<h2 class="text-xl font-semibold dark:text-white mb-3">Colored backgrounds</h2>
126
+
<p class="text-gray-500 dark:text-gray-300 mb-4">
127
White logo mark on colored backgrounds.
128
</p>
129
<p class="text-gray-700 dark:text-gray-300 mb-4">
···
165
</div>
166
<div class="order-1 lg:order-2">
167
<h2 class="text-xl font-semibold dark:text-white mb-3">Lighter backgrounds</h2>
168
+
<p class="text-gray-500 dark:text-gray-300 mb-4">
169
Dark logo mark on lighter, pastel backgrounds.
170
</p>
171
<p class="text-gray-700 dark:text-gray-300 mb-4">
···
186
</div>
187
<div class="order-1 lg:order-2">
188
<h2 class="text-xl font-semibold dark:text-white mb-3">Recoloring</h2>
189
+
<p class="text-gray-500 dark:text-gray-300 mb-4">
190
Custom coloring of the logotype is permitted.
191
</p>
192
<p class="text-gray-700 dark:text-gray-300 mb-4">
···
194
</p>
195
<p class="text-gray-700 dark:text-gray-300 text-sm">
196
<strong>Example:</strong> Gray/sand colored logotype on a light yellow/tan background.
197
</p>
198
</div>
199
</section>
+14
-2
appview/pages/templates/fragments/dolly/logo.html
+14
-2
appview/pages/templates/fragments/dolly/logo.html
···
2
<svg
3
version="1.1"
4
id="svg1"
5
-
class="{{ . }}"
6
width="25"
7
height="25"
8
viewBox="0 0 25 25"
···
17
xmlns:svg="http://www.w3.org/2000/svg"
18
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
19
xmlns:cc="http://creativecommons.org/ns#">
20
<sodipodi:namedview
21
id="namedview1"
22
pagecolor="#ffffff"
···
51
id="g1"
52
transform="translate(-0.42924038,-0.87777209)">
53
<path
54
-
fill="currentColor"
55
style="stroke-width:0.111183;"
56
d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z m 0.686342,-3.497495 c -0.643126,-0.394168 -0.33365,-1.249599 -0.359402,-1.870938 0.064,-0.749774 0.115321,-1.538054 0.452402,-2.221125 0.356724,-0.487008 1.226721,-0.299139 1.265134,0.325689 -0.02558,0.628509 -0.314101,1.25416 -0.279646,1.9057 -0.07482,0.544043 0.05418,1.155133 -0.186476,1.652391 -0.197455,0.275121 -0.599638,0.355105 -0.892012,0.208283 z m -2.808766,-0.358124 c -0.605767,-0.328664 -0.4133176,-1.155655 -0.5083256,-1.73063 0.078762,-0.66567 0.013203,-1.510085 0.5705316,-1.976886 0.545037,-0.380109 1.286917,0.270803 1.029164,0.868384 -0.274913,0.755214 -0.09475,1.580345 -0.08893,2.34609 -0.104009,0.451702 -0.587146,0.691508 -1.002445,0.493042 z"
57
id="path4"
···
2
<svg
3
version="1.1"
4
id="svg1"
5
+
class="{{ .Classes }}"
6
width="25"
7
height="25"
8
viewBox="0 0 25 25"
···
17
xmlns:svg="http://www.w3.org/2000/svg"
18
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
19
xmlns:cc="http://creativecommons.org/ns#">
20
+
<style>
21
+
.dolly {
22
+
color: #000000;
23
+
}
24
+
25
+
@media (prefers-color-scheme: dark) {
26
+
.dolly {
27
+
color: #ffffff;
28
+
}
29
+
}
30
+
</style>
31
<sodipodi:namedview
32
id="namedview1"
33
pagecolor="#ffffff"
···
62
id="g1"
63
transform="translate(-0.42924038,-0.87777209)">
64
<path
65
+
class="dolly"
66
+
fill="{{ or .FillColor "currentColor" }}"
67
style="stroke-width:0.111183;"
68
d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z m 0.686342,-3.497495 c -0.643126,-0.394168 -0.33365,-1.249599 -0.359402,-1.870938 0.064,-0.749774 0.115321,-1.538054 0.452402,-2.221125 0.356724,-0.487008 1.226721,-0.299139 1.265134,0.325689 -0.02558,0.628509 -0.314101,1.25416 -0.279646,1.9057 -0.07482,0.544043 0.05418,1.155133 -0.186476,1.652391 -0.197455,0.275121 -0.599638,0.355105 -0.892012,0.208283 z m -2.808766,-0.358124 c -0.605767,-0.328664 -0.4133176,-1.155655 -0.5083256,-1.73063 0.078762,-0.66567 0.013203,-1.510085 0.5705316,-1.976886 0.545037,-0.380109 1.286917,0.270803 1.029164,0.868384 -0.274913,0.755214 -0.09475,1.580345 -0.08893,2.34609 -0.104009,0.451702 -0.587146,0.691508 -1.002445,0.493042 z"
69
id="path4"
-95
appview/pages/templates/fragments/dolly/silhouette.html
-95
appview/pages/templates/fragments/dolly/silhouette.html
···
1
-
{{ define "fragments/dolly/silhouette" }}
2
-
<svg
3
-
version="1.1"
4
-
id="svg1"
5
-
width="25"
6
-
height="25"
7
-
viewBox="0 0 25 25"
8
-
sodipodi:docname="tangled_dolly_face_only_black_on_trans.svg"
9
-
inkscape:export-filename="tangled_dolly_silhouette_black_on_trans.svg"
10
-
inkscape:export-xdpi="96"
11
-
inkscape:export-ydpi="96"
12
-
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
13
-
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
14
-
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
15
-
xmlns="http://www.w3.org/2000/svg"
16
-
xmlns:svg="http://www.w3.org/2000/svg"
17
-
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
18
-
xmlns:cc="http://creativecommons.org/ns#">
19
-
<style>
20
-
.dolly {
21
-
color: #000000;
22
-
}
23
-
24
-
@media (prefers-color-scheme: dark) {
25
-
.dolly {
26
-
color: #ffffff;
27
-
}
28
-
}
29
-
</style>
30
-
<sodipodi:namedview
31
-
id="namedview1"
32
-
pagecolor="#ffffff"
33
-
bordercolor="#000000"
34
-
borderopacity="0.25"
35
-
inkscape:showpageshadow="2"
36
-
inkscape:pageopacity="0.0"
37
-
inkscape:pagecheckerboard="true"
38
-
inkscape:deskcolor="#d5d5d5"
39
-
inkscape:zoom="64"
40
-
inkscape:cx="4.96875"
41
-
inkscape:cy="13.429688"
42
-
inkscape:window-width="3840"
43
-
inkscape:window-height="2160"
44
-
inkscape:window-x="0"
45
-
inkscape:window-y="0"
46
-
inkscape:window-maximized="0"
47
-
inkscape:current-layer="g1"
48
-
borderlayer="true">
49
-
<inkscape:page
50
-
x="0"
51
-
y="0"
52
-
width="25"
53
-
height="25"
54
-
id="page2"
55
-
margin="0"
56
-
bleed="0" />
57
-
</sodipodi:namedview>
58
-
<g
59
-
inkscape:groupmode="layer"
60
-
inkscape:label="Image"
61
-
id="g1"
62
-
transform="translate(-0.42924038,-0.87777209)">
63
-
<path
64
-
class="dolly"
65
-
fill="currentColor"
66
-
style="stroke-width:0.111183"
67
-
d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z"
68
-
id="path7"
69
-
sodipodi:nodetypes="sccccccccccccccccccsscccccccccscccccccsc" />
70
-
</g>
71
-
<metadata
72
-
id="metadata1">
73
-
<rdf:RDF>
74
-
<cc:Work
75
-
rdf:about="">
76
-
<cc:license
77
-
rdf:resource="http://creativecommons.org/licenses/by/4.0/" />
78
-
</cc:Work>
79
-
<cc:License
80
-
rdf:about="http://creativecommons.org/licenses/by/4.0/">
81
-
<cc:permits
82
-
rdf:resource="http://creativecommons.org/ns#Reproduction" />
83
-
<cc:permits
84
-
rdf:resource="http://creativecommons.org/ns#Distribution" />
85
-
<cc:requires
86
-
rdf:resource="http://creativecommons.org/ns#Notice" />
87
-
<cc:requires
88
-
rdf:resource="http://creativecommons.org/ns#Attribution" />
89
-
<cc:permits
90
-
rdf:resource="http://creativecommons.org/ns#DerivativeWorks" />
91
-
</cc:License>
92
-
</rdf:RDF>
93
-
</metadata>
94
-
</svg>
95
-
{{ end }}
···
+1
-1
appview/pages/templates/fragments/logotype.html
+1
-1
appview/pages/templates/fragments/logotype.html
···
1
{{ define "fragments/logotype" }}
2
<span class="flex items-center gap-2">
3
-
{{ template "fragments/dolly/logo" "size-16 text-black dark:text-white" }}
4
<span class="font-bold text-4xl not-italic">tangled</span>
5
<span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1">
6
alpha
···
1
{{ define "fragments/logotype" }}
2
<span class="flex items-center gap-2">
3
+
{{ template "fragments/dolly/logo" (dict "Classes" "size-16 text-black dark:text-white") }}
4
<span class="font-bold text-4xl not-italic">tangled</span>
5
<span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1">
6
alpha
+1
-1
appview/pages/templates/fragments/logotypeSmall.html
+1
-1
appview/pages/templates/fragments/logotypeSmall.html
···
1
{{ define "fragments/logotypeSmall" }}
2
<span class="flex items-center gap-2">
3
-
{{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }}
4
<span class="font-bold text-xl not-italic">tangled</span>
5
<span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1">
6
alpha
···
1
{{ define "fragments/logotypeSmall" }}
2
<span class="flex items-center gap-2">
3
+
{{ template "fragments/dolly/logo" (dict "Classes" "size-8 text-black dark:text-white")}}
4
<span class="font-bold text-xl not-italic">tangled</span>
5
<span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1">
6
alpha
+1
-1
appview/pages/templates/knots/index.html
+1
-1
appview/pages/templates/knots/index.html
+4
appview/pages/templates/layouts/base.html
+4
appview/pages/templates/layouts/base.html
···
11
<script defer src="/static/htmx-ext-ws.min.js"></script>
12
<script defer src="/static/actor-typeahead.js" type="module"></script>
13
14
+
<link rel="icon" href="/static/logos/dolly.ico" sizes="48x48"/>
15
+
<link rel="icon" href="/static/logos/dolly.svg" sizes="any" type="image/svg+xml"/>
16
+
<link rel="apple-touch-icon" href="/static/logos/dolly.png"/>
17
+
18
<!-- preconnect to image cdn -->
19
<link rel="preconnect" href="https://avatar.tangled.sh" />
20
<link rel="preconnect" href="https://camo.tangled.sh" />
+1
-5
appview/pages/templates/layouts/fragments/topbar.html
+1
-5
appview/pages/templates/layouts/fragments/topbar.html
···
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">
6
-
{{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }}
7
-
<span class="font-bold text-xl not-italic hidden md:inline">tangled</span>
8
-
<span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1 hidden md:inline">
9
-
alpha
10
-
</span>
11
</a>
12
</div>
13
+1
-1
appview/pages/templates/repo/fragments/diff.html
+1
-1
appview/pages/templates/repo/fragments/diff.html
···
17
{{ else }}
18
{{ range $idx, $hunk := $diff }}
19
{{ with $hunk }}
20
-
<details open id="file-{{ .Name.New }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}">
21
<summary class="list-none cursor-pointer sticky top-0">
22
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
23
<div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
···
17
{{ else }}
18
{{ range $idx, $hunk := $diff }}
19
{{ with $hunk }}
20
+
<details open id="file-{{ .Id }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}">
21
<summary class="list-none cursor-pointer sticky top-0">
22
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
23
<div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
+35
-35
appview/pages/templates/repo/fragments/splitDiff.html
+35
-35
appview/pages/templates/repo/fragments/splitDiff.html
···
3
{{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800" -}}
4
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
5
{{- $lineNrSepStyle := "pr-2 border-r border-gray-200 dark:border-gray-700" -}}
6
-
{{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}}
7
{{- $emptyStyle := "bg-gray-200/30 dark:bg-gray-700/30" -}}
8
{{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400" -}}
9
{{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}}
10
{{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}}
11
{{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}}
12
<div class="grid grid-cols-2 divide-x divide-gray-200 dark:divide-gray-700">
13
-
<pre class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</div>
14
{{- range .LeftLines -}}
15
{{- if .IsEmpty -}}
16
-
<div class="{{ $emptyStyle }} {{ $containerStyle }}">
17
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></div>
18
-
<div class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></div>
19
-
<div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div>
20
-
</div>
21
{{- else if eq .Op.String "-" -}}
22
-
<div class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}">
23
-
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></div>
24
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
25
-
<div class="px-2">{{ .Content }}</div>
26
-
</div>
27
{{- else if eq .Op.String " " -}}
28
-
<div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}">
29
-
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></div>
30
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
31
-
<div class="px-2">{{ .Content }}</div>
32
-
</div>
33
{{- end -}}
34
{{- end -}}
35
-
{{- end -}}</div></div></pre>
36
37
-
<pre class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</div>
38
{{- range .RightLines -}}
39
{{- if .IsEmpty -}}
40
-
<div class="{{ $emptyStyle }} {{ $containerStyle }}">
41
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></div>
42
-
<div class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></div>
43
-
<div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div>
44
-
</div>
45
{{- else if eq .Op.String "+" -}}
46
-
<div class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}">
47
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></div>
48
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
49
-
<div class="px-2" >{{ .Content }}</div>
50
-
</div>
51
{{- else if eq .Op.String " " -}}
52
-
<div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}">
53
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></div>
54
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
55
-
<div class="px-2">{{ .Content }}</div>
56
-
</div>
57
{{- end -}}
58
{{- end -}}
59
-
{{- end -}}</div></div></pre>
60
</div>
61
{{ end }}
···
3
{{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800" -}}
4
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
5
{{- $lineNrSepStyle := "pr-2 border-r border-gray-200 dark:border-gray-700" -}}
6
+
{{- $containerStyle := "inline-flex w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}}
7
{{- $emptyStyle := "bg-gray-200/30 dark:bg-gray-700/30" -}}
8
{{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400" -}}
9
{{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}}
10
{{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}}
11
{{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}}
12
<div class="grid grid-cols-2 divide-x divide-gray-200 dark:divide-gray-700">
13
+
<div class="overflow-x-auto col-span-1 font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</span>
14
{{- range .LeftLines -}}
15
{{- if .IsEmpty -}}
16
+
<span class="{{ $emptyStyle }} {{ $containerStyle }}">
17
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></span>
18
+
<span class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></span>
19
+
<span class="px-2 invisible" aria-hidden="true">{{ .Content }}</span>
20
+
</span>
21
{{- else if eq .Op.String "-" -}}
22
+
<span class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}">
23
+
<span class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></span>
24
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
25
+
<span class="px-2 whitespace-pre">{{ .Content }}</span>
26
+
</span>
27
{{- else if eq .Op.String " " -}}
28
+
<span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}">
29
+
<span class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></span>
30
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
31
+
<span class="px-2 whitespace-pre">{{ .Content }}</span>
32
+
</span>
33
{{- end -}}
34
{{- end -}}
35
+
{{- end -}}</div></div></div>
36
37
+
<div class="overflow-x-auto col-span-1 font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</span>
38
{{- range .RightLines -}}
39
{{- if .IsEmpty -}}
40
+
<span class="{{ $emptyStyle }} {{ $containerStyle }}">
41
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></span>
42
+
<span class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></span>
43
+
<span class="px-2 invisible" aria-hidden="true">{{ .Content }}</span>
44
+
</span>
45
{{- else if eq .Op.String "+" -}}
46
+
<span class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}">
47
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></span>
48
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
49
+
<span class="px-2 whitespace-pre">{{ .Content }}</span>
50
+
</span>
51
{{- else if eq .Op.String " " -}}
52
+
<span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}">
53
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a> </span>
54
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
55
+
<span class="px-2 whitespace-pre">{{ .Content }}</span>
56
+
</span>
57
{{- end -}}
58
{{- end -}}
59
+
{{- end -}}</div></div></div>
60
</div>
61
{{ end }}
+21
-22
appview/pages/templates/repo/fragments/unifiedDiff.html
+21
-22
appview/pages/templates/repo/fragments/unifiedDiff.html
···
1
{{ define "repo/fragments/unifiedDiff" }}
2
{{ $name := .Id }}
3
-
<pre class="overflow-x-auto"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</div>
4
{{- $oldStart := .OldPosition -}}
5
{{- $newStart := .NewPosition -}}
6
{{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 target:bg-yellow-200 target:dark:bg-yellow-600" -}}
7
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
8
{{- $lineNrSepStyle1 := "" -}}
9
{{- $lineNrSepStyle2 := "pr-2 border-r border-gray-200 dark:border-gray-700" -}}
10
-
{{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}}
11
{{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 " -}}
12
{{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}}
13
{{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}}
14
{{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}}
15
{{- range .Lines -}}
16
{{- if eq .Op.String "+" -}}
17
-
<div class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{$newStart}}">
18
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div>
19
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div>
20
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
21
-
<div class="px-2">{{ .Line }}</div>
22
-
</div>
23
{{- $newStart = add64 $newStart 1 -}}
24
{{- end -}}
25
{{- if eq .Op.String "-" -}}
26
-
<div class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}">
27
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div>
28
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div>
29
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
30
-
<div class="px-2">{{ .Line }}</div>
31
-
</div>
32
{{- $oldStart = add64 $oldStart 1 -}}
33
{{- end -}}
34
{{- if eq .Op.String " " -}}
35
-
<div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}-N{{$newStart}}">
36
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $oldStart }}</a></div>
37
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $newStart }}</a></div>
38
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
39
-
<div class="px-2">{{ .Line }}</div>
40
-
</div>
41
{{- $newStart = add64 $newStart 1 -}}
42
{{- $oldStart = add64 $oldStart 1 -}}
43
{{- end -}}
44
{{- end -}}
45
-
{{- end -}}</div></div></pre>
46
{{ end }}
47
-
···
1
{{ define "repo/fragments/unifiedDiff" }}
2
{{ $name := .Id }}
3
+
<div class="overflow-x-auto font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</span>
4
{{- $oldStart := .OldPosition -}}
5
{{- $newStart := .NewPosition -}}
6
{{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 target:bg-yellow-200 target:dark:bg-yellow-600" -}}
7
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
8
{{- $lineNrSepStyle1 := "" -}}
9
{{- $lineNrSepStyle2 := "pr-2 border-r border-gray-200 dark:border-gray-700" -}}
10
+
{{- $containerStyle := "inline-flex w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}}
11
{{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 " -}}
12
{{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}}
13
{{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}}
14
{{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}}
15
{{- range .Lines -}}
16
{{- if eq .Op.String "+" -}}
17
+
<span class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{$newStart}}">
18
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></span>
19
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></span>
20
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
21
+
<span class="px-2 whitespace-pre">{{ .Line }}</span>
22
+
</span>
23
{{- $newStart = add64 $newStart 1 -}}
24
{{- end -}}
25
{{- if eq .Op.String "-" -}}
26
+
<span class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}">
27
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></span>
28
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></span>
29
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
30
+
<span class="px-2 whitespace-pre">{{ .Line }}</span>
31
+
</span>
32
{{- $oldStart = add64 $oldStart 1 -}}
33
{{- end -}}
34
{{- if eq .Op.String " " -}}
35
+
<span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}-N{{$newStart}}">
36
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $oldStart }}</a></span>
37
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $newStart }}</a></span>
38
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
39
+
<span class="px-2 whitespace-pre">{{ .Line }}</span>
40
+
</span>
41
{{- $newStart = add64 $newStart 1 -}}
42
{{- $oldStart = add64 $oldStart 1 -}}
43
{{- end -}}
44
{{- end -}}
45
+
{{- end -}}</div></div></div>
46
{{ end }}
+1
-1
appview/pages/templates/repo/pipelines/pipelines.html
+1
-1
appview/pages/templates/repo/pipelines/pipelines.html
···
23
</p>
24
<p>
25
<span class="{{ $bullet }}">2</span>Configure your CI/CD
26
-
<a href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/pipeline.md" class="underline">pipeline</a>.
27
</p>
28
<p><span class="{{ $bullet }}">3</span>Trigger a workflow with a push or a pull-request!</p>
29
</div>
+1
-1
appview/pages/templates/repo/settings/pipelines.html
+1
-1
appview/pages/templates/repo/settings/pipelines.html
···
22
<p class="text-gray-500 dark:text-gray-400">
23
Choose a spindle to execute your workflows on. Only repository owners
24
can configure spindles. Spindles can be selfhosted,
25
-
<a class="text-gray-500 dark:text-gray-400 underline" href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md">
26
click to learn more.
27
</a>
28
</p>
···
22
<p class="text-gray-500 dark:text-gray-400">
23
Choose a spindle to execute your workflows on. Only repository owners
24
can configure spindles. Spindles can be selfhosted,
25
+
<a class="text-gray-500 dark:text-gray-400 underline" href="https://docs.tangled.org/spindles.html#self-hosting-guide">
26
click to learn more.
27
</a>
28
</p>
+1
-1
appview/pages/templates/spindles/index.html
+1
-1
appview/pages/templates/spindles/index.html
+1
-1
appview/pulls/opengraph.go
+1
-1
appview/pulls/opengraph.go
···
242
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
243
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
244
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
245
-
err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor)
246
if err != nil {
247
log.Printf("dolly silhouette not available (this is ok): %v", err)
248
}
···
242
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
243
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
244
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
245
+
err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor)
246
if err != nil {
247
log.Printf("dolly silhouette not available (this is ok): %v", err)
248
}
+48
-36
appview/pulls/pulls.go
+48
-36
appview/pulls/pulls.go
···
1241
return
1242
}
1243
1244
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1245
Collection: tangled.RepoPullNSID,
1246
Repo: user.Did,
···
1252
Repo: string(repo.RepoAt()),
1253
Branch: targetBranch,
1254
},
1255
-
Patch: patch,
1256
Source: recordPullSource,
1257
CreatedAt: time.Now().Format(time.RFC3339),
1258
},
···
1328
// apply all record creations at once
1329
var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
1330
for _, p := range stack {
1331
record := p.AsRecord()
1332
-
write := comatproto.RepoApplyWrites_Input_Writes_Elem{
1333
RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
1334
Collection: tangled.RepoPullNSID,
1335
Rkey: &p.Rkey,
···
1337
Val: &record,
1338
},
1339
},
1340
-
}
1341
-
writes = append(writes, &write)
1342
}
1343
_, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{
1344
Repo: user.Did,
···
1871
return
1872
}
1873
1874
-
var recordPullSource *tangled.RepoPull_Source
1875
-
if pull.IsBranchBased() {
1876
-
recordPullSource = &tangled.RepoPull_Source{
1877
-
Branch: pull.PullSource.Branch,
1878
-
Sha: sourceRev,
1879
-
}
1880
}
1881
-
if pull.IsForkBased() {
1882
-
repoAt := pull.PullSource.RepoAt.String()
1883
-
recordPullSource = &tangled.RepoPull_Source{
1884
-
Branch: pull.PullSource.Branch,
1885
-
Repo: &repoAt,
1886
-
Sha: sourceRev,
1887
-
}
1888
-
}
1889
1890
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1891
Collection: tangled.RepoPullNSID,
···
1893
Rkey: pull.Rkey,
1894
SwapRecord: ex.Cid,
1895
Record: &lexutil.LexiconTypeDecoder{
1896
-
Val: &tangled.RepoPull{
1897
-
Title: pull.Title,
1898
-
Target: &tangled.RepoPull_Target{
1899
-
Repo: string(repo.RepoAt()),
1900
-
Branch: pull.TargetBranch,
1901
-
},
1902
-
Patch: patch, // new patch
1903
-
Source: recordPullSource,
1904
-
CreatedAt: time.Now().Format(time.RFC3339),
1905
-
},
1906
},
1907
})
1908
if err != nil {
···
1988
}
1989
defer tx.Rollback()
1990
1991
// pds updates to make
1992
var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
1993
···
2021
return
2022
}
2023
2024
record := p.AsRecord()
2025
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
2026
RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
2027
Collection: tangled.RepoPullNSID,
···
2056
return
2057
}
2058
2059
record := np.AsRecord()
2060
-
2061
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
2062
RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
2063
Collection: tangled.RepoPullNSID,
···
2091
if err != nil {
2092
log.Println("failed to resubmit pull", err)
2093
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
2094
-
return
2095
-
}
2096
-
2097
-
client, err := s.oauth.AuthorizedClient(r)
2098
-
if err != nil {
2099
-
log.Println("failed to authorize client")
2100
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
2101
return
2102
}
2103
···
1241
return
1242
}
1243
1244
+
blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch))
1245
+
if err != nil {
1246
+
log.Println("failed to upload patch", err)
1247
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1248
+
return
1249
+
}
1250
+
1251
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1252
Collection: tangled.RepoPullNSID,
1253
Repo: user.Did,
···
1259
Repo: string(repo.RepoAt()),
1260
Branch: targetBranch,
1261
},
1262
+
PatchBlob: blob.Blob,
1263
Source: recordPullSource,
1264
CreatedAt: time.Now().Format(time.RFC3339),
1265
},
···
1335
// apply all record creations at once
1336
var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
1337
for _, p := range stack {
1338
+
blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(p.LatestPatch()))
1339
+
if err != nil {
1340
+
log.Println("failed to upload patch blob", err)
1341
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1342
+
return
1343
+
}
1344
+
1345
record := p.AsRecord()
1346
+
record.PatchBlob = blob.Blob
1347
+
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
1348
RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
1349
Collection: tangled.RepoPullNSID,
1350
Rkey: &p.Rkey,
···
1352
Val: &record,
1353
},
1354
},
1355
+
})
1356
}
1357
_, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{
1358
Repo: user.Did,
···
1885
return
1886
}
1887
1888
+
blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch))
1889
+
if err != nil {
1890
+
log.Println("failed to upload patch blob", err)
1891
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1892
+
return
1893
}
1894
+
record := pull.AsRecord()
1895
+
record.PatchBlob = blob.Blob
1896
+
record.CreatedAt = time.Now().Format(time.RFC3339)
1897
1898
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1899
Collection: tangled.RepoPullNSID,
···
1901
Rkey: pull.Rkey,
1902
SwapRecord: ex.Cid,
1903
Record: &lexutil.LexiconTypeDecoder{
1904
+
Val: &record,
1905
},
1906
})
1907
if err != nil {
···
1987
}
1988
defer tx.Rollback()
1989
1990
+
client, err := s.oauth.AuthorizedClient(r)
1991
+
if err != nil {
1992
+
log.Println("failed to authorize client")
1993
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1994
+
return
1995
+
}
1996
+
1997
// pds updates to make
1998
var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
1999
···
2027
return
2028
}
2029
2030
+
blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch))
2031
+
if err != nil {
2032
+
log.Println("failed to upload patch blob", err)
2033
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
2034
+
return
2035
+
}
2036
record := p.AsRecord()
2037
+
record.PatchBlob = blob.Blob
2038
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
2039
RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
2040
Collection: tangled.RepoPullNSID,
···
2069
return
2070
}
2071
2072
+
blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch))
2073
+
if err != nil {
2074
+
log.Println("failed to upload patch blob", err)
2075
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
2076
+
return
2077
+
}
2078
record := np.AsRecord()
2079
+
record.PatchBlob = blob.Blob
2080
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
2081
RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
2082
Collection: tangled.RepoPullNSID,
···
2110
if err != nil {
2111
log.Println("failed to resubmit pull", err)
2112
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
2113
return
2114
}
2115
+1
appview/repo/archive.go
+1
appview/repo/archive.go
+1
-1
appview/repo/opengraph.go
+1
-1
appview/repo/opengraph.go
···
237
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
238
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
239
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
240
-
err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor)
241
if err != nil {
242
log.Printf("dolly silhouette not available (this is ok): %v", err)
243
}
···
237
dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
238
dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
239
dollyColor := color.RGBA{180, 180, 180, 255} // light gray
240
+
err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor)
241
if err != nil {
242
log.Printf("dolly silhouette not available (this is ok): %v", err)
243
}
+26
-1
appview/reporesolver/resolver.go
+26
-1
appview/reporesolver/resolver.go
···
63
}
64
65
// get dir/ref
66
+
currentDir := extractCurrentDir(r.URL.EscapedPath())
67
ref := chi.URLParam(r, "ref")
68
69
repoAt := repo.RepoAt()
···
130
}
131
132
return repoInfo
133
+
}
134
+
135
+
// extractCurrentDir gets the current directory for markdown link resolution.
136
+
// for blob paths, returns the parent dir. for tree paths, returns the path itself.
137
+
//
138
+
// /@user/repo/blob/main/docs/README.md => docs
139
+
// /@user/repo/tree/main/docs => docs
140
+
func extractCurrentDir(fullPath string) string {
141
+
fullPath = strings.TrimPrefix(fullPath, "/")
142
+
143
+
blobPattern := regexp.MustCompile(`blob/[^/]+/(.*)$`)
144
+
if matches := blobPattern.FindStringSubmatch(fullPath); len(matches) > 1 {
145
+
return path.Dir(matches[1])
146
+
}
147
+
148
+
treePattern := regexp.MustCompile(`tree/[^/]+/(.*)$`)
149
+
if matches := treePattern.FindStringSubmatch(fullPath); len(matches) > 1 {
150
+
dir := strings.TrimSuffix(matches[1], "/")
151
+
if dir == "" {
152
+
return "."
153
+
}
154
+
return dir
155
+
}
156
+
157
+
return "."
158
}
159
160
// extractPathAfterRef gets the actual repository path
+22
appview/reporesolver/resolver_test.go
+22
appview/reporesolver/resolver_test.go
···
···
1
+
package reporesolver
2
+
3
+
import "testing"
4
+
5
+
func TestExtractCurrentDir(t *testing.T) {
6
+
tests := []struct {
7
+
path string
8
+
want string
9
+
}{
10
+
{"/@user/repo/blob/main/docs/README.md", "docs"},
11
+
{"/@user/repo/blob/main/README.md", "."},
12
+
{"/@user/repo/tree/main/docs", "docs"},
13
+
{"/@user/repo/tree/main/docs/", "docs"},
14
+
{"/@user/repo/tree/main", "."},
15
+
}
16
+
17
+
for _, tt := range tests {
18
+
if got := extractCurrentDir(tt.path); got != tt.want {
19
+
t.Errorf("extractCurrentDir(%q) = %q, want %q", tt.path, got, tt.want)
20
+
}
21
+
}
22
+
}
-5
appview/spindles/spindles.go
-5
appview/spindles/spindles.go
···
653
s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
654
return
655
}
656
-
if memberId.Handle.IsInvalidHandle() {
657
-
l.Error("failed to resolve member identity to handle")
658
-
s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
659
-
return
660
-
}
661
662
tx, err := s.Db.Begin()
663
if err != nil {
+29
appview/state/manifest.go
+29
appview/state/manifest.go
···
···
1
+
package state
2
+
3
+
import (
4
+
"encoding/json"
5
+
"net/http"
6
+
)
7
+
8
+
// https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest
9
+
// https://www.w3.org/TR/appmanifest/
10
+
var manifestData = map[string]any{
11
+
"name": "tangled",
12
+
"description": "tightly-knit social coding.",
13
+
"icons": []map[string]string{
14
+
{
15
+
"src": "/static/logos/dolly.svg",
16
+
"sizes": "144x144",
17
+
},
18
+
},
19
+
"start_url": "/",
20
+
"id": "https://tangled.org",
21
+
"display": "standalone",
22
+
"background_color": "#111827",
23
+
"theme_color": "#111827",
24
+
}
25
+
26
+
func (p *State) WebAppManifest(w http.ResponseWriter, r *http.Request) {
27
+
w.Header().Set("Content-Type", "application/manifest+json")
28
+
json.NewEncoder(w).Encode(manifestData)
29
+
}
+6
-4
appview/state/profile.go
+6
-4
appview/state/profile.go
···
163
}
164
165
// populate commit counts in the timeline, using the punchcard
166
-
currentMonth := time.Now().Month()
167
for _, p := range profile.Punchcard.Punches {
168
-
idx := currentMonth - p.Date.Month()
169
-
if int(idx) < len(timeline.ByMonth) {
170
-
timeline.ByMonth[idx].Commits += p.Count
171
}
172
}
173
···
163
}
164
165
// populate commit counts in the timeline, using the punchcard
166
+
now := time.Now()
167
for _, p := range profile.Punchcard.Punches {
168
+
years := now.Year() - p.Date.Year()
169
+
months := int(now.Month() - p.Date.Month())
170
+
monthsAgo := years*12 + months
171
+
if monthsAgo >= 0 && monthsAgo < len(timeline.ByMonth) {
172
+
timeline.ByMonth[monthsAgo].Commits += p.Count
173
}
174
}
175
+3
-3
appview/state/router.go
+3
-3
appview/state/router.go
···
32
s.pages,
33
)
34
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)
···
109
})
110
111
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
112
s.pages.Error404(w)
113
})
114
···
182
r.Get("/brand", s.Brand)
183
184
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
185
s.pages.Error404(w)
186
})
187
return r
···
32
s.pages,
33
)
34
35
+
router.Get("/pwa-manifest.json", s.WebAppManifest)
36
router.Get("/robots.txt", s.RobotsTxt)
37
38
userRouter := s.UserRouter(&middleware)
···
107
})
108
109
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
110
+
w.WriteHeader(http.StatusNotFound)
111
s.pages.Error404(w)
112
})
113
···
181
r.Get("/brand", s.Brand)
182
183
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
184
+
w.WriteHeader(http.StatusNotFound)
185
s.pages.Error404(w)
186
})
187
return r
-36
appview/state/state.go
-36
appview/state/state.go
···
202
return s.db.Close()
203
}
204
205
-
func (s *State) Favicon(w http.ResponseWriter, r *http.Request) {
206
-
w.Header().Set("Content-Type", "image/svg+xml")
207
-
w.Header().Set("Cache-Control", "public, max-age=31536000") // one year
208
-
w.Header().Set("ETag", `"favicon-svg-v1"`)
209
-
210
-
if match := r.Header.Get("If-None-Match"); match == `"favicon-svg-v1"` {
211
-
w.WriteHeader(http.StatusNotModified)
212
-
return
213
-
}
214
-
215
-
s.pages.Favicon(w)
216
-
}
217
-
218
func (s *State) RobotsTxt(w http.ResponseWriter, r *http.Request) {
219
w.Header().Set("Content-Type", "text/plain")
220
w.Header().Set("Cache-Control", "public, max-age=86400") // one day
···
223
Allow: /
224
`
225
w.Write([]byte(robotsTxt))
226
-
}
227
-
228
-
// https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest
229
-
const manifestJson = `{
230
-
"name": "tangled",
231
-
"description": "tightly-knit social coding.",
232
-
"icons": [
233
-
{
234
-
"src": "/favicon.svg",
235
-
"sizes": "144x144"
236
-
}
237
-
],
238
-
"start_url": "/",
239
-
"id": "org.tangled",
240
-
241
-
"display": "standalone",
242
-
"background_color": "#111827",
243
-
"theme_color": "#111827"
244
-
}`
245
-
246
-
func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) {
247
-
w.Header().Set("Content-Type", "application/json")
248
-
w.Write([]byte(manifestJson))
249
}
250
251
func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {
···
202
return s.db.Close()
203
}
204
205
func (s *State) RobotsTxt(w http.ResponseWriter, r *http.Request) {
206
w.Header().Set("Content-Type", "text/plain")
207
w.Header().Set("Cache-Control", "public, max-age=86400") // one day
···
210
Allow: /
211
`
212
w.Write([]byte(robotsTxt))
213
}
214
215
func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {
+182
cmd/dolly/main.go
+182
cmd/dolly/main.go
···
···
1
+
package main
2
+
3
+
import (
4
+
"bytes"
5
+
"flag"
6
+
"fmt"
7
+
"image"
8
+
"image/color"
9
+
"image/png"
10
+
"os"
11
+
"path/filepath"
12
+
"strconv"
13
+
"strings"
14
+
"text/template"
15
+
16
+
"github.com/srwiley/oksvg"
17
+
"github.com/srwiley/rasterx"
18
+
"golang.org/x/image/draw"
19
+
"tangled.org/core/appview/pages"
20
+
"tangled.org/core/ico"
21
+
)
22
+
23
+
func main() {
24
+
var (
25
+
size string
26
+
fillColor string
27
+
output string
28
+
)
29
+
30
+
flag.StringVar(&size, "size", "512x512", "Output size in format WIDTHxHEIGHT (e.g., 512x512)")
31
+
flag.StringVar(&fillColor, "color", "#000000", "Fill color in hex format (e.g., #FF5733)")
32
+
flag.StringVar(&output, "output", "dolly.svg", "Output file path (format detected from extension: .svg, .png, or .ico)")
33
+
flag.Parse()
34
+
35
+
width, height, err := parseSize(size)
36
+
if err != nil {
37
+
fmt.Fprintf(os.Stderr, "Error parsing size: %v\n", err)
38
+
os.Exit(1)
39
+
}
40
+
41
+
// Detect format from file extension
42
+
ext := strings.ToLower(filepath.Ext(output))
43
+
format := strings.TrimPrefix(ext, ".")
44
+
45
+
if format != "svg" && format != "png" && format != "ico" {
46
+
fmt.Fprintf(os.Stderr, "Invalid file extension: %s. Must be .svg, .png, or .ico\n", ext)
47
+
os.Exit(1)
48
+
}
49
+
50
+
if !isValidHexColor(fillColor) {
51
+
fmt.Fprintf(os.Stderr, "Invalid color format: %s. Use hex format like #FF5733\n", fillColor)
52
+
os.Exit(1)
53
+
}
54
+
55
+
svgData, err := dolly(fillColor)
56
+
if err != nil {
57
+
fmt.Fprintf(os.Stderr, "Error generating SVG: %v\n", err)
58
+
os.Exit(1)
59
+
}
60
+
61
+
// Create output directory if it doesn't exist
62
+
dir := filepath.Dir(output)
63
+
if dir != "" && dir != "." {
64
+
if err := os.MkdirAll(dir, 0755); err != nil {
65
+
fmt.Fprintf(os.Stderr, "Error creating output directory: %v\n", err)
66
+
os.Exit(1)
67
+
}
68
+
}
69
+
70
+
switch format {
71
+
case "svg":
72
+
err = saveSVG(svgData, output, width, height)
73
+
case "png":
74
+
err = savePNG(svgData, output, width, height)
75
+
case "ico":
76
+
err = saveICO(svgData, output, width, height)
77
+
}
78
+
79
+
if err != nil {
80
+
fmt.Fprintf(os.Stderr, "Error saving file: %v\n", err)
81
+
os.Exit(1)
82
+
}
83
+
84
+
fmt.Printf("Successfully generated %s (%dx%d)\n", output, width, height)
85
+
}
86
+
87
+
func dolly(hexColor string) ([]byte, error) {
88
+
tpl, err := template.New("dolly").
89
+
ParseFS(pages.Files, "templates/fragments/dolly/logo.html")
90
+
if err != nil {
91
+
return nil, err
92
+
}
93
+
94
+
var svgData bytes.Buffer
95
+
if err := tpl.ExecuteTemplate(&svgData, "fragments/dolly/logo", pages.DollyParams{
96
+
FillColor: hexColor,
97
+
}); err != nil {
98
+
return nil, err
99
+
}
100
+
101
+
return svgData.Bytes(), nil
102
+
}
103
+
104
+
func svgToImage(svgData []byte, w, h int) (image.Image, error) {
105
+
icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData))
106
+
if err != nil {
107
+
return nil, fmt.Errorf("error parsing SVG: %v", err)
108
+
}
109
+
110
+
icon.SetTarget(0, 0, float64(w), float64(h))
111
+
rgba := image.NewRGBA(image.Rect(0, 0, w, h))
112
+
draw.Draw(rgba, rgba.Bounds(), &image.Uniform{color.Transparent}, image.Point{}, draw.Src)
113
+
scanner := rasterx.NewScannerGV(w, h, rgba, rgba.Bounds())
114
+
raster := rasterx.NewDasher(w, h, scanner)
115
+
icon.Draw(raster, 1.0)
116
+
117
+
return rgba, nil
118
+
}
119
+
120
+
func parseSize(size string) (int, int, error) {
121
+
parts := strings.Split(size, "x")
122
+
if len(parts) != 2 {
123
+
return 0, 0, fmt.Errorf("invalid size format, use WIDTHxHEIGHT")
124
+
}
125
+
126
+
width, err := strconv.Atoi(parts[0])
127
+
if err != nil {
128
+
return 0, 0, fmt.Errorf("invalid width: %v", err)
129
+
}
130
+
131
+
height, err := strconv.Atoi(parts[1])
132
+
if err != nil {
133
+
return 0, 0, fmt.Errorf("invalid height: %v", err)
134
+
}
135
+
136
+
if width <= 0 || height <= 0 {
137
+
return 0, 0, fmt.Errorf("width and height must be positive")
138
+
}
139
+
140
+
return width, height, nil
141
+
}
142
+
143
+
func isValidHexColor(hex string) bool {
144
+
if len(hex) != 7 || hex[0] != '#' {
145
+
return false
146
+
}
147
+
_, err := strconv.ParseUint(hex[1:], 16, 32)
148
+
return err == nil
149
+
}
150
+
151
+
func saveSVG(svgData []byte, filepath string, _, _ int) error {
152
+
return os.WriteFile(filepath, svgData, 0644)
153
+
}
154
+
155
+
func savePNG(svgData []byte, filepath string, width, height int) error {
156
+
img, err := svgToImage(svgData, width, height)
157
+
if err != nil {
158
+
return err
159
+
}
160
+
161
+
f, err := os.Create(filepath)
162
+
if err != nil {
163
+
return err
164
+
}
165
+
defer f.Close()
166
+
167
+
return png.Encode(f, img)
168
+
}
169
+
170
+
func saveICO(svgData []byte, filepath string, width, height int) error {
171
+
img, err := svgToImage(svgData, width, height)
172
+
if err != nil {
173
+
return err
174
+
}
175
+
176
+
icoData, err := ico.ImageToIco(img)
177
+
if err != nil {
178
+
return err
179
+
}
180
+
181
+
return os.WriteFile(filepath, icoData, 0644)
182
+
}
+86
-89
docs/DOCS.md
+86
-89
docs/DOCS.md
···
1
---
2
-
title: Tangled Documentation
3
author: The Tangled Contributors
4
date: 21 Sun, Dec 2025
5
-
---
6
-
7
-
# Introduction
8
-
9
-
Tangled is a decentralized code hosting and collaboration
10
-
platform. Every component of Tangled is open-source and
11
-
selfhostable. [tangled.org](https://tangled.org) also
12
-
provides hosting and CI services that are free to use.
13
14
-
There are several models for decentralized code
15
-
collaboration platforms, ranging from ActivityPubโs
16
-
(Forgejo) federated model, to Radicleโs entirely P2P model.
17
-
Our approach attempts to be the best of both worlds by
18
-
adopting atprotoโa protocol for building decentralized
19
-
social applications with a central identity
20
21
-
Our approach to this is the idea of โknotsโ. Knots are
22
-
lightweight, headless servers that enable users to host Git
23
-
repositories with ease. Knots are designed for either single
24
-
or multi-tenant use which is perfect for self-hosting on a
25
-
Raspberry Pi at home, or larger โcommunityโ servers. By
26
-
default, Tangled provides managed knots where you can host
27
-
your repositories for free.
28
29
-
The "appview" at tangled.org acts as a consolidated โviewโ
30
-
into the whole network, allowing users to access, clone and
31
-
contribute to repositories hosted across different knots
32
-
seamlessly.
33
34
-
# Quick Start Guide
35
36
-
## Login or Sign up
37
38
-
You can [login](https://tangled.org) by using your AT
39
account. If you are unclear on what that means, simply head
40
to the [signup](https://tangled.org/signup) page and create
41
an account. By doing so, you will be choosing Tangled as
42
your account provider (you will be granted a handle of the
43
form `user.tngl.sh`).
44
45
-
In the AT network, users are free to choose their account
46
provider (known as a "Personal Data Service", or PDS), and
47
login to applications that support AT accounts.
48
49
-
You can think of it as "one account for all of the
50
-
atmosphere"!
51
52
If you already have an AT account (you may have one if you
53
signed up to Bluesky, for example), you can login with the
54
same handle on Tangled (so just use `user.bsky.social` on
55
the login page).
56
57
-
## Add an SSH Key
58
59
Once you are logged in, you can start creating repositories
60
and pushing code. Tangled supports pushing git repositories
···
87
paste your public key, give it a descriptive name, and hit
88
save.
89
90
-
## Create a Repository
91
92
Once your SSH key is added, create your first repository:
93
···
98
4. Choose a knotserver to host this repository on
99
5. Hit create
100
101
-
"Knots" are selfhostable, lightweight git servers that can
102
host your repository. Unlike traditional code forges, your
103
code can live on any server. Read the [Knots](TODO) section
104
for more.
···
125
are hosted by tangled.org. If you use a custom knot, refer
126
to the [Knots](TODO) section.
127
128
-
## Push Your First Repository
129
130
-
Initialize a new git repository:
131
132
```bash
133
mkdir my-project
···
165
cd /path/to/your/existing/repo
166
```
167
168
-
You can inspect your existing git remote like so:
169
170
```bash
171
git remote -v
···
197
origin git@tangled.org:user.tngl.sh/my-project (push)
198
```
199
200
-
Push all your branches and tags to tangled:
201
202
```bash
203
git push -u origin --all
···
232
```
233
234
You also need to re-add the original URL as a push
235
-
destination (git replaces the push URL when you use `--add`
236
the first time):
237
238
```bash
···
249
```
250
251
Notice that there's one fetch URL (the primary remote) and
252
-
two push URLs. Now, whenever you push, git will
253
automatically push to both remotes:
254
255
```bash
···
301
## Docker
302
303
Refer to
304
-
[@tangled.org/knot-docker](https://tangled.sh/@tangled.sh/knot-docker).
305
Note that this is community maintained.
306
307
## Manual setup
···
372
```
373
KNOT_REPO_SCAN_PATH=/home/git
374
KNOT_SERVER_HOSTNAME=knot.example.com
375
-
APPVIEW_ENDPOINT=https://tangled.sh
376
KNOT_SERVER_OWNER=did:plc:foobar
377
KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444
378
KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555
···
603
- `nixery`: This uses an instance of
604
[Nixery](https://nixery.dev) to run steps, which allows
605
you to add [dependencies](#dependencies) from
606
-
[Nixpkgs](https://github.com/NixOS/nixpkgs). You can
607
search for packages on https://search.nixos.org, and
608
there's a pretty good chance the package(s) you're looking
609
for will be there.
···
630
default, the depth is set to 1, meaning only the most
631
recent commit will be fetched, which is the commit that
632
triggered the workflow.
633
-
- `submodules`: If you use [git
634
-
submodules](https://git-scm.com/book/en/v2/Git-Tools-Submodules)
635
in your repository, setting this field to `true` will
636
recursively fetch all submodules. This is `false` by
637
default.
···
657
Say you want to fetch Node.js and Go from `nixpkgs`, and a
658
package called `my_pkg` you've made from your own registry
659
at your repository at
660
-
`https://tangled.sh/@example.com/my_pkg`. You can define
661
those dependencies like so:
662
663
```yaml
···
779
780
If you want another example of a workflow, you can look at
781
the one [Tangled uses to build the
782
-
project](https://tangled.sh/@tangled.sh/core/blob/master/.tangled/workflows/build.yml).
783
784
## Self-hosting guide
785
···
836
837
## Architecture
838
839
-
Spindle is a small CI runner service. Here's a high level overview of how it operates:
840
841
-
* listens for [`sh.tangled.spindle.member`](/lexicons/spindle/member.json) and
842
[`sh.tangled.repo`](/lexicons/repo.json) records on the Jetstream.
843
-
* when a new repo record comes through (typically when you add a spindle to a
844
repo from the settings), spindle then resolves the underlying knot and
845
subscribes to repo events (see:
846
[`sh.tangled.pipeline`](/lexicons/pipeline.json)).
847
-
* the spindle engine then handles execution of the pipeline, with results and
848
-
logs beamed on the spindle event stream over wss
849
850
### The engine
851
852
At present, the only supported backend is Docker (and Podman, if Docker
853
-
compatibility is enabled, so that `/run/docker.sock` is created). Spindle
854
executes each step in the pipeline in a fresh container, with state persisted
855
across steps within the `/tangled/workspace` directory.
856
···
858
[Nixery](https://nixery.dev), which is handy for caching layers for frequently
859
used packages.
860
861
-
The pipeline manifest is [specified here](/docs/spindle/pipeline.md).
862
863
## Secrets with openbao
864
865
-
This document covers setting up Spindle to use OpenBao for secrets
866
management via OpenBao Proxy instead of the default SQLite backend.
867
868
### Overview
869
870
Spindle now uses OpenBao Proxy for secrets management. The proxy handles
871
-
authentication automatically using AppRole credentials, while Spindle
872
connects to the local proxy instead of directly to the OpenBao server.
873
874
This approach provides better security, automatic token renewal, and
···
876
877
### Installation
878
879
-
Install OpenBao from nixpkgs:
880
881
```bash
882
nix shell nixpkgs#openbao # for a local server
···
1029
}
1030
}
1031
1032
-
# Proxy listener for Spindle
1033
listener "tcp" {
1034
address = "127.0.0.1:8201"
1035
tls_disable = true
···
1062
1063
#### Configure spindle
1064
1065
-
Set these environment variables for Spindle:
1066
1067
```bash
1068
export SPINDLE_SERVER_SECRETS_PROVIDER=openbao
···
1070
export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle
1071
```
1072
1073
-
On startup, the spindle will now connect to the local proxy,
1074
which handles all authentication automatically.
1075
1076
### Production setup for proxy
···
1099
# List all secrets
1100
bao kv list spindle/
1101
1102
-
# Add a test secret via Spindle API, then check it exists
1103
bao kv list spindle/repos/
1104
1105
# Get a specific secret
···
1112
port 8200 or 8201)
1113
- The proxy authenticates with OpenBao using AppRole
1114
credentials
1115
-
- All Spindle requests go through the proxy, which injects
1116
authentication tokens
1117
- Secrets are stored at
1118
`spindle/repos/{sanitized_repo_path}/{secret_key}`
···
1131
and the policy has the necessary permissions.
1132
1133
**404 route errors**: The spindle KV mount probably doesn't
1134
-
exist - run the mount creation step again.
1135
1136
**Proxy authentication failures**: Check the proxy logs and
1137
verify the role-id and secret-id files are readable and
···
1159
secret_id="$(cat /tmp/openbao/secret-id)"
1160
```
1161
1162
-
# Migrating knots & spindles
1163
1164
Sometimes, non-backwards compatible changes are made to the
1165
knot/spindle XRPC APIs. If you host a knot or a spindle, you
···
1172
1173
## Upgrading from v1.8.x
1174
1175
-
After v1.8.2, the HTTP API for knot and spindles have been
1176
deprecated and replaced with XRPC. Repositories on outdated
1177
knots will not be viewable from the appview. Upgrading is
1178
straightforward however.
1179
1180
For knots:
1181
1182
-
- Upgrade to latest tag (v1.9.0 or above)
1183
- Head to the [knot dashboard](https://tangled.org/settings/knots) and
1184
hit the "retry" button to verify your knot
1185
1186
For spindles:
1187
1188
-
- Upgrade to latest tag (v1.9.0 or above)
1189
- Head to the [spindle
1190
dashboard](https://tangled.org/settings/spindles) and hit the
1191
"retry" button to verify your spindle
···
1227
# Hacking on Tangled
1228
1229
We highly recommend [installing
1230
-
nix](https://nixos.org/download/) (the package manager)
1231
-
before working on the codebase. The nix flake provides a lot
1232
of helpers to get started and most importantly, builds and
1233
dev shells are entirely deterministic.
1234
···
1238
nix develop
1239
```
1240
1241
-
Non-nix users can look at the `devShell` attribute in the
1242
`flake.nix` file to determine necessary dependencies.
1243
1244
## Running the appview
1245
1246
-
The nix flake also exposes a few `app` attributes (run `nix
1247
flake show` to see a full list of what the flake provides),
1248
one of the apps runs the appview with the `air`
1249
live-reloader:
···
1258
nix run .#watch-tailwind
1259
```
1260
1261
-
To authenticate with the appview, you will need redis and
1262
-
OAUTH JWKs to be setup:
1263
1264
```
1265
-
# oauth jwks should already be setup by the nix devshell:
1266
echo $TANGLED_OAUTH_CLIENT_SECRET
1267
z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc
1268
···
1280
# the secret key from above
1281
export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..."
1282
1283
-
# run redis in at a new shell to store oauth sessions
1284
redis-server
1285
```
1286
1287
## Running knots and spindles
1288
1289
An end-to-end knot setup requires setting up a machine with
1290
-
`sshd`, `AuthorizedKeysCommand`, and git user, which is
1291
-
quite cumbersome. So the nix flake provides a
1292
`nixosConfiguration` to do so.
1293
1294
<details>
1295
-
<summary><strong>MacOS users will have to setup a Nix Builder first</strong></summary>
1296
1297
In order to build Tangled's dev VM on macOS, you will
1298
first need to set up a Linux Nix builder. The recommended
···
1303
you are using Apple Silicon).
1304
1305
> IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside
1306
-
> the tangled repo so that it doesn't conflict with the other VM. For example,
1307
> you can do
1308
>
1309
> ```shell
···
1316
> avoid subtle problems.
1317
1318
Alternatively, you can use any other method to set up a
1319
-
Linux machine with `nix` installed that you can `sudo ssh`
1320
into (in other words, root user on your Mac has to be able
1321
to ssh into the Linux machine without entering a password)
1322
and that has the same architecture as your Mac. See
···
1347
with `ssh` exposed on port 2222.
1348
1349
Once the services are running, head to
1350
-
http://localhost:3000/settings/knots and hit verify. It should
1351
verify the ownership of the services instantly if everything
1352
went smoothly.
1353
···
1371
1372
The above VM should already be running a spindle on
1373
`localhost:6555`. Head to http://localhost:3000/settings/spindles and
1374
-
hit verify. You can then configure each repository to use
1375
this spindle and run CI jobs.
1376
1377
Of interest when debugging spindles:
1378
1379
```
1380
-
# service logs from journald:
1381
journalctl -xeu spindle
1382
1383
# CI job logs from disk:
1384
ls /var/log/spindle
1385
1386
-
# debugging spindle db:
1387
sqlite3 /var/lib/spindle/spindle.db
1388
1389
# litecli has a nicer REPL interface:
···
1432
1433
### General notes
1434
1435
-
- PRs get merged "as-is" (fast-forward) -- like applying a patch-series
1436
-
using `git am`. At present, there is no squashing -- so please author
1437
your commits as they would appear on `master`, following the above
1438
guidelines.
1439
- If there is a lot of nesting, for example "appview:
···
1454
## Code formatting
1455
1456
We use a variety of tools to format our code, and multiplex them with
1457
-
[`treefmt`](https://treefmt.com): all you need to do to format your changes
1458
is run `nix run .#fmt` (or just `treefmt` if you're in the devshell).
1459
1460
## Proposals for bigger changes
···
1482
We'll use the issue thread to discuss and refine the idea before moving
1483
forward.
1484
1485
-
## Developer certificate of origin (DCO)
1486
1487
We require all contributors to certify that they have the right to
1488
submit the code they're contributing. To do this, we follow the
···
1
---
2
+
title: Tangled docs
3
author: The Tangled Contributors
4
date: 21 Sun, Dec 2025
5
+
abstract: |
6
+
Tangled is a decentralized code hosting and collaboration
7
+
platform. Every component of Tangled is open-source and
8
+
self-hostable. [tangled.org](https://tangled.org) also
9
+
provides hosting and CI services that are free to use.
10
11
+
There are several models for decentralized code
12
+
collaboration platforms, ranging from ActivityPubโs
13
+
(Forgejo) federated model, to Radicleโs entirely P2P model.
14
+
Our approach attempts to be the best of both worlds by
15
+
adopting the AT Protocolโa protocol for building decentralized
16
+
social applications with a central identity
17
18
+
Our approach to this is the idea of โknotsโ. Knots are
19
+
lightweight, headless servers that enable users to host Git
20
+
repositories with ease. Knots are designed for either single
21
+
or multi-tenant use which is perfect for self-hosting on a
22
+
Raspberry Pi at home, or larger โcommunityโ servers. By
23
+
default, Tangled provides managed knots where you can host
24
+
your repositories for free.
25
26
+
The appview at tangled.org acts as a consolidated "view"
27
+
into the whole network, allowing users to access, clone and
28
+
contribute to repositories hosted across different knots
29
+
seamlessly.
30
+
---
31
32
+
# Quick start guide
33
34
+
## Login or sign up
35
36
+
You can [login](https://tangled.org) by using your AT Protocol
37
account. If you are unclear on what that means, simply head
38
to the [signup](https://tangled.org/signup) page and create
39
an account. By doing so, you will be choosing Tangled as
40
your account provider (you will be granted a handle of the
41
form `user.tngl.sh`).
42
43
+
In the AT Protocol network, users are free to choose their account
44
provider (known as a "Personal Data Service", or PDS), and
45
login to applications that support AT accounts.
46
47
+
You can think of it as "one account for all of the atmosphere"!
48
49
If you already have an AT account (you may have one if you
50
signed up to Bluesky, for example), you can login with the
51
same handle on Tangled (so just use `user.bsky.social` on
52
the login page).
53
54
+
## Add an SSH key
55
56
Once you are logged in, you can start creating repositories
57
and pushing code. Tangled supports pushing git repositories
···
84
paste your public key, give it a descriptive name, and hit
85
save.
86
87
+
## Create a repository
88
89
Once your SSH key is added, create your first repository:
90
···
95
4. Choose a knotserver to host this repository on
96
5. Hit create
97
98
+
Knots are self-hostable, lightweight Git servers that can
99
host your repository. Unlike traditional code forges, your
100
code can live on any server. Read the [Knots](TODO) section
101
for more.
···
122
are hosted by tangled.org. If you use a custom knot, refer
123
to the [Knots](TODO) section.
124
125
+
## Push your first repository
126
127
+
Initialize a new Git repository:
128
129
```bash
130
mkdir my-project
···
162
cd /path/to/your/existing/repo
163
```
164
165
+
You can inspect your existing Git remote like so:
166
167
```bash
168
git remote -v
···
194
origin git@tangled.org:user.tngl.sh/my-project (push)
195
```
196
197
+
Push all your branches and tags to Tangled:
198
199
```bash
200
git push -u origin --all
···
229
```
230
231
You also need to re-add the original URL as a push
232
+
destination (Git replaces the push URL when you use `--add`
233
the first time):
234
235
```bash
···
246
```
247
248
Notice that there's one fetch URL (the primary remote) and
249
+
two push URLs. Now, whenever you push, Git will
250
automatically push to both remotes:
251
252
```bash
···
298
## Docker
299
300
Refer to
301
+
[@tangled.org/knot-docker](https://tangled.org/@tangled.org/knot-docker).
302
Note that this is community maintained.
303
304
## Manual setup
···
369
```
370
KNOT_REPO_SCAN_PATH=/home/git
371
KNOT_SERVER_HOSTNAME=knot.example.com
372
+
APPVIEW_ENDPOINT=https://tangled.org
373
KNOT_SERVER_OWNER=did:plc:foobar
374
KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444
375
KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555
···
600
- `nixery`: This uses an instance of
601
[Nixery](https://nixery.dev) to run steps, which allows
602
you to add [dependencies](#dependencies) from
603
+
Nixpkgs (https://github.com/NixOS/nixpkgs). You can
604
search for packages on https://search.nixos.org, and
605
there's a pretty good chance the package(s) you're looking
606
for will be there.
···
627
default, the depth is set to 1, meaning only the most
628
recent commit will be fetched, which is the commit that
629
triggered the workflow.
630
+
- `submodules`: If you use Git submodules
631
+
(https://git-scm.com/book/en/v2/Git-Tools-Submodules)
632
in your repository, setting this field to `true` will
633
recursively fetch all submodules. This is `false` by
634
default.
···
654
Say you want to fetch Node.js and Go from `nixpkgs`, and a
655
package called `my_pkg` you've made from your own registry
656
at your repository at
657
+
`https://tangled.org/@example.com/my_pkg`. You can define
658
those dependencies like so:
659
660
```yaml
···
776
777
If you want another example of a workflow, you can look at
778
the one [Tangled uses to build the
779
+
project](https://tangled.org/@tangled.org/core/blob/master/.tangled/workflows/build.yml).
780
781
## Self-hosting guide
782
···
833
834
## Architecture
835
836
+
Spindle is a small CI runner service. Here's a high-level overview of how it operates:
837
838
+
* Listens for [`sh.tangled.spindle.member`](/lexicons/spindle/member.json) and
839
[`sh.tangled.repo`](/lexicons/repo.json) records on the Jetstream.
840
+
* When a new repo record comes through (typically when you add a spindle to a
841
repo from the settings), spindle then resolves the underlying knot and
842
subscribes to repo events (see:
843
[`sh.tangled.pipeline`](/lexicons/pipeline.json)).
844
+
* The spindle engine then handles execution of the pipeline, with results and
845
+
logs beamed on the spindle event stream over WebSocket
846
847
### The engine
848
849
At present, the only supported backend is Docker (and Podman, if Docker
850
+
compatibility is enabled, so that `/run/docker.sock` is created). spindle
851
executes each step in the pipeline in a fresh container, with state persisted
852
across steps within the `/tangled/workspace` directory.
853
···
855
[Nixery](https://nixery.dev), which is handy for caching layers for frequently
856
used packages.
857
858
+
The pipeline manifest is [specified here](https://docs.tangled.org/spindles.html#pipelines).
859
860
## Secrets with openbao
861
862
+
This document covers setting up spindle to use OpenBao for secrets
863
management via OpenBao Proxy instead of the default SQLite backend.
864
865
### Overview
866
867
Spindle now uses OpenBao Proxy for secrets management. The proxy handles
868
+
authentication automatically using AppRole credentials, while spindle
869
connects to the local proxy instead of directly to the OpenBao server.
870
871
This approach provides better security, automatic token renewal, and
···
873
874
### Installation
875
876
+
Install OpenBao from Nixpkgs:
877
878
```bash
879
nix shell nixpkgs#openbao # for a local server
···
1026
}
1027
}
1028
1029
+
# Proxy listener for spindle
1030
listener "tcp" {
1031
address = "127.0.0.1:8201"
1032
tls_disable = true
···
1059
1060
#### Configure spindle
1061
1062
+
Set these environment variables for spindle:
1063
1064
```bash
1065
export SPINDLE_SERVER_SECRETS_PROVIDER=openbao
···
1067
export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle
1068
```
1069
1070
+
On startup, spindle will now connect to the local proxy,
1071
which handles all authentication automatically.
1072
1073
### Production setup for proxy
···
1096
# List all secrets
1097
bao kv list spindle/
1098
1099
+
# Add a test secret via the spindle API, then check it exists
1100
bao kv list spindle/repos/
1101
1102
# Get a specific secret
···
1109
port 8200 or 8201)
1110
- The proxy authenticates with OpenBao using AppRole
1111
credentials
1112
+
- All spindle requests go through the proxy, which injects
1113
authentication tokens
1114
- Secrets are stored at
1115
`spindle/repos/{sanitized_repo_path}/{secret_key}`
···
1128
and the policy has the necessary permissions.
1129
1130
**404 route errors**: The spindle KV mount probably doesn't
1131
+
existโrun the mount creation step again.
1132
1133
**Proxy authentication failures**: Check the proxy logs and
1134
verify the role-id and secret-id files are readable and
···
1156
secret_id="$(cat /tmp/openbao/secret-id)"
1157
```
1158
1159
+
# Migrating knots and spindles
1160
1161
Sometimes, non-backwards compatible changes are made to the
1162
knot/spindle XRPC APIs. If you host a knot or a spindle, you
···
1169
1170
## Upgrading from v1.8.x
1171
1172
+
After v1.8.2, the HTTP API for knots and spindles has been
1173
deprecated and replaced with XRPC. Repositories on outdated
1174
knots will not be viewable from the appview. Upgrading is
1175
straightforward however.
1176
1177
For knots:
1178
1179
+
- Upgrade to the latest tag (v1.9.0 or above)
1180
- Head to the [knot dashboard](https://tangled.org/settings/knots) and
1181
hit the "retry" button to verify your knot
1182
1183
For spindles:
1184
1185
+
- Upgrade to the latest tag (v1.9.0 or above)
1186
- Head to the [spindle
1187
dashboard](https://tangled.org/settings/spindles) and hit the
1188
"retry" button to verify your spindle
···
1224
# Hacking on Tangled
1225
1226
We highly recommend [installing
1227
+
Nix](https://nixos.org/download/) (the package manager)
1228
+
before working on the codebase. The Nix flake provides a lot
1229
of helpers to get started and most importantly, builds and
1230
dev shells are entirely deterministic.
1231
···
1235
nix develop
1236
```
1237
1238
+
Non-Nix users can look at the `devShell` attribute in the
1239
`flake.nix` file to determine necessary dependencies.
1240
1241
## Running the appview
1242
1243
+
The Nix flake also exposes a few `app` attributes (run `nix
1244
flake show` to see a full list of what the flake provides),
1245
one of the apps runs the appview with the `air`
1246
live-reloader:
···
1255
nix run .#watch-tailwind
1256
```
1257
1258
+
To authenticate with the appview, you will need Redis and
1259
+
OAuth JWKs to be set up:
1260
1261
```
1262
+
# OAuth JWKs should already be set up by the Nix devshell:
1263
echo $TANGLED_OAUTH_CLIENT_SECRET
1264
z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc
1265
···
1277
# the secret key from above
1278
export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..."
1279
1280
+
# Run Redis in a new shell to store OAuth sessions
1281
redis-server
1282
```
1283
1284
## Running knots and spindles
1285
1286
An end-to-end knot setup requires setting up a machine with
1287
+
`sshd`, `AuthorizedKeysCommand`, and a Git user, which is
1288
+
quite cumbersome. So the Nix flake provides a
1289
`nixosConfiguration` to do so.
1290
1291
<details>
1292
+
<summary><strong>macOS users will have to set up a Nix Builder first</strong></summary>
1293
1294
In order to build Tangled's dev VM on macOS, you will
1295
first need to set up a Linux Nix builder. The recommended
···
1300
you are using Apple Silicon).
1301
1302
> IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside
1303
+
> the Tangled repo so that it doesn't conflict with the other VM. For example,
1304
> you can do
1305
>
1306
> ```shell
···
1313
> avoid subtle problems.
1314
1315
Alternatively, you can use any other method to set up a
1316
+
Linux machine with Nix installed that you can `sudo ssh`
1317
into (in other words, root user on your Mac has to be able
1318
to ssh into the Linux machine without entering a password)
1319
and that has the same architecture as your Mac. See
···
1344
with `ssh` exposed on port 2222.
1345
1346
Once the services are running, head to
1347
+
http://localhost:3000/settings/knots and hit "Verify". It should
1348
verify the ownership of the services instantly if everything
1349
went smoothly.
1350
···
1368
1369
The above VM should already be running a spindle on
1370
`localhost:6555`. Head to http://localhost:3000/settings/spindles and
1371
+
hit "Verify". You can then configure each repository to use
1372
this spindle and run CI jobs.
1373
1374
Of interest when debugging spindles:
1375
1376
```
1377
+
# Service logs from journald:
1378
journalctl -xeu spindle
1379
1380
# CI job logs from disk:
1381
ls /var/log/spindle
1382
1383
+
# Debugging spindle database:
1384
sqlite3 /var/lib/spindle/spindle.db
1385
1386
# litecli has a nicer REPL interface:
···
1429
1430
### General notes
1431
1432
+
- PRs get merged "as-is" (fast-forward)โlike applying a patch-series
1433
+
using `git am`. At present, there is no squashingโso please author
1434
your commits as they would appear on `master`, following the above
1435
guidelines.
1436
- If there is a lot of nesting, for example "appview:
···
1451
## Code formatting
1452
1453
We use a variety of tools to format our code, and multiplex them with
1454
+
[`treefmt`](https://treefmt.com). All you need to do to format your changes
1455
is run `nix run .#fmt` (or just `treefmt` if you're in the devshell).
1456
1457
## Proposals for bigger changes
···
1479
We'll use the issue thread to discuss and refine the idea before moving
1480
forward.
1481
1482
+
## Developer Certificate of Origin (DCO)
1483
1484
We require all contributors to certify that they have the right to
1485
submit the code they're contributing. To do this, we follow the
+3
docs/mode.html
+3
docs/mode.html
+7
docs/search.html
+7
docs/search.html
···
···
1
+
<form action="https://google.com/search" role="search" aria-label="Sitewide" class="w-full">
2
+
<input type="hidden" name="q" value="+[inurl:https://docs.tangled.org]">
3
+
<label>
4
+
<span style="display:none;">Search</span>
5
+
<input type="text" name="q" placeholder="Search docs ..." class="w-full font-normal">
6
+
</label>
7
+
</form>
+75
-36
docs/template.html
+75
-36
docs/template.html
···
20
<meta name="description" content="$description-meta$" />
21
$endif$
22
23
-
<title>$pagetitle$ - Tangled docs</title>
24
25
<style>
26
$styles.css()$
···
37
<link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin />
38
39
</head>
40
-
<body class="bg-white dark:bg-gray-900 min-h-screen flex flex-col min-h-screen">
41
$for(include-before)$
42
$include-before$
43
$endfor$
44
45
$if(toc)$
46
-
<!-- mobile topbar toc -->
47
-
<details id="mobile-$idprefix$TOC" role="doc-toc" class="md:hidden bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 z-50 space-y-4 group px-6 py-4">
48
-
<summary class="cursor-pointer list-none text-sm font-semibold select-none flex gap-2 justify-between items-center dark:text-white">
49
$if(toc-title)$$toc-title$$else$Table of Contents$endif$
50
-
<span class="group-open:hidden inline">${ menu.svg() }</span>
51
-
<span class="hidden group-open:inline">${ x.svg() }</span>
52
-
</summary>
53
-
${ table-of-contents:toc.html() }
54
-
</details>
55
<!-- desktop sidebar toc -->
56
-
<nav id="$idprefix$TOC" role="doc-toc" class="hidden md:block fixed left-0 top-0 w-80 h-screen bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 overflow-y-auto p-4 z-50">
57
-
$if(toc-title)$
58
-
<h2 id="$idprefix$toc-title" class="text-lg font-semibold mb-4 text-gray-900">$toc-title$</h2>
59
-
$endif$
60
-
${ table-of-contents:toc.html() }
61
</nav>
62
$endif$
63
64
<div class="$if(toc)$md:ml-80$endif$ flex-1 flex flex-col">
65
<main class="max-w-4xl w-full mx-auto p-6 flex-1">
66
$if(top)$
67
-
$-- only print title block if this is NOT the top page
68
$else$
69
$if(title)$
70
-
<header id="title-block-header" class="mb-8 pb-8 border-b border-gray-200 dark:border-gray-700">
71
-
<h1 class="text-4xl font-bold mb-2 text-black dark:text-white">$title$</h1>
72
-
$if(subtitle)$
73
-
<p class="text-xl text-gray-500 dark:text-gray-400 mb-2">$subtitle$</p>
74
-
$endif$
75
-
$for(author)$
76
-
<p class="text-sm text-gray-500 dark:text-gray-400">$author$</p>
77
-
$endfor$
78
-
$if(date)$
79
-
<p class="text-sm text-gray-500 dark:text-gray-400">Updated on $date$</p>
80
-
$endif$
81
-
$if(abstract)$
82
-
<div class="mt-6 p-4 bg-gray-50 rounded-lg">
83
-
<div class="text-sm font-semibold text-gray-700 uppercase mb-2">$abstract-title$</div>
84
-
<div class="text-gray-700">$abstract$</div>
85
-
</div>
86
-
$endif$
87
-
$endif$
88
-
</header>
89
$endif$
90
<article class="prose dark:prose-invert max-w-none">
91
$body$
92
</article>
93
</main>
94
-
<nav id="sitenav" class="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 ">
95
<div class="max-w-4xl mx-auto px-8 py-4">
96
<div class="flex justify-between gap-4">
97
<span class="flex-1">
···
20
<meta name="description" content="$description-meta$" />
21
$endif$
22
23
+
<title>$pagetitle$</title>
24
25
<style>
26
$styles.css()$
···
37
<link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin />
38
39
</head>
40
+
<body class="bg-white dark:bg-gray-900 flex flex-col min-h-svh">
41
$for(include-before)$
42
$include-before$
43
$endfor$
44
45
$if(toc)$
46
+
<!-- mobile TOC trigger -->
47
+
<div class="md:hidden px-6 py-4 border-b border-gray-200 dark:border-gray-700">
48
+
<button
49
+
type="button"
50
+
popovertarget="mobile-toc-popover"
51
+
popovertargetaction="toggle"
52
+
class="w-full flex gap-2 items-center text-sm font-semibold dark:text-white"
53
+
>
54
+
${ menu.svg() }
55
$if(toc-title)$$toc-title$$else$Table of Contents$endif$
56
+
</button>
57
+
</div>
58
+
59
+
<div
60
+
id="mobile-toc-popover"
61
+
popover
62
+
class="mobile-toc-popover
63
+
bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700
64
+
h-full overflow-y-auto shadow-sm
65
+
px-6 py-4 fixed inset-x-0 top-0 w-fit max-w-4/5 m-0"
66
+
>
67
+
<div class="flex flex-col min-h-full">
68
+
<div class="flex-1 space-y-4">
69
+
<button
70
+
type="button"
71
+
popovertarget="mobile-toc-popover"
72
+
popovertargetaction="toggle"
73
+
class="w-full flex gap-2 items-center text-sm font-semibold dark:text-white mb-4">
74
+
${ x.svg() }
75
+
$if(toc-title)$$toc-title$$else$Table of Contents$endif$
76
+
</button>
77
+
${ search.html() }
78
+
${ table-of-contents:toc.html() }
79
+
</div>
80
+
${ single-page:mode.html() }
81
+
</div>
82
+
</div>
83
+
84
<!-- desktop sidebar toc -->
85
+
<nav
86
+
id="$idprefix$TOC"
87
+
role="doc-toc"
88
+
class="hidden md:flex md:flex-col gap-4 fixed left-0 top-0 w-80 h-screen
89
+
bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700
90
+
p-4 z-50 overflow-y-auto">
91
+
${ search.html() }
92
+
<div class="flex-1">
93
+
$if(toc-title)$
94
+
<h2 id="$idprefix$toc-title" class="text-lg font-semibold mb-4 text-gray-900">$toc-title$</h2>
95
+
$endif$
96
+
${ table-of-contents:toc.html() }
97
+
</div>
98
+
${ single-page:mode.html() }
99
</nav>
100
$endif$
101
102
<div class="$if(toc)$md:ml-80$endif$ flex-1 flex flex-col">
103
<main class="max-w-4xl w-full mx-auto p-6 flex-1">
104
$if(top)$
105
+
$-- only print title block if this is NOT the top page
106
$else$
107
$if(title)$
108
+
<header id="title-block-header" class="mb-8 pb-8 border-b border-gray-200 dark:border-gray-700">
109
+
<h1 class="text-4xl font-bold mb-2 text-black dark:text-white">$title$</h1>
110
+
$if(subtitle)$
111
+
<p class="text-xl text-gray-500 dark:text-gray-400 mb-2">$subtitle$</p>
112
+
$endif$
113
+
$for(author)$
114
+
<p class="text-sm text-gray-500 dark:text-gray-400">$author$</p>
115
+
$endfor$
116
+
$if(date)$
117
+
<p class="text-sm text-gray-500 dark:text-gray-400">Updated on $date$</p>
118
+
$endif$
119
+
$endif$
120
+
</header>
121
+
$endif$
122
+
123
+
$if(abstract)$
124
+
<article class="prose dark:prose-invert max-w-none">
125
+
$abstract$
126
+
</article>
127
$endif$
128
+
129
<article class="prose dark:prose-invert max-w-none">
130
$body$
131
</article>
132
</main>
133
+
<nav id="sitenav" class="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
134
<div class="max-w-4xl mx-auto px-8 py-4">
135
<div class="flex justify-between gap-4">
136
<span class="flex-1">
+18
-3
flake.nix
+18
-3
flake.nix
···
76
};
77
buildGoApplication =
78
(self.callPackage "${gomod2nix}/builder" {
79
-
gomod2nix = gomod2nix.legacyPackages.${pkgs.system}.gomod2nix;
80
}).buildGoApplication;
81
modules = ./nix/gomod2nix.toml;
82
sqlite-lib = self.callPackage ./nix/pkgs/sqlite-lib.nix {
···
94
spindle = self.callPackage ./nix/pkgs/spindle.nix {};
95
knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {};
96
knot = self.callPackage ./nix/pkgs/knot.nix {};
97
});
98
in {
99
overlays.default = final: prev: {
100
-
inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview docs;
101
};
102
103
packages = forAllSystems (system: let
···
106
staticPackages = mkPackageSet pkgs.pkgsStatic;
107
crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic;
108
in {
109
-
inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib docs;
110
111
pkgsStatic-appview = staticPackages.appview;
112
pkgsStatic-knot = staticPackages.knot;
113
pkgsStatic-knot-unwrapped = staticPackages.knot-unwrapped;
114
pkgsStatic-spindle = staticPackages.spindle;
115
pkgsStatic-sqlite-lib = staticPackages.sqlite-lib;
116
117
pkgsCross-gnu64-pkgsStatic-appview = crossPackages.appview;
118
pkgsCross-gnu64-pkgsStatic-knot = crossPackages.knot;
119
pkgsCross-gnu64-pkgsStatic-knot-unwrapped = crossPackages.knot-unwrapped;
120
pkgsCross-gnu64-pkgsStatic-spindle = crossPackages.spindle;
121
122
treefmt-wrapper = pkgs.treefmt.withConfig {
123
settings.formatter = {
···
76
};
77
buildGoApplication =
78
(self.callPackage "${gomod2nix}/builder" {
79
+
gomod2nix = gomod2nix.legacyPackages.${pkgs.stdenv.hostPlatform.system}.gomod2nix;
80
}).buildGoApplication;
81
modules = ./nix/gomod2nix.toml;
82
sqlite-lib = self.callPackage ./nix/pkgs/sqlite-lib.nix {
···
94
spindle = self.callPackage ./nix/pkgs/spindle.nix {};
95
knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {};
96
knot = self.callPackage ./nix/pkgs/knot.nix {};
97
+
dolly = self.callPackage ./nix/pkgs/dolly.nix {};
98
});
99
in {
100
overlays.default = final: prev: {
101
+
inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview docs dolly;
102
};
103
104
packages = forAllSystems (system: let
···
107
staticPackages = mkPackageSet pkgs.pkgsStatic;
108
crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic;
109
in {
110
+
inherit
111
+
(packages)
112
+
appview
113
+
appview-static-files
114
+
lexgen
115
+
goat
116
+
spindle
117
+
knot
118
+
knot-unwrapped
119
+
sqlite-lib
120
+
docs
121
+
dolly
122
+
;
123
124
pkgsStatic-appview = staticPackages.appview;
125
pkgsStatic-knot = staticPackages.knot;
126
pkgsStatic-knot-unwrapped = staticPackages.knot-unwrapped;
127
pkgsStatic-spindle = staticPackages.spindle;
128
pkgsStatic-sqlite-lib = staticPackages.sqlite-lib;
129
+
pkgsStatic-dolly = staticPackages.dolly;
130
131
pkgsCross-gnu64-pkgsStatic-appview = crossPackages.appview;
132
pkgsCross-gnu64-pkgsStatic-knot = crossPackages.knot;
133
pkgsCross-gnu64-pkgsStatic-knot-unwrapped = crossPackages.knot-unwrapped;
134
pkgsCross-gnu64-pkgsStatic-spindle = crossPackages.spindle;
135
+
pkgsCross-gnu64-pkgsStatic-dolly = crossPackages.dolly;
136
137
treefmt-wrapper = pkgs.treefmt.withConfig {
138
settings.formatter = {
+88
ico/ico.go
+88
ico/ico.go
···
···
1
+
package ico
2
+
3
+
import (
4
+
"bytes"
5
+
"encoding/binary"
6
+
"fmt"
7
+
"image"
8
+
"image/png"
9
+
)
10
+
11
+
type IconDir struct {
12
+
Reserved uint16 // must be 0
13
+
Type uint16 // 1 for ICO, 2 for CUR
14
+
Count uint16 // number of images
15
+
}
16
+
17
+
type IconDirEntry struct {
18
+
Width uint8 // 0 means 256
19
+
Height uint8 // 0 means 256
20
+
ColorCount uint8
21
+
Reserved uint8 // must be 0
22
+
ColorPlanes uint16 // 0 or 1
23
+
BitsPerPixel uint16
24
+
SizeInBytes uint32
25
+
Offset uint32
26
+
}
27
+
28
+
func ImageToIco(img image.Image) ([]byte, error) {
29
+
// encode image as png
30
+
var pngBuf bytes.Buffer
31
+
if err := png.Encode(&pngBuf, img); err != nil {
32
+
return nil, fmt.Errorf("failed to encode PNG: %w", err)
33
+
}
34
+
pngData := pngBuf.Bytes()
35
+
36
+
// get image dimensions
37
+
bounds := img.Bounds()
38
+
width := bounds.Dx()
39
+
height := bounds.Dy()
40
+
41
+
// prepare output buffer
42
+
var icoBuf bytes.Buffer
43
+
44
+
iconDir := IconDir{
45
+
Reserved: 0,
46
+
Type: 1, // ICO format
47
+
Count: 1, // One image
48
+
}
49
+
50
+
w := uint8(width)
51
+
h := uint8(height)
52
+
53
+
// width/height of 256 should be stored as 0
54
+
if width == 256 {
55
+
w = 0
56
+
}
57
+
if height == 256 {
58
+
h = 0
59
+
}
60
+
61
+
iconDirEntry := IconDirEntry{
62
+
Width: w,
63
+
Height: h,
64
+
ColorCount: 0, // 0 for PNG (32-bit)
65
+
Reserved: 0,
66
+
ColorPlanes: 1,
67
+
BitsPerPixel: 32, // PNG with alpha
68
+
SizeInBytes: uint32(len(pngData)),
69
+
Offset: 6 + 16, // Size of ICONDIR + ICONDIRENTRY
70
+
}
71
+
72
+
// write IconDir
73
+
if err := binary.Write(&icoBuf, binary.LittleEndian, iconDir); err != nil {
74
+
return nil, fmt.Errorf("failed to write ICONDIR: %w", err)
75
+
}
76
+
77
+
// write IconDirEntry
78
+
if err := binary.Write(&icoBuf, binary.LittleEndian, iconDirEntry); err != nil {
79
+
return nil, fmt.Errorf("failed to write ICONDIRENTRY: %w", err)
80
+
}
81
+
82
+
// write PNG data directly
83
+
if _, err := icoBuf.Write(pngData); err != nil {
84
+
return nil, fmt.Errorf("failed to write PNG data: %w", err)
85
+
}
86
+
87
+
return icoBuf.Bytes(), nil
88
+
}
+1
input.css
+1
input.css
+10
-2
lexicons/pulls/pull.json
+10
-2
lexicons/pulls/pull.json
···
12
"required": [
13
"target",
14
"title",
15
+
"patchBlob",
16
"createdAt"
17
],
18
"properties": {
···
27
"type": "string"
28
},
29
"patch": {
30
+
"type": "string",
31
+
"description": "(deprecated) use patchBlob instead"
32
+
},
33
+
"patchBlob": {
34
+
"type": "blob",
35
+
"accept": [
36
+
"text/x-patch"
37
+
],
38
+
"description": "patch content"
39
},
40
"source": {
41
"type": "ref",
+6
-1
nix/pkgs/appview-static-files.nix
+6
-1
nix/pkgs/appview-static-files.nix
···
8
actor-typeahead-src,
9
sqlite-lib,
10
tailwindcss,
11
src,
12
}:
13
runCommandLocal "appview-static-files" {
···
17
(allow file-read* (subpath "/System/Library/OpenSSL"))
18
'';
19
} ''
20
-
mkdir -p $out/{fonts,icons} && cd $out
21
cp -f ${htmx-src} htmx.min.js
22
cp -f ${htmx-ws-src} htmx-ext-ws.min.js
23
cp -rf ${lucide-src}/*.svg icons/
···
26
cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/
27
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/
28
cp -f ${actor-typeahead-src}/actor-typeahead.js .
29
# tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work
30
# for whatever reason (produces broken css), so we are doing this instead
31
cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
···
8
actor-typeahead-src,
9
sqlite-lib,
10
tailwindcss,
11
+
dolly,
12
src,
13
}:
14
runCommandLocal "appview-static-files" {
···
18
(allow file-read* (subpath "/System/Library/OpenSSL"))
19
'';
20
} ''
21
+
mkdir -p $out/{fonts,icons,logos} && cd $out
22
cp -f ${htmx-src} htmx.min.js
23
cp -f ${htmx-ws-src} htmx-ext-ws.min.js
24
cp -rf ${lucide-src}/*.svg icons/
···
27
cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/
28
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/
29
cp -f ${actor-typeahead-src}/actor-typeahead.js .
30
+
31
+
${dolly}/bin/dolly -output logos/dolly.png -size 180x180
32
+
${dolly}/bin/dolly -output logos/dolly.ico -size 48x48
33
+
${dolly}/bin/dolly -output logos/dolly.svg
34
# tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work
35
# for whatever reason (produces broken css), so we are doing this instead
36
cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
+13
-1
nix/pkgs/docs.nix
+13
-1
nix/pkgs/docs.nix
···
18
# icons
19
cp -rf ${lucide-src}/*.svg working/
20
21
-
# content
22
${pandoc}/bin/pandoc ${src}/docs/DOCS.md \
23
-o $out/ \
24
-t chunkedhtml \
25
--variable toc \
26
--toc-depth=2 \
27
--css=stylesheet.css \
28
--chunk-template="%i.html" \
29
--highlight-style=working/highlight.theme \
30
--template=working/template.html
31
···
18
# icons
19
cp -rf ${lucide-src}/*.svg working/
20
21
+
# content - chunked
22
${pandoc}/bin/pandoc ${src}/docs/DOCS.md \
23
-o $out/ \
24
-t chunkedhtml \
25
--variable toc \
26
+
--variable-json single-page=false \
27
--toc-depth=2 \
28
--css=stylesheet.css \
29
--chunk-template="%i.html" \
30
+
--highlight-style=working/highlight.theme \
31
+
--template=working/template.html
32
+
33
+
# content - single page
34
+
${pandoc}/bin/pandoc ${src}/docs/DOCS.md \
35
+
-o $out/single-page.html \
36
+
--toc \
37
+
--variable toc \
38
+
--variable single-page \
39
+
--toc-depth=2 \
40
+
--css=stylesheet.css \
41
--highlight-style=working/highlight.theme \
42
--template=working/template.html
43
+21
nix/pkgs/dolly.nix
+21
nix/pkgs/dolly.nix
···
···
1
+
{
2
+
buildGoApplication,
3
+
modules,
4
+
src,
5
+
}:
6
+
buildGoApplication {
7
+
pname = "dolly";
8
+
version = "0.1.0";
9
+
inherit src modules;
10
+
11
+
# patch the static dir
12
+
postUnpack = ''
13
+
pushd source
14
+
mkdir -p appview/pages/static
15
+
touch appview/pages/static/x
16
+
popd
17
+
'';
18
+
19
+
doCheck = false;
20
+
subPackages = ["cmd/dolly"];
21
+
}
+1
-1
nix/vm.nix
+1
-1
nix/vm.nix
+3
-3
readme.md
+3
-3
readme.md
···
10
11
## docs
12
13
+
- [knot hosting guide](https://docs.tangled.org/knot-self-hosting-guide.html#knot-self-hosting-guide)
14
+
- [contributing guide](https://docs.tangled.org/contribution-guide.html#contribution-guide) **please read before opening a PR!**
15
+
- [hacking on tangled](https://docs.tangled.org/hacking-on-tangled.html#hacking-on-tangled)
16
17
## security
18
+1
-1
spindle/motd
+1
-1
spindle/motd
+31
-13
spindle/server.go
+31
-13
spindle/server.go
···
8
"log/slog"
9
"maps"
10
"net/http"
11
12
"github.com/go-chi/chi/v5"
13
"tangled.org/core/api/tangled"
···
30
)
31
32
//go:embed motd
33
-
var motd []byte
34
35
const (
36
rbacDomain = "thisserver"
37
)
38
39
type Spindle struct {
40
-
jc *jetstream.JetstreamClient
41
-
db *db.DB
42
-
e *rbac.Enforcer
43
-
l *slog.Logger
44
-
n *notifier.Notifier
45
-
engs map[string]models.Engine
46
-
jq *queue.Queue
47
-
cfg *config.Config
48
-
ks *eventconsumer.Consumer
49
-
res *idresolver.Resolver
50
-
vault secrets.Manager
51
}
52
53
// New creates a new Spindle server with the provided configuration and engines.
···
128
cfg: cfg,
129
res: resolver,
130
vault: vault,
131
}
132
133
err = e.AddSpindle(rbacDomain)
···
201
return s.e
202
}
203
204
// Start starts the Spindle server (blocking).
205
func (s *Spindle) Start(ctx context.Context) error {
206
// starts a job queue runner in the background
···
246
mux := chi.NewRouter()
247
248
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
249
-
w.Write(motd)
250
})
251
mux.HandleFunc("/events", s.Events)
252
mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs)
···
8
"log/slog"
9
"maps"
10
"net/http"
11
+
"sync"
12
13
"github.com/go-chi/chi/v5"
14
"tangled.org/core/api/tangled"
···
31
)
32
33
//go:embed motd
34
+
var defaultMotd []byte
35
36
const (
37
rbacDomain = "thisserver"
38
)
39
40
type Spindle struct {
41
+
jc *jetstream.JetstreamClient
42
+
db *db.DB
43
+
e *rbac.Enforcer
44
+
l *slog.Logger
45
+
n *notifier.Notifier
46
+
engs map[string]models.Engine
47
+
jq *queue.Queue
48
+
cfg *config.Config
49
+
ks *eventconsumer.Consumer
50
+
res *idresolver.Resolver
51
+
vault secrets.Manager
52
+
motd []byte
53
+
motdMu sync.RWMutex
54
}
55
56
// New creates a new Spindle server with the provided configuration and engines.
···
131
cfg: cfg,
132
res: resolver,
133
vault: vault,
134
+
motd: defaultMotd,
135
}
136
137
err = e.AddSpindle(rbacDomain)
···
205
return s.e
206
}
207
208
+
// SetMotdContent sets custom MOTD content, replacing the embedded default.
209
+
func (s *Spindle) SetMotdContent(content []byte) {
210
+
s.motdMu.Lock()
211
+
defer s.motdMu.Unlock()
212
+
s.motd = content
213
+
}
214
+
215
+
// GetMotdContent returns the current MOTD content.
216
+
func (s *Spindle) GetMotdContent() []byte {
217
+
s.motdMu.RLock()
218
+
defer s.motdMu.RUnlock()
219
+
return s.motd
220
+
}
221
+
222
// Start starts the Spindle server (blocking).
223
func (s *Spindle) Start(ctx context.Context) error {
224
// starts a job queue runner in the background
···
264
mux := chi.NewRouter()
265
266
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
267
+
w.Write(s.GetMotdContent())
268
})
269
mux.HandleFunc("/events", s.Events)
270
mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs)
+3
types/diff.go
+3
types/diff.go
+112
types/diff_test.go
+112
types/diff_test.go
···
···
1
+
package types
2
+
3
+
import "testing"
4
+
5
+
func TestDiffId(t *testing.T) {
6
+
tests := []struct {
7
+
name string
8
+
diff Diff
9
+
expected string
10
+
}{
11
+
{
12
+
name: "regular file uses new name",
13
+
diff: Diff{
14
+
Name: struct {
15
+
Old string `json:"old"`
16
+
New string `json:"new"`
17
+
}{Old: "", New: "src/main.go"},
18
+
},
19
+
expected: "src/main.go",
20
+
},
21
+
{
22
+
name: "new file uses new name",
23
+
diff: Diff{
24
+
Name: struct {
25
+
Old string `json:"old"`
26
+
New string `json:"new"`
27
+
}{Old: "", New: "src/new.go"},
28
+
IsNew: true,
29
+
},
30
+
expected: "src/new.go",
31
+
},
32
+
{
33
+
name: "deleted file uses old name",
34
+
diff: Diff{
35
+
Name: struct {
36
+
Old string `json:"old"`
37
+
New string `json:"new"`
38
+
}{Old: "src/deleted.go", New: ""},
39
+
IsDelete: true,
40
+
},
41
+
expected: "src/deleted.go",
42
+
},
43
+
{
44
+
name: "renamed file uses new name",
45
+
diff: Diff{
46
+
Name: struct {
47
+
Old string `json:"old"`
48
+
New string `json:"new"`
49
+
}{Old: "src/old.go", New: "src/renamed.go"},
50
+
IsRename: true,
51
+
},
52
+
expected: "src/renamed.go",
53
+
},
54
+
}
55
+
56
+
for _, tt := range tests {
57
+
t.Run(tt.name, func(t *testing.T) {
58
+
if got := tt.diff.Id(); got != tt.expected {
59
+
t.Errorf("Diff.Id() = %q, want %q", got, tt.expected)
60
+
}
61
+
})
62
+
}
63
+
}
64
+
65
+
func TestChangedFilesMatchesDiffId(t *testing.T) {
66
+
// ChangedFiles() must return values matching each Diff's Id()
67
+
// so that sidebar links point to the correct anchors.
68
+
// Tests existing, deleted, new, and renamed files.
69
+
nd := NiceDiff{
70
+
Diff: []Diff{
71
+
{
72
+
Name: struct {
73
+
Old string `json:"old"`
74
+
New string `json:"new"`
75
+
}{Old: "", New: "src/modified.go"},
76
+
},
77
+
{
78
+
Name: struct {
79
+
Old string `json:"old"`
80
+
New string `json:"new"`
81
+
}{Old: "src/deleted.go", New: ""},
82
+
IsDelete: true,
83
+
},
84
+
{
85
+
Name: struct {
86
+
Old string `json:"old"`
87
+
New string `json:"new"`
88
+
}{Old: "", New: "src/new.go"},
89
+
IsNew: true,
90
+
},
91
+
{
92
+
Name: struct {
93
+
Old string `json:"old"`
94
+
New string `json:"new"`
95
+
}{Old: "src/old.go", New: "src/renamed.go"},
96
+
IsRename: true,
97
+
},
98
+
},
99
+
}
100
+
101
+
changedFiles := nd.ChangedFiles()
102
+
103
+
if len(changedFiles) != len(nd.Diff) {
104
+
t.Fatalf("ChangedFiles() returned %d items, want %d", len(changedFiles), len(nd.Diff))
105
+
}
106
+
107
+
for i, diff := range nd.Diff {
108
+
if changedFiles[i] != diff.Id() {
109
+
t.Errorf("ChangedFiles()[%d] = %q, but Diff.Id() = %q", i, changedFiles[i], diff.Id())
110
+
}
111
+
}
112
+
}