From ecd46791151c26a30caaefb30588f2682755cc53 Mon Sep 17 00:00:00 2001 From: Gus Date: Wed, 15 Oct 2025 10:13:22 +0800 Subject: [PATCH 1/5] Split SEO generation menu options --- metal/cli/main.go | 37 ++++++++++++++++++++++++++------- metal/cli/panel/menu.go | 5 +++-- metal/cli/seo/generator.go | 16 +++++++++++--- metal/cli/seo/generator_test.go | 8 +++++-- 4 files changed, 51 insertions(+), 15 deletions(-) diff --git a/metal/cli/main.go b/metal/cli/main.go index 39aef3a8..8207db62 100644 --- a/metal/cli/main.go +++ b/metal/cli/main.go @@ -67,13 +67,20 @@ func main() { return case 4: - if err = generateSEO(); err != nil { + if err = generateStaticSEO(); err != nil { cli.Errorln(err.Error()) continue } return case 5: + if err = generatePostsSEO(); err != nil { + cli.Errorln(err.Error()) + continue + } + + return + case 6: if err = printTimestamp(); err != nil { cli.Errorln(err.Error()) continue @@ -150,7 +157,25 @@ func showApiAccount(menu panel.Menu) error { return nil } -func generateSEO() error { +func generateStaticSEO() error { + gen, err := newSEOGenerator() + if err != nil { + return err + } + + return gen.GenerateStaticPages() +} + +func generatePostsSEO() error { + gen, err := newSEOGenerator() + if err != nil { + return err + } + + return gen.GeneratePosts() +} + +func newSEOGenerator() (*seo.Generator, error) { gen, err := seo.NewGenerator( dbConn, environment, @@ -158,14 +183,10 @@ func generateSEO() error { ) if err != nil { - return err + return nil, err } - if err = gen.Generate(); err != nil { - return err - } - - return nil + return gen, nil } func printTimestamp() error { diff --git a/metal/cli/panel/menu.go b/metal/cli/panel/menu.go index 99225ef9..fbdb5cd2 100644 --- a/metal/cli/panel/menu.go +++ b/metal/cli/panel/menu.go @@ -89,8 +89,9 @@ func (p *Menu) Print() { p.PrintOption("1) Parse Blog Posts.", inner) p.PrintOption("2) Create new API account.", inner) p.PrintOption("3) Show API accounts.", inner) - p.PrintOption("4) Generate SEO.", inner) - p.PrintOption("5) Print Timestamp.", inner) + p.PrintOption("4) Generate SEO for static pages.", inner) + p.PrintOption("5) Generate SEO for blog posts.", inner) + p.PrintOption("6) Print Timestamp.", inner) p.PrintOption(" ", inner) p.PrintOption("0) Exit.", inner) diff --git a/metal/cli/seo/generator.go b/metal/cli/seo/generator.go index baf0b10a..9b465883 100644 --- a/metal/cli/seo/generator.go +++ b/metal/cli/seo/generator.go @@ -94,8 +94,16 @@ func NewGenerator(db *database.Connection, env *env.Environment, val *portal.Val } func (g *Generator) Generate() error { - cli.Magentaln("Starting SEO generation pipeline") - defer cli.Magentaln("SEO generation pipeline finished") + if err := g.GenerateStaticPages(); err != nil { + return err + } + + return g.GeneratePosts() +} + +func (g *Generator) GenerateStaticPages() error { + cli.Magentaln("Starting static SEO generation pipeline") + defer cli.Magentaln("Static SEO generation pipeline finished") steps := []struct { name string @@ -105,7 +113,6 @@ func (g *Generator) Generate() error { {"about", g.GenerateAbout}, {"projects", g.GenerateProjects}, {"resume", g.GenerateResume}, - {"posts", g.GeneratePosts}, } for _, step := range steps { @@ -286,6 +293,9 @@ func (g *Generator) GenerateResume() error { } func (g *Generator) GeneratePosts() error { + cli.Magentaln("Starting blog posts SEO generation pipeline") + defer cli.Magentaln("Blog posts SEO generation pipeline finished") + var posts []database.Post err := g.DB.Sql(). diff --git a/metal/cli/seo/generator_test.go b/metal/cli/seo/generator_test.go index 226117ba..515650d5 100644 --- a/metal/cli/seo/generator_test.go +++ b/metal/cli/seo/generator_test.go @@ -152,8 +152,12 @@ func TestGeneratorGenerateAllPages(t *testing.T) { t.Fatalf("expected categories from database") } - if err := gen.Generate(); err != nil { - t.Fatalf("generate err: %v", err) + if err := gen.GenerateStaticPages(); err != nil { + t.Fatalf("generate static err: %v", err) + } + + if err := gen.GeneratePosts(); err != nil { + t.Fatalf("generate posts err: %v", err) } output := filepath.Join(env.Seo.SpaDir, "index.seo.html") From fe6c5a419e10c4ad5cb3dc4111ee1c1de5988ff7 Mon Sep 17 00:00:00 2001 From: Gus Date: Wed, 15 Oct 2025 10:45:37 +0800 Subject: [PATCH 2/5] Remove unused SEO generator wrapper --- metal/cli/seo/generator.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/metal/cli/seo/generator.go b/metal/cli/seo/generator.go index 9b465883..81972062 100644 --- a/metal/cli/seo/generator.go +++ b/metal/cli/seo/generator.go @@ -93,14 +93,6 @@ func NewGenerator(db *database.Connection, env *env.Environment, val *portal.Val }, nil } -func (g *Generator) Generate() error { - if err := g.GenerateStaticPages(); err != nil { - return err - } - - return g.GeneratePosts() -} - func (g *Generator) GenerateStaticPages() error { cli.Magentaln("Starting static SEO generation pipeline") defer cli.Magentaln("Static SEO generation pipeline finished") From 676420c83ca86e01c55c48eca464847dc0d15420 Mon Sep 17 00:00:00 2001 From: Gus Date: Wed, 15 Oct 2025 10:55:03 +0800 Subject: [PATCH 3/5] Refactor CLI SEO generator setup --- metal/cli/main.go | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/metal/cli/main.go b/metal/cli/main.go index 8207db62..8a9f33ab 100644 --- a/metal/cli/main.go +++ b/metal/cli/main.go @@ -157,22 +157,21 @@ func showApiAccount(menu panel.Menu) error { return nil } -func generateStaticSEO() error { +func runSEOGeneration(genFunc func(*seo.Generator) error) error { gen, err := newSEOGenerator() if err != nil { return err } - return gen.GenerateStaticPages() + return genFunc(gen) } -func generatePostsSEO() error { - gen, err := newSEOGenerator() - if err != nil { - return err - } +func generateStaticSEO() error { + return runSEOGeneration((*seo.Generator).GenerateStaticPages) +} - return gen.GeneratePosts() +func generatePostsSEO() error { + return runSEOGeneration((*seo.Generator).GeneratePosts) } func newSEOGenerator() (*seo.Generator, error) { From 0bea4bb661e2711ffae7791e14247ae147e96f7c Mon Sep 17 00:00:00 2001 From: Gus Date: Wed, 15 Oct 2025 11:05:23 +0800 Subject: [PATCH 4/5] Add CLI support for post slug SEO generation --- metal/cli/main.go | 18 +++++++++ metal/cli/panel/menu.go | 26 ++++++++++++- metal/cli/panel/menu_test.go | 20 ++++++++++ metal/cli/seo/generator.go | 69 ++++++++++++++++++++++++++------- metal/cli/seo/generator_test.go | 4 ++ 5 files changed, 122 insertions(+), 15 deletions(-) diff --git a/metal/cli/main.go b/metal/cli/main.go index 8a9f33ab..873d7ec1 100644 --- a/metal/cli/main.go +++ b/metal/cli/main.go @@ -81,6 +81,13 @@ func main() { return case 6: + if err = generatePostSEOForSlug(menu); err != nil { + cli.Errorln(err.Error()) + continue + } + + return + case 7: if err = printTimestamp(); err != nil { cli.Errorln(err.Error()) continue @@ -174,6 +181,17 @@ func generatePostsSEO() error { return runSEOGeneration((*seo.Generator).GeneratePosts) } +func generatePostSEOForSlug(menu panel.Menu) error { + slug, err := menu.CapturePostSlug() + if err != nil { + return err + } + + return runSEOGeneration(func(gen *seo.Generator) error { + return gen.GeneratePost(slug) + }) +} + func newSEOGenerator() (*seo.Generator, error) { gen, err := seo.NewGenerator( dbConn, diff --git a/metal/cli/panel/menu.go b/metal/cli/panel/menu.go index fbdb5cd2..956d02cf 100644 --- a/metal/cli/panel/menu.go +++ b/metal/cli/panel/menu.go @@ -5,6 +5,7 @@ import ( "fmt" "net/url" "os" + "regexp" "strconv" "strings" @@ -21,6 +22,8 @@ type Menu struct { Validator *portal.Validator } +var slugPattern = regexp.MustCompile(`^[a-z0-9-]+$`) + func MakeMenu() Menu { menu := Menu{ Reader: bufio.NewReader(os.Stdin), @@ -91,7 +94,8 @@ func (p *Menu) Print() { p.PrintOption("3) Show API accounts.", inner) p.PrintOption("4) Generate SEO for static pages.", inner) p.PrintOption("5) Generate SEO for blog posts.", inner) - p.PrintOption("6) Print Timestamp.", inner) + p.PrintOption("6) Generate SEO for a blog post by slug.", inner) + p.PrintOption("7) Print Timestamp.", inner) p.PrintOption(" ", inner) p.PrintOption("0) Exit.", inner) @@ -184,3 +188,23 @@ func (p *Menu) CapturePostURL() (*posts.Input, error) { return &input, nil } + +func (p *Menu) CapturePostSlug() (string, error) { + fmt.Print("Enter the blog post slug: ") + + slug, err := p.Reader.ReadString('\n') + if err != nil { + return "", fmt.Errorf("%sError reading the post slug: %v %s", cli.RedColour, err, cli.Reset) + } + + slug = strings.TrimSpace(slug) + if slug == "" { + return "", fmt.Errorf("%sError: no slug provided: %s", cli.RedColour, cli.Reset) + } + + if !slugPattern.MatchString(slug) { + return "", fmt.Errorf("%sError: slug must contain only lowercase letters, numbers, or hyphens: %s", cli.RedColour, cli.Reset) + } + + return slug, nil +} diff --git a/metal/cli/panel/menu_test.go b/metal/cli/panel/menu_test.go index b741d49c..f7397d8b 100644 --- a/metal/cli/panel/menu_test.go +++ b/metal/cli/panel/menu_test.go @@ -128,3 +128,23 @@ func TestCapturePostURL(t *testing.T) { t.Fatalf("expected error") } } + +func TestCapturePostSlug(t *testing.T) { + m := Menu{ + Reader: bufio.NewReader(strings.NewReader("valid-slug\n")), + } + + slug, err := m.CapturePostSlug() + + if err != nil || slug != "valid-slug" { + t.Fatalf("got %q err %v", slug, err) + } + + bad := Menu{ + Reader: bufio.NewReader(strings.NewReader("Invalid Slug\n")), + } + + if _, err := bad.CapturePostSlug(); err == nil { + t.Fatalf("expected error") + } +} diff --git a/metal/cli/seo/generator.go b/metal/cli/seo/generator.go index 81972062..3e915705 100644 --- a/metal/cli/seo/generator.go +++ b/metal/cli/seo/generator.go @@ -20,6 +20,7 @@ import ( "github.com/oullin/metal/router" "github.com/oullin/pkg/cli" "github.com/oullin/pkg/portal" + "gorm.io/gorm" ) //go:embed stub.html @@ -314,28 +315,68 @@ func (g *Generator) GeneratePosts() error { sections := NewSections() for _, post := range posts { - cli.Cyanln(fmt.Sprintf("Building SEO for post: %s", post.Slug)) - response := payload.GetPostsResponse(post) - cli.Grayln(fmt.Sprintf("Post slug: %s", response.Slug)) - cli.Grayln(fmt.Sprintf("Post title: %s", response.Title)) - 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) + if err := g.generatePostSEO(sections, post); err != nil { + return fmt.Errorf("posts: %w", err) } + } - origin := filepath.Join("posts", response.Slug) - if err = g.Export(origin, data); err != nil { - return fmt.Errorf("posts: exporting %s: %w", response.Slug, err) - } + return nil +} + +func (g *Generator) GeneratePost(slug string) error { + cli.Magentaln(fmt.Sprintf("Starting blog post SEO generation pipeline for %s", slug)) + defer cli.Magentaln(fmt.Sprintf("Blog post SEO generation pipeline finished for %s", slug)) + + var post database.Post + + err := g.DB.Sql(). + Model(&database.Post{}). + Preload("Author"). + Preload("Categories"). + Preload("Tags"). + Where("posts.slug = ?", slug). + Where("posts.published_at IS NOT NULL"). + Where("posts.deleted_at IS NULL"). + First(&post).Error + + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("post %s: not found or not published", slug) + } + + if err != nil { + return fmt.Errorf("post %s: fetching post: %w", slug, err) + } - cli.Successln(fmt.Sprintf("Post SEO template generated for %s", response.Slug)) + if err := g.generatePostSEO(NewSections(), post); err != nil { + return fmt.Errorf("post %s: %w", slug, err) } return nil } +func (g *Generator) generatePostSEO(sections Sections, post database.Post) error { + cli.Cyanln(fmt.Sprintf("Building SEO for post: %s", post.Slug)) + + response := payload.GetPostsResponse(post) + cli.Grayln(fmt.Sprintf("Post slug: %s", response.Slug)) + cli.Grayln(fmt.Sprintf("Post title: %s", response.Title)) + body := []template.HTML{sections.Post(&response)} + + data, buildErr := g.BuildForPost(response, body) + if buildErr != nil { + return fmt.Errorf("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("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 diff --git a/metal/cli/seo/generator_test.go b/metal/cli/seo/generator_test.go index 515650d5..54ce609f 100644 --- a/metal/cli/seo/generator_test.go +++ b/metal/cli/seo/generator_test.go @@ -160,6 +160,10 @@ func TestGeneratorGenerateAllPages(t *testing.T) { t.Fatalf("generate posts err: %v", err) } + if err := gen.GeneratePost(post.Slug); err != nil { + t.Fatalf("generate post by slug err: %v", err) + } + output := filepath.Join(env.Seo.SpaDir, "index.seo.html") raw, err := os.ReadFile(output) if err != nil { From d38c75f021fb6850e9e1282f1a6a68eff60b05b6 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Wed, 15 Oct 2025 12:31:13 +0800 Subject: [PATCH 5/5] better title --- metal/cli/seo/generator.go | 12 ++++++++---- metal/cli/seo/web.go | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/metal/cli/seo/generator.go b/metal/cli/seo/generator.go index 3e915705..73132942 100644 --- a/metal/cli/seo/generator.go +++ b/metal/cli/seo/generator.go @@ -150,7 +150,11 @@ func (g *Generator) GenerateIndex() error { // ----- Template Parsing web := g.Web.GetHomePage() - tData, buildErr := g.buildForPage(web.Name, web.Url, html) + tData, buildErr := g.buildForPage(web.Name, web.Url, html, func(data *TemplateData) { + data.Title = web.Title + data.Description = web.Excerpt + }) + if buildErr != nil { return fmt.Errorf("home: generating template data: %w", buildErr) } @@ -192,7 +196,7 @@ func (g *Generator) GenerateAbout() error { web := g.Web.GetAboutPage() data, buildErr := g.buildForPage(web.Name, web.Url, html, func(data *TemplateData) { - data.Title = g.TitleFor(web.Title) + data.Title = web.Title data.Description = web.Excerpt }) @@ -222,7 +226,7 @@ func (g *Generator) GenerateProjects() error { web := g.Web.GetProjectsPage() data, buildErr := g.buildForPage(web.Name, web.Url, body, func(data *TemplateData) { - data.Title = g.TitleFor(web.Title) + data.Title = web.Title data.Description = web.Excerpt }) @@ -268,7 +272,7 @@ func (g *Generator) GenerateResume() error { web := g.Web.GetResumePage() data, buildErr := g.buildForPage(web.Name, web.Url, html, func(data *TemplateData) { - data.Title = g.TitleFor(web.Title) + data.Title = web.Title data.Description = web.Excerpt }) diff --git a/metal/cli/seo/web.go b/metal/cli/seo/web.go index 3f7842ee..e033176e 100644 --- a/metal/cli/seo/web.go +++ b/metal/cli/seo/web.go @@ -38,7 +38,7 @@ func NewWeb() *Web { about := WebPage{ Name: "About", Url: "/about", - Title: "About " + AuthorName, + Title: "Who is " + AuthorName, Excerpt: "Gus's an engineering leader who’s passionate about building reliable and smooth software that strive to make a difference. He also has led teams in designing and delivering scalable, high-performance systems that run efficiently even in complex environments", }