From 62fcb1b0b57ec4b88b99c07638aa3494e4746a6f Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 26 Sep 2025 17:07:39 +0800 Subject: [PATCH 01/14] Add SEO generation for additional pages --- metal/cli/seo/client.go | 56 ++++++++ metal/cli/seo/client_test.go | 36 +++++ metal/cli/seo/generator.go | 150 +++++++++++++++++++- metal/cli/seo/generator_test.go | 44 +++++- metal/cli/seo/sections.go | 241 +++++++++++++++++++++++++++++++- metal/cli/seo/sections_test.go | 71 ++++++++++ metal/router/fixture.go | 16 +++ 7 files changed, 606 insertions(+), 8 deletions(-) diff --git a/metal/cli/seo/client.go b/metal/cli/seo/client.go index dd9b9815..49ea046f 100644 --- a/metal/cli/seo/client.go +++ b/metal/cli/seo/client.go @@ -64,6 +64,62 @@ func (c *Client) GetProjects() (*payload.ProjectsResponse, error) { return &projects, nil } +func (c *Client) GetSocial() (*payload.SocialResponse, error) { + var social payload.SocialResponse + + fn := func() router.StaticRouteResource { + return handler.MakeSocialHandler(c.Fixture.GetSocialFile()) + } + + if err := fetch[payload.SocialResponse](&social, fn); err != nil { + return nil, fmt.Errorf("error fetching social: %w", err) + } + + return &social, nil +} + +func (c *Client) GetRecommendations() (*payload.RecommendationsResponse, error) { + var recs payload.RecommendationsResponse + + fn := func() router.StaticRouteResource { + return handler.MakeRecommendationsHandler(c.Fixture.GetRecommendationsFile()) + } + + if err := fetch[payload.RecommendationsResponse](&recs, fn); err != nil { + return nil, fmt.Errorf("error fetching recommendations: %w", err) + } + + return &recs, nil +} + +func (c *Client) GetExperience() (*payload.ExperienceResponse, error) { + var exp payload.ExperienceResponse + + fn := func() router.StaticRouteResource { + return handler.MakeExperienceHandler(c.Fixture.GetExperienceFile()) + } + + if err := fetch[payload.ExperienceResponse](&exp, fn); err != nil { + return nil, fmt.Errorf("error fetching experience: %w", err) + } + + return &exp, nil +} + +func (c *Client) GetEducation() (*payload.EducationResponse, error) { + var edu payload.EducationResponse + + fn := func() router.StaticRouteResource { + return handler.MakeEducationHandler(c.Fixture.GetEducationFile()) + } + + if err := fetch[payload.EducationResponse](&edu, fn); err != nil { + return nil, fmt.Errorf("error fetching education: %w", err) + } + + return &edu, nil +} + func fetch[T any](response *T, handler func() router.StaticRouteResource) error { req := httptest.NewRequest("GET", "http://localhost:8080/proxy", nil) rr := httptest.NewRecorder() 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/generator.go b/metal/cli/seo/generator.go index 1532aa5f..83f3a18c 100644 --- a/metal/cli/seo/generator.go +++ b/metal/cli/seo/generator.go @@ -7,6 +7,7 @@ import ( "html/template" "os" "path/filepath" + "strings" "github.com/oullin/database" "github.com/oullin/handler/payload" @@ -81,6 +82,18 @@ 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 + } + return nil } @@ -127,6 +140,111 @@ func (g *Generator) GenerateIndex() error { return nil } +func (g *Generator) GenerateAbout() error { + var ( + err error + profile *payload.ProfileResponse + social *payload.SocialResponse + recommendations *payload.RecommendationsResponse + ) + + if profile, err = g.Client.GetProfile(); err != nil { + return err + } + + if social, err = g.Client.GetSocial(); err != nil { + return err + } + + if recommendations, err = g.Client.GetRecommendations(); 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 { + var ( + err error + experience *payload.ExperienceResponse + education *payload.EducationResponse + recommendations *payload.RecommendationsResponse + ) + + if experience, err = g.Client.GetExperience(); err != nil { + return err + } + + if education, err = g.Client.GetEducation(); err != nil { + return err + } + + if recommendations, err = g.Client.GetRecommendations(); err != nil { + return err + } + + sections := NewSections() + var html []template.HTML + + html = append(html, sections.Experience(experience)) + html = append(html, sections.Education(education)) + 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) Export(origin string, data TemplateData) error { var err error var buffer bytes.Buffer @@ -155,6 +273,10 @@ func (g *Generator) Export(origin string, data TemplateData) error { } func (g *Generator) Build(body []template.HTML) (TemplateData, error) { + return g.buildForPage(WebHomeName, WebHomeUrl, body) +} + +func (g *Generator) buildForPage(pageName, path string, body []template.HTML) (TemplateData, error) { og := TagOgData{ ImageHeight: "630", ImageWidth: "1200", @@ -181,13 +303,11 @@ 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(), HrefLang: []HrefLangData{ - {Lang: g.Page.Lang, Href: g.Page.SiteURL}, + {Lang: g.Page.Lang, Href: g.canonicalFor(path)}, }, Favicons: []FaviconData{ { @@ -200,6 +320,8 @@ func (g *Generator) Build(body []template.HTML) (TemplateData, error) { } data.Body = body + data.Canonical = g.canonicalFor(path) + data.Title = g.titleFor(pageName) data.Manifest = NewManifest(g.Page, data).Render() if _, err := g.Validator.Rejects(og); err != nil { @@ -236,3 +358,25 @@ 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) +} diff --git a/metal/cli/seo/generator_test.go b/metal/cli/seo/generator_test.go index 4b2b12cb..1f623494 100644 --- a/metal/cli/seo/generator_test.go +++ b/metal/cli/seo/generator_test.go @@ -106,7 +106,7 @@ func TestGeneratorBuildRejectsInvalidTemplateData(t *testing.T) { } } -func TestNewGeneratorGenerateHome(t *testing.T) { +func TestGeneratorGenerateAllPages(t *testing.T) { withRepoRoot(t) conn, env := newPostgresConnection(t, &database.Category{}) @@ -123,8 +123,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 +141,42 @@ 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) + } } diff --git a/metal/cli/seo/sections.go b/metal/cli/seo/sections.go index 5b067186..cab05a62 100644 --- a/metal/cli/seo/sections.go +++ b/metal/cli/seo/sections.go @@ -1,6 +1,7 @@ package seo import ( + "fmt" "html/template" "strings" @@ -72,9 +73,28 @@ func (s *Sections) Projects(projects *payload.ProjectsResponse) template.HTML { var items []string for _, item := range projects.Data { + href := template.HTMLEscapeString(item.URL) + title := template.HTMLEscapeString(item.Title) + excerpt := template.HTMLEscapeString(item.Excerpt) + lang := template.HTMLEscapeString(item.Language) + + 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+ + formatDetails(details)+ "
  • ", ) } @@ -85,3 +105,220 @@ func (s *Sections) Projects(projects *payload.ProjectsResponse) template.HTML { "

    ", ) } + +func (s *Sections) Social(social *payload.SocialResponse) template.HTML { + var items []string + + if social != nil { + for _, item := range social.Data { + href := template.HTMLEscapeString(item.URL) + name := template.HTMLEscapeString(item.Name) + handle := template.HTMLEscapeString(item.Handle) + 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+ + formatDetails([]string{description})+ + "
  • ", + ) + } + } + + return template.HTML("

    Social

    " + + "

    ", + ) +} + +func (s *Sections) Recommendations(recs *payload.RecommendationsResponse) template.HTML { + var items []string + + if recs != nil { + for _, item := range recs.Data { + fullName := template.HTMLEscapeString(item.Person.FullName) + designation := template.HTMLEscapeString(item.Person.Designation) + company := template.HTMLEscapeString(item.Person.Company) + relation := template.HTMLEscapeString(item.Relation) + text := 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+""+ + formatDetails(details)+ + "
  • ", + ) + } + } + + return template.HTML("

    Recommendations

    " + + "

    ", + ) +} + +func (s *Sections) Experience(exp *payload.ExperienceResponse) template.HTML { + var items []string + + if exp != nil { + for _, item := range exp.Data { + company := template.HTMLEscapeString(item.Company) + position := template.HTMLEscapeString(item.Position) + employmentType := template.HTMLEscapeString(item.EmploymentType) + locationType := template.HTMLEscapeString(item.LocationType) + city := template.HTMLEscapeString(item.City) + country := template.HTMLEscapeString(item.Country) + start := template.HTMLEscapeString(item.StartDate) + end := template.HTMLEscapeString(item.EndDate) + summary := template.HTMLEscapeString(item.Summary) + skills := template.HTMLEscapeString(item.Skills) + + timeline := strings.TrimSpace(strings.Join(filterNonEmpty([]string{start, end}), " - ")) + location := strings.TrimSpace(strings.Join(filterNonEmpty([]string{city, country}), ", ")) + heading := strings.TrimSpace(strings.Join(filterNonEmpty([]string{position, company}), " at ")) + + details := []string{} + if timeline != "" { + details = append(details, "Timeline: "+timeline) + } + if employmentType != "" || locationType != "" { + details = append(details, strings.TrimSpace(strings.Join(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+""+ + formatDetails(details)+ + "
  • ", + ) + } + } + + return template.HTML("

    Experience

    " + + "

    ", + ) +} + +func (s *Sections) Education(edu *payload.EducationResponse) template.HTML { + var items []string + + if edu != nil { + for _, item := range edu.Data { + school := template.HTMLEscapeString(item.School) + degree := template.HTMLEscapeString(item.Degree) + field := template.HTMLEscapeString(item.Field) + description := template.HTMLEscapeString(item.Description) + graduated := template.HTMLEscapeString(item.GraduatedAt) + country := template.HTMLEscapeString(item.IssuingCountry) + + headingParts := 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, allowLineBreaks(description)) + } + + items = append(items, "
  • "+ + ""+heading+""+ + formatDetails(details)+ + "
  • ", + ) + } + } + + return template.HTML("

    Education

    " + + "

    ", + ) +} + +func formatDetails(parts []string) string { + var filtered []string + + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + filtered = append(filtered, part) + } + } + + if len(filtered) == 0 { + return "" + } + + return ": " + strings.Join(filtered, " | ") +} + +func allowLineBreaks(text string) string { + replacer := strings.NewReplacer( + "<br/>", "
    ", + "<br />", "
    ", + "<br>", "
    ", + ) + + return replacer.Replace(text) +} + +func filterNonEmpty(values []string) []string { + var out []string + for _, v := range values { + if strings.TrimSpace(v) != "" { + out = append(out, strings.TrimSpace(v)) + } + } + + return out +} diff --git a/metal/cli/seo/sections_test.go b/metal/cli/seo/sections_test.go index f82d2ea9..28e5d721 100644 --- a/metal/cli/seo/sections_test.go +++ b/metal/cli/seo/sections_test.go @@ -30,6 +30,53 @@ 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"} renderedProfile := string(sections.Profile(profile)) @@ -57,12 +104,36 @@ func TestSectionsRenderersEscapeContent(t *testing.T) { t.Fatalf("projects output missing escaped title: %q", renderedProjects) } + renderedSocial := string(sections.Social(social)) + if strings.Contains(renderedSocial, "") { + t.Fatalf("social section should escape handles: %q", renderedSocial) + } + + renderedRecommendations := string(sections.Recommendations(recommendations)) + if !strings.Contains(renderedRecommendations, "Great
    lead") { + t.Fatalf("recommendations should allow br tags: %q", renderedRecommendations) + } + + renderedExperience := string(sections.Experience(experience)) + if strings.Contains(renderedExperience, "Perx ") { + t.Fatalf("experience should escape HTML: %q", renderedExperience) + } + + renderedEducation := string(sections.Education(education)) + if strings.Contains(renderedEducation, "Uni") { + t.Fatalf("education should escape HTML: %q", renderedEducation) + } + for _, html := range []template.HTML{ sections.Profile(profile), sections.Categories(categories), sections.Skills(profile), sections.Talks(talks), sections.Projects(projects), + sections.Social(social), + sections.Recommendations(recommendations), + sections.Experience(experience), + sections.Education(education), } { if !strings.HasPrefix(string(html), "

    ") { t.Fatalf("section should start with heading: %q", html) diff --git a/metal/router/fixture.go b/metal/router/fixture.go index 8a3e064c..9c703a10 100644 --- a/metal/router/fixture.go +++ b/metal/router/fixture.go @@ -39,6 +39,10 @@ func (f *Fixture) GetSocial() *Fixture { return f.resolveFor(FixtureSocial) } +func (f *Fixture) GetSocialFile() string { + return f.resolveFor(FixtureSocial).fullPath +} + func (f *Fixture) GetProfile() *Fixture { return f.resolveFor(FixtureProfile) } @@ -59,14 +63,26 @@ func (f *Fixture) GetEducation() *Fixture { return f.resolveFor(FixtureEducation) } +func (f *Fixture) GetEducationFile() string { + return f.resolveFor(FixtureEducation).fullPath +} + func (f *Fixture) GetExperience() *Fixture { return f.resolveFor(FixtureExperience) } +func (f *Fixture) GetExperienceFile() string { + return f.resolveFor(FixtureExperience).fullPath +} + func (f *Fixture) GetRecommendations() *Fixture { return f.resolveFor(FixtureRecommendations) } +func (f *Fixture) GetRecommendationsFile() string { + return f.resolveFor(FixtureRecommendations).fullPath +} + func (f *Fixture) resolveFor(slug string) *Fixture { clone := f clone.fullPath = clone.getFileFor(slug) From 6636b4a140f3fa808c76bb34f89edcfdd0b26180 Mon Sep 17 00:00:00 2001 From: Gus Date: Tue, 30 Sep 2025 15:40:49 +0800 Subject: [PATCH 02/14] Refactor SEO client helpers --- metal/cli/seo/client.go | 70 ++++++++++++----------------------------- 1 file changed, 20 insertions(+), 50 deletions(-) diff --git a/metal/cli/seo/client.go b/metal/cli/seo/client.go index 49ea046f..0c1515a9 100644 --- a/metal/cli/seo/client.go +++ b/metal/cli/seo/client.go @@ -22,6 +22,16 @@ func NewClient(routes *router.WebsiteRoutes) *Client { } } +func get[T any](handler func() router.StaticRouteResource, entityName string) (*T, error) { + var response T + + if err := fetch[T](&response, handler); err != nil { + return nil, fmt.Errorf("error fetching %s: %w", entityName, err) + } + + return &response, nil +} + func (c *Client) GetTalks() (*payload.TalksResponse, error) { var talks payload.TalksResponse @@ -51,73 +61,33 @@ func (c *Client) GetProfile() (*payload.ProfileResponse, error) { } 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()) - } - - if err := fetch[payload.ProjectsResponse](&projects, fn); err != nil { - return nil, fmt.Errorf("error fetching projects: %w", err) - } - - return &projects, nil + }, "projects") } func (c *Client) GetSocial() (*payload.SocialResponse, error) { - var social payload.SocialResponse - - fn := func() router.StaticRouteResource { + return get[payload.SocialResponse](func() router.StaticRouteResource { return handler.MakeSocialHandler(c.Fixture.GetSocialFile()) - } - - if err := fetch[payload.SocialResponse](&social, fn); err != nil { - return nil, fmt.Errorf("error fetching social: %w", err) - } - - return &social, nil + }, "social") } func (c *Client) GetRecommendations() (*payload.RecommendationsResponse, error) { - var recs payload.RecommendationsResponse - - fn := func() router.StaticRouteResource { + return get[payload.RecommendationsResponse](func() router.StaticRouteResource { return handler.MakeRecommendationsHandler(c.Fixture.GetRecommendationsFile()) - } - - if err := fetch[payload.RecommendationsResponse](&recs, fn); err != nil { - return nil, fmt.Errorf("error fetching recommendations: %w", err) - } - - return &recs, nil + }, "recommendations") } func (c *Client) GetExperience() (*payload.ExperienceResponse, error) { - var exp payload.ExperienceResponse - - fn := func() router.StaticRouteResource { + return get[payload.ExperienceResponse](func() router.StaticRouteResource { return handler.MakeExperienceHandler(c.Fixture.GetExperienceFile()) - } - - if err := fetch[payload.ExperienceResponse](&exp, fn); err != nil { - return nil, fmt.Errorf("error fetching experience: %w", err) - } - - return &exp, nil + }, "experience") } func (c *Client) GetEducation() (*payload.EducationResponse, error) { - var edu payload.EducationResponse - - fn := func() router.StaticRouteResource { + return get[payload.EducationResponse](func() router.StaticRouteResource { return handler.MakeEducationHandler(c.Fixture.GetEducationFile()) - } - - if err := fetch[payload.EducationResponse](&edu, fn); err != nil { - return nil, fmt.Errorf("error fetching education: %w", err) - } - - return &edu, nil + }, "education") } func fetch[T any](response *T, handler func() router.StaticRouteResource) error { From 20d2869b4cc196f073024d795d66fe5989de547d Mon Sep 17 00:00:00 2001 From: Gus Date: Tue, 30 Sep 2025 15:51:38 +0800 Subject: [PATCH 03/14] Simplify SEO generation helpers --- metal/cli/seo/generator.go | 32 ++++++++++++-------------------- metal/cli/seo/sections.go | 17 +++++------------ 2 files changed, 17 insertions(+), 32 deletions(-) diff --git a/metal/cli/seo/generator.go b/metal/cli/seo/generator.go index 83f3a18c..0e64dc69 100644 --- a/metal/cli/seo/generator.go +++ b/metal/cli/seo/generator.go @@ -141,22 +141,18 @@ func (g *Generator) GenerateIndex() error { } func (g *Generator) GenerateAbout() error { - var ( - err error - profile *payload.ProfileResponse - social *payload.SocialResponse - recommendations *payload.RecommendationsResponse - ) - - if profile, err = g.Client.GetProfile(); err != nil { + profile, err := g.Client.GetProfile() + if err != nil { return err } - if social, err = g.Client.GetSocial(); err != nil { + social, err := g.Client.GetSocial() + if err != nil { return err } - if recommendations, err = g.Client.GetRecommendations(); err != nil { + recommendations, err := g.Client.GetRecommendations() + if err != nil { return err } @@ -205,22 +201,18 @@ func (g *Generator) GenerateProjects() error { } func (g *Generator) GenerateResume() error { - var ( - err error - experience *payload.ExperienceResponse - education *payload.EducationResponse - recommendations *payload.RecommendationsResponse - ) - - if experience, err = g.Client.GetExperience(); err != nil { + experience, err := g.Client.GetExperience() + if err != nil { return err } - if education, err = g.Client.GetEducation(); err != nil { + education, err := g.Client.GetEducation() + if err != nil { return err } - if recommendations, err = g.Client.GetRecommendations(); err != nil { + recommendations, err := g.Client.GetRecommendations() + if err != nil { return err } diff --git a/metal/cli/seo/sections.go b/metal/cli/seo/sections.go index cab05a62..37543b35 100644 --- a/metal/cli/seo/sections.go +++ b/metal/cli/seo/sections.go @@ -203,16 +203,16 @@ func (s *Sections) Experience(exp *payload.ExperienceResponse) template.HTML { summary := template.HTMLEscapeString(item.Summary) skills := template.HTMLEscapeString(item.Skills) - timeline := strings.TrimSpace(strings.Join(filterNonEmpty([]string{start, end}), " - ")) - location := strings.TrimSpace(strings.Join(filterNonEmpty([]string{city, country}), ", ")) - heading := strings.TrimSpace(strings.Join(filterNonEmpty([]string{position, company}), " at ")) + timeline := strings.Join(filterNonEmpty([]string{start, end}), " - ") + location := strings.Join(filterNonEmpty([]string{city, country}), ", ") + heading := strings.Join(filterNonEmpty([]string{position, company}), " at ") details := []string{} if timeline != "" { details = append(details, "Timeline: "+timeline) } if employmentType != "" || locationType != "" { - details = append(details, strings.TrimSpace(strings.Join(filterNonEmpty([]string{employmentType, locationType}), " · "))) + details = append(details, strings.Join(filterNonEmpty([]string{employmentType, locationType}), " · ")) } if location != "" { details = append(details, "Location: "+location) @@ -286,14 +286,7 @@ func (s *Sections) Education(edu *payload.EducationResponse) template.HTML { } func formatDetails(parts []string) string { - var filtered []string - - for _, part := range parts { - part = strings.TrimSpace(part) - if part != "" { - filtered = append(filtered, part) - } - } + filtered := filterNonEmpty(parts) if len(filtered) == 0 { return "" From 3c2485fa03957804a34001a078c2751685124555 Mon Sep 17 00:00:00 2001 From: Gus Date: Tue, 30 Sep 2025 16:00:13 +0800 Subject: [PATCH 04/14] Harden SEO sections links --- metal/cli/seo/sections.go | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/metal/cli/seo/sections.go b/metal/cli/seo/sections.go index 37543b35..4c23ad9c 100644 --- a/metal/cli/seo/sections.go +++ b/metal/cli/seo/sections.go @@ -73,7 +73,7 @@ func (s *Sections) Projects(projects *payload.ProjectsResponse) template.HTML { var items []string for _, item := range projects.Data { - href := template.HTMLEscapeString(item.URL) + href := sanitizeURL(item.URL) title := template.HTMLEscapeString(item.Title) excerpt := template.HTMLEscapeString(item.Excerpt) lang := template.HTMLEscapeString(item.Language) @@ -111,7 +111,7 @@ func (s *Sections) Social(social *payload.SocialResponse) template.HTML { if social != nil { for _, item := range social.Data { - href := template.HTMLEscapeString(item.URL) + href := sanitizeURL(item.URL) name := template.HTMLEscapeString(item.Name) handle := template.HTMLEscapeString(item.Handle) description := template.HTMLEscapeString(item.Description) @@ -315,3 +315,16 @@ func filterNonEmpty(values []string) []string { return out } + +// sanitizeURL only allows http(s) schemes and returns an escaped value or empty string. +func sanitizeURL(u string) string { + u = strings.TrimSpace(u) + if u == "" { + return "" + } + lower := strings.ToLower(u) + if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") { + return template.HTMLEscapeString(u) + } + return "" +} From 3db4a33034ed3dc10a9a9573baf30e8f285d9f70 Mon Sep 17 00:00:00 2001 From: Gus Date: Tue, 30 Sep 2025 16:19:32 +0800 Subject: [PATCH 05/14] Refactor SEO client getters --- metal/cli/seo/client.go | 24 ++++-------------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/metal/cli/seo/client.go b/metal/cli/seo/client.go index 0c1515a9..da434421 100644 --- a/metal/cli/seo/client.go +++ b/metal/cli/seo/client.go @@ -33,31 +33,15 @@ func get[T any](handler func() router.StaticRouteResource, entityName string) (* } func (c *Client) GetTalks() (*payload.TalksResponse, error) { - var talks payload.TalksResponse - - fn := func() router.StaticRouteResource { + return get[payload.TalksResponse](func() router.StaticRouteResource { return handler.MakeTalksHandler(c.Fixture.GetTalksFile()) - } - - if err := fetch[payload.TalksResponse](&talks, fn); err != nil { - return nil, fmt.Errorf("home: error fetching talks: %w", err) - } - - return &talks, nil + }, "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) { From 6370591dd125c3f4fc4a49a4072c8dd614eaaf1d Mon Sep 17 00:00:00 2001 From: Gus Date: Tue, 30 Sep 2025 16:30:26 +0800 Subject: [PATCH 06/14] Refine SEO generator index build --- metal/cli/seo/generator.go | 10 +++------- metal/cli/seo/generator_test.go | 4 ++-- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/metal/cli/seo/generator.go b/metal/cli/seo/generator.go index 0e64dc69..acbb3031 100644 --- a/metal/cli/seo/generator.go +++ b/metal/cli/seo/generator.go @@ -126,9 +126,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 { @@ -264,10 +264,6 @@ func (g *Generator) Export(origin string, data TemplateData) error { return nil } -func (g *Generator) Build(body []template.HTML) (TemplateData, error) { - return g.buildForPage(WebHomeName, WebHomeUrl, body) -} - func (g *Generator) buildForPage(pageName, path string, body []template.HTML) (TemplateData, error) { og := TagOgData{ ImageHeight: "630", diff --git a/metal/cli/seo/generator_test.go b/metal/cli/seo/generator_test.go index 1f623494..097c6627 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,7 +101,7 @@ 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) } } From 2c7ca4391f86d2b36ed9f43300820e62b36e7726 Mon Sep 17 00:00:00 2001 From: Gus Date: Tue, 30 Sep 2025 16:37:40 +0800 Subject: [PATCH 07/14] Guard SEO sections with early returns --- metal/cli/seo/sections.go | 262 ++++++++++++++++++++------------------ 1 file changed, 135 insertions(+), 127 deletions(-) diff --git a/metal/cli/seo/sections.go b/metal/cli/seo/sections.go index 4c23ad9c..eb5e2aca 100644 --- a/metal/cli/seo/sections.go +++ b/metal/cli/seo/sections.go @@ -107,30 +107,32 @@ func (s *Sections) Projects(projects *payload.ProjectsResponse) template.HTML { } func (s *Sections) Social(social *payload.SocialResponse) template.HTML { + if social == nil { + return template.HTML("

    Social

      ") + } + var items []string - if social != nil { - for _, item := range social.Data { - href := sanitizeURL(item.URL) - name := template.HTMLEscapeString(item.Name) - handle := template.HTMLEscapeString(item.Handle) - 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+ - formatDetails([]string{description})+ - "
    • ", - ) + for _, item := range social.Data { + href := sanitizeURL(item.URL) + name := template.HTMLEscapeString(item.Name) + handle := template.HTMLEscapeString(item.Handle) + 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+ + formatDetails([]string{description})+ + "
    • ", + ) } return template.HTML("

      Social

      " + @@ -141,43 +143,45 @@ func (s *Sections) Social(social *payload.SocialResponse) template.HTML { } func (s *Sections) Recommendations(recs *payload.RecommendationsResponse) template.HTML { + if recs == nil { + return template.HTML("

      Recommendations

        ") + } + var items []string - if recs != nil { - for _, item := range recs.Data { - fullName := template.HTMLEscapeString(item.Person.FullName) - designation := template.HTMLEscapeString(item.Person.Designation) - company := template.HTMLEscapeString(item.Person.Company) - relation := template.HTMLEscapeString(item.Relation) - text := 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+""+ - formatDetails(details)+ - "
      • ", - ) + for _, item := range recs.Data { + fullName := template.HTMLEscapeString(item.Person.FullName) + designation := template.HTMLEscapeString(item.Person.Designation) + company := template.HTMLEscapeString(item.Person.Company) + relation := template.HTMLEscapeString(item.Relation) + text := 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+""+ + formatDetails(details)+ + "
      • ", + ) } return template.HTML("

        Recommendations

        " + @@ -188,48 +192,50 @@ func (s *Sections) Recommendations(recs *payload.RecommendationsResponse) templa } func (s *Sections) Experience(exp *payload.ExperienceResponse) template.HTML { + if exp == nil { + return template.HTML("

        Experience

          ") + } + var items []string - if exp != nil { - for _, item := range exp.Data { - company := template.HTMLEscapeString(item.Company) - position := template.HTMLEscapeString(item.Position) - employmentType := template.HTMLEscapeString(item.EmploymentType) - locationType := template.HTMLEscapeString(item.LocationType) - city := template.HTMLEscapeString(item.City) - country := template.HTMLEscapeString(item.Country) - start := template.HTMLEscapeString(item.StartDate) - end := template.HTMLEscapeString(item.EndDate) - summary := template.HTMLEscapeString(item.Summary) - skills := template.HTMLEscapeString(item.Skills) - - timeline := strings.Join(filterNonEmpty([]string{start, end}), " - ") - location := strings.Join(filterNonEmpty([]string{city, country}), ", ") - heading := strings.Join(filterNonEmpty([]string{position, company}), " at ") - - details := []string{} - if timeline != "" { - details = append(details, "Timeline: "+timeline) - } - if employmentType != "" || locationType != "" { - details = append(details, strings.Join(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+""+ - formatDetails(details)+ - "
        • ", - ) + for _, item := range exp.Data { + company := template.HTMLEscapeString(item.Company) + position := template.HTMLEscapeString(item.Position) + employmentType := template.HTMLEscapeString(item.EmploymentType) + locationType := template.HTMLEscapeString(item.LocationType) + city := template.HTMLEscapeString(item.City) + country := template.HTMLEscapeString(item.Country) + start := template.HTMLEscapeString(item.StartDate) + end := template.HTMLEscapeString(item.EndDate) + summary := template.HTMLEscapeString(item.Summary) + skills := template.HTMLEscapeString(item.Skills) + + timeline := strings.Join(filterNonEmpty([]string{start, end}), " - ") + location := strings.Join(filterNonEmpty([]string{city, country}), ", ") + heading := strings.Join(filterNonEmpty([]string{position, company}), " at ") + + details := []string{} + if timeline != "" { + details = append(details, "Timeline: "+timeline) + } + if employmentType != "" || locationType != "" { + details = append(details, strings.Join(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+""+ + formatDetails(details)+ + "
        • ", + ) } return template.HTML("

          Experience

          " + @@ -240,42 +246,44 @@ func (s *Sections) Experience(exp *payload.ExperienceResponse) template.HTML { } func (s *Sections) Education(edu *payload.EducationResponse) template.HTML { + if edu == nil { + return template.HTML("

          Education

            ") + } + var items []string - if edu != nil { - for _, item := range edu.Data { - school := template.HTMLEscapeString(item.School) - degree := template.HTMLEscapeString(item.Degree) - field := template.HTMLEscapeString(item.Field) - description := template.HTMLEscapeString(item.Description) - graduated := template.HTMLEscapeString(item.GraduatedAt) - country := template.HTMLEscapeString(item.IssuingCountry) - - headingParts := 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, allowLineBreaks(description)) - } - - items = append(items, "
          • "+ - ""+heading+""+ - formatDetails(details)+ - "
          • ", - ) + for _, item := range edu.Data { + school := template.HTMLEscapeString(item.School) + degree := template.HTMLEscapeString(item.Degree) + field := template.HTMLEscapeString(item.Field) + description := template.HTMLEscapeString(item.Description) + graduated := template.HTMLEscapeString(item.GraduatedAt) + country := template.HTMLEscapeString(item.IssuingCountry) + + headingParts := 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, allowLineBreaks(description)) + } + + items = append(items, "
          • "+ + ""+heading+""+ + formatDetails(details)+ + "
          • ", + ) } return template.HTML("

            Education

            " + From f126e90889dd6297b79d9a64a9a2c0f2664cbbf2 Mon Sep 17 00:00:00 2001 From: Gus 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