A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

implement spdx license check for manifests, clean up generators

+587 -15
+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"