···1010}
11111212func (h *LearnMoreHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
1313+ meta := NewPageMeta(
1414+ "About ATCR - Decentralized Container Registry on AT Protocol",
1515+ "Learn how ATCR brings Docker container registries to the decentralized web using AT Protocol. Own your data, use your identity.",
1616+ ).WithCanonical("https://" + h.RegistryURL + "/learn-more")
1717+1318 data := struct {
1419 PageData
2020+ Meta *PageMeta
1521 }{
1622 PageData: NewPageData(r, h.RegistryURL),
2323+ Meta: meta,
1724 }
18251926 if err := h.Templates.ExecuteTemplate(w, "learn-more", data); err != nil {
+13
pkg/appview/handlers/legal.go
···77// LegalPageData contains data for legal pages (terms, privacy)
88type LegalPageData struct {
99 PageData
1010+ Meta *PageMeta
1011 CompanyName string
1112 Jurisdiction string
1213}
···1718}
18191920func (h *PrivacyPolicyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
2121+ meta := NewPageMeta(
2222+ "Privacy Policy - ATCR",
2323+ "ATCR privacy policy - how we collect, use, and protect your data on the decentralized container registry",
2424+ ).WithCanonical("https://" + h.RegistryURL + "/privacy")
2525+2026 data := LegalPageData{
2127 PageData: NewPageData(r, h.RegistryURL),
2828+ Meta: meta,
2229 CompanyName: h.CompanyName,
2330 Jurisdiction: h.Jurisdiction,
2431 }
···3542}
36433744func (h *TermsOfServiceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
4545+ meta := NewPageMeta(
4646+ "Terms of Service - ATCR",
4747+ "ATCR terms of service - rules and guidelines for using the decentralized container registry",
4848+ ).WithCanonical("https://" + h.RegistryURL + "/terms")
4949+3850 data := LegalPageData{
3951 PageData: NewPageData(r, h.RegistryURL),
5252+ Meta: meta,
4053 CompanyName: h.CompanyName,
4154 Jurisdiction: h.Jurisdiction,
4255 }
+54
pkg/appview/handlers/meta.go
···11+package handlers
22+33+// PageMeta holds all metadata for a page's <head> section.
44+// Use the builder methods to construct it with a fluent API.
55+type PageMeta struct {
66+ Title string // Page title (required)
77+ Description string // Meta description (required)
88+ Canonical string // Canonical URL (optional)
99+ Robots string // Robots directive, e.g. "noindex" (optional, defaults to "index, follow")
1010+ OGType string // OpenGraph type, defaults to "website"
1111+ OGImage string // OpenGraph image URL (optional)
1212+ TwitterCard string // Twitter card type, defaults to "summary_large_image"
1313+ JSONLD []any // JSON-LD structured data objects (optional)
1414+}
1515+1616+// NewPageMeta creates a new PageMeta with required fields and sensible defaults.
1717+func NewPageMeta(title, description string) *PageMeta {
1818+ return &PageMeta{
1919+ Title: title,
2020+ Description: description,
2121+ OGType: "website",
2222+ TwitterCard: "summary_large_image",
2323+ }
2424+}
2525+2626+// WithCanonical sets the canonical URL.
2727+func (m *PageMeta) WithCanonical(url string) *PageMeta {
2828+ m.Canonical = url
2929+ return m
3030+}
3131+3232+// WithOGImage sets the OpenGraph image URL.
3333+func (m *PageMeta) WithOGImage(url string) *PageMeta {
3434+ m.OGImage = url
3535+ return m
3636+}
3737+3838+// WithOGType sets the OpenGraph type (e.g., "website", "profile", "article").
3939+func (m *PageMeta) WithOGType(ogType string) *PageMeta {
4040+ m.OGType = ogType
4141+ return m
4242+}
4343+4444+// WithRobots sets the robots meta directive (e.g., "noindex").
4545+func (m *PageMeta) WithRobots(robots string) *PageMeta {
4646+ m.Robots = robots
4747+ return m
4848+}
4949+5050+// WithJSONLD sets the JSON-LD structured data objects.
5151+func (m *PageMeta) WithJSONLD(data ...any) *PageMeta {
5252+ m.JSONLD = data
5353+ return m
5454+}
+237
pkg/appview/handlers/meta_test.go
···11+package handlers
22+33+import (
44+ "testing"
55+)
66+77+func TestNewPageMeta(t *testing.T) {
88+ title := "Test Page"
99+ description := "A test description"
1010+1111+ meta := NewPageMeta(title, description)
1212+1313+ if meta.Title != title {
1414+ t.Errorf("Title = %q, want %q", meta.Title, title)
1515+ }
1616+ if meta.Description != description {
1717+ t.Errorf("Description = %q, want %q", meta.Description, description)
1818+ }
1919+ if meta.OGType != "website" {
2020+ t.Errorf("OGType = %q, want %q", meta.OGType, "website")
2121+ }
2222+ if meta.TwitterCard != "summary_large_image" {
2323+ t.Errorf("TwitterCard = %q, want %q", meta.TwitterCard, "summary_large_image")
2424+ }
2525+ // Optional fields should be empty
2626+ if meta.Canonical != "" {
2727+ t.Errorf("Canonical = %q, want empty", meta.Canonical)
2828+ }
2929+ if meta.Robots != "" {
3030+ t.Errorf("Robots = %q, want empty", meta.Robots)
3131+ }
3232+ if meta.OGImage != "" {
3333+ t.Errorf("OGImage = %q, want empty", meta.OGImage)
3434+ }
3535+ if meta.JSONLD != nil {
3636+ t.Errorf("JSONLD = %v, want nil", meta.JSONLD)
3737+ }
3838+}
3939+4040+func TestPageMeta_WithCanonical(t *testing.T) {
4141+ meta := NewPageMeta("Title", "Desc")
4242+ canonical := "https://atcr.io/page"
4343+4444+ result := meta.WithCanonical(canonical)
4545+4646+ // Should return same pointer for chaining
4747+ if result != meta {
4848+ t.Error("WithCanonical should return same pointer")
4949+ }
5050+ if meta.Canonical != canonical {
5151+ t.Errorf("Canonical = %q, want %q", meta.Canonical, canonical)
5252+ }
5353+}
5454+5555+func TestPageMeta_WithOGImage(t *testing.T) {
5656+ meta := NewPageMeta("Title", "Desc")
5757+ image := "https://atcr.io/og-image.png"
5858+5959+ result := meta.WithOGImage(image)
6060+6161+ if result != meta {
6262+ t.Error("WithOGImage should return same pointer")
6363+ }
6464+ if meta.OGImage != image {
6565+ t.Errorf("OGImage = %q, want %q", meta.OGImage, image)
6666+ }
6767+}
6868+6969+func TestPageMeta_WithOGType(t *testing.T) {
7070+ meta := NewPageMeta("Title", "Desc")
7171+7272+ result := meta.WithOGType("profile")
7373+7474+ if result != meta {
7575+ t.Error("WithOGType should return same pointer")
7676+ }
7777+ if meta.OGType != "profile" {
7878+ t.Errorf("OGType = %q, want %q", meta.OGType, "profile")
7979+ }
8080+}
8181+8282+func TestPageMeta_WithRobots(t *testing.T) {
8383+ meta := NewPageMeta("Title", "Desc")
8484+8585+ result := meta.WithRobots("noindex, nofollow")
8686+8787+ if result != meta {
8888+ t.Error("WithRobots should return same pointer")
8989+ }
9090+ if meta.Robots != "noindex, nofollow" {
9191+ t.Errorf("Robots = %q, want %q", meta.Robots, "noindex, nofollow")
9292+ }
9393+}
9494+9595+func TestPageMeta_WithJSONLD(t *testing.T) {
9696+ meta := NewPageMeta("Title", "Desc")
9797+9898+ org := NewJSONLDOrganization("atcr.io")
9999+ site := NewJSONLDWebSite("atcr.io")
100100+101101+ result := meta.WithJSONLD(org, site)
102102+103103+ if result != meta {
104104+ t.Error("WithJSONLD should return same pointer")
105105+ }
106106+ if len(meta.JSONLD) != 2 {
107107+ t.Errorf("JSONLD len = %d, want 2", len(meta.JSONLD))
108108+ }
109109+}
110110+111111+func TestPageMeta_Chaining(t *testing.T) {
112112+ // Test that all methods can be chained fluently
113113+ meta := NewPageMeta("ATCR - Container Registry", "Decentralized container registry").
114114+ WithCanonical("https://atcr.io/").
115115+ WithOGImage("https://atcr.io/og.png").
116116+ WithOGType("website").
117117+ WithRobots("index, follow").
118118+ WithJSONLD(NewJSONLDOrganization("atcr.io"))
119119+120120+ if meta.Title != "ATCR - Container Registry" {
121121+ t.Errorf("Title not set correctly after chaining")
122122+ }
123123+ if meta.Canonical != "https://atcr.io/" {
124124+ t.Errorf("Canonical not set correctly after chaining")
125125+ }
126126+ if meta.OGImage != "https://atcr.io/og.png" {
127127+ t.Errorf("OGImage not set correctly after chaining")
128128+ }
129129+ if meta.OGType != "website" {
130130+ t.Errorf("OGType not set correctly after chaining")
131131+ }
132132+ if meta.Robots != "index, follow" {
133133+ t.Errorf("Robots not set correctly after chaining")
134134+ }
135135+ if len(meta.JSONLD) != 1 {
136136+ t.Errorf("JSONLD not set correctly after chaining")
137137+ }
138138+}
139139+140140+func TestPageMeta_EmptyJSONLD(t *testing.T) {
141141+ meta := NewPageMeta("Title", "Desc")
142142+143143+ // Calling WithJSONLD with no args sets nil slice (variadic behavior)
144144+ result := meta.WithJSONLD()
145145+146146+ if result != meta {
147147+ t.Error("WithJSONLD should return same pointer")
148148+ }
149149+ // Variadic with no args produces nil slice, which is fine
150150+ // len(nil slice) == 0, so templates handle it correctly
151151+ if len(meta.JSONLD) != 0 {
152152+ t.Errorf("JSONLD len = %d, want 0", len(meta.JSONLD))
153153+ }
154154+}
155155+156156+func TestPageMeta_RealWorldExamples(t *testing.T) {
157157+ tests := []struct {
158158+ name string
159159+ builder func() *PageMeta
160160+ wantTitle string
161161+ wantOGType string
162162+ wantJSONLen int
163163+ }{
164164+ {
165165+ name: "home page",
166166+ builder: func() *PageMeta {
167167+ return NewPageMeta(
168168+ "ATCR - Decentralized Container Registry",
169169+ "Push and pull Docker images with your AT Protocol identity",
170170+ ).
171171+ WithCanonical("https://atcr.io/").
172172+ WithJSONLD(
173173+ NewJSONLDOrganization("atcr.io"),
174174+ NewJSONLDWebSite("atcr.io"),
175175+ )
176176+ },
177177+ wantTitle: "ATCR - Decentralized Container Registry",
178178+ wantOGType: "website",
179179+ wantJSONLen: 2,
180180+ },
181181+ {
182182+ name: "user profile",
183183+ builder: func() *PageMeta {
184184+ return NewPageMeta(
185185+ "alice.bsky.social - ATCR",
186186+ "Container images by alice.bsky.social",
187187+ ).
188188+ WithOGType("profile").
189189+ WithOGImage("https://cdn.bsky.app/avatar.jpg").
190190+ WithJSONLD(NewJSONLDProfilePage("atcr.io", "alice.bsky.social", "https://cdn.bsky.app/avatar.jpg"))
191191+ },
192192+ wantTitle: "alice.bsky.social - ATCR",
193193+ wantOGType: "profile",
194194+ wantJSONLen: 1,
195195+ },
196196+ {
197197+ name: "repository page",
198198+ builder: func() *PageMeta {
199199+ return NewPageMeta(
200200+ "alice.bsky.social/myapp - ATCR",
201201+ "A cool container image",
202202+ ).
203203+ WithCanonical("https://atcr.io/r/alice.bsky.social/myapp").
204204+ WithJSONLD(NewJSONLDSoftwareSourceCode("atcr.io", "alice.bsky.social", "myapp", "A cool container image", "MIT", ""))
205205+ },
206206+ wantTitle: "alice.bsky.social/myapp - ATCR",
207207+ wantOGType: "website",
208208+ wantJSONLen: 1,
209209+ },
210210+ {
211211+ name: "login page with noindex",
212212+ builder: func() *PageMeta {
213213+ return NewPageMeta("Login - ATCR", "Sign in with your AT Protocol identity").
214214+ WithRobots("noindex")
215215+ },
216216+ wantTitle: "Login - ATCR",
217217+ wantOGType: "website",
218218+ wantJSONLen: 0,
219219+ },
220220+ }
221221+222222+ for _, tt := range tests {
223223+ t.Run(tt.name, func(t *testing.T) {
224224+ meta := tt.builder()
225225+226226+ if meta.Title != tt.wantTitle {
227227+ t.Errorf("Title = %q, want %q", meta.Title, tt.wantTitle)
228228+ }
229229+ if meta.OGType != tt.wantOGType {
230230+ t.Errorf("OGType = %q, want %q", meta.OGType, tt.wantOGType)
231231+ }
232232+ if len(meta.JSONLD) != tt.wantJSONLen {
233233+ t.Errorf("JSONLD len = %d, want %d", len(meta.JSONLD), tt.wantJSONLen)
234234+ }
235235+ })
236236+ }
237237+}