diff --git a/metal/cli/seo/client.go b/metal/cli/seo/client.go index dd9b9815..da434421 100644 --- a/metal/cli/seo/client.go +++ b/metal/cli/seo/client.go @@ -22,46 +22,56 @@ func NewClient(routes *router.WebsiteRoutes) *Client { } } -func (c *Client) GetTalks() (*payload.TalksResponse, error) { - var talks payload.TalksResponse +func get[T any](handler func() router.StaticRouteResource, entityName string) (*T, error) { + var response T - fn := func() router.StaticRouteResource { - return handler.MakeTalksHandler(c.Fixture.GetTalksFile()) + if err := fetch[T](&response, handler); err != nil { + return nil, fmt.Errorf("error fetching %s: %w", entityName, err) } - if err := fetch[payload.TalksResponse](&talks, fn); err != nil { - return nil, fmt.Errorf("home: error fetching talks: %w", err) - } + return &response, nil +} - return &talks, nil +func (c *Client) GetTalks() (*payload.TalksResponse, error) { + return get[payload.TalksResponse](func() router.StaticRouteResource { + return handler.MakeTalksHandler(c.Fixture.GetTalksFile()) + }, "talks") } func (c *Client) GetProfile() (*payload.ProfileResponse, error) { - var profile payload.ProfileResponse - - fn := func() router.StaticRouteResource { + return get[payload.ProfileResponse](func() router.StaticRouteResource { return handler.MakeProfileHandler(c.Fixture.GetProfileFile()) - } - - if err := fetch[payload.ProfileResponse](&profile, fn); err != nil { - return nil, fmt.Errorf("error fetching profile: %w", err) - } - - return &profile, nil + }, "profile") } func (c *Client) GetProjects() (*payload.ProjectsResponse, error) { - var projects payload.ProjectsResponse - - fn := func() router.StaticRouteResource { + return get[payload.ProjectsResponse](func() router.StaticRouteResource { return handler.MakeProjectsHandler(c.Fixture.GetProjectsFile()) - } + }, "projects") +} - if err := fetch[payload.ProjectsResponse](&projects, fn); err != nil { - return nil, fmt.Errorf("error fetching projects: %w", err) - } +func (c *Client) GetSocial() (*payload.SocialResponse, error) { + return get[payload.SocialResponse](func() router.StaticRouteResource { + return handler.MakeSocialHandler(c.Fixture.GetSocialFile()) + }, "social") +} + +func (c *Client) GetRecommendations() (*payload.RecommendationsResponse, error) { + return get[payload.RecommendationsResponse](func() router.StaticRouteResource { + return handler.MakeRecommendationsHandler(c.Fixture.GetRecommendationsFile()) + }, "recommendations") +} + +func (c *Client) GetExperience() (*payload.ExperienceResponse, error) { + return get[payload.ExperienceResponse](func() router.StaticRouteResource { + return handler.MakeExperienceHandler(c.Fixture.GetExperienceFile()) + }, "experience") +} - return &projects, nil +func (c *Client) GetEducation() (*payload.EducationResponse, error) { + return get[payload.EducationResponse](func() router.StaticRouteResource { + return handler.MakeEducationHandler(c.Fixture.GetEducationFile()) + }, "education") } func fetch[T any](response *T, handler func() router.StaticRouteResource) error { diff --git a/metal/cli/seo/client_test.go b/metal/cli/seo/client_test.go index a477a277..577a1efa 100644 --- a/metal/cli/seo/client_test.go +++ b/metal/cli/seo/client_test.go @@ -93,4 +93,40 @@ func TestClientLoadsFixtures(t *testing.T) { if len(projects.Data) == 0 { t.Fatalf("expected projects data") } + + social, err := client.GetSocial() + if err != nil { + t.Fatalf("social err: %v", err) + } + + if len(social.Data) == 0 { + t.Fatalf("expected social data") + } + + recs, err := client.GetRecommendations() + if err != nil { + t.Fatalf("recommendations err: %v", err) + } + + if len(recs.Data) == 0 { + t.Fatalf("expected recommendations data") + } + + experience, err := client.GetExperience() + if err != nil { + t.Fatalf("experience err: %v", err) + } + + if len(experience.Data) == 0 { + t.Fatalf("expected experience data") + } + + education, err := client.GetEducation() + if err != nil { + t.Fatalf("education err: %v", err) + } + + if len(education.Data) == 0 { + t.Fatalf("expected education data") + } } diff --git a/metal/cli/seo/defaults.go b/metal/cli/seo/defaults.go index 753bf9ff..dfe18dc1 100644 --- a/metal/cli/seo/defaults.go +++ b/metal/cli/seo/defaults.go @@ -16,6 +16,9 @@ const WebHomeName = "Home" const WebAboutName = "About" const WebAboutUrl = "/about" +const WebPostsName = "Posts" +const WebPostsUrl = "/posts" + const WebResumeName = "Resume" const WebResumeUrl = "/resume" diff --git a/metal/cli/seo/generator.go b/metal/cli/seo/generator.go index 1532aa5f..359f3864 100644 --- a/metal/cli/seo/generator.go +++ b/metal/cli/seo/generator.go @@ -5,8 +5,11 @@ import ( "embed" "fmt" "html/template" + "net/url" "os" "path/filepath" + "strings" + "unicode/utf8" "github.com/oullin/database" "github.com/oullin/handler/payload" @@ -38,18 +41,22 @@ func NewGenerator(db *database.Connection, env *env.Environment, val *portal.Val } page := Page{ - LogoURL: LogoUrl, StubPath: StubPath, - WebRepoURL: RepoWebUrl, - APIRepoURL: RepoApiUrl, Categories: categories, - SiteURL: env.App.URL, SiteName: env.App.Name, - AboutPhotoUrl: AboutPhotoUrl, Lang: env.App.Lang(), OutputDir: env.Seo.SpaDir, Template: &template.Template{}, - SameAsURL: []string{RepoApiUrl, RepoWebUrl, GocantoUrl}, + LogoURL: portal.SanitiseURL(LogoUrl), + WebRepoURL: portal.SanitiseURL(RepoWebUrl), + APIRepoURL: portal.SanitiseURL(RepoApiUrl), + SiteURL: portal.SanitiseURL(env.App.URL), + AboutPhotoUrl: portal.SanitiseURL(AboutPhotoUrl), + SameAsURL: []string{ + portal.SanitiseURL(RepoApiUrl), + portal.SanitiseURL(RepoWebUrl), + portal.SanitiseURL(GocantoUrl), + }, } if _, err = val.Rejects(page); err != nil { @@ -81,6 +88,22 @@ func (g *Generator) Generate() error { return err } + if err = g.GenerateAbout(); err != nil { + return err + } + + if err = g.GenerateProjects(); err != nil { + return err + } + + if err = g.GenerateResume(); err != nil { + return err + } + + if err = g.GeneratePosts(); err != nil { + return err + } + return nil } @@ -113,9 +136,9 @@ func (g *Generator) GenerateIndex() error { // ----- Template Parsing - var tData TemplateData - if tData, err = g.Build(html); err != nil { - return fmt.Errorf("home: generating template data: %w", err) + tData, buildErr := g.buildForPage(WebHomeName, WebHomeUrl, html) + if buildErr != nil { + return fmt.Errorf("home: generating template data: %w", buildErr) } if err = g.Export("index", tData); err != nil { @@ -127,6 +150,149 @@ func (g *Generator) GenerateIndex() error { return nil } +func (g *Generator) GenerateAbout() error { + profile, err := g.Client.GetProfile() + if err != nil { + return err + } + + social, err := g.Client.GetSocial() + if err != nil { + return err + } + + recommendations, err := g.Client.GetRecommendations() + if err != nil { + return err + } + + sections := NewSections() + var html []template.HTML + + html = append(html, sections.Profile(profile)) + html = append(html, sections.Social(social)) + html = append(html, sections.Recommendations(recommendations)) + + data, buildErr := g.buildForPage(WebAboutName, WebAboutUrl, html) + if buildErr != nil { + return fmt.Errorf("about: generating template data: %w", buildErr) + } + + if err = g.Export("about", data); err != nil { + return fmt.Errorf("about: exporting template data: %w", err) + } + + cli.Successln("About SEO template generated") + + return nil +} + +func (g *Generator) GenerateProjects() error { + projects, err := g.Client.GetProjects() + + if err != nil { + return err + } + + sections := NewSections() + body := []template.HTML{sections.Projects(projects)} + + data, buildErr := g.buildForPage(WebProjectsName, WebProjectsUrl, body) + if buildErr != nil { + return fmt.Errorf("projects: generating template data: %w", buildErr) + } + + if err = g.Export("projects", data); err != nil { + return fmt.Errorf("projects: exporting template data: %w", err) + } + + cli.Successln("Projects SEO template generated") + + return nil +} + +func (g *Generator) GenerateResume() error { + experience, err := g.Client.GetExperience() + + if err != nil { + return err + } + + education, err := g.Client.GetEducation() + if err != nil { + return err + } + + recommendations, err := g.Client.GetRecommendations() + if err != nil { + return err + } + + sections := NewSections() + var html []template.HTML + + html = append(html, sections.Education(education)) + html = append(html, sections.Experience(experience)) + html = append(html, sections.Recommendations(recommendations)) + + data, buildErr := g.buildForPage(WebResumeName, WebResumeUrl, html) + if buildErr != nil { + return fmt.Errorf("resume: generating template data: %w", buildErr) + } + + if err = g.Export("resume", data); err != nil { + return fmt.Errorf("resume: exporting template data: %w", err) + } + + cli.Successln("Resume SEO template generated") + + 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 @@ -137,12 +303,13 @@ 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) } - out := filepath.Join(g.Page.OutputDir, fileName) 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) @@ -154,7 +321,7 @@ func (g *Generator) Export(origin string, data TemplateData) error { return nil } -func (g *Generator) Build(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", @@ -163,13 +330,13 @@ func (g *Generator) Build(body []template.HTML) (TemplateData, error) { Locale: g.Page.Lang, ImageAlt: g.Page.SiteName, SiteName: g.Page.SiteName, - Image: g.Page.AboutPhotoUrl, + Image: portal.SanitiseURL(g.Page.AboutPhotoUrl), } twitter := TwitterData{ - Card: "summary_large_image", - Image: g.Page.AboutPhotoUrl, ImageAlt: g.Page.SiteName, + Card: "summary_large_image", + Image: portal.SanitiseURL(g.Page.AboutPhotoUrl), } data := TemplateData{ @@ -181,26 +348,33 @@ func (g *Generator) Build(body []template.HTML) (TemplateData, error) { BgColor: ThemeColor, Lang: g.Page.Lang, Description: Description, - Canonical: g.Page.SiteURL, - AppleTouchIcon: g.Page.LogoURL, - Title: g.Page.SiteName, Categories: g.Page.Categories, JsonLD: NewJsonID(g.Page).Render(), + AppleTouchIcon: portal.SanitiseURL(g.Page.LogoURL), HrefLang: []HrefLangData{ - {Lang: g.Page.Lang, Href: g.Page.SiteURL}, + { + Lang: g.Page.Lang, + Href: portal.SanitiseURL(g.CanonicalFor(path)), + }, }, Favicons: []FaviconData{ { Rel: "icon", Sizes: "48x48", Type: "image/ico", - Href: g.Page.SiteURL + "/favicon.ico", + Href: portal.SanitiseURL(g.Page.SiteURL + "/favicon.ico"), }, }, } data.Body = body + data.Title = g.TitleFor(pageName) data.Manifest = NewManifest(g.Page, data).Render() + data.Canonical = portal.SanitiseURL(g.CanonicalFor(path)) + + 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()) @@ -219,6 +393,7 @@ func (g *Generator) Build(body []template.HTML) (TemplateData, error) { func (t *Page) Load() (*template.Template, error) { raw, err := templatesFS.ReadFile(t.StubPath) + if err != nil { return nil, fmt.Errorf("reading template: %w", err) } @@ -236,3 +411,107 @@ func (t *Page) Load() (*template.Template, error) { return tmpl, nil } + +func (g *Generator) CanonicalFor(path string) string { + base := strings.TrimSuffix(g.Page.SiteURL, "/") + + if path == "" || path == "/" { + return base + } + + if strings.HasSuffix(base, path) { + return base + } + + return base + path +} + +func (g *Generator) TitleFor(pageName string) string { + if pageName == WebHomeName { + return g.Page.SiteName + } + + return fmt.Sprintf("%s · %s", pageName, g.Page.SiteName) +} + +func (g *Generator) BuildForPost(post payload.PostResponse, body []template.HTML) (TemplateData, error) { + path := g.CanonicalPostPath(post.Slug) + imageAlt := g.SanitizeAltText(post.Title, g.Page.SiteName) + description := g.SanitizeMetaDescription(post.Excerpt, Description) + image := g.PreferredImageURL(post.CoverImageURL, g.Page.AboutPhotoUrl) + + return g.buildForPage(post.Title, path, body, func(data *TemplateData) { + data.OGTagOg.Image = image + data.Twitter.Image = image + data.Description = description + data.OGTagOg.ImageAlt = imageAlt + data.Twitter.ImageAlt = imageAlt + }) +} + +func (g *Generator) CanonicalPostPath(slug string) string { + cleaned := strings.TrimSpace(slug) + cleaned = strings.Trim(cleaned, "/") + + if cleaned == "" { + return WebPostsUrl + } + + return WebPostsUrl + "/" + cleaned +} + +func (g *Generator) 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 (g *Generator) 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 (g *Generator) 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 4b2b12cb..0bb08793 100644 --- a/metal/cli/seo/generator_test.go +++ b/metal/cli/seo/generator_test.go @@ -46,7 +46,7 @@ func TestGeneratorBuildAndExport(t *testing.T) { } body := []template.HTML{"
hello
"} - data, err := gen.Build(body) + data, err := gen.buildForPage(WebHomeName, WebHomeUrl, body) if err != nil { t.Fatalf("build err: %v", err) } @@ -101,18 +101,28 @@ func TestGeneratorBuildRejectsInvalidTemplateData(t *testing.T) { Validator: newTestValidator(t), } - if _, err := gen.Build([]template.HTML{"hello
"}); err == nil || !strings.Contains(err.Error(), "invalid template data") { + if _, err := gen.buildForPage(WebHomeName, WebHomeUrl, []template.HTML{"hello
"}); err == nil || !strings.Contains(err.Error(), "invalid template data") { t.Fatalf("expected validation error, got %v", err) } } -func TestNewGeneratorGenerateHome(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 { @@ -123,8 +133,8 @@ func TestNewGeneratorGenerateHome(t *testing.T) { t.Fatalf("expected categories from database") } - if err := gen.GenerateIndex(); err != nil { - t.Fatalf("generate home err: %v", err) + if err := gen.Generate(); err != nil { + t.Fatalf("generate err: %v", err) } output := filepath.Join(env.Seo.SpaDir, "index.seo.html") @@ -141,4 +151,56 @@ func TestNewGeneratorGenerateHome(t *testing.T) { if !strings.Contains(content, "cli tools") { t.Fatalf("expected categories to be rendered: %q", content) } + + aboutRaw, err := os.ReadFile(filepath.Join(env.Seo.SpaDir, "about.seo.html")) + if err != nil { + t.Fatalf("read about output: %v", err) + } + + aboutContent := strings.ToLower(string(aboutRaw)) + if !strings.Contains(aboutContent, ""+ template.HTMLEscapeString(profile.Data.Name)+", "+ @@ -37,6 +43,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 { @@ -51,6 +61,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 { @@ -72,9 +86,29 @@ func (s *Sections) Projects(projects *payload.ProjectsResponse) template.HTML { var items []string for _, item := range projects.Data { + title := template.HTMLEscapeString(item.Title) + lang := template.HTMLEscapeString(item.Language) + excerpt := template.HTMLEscapeString(item.Excerpt) + href := portal.SanitiseURL(strings.TrimSpace(item.URL)) + + project := fmt.Sprintf("%s", title) + + if href != "" { + project = fmt.Sprintf("%s", href, project) + } + + details := []string{} + if excerpt != "" { + details = append(details, excerpt) + } + + if lang != "" { + details = append(details, "Language: "+lang) + } + items = append(items, "
" + 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", "" + portal.AllowLineBreaks(escaped) + "
" + } + + contentHTML := s.FormatPostContent(post.Content) + + return template.HTML(""+escaped+"
") + } + + if len(rendered) == 0 { + return "" + } + + return strings.Join(rendered, "") +} + +func (s *Sections) FormatDetails(parts []string) string { + filtered := portal.FilterNonEmpty(parts) + + if len(filtered) == 0 { + return "" + } + + return ": " + strings.Join(filtered, " | ") +} diff --git a/metal/cli/seo/sections_test.go b/metal/cli/seo/sections_test.go index f82d2ea9..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" ) @@ -30,8 +31,71 @@ func TestSectionsRenderersEscapeContent(t *testing.T) { Data: []payload.ProjectsData{{Title: "API