diff --git a/metal/cli/main.go b/metal/cli/main.go index 39aef3a8..873d7ec1 100644 --- a/metal/cli/main.go +++ b/metal/cli/main.go @@ -67,13 +67,27 @@ 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 = generatePostSEOForSlug(menu); err != nil { + cli.Errorln(err.Error()) + continue + } + + return + case 7: if err = printTimestamp(); err != nil { cli.Errorln(err.Error()) continue @@ -150,7 +164,35 @@ func showApiAccount(menu panel.Menu) error { return nil } -func generateSEO() error { +func runSEOGeneration(genFunc func(*seo.Generator) error) error { + gen, err := newSEOGenerator() + if err != nil { + return err + } + + return genFunc(gen) +} + +func generateStaticSEO() error { + return runSEOGeneration((*seo.Generator).GenerateStaticPages) +} + +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, environment, @@ -158,14 +200,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..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), @@ -89,8 +92,10 @@ 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) Generate SEO for a blog post by slug.", inner) + p.PrintOption("7) Print Timestamp.", inner) p.PrintOption(" ", inner) p.PrintOption("0) Exit.", inner) @@ -183,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 baf0b10a..73132942 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 @@ -93,9 +94,9 @@ func NewGenerator(db *database.Connection, env *env.Environment, val *portal.Val }, nil } -func (g *Generator) Generate() error { - cli.Magentaln("Starting SEO generation pipeline") - defer cli.Magentaln("SEO generation pipeline finished") +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 +106,6 @@ func (g *Generator) Generate() error { {"about", g.GenerateAbout}, {"projects", g.GenerateProjects}, {"resume", g.GenerateResume}, - {"posts", g.GeneratePosts}, } for _, step := range steps { @@ -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 }) @@ -286,6 +290,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(). @@ -312,25 +319,65 @@ 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 - cli.Successln(fmt.Sprintf("Post SEO template generated for %s", response.Slug)) + 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) + } + + 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 } diff --git a/metal/cli/seo/generator_test.go b/metal/cli/seo/generator_test.go index 226117ba..54ce609f 100644 --- a/metal/cli/seo/generator_test.go +++ b/metal/cli/seo/generator_test.go @@ -152,8 +152,16 @@ 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) + } + + 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") 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", }