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 before: 7 hooks: 8 - go mod tidy 9 10 builds: 11 # Credential helper - cross-platform native binary distribution
··· 6 before: 7 hooks: 8 - go mod tidy 9 + - go generate ./... 10 11 builds: 12 # Credential helper - cross-platform native binary distribution
+7 -2
gen/main.go pkg/atproto/generate.go
··· 1 package main 2 3 // CBOR Code Generator ··· 5 // This generates optimized CBOR marshaling code for ATProto records. 6 // 7 // Usage: 8 - // go run gen/main.go 9 // 10 // This creates pkg/atproto/cbor_gen.go which should be committed to git. 11 // Only re-run when you modify types in pkg/atproto/types.go 12 13 import ( 14 "fmt" ··· 21 22 func main() { 23 // Generate map-style encoders for CrewRecord and CaptainRecord 24 - if err := cbg.WriteMapEncodersToFile("pkg/atproto/cbor_gen.go", "atproto", 25 atproto.CrewRecord{}, 26 atproto.CaptainRecord{}, 27 ); err != nil {
··· 1 + //go:build ignore 2 + // +build ignore 3 + 4 package main 5 6 // CBOR Code Generator ··· 8 // This generates optimized CBOR marshaling code for ATProto records. 9 // 10 // Usage: 11 + // go generate ./pkg/atproto/... 12 // 13 // This creates pkg/atproto/cbor_gen.go which should be committed to git. 14 // Only re-run when you modify types in pkg/atproto/types.go 15 + // 16 + // The //go:generate directive is in lexicon.go 17 18 import ( 19 "fmt" ··· 26 27 func main() { 28 // Generate map-style encoders for CrewRecord and CaptainRecord 29 + if err := cbg.WriteMapEncodersToFile("cbor_gen.go", "atproto", 30 atproto.CrewRecord{}, 31 atproto.CaptainRecord{}, 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 background: var(--code-bg); 339 padding: 0.1rem 0.3rem; 340 border-radius: 3px; 341 } 342 343 .separator { ··· 491 border: 1px solid #90caf9; 492 } 493 494 .repo-description { 495 color: var(--border-dark); 496 font-size: 0.95rem; ··· 559 color: var(--border-dark); 560 } 561 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 - } 569 570 /* Settings Page */ 571 .settings-page { ··· 813 814 /* Repository Page */ 815 .repository-page { 816 - max-width: 1000px; 817 margin: 0 auto; 818 } 819 ··· 1643 /* README and Repository Layout */ 1644 .repo-content-layout { 1645 display: grid; 1646 - grid-template-columns: 1fr 400px; 1647 gap: 2rem; 1648 margin-top: 2rem; 1649 }
··· 338 background: var(--code-bg); 339 padding: 0.1rem 0.3rem; 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); 380 } 381 382 .separator { ··· 530 border: 1px solid #90caf9; 531 } 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 + 548 .repo-description { 549 color: var(--border-dark); 550 font-size: 0.95rem; ··· 613 color: var(--border-dark); 614 } 615 616 + /* Note: .tag-digest and .manifest-digest styling now handled by .digest class above */ 617 618 /* Settings Page */ 619 .settings-page { ··· 861 862 /* Repository Page */ 863 .repository-page { 864 + /* Let container's max-width (1200px) control page width */ 865 margin: 0 auto; 866 } 867 ··· 1691 /* README and Repository Layout */ 1692 .repo-content-layout { 1693 display: grid; 1694 + grid-template-columns: 1fr 450px; 1695 gap: 2rem; 1696 margin-top: 2rem; 1697 }
+19 -3
pkg/appview/templates/pages/repository.html
··· 46 {{ if or .Repository.Licenses .Repository.SourceURL .Repository.DocumentationURL }} 47 <div class="repo-metadata"> 48 {{ if .Repository.Licenses }} 49 - <span class="metadata-badge license-badge">{{ .Repository.Licenses }}</span> 50 {{ end }} 51 {{ if .Repository.SourceURL }} 52 <a href="{{ .Repository.SourceURL }}" target="_blank" class="metadata-link"> ··· 129 </div> 130 <div class="tag-item-details"> 131 <div style="display: flex; justify-content: space-between; align-items: center;"> 132 - <code class="digest">{{ .Tag.Digest }}</code> 133 {{ if .Platforms }} 134 <div class="platforms-inline"> 135 {{ range .Platforms }} ··· 183 {{ else if not .Reachable }} 184 <span class="offline-badge">⚠️ Offline</span> 185 {{ end }} 186 - <code class="manifest-digest">{{ .Manifest.Digest }}</code> 187 </div> 188 <div style="display: flex; gap: 1rem; align-items: center;"> 189 <time datetime="{{ .Manifest.CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
··· 46 {{ if or .Repository.Licenses .Repository.SourceURL .Repository.DocumentationURL }} 47 <div class="repo-metadata"> 48 {{ if .Repository.Licenses }} 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 }} 60 {{ end }} 61 {{ if .Repository.SourceURL }} 62 <a href="{{ .Repository.SourceURL }}" target="_blank" class="metadata-link"> ··· 139 </div> 140 <div class="tag-item-details"> 141 <div style="display: flex; justify-content: space-between; align-items: center;"> 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> 146 {{ if .Platforms }} 147 <div class="platforms-inline"> 148 {{ range .Platforms }} ··· 196 {{ else if not .Reachable }} 197 <span class="offline-badge">⚠️ Offline</span> 198 {{ end }} 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> 203 </div> 204 <div style="display: flex; gap: 1rem; align-items: center;"> 205 <time datetime="{{ .Manifest.CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
+4 -1
pkg/appview/templates/partials/push-list.html
··· 33 </div> 34 35 <div class="push-details"> 36 - <code class="digest" title="{{ .Digest }}">{{ .Digest }}</code> 37 <span class="separator">•</span> 38 <time class="timestamp" datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}"> 39 {{ timeAgo .CreatedAt }}
··· 33 </div> 34 35 <div class="push-details"> 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> 40 <span class="separator">•</span> 41 <time class="timestamp" datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}"> 42 {{ timeAgo .CreatedAt }}
+6
pkg/appview/ui.go
··· 8 "net/http" 9 "strings" 10 "time" 11 ) 12 13 //go:embed templates/**/*.html ··· 83 // Replace colons with dashes to make valid CSS selectors 84 // e.g., "sha256:abc123" becomes "sha256-abc123" 85 return strings.ReplaceAll(s, ":", "-") 86 }, 87 } 88
··· 8 "net/http" 9 "strings" 10 "time" 11 + 12 + "atcr.io/pkg/appview/licenses" 13 ) 14 15 //go:embed templates/**/*.html ··· 85 // Replace colons with dashes to make valid CSS selectors 86 // e.g., "sha256:abc123" becomes "sha256-abc123" 87 return strings.ReplaceAll(s, ":", "-") 88 + }, 89 + 90 + "parseLicenses": func(licensesStr string) []licenses.LicenseInfo { 91 + return licenses.ParseLicenses(licensesStr) 92 }, 93 } 94
+2
pkg/atproto/lexicon.go
··· 1 package atproto 2 3 import ( 4 "encoding/base64" 5 "encoding/json"
··· 1 package atproto 2 3 + //go:generate go run generate.go 4 + 5 import ( 6 "encoding/base64" 7 "encoding/json"