+1
.goreleaser.yaml
+1
.goreleaser.yaml
+7
-2
gen/main.go
pkg/atproto/generate.go
+7
-2
gen/main.go
pkg/atproto/generate.go
···
1
+
//go:build ignore
2
+
// +build ignore
3
+
1
4
package main
2
5
3
6
// CBOR Code Generator
···
5
8
// This generates optimized CBOR marshaling code for ATProto records.
6
9
//
7
10
// Usage:
8
-
// go run gen/main.go
11
+
// go generate ./pkg/atproto/...
9
12
//
10
13
// This creates pkg/atproto/cbor_gen.go which should be committed to git.
11
14
// Only re-run when you modify types in pkg/atproto/types.go
15
+
//
16
+
// The //go:generate directive is in lexicon.go
12
17
13
18
import (
14
19
"fmt"
···
21
26
22
27
func main() {
23
28
// Generate map-style encoders for CrewRecord and CaptainRecord
24
-
if err := cbg.WriteMapEncodersToFile("pkg/atproto/cbor_gen.go", "atproto",
29
+
if err := cbg.WriteMapEncodersToFile("cbor_gen.go", "atproto",
25
30
atproto.CrewRecord{},
26
31
atproto.CaptainRecord{},
27
32
); err != nil {
+64
pkg/appview/licenses/integration_test.go
+64
pkg/appview/licenses/integration_test.go
···
1
+
package licenses_test
2
+
3
+
import (
4
+
"html/template"
5
+
"strings"
6
+
"testing"
7
+
8
+
"atcr.io/pkg/appview/licenses"
9
+
)
10
+
11
+
// Test template integration with parseLicenses
12
+
func TestTemplateIntegration(t *testing.T) {
13
+
funcMap := template.FuncMap{
14
+
"parseLicenses": func(licensesStr string) []licenses.LicenseInfo {
15
+
return licenses.ParseLicenses(licensesStr)
16
+
},
17
+
}
18
+
19
+
tmplStr := `{{ range parseLicenses . }}{{ if .IsValid }}[VALID:{{ .SPDXID }}:{{ .URL }}]{{ else }}[INVALID:{{ .Name }}]{{ end }}{{ end }}`
20
+
21
+
tmpl := template.Must(template.New("test").Funcs(funcMap).Parse(tmplStr))
22
+
23
+
tests := []struct {
24
+
name string
25
+
input string
26
+
wantText string
27
+
}{
28
+
{
29
+
name: "MIT license",
30
+
input: "MIT",
31
+
wantText: "[VALID:MIT:https://spdx.org/licenses/MIT.html]",
32
+
},
33
+
{
34
+
name: "Multiple licenses",
35
+
input: "MIT, Apache-2.0",
36
+
wantText: "[VALID:MIT:https://spdx.org/licenses/MIT.html][VALID:Apache-2.0:https://spdx.org/licenses/Apache-2.0.html]",
37
+
},
38
+
{
39
+
name: "Unknown license",
40
+
input: "CustomProprietary",
41
+
wantText: "[INVALID:CustomProprietary]",
42
+
},
43
+
{
44
+
name: "Mixed valid and invalid",
45
+
input: "MIT, CustomLicense, Apache-2.0",
46
+
wantText: "[VALID:MIT:https://spdx.org/licenses/MIT.html][INVALID:CustomLicense][VALID:Apache-2.0:https://spdx.org/licenses/Apache-2.0.html]",
47
+
},
48
+
}
49
+
50
+
for _, tt := range tests {
51
+
t.Run(tt.name, func(t *testing.T) {
52
+
var buf strings.Builder
53
+
err := tmpl.Execute(&buf, tt.input)
54
+
if err != nil {
55
+
t.Fatalf("Template execution failed: %v", err)
56
+
}
57
+
58
+
got := buf.String()
59
+
if got != tt.wantText {
60
+
t.Errorf("Template output mismatch:\nGot: %s\nWant: %s", got, tt.wantText)
61
+
}
62
+
})
63
+
}
64
+
}
+166
pkg/appview/licenses/licenses.go
+166
pkg/appview/licenses/licenses.go
···
1
+
package licenses
2
+
3
+
//go:generate curl -fsSL -o spdx-licenses.json https://spdx.org/licenses/licenses.json
4
+
5
+
import (
6
+
_ "embed"
7
+
"encoding/json"
8
+
"strings"
9
+
)
10
+
11
+
//go:embed spdx-licenses.json
12
+
var spdxLicensesJSON []byte
13
+
14
+
// SPDXLicense represents a license from the SPDX license list
15
+
type SPDXLicense struct {
16
+
LicenseID string `json:"licenseId"`
17
+
Name string `json:"name"`
18
+
Reference string `json:"reference"`
19
+
IsOsiApproved bool `json:"isOsiApproved"`
20
+
IsDeprecated bool `json:"isDeprecatedLicenseId"`
21
+
DetailsURL string `json:"detailsUrl"`
22
+
SeeAlso []string `json:"seeAlso"`
23
+
IsFsfLibre bool `json:"isFsfLibre,omitempty"`
24
+
}
25
+
26
+
// SPDXLicenseList represents the complete SPDX license list JSON structure
27
+
type SPDXLicenseList struct {
28
+
LicenseListVersion string `json:"licenseListVersion"`
29
+
Licenses []SPDXLicense `json:"licenses"`
30
+
ReleaseDate string `json:"releaseDate"`
31
+
}
32
+
33
+
// LicenseInfo represents parsed license information for template rendering
34
+
type LicenseInfo struct {
35
+
Name string // Original name from annotation
36
+
SPDXID string // Normalized SPDX identifier
37
+
URL string // Link to SPDX license page
38
+
IsValid bool // Whether this is a recognized SPDX license
39
+
}
40
+
41
+
var spdxLicenses map[string]SPDXLicense
42
+
var spdxLicenseListVersion string
43
+
44
+
// init parses the embedded SPDX license list JSON and builds a lookup map
45
+
func init() {
46
+
var list SPDXLicenseList
47
+
if err := json.Unmarshal(spdxLicensesJSON, &list); err != nil {
48
+
// If parsing fails, just use an empty map
49
+
spdxLicenses = make(map[string]SPDXLicense)
50
+
return
51
+
}
52
+
53
+
spdxLicenseListVersion = list.LicenseListVersion
54
+
55
+
// Build lookup map: licenseId -> SPDXLicense
56
+
spdxLicenses = make(map[string]SPDXLicense, len(list.Licenses))
57
+
for _, lic := range list.Licenses {
58
+
// Store with original ID
59
+
spdxLicenses[lic.LicenseID] = lic
60
+
61
+
// Also store normalized version (lowercase, no spaces/dashes)
62
+
normalized := normalizeID(lic.LicenseID)
63
+
spdxLicenses[normalized] = lic
64
+
}
65
+
}
66
+
67
+
// normalizeID converts a license ID to a normalized form for fuzzy matching
68
+
// Examples: "Apache-2.0" -> "apache20", "GPL-3.0-only" -> "gpl30only"
69
+
func normalizeID(id string) string {
70
+
id = strings.ToLower(id)
71
+
id = strings.ReplaceAll(id, "-", "")
72
+
id = strings.ReplaceAll(id, "_", "")
73
+
id = strings.ReplaceAll(id, ".", "")
74
+
id = strings.ReplaceAll(id, " ", "")
75
+
return id
76
+
}
77
+
78
+
// GetLicenseInfo looks up a license by SPDX ID with fuzzy matching
79
+
func GetLicenseInfo(licenseID string) (LicenseInfo, bool) {
80
+
// Try exact match first
81
+
if lic, ok := spdxLicenses[licenseID]; ok {
82
+
return LicenseInfo{
83
+
Name: lic.Name,
84
+
SPDXID: lic.LicenseID,
85
+
URL: lic.Reference,
86
+
IsValid: true,
87
+
}, true
88
+
}
89
+
90
+
// Try normalized match
91
+
normalized := normalizeID(licenseID)
92
+
if lic, ok := spdxLicenses[normalized]; ok {
93
+
return LicenseInfo{
94
+
Name: lic.Name,
95
+
SPDXID: lic.LicenseID,
96
+
URL: lic.Reference,
97
+
IsValid: true,
98
+
}, true
99
+
}
100
+
101
+
// Not found - return invalid license info
102
+
return LicenseInfo{
103
+
Name: licenseID,
104
+
SPDXID: licenseID,
105
+
URL: "",
106
+
IsValid: false,
107
+
}, false
108
+
}
109
+
110
+
// ParseLicenses parses a license string (possibly containing multiple licenses)
111
+
// and returns a slice of LicenseInfo structs.
112
+
//
113
+
// Supported separators: comma, semicolon, " AND ", " OR "
114
+
// Examples:
115
+
// - "MIT" -> [{MIT}]
116
+
// - "MIT, Apache-2.0" -> [{MIT}, {Apache-2.0}]
117
+
// - "MIT AND Apache-2.0" -> [{MIT}, {Apache-2.0}]
118
+
func ParseLicenses(licensesStr string) []LicenseInfo {
119
+
if licensesStr == "" {
120
+
return nil
121
+
}
122
+
123
+
// Split on various separators
124
+
licensesStr = strings.ReplaceAll(licensesStr, " AND ", ",")
125
+
licensesStr = strings.ReplaceAll(licensesStr, " OR ", ",")
126
+
licensesStr = strings.ReplaceAll(licensesStr, ";", ",")
127
+
128
+
parts := strings.Split(licensesStr, ",")
129
+
130
+
var result []LicenseInfo
131
+
seen := make(map[string]bool) // Deduplicate
132
+
133
+
for _, part := range parts {
134
+
part = strings.TrimSpace(part)
135
+
if part == "" {
136
+
continue
137
+
}
138
+
139
+
// Skip if we've already seen this license
140
+
if seen[part] {
141
+
continue
142
+
}
143
+
seen[part] = true
144
+
145
+
// Look up license info
146
+
info, found := GetLicenseInfo(part)
147
+
if !found {
148
+
// Unknown license - still include it as invalid
149
+
info = LicenseInfo{
150
+
Name: part,
151
+
SPDXID: part,
152
+
URL: "",
153
+
IsValid: false,
154
+
}
155
+
}
156
+
157
+
result = append(result, info)
158
+
}
159
+
160
+
return result
161
+
}
162
+
163
+
// GetVersion returns the SPDX License List version
164
+
func GetVersion() string {
165
+
return spdxLicenseListVersion
166
+
}
+125
pkg/appview/licenses/licenses_test.go
+125
pkg/appview/licenses/licenses_test.go
···
1
+
package licenses
2
+
3
+
import (
4
+
"testing"
5
+
)
6
+
7
+
func TestGetLicenseInfo(t *testing.T) {
8
+
tests := []struct {
9
+
name string
10
+
input string
11
+
wantValid bool
12
+
wantSPDX string
13
+
}{
14
+
{"MIT exact", "MIT", true, "MIT"},
15
+
{"Apache-2.0 exact", "Apache-2.0", true, "Apache-2.0"},
16
+
{"Apache 2.0 fuzzy", "Apache 2.0", true, "Apache-2.0"},
17
+
{"GPL-3.0 exact", "GPL-3.0-only", true, "GPL-3.0-only"},
18
+
{"Unknown license", "CustomProprietary", false, "CustomProprietary"},
19
+
{"BSD-3-Clause", "BSD-3-Clause", true, "BSD-3-Clause"},
20
+
}
21
+
22
+
for _, tt := range tests {
23
+
t.Run(tt.name, func(t *testing.T) {
24
+
info, found := GetLicenseInfo(tt.input)
25
+
26
+
if info.IsValid != tt.wantValid {
27
+
t.Errorf("GetLicenseInfo(%q).IsValid = %v, want %v", tt.input, info.IsValid, tt.wantValid)
28
+
}
29
+
30
+
if tt.wantValid && !found {
31
+
t.Errorf("GetLicenseInfo(%q) not found, want found", tt.input)
32
+
}
33
+
34
+
if info.SPDXID != tt.wantSPDX {
35
+
t.Errorf("GetLicenseInfo(%q).SPDXID = %q, want %q", tt.input, info.SPDXID, tt.wantSPDX)
36
+
}
37
+
38
+
if info.IsValid && info.URL == "" {
39
+
t.Errorf("GetLicenseInfo(%q).URL is empty for valid license", tt.input)
40
+
}
41
+
})
42
+
}
43
+
}
44
+
45
+
func TestParseLicenses(t *testing.T) {
46
+
tests := []struct {
47
+
name string
48
+
input string
49
+
wantCount int
50
+
wantFirst string
51
+
wantSecond string
52
+
}{
53
+
{"Single license", "MIT", 1, "MIT", ""},
54
+
{"Two licenses comma", "MIT, Apache-2.0", 2, "MIT", "Apache-2.0"},
55
+
{"Two licenses AND", "MIT AND Apache-2.0", 2, "MIT", "Apache-2.0"},
56
+
{"Three licenses", "MIT, Apache-2.0, GPL-3.0-only", 3, "MIT", "Apache-2.0"},
57
+
{"Empty string", "", 0, "", ""},
58
+
{"Whitespace", " MIT ", 1, "MIT", ""},
59
+
{"Duplicate licenses", "MIT, MIT, Apache-2.0", 2, "MIT", "Apache-2.0"},
60
+
{"Mixed separators", "MIT; Apache-2.0, BSD-3-Clause", 3, "MIT", "Apache-2.0"},
61
+
}
62
+
63
+
for _, tt := range tests {
64
+
t.Run(tt.name, func(t *testing.T) {
65
+
result := ParseLicenses(tt.input)
66
+
67
+
if len(result) != tt.wantCount {
68
+
t.Errorf("ParseLicenses(%q) returned %d licenses, want %d", tt.input, len(result), tt.wantCount)
69
+
}
70
+
71
+
if tt.wantCount > 0 && result[0].SPDXID != tt.wantFirst {
72
+
t.Errorf("ParseLicenses(%q)[0].SPDXID = %q, want %q", tt.input, result[0].SPDXID, tt.wantFirst)
73
+
}
74
+
75
+
if tt.wantCount > 1 && result[1].SPDXID != tt.wantSecond {
76
+
t.Errorf("ParseLicenses(%q)[1].SPDXID = %q, want %q", tt.input, result[1].SPDXID, tt.wantSecond)
77
+
}
78
+
})
79
+
}
80
+
}
81
+
82
+
func TestNormalizeID(t *testing.T) {
83
+
tests := []struct {
84
+
input string
85
+
want string
86
+
}{
87
+
{"MIT", "mit"},
88
+
{"Apache-2.0", "apache20"},
89
+
{"GPL-3.0-only", "gpl30only"},
90
+
{"BSD-3-Clause", "bsd3clause"},
91
+
{"CC-BY-4.0", "ccby40"},
92
+
}
93
+
94
+
for _, tt := range tests {
95
+
t.Run(tt.input, func(t *testing.T) {
96
+
got := normalizeID(tt.input)
97
+
if got != tt.want {
98
+
t.Errorf("normalizeID(%q) = %q, want %q", tt.input, got, tt.want)
99
+
}
100
+
})
101
+
}
102
+
}
103
+
104
+
func TestGetVersion(t *testing.T) {
105
+
version := GetVersion()
106
+
if version == "" {
107
+
t.Error("GetVersion() returned empty string")
108
+
}
109
+
t.Logf("SPDX License List version: %s", version)
110
+
}
111
+
112
+
func TestSPDXDataLoaded(t *testing.T) {
113
+
if len(spdxLicenses) == 0 {
114
+
t.Fatal("SPDX license data not loaded")
115
+
}
116
+
t.Logf("Loaded %d SPDX licenses", len(spdxLicenses))
117
+
118
+
// Verify some common licenses exist
119
+
commonLicenses := []string{"MIT", "Apache-2.0", "GPL-3.0-only", "BSD-3-Clause"}
120
+
for _, lic := range commonLicenses {
121
+
if _, ok := spdxLicenses[lic]; !ok {
122
+
t.Errorf("Common license %q not found in SPDX data", lic)
123
+
}
124
+
}
125
+
}
+134
pkg/appview/licenses/template_example_test.go
+134
pkg/appview/licenses/template_example_test.go
···
1
+
package licenses_test
2
+
3
+
import (
4
+
"html/template"
5
+
"strings"
6
+
"testing"
7
+
8
+
"atcr.io/pkg/appview/licenses"
9
+
)
10
+
11
+
// TestRepositoryPageTemplate demonstrates how the license badges will render
12
+
// in the actual repository.html template
13
+
func TestRepositoryPageTemplate(t *testing.T) {
14
+
funcMap := template.FuncMap{
15
+
"parseLicenses": func(licensesStr string) []licenses.LicenseInfo {
16
+
return licenses.ParseLicenses(licensesStr)
17
+
},
18
+
}
19
+
20
+
// This is the exact template structure from repository.html
21
+
tmplStr := `{{ if .Licenses }}` +
22
+
`{{ range parseLicenses .Licenses }}` +
23
+
`{{ if .IsValid }}` +
24
+
`<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" class="metadata-badge license-badge" title="{{ .Name }}">{{ .SPDXID }}</a>` +
25
+
`{{ else }}` +
26
+
`<span class="metadata-badge license-badge" title="Custom license: {{ .Name }}">{{ .Name }}</span>` +
27
+
`{{ end }}` +
28
+
`{{ end }}` +
29
+
`{{ end }}`
30
+
31
+
tmpl := template.Must(template.New("test").Funcs(funcMap).Parse(tmplStr))
32
+
33
+
tests := []struct {
34
+
name string
35
+
licenses string
36
+
wantContain []string
37
+
wantNotContain []string
38
+
}{
39
+
{
40
+
name: "MIT license",
41
+
licenses: "MIT",
42
+
wantContain: []string{
43
+
`<a href="https://spdx.org/licenses/MIT.html"`,
44
+
`class="metadata-badge license-badge"`,
45
+
`title="MIT License"`,
46
+
`>MIT</a>`,
47
+
},
48
+
},
49
+
{
50
+
name: "Multiple valid licenses",
51
+
licenses: "MIT, Apache-2.0, GPL-3.0-only",
52
+
wantContain: []string{
53
+
`https://spdx.org/licenses/MIT.html`,
54
+
`https://spdx.org/licenses/Apache-2.0.html`,
55
+
`https://spdx.org/licenses/GPL-3.0-only.html`,
56
+
`>MIT</a>`,
57
+
`>Apache-2.0</a>`,
58
+
`>GPL-3.0-only</a>`,
59
+
},
60
+
},
61
+
{
62
+
name: "Custom license",
63
+
licenses: "CustomProprietary",
64
+
wantContain: []string{
65
+
`<span class="metadata-badge license-badge"`,
66
+
`title="Custom license: CustomProprietary"`,
67
+
`>CustomProprietary</span>`,
68
+
},
69
+
wantNotContain: []string{
70
+
`<a href=`,
71
+
`https://spdx.org`,
72
+
},
73
+
},
74
+
{
75
+
name: "Mixed valid and custom",
76
+
licenses: "MIT, MyCustomLicense",
77
+
wantContain: []string{
78
+
// Valid license (MIT) should be a link
79
+
`<a href="https://spdx.org/licenses/MIT.html"`,
80
+
`>MIT</a>`,
81
+
// Custom license should be a span
82
+
`<span class="metadata-badge license-badge"`,
83
+
`title="Custom license: MyCustomLicense"`,
84
+
`>MyCustomLicense</span>`,
85
+
},
86
+
},
87
+
{
88
+
name: "Apache fuzzy match",
89
+
licenses: "Apache 2.0",
90
+
wantContain: []string{
91
+
`https://spdx.org/licenses/Apache-2.0.html`,
92
+
`>Apache-2.0</a>`,
93
+
},
94
+
},
95
+
{
96
+
name: "Empty licenses",
97
+
licenses: "",
98
+
wantNotContain: []string{
99
+
`<a `,
100
+
`<span`,
101
+
},
102
+
},
103
+
}
104
+
105
+
for _, tt := range tests {
106
+
t.Run(tt.name, func(t *testing.T) {
107
+
data := struct{ Licenses string }{Licenses: tt.licenses}
108
+
109
+
var buf strings.Builder
110
+
err := tmpl.Execute(&buf, data)
111
+
if err != nil {
112
+
t.Fatalf("Template execution failed: %v", err)
113
+
}
114
+
115
+
output := buf.String()
116
+
117
+
// Check for expected content
118
+
for _, want := range tt.wantContain {
119
+
if !strings.Contains(output, want) {
120
+
t.Errorf("Output missing expected content:\nWant: %s\nGot: %s", want, output)
121
+
}
122
+
}
123
+
124
+
// Check for unexpected content
125
+
for _, notWant := range tt.wantNotContain {
126
+
if strings.Contains(output, notWant) {
127
+
t.Errorf("Output contains unexpected content:\nDon't want: %s\nGot: %s", notWant, output)
128
+
}
129
+
}
130
+
131
+
t.Logf("Template output:\n%s", output)
132
+
})
133
+
}
134
+
}
+57
-9
pkg/appview/static/css/style.css
+57
-9
pkg/appview/static/css/style.css
···
338
338
background: var(--code-bg);
339
339
padding: 0.1rem 0.3rem;
340
340
border-radius: 3px;
341
+
max-width: 200px;
342
+
overflow: hidden;
343
+
text-overflow: ellipsis;
344
+
white-space: nowrap;
345
+
display: inline-block;
346
+
vertical-align: middle;
347
+
position: relative;
348
+
}
349
+
350
+
/* Digest with copy button container */
351
+
.digest-container {
352
+
display: inline-flex;
353
+
align-items: center;
354
+
gap: 0.5rem;
355
+
}
356
+
357
+
/* Digest tooltip on hover - using title attribute for native browser tooltip */
358
+
.digest {
359
+
cursor: default;
360
+
}
361
+
362
+
/* Digest copy button */
363
+
.digest-copy-btn {
364
+
background: transparent;
365
+
border: 1px solid var(--border);
366
+
color: var(--secondary);
367
+
padding: 0.1rem 0.4rem;
368
+
font-size: 0.75rem;
369
+
border-radius: 3px;
370
+
cursor: pointer;
371
+
transition: all 0.2s;
372
+
display: inline-flex;
373
+
align-items: center;
374
+
}
375
+
376
+
.digest-copy-btn:hover {
377
+
background: var(--hover-bg);
378
+
border-color: var(--primary);
379
+
color: var(--primary);
341
380
}
342
381
343
382
.separator {
···
491
530
border: 1px solid #90caf9;
492
531
}
493
532
533
+
/* Clickable license badges */
534
+
a.license-badge {
535
+
text-decoration: none;
536
+
cursor: pointer;
537
+
transition: all 0.2s ease;
538
+
}
539
+
540
+
a.license-badge:hover {
541
+
background: var(--primary);
542
+
color: var(--bg);
543
+
border-color: var(--primary);
544
+
transform: translateY(-1px);
545
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
546
+
}
547
+
494
548
.repo-description {
495
549
color: var(--border-dark);
496
550
font-size: 0.95rem;
···
559
613
color: var(--border-dark);
560
614
}
561
615
562
-
.tag-digest, .manifest-digest {
563
-
font-family: 'Monaco', 'Courier New', monospace;
564
-
font-size: 0.85rem;
565
-
background: var(--code-bg);
566
-
padding: 0.1rem 0.3rem;
567
-
border-radius: 3px;
568
-
}
616
+
/* Note: .tag-digest and .manifest-digest styling now handled by .digest class above */
569
617
570
618
/* Settings Page */
571
619
.settings-page {
···
813
861
814
862
/* Repository Page */
815
863
.repository-page {
816
-
max-width: 1000px;
864
+
/* Let container's max-width (1200px) control page width */
817
865
margin: 0 auto;
818
866
}
819
867
···
1643
1691
/* README and Repository Layout */
1644
1692
.repo-content-layout {
1645
1693
display: grid;
1646
-
grid-template-columns: 1fr 400px;
1694
+
grid-template-columns: 1fr 450px;
1647
1695
gap: 2rem;
1648
1696
margin-top: 2rem;
1649
1697
}
+19
-3
pkg/appview/templates/pages/repository.html
+19
-3
pkg/appview/templates/pages/repository.html
···
46
46
{{ if or .Repository.Licenses .Repository.SourceURL .Repository.DocumentationURL }}
47
47
<div class="repo-metadata">
48
48
{{ if .Repository.Licenses }}
49
-
<span class="metadata-badge license-badge">{{ .Repository.Licenses }}</span>
49
+
{{ range parseLicenses .Repository.Licenses }}
50
+
{{ if .IsValid }}
51
+
<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" class="metadata-badge license-badge" title="{{ .Name }}">
52
+
{{ .SPDXID }}
53
+
</a>
54
+
{{ else }}
55
+
<span class="metadata-badge license-badge" title="Custom license: {{ .Name }}">
56
+
{{ .Name }}
57
+
</span>
58
+
{{ end }}
59
+
{{ end }}
50
60
{{ end }}
51
61
{{ if .Repository.SourceURL }}
52
62
<a href="{{ .Repository.SourceURL }}" target="_blank" class="metadata-link">
···
129
139
</div>
130
140
<div class="tag-item-details">
131
141
<div style="display: flex; justify-content: space-between; align-items: center;">
132
-
<code class="digest">{{ .Tag.Digest }}</code>
142
+
<div class="digest-container">
143
+
<code class="digest" title="{{ .Tag.Digest }}">{{ .Tag.Digest }}</code>
144
+
<button class="digest-copy-btn" onclick="copyToClipboard('{{ .Tag.Digest }}')">📋</button>
145
+
</div>
133
146
{{ if .Platforms }}
134
147
<div class="platforms-inline">
135
148
{{ range .Platforms }}
···
183
196
{{ else if not .Reachable }}
184
197
<span class="offline-badge">⚠️ Offline</span>
185
198
{{ end }}
186
-
<code class="manifest-digest">{{ .Manifest.Digest }}</code>
199
+
<div class="digest-container">
200
+
<code class="digest manifest-digest" title="{{ .Manifest.Digest }}">{{ .Manifest.Digest }}</code>
201
+
<button class="digest-copy-btn" onclick="copyToClipboard('{{ .Manifest.Digest }}')">📋</button>
202
+
</div>
187
203
</div>
188
204
<div style="display: flex; gap: 1rem; align-items: center;">
189
205
<time datetime="{{ .Manifest.CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
+4
-1
pkg/appview/templates/partials/push-list.html
+4
-1
pkg/appview/templates/partials/push-list.html
···
33
33
</div>
34
34
35
35
<div class="push-details">
36
-
<code class="digest" title="{{ .Digest }}">{{ .Digest }}</code>
36
+
<div class="digest-container">
37
+
<code class="digest" title="{{ .Digest }}">{{ .Digest }}</code>
38
+
<button class="digest-copy-btn" onclick="copyToClipboard('{{ .Digest }}')">📋</button>
39
+
</div>
37
40
<span class="separator">•</span>
38
41
<time class="timestamp" datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
39
42
{{ timeAgo .CreatedAt }}
+6
pkg/appview/ui.go
+6
pkg/appview/ui.go
···
8
8
"net/http"
9
9
"strings"
10
10
"time"
11
+
12
+
"atcr.io/pkg/appview/licenses"
11
13
)
12
14
13
15
//go:embed templates/**/*.html
···
83
85
// Replace colons with dashes to make valid CSS selectors
84
86
// e.g., "sha256:abc123" becomes "sha256-abc123"
85
87
return strings.ReplaceAll(s, ":", "-")
88
+
},
89
+
90
+
"parseLicenses": func(licensesStr string) []licenses.LicenseInfo {
91
+
return licenses.ParseLicenses(licensesStr)
86
92
},
87
93
}
88
94