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.

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 + }