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

Configure Feed

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

i don't think i can make this website any faster...

+1096 -156
+7
pkg/appview/handlers/auth.go
··· 17 17 returnTo = "/" 18 18 } 19 19 20 + meta := NewPageMeta( 21 + "Login - ATCR", 22 + "Sign in to ATCR with your AT Protocol account to push and pull container images", 23 + ).WithCanonical("https://" + h.RegistryURL + "/login") 24 + 20 25 data := struct { 21 26 PageData 27 + Meta *PageMeta 22 28 ReturnTo string 23 29 Error string 24 30 }{ 25 31 PageData: NewPageData(r, h.RegistryURL), 32 + Meta: meta, 26 33 ReturnTo: returnTo, 27 34 Error: r.URL.Query().Get("error"), 28 35 }
+7
pkg/appview/handlers/errors.go
··· 19 19 func RenderNotFound(w http.ResponseWriter, r *http.Request, templates *template.Template, registryURL string) { 20 20 w.WriteHeader(http.StatusNotFound) 21 21 22 + meta := NewPageMeta( 23 + "404 - Lost at Sea | ATCR", 24 + "Page not found - the requested resource doesn't exist on ATCR", 25 + ).WithRobots("noindex") 26 + 22 27 data := struct { 23 28 PageData 29 + Meta *PageMeta 24 30 }{ 25 31 PageData: NewPageData(r, registryURL), 32 + Meta: meta, 26 33 } 27 34 28 35 if err := templates.ExecuteTemplate(w, "404", data); err != nil {
+12 -1
pkg/appview/handlers/home.go
··· 41 41 42 42 data := struct { 43 43 PageData 44 + Meta *PageMeta 44 45 FeaturedRepos []db.RepoCardData 45 46 RecentRepos []db.RepoCardData 46 47 }{ 47 - PageData: NewPageData(r, h.RegistryURL), 48 + PageData: NewPageData(r, h.RegistryURL), 49 + Meta: NewPageMeta( 50 + "ATCR - Distributed Container Registry", 51 + "Push and pull Docker images on the AT Protocol. Same Docker, decentralized.", 52 + ). 53 + WithCanonical("https://"+h.RegistryURL+"/"). 54 + WithOGImage("https://"+h.RegistryURL+"/og/home"). 55 + WithJSONLD( 56 + NewJSONLDOrganization(h.RegistryURL), 57 + NewJSONLDWebSite(h.RegistryURL), 58 + ), 48 59 FeaturedRepos: featuredCards, 49 60 RecentRepos: recentCards, 50 61 }
+7
pkg/appview/handlers/install.go
··· 10 10 } 11 11 12 12 func (h *InstallHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 13 + meta := NewPageMeta( 14 + "Install ATCR Credential Helper - ATCR", 15 + "Install the ATCR credential helper to push and pull containers using your AT Protocol identity", 16 + ).WithCanonical("https://" + h.RegistryURL + "/install") 17 + 13 18 data := struct { 14 19 PageData 20 + Meta *PageMeta 15 21 }{ 16 22 PageData: NewPageData(r, h.RegistryURL), 23 + Meta: meta, 17 24 } 18 25 19 26 if err := h.Templates.ExecuteTemplate(w, "install", data); err != nil {
+152
pkg/appview/handlers/jsonld.go
··· 1 + package handlers 2 + 3 + // JSON-LD structured data types for rich results in search engines. 4 + // These are marshaled to JSON and embedded in <script type="application/ld+json"> tags. 5 + 6 + // JSONLDOrganization represents a schema.org Organization. 7 + type JSONLDOrganization struct { 8 + Context string `json:"@context"` 9 + Type string `json:"@type"` 10 + Name string `json:"name"` 11 + AlternateName string `json:"alternateName,omitempty"` 12 + URL string `json:"url"` 13 + Logo string `json:"logo,omitempty"` 14 + Description string `json:"description,omitempty"` 15 + SameAs []string `json:"sameAs,omitempty"` 16 + } 17 + 18 + // JSONLDWebSite represents a schema.org WebSite with search action. 19 + type JSONLDWebSite struct { 20 + Context string `json:"@context"` 21 + Type string `json:"@type"` 22 + Name string `json:"name"` 23 + URL string `json:"url"` 24 + PotentialAction *JSONLDSearchAction `json:"potentialAction,omitempty"` 25 + } 26 + 27 + // JSONLDSearchAction represents a schema.org SearchAction. 28 + type JSONLDSearchAction struct { 29 + Type string `json:"@type"` 30 + Target *JSONLDEntryPoint `json:"target"` 31 + QueryInput string `json:"query-input"` 32 + } 33 + 34 + // JSONLDEntryPoint represents a schema.org EntryPoint for search. 35 + type JSONLDEntryPoint struct { 36 + Type string `json:"@type"` 37 + URLTemplate string `json:"urlTemplate"` 38 + } 39 + 40 + // JSONLDSoftwareSourceCode represents a schema.org SoftwareSourceCode (for repositories). 41 + type JSONLDSoftwareSourceCode struct { 42 + Context string `json:"@context"` 43 + Type string `json:"@type"` 44 + Name string `json:"name"` 45 + Description string `json:"description"` 46 + CodeRepository string `json:"codeRepository"` 47 + Author *JSONLDPerson `json:"author,omitempty"` 48 + Publisher *JSONLDOrg `json:"publisher,omitempty"` 49 + License string `json:"license,omitempty"` 50 + IsBasedOn string `json:"isBasedOn,omitempty"` 51 + } 52 + 53 + // JSONLDProfilePage represents a schema.org ProfilePage. 54 + type JSONLDProfilePage struct { 55 + Context string `json:"@context"` 56 + Type string `json:"@type"` 57 + MainEntity *JSONLDPerson `json:"mainEntity"` 58 + } 59 + 60 + // JSONLDPerson represents a schema.org Person. 61 + type JSONLDPerson struct { 62 + Type string `json:"@type"` 63 + Name string `json:"name"` 64 + URL string `json:"url,omitempty"` 65 + Image string `json:"image,omitempty"` 66 + } 67 + 68 + // JSONLDOrg represents a schema.org Organization (minimal version for embedding). 69 + type JSONLDOrg struct { 70 + Type string `json:"@type"` 71 + Name string `json:"name"` 72 + URL string `json:"url,omitempty"` 73 + } 74 + 75 + // Helper constructors for common JSON-LD objects 76 + 77 + // NewJSONLDOrganization creates an Organization object for ATCR. 78 + func NewJSONLDOrganization(registryURL string) JSONLDOrganization { 79 + return JSONLDOrganization{ 80 + Context: "https://schema.org", 81 + Type: "Organization", 82 + Name: "ATCR", 83 + AlternateName: "AT Protocol Container Registry", 84 + URL: "https://" + registryURL, 85 + Logo: "https://" + registryURL + "/favicon.svg", 86 + Description: "Decentralized container registry using AT Protocol. Push and pull Docker images with your AT Protocol identity.", 87 + SameAs: []string{}, 88 + } 89 + } 90 + 91 + // NewJSONLDWebSite creates a WebSite object with search action. 92 + func NewJSONLDWebSite(registryURL string) JSONLDWebSite { 93 + return JSONLDWebSite{ 94 + Context: "https://schema.org", 95 + Type: "WebSite", 96 + Name: "ATCR", 97 + URL: "https://" + registryURL, 98 + PotentialAction: &JSONLDSearchAction{ 99 + Type: "SearchAction", 100 + Target: &JSONLDEntryPoint{ 101 + Type: "EntryPoint", 102 + URLTemplate: "https://" + registryURL + "/search?q={search_term_string}", 103 + }, 104 + QueryInput: "required name=search_term_string", 105 + }, 106 + } 107 + } 108 + 109 + // NewJSONLDSoftwareSourceCode creates a SoftwareSourceCode object for a repository. 110 + func NewJSONLDSoftwareSourceCode(registryURL, handle, repoName, description, license, sourceURL string) JSONLDSoftwareSourceCode { 111 + code := JSONLDSoftwareSourceCode{ 112 + Context: "https://schema.org", 113 + Type: "SoftwareSourceCode", 114 + Name: handle + "/" + repoName, 115 + Description: description, 116 + CodeRepository: "https://" + registryURL + "/r/" + handle + "/" + repoName, 117 + Author: &JSONLDPerson{ 118 + Type: "Person", 119 + Name: handle, 120 + URL: "https://" + registryURL + "/u/" + handle, 121 + }, 122 + Publisher: &JSONLDOrg{ 123 + Type: "Organization", 124 + Name: "ATCR", 125 + URL: "https://" + registryURL, 126 + }, 127 + } 128 + if license != "" { 129 + code.License = license 130 + } 131 + if sourceURL != "" { 132 + code.IsBasedOn = sourceURL 133 + } 134 + return code 135 + } 136 + 137 + // NewJSONLDProfilePage creates a ProfilePage object for a user. 138 + func NewJSONLDProfilePage(registryURL, handle, avatar string) JSONLDProfilePage { 139 + person := &JSONLDPerson{ 140 + Type: "Person", 141 + Name: handle, 142 + URL: "https://" + registryURL + "/u/" + handle, 143 + } 144 + if avatar != "" { 145 + person.Image = avatar 146 + } 147 + return JSONLDProfilePage{ 148 + Context: "https://schema.org", 149 + Type: "ProfilePage", 150 + MainEntity: person, 151 + } 152 + }
+310
pkg/appview/handlers/jsonld_test.go
··· 1 + package handlers 2 + 3 + import ( 4 + "encoding/json" 5 + "testing" 6 + ) 7 + 8 + func TestNewJSONLDOrganization(t *testing.T) { 9 + registryURL := "atcr.io" 10 + org := NewJSONLDOrganization(registryURL) 11 + 12 + // Verify required fields 13 + if org.Context != "https://schema.org" { 14 + t.Errorf("Context = %q, want %q", org.Context, "https://schema.org") 15 + } 16 + if org.Type != "Organization" { 17 + t.Errorf("Type = %q, want %q", org.Type, "Organization") 18 + } 19 + if org.Name != "ATCR" { 20 + t.Errorf("Name = %q, want %q", org.Name, "ATCR") 21 + } 22 + if org.URL != "https://atcr.io" { 23 + t.Errorf("URL = %q, want %q", org.URL, "https://atcr.io") 24 + } 25 + if org.Logo != "https://atcr.io/favicon.svg" { 26 + t.Errorf("Logo = %q, want %q", org.Logo, "https://atcr.io/favicon.svg") 27 + } 28 + 29 + // Verify it marshals to valid JSON 30 + data, err := json.Marshal(org) 31 + if err != nil { 32 + t.Fatalf("Failed to marshal organization: %v", err) 33 + } 34 + if len(data) == 0 { 35 + t.Error("Marshaled JSON is empty") 36 + } 37 + } 38 + 39 + func TestNewJSONLDWebSite(t *testing.T) { 40 + registryURL := "atcr.io" 41 + site := NewJSONLDWebSite(registryURL) 42 + 43 + // Verify required fields 44 + if site.Context != "https://schema.org" { 45 + t.Errorf("Context = %q, want %q", site.Context, "https://schema.org") 46 + } 47 + if site.Type != "WebSite" { 48 + t.Errorf("Type = %q, want %q", site.Type, "WebSite") 49 + } 50 + if site.Name != "ATCR" { 51 + t.Errorf("Name = %q, want %q", site.Name, "ATCR") 52 + } 53 + if site.URL != "https://atcr.io" { 54 + t.Errorf("URL = %q, want %q", site.URL, "https://atcr.io") 55 + } 56 + 57 + // Verify search action 58 + if site.PotentialAction == nil { 59 + t.Fatal("PotentialAction is nil") 60 + } 61 + if site.PotentialAction.Type != "SearchAction" { 62 + t.Errorf("PotentialAction.Type = %q, want %q", site.PotentialAction.Type, "SearchAction") 63 + } 64 + if site.PotentialAction.Target == nil { 65 + t.Fatal("PotentialAction.Target is nil") 66 + } 67 + expectedTemplate := "https://atcr.io/search?q={search_term_string}" 68 + if site.PotentialAction.Target.URLTemplate != expectedTemplate { 69 + t.Errorf("URLTemplate = %q, want %q", site.PotentialAction.Target.URLTemplate, expectedTemplate) 70 + } 71 + 72 + // Verify it marshals to valid JSON 73 + data, err := json.Marshal(site) 74 + if err != nil { 75 + t.Fatalf("Failed to marshal website: %v", err) 76 + } 77 + if len(data) == 0 { 78 + t.Error("Marshaled JSON is empty") 79 + } 80 + } 81 + 82 + func TestNewJSONLDSoftwareSourceCode(t *testing.T) { 83 + tests := []struct { 84 + name string 85 + registryURL string 86 + handle string 87 + repoName string 88 + description string 89 + license string 90 + sourceURL string 91 + }{ 92 + { 93 + name: "full details", 94 + registryURL: "atcr.io", 95 + handle: "alice.bsky.social", 96 + repoName: "myapp", 97 + description: "A cool container image", 98 + license: "MIT", 99 + sourceURL: "https://github.com/alice/myapp", 100 + }, 101 + { 102 + name: "minimal details", 103 + registryURL: "atcr.io", 104 + handle: "bob.test", 105 + repoName: "simple", 106 + description: "", 107 + license: "", 108 + sourceURL: "", 109 + }, 110 + { 111 + name: "with license only", 112 + registryURL: "localhost:5000", 113 + handle: "dev", 114 + repoName: "test-image", 115 + description: "Test image", 116 + license: "Apache-2.0", 117 + sourceURL: "", 118 + }, 119 + } 120 + 121 + for _, tt := range tests { 122 + t.Run(tt.name, func(t *testing.T) { 123 + code := NewJSONLDSoftwareSourceCode( 124 + tt.registryURL, 125 + tt.handle, 126 + tt.repoName, 127 + tt.description, 128 + tt.license, 129 + tt.sourceURL, 130 + ) 131 + 132 + // Verify required fields 133 + if code.Context != "https://schema.org" { 134 + t.Errorf("Context = %q, want %q", code.Context, "https://schema.org") 135 + } 136 + if code.Type != "SoftwareSourceCode" { 137 + t.Errorf("Type = %q, want %q", code.Type, "SoftwareSourceCode") 138 + } 139 + 140 + expectedName := tt.handle + "/" + tt.repoName 141 + if code.Name != expectedName { 142 + t.Errorf("Name = %q, want %q", code.Name, expectedName) 143 + } 144 + 145 + expectedCodeRepo := "https://" + tt.registryURL + "/r/" + tt.handle + "/" + tt.repoName 146 + if code.CodeRepository != expectedCodeRepo { 147 + t.Errorf("CodeRepository = %q, want %q", code.CodeRepository, expectedCodeRepo) 148 + } 149 + 150 + // Verify author 151 + if code.Author == nil { 152 + t.Fatal("Author is nil") 153 + } 154 + if code.Author.Type != "Person" { 155 + t.Errorf("Author.Type = %q, want %q", code.Author.Type, "Person") 156 + } 157 + if code.Author.Name != tt.handle { 158 + t.Errorf("Author.Name = %q, want %q", code.Author.Name, tt.handle) 159 + } 160 + 161 + // Verify publisher 162 + if code.Publisher == nil { 163 + t.Fatal("Publisher is nil") 164 + } 165 + if code.Publisher.Name != "ATCR" { 166 + t.Errorf("Publisher.Name = %q, want %q", code.Publisher.Name, "ATCR") 167 + } 168 + 169 + // Verify optional fields 170 + if tt.license != "" && code.License != tt.license { 171 + t.Errorf("License = %q, want %q", code.License, tt.license) 172 + } 173 + if tt.license == "" && code.License != "" { 174 + t.Errorf("License = %q, want empty", code.License) 175 + } 176 + 177 + if tt.sourceURL != "" && code.IsBasedOn != tt.sourceURL { 178 + t.Errorf("IsBasedOn = %q, want %q", code.IsBasedOn, tt.sourceURL) 179 + } 180 + if tt.sourceURL == "" && code.IsBasedOn != "" { 181 + t.Errorf("IsBasedOn = %q, want empty", code.IsBasedOn) 182 + } 183 + 184 + // Verify it marshals to valid JSON 185 + data, err := json.Marshal(code) 186 + if err != nil { 187 + t.Fatalf("Failed to marshal code: %v", err) 188 + } 189 + if len(data) == 0 { 190 + t.Error("Marshaled JSON is empty") 191 + } 192 + }) 193 + } 194 + } 195 + 196 + func TestNewJSONLDProfilePage(t *testing.T) { 197 + tests := []struct { 198 + name string 199 + registryURL string 200 + handle string 201 + avatar string 202 + }{ 203 + { 204 + name: "with avatar", 205 + registryURL: "atcr.io", 206 + handle: "alice.bsky.social", 207 + avatar: "https://cdn.bsky.app/avatar/alice.jpg", 208 + }, 209 + { 210 + name: "without avatar", 211 + registryURL: "atcr.io", 212 + handle: "bob.test", 213 + avatar: "", 214 + }, 215 + { 216 + name: "localhost registry", 217 + registryURL: "localhost:5000", 218 + handle: "dev", 219 + avatar: "", 220 + }, 221 + } 222 + 223 + for _, tt := range tests { 224 + t.Run(tt.name, func(t *testing.T) { 225 + page := NewJSONLDProfilePage(tt.registryURL, tt.handle, tt.avatar) 226 + 227 + // Verify required fields 228 + if page.Context != "https://schema.org" { 229 + t.Errorf("Context = %q, want %q", page.Context, "https://schema.org") 230 + } 231 + if page.Type != "ProfilePage" { 232 + t.Errorf("Type = %q, want %q", page.Type, "ProfilePage") 233 + } 234 + 235 + // Verify main entity 236 + if page.MainEntity == nil { 237 + t.Fatal("MainEntity is nil") 238 + } 239 + if page.MainEntity.Type != "Person" { 240 + t.Errorf("MainEntity.Type = %q, want %q", page.MainEntity.Type, "Person") 241 + } 242 + if page.MainEntity.Name != tt.handle { 243 + t.Errorf("MainEntity.Name = %q, want %q", page.MainEntity.Name, tt.handle) 244 + } 245 + 246 + expectedURL := "https://" + tt.registryURL + "/u/" + tt.handle 247 + if page.MainEntity.URL != expectedURL { 248 + t.Errorf("MainEntity.URL = %q, want %q", page.MainEntity.URL, expectedURL) 249 + } 250 + 251 + // Verify avatar handling 252 + if tt.avatar != "" && page.MainEntity.Image != tt.avatar { 253 + t.Errorf("MainEntity.Image = %q, want %q", page.MainEntity.Image, tt.avatar) 254 + } 255 + if tt.avatar == "" && page.MainEntity.Image != "" { 256 + t.Errorf("MainEntity.Image = %q, want empty", page.MainEntity.Image) 257 + } 258 + 259 + // Verify it marshals to valid JSON 260 + data, err := json.Marshal(page) 261 + if err != nil { 262 + t.Fatalf("Failed to marshal page: %v", err) 263 + } 264 + if len(data) == 0 { 265 + t.Error("Marshaled JSON is empty") 266 + } 267 + }) 268 + } 269 + } 270 + 271 + func TestJSONLD_ValidJSON(t *testing.T) { 272 + // Test that all constructors produce valid JSON that can be unmarshaled 273 + registryURL := "atcr.io" 274 + 275 + testCases := []struct { 276 + name string 277 + data any 278 + }{ 279 + {"Organization", NewJSONLDOrganization(registryURL)}, 280 + {"WebSite", NewJSONLDWebSite(registryURL)}, 281 + {"SoftwareSourceCode", NewJSONLDSoftwareSourceCode(registryURL, "alice", "myapp", "desc", "MIT", "https://github.com/alice/myapp")}, 282 + {"ProfilePage", NewJSONLDProfilePage(registryURL, "alice", "https://example.com/avatar.jpg")}, 283 + } 284 + 285 + for _, tc := range testCases { 286 + t.Run(tc.name, func(t *testing.T) { 287 + // Marshal to JSON 288 + data, err := json.MarshalIndent(tc.data, "", " ") 289 + if err != nil { 290 + t.Fatalf("Failed to marshal %s: %v", tc.name, err) 291 + } 292 + 293 + // Unmarshal back to verify it's valid JSON 294 + var result map[string]any 295 + if err := json.Unmarshal(data, &result); err != nil { 296 + t.Fatalf("Failed to unmarshal %s: %v\nJSON: %s", tc.name, err, string(data)) 297 + } 298 + 299 + // Verify @context is present 300 + if _, ok := result["@context"]; !ok { 301 + t.Errorf("%s missing @context field", tc.name) 302 + } 303 + 304 + // Verify @type is present 305 + if _, ok := result["@type"]; !ok { 306 + t.Errorf("%s missing @type field", tc.name) 307 + } 308 + }) 309 + } 310 + }
+7
pkg/appview/handlers/learn_more.go
··· 10 10 } 11 11 12 12 func (h *LearnMoreHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 13 + meta := NewPageMeta( 14 + "About ATCR - Decentralized Container Registry on AT Protocol", 15 + "Learn how ATCR brings Docker container registries to the decentralized web using AT Protocol. Own your data, use your identity.", 16 + ).WithCanonical("https://" + h.RegistryURL + "/learn-more") 17 + 13 18 data := struct { 14 19 PageData 20 + Meta *PageMeta 15 21 }{ 16 22 PageData: NewPageData(r, h.RegistryURL), 23 + Meta: meta, 17 24 } 18 25 19 26 if err := h.Templates.ExecuteTemplate(w, "learn-more", data); err != nil {
+13
pkg/appview/handlers/legal.go
··· 7 7 // LegalPageData contains data for legal pages (terms, privacy) 8 8 type LegalPageData struct { 9 9 PageData 10 + Meta *PageMeta 10 11 CompanyName string 11 12 Jurisdiction string 12 13 } ··· 17 18 } 18 19 19 20 func (h *PrivacyPolicyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 21 + meta := NewPageMeta( 22 + "Privacy Policy - ATCR", 23 + "ATCR privacy policy - how we collect, use, and protect your data on the decentralized container registry", 24 + ).WithCanonical("https://" + h.RegistryURL + "/privacy") 25 + 20 26 data := LegalPageData{ 21 27 PageData: NewPageData(r, h.RegistryURL), 28 + Meta: meta, 22 29 CompanyName: h.CompanyName, 23 30 Jurisdiction: h.Jurisdiction, 24 31 } ··· 35 42 } 36 43 37 44 func (h *TermsOfServiceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 45 + meta := NewPageMeta( 46 + "Terms of Service - ATCR", 47 + "ATCR terms of service - rules and guidelines for using the decentralized container registry", 48 + ).WithCanonical("https://" + h.RegistryURL + "/terms") 49 + 38 50 data := LegalPageData{ 39 51 PageData: NewPageData(r, h.RegistryURL), 52 + Meta: meta, 40 53 CompanyName: h.CompanyName, 41 54 Jurisdiction: h.Jurisdiction, 42 55 }
+54
pkg/appview/handlers/meta.go
··· 1 + package handlers 2 + 3 + // PageMeta holds all metadata for a page's <head> section. 4 + // Use the builder methods to construct it with a fluent API. 5 + type PageMeta struct { 6 + Title string // Page title (required) 7 + Description string // Meta description (required) 8 + Canonical string // Canonical URL (optional) 9 + Robots string // Robots directive, e.g. "noindex" (optional, defaults to "index, follow") 10 + OGType string // OpenGraph type, defaults to "website" 11 + OGImage string // OpenGraph image URL (optional) 12 + TwitterCard string // Twitter card type, defaults to "summary_large_image" 13 + JSONLD []any // JSON-LD structured data objects (optional) 14 + } 15 + 16 + // NewPageMeta creates a new PageMeta with required fields and sensible defaults. 17 + func NewPageMeta(title, description string) *PageMeta { 18 + return &PageMeta{ 19 + Title: title, 20 + Description: description, 21 + OGType: "website", 22 + TwitterCard: "summary_large_image", 23 + } 24 + } 25 + 26 + // WithCanonical sets the canonical URL. 27 + func (m *PageMeta) WithCanonical(url string) *PageMeta { 28 + m.Canonical = url 29 + return m 30 + } 31 + 32 + // WithOGImage sets the OpenGraph image URL. 33 + func (m *PageMeta) WithOGImage(url string) *PageMeta { 34 + m.OGImage = url 35 + return m 36 + } 37 + 38 + // WithOGType sets the OpenGraph type (e.g., "website", "profile", "article"). 39 + func (m *PageMeta) WithOGType(ogType string) *PageMeta { 40 + m.OGType = ogType 41 + return m 42 + } 43 + 44 + // WithRobots sets the robots meta directive (e.g., "noindex"). 45 + func (m *PageMeta) WithRobots(robots string) *PageMeta { 46 + m.Robots = robots 47 + return m 48 + } 49 + 50 + // WithJSONLD sets the JSON-LD structured data objects. 51 + func (m *PageMeta) WithJSONLD(data ...any) *PageMeta { 52 + m.JSONLD = data 53 + return m 54 + }
+237
pkg/appview/handlers/meta_test.go
··· 1 + package handlers 2 + 3 + import ( 4 + "testing" 5 + ) 6 + 7 + func TestNewPageMeta(t *testing.T) { 8 + title := "Test Page" 9 + description := "A test description" 10 + 11 + meta := NewPageMeta(title, description) 12 + 13 + if meta.Title != title { 14 + t.Errorf("Title = %q, want %q", meta.Title, title) 15 + } 16 + if meta.Description != description { 17 + t.Errorf("Description = %q, want %q", meta.Description, description) 18 + } 19 + if meta.OGType != "website" { 20 + t.Errorf("OGType = %q, want %q", meta.OGType, "website") 21 + } 22 + if meta.TwitterCard != "summary_large_image" { 23 + t.Errorf("TwitterCard = %q, want %q", meta.TwitterCard, "summary_large_image") 24 + } 25 + // Optional fields should be empty 26 + if meta.Canonical != "" { 27 + t.Errorf("Canonical = %q, want empty", meta.Canonical) 28 + } 29 + if meta.Robots != "" { 30 + t.Errorf("Robots = %q, want empty", meta.Robots) 31 + } 32 + if meta.OGImage != "" { 33 + t.Errorf("OGImage = %q, want empty", meta.OGImage) 34 + } 35 + if meta.JSONLD != nil { 36 + t.Errorf("JSONLD = %v, want nil", meta.JSONLD) 37 + } 38 + } 39 + 40 + func TestPageMeta_WithCanonical(t *testing.T) { 41 + meta := NewPageMeta("Title", "Desc") 42 + canonical := "https://atcr.io/page" 43 + 44 + result := meta.WithCanonical(canonical) 45 + 46 + // Should return same pointer for chaining 47 + if result != meta { 48 + t.Error("WithCanonical should return same pointer") 49 + } 50 + if meta.Canonical != canonical { 51 + t.Errorf("Canonical = %q, want %q", meta.Canonical, canonical) 52 + } 53 + } 54 + 55 + func TestPageMeta_WithOGImage(t *testing.T) { 56 + meta := NewPageMeta("Title", "Desc") 57 + image := "https://atcr.io/og-image.png" 58 + 59 + result := meta.WithOGImage(image) 60 + 61 + if result != meta { 62 + t.Error("WithOGImage should return same pointer") 63 + } 64 + if meta.OGImage != image { 65 + t.Errorf("OGImage = %q, want %q", meta.OGImage, image) 66 + } 67 + } 68 + 69 + func TestPageMeta_WithOGType(t *testing.T) { 70 + meta := NewPageMeta("Title", "Desc") 71 + 72 + result := meta.WithOGType("profile") 73 + 74 + if result != meta { 75 + t.Error("WithOGType should return same pointer") 76 + } 77 + if meta.OGType != "profile" { 78 + t.Errorf("OGType = %q, want %q", meta.OGType, "profile") 79 + } 80 + } 81 + 82 + func TestPageMeta_WithRobots(t *testing.T) { 83 + meta := NewPageMeta("Title", "Desc") 84 + 85 + result := meta.WithRobots("noindex, nofollow") 86 + 87 + if result != meta { 88 + t.Error("WithRobots should return same pointer") 89 + } 90 + if meta.Robots != "noindex, nofollow" { 91 + t.Errorf("Robots = %q, want %q", meta.Robots, "noindex, nofollow") 92 + } 93 + } 94 + 95 + func TestPageMeta_WithJSONLD(t *testing.T) { 96 + meta := NewPageMeta("Title", "Desc") 97 + 98 + org := NewJSONLDOrganization("atcr.io") 99 + site := NewJSONLDWebSite("atcr.io") 100 + 101 + result := meta.WithJSONLD(org, site) 102 + 103 + if result != meta { 104 + t.Error("WithJSONLD should return same pointer") 105 + } 106 + if len(meta.JSONLD) != 2 { 107 + t.Errorf("JSONLD len = %d, want 2", len(meta.JSONLD)) 108 + } 109 + } 110 + 111 + func TestPageMeta_Chaining(t *testing.T) { 112 + // Test that all methods can be chained fluently 113 + meta := NewPageMeta("ATCR - Container Registry", "Decentralized container registry"). 114 + WithCanonical("https://atcr.io/"). 115 + WithOGImage("https://atcr.io/og.png"). 116 + WithOGType("website"). 117 + WithRobots("index, follow"). 118 + WithJSONLD(NewJSONLDOrganization("atcr.io")) 119 + 120 + if meta.Title != "ATCR - Container Registry" { 121 + t.Errorf("Title not set correctly after chaining") 122 + } 123 + if meta.Canonical != "https://atcr.io/" { 124 + t.Errorf("Canonical not set correctly after chaining") 125 + } 126 + if meta.OGImage != "https://atcr.io/og.png" { 127 + t.Errorf("OGImage not set correctly after chaining") 128 + } 129 + if meta.OGType != "website" { 130 + t.Errorf("OGType not set correctly after chaining") 131 + } 132 + if meta.Robots != "index, follow" { 133 + t.Errorf("Robots not set correctly after chaining") 134 + } 135 + if len(meta.JSONLD) != 1 { 136 + t.Errorf("JSONLD not set correctly after chaining") 137 + } 138 + } 139 + 140 + func TestPageMeta_EmptyJSONLD(t *testing.T) { 141 + meta := NewPageMeta("Title", "Desc") 142 + 143 + // Calling WithJSONLD with no args sets nil slice (variadic behavior) 144 + result := meta.WithJSONLD() 145 + 146 + if result != meta { 147 + t.Error("WithJSONLD should return same pointer") 148 + } 149 + // Variadic with no args produces nil slice, which is fine 150 + // len(nil slice) == 0, so templates handle it correctly 151 + if len(meta.JSONLD) != 0 { 152 + t.Errorf("JSONLD len = %d, want 0", len(meta.JSONLD)) 153 + } 154 + } 155 + 156 + func TestPageMeta_RealWorldExamples(t *testing.T) { 157 + tests := []struct { 158 + name string 159 + builder func() *PageMeta 160 + wantTitle string 161 + wantOGType string 162 + wantJSONLen int 163 + }{ 164 + { 165 + name: "home page", 166 + builder: func() *PageMeta { 167 + return NewPageMeta( 168 + "ATCR - Decentralized Container Registry", 169 + "Push and pull Docker images with your AT Protocol identity", 170 + ). 171 + WithCanonical("https://atcr.io/"). 172 + WithJSONLD( 173 + NewJSONLDOrganization("atcr.io"), 174 + NewJSONLDWebSite("atcr.io"), 175 + ) 176 + }, 177 + wantTitle: "ATCR - Decentralized Container Registry", 178 + wantOGType: "website", 179 + wantJSONLen: 2, 180 + }, 181 + { 182 + name: "user profile", 183 + builder: func() *PageMeta { 184 + return NewPageMeta( 185 + "alice.bsky.social - ATCR", 186 + "Container images by alice.bsky.social", 187 + ). 188 + WithOGType("profile"). 189 + WithOGImage("https://cdn.bsky.app/avatar.jpg"). 190 + WithJSONLD(NewJSONLDProfilePage("atcr.io", "alice.bsky.social", "https://cdn.bsky.app/avatar.jpg")) 191 + }, 192 + wantTitle: "alice.bsky.social - ATCR", 193 + wantOGType: "profile", 194 + wantJSONLen: 1, 195 + }, 196 + { 197 + name: "repository page", 198 + builder: func() *PageMeta { 199 + return NewPageMeta( 200 + "alice.bsky.social/myapp - ATCR", 201 + "A cool container image", 202 + ). 203 + WithCanonical("https://atcr.io/r/alice.bsky.social/myapp"). 204 + WithJSONLD(NewJSONLDSoftwareSourceCode("atcr.io", "alice.bsky.social", "myapp", "A cool container image", "MIT", "")) 205 + }, 206 + wantTitle: "alice.bsky.social/myapp - ATCR", 207 + wantOGType: "website", 208 + wantJSONLen: 1, 209 + }, 210 + { 211 + name: "login page with noindex", 212 + builder: func() *PageMeta { 213 + return NewPageMeta("Login - ATCR", "Sign in with your AT Protocol identity"). 214 + WithRobots("noindex") 215 + }, 216 + wantTitle: "Login - ATCR", 217 + wantOGType: "website", 218 + wantJSONLen: 0, 219 + }, 220 + } 221 + 222 + for _, tt := range tests { 223 + t.Run(tt.name, func(t *testing.T) { 224 + meta := tt.builder() 225 + 226 + if meta.Title != tt.wantTitle { 227 + t.Errorf("Title = %q, want %q", meta.Title, tt.wantTitle) 228 + } 229 + if meta.OGType != tt.wantOGType { 230 + t.Errorf("OGType = %q, want %q", meta.OGType, tt.wantOGType) 231 + } 232 + if len(meta.JSONLD) != tt.wantJSONLen { 233 + t.Errorf("JSONLD len = %d, want %d", len(meta.JSONLD), tt.wantJSONLen) 234 + } 235 + }) 236 + } 237 + }
+24
pkg/appview/handlers/repository.go
··· 229 229 artifactType = manifests[0].ArtifactType 230 230 } 231 231 232 + // Build page meta 233 + title := owner.Handle + "/" + repository + " - ATCR" 234 + if repo.Title != "" { 235 + title = repo.Title + " - ATCR" 236 + } 237 + description := "Container image " + owner.Handle + "/" + repository + " on ATCR" 238 + if repo.Description != "" { 239 + description = repo.Description 240 + } 241 + 242 + meta := NewPageMeta(title, description). 243 + WithCanonical("https://" + h.RegistryURL + "/r/" + owner.Handle + "/" + repository). 244 + WithOGImage("https://" + h.RegistryURL + "/og/r/" + owner.Handle + "/" + repository). 245 + WithJSONLD(NewJSONLDSoftwareSourceCode( 246 + h.RegistryURL, 247 + owner.Handle, 248 + repository, 249 + description, 250 + repo.Licenses, 251 + repo.SourceURL, 252 + )) 253 + 232 254 data := struct { 233 255 PageData 256 + Meta *PageMeta 234 257 Owner *db.User // Repository owner 235 258 Repository *db.Repository // Repository summary 236 259 Tags []db.TagWithPlatforms // Tags with platform info ··· 243 266 ArtifactType string // Dominant artifact type: container-image, helm-chart, unknown 244 267 }{ 245 268 PageData: NewPageData(r, h.RegistryURL), 269 + Meta: meta, 246 270 Owner: owner, 247 271 Repository: repo, 248 272 Tags: tagsWithPlatforms,
+14
pkg/appview/handlers/search.go
··· 17 17 func (h *SearchHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 18 18 query := r.URL.Query().Get("q") 19 19 20 + // Build page meta 21 + title := "Search - ATCR" 22 + description := "Search for container images on ATCR, the decentralized container registry" 23 + canonical := "https://" + h.RegistryURL + "/search" 24 + if query != "" { 25 + title = "Search: " + query + " - ATCR" 26 + description = "Search results for '" + query + "' on ATCR container registry" 27 + canonical = "https://" + h.RegistryURL + "/search?q=" + query 28 + } 29 + 30 + meta := NewPageMeta(title, description).WithCanonical(canonical) 31 + 20 32 data := struct { 21 33 PageData 34 + Meta *PageMeta 22 35 SearchQuery string 23 36 }{ 24 37 PageData: NewPageData(r, h.RegistryURL), 38 + Meta: meta, 25 39 SearchQuery: query, 26 40 } 27 41
+7
pkg/appview/handlers/settings.go
··· 116 116 } 117 117 } 118 118 119 + meta := NewPageMeta( 120 + "Settings - ATCR", 121 + "Manage your ATCR account settings, authorized devices, and storage preferences", 122 + ).WithRobots("noindex") 123 + 119 124 data := struct { 120 125 PageData 126 + Meta *PageMeta 121 127 Profile struct { 122 128 Handle string 123 129 DID string ··· 136 142 HoldDataJSON template.JS 137 143 }{ 138 144 PageData: NewPageData(r, h.RegistryURL), 145 + Meta: meta, 139 146 CurrentHoldDID: profile.DefaultHold, 140 147 CurrentHoldDisplay: deriveDisplayName(profile.DefaultHold), 141 148 ShowCurrentHold: showCurrentHold,
+12
pkg/appview/handlers/user.go
··· 62 62 } 63 63 db.SetRegistryURL(cards, h.RegistryURL) 64 64 65 + // Build page meta 66 + meta := NewPageMeta( 67 + viewedUser.Handle+" - ATCR", 68 + "Container images by "+viewedUser.Handle+" on ATCR, the decentralized container registry", 69 + ). 70 + WithCanonical("https://" + h.RegistryURL + "/u/" + viewedUser.Handle). 71 + WithOGImage("https://" + h.RegistryURL + "/og/u/" + viewedUser.Handle). 72 + WithOGType("profile"). 73 + WithJSONLD(NewJSONLDProfilePage(h.RegistryURL, viewedUser.Handle, viewedUser.Avatar)) 74 + 65 75 data := struct { 66 76 PageData 77 + Meta *PageMeta 67 78 ViewedUser *db.User // User whose page we're viewing 68 79 Repositories []db.RepoCardData 69 80 HasProfile bool 70 81 }{ 71 82 PageData: NewPageData(r, h.RegistryURL), 83 + Meta: meta, 72 84 ViewedUser: viewedUser, 73 85 Repositories: cards, 74 86 HasProfile: hasProfile,
-2
pkg/appview/templates/components/head.html
··· 1 1 {{ define "head" }} 2 2 <meta charset="UTF-8"> 3 3 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 4 - <meta name="robots" content="index, follow"> 5 4 <meta name="theme-color" id="theme-color"> 6 - <meta property="og:locale" content="en_US"> 7 5 8 6 <!-- Favicons --> 9 7 <link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
+1 -1
pkg/appview/templates/components/hero.html
··· 9 9 /amathea_manatee-576w.webp 576w, 10 10 /amathea_manatee-768w.webp 768w, 11 11 /amathea_manatee-1152w.webp 1152w" 12 - sizes="(max-width: 767px) 384px, (max-width: 1023px) 448px, 576px" 12 + sizes="(max-width: 767px) 192px, (max-width: 1023px) 224px, 288px" 13 13 type="image/webp"> 14 14 <img src="/amathea_manatee.png" 15 15 width="1408" height="768"
+35
pkg/appview/templates/components/meta.html
··· 1 + {{ define "meta" }} 2 + {{/* Title */}} 3 + <title>{{ .Title }}</title> 4 + 5 + {{/* Basic meta */}} 6 + <meta name="description" content="{{ .Description }}"> 7 + {{ if .Canonical }}<link rel="canonical" href="{{ .Canonical }}">{{ end }} 8 + {{ if .Robots }}<meta name="robots" content="{{ .Robots }}">{{ end }} 9 + 10 + {{/* OpenGraph */}} 11 + <meta property="og:locale" content="en_US"> 12 + <meta property="og:title" content="{{ .Title }}"> 13 + <meta property="og:description" content="{{ .Description }}"> 14 + <meta property="og:type" content="{{ or .OGType "website" }}"> 15 + {{ if .Canonical }}<meta property="og:url" content="{{ .Canonical }}">{{ end }} 16 + {{ if .OGImage }} 17 + <meta property="og:image" content="{{ .OGImage }}"> 18 + <meta property="og:image:width" content="1200"> 19 + <meta property="og:image:height" content="630"> 20 + {{ end }} 21 + <meta property="og:site_name" content="ATCR"> 22 + 23 + {{/* Twitter Card */}} 24 + <meta name="twitter:card" content="{{ or .TwitterCard "summary_large_image" }}"> 25 + <meta name="twitter:title" content="{{ .Title }}"> 26 + <meta name="twitter:description" content="{{ .Description }}"> 27 + {{ if .OGImage }}<meta name="twitter:image" content="{{ .OGImage }}">{{ end }} 28 + 29 + {{/* JSON-LD */}} 30 + {{ range .JSONLD }} 31 + <script type="application/ld+json"> 32 + {{ jsonld . }} 33 + </script> 34 + {{ end }} 35 + {{ end }}
+1 -3
pkg/appview/templates/pages/404.html
··· 2 2 <!DOCTYPE html> 3 3 <html lang="en"> 4 4 <head> 5 - <title>404 - Lost at Sea | ATCR</title> 6 - <meta name="description" content="Page not found - the requested resource doesn't exist on ATCR"> 7 - <meta name="robots" content="noindex"> 8 5 {{ template "head" . }} 6 + {{ template "meta" .Meta }} 9 7 </head> 10 8 <body> 11 9 {{ template "nav-simple" . }}
+2 -47
pkg/appview/templates/pages/home.html
··· 2 2 <!DOCTYPE html> 3 3 <html lang="en"> 4 4 <head> 5 - <title>ATCR - Distributed Container Registry</title> 6 5 {{ template "head" . }} 7 - <meta name="description" content="Push and pull Docker images on the AT Protocol. Same Docker, decentralized."> 8 - <!-- Open Graph --> 9 - <meta property="og:title" content="ATCR - Distributed Container Registry"> 10 - <meta property="og:description" content="Push and pull Docker images on the AT Protocol. Same Docker, decentralized."> 11 - <meta property="og:image" content="https://{{ .RegistryURL }}/og/home"> 12 - <meta property="og:image:width" content="1200"> 13 - <meta property="og:image:height" content="630"> 14 - <meta property="og:type" content="website"> 15 - <meta property="og:url" content="https://{{ .RegistryURL }}"> 16 - <meta property="og:site_name" content="ATCR"> 17 - <!-- Twitter Card (used by Discord) --> 18 - <meta name="twitter:card" content="summary_large_image"> 19 - <meta name="twitter:title" content="ATCR - Distributed Container Registry"> 20 - <meta name="twitter:description" content="Push and pull Docker images on the AT Protocol. Same Docker, decentralized."> 21 - <meta name="twitter:image" content="https://{{ .RegistryURL }}/og/home"> 22 - <link rel="canonical" href="https://{{ .RegistryURL }}/"> 23 - <!-- JSON-LD Structured Data --> 24 - <script type="application/ld+json"> 25 - { 26 - "@context": "https://schema.org", 27 - "@type": "Organization", 28 - "name": "ATCR", 29 - "alternateName": "AT Protocol Container Registry", 30 - "url": "https://{{ .RegistryURL }}", 31 - "logo": "https://{{ .RegistryURL }}/favicon.svg", 32 - "description": "Decentralized container registry using AT Protocol. Push and pull Docker images with your AT Protocol identity.", 33 - "sameAs": [] 34 - } 35 - </script> 36 - <script type="application/ld+json"> 37 - { 38 - "@context": "https://schema.org", 39 - "@type": "WebSite", 40 - "name": "ATCR", 41 - "url": "https://{{ .RegistryURL }}", 42 - "potentialAction": { 43 - "@type": "SearchAction", 44 - "target": { 45 - "@type": "EntryPoint", 46 - "urlTemplate": "https://{{ .RegistryURL }}/search?q={search_term_string}" 47 - }, 48 - "query-input": "required name=search_term_string" 49 - } 50 - } 51 - </script> 6 + {{ template "meta" .Meta }} 52 7 {{ if not .User }} 53 8 <!-- Preload LCP hero image --> 54 9 <link rel="preload" as="image" ··· 57 12 /amathea_manatee-576w.webp 576w, 58 13 /amathea_manatee-768w.webp 768w, 59 14 /amathea_manatee-1152w.webp 1152w" 60 - imagesizes="(max-width: 767px) 384px, (max-width: 1023px) 448px, 576px" 15 + imagesizes="(max-width: 767px) 192px, (max-width: 1023px) 224px, 288px" 61 16 fetchpriority="high" 62 17 type="image/webp"> 63 18 {{ end }}
+1 -3
pkg/appview/templates/pages/install.html
··· 2 2 <!DOCTYPE html> 3 3 <html lang="en"> 4 4 <head> 5 - <title>Install ATCR Credential Helper - ATCR</title> 6 - <meta name="description" content="Install the ATCR credential helper to push and pull containers using your AT Protocol identity"> 7 - <link rel="canonical" href="https://{{ .RegistryURL }}/install"> 8 5 {{ template "head" . }} 6 + {{ template "meta" .Meta }} 9 7 </head> 10 8 <body> 11 9 {{ template "nav" . }}
+1 -7
pkg/appview/templates/pages/learn-more.html
··· 2 2 <!DOCTYPE html> 3 3 <html lang="en"> 4 4 <head> 5 - <title>About ATCR - Decentralized Container Registry on AT Protocol</title> 6 - <meta name="description" content="Learn how ATCR brings Docker container registries to the decentralized web using AT Protocol. Own your data, use your identity."> 7 - <link rel="canonical" href="https://{{ .RegistryURL }}/learn-more"> 8 - <meta property="og:title" content="About ATCR - Decentralized Container Registry"> 9 - <meta property="og:description" content="Docker meets the decentralized web. Push and pull container images using your AT Protocol identity."> 10 - <meta property="og:url" content="https://{{ .RegistryURL }}/learn-more"> 11 - <meta property="og:type" content="website"> 12 5 {{ template "head" . }} 6 + {{ template "meta" .Meta }} 13 7 </head> 14 8 <body> 15 9 {{ template "nav" . }}
+1 -3
pkg/appview/templates/pages/login.html
··· 2 2 <!DOCTYPE html> 3 3 <html lang="en"> 4 4 <head> 5 - <title>Login - ATCR</title> 6 - <meta name="description" content="Sign in to ATCR with your AT Protocol account to push and pull container images"> 7 - <link rel="canonical" href="https://{{ .RegistryURL }}/login"> 8 5 {{ template "head" . }} 6 + {{ template "meta" .Meta }} 9 7 </head> 10 8 <body> 11 9 {{ template "nav-simple" . }}
+1 -3
pkg/appview/templates/pages/privacy.html
··· 2 2 <!DOCTYPE html> 3 3 <html lang="en"> 4 4 <head> 5 - <title>Privacy Policy - ATCR</title> 6 - <meta name="description" content="ATCR privacy policy - how we collect, use, and protect your data on the decentralized container registry"> 7 - <link rel="canonical" href="https://{{ .RegistryURL }}/privacy"> 8 5 {{ template "head" . }} 6 + {{ template "meta" .Meta }} 9 7 </head> 10 8 <body> 11 9 {{ template "nav" . }}
+1 -39
pkg/appview/templates/pages/repository.html
··· 2 2 <!DOCTYPE html> 3 3 <html lang="en"> 4 4 <head> 5 - <title>{{ if .Repository.Title }}{{ .Repository.Title }}{{ else }}{{ .Owner.Handle }}/{{ .Repository.Name }}{{ end }} - ATCR</title> 6 - <meta name="description" content="{{ if .Repository.Description }}{{ .Repository.Description }}{{ else }}Container image {{ .Owner.Handle }}/{{ .Repository.Name }} on ATCR{{ end }}"> 7 - <link rel="canonical" href="https://{{ .RegistryURL }}/r/{{ .Owner.Handle }}/{{ .Repository.Name }}"> 8 - <!-- Open Graph --> 9 - <meta property="og:title" content="{{ .Owner.Handle }}/{{ .Repository.Name }} - ATCR"> 10 - <meta property="og:description" content="{{ if .Repository.Description }}{{ .Repository.Description }}{{ else }}Container image on ATCR{{ end }}"> 11 - <meta property="og:image" content="https://{{ .RegistryURL }}/og/r/{{ .Owner.Handle }}/{{ .Repository.Name }}"> 12 - <meta property="og:image:width" content="1200"> 13 - <meta property="og:image:height" content="630"> 14 - <meta property="og:type" content="website"> 15 - <meta property="og:url" content="https://{{ .RegistryURL }}/r/{{ .Owner.Handle }}/{{ .Repository.Name }}"> 16 - <meta property="og:site_name" content="ATCR"> 17 - <!-- Twitter Card (used by Discord) --> 18 - <meta name="twitter:card" content="summary_large_image"> 19 - <meta name="twitter:title" content="{{ .Owner.Handle }}/{{ .Repository.Name }} - ATCR"> 20 - <meta name="twitter:description" content="{{ if .Repository.Description }}{{ .Repository.Description }}{{ else }}Container image on ATCR{{ end }}"> 21 - <meta name="twitter:image" content="https://{{ .RegistryURL }}/og/r/{{ .Owner.Handle }}/{{ .Repository.Name }}"> 22 - <!-- JSON-LD Structured Data --> 23 - <script type="application/ld+json"> 24 - { 25 - "@context": "https://schema.org", 26 - "@type": "SoftwareSourceCode", 27 - "name": "{{ .Owner.Handle }}/{{ .Repository.Name }}", 28 - "description": {{ if .Repository.Description }}"{{ .Repository.Description }}"{{ else }}"Container image on ATCR"{{ end }}, 29 - "codeRepository": "https://{{ .RegistryURL }}/r/{{ .Owner.Handle }}/{{ .Repository.Name }}", 30 - "author": { 31 - "@type": "Person", 32 - "name": "{{ .Owner.Handle }}", 33 - "url": "https://{{ .RegistryURL }}/u/{{ .Owner.Handle }}" 34 - }, 35 - "publisher": { 36 - "@type": "Organization", 37 - "name": "ATCR", 38 - "url": "https://{{ .RegistryURL }}" 39 - }{{ if .Repository.Licenses }}, 40 - "license": "{{ .Repository.Licenses }}"{{ end }}{{ if .Repository.SourceURL }}, 41 - "isBasedOn": "{{ .Repository.SourceURL }}"{{ end }} 42 - } 43 - </script> 44 5 {{ template "head" . }} 6 + {{ template "meta" .Meta }} 45 7 </head> 46 8 <body> 47 9 {{ template "nav" . }}
+1 -8
pkg/appview/templates/pages/search.html
··· 2 2 <!DOCTYPE html> 3 3 <html lang="en"> 4 4 <head> 5 - <title>Search{{ if .SearchQuery }}: {{ .SearchQuery }}{{ end }} - ATCR</title> 6 - <meta name="description" content="{{ if .SearchQuery }}Search results for '{{ .SearchQuery }}' on ATCR container registry{{ else }}Search for container images on ATCR, the decentralized container registry{{ end }}"> 7 - <!-- Open Graph --> 8 - <meta property="og:title" content="Search{{ if .SearchQuery }}: {{ .SearchQuery }}{{ end }} - ATCR"> 9 - <meta property="og:description" content="{{ if .SearchQuery }}Search results for '{{ .SearchQuery }}' on ATCR container registry{{ else }}Search for container images on ATCR{{ end }}"> 10 - <meta property="og:type" content="website"> 11 - <meta property="og:url" content="https://{{ .RegistryURL }}/search{{ if .SearchQuery }}?q={{ .SearchQuery }}{{ end }}"> 12 - <meta property="og:site_name" content="ATCR"> 13 5 {{ template "head" . }} 6 + {{ template "meta" .Meta }} 14 7 </head> 15 8 <body> 16 9 {{ template "nav" . }}
+1 -3
pkg/appview/templates/pages/settings.html
··· 2 2 <!DOCTYPE html> 3 3 <html lang="en"> 4 4 <head> 5 - <title>Settings - ATCR</title> 6 - <meta name="description" content="Manage your ATCR account settings, authorized devices, and storage preferences"> 7 - <meta name="robots" content="noindex"> 8 5 {{ template "head" . }} 6 + {{ template "meta" .Meta }} 9 7 </head> 10 8 <body> 11 9 {{ template "nav" . }}
+1 -3
pkg/appview/templates/pages/terms.html
··· 2 2 <!DOCTYPE html> 3 3 <html lang="en"> 4 4 <head> 5 - <title>Terms of Service - ATCR</title> 6 - <meta name="description" content="ATCR terms of service - rules and guidelines for using the decentralized container registry"> 7 - <link rel="canonical" href="https://{{ .RegistryURL }}/terms"> 8 5 {{ template "head" . }} 6 + {{ template "meta" .Meta }} 9 7 </head> 10 8 <body> 11 9 {{ template "nav" . }}
+1 -30
pkg/appview/templates/pages/user.html
··· 2 2 <!DOCTYPE html> 3 3 <html lang="en"> 4 4 <head> 5 - <title>{{ .ViewedUser.Handle }} - ATCR</title> 6 - <meta name="description" content="Container images by {{ .ViewedUser.Handle }} on ATCR, the decentralized container registry"> 7 - <link rel="canonical" href="https://{{ .RegistryURL }}/u/{{ .ViewedUser.Handle }}"> 8 - <!-- Open Graph --> 9 - <meta property="og:title" content="{{ .ViewedUser.Handle }} - ATCR"> 10 - <meta property="og:description" content="Container images by {{ .ViewedUser.Handle }} on ATCR"> 11 - <meta property="og:image" content="https://{{ .RegistryURL }}/og/u/{{ .ViewedUser.Handle }}"> 12 - <meta property="og:image:width" content="1200"> 13 - <meta property="og:image:height" content="630"> 14 - <meta property="og:type" content="profile"> 15 - <meta property="og:url" content="https://{{ .RegistryURL }}/u/{{ .ViewedUser.Handle }}"> 16 - <meta property="og:site_name" content="ATCR"> 17 - <!-- Twitter Card (used by Discord) --> 18 - <meta name="twitter:card" content="summary_large_image"> 19 - <meta name="twitter:title" content="{{ .ViewedUser.Handle }} - ATCR"> 20 - <meta name="twitter:description" content="Container images by {{ .ViewedUser.Handle }} on ATCR"> 21 - <meta name="twitter:image" content="https://{{ .RegistryURL }}/og/u/{{ .ViewedUser.Handle }}"> 22 - <!-- JSON-LD Structured Data --> 23 - <script type="application/ld+json"> 24 - { 25 - "@context": "https://schema.org", 26 - "@type": "ProfilePage", 27 - "mainEntity": { 28 - "@type": "Person", 29 - "name": "{{ .ViewedUser.Handle }}", 30 - "url": "https://{{ .RegistryURL }}/u/{{ .ViewedUser.Handle }}"{{ if .ViewedUser.Avatar }}, 31 - "image": "{{ .ViewedUser.Avatar }}"{{ end }} 32 - } 33 - } 34 - </script> 35 5 {{ template "head" . }} 6 + {{ template "meta" .Meta }} 36 7 </head> 37 8 <body> 38 9 {{ template "nav" . }}
+16 -1
pkg/appview/ui.go
··· 3 3 import ( 4 4 "crypto/md5" 5 5 "embed" 6 + "encoding/json" 6 7 "fmt" 7 8 "html/template" 8 9 "io/fs" ··· 148 149 return imgURL 149 150 } 150 151 // Cloudflare uses /cdn-cgi/image/width=X/ path format 151 - parsed.Path = fmt.Sprintf("/cdn-cgi/image/width=%d%s", width, parsed.Path) 152 + parsed.Path = fmt.Sprintf("/cdn-cgi/image/width=%d,format=auto%s", width, parsed.Path) 152 153 return parsed.String() 153 154 }, 154 155 ··· 171 172 template.HTMLEscapeString(classes), 172 173 template.HTMLEscapeString(name), 173 174 )) 175 + }, 176 + 177 + // jsonld marshals a value to indented JSON for JSON-LD script tags 178 + // Usage: {{ jsonld .SomeStruct }} 179 + "jsonld": func(v any) template.HTML { 180 + // If v is already a string, assume it's pre-formatted JSON 181 + if s, ok := v.(string); ok { 182 + return template.HTML(s) 183 + } 184 + b, err := json.MarshalIndent(v, " ", " ") 185 + if err != nil { 186 + return template.HTML("{}") 187 + } 188 + return template.HTML(b) 174 189 }, 175 190 } 176 191
+169 -2
pkg/appview/ui_test.go
··· 764 764 } 765 765 766 766 data := map[string]string{ 767 - "Class": "success", 768 - "Icon": "check", 767 + "Type": "success", 769 768 "Message": "Operation completed!", 770 769 } 771 770 ··· 794 793 // Further testing would require HTTP request/response testing 795 794 // which is typically done in integration tests 796 795 } 796 + 797 + func TestJSONLD(t *testing.T) { 798 + tests := []struct { 799 + name string 800 + input any 801 + expectContains []string 802 + expectMissing []string 803 + }{ 804 + { 805 + name: "struct input - marshals to JSON", 806 + input: struct { 807 + Context string `json:"@context"` 808 + Type string `json:"@type"` 809 + Name string `json:"name"` 810 + }{ 811 + Context: "https://schema.org", 812 + Type: "Organization", 813 + Name: "ATCR", 814 + }, 815 + expectContains: []string{ 816 + `"@context": "https://schema.org"`, 817 + `"@type": "Organization"`, 818 + `"name": "ATCR"`, 819 + }, 820 + expectMissing: []string{ 821 + `\"`, // Should NOT contain escaped quotes (double-encoding) 822 + `\n`, // Should NOT contain escaped newlines 823 + }, 824 + }, 825 + { 826 + name: "string input - returns as-is without re-encoding", 827 + input: `{"@context": "https://schema.org", "@type": "Thing"}`, 828 + expectContains: []string{ 829 + `{"@context": "https://schema.org", "@type": "Thing"}`, 830 + }, 831 + expectMissing: []string{ 832 + `\"`, // Should NOT have escaped quotes 833 + `\n`, // Should NOT have escaped newlines 834 + }, 835 + }, 836 + { 837 + name: "pre-formatted JSON string - no double encoding", 838 + input: "{\n \"@context\": \"https://schema.org\"\n}", 839 + expectContains: []string{ 840 + `"@context": "https://schema.org"`, 841 + }, 842 + expectMissing: []string{ 843 + `\\n`, // Should NOT have double-escaped newlines 844 + `\\"`, // Should NOT have double-escaped quotes 845 + }, 846 + }, 847 + { 848 + name: "nested struct - proper indentation", 849 + input: struct { 850 + Context string `json:"@context"` 851 + Author struct { 852 + Type string `json:"@type"` 853 + Name string `json:"name"` 854 + } `json:"author"` 855 + }{ 856 + Context: "https://schema.org", 857 + Author: struct { 858 + Type string `json:"@type"` 859 + Name string `json:"name"` 860 + }{ 861 + Type: "Person", 862 + Name: "Alice", 863 + }, 864 + }, 865 + expectContains: []string{ 866 + `"@context": "https://schema.org"`, 867 + `"author": {`, 868 + `"@type": "Person"`, 869 + `"name": "Alice"`, 870 + }, 871 + }, 872 + { 873 + name: "empty struct - returns empty JSON object", 874 + input: struct{}{}, 875 + expectContains: []string{ 876 + `{}`, 877 + }, 878 + }, 879 + { 880 + name: "empty string - returns empty string", 881 + input: "", 882 + expectContains: []string{ 883 + ``, 884 + }, 885 + }, 886 + } 887 + 888 + for _, tt := range tests { 889 + t.Run(tt.name, func(t *testing.T) { 890 + tmpl, err := Templates() 891 + if err != nil { 892 + t.Fatalf("Templates() error = %v", err) 893 + } 894 + 895 + templateStr := `{{ jsonld . }}` 896 + buf := new(bytes.Buffer) 897 + temp, err := tmpl.New("test").Parse(templateStr) 898 + if err != nil { 899 + t.Fatalf("Failed to parse template: %v", err) 900 + } 901 + 902 + err = temp.Execute(buf, tt.input) 903 + if err != nil { 904 + t.Fatalf("Failed to execute template: %v", err) 905 + } 906 + 907 + got := buf.String() 908 + 909 + for _, expected := range tt.expectContains { 910 + if !strings.Contains(got, expected) { 911 + t.Errorf("jsonld output missing expected %q\nGot: %s", expected, got) 912 + } 913 + } 914 + 915 + for _, notExpected := range tt.expectMissing { 916 + if strings.Contains(got, notExpected) { 917 + t.Errorf("jsonld output should not contain %q\nGot: %s", notExpected, got) 918 + } 919 + } 920 + }) 921 + } 922 + } 923 + 924 + func TestJSONLD_Indentation(t *testing.T) { 925 + // Test that the indentation uses 8-space prefix (for alignment with <script> tag) 926 + tmpl, err := Templates() 927 + if err != nil { 928 + t.Fatalf("Templates() error = %v", err) 929 + } 930 + 931 + input := struct { 932 + Context string `json:"@context"` 933 + Name string `json:"name"` 934 + }{ 935 + Context: "https://schema.org", 936 + Name: "Test", 937 + } 938 + 939 + templateStr := `{{ jsonld . }}` 940 + buf := new(bytes.Buffer) 941 + temp, err := tmpl.New("test").Parse(templateStr) 942 + if err != nil { 943 + t.Fatalf("Failed to parse template: %v", err) 944 + } 945 + 946 + err = temp.Execute(buf, input) 947 + if err != nil { 948 + t.Fatalf("Failed to execute template: %v", err) 949 + } 950 + 951 + got := buf.String() 952 + 953 + // Check that lines after the first have 8-space prefix + 4-space indent 954 + lines := strings.Split(got, "\n") 955 + if len(lines) < 2 { 956 + t.Fatalf("Expected multi-line output, got: %s", got) 957 + } 958 + 959 + // Second line should start with 8 spaces (prefix) + 4 spaces (indent) = 12 spaces 960 + if len(lines[1]) < 12 || lines[1][:12] != " " { 961 + t.Errorf("Expected line to start with 12 spaces (8 prefix + 4 indent), got: %q", lines[1]) 962 + } 963 + }