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{"

Profile

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, "

social

") { + t.Fatalf("expected social section in about page: %q", aboutContent) + } + + if !strings.Contains(aboutContent, "

recommendations

") { + t.Fatalf("expected recommendations section in about page: %q", aboutContent) + } + + projectsRaw, err := os.ReadFile(filepath.Join(env.Seo.SpaDir, "projects.seo.html")) + if err != nil { + t.Fatalf("read projects output: %v", err) + } + + projectsContent := strings.ToLower(string(projectsRaw)) + if !strings.Contains(projectsContent, "

projects

") { + t.Fatalf("expected projects section in projects page: %q", projectsContent) + } + + resumeRaw, err := os.ReadFile(filepath.Join(env.Seo.SpaDir, "resume.seo.html")) + if err != nil { + t.Fatalf("read resume output: %v", err) + } + + resumeContent := strings.ToLower(string(resumeRaw)) + if !strings.Contains(resumeContent, "

experience

") { + t.Fatalf("expected experience section in resume page: %q", resumeContent) + } + + 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 5b067186..46768bcf 100644 --- a/metal/cli/seo/sections.go +++ b/metal/cli/seo/sections.go @@ -1,10 +1,12 @@ package seo import ( + "fmt" "html/template" "strings" "github.com/oullin/handler/payload" + "github.com/oullin/pkg/portal" ) type Sections struct{} @@ -28,6 +30,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)+", "+ @@ -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, "

  • "+ - template.HTMLEscapeString(item.Title)+": "+ - template.HTMLEscapeString(item.Excerpt)+ + project+ + s.FormatDetails(details)+ "
  • ", ) } @@ -85,3 +119,318 @@ 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(portal.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 = "

    " + portal.AllowLineBreaks(escaped) + "

    " + } + + contentHTML := s.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

    ") + } + + var items []string + + for _, item := range social.Data { + name := template.HTMLEscapeString(item.Name) + handle := template.HTMLEscapeString(item.Handle) + href := portal.SanitiseURL(strings.TrimSpace(item.URL)) + description := template.HTMLEscapeString(item.Description) + + linkText := name + if handle != "" { + linkText = fmt.Sprintf("%s (%s)", linkText, handle) + } + + if href != "" { + linkText = fmt.Sprintf("%s", href, linkText) + } + + items = append(items, "
  • "+ + linkText+ + s.FormatDetails([]string{description})+ + "
  • ", + ) + } + + return template.HTML("

    Social

    " + + "

    ", + ) +} + +func (s *Sections) Recommendations(recs *payload.RecommendationsResponse) template.HTML { + if recs == nil { + return template.HTML("

    Recommendations

    ") + } + + var items []string + + for _, item := range recs.Data { + relation := template.HTMLEscapeString(item.Relation) + company := template.HTMLEscapeString(item.Person.Company) + fullName := template.HTMLEscapeString(item.Person.FullName) + designation := template.HTMLEscapeString(item.Person.Designation) + text := portal.AllowLineBreaks(template.HTMLEscapeString(item.Text)) + + meta := []string{} + if designation != "" { + meta = append(meta, designation) + } + + if company != "" { + meta = append(meta, company) + } + + heading := fullName + if len(meta) > 0 { + heading += " (" + strings.Join(meta, ", ") + ")" + } + + details := []string{} + if relation != "" { + details = append(details, relation) + } + + if text != "" { + details = append(details, text) + } + + items = append(items, "
  • "+ + ""+heading+""+ + s.FormatDetails(details)+ + "
  • ", + ) + } + + return template.HTML("

    Recommendations

    " + + "

    ", + ) +} + +func (s *Sections) Experience(exp *payload.ExperienceResponse) template.HTML { + if exp == nil { + return template.HTML("

    Experience

    ") + } + + var items []string + + for _, item := range exp.Data { + city := template.HTMLEscapeString(item.City) + end := template.HTMLEscapeString(item.EndDate) + skills := template.HTMLEscapeString(item.Skills) + company := template.HTMLEscapeString(item.Company) + country := template.HTMLEscapeString(item.Country) + start := template.HTMLEscapeString(item.StartDate) + summary := template.HTMLEscapeString(item.Summary) + position := template.HTMLEscapeString(item.Position) + locationType := template.HTMLEscapeString(item.LocationType) + employmentType := template.HTMLEscapeString(item.EmploymentType) + + timeline := strings.Join(portal.FilterNonEmpty([]string{start, end}), " - ") + location := strings.Join(portal.FilterNonEmpty([]string{city, country}), ", ") + heading := strings.Join(portal.FilterNonEmpty([]string{position, company}), " at ") + + details := []string{} + if timeline != "" { + details = append(details, "Timeline: "+timeline) + } + + if employmentType != "" || locationType != "" { + details = append(details, strings.Join(portal.FilterNonEmpty([]string{employmentType, locationType}), " · ")) + } + + if location != "" { + details = append(details, "Location: "+location) + } + + if summary != "" { + details = append(details, summary) + } + + if skills != "" { + details = append(details, "Skills: "+skills) + } + + items = append(items, "
  • "+ + ""+heading+""+ + s.FormatDetails(details)+ + "
  • ", + ) + } + + return template.HTML("

    Experience

    " + + "

    ", + ) +} + +func (s *Sections) Education(edu *payload.EducationResponse) template.HTML { + if edu == nil { + return template.HTML("

    Education

    ") + } + + var items []string + + for _, item := range edu.Data { + field := template.HTMLEscapeString(item.Field) + school := template.HTMLEscapeString(item.School) + degree := template.HTMLEscapeString(item.Degree) + graduated := template.HTMLEscapeString(item.GraduatedAt) + description := template.HTMLEscapeString(item.Description) + country := template.HTMLEscapeString(item.IssuingCountry) + + headingParts := portal.FilterNonEmpty([]string{degree, field}) + heading := strings.Join(headingParts, " in ") + + if heading == "" { + heading = school + } else if school != "" { + heading += " at " + school + } + + details := []string{} + if graduated != "" { + details = append(details, "Graduated: "+graduated) + } + + if country != "" { + details = append(details, "Country: "+country) + } + + if description != "" { + details = append(details, portal.AllowLineBreaks(description)) + } + + items = append(items, "
  • "+ + ""+heading+""+ + s.FormatDetails(details)+ + "
  • ", + ) + } + + return template.HTML("

    Education

    " + + "

    ", + ) +} + +func (s *Sections) 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 = portal.AllowLineBreaks(escaped) + rendered = append(rendered, "

    "+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", Excerpt: "CLI & Tools"}}, } + social := &payload.SocialResponse{ + Data: []payload.SocialData{{ + Name: "X", + Handle: "@", + URL: "https://social.example/", + Description: "Follow ", + }}, + } + + recommendations := &payload.RecommendationsResponse{ + Data: []payload.RecommendationsData{{ + Relation: "Colleague friend", + Text: "Great
    lead", + Person: payload.RecommendationsPersonData{ + FullName: "Jane ", + Company: "Tech ", + Designation: "CTO", + }, + }}, + } + + experience := &payload.ExperienceResponse{ + Data: []payload.ExperienceData{{ + Company: "Perx ", + Position: "Head ", + EmploymentType: "Full-Time", + LocationType: "Remote", + City: "Sing", + Country: "Singa", + StartDate: "2020", + EndDate: "2024", + Summary: "Led ", + Skills: "Go, ", + }}, + } + + education := &payload.EducationResponse{ + Data: []payload.EducationData{{ + School: "Uni", + Degree: "BSc", + Field: "Computer ", + Description: "Studied ", + GraduatedAt: "2012", + IssuingCountry: "Vene", + }}, + } + 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