Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 7 additions & 33 deletions metal/cli/seo/defaults.go
Original file line number Diff line number Diff line change
@@ -1,36 +1,10 @@
package seo

// --- Web URLs

const GocantoUrl = "https://gocanto.dev/"
const RepoApiUrl = "https://github.com/oullin/api"
const RepoWebUrl = "https://github.com/oullin/web"
const LogoUrl = "https://oullin.io/assets/001-BBig3EFt.png"
const AboutPhotoUrl = "https://oullin.io/images/profile/about-seo.png"

// --- Web Pages

const WebHomeUrl = "/"
const WebHomeName = "Home"

const WebAboutName = "About"
const WebAboutUrl = "/about"

const WebPostsName = "Posts"
const WebPostsUrl = "/posts"
const WebPostDetailUrl = "/post"

const WebResumeName = "Resume"
const WebResumeUrl = "/resume"

const WebProjectsName = "Projects"
const WebProjectsUrl = "/projects"

// --- Web Meta

const FoundedYear = 2020
const StubPath = "stub.html"
const ThemeColor = "#0E172B"
const Robots = "index,follow"
const ColorScheme = "light dark"
const Description = "Gustavo is a full-stack Software Engineer leader with over two decades of experience in building complex web systems and products, specialising in areas like e-commerce, banking, cross-payment solutions, cyber security, and customer success."
const AuthorName = "Gustavo Ocanto"
const HomeSlug = "home"
const AboutSlug = "about"
const ProjectsSlug = "projects"
const ResumeSlug = "resume"
const PostsSlug = "posts"
const PostDetailsSlug = "post-details"
71 changes: 41 additions & 30 deletions metal/cli/seo/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ var templatesFS embed.FS
const cgoEnabled = true

