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

This commit is contained in:
Evan Jarrett
2026-01-18 16:54:03 -06:00
parent d8b0305ce8
commit 536fa416d4
30 changed files with 1096 additions and 156 deletions
+7
View File
@@ -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"),
}
+7
View File
@@ -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 {
+12 -1
View File
@@ -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,
}
+7
View File
@@ -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 {
+152
View File
@@ -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,
}
}
+310
View File
@@ -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)
}
})
}
}
+7
View File
@@ -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 {
+13
View File
@@ -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,
}
+54
View File
@@ -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
}
+237
View File
@@ -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)
}
})
}
}
+24
View File
@@ -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,
+14
View File
@@ -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,
}
+7
View File
@@ -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,
+12
View File
@@ -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" />
+1 -1
View File
@@ -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 }}
+1 -3
View File
@@ -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 -47
View File
@@ -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 }}
+1 -3
View File
@@ -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" . }}
+1 -7
View File
@@ -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" . }}
+1 -3
View File
@@ -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" . }}
+1 -3
View File
@@ -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" . }}
+1 -39
View File
@@ -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" . }}
+1 -8
View File
@@ -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" . }}
+1 -3
View File
@@ -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" . }}
+1 -3
View File
@@ -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" . }}
+1 -30
View File
@@ -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
View File
@@ -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
View File
@@ -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])
}
}