A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go

implement spdx license check for manifests, clean up generators

evan.jarrett.net 7cde02bf 1f72d907

verified
+1
.goreleaser.yaml
··· 6 6 before: 7 7 hooks: 8 8 - go mod tidy 9 + - go generate ./... 9 10 10 11 builds: 11 12 # Credential helper - cross-platform native binary distribution
+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 {
+2
pkg/appview/licenses/.gitignore
··· 1 + # Generated SPDX license data 2 + spdx-licenses.json
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
+2
pkg/atproto/lexicon.go
··· 1 1 package atproto 2 2 3 + //go:generate go run generate.go 4 + 3 5 import ( 4 6 "encoding/base64" 5 7 "encoding/json"