type Generator struct {
Web *Web
Page Page
Client *Client
Env *env.Environment
Expand All @@ -48,22 +49,24 @@ func NewGenerator(db *database.Connection, env *env.Environment, val *portal.Val
return nil, fmt.Errorf("initialising categories: %w", err)
}

web := NewWeb()

page := Page{
StubPath: StubPath,
Categories: categories,
SiteName: env.App.Name,
Lang: env.App.Lang(),
OutputDir: env.Seo.SpaDir,
Template: &template.Template{},
LogoURL: portal.SanitiseURL(LogoUrl),
WebRepoURL: portal.SanitiseURL(RepoWebUrl),
APIRepoURL: portal.SanitiseURL(RepoApiUrl),
LogoURL: portal.SanitiseURL(web.Urls.LogoUrl),
WebRepoURL: portal.SanitiseURL(web.Urls.RepoWebUrl),
APIRepoURL: portal.SanitiseURL(web.Urls.RepoApiUrl),
SiteURL: portal.SanitiseURL(env.App.URL),
AboutPhotoUrl: portal.SanitiseURL(AboutPhotoUrl),
AboutPhotoUrl: portal.SanitiseURL(web.Urls.AboutPhotoUrl),
SameAsURL: []string{
portal.SanitiseURL(RepoApiUrl),
portal.SanitiseURL(RepoWebUrl),
portal.SanitiseURL(GocantoUrl),
portal.SanitiseURL(web.Urls.RepoApiUrl),
portal.SanitiseURL(web.Urls.RepoWebUrl),
portal.SanitiseURL(web.Urls.GocantoUrl),
},
}

Expand All @@ -82,6 +85,7 @@ func NewGenerator(db *database.Connection, env *env.Environment, val *portal.Val
return &Generator{
DB: db,
Env: env,
Web: web,
Validator: val,
Page: page,
WebsiteRoutes: webRoutes,
Expand Down Expand Up @@ -145,7 +149,8 @@ func (g *Generator) GenerateIndex() error {

// ----- Template Parsing

tData, buildErr := g.buildForPage(WebHomeName, WebHomeUrl, html)
web := g.Web.GetHomePage()
tData, buildErr := g.buildForPage(web.Name, web.Url, html)
if buildErr != nil {
return fmt.Errorf("home: generating template data: %w", buildErr)
}
Expand Down Expand Up @@ -185,7 +190,8 @@ func (g *Generator) GenerateAbout() error {
html = append(html, sections.Social(social))
html = append(html, sections.Recommendations(recommendations))

data, buildErr := g.buildForPage(WebAboutName, WebAboutUrl, html)
web := g.Web.GetAboutPage()
data, buildErr := g.buildForPage(web.Name, web.Url, html)
if buildErr != nil {
return fmt.Errorf("about: generating template data: %w", buildErr)
}
Expand All @@ -210,7 +216,8 @@ func (g *Generator) GenerateProjects() error {
sections := NewSections()
body := []template.HTML{sections.Projects(projects)}

data, buildErr := g.buildForPage(WebProjectsName, WebProjectsUrl, body)
web := g.Web.GetProjectsPage()
data, buildErr := g.buildForPage(web.Name, web.Url, body)
if buildErr != nil {
return fmt.Errorf("projects: generating template data: %w", buildErr)
}
Expand Down Expand Up @@ -251,7 +258,8 @@ func (g *Generator) GenerateResume() error {
html = append(html, sections.Experience(experience))
html = append(html, sections.Recommendations(recommendations))

data, buildErr := g.buildForPage(WebResumeName, WebResumeUrl, html)
web := g.Web.GetResumePage()
data, buildErr := g.buildForPage(web.Name, web.Url, html)
if buildErr != nil {
return fmt.Errorf("resume: generating template data: %w", buildErr)
}
Expand Down Expand Up @@ -366,15 +374,15 @@ func (g *Generator) buildForPage(pageName, path string, body []template.HTML, op

data := TemplateData{
OGTagOg: og,
Robots: Robots,
Robots: g.Web.Robots,
Twitter: twitter,
ThemeColor: ThemeColor,
ColorScheme: ColorScheme,
BgColor: ThemeColor,
ThemeColor: g.Web.ThemeColor,
ColorScheme: g.Web.ColorScheme,
BgColor: g.Web.ThemeColor,
Lang: g.Page.Lang,
Description: Description,
Description: g.Web.Description,
Categories: g.Page.Categories,
JsonLD: NewJsonID(g.Page).Render(),
JsonLD: NewJsonID(g.Page, g.Web).Render(),
AppleTouchIcon: portal.SanitiseURL(g.Page.LogoURL),
HrefLang: []HrefLangData{
{
Expand All @@ -394,7 +402,7 @@ func (g *Generator) buildForPage(pageName, path string, body []template.HTML, op

data.Body = body
data.Title = g.TitleFor(pageName)
data.Manifest = NewManifest(g.Page, data).Render()
data.Manifest = NewManifest(g.Page, data, g.Web).Render()
data.Canonical = portal.SanitiseURL(g.CanonicalFor(path))

for _, opt := range opts {
Expand Down Expand Up @@ -452,7 +460,7 @@ func (g *Generator) CanonicalFor(path string) string {
}

func (g *Generator) TitleFor(pageName string) string {
if pageName == WebHomeName {
if pageName == g.Web.GetHomePage().Name {
return g.Page.SiteName
}

Expand All @@ -478,23 +486,25 @@ func truncateForLog(value string) string {
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)
description := g.SanitizeMetaDescription(post.Excerpt, g.Web.Description)
image := g.PreferredImageURL(post.CoverImageURL, g.Page.AboutPhotoUrl)
imageType := "image/png"

cli.Grayln(fmt.Sprintf("Preparing post metadata"))
cli.Grayln(fmt.Sprintf(" Canonical path: %s", path))
cli.Grayln(fmt.Sprintf(" Sanitised alt text: %s", imageAlt))
cli.Grayln(fmt.Sprintf(" Description preview: %s", truncateForLog(description)))
cli.Grayln(fmt.Sprintf(" Preferred image candidate: %s", image))
cli.Grayln("\n----------------- [POST BUILD] ----------------- ")

cli.Grayln(fmt.Sprintf("Preparing POSTS metadata"))
cli.Magentaln(fmt.Sprintf(" .......... Canonical path: %s", path))
cli.Blueln(fmt.Sprintf(" .......... Sanitised alt text: %s", imageAlt))
cli.Successln(fmt.Sprintf(" .......... Description preview: %s", truncateForLog(description)))
cli.Cyanln(fmt.Sprintf(" .......... Preferred image candidate: %s", image))

if prepared, err := g.preparePostImage(post); err == nil && prepared.URL != "" {
cli.Grayln(fmt.Sprintf(" Post image prepared at: %s (%s)", prepared.URL, prepared.Mime))
cli.Successln(fmt.Sprintf(" Post image prepared at: %s (%s)", prepared.URL, prepared.Mime))
image = prepared.URL
imageType = prepared.Mime
} else if err != nil {
cli.Errorln(fmt.Sprintf("failed to prepare post image for %s: %v", post.Slug, err))
cli.Grayln(fmt.Sprintf(" Falling back to preferred image URL: %s", image))
cli.Errorln(fmt.Sprintf("Failed to prepare post image for %s: %v", post.Slug, err))
cli.Warningln(fmt.Sprintf(" .......... Falling back to preferred image URL: %s", image))
}

return g.buildForPage(post.Title, path, body, func(data *TemplateData) {
Expand All @@ -511,12 +521,13 @@ func (g *Generator) BuildForPost(post payload.PostResponse, body []template.HTML
func (g *Generator) CanonicalPostPath(slug string) string {
cleaned := strings.TrimSpace(slug)
cleaned = strings.Trim(cleaned, "/")
web := g.Web.GetPostsDetailPage()

if cleaned == "" {
return WebPostDetailUrl
return web.Url
}

return WebPostDetailUrl + "/" + cleaned
return web.Url + "/" + cleaned
}

func (g *Generator) SanitizeMetaDescription(raw, fallback string) string {
Expand Down
10 changes: 8 additions & 2 deletions metal/cli/seo/generator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,12 @@ func TestGeneratorBuildAndExport(t *testing.T) {
gen := &Generator{
Page: page,
Validator: newTestValidator(t),
Web: NewWeb(),
}

web := gen.Web.GetHomePage()
body := []template.HTML{"<h1>Profile</h1><p>hello</p>"}
data, err := gen.buildForPage(WebHomeName, WebHomeUrl, body)
data, err := gen.buildForPage(web.Name, web.Url, body)
if err != nil {
t.Fatalf("build err: %v", err)
}
Expand Down Expand Up @@ -114,9 +116,11 @@ func TestGeneratorBuildRejectsInvalidTemplateData(t *testing.T) {
Categories: []string{"golang"},
},
Validator: newTestValidator(t),
Web: NewWeb(),
}

if _, err := gen.buildForPage(WebHomeName, WebHomeUrl, []template.HTML{"<p>hello</p>"}); err == nil || !strings.Contains(err.Error(), "invalid template data") {
web := gen.Web.GetHomePage()
if _, err := gen.buildForPage(web.Name, web.Url, []template.HTML{"<p>hello</p>"}); err == nil || !strings.Contains(err.Error(), "invalid template data") {
t.Fatalf("expected validation error, got %v", err)
}
}
Expand Down Expand Up @@ -258,6 +262,7 @@ func TestGeneratorPreparePostImage(t *testing.T) {
OutputDir: outputDir,
},
Env: &env.Environment{Seo: env.SeoEnvironment{SpaDir: outputDir, SpaImagesDir: imagesDir}},
Web: NewWeb(),
}

post := payload.PostResponse{Slug: "awesome-post", CoverImageURL: fileURL.String()}
Expand Down Expand Up @@ -339,6 +344,7 @@ func TestGeneratorPreparePostImageRemote(t *testing.T) {
OutputDir: outputDir,
},
Env: &env.Environment{Seo: env.SeoEnvironment{SpaDir: outputDir, SpaImagesDir: imagesDir}},
Web: NewWeb(),
}

post := payload.PostResponse{Slug: "remote-post", CoverImageURL: server.URL + "/cover.png"}
Expand Down
8 changes: 6 additions & 2 deletions metal/cli/seo/jsonld.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ type JsonID struct {
WebName string
}

func NewJsonID(tmpl Page) *JsonID {
func NewJsonID(tmpl Page, web *Web) *JsonID {
if web == nil {
web = NewWeb()
}

return &JsonID{
Lang: tmpl.Lang,
SiteURL: tmpl.SiteURL,
Expand All @@ -35,7 +39,7 @@ func NewJsonID(tmpl Page) *JsonID {
SameAs: tmpl.SameAsURL,
APIRepoURL: tmpl.APIRepoURL,
WebRepoURL: tmpl.WebRepoURL,
FoundedYear: fmt.Sprintf("%d", FoundedYear),
FoundedYear: fmt.Sprintf("%d", web.FoundedYear),
Now: func() time.Time { return time.Now().UTC() },
}
}
Expand Down
38 changes: 21 additions & 17 deletions metal/cli/seo/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ type ManifestShortcut struct {
Desc string `json:"description,omitempty"`
}

func NewManifest(tmpl Page, data TemplateData) *Manifest {
func NewManifest(tmpl Page, data TemplateData, web *Web) *Manifest {
var icons []ManifestIcon

if len(data.Favicons) > 0 {
Expand All @@ -48,10 +48,14 @@ func NewManifest(tmpl Page, data TemplateData) *Manifest {
icons = []ManifestIcon{{Src: tmpl.LogoURL, Sizes: "512x512", Type: "image/png", Purpose: "any"}}
}

if web == nil {
web = NewWeb()
}

b := &Manifest{
Icons: icons,
Lang: tmpl.Lang,
Scope: WebHomeUrl,
Scope: web.GetHomePage().Url,
BgColor: data.BgColor,
StartURL: tmpl.SiteURL,
Name: tmpl.SiteName,
Expand All @@ -64,33 +68,33 @@ func NewManifest(tmpl Page, data TemplateData) *Manifest {
Shortcuts: []ManifestShortcut{
{
Icons: icons,
URL: WebHomeUrl,
Name: WebHomeName,
ShortName: WebHomeName,
URL: web.GetHomePage().Url,
Name: web.GetHomePage().Name,
ShortName: web.GetHomePage().Name,
},
{
Icons: icons,
URL: WebProjectsUrl,
Name: WebProjectsName,
ShortName: WebProjectsName,
URL: web.GetProjectsPage().Url,
Name: web.GetProjectsPage().Name,
ShortName: web.GetProjectsPage().Name,
},
{
Icons: icons,
URL: WebPostsUrl,
Name: WebPostsName,
ShortName: WebPostsName,
URL: web.GetPostsPage().Url,
Name: web.GetPostsPage().Name,
ShortName: web.GetPostsPage().Name,
},
{
Icons: icons,
URL: WebAboutUrl,
Name: WebAboutName,
ShortName: WebAboutName,
URL: web.GetAboutPage().Url,
Name: web.GetAboutPage().Name,
ShortName: web.GetAboutPage().Name,
},
{
Icons: icons,
URL: WebResumeUrl,
Name: WebResumeName,
ShortName: WebResumeName,
URL: web.GetResumePage().Url,
Name: web.GetResumePage().Name,
ShortName: web.GetResumePage().Name,
},
},
}
Expand Down
4 changes: 2 additions & 2 deletions metal/cli/seo/manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func TestManifestRenderUsesFavicons(t *testing.T) {
Body: []template.HTML{"<p>body</p>"},
}

manifest := NewManifest(tmpl, data)
manifest := NewManifest(tmpl, data, NewWeb())
manifest.Now = func() time.Time { return time.Unix(0, 0).UTC() }

rendered := manifest.Render()
Expand Down Expand Up @@ -123,7 +123,7 @@ func TestManifestRenderFallsBackToLogo(t *testing.T) {
Body: []template.HTML{"<p>body</p>"},
}

manifest := NewManifest(tmpl, data)
manifest := NewManifest(tmpl, data, NewWeb())
rendered := manifest.Render()

var got map[string]any
Expand Down
Loading
Loading