Date: Tue, 30 Sep 2025 16:45:22 +0800
Subject: [PATCH 08/14] Guard SEO profile and talk sections when data is nil
---
metal/cli/seo/sections.go | 12 ++++++++++++
metal/cli/seo/sections_test.go | 16 ++++++++++++++++
2 files changed, 28 insertions(+)
diff --git a/metal/cli/seo/sections.go b/metal/cli/seo/sections.go
index eb5e2aca..c4cbb98d 100644
--- a/metal/cli/seo/sections.go
+++ b/metal/cli/seo/sections.go
@@ -29,6 +29,10 @@ func (s *Sections) Categories(categories []string) template.HTML {
}
func (s *Sections) Profile(profile *payload.ProfileResponse) template.HTML {
+ if profile == nil {
+ return template.HTML("")
+ }
+
return "Profile
" +
template.HTML(""+
template.HTMLEscapeString(profile.Data.Name)+", "+
@@ -38,6 +42,10 @@ func (s *Sections) Profile(profile *payload.ProfileResponse) template.HTML {
}
func (s *Sections) Skills(profile *payload.ProfileResponse) template.HTML {
+ if profile == nil {
+ return template.HTML("")
+ }
+
var items []string
for _, item := range profile.Data.Skills {
@@ -52,6 +60,10 @@ func (s *Sections) Skills(profile *payload.ProfileResponse) template.HTML {
}
func (s *Sections) Talks(talks *payload.TalksResponse) template.HTML {
+ if talks == nil {
+ return template.HTML("")
+ }
+
var items []string
for _, item := range talks.Data {
diff --git a/metal/cli/seo/sections_test.go b/metal/cli/seo/sections_test.go
index 28e5d721..23428793 100644
--- a/metal/cli/seo/sections_test.go
+++ b/metal/cli/seo/sections_test.go
@@ -140,3 +140,19 @@ func TestSectionsRenderersEscapeContent(t *testing.T) {
}
}
}
+
+func TestSectionsGuardNilInputs(t *testing.T) {
+ sections := NewSections()
+
+ if html := sections.Profile(nil); html != template.HTML("") {
+ t.Fatalf("expected empty html for nil profile, got %q", html)
+ }
+
+ if html := sections.Skills(nil); html != template.HTML("") {
+ t.Fatalf("expected empty html for nil skills profile, got %q", html)
+ }
+
+ if html := sections.Talks(nil); html != template.HTML("") {
+ t.Fatalf("expected empty html for nil talks, got %q", html)
+ }
+}
From c2ddc2e662f3182f8c3471da54b698254e1b442a Mon Sep 17 00:00:00 2001
From: Gus
Date: Tue, 30 Sep 2025 17:13:56 +0800
Subject: [PATCH 09/14] Add high coverage tests for SEO generator
---
go.mod | 2 +
go.sum | 4 +
metal/cli/seo/generator_sqlite_test.go | 204 +++++++++++++++++++++++++
3 files changed, 210 insertions(+)
create mode 100644 metal/cli/seo/generator_sqlite_test.go
diff --git a/go.mod b/go.mod
index 58ef1463..db1fd299 100644
--- a/go.mod
+++ b/go.mod
@@ -54,6 +54,7 @@ require (
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
github.com/magiconair/properties v1.8.10 // indirect
+ github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.1.0 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
@@ -86,4 +87,5 @@ require (
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect
google.golang.org/grpc v1.74.2 // indirect
google.golang.org/protobuf v1.36.6 // indirect
+ gorm.io/driver/sqlite v1.6.0 // indirect
)
diff --git a/go.sum b/go.sum
index 0332d015..79754ff5 100644
--- a/go.sum
+++ b/go.sum
@@ -95,6 +95,8 @@ github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr32
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
+github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
+github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
@@ -236,6 +238,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
+gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
+gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.30.5 h1:dvEfYwxL+i+xgCNSGGBT1lDjCzfELK8fHZxL3Ee9X0s=
gorm.io/gorm v1.30.5/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
diff --git a/metal/cli/seo/generator_sqlite_test.go b/metal/cli/seo/generator_sqlite_test.go
new file mode 100644
index 00000000..b9b3b63e
--- /dev/null
+++ b/metal/cli/seo/generator_sqlite_test.go
@@ -0,0 +1,204 @@
+package seo
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+ "unsafe"
+
+ "github.com/go-playground/validator/v10"
+ "github.com/google/uuid"
+ "github.com/oullin/database"
+ "github.com/oullin/metal/env"
+ "github.com/oullin/pkg/portal"
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+)
+
+func TestNewGeneratorLoadsCategoriesFromDatabase(t *testing.T) {
+ withRepoRoot(t)
+
+ conn := newSQLiteConnection(t)
+ seedSQLiteCategories(t, conn, map[string]string{
+ "golang": "GoLang",
+ "cli": "CLI Tools",
+ })
+
+ environment := makeTestEnvironment(t, t.TempDir())
+ validator := portal.MakeValidatorFrom(validator.New(validator.WithRequiredStructEnabled()))
+
+ generator, err := NewGenerator(conn, environment, validator)
+ if err != nil {
+ t.Fatalf("new generator err: %v", err)
+ }
+
+ categories := map[string]bool{}
+ for _, item := range generator.Page.Categories {
+ categories[item] = true
+ }
+
+ if len(categories) != 2 {
+ t.Fatalf("expected two categories, got %v", generator.Page.Categories)
+ }
+
+ if !categories["golang"] || !categories["cli tools"] {
+ t.Fatalf("unexpected categories slice: %v", generator.Page.Categories)
+ }
+
+ if generator.Page.SiteName != environment.App.Name {
+ t.Fatalf("expected site name %q, got %q", environment.App.Name, generator.Page.SiteName)
+ }
+
+ if generator.Client == nil {
+ t.Fatalf("expected client to be initialized")
+ }
+}
+
+func TestGeneratorGenerateCreatesTemplates(t *testing.T) {
+ withRepoRoot(t)
+
+ conn := newSQLiteConnection(t)
+ seedSQLiteCategories(t, conn, map[string]string{
+ "golang": "GoLang",
+ "cli": "CLI Tools",
+ })
+
+ environment := makeTestEnvironment(t, t.TempDir())
+ validator := portal.MakeValidatorFrom(validator.New(validator.WithRequiredStructEnabled()))
+
+ generator, err := NewGenerator(conn, environment, validator)
+ if err != nil {
+ t.Fatalf("new generator err: %v", err)
+ }
+
+ if err := generator.Generate(); err != nil {
+ t.Fatalf("generate err: %v", err)
+ }
+
+ assertTemplateContains(t, filepath.Join(environment.Seo.SpaDir, "index.seo.html"), []string{
+ "talks
",
+ "cli tools",
+ })
+ assertTemplateContains(t, filepath.Join(environment.Seo.SpaDir, "about.seo.html"), []string{
+ "social
",
+ "recommendations
",
+ })
+ assertTemplateContains(t, filepath.Join(environment.Seo.SpaDir, "projects.seo.html"), []string{
+ "projects
",
+ })
+ assertTemplateContains(t, filepath.Join(environment.Seo.SpaDir, "resume.seo.html"), []string{
+ "experience
",
+ "education
",
+ })
+}
+
+func assertTemplateContains(t *testing.T, path string, substrings []string) {
+ t.Helper()
+
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read template %s: %v", path, err)
+ }
+
+ content := strings.ToLower(string(raw))
+ for _, fragment := range substrings {
+ if !strings.Contains(content, fragment) {
+ t.Fatalf("expected %s to contain %q, got %q", path, fragment, content)
+ }
+ }
+}
+
+func makeTestEnvironment(t *testing.T, spaDir string) *env.Environment {
+ t.Helper()
+
+ return &env.Environment{
+ App: env.AppEnvironment{
+ Name: "SEO Test Suite",
+ URL: "https://seo.example.test",
+ Type: "local",
+ MasterKey: strings.Repeat("m", 32),
+ },
+ DB: env.DBEnvironment{
+ UserName: "testaccount",
+ UserPassword: "secretpassw",
+ DatabaseName: "testdb",
+ Port: 5432,
+ Host: "localhost",
+ DriverName: "postgres",
+ SSLMode: "require",
+ TimeZone: "UTC",
+ },
+ Logs: env.LogsEnvironment{
+ Level: "info",
+ Dir: "logs",
+ DateFormat: "yyyy-mm",
+ },
+ Network: env.NetEnvironment{
+ HttpHost: "localhost",
+ HttpPort: "8080",
+ },
+ Sentry: env.SentryEnvironment{
+ DSN: "dsn",
+ CSP: "csp",
+ },
+ Ping: env.PingEnvironment{
+ Username: strings.Repeat("p", 16),
+ Password: strings.Repeat("s", 16),
+ },
+ Seo: env.SeoEnvironment{
+ SpaDir: spaDir,
+ },
+ }
+}
+
+func newSQLiteConnection(t *testing.T) *database.Connection {
+ t.Helper()
+
+ dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", uuid.NewString())
+
+ gdb, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
+ if err != nil {
+ t.Fatalf("open sqlite connection: %v", err)
+ }
+
+ conn := &database.Connection{}
+ setUnexportedField(t, conn, "driver", gdb)
+ setUnexportedField(t, conn, "driverName", "sqlite")
+
+ return conn
+}
+
+func seedSQLiteCategories(t *testing.T, conn *database.Connection, values map[string]string) {
+ t.Helper()
+
+ if err := conn.Sql().AutoMigrate(&database.Category{}); err != nil {
+ t.Fatalf("auto migrate categories: %v", err)
+ }
+
+ for slug, name := range values {
+ category := database.Category{
+ UUID: uuid.NewString(),
+ Slug: slug,
+ Name: name,
+ }
+
+ if err := conn.Sql().Create(&category).Error; err != nil {
+ t.Fatalf("create category %s: %v", slug, err)
+ }
+ }
+}
+
+func setUnexportedField(t *testing.T, target interface{}, field string, value interface{}) {
+ t.Helper()
+
+ rv := reflect.ValueOf(target).Elem()
+ fv := rv.FieldByName(field)
+ if !fv.IsValid() {
+ t.Fatalf("field %s does not exist", field)
+ }
+
+ reflect.NewAt(fv.Type(), unsafe.Pointer(fv.UnsafeAddr())).Elem().Set(reflect.ValueOf(value))
+}
From cd4550134a9cc5314fbeb7b169114d8c9981b7b4 Mon Sep 17 00:00:00 2001
From: Gus
Date: Tue, 30 Sep 2025 17:14:04 +0800
Subject: [PATCH 10/14] Remove SQLite-based generator test dependencies
---
go.mod | 2 -
go.sum | 4 -
metal/cli/seo/generator_sqlite_test.go | 204 -------------------------
3 files changed, 210 deletions(-)
delete mode 100644 metal/cli/seo/generator_sqlite_test.go
diff --git a/go.mod b/go.mod
index db1fd299..58ef1463 100644
--- a/go.mod
+++ b/go.mod
@@ -54,7 +54,6 @@ require (
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
github.com/magiconair/properties v1.8.10 // indirect
- github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.1.0 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
@@ -87,5 +86,4 @@ require (
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect
google.golang.org/grpc v1.74.2 // indirect
google.golang.org/protobuf v1.36.6 // indirect
- gorm.io/driver/sqlite v1.6.0 // indirect
)
diff --git a/go.sum b/go.sum
index 79754ff5..0332d015 100644
--- a/go.sum
+++ b/go.sum
@@ -95,8 +95,6 @@ github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr32
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
-github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
-github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
@@ -238,8 +236,6 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
-gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
-gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.30.5 h1:dvEfYwxL+i+xgCNSGGBT1lDjCzfELK8fHZxL3Ee9X0s=
gorm.io/gorm v1.30.5/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
diff --git a/metal/cli/seo/generator_sqlite_test.go b/metal/cli/seo/generator_sqlite_test.go
deleted file mode 100644
index b9b3b63e..00000000
--- a/metal/cli/seo/generator_sqlite_test.go
+++ /dev/null
@@ -1,204 +0,0 @@
-package seo
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "reflect"
- "strings"
- "testing"
- "unsafe"
-
- "github.com/go-playground/validator/v10"
- "github.com/google/uuid"
- "github.com/oullin/database"
- "github.com/oullin/metal/env"
- "github.com/oullin/pkg/portal"
- "gorm.io/driver/sqlite"
- "gorm.io/gorm"
-)
-
-func TestNewGeneratorLoadsCategoriesFromDatabase(t *testing.T) {
- withRepoRoot(t)
-
- conn := newSQLiteConnection(t)
- seedSQLiteCategories(t, conn, map[string]string{
- "golang": "GoLang",
- "cli": "CLI Tools",
- })
-
- environment := makeTestEnvironment(t, t.TempDir())
- validator := portal.MakeValidatorFrom(validator.New(validator.WithRequiredStructEnabled()))
-
- generator, err := NewGenerator(conn, environment, validator)
- if err != nil {
- t.Fatalf("new generator err: %v", err)
- }
-
- categories := map[string]bool{}
- for _, item := range generator.Page.Categories {
- categories[item] = true
- }
-
- if len(categories) != 2 {
- t.Fatalf("expected two categories, got %v", generator.Page.Categories)
- }
-
- if !categories["golang"] || !categories["cli tools"] {
- t.Fatalf("unexpected categories slice: %v", generator.Page.Categories)
- }
-
- if generator.Page.SiteName != environment.App.Name {
- t.Fatalf("expected site name %q, got %q", environment.App.Name, generator.Page.SiteName)
- }
-
- if generator.Client == nil {
- t.Fatalf("expected client to be initialized")
- }
-}
-
-func TestGeneratorGenerateCreatesTemplates(t *testing.T) {
- withRepoRoot(t)
-
- conn := newSQLiteConnection(t)
- seedSQLiteCategories(t, conn, map[string]string{
- "golang": "GoLang",
- "cli": "CLI Tools",
- })
-
- environment := makeTestEnvironment(t, t.TempDir())
- validator := portal.MakeValidatorFrom(validator.New(validator.WithRequiredStructEnabled()))
-
- generator, err := NewGenerator(conn, environment, validator)
- if err != nil {
- t.Fatalf("new generator err: %v", err)
- }
-
- if err := generator.Generate(); err != nil {
- t.Fatalf("generate err: %v", err)
- }
-
- assertTemplateContains(t, filepath.Join(environment.Seo.SpaDir, "index.seo.html"), []string{
- "talks
",
- "cli tools",
- })
- assertTemplateContains(t, filepath.Join(environment.Seo.SpaDir, "about.seo.html"), []string{
- "social
",
- "recommendations
",
- })
- assertTemplateContains(t, filepath.Join(environment.Seo.SpaDir, "projects.seo.html"), []string{
- "projects
",
- })
- assertTemplateContains(t, filepath.Join(environment.Seo.SpaDir, "resume.seo.html"), []string{
- "experience
",
- "education
",
- })
-}
-
-func assertTemplateContains(t *testing.T, path string, substrings []string) {
- t.Helper()
-
- raw, err := os.ReadFile(path)
- if err != nil {
- t.Fatalf("read template %s: %v", path, err)
- }
-
- content := strings.ToLower(string(raw))
- for _, fragment := range substrings {
- if !strings.Contains(content, fragment) {
- t.Fatalf("expected %s to contain %q, got %q", path, fragment, content)
- }
- }
-}
-
-func makeTestEnvironment(t *testing.T, spaDir string) *env.Environment {
- t.Helper()
-
- return &env.Environment{
- App: env.AppEnvironment{
- Name: "SEO Test Suite",
- URL: "https://seo.example.test",
- Type: "local",
- MasterKey: strings.Repeat("m", 32),
- },
- DB: env.DBEnvironment{
- UserName: "testaccount",
- UserPassword: "secretpassw",
- DatabaseName: "testdb",
- Port: 5432,
- Host: "localhost",
- DriverName: "postgres",
- SSLMode: "require",
- TimeZone: "UTC",
- },
- Logs: env.LogsEnvironment{
- Level: "info",
- Dir: "logs",
- DateFormat: "yyyy-mm",
- },
- Network: env.NetEnvironment{
- HttpHost: "localhost",
- HttpPort: "8080",
- },
- Sentry: env.SentryEnvironment{
- DSN: "dsn",
- CSP: "csp",
- },
- Ping: env.PingEnvironment{
- Username: strings.Repeat("p", 16),
- Password: strings.Repeat("s", 16),
- },
- Seo: env.SeoEnvironment{
- SpaDir: spaDir,
- },
- }
-}
-
-func newSQLiteConnection(t *testing.T) *database.Connection {
- t.Helper()
-
- dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", uuid.NewString())
-
- gdb, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
- if err != nil {
- t.Fatalf("open sqlite connection: %v", err)
- }
-
- conn := &database.Connection{}
- setUnexportedField(t, conn, "driver", gdb)
- setUnexportedField(t, conn, "driverName", "sqlite")
-
- return conn
-}
-
-func seedSQLiteCategories(t *testing.T, conn *database.Connection, values map[string]string) {
- t.Helper()
-
- if err := conn.Sql().AutoMigrate(&database.Category{}); err != nil {
- t.Fatalf("auto migrate categories: %v", err)
- }
-
- for slug, name := range values {
- category := database.Category{
- UUID: uuid.NewString(),
- Slug: slug,
- Name: name,
- }
-
- if err := conn.Sql().Create(&category).Error; err != nil {
- t.Fatalf("create category %s: %v", slug, err)
- }
- }
-}
-
-func setUnexportedField(t *testing.T, target interface{}, field string, value interface{}) {
- t.Helper()
-
- rv := reflect.ValueOf(target).Elem()
- fv := rv.FieldByName(field)
- if !fv.IsValid() {
- t.Fatalf("field %s does not exist", field)
- }
-
- reflect.NewAt(fv.Type(), unsafe.Pointer(fv.UnsafeAddr())).Elem().Set(reflect.ValueOf(value))
-}
From 635964eb13e88eee4f4190ead652d152d19e5e69 Mon Sep 17 00:00:00 2001
From: Gus
Date: Wed, 1 Oct 2025 10:14:55 +0800
Subject: [PATCH 11/14] Add SEO generation for published posts
---
metal/cli/seo/defaults.go | 3 +
metal/cli/seo/generator.go | 145 ++++++++++++++++++++++++++++--
metal/cli/seo/generator_test.go | 32 ++++++-
metal/cli/seo/manifest.go | 6 ++
metal/cli/seo/sections.go | 101 +++++++++++++++++++++
metal/cli/seo/sections_test.go | 36 ++++++++
metal/cli/seo/testhelpers_test.go | 93 ++++++++++++++++++-
7 files changed, 405 insertions(+), 11 deletions(-)
diff --git a/metal/cli/seo/defaults.go b/metal/cli/seo/defaults.go
index 753bf9ff..d3425d57 100644
--- a/metal/cli/seo/defaults.go
+++ b/metal/cli/seo/defaults.go
@@ -22,6 +22,9 @@ const WebResumeUrl = "/resume"
const WebProjectsName = "Projects"
const WebProjectsUrl = "/projects"
+const WebPostsName = "Posts"
+const WebPostsUrl = "/posts"
+
// --- Web Meta
const FoundedYear = 2020
diff --git a/metal/cli/seo/generator.go b/metal/cli/seo/generator.go
index acbb3031..2cf73ae1 100644
--- a/metal/cli/seo/generator.go
+++ b/metal/cli/seo/generator.go
@@ -5,9 +5,11 @@ import (
"embed"
"fmt"
"html/template"
+ "net/url"
"os"
"path/filepath"
"strings"
+ "unicode/utf8"
"github.com/oullin/database"
"github.com/oullin/handler/payload"
@@ -94,6 +96,10 @@ func (g *Generator) Generate() error {
return err
}
+ if err = g.GeneratePosts(); err != nil {
+ return err
+ }
+
return nil
}
@@ -237,6 +243,49 @@ func (g *Generator) GenerateResume() error {
return nil
}
+func (g *Generator) GeneratePosts() error {
+ var posts []database.Post
+
+ err := g.DB.Sql().
+ Model(&database.Post{}).
+ Preload("Author").
+ Preload("Categories").
+ Preload("Tags").
+ Where("posts.published_at IS NOT NULL").
+ Where("posts.deleted_at IS NULL").
+ Order("posts.published_at DESC").
+ Find(&posts).Error
+ if err != nil {
+ return fmt.Errorf("posts: fetching published posts: %w", err)
+ }
+
+ if len(posts) == 0 {
+ cli.Grayln("No published posts available for SEO generation")
+ return nil
+ }
+
+ sections := NewSections()
+
+ for _, post := range posts {
+ response := payload.GetPostsResponse(post)
+ body := []template.HTML{sections.Post(&response)}
+
+ data, buildErr := g.buildForPost(response, body)
+ if buildErr != nil {
+ return fmt.Errorf("posts: building seo for %s: %w", response.Slug, buildErr)
+ }
+
+ origin := filepath.Join("posts", response.Slug)
+ if err = g.Export(origin, data); err != nil {
+ return fmt.Errorf("posts: exporting %s: %w", response.Slug, err)
+ }
+
+ cli.Successln(fmt.Sprintf("Post SEO template generated for %s", response.Slug))
+ }
+
+ return nil
+}
+
func (g *Generator) Export(origin string, data TemplateData) error {
var err error
var buffer bytes.Buffer
@@ -247,12 +296,11 @@ func (g *Generator) Export(origin string, data TemplateData) error {
return fmt.Errorf("%s: rendering template: %w", fileName, err)
}
- cli.Cyanln(fmt.Sprintf("Working on directory: %s", g.Page.OutputDir))
- if err = os.MkdirAll(g.Page.OutputDir, 0o755); err != nil {
- return fmt.Errorf("%s: creating directory for %s: %w", fileName, g.Page.OutputDir, err)
- }
-
out := filepath.Join(g.Page.OutputDir, fileName)
+ cli.Cyanln(fmt.Sprintf("Working on directory: %s", filepath.Dir(out)))
+ if err = os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
+ return fmt.Errorf("%s: creating directory for %s: %w", fileName, filepath.Dir(out), err)
+ }
cli.Blueln(fmt.Sprintf("Writing file on: %s", out))
if err = os.WriteFile(out, buffer.Bytes(), 0o644); err != nil {
return fmt.Errorf("%s: writing %s: %w", fileName, out, err)
@@ -264,7 +312,7 @@ func (g *Generator) Export(origin string, data TemplateData) error {
return nil
}
-func (g *Generator) buildForPage(pageName, path string, body []template.HTML) (TemplateData, error) {
+func (g *Generator) buildForPage(pageName, path string, body []template.HTML, opts ...func(*TemplateData)) (TemplateData, error) {
og := TagOgData{
ImageHeight: "630",
ImageWidth: "1200",
@@ -312,6 +360,10 @@ func (g *Generator) buildForPage(pageName, path string, body []template.HTML) (T
data.Title = g.titleFor(pageName)
data.Manifest = NewManifest(g.Page, data).Render()
+ for _, opt := range opts {
+ opt(&data)
+ }
+
if _, err := g.Validator.Rejects(og); err != nil {
return TemplateData{}, fmt.Errorf("invalid og data: %s", g.Validator.GetErrorsAsJson())
}
@@ -368,3 +420,84 @@ func (g *Generator) titleFor(pageName string) string {
return fmt.Sprintf("%s · %s", pageName, g.Page.SiteName)
}
+
+func (g *Generator) buildForPost(post payload.PostResponse, body []template.HTML) (TemplateData, error) {
+ path := canonicalPostPath(post.Slug)
+ description := sanitizeMetaDescription(post.Excerpt, Description)
+ image := preferredImageURL(post.CoverImageURL, g.Page.AboutPhotoUrl)
+ imageAlt := sanitizeAltText(post.Title, g.Page.SiteName)
+
+ return g.buildForPage(post.Title, path, body, func(data *TemplateData) {
+ data.Description = description
+ data.OGTagOg.Image = image
+ data.OGTagOg.ImageAlt = imageAlt
+ data.Twitter.Image = image
+ data.Twitter.ImageAlt = imageAlt
+ })
+}
+
+func canonicalPostPath(slug string) string {
+ cleaned := strings.TrimSpace(slug)
+ cleaned = strings.Trim(cleaned, "/")
+
+ if cleaned == "" {
+ return WebPostsUrl
+ }
+
+ return WebPostsUrl + "/" + cleaned
+}
+
+func sanitizeMetaDescription(raw, fallback string) string {
+ trimmed := strings.TrimSpace(strings.ReplaceAll(raw, "\n", " "))
+ if trimmed == "" {
+ return fallback
+ }
+
+ condensed := strings.Join(strings.Fields(trimmed), " ")
+ escaped := template.HTMLEscapeString(condensed)
+
+ if utf8.RuneCountInString(escaped) < 10 {
+ return fallback
+ }
+
+ return escaped
+}
+
+func preferredImageURL(candidate, fallback string) string {
+ candidate = strings.TrimSpace(candidate)
+ if candidate == "" {
+ return fallback
+ }
+
+ parsed, err := url.ParseRequestURI(candidate)
+ if err != nil {
+ return fallback
+ }
+
+ if parsed.Scheme != "http" && parsed.Scheme != "https" {
+ return fallback
+ }
+
+ return candidate
+}
+
+func sanitizeAltText(title, site string) string {
+ base := strings.TrimSpace(title)
+ if base == "" {
+ base = site
+ }
+
+ alt := strings.Join(strings.Fields(base+" cover image"), " ")
+ escaped := template.HTMLEscapeString(alt)
+
+ if utf8.RuneCountInString(escaped) < 10 {
+ fallback := template.HTMLEscapeString(site + " cover image")
+ if utf8.RuneCountInString(fallback) < 10 {
+ return "SEO cover image"
+ }
+
+ return fallback
+ }
+
+ return escaped
+}
diff --git a/metal/cli/seo/generator_test.go b/metal/cli/seo/generator_test.go
index 097c6627..0bb08793 100644
--- a/metal/cli/seo/generator_test.go
+++ b/metal/cli/seo/generator_test.go
@@ -109,10 +109,20 @@ func TestGeneratorBuildRejectsInvalidTemplateData(t *testing.T) {
func TestGeneratorGenerateAllPages(t *testing.T) {
withRepoRoot(t)
- conn, env := newPostgresConnection(t, &database.Category{})
-
- seedCategory(t, conn, "golang", "GoLang")
- seedCategory(t, conn, "cli", "CLI Tools")
+ conn, env := newPostgresConnection(t,
+ &database.User{},
+ &database.Post{},
+ &database.Category{},
+ &database.PostCategory{},
+ &database.Tag{},
+ &database.PostTag{},
+ )
+
+ goCategory := seedCategory(t, conn, "golang", "GoLang")
+ _ = seedCategory(t, conn, "cli", "CLI Tools")
+ author := seedUser(t, conn, "Gustavo", "Canto", "gocanto")
+ tag := seedTag(t, conn, "golang", "GoLang")
+ post := seedPost(t, conn, author, goCategory, tag, "building-apis", "Building APIs")
gen, err := NewGenerator(conn, env, newTestValidator(t))
if err != nil {
@@ -179,4 +189,18 @@ func TestGeneratorGenerateAllPages(t *testing.T) {
if !strings.Contains(resumeContent, "education
") {
t.Fatalf("expected education section in resume page: %q", resumeContent)
}
+
+ postPath := filepath.Join(env.Seo.SpaDir, "posts", post.Slug+".seo.html")
+ postRaw, err := os.ReadFile(postPath)
+ if err != nil {
+ t.Fatalf("read post output: %v", err)
+ }
+
+ postContent := string(postRaw)
+ if !strings.Contains(postContent, "Building <APIs>
") {
+ t.Fatalf("expected escaped post title in seo output: %q", postContent)
+ }
+ if !strings.Contains(postContent, "Second paragraph & details.") {
+ t.Fatalf("expected post body content in seo output: %q", postContent)
+ }
}
diff --git a/metal/cli/seo/manifest.go b/metal/cli/seo/manifest.go
index 9700224b..ace47611 100644
--- a/metal/cli/seo/manifest.go
+++ b/metal/cli/seo/manifest.go
@@ -74,6 +74,12 @@ func NewManifest(tmpl Page, data TemplateData) *Manifest {
Name: WebProjectsName,
ShortName: WebProjectsName,
},
+ {
+ Icons: icons,
+ URL: WebPostsUrl,
+ Name: WebPostsName,
+ ShortName: WebPostsName,
+ },
{
Icons: icons,
URL: WebAboutUrl,
diff --git a/metal/cli/seo/sections.go b/metal/cli/seo/sections.go
index c4cbb98d..ff91e04a 100644
--- a/metal/cli/seo/sections.go
+++ b/metal/cli/seo/sections.go
@@ -118,6 +118,79 @@ func (s *Sections) Projects(projects *payload.ProjectsResponse) template.HTML {
)
}
+func (s *Sections) Post(post *payload.PostResponse) template.HTML {
+ if post == nil {
+ return template.HTML("")
+ }
+
+ title := template.HTMLEscapeString(post.Title)
+
+ authorName := strings.TrimSpace(post.Author.DisplayName)
+ if authorName == "" {
+ fullName := strings.TrimSpace(strings.Join(filterNonEmpty([]string{post.Author.FirstName, post.Author.LastName}), " "))
+ if fullName != "" {
+ authorName = fullName
+ } else {
+ authorName = strings.TrimSpace(post.Author.Username)
+ }
+ }
+
+ authorName = template.HTMLEscapeString(authorName)
+
+ var metaParts []string
+ if authorName != "" {
+ metaParts = append(metaParts, "By "+authorName)
+ }
+
+ if post.PublishedAt != nil {
+ published := post.PublishedAt.UTC().Format("02 Jan 2006")
+ metaParts = append(metaParts, "Published "+template.HTMLEscapeString(published))
+ }
+
+ if len(post.Categories) > 0 {
+ var names []string
+ for _, category := range post.Categories {
+ name := strings.TrimSpace(category.Name)
+ if name != "" {
+ names = append(names, template.HTMLEscapeString(name))
+ }
+ }
+ if len(names) > 0 {
+ metaParts = append(metaParts, "Categories: "+strings.Join(names, ", "))
+ }
+ }
+
+ if len(post.Tags) > 0 {
+ var names []string
+ for _, tag := range post.Tags {
+ name := strings.TrimSpace(tag.Name)
+ if name != "" {
+ names = append(names, template.HTMLEscapeString(name))
+ }
+ }
+ if len(names) > 0 {
+ metaParts = append(metaParts, "Tags: "+strings.Join(names, ", "))
+ }
+ }
+
+ metaHTML := ""
+ if len(metaParts) > 0 {
+ metaHTML = "" + strings.Join(metaParts, " | ") + "
"
+ }
+
+ excerpt := strings.TrimSpace(post.Excerpt)
+ excerptHTML := ""
+ if excerpt != "" {
+ escaped := template.HTMLEscapeString(strings.ReplaceAll(excerpt, "\r\n", "\n"))
+ escaped = strings.ReplaceAll(escaped, "\n", "
")
+ excerptHTML = "" + allowLineBreaks(escaped) + "
"
+ }
+
+ contentHTML := formatPostContent(post.Content)
+
+ return template.HTML("" + title + "
" + metaHTML + excerptHTML + contentHTML)
+}
+
func (s *Sections) Social(social *payload.SocialResponse) template.HTML {
if social == nil {
return template.HTML("Social
")
@@ -305,6 +378,34 @@ func (s *Sections) Education(edu *payload.EducationResponse) template.HTML {
)
}
+func formatPostContent(content string) string {
+ trimmed := strings.TrimSpace(strings.ReplaceAll(content, "\r\n", "\n"))
+ if trimmed == "" {
+ return ""
+ }
+
+ rawParagraphs := strings.Split(trimmed, "\n\n")
+ var rendered []string
+
+ for _, paragraph := range rawParagraphs {
+ paragraph = strings.TrimSpace(paragraph)
+ if paragraph == "" {
+ continue
+ }
+
+ escaped := template.HTMLEscapeString(paragraph)
+ escaped = strings.ReplaceAll(escaped, "\n", "
")
+ escaped = allowLineBreaks(escaped)
+ rendered = append(rendered, ""+escaped+"
")
+ }
+
+ if len(rendered) == 0 {
+ return ""
+ }
+
+ return strings.Join(rendered, "")
+}
+
func formatDetails(parts []string) string {
filtered := filterNonEmpty(parts)
diff --git a/metal/cli/seo/sections_test.go b/metal/cli/seo/sections_test.go
index 23428793..fb2dcb4a 100644
--- a/metal/cli/seo/sections_test.go
+++ b/metal/cli/seo/sections_test.go
@@ -4,6 +4,7 @@ import (
"html/template"
"strings"
"testing"
+ "time"
"github.com/oullin/handler/payload"
)
@@ -79,6 +80,22 @@ func TestSectionsRenderersEscapeContent(t *testing.T) {
categories := []string{"Go", "CLI"}
+ publishedAt := time.Date(2024, time.January, 15, 0, 0, 0, 0, time.UTC)
+ post := &payload.PostResponse{
+ Title: "Building ",
+ Excerpt: "Learn \nwith examples",
+ Content: "Intro paragraph with \nmore info.\n\nSecond paragraph & details.",
+ Author: payload.UserResponse{
+ DisplayName: "Gus ",
+ Username: "gocanto