mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-31 13:17:09 +00:00
implement spdx license check for manifests, clean up generators
This commit is contained in:
@@ -6,6 +6,7 @@ version: 2
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
- go generate ./...
|
||||
|
||||
builds:
|
||||
# Credential helper - cross-platform native binary distribution
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Generated SPDX license data
|
||||
spdx-licenses.json
|
||||
@@ -0,0 +1,64 @@
|
||||
package licenses_test
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"atcr.io/pkg/appview/licenses"
|
||||
)
|
||||
|
||||
// Test template integration with parseLicenses
|
||||
func TestTemplateIntegration(t *testing.T) {
|
||||
funcMap := template.FuncMap{
|
||||
"parseLicenses": func(licensesStr string) []licenses.LicenseInfo {
|
||||
return licenses.ParseLicenses(licensesStr)
|
||||
},
|
||||
}
|
||||
|
||||
tmplStr := `{{ range parseLicenses . }}{{ if .IsValid }}[VALID:{{ .SPDXID }}:{{ .URL }}]{{ else }}[INVALID:{{ .Name }}]{{ end }}{{ end }}`
|
||||
|
||||
tmpl := template.Must(template.New("test").Funcs(funcMap).Parse(tmplStr))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantText string
|
||||
}{
|
||||
{
|
||||
name: "MIT license",
|
||||
input: "MIT",
|
||||
wantText: "[VALID:MIT:https://spdx.org/licenses/MIT.html]",
|
||||
},
|
||||
{
|
||||
name: "Multiple licenses",
|
||||
input: "MIT, Apache-2.0",
|
||||
wantText: "[VALID:MIT:https://spdx.org/licenses/MIT.html][VALID:Apache-2.0:https://spdx.org/licenses/Apache-2.0.html]",
|
||||
},
|
||||
{
|
||||
name: "Unknown license",
|
||||
input: "CustomProprietary",
|
||||
wantText: "[INVALID:CustomProprietary]",
|
||||
},
|
||||
{
|
||||
name: "Mixed valid and invalid",
|
||||
input: "MIT, CustomLicense, Apache-2.0",
|
||||
wantText: "[VALID:MIT:https://spdx.org/licenses/MIT.html][INVALID:CustomLicense][VALID:Apache-2.0:https://spdx.org/licenses/Apache-2.0.html]",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var buf strings.Builder
|
||||
err := tmpl.Execute(&buf, tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("Template execution failed: %v", err)
|
||||
}
|
||||
|
||||
got := buf.String()
|
||||
if got != tt.wantText {
|
||||
t.Errorf("Template output mismatch:\nGot: %s\nWant: %s", got, tt.wantText)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package licenses
|
||||
|
||||
//go:generate curl -fsSL -o spdx-licenses.json https://spdx.org/licenses/licenses.json
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed spdx-licenses.json
|
||||
var spdxLicensesJSON []byte
|
||||
|
||||
// SPDXLicense represents a license from the SPDX license list
|
||||
type SPDXLicense struct {
|
||||
LicenseID string `json:"licenseId"`
|
||||
Name string `json:"name"`
|
||||
Reference string `json:"reference"`
|
||||
IsOsiApproved bool `json:"isOsiApproved"`
|
||||
IsDeprecated bool `json:"isDeprecatedLicenseId"`
|
||||
DetailsURL string `json:"detailsUrl"`
|
||||
SeeAlso []string `json:"seeAlso"`
|
||||
IsFsfLibre bool `json:"isFsfLibre,omitempty"`
|
||||
}
|
||||
|
||||
// SPDXLicenseList represents the complete SPDX license list JSON structure
|
||||
type SPDXLicenseList struct {
|
||||
LicenseListVersion string `json:"licenseListVersion"`
|
||||
Licenses []SPDXLicense `json:"licenses"`
|
||||
ReleaseDate string `json:"releaseDate"`
|
||||
}
|
||||
|
||||
// LicenseInfo represents parsed license information for template rendering
|
||||
type LicenseInfo struct {
|
||||
Name string // Original name from annotation
|
||||
SPDXID string // Normalized SPDX identifier
|
||||
URL string // Link to SPDX license page
|
||||
IsValid bool // Whether this is a recognized SPDX license
|
||||
}
|
||||
|
||||
var spdxLicenses map[string]SPDXLicense
|
||||
var spdxLicenseListVersion string
|
||||
|
||||
// init parses the embedded SPDX license list JSON and builds a lookup map
|
||||
func init() {
|
||||
var list SPDXLicenseList
|
||||
if err := json.Unmarshal(spdxLicensesJSON, &list); err != nil {
|
||||
// If parsing fails, just use an empty map
|
||||
spdxLicenses = make(map[string]SPDXLicense)
|
||||
return
|
||||
}
|
||||
|
||||
spdxLicenseListVersion = list.LicenseListVersion
|
||||
|
||||
// Build lookup map: licenseId -> SPDXLicense
|
||||
spdxLicenses = make(map[string]SPDXLicense, len(list.Licenses))
|
||||
for _, lic := range list.Licenses {
|
||||
// Store with original ID
|
||||
spdxLicenses[lic.LicenseID] = lic
|
||||
|
||||
// Also store normalized version (lowercase, no spaces/dashes)
|
||||
normalized := normalizeID(lic.LicenseID)
|
||||
spdxLicenses[normalized] = lic
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeID converts a license ID to a normalized form for fuzzy matching
|
||||
// Examples: "Apache-2.0" -> "apache20", "GPL-3.0-only" -> "gpl30only"
|
||||
func normalizeID(id string) string {
|
||||
id = strings.ToLower(id)
|
||||
id = strings.ReplaceAll(id, "-", "")
|
||||
id = strings.ReplaceAll(id, "_", "")
|
||||
id = strings.ReplaceAll(id, ".", "")
|
||||
id = strings.ReplaceAll(id, " ", "")
|
||||
return id
|
||||
}
|
||||
|
||||
// GetLicenseInfo looks up a license by SPDX ID with fuzzy matching
|
||||
func GetLicenseInfo(licenseID string) (LicenseInfo, bool) {
|
||||
// Try exact match first
|
||||
if lic, ok := spdxLicenses[licenseID]; ok {
|
||||
return LicenseInfo{
|
||||
Name: lic.Name,
|
||||
SPDXID: lic.LicenseID,
|
||||
URL: lic.Reference,
|
||||
IsValid: true,
|
||||
}, true
|
||||
}
|
||||
|
||||
// Try normalized match
|
||||
normalized := normalizeID(licenseID)
|
||||
if lic, ok := spdxLicenses[normalized]; ok {
|
||||
return LicenseInfo{
|
||||
Name: lic.Name,
|
||||
SPDXID: lic.LicenseID,
|
||||
URL: lic.Reference,
|
||||
IsValid: true,
|
||||
}, true
|
||||
}
|
||||
|
||||
// Not found - return invalid license info
|
||||
return LicenseInfo{
|
||||
Name: licenseID,
|
||||
SPDXID: licenseID,
|
||||
URL: "",
|
||||
IsValid: false,
|
||||
}, false
|
||||
}
|
||||
|
||||
// ParseLicenses parses a license string (possibly containing multiple licenses)
|
||||
// and returns a slice of LicenseInfo structs.
|
||||
//
|
||||
// Supported separators: comma, semicolon, " AND ", " OR "
|
||||
// Examples:
|
||||
// - "MIT" -> [{MIT}]
|
||||
// - "MIT, Apache-2.0" -> [{MIT}, {Apache-2.0}]
|
||||
// - "MIT AND Apache-2.0" -> [{MIT}, {Apache-2.0}]
|
||||
func ParseLicenses(licensesStr string) []LicenseInfo {
|
||||
if licensesStr == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Split on various separators
|
||||
licensesStr = strings.ReplaceAll(licensesStr, " AND ", ",")
|
||||
licensesStr = strings.ReplaceAll(licensesStr, " OR ", ",")
|
||||
licensesStr = strings.ReplaceAll(licensesStr, ";", ",")
|
||||
|
||||
parts := strings.Split(licensesStr, ",")
|
||||
|
||||
var result []LicenseInfo
|
||||
seen := make(map[string]bool) // Deduplicate
|
||||
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if we've already seen this license
|
||||
if seen[part] {
|
||||
continue
|
||||
}
|
||||
seen[part] = true
|
||||
|
||||
// Look up license info
|
||||
info, found := GetLicenseInfo(part)
|
||||
if !found {
|
||||
// Unknown license - still include it as invalid
|
||||
info = LicenseInfo{
|
||||
Name: part,
|
||||
SPDXID: part,
|
||||
URL: "",
|
||||
IsValid: false,
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, info)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GetVersion returns the SPDX License List version
|
||||
func GetVersion() string {
|
||||
return spdxLicenseListVersion
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package licenses
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetLicenseInfo(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantValid bool
|
||||
wantSPDX string
|
||||
}{
|
||||
{"MIT exact", "MIT", true, "MIT"},
|
||||
{"Apache-2.0 exact", "Apache-2.0", true, "Apache-2.0"},
|
||||
{"Apache 2.0 fuzzy", "Apache 2.0", true, "Apache-2.0"},
|
||||
{"GPL-3.0 exact", "GPL-3.0-only", true, "GPL-3.0-only"},
|
||||
{"Unknown license", "CustomProprietary", false, "CustomProprietary"},
|
||||
{"BSD-3-Clause", "BSD-3-Clause", true, "BSD-3-Clause"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
info, found := GetLicenseInfo(tt.input)
|
||||
|
||||
if info.IsValid != tt.wantValid {
|
||||
t.Errorf("GetLicenseInfo(%q).IsValid = %v, want %v", tt.input, info.IsValid, tt.wantValid)
|
||||
}
|
||||
|
||||
if tt.wantValid && !found {
|
||||
t.Errorf("GetLicenseInfo(%q) not found, want found", tt.input)
|
||||
}
|
||||
|
||||
if info.SPDXID != tt.wantSPDX {
|
||||
t.Errorf("GetLicenseInfo(%q).SPDXID = %q, want %q", tt.input, info.SPDXID, tt.wantSPDX)
|
||||
}
|
||||
|
||||
if info.IsValid && info.URL == "" {
|
||||
t.Errorf("GetLicenseInfo(%q).URL is empty for valid license", tt.input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLicenses(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantCount int
|
||||
wantFirst string
|
||||
wantSecond string
|
||||
}{
|
||||
{"Single license", "MIT", 1, "MIT", ""},
|
||||
{"Two licenses comma", "MIT, Apache-2.0", 2, "MIT", "Apache-2.0"},
|
||||
{"Two licenses AND", "MIT AND Apache-2.0", 2, "MIT", "Apache-2.0"},
|
||||
{"Three licenses", "MIT, Apache-2.0, GPL-3.0-only", 3, "MIT", "Apache-2.0"},
|
||||
{"Empty string", "", 0, "", ""},
|
||||
{"Whitespace", " MIT ", 1, "MIT", ""},
|
||||
{"Duplicate licenses", "MIT, MIT, Apache-2.0", 2, "MIT", "Apache-2.0"},
|
||||
{"Mixed separators", "MIT; Apache-2.0, BSD-3-Clause", 3, "MIT", "Apache-2.0"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ParseLicenses(tt.input)
|
||||
|
||||
if len(result) != tt.wantCount {
|
||||
t.Errorf("ParseLicenses(%q) returned %d licenses, want %d", tt.input, len(result), tt.wantCount)
|
||||
}
|
||||
|
||||
if tt.wantCount > 0 && result[0].SPDXID != tt.wantFirst {
|
||||
t.Errorf("ParseLicenses(%q)[0].SPDXID = %q, want %q", tt.input, result[0].SPDXID, tt.wantFirst)
|
||||
}
|
||||
|
||||
if tt.wantCount > 1 && result[1].SPDXID != tt.wantSecond {
|
||||
t.Errorf("ParseLicenses(%q)[1].SPDXID = %q, want %q", tt.input, result[1].SPDXID, tt.wantSecond)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeID(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"MIT", "mit"},
|
||||
{"Apache-2.0", "apache20"},
|
||||
{"GPL-3.0-only", "gpl30only"},
|
||||
{"BSD-3-Clause", "bsd3clause"},
|
||||
{"CC-BY-4.0", "ccby40"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := normalizeID(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("normalizeID(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetVersion(t *testing.T) {
|
||||
version := GetVersion()
|
||||
if version == "" {
|
||||
t.Error("GetVersion() returned empty string")
|
||||
}
|
||||
t.Logf("SPDX License List version: %s", version)
|
||||
}
|
||||
|
||||
func TestSPDXDataLoaded(t *testing.T) {
|
||||
if len(spdxLicenses) == 0 {
|
||||
t.Fatal("SPDX license data not loaded")
|
||||
}
|
||||
t.Logf("Loaded %d SPDX licenses", len(spdxLicenses))
|
||||
|
||||
// Verify some common licenses exist
|
||||
commonLicenses := []string{"MIT", "Apache-2.0", "GPL-3.0-only", "BSD-3-Clause"}
|
||||
for _, lic := range commonLicenses {
|
||||
if _, ok := spdxLicenses[lic]; !ok {
|
||||
t.Errorf("Common license %q not found in SPDX data", lic)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package licenses_test
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"atcr.io/pkg/appview/licenses"
|
||||
)
|
||||
|
||||
// TestRepositoryPageTemplate demonstrates how the license badges will render
|
||||
// in the actual repository.html template
|
||||
func TestRepositoryPageTemplate(t *testing.T) {
|
||||
funcMap := template.FuncMap{
|
||||
"parseLicenses": func(licensesStr string) []licenses.LicenseInfo {
|
||||
return licenses.ParseLicenses(licensesStr)
|
||||
},
|
||||
}
|
||||
|
||||
// This is the exact template structure from repository.html
|
||||
tmplStr := `{{ if .Licenses }}` +
|
||||
`{{ range parseLicenses .Licenses }}` +
|
||||
`{{ if .IsValid }}` +
|
||||
`<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" class="metadata-badge license-badge" title="{{ .Name }}">{{ .SPDXID }}</a>` +
|
||||
`{{ else }}` +
|
||||
`<span class="metadata-badge license-badge" title="Custom license: {{ .Name }}">{{ .Name }}</span>` +
|
||||
`{{ end }}` +
|
||||
`{{ end }}` +
|
||||
`{{ end }}`
|
||||
|
||||
tmpl := template.Must(template.New("test").Funcs(funcMap).Parse(tmplStr))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
licenses string
|
||||
wantContain []string
|
||||
wantNotContain []string
|
||||
}{
|
||||
{
|
||||
name: "MIT license",
|
||||
licenses: "MIT",
|
||||
wantContain: []string{
|
||||
`<a href="https://spdx.org/licenses/MIT.html"`,
|
||||
`class="metadata-badge license-badge"`,
|
||||
`title="MIT License"`,
|
||||
`>MIT</a>`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Multiple valid licenses",
|
||||
licenses: "MIT, Apache-2.0, GPL-3.0-only",
|
||||
wantContain: []string{
|
||||
`https://spdx.org/licenses/MIT.html`,
|
||||
`https://spdx.org/licenses/Apache-2.0.html`,
|
||||
`https://spdx.org/licenses/GPL-3.0-only.html`,
|
||||
`>MIT</a>`,
|
||||
`>Apache-2.0</a>`,
|
||||
`>GPL-3.0-only</a>`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Custom license",
|
||||
licenses: "CustomProprietary",
|
||||
wantContain: []string{
|
||||
`<span class="metadata-badge license-badge"`,
|
||||
`title="Custom license: CustomProprietary"`,
|
||||
`>CustomProprietary</span>`,
|
||||
},
|
||||
wantNotContain: []string{
|
||||
`<a href=`,
|
||||
`https://spdx.org`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Mixed valid and custom",
|
||||
licenses: "MIT, MyCustomLicense",
|
||||
wantContain: []string{
|
||||
// Valid license (MIT) should be a link
|
||||
`<a href="https://spdx.org/licenses/MIT.html"`,
|
||||
`>MIT</a>`,
|
||||
// Custom license should be a span
|
||||
`<span class="metadata-badge license-badge"`,
|
||||
`title="Custom license: MyCustomLicense"`,
|
||||
`>MyCustomLicense</span>`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Apache fuzzy match",
|
||||
licenses: "Apache 2.0",
|
||||
wantContain: []string{
|
||||
`https://spdx.org/licenses/Apache-2.0.html`,
|
||||
`>Apache-2.0</a>`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Empty licenses",
|
||||
licenses: "",
|
||||
wantNotContain: []string{
|
||||
`<a `,
|
||||
`<span`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
data := struct{ Licenses string }{Licenses: tt.licenses}
|
||||
|
||||
var buf strings.Builder
|
||||
err := tmpl.Execute(&buf, data)
|
||||
if err != nil {
|
||||
t.Fatalf("Template execution failed: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
|
||||
// Check for expected content
|
||||
for _, want := range tt.wantContain {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Errorf("Output missing expected content:\nWant: %s\nGot: %s", want, output)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for unexpected content
|
||||
for _, notWant := range tt.wantNotContain {
|
||||
if strings.Contains(output, notWant) {
|
||||
t.Errorf("Output contains unexpected content:\nDon't want: %s\nGot: %s", notWant, output)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Template output:\n%s", output)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -338,6 +338,45 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover {
|
||||
background: var(--code-bg);
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Digest with copy button container */
|
||||
.digest-container {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Digest tooltip on hover - using title attribute for native browser tooltip */
|
||||
.digest {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Digest copy button */
|
||||
.digest-copy-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--secondary);
|
||||
padding: 0.1rem 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.digest-copy-btn:hover {
|
||||
background: var(--hover-bg);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.separator {
|
||||
@@ -491,6 +530,21 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover {
|
||||
border: 1px solid #90caf9;
|
||||
}
|
||||
|
||||
/* Clickable license badges */
|
||||
a.license-badge {
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
a.license-badge:hover {
|
||||
background: var(--primary);
|
||||
color: var(--bg);
|
||||
border-color: var(--primary);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.repo-description {
|
||||
color: var(--border-dark);
|
||||
font-size: 0.95rem;
|
||||
@@ -559,13 +613,7 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover {
|
||||
color: var(--border-dark);
|
||||
}
|
||||
|
||||
.tag-digest, .manifest-digest {
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
font-size: 0.85rem;
|
||||
background: var(--code-bg);
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
/* Note: .tag-digest and .manifest-digest styling now handled by .digest class above */
|
||||
|
||||
/* Settings Page */
|
||||
.settings-page {
|
||||
@@ -813,7 +861,7 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover {
|
||||
|
||||
/* Repository Page */
|
||||
.repository-page {
|
||||
max-width: 1000px;
|
||||
/* Let container's max-width (1200px) control page width */
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@@ -1643,7 +1691,7 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover {
|
||||
/* README and Repository Layout */
|
||||
.repo-content-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
grid-template-columns: 1fr 450px;
|
||||
gap: 2rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,17 @@
|
||||
{{ if or .Repository.Licenses .Repository.SourceURL .Repository.DocumentationURL }}
|
||||
<div class="repo-metadata">
|
||||
{{ if .Repository.Licenses }}
|
||||
<span class="metadata-badge license-badge">{{ .Repository.Licenses }}</span>
|
||||
{{ range parseLicenses .Repository.Licenses }}
|
||||
{{ if .IsValid }}
|
||||
<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" class="metadata-badge license-badge" title="{{ .Name }}">
|
||||
{{ .SPDXID }}
|
||||
</a>
|
||||
{{ else }}
|
||||
<span class="metadata-badge license-badge" title="Custom license: {{ .Name }}">
|
||||
{{ .Name }}
|
||||
</span>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ if .Repository.SourceURL }}
|
||||
<a href="{{ .Repository.SourceURL }}" target="_blank" class="metadata-link">
|
||||
@@ -129,7 +139,10 @@
|
||||
</div>
|
||||
<div class="tag-item-details">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<code class="digest">{{ .Tag.Digest }}</code>
|
||||
<div class="digest-container">
|
||||
<code class="digest" title="{{ .Tag.Digest }}">{{ .Tag.Digest }}</code>
|
||||
<button class="digest-copy-btn" onclick="copyToClipboard('{{ .Tag.Digest }}')">📋</button>
|
||||
</div>
|
||||
{{ if .Platforms }}
|
||||
<div class="platforms-inline">
|
||||
{{ range .Platforms }}
|
||||
@@ -183,7 +196,10 @@
|
||||
{{ else if not .Reachable }}
|
||||
<span class="offline-badge">⚠️ Offline</span>
|
||||
{{ end }}
|
||||
<code class="manifest-digest">{{ .Manifest.Digest }}</code>
|
||||
<div class="digest-container">
|
||||
<code class="digest manifest-digest" title="{{ .Manifest.Digest }}">{{ .Manifest.Digest }}</code>
|
||||
<button class="digest-copy-btn" onclick="copyToClipboard('{{ .Manifest.Digest }}')">📋</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 1rem; align-items: center;">
|
||||
<time datetime="{{ .Manifest.CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
|
||||
|
||||
@@ -33,7 +33,10 @@
|
||||
</div>
|
||||
|
||||
<div class="push-details">
|
||||
<code class="digest" title="{{ .Digest }}">{{ .Digest }}</code>
|
||||
<div class="digest-container">
|
||||
<code class="digest" title="{{ .Digest }}">{{ .Digest }}</code>
|
||||
<button class="digest-copy-btn" onclick="copyToClipboard('{{ .Digest }}')">📋</button>
|
||||
</div>
|
||||
<span class="separator">•</span>
|
||||
<time class="timestamp" datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
|
||||
{{ timeAgo .CreatedAt }}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/appview/licenses"
|
||||
)
|
||||
|
||||
//go:embed templates/**/*.html
|
||||
@@ -84,6 +86,10 @@ func Templates() (*template.Template, error) {
|
||||
// e.g., "sha256:abc123" becomes "sha256-abc123"
|
||||
return strings.ReplaceAll(s, ":", "-")
|
||||
},
|
||||
|
||||
"parseLicenses": func(licensesStr string) []licenses.LicenseInfo {
|
||||
return licenses.ParseLicenses(licensesStr)
|
||||
},
|
||||
}
|
||||
|
||||
tmpl := template.New("").Funcs(funcMap)
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//go:build ignore
|
||||
// +build ignore
|
||||
|
||||
package main
|
||||
|
||||
// CBOR Code Generator
|
||||
@@ -5,10 +8,12 @@ package main
|
||||
// This generates optimized CBOR marshaling code for ATProto records.
|
||||
//
|
||||
// Usage:
|
||||
// go run gen/main.go
|
||||
// go generate ./pkg/atproto/...
|
||||
//
|
||||
// This creates pkg/atproto/cbor_gen.go which should be committed to git.
|
||||
// Only re-run when you modify types in pkg/atproto/types.go
|
||||
//
|
||||
// The //go:generate directive is in lexicon.go
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -21,7 +26,7 @@ import (
|
||||
|
||||
func main() {
|
||||
// Generate map-style encoders for CrewRecord and CaptainRecord
|
||||
if err := cbg.WriteMapEncodersToFile("pkg/atproto/cbor_gen.go", "atproto",
|
||||
if err := cbg.WriteMapEncodersToFile("cbor_gen.go", "atproto",
|
||||
atproto.CrewRecord{},
|
||||
atproto.CaptainRecord{},
|
||||
); err != nil {
|
||||
@@ -1,5 +1,7 @@
|
||||
package atproto
|
||||
|
||||
//go:generate go run generate.go
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
|
||||
Reference in New Issue
Block a user