diff --git a/pkg/appview/handlers/auth.go b/pkg/appview/handlers/auth.go index 3c5bb0d..64e5841 100644 --- a/pkg/appview/handlers/auth.go +++ b/pkg/appview/handlers/auth.go @@ -17,12 +17,19 @@ func (h *LoginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { returnTo = "/" } + meta := NewPageMeta( + "Login - ATCR", + "Sign in to ATCR with your AT Protocol account to push and pull container images", + ).WithCanonical("https://" + h.RegistryURL + "/login") + data := struct { PageData + Meta *PageMeta ReturnTo string Error string }{ PageData: NewPageData(r, h.RegistryURL), + Meta: meta, ReturnTo: returnTo, Error: r.URL.Query().Get("error"), } diff --git a/pkg/appview/handlers/errors.go b/pkg/appview/handlers/errors.go index 1fe1497..fe9127e 100644 --- a/pkg/appview/handlers/errors.go +++ b/pkg/appview/handlers/errors.go @@ -19,10 +19,17 @@ func (h *NotFoundHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func RenderNotFound(w http.ResponseWriter, r *http.Request, templates *template.Template, registryURL string) { w.WriteHeader(http.StatusNotFound) + meta := NewPageMeta( + "404 - Lost at Sea | ATCR", + "Page not found - the requested resource doesn't exist on ATCR", + ).WithRobots("noindex") + data := struct { PageData + Meta *PageMeta }{ PageData: NewPageData(r, registryURL), + Meta: meta, } if err := templates.ExecuteTemplate(w, "404", data); err != nil { diff --git a/pkg/appview/handlers/home.go b/pkg/appview/handlers/home.go index 2276e89..fcd3ea4 100644 --- a/pkg/appview/handlers/home.go +++ b/pkg/appview/handlers/home.go @@ -41,10 +41,21 @@ func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { data := struct { PageData + Meta *PageMeta FeaturedRepos []db.RepoCardData RecentRepos []db.RepoCardData }{ - PageData: NewPageData(r, h.RegistryURL), + PageData: NewPageData(r, h.RegistryURL), + Meta: NewPageMeta( + "ATCR - Distributed Container Registry", + "Push and pull Docker images on the AT Protocol. Same Docker, decentralized.", + ). + WithCanonical("https://"+h.RegistryURL+"/"). + WithOGImage("https://"+h.RegistryURL+"/og/home"). + WithJSONLD( + NewJSONLDOrganization(h.RegistryURL), + NewJSONLDWebSite(h.RegistryURL), + ), FeaturedRepos: featuredCards, RecentRepos: recentCards, } diff --git a/pkg/appview/handlers/install.go b/pkg/appview/handlers/install.go index 1136c37..bc055c9 100644 --- a/pkg/appview/handlers/install.go +++ b/pkg/appview/handlers/install.go @@ -10,10 +10,17 @@ type InstallHandler struct { } func (h *InstallHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + meta := NewPageMeta( + "Install ATCR Credential Helper - ATCR", + "Install the ATCR credential helper to push and pull containers using your AT Protocol identity", + ).WithCanonical("https://" + h.RegistryURL + "/install") + data := struct { PageData + Meta *PageMeta }{ PageData: NewPageData(r, h.RegistryURL), + Meta: meta, } if err := h.Templates.ExecuteTemplate(w, "install", data); err != nil { diff --git a/pkg/appview/handlers/jsonld.go b/pkg/appview/handlers/jsonld.go new file mode 100644 index 0000000..080660d --- /dev/null +++ b/pkg/appview/handlers/jsonld.go @@ -0,0 +1,152 @@ +package handlers + +// JSON-LD structured data types for rich results in search engines. +// These are marshaled to JSON and embedded in + {{ end }} +{{ end }} diff --git a/pkg/appview/templates/pages/404.html b/pkg/appview/templates/pages/404.html index cd3e246..5d5d61f 100644 --- a/pkg/appview/templates/pages/404.html +++ b/pkg/appview/templates/pages/404.html @@ -2,10 +2,8 @@ - 404 - Lost at Sea | ATCR - - {{ template "head" . }} + {{ template "meta" .Meta }} {{ template "nav-simple" . }} diff --git a/pkg/appview/templates/pages/home.html b/pkg/appview/templates/pages/home.html index 59a278b..15d41e3 100644 --- a/pkg/appview/templates/pages/home.html +++ b/pkg/appview/templates/pages/home.html @@ -2,53 +2,8 @@ - ATCR - Distributed Container Registry {{ template "head" . }} - - - - - - - - - - - - - - - - - - - + {{ template "meta" .Meta }} {{ if not .User }} {{ end }} diff --git a/pkg/appview/templates/pages/install.html b/pkg/appview/templates/pages/install.html index 4cafe6d..de255b4 100644 --- a/pkg/appview/templates/pages/install.html +++ b/pkg/appview/templates/pages/install.html @@ -2,10 +2,8 @@ - Install ATCR Credential Helper - ATCR - - {{ template "head" . }} + {{ template "meta" .Meta }} {{ template "nav" . }} diff --git a/pkg/appview/templates/pages/learn-more.html b/pkg/appview/templates/pages/learn-more.html index 0f27443..2541e47 100644 --- a/pkg/appview/templates/pages/learn-more.html +++ b/pkg/appview/templates/pages/learn-more.html @@ -2,14 +2,8 @@ - About ATCR - Decentralized Container Registry on AT Protocol - - - - - - {{ template "head" . }} + {{ template "meta" .Meta }} {{ template "nav" . }} diff --git a/pkg/appview/templates/pages/login.html b/pkg/appview/templates/pages/login.html index fdd16dc..d3b2318 100644 --- a/pkg/appview/templates/pages/login.html +++ b/pkg/appview/templates/pages/login.html @@ -2,10 +2,8 @@ - Login - ATCR - - {{ template "head" . }} + {{ template "meta" .Meta }} {{ template "nav-simple" . }} diff --git a/pkg/appview/templates/pages/privacy.html b/pkg/appview/templates/pages/privacy.html index 6998e9b..fb68829 100644 --- a/pkg/appview/templates/pages/privacy.html +++ b/pkg/appview/templates/pages/privacy.html @@ -2,10 +2,8 @@ - Privacy Policy - ATCR - - {{ template "head" . }} + {{ template "meta" .Meta }} {{ template "nav" . }} diff --git a/pkg/appview/templates/pages/repository.html b/pkg/appview/templates/pages/repository.html index 768b9fe..f2897e4 100644 --- a/pkg/appview/templates/pages/repository.html +++ b/pkg/appview/templates/pages/repository.html @@ -2,46 +2,8 @@ - {{ if .Repository.Title }}{{ .Repository.Title }}{{ else }}{{ .Owner.Handle }}/{{ .Repository.Name }}{{ end }} - ATCR - - - - - - - - - - - - - - - - - - {{ template "head" . }} + {{ template "meta" .Meta }} {{ template "nav" . }} diff --git a/pkg/appview/templates/pages/search.html b/pkg/appview/templates/pages/search.html index ea1619d..fdaa9f8 100644 --- a/pkg/appview/templates/pages/search.html +++ b/pkg/appview/templates/pages/search.html @@ -2,15 +2,8 @@ - Search{{ if .SearchQuery }}: {{ .SearchQuery }}{{ end }} - ATCR - - - - - - - {{ template "head" . }} + {{ template "meta" .Meta }} {{ template "nav" . }} diff --git a/pkg/appview/templates/pages/settings.html b/pkg/appview/templates/pages/settings.html index 0d1943d..5d88eb8 100644 --- a/pkg/appview/templates/pages/settings.html +++ b/pkg/appview/templates/pages/settings.html @@ -2,10 +2,8 @@ - Settings - ATCR - - {{ template "head" . }} + {{ template "meta" .Meta }} {{ template "nav" . }} diff --git a/pkg/appview/templates/pages/terms.html b/pkg/appview/templates/pages/terms.html index f9b2fa1..335f7e0 100644 --- a/pkg/appview/templates/pages/terms.html +++ b/pkg/appview/templates/pages/terms.html @@ -2,10 +2,8 @@ - Terms of Service - ATCR - - {{ template "head" . }} + {{ template "meta" .Meta }} {{ template "nav" . }} diff --git a/pkg/appview/templates/pages/user.html b/pkg/appview/templates/pages/user.html index 5350cb7..a524237 100644 --- a/pkg/appview/templates/pages/user.html +++ b/pkg/appview/templates/pages/user.html @@ -2,37 +2,8 @@ - {{ .ViewedUser.Handle }} - ATCR - - - - - - - - - - - - - - - - - - {{ template "head" . }} + {{ template "meta" .Meta }} {{ template "nav" . }} diff --git a/pkg/appview/ui.go b/pkg/appview/ui.go index 0f4cb03..1702b25 100644 --- a/pkg/appview/ui.go +++ b/pkg/appview/ui.go @@ -3,6 +3,7 @@ package appview import ( "crypto/md5" "embed" + "encoding/json" "fmt" "html/template" "io/fs" @@ -148,7 +149,7 @@ func Templates() (*template.Template, error) { return imgURL } // Cloudflare uses /cdn-cgi/image/width=X/ path format - parsed.Path = fmt.Sprintf("/cdn-cgi/image/width=%d%s", width, parsed.Path) + parsed.Path = fmt.Sprintf("/cdn-cgi/image/width=%d,format=auto%s", width, parsed.Path) return parsed.String() }, @@ -172,6 +173,20 @@ func Templates() (*template.Template, error) { template.HTMLEscapeString(name), )) }, + + // jsonld marshals a value to indented JSON for JSON-LD script tags + // Usage: {{ jsonld .SomeStruct }} + "jsonld": func(v any) template.HTML { + // If v is already a string, assume it's pre-formatted JSON + if s, ok := v.(string); ok { + return template.HTML(s) + } + b, err := json.MarshalIndent(v, " ", " ") + if err != nil { + return template.HTML("{}") + } + return template.HTML(b) + }, } tmpl := template.New("").Funcs(funcMap) diff --git a/pkg/appview/ui_test.go b/pkg/appview/ui_test.go index 360903a..89662d2 100644 --- a/pkg/appview/ui_test.go +++ b/pkg/appview/ui_test.go @@ -764,8 +764,7 @@ func TestTemplateExecution_Alert(t *testing.T) { } data := map[string]string{ - "Class": "success", - "Icon": "check", + "Type": "success", "Message": "Operation completed!", } @@ -794,3 +793,171 @@ func TestPublicHandler(t *testing.T) { // Further testing would require HTTP request/response testing // which is typically done in integration tests } + +func TestJSONLD(t *testing.T) { + tests := []struct { + name string + input any + expectContains []string + expectMissing []string + }{ + { + name: "struct input - marshals to JSON", + input: struct { + Context string `json:"@context"` + Type string `json:"@type"` + Name string `json:"name"` + }{ + Context: "https://schema.org", + Type: "Organization", + Name: "ATCR", + }, + expectContains: []string{ + `"@context": "https://schema.org"`, + `"@type": "Organization"`, + `"name": "ATCR"`, + }, + expectMissing: []string{ + `\"`, // Should NOT contain escaped quotes (double-encoding) + `\n`, // Should NOT contain escaped newlines + }, + }, + { + name: "string input - returns as-is without re-encoding", + input: `{"@context": "https://schema.org", "@type": "Thing"}`, + expectContains: []string{ + `{"@context": "https://schema.org", "@type": "Thing"}`, + }, + expectMissing: []string{ + `\"`, // Should NOT have escaped quotes + `\n`, // Should NOT have escaped newlines + }, + }, + { + name: "pre-formatted JSON string - no double encoding", + input: "{\n \"@context\": \"https://schema.org\"\n}", + expectContains: []string{ + `"@context": "https://schema.org"`, + }, + expectMissing: []string{ + `\\n`, // Should NOT have double-escaped newlines + `\\"`, // Should NOT have double-escaped quotes + }, + }, + { + name: "nested struct - proper indentation", + input: struct { + Context string `json:"@context"` + Author struct { + Type string `json:"@type"` + Name string `json:"name"` + } `json:"author"` + }{ + Context: "https://schema.org", + Author: struct { + Type string `json:"@type"` + Name string `json:"name"` + }{ + Type: "Person", + Name: "Alice", + }, + }, + expectContains: []string{ + `"@context": "https://schema.org"`, + `"author": {`, + `"@type": "Person"`, + `"name": "Alice"`, + }, + }, + { + name: "empty struct - returns empty JSON object", + input: struct{}{}, + expectContains: []string{ + `{}`, + }, + }, + { + name: "empty string - returns empty string", + input: "", + expectContains: []string{ + ``, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpl, err := Templates() + if err != nil { + t.Fatalf("Templates() error = %v", err) + } + + templateStr := `{{ jsonld . }}` + buf := new(bytes.Buffer) + temp, err := tmpl.New("test").Parse(templateStr) + if err != nil { + t.Fatalf("Failed to parse template: %v", err) + } + + err = temp.Execute(buf, tt.input) + if err != nil { + t.Fatalf("Failed to execute template: %v", err) + } + + got := buf.String() + + for _, expected := range tt.expectContains { + if !strings.Contains(got, expected) { + t.Errorf("jsonld output missing expected %q\nGot: %s", expected, got) + } + } + + for _, notExpected := range tt.expectMissing { + if strings.Contains(got, notExpected) { + t.Errorf("jsonld output should not contain %q\nGot: %s", notExpected, got) + } + } + }) + } +} + +func TestJSONLD_Indentation(t *testing.T) { + // Test that the indentation uses 8-space prefix (for alignment with