diff --git a/.env.example b/.env.example index 47fbb77e..f798bd7f 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,8 @@ ENV_APP_NAME="Gus Blog" ENV_APP_ENV_TYPE=local ENV_APP_LOG_LEVEL=debug ENV_APP_LOGS_DIR="./storage/logs/logs_%s.log" -ENV_APP_LOGS_DATE_FORMAT="2006_02_01" +ENV_APP_LOGS_DATE_FORMAT="2006-01-02" +ENV_APP_URL= # --- The App master key for encryption. ENV_APP_MASTER_KEY= @@ -32,3 +33,6 @@ ENV_DOCKER_USER_GROUP="ggroup" # type: string, min: 16 characters. ENV_PING_USERNAME= ENV_PING_PASSWORD= + +# --- SEO: SPA application directory +ENV_SPA_DIR= diff --git a/.env.gh.example b/.env.gh.example deleted file mode 100644 index ec9f6582..00000000 --- a/.env.gh.example +++ /dev/null @@ -1,5 +0,0 @@ -ENV_HTTP_PORT=8080 -ENV_DOCKER_USER=gocanto -ENV_DOCKER_USER_GROUP=ggroup -CADDY_LOGS_PATH=./storage/logs/caddy -ENV_PUBLIC_ALLOWED_IP= diff --git a/.env.prod.example b/.env.prod.example deleted file mode 100644 index a0393fed..00000000 --- a/.env.prod.example +++ /dev/null @@ -1,37 +0,0 @@ -# --- App -ENV_APP_NAME="Gus Blog" -ENV_APP_ENV_TYPE=production -ENV_APP_LOG_LEVEL=debug -ENV_APP_LOGS_DIR="./storage/logs/logs_%s.log" -ENV_APP_LOGS_DATE_FORMAT="2006_02_01" - -# --- Public middleware -ENV_PUBLIC_ALLOWED_IP= - -# --- DB -ENV_DB_PORT= -ENV_DB_HOST= -ENV_DB_SSL_MODE= -ENV_DB_TIMEZONE= -DB_VOLUME_DATA_DIRECTORY=./database/infra/data - -# --- Database Secrets -DB_SECRET_USERNAME=./database/infra/secrets/pg_username -DB_SECRET_PASSWORD=./database/infra/secrets/pg_password -DB_SECRET_DBNAME=./database/infra/secrets/pg_dbname - - -# --- Sentry -ENV_SENTRY_DSN="" -ENV_SENTRY_CSP="" - -# --- HTTP -ENV_HTTP_HOST= -ENV_HTTP_PORT= - -# --- Docker -ENV_DOCKER_USER= -ENV_DOCKER_USER_GROUP= - -# --- Caddy -CADDY_LOGS_PATH=./storage/logs/caddy diff --git a/.gitignore b/.gitignore index dda43df9..12e2403d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,10 @@ tmp database/infra/data +# -- [SEO]: static files +storage/seo/*.* +!storage/seo/.gitkeep + # --- [Caddy]: mtls caddy/mtls/*.* !caddy/mtls/.gitkeep diff --git a/Makefile b/Makefile index 352473bf..c3bfb0e0 100644 --- a/Makefile +++ b/Makefile @@ -54,14 +54,16 @@ help: @printf " $(BOLD)$(GREEN)audit$(NC) : Run code audits and checks.\n" @printf " $(BOLD)$(GREEN)watch$(NC) : Start a file watcher process.\n" @printf " $(BOLD)$(GREEN)format$(NC) : Automatically format code.\n" - @printf " $(BOLD)$(GREEN)test-all$(NC) : Run all the application tests.\n\n" - @printf " $(BOLD)$(GREEN)run-cli$(NC) : Run the application CLI interface.\n\n" - @printf " $(BOLD)$(GREEN)run-cli-local$(NC) : Run the application dev's CLI interface.\n\n" + @printf " $(BOLD)$(GREEN)test-all$(NC) : Run all the application tests.\n" + @printf " $(BOLD)$(GREEN)run-cli$(NC) : Run the application CLI interface.\n" + @printf " $(BOLD)$(GREEN)run-cli-docker$(NC) : Run the application [docker] dev's CLI interface.\n\n" + @printf " $(BOLD)$(GREEN)run-metal$(NC) : Run the application dev's CLI interface.\n\n" @printf "$(BOLD)$(BLUE)Build Commands:$(NC)\n" @printf " $(BOLD)$(GREEN)build-local$(NC) : Build the main application for development.\n" @printf " $(BOLD)$(GREEN)build-ci$(NC) : Build the main application for the CI.\n" - @printf " $(BOLD)$(GREEN)build-release$(NC) : Build a release version of the application.\n\n" + @printf " $(BOLD)$(GREEN)build-release$(NC) : Build a release version of the application.\n" + @printf " $(BOLD)$(GREEN)build-fresh$(NC) : Build a fresh development environment.\n\n" @printf "$(BOLD)$(BLUE)Database Commands:$(NC)\n" @printf " $(BOLD)$(GREEN)db:local$(NC) : Set up or manage the local database environment.\n" @@ -95,7 +97,7 @@ help: @printf " $(BOLD)$(GREEN)supv:api:stop$(NC) : Stop the API service supervisor.\n" @printf " $(BOLD)$(GREEN)supv:api:restart$(NC) : Restart the API service supervisor.\n" @printf " $(BOLD)$(GREEN)supv:api:logs$(NC) : Show the the API service supervisor logs.\n" - @printf " $(BOLD)$(GREEN)supv:api:logs-err$(NC): Show the the API service supervisor error logs.\n" + @printf " $(BOLD)$(GREEN)supv:api:logs-err$(NC): Show the the API service supervisor error logs.\n\n" @printf "$(BOLD)$(BLUE)Caddy Commands:$(NC)\n" @printf " $(BOLD)$(GREEN)caddy-gen-cert$(NC) : Generate the caddy's mtls certificates.\n" diff --git a/database/repository/categories.go b/database/repository/categories.go index 17a2f92d..2a5b1195 100644 --- a/database/repository/categories.go +++ b/database/repository/categories.go @@ -2,17 +2,33 @@ package repository import ( "fmt" + "strings" + "github.com/google/uuid" "github.com/oullin/database" "github.com/oullin/database/repository/pagination" "github.com/oullin/pkg/gorm" - "strings" ) type Categories struct { DB *database.Connection } +func (c Categories) Get() ([]database.Category, error) { + var categories []database.Category + + err := c.DB.Sql(). + Model(&database.Category{}). + Where("categories.deleted_at is null"). + Find(&categories).Error + + if err != nil { + return nil, err + } + + return categories, nil +} + func (c Categories) GetAll(paginate pagination.Paginate) (*pagination.Pagination[database.Category], error) { var numItems int64 var categories []database.Category diff --git a/docker-compose.yml b/docker-compose.yml index 5f2ab818..b76cdb39 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -80,10 +80,11 @@ services: # A dedicated service for running one-off Go commands api-runner: restart: no + env_file: + - ./.env image: golang:1.25.1-alpine@sha256:b6ed3fd0452c0e9bcdef5597f29cc1418f61672e9d3a2f55bf02e7222c014abd volumes: - .:/app - - ./.env:/.env:ro - go_mod_cache:/go/pkg/mod working_dir: /app environment: diff --git a/handler/keep_alive.go b/handler/keep_alive.go index 521a5235..7b1209a0 100644 --- a/handler/keep_alive.go +++ b/handler/keep_alive.go @@ -12,10 +12,10 @@ import ( ) type KeepAliveHandler struct { - env *env.Ping + env *env.PingEnvironment } -func MakeKeepAliveHandler(e *env.Ping) KeepAliveHandler { +func MakeKeepAliveHandler(e *env.PingEnvironment) KeepAliveHandler { return KeepAliveHandler{env: e} } diff --git a/handler/keep_alive_db.go b/handler/keep_alive_db.go index b589377c..cc7ab05c 100644 --- a/handler/keep_alive_db.go +++ b/handler/keep_alive_db.go @@ -13,11 +13,11 @@ import ( ) type KeepAliveDBHandler struct { - env *env.Ping + env *env.PingEnvironment db *database.Connection } -func MakeKeepAliveDBHandler(e *env.Ping, db *database.Connection) KeepAliveDBHandler { +func MakeKeepAliveDBHandler(e *env.PingEnvironment, db *database.Connection) KeepAliveDBHandler { return KeepAliveDBHandler{env: e, db: db} } diff --git a/handler/keep_alive_db_test.go b/handler/keep_alive_db_test.go index 2ce01b2d..da678956 100644 --- a/handler/keep_alive_db_test.go +++ b/handler/keep_alive_db_test.go @@ -15,7 +15,7 @@ import ( func TestKeepAliveDBHandler(t *testing.T) { db, _ := handlertests.MakeTestDB(t) - e := env.Ping{Username: "user", Password: "pass"} + e := env.PingEnvironment{Username: "user", Password: "pass"} h := MakeKeepAliveDBHandler(&e, db) t.Run("valid credentials", func(t *testing.T) { diff --git a/handler/keep_alive_test.go b/handler/keep_alive_test.go index a0296be2..015921d1 100644 --- a/handler/keep_alive_test.go +++ b/handler/keep_alive_test.go @@ -13,7 +13,7 @@ import ( ) func TestKeepAliveHandler(t *testing.T) { - e := env.Ping{Username: "user", Password: "pass"} + e := env.PingEnvironment{Username: "user", Password: "pass"} h := MakeKeepAliveHandler(&e) t.Run("valid credentials", func(t *testing.T) { diff --git a/metal/cli/accounts/handler.go b/metal/cli/accounts/handler.go index fa345d01..9a31f59e 100644 --- a/metal/cli/accounts/handler.go +++ b/metal/cli/accounts/handler.go @@ -15,7 +15,7 @@ func (h Handler) CreateAccount(accountName string) error { return fmt.Errorf("failed to create the given account [%s] tokens pair: %v", accountName, err) } - _, err = h.Tokens.Create(database.APIKeyAttr{ + item, err := h.Tokens.Create(database.APIKeyAttr{ AccountName: token.AccountName, SecretKey: token.EncryptedSecretKey, PublicKey: token.EncryptedPublicKey, @@ -25,12 +25,14 @@ func (h Handler) CreateAccount(accountName string) error { return fmt.Errorf("failed to create account [%s]: %v", accountName, err) } - cli.Successln("Account created successfully.\n") + if err = h.print(token, item); err != nil { + return fmt.Errorf("could not decode the given account [%s] keys: %v", item.AccountName, err) + } return nil } -func (h Handler) ReadAccount(accountName string) error { +func (h Handler) ShowAccount(accountName string) error { item := h.Tokens.FindBy(accountName) if item == nil { @@ -43,30 +45,14 @@ func (h Handler) ReadAccount(accountName string) error { item.PublicKey, ) - if err != nil { + if h.print(token, item) != nil { return fmt.Errorf("could not decode the given account [%s] keys: %v", item.AccountName, err) } - cli.Successln("\nThe given account has been found successfully!\n") - cli.Blueln(" > " + fmt.Sprintf("Account name: %s", token.AccountName)) - cli.Blueln(" > " + fmt.Sprintf("Public Key: %s", token.PublicKey)) - cli.Blueln(" > " + fmt.Sprintf("Secret Key: %s", token.SecretKey)) - cli.Blueln(" > " + fmt.Sprintf("API Signature: %s", auth.CreateSignatureFrom(token.AccountName, token.SecretKey))) - cli.Warningln("----- Encrypted Values -----") - cli.Magentaln(" > " + fmt.Sprintf("Public Key: %x", token.EncryptedPublicKey)) - cli.Magentaln(" > " + fmt.Sprintf("Secret Key: %x", token.EncryptedSecretKey)) - fmt.Println(" ") - return nil } -func (h Handler) CreateSignature(accountName string) error { - item := h.Tokens.FindBy(accountName) - - if item == nil { - return fmt.Errorf("the given account [%s] was not found", accountName) - } - +func (h Handler) print(token *auth.Token, item *database.APIKey) error { token, err := h.TokenHandler.DecodeTokensFor( item.AccountName, item.SecretKey, @@ -77,14 +63,14 @@ func (h Handler) CreateSignature(accountName string) error { return fmt.Errorf("could not decode the given account [%s] keys: %v", item.AccountName, err) } - signature := auth.CreateSignatureFrom(token.AccountName, token.SecretKey) - cli.Successln("\nThe given account has been found successfully!\n") cli.Blueln(" > " + fmt.Sprintf("Account name: %s", token.AccountName)) - cli.Blueln(" > " + fmt.Sprintf("Public Key: %s", auth.SafeDisplay(token.PublicKey))) - cli.Blueln(" > " + fmt.Sprintf("Secret Key: %s", auth.SafeDisplay(token.SecretKey))) + cli.Blueln(" > " + fmt.Sprintf("Public Key: %s", token.PublicKey)) + cli.Blueln(" > " + fmt.Sprintf("Secret Key: %s", token.SecretKey)) + cli.Blueln(" > " + fmt.Sprintf("API Signature: %s", auth.CreateSignatureFrom(token.AccountName, token.SecretKey))) cli.Warningln("----- Encrypted Values -----") - cli.Magentaln(" > " + fmt.Sprintf("Signature: %s", signature)) + cli.Magentaln(" > " + fmt.Sprintf("Public Key: %x", token.EncryptedPublicKey)) + cli.Magentaln(" > " + fmt.Sprintf("Secret Key: %x", token.EncryptedSecretKey)) fmt.Println(" ") return nil diff --git a/metal/cli/accounts/handler_test.go b/metal/cli/accounts/handler_test.go index ac53ce0d..9ac813a8 100644 --- a/metal/cli/accounts/handler_test.go +++ b/metal/cli/accounts/handler_test.go @@ -25,13 +25,9 @@ func TestCreateReadSignature(t *testing.T) { t.Fatalf("create: %v", err) } - if err := h.ReadAccount("tester"); err != nil { + if err := h.ShowAccount("tester"); err != nil { t.Fatalf("read: %v", err) } - - if err := h.CreateSignature("tester"); err != nil { - t.Fatalf("signature: %v", err) - } } func TestCreateAccountInvalid(t *testing.T) { @@ -42,18 +38,10 @@ func TestCreateAccountInvalid(t *testing.T) { } } -func TestReadAccountNotFound(t *testing.T) { - h := setupAccountHandler(t) - - if err := h.ReadAccount("missing"); err == nil { - t.Fatalf("expected error") - } -} - -func TestCreateSignatureNotFound(t *testing.T) { +func TestShowAccountNotFound(t *testing.T) { h := setupAccountHandler(t) - if err := h.CreateSignature("missing"); err == nil { + if err := h.ShowAccount("missing"); err == nil { t.Fatalf("expected error") } } diff --git a/metal/cli/main.go b/metal/cli/main.go index c841cef2..bf916e23 100644 --- a/metal/cli/main.go +++ b/metal/cli/main.go @@ -1,10 +1,14 @@ package main import ( + "fmt" + "time" + "github.com/oullin/database" "github.com/oullin/metal/cli/accounts" "github.com/oullin/metal/cli/panel" "github.com/oullin/metal/cli/posts" + "github.com/oullin/metal/cli/seo" "github.com/oullin/metal/env" "github.com/oullin/metal/kernel" "github.com/oullin/pkg/cli" @@ -57,7 +61,14 @@ func main() { return case 4: - if err = generateApiAccountsHTTPSignature(menu); err != nil { + if err = generateSEO(); err != nil { + cli.Errorln(err.Error()) + continue + } + + return + case 5: + if err = printTimestamp(); err != nil { cli.Errorln(err.Error()) continue } @@ -126,29 +137,58 @@ func showApiAccount(menu panel.Menu) error { return err } - if err = handler.ReadAccount(account); err != nil { + if err = handler.ShowAccount(account); err != nil { return err } return nil } -func generateApiAccountsHTTPSignature(menu panel.Menu) error { - var err error - var account string - var handler *accounts.Handler +func generateSEO() error { + gen, err := seo.NewGenerator( + dbConn, + environment, + portal.GetDefaultValidator(), + ) - if account, err = menu.CaptureAccountName(); err != nil { + if err != nil { return err } - if handler, err = accounts.MakeHandler(dbConn, environment); err != nil { + if err = gen.Generate(); err != nil { return err } - if err = handler.CreateSignature(account); err != nil { - return err - } + return nil +} + +func printTimestamp() error { + now := time.Now() + + fmt.Println("--- Timestamps ---") + + // 1. Unix Timestamp (seconds since epoch) + unixTimestampSeconds := now.Unix() + fmt.Printf("Unix (seconds): %d\n", unixTimestampSeconds) + + // 2. Unix Timestamp (milliseconds since epoch) + unixTimestampMillis := now.UnixMilli() + fmt.Printf("Unix (milliseconds): %d\n", unixTimestampMillis) + + // 3. Unix Timestamp (nanoseconds since epoch) + unixTimestampNanos := now.UnixNano() + fmt.Printf("Unix (nanoseconds): %d\n", unixTimestampNanos) + + fmt.Println("\n--- Formatted Strings ---") + + // 4. Standard RFC3339 format (e.g., "2025-09-22T14:10:16+08:00") + rfc3339Timestamp := now.Format(time.RFC3339) + fmt.Printf("RFC3339: %s\n", rfc3339Timestamp) + + // 5. Custom format (e.g., "YYYY-MM-DD HH:MM:SS") + // time for layouts: Mon Jan 2 15:04:05 MST 2006 + customTimestamp := now.Format("2006-01-02 15:04:05") + fmt.Printf("Custom (YYYY-MM-DD HH:MM:SS): %s\n", customTimestamp) return nil } diff --git a/metal/cli/panel/menu.go b/metal/cli/panel/menu.go index 074c8d7f..99225ef9 100644 --- a/metal/cli/panel/menu.go +++ b/metal/cli/panel/menu.go @@ -89,7 +89,9 @@ func (p *Menu) Print() { p.PrintOption("1) Parse Blog Posts.", inner) p.PrintOption("2) Create new API account.", inner) p.PrintOption("3) Show API accounts.", inner) - p.PrintOption("4) Generate API account HTTP key pair.", inner) + p.PrintOption("4) Generate SEO.", inner) + p.PrintOption("5) Print Timestamp.", inner) + p.PrintOption(" ", inner) p.PrintOption("0) Exit.", inner) diff --git a/metal/cli/seo/categories.go b/metal/cli/seo/categories.go new file mode 100644 index 00000000..601d5f65 --- /dev/null +++ b/metal/cli/seo/categories.go @@ -0,0 +1,37 @@ +package seo + +import ( + "fmt" + "strings" + + "github.com/oullin/database" + "github.com/oullin/database/repository" +) + +type CategoriesSEO struct { + DB *database.Connection + Repository repository.Categories +} + +func NewCategories(db *database.Connection) *CategoriesSEO { + return &CategoriesSEO{ + DB: db, + Repository: repository.Categories{DB: db}, + } +} + +func (c *CategoriesSEO) Generate() ([]string, error) { + var err error + var items []database.Category + + if items, err = c.Repository.Get(); err != nil { + return nil, fmt.Errorf("could not get categories: %w", err) + } + + var categories []string + for item := range items { + categories = append(categories, strings.ToLower(items[item].Name)) + } + + return categories, nil +} diff --git a/metal/cli/seo/client.go b/metal/cli/seo/client.go new file mode 100644 index 00000000..dd9b9815 --- /dev/null +++ b/metal/cli/seo/client.go @@ -0,0 +1,82 @@ +package seo + +import ( + "encoding/json" + "fmt" + "net/http/httptest" + + "github.com/oullin/handler" + "github.com/oullin/handler/payload" + "github.com/oullin/metal/router" +) + +type Client struct { + WebsiteRoutes *router.WebsiteRoutes + Fixture router.Fixture +} + +func NewClient(routes *router.WebsiteRoutes) *Client { + return &Client{ + WebsiteRoutes: routes, + Fixture: routes.Fixture, + } +} + +func (c *Client) GetTalks() (*payload.TalksResponse, error) { + var talks payload.TalksResponse + + fn := 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 +} + +func (c *Client) GetProfile() (*payload.ProfileResponse, error) { + var profile payload.ProfileResponse + + fn := 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 +} + +func (c *Client) GetProjects() (*payload.ProjectsResponse, error) { + var projects payload.ProjectsResponse + + fn := 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 +} + +func fetch[T any](response *T, handler func() router.StaticRouteResource) error { + req := httptest.NewRequest("GET", "http://localhost:8080/proxy", nil) + rr := httptest.NewRecorder() + + maker := handler() + + if err := maker.Handle(rr, req); err != nil { + return err + } + + if err := json.Unmarshal(rr.Body.Bytes(), response); err != nil { + return err + } + + return nil +} diff --git a/metal/cli/seo/data.go b/metal/cli/seo/data.go new file mode 100644 index 00000000..79ef790f --- /dev/null +++ b/metal/cli/seo/data.go @@ -0,0 +1,65 @@ +package seo + +import "html/template" + +type Page struct { + OutputDir string `validate:"required"` + Template *template.Template `validate:"required"` + SiteName string `validate:"required"` + SameAsURL []string `validate:"required"` + Categories []string `validate:"required"` + SiteURL string `validate:"required,uri"` + LogoURL string `validate:"required,uri"` + WebRepoURL string `validate:"required,uri"` + APIRepoURL string `validate:"required,uri"` + AboutPhotoUrl string `validate:"required,uri"` + Lang string `validate:"required,oneof=en_GB"` + StubPath string `validate:"required,oneof=stub.html"` +} + +type TemplateData struct { + Lang string `validate:"required,oneof=en_GB"` + Title string `validate:"required,min=10"` + Description string `validate:"required,min=10"` + Canonical string `validate:"required,url"` + Robots string `validate:"required"` + ThemeColor string `validate:"required"` + JsonLD template.JS `validate:"required"` + OGTagOg TagOgData `validate:"required"` + Twitter TwitterData `validate:"required"` + HrefLang []HrefLangData `validate:"required"` + Favicons []FaviconData `validate:"required"` + Manifest template.JS `validate:"required"` + AppleTouchIcon string `validate:"required"` + Categories []string `validate:"required"` + BgColor string `validate:"required"` + Body []template.HTML `validate:"required"` +} + +type TagOgData struct { + Type string `validate:"required,oneof=website"` + Image string `validate:"required,url"` + ImageAlt string `validate:"required,min=10"` + ImageWidth string `validate:"required"` + ImageHeight string `validate:"required"` + SiteName string `validate:"required,min=5"` + Locale string `validate:"required,min=5"` +} + +type TwitterData struct { + Card string `validate:"required,oneof=summary_large_image"` + Image string `validate:"required,url"` + ImageAlt string `validate:"required,min=10"` +} + +type HrefLangData struct { + Lang string `validate:"required,oneof=en_GB"` + Href string `validate:"required,url"` +} + +type FaviconData struct { + Rel string `validate:"required,oneof=icon"` + Href string `validate:"required,url"` + Type string `validate:"required"` + Sizes string `validate:"required"` +} diff --git a/metal/cli/seo/defaults.go b/metal/cli/seo/defaults.go new file mode 100644 index 00000000..f387493b --- /dev/null +++ b/metal/cli/seo/defaults.go @@ -0,0 +1,31 @@ +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/assets/about-Dt5rMl63.jpg" + +// --- Web Pages + +const WebHomeUrl = "/" +const WebHomeName = "Home" + +const WebAboutName = "About" +const WebAboutUrl = "/about" + +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 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." diff --git a/metal/cli/seo/generator.go b/metal/cli/seo/generator.go new file mode 100644 index 00000000..a38805a9 --- /dev/null +++ b/metal/cli/seo/generator.go @@ -0,0 +1,230 @@ +package seo + +import ( + "bytes" + "embed" + "fmt" + "html/template" + "os" + "path/filepath" + + "github.com/oullin/database" + "github.com/oullin/handler/payload" + "github.com/oullin/metal/env" + "github.com/oullin/metal/router" + "github.com/oullin/pkg/cli" + "github.com/oullin/pkg/portal" +) + +//go:embed stub.html +var templatesFS embed.FS + +type Generator struct { + Page Page + Client *Client + Env *env.Environment + Validator *portal.Validator + DB *database.Connection + WebsiteRoutes *router.WebsiteRoutes +} + +func NewGenerator(db *database.Connection, env *env.Environment, val *portal.Validator) (*Generator, error) { + var err error + var categories []string + var html *template.Template + + if categories, err = NewCategories(db).Generate(); err != nil { + return nil, err + } + + page := Page{ + LogoURL: LogoUrl, + StubPath: StubPath, + WebRepoURL: RepoWebUrl, + APIRepoURL: RepoApiUrl, + Categories: categories, + SiteURL: env.App.URL, + SiteName: env.App.Name, + AboutPhotoUrl: AboutPhotoUrl, + Lang: env.App.Lang(), + OutputDir: env.Seo.SpaDir, + Template: &template.Template{}, + SameAsURL: []string{RepoApiUrl, RepoWebUrl, GocantoUrl}, + } + + if _, err = val.Rejects(page); err != nil { + return nil, fmt.Errorf("invalid template state: %s", val.GetErrorsAsJson()) + } + + if html, err = page.Load(); err != nil { + return nil, fmt.Errorf("could not load initial stub: %w", err) + } else { + page.Template = html + } + + webRoutes := router.NewWebsiteRoutes(env) + + return &Generator{ + DB: db, + Env: env, + Validator: val, + Page: page, + WebsiteRoutes: webRoutes, + Client: NewClient(webRoutes), + }, nil +} + +func (g *Generator) Generate() error { + var err error + + if err = g.GenerateHome(); err != nil { + return err + } + + return nil +} + +func (g *Generator) GenerateHome() error { + var err error + var talks *payload.TalksResponse + var profile *payload.ProfileResponse + var projects *payload.ProjectsResponse + + if profile, err = g.Client.GetProfile(); err != nil { + return err + } + + if talks, err = g.Client.GetTalks(); err != nil { + return err + } + + if projects, err = g.Client.GetProjects(); err != nil { + return err + } + + var html []template.HTML + sections := NewSections() + + html = append(html, sections.Profile(profile)) + html = append(html, sections.Categories(g.Page.Categories)) + html = append(html, sections.Talks(talks)) + html = append(html, sections.Skills(profile)) + html = append(html, sections.Projects(projects)) + + // ----- Template Parsing + + var tData TemplateData + if tData, err = g.Build(html); err != nil { + return fmt.Errorf("home: generating template data: %w", err) + } + + if err = g.Export("home", tData); err != nil { + return fmt.Errorf("home: exporting template data: %w", err) + } + + cli.Successln("Home SEO template generated") + + return nil +} + +func (g *Generator) Export(origin string, data TemplateData) error { + var err error + var buffer bytes.Buffer + fileName := fmt.Sprintf("%s.seo.html", origin) + + if err = g.Page.Template.Execute(&buffer, data); err != nil { + return fmt.Errorf("%s: rendering template: %w", fileName, err) + } + + 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) + if err = os.WriteFile(out, buffer.Bytes(), 0o644); err != nil { + return fmt.Errorf("%s: writing %s: %w", fileName, out, err) + } + + return nil +} + +func (g *Generator) Build(body []template.HTML) (TemplateData, error) { + og := TagOgData{ + ImageWidth: "600", + ImageHeight: "400", + Type: "website", + Locale: g.Page.Lang, + ImageAlt: g.Page.SiteName, + SiteName: g.Page.SiteName, + Image: g.Page.AboutPhotoUrl, + } + + twitter := TwitterData{ + Card: "summary_large_image", + Image: g.Page.AboutPhotoUrl, + ImageAlt: g.Page.SiteName, + } + + data := TemplateData{ + OGTagOg: og, + Robots: Robots, + Twitter: twitter, + ThemeColor: ThemeColor, + 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}, + }, + Favicons: []FaviconData{ + { + Rel: "icon", + Sizes: "48x48", + Type: "image/ico", + Href: g.Page.SiteURL + "/favicon.ico", + }, + }, + } + + data.Body = body + data.Manifest = NewManifest(g.Page, data).Render() + + if _, err := g.Validator.Rejects(og); err != nil { + return TemplateData{}, fmt.Errorf("invalid og data: %s", g.Validator.GetErrorsAsJson()) + } + + if _, err := g.Validator.Rejects(twitter); err != nil { + return TemplateData{}, fmt.Errorf("invalid twitter data: %s", g.Validator.GetErrorsAsJson()) + } + + if _, err := g.Validator.Rejects(data); err != nil { + return TemplateData{}, fmt.Errorf("invalid template data: %s", g.Validator.GetErrorsAsJson()) + } + + return data, nil +} + +func (t *Page) Load() (*template.Template, error) { + raw, err := templatesFS.ReadFile(t.StubPath) + if err != nil { + return nil, fmt.Errorf("reading template: %w", err) + } + + tmpl, err := template. + New("seo"). + Funcs(template.FuncMap{ + "ManifestDataURL": ManifestDataURL, + }). + Parse(string(raw)) + + if err != nil { + return nil, fmt.Errorf("parsing template: %w", err) + } + + return tmpl, nil +} diff --git a/metal/cli/seo/jsonld.go b/metal/cli/seo/jsonld.go new file mode 100644 index 00000000..1b1225b6 --- /dev/null +++ b/metal/cli/seo/jsonld.go @@ -0,0 +1,151 @@ +package seo + +import ( + "bytes" + "encoding/json" + "fmt" + "html/template" + "time" +) + +type JsonID struct { + SiteURL string + OrgName string + LogoURL string + Lang string + FoundedYear string + SameAs []string + Now func() time.Time + + // Repos and API + WebRepoURL string + APIRepoURL string + APIName string + WebName string +} + +func NewJsonID(tmpl Page) *JsonID { + return &JsonID{ + Lang: tmpl.Lang, + SiteURL: tmpl.SiteURL, + LogoURL: tmpl.LogoURL, + OrgName: tmpl.SiteName, + WebName: tmpl.SiteName, + APIName: tmpl.SiteName, + SameAs: tmpl.SameAsURL, + APIRepoURL: tmpl.APIRepoURL, + WebRepoURL: tmpl.WebRepoURL, + FoundedYear: fmt.Sprintf("%d", FoundedYear), + Now: func() time.Time { return time.Now().UTC() }, + } +} + +func (j *JsonID) Render() template.JS { + now := j.Now().Format(time.RFC3339) + siteID := j.SiteURL + "#org" + + graph := []any{ + + map[string]any{ + "@id": siteID, + "sameAs": j.SameAs, + "brand": j.OrgName, + "image": j.LogoURL, + "name": j.OrgName, + "legalName": j.OrgName, + "url": j.SiteURL, + "foundedYear": j.FoundedYear, + "@type": "Organization", + "logo": map[string]any{"@type": "ImageObject", "url": j.LogoURL}, + }, + + map[string]any{ + "dateModified": now, + "inLanguage": j.Lang, + "@type": "WebSite", + "url": j.SiteURL, + "name": j.OrgName, + "image": j.LogoURL, + "@id": j.SiteURL + "#website", + "publisher": map[string]any{"@id": siteID}, + }, + + map[string]any{ + "operatingSystem": "All", + "url": j.SiteURL, + "name": j.OrgName, + "@type": "WebApplication", + "@id": j.SiteURL + "#app", + "applicationCategory": "DeveloperApplication", + "browserRequirements": "Requires a modern browser", + "publisher": map[string]any{"@id": siteID}, + }, + + map[string]any{ + "@type": "SoftwareSourceCode", + "@id": j.WebRepoURL + "#code", + "name": j.WebName, + "url": j.WebRepoURL, + "codeRepository": j.WebRepoURL, + "programmingLanguage": []string{"TypeScript", "JavaScript", "Vue"}, + "issueTracker": j.WebRepoURL + "/issues", + "license": j.WebRepoURL + "/blob/main/LICENSE", + "publisher": map[string]any{"@id": siteID}, + "dateModified": now, + }, + + map[string]any{ + "dateModified": now, + "inLanguage": j.Lang, + "@type": "WebAPI", + "name": j.APIName, + "documentation": j.APIRepoURL, + "@id": j.SiteURL + "#api", + "provider": map[string]any{"@id": siteID}, + "softwareHelp": []any{ + map[string]any{ + "@type": "CreativeWork", + "learningResourceType": "Documentation", + "url": j.APIRepoURL + "#readme", + }, + }, + "workExample": []any{ + map[string]any{ + "dateModified": now, + "name": j.APIName, + "url": j.APIRepoURL, + "codeRepository": j.APIRepoURL, + "@type": "SoftwareSourceCode", + "@id": j.APIRepoURL + "#code", + "issueTracker": j.APIRepoURL + "/issues", + "programmingLanguage": []string{"Go", "Makefile"}, + "publisher": map[string]any{"@id": siteID}, + "license": j.APIRepoURL + "/blob/main/LICENSE", + }, + }, + }, + } + + root := map[string]any{ + "@graph": graph, + "@context": "https://schema.org", + } + + // Encode without Template escaping and compact. + var buf bytes.Buffer + + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + + if err := enc.Encode(root); err != nil { + return `{}` + } + + var compact bytes.Buffer + if err := json.Compact(&compact, buf.Bytes()); err != nil { + // Fallback to un-compacted if compaction fails + return template.JS(buf.String()) + } + + return template.JS(compact.String()) +} diff --git a/metal/cli/seo/manifest.go b/metal/cli/seo/manifest.go new file mode 100644 index 00000000..dfc3ec3d --- /dev/null +++ b/metal/cli/seo/manifest.go @@ -0,0 +1,129 @@ +package seo + +import ( + "bytes" + "encoding/json" + "html/template" + "time" +) + +type Manifest struct { + Name string + ShortName string + Description string + StartURL string + Scope string + Lang string + ThemeColor string + BgColor string + Categories []string + Icons []ManifestIcon + Now func() time.Time + Shortcuts []ManifestShortcut +} + +type ManifestIcon struct { + Src string `json:"src"` + Type string `json:"type"` + Sizes string `json:"sizes"` + Purpose string `json:"purpose,omitempty"` +} + +type ManifestShortcut struct { + URL string `json:"url"` + Name string `json:"name"` + ShortName string `json:"short_name"` + Icons []ManifestIcon `json:"icons,omitempty"` + Desc string `json:"description,omitempty"` +} + +func NewManifest(tmpl Page, data TemplateData) *Manifest { + var icons []ManifestIcon + + if len(data.Favicons) > 0 { + f := data.Favicons[0] + icons = []ManifestIcon{{Src: f.Href, Sizes: f.Sizes, Type: f.Type, Purpose: "any"}} + } else { + icons = []ManifestIcon{{Src: tmpl.LogoURL, Sizes: "512x512", Type: "image/png", Purpose: "any"}} + } + + b := &Manifest{ + Icons: icons, + Lang: tmpl.Lang, + Scope: WebHomeUrl, + BgColor: data.BgColor, + StartURL: tmpl.SiteURL, + Name: tmpl.SiteName, + ShortName: tmpl.SiteName, + Categories: data.Categories, + ThemeColor: data.ThemeColor, + Description: data.Description, + Now: func() time.Time { return time.Now().UTC() }, + Shortcuts: []ManifestShortcut{ + { + Icons: icons, + URL: WebHomeUrl, + Name: WebHomeName, + ShortName: WebHomeName, + }, + { + Icons: icons, + URL: WebProjectsUrl, + Name: WebProjectsName, + ShortName: WebProjectsName, + }, + { + Icons: icons, + URL: WebAboutUrl, + Name: WebAboutName, + ShortName: WebAboutName, + }, + { + Icons: icons, + URL: WebResumeUrl, + Name: WebResumeName, + ShortName: WebResumeName, + }, + }, + } + + return b +} + +func (m *Manifest) Render() template.JS { + root := map[string]any{ + "dir": "ltr", + "orientation": "any", + "prefer_related_applications": false, + "name": m.Name, + "lang": m.Lang, + "scope": m.Scope, + "icons": m.Icons, + "screenshots": []any{}, + "background_color": m.BgColor, + "start_url": m.StartURL, + "short_name": m.ShortName, + "shortcuts": m.Shortcuts, + "display": "standalone", + "theme_color": m.ThemeColor, + "categories": m.Categories, + "description": m.Description, + "id": m.StartURL + "#websitemanifest", + } + + var buf bytes.Buffer + + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + + if err := enc.Encode(root); err != nil { + return `{}` + } + + var compact bytes.Buffer + if err := json.Compact(&compact, buf.Bytes()); err != nil { + return template.JS(buf.String()) + } + + return template.JS(compact.String()) +} diff --git a/metal/cli/seo/sections.go b/metal/cli/seo/sections.go new file mode 100644 index 00000000..5b067186 --- /dev/null +++ b/metal/cli/seo/sections.go @@ -0,0 +1,87 @@ +package seo + +import ( + "html/template" + "strings" + + "github.com/oullin/handler/payload" +) + +type Sections struct{} + +func NewSections() Sections { + return Sections{} +} + +func (s *Sections) Categories(categories []string) template.HTML { + var items []string + + for _, item := range categories { + items = append(items, "
"+ + template.HTMLEscapeString(profile.Data.Name)+", "+ + template.HTMLEscapeString(profile.Data.Profession)+ + "
", + ) +} + +func (s *Sections) Skills(profile *payload.ProfileResponse) template.HTML { + var items []string + + for _, item := range profile.Data.Skills { + items = append(items, "