mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-02 00:06:58 +00:00
i don't think i can make this website any faster...
This commit is contained in:
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 <script type="application/ld+json"> tags.
|
||||
|
||||
// JSONLDOrganization represents a schema.org Organization.
|
||||
type JSONLDOrganization struct {
|
||||
Context string `json:"@context"`
|
||||
Type string `json:"@type"`
|
||||
Name string `json:"name"`
|
||||
AlternateName string `json:"alternateName,omitempty"`
|
||||
URL string `json:"url"`
|
||||
Logo string `json:"logo,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
SameAs []string `json:"sameAs,omitempty"`
|
||||
}
|
||||
|
||||
// JSONLDWebSite represents a schema.org WebSite with search action.
|
||||
type JSONLDWebSite struct {
|
||||
Context string `json:"@context"`
|
||||
Type string `json:"@type"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
PotentialAction *JSONLDSearchAction `json:"potentialAction,omitempty"`
|
||||
}
|
||||
|
||||
// JSONLDSearchAction represents a schema.org SearchAction.
|
||||
type JSONLDSearchAction struct {
|
||||
Type string `json:"@type"`
|
||||
Target *JSONLDEntryPoint `json:"target"`
|
||||
QueryInput string `json:"query-input"`
|
||||
}
|
||||
|
||||
// JSONLDEntryPoint represents a schema.org EntryPoint for search.
|
||||
type JSONLDEntryPoint struct {
|
||||
Type string `json:"@type"`
|
||||
URLTemplate string `json:"urlTemplate"`
|
||||
}
|
||||
|
||||
// JSONLDSoftwareSourceCode represents a schema.org SoftwareSourceCode (for repositories).
|
||||
type JSONLDSoftwareSourceCode struct {
|
||||
Context string `json:"@context"`
|
||||
Type string `json:"@type"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
CodeRepository string `json:"codeRepository"`
|
||||
Author *JSONLDPerson `json:"author,omitempty"`
|
||||
Publisher *JSONLDOrg `json:"publisher,omitempty"`
|
||||
License string `json:"license,omitempty"`
|
||||
IsBasedOn string `json:"isBasedOn,omitempty"`
|
||||
}
|
||||
|
||||
// JSONLDProfilePage represents a schema.org ProfilePage.
|
||||
type JSONLDProfilePage struct {
|
||||
Context string `json:"@context"`
|
||||
Type string `json:"@type"`
|
||||
MainEntity *JSONLDPerson `json:"mainEntity"`
|
||||
}
|
||||
|
||||
// JSONLDPerson represents a schema.org Person.
|
||||
type JSONLDPerson struct {
|
||||
Type string `json:"@type"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
}
|
||||
|
||||
// JSONLDOrg represents a schema.org Organization (minimal version for embedding).
|
||||
type JSONLDOrg struct {
|
||||
Type string `json:"@type"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
// Helper constructors for common JSON-LD objects
|
||||
|
||||
// NewJSONLDOrganization creates an Organization object for ATCR.
|
||||
func NewJSONLDOrganization(registryURL string) JSONLDOrganization {
|
||||
return JSONLDOrganization{
|
||||
Context: "https://schema.org",
|
||||
Type: "Organization",
|
||||
Name: "ATCR",
|
||||
AlternateName: "AT Protocol Container Registry",
|
||||
URL: "https://" + registryURL,
|
||||
Logo: "https://" + registryURL + "/favicon.svg",
|
||||
Description: "Decentralized container registry using AT Protocol. Push and pull Docker images with your AT Protocol identity.",
|
||||
SameAs: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
// NewJSONLDWebSite creates a WebSite object with search action.
|
||||
func NewJSONLDWebSite(registryURL string) JSONLDWebSite {
|
||||
return JSONLDWebSite{
|
||||
Context: "https://schema.org",
|
||||
Type: "WebSite",
|
||||
Name: "ATCR",
|
||||
URL: "https://" + registryURL,
|
||||
PotentialAction: &JSONLDSearchAction{
|
||||
Type: "SearchAction",
|
||||
Target: &JSONLDEntryPoint{
|
||||
Type: "EntryPoint",
|
||||
URLTemplate: "https://" + registryURL + "/search?q={search_term_string}",
|
||||
},
|
||||
QueryInput: "required name=search_term_string",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewJSONLDSoftwareSourceCode creates a SoftwareSourceCode object for a repository.
|
||||
func NewJSONLDSoftwareSourceCode(registryURL, handle, repoName, description, license, sourceURL string) JSONLDSoftwareSourceCode {
|
||||
code := JSONLDSoftwareSourceCode{
|
||||
Context: "https://schema.org",
|
||||
Type: "SoftwareSourceCode",
|
||||
Name: handle + "/" + repoName,
|
||||
Description: description,
|
||||
CodeRepository: "https://" + registryURL + "/r/" + handle + "/" + repoName,
|
||||
Author: &JSONLDPerson{
|
||||
Type: "Person",
|
||||
Name: handle,
|
||||
URL: "https://" + registryURL + "/u/" + handle,
|
||||
},
|
||||
Publisher: &JSONLDOrg{
|
||||
Type: "Organization",
|
||||
Name: "ATCR",
|
||||
URL: "https://" + registryURL,
|
||||
},
|
||||
}
|
||||
if license != "" {
|
||||
code.License = license
|
||||
}
|
||||
if sourceURL != "" {
|
||||
code.IsBasedOn = sourceURL
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
// NewJSONLDProfilePage creates a ProfilePage object for a user.
|
||||
func NewJSONLDProfilePage(registryURL, handle, avatar string) JSONLDProfilePage {
|
||||
person := &JSONLDPerson{
|
||||
Type: "Person",
|
||||
Name: handle,
|
||||
URL: "https://" + registryURL + "/u/" + handle,
|
||||
}
|
||||
if avatar != "" {
|
||||
person.Image = avatar
|
||||
}
|
||||
return JSONLDProfilePage{
|
||||
Context: "https://schema.org",
|
||||
Type: "ProfilePage",
|
||||
MainEntity: person,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewJSONLDOrganization(t *testing.T) {
|
||||
registryURL := "atcr.io"
|
||||
org := NewJSONLDOrganization(registryURL)
|
||||
|
||||
// Verify required fields
|
||||
if org.Context != "https://schema.org" {
|
||||
t.Errorf("Context = %q, want %q", org.Context, "https://schema.org")
|
||||
}
|
||||
if org.Type != "Organization" {
|
||||
t.Errorf("Type = %q, want %q", org.Type, "Organization")
|
||||
}
|
||||
if org.Name != "ATCR" {
|
||||
t.Errorf("Name = %q, want %q", org.Name, "ATCR")
|
||||
}
|
||||
if org.URL != "https://atcr.io" {
|
||||
t.Errorf("URL = %q, want %q", org.URL, "https://atcr.io")
|
||||
}
|
||||
if org.Logo != "https://atcr.io/favicon.svg" {
|
||||
t.Errorf("Logo = %q, want %q", org.Logo, "https://atcr.io/favicon.svg")
|
||||
}
|
||||
|
||||
// Verify it marshals to valid JSON
|
||||
data, err := json.Marshal(org)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal organization: %v", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Error("Marshaled JSON is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewJSONLDWebSite(t *testing.T) {
|
||||
registryURL := "atcr.io"
|
||||
site := NewJSONLDWebSite(registryURL)
|
||||
|
||||
// Verify required fields
|
||||
if site.Context != "https://schema.org" {
|
||||
t.Errorf("Context = %q, want %q", site.Context, "https://schema.org")
|
||||
}
|
||||
if site.Type != "WebSite" {
|
||||
t.Errorf("Type = %q, want %q", site.Type, "WebSite")
|
||||
}
|
||||
if site.Name != "ATCR" {
|
||||
t.Errorf("Name = %q, want %q", site.Name, "ATCR")
|
||||
}
|
||||
if site.URL != "https://atcr.io" {
|
||||
t.Errorf("URL = %q, want %q", site.URL, "https://atcr.io")
|
||||
}
|
||||
|
||||
// Verify search action
|
||||
if site.PotentialAction == nil {
|
||||
t.Fatal("PotentialAction is nil")
|
||||
}
|
||||
if site.PotentialAction.Type != "SearchAction" {
|
||||
t.Errorf("PotentialAction.Type = %q, want %q", site.PotentialAction.Type, "SearchAction")
|
||||
}
|
||||
if site.PotentialAction.Target == nil {
|
||||
t.Fatal("PotentialAction.Target is nil")
|
||||
}
|
||||
expectedTemplate := "https://atcr.io/search?q={search_term_string}"
|
||||
if site.PotentialAction.Target.URLTemplate != expectedTemplate {
|
||||
t.Errorf("URLTemplate = %q, want %q", site.PotentialAction.Target.URLTemplate, expectedTemplate)
|
||||
}
|
||||
|
||||
// Verify it marshals to valid JSON
|
||||
data, err := json.Marshal(site)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal website: %v", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Error("Marshaled JSON is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewJSONLDSoftwareSourceCode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
registryURL string
|
||||
handle string
|
||||
repoName string
|
||||
description string
|
||||
license string
|
||||
sourceURL string
|
||||
}{
|
||||
{
|
||||
name: "full details",
|
||||
registryURL: "atcr.io",
|
||||
handle: "alice.bsky.social",
|
||||
repoName: "myapp",
|
||||
description: "A cool container image",
|
||||
license: "MIT",
|
||||
sourceURL: "https://github.com/alice/myapp",
|
||||
},
|
||||
{
|
||||
name: "minimal details",
|
||||
registryURL: "atcr.io",
|
||||
handle: "bob.test",
|
||||
repoName: "simple",
|
||||
description: "",
|
||||
license: "",
|
||||
sourceURL: "",
|
||||
},
|
||||
{
|
||||
name: "with license only",
|
||||
registryURL: "localhost:5000",
|
||||
handle: "dev",
|
||||
repoName: "test-image",
|
||||
description: "Test image",
|
||||
license: "Apache-2.0",
|
||||
sourceURL: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
code := NewJSONLDSoftwareSourceCode(
|
||||
tt.registryURL,
|
||||
tt.handle,
|
||||
tt.repoName,
|
||||
tt.description,
|
||||
tt.license,
|
||||
tt.sourceURL,
|
||||
)
|
||||
|
||||
// Verify required fields
|
||||
if code.Context != "https://schema.org" {
|
||||
t.Errorf("Context = %q, want %q", code.Context, "https://schema.org")
|
||||
}
|
||||
if code.Type != "SoftwareSourceCode" {
|
||||
t.Errorf("Type = %q, want %q", code.Type, "SoftwareSourceCode")
|
||||
}
|
||||
|
||||
expectedName := tt.handle + "/" + tt.repoName
|
||||
if code.Name != expectedName {
|
||||
t.Errorf("Name = %q, want %q", code.Name, expectedName)
|
||||
}
|
||||
|
||||
expectedCodeRepo := "https://" + tt.registryURL + "/r/" + tt.handle + "/" + tt.repoName
|
||||
if code.CodeRepository != expectedCodeRepo {
|
||||
t.Errorf("CodeRepository = %q, want %q", code.CodeRepository, expectedCodeRepo)
|
||||
}
|
||||
|
||||
// Verify author
|
||||
if code.Author == nil {
|
||||
t.Fatal("Author is nil")
|
||||
}
|
||||
if code.Author.Type != "Person" {
|
||||
t.Errorf("Author.Type = %q, want %q", code.Author.Type, "Person")
|
||||
}
|
||||
if code.Author.Name != tt.handle {
|
||||
t.Errorf("Author.Name = %q, want %q", code.Author.Name, tt.handle)
|
||||
}
|
||||
|
||||
// Verify publisher
|
||||
if code.Publisher == nil {
|
||||
t.Fatal("Publisher is nil")
|
||||
}
|
||||
if code.Publisher.Name != "ATCR" {
|
||||
t.Errorf("Publisher.Name = %q, want %q", code.Publisher.Name, "ATCR")
|
||||
}
|
||||
|
||||
// Verify optional fields
|
||||
if tt.license != "" && code.License != tt.license {
|
||||
t.Errorf("License = %q, want %q", code.License, tt.license)
|
||||
}
|
||||
if tt.license == "" && code.License != "" {
|
||||
t.Errorf("License = %q, want empty", code.License)
|
||||
}
|
||||
|
||||
if tt.sourceURL != "" && code.IsBasedOn != tt.sourceURL {
|
||||
t.Errorf("IsBasedOn = %q, want %q", code.IsBasedOn, tt.sourceURL)
|
||||
}
|
||||
if tt.sourceURL == "" && code.IsBasedOn != "" {
|
||||
t.Errorf("IsBasedOn = %q, want empty", code.IsBasedOn)
|
||||
}
|
||||
|
||||
// Verify it marshals to valid JSON
|
||||
data, err := json.Marshal(code)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal code: %v", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Error("Marshaled JSON is empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewJSONLDProfilePage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
registryURL string
|
||||
handle string
|
||||
avatar string
|
||||
}{
|
||||
{
|
||||
name: "with avatar",
|
||||
registryURL: "atcr.io",
|
||||
handle: "alice.bsky.social",
|
||||
avatar: "https://cdn.bsky.app/avatar/alice.jpg",
|
||||
},
|
||||
{
|
||||
name: "without avatar",
|
||||
registryURL: "atcr.io",
|
||||
handle: "bob.test",
|
||||
avatar: "",
|
||||
},
|
||||
{
|
||||
name: "localhost registry",
|
||||
registryURL: "localhost:5000",
|
||||
handle: "dev",
|
||||
avatar: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
page := NewJSONLDProfilePage(tt.registryURL, tt.handle, tt.avatar)
|
||||
|
||||
// Verify required fields
|
||||
if page.Context != "https://schema.org" {
|
||||
t.Errorf("Context = %q, want %q", page.Context, "https://schema.org")
|
||||
}
|
||||
if page.Type != "ProfilePage" {
|
||||
t.Errorf("Type = %q, want %q", page.Type, "ProfilePage")
|
||||
}
|
||||
|
||||
// Verify main entity
|
||||
if page.MainEntity == nil {
|
||||
t.Fatal("MainEntity is nil")
|
||||
}
|
||||
if page.MainEntity.Type != "Person" {
|
||||
t.Errorf("MainEntity.Type = %q, want %q", page.MainEntity.Type, "Person")
|
||||
}
|
||||
if page.MainEntity.Name != tt.handle {
|
||||
t.Errorf("MainEntity.Name = %q, want %q", page.MainEntity.Name, tt.handle)
|
||||
}
|
||||
|
||||
expectedURL := "https://" + tt.registryURL + "/u/" + tt.handle
|
||||
if page.MainEntity.URL != expectedURL {
|
||||
t.Errorf("MainEntity.URL = %q, want %q", page.MainEntity.URL, expectedURL)
|
||||
}
|
||||
|
||||
// Verify avatar handling
|
||||
if tt.avatar != "" && page.MainEntity.Image != tt.avatar {
|
||||
t.Errorf("MainEntity.Image = %q, want %q", page.MainEntity.Image, tt.avatar)
|
||||
}
|
||||
if tt.avatar == "" && page.MainEntity.Image != "" {
|
||||
t.Errorf("MainEntity.Image = %q, want empty", page.MainEntity.Image)
|
||||
}
|
||||
|
||||
// Verify it marshals to valid JSON
|
||||
data, err := json.Marshal(page)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal page: %v", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Error("Marshaled JSON is empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONLD_ValidJSON(t *testing.T) {
|
||||
// Test that all constructors produce valid JSON that can be unmarshaled
|
||||
registryURL := "atcr.io"
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
data any
|
||||
}{
|
||||
{"Organization", NewJSONLDOrganization(registryURL)},
|
||||
{"WebSite", NewJSONLDWebSite(registryURL)},
|
||||
{"SoftwareSourceCode", NewJSONLDSoftwareSourceCode(registryURL, "alice", "myapp", "desc", "MIT", "https://github.com/alice/myapp")},
|
||||
{"ProfilePage", NewJSONLDProfilePage(registryURL, "alice", "https://example.com/avatar.jpg")},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Marshal to JSON
|
||||
data, err := json.MarshalIndent(tc.data, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal %s: %v", tc.name, err)
|
||||
}
|
||||
|
||||
// Unmarshal back to verify it's valid JSON
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
t.Fatalf("Failed to unmarshal %s: %v\nJSON: %s", tc.name, err, string(data))
|
||||
}
|
||||
|
||||
// Verify @context is present
|
||||
if _, ok := result["@context"]; !ok {
|
||||
t.Errorf("%s missing @context field", tc.name)
|
||||
}
|
||||
|
||||
// Verify @type is present
|
||||
if _, ok := result["@type"]; !ok {
|
||||
t.Errorf("%s missing @type field", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,17 @@ type LearnMoreHandler struct {
|
||||
}
|
||||
|
||||
func (h *LearnMoreHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
meta := NewPageMeta(
|
||||
"About ATCR - Decentralized Container Registry on AT Protocol",
|
||||
"Learn how ATCR brings Docker container registries to the decentralized web using AT Protocol. Own your data, use your identity.",
|
||||
).WithCanonical("https://" + h.RegistryURL + "/learn-more")
|
||||
|
||||
data := struct {
|
||||
PageData
|
||||
Meta *PageMeta
|
||||
}{
|
||||
PageData: NewPageData(r, h.RegistryURL),
|
||||
Meta: meta,
|
||||
}
|
||||
|
||||
if err := h.Templates.ExecuteTemplate(w, "learn-more", data); err != nil {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
// LegalPageData contains data for legal pages (terms, privacy)
|
||||
type LegalPageData struct {
|
||||
PageData
|
||||
Meta *PageMeta
|
||||
CompanyName string
|
||||
Jurisdiction string
|
||||
}
|
||||
@@ -17,8 +18,14 @@ type PrivacyPolicyHandler struct {
|
||||
}
|
||||
|
||||
func (h *PrivacyPolicyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
meta := NewPageMeta(
|
||||
"Privacy Policy - ATCR",
|
||||
"ATCR privacy policy - how we collect, use, and protect your data on the decentralized container registry",
|
||||
).WithCanonical("https://" + h.RegistryURL + "/privacy")
|
||||
|
||||
data := LegalPageData{
|
||||
PageData: NewPageData(r, h.RegistryURL),
|
||||
Meta: meta,
|
||||
CompanyName: h.CompanyName,
|
||||
Jurisdiction: h.Jurisdiction,
|
||||
}
|
||||
@@ -35,8 +42,14 @@ type TermsOfServiceHandler struct {
|
||||
}
|
||||
|
||||
func (h *TermsOfServiceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
meta := NewPageMeta(
|
||||
"Terms of Service - ATCR",
|
||||
"ATCR terms of service - rules and guidelines for using the decentralized container registry",
|
||||
).WithCanonical("https://" + h.RegistryURL + "/terms")
|
||||
|
||||
data := LegalPageData{
|
||||
PageData: NewPageData(r, h.RegistryURL),
|
||||
Meta: meta,
|
||||
CompanyName: h.CompanyName,
|
||||
Jurisdiction: h.Jurisdiction,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package handlers
|
||||
|
||||
// PageMeta holds all metadata for a page's <head> section.
|
||||
// Use the builder methods to construct it with a fluent API.
|
||||
type PageMeta struct {
|
||||
Title string // Page title (required)
|
||||
Description string // Meta description (required)
|
||||
Canonical string // Canonical URL (optional)
|
||||
Robots string // Robots directive, e.g. "noindex" (optional, defaults to "index, follow")
|
||||
OGType string // OpenGraph type, defaults to "website"
|
||||
OGImage string // OpenGraph image URL (optional)
|
||||
TwitterCard string // Twitter card type, defaults to "summary_large_image"
|
||||
JSONLD []any // JSON-LD structured data objects (optional)
|
||||
}
|
||||
|
||||
// NewPageMeta creates a new PageMeta with required fields and sensible defaults.
|
||||
func NewPageMeta(title, description string) *PageMeta {
|
||||
return &PageMeta{
|
||||
Title: title,
|
||||
Description: description,
|
||||
OGType: "website",
|
||||
TwitterCard: "summary_large_image",
|
||||
}
|
||||
}
|
||||
|
||||
// WithCanonical sets the canonical URL.
|
||||
func (m *PageMeta) WithCanonical(url string) *PageMeta {
|
||||
m.Canonical = url
|
||||
return m
|
||||
}
|
||||
|
||||
// WithOGImage sets the OpenGraph image URL.
|
||||
func (m *PageMeta) WithOGImage(url string) *PageMeta {
|
||||
m.OGImage = url
|
||||
return m
|
||||
}
|
||||
|
||||
// WithOGType sets the OpenGraph type (e.g., "website", "profile", "article").
|
||||
func (m *PageMeta) WithOGType(ogType string) *PageMeta {
|
||||
m.OGType = ogType
|
||||
return m
|
||||
}
|
||||
|
||||
// WithRobots sets the robots meta directive (e.g., "noindex").
|
||||
func (m *PageMeta) WithRobots(robots string) *PageMeta {
|
||||
m.Robots = robots
|
||||
return m
|
||||
}
|
||||
|
||||
// WithJSONLD sets the JSON-LD structured data objects.
|
||||
func (m *PageMeta) WithJSONLD(data ...any) *PageMeta {
|
||||
m.JSONLD = data
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewPageMeta(t *testing.T) {
|
||||
title := "Test Page"
|
||||
description := "A test description"
|
||||
|
||||
meta := NewPageMeta(title, description)
|
||||
|
||||
if meta.Title != title {
|
||||
t.Errorf("Title = %q, want %q", meta.Title, title)
|
||||
}
|
||||
if meta.Description != description {
|
||||
t.Errorf("Description = %q, want %q", meta.Description, description)
|
||||
}
|
||||
if meta.OGType != "website" {
|
||||
t.Errorf("OGType = %q, want %q", meta.OGType, "website")
|
||||
}
|
||||
if meta.TwitterCard != "summary_large_image" {
|
||||
t.Errorf("TwitterCard = %q, want %q", meta.TwitterCard, "summary_large_image")
|
||||
}
|
||||
// Optional fields should be empty
|
||||
if meta.Canonical != "" {
|
||||
t.Errorf("Canonical = %q, want empty", meta.Canonical)
|
||||
}
|
||||
if meta.Robots != "" {
|
||||
t.Errorf("Robots = %q, want empty", meta.Robots)
|
||||
}
|
||||
if meta.OGImage != "" {
|
||||
t.Errorf("OGImage = %q, want empty", meta.OGImage)
|
||||
}
|
||||
if meta.JSONLD != nil {
|
||||
t.Errorf("JSONLD = %v, want nil", meta.JSONLD)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageMeta_WithCanonical(t *testing.T) {
|
||||
meta := NewPageMeta("Title", "Desc")
|
||||
canonical := "https://atcr.io/page"
|
||||
|
||||
result := meta.WithCanonical(canonical)
|
||||
|
||||
// Should return same pointer for chaining
|
||||
if result != meta {
|
||||
t.Error("WithCanonical should return same pointer")
|
||||
}
|
||||
if meta.Canonical != canonical {
|
||||
t.Errorf("Canonical = %q, want %q", meta.Canonical, canonical)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageMeta_WithOGImage(t *testing.T) {
|
||||
meta := NewPageMeta("Title", "Desc")
|
||||
image := "https://atcr.io/og-image.png"
|
||||
|
||||
result := meta.WithOGImage(image)
|
||||
|
||||
if result != meta {
|
||||
t.Error("WithOGImage should return same pointer")
|
||||
}
|
||||
if meta.OGImage != image {
|
||||
t.Errorf("OGImage = %q, want %q", meta.OGImage, image)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageMeta_WithOGType(t *testing.T) {
|
||||
meta := NewPageMeta("Title", "Desc")
|
||||
|
||||
result := meta.WithOGType("profile")
|
||||
|
||||
if result != meta {
|
||||
t.Error("WithOGType should return same pointer")
|
||||
}
|
||||
if meta.OGType != "profile" {
|
||||
t.Errorf("OGType = %q, want %q", meta.OGType, "profile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageMeta_WithRobots(t *testing.T) {
|
||||
meta := NewPageMeta("Title", "Desc")
|
||||
|
||||
result := meta.WithRobots("noindex, nofollow")
|
||||
|
||||
if result != meta {
|
||||
t.Error("WithRobots should return same pointer")
|
||||
}
|
||||
if meta.Robots != "noindex, nofollow" {
|
||||
t.Errorf("Robots = %q, want %q", meta.Robots, "noindex, nofollow")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageMeta_WithJSONLD(t *testing.T) {
|
||||
meta := NewPageMeta("Title", "Desc")
|
||||
|
||||
org := NewJSONLDOrganization("atcr.io")
|
||||
site := NewJSONLDWebSite("atcr.io")
|
||||
|
||||
result := meta.WithJSONLD(org, site)
|
||||
|
||||
if result != meta {
|
||||
t.Error("WithJSONLD should return same pointer")
|
||||
}
|
||||
if len(meta.JSONLD) != 2 {
|
||||
t.Errorf("JSONLD len = %d, want 2", len(meta.JSONLD))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageMeta_Chaining(t *testing.T) {
|
||||
// Test that all methods can be chained fluently
|
||||
meta := NewPageMeta("ATCR - Container Registry", "Decentralized container registry").
|
||||
WithCanonical("https://atcr.io/").
|
||||
WithOGImage("https://atcr.io/og.png").
|
||||
WithOGType("website").
|
||||
WithRobots("index, follow").
|
||||
WithJSONLD(NewJSONLDOrganization("atcr.io"))
|
||||
|
||||
if meta.Title != "ATCR - Container Registry" {
|
||||
t.Errorf("Title not set correctly after chaining")
|
||||
}
|
||||
if meta.Canonical != "https://atcr.io/" {
|
||||
t.Errorf("Canonical not set correctly after chaining")
|
||||
}
|
||||
if meta.OGImage != "https://atcr.io/og.png" {
|
||||
t.Errorf("OGImage not set correctly after chaining")
|
||||
}
|
||||
if meta.OGType != "website" {
|
||||
t.Errorf("OGType not set correctly after chaining")
|
||||
}
|
||||
if meta.Robots != "index, follow" {
|
||||
t.Errorf("Robots not set correctly after chaining")
|
||||
}
|
||||
if len(meta.JSONLD) != 1 {
|
||||
t.Errorf("JSONLD not set correctly after chaining")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageMeta_EmptyJSONLD(t *testing.T) {
|
||||
meta := NewPageMeta("Title", "Desc")
|
||||
|
||||
// Calling WithJSONLD with no args sets nil slice (variadic behavior)
|
||||
result := meta.WithJSONLD()
|
||||
|
||||
if result != meta {
|
||||
t.Error("WithJSONLD should return same pointer")
|
||||
}
|
||||
// Variadic with no args produces nil slice, which is fine
|
||||
// len(nil slice) == 0, so templates handle it correctly
|
||||
if len(meta.JSONLD) != 0 {
|
||||
t.Errorf("JSONLD len = %d, want 0", len(meta.JSONLD))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageMeta_RealWorldExamples(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
builder func() *PageMeta
|
||||
wantTitle string
|
||||
wantOGType string
|
||||
wantJSONLen int
|
||||
}{
|
||||
{
|
||||
name: "home page",
|
||||
builder: func() *PageMeta {
|
||||
return NewPageMeta(
|
||||
"ATCR - Decentralized Container Registry",
|
||||
"Push and pull Docker images with your AT Protocol identity",
|
||||
).
|
||||
WithCanonical("https://atcr.io/").
|
||||
WithJSONLD(
|
||||
NewJSONLDOrganization("atcr.io"),
|
||||
NewJSONLDWebSite("atcr.io"),
|
||||
)
|
||||
},
|
||||
wantTitle: "ATCR - Decentralized Container Registry",
|
||||
wantOGType: "website",
|
||||
wantJSONLen: 2,
|
||||
},
|
||||
{
|
||||
name: "user profile",
|
||||
builder: func() *PageMeta {
|
||||
return NewPageMeta(
|
||||
"alice.bsky.social - ATCR",
|
||||
"Container images by alice.bsky.social",
|
||||
).
|
||||
WithOGType("profile").
|
||||
WithOGImage("https://cdn.bsky.app/avatar.jpg").
|
||||
WithJSONLD(NewJSONLDProfilePage("atcr.io", "alice.bsky.social", "https://cdn.bsky.app/avatar.jpg"))
|
||||
},
|
||||
wantTitle: "alice.bsky.social - ATCR",
|
||||
wantOGType: "profile",
|
||||
wantJSONLen: 1,
|
||||
},
|
||||
{
|
||||
name: "repository page",
|
||||
builder: func() *PageMeta {
|
||||
return NewPageMeta(
|
||||
"alice.bsky.social/myapp - ATCR",
|
||||
"A cool container image",
|
||||
).
|
||||
WithCanonical("https://atcr.io/r/alice.bsky.social/myapp").
|
||||
WithJSONLD(NewJSONLDSoftwareSourceCode("atcr.io", "alice.bsky.social", "myapp", "A cool container image", "MIT", ""))
|
||||
},
|
||||
wantTitle: "alice.bsky.social/myapp - ATCR",
|
||||
wantOGType: "website",
|
||||
wantJSONLen: 1,
|
||||
},
|
||||
{
|
||||
name: "login page with noindex",
|
||||
builder: func() *PageMeta {
|
||||
return NewPageMeta("Login - ATCR", "Sign in with your AT Protocol identity").
|
||||
WithRobots("noindex")
|
||||
},
|
||||
wantTitle: "Login - ATCR",
|
||||
wantOGType: "website",
|
||||
wantJSONLen: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
meta := tt.builder()
|
||||
|
||||
if meta.Title != tt.wantTitle {
|
||||
t.Errorf("Title = %q, want %q", meta.Title, tt.wantTitle)
|
||||
}
|
||||
if meta.OGType != tt.wantOGType {
|
||||
t.Errorf("OGType = %q, want %q", meta.OGType, tt.wantOGType)
|
||||
}
|
||||
if len(meta.JSONLD) != tt.wantJSONLen {
|
||||
t.Errorf("JSONLD len = %d, want %d", len(meta.JSONLD), tt.wantJSONLen)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -229,8 +229,31 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
|
||||
artifactType = manifests[0].ArtifactType
|
||||
}
|
||||
|
||||
// Build page meta
|
||||
title := owner.Handle + "/" + repository + " - ATCR"
|
||||
if repo.Title != "" {
|
||||
title = repo.Title + " - ATCR"
|
||||
}
|
||||
description := "Container image " + owner.Handle + "/" + repository + " on ATCR"
|
||||
if repo.Description != "" {
|
||||
description = repo.Description
|
||||
}
|
||||
|
||||
meta := NewPageMeta(title, description).
|
||||
WithCanonical("https://" + h.RegistryURL + "/r/" + owner.Handle + "/" + repository).
|
||||
WithOGImage("https://" + h.RegistryURL + "/og/r/" + owner.Handle + "/" + repository).
|
||||
WithJSONLD(NewJSONLDSoftwareSourceCode(
|
||||
h.RegistryURL,
|
||||
owner.Handle,
|
||||
repository,
|
||||
description,
|
||||
repo.Licenses,
|
||||
repo.SourceURL,
|
||||
))
|
||||
|
||||
data := struct {
|
||||
PageData
|
||||
Meta *PageMeta
|
||||
Owner *db.User // Repository owner
|
||||
Repository *db.Repository // Repository summary
|
||||
Tags []db.TagWithPlatforms // Tags with platform info
|
||||
@@ -243,6 +266,7 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
|
||||
ArtifactType string // Dominant artifact type: container-image, helm-chart, unknown
|
||||
}{
|
||||
PageData: NewPageData(r, h.RegistryURL),
|
||||
Meta: meta,
|
||||
Owner: owner,
|
||||
Repository: repo,
|
||||
Tags: tagsWithPlatforms,
|
||||
|
||||
@@ -17,11 +17,25 @@ type SearchHandler struct {
|
||||
func (h *SearchHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query().Get("q")
|
||||
|
||||
// Build page meta
|
||||
title := "Search - ATCR"
|
||||
description := "Search for container images on ATCR, the decentralized container registry"
|
||||
canonical := "https://" + h.RegistryURL + "/search"
|
||||
if query != "" {
|
||||
title = "Search: " + query + " - ATCR"
|
||||
description = "Search results for '" + query + "' on ATCR container registry"
|
||||
canonical = "https://" + h.RegistryURL + "/search?q=" + query
|
||||
}
|
||||
|
||||
meta := NewPageMeta(title, description).WithCanonical(canonical)
|
||||
|
||||
data := struct {
|
||||
PageData
|
||||
Meta *PageMeta
|
||||
SearchQuery string
|
||||
}{
|
||||
PageData: NewPageData(r, h.RegistryURL),
|
||||
Meta: meta,
|
||||
SearchQuery: query,
|
||||
}
|
||||
|
||||
|
||||
@@ -116,8 +116,14 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
meta := NewPageMeta(
|
||||
"Settings - ATCR",
|
||||
"Manage your ATCR account settings, authorized devices, and storage preferences",
|
||||
).WithRobots("noindex")
|
||||
|
||||
data := struct {
|
||||
PageData
|
||||
Meta *PageMeta
|
||||
Profile struct {
|
||||
Handle string
|
||||
DID string
|
||||
@@ -136,6 +142,7 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
HoldDataJSON template.JS
|
||||
}{
|
||||
PageData: NewPageData(r, h.RegistryURL),
|
||||
Meta: meta,
|
||||
CurrentHoldDID: profile.DefaultHold,
|
||||
CurrentHoldDisplay: deriveDisplayName(profile.DefaultHold),
|
||||
ShowCurrentHold: showCurrentHold,
|
||||
|
||||
@@ -62,13 +62,25 @@ func (h *UserPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
db.SetRegistryURL(cards, h.RegistryURL)
|
||||
|
||||
// Build page meta
|
||||
meta := NewPageMeta(
|
||||
viewedUser.Handle+" - ATCR",
|
||||
"Container images by "+viewedUser.Handle+" on ATCR, the decentralized container registry",
|
||||
).
|
||||
WithCanonical("https://" + h.RegistryURL + "/u/" + viewedUser.Handle).
|
||||
WithOGImage("https://" + h.RegistryURL + "/og/u/" + viewedUser.Handle).
|
||||
WithOGType("profile").
|
||||
WithJSONLD(NewJSONLDProfilePage(h.RegistryURL, viewedUser.Handle, viewedUser.Avatar))
|
||||
|
||||
data := struct {
|
||||
PageData
|
||||
Meta *PageMeta
|
||||
ViewedUser *db.User // User whose page we're viewing
|
||||
Repositories []db.RepoCardData
|
||||
HasProfile bool
|
||||
}{
|
||||
PageData: NewPageData(r, h.RegistryURL),
|
||||
Meta: meta,
|
||||
ViewedUser: viewedUser,
|
||||
Repositories: cards,
|
||||
HasProfile: hasProfile,
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
{{ define "head" }}
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="index, follow">
|
||||
<meta name="theme-color" id="theme-color">
|
||||
<meta property="og:locale" content="en_US">
|
||||
|
||||
<!-- Favicons -->
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
/amathea_manatee-576w.webp 576w,
|
||||
/amathea_manatee-768w.webp 768w,
|
||||
/amathea_manatee-1152w.webp 1152w"
|
||||
sizes="(max-width: 767px) 384px, (max-width: 1023px) 448px, 576px"
|
||||
sizes="(max-width: 767px) 192px, (max-width: 1023px) 224px, 288px"
|
||||
type="image/webp">
|
||||
<img src="/amathea_manatee.png"
|
||||
width="1408" height="768"
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{{ define "meta" }}
|
||||
{{/* Title */}}
|
||||
<title>{{ .Title }}</title>
|
||||
|
||||
{{/* Basic meta */}}
|
||||
<meta name="description" content="{{ .Description }}">
|
||||
{{ if .Canonical }}<link rel="canonical" href="{{ .Canonical }}">{{ end }}
|
||||
{{ if .Robots }}<meta name="robots" content="{{ .Robots }}">{{ end }}
|
||||
|
||||
{{/* OpenGraph */}}
|
||||
<meta property="og:locale" content="en_US">
|
||||
<meta property="og:title" content="{{ .Title }}">
|
||||
<meta property="og:description" content="{{ .Description }}">
|
||||
<meta property="og:type" content="{{ or .OGType "website" }}">
|
||||
{{ if .Canonical }}<meta property="og:url" content="{{ .Canonical }}">{{ end }}
|
||||
{{ if .OGImage }}
|
||||
<meta property="og:image" content="{{ .OGImage }}">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
{{ end }}
|
||||
<meta property="og:site_name" content="ATCR">
|
||||
|
||||
{{/* Twitter Card */}}
|
||||
<meta name="twitter:card" content="{{ or .TwitterCard "summary_large_image" }}">
|
||||
<meta name="twitter:title" content="{{ .Title }}">
|
||||
<meta name="twitter:description" content="{{ .Description }}">
|
||||
{{ if .OGImage }}<meta name="twitter:image" content="{{ .OGImage }}">{{ end }}
|
||||
|
||||
{{/* JSON-LD */}}
|
||||
{{ range .JSONLD }}
|
||||
<script type="application/ld+json">
|
||||
{{ jsonld . }}
|
||||
</script>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
@@ -2,10 +2,8 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>404 - Lost at Sea | ATCR</title>
|
||||
<meta name="description" content="Page not found - the requested resource doesn't exist on ATCR">
|
||||
<meta name="robots" content="noindex">
|
||||
{{ template "head" . }}
|
||||
{{ template "meta" .Meta }}
|
||||
</head>
|
||||
<body>
|
||||
{{ template "nav-simple" . }}
|
||||
|
||||
@@ -2,53 +2,8 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>ATCR - Distributed Container Registry</title>
|
||||
{{ template "head" . }}
|
||||
<meta name="description" content="Push and pull Docker images on the AT Protocol. Same Docker, decentralized.">
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="ATCR - Distributed Container Registry">
|
||||
<meta property="og:description" content="Push and pull Docker images on the AT Protocol. Same Docker, decentralized.">
|
||||
<meta property="og:image" content="https://{{ .RegistryURL }}/og/home">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://{{ .RegistryURL }}">
|
||||
<meta property="og:site_name" content="ATCR">
|
||||
<!-- Twitter Card (used by Discord) -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="ATCR - Distributed Container Registry">
|
||||
<meta name="twitter:description" content="Push and pull Docker images on the AT Protocol. Same Docker, decentralized.">
|
||||
<meta name="twitter:image" content="https://{{ .RegistryURL }}/og/home">
|
||||
<link rel="canonical" href="https://{{ .RegistryURL }}/">
|
||||
<!-- JSON-LD Structured Data -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"name": "ATCR",
|
||||
"alternateName": "AT Protocol Container Registry",
|
||||
"url": "https://{{ .RegistryURL }}",
|
||||
"logo": "https://{{ .RegistryURL }}/favicon.svg",
|
||||
"description": "Decentralized container registry using AT Protocol. Push and pull Docker images with your AT Protocol identity.",
|
||||
"sameAs": []
|
||||
}
|
||||
</script>
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": "ATCR",
|
||||
"url": "https://{{ .RegistryURL }}",
|
||||
"potentialAction": {
|
||||
"@type": "SearchAction",
|
||||
"target": {
|
||||
"@type": "EntryPoint",
|
||||
"urlTemplate": "https://{{ .RegistryURL }}/search?q={search_term_string}"
|
||||
},
|
||||
"query-input": "required name=search_term_string"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{{ template "meta" .Meta }}
|
||||
{{ if not .User }}
|
||||
<!-- Preload LCP hero image -->
|
||||
<link rel="preload" as="image"
|
||||
@@ -57,7 +12,7 @@
|
||||
/amathea_manatee-576w.webp 576w,
|
||||
/amathea_manatee-768w.webp 768w,
|
||||
/amathea_manatee-1152w.webp 1152w"
|
||||
imagesizes="(max-width: 767px) 384px, (max-width: 1023px) 448px, 576px"
|
||||
imagesizes="(max-width: 767px) 192px, (max-width: 1023px) 224px, 288px"
|
||||
fetchpriority="high"
|
||||
type="image/webp">
|
||||
{{ end }}
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Install ATCR Credential Helper - ATCR</title>
|
||||
<meta name="description" content="Install the ATCR credential helper to push and pull containers using your AT Protocol identity">
|
||||
<link rel="canonical" href="https://{{ .RegistryURL }}/install">
|
||||
{{ template "head" . }}
|
||||
{{ template "meta" .Meta }}
|
||||
</head>
|
||||
<body>
|
||||
{{ template "nav" . }}
|
||||
|
||||
@@ -2,14 +2,8 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>About ATCR - Decentralized Container Registry on AT Protocol</title>
|
||||
<meta name="description" content="Learn how ATCR brings Docker container registries to the decentralized web using AT Protocol. Own your data, use your identity.">
|
||||
<link rel="canonical" href="https://{{ .RegistryURL }}/learn-more">
|
||||
<meta property="og:title" content="About ATCR - Decentralized Container Registry">
|
||||
<meta property="og:description" content="Docker meets the decentralized web. Push and pull container images using your AT Protocol identity.">
|
||||
<meta property="og:url" content="https://{{ .RegistryURL }}/learn-more">
|
||||
<meta property="og:type" content="website">
|
||||
{{ template "head" . }}
|
||||
{{ template "meta" .Meta }}
|
||||
</head>
|
||||
<body>
|
||||
{{ template "nav" . }}
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Login - ATCR</title>
|
||||
<meta name="description" content="Sign in to ATCR with your AT Protocol account to push and pull container images">
|
||||
<link rel="canonical" href="https://{{ .RegistryURL }}/login">
|
||||
{{ template "head" . }}
|
||||
{{ template "meta" .Meta }}
|
||||
</head>
|
||||
<body>
|
||||
{{ template "nav-simple" . }}
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Privacy Policy - ATCR</title>
|
||||
<meta name="description" content="ATCR privacy policy - how we collect, use, and protect your data on the decentralized container registry">
|
||||
<link rel="canonical" href="https://{{ .RegistryURL }}/privacy">
|
||||
{{ template "head" . }}
|
||||
{{ template "meta" .Meta }}
|
||||
</head>
|
||||
<body>
|
||||
{{ template "nav" . }}
|
||||
|
||||
@@ -2,46 +2,8 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{{ if .Repository.Title }}{{ .Repository.Title }}{{ else }}{{ .Owner.Handle }}/{{ .Repository.Name }}{{ end }} - ATCR</title>
|
||||
<meta name="description" content="{{ if .Repository.Description }}{{ .Repository.Description }}{{ else }}Container image {{ .Owner.Handle }}/{{ .Repository.Name }} on ATCR{{ end }}">
|
||||
<link rel="canonical" href="https://{{ .RegistryURL }}/r/{{ .Owner.Handle }}/{{ .Repository.Name }}">
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="{{ .Owner.Handle }}/{{ .Repository.Name }} - ATCR">
|
||||
<meta property="og:description" content="{{ if .Repository.Description }}{{ .Repository.Description }}{{ else }}Container image on ATCR{{ end }}">
|
||||
<meta property="og:image" content="https://{{ .RegistryURL }}/og/r/{{ .Owner.Handle }}/{{ .Repository.Name }}">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://{{ .RegistryURL }}/r/{{ .Owner.Handle }}/{{ .Repository.Name }}">
|
||||
<meta property="og:site_name" content="ATCR">
|
||||
<!-- Twitter Card (used by Discord) -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{{ .Owner.Handle }}/{{ .Repository.Name }} - ATCR">
|
||||
<meta name="twitter:description" content="{{ if .Repository.Description }}{{ .Repository.Description }}{{ else }}Container image on ATCR{{ end }}">
|
||||
<meta name="twitter:image" content="https://{{ .RegistryURL }}/og/r/{{ .Owner.Handle }}/{{ .Repository.Name }}">
|
||||
<!-- JSON-LD Structured Data -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareSourceCode",
|
||||
"name": "{{ .Owner.Handle }}/{{ .Repository.Name }}",
|
||||
"description": {{ if .Repository.Description }}"{{ .Repository.Description }}"{{ else }}"Container image on ATCR"{{ end }},
|
||||
"codeRepository": "https://{{ .RegistryURL }}/r/{{ .Owner.Handle }}/{{ .Repository.Name }}",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "{{ .Owner.Handle }}",
|
||||
"url": "https://{{ .RegistryURL }}/u/{{ .Owner.Handle }}"
|
||||
},
|
||||
"publisher": {
|
||||
"@type": "Organization",
|
||||
"name": "ATCR",
|
||||
"url": "https://{{ .RegistryURL }}"
|
||||
}{{ if .Repository.Licenses }},
|
||||
"license": "{{ .Repository.Licenses }}"{{ end }}{{ if .Repository.SourceURL }},
|
||||
"isBasedOn": "{{ .Repository.SourceURL }}"{{ end }}
|
||||
}
|
||||
</script>
|
||||
{{ template "head" . }}
|
||||
{{ template "meta" .Meta }}
|
||||
</head>
|
||||
<body>
|
||||
{{ template "nav" . }}
|
||||
|
||||
@@ -2,15 +2,8 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Search{{ if .SearchQuery }}: {{ .SearchQuery }}{{ end }} - ATCR</title>
|
||||
<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 }}">
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="Search{{ if .SearchQuery }}: {{ .SearchQuery }}{{ end }} - ATCR">
|
||||
<meta property="og:description" content="{{ if .SearchQuery }}Search results for '{{ .SearchQuery }}' on ATCR container registry{{ else }}Search for container images on ATCR{{ end }}">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://{{ .RegistryURL }}/search{{ if .SearchQuery }}?q={{ .SearchQuery }}{{ end }}">
|
||||
<meta property="og:site_name" content="ATCR">
|
||||
{{ template "head" . }}
|
||||
{{ template "meta" .Meta }}
|
||||
</head>
|
||||
<body>
|
||||
{{ template "nav" . }}
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Settings - ATCR</title>
|
||||
<meta name="description" content="Manage your ATCR account settings, authorized devices, and storage preferences">
|
||||
<meta name="robots" content="noindex">
|
||||
{{ template "head" . }}
|
||||
{{ template "meta" .Meta }}
|
||||
</head>
|
||||
<body>
|
||||
{{ template "nav" . }}
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Terms of Service - ATCR</title>
|
||||
<meta name="description" content="ATCR terms of service - rules and guidelines for using the decentralized container registry">
|
||||
<link rel="canonical" href="https://{{ .RegistryURL }}/terms">
|
||||
{{ template "head" . }}
|
||||
{{ template "meta" .Meta }}
|
||||
</head>
|
||||
<body>
|
||||
{{ template "nav" . }}
|
||||
|
||||
@@ -2,37 +2,8 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{{ .ViewedUser.Handle }} - ATCR</title>
|
||||
<meta name="description" content="Container images by {{ .ViewedUser.Handle }} on ATCR, the decentralized container registry">
|
||||
<link rel="canonical" href="https://{{ .RegistryURL }}/u/{{ .ViewedUser.Handle }}">
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="{{ .ViewedUser.Handle }} - ATCR">
|
||||
<meta property="og:description" content="Container images by {{ .ViewedUser.Handle }} on ATCR">
|
||||
<meta property="og:image" content="https://{{ .RegistryURL }}/og/u/{{ .ViewedUser.Handle }}">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
<meta property="og:type" content="profile">
|
||||
<meta property="og:url" content="https://{{ .RegistryURL }}/u/{{ .ViewedUser.Handle }}">
|
||||
<meta property="og:site_name" content="ATCR">
|
||||
<!-- Twitter Card (used by Discord) -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{{ .ViewedUser.Handle }} - ATCR">
|
||||
<meta name="twitter:description" content="Container images by {{ .ViewedUser.Handle }} on ATCR">
|
||||
<meta name="twitter:image" content="https://{{ .RegistryURL }}/og/u/{{ .ViewedUser.Handle }}">
|
||||
<!-- JSON-LD Structured Data -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "ProfilePage",
|
||||
"mainEntity": {
|
||||
"@type": "Person",
|
||||
"name": "{{ .ViewedUser.Handle }}",
|
||||
"url": "https://{{ .RegistryURL }}/u/{{ .ViewedUser.Handle }}"{{ if .ViewedUser.Avatar }},
|
||||
"image": "{{ .ViewedUser.Avatar }}"{{ end }}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{{ template "head" . }}
|
||||
{{ template "meta" .Meta }}
|
||||
</head>
|
||||
<body>
|
||||
{{ template "nav" . }}
|
||||
|
||||
+16
-1
@@ -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)
|
||||
|
||||
+169
-2
@@ -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 <script> tag)
|
||||
tmpl, err := Templates()
|
||||
if err != nil {
|
||||
t.Fatalf("Templates() error = %v", err)
|
||||
}
|
||||
|
||||
input := struct {
|
||||
Context string `json:"@context"`
|
||||
Name string `json:"name"`
|
||||
}{
|
||||
Context: "https://schema.org",
|
||||
Name: "Test",
|
||||
}
|
||||
|
||||
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, input)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to execute template: %v", err)
|
||||
}
|
||||
|
||||
got := buf.String()
|
||||
|
||||
// Check that lines after the first have 8-space prefix + 4-space indent
|
||||
lines := strings.Split(got, "\n")
|
||||
if len(lines) < 2 {
|
||||
t.Fatalf("Expected multi-line output, got: %s", got)
|
||||
}
|
||||
|
||||
// Second line should start with 8 spaces (prefix) + 4 spaces (indent) = 12 spaces
|
||||
if len(lines[1]) < 12 || lines[1][:12] != " " {
|
||||
t.Errorf("Expected line to start with 12 spaces (8 prefix + 4 indent), got: %q", lines[1])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user