diff --git a/metal/cli/seo/defaults.go b/metal/cli/seo/defaults.go index e88c4c40..03a0f9c5 100644 --- a/metal/cli/seo/defaults.go +++ b/metal/cli/seo/defaults.go @@ -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" diff --git a/metal/cli/seo/generator.go b/metal/cli/seo/generator.go index 5129f0ad..bc92f736 100644 --- a/metal/cli/seo/generator.go +++ b/metal/cli/seo/generator.go @@ -28,6 +28,7 @@ var templatesFS embed.FS const cgoEnabled = true type Generator struct { + Web *Web Page Page Client *Client Env *env.Environment @@ -48,6 +49,8 @@ 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, @@ -55,15 +58,15 @@ func NewGenerator(db *database.Connection, env *env.Environment, val *portal.Val 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), }, } @@ -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, @@ -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) } @@ -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) } @@ -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) } @@ -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) } @@ -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{ { @@ -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 { @@ -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 } @@ -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) { @@ -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 { diff --git a/metal/cli/seo/generator_test.go b/metal/cli/seo/generator_test.go index fc73b3dd..226117ba 100644 --- a/metal/cli/seo/generator_test.go +++ b/metal/cli/seo/generator_test.go @@ -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{"

Profile

hello

"} - 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) } @@ -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{"

hello

"}); err == nil || !strings.Contains(err.Error(), "invalid template data") { + web := gen.Web.GetHomePage() + if _, err := gen.buildForPage(web.Name, web.Url, []template.HTML{"

hello

"}); err == nil || !strings.Contains(err.Error(), "invalid template data") { t.Fatalf("expected validation error, got %v", err) } } @@ -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()} @@ -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"} diff --git a/metal/cli/seo/jsonld.go b/metal/cli/seo/jsonld.go index 1b1225b6..392d0fbd 100644 --- a/metal/cli/seo/jsonld.go +++ b/metal/cli/seo/jsonld.go @@ -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, @@ -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() }, } } diff --git a/metal/cli/seo/manifest.go b/metal/cli/seo/manifest.go index ace47611..c7d85b7b 100644 --- a/metal/cli/seo/manifest.go +++ b/metal/cli/seo/manifest.go @@ -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 { @@ -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, @@ -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, }, }, } diff --git a/metal/cli/seo/manifest_test.go b/metal/cli/seo/manifest_test.go index 1824851a..61195876 100644 --- a/metal/cli/seo/manifest_test.go +++ b/metal/cli/seo/manifest_test.go @@ -55,7 +55,7 @@ func TestManifestRenderUsesFavicons(t *testing.T) { Body: []template.HTML{"

body

"}, } - manifest := NewManifest(tmpl, data) + manifest := NewManifest(tmpl, data, NewWeb()) manifest.Now = func() time.Time { return time.Unix(0, 0).UTC() } rendered := manifest.Render() @@ -123,7 +123,7 @@ func TestManifestRenderFallsBackToLogo(t *testing.T) { Body: []template.HTML{"

body

"}, } - manifest := NewManifest(tmpl, data) + manifest := NewManifest(tmpl, data, NewWeb()) rendered := manifest.Render() var got map[string]any diff --git a/metal/cli/seo/web.go b/metal/cli/seo/web.go new file mode 100644 index 00000000..3f7842ee --- /dev/null +++ b/metal/cli/seo/web.go @@ -0,0 +1,117 @@ +package seo + +type Web struct { + FoundedYear int16 + ThemeColor string + Robots string + ColorScheme string + Description string + Urls WebPageUrls + Pages map[string]WebPage +} + +type WebPage struct { + Name string + Url string + Title string + Excerpt string +} + +type WebPageUrls struct { + GocantoUrl string + RepoApiUrl string + RepoWebUrl string + LogoUrl string + AboutPhotoUrl string +} + +func NewWeb() *Web { + pages := make(map[string]WebPage, 6) + + home := WebPage{ + Name: "Home", + Url: "/", + Title: AuthorName + "'s Personal Website & Journal", + Excerpt: "Gus's a dedicated engineering leader with over twenty years of experience. He specialises in building high-quality, scalable systems across software development, IT infrastructure, and workplace technology. With expertise in Golang, Node.js, and PHP, He has a proven track record of leading cross-functional teams to deliver secure, compliant solutions, particularly within the financial services sector. His background combines deep technical knowledge in cloud architecture and network protocols with a strategic focus on optimizing workflows, driving innovation, and empowering teams to achieve exceptional results in fast-paced environments.", + } + + about := WebPage{ + Name: "About", + Url: "/about", + Title: "About " + AuthorName, + Excerpt: "Gus's an engineering leader who’s passionate about building reliable and smooth software that strive to make a difference. He also has led teams in designing and delivering scalable, high-performance systems that run efficiently even in complex environments", + } + + projects := WebPage{ + Name: "Projects", + Url: "/projects", + Title: AuthorName + "'s Projects & Tools", + Excerpt: "Over the years, Gus’s built and shared command-line tools and frameworks to tackle real engineering challenges—complete with clear docs and automated tests—and partnered with banks, insurers, and fintech to deliver custom software that balances performance, security, and scalability.", + } + + resume := WebPage{ + Name: "Resume", + Url: "/resume", + Title: AuthorName + "'s professional experience", + Excerpt: "Gus' worked closely with financial services companies, delivering secure and compliant solutions that align with industry regulations and standards. He understands the technical and operational demands of financial institutions and have implemented robust architectures that support high-availability systems, data security, and transactional integrity.", + } + + posts := WebPage{ + Name: "Posts", + Url: "/posts", + } + + postsD := WebPage{ + Name: "Post", + Url: "/post", + } + + pages[HomeSlug] = home + pages[AboutSlug] = about + pages[ProjectsSlug] = projects + pages[ResumeSlug] = resume + pages[PostsSlug] = posts + pages[PostDetailsSlug] = postsD + + urls := WebPageUrls{ + GocantoUrl: "https://gocanto.dev/", + RepoApiUrl: "https://github.com/oullin/api", + RepoWebUrl: "https://github.com/oullin/web", + LogoUrl: "https://oullin.io/assets/001-BBig3EFt.png", + AboutPhotoUrl: "https://oullin.io/images/profile/about-seo.png", + } + + return &Web{ + FoundedYear: 2020, + Urls: urls, + Pages: pages, + ThemeColor: "#0E172B", + Robots: "index,follow", + ColorScheme: "light dark", + Description: "Gus 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.", + } +} + +func (w *Web) GetHomePage() WebPage { + return w.Pages[HomeSlug] +} + +func (w *Web) GetAboutPage() WebPage { + return w.Pages[AboutSlug] +} + +func (w *Web) GetResumePage() WebPage { + return w.Pages[ResumeSlug] +} + +func (w *Web) GetProjectsPage() WebPage { + return w.Pages[ProjectsSlug] +} + +func (w *Web) GetPostsPage() WebPage { + return w.Pages[PostsSlug] +} + +func (w *Web) GetPostsDetailPage() WebPage { + return w.Pages[PostDetailsSlug] +}